diff --git a/src/components/QuoteList/index.tsx b/src/components/QuoteList/index.tsx index 9ce973af..afe86384 100644 --- a/src/components/QuoteList/index.tsx +++ b/src/components/QuoteList/index.tsx @@ -1,6 +1,7 @@ import { FAST_READ_RELAY_URLS } from '@/constants' import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' import { normalizeUrl } from '@/lib/url' +import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useNostr } from '@/providers/NostrProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import client from '@/services/client.service' @@ -13,6 +14,8 @@ import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' const LIMIT = 100 const SHOW_COUNT = 10 +/** Multi-filter quote subs only set `eosed` after every sub EOSEs; one stuck relay would otherwise leave the UI loading forever. */ +const INITIAL_QUOTE_LOAD_TIMEOUT_MS = 12_000 export default function QuoteList({ event, @@ -26,6 +29,7 @@ export default function QuoteList({ }) { const { t } = useTranslation() const { relayList: userRelayList } = useNostr() + const { relayUrls: browsingRelayUrls } = useCurrentRelays() const { hideUntrustedInteractions, isUserTrusted } = useUserTrust() const [timelineKey, setTimelineKey] = useState(undefined) const [events, setEvents] = useState([]) @@ -33,19 +37,35 @@ export default function QuoteList({ const [hasMore, setHasMore] = useState(true) const [loading, setLoading] = useState(true) const bottomRef = useRef(null) + const receivedAnyQuotesRef = useRef(false) useEffect(() => { + let cancelled = false + let loadTimeoutId: ReturnType | undefined + async function init() { setLoading(true) setEvents([]) setHasMore(true) + receivedAnyQuotesRef.current = false + + loadTimeoutId = setTimeout(() => { + if (cancelled) return + setLoading(false) + if (!receivedAnyQuotesRef.current) { + setHasMore(false) + } + }, INITIAL_QUOTE_LOAD_TIMEOUT_MS) - // Privacy: Only use user's own relays + defaults, never connect to other users' relays const userRelays = userRelayList?.read || [] - const finalRelayUrls = Array.from(new Set([ - ...userRelays.map(url => normalizeUrl(url) || url), - ...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url) - ])) + const fromFeed = browsingRelayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean) + const finalRelayUrls = Array.from( + new Set([ + ...fromFeed, + ...userRelays.map((url) => normalizeUrl(url) || url), + ...FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url) + ]) + ) const eventId = isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : event.id const eventCoordinate = isReplaceableEvent(event.kind) ? getReplaceableCoordinateFromEvent(event) : `${event.kind}:${event.pubkey}:${event.id}` @@ -86,21 +106,34 @@ export default function QuoteList({ } ], { - onEvents: (events, eosed) => { - if (events.length > 0) { - setEvents(events) + onEvents: (batch, eosed) => { + if (cancelled) return + if (batch.length > 0) { + receivedAnyQuotesRef.current = true + setEvents(batch) } - if (eosed) { + if (batch.length > 0 || eosed) { setLoading(false) - // CRITICAL FIX: Always assume there might be more events - // Even if we got fewer events than the limit, there might be more due to filtering - // The loadMore logic will handle stopping when we've truly reached the end - setHasMore(true) + if (loadTimeoutId) { + clearTimeout(loadTimeoutId) + loadTimeoutId = undefined + } + } + if (eosed) { + setHasMore(batch.length > 0) } }, - onNew: (event) => { + onNew: (newEvt) => { + if (cancelled) return + receivedAnyQuotesRef.current = true + setLoading(false) + if (loadTimeoutId) { + clearTimeout(loadTimeoutId) + loadTimeoutId = undefined + } + setHasMore(true) setEvents((oldEvents) => - [event, ...oldEvents].sort((a, b) => b.created_at - a.created_at) + [newEvt, ...oldEvents].sort((a, b) => b.created_at - a.created_at) ) } }, @@ -108,15 +141,21 @@ export default function QuoteList({ useCache: false // NO CACHING - stream raw from relays } ) + if (cancelled) { + closer() + return undefined + } setTimelineKey(timelineKey) return closer } const promise = init() return () => { - promise.then((closer) => closer()) + cancelled = true + if (loadTimeoutId) clearTimeout(loadTimeoutId) + promise.then((closer) => closer?.()) } - }, [event]) + }, [event, browsingRelayUrls, userRelayList?.read]) useEffect(() => { const options = { diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index f9baa154..959b5230 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -11,6 +11,7 @@ import { } from '@/lib/event' import { shouldHideInteractions } from '@/lib/event-filtering' import logger from '@/lib/logger' +import { normalizeUrl } from '@/lib/url' import { toNote } from '@/lib/link' import { generateBech32IdFromETag, tagNameEquals } from '@/lib/tag' import { useSmartNoteNavigation, useSecondaryPage } from '@/PageManager' @@ -19,12 +20,13 @@ import { useMuteList } from '@/providers/MuteListProvider' import { useNostr } from '@/providers/NostrProvider' import { useReply } from '@/providers/ReplyProvider' import { useUserTrust } from '@/providers/UserTrustProvider' +import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import client from '@/services/client.service' import { eventService, queryService } from '@/services/client.service' import noteStatsService from '@/services/note-stats.service' import discussionFeedCache from '@/services/discussion-feed-cache.service' -import { buildReplyReadRelayList } from '@/lib/relay-list-builder' +import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' import { Filter, Event as NEvent, kinds } from 'nostr-tools' import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -63,6 +65,7 @@ function ReplyNoteList({ const { hideContentMentioningMutedUsers } = useContentPolicy() const { relayList: userRelayList, pubkey: userPubkey } = useNostr() const { blockedRelays } = useFavoriteRelays() + const { relayUrls: browsingRelayUrls } = useCurrentRelays() const [rootInfo, setRootInfo] = useState(undefined) const { repliesMap, addReplies } = useReply() @@ -324,10 +327,16 @@ function ReplyNoteList({ try { // READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes const opAuthorPubkey = rootInfo.type === 'E' || rootInfo.type === 'A' ? rootInfo.pubkey : undefined + const seenOn = client.getSeenEventRelayUrls(event.id).map((u) => normalizeUrl(u) || u).filter(Boolean) + const fromBrowsingFeed = browsingRelayUrls.map((u) => normalizeUrl(u) || u).filter(Boolean) + const threadRelayHints = [ + ...new Set([...relayHintsFromEventTags(event), ...seenOn, ...fromBrowsingFeed]) + ] const finalRelayUrls = await buildReplyReadRelayList( opAuthorPubkey, userPubkey || undefined, - blockedRelays || [] + blockedRelays || [], + threadRelayHints ) const filters: Filter[] = [] @@ -410,7 +419,7 @@ function ReplyNoteList({ } init() - }, [rootInfo, currentIndex, index, userRelayList, event.kind, addReplies]) + }, [rootInfo, currentIndex, index, userRelayList, event, blockedRelays, browsingRelayUrls, addReplies]) useEffect(() => { if (replies.length === 0 && !loading && timelineKey) { diff --git a/src/components/WebPreview/index.tsx b/src/components/WebPreview/index.tsx index fce47315..1625e5de 100644 --- a/src/components/WebPreview/index.tsx +++ b/src/components/WebPreview/index.tsx @@ -134,7 +134,7 @@ export default function WebPreview({ url, className }: { url: string; className? const { isSmallScreen } = useScreenSize() const cleanedUrl = useMemo(() => cleanUrl(url), [url]) - const { title, description, image } = useFetchWebMetadata(cleanedUrl) + const { title, description, image, ogLoading } = useFetchWebMetadata(cleanedUrl) const hostname = useMemo(() => { try { diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index a9a1fdc6..86242d30 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -257,6 +257,18 @@ export async function buildExploreProfileAndUserRelayList( } } +/** NIP-10 relay hints from `e` / `E` tags (third value) on the focused event or thread. */ +export function relayHintsFromEventTags(event: { tags: string[][] }): string[] { + const out = new Set() + for (const tag of event.tags) { + if ((tag[0] === 'e' || tag[0] === 'E') && tag[2]) { + const n = normalizeUrl(tag[2]) || tag[2] + if (n) out.add(n) + } + } + return [...out] +} + /** * Build relay list for reading replies/comments * READ from: FAST_READ_RELAY_URLS + user's inboxes + local relays + OP author's outboxes @@ -264,11 +276,13 @@ export async function buildExploreProfileAndUserRelayList( export async function buildReplyReadRelayList( opAuthorPubkey: string | undefined, userPubkey: string | undefined, - blockedRelays: string[] = [] + blockedRelays: string[] = [], + threadRelayHints: string[] = [] ): Promise { return buildComprehensiveRelayList({ authorPubkey: opAuthorPubkey, userPubkey, + relayHints: threadRelayHints, includeFastReadRelays: true, includeLocalRelays: true, blockedRelays diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index 2f1c3090..76ccea47 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts @@ -42,14 +42,38 @@ export function fauxFavoriteRelayUrls(favoriteRelays: string[], blockedRelays: s return dedupe(base.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]) } +/** Same cap/priority as the main Notification list: read/inbox relays first, then favorites, then defaults (few relays → faster EOSE, fewer dead sockets). */ +const NOTIFICATION_FEED_MAX_RELAYS = 5 + +function relayUrlsUpToUnblocked(urls: string[], blocked: Set, max: number): string[] { + const seen = new Set() + const out: string[] = [] + for (const u of urls) { + const k = normalizeUrl(u) || u + if (!k || blocked.has(k) || seen.has(k)) continue + seen.add(k) + out.push(k) + if (out.length >= max) break + } + return out +} + export function notificationRelayUrls( relayList: TRelayList | null | undefined, - favoriteRelays: string[] + favoriteRelays: string[], + blockedRelays: string[] = [] ): string[] { + const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b)) const read = relayList?.read ?? [] - if (read.length > 0) return dedupe(read.slice(0, 5)) - if (favoriteRelays.length > 0) return dedupe(favoriteRelays.slice(0, 5)) - return dedupe(FAST_READ_RELAY_URLS.slice(0, 5)) + if (read.length > 0) { + const fromRead = relayUrlsUpToUnblocked(read, blocked, NOTIFICATION_FEED_MAX_RELAYS) + if (fromRead.length > 0) return fromRead + } + if (favoriteRelays.length > 0) { + const fromFav = relayUrlsUpToUnblocked(favoriteRelays, blocked, NOTIFICATION_FEED_MAX_RELAYS) + if (fromFav.length > 0) return fromFav + } + return relayUrlsUpToUnblocked(FAST_READ_RELAY_URLS, blocked, NOTIFICATION_FEED_MAX_RELAYS) } function dedupe(urls: string[]): string[] { diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 20ac8a44..d3858ef1 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -15,12 +15,7 @@ import { DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import { Separator } from '@/components/ui/separator' -import { - Sheet, - SheetContent, - SheetHeader, - SheetTitle -} from '@/components/ui/sheet' +import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from '@/components/ui/drawer' import UserAvatar from '@/components/UserAvatar' import Username from '@/components/Username' import PrimaryPageLayout from '@/layouts/PrimaryPageLayout' @@ -28,9 +23,11 @@ import { usePrimaryPage } from '@/PageManager' 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 { useKindFilter } from '@/providers/KindFilterProvider' import { useNostr } from '@/providers/NostrProvider' +import { useScreenSize } from '@/providers/ScreenSizeProvider' import { useUserTrust } from '@/providers/UserTrustProvider' import client from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' @@ -38,6 +35,7 @@ import storage from '@/services/local-storage.service' import { ExtendedKind, FAUX_SPELL_ORDER, PROFILE_FEED_KINDS } from '@/constants' import { isUserInEventMentions } from '@/lib/event' import { formatPubkey } from '@/lib/pubkey' +import { normalizeUrl } from '@/lib/url' import { buildSpellCatalogAuthors, getRelaysForSpell, @@ -242,6 +240,7 @@ const SpellsPage = forwardRef(function SpellsPage( const { navigate: navigatePrimary } = usePrimaryPage() const { pubkey, relayList, attemptDelete, bookmarkListEvent, interestListEvent } = useNostr() const { hideUntrustedNotifications } = useUserTrust() + const { isSmallScreen } = useScreenSize() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { showKinds: kindFilterShowKinds, @@ -320,6 +319,18 @@ const SpellsPage = forwardRef(function SpellsPage( [relayList] ) + /** Content key only — `relayList` often gets a new object ref from NostrProvider; recomputing spell filters would re-run `resolveRelativeTime` (Date.now) and churn NoteList subscriptions. */ + const relayListWriteKey = useMemo( + () => + JSON.stringify( + [...(relayList?.write ?? [])] + .map((u) => normalizeUrl(u) || u) + .filter(Boolean) + .sort() + ), + [relayList] + ) + useEffect(() => { loadSpells() }, [loadSpells]) @@ -414,7 +425,7 @@ const SpellsPage = forwardRef(function SpellsPage( if (selectedFauxSpell === 'notifications') { if (!pubkey) return [] - const urls = fauxFavoriteRelayUrls(favoriteRelays, blockedRelays) + const urls = notificationRelayUrls(relayList, favoriteRelays, blockedRelays) if (!urls.length) return [] return [{ urls, filter: buildMentionsSpellFilter(pubkey) }] } @@ -441,17 +452,18 @@ const SpellsPage = forwardRef(function SpellsPage( } if (selectedFauxSpell === 'bookmarks') { if (!pubkey) return [] - const urls = notificationRelayUrls(relayList, favoriteRelays) + const urls = notificationRelayUrls(relayList, favoriteRelays, blockedRelays) return buildBookmarksSubRequests(bookmarkListEvent, urls) } if (selectedFauxSpell === 'followPacks') { return buildFollowPacksSubRequests() } return [] + // spellCatalogRelayKey: stable mailbox fingerprint (not relayList ref) so faux feeds don’t rebuild every NostrProvider tick }, [ selectedFauxSpell, pubkey, - relayList, + spellCatalogRelayKey, favoriteRelays, blockedRelays, interestListEvent, @@ -472,13 +484,32 @@ const SpellsPage = forwardRef(function SpellsPage( const relays = getRelaysForSpell(selectedSpell, { relayListWrite }) if (!relays.length) return [] return [{ urls: relays, filter }] - }, [selectedSpell, pubkey, contacts, relayList?.write]) + // 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 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] + }, [subRequests]) + + const { addRelayUrls, removeRelayUrls } = useCurrentRelays() + useEffect(() => { + if (!spellBrowseRelayUrls.length) return + addRelayUrls(spellBrowseRelayUrls) + return () => removeRelayUrls(spellBrowseRelayUrls) + }, [spellBrowseRelayUrls, addRelayUrls, removeRelayUrls]) + const toggleFavorite = useCallback(async (spellId: string) => { const ids = await indexedDb.getSpellFavoriteIds() const set = new Set(ids) @@ -645,6 +676,162 @@ const SpellsPage = forwardRef(function SpellsPage( return t('Nothing to load for this feed.') }, [selectedFauxSpell, fauxSubRequests.length, t]) + const spellPickerList = ( + <> + {FAUX_SPELL_ORDER.map((name) => { + if ( + (name === 'notifications' || + name === 'following' || + name === 'bookmarks' || + name === 'interests') && + !pubkey + ) { + return null + } + const Icon = FAUX_SPELL_ICON[name] + const selected = selectedFauxSpell === name + return ( + + ) + })} + + + {ownSpells.length > 0 ? ( + <> + +

+ {t('spellPickerSectionYours')} +

+ {ownSpells.map((spell) => ( + + ))} + + ) : null} + + {followSpells.length > 0 ? ( + <> + +

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

+ {followSpellGroups.map(({ pubkey: authorPk, spells: groupSpells }) => ( +
+ +
+ {groupSpells.map((spell) => ( + + ))} +
+
+ ))} + + ) : null} + + {otherSpells.length > 0 ? ( + <> + +

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

+ {otherSpellGroups.map(({ pubkey: authorPk, spells: groupSpells }) => ( +
+ +
+ {groupSpells.map((spell) => ( + + ))} +
+
+ ))} + + ) : null} + + ) + + const spellPickerTriggerButton = ( + + ) + return ( (function SpellsPage( {/* Spell picker + actions above the feed */}
<> - - - - - - {t('Select a spell…')} - - -
+ - ) - })} - - - {ownSpells.length > 0 ? ( - <> - -

- {t('spellPickerSectionYours')} -

- {ownSpells.map((spell) => ( - - ))} - - ) : null} - - {followSpells.length > 0 ? ( - <> - -

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

- {followSpellGroups.map(({ pubkey: authorPk, spells: groupSpells }) => ( -
- -
- {groupSpells.map((spell) => ( - - ))} -
-
- ))} - - ) : null} - - {otherSpells.length > 0 ? ( - <> - -

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

- {otherSpellGroups.map(({ pubkey: authorPk, spells: groupSpells }) => ( -
- -
- {groupSpells.map((spell) => ( - - ))} -
-
- ))} - - ) : null} -
-
-
+ + {selectedFauxSpell + ? t(fauxSpellLabelKey(selectedFauxSpell)) + : selectedSpell + ? spellMenuLabel(selectedSpell) + : t('Select a spell…')} + + + + + + + {t('Select a spell…')} + +
+ {spellPickerList} +
+
+
+ + ) : ( + + + {spellPickerTriggerButton} + + +
+ {t('Select a spell…')} +
+
+ {spellPickerList} +
+
+
+ )}
diff --git a/src/services/navigation-event-store.ts b/src/services/navigation-event-store.ts index 28e025e8..106c7690 100644 --- a/src/services/navigation-event-store.ts +++ b/src/services/navigation-event-store.ts @@ -2,37 +2,75 @@ * Navigation Event Store * Temporarily stores events when navigating to avoid re-fetching */ -import { Event } from 'nostr-tools' +import { getNoteBech32Id } from '@/lib/event' +import { Event, nip19 } from 'nostr-tools' + +/** URL paths use bech32 (nevent1…, naddr1…); lookups must match the `id` passed to `useFetchEvent`. */ +function candidateKeysForNoteUrlId(eventId: string): string[] { + const keys = [eventId] + if (/^[a-f0-9]{64}$/i.test(eventId)) return keys + try { + const decoded = nip19.decode(eventId) + if (decoded.type === 'nevent') { + keys.push(decoded.data.id) + } else if (decoded.type === 'note') { + keys.push(decoded.data) + } + } catch { + /* not bech32 */ + } + return keys +} class NavigationEventStore { private eventMap = new Map() + private removeEventFromAllKeys(event: Event): void { + this.eventMap.delete(event.id) + try { + const urlId = getNoteBech32Id(event) + if (urlId !== event.id) { + this.eventMap.delete(urlId) + } + } catch { + /* ignore */ + } + } + /** - * Store an event for navigation (keyed by event ID) + * Store an event for navigation (hex id + same bech32 form as {@link toNote} / the URL). */ setEvent(event: Event): void { this.eventMap.set(event.id, event) - // Also store by bech32 ID if available (for naddr/nevent) - // This will be handled by the navigation system + try { + const urlId = getNoteBech32Id(event) + if (urlId !== event.id) { + this.eventMap.set(urlId, event) + } + } catch { + /* ignore */ + } } /** * Get an event by ID (removes it after retrieval to prevent memory leaks) */ getEvent(eventId: string): Event | undefined { - const event = this.eventMap.get(eventId) - if (event) { - // Remove after retrieval to prevent memory leaks - this.eventMap.delete(eventId) + for (const key of candidateKeysForNoteUrlId(eventId)) { + const event = this.eventMap.get(key) + if (event) { + this.removeEventFromAllKeys(event) + return event + } } - return event + return undefined } /** * Check if an event exists without removing it */ hasEvent(eventId: string): boolean { - return this.eventMap.has(eventId) + return candidateKeysForNoteUrlId(eventId).some((k) => this.eventMap.has(k)) } /**