From 550434d156182dd97bdad91c4d983081f55427ff Mon Sep 17 00:00:00 2001 From: Silberengel Date: Wed, 13 May 2026 20:00:17 +0200 Subject: [PATCH] bug-fixes --- eslint.config.js | 7 + src/PageManager.tsx | 48 +++- src/components/Note/index.tsx | 28 +- src/components/NoteCard/MainNoteCard.tsx | 37 ++- src/components/NoteCard/index.tsx | 16 +- src/components/RelayIcon/index.tsx | 3 - .../SearchResult/FullTextSearchByRelay.tsx | 257 +++++++++++++++--- src/lib/relay-strikes.ts | 10 +- src/providers/FeedProvider.tsx | 62 +++-- src/providers/UserTrustProvider.tsx | 38 +-- src/services/client.service.ts | 2 +- src/services/indexed-db.service.ts | 59 +++- 12 files changed, 440 insertions(+), 127 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 6926ca4e..f0f9c2a1 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -26,5 +26,12 @@ export default tseslint.config( 'react-hooks/exhaustive-deps': 'off', '@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }] } + }, + { + files: ['src/PageManager.tsx'], + rules: { + // File exports hooks + `PageManager` + helpers; Vite uses `// @refresh reset` instead of Fast Refresh. + 'react-refresh/only-export-components': 'off' + } } ) diff --git a/src/PageManager.tsx b/src/PageManager.tsx index edf33ae9..aa4b671a 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -237,6 +237,15 @@ function mergePrimaryPageEntry( return [...prev, { name: entry.name, element, props: entry.props }] } +function primaryPagePropsDebugFingerprint(props: object | undefined): string { + if (!props || typeof props !== 'object') return '' + return Object.keys(props) + .sort() + .join(',') +} + +let lastActivePrimaryPageContentDebugKey = '' + function renderActivePrimaryPageContent( primaryPages: TPrimaryPageStateEntry[], currentPrimaryPage: TPrimaryPageName @@ -246,7 +255,11 @@ function renderActivePrimaryPageContent( (primaryPages.length > 0 ? primaryPages[0] : undefined) if (!entry) return null try { - logger.debug(`Rendering active primary page: ${entry.name}`) + const dbgKey = `${currentPrimaryPage}|${entry.name}|${primaryPagePropsDebugFingerprint(entry.props)}` + if (dbgKey !== lastActivePrimaryPageContentDebugKey) { + lastActivePrimaryPageContentDebugKey = dbgKey + logger.debug(`Rendering active primary page: ${entry.name}`) + } return entry.props ? applyPrimaryPageProps(entry.element, entry.props) : entry.element } catch (error) { logger.error(`Error rendering ${entry.name} component:`, error) @@ -964,7 +977,12 @@ function MainContentArea({ onPrimaryPanelRefresh: () => void }) { const [, forceUpdate] = useState(0) - + const mainContentDebugRef = useRef({ + currentPrimaryPage: '' as TPrimaryPageName, + pages: '', + noteView: false + }) + // Listen for note page title updates useEffect(() => { const handleTitleUpdate = () => { @@ -975,12 +993,26 @@ function MainContentArea({ window.removeEventListener('notePageTitleUpdated', handleTitleUpdate) } }, []) - - logger.debug('MainContentArea rendering:', { - currentPrimaryPage, - primaryPages: primaryPages.map(p => p.name), - primaryNoteView: !!primaryNoteView - }) + + const pagesKey = primaryPages.map((p) => p.name).join(',') + const noteView = !!primaryNoteView + const prevDbg = mainContentDebugRef.current + if ( + prevDbg.currentPrimaryPage !== currentPrimaryPage || + prevDbg.pages !== pagesKey || + prevDbg.noteView !== noteView + ) { + mainContentDebugRef.current = { + currentPrimaryPage, + pages: pagesKey, + noteView + } + logger.debug('MainContentArea rendering:', { + currentPrimaryPage, + primaryPages: primaryPages.map((p) => p.name), + primaryNoteView: noteView + }) + } // flex + min-h-0 + min-w-0 so primary pages get a real height in flex parents and can shrink horizontally (double-pane). return ( diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 6e3196f9..96e4eb6d 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -119,11 +119,13 @@ function cacheEmbeddedRepostTarget(hostEvent: Event, targetEvent: Event) { function StringifiedNostrEventPreviewCard({ hostEvent, targetEvent, - className + className, + deferAuthorAvatar = false }: { hostEvent: Event targetEvent: Event className?: string + deferAuthorAvatar?: boolean }) { const { t } = useTranslation() @@ -148,7 +150,7 @@ function StringifiedNostrEventPreviewCard({ userId={targetEvent.pubkey} size="tiny" className="mt-0.5 shrink-0" - deferRemoteAvatar={false} + deferRemoteAvatar={deferAuthorAvatar} />
@@ -164,7 +166,8 @@ function StringifiedNostrEventContent({ className, hideMetadata, autoLoadMedia, - fullCalendarInvite + fullCalendarInvite, + deferAuthorAvatar = false }: { hostEvent: Event match: StringifiedNostrEventMatch @@ -172,6 +175,7 @@ function StringifiedNostrEventContent({ hideMetadata?: boolean autoLoadMedia: boolean fullCalendarInvite?: { event: Event; naddr: string } + deferAuthorAvatar?: boolean }) { const textEvent = match.textBefore.trim() ? { ...hostEvent, content: match.textBefore } @@ -187,7 +191,11 @@ function StringifiedNostrEventContent({ fullCalendarInvite={fullCalendarInvite} /> ) : null} - +
) } @@ -218,7 +226,8 @@ export default function Note({ embedded, fullCalendarInvite, zapPollVoteHighlightOption, - nip84HighlightEvents + nip84HighlightEvents, + deferAuthorAvatar = false }: { event: Event originalNoteId?: string @@ -234,6 +243,8 @@ export default function Note({ zapPollVoteHighlightOption?: number /** Kind-9802 events that cite this note; when spans match {@link displayEvent.content}, render green marks (note page OP). */ nip84HighlightEvents?: Event[] + /** When true, defer remote profile avatars until near-viewport (dense lists e.g. merged NIP-50 search). */ + deferAuthorAvatar?: boolean }) { const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigationOptional() @@ -317,6 +328,7 @@ export default function Note({ hideMetadata={hideMetadata} autoLoadMedia={autoLoadMedia} fullCalendarInvite={fullCalendarInvite} + deferAuthorAvatar={deferAuthorAvatar} /> ) } @@ -374,7 +386,7 @@ export default function Note({ /> ) }, - [displayEvent, fullCalendarInvite, autoLoadMedia, nip84HighlightEvents] + [displayEvent, fullCalendarInvite, autoLoadMedia, nip84HighlightEvents, deferAuthorAvatar] ) let content: React.ReactNode @@ -618,7 +630,7 @@ export default function Note({ userId={event.pubkey} size={size === 'small' ? 'medium' : 'normal'} maxFileSizeKb={showFull ? 2048 : 500} - deferRemoteAvatar={false} + deferRemoteAvatar={deferAuthorAvatar} />
diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index eca38707..ac67f896 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -23,7 +23,10 @@ export default function MainNoteCard({ hideParentNotePreview = false, zapPollVoteHighlightOption, bottomNoteLabel, - showFull = false + showFull = false, + fetchNoteStatsIfMissing = true, + deferAuthorAvatar = false, + searchListPreview = false }: { event: Event className?: string @@ -37,6 +40,12 @@ export default function MainNoteCard({ zapPollVoteHighlightOption?: number bottomNoteLabel?: string showFull?: boolean + /** When false, skip relay-backed stats prefetch (e.g. merged NIP-50 search lists). */ + fetchNoteStatsIfMissing?: boolean + /** When true, defer remote avatar HTTP until near-viewport (lighter list mounts). */ + deferAuthorAvatar?: boolean + /** Compact row: no stats bar, no separator, no boost badges (e.g. merged NIP-50 search). */ + searchListPreview?: boolean }) { const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigationOptional() @@ -44,7 +53,10 @@ export default function MainNoteCard({ event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === ExtendedKind.ZAP_REQUEST /** NIP-52 kinds 31922 / 31923: card-level {@link Collapsible} clips the stats row; description collapses inside the card. */ const isCalendarNoteKind = isNip52CalendarCardKind(event.kind) - const showNoteStatsRow = !embedded || isZapFeedCard + const showNoteStatsRow = + !searchListPreview && (!embedded || isZapFeedCard) + const notePadX = searchListPreview ? 'px-3' : 'px-4' + const innerY = searchListPreview ? 'py-2' : 'py-3' return (
{pinned && !embedded && (
@@ -97,10 +109,10 @@ export default function MainNoteCard({
)} - + - {!embedded ? : null} + {!embedded && !searchListPreview ? : null} {showNoteStatsRow ? ( ) : null} {!embedded && bottomNoteLabel ? ( -
{bottomNoteLabel}
+
{bottomNoteLabel}
) : null}
- {!embedded && } + {!embedded && !searchListPreview ? : null}
) } diff --git a/src/components/NoteCard/index.tsx b/src/components/NoteCard/index.tsx index 483fa2ec..f5edb315 100644 --- a/src/components/NoteCard/index.tsx +++ b/src/components/NoteCard/index.tsx @@ -15,7 +15,10 @@ const NoteCard = memo(function NoteCard({ pinned = false, hideParentNotePreview = false, zapPollVoteHighlightOption, - bottomNoteLabel + bottomNoteLabel, + fetchNoteStatsIfMissing = true, + deferAuthorAvatar = false, + searchListPreview = false }: { event: Event className?: string @@ -26,6 +29,9 @@ const NoteCard = memo(function NoteCard({ zapPollVoteHighlightOption?: number /** Optional label rendered at the bottom of the card (e.g. why this event is in a composed feed). */ bottomNoteLabel?: string + fetchNoteStatsIfMissing?: boolean + deferAuthorAvatar?: boolean + searchListPreview?: boolean }) { const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() @@ -59,6 +65,9 @@ const NoteCard = memo(function NoteCard({ hideParentNotePreview={hideParentNotePreview} zapPollVoteHighlightOption={zapPollVoteHighlightOption} bottomNoteLabel={bottomNoteLabel} + fetchNoteStatsIfMissing={fetchNoteStatsIfMissing} + deferAuthorAvatar={deferAuthorAvatar} + searchListPreview={searchListPreview} /> ) }, (prevProps, nextProps) => { @@ -71,7 +80,10 @@ const NoteCard = memo(function NoteCard({ prevProps.pinned === nextProps.pinned && prevProps.hideParentNotePreview === nextProps.hideParentNotePreview && prevProps.zapPollVoteHighlightOption === nextProps.zapPollVoteHighlightOption && - prevProps.bottomNoteLabel === nextProps.bottomNoteLabel + prevProps.bottomNoteLabel === nextProps.bottomNoteLabel && + prevProps.fetchNoteStatsIfMissing === nextProps.fetchNoteStatsIfMissing && + prevProps.deferAuthorAvatar === nextProps.deferAuthorAvatar && + prevProps.searchListPreview === nextProps.searchListPreview ) }) diff --git a/src/components/RelayIcon/index.tsx b/src/components/RelayIcon/index.tsx index a0a7c204..6adc54f0 100644 --- a/src/components/RelayIcon/index.tsx +++ b/src/components/RelayIcon/index.tsx @@ -2,7 +2,6 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { useFetchRelayInfo } from '@/hooks' import { getRelayIconOverrideSrc, relayUrlFingerprintColors } from '@/lib/relay-icon-source' import { cn } from '@/lib/utils' -import logger from '@/lib/logger' import { Server } from 'lucide-react' import { useMemo } from 'react' @@ -48,7 +47,6 @@ export default function RelayIcon({ const override = getRelayIconOverrideSrc(url) if (override) { - logger.debug('[RelayIcon] using override icon', { url, override }) return override } @@ -56,7 +54,6 @@ export default function RelayIcon({ const rawIcon = relayInfo?.icon && typeof relayInfo.icon === 'string' ? relayInfo.icon : undefined const nip11Icon = rawIcon ? resolveRelayImageUrl(rawIcon, url) : undefined if (nip11Icon) { - logger.debug('[RelayIcon] using NIP-11 icon', { url, rawIcon, nip11Icon }) return nip11Icon } diff --git a/src/components/SearchResult/FullTextSearchByRelay.tsx b/src/components/SearchResult/FullTextSearchByRelay.tsx index 7264091a..eefb1bff 100644 --- a/src/components/SearchResult/FullTextSearchByRelay.tsx +++ b/src/components/SearchResult/FullTextSearchByRelay.tsx @@ -1,17 +1,24 @@ import NoteCard from '@/components/NoteCard' import RelayIcon from '@/components/RelayIcon' -import { Card, CardContent, CardHeader } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import { compareEventsForDTagQuery } from '@/lib/dtag-search' import logger from '@/lib/logger' +import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { normalizeUrl } from '@/lib/url' +import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext' import client from '@/services/client.service' import { relayHostForSubscribeLog } from '@/services/relay-operation-log.service' +import type { TProfile } from '@/types' import type { Event, Filter } from 'nostr-tools' import { Loader2 } from 'lucide-react' -import { useEffect, useMemo, useRef, useState } from 'react' +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useTranslation } from 'react-i18next' +type MergedHit = { + event: Event + relayUrls: string[] +} + /** One-shot NIP-50 REQ per relay; bounded wait so the page always reaches a terminal state (see QueryService NIP-50 global floor). */ const FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS = 45_000 /** Avoid opening every index relay at once (pool + main thread); still completes all relays. */ @@ -21,6 +28,163 @@ const FULL_TEXT_SEARCH_PER_RELAY_LIMIT = 80 const FULL_TEXT_SEARCH_MAX_NOTES_PER_RELAY = 40 /** Max merged unique notes shown after deduping across relays. */ const FULL_TEXT_SEARCH_MAX_MERGED_EVENTS = 150 +/** Batched kind-0 fetch chunk size (aligned with feed profile batching). */ +const SEARCH_MERGED_PROFILE_CHUNK = 80 +/** Coalesce rapid merge updates before hitting the network. */ +const SEARCH_MERGED_PROFILE_DEBOUNCE_MS = 240 + +function extractMergedHitAuthorPubkeys(hits: MergedHit[]): string[] { + const out: string[] = [] + const seen = new Set() + for (const h of hits) { + const pk = h.event.pubkey?.trim().toLowerCase() + if (!pk || !/^[0-9a-f]{64}$/.test(pk) || seen.has(pk)) continue + seen.add(pk) + out.push(pk) + } + return out +} + +/** + * Feed-style batched profile hydration so merged NIP-50 cards do not each run a separate + * {@link useFetchProfile} network path (main-thread + pool pressure). + */ +function SearchMergedProfileProvider({ + resetKey, + mergedHits, + children +}: { + resetKey: string + mergedHits: MergedHit[] + children: ReactNode +}) { + const [batch, setBatch] = useState(() => ({ + profiles: new Map(), + pending: new Set(), + version: 0 + })) + const mergedHitsRef = useRef(mergedHits) + mergedHitsRef.current = mergedHits + const fetchAttemptedRef = useRef(new Set()) + + useEffect(() => { + fetchAttemptedRef.current = new Set() + setBatch({ profiles: new Map(), pending: new Set(), version: 0 }) + }, [resetKey]) + + const hitsIdentity = useMemo( + () => + [...mergedHits] + .map((h) => h.event.id) + .sort() + .join('\x1e'), + [mergedHits] + ) + + useEffect(() => { + if (!hitsIdentity) return + let cancelled = false + const t = window.setTimeout(() => { + if (cancelled) return + const hits = mergedHitsRef.current + const pubkeys = extractMergedHitAuthorPubkeys(hits) + if (pubkeys.length === 0) return + + const need = pubkeys.filter((pk) => !fetchAttemptedRef.current.has(pk)) + if (need.length === 0) return + for (const pk of need) { + fetchAttemptedRef.current.add(pk) + } + + setBatch((prev) => { + const pending = new Set(prev.pending) + let changed = false + for (const pk of need) { + if (!prev.profiles.has(pk)) { + pending.add(pk) + changed = true + } + } + if (!changed) return prev + return { ...prev, pending } + }) + + void (async () => { + const chunks: string[][] = [] + for (let i = 0; i < need.length; i += SEARCH_MERGED_PROFILE_CHUNK) { + chunks.push(need.slice(i, i + SEARCH_MERGED_PROFILE_CHUNK)) + } + for (const chunk of chunks) { + if (cancelled) return + let profiles: TProfile[] = [] + try { + profiles = await client.fetchProfilesForPubkeys(chunk) + } catch { + profiles = [] + } + if (cancelled) return + setBatch((prev) => { + const next = new Map(prev.profiles) + const pend = new Set(prev.pending) + for (const p of profiles) { + const pkNorm = p.pubkey.toLowerCase() + next.set(pkNorm, { ...p, pubkey: pkNorm }) + pend.delete(pkNorm) + } + for (const pk of chunk) { + const pkNorm = pk.toLowerCase() + pend.delete(pkNorm) + if (!next.has(pkNorm)) { + next.set(pkNorm, { + pubkey: pkNorm, + npub: pubkeyToNpub(pkNorm) ?? '', + username: formatPubkey(pkNorm), + batchPlaceholder: true + }) + } + } + return { profiles: next, pending: pend, version: prev.version + 1 } + }) + } + })() + }, SEARCH_MERGED_PROFILE_DEBOUNCE_MS) + return () => { + cancelled = true + window.clearTimeout(t) + } + }, [hitsIdentity, resetKey]) + + const ctxVal = useMemo( + () => ({ + profiles: batch.profiles, + pendingPubkeys: batch.pending, + version: batch.version + }), + [batch.profiles, batch.pending, batch.version] + ) + + return {children} +} + +/** Max events to push into session cache per animation frame (keeps the tab responsive during merges). */ +const ADD_TO_CACHE_PER_FRAME = 8 + +async function addSearchEventsToSessionCacheBatched( + events: Event[], + runGeneration: { current: number }, + myRun: number +): Promise { + for (let i = 0; i < events.length; i += ADD_TO_CACHE_PER_FRAME) { + if (myRun !== runGeneration.current) return + const slice = events.slice(i, i + ADD_TO_CACHE_PER_FRAME) + for (const e of slice) { + client.addEventToCache(e, { explicitNoteLookupHexId: e.id }) + } + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()) + }) + } +} type RelayFetchPhase = 'loading' | 'done' | 'error' @@ -33,11 +197,6 @@ type RelayFetchRow = { errorMessage?: string } -type MergedHit = { - event: Event - relayUrls: string[] -} - function normalizeRelayList(urls: readonly string[]): string[] { return Array.from( new Set(urls.map((u) => normalizeUrl(u) || u.trim()).filter((u): u is string => u.length > 0)) @@ -90,6 +249,10 @@ export default function FullTextSearchByRelay({ const q = searchQuery.trim() const timeoutSec = Math.round(FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS / 1000) + const searchProfileResetKey = useMemo( + () => `${q}\n${normalizedRelays.join('\n')}`, + [q, normalizedRelays] + ) const doneRelayCount = relayRows.filter((r) => r.phase === 'done' || r.phase === 'error').length const errorRelayCount = relayRows.filter((r) => r.phase === 'error').length @@ -183,10 +346,8 @@ export default function FullTextSearchByRelay({ const sorted = [...raw] .sort((a, b) => compareEventsForDTagQuery(q, a, b)) .slice(0, FULL_TEXT_SEARCH_MAX_NOTES_PER_RELAY) - for (const e of sorted) { - client.addEventToCache(e, { explicitNoteLookupHexId: e.id }) - } - + await addSearchEventsToSessionCacheBatched(sorted, runGeneration, myRun) + if (myRun !== runGeneration.current) return const ms = Math.round(performance.now() - t0) if (sorted.length === 0 && connectionError) { logger.debug('[NIP-50 full-text] card_end', { @@ -310,8 +471,8 @@ export default function FullTextSearchByRelay({ } return ( -
-

+

+

{t('Full-text search merged intro', { relayCount: normalizedRelays.length, seconds: timeoutSec, @@ -332,42 +493,52 @@ export default function FullTextSearchByRelay({

)} -
- {anyLoading && mergedHits.length === 0 && ( -
- - - -
- )} - - {mergedHits.map((hit) => ( - - + +
+ {anyLoading && mergedHits.length === 0 && ( +
+ + + +
+ )} + + {mergedHits.map((hit) => ( +
- + {t('Full-text search seen on label')} - {hit.relayUrls.map((url) => ( - - - - ))} +
+ {hit.relayUrls.map((url) => ( + + + + ))} +
- - - - - - ))} -
+ + + ))} +
+ {allTerminal && mergedHits.length === 0 && (

diff --git a/src/lib/relay-strikes.ts b/src/lib/relay-strikes.ts index 5284b899..c9e28dbe 100644 --- a/src/lib/relay-strikes.ts +++ b/src/lib/relay-strikes.ts @@ -65,6 +65,8 @@ function sessionKey(url: string): string { class RelaySessionStrikes { private byKey = new Map() private cacheRelayKeys = new Set() + /** Throttle debug spam when many parallel REQs hit the same dead relay (cache rows bypass strike debounce). */ + private lastReadFailureDebugLogAt = new Map() setSessionCacheRelayKeysFromKind10432(ev: Event | null | undefined): void { this.cacheRelayKeys.clear() @@ -168,7 +170,11 @@ class RelaySessionStrikes { e.readStrikeSkipUntil = Math.max(e.readStrikeSkipUntil, now + STRIKE_COOLDOWN_MS) logger.info('[RelayStrikes] read path strike skip', { key, readFailures: e.readFailures }) } else { - logger.debug('[RelayStrikes] read failure counted', { key, readFailures: e.readFailures, cache }) + const lastDbg = this.lastReadFailureDebugLogAt.get(key) ?? 0 + if (now - lastDbg >= STRIKE_INCREMENT_DEBOUNCE_MS) { + this.lastReadFailureDebugLogAt.set(key, now) + logger.debug('[RelayStrikes] read failure counted', { key, readFailures: e.readFailures, cache }) + } } } @@ -180,6 +186,7 @@ class RelaySessionStrikes { e.readFailures = 0 e.readStrikeSkipUntil = 0 e.readLastStrikeIncrementAt = 0 + this.lastReadFailureDebugLogAt.delete(key) } recordPublishFailure(url: string): void { @@ -241,6 +248,7 @@ class RelaySessionStrikes { reset(): void { this.byKey.clear() this.cacheRelayKeys.clear() + this.lastReadFailureDebugLogAt.clear() } } diff --git a/src/providers/FeedProvider.tsx b/src/providers/FeedProvider.tsx index 6bc3d218..6cf25298 100644 --- a/src/providers/FeedProvider.tsx +++ b/src/providers/FeedProvider.tsx @@ -6,7 +6,7 @@ import logger from '@/lib/logger' import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' import { normalizeAnyRelayUrl } from '@/lib/url' import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' -import { useEffect, useMemo, useState, useCallback } from 'react' +import { useEffect, useMemo, useState, useCallback, useRef } from 'react' import type { Dispatch, ReactNode, SetStateAction } from 'react' import { FeedContext } from './feed-context' import { useFavoriteRelays } from './FavoriteRelaysProvider' @@ -117,6 +117,7 @@ export function FeedProvider({ children }: { children: ReactNode }) { [] ) + const lastHomeFeedUrlLogRef = useRef({ primary: '', reply: '' }) const updateFeedRelayUrls = useCallback(() => { const primaryRelays = buildAllFavoritesFeedRelayUrls(favoriteFeedRelayUrls, blockedRelays, primaryExtraRelayUrls) const aggrEligibleRelayUrls = [ @@ -133,10 +134,16 @@ export function FeedProvider({ children }: { children: ReactNode }) { relayListMentionsNostrLand(aggrEligibleRelayUrls), blockedRelays ) - logger.debug('Updating home feed relay URLs:', { - primaryRelays, - replyRelays - }) + const primaryId = relayUrlListIdentity(primaryRelays) + const replyId = relayUrlListIdentity(replyRelays) + const prevUrls = lastHomeFeedUrlLogRef.current + if (prevUrls.primary !== primaryId || prevUrls.reply !== replyId) { + lastHomeFeedUrlLogRef.current = { primary: primaryId, reply: replyId } + logger.debug('Updating home feed relay URLs:', { + primaryRelays, + replyRelays + }) + } setUrlStateIfChanged(setRelayUrls, primaryRelays) setUrlStateIfChanged(setReplyRelayUrls, replyRelays) }, [favoriteFeedRelayUrls, blockedRelays, primaryExtraRelayUrls, replyExtraRelayLayers, setUrlStateIfChanged]) @@ -173,20 +180,39 @@ export function FeedProvider({ children }: { children: ReactNode }) { .join('|'), [replyExtraRelayLayers] ) + const lastRelayInitDebugKey = useRef('') + const lastHadFavoriteRelaysRef = useRef(null) useEffect(() => { - logger.debug('FeedProvider relay init:', { - isInitialized, - favoriteRelays: favoriteRelays.length, - relaySets: relaySets.length, - relaySetRelays: favoriteFeedRelayUrls.length - favoriteRelays.length, - inboxRelays: replyExtraRelayLayers.inboxRelayUrls.length, - outboxRelays: replyExtraRelayLayers.outboxRelayUrls.length, - cacheRelays: replyExtraRelayLayers.cacheRelayUrls.length, - httpRelays: replyExtraRelayLayers.httpRelayUrls.length, - blockedRelays: blockedRelays.length - }) - - if (favoriteFeedRelayUrls.length === 0) { + const initKey = [ + isInitialized ? '1' : '0', + favoriteRelays.length, + relaySets.length, + favoriteFeedRelayUrls.length - favoriteRelays.length, + replyExtraRelayLayers.inboxRelayUrls.length, + replyExtraRelayLayers.outboxRelayUrls.length, + replyExtraRelayLayers.cacheRelayUrls.length, + replyExtraRelayLayers.httpRelayUrls.length, + blockedRelays.length + ].join('\x1e') + if (initKey !== lastRelayInitDebugKey.current) { + lastRelayInitDebugKey.current = initKey + logger.debug('FeedProvider relay init:', { + isInitialized, + favoriteRelays: favoriteRelays.length, + relaySets: relaySets.length, + relaySetRelays: favoriteFeedRelayUrls.length - favoriteRelays.length, + inboxRelays: replyExtraRelayLayers.inboxRelayUrls.length, + outboxRelays: replyExtraRelayLayers.outboxRelayUrls.length, + cacheRelays: replyExtraRelayLayers.cacheRelayUrls.length, + httpRelays: replyExtraRelayLayers.httpRelayUrls.length, + blockedRelays: blockedRelays.length + }) + } + + const hasFavoriteRelays = favoriteFeedRelayUrls.length > 0 + const prevHad = lastHadFavoriteRelaysRef.current + lastHadFavoriteRelaysRef.current = hasFavoriteRelays + if (!hasFavoriteRelays && prevHad !== false) { logger.debug('FeedProvider: no favorite or relay-set relays, using defaults') } diff --git a/src/providers/UserTrustProvider.tsx b/src/providers/UserTrustProvider.tsx index 8f7d6ee4..7a4fda8b 100644 --- a/src/providers/UserTrustProvider.tsx +++ b/src/providers/UserTrustProvider.tsx @@ -1,6 +1,8 @@ +import { getPubkeysFromPTags } from '@/lib/tag' import storage from '@/services/local-storage.service' import { replaceableEventService } from '@/services/client.service' import { UserTrustContext } from '@/contexts/user-trust-context' +import { kinds } from 'nostr-tools' import { type ReactNode, useCallback, useEffect, useState } from 'react' import { useNostr } from './NostrProvider' @@ -30,26 +32,30 @@ export function UserTrustProvider({ children }: { children: ReactNode }) { setIsTrustLoaded(false) const initWoT = async () => { - const followListEvent = await replaceableEventService.fetchReplaceableEvent(currentPubkey, kinds.Contacts) - const followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] - followings.forEach((pubkey) => wotSet.add(pubkey.toLowerCase())) + try { + const followListEvent = await replaceableEventService.fetchReplaceableEvent(currentPubkey, kinds.Contacts) + const followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] + followings.forEach((pubkey) => wotSet.add(pubkey.toLowerCase())) - const batchSize = 20 - for (let i = 0; i < followings.length; i += batchSize) { - const batch = followings.slice(i, i + batchSize) - await Promise.allSettled( - batch.map(async (pubkey) => { - const followListEvent = await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Contacts) - const _followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] - _followings.forEach((following) => { - wotSet.add(following.toLowerCase()) + const batchSize = 20 + for (let i = 0; i < followings.length; i += batchSize) { + const batch = followings.slice(i, i + batchSize) + await Promise.allSettled( + batch.map(async (pubkey) => { + const innerFollow = await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Contacts) + const _followings = innerFollow ? getPubkeysFromPTags(innerFollow.tags) : [] + _followings.forEach((following) => { + wotSet.add(following.toLowerCase()) + }) }) - }) - ) - await new Promise((resolve) => setTimeout(resolve, 200)) + ) + await new Promise((resolve) => setTimeout(resolve, 200)) + } + } finally { + setIsTrustLoaded(true) } } - initWoT() + void initWoT() }, [currentPubkey]) const isUserTrusted = useCallback( diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 8f237673..8cfd0cec 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -3941,7 +3941,7 @@ class ClientService extends EventTarget { const cacheKey = this.relayListRequestCacheKey(pubkey) const existingRequest = this.relayListRequestCache.get(cacheKey) if (existingRequest) { - logger.debug('[FetchRelayList] Using cached in-flight request', { pubkey }) + // Leader already logged `[FetchRelayList] Starting fetch`; joiners stay silent per burst. return existingRequest } diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 9f7e2281..fd4efeac 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -270,6 +270,28 @@ class IndexedDbService { private static readonly TOMBSTONE_NOT_CACHE_TTL_MS = 45_000 private static readonly TOMBSTONE_NOT_CACHE_MAX = 4096 + /** + * During bulk hydrates, `getReplaceableEvent` can run hundreds of times in a short window. + * One sample slot per completed lookup; first few per window log in full, then sample. + */ + private replaceableGetDebugWindow = { t0: 0, n: 0 } + private static readonly REPLACEABLE_GET_DEBUG_WINDOW_MS = 150 + private static readonly REPLACEABLE_GET_DEBUG_BURST_AFTER = 10 + private static readonly REPLACEABLE_GET_DEBUG_SAMPLE_EVERY = 24 + + private takeReplaceableGetDebugLogSlot(): boolean { + const now = Date.now() + const winMs = IndexedDbService.REPLACEABLE_GET_DEBUG_WINDOW_MS + if (now - this.replaceableGetDebugWindow.t0 > winMs) { + this.replaceableGetDebugWindow = { t0: now, n: 0 } + } + this.replaceableGetDebugWindow.n += 1 + const n = this.replaceableGetDebugWindow.n + const burstAfter = IndexedDbService.REPLACEABLE_GET_DEBUG_BURST_AFTER + const sampleEvery = IndexedDbService.REPLACEABLE_GET_DEBUG_SAMPLE_EVERY + return n <= burstAfter || n % sampleEvery === 0 + } + /** First TTL sweep after DB open (profile / relay list rows). */ private static readonly CLEANUP_INITIAL_DELAY_MS = 60 * 1000 /** Repeat TTL sweeps on this interval so pruning is not a one-shot. */ @@ -602,13 +624,16 @@ class IndexedDbService { const request = store.get(key) request.onsuccess = () => { + const allowDetailLog = this.takeReplaceableGetDebugLogSlot() const row = request.result as TValue | undefined if (!row) { - logger.debug('[IndexedDB] getReplaceableEvent - no row found', { - pubkey, - kind, - d - }) + if (allowDetailLog) { + logger.debug('[IndexedDB] getReplaceableEvent - no row found', { + pubkey, + kind, + d + }) + } transaction.commit() return resolve(undefined) } @@ -619,19 +644,23 @@ class IndexedDbService { if (isProfileOrPayment && row.addedAt && Date.now() - row.addedAt > PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS) { // Profile is stale, but return it anyway - refresh will happen in background // This prevents the "no profile" state when cache exists but is just old - logger.debug('[IndexedDB] Profile cache is stale but returning anyway', { + if (allowDetailLog) { + logger.debug('[IndexedDB] Profile cache is stale but returning anyway', { + pubkey, + age: Date.now() - row.addedAt, + maxAge: PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS, + eventId: row.value?.id + }) + } + } + if (allowDetailLog) { + logger.debug('[IndexedDB] getReplaceableEvent - found', { pubkey, - age: Date.now() - row.addedAt, - maxAge: PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS, - eventId: row.value?.id + kind, + eventId: row.value?.id, + addedAt: row.addedAt }) } - logger.debug('[IndexedDB] getReplaceableEvent - found', { - pubkey, - kind, - eventId: row.value?.id, - addedAt: row.addedAt - }) transaction.commit() resolve(row.value) }