diff --git a/src/components/Explore/ExploreRelayReviews.tsx b/src/components/Explore/ExploreRelayReviews.tsx index 91b76894..741f6921 100644 --- a/src/components/Explore/ExploreRelayReviews.tsx +++ b/src/components/Explore/ExploreRelayReviews.tsx @@ -1,50 +1,133 @@ -import NoteList from '@/components/NoteList' +import RelayReviewCard from '@/components/RelayInfo/RelayReviewCard' +import { Skeleton } from '@/components/ui/skeleton' import { ExtendedKind } from '@/constants' +import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' +import { getRelayUrlFromRelayReviewEvent } from '@/lib/event-metadata' import { getRelayUrlsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays' -import { - getRelayUrlFromRelayReviewEvent, - getStarsFromRelayReviewEvent -} from '@/lib/event-metadata' +import { appendCuratedReadOnlyRelays } from '@/pages/primary/SpellsPage/fauxSpellFeeds' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useNostr } from '@/providers/NostrProvider' -import { Event } from 'nostr-tools' -import { useCallback, useMemo } from 'react' +import client from '@/services/client.service' +import type { Event } from 'nostr-tools' +import { useEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const REVIEW_QUERY_LIMIT = 100 +const SHOW_COUNT = 20 + +function dedupeRelayReviewsNewestFirst(events: Event[]): Event[] { + const sorted = [...events].sort((a, b) => b.created_at - a.created_at) + const seen = new Set() + const out: Event[] = [] + for (const evt of sorted) { + const key = isReplaceableEvent(evt.kind) ? getReplaceableCoordinateFromEvent(evt) : evt.id + if (seen.has(key)) continue + seen.add(key) + out.push(evt) + } + return out +} export default function ExploreRelayReviews() { + const { t } = useTranslation() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { relayList } = useNostr() const relayUrls = useMemo( () => - getRelayUrlsWithFavoritesFastReadAndInbox( - favoriteRelays, - blockedRelays, - relayList?.read ?? [], - { userWriteRelays: relayList?.write ?? [] } + appendCuratedReadOnlyRelays( + getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays, + blockedRelays, + relayList?.read ?? [], + { userWriteRelays: relayList?.write ?? [] } + ), + blockedRelays ), [favoriteRelays, blockedRelays, relayList] ) - const subRequests = useMemo(() => [{ urls: relayUrls, filter: {} }], [relayUrls]) + const relayUrlsKey = useMemo(() => relayUrls.join('|'), [relayUrls]) + + const [loading, setLoading] = useState(true) + const [events, setEvents] = useState([]) + const [showCount, setShowCount] = useState(SHOW_COUNT) + const bottomRef = useRef(null) + + useEffect(() => { + let cancelled = false + setLoading(true) + setEvents([]) + setShowCount(SHOW_COUNT) + + void (async () => { + try { + const raw = await client.fetchEvents( + relayUrls, + { kinds: [ExtendedKind.RELAY_REVIEW], limit: REVIEW_QUERY_LIMIT }, + { + firstRelayResultGraceMs: false, + globalTimeout: 12_000, + eoseTimeout: 800, + cache: true + } + ) + if (cancelled) return + const withRelay = raw.filter( + (e) => e.kind === ExtendedKind.RELAY_REVIEW && getRelayUrlFromRelayReviewEvent(e) + ) + setEvents(dedupeRelayReviewsNewestFirst(withRelay)) + } catch { + if (!cancelled) setEvents([]) + } finally { + if (!cancelled) setLoading(false) + } + })() + + return () => { + cancelled = true + } + }, [relayUrlsKey]) + + useEffect(() => { + const options = { root: null, rootMargin: '120px', threshold: 0 } + const observer = new IntersectionObserver((entries) => { + if (entries[0]?.isIntersecting && showCount < events.length) { + setShowCount((prev) => prev + SHOW_COUNT) + } + }, options) + const el = bottomRef.current + if (el) observer.observe(el) + return () => { + if (el) observer.unobserve(el) + } + }, [showCount, events.length]) - const extraShouldHideEvent = useCallback((evt: Event) => { - if (evt.kind !== ExtendedKind.RELAY_REVIEW) return false - if (!getRelayUrlFromRelayReviewEvent(evt)) return true - return !getStarsFromRelayReviewEvent(evt) - }, []) + const visible = events.slice(0, showCount) return ( -
- +
+ {loading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : events.length === 0 ? ( +

{t('no relays found')}

+ ) : ( + <> +
+ {visible.map((event) => ( + + ))} +
+ {showCount < events.length ?
: null} + {showCount >= events.length ? ( +

{t('no more relays')}

+ ) : null} + + )}
) } diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 1873f44b..62da40ae 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1,5 +1,4 @@ import NewNotesButton from '@/components/NewNotesButton' -import { Button } from '@/components/ui/button' import { ExtendedKind, FIRST_RELAY_RESULT_GRACE_MS } from '@/constants' import { collectEmbeddedEventPrefetchTargets, @@ -13,7 +12,6 @@ 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' import { isTouchDevice } from '@/lib/utils' @@ -45,9 +43,11 @@ import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/prov import type { TProfile } from '@/types' import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard' -const LIMIT = 500 // Increased from 200 to load more events per request -const ALGO_LIMIT = 1000 // Increased from 500 for algorithm feeds -const SHOW_COUNT = 50 // Increased from 10 to show more events at once, reducing scroll load frequency +const LIMIT = 100 // Increased from 200 to load more events per request +const ALGO_LIMIT = 200 // Increased from 500 for algorithm feeds +const SHOW_COUNT = 20 // Increased from 10 to show more events at once, reducing scroll load frequency +/** Hard cap after merging parallel one-shot fetches (e.g. interests = one REQ per topic). */ +const ONE_SHOT_MERGED_CAP =100 const FEED_PROFILE_BATCH_DEBOUNCE_MS = 120 const FEED_PROFILE_CHUNK = 36 @@ -93,7 +93,13 @@ const NoteList = forwardRef( /** Spells page: bumps when user picks a feed; used with {@link onSpellFeedFirstPaint}. */ spellFeedInstrumentToken, /** Spells page: fired once when the filtered list first has rows after a picker change. */ - onSpellFeedFirstPaint + onSpellFeedFirstPaint, + /** + * When true, load events with parallel {@link client.fetchEvents} per subRequest instead of + * {@link client.subscribeTimeline}. No live stream or `loadMore` timeline pagination; use for faux spells + * (except Following). Refresh re-fetches. + */ + oneShotFetch = false }: { subRequests: TFeedSubRequest[] showKinds: number[] @@ -115,11 +121,12 @@ const NoteList = forwardRef( spellFetchTimeoutMs?: number spellFeedInstrumentToken?: number onSpellFeedFirstPaint?: (detail: { eventCount: number; firstEventId: string }) => void + oneShotFetch?: boolean }, ref ) => { const { t } = useTranslation() - const { startLogin, pubkey, relayList } = useNostr() + const { startLogin, pubkey } = useNostr() const { isUserTrusted } = useUserTrust() const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() @@ -185,8 +192,10 @@ const NoteList = forwardRef( useLayoutEffect(() => { const candidates = new Set() const addPk = (p: string | undefined) => { - if (p && p.length === 64 && /^[0-9a-f]{64}$/.test(p)) { - candidates.add(p) + if (!p) return + const t = p.trim() + if (t.length === 64 && /^[0-9a-f]{64}$/i.test(t)) { + candidates.add(t.toLowerCase()) } } for (const e of events) { @@ -431,12 +440,9 @@ const NoteList = forwardRef( const refresh = useCallback(() => { scrollToTop() setTimeout(() => { - void (async () => { - await syncUserDeletionTombstones(pubkey, relayList) - setRefreshCount((count) => count + 1) - })() + setRefreshCount((count) => count + 1) }, 500) - }, [pubkey, relayList, scrollToTop]) + }, [scrollToTop]) useImperativeHandle(ref, () => ({ scrollToTop, refresh }), [scrollToTop, refresh]) @@ -515,6 +521,48 @@ const NoteList = forwardRef( return () => {} } + if (oneShotFetch) { + if (!keepExistingTimelineEvents) { + setEvents([]) + setNewEvents([]) + } + setHasMore(false) + try { + const batches = await Promise.all( + mappedSubRequests.map(({ urls, filter }) => + client.fetchEvents(urls, filter, { + firstRelayResultGraceMs: false, + globalTimeout: 14_000, + eoseTimeout: 800, + cache: true + }) + ) + ) + if (!effectActive) return undefined + const byId = new Map() + for (const ev of batches.flat()) { + const prev = byId.get(ev.id) + if (!prev || ev.created_at > prev.created_at) { + byId.set(ev.id, ev) + } + } + const merged = [...byId.values()] + .sort((a, b) => b.created_at - a.created_at) + .slice(0, ONE_SHOT_MERGED_CAP) + setEvents(merged) + lastEventsForTimelinePrefetchRef.current = merged + } catch { + if (effectActive) setEvents([]) + } finally { + if (effectActive) { + setLoading(false) + setHasMore(false) + setTimelineKey(undefined) + } + } + return undefined + } + 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 @@ -683,7 +731,8 @@ const NoteList = forwardRef( showKind1111, useFilterAsIs, areAlgoRelays, - spellFetchTimeoutMs + spellFetchTimeoutMs, + oneShotFetch ]) useEffect(() => { @@ -1092,11 +1141,7 @@ const NoteList = forwardRef( ) : events.length > 0 ? (
{t('no more notes')}
) : ( -
- -
+
)}
) diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index ea35904e..7bde505f 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -212,7 +212,7 @@ export function useFetchProfile(id?: string, skipCache = false) { } catch (err) { const isTimeout = err instanceof Error && err.message.includes('timeout') if (isTimeout) { - logger.warn('[useFetchProfile] Profile fetch timed out', { + logger.debug('[useFetchProfile] Profile fetch timed out', { pubkey: pubkey.substring(0, 8), error: err.message }) diff --git a/src/lib/event.ts b/src/lib/event.ts index 01d4cbef..5501edd0 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -6,6 +6,7 @@ import { TImetaInfo } from '@/types' import { LRUCache } from 'lru-cache' import { Event, getEventHash, kinds, nip19, UnsignedEvent } from 'nostr-tools' import { getPow } from 'nostr-tools/nip13' +import { hexPubkeysEqual, normalizeHexPubkey } from './pubkey' import { generateBech32IdFromATag, generateBech32IdFromETag, @@ -322,10 +323,11 @@ export function getEmbeddedPubkeys(event: Event) { * Events authored by the user are excluded (not treated as incoming mentions). */ export function isUserInEventMentions(event: Event, userPubkey: string): boolean { - if (event.pubkey === userPubkey) return false - const inPtags = event.tags.some((t) => t[0] === 'p' && t[1] === userPubkey) + const u = normalizeHexPubkey(userPubkey) + if (hexPubkeysEqual(event.pubkey, u)) return false + const inPtags = event.tags.some((t) => t[0] === 'p' && t[1] && hexPubkeysEqual(t[1], u)) if (inPtags) return true - return getEmbeddedPubkeys(event).includes(userPubkey) + return getEmbeddedPubkeys(event).some((pk) => hexPubkeysEqual(pk, u)) } export function getLatestEvent(events: Event[]): Event | undefined { diff --git a/src/lib/notification.ts b/src/lib/notification.ts index 6446980e..caa41809 100644 --- a/src/lib/notification.ts +++ b/src/lib/notification.ts @@ -1,5 +1,6 @@ import { kinds, NostrEvent } from 'nostr-tools' import { ExtendedKind } from '@/constants' +import { hexPubkeysEqual } from '@/lib/pubkey' import { isMentioningMutedUsers } from './event' import { tagNameEquals } from './tag' @@ -29,12 +30,14 @@ export function notificationFilter( if (pubkey && event.kind === kinds.Reaction) { const targetPubkey = event.tags.findLast(tagNameEquals('p'))?.[1] - if (targetPubkey !== pubkey) return false + if (!targetPubkey || !hexPubkeysEqual(targetPubkey, pubkey)) return false } // For PUBLIC_MESSAGE (kind 24) events, ensure the user is in the 'p' tags if (pubkey && event.kind === ExtendedKind.PUBLIC_MESSAGE) { - const hasUserInPTags = event.tags.some((tag) => tag[0] === 'p' && tag[1] === pubkey) + const hasUserInPTags = event.tags.some( + (tag) => tag[0] === 'p' && tag[1] && hexPubkeysEqual(tag[1], pubkey) + ) if (!hasUserInPTags) return false } diff --git a/src/lib/pubkey.ts b/src/lib/pubkey.ts index 2bd52f0e..55cd835e 100644 --- a/src/lib/pubkey.ts +++ b/src/lib/pubkey.ts @@ -52,9 +52,31 @@ export function userIdToPubkey(userId: string) { logger.error('Error decoding userId', { userId, error }) } } + const trimmed = userId.trim() + if (/^[0-9a-f]{64}$/i.test(trimmed)) { + return trimmed.toLowerCase() + } return userId } +/** Lowercase 64-char hex pubkeys for stable Maps, REQ filters, and tag comparison. */ +export function normalizeHexPubkey(pubkey: string): string { + const t = pubkey.trim() + return /^[0-9a-f]{64}$/i.test(t) ? t.toLowerCase() : t +} + +export function hexPubkeysEqual(a: string, b: string): boolean { + if (a === b) return true + const na = normalizeHexPubkey(a) + const nb = normalizeHexPubkey(b) + return ( + na.length === 64 && + nb.length === 64 && + /^[0-9a-f]{64}$/.test(na) && + na === nb + ) +} + export function isValidPubkey(pubkey: string) { return /^[0-9a-f]{64}$/.test(pubkey) } diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index 81ff2672..4b2b1c12 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts @@ -1,5 +1,6 @@ /** - * Built-in “faux spells” use the same NoteList path as kind-777 REQ spells. + * Built-in “faux spells”: same NoteList + filters as kind-777 spells; except Following, feeds use one-shot + * `fetchEvents` per subRequest (see NoteList `oneShotFetch`) instead of a live timeline subscription. */ import { ExtendedKind, PROFILE_FEED_KINDS, READ_ONLY_RELAY_URLS } from '@/constants' import { @@ -139,10 +140,11 @@ export function mediaSpellExtraShouldHideEvent(evt: Event): boolean { /** Notifications spell: same kind set as profile-style feeds, restricted to `#p` = you on the relay. */ export function buildMentionsSpellFilter(pubkey: string): Filter { + const pk = /^[0-9a-f]{64}$/i.test(pubkey.trim()) ? pubkey.trim().toLowerCase() : pubkey.trim() return { kinds: [...PROFILE_FEED_KINDS], limit: NOTIFICATION_LIMIT, - '#p': [pubkey] + '#p': [pk] } } diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index ca1a37f1..d463168b 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -274,6 +274,8 @@ const SpellsPage = forwardRef(function SpellsPage( const spellCatalogCloserRef = useRef<(() => void) | null>(null) /** Bumps spell catalog relay re-sync when the user taps refresh in the titlebar. */ const [spellCatalogManualRefreshKey, setSpellCatalogManualRefreshKey] = useState(0) + /** Last processed {@link spellCatalogManualRefreshKey} so we only treat real bumps as “force sync”. */ + const spellCatalogLastManualKeyRef = useRef(0) const spellFeedListRef = useRef(null) const layoutRef = useRef(null) const [spellPickerOpen, setSpellPickerOpen] = useState(false) @@ -386,15 +388,41 @@ const SpellsPage = forwardRef(function SpellsPage( [blockedRelays] ) + /** + * Kind-777 list for the dropdown. When opening with `?spell=…` (faux name, hex id, nevent, etc.), defer + * this IndexedDB read so the feed can subscribe and paint first; the header already reflects the URL. + */ useEffect(() => { - loadSpells() - }, [loadSpells]) + let cancelled = false + const run = () => { + if (!cancelled) void loadSpells() + } + let idleId: number | undefined + let timeoutId: ReturnType | undefined + + if (spellProp?.trim()) { + if (typeof requestIdleCallback !== 'undefined') { + idleId = requestIdleCallback(run, { timeout: 2500 }) + } else { + timeoutId = setTimeout(run, 0) + } + } else { + run() + } + + return () => { + cancelled = true + if (idleId !== undefined) cancelIdleCallback(idleId) + if (timeoutId !== undefined) clearTimeout(timeoutId) + } + }, [loadSpells, spellProp]) /** Stable key so we re-sync when the follow list changes (not only on array identity). */ const contactsSyncKey = useMemo(() => [...contacts].sort().join(','), [contacts]) /** - * After showing the cache, pull kind 777 using the same relay set as the favorites feed. + * Pull kind 777 from relays only when IndexedDB has no spells yet, or when the user requests refresh. + * Otherwise the picker uses {@link loadSpells} from cache only (no extra REQ on each visit / relay churn). */ useEffect(() => { if (!pubkey) { @@ -404,6 +432,16 @@ const SpellsPage = forwardRef(function SpellsPage( let cancelled = false spellCatalogCloserRef.current = null let loadSpellsDebounce: ReturnType | null = null + let delayId: ReturnType | null = null + let syncTimeout: ReturnType | null = null + let afterFirstBatchTimer: ReturnType | null = null + const clearAfterFirstBatchTimer = () => { + if (afterFirstBatchTimer != null) { + clearTimeout(afterFirstBatchTimer) + afterFirstBatchTimer = null + } + } + const scheduleLoadSpells = () => { if (loadSpellsDebounce != null) clearTimeout(loadSpellsDebounce) loadSpellsDebounce = setTimeout(() => { @@ -411,115 +449,125 @@ const SpellsPage = forwardRef(function SpellsPage( if (!cancelled) void loadSpells() }, 120) } - const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, relayList?.read ?? [], { - userWriteRelays: relayList?.write ?? [] - }) - const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts) - const authorAllowlist = new Set(catalogAuthors) - const filter = { - kinds: [ExtendedKind.SPELL], - authors: catalogAuthors, - limit: contacts.length > 0 ? SPELL_CATALOG_SYNC_LIMIT_WITH_FOLLOWS : SPELL_CATALOG_SYNC_LIMIT - } - const syncTimeout = window.setTimeout(() => { + + void (async () => { + const manualBump = spellCatalogManualRefreshKey !== spellCatalogLastManualKeyRef.current + if (manualBump) { + spellCatalogLastManualKeyRef.current = spellCatalogManualRefreshKey + } + const cachedSpells = await indexedDb.getSpellEvents() if (cancelled) return - logger.warn('[SpellsPage] Spell catalog sync timed out') - spellCatalogCloserRef.current?.() - spellCatalogCloserRef.current = null - setSpellsCatalogSyncing(false) - }, SPELL_CATALOG_SYNC_TIMEOUT_MS) - let afterFirstBatchTimer: ReturnType | null = null - let catalogSyncDone = false - const clearAfterFirstBatchTimer = () => { - if (afterFirstBatchTimer != null) { - clearTimeout(afterFirstBatchTimer) - afterFirstBatchTimer = null + const shouldSyncFromRelays = manualBump || cachedSpells.length === 0 + if (!shouldSyncFromRelays) { + return } - } - /** Defer catalog REQ so faux/kind-777 feed opens sockets and paints first. */ - const catalogDelayMs = 800 - const delayId = window.setTimeout(() => { + const urls = getRelaysForSpellCatalogSync(favoriteRelays, blockedRelays, relayList?.read ?? [], { + userWriteRelays: relayList?.write ?? [] + }) + const catalogAuthors = buildSpellCatalogAuthors(pubkey, contacts) + const authorAllowlist = new Set(catalogAuthors) + const filter = { + kinds: [ExtendedKind.SPELL], + authors: catalogAuthors, + limit: contacts.length > 0 ? SPELL_CATALOG_SYNC_LIMIT_WITH_FOLLOWS : SPELL_CATALOG_SYNC_LIMIT + } + + syncTimeout = setTimeout(() => { + if (cancelled) return + logger.warn('[SpellsPage] Spell catalog sync timed out') + spellCatalogCloserRef.current?.() + spellCatalogCloserRef.current = null + setSpellsCatalogSyncing(false) + }, SPELL_CATALOG_SYNC_TIMEOUT_MS) + + let catalogSyncDone = false + + /** Defer catalog REQ so faux/kind-777 feed opens sockets and paints first. */ + const catalogDelayMs = 800 if (cancelled) return - void (async () => { - try { - setSpellsCatalogSyncing(true) - const { closer } = await client.subscribeTimeline( - [{ urls, filter }], - { - onEvents: async (events, eosed) => { - 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 (wrote) scheduleLoadSpells() - if (wrote && afterFirstBatchTimer == null) { - afterFirstBatchTimer = setTimeout(() => { - afterFirstBatchTimer = null - if (cancelled || catalogSyncDone) return - catalogSyncDone = true - window.clearTimeout(syncTimeout) - if (loadSpellsDebounce != null) { - clearTimeout(loadSpellsDebounce) - loadSpellsDebounce = null + delayId = setTimeout(() => { + if (cancelled) return + void (async () => { + try { + setSpellsCatalogSyncing(true) + const { closer } = await client.subscribeTimeline( + [{ urls, filter }], + { + onEvents: async (events, eosed) => { + 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 (wrote) scheduleLoadSpells() + if (wrote && afterFirstBatchTimer == null) { + afterFirstBatchTimer = setTimeout(() => { + afterFirstBatchTimer = null + if (cancelled || catalogSyncDone) return + catalogSyncDone = true + if (syncTimeout != null) clearTimeout(syncTimeout) + if (loadSpellsDebounce != null) { + clearTimeout(loadSpellsDebounce) + loadSpellsDebounce = null + } + void (async () => { + if (!cancelled) await loadSpells() + if (!cancelled) setSpellsCatalogSyncing(false) + })() + closer() + spellCatalogCloserRef.current = null + }, FIRST_RELAY_RESULT_GRACE_MS) } - void (async () => { + if (eosed) { + clearAfterFirstBatchTimer() + if (cancelled || catalogSyncDone) return + catalogSyncDone = true + if (syncTimeout != null) clearTimeout(syncTimeout) + if (loadSpellsDebounce != null) { + clearTimeout(loadSpellsDebounce) + loadSpellsDebounce = null + } if (!cancelled) await loadSpells() if (!cancelled) setSpellsCatalogSyncing(false) - })() - closer() - spellCatalogCloserRef.current = null - }, FIRST_RELAY_RESULT_GRACE_MS) - } - if (eosed) { - clearAfterFirstBatchTimer() - if (cancelled || catalogSyncDone) return - catalogSyncDone = true - window.clearTimeout(syncTimeout) - if (loadSpellsDebounce != null) { - clearTimeout(loadSpellsDebounce) - loadSpellsDebounce = null - } - if (!cancelled) await loadSpells() - if (!cancelled) setSpellsCatalogSyncing(false) - closer() - spellCatalogCloserRef.current = null + closer() + spellCatalogCloserRef.current = null + } + }, + onNew: () => {} // Not needed + }, + { + firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS } - }, - onNew: () => {} // Not needed - }, - { - firstRelayResultGraceMs: FIRST_RELAY_RESULT_GRACE_MS + ) + if (cancelled) { + closer() + return + } + spellCatalogCloserRef.current = closer + } catch (e) { + if (syncTimeout != null) clearTimeout(syncTimeout) + logger.warn('[SpellsPage] Spell catalog subscribe failed', e) + if (!cancelled) setSpellsCatalogSyncing(false) } - ) - if (cancelled) { - closer() - return - } - spellCatalogCloserRef.current = closer - } catch (e) { - window.clearTimeout(syncTimeout) - logger.warn('[SpellsPage] Spell catalog subscribe failed', e) - if (!cancelled) setSpellsCatalogSyncing(false) - } - })() - }, catalogDelayMs) + })() + }, catalogDelayMs) + })() return () => { cancelled = true - window.clearTimeout(delayId) clearAfterFirstBatchTimer() + if (delayId != null) clearTimeout(delayId) + if (syncTimeout != null) clearTimeout(syncTimeout) if (loadSpellsDebounce != null) clearTimeout(loadSpellsDebounce) - window.clearTimeout(syncTimeout) spellCatalogCloserRef.current?.() spellCatalogCloserRef.current = null setSpellsCatalogSyncing(false) @@ -627,8 +675,12 @@ const SpellsPage = forwardRef(function SpellsPage( return [{ urls: feedUrls, filter: buildMentionsSpellFilter(pubkey) }] } if (selectedFauxSpell === 'discussions') { - if (!feedUrls.length) return [] - return [{ urls: feedUrls, filter: buildDiscussionFilter() }] + // Same as followPacks: prioritized stack is capped (MAX_REQ_RELAY_URLS); tier-4 FAST_READ + // (incl. aggr) is often dropped when inbox + favorites fill the cap. Append read-only aggr so + // kind-11 discussions still resolve; also recover when feedUrls is empty (all blocked / no list). + const urls = appendCuratedReadOnlyRelays(feedUrls, blockedRelays) + if (!urls.length) return [] + return [{ urls, filter: buildDiscussionFilter() }] } if (selectedFauxSpell === 'media') { if (!feedUrls.length) return [] @@ -1279,6 +1331,7 @@ const SpellsPage = forwardRef(function SpellsPage( spellFeedInstrumentToken={spellFeedInstrumentToken} onSpellFeedFirstPaint={handleSpellFeedFirstPaint} useFilterAsIs={fauxNoteListUseFilterAsIs} + oneShotFetch={selectedFauxSpell !== 'following'} showKind1OPs={selectedFauxSpell === 'following' ? showKind1OPs : true} showKind1Replies={selectedFauxSpell === 'following' ? showKind1Replies : true} showKind1111={selectedFauxSpell === 'following' ? showKind1111 : true} diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index a7bfee24..1f1d7355 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -377,18 +377,6 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const mergedRelayList = await client.fetchRelayList(account.pubkey) // Keep using client for relay list merging setRelayList(mergedRelayList) - const deletionRelayUrls = Array.from( - new Set([ - ...mergedRelayList.write.map((url: string) => normalizeUrl(url) || url), - ...mergedRelayList.read.slice(0, 8).map((url: string) => normalizeUrl(url) || url), - ...PROFILE_FETCH_RELAY_URLS.map((url: string) => normalizeUrl(url) || url), - ]) - ).slice(0, 20) - - client.fetchDeletionEvents(deletionRelayUrls, account.pubkey).catch((err) => - logger.warn('[NostrProvider] Failed to sync deletion events / tombstones', { error: err }) - ) - const normalizedRelays = [ ...relayList.write.map((url: string) => normalizeUrl(url) || url), ...PROFILE_FETCH_RELAY_URLS.map((url: string) => normalizeUrl(url) || url) diff --git a/src/providers/NotificationProvider.tsx b/src/providers/NotificationProvider.tsx index 71528d65..168920e2 100644 --- a/src/providers/NotificationProvider.tsx +++ b/src/providers/NotificationProvider.tsx @@ -89,6 +89,26 @@ export function NotificationProvider({ children }: { children: React.ReactNode } } let discussionEosed = false + let initialBufferFlushed = false + const flushBufferedIfReady = () => { + if ( + !eosed || + !discussionEosed || + !isMountedRef.current || + initialBufferFlushed + ) { + return + } + initialBufferFlushed = true + const buf = notificationBufferRef.current + if (buf.length === 0) return + const sorted = [...buf].sort((a, b) => compareEvents(b, a)) + notificationBufferRef.current = sorted.slice(0, 50) + for (const evt of sorted) { + client.emitNewEvent(evt) + } + } + const discussionSubCloser = client.subscribe( notificationRelays, [ @@ -101,6 +121,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode } oneose: (e) => { if (e) { discussionEosed = e + flushBufferedIfReady() } }, onevent: (evt) => { @@ -160,6 +181,7 @@ export function NotificationProvider({ children }: { children: React.ReactNode } ...notificationBufferRef.current.sort((a, b) => compareEvents(b, a)) ] } + flushBufferedIfReady() } }, onevent: (evt) => { diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 5f5d9893..3344f108 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -240,7 +240,7 @@ export class ReplaceableEventService { // Log when no event is found (helps debug relay failures) if (kind === kinds.Metadata) { - logger.warn('[ReplaceableEventService] No profile found for pubkey', { + logger.debug('[ReplaceableEventService] No profile found for pubkey', { pubkey, cacheKey }) @@ -785,7 +785,7 @@ export class ReplaceableEventService { const relayListPromise = client.fetchRelayList(pubkey) const timeoutPromise = new Promise((resolve) => { setTimeout(() => { - logger.warn('[ReplaceableEventService] fetchRelayList timeout, giving up', { pubkey }) + logger.debug('[ReplaceableEventService] fetchRelayList timeout, giving up', { pubkey }) resolve(null) }, 2000) }) @@ -896,7 +896,7 @@ export class ReplaceableEventService { ReplaceableEventService.releaseProfileFallbackNetworkSlot() } - logger.warn('[ReplaceableEventService] Profile not found after cache, relay-list fallback, and comprehensive search', { + logger.debug('[ReplaceableEventService] Profile not found after cache, relay-list fallback, and comprehensive search', { pubkey, triedRelayHints: relayHints.length > 0 }) diff --git a/src/services/client.service.ts b/src/services/client.service.ts index cac0467c..e6722d3d 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1960,73 +1960,18 @@ class ClientService extends EventTarget { /** * Fetch deletion events (kind 5) and update the tombstone list. - * When `authorPubkey` is set, only that author's deletion requests are queried (typical on login). + * Network sync is intentionally disabled: it queried many relays on every refresh/login and saturated + * the connection pool. Tombstones still update via {@link applyDeletionRequestToLocalCache} when the user deletes from this client. */ - async fetchDeletionEvents(relayUrls: string[] = [], authorPubkey?: string): Promise { - const relays = - relayUrls.length > 0 ? relayUrls : Array.from(new Set([...PROFILE_FETCH_RELAY_URLS])) - - logger.info('[ClientService] Fetching deletion events', { - relayCount: relays.length, - authorPubkey: authorPubkey?.slice(0, 12), - }) - - try { - const deletionEvents = await this.queryService.query( - relays, - { - kinds: [kinds.EventDeletion], - limit: 100, - ...(authorPubkey ? { authors: [authorPubkey] } : {}), - }, - undefined, - { - replaceableRace: true, - eoseTimeout: 500, - globalTimeout: 5000, - } - ) - - logger.debug('[ClientService] Fetched deletion events', { count: deletionEvents.length }) - - for (const deletionEvent of deletionEvents) { - await this.addTombstoneEntriesFromDeletionEvent(deletionEvent) - } - - const removed = await indexedDb.removeTombstonedFromCache() - if (removed > 0) { - logger.info('[ClientService] Removed tombstoned events from cache', { count: removed }) - } - dispatchTombstonesUpdated() - } catch (error) { - logger.warn('[ClientService] Failed to fetch deletion events', { error }) - } + async fetchDeletionEvents(_relayUrls: string[] = [], _authorPubkey?: string): Promise { + return } /** - * Fetch kind-5 events for a profile pubkey (e.g. on profile feed refresh) so their deletes apply to tombstones + UI. + * @deprecated No-op — see {@link fetchDeletionEvents}. */ - async fetchDeletionEventsForPubkey(profilePubkey: string): Promise { - if (!profilePubkey) return - try { - const [relayList, favoriteRelays] = await Promise.all([ - this.fetchRelayList(profilePubkey).catch(() => ({ read: [] as string[], write: [] as string[] })), - this.fetchFavoriteRelays(profilePubkey).catch(() => [] as string[]) - ]) - const urls = Array.from( - new Set( - [ - ...relayList.write.map((url: string) => normalizeUrl(url) || url), - ...relayList.read.slice(0, 8).map((url: string) => normalizeUrl(url) || url), - ...favoriteRelays.map((url: string) => normalizeUrl(url) || url), - ...FAST_READ_RELAY_URLS.map((url: string) => normalizeUrl(url) || url) - ].filter(Boolean) - ) - ).slice(0, 24) - await this.fetchDeletionEvents(urls.length > 0 ? urls : undefined, profilePubkey) - } catch (error) { - logger.warn('[ClientService] fetchDeletionEventsForPubkey failed', { error }) - } + async fetchDeletionEventsForPubkey(_profilePubkey: string): Promise { + return } async searchNpubsForMention(