From fa4efed6cfc855283fc192358f7ac6255d9b9597 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 21 Mar 2026 19:47:26 +0100 Subject: [PATCH] bug-fixes --- src/components/ErrorBoundary.tsx | 53 +++++- src/components/NoteList/index.tsx | 54 +++++- src/contexts/secondary-page-context.tsx | 4 +- src/lib/spell-feed-request-identity.ts | 26 +++ .../primary/SpellsPage/fauxSpellFeeds.ts | 179 ++++++++++++++++-- src/pages/primary/SpellsPage/index.tsx | 179 ++++++++++++------ src/providers/CurrentRelaysProvider.tsx | 9 +- src/services/client.service.ts | 18 +- vite.config.ts | 20 ++ 9 files changed, 446 insertions(+), 96 deletions(-) create mode 100644 src/lib/spell-feed-request-identity.ts diff --git a/src/components/ErrorBoundary.tsx b/src/components/ErrorBoundary.tsx index 78cd6b6d..447cd4d7 100644 --- a/src/components/ErrorBoundary.tsx +++ b/src/components/ErrorBoundary.tsx @@ -7,6 +7,38 @@ import logger from '@/lib/logger' const ISSUES_URL = 'https://gitrepublic.imwald.eu/repos/npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z/jumble-imwald-edition?tab=issues' +/** HMR can remount children before parents; context hooks throw. One recovery reload fixes it. */ +const CONTEXT_RECOVERY_RELOAD_KEY = 'jumble-context-recovery-reload-at' +const CONTEXT_RECOVERY_COOLDOWN_MS = 20_000 + +function isLikelyBrokenReactContextFromHmr(message: string): boolean { + return ( + /must be used within (a )?[\w]+/i.test(message) || + message.includes('useNostr must be used within') || + message.includes('useInterestList must be used within') || + (message.includes('useContext') && message.includes('null')) + ) +} + +/** Avoid double `reload()` when React StrictMode runs render twice before navigation. */ +let contextRecoveryReloadScheduled = false + +function tryContextRecoveryReload(): boolean { + if (typeof window === 'undefined') return false + if (contextRecoveryReloadScheduled) return true + try { + const last = Number(sessionStorage.getItem(CONTEXT_RECOVERY_RELOAD_KEY) || '0') + const now = Date.now() + if (now - last <= CONTEXT_RECOVERY_COOLDOWN_MS) return false + sessionStorage.setItem(CONTEXT_RECOVERY_RELOAD_KEY, String(now)) + contextRecoveryReloadScheduled = true + window.location.reload() + return true + } catch { + return false + } +} + interface ErrorBoundaryProps { children: ReactNode } @@ -28,10 +60,19 @@ export class ErrorBoundary extends Component + Reloading after a dev hot-reload glitch… + + ) + } return (

Oops, something went wrong.

@@ -67,7 +108,17 @@ export class ErrorBoundary extends Component )} - diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 1eabe6b4..903b6bc8 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -9,6 +9,8 @@ import { isReplyNoteEvent } from '@/lib/event' import { shouldFilterEvent } from '@/lib/event-filtering' +import { stableSpellFeedFilterKey } from '@/lib/spell-feed-request-identity' +import { normalizeUrl } from '@/lib/url' import { getZapInfoFromEvent } from '@/lib/event-metadata' import { isTouchDevice } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' @@ -53,7 +55,14 @@ const NoteList = forwardRef( areAlgoRelays = false, pinnedEventIds = [], useFilterAsIs = false, - extraShouldHideEvent + extraShouldHideEvent, + /** 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. + */ + useTimelineCacheBootstrap = false }: { subRequests: TFeedSubRequest[] showKinds: number[] @@ -69,6 +78,8 @@ const NoteList = forwardRef( useFilterAsIs?: boolean /** 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 }, ref ) => { @@ -94,12 +105,19 @@ const NoteList = forwardRef( // Memoize subRequests serialization to avoid expensive JSON.stringify on every render const subRequestsKey = useMemo(() => { - return JSON.stringify(subRequests.map(req => ({ - urls: [...req.urls].sort(), // Create a copy before sorting to avoid mutation - filter: req.filter - }))) + return JSON.stringify( + subRequests.map((req) => ({ + urls: [...req.urls].map((u) => normalizeUrl(u) || u).filter(Boolean).sort(), + filter: stableSpellFeedFilterKey(req.filter) + })) + ) }, [subRequests]) + const timelineSubscriptionKey = feedSubscriptionKey ?? subRequestsKey + + const subRequestsRef = useRef(subRequests) + subRequestsRef.current = subRequests + // Stable key for kind filter so subscription effect doesn't re-run on parent re-renders with same kinds // Use sorted array and JSON.stringify to create a stable key that only changes when content changes const showKindsKey = useMemo(() => { @@ -230,7 +248,8 @@ const NoteList = forwardRef( useImperativeHandle(ref, () => ({ scrollToTop, refresh }), []) useEffect(() => { - if (!subRequests.length) { + const currentSubRequests = subRequestsRef.current + if (!currentSubRequests.length) { setLoading(false) setEvents([]) // Return a no-op closer function to satisfy the cleanup function @@ -247,7 +266,7 @@ const NoteList = forwardRef( setHasMore(true) consecutiveEmptyRef.current = 0 // Reset counter on refresh - const mappedSubRequests = subRequests.map(({ urls, filter }) => { + const mappedSubRequests = subRequestsRef.current.map(({ urls, filter }) => { // CRITICAL: Always ensure filter has kinds - relays require this to return events const defaultKinds = showKinds.length > 0 ? showKinds : [kinds.ShortTextNote] const finalFilter = useFilterAsIs @@ -402,7 +421,8 @@ const NoteList = forwardRef( { startLogin, needSort: !areAlgoRelays, - useCache: false // Main feeds should always fetch fresh from relays, not use cache + useCache: useTimelineCacheBootstrap, + omitDefaultSinceWhenUseCache: useTimelineCacheBootstrap } ) @@ -435,16 +455,30 @@ const NoteList = forwardRef( promise.then((closer) => closer?.()) } }, [ - subRequestsKey, + timelineSubscriptionKey, refreshCount, showKindsKey, showKind1OPs, showKind1Replies, showKind1111, useFilterAsIs, - areAlgoRelays + areAlgoRelays, + useTimelineCacheBootstrap ]) + useEffect(() => { + if (!subRequestsRef.current.length) return + let cancelled = false + const timer = window.setTimeout(() => { + if (cancelled) return + setLoading((prev) => (prev ? false : prev)) + }, 15_000) + return () => { + cancelled = true + clearTimeout(timer) + } + }, [timelineSubscriptionKey, refreshCount]) + // Use refs to avoid dependency issues and ensure latest values in async callbacks const eventsRef = useRef(events) const showCountRef = useRef(showCount) diff --git a/src/contexts/secondary-page-context.tsx b/src/contexts/secondary-page-context.tsx index 3ba459e3..fff50cf3 100644 --- a/src/contexts/secondary-page-context.tsx +++ b/src/contexts/secondary-page-context.tsx @@ -1,15 +1,17 @@ import { createContext, useContext } from 'react' +import type { TPrimaryPageName } from '@/PageManager' /** * Lives in a dedicated module so lazy chunks (e.g. TooManyRelaysAlertDialog) share the same * context instance as PageManager. Importing from PageManager into those chunks can duplicate * the module and break Provider matching (useSecondaryPage throws "must be used within Provider"). + * Use `import type` only so this file does not create a runtime dependency on PageManager. */ export type SecondaryPageContextValue = { push: (url: string) => void pop: () => void currentIndex: number - navigateToPrimaryPage: (page: string, props?: object) => void + navigateToPrimaryPage: (page: TPrimaryPageName, props?: object) => void } export const SecondaryPageContext = createContext(undefined) diff --git a/src/lib/spell-feed-request-identity.ts b/src/lib/spell-feed-request-identity.ts new file mode 100644 index 00000000..7203e5c2 --- /dev/null +++ b/src/lib/spell-feed-request-identity.ts @@ -0,0 +1,26 @@ +import type { TFeedSubRequest } from '@/types' +import { normalizeUrl } from '@/lib/url' +import type { Filter } from 'nostr-tools' + +/** Canonical JSON for a REQ filter so subscription identity ignores object identity / key order. */ +export function stableSpellFeedFilterKey(filter: Filter): string { + const entries = Object.entries(filter) + .filter(([, v]) => v !== undefined) + .sort(([a], [b]) => a.localeCompare(b)) + return JSON.stringify(Object.fromEntries(entries)) +} + +/** + * Single string identity for spell / faux-spell `subRequests`. + * Pass from SpellsPage into NoteList as `feedSubscriptionKey` so timeline subscription does not + * restart when parent passes a new `subRequests` array reference with identical REQ shape. + */ +export function computeSpellSubRequestsIdentityKey(subRequests: TFeedSubRequest[]): string { + if (!subRequests.length) return '' + return JSON.stringify( + subRequests.map((req) => ({ + urls: [...req.urls].map((u) => normalizeUrl(u) || u).filter(Boolean).sort(), + filter: stableSpellFeedFilterKey(req.filter) + })) + ) +} diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index 76ccea47..310c11f2 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts @@ -8,10 +8,15 @@ import { FAST_WRITE_RELAY_URLS, PROFILE_FEED_KINDS } from '@/constants' -import { normalizeTopic } from '@/lib/discussion-topics' +import { + extractHashtagsFromContent, + extractTTagsFromEvent, + normalizeTopic +} from '@/lib/discussion-topics' +import { getImetaInfosFromEvent } from '@/lib/event' import { normalizeUrl } from '@/lib/url' import type { TFeedSubRequest, TRelayList } from '@/types' -import { type Event, type Filter } from 'nostr-tools' +import { type Event, type Filter, kinds } from 'nostr-tools' const NOTIFICATION_LIMIT = 500 const DISCUSSION_LIMIT = 500 @@ -31,6 +36,98 @@ export const MEDIA_SPELL_KINDS = [ ExtendedKind.VOICE ] as const +/** Kinds shown in the Media faux spell: native media + kind 1 notes filtered by {@link mediaSpellExtraShouldHideEvent}. */ +export const MEDIA_SPELL_SHOW_KINDS = [ + kinds.ShortTextNote, + ...MEDIA_SPELL_KINDS +] as const + +/** + * Topic roots for kind 1 in the Media spell: a note must also match one of these via `t` tag or `#hashtag` + * (after {@link normalizeTopic}), **and** carry media (imeta / media URL / image|video|audio tag). + */ +export const MEDIA_SPELL_TOPIC_SEEDS = [ + 'vlog', + 'video', + 'reel', + 'gallery', + 'podcast', + 'photography', + 'photo', + 'music', + 'screencast' +] as const + +const MEDIA_SPELL_TOPIC_KEYWORDS = new Set( + MEDIA_SPELL_TOPIC_SEEDS.map((t) => normalizeTopic(t)).filter(Boolean) +) + +function hasMediaSpellTopicTag(event: Event): boolean { + for (const topic of extractTTagsFromEvent(event)) { + if (topic && MEDIA_SPELL_TOPIC_KEYWORDS.has(topic)) return true + } + for (const topic of extractHashtagsFromContent(event.content)) { + if (topic && MEDIA_SPELL_TOPIC_KEYWORDS.has(topic)) return true + } + return false +} + +function imetaTagsIndicateMedia(event: Event): boolean { + for (const im of getImetaInfosFromEvent(event)) { + const mime = im.m?.toLowerCase() ?? '' + if (mime.startsWith('image/') || mime.startsWith('video/') || mime.startsWith('audio/')) { + return true + } + const u = im.url ?? '' + if ( + /\.(jpe?g|png|gif|webp|heic|mp4|webm|m4v|mov|mkv|avi|mp3|m4a|aac|ogg|opus|wav|flac)(\?|#|$)/i.test( + u + ) + ) { + return true + } + } + return false +} + +function hasImageOrStreamTag(event: Event): boolean { + for (const t of event.tags) { + const name = t[0]?.toLowerCase() + if (name === 'image' && t[1]?.startsWith('http')) return true + if ((name === 'video' || name === 'audio' || name === 'stream') && t[1]?.startsWith('http')) { + return true + } + } + return false +} + +const CONTENT_MEDIA_FILE_EXT_RE = + /https?:\/\/[^\s<>"')]+\.(?:jpe?g|png|gif|webp|svg|bmp|heic|mp4|webm|m4v|mov|mkv|avi|mp3|m4a|aac|ogg|opus|wav|flac)(?:[\w#./?&=%~+-]*)/i + +/** Embed-style hosts (excludes GIF sticker sites like Giphy/Tenor). */ +const CONTENT_MEDIA_HOST_RE = + /https?:\/\/(?:(?:[\w-]+\.)*(?:spotify\.com|fountain\.fm)\/|(?:www\.)?(?:youtube\.com\/(?:watch|embed|shorts)|youtu\.be\/|vimeo\.com\/|twitch\.tv\/|instagram\.com\/|(?:i\.)?imgur\.com\/|soundcloud\.com\/|(?:www\.)?tiktok\.com\/|rumble\.com\/|odysee\.com\/))/i + +function contentHasMediaUrl(content: string): boolean { + return CONTENT_MEDIA_FILE_EXT_RE.test(content) || CONTENT_MEDIA_HOST_RE.test(content) +} + +function hasKind1MediaPayload(event: Event): boolean { + return imetaTagsIndicateMedia(event) || hasImageOrStreamTag(event) || contentHasMediaUrl(event.content) +} + +/** Kind 1: require {@link MEDIA_SPELL_TOPIC_SEEDS} match **and** imeta / media URL / image|video|audio tag. */ +export function isKind1MediaSpellEligible(event: Event): boolean { + if (event.kind !== kinds.ShortTextNote) return false + return hasMediaSpellTopicTag(event) && hasKind1MediaPayload(event) +} + +/** NoteList `extraShouldHideEvent`: hide kind 1 notes that fail the combined topic + media check. */ +export function mediaSpellExtraShouldHideEvent(evt: Event): boolean { + if (evt.kind !== kinds.ShortTextNote) return false + 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)) @@ -42,8 +139,14 @@ 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 +/** + * Notifications / bookmarks faux spells: prefer inbox (then favorites), but **always** merge FAST_READ. + * Using only the first N inbox relays meant one dead relay (e.g. offline personal relay) could dominate + * connection/EOSE latency while public relays were never asked — skeletons until timeout. + */ +const NOTIFICATION_PRIMARY_MAX = 6 +const NOTIFICATION_BLEND_FAST_MAX = 6 +const NOTIFICATION_RELAY_CAP = 12 function relayUrlsUpToUnblocked(urls: string[], blocked: Set, max: number): string[] { const seen = new Set() @@ -58,6 +161,25 @@ function relayUrlsUpToUnblocked(urls: string[], blocked: Set, max: numbe 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[], @@ -65,15 +187,21 @@ export function notificationRelayUrls( ): string[] { const blocked = new Set(blockedRelays.map((b) => normalizeUrl(b) || b)) const read = relayList?.read ?? [] - 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) + 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 fromFast = relayUrlsUpToUnblocked(FAST_READ_RELAY_URLS, blocked, NOTIFICATION_BLEND_FAST_MAX) + const merged = mergeRelayListsUnique([primary, fromFast], blocked, NOTIFICATION_RELAY_CAP) + if (merged.length > 0) return merged + return relayUrlsUpToUnblocked(FAST_READ_RELAY_URLS, blocked, NOTIFICATION_RELAY_CAP) } function dedupe(urls: string[]): string[] { @@ -101,20 +229,31 @@ export function buildMentionsSpellFilter(pubkey: string): Filter { * Relay set for Spells “Discussions” (kind 11): same merge order as DiscussionsPage, but 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 read = relayList?.read ?? [] - const write = relayList?.write ?? [] - const merged = [...read, ...write, ...favoriteRelays, ...FAST_READ_RELAY_URLS, ...FAST_WRITE_RELAY_URLS] 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 merged = [...read, ...write, ...fav, ...fastR, ...fastW] const seen = new Set() const out: string[] = [] - for (const u of merged) { - const k = normalizeUrl(u) || u - if (!k || seen.has(k) || blocked.has(k)) continue + for (const k of merged) { + if (seen.has(k)) continue seen.add(k) out.push(k) if (out.length >= DISCUSSION_FAUX_SPELL_MAX_RELAYS) break @@ -130,7 +269,7 @@ export function buildDiscussionFilter(): Filter { } export function buildMediaSpellFilter(): Filter { - return { kinds: [...MEDIA_SPELL_KINDS], limit: 500 } + return { kinds: [...MEDIA_SPELL_SHOW_KINDS], limit: 500 } } export function buildCalendarSpellFilter(): Filter { diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index d3858ef1..60f71889 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -35,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 { computeSpellSubRequestsIdentityKey } from '@/lib/spell-feed-request-identity' import { normalizeUrl } from '@/lib/url' import { buildSpellCatalogAuthors, @@ -82,7 +83,8 @@ import { buildMentionsSpellFilter, discussionRelayUrls, fauxFavoriteRelayUrls, - MEDIA_SPELL_KINDS, + MEDIA_SPELL_SHOW_KINDS, + mediaSpellExtraShouldHideEvent, notificationRelayUrls } from './fauxSpellFeeds' import type { TPageRef } from '@/types' @@ -306,30 +308,29 @@ const SpellsPage = forwardRef(function SpellsPage( setFavoriteIds(new Set(ids)) }, []) - /** Re-sync catalog when inbox / outbox / mailbox entries change (not only `write`). */ - const spellCatalogRelayKey = useMemo( - () => - relayList - ? JSON.stringify({ - r: relayList.read, - w: relayList.write, - o: relayList.originalRelays.map((x) => [x.url, x.scope]) - }) - : '', - [relayList] - ) + /** + * 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() + : [] - /** 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] - ) + /** 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]) useEffect(() => { loadSpells() @@ -338,7 +339,10 @@ const SpellsPage = forwardRef(function SpellsPage( /** Stable key so we re-sync when the follow list changes (not only on array identity). */ const contactsSyncKey = useMemo(() => [...contacts].sort().join(','), [contacts]) - /** After showing the cache, pull kind 777 from merged mailbox (10002 + 10432) read/write + fast read. */ + /** + * 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. + */ useEffect(() => { if (!pubkey) { setSpellsCatalogSyncing(false) @@ -346,7 +350,14 @@ const SpellsPage = forwardRef(function SpellsPage( } let cancelled = false spellCatalogCloserRef.current = null - setSpellsCatalogSyncing(true) + let loadSpellsDebounce: ReturnType | null = null + const scheduleLoadSpells = () => { + if (loadSpellsDebounce != null) clearTimeout(loadSpellsDebounce) + loadSpellsDebounce = setTimeout(() => { + loadSpellsDebounce = null + if (!cancelled) void loadSpells() + }, 120) + } const urls = getRelaysForSpellCatalogSync(relayList ?? undefined) const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts) const authorAllowlist = new Set(catalogAuthors) @@ -365,30 +376,41 @@ const SpellsPage = forwardRef(function SpellsPage( void (async () => { try { + setSpellsCatalogSyncing(true) const { closer } = await client.subscribeTimeline( [{ urls, filter }], { onEvents: async (events, eosed) => { - if (!eosed || cancelled) return - window.clearTimeout(syncTimeout) + if (cancelled) return + let wrote = false for (const ev of events) { if (cancelled) return if (!verifyEvent(ev) || !isSpellEvent(ev) || !authorAllowlist.has(ev.pubkey)) continue try { await indexedDb.putSpellEvent(ev) + wrote = true } catch (e) { logger.warn('[SpellsPage] Failed to cache spell from relay', e) } } - if (!cancelled) await loadSpells() - if (!cancelled) setSpellsCatalogSyncing(false) - closer() - spellCatalogCloserRef.current = null + if (wrote) scheduleLoadSpells() + if (eosed) { + window.clearTimeout(syncTimeout) + if (loadSpellsDebounce != null) { + clearTimeout(loadSpellsDebounce) + loadSpellsDebounce = null + } + if (!cancelled) await loadSpells() + if (!cancelled) setSpellsCatalogSyncing(false) + closer() + spellCatalogCloserRef.current = null + } }, onNew: () => {} // Not needed }, { - useCache: false // NO CACHING - stream raw from relays + useCache: true, + omitDefaultSinceWhenUseCache: true } ) if (cancelled) { @@ -405,12 +427,13 @@ const SpellsPage = forwardRef(function SpellsPage( return () => { cancelled = true + if (loadSpellsDebounce != null) clearTimeout(loadSpellsDebounce) window.clearTimeout(syncTimeout) spellCatalogCloserRef.current?.() spellCatalogCloserRef.current = null setSpellsCatalogSyncing(false) } - }, [pubkey, spellCatalogRelayKey, loadSpells, contactsSyncKey]) + }, [pubkey, relayMailboxStableKey, loadSpells, contactsSyncKey]) useEffect(() => { if (!pubkey) { @@ -420,6 +443,37 @@ 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)) + ) + + 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 || selectedFauxSpell === 'following') return [] @@ -459,16 +513,8 @@ const SpellsPage = forwardRef(function SpellsPage( return buildFollowPacksSubRequests() } return [] - // spellCatalogRelayKey: stable mailbox fingerprint (not relayList ref) so faux feeds don’t rebuild every NostrProvider tick - }, [ - selectedFauxSpell, - pubkey, - spellCatalogRelayKey, - favoriteRelays, - blockedRelays, - interestListEvent, - bookmarkListEvent - ]) + // relayMailboxStableKey: read/write only — do not tie faux feeds to originalRelays (NIP-66 churn). + }, [selectedFauxSpell, pubkey, relayMailboxStableKey, fauxFeedRelaysDepsKey]) const fauxSubRequests = useMemo(() => { if (selectedFauxSpell === 'following') return followingSubRequests @@ -492,6 +538,11 @@ const SpellsPage = forwardRef(function SpellsPage( return spellSubRequests }, [selectedFauxSpell, fauxSubRequests, spellSubRequests]) + const spellFeedSubscriptionKey = useMemo( + () => computeSpellSubRequestsIdentityKey(subRequests), + [subRequests] + ) + const spellBrowseRelayUrls = useMemo(() => { const set = new Set() for (const req of subRequests) { @@ -500,15 +551,18 @@ const SpellsPage = forwardRef(function SpellsPage( if (n) set.add(n) } } - return [...set] + return [...set].sort() }, [subRequests]) + const spellBrowseRelayUrlsKey = spellBrowseRelayUrls.join('|') + const { addRelayUrls, removeRelayUrls } = useCurrentRelays() useEffect(() => { - if (!spellBrowseRelayUrls.length) return - addRelayUrls(spellBrowseRelayUrls) - return () => removeRelayUrls(spellBrowseRelayUrls) - }, [spellBrowseRelayUrls, addRelayUrls, removeRelayUrls]) + if (!spellBrowseRelayUrlsKey) return + const urls = spellBrowseRelayUrlsKey.split('|') + addRelayUrls(urls) + return () => removeRelayUrls(urls) + }, [spellBrowseRelayUrlsKey, addRelayUrls, removeRelayUrls]) const toggleFavorite = useCallback(async (spellId: string) => { const ids = await indexedDb.getSpellFavoriteIds() @@ -585,6 +639,10 @@ const SpellsPage = forwardRef(function SpellsPage( .join(',') }, [selectedSpell?.id]) + /** Avoid depending on `kindFilterShowKinds` ref for faux spells that don’t use it (e.g. Discussions). */ + const followingShowKindsKey = + selectedFauxSpell === 'following' ? JSON.stringify(kindFilterShowKinds) : '' + const showKinds = useMemo(() => { if (selectedFauxSpell === 'notifications') { return PROFILE_FEED_KINDS @@ -599,7 +657,7 @@ const SpellsPage = forwardRef(function SpellsPage( return [ExtendedKind.FOLLOW_PACK] } if (selectedFauxSpell === 'media') { - return [...MEDIA_SPELL_KINDS] + return [...MEDIA_SPELL_SHOW_KINDS] } if (selectedFauxSpell === 'calendar') { return [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME] @@ -616,12 +674,7 @@ const SpellsPage = forwardRef(function SpellsPage( .map((tag) => parseInt(tag[1], 10)) .filter((n) => !Number.isNaN(n)) return kinds.length ? kinds : [1] - }, [ - selectedFauxSpell, - selectedSpell?.id, - showKindsTagKey, - kindFilterShowKinds - ]) + }, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey]) const spellMenuLabel = useCallback( (spell: Event) => (favoriteIds.has(spell.id) ? `★ ${getSpellName(spell)}` : getSpellName(spell)), @@ -1043,7 +1096,9 @@ const SpellsPage = forwardRef(function SpellsPage(
(function SpellsPage( extraShouldHideEvent={ selectedFauxSpell === 'notifications' && pubkey ? notificationsMentionExtraHide - : undefined + : selectedFauxSpell === 'media' + ? mediaSpellExtraShouldHideEvent + : undefined } hideUntrustedNotes={ selectedFauxSpell === 'notifications' ? hideUntrustedNotifications : false @@ -1062,7 +1119,13 @@ const SpellsPage = forwardRef(function SpellsPage( ) : selectedSpell ? ( subRequests.length > 0 ? ( - + ) : !pubkey && selectedSpell.tags.some( (tag) => tag[0] === 'authors' && (tag.includes('$me') || tag.includes('$contacts')) diff --git a/src/providers/CurrentRelaysProvider.tsx b/src/providers/CurrentRelaysProvider.tsx index f42dddc8..538a13a5 100644 --- a/src/providers/CurrentRelaysProvider.tsx +++ b/src/providers/CurrentRelaysProvider.tsx @@ -21,6 +21,7 @@ export function CurrentRelaysProvider({ children }: { children: React.ReactNode const relayUrls = useMemo(() => Object.keys(relayRefCount), [relayRefCount]) const addRelayUrls = useCallback((urls: string[]) => { + if (!urls.length) return setRelayRefCount((prev) => { const newCounts = { ...prev } urls.forEach((url) => { @@ -31,6 +32,7 @@ export function CurrentRelaysProvider({ children }: { children: React.ReactNode }, []) const removeRelayUrls = useCallback((urls: string[]) => { + if (!urls.length) return setRelayRefCount((prev) => { const newCounts = { ...prev } urls.forEach((url) => { @@ -45,8 +47,13 @@ export function CurrentRelaysProvider({ children }: { children: React.ReactNode }) }, []) + const contextValue = useMemo( + () => ({ relayUrls, addRelayUrls, removeRelayUrls }), + [relayUrls, addRelayUrls, removeRelayUrls] + ) + return ( - + {children} ) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index e710ca5f..70565297 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -853,16 +853,22 @@ class ClientService extends EventTarget { { startLogin, needSort = true, - useCache = false + useCache = false, + omitDefaultSinceWhenUseCache = false }: { startLogin?: () => void needSort?: boolean useCache?: boolean + /** When useCache is true but there are no timeline refs yet, skip the default 24h `since` so REQ stays unbounded (spell feeds / catalog). */ + omitDefaultSinceWhenUseCache?: boolean } = {} ) { const newEventIdSet = new Set() const requestCount = subRequests.length - const threshold = Math.floor(requestCount / 2) + // For requestCount===1, floor(1/2)=0 makes eosedCount>=threshold true from the first inner + // callback, so every progressive update forwards to the outer onEvents → setState storms and + // stuck feeds (e.g. Spells Discussions). Require at least one EOSE before opening the gate. + const threshold = requestCount <= 1 ? 1 : Math.floor(requestCount / 2) let eventIdSet = new Set() let events: NEvent[] = [] let eosedCount = 0 @@ -901,7 +907,7 @@ class ClientService extends EventTarget { }, onClose }, - { startLogin, needSort, useCache } + { startLogin, needSort, useCache, omitDefaultSinceWhenUseCache } ) }) ) @@ -1191,11 +1197,13 @@ class ClientService extends EventTarget { { startLogin, needSort = true, - useCache = false + useCache = false, + omitDefaultSinceWhenUseCache = false }: { startLogin?: () => void needSort?: boolean useCache?: boolean + omitDefaultSinceWhenUseCache?: boolean } = {} ) { const relays = Array.from(new Set(urls)) @@ -1245,7 +1253,7 @@ class ClientService extends EventTarget { // CRITICAL FIX: Only set since parameter if caching is enabled // When useCache is false, we want to stream raw from relays without time restrictions // This allows relay feeds to show all available events, not just recent ones - if (!since && needSort && useCache) { + if (!since && needSort && useCache && !omitDefaultSinceWhenUseCache) { // Default to last 24 hours if no recent cached events (only when caching is enabled) // This ensures we get recent content even if relays are slow const oneDayAgo = dayjs().subtract(24, 'hours').unix() diff --git a/vite.config.ts b/vite.config.ts index bb8e9629..ac6328f9 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import react from '@vitejs/plugin-react' import { execSync } from 'child_process' import path from 'path' +import type { Plugin } from 'vite' import { defineConfig } from 'vitest/config' import { VitePWA } from 'vite-plugin-pwa' import packageJson from './package.json' @@ -24,6 +25,24 @@ const getAppVersion = () => { } } +/** + * React Fast Refresh can remount provider children without NostrProvider (e.g. after editing pages), + * causing `useNostr must be used within a NostrProvider`. Full page reload keeps the tree consistent. + */ +function fullReloadOnProvidersAndPages(): Plugin { + return { + name: 'full-reload-providers-pages', + apply: 'serve', + handleHotUpdate({ file, server }) { + const normalized = file.replace(/\\/g, '/') + if (normalized.includes('/src/providers/') || normalized.includes('/src/pages/')) { + server.ws.send({ type: 'full-reload' }) + return [] + } + } + } +} + // https://vite.dev/config/ export default defineConfig({ base: '/', @@ -172,6 +191,7 @@ export default defineConfig({ }, plugins: [ react(), + fullReloadOnProvidersAndPages(), VitePWA({ registerType: 'autoUpdate', // Use public/manifest.webmanifest and index.html only; avoid duplicate manifest link in build