diff --git a/src/PageManager.tsx b/src/PageManager.tsx index ee8091d9..3a33d11e 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -229,7 +229,10 @@ function mergePrimaryPageEntry( const element = map[entry.name] const exists = prev.find((p) => p.name === entry.name) if (exists) { - if (entry.props) { + /** Popstate sync passes `{ props: undefined }` when the URL has no `?spell=` — must clear stale props. */ + if (Object.prototype.hasOwnProperty.call(entry, 'props')) { + exists.props = entry.props + } else if (entry.props) { exists.props = { ...(exists.props || {}), ...entry.props } } return [...prev] diff --git a/src/components/BottomNavigationBar/DiscussionsButton.tsx b/src/components/BottomNavigationBar/DiscussionsButton.tsx deleted file mode 100644 index 575d6bfe..00000000 --- a/src/components/BottomNavigationBar/DiscussionsButton.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { usePrimaryPage } from '@/contexts/primary-page-context' -import { MessageCircle } from 'lucide-react' -import BottomNavigationBarItem from './BottomNavigationBarItem' - -export default function DiscussionsButton() { - const { navigate, current, currentPageProps, display } = usePrimaryPage() - const spell = (currentPageProps as { spell?: string } | undefined)?.spell - - return ( - navigate('spells', { spell: 'discussions' })} - > - - - ) -} diff --git a/src/components/BottomNavigationBar/index.tsx b/src/components/BottomNavigationBar/index.tsx index 9f1a6045..26842a41 100644 --- a/src/components/BottomNavigationBar/index.tsx +++ b/src/components/BottomNavigationBar/index.tsx @@ -1,7 +1,6 @@ import { cn } from '@/lib/utils' import RssButton from './RssButton' import HomeButton from './HomeButton' -import DiscussionsButton from './DiscussionsButton' import NotificationsButton from './NotificationsButton' import SearchButton from './SearchButton' import SpellsButton from './SpellsButton' @@ -19,7 +18,6 @@ export default function BottomNavigationBar() { }} > - diff --git a/src/components/Sidebar/DiscussionsButton.tsx b/src/components/Sidebar/DiscussionsButton.tsx deleted file mode 100644 index f9c1ae7b..00000000 --- a/src/components/Sidebar/DiscussionsButton.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { usePrimaryPage } from '@/contexts/primary-page-context' -import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' -import { MessageCircle } from 'lucide-react' -import { useTranslation } from 'react-i18next' -import SidebarItem from './SidebarItem' - -export default function DiscussionsButton() { - const { t } = useTranslation() - const { navigate, current, currentPageProps, display } = usePrimaryPage() - const { primaryViewType } = usePrimaryNoteView() - const spell = (currentPageProps as { spell?: string } | undefined)?.spell - - return ( - navigate('spells', { spell: 'discussions' })} - active={ - display && current === 'spells' && primaryViewType === null && spell === 'discussions' - } - > - - - ) -} diff --git a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx index f2067166..6df76764 100644 --- a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx +++ b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx @@ -116,7 +116,6 @@ export default function SidebarCalendarWeekWidget() { indexedDb.getCalendarEventsForOccurrenceWindow(weekStartMs, weekEndExclusiveMs), indexedDb.getArchivedCalendarEventsOverlappingWindow(weekStartMs, weekEndExclusiveMs, 25_000, 400) ]) - if (cancelled) return const localBaseline = dedupeCalendarEvents([...fromIdb, ...fromArchive]) const sessionSnap = client.getSessionEventsMatchingSearch( @@ -125,8 +124,13 @@ export default function SidebarCalendarWeekWidget() { [...CALENDAR_EVENT_KINDS] ) const mergedLocal = dedupeCalendarEvents([...localBaseline, ...sessionSnap]) - setRawEvents(mergedLocal) - setLoading(false) + /** Always paint IDB + session first; a superseded effect must not skip this (relayKey churn would leave the list blank). */ + if (!cancelled) { + setRawEvents(mergedLocal) + setLoading(false) + } + + if (cancelled) return if (!relayUrls.length) { lateMergeTimer = window.setTimeout(() => { @@ -189,7 +193,10 @@ export default function SidebarCalendarWeekWidget() { fromFollowing.push(...(merged[i] ?? [])) } } catch { - /* keep IndexedDB + session; relays may be slow or unreachable */ + /** Relay REQ failed or timed out — keep the snapshot we already painted (re-apply in case of races). */ + if (!cancelled) { + setRawEvents(mergedLocal) + } } if (cancelled) return @@ -198,9 +205,11 @@ export default function SidebarCalendarWeekWidget() { SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents( - dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSessionAfterNet, ...localBaseline]) - ) + if (!cancelled) { + setRawEvents( + dedupeCalendarEvents([...localBaseline, ...fromSessionAfterNet, ...batch, ...fromFollowing]) + ) + } lateMergeTimer = window.setTimeout(() => { lateMergeTimer = null if (cancelled) return diff --git a/src/components/Sidebar/index.tsx b/src/components/Sidebar/index.tsx index 52329c2d..6038065e 100644 --- a/src/components/Sidebar/index.tsx +++ b/src/components/Sidebar/index.tsx @@ -2,7 +2,6 @@ import Icon from '@/assets/Icon' import Logo from '@/assets/Logo' import { useScreenSize } from '@/providers/ScreenSizeProvider' import HelpAndAccountMenu from '@/components/HelpAndAccountMenu' -import DiscussionsButton from './DiscussionsButton' import FeedButton from './FeedButton' import HomeButton from './HomeButton' import NotificationButton from './NotificationButton' @@ -38,7 +37,6 @@ export default function PrimaryPageSidebar() { - diff --git a/src/pages/primary/SpellsPage/SpellPickerContent.tsx b/src/pages/primary/SpellsPage/SpellPickerContent.tsx new file mode 100644 index 00000000..d05e10cd --- /dev/null +++ b/src/pages/primary/SpellsPage/SpellPickerContent.tsx @@ -0,0 +1,389 @@ +import UserAvatar from '@/components/UserAvatar' +import Username from '@/components/Username' +import { Separator } from '@/components/ui/separator' +import { cn } from '@/lib/utils' +import { formatPubkey } from '@/lib/pubkey' +import { getSpellName } from '@/services/spell.service' +import { FAUX_SPELL_ORDER } from '@/constants' +import { Check, Star } from 'lucide-react' +import type { Event } from 'nostr-tools' +import type { TFunction } from 'i18next' +import { + encodeFollowSetSpellId, + fauxSpellLabelKey, + FAUX_SPELL_ICON, + FOLLOW_SET_SPELL_ROW_ICON, + getFollowSetDTag, + labelFollowSetEvent +} from './fauxSpellConfig' + +function spellPickerPrimaryAndSecondary( + spell: Event, + accountPubkey: string | undefined, + labelFor: (e: Event) => string, + options?: { omitAuthorNpub?: boolean } +) { + const primary = labelFor(spell) + const isOwn = !!(accountPubkey && spell.pubkey === accountPubkey) + const shortTitle = primary.trim().length < 4 + const secondaryParts: string[] = [] + if (!isOwn && !options?.omitAuthorNpub) secondaryParts.push(formatPubkey(spell.pubkey)) + if (shortTitle) secondaryParts.push(`${spell.id.slice(0, 8)}…`) + return { + primary, + secondary: secondaryParts.length > 0 ? secondaryParts.join(' · ') : null + } +} + +export function groupSpellsByPubkeySorted(spells: Event[]): { pubkey: string; spells: Event[] }[] { + const map = new Map() + for (const s of spells) { + const list = map.get(s.pubkey) + if (list) list.push(s) + else map.set(s.pubkey, [s]) + } + for (const list of map.values()) { + list.sort((a, b) => + getSpellName(a).localeCompare(getSpellName(b), undefined, { sensitivity: 'base' }) + ) + } + return [...map.entries()] + .sort(([a], [b]) => a.localeCompare(b)) + .map(([pk, list]) => ({ pubkey: pk, spells: list })) +} + +function SpellSheetAuthorHeader({ userId }: { userId: string }) { + return ( +
+ + +
+ ) +} + +function SpellSheetOptionRow({ + spell, + selected, + accountPubkey, + labelFor, + onPick, + groupedUnderAuthor = false, + starred = false, + onToggleStar, + starTitleAdd, + starTitleRemove, + t +}: { + spell: Event + selected: boolean + accountPubkey: string | undefined + labelFor: (e: Event) => string + onPick: (e: Event) => void + groupedUnderAuthor?: boolean + starred?: boolean + onToggleStar?: (spell: Event) => void + starTitleAdd?: string + starTitleRemove?: string + t: TFunction +}) { + const { primary, secondary } = spellPickerPrimaryAndSecondary(spell, accountPubkey, labelFor, { + omitAuthorNpub: groupedUnderAuthor + }) + return ( +
+ + {onToggleStar ? ( + + ) : null} +
+ ) +} + +export type SpellPickerContentProps = { + t: TFunction + pubkey: string | null | undefined + selectedSpell: Event | null + selectedFauxSpell: string | null + favoriteSpellSet: Set + starredSpellsForPicker: Event[] + ownSpells: Event[] + followSpells: Event[] + otherSpells: Event[] + followSetListEvents: Event[] + spellMenuLabel: (spell: Event) => string + spellStarAddTitle: string + spellStarRemoveTitle: string + pickSpell: (spell: Event | null) => void + pickFauxSpell: (name: string | null) => void + clearSpellSelection: () => void + toggleFavoriteSpell: (spell: Event) => void +} + +export function SpellPickerContent({ + t, + pubkey, + selectedSpell, + selectedFauxSpell, + favoriteSpellSet, + starredSpellsForPicker, + ownSpells, + followSpells, + otherSpells, + followSetListEvents, + spellMenuLabel, + spellStarAddTitle, + spellStarRemoveTitle, + pickSpell, + pickFauxSpell, + clearSpellSelection, + toggleFavoriteSpell +}: SpellPickerContentProps) { + const followSpellGroups = groupSpellsByPubkeySorted(followSpells) + const otherSpellGroups = groupSpellsByPubkeySorted(otherSpells) + + return ( + <> + {starredSpellsForPicker.length > 0 ? ( + <> +

+ {t('Starred spells')} +

+ {starredSpellsForPicker.map((spell) => ( + getSpellName(e)} + onPick={pickSpell} + starred + t={t} + onToggleStar={(s) => void toggleFavoriteSpell(s)} + starTitleAdd={spellStarAddTitle} + starTitleRemove={spellStarRemoveTitle} + /> + ))} + + + ) : null} + {FAUX_SPELL_ORDER.flatMap((name) => { + if ( + (name === 'notifications' || + name === 'following' || + name === 'heatMap' || + name === 'bookmarks' || + name === 'interests') && + !pubkey + ) { + return [] + } + const Icon = FAUX_SPELL_ICON[name] + const selected = selectedFauxSpell === name + 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] + })} + + + {ownSpells.length > 0 ? ( + <> + +

+ {t('spellPickerSectionYours')} +

+ {ownSpells.map((spell) => ( + void toggleFavoriteSpell(s)} + starTitleAdd={spellStarAddTitle} + starTitleRemove={spellStarRemoveTitle} + /> + ))} + + ) : null} + + {followSpells.length > 0 ? ( + <> + +

+ {t('Spells from follows', { count: followSpells.length })} +

+ {followSpellGroups.map(({ pubkey: authorPk, spells: groupSpells }) => ( +
+ +
+ {groupSpells.map((spell) => ( + void toggleFavoriteSpell(s)} + starTitleAdd={spellStarAddTitle} + starTitleRemove={spellStarRemoveTitle} + /> + ))} +
+
+ ))} + + ) : null} + + {otherSpells.length > 0 ? ( + <> + +

+ {t('Other spells', { count: otherSpells.length })} +

+ {otherSpellGroups.map(({ pubkey: authorPk, spells: groupSpells }) => ( +
+ +
+ {groupSpells.map((spell) => ( + void toggleFavoriteSpell(s)} + starTitleAdd={spellStarAddTitle} + starTitleRemove={spellStarRemoveTitle} + /> + ))} +
+
+ ))} + + ) : null} + + ) +} diff --git a/src/pages/primary/SpellsPage/fauxSpellConfig.ts b/src/pages/primary/SpellsPage/fauxSpellConfig.ts new file mode 100644 index 00000000..69e42939 --- /dev/null +++ b/src/pages/primary/SpellsPage/fauxSpellConfig.ts @@ -0,0 +1,86 @@ +import { + decodeFollowSetSpellId, + encodeFollowSetSpellId, + getFollowSetDTag, + isFollowSetSpellId, + labelFollowSetEvent +} from '@/lib/follow-set-spell' +import { FAUX_SPELL_ORDER } from '@/constants' +import { + Bell, + Bookmark, + CalendarDays, + Flame, + Gift, + Hash, + Image as ImageIcon, + MessageSquare, + Users, + type LucideIcon +} from 'lucide-react' + +export type FauxSpellName = (typeof FAUX_SPELL_ORDER)[number] + +export function isBuiltinFauxSpell(s: string): s is FauxSpellName { + return (FAUX_SPELL_ORDER as readonly string[]).includes(s) +} + +/** URL / picker param: built-in faux name or encoded follow-set spell id. */ +export function isFauxSpellPageParam(s: string): boolean { + if (isBuiltinFauxSpell(s)) return true + if (!isFollowSetSpellId(s)) return false + return decodeFollowSetSpellId(s) != null +} + +export function isFollowFeedFauxSpellId(s: string | null): boolean { + return s === 'following' || (!!s && isFollowSetSpellId(s)) +} + +export function fauxSpellLabelKey(name: FauxSpellName): string { + switch (name) { + case 'notifications': + return 'Notifications' + case 'discussions': + return 'Discussions' + case 'following': + return 'Following' + case 'heatMap': + return 'Heat map' + case 'followPacks': + return 'Follow Packs' + case 'media': + return 'Media' + case 'interests': + return 'Interests' + case 'bookmarks': + return 'Bookmarks' + case 'calendar': + return 'Calendar' + default: + return 'Spells' + } +} + +export const FAUX_SPELL_ICON: Record = { + notifications: Bell, + discussions: MessageSquare, + following: Users, + heatMap: Flame, + followPacks: Gift, + media: ImageIcon, + interests: Hash, + bookmarks: Bookmark, + calendar: CalendarDays +} + +/** Lucide icon for a follow-set row (indented under Following). */ +export const FOLLOW_SET_SPELL_ROW_ICON = Users + +/** Follow-set rows + URL segments */ +export { + decodeFollowSetSpellId, + encodeFollowSetSpellId, + getFollowSetDTag, + isFollowSetSpellId, + labelFollowSetEvent +} diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 49bd1f83..693378bf 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -16,15 +16,11 @@ import { DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' -import { Separator } from '@/components/ui/separator' import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer' -import UserAvatar from '@/components/UserAvatar' -import Username from '@/components/Username' import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' import { usePrimaryPage } from '@/contexts/primary-page-context' import logger from '@/lib/logger' import { showPublishingError } from '@/lib/publishing-feedback' -import { cn } from '@/lib/utils' import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' @@ -32,285 +28,43 @@ import { useBookmarks } from '@/providers/bookmarks-context' import { useNostr } from '@/providers/NostrProvider' import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useUserTrust } from '@/contexts/user-trust-context' -import { - decodeFollowSetSpellId, - dedupeFollowSetEventsByD, - encodeFollowSetSpellId, - getFollowSetDTag, - isFollowSetSpellId, - labelFollowSetEvent, - pubkeysFromFollowSetEvent -} from '@/lib/follow-set-spell' +import { dedupeFollowSetEventsByD } 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 { - ExtendedKind, - DEFAULT_FEED_SHOW_KINDS, - FAUX_SPELL_ORDER, - FIRST_RELAY_RESULT_GRACE_MS, -} from '@/constants' -import { filterEventsExcludingTombstones, isUserInEventMentions } from '@/lib/event' -import { getPubkeysFromPTags } from '@/lib/tag' -import { formatPubkey, normalizeHexPubkey } from '@/lib/pubkey' -import { - augmentSubRequestsWithFavoritesFastReadAndInbox, - getRelayUrlsWithFavoritesFastReadAndInbox, - userReadRelaysWithHttp -} from '@/lib/favorites-feed-relays' -import { - computeKind777SpellFeedSubscriptionKey, - computeSpellSubRequestsIdentityKey -} from '@/lib/spell-feed-request-identity' +import { ExtendedKind, FIRST_RELAY_RESULT_GRACE_MS } from '@/constants' +import { filterEventsExcludingTombstones } from '@/lib/event' +import { normalizeHexPubkey } from '@/lib/pubkey' +import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { TOMBSTONES_UPDATED_EVENT } from '@/lib/tombstone-events' -import { normalizeUrl } from '@/lib/url' import { buildSpellCatalogAuthors, - getRelaysForSpell, getRelaysForSpellCatalogSync, getSpellName, isSpellEvent, SPELL_CATALOG_SYNC_LIMIT, SPELL_CATALOG_SYNC_LIMIT_WITH_FOLLOWS, - SPELL_CATALOG_SYNC_TIMEOUT_MS, - spellEventToFilter + SPELL_CATALOG_SYNC_TIMEOUT_MS } from '@/services/spell.service' -import { TFeedSubRequest } from '@/types' -import { - Bell, - Bookmark, - CalendarDays, - Check, - ChevronDown, - ChevronLeft, - Copy, - FileText, - Flame, - Gift, - Hash, - Image as ImageIcon, - MessageSquare, - MoreVertical, - Pencil, - Plus, - Star, - Trash2, - Users, - Wand2 -} from 'lucide-react' +import { ChevronDown, ChevronLeft, Copy, FileText, MoreVertical, Pencil, Plus, Star, Trash2, Wand2 } from 'lucide-react' import type { Event } from 'nostr-tools' -import { kinds as nostrKinds, verifyEvent } from 'nostr-tools' +import { verifyEvent } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import CreateSpellDialog from './CreateSpellDialog' import RelayThreadHeatMap from './RelayThreadHeatMap' -import { - applyFauxSpellCapsToSubRequests, - buildBookmarksSubRequests, - buildWebBookmarksSpellSubRequests, - buildCalendarSpellFilter, - buildDiscussionFilter, - buildInterestsSubRequests, - buildMediaSpellFilter, - buildNotificationsSpellSubRequests, - NOTIFICATION_SPELL_LOADING_SAFETY_MS, - FAUX_SPELL_EVENT_LIMIT, - MEDIA_SPELL_KINDS, - NOTIFICATION_SPELL_KINDS -} from './fauxSpellFeeds' import type { TPageRef } from '@/types' - -/** Primary + optional subtitle (npub and/or short id). When grouped under an author header, omit npub. */ -function spellPickerPrimaryAndSecondary( - spell: Event, - accountPubkey: string | undefined, - labelFor: (e: Event) => string, - options?: { omitAuthorNpub?: boolean } -) { - const primary = labelFor(spell) - const isOwn = !!(accountPubkey && spell.pubkey === accountPubkey) - const shortTitle = primary.trim().length < 4 - const secondaryParts: string[] = [] - if (!isOwn && !options?.omitAuthorNpub) secondaryParts.push(formatPubkey(spell.pubkey)) - if (shortTitle) secondaryParts.push(`${spell.id.slice(0, 8)}…`) - return { - primary, - secondary: secondaryParts.length > 0 ? secondaryParts.join(' · ') : null - } -} - -function groupSpellsByPubkeySorted(spells: Event[]): { pubkey: string; spells: Event[] }[] { - const map = new Map() - for (const s of spells) { - const list = map.get(s.pubkey) - if (list) list.push(s) - else map.set(s.pubkey, [s]) - } - for (const list of map.values()) { - list.sort((a, b) => - getSpellName(a).localeCompare(getSpellName(b), undefined, { sensitivity: 'base' }) - ) - } - return [...map.entries()] - .sort(([a], [b]) => a.localeCompare(b)) - .map(([pubkey, list]) => ({ pubkey, spells: list })) -} - -function SpellSheetAuthorHeader({ userId }: { userId: string }) { - return ( -
- - -
- ) -} - -function SpellSheetOptionRow({ - spell, - selected, - accountPubkey, - labelFor, - onPick, - groupedUnderAuthor = false, - starred = false, - onToggleStar, - starTitleAdd, - starTitleRemove -}: { - spell: Event - selected: boolean - accountPubkey: string | undefined - labelFor: (e: Event) => string - onPick: (e: Event) => void - /** Author shown in a header above this block — hide npub under each row */ - groupedUnderAuthor?: boolean - starred?: boolean - onToggleStar?: (spell: Event) => void - starTitleAdd?: string - starTitleRemove?: string -}) { - const { t } = useTranslation() - const { primary, secondary } = spellPickerPrimaryAndSecondary(spell, accountPubkey, labelFor, { - omitAuthorNpub: groupedUnderAuthor - }) - return ( -
- - {onToggleStar ? ( - - ) : null} -
- ) -} - -type FauxSpellName = (typeof FAUX_SPELL_ORDER)[number] - -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') - - useEffect(() => { - const sync = () => setHideReplies(storage.getNoteListMode() === 'posts') - window.addEventListener('noteListModeChanged', sync) - return () => window.removeEventListener('noteListModeChanged', sync) - }, []) - - return hideReplies -} - -function fauxSpellLabelKey(name: FauxSpellName): string { - switch (name) { - case 'notifications': - return 'Notifications' - case 'discussions': - return 'Discussions' - case 'following': - return 'Following' - case 'heatMap': - return 'Heat map' - case 'followPacks': - return 'Follow Packs' - case 'media': - return 'Media' - case 'interests': - return 'Interests' - case 'bookmarks': - return 'Bookmarks' - case 'calendar': - return 'Calendar' - default: - return 'Spells' - } -} - -const FAUX_SPELL_ICON: Record = { - notifications: Bell, - discussions: MessageSquare, - following: Users, - heatMap: Flame, - followPacks: Gift, - media: ImageIcon, - interests: Hash, - bookmarks: Bookmark, - calendar: CalendarDays -} +import { + decodeFollowSetSpellId, + fauxSpellLabelKey, + getFollowSetDTag, + isBuiltinFauxSpell, + isFollowFeedFauxSpellId, + isFollowSetSpellId, + isFauxSpellPageParam, + labelFollowSetEvent +} from './fauxSpellConfig' +import { SpellPickerContent } from './SpellPickerContent' +import { useSpellsPageFeed } from './useSpellsPageFeed' const SpellsPage = forwardRef(function SpellsPage( { spell: spellProp }: { spell?: string }, @@ -337,7 +91,6 @@ const SpellsPage = forwardRef(function SpellsPage( showKind1Replies, showKind1111 } = useKindFilterOrDefaults() - const hideRepliesFollowing = useNoteListHideReplies() const [spells, setSpells] = useState([]) /** Ordered spell event ids (newest star first). Drives picker order + bookmark list sync when logged in. */ const [favoriteSpellIds, setFavoriteSpellIds] = useState([]) @@ -350,6 +103,7 @@ const SpellsPage = forwardRef(function SpellsPage( const [spellToClone, setSpellToClone] = useState(null) const [definitionSpell, setDefinitionSpell] = useState(null) const [contacts, setContacts] = useState([]) + const contactsSyncKey = useMemo(() => [...contacts].sort().join(','), [contacts]) /** True while fetching kind 777 authored by the user from write relays into IndexedDB */ const [spellsCatalogSyncing, setSpellsCatalogSyncing] = useState(false) const spellCatalogCloserRef = useRef<(() => void) | null>(null) @@ -397,7 +151,7 @@ const SpellsPage = forwardRef(function SpellsPage( navigatePrimary('spells', { spell: 'heatMap' }) return } - if (spellProp && isSpellsPageFauxSpellParam(spellProp)) { + if (spellProp && isFauxSpellPageParam(spellProp)) { if (fauxSpellUrlSyncFromPickerRef.current === spellProp) { fauxSpellUrlSyncFromPickerRef.current = null urlFauxSpellInstrumentedRef.current = spellProp @@ -417,8 +171,6 @@ const SpellsPage = forwardRef(function SpellsPage( } }, [spellProp, logSpellFeedPickerSelection, navigatePrimary]) - const [followingSubRequests, setFollowingSubRequests] = useState([]) - const loadSpells = useCallback(async () => { const [events, ids] = await Promise.all([ indexedDb.getSpellEvents(), @@ -449,45 +201,38 @@ const SpellsPage = forwardRef(function SpellsPage( [refreshSpellsFeedAndCatalog] ) - /** - * Fingerprint by value — `relayList` from NostrProvider often gets a new object ref each render. - * Using `[relayList]` in useMemo deps was invalidating every tick → new subRequests → browse-relay - * effect → CurrentRelays churn → mass useFetchProfile cancellation (e.g. Discussions spell). - */ - const normalizedReadSorted = relayList - ? [...relayList.read].map((u) => normalizeUrl(u) || u).filter(Boolean).sort() - : [] - const normalizedWriteSorted = relayList - ? [...relayList.write].map((u) => normalizeUrl(u) || u).filter(Boolean).sort() - : [] - - /** Read+write only, order-stable. `originalRelays` churns during NIP-66 / discovery but faux spell REQ lists ignore it. */ - const relayMailboxStableKey = - relayList == null - ? '' - : JSON.stringify({ r: normalizedReadSorted, w: normalizedWriteSorted }) - - /** Write URLs only; mailbox key excludes discovery merges on `originalRelays`. */ - const relayListWriteKey = useMemo(() => { - if (!relayList) return '[]' - return JSON.stringify(normalizedWriteSorted) - }, [relayMailboxStableKey]) - - /** Order-independent favorites/blocked — array order from providers must not rebuild subs. */ - const sortedFavoriteRelaysKey = useMemo( - () => - JSON.stringify( - [...favoriteRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort((a, b) => a.localeCompare(b)) - ), - [favoriteRelays] - ) - const sortedBlockedRelaysKey = useMemo( - () => - JSON.stringify( - [...blockedRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort((a, b) => a.localeCompare(b)) - ), - [blockedRelays] - ) + const { + relayMailboxStableKey, + sortedFavoriteRelaysKey, + sortedBlockedRelaysKey, + subRequests, + spellFeedSubscriptionKey, + spellBrowseRelayUrlsKey, + showKinds, + fauxNoteListUseFilterAsIs, + spellFauxMergeTimeline, + notificationsMentionExtraHide, + hideRepliesFollowing, + fauxSubRequests, + NOTIFICATION_SPELL_LOADING_SAFETY_MS, + NOTIFICATION_SPELL_KINDS + } = useSpellsPageFeed({ + selectedFauxSpell, + selectedSpell, + pubkey, + relayList, + favoriteRelays, + blockedRelays, + notificationsFeedPubkey, + interestListEvent, + bookmarkListEvent, + followListEvent, + contacts, + contactsSyncKey, + followSetListEvents, + followSetCatalogLoading, + kindFilterShowKinds + }) useEffect(() => { if (!pubkey) { @@ -527,7 +272,7 @@ const SpellsPage = forwardRef(function SpellsPage( return () => { cancelled = true } - }, [pubkey, sortedFavoriteRelaysKey, sortedBlockedRelaysKey, relayMailboxStableKey, followSetManualRefreshKey]) + }, [pubkey, sortedFavoriteRelaysKey, sortedBlockedRelaysKey, relayMailboxStableKey, followSetManualRefreshKey, favoriteRelays, blockedRelays, relayList]) useEffect(() => { const onTombstones = () => setFollowSetManualRefreshKey((k) => k + 1) @@ -564,9 +309,6 @@ const SpellsPage = forwardRef(function SpellsPage( } }, [loadSpells, spellProp]) - /** Stable key so we re-sync when the follow list changes (not only on array identity). */ - const contactsSyncKey = useMemo(() => [...contacts].sort().join(','), [contacts]) - /** * Pull kind 777 from relays only when IndexedDB has no spells yet, or when the user requests refresh. * Otherwise the picker uses {@link loadSpells} from cache only (no extra REQ on each visit / relay churn). @@ -737,231 +479,6 @@ 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 (!pubkey || !isFollowFeedFauxSpellId(selectedFauxSpell)) { - setFollowingSubRequests([]) - return - } - - const followSetD = - selectedFauxSpell && isFollowSetSpellId(selectedFauxSpell) - ? decodeFollowSetSpellId(selectedFauxSpell) - : null - - if (followSetD && followSetCatalogLoading) { - setFollowingSubRequests([]) - return - } - - let cancelled = false - void (async () => { - const augment = (raw: TFeedSubRequest[]) => - augmentSubRequestsWithFavoritesFastReadAndInbox( - raw, - favoriteRelays, - blockedRelays, - userReadRelaysWithHttp(relayList), - { userWriteRelays: relayList?.write ?? [] } - ) - try { - if (selectedFauxSpell === 'following') { - const fromTags = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] - const provisionalAuthors = [...new Set([pubkey, ...fromTags])] - try { - const rawProv = await client.generateSubRequestsForPubkeys(provisionalAuthors, pubkey) - if (!cancelled) setFollowingSubRequests(augment(rawProv)) - } catch { - /* refined wave may still succeed */ - } - - let followings = fromTags - try { - followings = await client.fetchFollowings(pubkey) - } catch { - followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] - } - const fullAuthors = [...new Set([pubkey, ...followings])] - const sameSet = - fullAuthors.length === provisionalAuthors.length && - fullAuthors.every((p) => provisionalAuthors.includes(p)) && - provisionalAuthors.every((p) => fullAuthors.includes(p)) - if (sameSet) { - return - } - const req = await client.generateSubRequestsForPubkeys(fullAuthors, pubkey) - if (!cancelled) setFollowingSubRequests(augment(req)) - } else if (followSetD) { - const ev = followSetListEvents.find((e) => getFollowSetDTag(e) === followSetD) - if (!ev) { - if (!cancelled) setFollowingSubRequests([]) - return - } - const listed = pubkeysFromFollowSetEvent(ev) - const authorPubkeys = [pubkey, ...listed] - const req = await client.generateSubRequestsForPubkeys(authorPubkeys, pubkey) - if (!cancelled) setFollowingSubRequests(augment(req)) - } else { - if (!cancelled) setFollowingSubRequests([]) - } - } catch { - if (!cancelled) setFollowingSubRequests([]) - } - })() - return () => { - cancelled = true - } - }, [ - selectedFauxSpell, - pubkey, - sortedFavoriteRelaysKey, - sortedBlockedRelaysKey, - relayMailboxStableKey, - followSetCatalogLoading, - followSetListStableKey, - followListEvent?.id - ]) - - const interestTagsStableKey = interestListEvent - ? JSON.stringify( - [...interestListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) - ) - : '' - const bookmarkTagsStableKey = bookmarkListEvent - ? JSON.stringify( - [...bookmarkListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) - ) - : '' - - /** Content-based key so event ref churn does not rebuild faux subs every render. */ - const fauxFeedRelaysDepsKey = [ - sortedFavoriteRelaysKey, - sortedBlockedRelaysKey, - interestListEvent?.id ?? '', - String(interestListEvent?.created_at ?? ''), - interestTagsStableKey, - bookmarkListEvent?.id ?? '', - String(bookmarkListEvent?.created_at ?? ''), - bookmarkTagsStableKey - ].join('\0') - - const syncFauxSubRequests = useMemo(() => { - if ( - !selectedFauxSpell || - isFollowFeedFauxSpellId(selectedFauxSpell) || - selectedFauxSpell === 'heatMap' - ) - 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' || - selectedFauxSpell === 'followPacks' || - selectedFauxSpell === 'media' || - selectedFauxSpell === 'bookmarks' || - selectedFauxSpell === 'interests' - const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( - favoriteRelays, - blockedRelays, - userReadRelaysWithHttp(relayList), - { - userWriteRelays: relayList?.write ?? [], - applySocialKindBlockedFilter: fauxSpellSkipSocialKindBlocked ? false : undefined - } - ) - - if (selectedFauxSpell === 'notifications') { - if (!notificationsFeedPubkey || !feedUrls.length) return [] - return buildNotificationsSpellSubRequests(feedUrls, notificationsFeedPubkey) - } - if (selectedFauxSpell === 'discussions') { - if (!feedUrls.length) return [] - return [{ urls: feedUrls, filter: buildDiscussionFilter() }] - } - if (selectedFauxSpell === 'media') { - if (!feedUrls.length) return [] - return [{ urls: feedUrls, filter: buildMediaSpellFilter() }] - } - if (selectedFauxSpell === 'calendar') { - if (!feedUrls.length) return [] - return [{ urls: feedUrls, filter: buildCalendarSpellFilter() }] - } - if (selectedFauxSpell === 'interests') { - if (!pubkey || !interestListEvent) return [] - const topics = interestListEvent.tags.filter((tag) => tag[0] === 't' && tag[1]).map((tag) => tag[1]!) - return buildInterestsSubRequests(feedUrls, topics, DEFAULT_FEED_SHOW_KINDS) - } - if (selectedFauxSpell === 'bookmarks') { - if (!pubkey) return [] - const idReqs = buildBookmarksSubRequests(bookmarkListEvent, feedUrls) - const webReqs = buildWebBookmarksSpellSubRequests(pubkey, feedUrls) - return [...idReqs, ...webReqs] - } - if (selectedFauxSpell === 'followPacks') { - if (!feedUrls.length) return [] - return [ - { - urls: feedUrls, - filter: { kinds: [ExtendedKind.FOLLOW_PACK], limit: FAUX_SPELL_EVENT_LIMIT } - } - ] - } - return [] - }, [selectedFauxSpell, pubkey, notificationsFeedPubkey, fauxFeedRelaysDepsKey, relayMailboxStableKey]) - - const fauxSubRequests = useMemo(() => { - const base = isFollowFeedFauxSpellId(selectedFauxSpell ?? '') - ? followingSubRequests - : syncFauxSubRequests - return applyFauxSpellCapsToSubRequests(base) - }, [selectedFauxSpell, followingSubRequests, syncFauxSubRequests]) - - const spellSubRequests = useMemo(() => { - if (!selectedSpell) return [] - const relayListWrite = relayList?.write ?? [] - const ctx = { pubkey, contacts } - const filter = spellEventToFilter(selectedSpell, ctx) - if (!filter) return [] - const relays = getRelaysForSpell(selectedSpell, { relayListWrite }) - if (!relays.length) return [] - return [{ urls: relays, filter }] - // relayListWriteKey + contactsSyncKey: avoid recomputing when relayList/contacts are new refs with same contents (spell filters use Date.now via resolveRelativeTime) - }, [selectedSpell, pubkey, contactsSyncKey, relayListWriteKey]) - - const subRequests = useMemo(() => { - if (selectedFauxSpell) return fauxSubRequests - return spellSubRequests - }, [selectedFauxSpell, fauxSubRequests, spellSubRequests]) - - const spellFeedSubscriptionKey = useMemo(() => { - if (selectedFauxSpell) return computeSpellSubRequestsIdentityKey(subRequests) - if (selectedSpell) return computeKind777SpellFeedSubscriptionKey(selectedSpell, subRequests) - return '' - }, [selectedFauxSpell, selectedSpell, subRequests]) - - const spellBrowseRelayUrls = useMemo(() => { - const set = new Set() - for (const req of subRequests) { - for (const u of req.urls) { - const n = normalizeUrl(u) || u - if (n) set.add(n) - } - } - return [...set].sort() - }, [subRequests]) - - const spellBrowseRelayUrlsKey = spellBrowseRelayUrls.join('|') - const { addRelayUrls, removeRelayUrls } = useCurrentRelays() useEffect(() => { if (!spellBrowseRelayUrlsKey) return @@ -1083,66 +600,6 @@ const SpellsPage = forwardRef(function SpellsPage( } }, [spells, pubkey, contacts, favoriteSpellSet]) - const followSpellGroups = useMemo(() => groupSpellsByPubkeySorted(followSpells), [followSpells]) - const otherSpellGroups = useMemo(() => groupSpellsByPubkeySorted(otherSpells), [otherSpells]) - - // Memoize showKinds to prevent NoteList from re-subscribing when array reference changes - // Create stable key from 'k' tags for dependency - const showKindsTagKey = useMemo(() => { - if (!selectedSpell) return '' - return selectedSpell.tags - .filter((tag) => tag[0] === 'k') - .map((tag) => tag[1]) - .sort() - .join(',') - }, [selectedSpell?.id]) - - /** Avoid depending on `kindFilterShowKinds` ref for faux spells that don’t use it (e.g. Discussions). */ - const followingShowKindsKey = - selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell) - ? JSON.stringify(kindFilterShowKinds) - : '' - - const showKinds = useMemo(() => { - if (selectedFauxSpell === 'notifications') { - return [...NOTIFICATION_SPELL_KINDS] - } - if (selectedFauxSpell === 'discussions') { - return [ExtendedKind.DISCUSSION] - } - if (selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)) { - // Profile feed kinds omit boosts; show reposts as cards in this faux spell only. - const k = kindFilterShowKinds - const out = [...k] - if (!out.includes(nostrKinds.Repost)) out.push(nostrKinds.Repost) - if (!out.includes(ExtendedKind.GENERIC_REPOST)) out.push(ExtendedKind.GENERIC_REPOST) - return out.sort((a, b) => a - b) - } - if (selectedFauxSpell === 'followPacks') { - return [ExtendedKind.FOLLOW_PACK] - } - if (selectedFauxSpell === 'media') { - return [...MEDIA_SPELL_KINDS] - } - if (selectedFauxSpell === 'calendar') { - return [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME] - } - if (selectedFauxSpell === 'interests') { - return [...DEFAULT_FEED_SHOW_KINDS] - } - if (selectedFauxSpell === 'bookmarks') { - const out = [...DEFAULT_FEED_SHOW_KINDS] - if (!out.includes(ExtendedKind.WEB_BOOKMARK)) out.push(ExtendedKind.WEB_BOOKMARK) - return out.sort((a, b) => a - b) - } - if (!selectedSpell) return [1] - const kinds = selectedSpell.tags - .filter((tag) => tag[0] === 'k') - .map((tag) => parseInt(tag[1], 10)) - .filter((n) => !Number.isNaN(n)) - return kinds.length ? kinds : [1] - }, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey]) - const spellMenuLabel = useCallback( (spell: Event) => favoriteSpellSet.has(spell.id.toLowerCase()) ? `★ ${getSpellName(spell)}` : getSpellName(spell), @@ -1157,7 +614,7 @@ const SpellsPage = forwardRef(function SpellsPage( const ev = followSetListEvents.find((e) => getFollowSetDTag(e) === d) return ev ? labelFollowSetEvent(ev) : d } - if (isSpellsPageBuiltinFauxSpell(selectedFauxSpell)) { + if (isBuiltinFauxSpell(selectedFauxSpell)) { return t(fauxSpellLabelKey(selectedFauxSpell)) } return selectedFauxSpell @@ -1201,7 +658,7 @@ const SpellsPage = forwardRef(function SpellsPage( (name: string | null) => { setSpellPickerOpen(false) if (name) { - if (!isSpellsPageFauxSpellParam(name)) return + if (!isFauxSpellPageParam(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) { @@ -1241,18 +698,6 @@ const SpellsPage = forwardRef(function SpellsPage( [] ) - const fauxNoteListUseFilterAsIs = useMemo(() => { - if (!selectedFauxSpell) return true - if (selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)) return false - return true - }, [selectedFauxSpell]) - - const notificationsMentionExtraHide = useCallback( - (evt: Event) => - notificationsFeedPubkey ? !isUserInEventMentions(evt, notificationsFeedPubkey) : false, - [notificationsFeedPubkey] - ) - const fauxFeedEmptyMessage = useMemo(() => { if (!selectedFauxSpell || fauxSubRequests.length > 0) return null if (selectedFauxSpell === 'interests') return t('No subscribed interests yet.') @@ -1263,210 +708,50 @@ const SpellsPage = forwardRef(function SpellsPage( return t('Nothing to load for this feed.') }, [selectedFauxSpell, fauxSubRequests.length, t]) - const spellFauxMergeTimeline = useMemo( - () => !!selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell), - [selectedFauxSpell] - ) - const spellStarAddTitle = t('Spell star add title') const spellStarRemoveTitle = t('Spell star remove title') - const spellPickerList = ( - <> - {starredSpellsForPicker.length > 0 ? ( - <> -

- {t('Starred spells')} -

- {starredSpellsForPicker.map((spell) => ( - getSpellName(e)} - onPick={pickSpell} - starred - onToggleStar={(s) => void toggleFavoriteSpell(s)} - starTitleAdd={spellStarAddTitle} - starTitleRemove={spellStarRemoveTitle} - /> - ))} - - - ) : null} - {FAUX_SPELL_ORDER.flatMap((name) => { - if ( - (name === 'notifications' || - name === 'following' || - name === 'heatMap' || - name === 'bookmarks' || - name === 'interests') && - !pubkey - ) { - return [] - } - const Icon = FAUX_SPELL_ICON[name] - const selected = selectedFauxSpell === name - 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] - })} - - - {ownSpells.length > 0 ? ( - <> - -

- {t('spellPickerSectionYours')} -

- {ownSpells.map((spell) => ( - void toggleFavoriteSpell(s)} - starTitleAdd={spellStarAddTitle} - starTitleRemove={spellStarRemoveTitle} - /> - ))} - - ) : null} - - {followSpells.length > 0 ? ( - <> - -

- {t('Spells from follows', { count: followSpells.length })} -

- {followSpellGroups.map(({ pubkey: authorPk, spells: groupSpells }) => ( -
- -
- {groupSpells.map((spell) => ( - void toggleFavoriteSpell(s)} - starTitleAdd={spellStarAddTitle} - starTitleRemove={spellStarRemoveTitle} - /> - ))} -
-
- ))} - - ) : null} - - {otherSpells.length > 0 ? ( - <> - -

- {t('Other spells', { count: otherSpells.length })} -

- {otherSpellGroups.map(({ pubkey: authorPk, spells: groupSpells }) => ( -
- -
- {groupSpells.map((spell) => ( - void toggleFavoriteSpell(s)} - starTitleAdd={spellStarAddTitle} - starTitleRemove={spellStarRemoveTitle} - /> - ))} -
-
- ))} - - ) : null} - + const spellPickerPanel = useMemo( + () => ( + + ), + [ + t, + pubkey, + selectedSpell, + selectedFauxSpell, + favoriteSpellSet, + starredSpellsForPicker, + ownSpells, + followSpells, + otherSpells, + followSetListEvents, + spellMenuLabel, + spellStarAddTitle, + spellStarRemoveTitle, + pickSpell, + pickFauxSpell, + clearSpellSelection, + toggleFavoriteSpell + ] ) const spellPickerTriggerButton = ( @@ -1570,7 +855,7 @@ const SpellsPage = forwardRef(function SpellsPage( role="listbox" aria-label={t('Select a spell…')} > - {spellPickerList} + {spellPickerPanel} @@ -1590,7 +875,7 @@ const SpellsPage = forwardRef(function SpellsPage( {t('Select a spell…')}
- {spellPickerList} + {spellPickerPanel}
diff --git a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts new file mode 100644 index 00000000..6fc2a99f --- /dev/null +++ b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts @@ -0,0 +1,453 @@ +import { useCallback, useEffect, useMemo, useState } from 'react' +import type { Event } from 'nostr-tools' +import { kinds as nostrKinds } from 'nostr-tools' +import { ExtendedKind, DEFAULT_FEED_SHOW_KINDS } from '@/constants' +import { getPubkeysFromPTags } from '@/lib/tag' +import { normalizeUrl } from '@/lib/url' +import { + augmentSubRequestsWithFavoritesFastReadAndInbox, + getRelayUrlsWithFavoritesFastReadAndInbox, + userReadRelaysWithHttp +} from '@/lib/favorites-feed-relays' +import { + computeKind777SpellFeedSubscriptionKey, + computeSpellSubRequestsIdentityKey +} from '@/lib/spell-feed-request-identity' +import { isUserInEventMentions } from '@/lib/event' +import { + decodeFollowSetSpellId, + getFollowSetDTag, + isFollowSetSpellId, + pubkeysFromFollowSetEvent +} from '@/lib/follow-set-spell' +import client from '@/services/client.service' +import { + buildBookmarksSubRequests, + buildCalendarSpellFilter, + buildDiscussionFilter, + buildInterestsSubRequests, + buildMediaSpellFilter, + buildNotificationsSpellSubRequests, + buildWebBookmarksSpellSubRequests, + NOTIFICATION_SPELL_LOADING_SAFETY_MS, + FAUX_SPELL_EVENT_LIMIT, + MEDIA_SPELL_KINDS, + NOTIFICATION_SPELL_KINDS, + applyFauxSpellCapsToSubRequests +} from './fauxSpellFeeds' +import { getRelaysForSpell, spellEventToFilter } from '@/services/spell.service' +import type { TFeedSubRequest } from '@/types' +import { isFollowFeedFauxSpellId } from './fauxSpellConfig' +import storage from '@/services/local-storage.service' + +function useNoteListHideReplies() { + const [hideReplies, setHideReplies] = useState(() => storage.getNoteListMode() === 'posts') + + useEffect(() => { + const sync = () => setHideReplies(storage.getNoteListMode() === 'posts') + window.addEventListener('noteListModeChanged', sync) + return () => window.removeEventListener('noteListModeChanged', sync) + }, []) + + return hideReplies +} + +export type UseSpellsPageFeedArgs = { + selectedFauxSpell: string | null + selectedSpell: Event | null + pubkey: string | null | undefined + relayList: { read: string[]; write: string[] } | null | undefined + favoriteRelays: string[] + blockedRelays: string[] + notificationsFeedPubkey: string | null + interestListEvent: Event | null | undefined + bookmarkListEvent: Event | null | undefined + followListEvent: Event | null | undefined + contacts: string[] + contactsSyncKey: string + followSetListEvents: Event[] + followSetCatalogLoading: boolean + kindFilterShowKinds: number[] +} + +export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { + const { + selectedFauxSpell, + selectedSpell, + pubkey, + relayList, + favoriteRelays, + blockedRelays, + notificationsFeedPubkey, + interestListEvent, + bookmarkListEvent, + followListEvent, + contacts, + contactsSyncKey, + followSetListEvents, + followSetCatalogLoading, + kindFilterShowKinds + } = a + + const hideRepliesFollowing = useNoteListHideReplies() + const [followingSubRequests, setFollowingSubRequests] = useState([]) + + const normalizedReadSorted = relayList + ? [...relayList.read].map((u) => normalizeUrl(u) || u).filter(Boolean).sort() + : [] + const normalizedWriteSorted = relayList + ? [...relayList.write].map((u) => normalizeUrl(u) || u).filter(Boolean).sort() + : [] + + const relayMailboxStableKey = + relayList == null + ? '' + : JSON.stringify({ r: normalizedReadSorted, w: normalizedWriteSorted }) + + const relayListWriteKey = useMemo(() => { + if (!relayList) return '[]' + return JSON.stringify(normalizedWriteSorted) + }, [relayMailboxStableKey]) + + const sortedFavoriteRelaysKey = useMemo( + () => + JSON.stringify( + [...favoriteRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort((x, y) => x.localeCompare(y)) + ), + [favoriteRelays] + ) + const sortedBlockedRelaysKey = useMemo( + () => + JSON.stringify( + [...blockedRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort((x, y) => x.localeCompare(y)) + ), + [blockedRelays] + ) + + const followSetListStableKey = useMemo( + () => + followSetListEvents + .map((e) => { + const d = getFollowSetDTag(e) ?? '' + return `${d}:${e.id}:${e.created_at}` + }) + .sort() + .join('|'), + [followSetListEvents] + ) + + useEffect(() => { + if (!pubkey || !isFollowFeedFauxSpellId(selectedFauxSpell)) { + setFollowingSubRequests([]) + return + } + + const followSetD = + selectedFauxSpell && isFollowSetSpellId(selectedFauxSpell) + ? decodeFollowSetSpellId(selectedFauxSpell) + : null + + if (followSetD && followSetCatalogLoading) { + setFollowingSubRequests([]) + return + } + + let cancelled = false + void (async () => { + const augment = (raw: TFeedSubRequest[]) => + augmentSubRequestsWithFavoritesFastReadAndInbox( + raw, + favoriteRelays, + blockedRelays, + userReadRelaysWithHttp(relayList), + { userWriteRelays: relayList?.write ?? [] } + ) + try { + if (selectedFauxSpell === 'following') { + const fromTags = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] + const provisionalAuthors = [...new Set([pubkey, ...fromTags])] + let provisionalOk = false + try { + const rawProv = await client.generateSubRequestsForPubkeys(provisionalAuthors, pubkey) + if (!cancelled) { + setFollowingSubRequests(augment(rawProv)) + provisionalOk = true + } + } catch { + /* refined wave may still succeed */ + } + + let followings = fromTags + try { + followings = await client.fetchFollowings(pubkey) + } catch { + followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] + } + const fullAuthors = [...new Set([pubkey, ...followings])] + const sameSet = + fullAuthors.length === provisionalAuthors.length && + fullAuthors.every((p) => provisionalAuthors.includes(p)) && + provisionalAuthors.every((p) => fullAuthors.includes(p)) + if (sameSet) { + if (!provisionalOk && !cancelled) { + try { + const req = await client.generateSubRequestsForPubkeys(fullAuthors, pubkey) + if (!cancelled) setFollowingSubRequests(augment(req)) + } catch { + if (!cancelled) setFollowingSubRequests([]) + } + } + return + } + const req = await client.generateSubRequestsForPubkeys(fullAuthors, pubkey) + if (!cancelled) setFollowingSubRequests(augment(req)) + } else if (followSetD) { + const ev = followSetListEvents.find((e) => getFollowSetDTag(e) === followSetD) + if (!ev) { + if (!cancelled) setFollowingSubRequests([]) + return + } + const listed = pubkeysFromFollowSetEvent(ev) + const authorPubkeys = [pubkey, ...listed] + const req = await client.generateSubRequestsForPubkeys(authorPubkeys, pubkey) + if (!cancelled) setFollowingSubRequests(augment(req)) + } else { + if (!cancelled) setFollowingSubRequests([]) + } + } catch { + if (!cancelled) setFollowingSubRequests([]) + } + })() + return () => { + cancelled = true + } + }, [ + selectedFauxSpell, + pubkey, + sortedFavoriteRelaysKey, + sortedBlockedRelaysKey, + relayMailboxStableKey, + followSetCatalogLoading, + followSetListStableKey, + followListEvent?.id, + favoriteRelays, + blockedRelays, + relayList, + followListEvent + ]) + + const interestTagsStableKey = interestListEvent + ? JSON.stringify( + [...interestListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) + ) + : '' + const bookmarkTagsStableKey = bookmarkListEvent + ? JSON.stringify( + [...bookmarkListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) + ) + : '' + + const fauxFeedRelaysDepsKey = [ + sortedFavoriteRelaysKey, + sortedBlockedRelaysKey, + interestListEvent?.id ?? '', + String(interestListEvent?.created_at ?? ''), + interestTagsStableKey, + bookmarkListEvent?.id ?? '', + String(bookmarkListEvent?.created_at ?? ''), + bookmarkTagsStableKey + ].join('\0') + + const syncFauxSubRequests = useMemo(() => { + if ( + !selectedFauxSpell || + isFollowFeedFauxSpellId(selectedFauxSpell) || + selectedFauxSpell === 'heatMap' + ) + return [] + const fauxSpellSkipSocialKindBlocked = + selectedFauxSpell === 'calendar' || + selectedFauxSpell === 'followPacks' || + selectedFauxSpell === 'media' || + selectedFauxSpell === 'bookmarks' || + selectedFauxSpell === 'interests' + const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays, + blockedRelays, + userReadRelaysWithHttp(relayList), + { + userWriteRelays: relayList?.write ?? [], + applySocialKindBlockedFilter: fauxSpellSkipSocialKindBlocked ? false : undefined + } + ) + + if (selectedFauxSpell === 'notifications') { + if (!notificationsFeedPubkey || !feedUrls.length) return [] + return buildNotificationsSpellSubRequests(feedUrls, notificationsFeedPubkey) + } + if (selectedFauxSpell === 'discussions') { + if (!feedUrls.length) return [] + return [{ urls: feedUrls, filter: buildDiscussionFilter() }] + } + if (selectedFauxSpell === 'media') { + if (!feedUrls.length) return [] + return [{ urls: feedUrls, filter: buildMediaSpellFilter() }] + } + if (selectedFauxSpell === 'calendar') { + if (!feedUrls.length) return [] + return [{ urls: feedUrls, filter: buildCalendarSpellFilter() }] + } + if (selectedFauxSpell === 'interests') { + if (!pubkey || !interestListEvent) return [] + const topics = interestListEvent.tags.filter((tag) => tag[0] === 't' && tag[1]).map((tag) => tag[1]!) + return buildInterestsSubRequests(feedUrls, topics, DEFAULT_FEED_SHOW_KINDS) + } + if (selectedFauxSpell === 'bookmarks') { + if (!pubkey) return [] + const idReqs = buildBookmarksSubRequests(bookmarkListEvent ?? null, feedUrls) + const webReqs = buildWebBookmarksSpellSubRequests(pubkey, feedUrls) + return [...idReqs, ...webReqs] + } + if (selectedFauxSpell === 'followPacks') { + if (!feedUrls.length) return [] + return [ + { + urls: feedUrls, + filter: { kinds: [ExtendedKind.FOLLOW_PACK], limit: FAUX_SPELL_EVENT_LIMIT } + } + ] + } + return [] + }, [selectedFauxSpell, pubkey, notificationsFeedPubkey, fauxFeedRelaysDepsKey, relayMailboxStableKey, interestListEvent, bookmarkListEvent, favoriteRelays, blockedRelays, relayList]) + + const fauxSubRequests = useMemo(() => { + const base = isFollowFeedFauxSpellId(selectedFauxSpell ?? '') + ? followingSubRequests + : syncFauxSubRequests + return applyFauxSpellCapsToSubRequests(base) + }, [selectedFauxSpell, followingSubRequests, syncFauxSubRequests]) + + const spellSubRequests = useMemo(() => { + if (!selectedSpell) return [] + const relayListWrite = relayList?.write ?? [] + const ctx = { pubkey: pubkey ?? null, contacts } + const filter = spellEventToFilter(selectedSpell, ctx) + if (!filter) return [] + const relays = getRelaysForSpell(selectedSpell, { relayListWrite }) + if (!relays.length) return [] + return [{ urls: relays, filter }] + }, [selectedSpell, pubkey, contactsSyncKey, relayListWriteKey, contacts, relayList]) + + const subRequests = useMemo(() => { + if (selectedFauxSpell) return fauxSubRequests + return spellSubRequests + }, [selectedFauxSpell, fauxSubRequests, spellSubRequests]) + + const spellFeedSubscriptionKey = useMemo(() => { + if (selectedFauxSpell) return computeSpellSubRequestsIdentityKey(subRequests) + if (selectedSpell) return computeKind777SpellFeedSubscriptionKey(selectedSpell, subRequests) + return '' + }, [selectedFauxSpell, selectedSpell, subRequests]) + + const spellBrowseRelayUrls = useMemo(() => { + const set = new Set() + for (const req of subRequests) { + for (const u of req.urls) { + const n = normalizeUrl(u) || u + if (n) set.add(n) + } + } + return [...set].sort() + }, [subRequests]) + + const spellBrowseRelayUrlsKey = spellBrowseRelayUrls.join('|') + + const showKindsTagKey = useMemo(() => { + if (!selectedSpell) return '' + return selectedSpell.tags + .filter((tag) => tag[0] === 'k') + .map((tag) => tag[1]) + .sort() + .join(',') + }, [selectedSpell?.id]) + + const followingShowKindsKey = + selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell) + ? JSON.stringify(kindFilterShowKinds) + : '' + + const showKinds = useMemo(() => { + if (selectedFauxSpell === 'notifications') { + return [...NOTIFICATION_SPELL_KINDS] + } + if (selectedFauxSpell === 'discussions') { + return [ExtendedKind.DISCUSSION] + } + if (selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)) { + const k = kindFilterShowKinds + const out = [...k] + if (!out.includes(nostrKinds.Repost)) out.push(nostrKinds.Repost) + if (!out.includes(ExtendedKind.GENERIC_REPOST)) out.push(ExtendedKind.GENERIC_REPOST) + return out.sort((x, y) => x - y) + } + if (selectedFauxSpell === 'followPacks') { + return [ExtendedKind.FOLLOW_PACK] + } + if (selectedFauxSpell === 'media') { + return [...MEDIA_SPELL_KINDS] + } + if (selectedFauxSpell === 'calendar') { + return [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME] + } + if (selectedFauxSpell === 'interests') { + return [...DEFAULT_FEED_SHOW_KINDS] + } + if (selectedFauxSpell === 'bookmarks') { + const out = [...DEFAULT_FEED_SHOW_KINDS] + if (!out.includes(ExtendedKind.WEB_BOOKMARK)) out.push(ExtendedKind.WEB_BOOKMARK) + return out.sort((a, b) => a - b) + } + if (!selectedSpell) return [1] + const kinds = selectedSpell.tags + .filter((tag) => tag[0] === 'k') + .map((tag) => parseInt(tag[1], 10)) + .filter((n) => !Number.isNaN(n)) + return kinds.length ? kinds : [1] + }, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey]) + + const fauxNoteListUseFilterAsIs = useMemo(() => { + if (!selectedFauxSpell) return true + if (selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)) return false + return true + }, [selectedFauxSpell]) + + const spellFauxMergeTimeline = useMemo( + () => !!selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell), + [selectedFauxSpell] + ) + + const notificationsMentionExtraHide = useCallback( + (evt: Event) => + notificationsFeedPubkey ? !isUserInEventMentions(evt, notificationsFeedPubkey) : false, + [notificationsFeedPubkey] + ) + + return { + relayMailboxStableKey, + sortedFavoriteRelaysKey, + sortedBlockedRelaysKey, + followingSubRequests, + fauxSubRequests, + subRequests, + spellFeedSubscriptionKey, + spellBrowseRelayUrls, + spellBrowseRelayUrlsKey, + showKinds, + fauxNoteListUseFilterAsIs, + spellFauxMergeTimeline, + notificationsMentionExtraHide, + hideRepliesFollowing, + NOTIFICATION_SPELL_LOADING_SAFETY_MS, + NOTIFICATION_SPELL_KINDS + } +} diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 7104560c..89e3305d 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -16,6 +16,14 @@ import { useNostr } from './NostrProvider' export { useFeed } from './feed-context' export type { TFeedContext } from './feed-context' +function relayUrlListIdentity(urls: string[]): string { + return urls + .map((u) => normalizeAnyRelayUrl(u) || u.trim()) + .filter(Boolean) + .sort() + .join('\n') +} + export function FeedProvider({ children }: { children: React.ReactNode }) { const { pubkey, isInitialized, cacheRelayListEvent, httpRelayListEvent } = useNostr() const { relaySets, favoriteRelays, blockedRelays } = useFavoriteRelays() @@ -48,6 +56,13 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { id: DEFAULT_FAVORITE_RELAYS[0] }) const feedInfoRef = useRef(feedInfo) + /** Same logical list as {@link mergeRelayUrlLayers} result — reuse array ref so NoteList does not re-subscribe. */ + const setRelayUrlsIfChanged = useCallback((next: string[]) => { + setRelayUrls((prev) => { + if (relayUrlListIdentity(prev) === relayUrlListIdentity(next)) return prev + return next + }) + }, []) const switchFeed = useCallback(async ( feedType: TFeedType, @@ -81,7 +96,7 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { logger.component('FeedProvider', 'Setting relay feed info', newFeedInfo) setFeedInfo(newFeedInfo) feedInfoRef.current = newFeedInfo - setRelayUrls([normalizedUrl]) + setRelayUrlsIfChanged([normalizedUrl]) logger.component('FeedProvider', 'Set relayUrls', { relayUrls: [normalizedUrl] }) storage.setFeedInfo(newFeedInfo, pubkey) // Reset note list mode to 'posts' when switching to relay feed to ensure main content is shown @@ -114,7 +129,7 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { const newFeedInfo = { feedType, id: relaySet.id } setFeedInfo(newFeedInfo) feedInfoRef.current = newFeedInfo - setRelayUrls(relaySet.relayUrls) + setRelayUrlsIfChanged(relaySet.relayUrls) storage.setFeedInfo(newFeedInfo, pubkey) // Reset note list mode to 'posts' when switching to relay set to ensure main content is shown storage.setNoteListMode('posts') @@ -130,7 +145,7 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { const newFeedInfo = { feedType } setFeedInfo(newFeedInfo) feedInfoRef.current = newFeedInfo - setRelayUrls(finalRelays) + setRelayUrlsIfChanged(finalRelays) storage.setFeedInfo(newFeedInfo, pubkey) // Reset note list mode to 'posts' when switching to all-favorites to ensure main content is shown storage.setNoteListMode('posts') @@ -138,7 +153,44 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { return } setIsReady(true) - }, [pubkey, favoriteRelays, blockedRelays, relaySets, extraFeedRelayUrls]) + }, [pubkey, favoriteRelays, blockedRelays, relaySets, extraFeedRelayUrls, setRelayUrlsIfChanged]) + + const switchFeedRef = useRef(switchFeed) + switchFeedRef.current = switchFeed + + const favoriteRelaysIdentity = useMemo( + () => + [...favoriteRelays] + .map((u) => normalizeAnyRelayUrl(u) || u.trim()) + .filter(Boolean) + .sort() + .join('|'), + [favoriteRelays] + ) + const blockedRelaysIdentity = useMemo( + () => + [...blockedRelays] + .map((u) => normalizeAnyRelayUrl(u) || u.trim()) + .filter(Boolean) + .sort() + .join('|'), + [blockedRelays] + ) + const relaySetsIdentity = useMemo( + () => + relaySets + .map((s) => { + const urls = [...s.relayUrls] + .map((u) => normalizeAnyRelayUrl(u) || u.trim()) + .filter(Boolean) + .sort() + .join(',') + return `${s.id}:${urls}` + }) + .sort() + .join('\n'), + [relaySets] + ) useEffect(() => { const init = async () => { @@ -185,11 +237,11 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { logger.info('[FeedProvider] Migrated deprecated feed type to all-favorites', { previous: previousMainFeed }) - return await switchFeed('all-favorites') + return await switchFeedRef.current('all-favorites') } if (feedInfo.feedType === 'relays') { - return await switchFeed('relays', { activeRelaySetId: feedInfo.id }) + return await switchFeedRef.current('relays', { activeRelaySetId: feedInfo.id }) } if (feedInfo.feedType === 'relay') { @@ -199,17 +251,17 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { feedInfo.id = favoritesFeedRelays[0] ?? DEFAULT_FAVORITE_RELAYS[0] } logger.component('FeedProvider', 'Initial relay setup, calling switchFeed', { relayId: feedInfo.id }) - return await switchFeed('relay', { relay: feedInfo.id }) + return await switchFeedRef.current('relay', { relay: feedInfo.id }) } if (feedInfo.feedType === 'all-favorites') { logger.debug('Initializing all-favorites feed') - return await switchFeed('all-favorites') + return await switchFeedRef.current('all-favorites') } } - init() - }, [pubkey, isInitialized, favoriteRelays, blockedRelays, switchFeed]) + void init() + }, [pubkey, isInitialized, favoriteRelaysIdentity, blockedRelaysIdentity, relaySetsIdentity]) // Update relay URLs when favoriteRelays, blocked, or extra relay lists change while in all-favorites mode useEffect(() => { @@ -219,21 +271,8 @@ export function FeedProvider({ children }: { children: React.ReactNode }) { logger.debug('Updating relay URLs for all-favorites:', finalRelays) // Same logical list can be merged into a new array each run; keep the previous reference so // feed consumers (RelaysFeed → NoteList relay subscription) do not re-enter effects in a tight loop. - const nextKey = finalRelays - .map((u) => normalizeAnyRelayUrl(u) || u) - .filter(Boolean) - .sort() - .join('\n') - setRelayUrls((prev) => { - const prevKey = prev - .map((u) => normalizeAnyRelayUrl(u) || u) - .filter(Boolean) - .sort() - .join('\n') - if (prevKey === nextKey) return prev - return finalRelays - }) - }, [feedInfo.feedType, favoriteRelays, blockedRelays, extraFeedRelayUrls]) + setRelayUrlsIfChanged(finalRelays) + }, [feedInfo.feedType, favoriteRelays, blockedRelays, extraFeedRelayUrls, setRelayUrlsIfChanged]) return (