From 38ae434ff0f8c9ce59c2e656046126d483ba19ae Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 26 Mar 2026 11:04:56 +0100 Subject: [PATCH] handle lists --- src/components/Settings/SettingsMenuBody.tsx | 13 +- src/constants.ts | 2 + src/i18n/locales/de.ts | 21 + src/i18n/locales/en.ts | 21 + src/lib/draft-event.ts | 10 + src/lib/follow-set-spell.ts | 103 +++++ src/lib/link.ts | 1 + src/pages/primary/NoteListPage/index.tsx | 55 ++- src/pages/primary/SpellsPage/index.tsx | 239 +++++++++-- .../FollowSetsSettingsPage/index.tsx | 381 ++++++++++++++++++ src/routes.tsx | 2 + src/services/client.service.ts | 6 +- src/services/indexed-db.service.ts | 15 +- src/services/navigation.service.ts | 3 + 14 files changed, 822 insertions(+), 50 deletions(-) create mode 100644 src/lib/follow-set-spell.ts create mode 100644 src/pages/secondary/FollowSetsSettingsPage/index.tsx diff --git a/src/components/Settings/SettingsMenuBody.tsx b/src/components/Settings/SettingsMenuBody.tsx index 77f3f12f..023cdf27 100644 --- a/src/components/Settings/SettingsMenuBody.tsx +++ b/src/components/Settings/SettingsMenuBody.tsx @@ -6,7 +6,8 @@ import { toCacheSettings, toTranslation, toWallet, - toRssFeedSettings + toRssFeedSettings, + toFollowSetsSettings } from '@/lib/link' import { cn } from '@/lib/utils' import { useSmartSettingsNavigation } from '@/PageManager' @@ -23,6 +24,7 @@ import { Rss, Server, Settings2, + Users, Wallet } from 'lucide-react' import { forwardRef, HTMLProps, useState } from 'react' @@ -98,6 +100,15 @@ export default function SettingsMenuBody({ className }: { className?: string }) )} + {!!pubkey && ( + navigateToSettings(toFollowSetsSettings())}> +
+ +
{t('Follow sets')}
+
+ +
+ )} {!!nsec && ( 0 ? d : null + } catch { + return null + } +} + +export function getFollowSetDTag(event: Event): string | undefined { + return event.tags.find(tagNameEquals('d'))?.[1] +} + +export function labelFollowSetEvent(event: Event): string { + const title = event.tags.find(tagNameEquals('title'))?.[1]?.trim() + if (title) return title + const d = getFollowSetDTag(event) + return d ?? 'follow set' +} + +/** Hex pubkeys from `p` tags (NIP-51 follow sets). */ +export function pubkeysFromFollowSetEvent(event: Event): string[] { + const out: string[] = [] + const seen = new Set() + for (const t of event.tags) { + if (t[0] !== 'p' || !t[1]) continue + const pk = t[1].trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(pk)) continue + if (seen.has(pk)) continue + seen.add(pk) + out.push(pk) + } + return out +} + +/** + * Latest event per `d` tag. Skips deprecated NIP-51 kind 30000 + `d`=mute (use kind 10000 mute list). + */ +/** Build NIP-51 kind 30000 tags (`d` required; optional metadata; then `p` in order). */ +export function buildFollowSetTags(params: { + d: string + title?: string + description?: string + image?: string + pubkeys: string[] +}): string[][] { + const d = params.d.trim() + if (!d || d === 'mute') throw new Error('Invalid list id') + const tags: string[][] = [['d', d]] + const title = params.title?.trim() + if (title) tags.push(['title', title]) + const description = params.description?.trim() + if (description) tags.push(['description', description]) + const image = params.image?.trim() + if (image) tags.push(['image', image]) + for (const pk of params.pubkeys) { + const hex = pk.trim().toLowerCase() + if (/^[0-9a-f]{64}$/.test(hex)) tags.push(['p', hex]) + } + return tags +} + +export function extractFollowSetEditorFields(event: Event): { + d: string + title: string + description: string + image: string + pubkeys: string[] +} { + return { + d: getFollowSetDTag(event) ?? '', + title: event.tags.find(tagNameEquals('title'))?.[1] ?? '', + description: event.tags.find(tagNameEquals('description'))?.[1] ?? '', + image: event.tags.find(tagNameEquals('image'))?.[1] ?? '', + pubkeys: pubkeysFromFollowSetEvent(event) + } +} + +export function dedupeFollowSetEventsByD(events: Event[]): Event[] { + const byD = new Map() + for (const e of [...events].sort((a, b) => b.created_at - a.created_at)) { + const d = getFollowSetDTag(e) + if (!d || d === 'mute') continue + if (!byD.has(d)) byD.set(d, e) + } + return [...byD.values()].sort((a, b) => + labelFollowSetEvent(a).localeCompare(labelFollowSetEvent(b), undefined, { sensitivity: 'base' }) + ) +} diff --git a/src/lib/link.ts b/src/lib/link.ts index 9d80c9fb..0c987172 100644 --- a/src/lib/link.ts +++ b/src/lib/link.ts @@ -71,6 +71,7 @@ export const toPostSettings = () => '/settings/posts' export const toGeneralSettings = () => '/settings/general' export const toTranslation = () => '/settings/translation' export const toRssFeedSettings = () => '/settings/rss-feeds' +export const toFollowSetsSettings = () => '/settings/follow-sets' export const toCacheSettings = () => '/settings/cache' export const toProfileEditor = () => '/profile-editor' export const toRelay = (url: string) => `/relays/${encodeURIComponent(url)}` diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index 4f00d9e7..8f42459e 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -11,7 +11,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' import type { TNoteListRef } from '@/components/NoteList' import { NoteCardLoadingSkeleton } from '@/components/NoteCard' import { TPageRef } from '@/types' -import { Compass, Info } from 'lucide-react' +import { Compass, Info, UsersRound } from 'lucide-react' import React, { Dispatch, forwardRef, @@ -224,28 +224,47 @@ function NoteListPageTitlebar({ const { navigate, current, display } = usePrimaryPage() const { primaryViewType, setPrimaryNoteView } = usePrimaryNoteView() const exploreActive = display && current === 'explore' && primaryViewType === null + const followsLatestActive = display && current === 'follows-latest' && primaryViewType === null return (
{isSmallScreen && ( - + <> + + + )}
{isSmallScreen && ( diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 9f41d31d..3bfdaf92 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -30,7 +30,16 @@ import { useKindFilter } from '@/providers/KindFilterProvider' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useUserTrust } from '@/contexts/user-trust-context' -import client from '@/services/client.service' +import { + decodeFollowSetSpellId, + dedupeFollowSetEventsByD, + encodeFollowSetSpellId, + getFollowSetDTag, + isFollowSetSpellId, + labelFollowSetEvent, + pubkeysFromFollowSetEvent +} from '@/lib/follow-set-spell' +import client, { queryService } from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import storage from '@/services/local-storage.service' import { @@ -199,10 +208,20 @@ function SpellSheetOptionRow({ type FauxSpellName = (typeof FAUX_SPELL_ORDER)[number] -function isFauxSpellName(s: string): s is FauxSpellName { +function isSpellsPageBuiltinFauxSpell(s: string): s is FauxSpellName { return (FAUX_SPELL_ORDER as readonly string[]).includes(s) } +function isSpellsPageFauxSpellParam(s: string): boolean { + if (isSpellsPageBuiltinFauxSpell(s)) return true + if (!isFollowSetSpellId(s)) return false + return decodeFollowSetSpellId(s) != null +} + +function isFollowFeedFauxSpellId(s: string | null): boolean { + return s === 'following' || (!!s && isFollowSetSpellId(s)) +} + function useNoteListHideReplies() { const [hideReplies, setHideReplies] = useState(() => storage.getNoteListMode() === 'posts') @@ -269,7 +288,9 @@ const SpellsPage = forwardRef(function SpellsPage( const [spells, setSpells] = useState([]) const [favoriteIds, setFavoriteIds] = useState>(new Set()) const [selectedSpell, setSelectedSpell] = useState(null) - const [selectedFauxSpell, setSelectedFauxSpell] = useState(null) + const [selectedFauxSpell, setSelectedFauxSpell] = useState(null) + const [followSetListEvents, setFollowSetListEvents] = useState([]) + const [followSetCatalogLoading, setFollowSetCatalogLoading] = useState(false) const [createOpen, setCreateOpen] = useState(false) const [spellToEdit, setSpellToEdit] = useState(null) const [spellToClone, setSpellToClone] = useState(null) @@ -291,6 +312,7 @@ const SpellsPage = forwardRef(function SpellsPage( const spellFeedInstrT0Ref = useRef(0) const spellFeedInstrLabelRef = useRef('') const [spellFeedInstrumentToken, setSpellFeedInstrumentToken] = useState(0) + const [followSetManualRefreshKey, setFollowSetManualRefreshKey] = useState(0) const logSpellFeedPickerSelection = useCallback((label: string, extra?: Record) => { spellFeedInstrT0Ref.current = performance.now() @@ -309,7 +331,7 @@ const SpellsPage = forwardRef(function SpellsPage( /** Set when picker calls `navigatePrimary(..., { spell })` so URL effect does not log/bump token again. */ const fauxSpellUrlSyncFromPickerRef = useRef(null) useEffect(() => { - if (spellProp && isFauxSpellName(spellProp)) { + if (spellProp && isSpellsPageFauxSpellParam(spellProp)) { if (fauxSpellUrlSyncFromPickerRef.current === spellProp) { fauxSpellUrlSyncFromPickerRef.current = null urlFauxSpellInstrumentedRef.current = spellProp @@ -343,7 +365,10 @@ const SpellsPage = forwardRef(function SpellsPage( const refreshSpellsFeedAndCatalog = useCallback(() => { void loadSpells() - if (pubkey) setSpellCatalogManualRefreshKey((k) => k + 1) + if (pubkey) { + setSpellCatalogManualRefreshKey((k) => k + 1) + setFollowSetManualRefreshKey((k) => k + 1) + } spellFeedListRef.current?.refresh() }, [loadSpells, pubkey]) @@ -396,6 +421,44 @@ const SpellsPage = forwardRef(function SpellsPage( [blockedRelays] ) + useEffect(() => { + if (!pubkey) { + setFollowSetListEvents([]) + setFollowSetCatalogLoading(false) + return + } + let cancelled = false + setFollowSetCatalogLoading(true) + void (async () => { + try { + const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays, + blockedRelays, + relayList?.read ?? [], + { userWriteRelays: relayList?.write ?? [] } + ) + const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) + if (!urls.length) { + if (!cancelled) setFollowSetListEvents([]) + return + } + const events = await queryService.fetchEvents( + urls, + { authors: [pubkey], kinds: [ExtendedKind.FOLLOW_SET], limit: 500 }, + { eoseTimeout: 2000, globalTimeout: 15000, firstRelayResultGraceMs: false } + ) + if (!cancelled) setFollowSetListEvents(dedupeFollowSetEventsByD(events)) + } catch { + if (!cancelled) setFollowSetListEvents([]) + } finally { + if (!cancelled) setFollowSetCatalogLoading(false) + } + })() + return () => { + cancelled = true + } + }, [pubkey, sortedFavoriteRelaysKey, sortedBlockedRelaysKey, relayMailboxStableKey, followSetManualRefreshKey]) + /** * Kind-777 list for the dropdown. When opening with `?spell=…` (faux name, hex id, nevent, etc.), defer * this IndexedDB read so the feed can subscribe and paint first; the header already reflects the URL. @@ -598,18 +661,58 @@ const SpellsPage = forwardRef(function SpellsPage( client.fetchFollowings(pubkey).then(setContacts).catch(() => setContacts([])) }, [pubkey]) + const followSetListStableKey = useMemo( + () => + followSetListEvents + .map((e) => { + const d = getFollowSetDTag(e) ?? '' + return `${d}:${e.id}:${e.created_at}` + }) + .sort() + .join('|'), + [followSetListEvents] + ) + useEffect(() => { - if (selectedFauxSpell !== 'following' || !pubkey) { + if (!pubkey || !isFollowFeedFauxSpellId(selectedFauxSpell)) { setFollowingSubRequests([]) setFollowingFeedLoading(false) return } + + const followSetD = + selectedFauxSpell && isFollowSetSpellId(selectedFauxSpell) + ? decodeFollowSetSpellId(selectedFauxSpell) + : null + + if (followSetD && followSetCatalogLoading) { + setFollowingSubRequests([]) + setFollowingFeedLoading(true) + return + } + let cancelled = false setFollowingFeedLoading(true) void (async () => { try { - const followings = await client.fetchFollowings(pubkey) - const req = await client.generateSubRequestsForPubkeys([pubkey, ...followings], pubkey) + let authorPubkeys: string[] + if (selectedFauxSpell === 'following') { + const followings = await client.fetchFollowings(pubkey) + authorPubkeys = [pubkey, ...followings] + } else if (followSetD) { + const ev = followSetListEvents.find((e) => getFollowSetDTag(e) === followSetD) + if (!ev) { + if (!cancelled) setFollowingSubRequests([]) + return + } + const listed = pubkeysFromFollowSetEvent(ev) + authorPubkeys = [pubkey, ...listed] + } else { + if (!cancelled) setFollowingSubRequests([]) + return + } + + const req = await client.generateSubRequestsForPubkeys(authorPubkeys, pubkey) const merged = augmentSubRequestsWithFavoritesFastReadAndInbox( req, favoriteRelays, @@ -636,7 +739,9 @@ const SpellsPage = forwardRef(function SpellsPage( pubkey, sortedFavoriteRelaysKey, sortedBlockedRelaysKey, - relayMailboxStableKey + relayMailboxStableKey, + followSetCatalogLoading, + followSetListStableKey ]) const interestTagsStableKey = interestListEvent @@ -663,7 +768,7 @@ const SpellsPage = forwardRef(function SpellsPage( ].join('\0') const syncFauxSubRequests = useMemo(() => { - if (!selectedFauxSpell || selectedFauxSpell === 'following') return [] + if (!selectedFauxSpell || isFollowFeedFauxSpellId(selectedFauxSpell)) return [] /** Widen relay pool: these faux spells do not target social kinds (1 / 11 / 1111); skipping strip keeps fast-read mirrors in the stack. */ const fauxSpellSkipSocialKindBlocked = selectedFauxSpell === 'calendar' || @@ -726,8 +831,9 @@ const SpellsPage = forwardRef(function SpellsPage( }, [selectedFauxSpell, pubkey, fauxFeedRelaysDepsKey, relayMailboxStableKey]) const fauxSubRequests = useMemo(() => { - const base = - selectedFauxSpell === 'following' ? followingSubRequests : syncFauxSubRequests + const base = isFollowFeedFauxSpellId(selectedFauxSpell ?? '') + ? followingSubRequests + : syncFauxSubRequests return applyFauxSpellCapsToSubRequests(base) }, [selectedFauxSpell, followingSubRequests, syncFauxSubRequests]) @@ -852,7 +958,9 @@ const SpellsPage = forwardRef(function SpellsPage( /** Avoid depending on `kindFilterShowKinds` ref for faux spells that don’t use it (e.g. Discussions). */ const followingShowKindsKey = - selectedFauxSpell === 'following' ? JSON.stringify(kindFilterShowKinds) : '' + selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell) + ? JSON.stringify(kindFilterShowKinds) + : '' const showKinds = useMemo(() => { if (selectedFauxSpell === 'notifications') { @@ -861,7 +969,7 @@ const SpellsPage = forwardRef(function SpellsPage( if (selectedFauxSpell === 'discussions') { return [ExtendedKind.DISCUSSION] } - if (selectedFauxSpell === 'following') { + if (selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)) { // Profile feed kinds omit boosts; show reposts as cards in this faux spell only. const k = kindFilterShowKinds if (k.includes(nostrKinds.Repost)) return k @@ -895,11 +1003,25 @@ const SpellsPage = forwardRef(function SpellsPage( [favoriteIds] ) + const selectedFauxSpellDisplayLabel = useMemo(() => { + if (!selectedFauxSpell) return '' + if (isFollowSetSpellId(selectedFauxSpell)) { + const d = decodeFollowSetSpellId(selectedFauxSpell) + if (!d) return t('Follow set') + const ev = followSetListEvents.find((e) => getFollowSetDTag(e) === d) + return ev ? labelFollowSetEvent(ev) : d + } + if (isSpellsPageBuiltinFauxSpell(selectedFauxSpell)) { + return t(fauxSpellLabelKey(selectedFauxSpell)) + } + return selectedFauxSpell + }, [selectedFauxSpell, followSetListEvents, t]) + const spellsTitlebarTitle = useMemo(() => { - if (selectedFauxSpell) return t(fauxSpellLabelKey(selectedFauxSpell)) + if (selectedFauxSpell) return selectedFauxSpellDisplayLabel if (selectedSpell) return spellMenuLabel(selectedSpell) return t('Spells') - }, [selectedFauxSpell, selectedSpell, spellMenuLabel, t]) + }, [selectedFauxSpell, selectedSpell, selectedFauxSpellDisplayLabel, spellMenuLabel, t]) const pickSpell = useCallback( (spell: Event | null) => { @@ -930,9 +1052,10 @@ const SpellsPage = forwardRef(function SpellsPage( }, [logSpellFeedPickerSelection, navigatePrimary]) const pickFauxSpell = useCallback( - (name: FauxSpellName | null) => { + (name: string | null) => { setSpellPickerOpen(false) if (name) { + if (!isSpellsPageFauxSpellParam(name)) return // Re-selecting the same built-in feed from the picker should not clear + resubscribe (toggle used to call // pickFauxSpell(null) and wipe the timeline when the row was already selected). if (selectedFauxSpell === name && selectedSpell === null) { @@ -972,7 +1095,8 @@ const SpellsPage = forwardRef(function SpellsPage( const fauxNoteListUseFilterAsIs = useMemo(() => { if (!selectedFauxSpell) return true - return selectedFauxSpell !== 'following' && selectedFauxSpell !== 'bookmarks' + if (selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)) return false + return selectedFauxSpell !== 'bookmarks' }, [selectedFauxSpell]) const notificationsMentionExtraHide = useCallback( @@ -985,12 +1109,21 @@ const SpellsPage = forwardRef(function SpellsPage( if (selectedFauxSpell === 'interests') return t('No subscribed interests yet.') if (selectedFauxSpell === 'bookmarks') return t('No bookmarked notes with id tags yet.') if (selectedFauxSpell === 'following') return t('No follows or relays to load yet.') + if (isFollowSetSpellId(selectedFauxSpell)) return t('Follow set feed empty') return t('Nothing to load for this feed.') }, [selectedFauxSpell, fauxSubRequests.length, t]) + const showFollowFeedLoading = !!( + pubkey && + selectedFauxSpell && + isFollowFeedFauxSpellId(selectedFauxSpell) && + (followingFeedLoading || + (isFollowSetSpellId(selectedFauxSpell) && followSetCatalogLoading)) + ) + const spellPickerList = ( <> - {FAUX_SPELL_ORDER.map((name) => { + {FAUX_SPELL_ORDER.flatMap((name) => { if ( (name === 'notifications' || name === 'following' || @@ -998,11 +1131,11 @@ const SpellsPage = forwardRef(function SpellsPage( name === 'interests') && !pubkey ) { - return null + return [] } const Icon = FAUX_SPELL_ICON[name] const selected = selectedFauxSpell === name - return ( + const builtinRow = ( ) + if (name !== 'following' || !pubkey || followSetListEvents.length === 0) { + return [builtinRow] + } + const setRows = followSetListEvents.flatMap((ev) => { + const d = getFollowSetDTag(ev) + if (!d) return [] + const spellId = encodeFollowSetSpellId(d) + const setSelected = selectedFauxSpell === spellId + return [ + + ] + }) + return [builtinRow, ...setRows] })} +
+ + {loading ? ( +
+ + +
+ ) : lists.length === 0 ? ( +

{t('No follow sets yet')}

+ ) : ( +
    + {lists.map((ev) => ( +
  • +
    + +
    +
    {labelFollowSetEvent(ev)}
    +
    + {extractFollowSetEditorFields(ev).pubkeys.length} {t('members')} + · + d={extractFollowSetEditorFields(ev).d} +
    +
    +
    +
    + + +
    +
  • + ))} +
+ )} + + )} + + + !o && closeDialog()}> + + + {editing ? t('Edit follow set') : t('New follow set')} + +
+
+ + setFormD(e.target.value)} + disabled={!!editing} + className="font-mono text-sm" + /> +

{t('Follow set d tag hint')}

+
+
+ + setFormTitle(e.target.value)} + placeholder={t('Optional display title')} + /> +
+
+ +