From e5ed682ee5959d7f843bc57a3008010af3107d46 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sun, 22 Mar 2026 09:43:14 +0100 Subject: [PATCH] speed up timelines --- .../Explore/ExploreRelayReviews.tsx | 31 +- .../PublicationIndex/PublicationIndex.tsx | 2 +- src/components/NoteList/index.tsx | 166 ++++++-- src/components/NoteStats/LikeButton.tsx | 4 +- src/components/NoteStats/Likes.tsx | 4 +- src/components/NoteStats/RepostButton.tsx | 4 +- src/components/NoteStats/VoteButtons.tsx | 8 +- src/components/PostEditor/PostContent.tsx | 7 + src/components/QuoteList/index.tsx | 3 - src/components/Username/index.tsx | 17 +- src/constants.ts | 7 +- src/hooks/useFetchProfile.tsx | 7 +- src/hooks/useProfileTimeline.tsx | 293 ++++++-------- src/lib/event-metadata.ts | 15 +- src/lib/favorites-feed-relays.ts | 81 ++++ src/lib/relay-list-builder.ts | 34 +- src/lib/spell-feed-request-identity.ts | 27 ++ src/lib/tag.ts | 12 + .../primary/NoteListPage/FollowingFeed.tsx | 19 +- .../primary/SpellsPage/CreateSpellDialog.tsx | 10 +- .../primary/SpellsPage/fauxSpellFeeds.ts | 176 +------- src/pages/primary/SpellsPage/index.tsx | 139 ++++--- src/pages/secondary/NoteListPage/index.tsx | 59 ++- src/providers/FeedProvider.tsx | 24 +- .../client-replaceable-events.service.ts | 252 +++++------- src/services/client.service.ts | 383 +++++------------- src/services/note-stats.service.ts | 102 +++-- src/services/spell.service.ts | 23 +- 28 files changed, 936 insertions(+), 973 deletions(-) create mode 100644 src/lib/favorites-feed-relays.ts diff --git a/src/components/Explore/ExploreRelayReviews.tsx b/src/components/Explore/ExploreRelayReviews.tsx index 6f99cf92..cd0ce8a0 100644 --- a/src/components/Explore/ExploreRelayReviews.tsx +++ b/src/components/Explore/ExploreRelayReviews.tsx @@ -1,27 +1,28 @@ import NoteList from '@/components/NoteList' -import { ExtendedKind, PROFILE_FETCH_RELAY_URLS } from '@/constants' +import { ExtendedKind } from '@/constants' +import { getRelayUrlsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays' import { getRelayUrlFromRelayReviewEvent, getStarsFromRelayReviewEvent } from '@/lib/event-metadata' -import { buildExploreProfileAndUserRelayList } from '@/lib/relay-list-builder' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' import { Event } from 'nostr-tools' -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useMemo } from 'react' export default function ExploreRelayReviews() { - const { pubkey } = useNostr() - const [relayUrls, setRelayUrls] = useState(() => [...PROFILE_FETCH_RELAY_URLS]) + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const { relayList } = useNostr() - useEffect(() => { - let cancelled = false - buildExploreProfileAndUserRelayList(pubkey ?? null).then((urls) => { - if (!cancelled) setRelayUrls(urls) - }) - return () => { - cancelled = true - } - }, [pubkey]) + const relayUrls = useMemo( + () => + getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays, + blockedRelays, + relayList?.read ?? [] + ), + [favoriteRelays, blockedRelays, relayList] + ) const subRequests = useMemo(() => [{ urls: relayUrls, filter: {} }], [relayUrls]) @@ -34,6 +35,8 @@ export default function ExploreRelayReviews() { return (
{} // Not needed for one-time fetch }, - { needSort: false, useCache: false } // NO CACHING - stream raw from relays + { needSort: false } ) // Wait for up to 10 seconds for events to arrive or eosed diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 22003155..52a74c4a 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -9,7 +9,10 @@ import { isReplyNoteEvent } from '@/lib/event' import { shouldFilterEvent } from '@/lib/event-filtering' -import { stableSpellFeedFilterKey } from '@/lib/spell-feed-request-identity' +import { + isRelayUrlStrictSupersetIdentityKey, + stableSpellFeedFilterKey +} from '@/lib/spell-feed-request-identity' import { syncUserDeletionTombstones } from '@/lib/sync-user-deletions' import { normalizeUrl } from '@/lib/url' import { getZapInfoFromEvent } from '@/lib/event-metadata' @@ -48,6 +51,19 @@ const SHOW_COUNT = 50 // Increased from 10 to show more events at once, reducing const FEED_PROFILE_BATCH_DEBOUNCE_MS = 120 const FEED_PROFILE_CHUNK = 36 +function mergeEventBatchesById(prev: Event[], incoming: Event[], cap: number): Event[] { + const byId = new Map() + for (const e of prev) { + byId.set(e.id, e) + } + for (const e of incoming) { + byId.set(e.id, e) + } + return Array.from(byId.values()) + .sort((a, b) => b.created_at - a.created_at) + .slice(0, cap) +} + const NoteList = forwardRef( ( { @@ -66,14 +82,12 @@ const NoteList = forwardRef( /** When set (e.g. Spells page), timeline subscription keys off this string instead of `subRequests` reference churn. */ feedSubscriptionKey, /** - * When true, hydrate the list from the client timeline cache (IndexedDB-backed) before/at same time as - * live REQ, so feeds feel instant on repeat visits. Spells faux feeds use this; home feed stays false. + * When true (e.g. Explore relay reviews), `subRequests` may grow after first paint (bootstrap relays → full list). + * Re-subscribe when URLs change but **merge** new timeline batches into existing rows by event id instead of clearing. */ - useTimelineCacheBootstrap = false, + preserveTimelineOnSubRequestsChange = false, /** - * When set (Spells page), passed to `subscribeTimeline` as `firstRelayResultGraceMs` only — ms to wait after - * the first live event before treating initial load as EOSE. Subscribe setup and loading fallback keep - * longer defaults so multi-relay spell feeds do not race-fail and stay blank after refresh. + * Spells page: after this many ms, clear the loading skeleton so the list area renders; subscription keeps running. */ spellFetchTimeoutMs, /** Spells page: bumps when user picks a feed; used with {@link onSpellFeedFirstPaint}. */ @@ -96,7 +110,8 @@ const NoteList = forwardRef( /** When provided and returns true, the event is omitted from the feed (in addition to built-in rules). */ extraShouldHideEvent?: (evt: Event) => boolean feedSubscriptionKey?: string - useTimelineCacheBootstrap?: boolean + preserveTimelineOnSubRequestsChange?: boolean + /** When set (spells), max time to show the initial loading skeleton (ms). */ spellFetchTimeoutMs?: number spellFeedInstrumentToken?: number onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void @@ -156,6 +171,9 @@ const NoteList = forwardRef( }, [subRequests]) const timelineSubscriptionKey = feedSubscriptionKey ?? subRequestsKey + const prevSubRequestsKeyForTimelineRef = useRef(null) + /** Detect pull-to-refresh so preserve-mode feeds still clear; unrelated dep changes must not clear. */ + const timelineEffectLastRefreshCountRef = useRef(refreshCount) useEffect(() => { feedProfileBatchGenRef.current += 1 @@ -163,6 +181,35 @@ const NoteList = forwardRef( setFeedProfileBatch({ profiles: new Map(), pending: new Set(), version: 0 }) }, [timelineSubscriptionKey, refreshCount]) + /** Pending pubkeys sync with rows so useFetchProfile skips per-note fetches before the debounced batch. */ + useLayoutEffect(() => { + const candidates = new Set() + const addPk = (p: string | undefined) => { + if (p && p.length === 64 && /^[0-9a-f]{64}$/.test(p)) { + candidates.add(p) + } + } + for (const e of events) { + addPk(e.pubkey) + } + for (const e of newEvents) { + addPk(e.pubkey) + } + + setFeedProfileBatch((prev) => { + const pending = new Set(prev.pending) + let changed = false + for (const pk of candidates) { + if (!prev.profiles.has(pk) && !pending.has(pk)) { + pending.add(pk) + changed = true + } + } + if (!changed) return prev + return { ...prev, pending, version: prev.version + 1 } + }) + }, [events, newEvents]) + const subRequestsRef = useRef(subRequests) subRequestsRef.current = subRequests @@ -309,9 +356,12 @@ const NoteList = forwardRef( candidates.add(p) } } - filteredEvents.slice(0, 50).forEach((e) => addPk(e.pubkey)) - events.slice(0, 120).forEach((e) => addPk(e.pubkey)) - events.slice(showCount, showCount + 60).forEach((e) => addPk(e.pubkey)) + for (const e of events) { + addPk(e.pubkey) + } + for (const e of newEvents) { + addPk(e.pubkey) + } const need = [...candidates].filter((pk) => !feedProfileLoadedRef.current.has(pk)) if (need.length === 0) return @@ -320,7 +370,14 @@ const NoteList = forwardRef( setFeedProfileBatch((prev) => { const pending = new Set(prev.pending) - need.forEach((pk) => pending.add(pk)) + let pendingChanged = false + for (const pk of need) { + if (!pending.has(pk)) { + pending.add(pk) + pendingChanged = true + } + } + if (!pendingChanged) return prev return { ...prev, pending, version: prev.version + 1 } }) @@ -363,7 +420,7 @@ const NoteList = forwardRef( })() }, FEED_PROFILE_BATCH_DEBOUNCE_MS) return () => window.clearTimeout(handle) - }, [filteredEvents, events, showCount]) + }, [events, newEvents]) const scrollToTop = useCallback((behavior: ScrollBehavior = 'instant') => { setTimeout(() => { @@ -392,13 +449,34 @@ const NoteList = forwardRef( return () => {} } + const prevSubKey = prevSubRequestsKeyForTimelineRef.current + const userPulledRefresh = refreshCount !== timelineEffectLastRefreshCountRef.current + if (userPulledRefresh) { + timelineEffectLastRefreshCountRef.current = refreshCount + } + const keepExistingTimelineEvents = + preserveTimelineOnSubRequestsChange && + !userPulledRefresh && + (prevSubKey === subRequestsKey || + isRelayUrlStrictSupersetIdentityKey(prevSubKey, subRequestsKey)) + prevSubRequestsKeyForTimelineRef.current = subRequestsKey + /** False after cleanup so stale timeline callbacks cannot overwrite state after switching feeds (e.g. Spells discussions → notifications). */ let effectActive = true async function init() { - setLoading(true) - setEvents([]) - setNewEvents([]) + // Re-subscribe with rows visible (e.g. relay URL expansion): don't flash global loading / skeleton. + const keepRowsVisible = + preserveTimelineOnSubRequestsChange && + keepExistingTimelineEvents && + eventsRef.current.length > 0 + if (!keepRowsVisible) { + setLoading(true) + } + if (!keepExistingTimelineEvents) { + setEvents([]) + setNewEvents([]) + } setHasMore(true) consecutiveEmptyRef.current = 0 // Reset counter on refresh @@ -437,6 +515,10 @@ const NoteList = forwardRef( return () => {} } + const totalRelayUrls = mappedSubRequests.reduce((n, r) => n + r.urls.length, 0) + // Explore-style feeds merge many read relays; subscribeTimeline awaits every ensureRelay — 5s often loses the race. + const subscribeSetupRaceMs = totalRelayUrls > 24 ? 30_000 : 5000 + let closer: (() => void) | undefined let timelineKey: string | undefined let timelineSubscribePromise: @@ -444,30 +526,37 @@ const NoteList = forwardRef( | undefined try { - // Opening subs + IndexedDB timeline hydration can exceed 2s on spell feeds with many relays; a short race + // Opening many relay subs can exceed 2s on spell feeds; a short race // rejects, the catch closes the late subscription, and the list stays empty after refresh. - const subscribeSetupRaceMs = 5000 const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { reject(new Error(`subscribeTimeline timeout after ${subscribeSetupRaceMs}ms`)) }, subscribeSetupRaceMs) }) - const firstRelayGraceMs = spellFetchTimeoutMs ?? FIRST_RELAY_RESULT_GRACE_MS + const eventCap = areAlgoRelays ? ALGO_LIMIT : LIMIT timelineSubscribePromise = client.subscribeTimeline( mappedSubRequests, { - onEvents: (events: Event[], eosed: boolean) => { + onEvents: (batch: Event[], eosed: boolean) => { if (!effectActive) return - if (events.length > 0) { - setEvents(events) + if (batch.length > 0) { + if (preserveTimelineOnSubRequestsChange) { + setEvents((prev) => { + const next = mergeEventBatchesById(prev, batch, eventCap) + lastEventsForTimelinePrefetchRef.current = next + return next + }) + } else { + setEvents(batch) + lastEventsForTimelinePrefetchRef.current = batch + } // Do not wait for full EOSE across many relays — otherwise loading/skeleton stays up for 10–30s+ setLoading(false) // Defer profile + embed prefetch: streaming timelines fire onEvents often; starting // fetchProfilesForPubkeys on every update spams relays (multi-second each) and cancels hooks. - lastEventsForTimelinePrefetchRef.current = events if (timelinePrefetchDebounceRef.current) { clearTimeout(timelinePrefetchDebounceRef.current) } @@ -492,11 +581,12 @@ const NoteList = forwardRef( } }, 450) } else if (eosed) { - // No events received but EOSE - set empty events array and stop loading - setEvents([]) + if (!preserveTimelineOnSubRequestsChange) { + setEvents([]) + } setLoading(false) } - + if (areAlgoRelays) { // Algorithm feeds typically return all results at once setHasMore(false) @@ -507,7 +597,7 @@ const NoteList = forwardRef( // We should still try to load more on scroll - the loadMore logic will handle stopping // Only set to false if we explicitly know there are no more events (handled in loadMore) // If we got a full limit of events, there's likely more available - if (events.length >= (areAlgoRelays ? ALGO_LIMIT : LIMIT)) { + if (batch.length >= (areAlgoRelays ? ALGO_LIMIT : LIMIT)) { setHasMore(true) } else { // Even with fewer events, there might be more (filtering, slow relays, etc.) @@ -542,9 +632,7 @@ const NoteList = forwardRef( { startLogin, needSort: !areAlgoRelays, - useCache: useTimelineCacheBootstrap, - omitDefaultSinceWhenUseCache: useTimelineCacheBootstrap, - firstRelayResultGraceMs: firstRelayGraceMs + firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS } ) @@ -582,6 +670,8 @@ const NoteList = forwardRef( } }, [ timelineSubscriptionKey, + subRequestsKey, + preserveTimelineOnSubRequestsChange, refreshCount, showKindsKey, showKind1OPs, @@ -589,7 +679,6 @@ const NoteList = forwardRef( showKind1111, useFilterAsIs, areAlgoRelays, - useTimelineCacheBootstrap, spellFetchTimeoutMs ]) @@ -615,6 +704,21 @@ const NoteList = forwardRef( } }, [timelineSubscriptionKey, refreshCount]) + /** Spells: drop loading skeleton quickly so rows (or empty + reload) appear while REQ continues. */ + useEffect(() => { + if (spellFetchTimeoutMs == null || spellFetchTimeoutMs <= 0) return + if (!subRequestsRef.current.length) return + let cancelled = false + const id = window.setTimeout(() => { + if (cancelled) return + setLoading(false) + }, spellFetchTimeoutMs) + return () => { + cancelled = true + clearTimeout(id) + } + }, [timelineSubscriptionKey, refreshCount, spellFetchTimeoutMs]) + // Use refs to avoid dependency issues and ensure latest values in async callbacks const showCountRef = useRef(showCount) const loadingRef = useRef(loading) diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx index 400fd57c..536537d6 100644 --- a/src/components/NoteStats/LikeButton.tsx +++ b/src/components/NoteStats/LikeButton.tsx @@ -159,7 +159,9 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; showSimplePublishSuccess(t('Reaction published')) } - noteStatsService.updateNoteStatsByEvents([evt]) + noteStatsService.updateNoteStatsByEvents([evt], undefined, { + interactionTargetNoteId: event.id + }) } } catch (error) { logger.error('Like failed', { error, eventId: event.id }) diff --git a/src/components/NoteStats/Likes.tsx b/src/components/NoteStats/Likes.tsx index 6c01a60b..6109000a 100644 --- a/src/components/NoteStats/Likes.tsx +++ b/src/components/NoteStats/Likes.tsx @@ -71,7 +71,9 @@ export default function Likes({ event }: { event: Event }) { try { const reaction = createReactionDraftEvent(event, emoji) const evt = await publish(reaction) - noteStatsService.updateNoteStatsByEvents([evt]) + noteStatsService.updateNoteStatsByEvents([evt], undefined, { + interactionTargetNoteId: event.id + }) } catch (error) { logger.error('Like failed', { error, eventId: event.id }) } finally { diff --git a/src/components/NoteStats/RepostButton.tsx b/src/components/NoteStats/RepostButton.tsx index 620ffdb9..385fa262 100644 --- a/src/components/NoteStats/RepostButton.tsx +++ b/src/components/NoteStats/RepostButton.tsx @@ -76,7 +76,9 @@ export default function RepostButton({ event, hideCount = false }: { event: Even showSimplePublishSuccess(t('Boost published')) } - noteStatsService.updateNoteStatsByEvents([evt]) + noteStatsService.updateNoteStatsByEvents([evt], undefined, { + interactionTargetNoteId: event.id + }) } catch (error) { logger.error('Boost failed', { error, eventId: event.id }) } finally { diff --git a/src/components/NoteStats/VoteButtons.tsx b/src/components/NoteStats/VoteButtons.tsx index b401db66..4272a4a8 100644 --- a/src/components/NoteStats/VoteButtons.tsx +++ b/src/components/NoteStats/VoteButtons.tsx @@ -81,7 +81,9 @@ export default function VoteButtons({ event }: { event: Event }) { showSimplePublishSuccess(t('Vote removed')) } - noteStatsService.updateNoteStatsByEvents([evt]) + noteStatsService.updateNoteStatsByEvents([evt], undefined, { + interactionTargetNoteId: event.id + }) } else { // If user voted the opposite way, first remove the old vote if (userVote) { @@ -109,7 +111,9 @@ export default function VoteButtons({ event }: { event: Event }) { showSimplePublishSuccess(t('Vote published')) } - noteStatsService.updateNoteStatsByEvents([evt]) + noteStatsService.updateNoteStatsByEvents([evt], undefined, { + interactionTargetNoteId: event.id + }) } } catch (error) { logger.error('Vote failed', { error, eventId: event.id }) diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index d982bc45..d1b8de05 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -66,6 +66,7 @@ import mediaUpload from '@/services/media-upload.service' import { successfulPublishRelayUrls, type TRelayPublishStatus } from '@/lib/publish-relay-urls' import client, { eventService } from '@/services/client.service' import discussionFeedCache from '@/services/discussion-feed-cache.service' +import noteStatsService from '@/services/note-stats.service' import CreateThreadDialog from '@/pages/primary/DiscussionsPage/CreateThreadDialog' import { getReplaceableCoordinateFromEvent, isProtectedEvent as isEventProtected, isReplaceableEvent, isReplyNoteEvent } from '@/lib/event' import { Event, kinds } from 'nostr-tools' @@ -112,6 +113,12 @@ export default function PostContent({ const clean = { ...reply } as Event delete (clean as any).relayStatuses addReplies([clean]) + const isQuotePost = clean.tags.some((t) => t[0] === 'q' && t[1]) + noteStatsService.updateNoteStatsByEvents( + [clean], + undefined, + isQuotePost ? undefined : { replyParentNoteId: parentEvent.id } + ) const rootInfo = !isReplaceableEvent(parentEvent.kind) ? { type: 'E' as const, id: parentEvent.id, pubkey: parentEvent.pubkey } : { diff --git a/src/components/QuoteList/index.tsx b/src/components/QuoteList/index.tsx index afe86384..2b9f59dd 100644 --- a/src/components/QuoteList/index.tsx +++ b/src/components/QuoteList/index.tsx @@ -136,9 +136,6 @@ export default function QuoteList({ [newEvt, ...oldEvents].sort((a, b) => b.created_at - a.created_at) ) } - }, - { - useCache: false // NO CACHING - stream raw from relays } ) if (cancelled) { diff --git a/src/components/Username/index.tsx b/src/components/Username/index.tsx index 7534677e..dd691249 100644 --- a/src/components/Username/index.tsx +++ b/src/components/Username/index.tsx @@ -23,15 +23,16 @@ export default function Username({ }) { const { profile, isFetching } = useFetchProfile(userId) const { navigateToProfile } = useSmartProfileNavigation() - + // Get pubkey from userId (works even if profile isn't loaded) const pubkey = useMemo(() => { if (profile?.pubkey) return profile.pubkey return userIdToPubkey(userId) || '' }, [userId, profile?.pubkey]) - - // Show skeleton while fetching (unless withoutSkeleton is true) - if (isFetching && !withoutSkeleton) { + + // Never block on profile fetch when we can already show npub/hex fallback (feeds batch-fetch profiles). + const canShowWithoutProfile = Boolean(pubkey) + if (isFetching && !withoutSkeleton && !canShowWithoutProfile) { return (
@@ -108,15 +109,15 @@ export function SimpleUsername({ style?: React.CSSProperties }) { const { profile, isFetching } = useFetchProfile(userId) - + // Get pubkey from userId (works even if profile isn't loaded) const pubkey = useMemo(() => { if (profile?.pubkey) return profile.pubkey return userIdToPubkey(userId) || '' }, [userId, profile?.pubkey]) - - // Show skeleton while fetching (unless withoutSkeleton is true) - if (isFetching && !withoutSkeleton) { + + const canShowWithoutProfile = Boolean(pubkey) + if (isFetching && !withoutSkeleton && !canShowWithoutProfile) { return (
diff --git a/src/constants.ts b/src/constants.ts index 40a27b7f..00db130c 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -17,8 +17,11 @@ export const DEFAULT_FAVORITE_RELAYS = [ /** Multi-relay queries and timeline initial REQ: after the first event, wait this long then close (query) or finalize EOSE (live feed) while keeping the subscription open for new events. */ export const FIRST_RELAY_RESULT_GRACE_MS = 2000 -/** Spells page feeds: shorter grace so multi-relay spell REQs finalize initial load sooner (still keeps subscription open for `onNew`). */ -export const SPELL_FEED_FIRST_RELAY_GRACE_MS = 450 +/** Spells page NoteList: drop the loading skeleton after this long so the feed can render; REQ stays open and rows stream in. */ +export const SPELL_FEED_LOADING_MAX_MS = 1000 + +/** @deprecated Use {@link SPELL_FEED_LOADING_MAX_MS}; kept so old imports do not break. */ +export const SPELL_FEED_FIRST_RELAY_GRACE_MS = SPELL_FEED_LOADING_MAX_MS /** * Implicit query feed grace ({@link FIRST_RELAY_RESULT_GRACE_MS}) applies only when the largest `limit` among diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index fb17414b..74eec974 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -26,7 +26,12 @@ export function useFetchProfile(id?: string, skipCache = false) { const { profile: currentAccountProfile } = useNostr() const noteFeed = useNoteFeedProfileContext() - const [isFetching, setIsFetching] = useState(true) + /** Hex/npub ids can show npub fallback immediately; avoid a skeleton frame before the first effect. */ + const [isFetching, setIsFetching] = useState(() => { + if (!id) return false + const pk = userIdToPubkey(id) + return !(pk.length === 64 && /^[0-9a-f]{64}$/.test(pk)) + }) const [error, setError] = useState(null) const [profile, setProfile] = useState(null) const [pubkey, setPubkey] = useState(null) diff --git a/src/hooks/useProfileTimeline.tsx b/src/hooks/useProfileTimeline.tsx index 6c67745f..4b57a5e2 100644 --- a/src/hooks/useProfileTimeline.tsx +++ b/src/hooks/useProfileTimeline.tsx @@ -1,18 +1,20 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider' import client from '@/services/client.service' -import { useEffect, useMemo, useRef, useState, useCallback } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { Event } from 'nostr-tools' -import { CALENDAR_EVENT_KINDS, ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants' -import { normalizeUrl } from '@/lib/url' +import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants' +import { getRelayUrlsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' +import { useNostr } from '@/providers/NostrProvider' -type ProfileTimelineCacheEntry = { +type ProfileTimelineMemoryEntry = { events: Event[] lastUpdated: number } -const timelineCache = new Map() -const CACHE_DURATION = 5 * 60 * 1000 // 5 minutes - cache is considered fresh for this long -const relayGroupCache = new Map() +/** 5-minute in-memory cache for this hook only — not IndexedDB, not client timeline refs. */ +const memoryTimelineByKey = new Map() +const CACHE_DURATION = 5 * 60 * 1000 type UseProfileTimelineOptions = { pubkey: string @@ -28,55 +30,36 @@ type UseProfileTimelineResult = { refresh: () => void } -async function getRelayGroups(pubkey: string): Promise { - const cached = relayGroupCache.get(pubkey) - if (cached) { - return cached - } - - const [relayList, favoriteRelays] = await Promise.all([ - client.fetchRelayList(pubkey).catch(() => ({ read: [], write: [] })), - client.fetchFavoriteRelays(pubkey).catch(() => []) - ]) - - const groups: string[][] = [] - - const normalizeList = (urls?: string[]) => - Array.from( - new Set( - (urls || []) - .map((url) => normalizeUrl(url)) - .filter((value): value is string => !!value) - ) - ) - - const readRelays = normalizeList(relayList.read) - if (readRelays.length) { - groups.push(readRelays) - } - - const writeRelays = normalizeList(relayList.write) - if (writeRelays.length) { - groups.push(writeRelays) - } - - const favoriteRelayList = normalizeList(favoriteRelays) - if (favoriteRelayList.length) { - groups.push(favoriteRelayList) - } - - const fastReadRelays = normalizeList(FAST_READ_RELAY_URLS) - if (fastReadRelays.length) { - groups.push(fastReadRelays) - } - - if (!groups.length) { - relayGroupCache.set(pubkey, [fastReadRelays]) - return [fastReadRelays] - } - - relayGroupCache.set(pubkey, groups) - return groups +function buildSubRequests( + groups: string[][], + pubkey: string, + kindsArg: number[], + limit: number, + hasCalendarKinds: boolean +) { + const authorRequests = groups + .map((urls) => ({ + urls, + filter: { + authors: [pubkey], + kinds: kindsArg, + limit + } as any + })) + .filter((request) => request.urls.length) + const calendarInviteRequests = hasCalendarKinds + ? groups + .map((urls) => ({ + urls, + filter: { + kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], + '#p': [pubkey], + limit: 100 + } as any + })) + .filter((request) => request.urls.length) + : [] + return [...authorRequests, ...calendarInviteRequests] } function postProcessEvents( @@ -107,11 +90,18 @@ export function useProfileTimeline({ limit = 200, filterPredicate }: UseProfileTimelineOptions): UseProfileTimelineResult { + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const { relayList } = useNostr() const { isEventDeleted, tombstoneEpoch } = useDeletedEvent() const isEventDeletedRef = useRef(isEventDeleted) isEventDeletedRef.current = isEventDeleted - const cachedEntry = useMemo(() => timelineCache.get(cacheKey), [cacheKey]) + const filterPredicateRef = useRef(filterPredicate) + filterPredicateRef.current = filterPredicate + const limitRef = useRef(limit) + limitRef.current = limit + + const cachedEntry = useMemo(() => memoryTimelineByKey.get(cacheKey), [cacheKey]) const [events, setEvents] = useState(cachedEntry?.events ?? []) const [isLoading, setIsLoading] = useState(!cachedEntry) const [refreshToken, setRefreshToken] = useState(0) @@ -121,9 +111,9 @@ export function useProfileTimeline({ setEvents((prev) => { const next = prev.filter((e) => !isEventDeletedRef.current(e)) if (next.length === prev.length) return prev - const cached = timelineCache.get(cacheKey) + const cached = memoryTimelineByKey.get(cacheKey) if (cached) { - timelineCache.set(cacheKey, { events: next, lastUpdated: cached.lastUpdated }) + memoryTimelineByKey.set(cacheKey, { events: next, lastUpdated: cached.lastUpdated }) } return next }) @@ -131,129 +121,117 @@ export function useProfileTimeline({ useEffect(() => { let cancelled = false + const closers: (() => void)[] = [] + const pool = new Map() + + const flushPool = () => { + if (cancelled) return + const processed = postProcessEvents( + Array.from(pool.values()), + filterPredicateRef.current, + limitRef.current, + isEventDeletedRef.current + ) + memoryTimelineByKey.set(cacheKey, { events: processed, lastUpdated: Date.now() }) + setEvents(processed) + setIsLoading(false) + } + + subscriptionRef.current = () => { + closers.forEach((c) => c()) + closers.length = 0 + } + + const registerCloser = (closer: () => void) => { + if (cancelled) { + closer() + return + } + closers.push(closer) + } const subscribe = async () => { - // Check if we have fresh cached data - const cachedEntry = timelineCache.get(cacheKey) - const cacheAge = cachedEntry ? Date.now() - cachedEntry.lastUpdated : Infinity + const mem = memoryTimelineByKey.get(cacheKey) + const cacheAge = mem ? Date.now() - mem.lastUpdated : Infinity const isCacheFresh = cacheAge < CACHE_DURATION - - // If cache is fresh, show it immediately and skip subscribing - if (isCacheFresh && cachedEntry) { - setEvents(cachedEntry.events) + + pool.clear() + if (isCacheFresh && mem) { + setEvents(mem.events) setIsLoading(false) - // Still subscribe in background to get updates, but don't show loading - // This ensures we get new events without disrupting the UI + mem.events.forEach((e) => pool.set(e.id, e)) } else { - // Cache is stale or missing - show loading and fetch - setIsLoading(!cachedEntry) + setIsLoading(!mem) } - - try { - const relayGroups = await getRelayGroups(pubkey) - if (cancelled) { - return - } - const hasCalendarKinds = kinds.some((k) => CALENDAR_EVENT_KINDS.includes(k)) - const authorRequests = relayGroups - .map((urls) => ({ - urls, - filter: { - authors: [pubkey], - kinds, - limit - } as any - })) - .filter((request) => request.urls.length) - // When profile includes calendar event kinds, also subscribe to events where this user is an invitee (#p tag) - const calendarInviteRequests = hasCalendarKinds - ? relayGroups - .map((urls) => ({ - urls, - filter: { - kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], - '#p': [pubkey], - limit: 100 - } as any - })) - .filter((request) => request.urls.length) - : [] - const subRequests = [...authorRequests, ...calendarInviteRequests] - - if (!subRequests.length) { - timelineCache.set(cacheKey, { - events: [], - lastUpdated: Date.now() - }) - setEvents([]) - setIsLoading(false) - return - } + const hasCalendarKinds = kinds.some((k) => CALENDAR_EVENT_KINDS.includes(k)) + const feedRelayUrls = getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays, + blockedRelays, + relayList?.read ?? [] + ) - const { closer } = await client.subscribeTimeline( - subRequests, - { - onEvents: (fetchedEvents) => { - if (cancelled) return - const processed = postProcessEvents( - fetchedEvents as Event[], - filterPredicate, - limit, - isEventDeletedRef.current - ) - timelineCache.set(cacheKey, { - events: processed, - lastUpdated: Date.now() - }) - setEvents(processed) - setIsLoading(false) + const startWave = async (subRequests: ReturnType) => { + if (cancelled || subRequests.length === 0) return + try { + const { closer } = await client.subscribeTimeline( + subRequests, + { + onEvents: (fetched) => { + if (cancelled) return + for (const e of fetched as Event[]) { + pool.set(e.id, e) + } + flushPool() + }, + onNew: (evt) => { + if (cancelled) return + pool.set((evt as Event).id, evt as Event) + flushPool() + } }, - onNew: (evt) => { - if (cancelled) return - setEvents((prevEvents) => { - const combined = [evt as Event, ...prevEvents] - const processed = postProcessEvents( - combined, - filterPredicate, - limit, - isEventDeletedRef.current - ) - timelineCache.set(cacheKey, { - events: processed, - lastUpdated: Date.now() - }) - return processed - }) - } - }, - { needSort: true, useCache: false } // NO CACHING - stream raw from relays - ) - - subscriptionRef.current = () => closer() - } catch (error) { - if (!cancelled) { - setIsLoading(false) + { needSort: true } + ) + registerCloser(closer) + } catch { + if (!cancelled) setIsLoading(false) } } + + if (feedRelayUrls.length === 0) { + if (!cancelled) setIsLoading(false) + return + } + + void startWave(buildSubRequests([feedRelayUrls], pubkey, kinds, limit, hasCalendarKinds)) } - subscribe() + void subscribe() return () => { cancelled = true subscriptionRef.current() subscriptionRef.current = () => {} } - }, [pubkey, cacheKey, JSON.stringify(kinds), limit, filterPredicate, refreshToken]) + }, [ + pubkey, + cacheKey, + JSON.stringify(kinds), + limit, + filterPredicate, + refreshToken, + favoriteRelays, + blockedRelays, + relayList + ]) const refresh = useCallback(() => { subscriptionRef.current() subscriptionRef.current = () => {} - timelineCache.delete(cacheKey) + memoryTimelineByKey.delete(cacheKey) setIsLoading(true) setRefreshToken((token) => token + 1) - }, []) + }, [cacheKey]) return { events, @@ -261,4 +239,3 @@ export function useProfileTimeline({ refresh } } - diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index c5a2eec8..383d1bee 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -569,12 +569,17 @@ export function getEmojisFromEvent(event: Event): TEmoji[] { export function getStarsFromRelayReviewEvent(event: Event): number { const ratingTag = event.tags.find((t) => t[0] === 'rating') - if (ratingTag) { - const stars = parseFloat(ratingTag[1]) * 5 - if (stars > 0 && stars <= 5) { - return stars - } + if (!ratingTag?.[1]?.trim()) return 0 + const raw = parseFloat(ratingTag[1]) + if (Number.isNaN(raw) || raw <= 0) return 0 + // This app publishes `rating` as stars/5 (e.g. 5★ → "1"); scale back to 1–5. + if (raw <= 1) { + const scaled = raw * 5 + if (scaled > 0 && scaled <= 5) return scaled + return 0 } + // Many clients use a plain 1–5 value in the tag. + if (raw >= 1 && raw <= 5) return raw return 0 } diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts new file mode 100644 index 00000000..ed46ee3b --- /dev/null +++ b/src/lib/favorites-feed-relays.ts @@ -0,0 +1,81 @@ +import { DEFAULT_FAVORITE_RELAYS, FAST_READ_RELAY_URLS } from '@/constants' +import type { TFeedSubRequest } from '@/types' +import { normalizeUrl } from '@/lib/url' + +const blockedSet = (blockedRelays: string[]) => + new Set(blockedRelays.map((b) => normalizeUrl(b) || b)) + +/** + * Relay URLs for the “all favorites” home feed only (`FeedProvider` `all-favorites` / that `RelaysFeed` mode). + * Non-blocked user favorites, or {@link DEFAULT_FAVORITE_RELAYS} when none remain. + */ +export function getFavoritesFeedRelayUrls( + favoriteRelays: string[], + blockedRelays: string[] +): string[] { + const blocked = blockedSet(blockedRelays) + const visible = favoriteRelays.filter((r) => { + const k = normalizeUrl(r) || r + return k && !blocked.has(k) + }) + const base = visible.length > 0 ? visible : DEFAULT_FAVORITE_RELAYS + const seen = new Set() + const out: string[] = [] + for (const u of base) { + const k = normalizeUrl(u) || u + if (!k || seen.has(k)) continue + seen.add(k) + out.push(k) + } + return out +} + +/** + * Merge relay URL lists in order; first occurrence wins; drops blocked. + */ +export function mergeRelayUrlLayers(layers: string[][], blockedRelays: string[]): string[] { + const blocked = blockedSet(blockedRelays) + const seen = new Set() + const out: string[] = [] + for (const layer of layers) { + for (const u of layer) { + const k = normalizeUrl(u) || u + if (!k || blocked.has(k) || seen.has(k)) continue + seen.add(k) + out.push(k) + } + } + return out +} + +/** + * Favorites (same set as the favorites feed) plus {@link FAST_READ_RELAY_URLS} and the user’s NIP-65 **read** / inbox relays. + * Fast-read URLs are merged first so REQ setup hits responsive indexers early (same deduped set). + */ +export function getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays: string[], + blockedRelays: string[], + userInboxReadRelays: string[] +): string[] { + const favorites = getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays) + const fast = FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[] + return mergeRelayUrlLayers([fast, favorites, userInboxReadRelays], blockedRelays) +} + +/** Prefix each subrequest’s `urls` with the extended read set (favorites + fast read + inboxes). */ +export function augmentSubRequestsWithFavoritesFastReadAndInbox( + requests: TFeedSubRequest[], + favoriteRelays: string[], + blockedRelays: string[], + userInboxReadRelays: string[] +): TFeedSubRequest[] { + const base = getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays, + blockedRelays, + userInboxReadRelays + ) + return requests.map((r) => ({ + ...r, + urls: mergeRelayUrlLayers([base, r.urls], blockedRelays) + })) +} diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index 86242d30..86ed330b 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -15,6 +15,26 @@ import { getCacheRelayUrls } from './private-relays' import client from '@/services/client.service' import logger from '@/lib/logger' +function dedupeNormalizedRelayUrls(urls: string[]): string[] { + const seen = new Set() + const out: string[] = [] + for (const u of urls) { + const n = normalizeUrl(u) || u + if (!n || seen.has(n)) continue + seen.add(n) + out.push(n) + } + return out +} + +/** + * Relays to bootstrap Explore replaceable fetches (e.g. kind 10012 batch) before NIP-65 resolves. + * PROFILE_FETCH + FAST_READ. + */ +export function exploreDiscoveryBootstrapRelayUrls(): string[] { + return dedupeNormalizedRelayUrls([...PROFILE_FETCH_RELAY_URLS, ...FAST_READ_RELAY_URLS]) +} + export interface RelayListBuilderOptions { /** Author's pubkey - will include their outboxes (write relays) */ authorPubkey?: string @@ -231,29 +251,31 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio } /** - * Explore: Following's Favorites (kind 10012 batch) and Relay reviews tab. - * PROFILE_FETCH_RELAY_URLS plus the viewer's read/write and cache (10432) relays — no FAST_READ. + * Explore: Following's Favorites (kind 10012 batch) / replaceable discovery. + * Bootstrap relays (profile + FAST_READ) plus the viewer's read/write and cache (10432) when logged in. */ export async function buildExploreProfileAndUserRelayList( userPubkey: string | null | undefined ): Promise { + const boot = exploreDiscoveryBootstrapRelayUrls() if (!userPubkey) { - return Array.from(new Set([...PROFILE_FETCH_RELAY_URLS])) + return boot } try { const built = await buildComprehensiveRelayList({ userPubkey, includeUserOwnRelays: true, includeProfileFetchRelays: true, - includeFastReadRelays: false, + includeFastReadRelays: true, includeFavoriteRelays: false, includeLocalRelays: true, includeFastWriteRelays: false, includeSearchableRelays: false }) - return built.length > 0 ? built : Array.from(new Set([...PROFILE_FETCH_RELAY_URLS])) + if (!built.length) return boot + return dedupeNormalizedRelayUrls([...boot, ...built]) } catch { - return Array.from(new Set([...PROFILE_FETCH_RELAY_URLS])) + return boot } } diff --git a/src/lib/spell-feed-request-identity.ts b/src/lib/spell-feed-request-identity.ts index 7203e5c2..2d746cac 100644 --- a/src/lib/spell-feed-request-identity.ts +++ b/src/lib/spell-feed-request-identity.ts @@ -24,3 +24,30 @@ export function computeSpellSubRequestsIdentityKey(subRequests: TFeedSubRequest[ })) ) } + +/** + * True when `nextKey` is the same REQ filters as `prevKey` but with a strict superset of relay URLs + * in at least one request slot (e.g. Explore relay reviews: bootstrap relays → full list). + */ +export function isRelayUrlStrictSupersetIdentityKey(prevKey: string | null, nextKey: string): boolean { + if (!prevKey || prevKey === nextKey) return false + try { + type Item = { urls: string[]; filter: string } + const prev = JSON.parse(prevKey) as Item[] + const next = JSON.parse(nextKey) as Item[] + if (!Array.isArray(prev) || !Array.isArray(next) || prev.length !== next.length) return false + let sawStrictGrowth = false + for (let i = 0; i < prev.length; i++) { + if (prev[i].filter !== next[i].filter) return false + const ps = new Set(prev[i].urls) + const ns = new Set(next[i].urls) + for (const u of ps) { + if (!ns.has(u)) return false + } + if (ns.size > ps.size) sawStrictGrowth = true + } + return sawStrictGrowth + } catch { + return false + } +} diff --git a/src/lib/tag.ts b/src/lib/tag.ts index d3786298..b3d0e500 100644 --- a/src/lib/tag.ts +++ b/src/lib/tag.ts @@ -17,6 +17,18 @@ export function tagNameEquals(tagName: string) { return (tag: string[]) => tag[0] === tagName } +const NOTE_HEX_ID_RE = /^[0-9a-f]{64}$/i + +/** First hex event id on an `e` / `E` tag (reactions, reposts, replies). */ +export function getFirstHexEventIdFromETags(tags: string[][]): string | undefined { + for (const t of tags) { + if (t[0] !== 'e' && t[0] !== 'E') continue + const id = t[1] + if (id && NOTE_HEX_ID_RE.test(id)) return id + } + return undefined +} + export function generateBech32IdFromETag(tag: string[]) { try { const [, id, relay, markerOrPubkey, pubkey] = tag diff --git a/src/pages/primary/NoteListPage/FollowingFeed.tsx b/src/pages/primary/NoteListPage/FollowingFeed.tsx index 4ee0168a..94e2feac 100644 --- a/src/pages/primary/NoteListPage/FollowingFeed.tsx +++ b/src/pages/primary/NoteListPage/FollowingFeed.tsx @@ -1,6 +1,8 @@ import NormalFeed from '@/components/NormalFeed' import type { TNoteListRef } from '@/components/NoteList' +import { augmentSubRequestsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays' import { useFeed } from '@/providers/FeedProvider' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' import client from '@/services/client.service' import { TFeedSubRequest } from '@/types' @@ -13,7 +15,8 @@ const FollowingFeed = forwardRef< setSubHeader?: (node: ReactNode) => void } >(function FollowingFeed({ setSubHeader }, ref) { - const { pubkey } = useNostr() + const { pubkey, relayList } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { feedInfo } = useFeed() const [subRequests, setSubRequests] = useState([]) @@ -25,11 +28,19 @@ const FollowingFeed = forwardRef< } const followings = await client.fetchFollowings(pubkey) - setSubRequests(await client.generateSubRequestsForPubkeys([pubkey, ...followings], pubkey)) + const raw = await client.generateSubRequestsForPubkeys([pubkey, ...followings], pubkey) + setSubRequests( + augmentSubRequestsWithFavoritesFastReadAndInbox( + raw, + favoriteRelays, + blockedRelays, + relayList?.read ?? [] + ) + ) } - init() - }, [feedInfo.feedType, pubkey]) + void init() + }, [feedInfo.feedType, pubkey, favoriteRelays, blockedRelays, relayList]) return }) diff --git a/src/pages/primary/SpellsPage/CreateSpellDialog.tsx b/src/pages/primary/SpellsPage/CreateSpellDialog.tsx index 57a2e901..8ce458be 100644 --- a/src/pages/primary/SpellsPage/CreateSpellDialog.tsx +++ b/src/pages/primary/SpellsPage/CreateSpellDialog.tsx @@ -18,6 +18,7 @@ import { dedupeAppendIds, resolveSpellListATags } from '@/lib/spell-list-import' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' import { showPublishingError, showSimplePublishSuccess } from '@/lib/publishing-feedback' import { eventService } from '@/services/client.service' @@ -289,6 +290,7 @@ export default function CreateSpellDialog({ }) { const { t } = useTranslation() const { pubkey, publish, checkLogin, relayList } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() const [form, setForm] = useState(DEFAULT_PARAMS) const [saving, setSaving] = useState(false) const scrollBodyRef = useRef(null) @@ -319,7 +321,11 @@ export default function CreateSpellDialog({ const { draft, notices, pendingATags } = applyListEventToSpellDraft(base, ev) setForm(draft) setListImportNotices(notices) - const urls = getRelaysForSpellCatalogSync(relayList ?? undefined) + const urls = getRelaysForSpellCatalogSync( + favoriteRelays, + blockedRelays, + relayList?.read ?? [] + ) if (pendingATags.length === 0) return void resolveSpellListATags(pendingATags, urls).then(({ ids, notices: extra }) => { if (ids.length) { @@ -328,7 +334,7 @@ export default function CreateSpellDialog({ if (extra.length) setListImportNotices((n) => [...n, ...extra]) }) }, - [relayList] + [favoriteRelays, blockedRelays, relayList] ) const handleLoadManualList = useCallback(async () => { diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index e9215d5f..81ff2672 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts @@ -1,14 +1,7 @@ /** * Built-in “faux spells” use the same NoteList path as kind-777 REQ spells. */ -import { - DEFAULT_FAVORITE_RELAYS, - ExtendedKind, - FAST_READ_RELAY_URLS, - FAST_WRITE_RELAY_URLS, - PROFILE_FEED_KINDS, - READ_ONLY_RELAY_URLS -} from '@/constants' +import { ExtendedKind, PROFILE_FEED_KINDS, READ_ONLY_RELAY_URLS } from '@/constants' import { extractHashtagsFromContent, extractTTagsFromEvent, @@ -16,36 +9,13 @@ import { } from '@/lib/discussion-topics' import { getImetaInfosFromEvent } from '@/lib/event' import { normalizeUrl } from '@/lib/url' -import type { TFeedSubRequest, TRelayList } from '@/types' +import type { TFeedSubRequest } from '@/types' import { type Event, type Filter, kinds } from 'nostr-tools' const NOTIFICATION_LIMIT = 500 const DISCUSSION_LIMIT = 500 const MAX_BOOKMARK_IDS = 250 -/** - * Spells “Discussions” uses NoteList → subscribeTimeline → one live REQ per relay. - * An uncapped merged relay list would open 80+ sockets and exhaust subscription slots; - * cap keeps first paint fast. - */ -const DISCUSSION_FAUX_SPELL_MAX_RELAYS = 10 -/** Without caps, a long NIP-66 read list consumes the whole 32 slots and fast public relays never get a REQ — discussions stay empty while notifications still work (they blend fast reads). */ -const DISCUSSION_SPELL_READ_CAP = 10 -const DISCUSSION_SPELL_WRITE_CAP = 8 -const DISCUSSION_SPELL_FAV_CAP = 8 - -function dedupe(urls: string[]): string[] { - const seen = new Set() - const out: string[] = [] - for (const u of urls) { - const k = normalizeUrl(u) || u - if (!k || seen.has(k)) continue - seen.add(k) - out.push(k) - } - return out -} - /** * Append {@link READ_ONLY_RELAY_URLS} (e.g. aggr) after the curated set so every faux REQ includes them unless blocked. */ @@ -167,94 +137,6 @@ export function mediaSpellExtraShouldHideEvent(evt: Event): boolean { return !isKind1MediaSpellEligible(evt) } -/** Relays for “global” faux feeds (media, calendar): visible favorites or defaults. */ -export function fauxFavoriteRelayUrls(favoriteRelays: string[], blockedRelays: string[]): string[] { - const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b)) - const visible = favoriteRelays.filter((r) => { - const k = normalizeUrl(r) || r - return k && !blocked.has(k) - }) - const base = visible.length > 0 ? visible : DEFAULT_FAVORITE_RELAYS - const curated = dedupe(base.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[]) - return appendCuratedReadOnlyRelays(curated, blockedRelays) -} - -/** - * Notifications / bookmarks faux spells: **fast public relays first**, then inbox/favorites. - * `FAST_READ_RELAY_URLS` has 7 entries; the old cap of 6 never subscribed to `wss://aggr.nostr.land` - * (last in the list) — a major `#p` indexer — so mentions could take tens of seconds or look empty. - * Fast-write relays catch mentions replicated to outboxes (damus/primal/nos.lol) with little overlap. - */ -const NOTIFICATION_PRIMARY_MAX = 4 -/** Must be ≥ FAST_READ length so every default fast read relay is eligible (currently 7). */ -const NOTIFICATION_FAST_READ_MAX = 10 -const NOTIFICATION_FAST_WRITE_MAX = 4 -const NOTIFICATION_RELAY_CAP = 14 - -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 -} - -function mergeRelayListsUnique( - lists: string[][], - blocked: Set, - cap: number -): string[] { - const seen = new Set() - const out: string[] = [] - for (const list of lists) { - for (const u of list) { - const k = normalizeUrl(u) || u - if (!k || blocked.has(k) || seen.has(k)) continue - seen.add(k) - out.push(k) - if (out.length >= cap) return out - } - } - return out -} - -export function notificationRelayUrls( - relayList: TRelayList | null | undefined, - favoriteRelays: string[], - blockedRelays: string[] = [] -): string[] { - const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b)) - const read = relayList?.read ?? [] - const readSorted = [...read].map((u) => normalizeUrl(u) || u).filter(Boolean).sort((a, b) => a.localeCompare(b)) - const favSorted = [...favoriteRelays] - .map((u) => normalizeUrl(u) || u) - .filter(Boolean) - .sort((a, b) => a.localeCompare(b)) - const primary = - read.length > 0 - ? relayUrlsUpToUnblocked(readSorted, blocked, NOTIFICATION_PRIMARY_MAX) - : favoriteRelays.length > 0 - ? relayUrlsUpToUnblocked(favSorted, blocked, NOTIFICATION_PRIMARY_MAX) - : [] - const fromFastRead = relayUrlsUpToUnblocked(FAST_READ_RELAY_URLS, blocked, NOTIFICATION_FAST_READ_MAX) - const fromFastWrite = relayUrlsUpToUnblocked(FAST_WRITE_RELAY_URLS, blocked, NOTIFICATION_FAST_WRITE_MAX) - const merged = mergeRelayListsUnique( - [fromFastRead, fromFastWrite, primary], - blocked, - NOTIFICATION_RELAY_CAP - ) - if (merged.length > 0) return appendCuratedReadOnlyRelays(merged, blockedRelays) - return appendCuratedReadOnlyRelays( - relayUrlsUpToUnblocked(FAST_READ_RELAY_URLS, blocked, NOTIFICATION_RELAY_CAP), - blockedRelays - ) -} - /** Notifications spell: same kind set as profile-style feeds, restricted to `#p` = you on the relay. */ export function buildMentionsSpellFilter(pubkey: string): Filter { return { @@ -264,45 +146,6 @@ export function buildMentionsSpellFilter(pubkey: string): Filter { } } -/** - * Relay set for Spells “Discussions” (kind 11), capped for subscription-based loading - * (see DISCUSSION_FAUX_SPELL_MAX_RELAYS). - */ -/** - * Deterministic relay pick: each tier (read / write / fav / fast) is normalized + sorted so NostrProvider - * array order and NIP-66 ref churn do not change which 32 relays we REQ (prevents subscription identity thrash). - */ -export function discussionRelayUrls( - relayList: TRelayList | null | undefined, - favoriteRelays: string[], - blockedRelays: string[] -): string[] { - const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b)) - const tier = (urls: string[]) => - [...new Set(urls.map((u) => normalizeUrl(u) || u).filter(Boolean))] - .filter((k) => !blocked.has(k)) - .sort((a, b) => a.localeCompare(b)) - - const read = tier(relayList?.read ?? []) - const write = tier(relayList?.write ?? []) - const fav = tier(favoriteRelays) - const fastR = tier([...FAST_READ_RELAY_URLS]) - const fastW = tier([...FAST_WRITE_RELAY_URLS]) - - const curated = mergeRelayListsUnique( - [ - read.slice(0, DISCUSSION_SPELL_READ_CAP), - write.slice(0, DISCUSSION_SPELL_WRITE_CAP), - fav.slice(0, DISCUSSION_SPELL_FAV_CAP), - fastR, - fastW - ], - blocked, - DISCUSSION_FAUX_SPELL_MAX_RELAYS - ) - return appendCuratedReadOnlyRelays(curated, blockedRelays) -} - export function buildDiscussionFilter(): Filter { return { kinds: [ExtendedKind.DISCUSSION], @@ -321,21 +164,6 @@ export function buildCalendarSpellFilter(): Filter { } } -const FOLLOW_PACK_LIMIT = 100 - -/** Kind 39089 follow/starter packs from fast read relays (same scope as the old Follow Packs page). */ -export function buildFollowPacksSubRequests(): TFeedSubRequest[] { - const curated = FAST_READ_RELAY_URLS.map((u) => normalizeUrl(u) || u).filter(Boolean) as string[] - if (!curated.length) return [] - const urls = appendCuratedReadOnlyRelays(curated, []) - return [ - { - urls, - filter: { kinds: [ExtendedKind.FOLLOW_PACK], limit: FOLLOW_PACK_LIMIT } - } - ] -} - /** One subrequest per topic (OR). Uses same kind set as the main profile/favorites feed. */ export function buildInterestsSubRequests( relayUrls: string[], diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 275ac507..50f77909 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -38,10 +38,14 @@ import { FAUX_SPELL_ORDER, FIRST_RELAY_RESULT_GRACE_MS, PROFILE_FEED_KINDS, - SPELL_FEED_FIRST_RELAY_GRACE_MS + SPELL_FEED_LOADING_MAX_MS } from '@/constants' import { isUserInEventMentions } from '@/lib/event' import { formatPubkey } from '@/lib/pubkey' +import { + augmentSubRequestsWithFavoritesFastReadAndInbox, + getRelayUrlsWithFavoritesFastReadAndInbox +} from '@/lib/favorites-feed-relays' import { computeSpellSubRequestsIdentityKey } from '@/lib/spell-feed-request-identity' import { normalizeUrl } from '@/lib/url' import { @@ -86,15 +90,11 @@ import { buildBookmarksSubRequests, buildCalendarSpellFilter, buildDiscussionFilter, - buildFollowPacksSubRequests, buildInterestsSubRequests, buildMediaSpellFilter, buildMentionsSpellFilter, - discussionRelayUrls, - fauxFavoriteRelayUrls, MEDIA_SPELL_SHOW_KINDS, - mediaSpellExtraShouldHideEvent, - notificationRelayUrls + mediaSpellExtraShouldHideEvent } from './fauxSpellFeeds' import type { TPageRef } from '@/types' @@ -370,6 +370,22 @@ const SpellsPage = forwardRef(function SpellsPage( 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] + ) + useEffect(() => { loadSpells() }, [loadSpells]) @@ -378,8 +394,7 @@ const SpellsPage = forwardRef(function SpellsPage( const contactsSyncKey = useMemo(() => [...contacts].sort().join(','), [contacts]) /** - * After showing the cache, pull kind 777 from merged mailbox (10002 + 10432) read/write + fast read. - * Deps use `relayMailboxStableKey` only — not NIP-66 `originalRelays` — so discovery merges don’t restart this sub. + * After showing the cache, pull kind 777 using the same relay set as the favorites feed. */ useEffect(() => { if (!pubkey) { @@ -396,7 +411,11 @@ const SpellsPage = forwardRef(function SpellsPage( if (!cancelled) void loadSpells() }, 120) } - const urls = getRelaysForSpellCatalogSync(relayList ?? undefined) + const urls = getRelaysForSpellCatalogSync( + favoriteRelays, + blockedRelays, + relayList?.read ?? [] + ) const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts) const authorAllowlist = new Set(catalogAuthors) const filter = { @@ -421,10 +440,14 @@ const SpellsPage = forwardRef(function SpellsPage( } } - void (async () => { - try { - setSpellsCatalogSyncing(true) - const { closer } = await client.subscribeTimeline( + /** Defer catalog REQ so faux/kind-777 feed opens sockets and paints first. */ + const catalogDelayMs = 800 + const delayId = window.setTimeout(() => { + if (cancelled) return + void (async () => { + try { + setSpellsCatalogSyncing(true) + const { closer } = await client.subscribeTimeline( [{ urls, filter }], { onEvents: async (events, eosed) => { @@ -477,8 +500,6 @@ const SpellsPage = forwardRef(function SpellsPage( onNew: () => {} // Not needed }, { - useCache: true, - omitDefaultSinceWhenUseCache: true, firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS } ) @@ -492,10 +513,12 @@ const SpellsPage = forwardRef(function SpellsPage( logger.warn('[SpellsPage] Spell catalog subscribe failed', e) if (!cancelled) setSpellsCatalogSyncing(false) } - })() + })() + }, catalogDelayMs) return () => { cancelled = true + window.clearTimeout(delayId) clearAfterFirstBatchTimer() if (loadSpellsDebounce != null) clearTimeout(loadSpellsDebounce) window.clearTimeout(syncTimeout) @@ -503,7 +526,15 @@ const SpellsPage = forwardRef(function SpellsPage( spellCatalogCloserRef.current = null setSpellsCatalogSyncing(false) } - }, [pubkey, relayMailboxStableKey, loadSpells, contactsSyncKey, spellCatalogManualRefreshKey]) + }, [ + pubkey, + sortedFavoriteRelaysKey, + sortedBlockedRelaysKey, + relayMailboxStableKey, + loadSpells, + contactsSyncKey, + spellCatalogManualRefreshKey + ]) useEffect(() => { if (!pubkey) { @@ -513,14 +544,6 @@ const SpellsPage = forwardRef(function SpellsPage( client.fetchFollowings(pubkey).then(setContacts).catch(() => setContacts([])) }, [pubkey]) - /** Order-independent favorites/blocked — array order from providers must not rebuild faux subs. */ - const sortedFavoriteRelaysKey = JSON.stringify( - [...favoriteRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort((a, b) => a.localeCompare(b)) - ) - const sortedBlockedRelaysKey = JSON.stringify( - [...blockedRelays].map((u) => normalizeUrl(u) || u).filter(Boolean).sort((a, b) => a.localeCompare(b)) - ) - useEffect(() => { if (selectedFauxSpell !== 'following' || !pubkey) { setFollowingSubRequests([]) @@ -533,7 +556,13 @@ const SpellsPage = forwardRef(function SpellsPage( try { const followings = await client.fetchFollowings(pubkey) const req = await client.generateSubRequestsForPubkeys([pubkey, ...followings], pubkey) - const withReadOnly = req.map((r) => ({ + const merged = augmentSubRequestsWithFavoritesFastReadAndInbox( + req, + favoriteRelays, + blockedRelays, + relayList?.read ?? [] + ) + const withReadOnly = merged.map((r) => ({ ...r, urls: appendCuratedReadOnlyRelays(r.urls, blockedRelays) })) @@ -547,7 +576,13 @@ const SpellsPage = forwardRef(function SpellsPage( return () => { cancelled = true } - }, [selectedFauxSpell, pubkey, sortedBlockedRelaysKey]) + }, [ + selectedFauxSpell, + pubkey, + sortedFavoriteRelaysKey, + sortedBlockedRelaysKey, + relayMailboxStableKey + ]) const interestTagsStableKey = interestListEvent ? JSON.stringify( @@ -574,45 +609,49 @@ const SpellsPage = forwardRef(function SpellsPage( const syncFauxSubRequests = useMemo(() => { if (!selectedFauxSpell || selectedFauxSpell === 'following') return [] + const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays, + blockedRelays, + relayList?.read ?? [] + ) if (selectedFauxSpell === 'notifications') { - if (!pubkey) return [] - const urls = notificationRelayUrls(relayList, favoriteRelays, blockedRelays) - if (!urls.length) return [] - return [{ urls, filter: buildMentionsSpellFilter(pubkey) }] + if (!pubkey || !feedUrls.length) return [] + return [{ urls: feedUrls, filter: buildMentionsSpellFilter(pubkey) }] } if (selectedFauxSpell === 'discussions') { - const urls = discussionRelayUrls(relayList, favoriteRelays, blockedRelays) - if (!urls.length) return [] - return [{ urls, filter: buildDiscussionFilter() }] + if (!feedUrls.length) return [] + return [{ urls: feedUrls, filter: buildDiscussionFilter() }] } if (selectedFauxSpell === 'media') { - const urls = fauxFavoriteRelayUrls(favoriteRelays, blockedRelays) - if (!urls.length) return [] - return [{ urls, filter: buildMediaSpellFilter() }] + if (!feedUrls.length) return [] + return [{ urls: feedUrls, filter: buildMediaSpellFilter() }] } if (selectedFauxSpell === 'calendar') { - const urls = fauxFavoriteRelayUrls(favoriteRelays, blockedRelays) - if (!urls.length) return [] - return [{ urls, filter: buildCalendarSpellFilter() }] + 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]!) - const urls = fauxFavoriteRelayUrls(favoriteRelays, blockedRelays) - return buildInterestsSubRequests(urls, topics, PROFILE_FEED_KINDS) + return buildInterestsSubRequests(feedUrls, topics, PROFILE_FEED_KINDS) } if (selectedFauxSpell === 'bookmarks') { if (!pubkey) return [] - const urls = notificationRelayUrls(relayList, favoriteRelays, blockedRelays) - return buildBookmarksSubRequests(bookmarkListEvent, urls) + return buildBookmarksSubRequests(bookmarkListEvent, feedUrls) } if (selectedFauxSpell === 'followPacks') { - return buildFollowPacksSubRequests() + const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) + if (!urls.length) return [] + return [ + { + urls, + filter: { kinds: [ExtendedKind.FOLLOW_PACK], limit: 100 } + } + ] } return [] - // relayMailboxStableKey: read/write only — do not tie faux feeds to originalRelays (NIP-66 churn). - }, [selectedFauxSpell, pubkey, relayMailboxStableKey, fauxFeedRelaysDepsKey]) + }, [selectedFauxSpell, pubkey, fauxFeedRelaysDepsKey, relayMailboxStableKey]) const fauxSubRequests = useMemo(() => { if (selectedFauxSpell === 'following') return followingSubRequests @@ -1229,8 +1268,7 @@ const SpellsPage = forwardRef(function SpellsPage( subRequests={subRequests} feedSubscriptionKey={spellFeedSubscriptionKey} showKinds={showKinds} - useTimelineCacheBootstrap - spellFetchTimeoutMs={SPELL_FEED_FIRST_RELAY_GRACE_MS} + spellFetchTimeoutMs={SPELL_FEED_LOADING_MAX_MS} spellFeedInstrumentToken={spellFeedInstrumentToken} onSpellFeedFirstPaint={handleSpellFeedFirstPaint} useFilterAsIs={fauxNoteListUseFilterAsIs} @@ -1258,8 +1296,7 @@ const SpellsPage = forwardRef(function SpellsPage( subRequests={subRequests} feedSubscriptionKey={spellFeedSubscriptionKey} showKinds={showKinds} - useTimelineCacheBootstrap - spellFetchTimeoutMs={SPELL_FEED_FIRST_RELAY_GRACE_MS} + spellFetchTimeoutMs={SPELL_FEED_LOADING_MAX_MS} spellFeedInstrumentToken={spellFeedInstrumentToken} onSpellFeedFirstPaint={handleSpellFeedFirstPaint} useFilterAsIs diff --git a/src/pages/secondary/NoteListPage/index.tsx b/src/pages/secondary/NoteListPage/index.tsx index 29509ffb..357107fa 100644 --- a/src/pages/secondary/NoteListPage/index.tsx +++ b/src/pages/secondary/NoteListPage/index.tsx @@ -3,12 +3,18 @@ import type { TNoteListRef } from '@/components/NoteList' import NormalFeed from '@/components/NormalFeed' import { RefreshButton } from '@/components/RefreshButton' import { Button } from '@/components/ui/button' -import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' +import { SEARCHABLE_RELAY_URLS } from '@/constants' +import { + augmentSubRequestsWithFavoritesFastReadAndInbox, + getRelayUrlsWithFavoritesFastReadAndInbox, + mergeRelayUrlLayers +} from '@/lib/favorites-feed-relays' import { normalizeUrl } from '@/lib/url' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { toProfileList } from '@/lib/link' import { fetchPubkeysFromDomain, getWellKnownNip05Url } from '@/lib/nip05' import { usePrimaryNoteView, useSecondaryPage } from '@/PageManager' +import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' import { useInterestList } from '@/providers/InterestListProvider' import client from '@/services/client.service' @@ -29,6 +35,7 @@ const NoteListPage = forwardRef(({ index, hid const bumpFeed = useCallback(() => feedRef.current?.refresh(), []) const { push } = useSecondaryPage() const { relayList, pubkey } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { isSubscribed, subscribe } = useInterestList() const [title, setTitle] = useState(null) const [controls, setControls] = useState(null) @@ -84,7 +91,11 @@ const NoteListPage = forwardRef(({ index, hid setSubRequests([ { filter: { '#t': [hashtag], ...(kinds.length > 0 ? { kinds } : {}) }, - urls: FAST_READ_RELAY_URLS + urls: getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays, + blockedRelays, + relayList?.read ?? [] + ) } ]) // Set controls for hashtag subscribe button - check subscription status @@ -122,10 +133,17 @@ const NoteListPage = forwardRef(({ index, hid setSubRequests([ { filter: { '#I': [externalContentId], ...(kinds.length > 0 ? { kinds } : {}) }, - urls: Array.from(new Set([ - ...FAST_READ_RELAY_URLS.map(url => normalizeUrl(url) || url), - ...(relayList?.write || []).map(url => normalizeUrl(url) || url) - ])) + urls: mergeRelayUrlLayers( + [ + getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays, + blockedRelays, + relayList?.read ?? [] + ), + (relayList?.write || []).map((url) => normalizeUrl(url) || url).filter(Boolean) as string[] + ], + blockedRelays + ) } ]) return @@ -149,7 +167,15 @@ const NoteListPage = forwardRef(({ index, hid domain }) if (pubkeys.length) { - setSubRequests(await client.generateSubRequestsForPubkeys(pubkeys, pubkey)) + const raw = await client.generateSubRequestsForPubkeys(pubkeys, pubkey) + setSubRequests( + augmentSubRequestsWithFavoritesFastReadAndInbox( + raw, + favoriteRelays, + blockedRelays, + relayList?.read ?? [] + ) + ) setControls(