Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
550434d156
  1. 7
      eslint.config.js
  2. 36
      src/PageManager.tsx
  3. 28
      src/components/Note/index.tsx
  4. 37
      src/components/NoteCard/MainNoteCard.tsx
  5. 16
      src/components/NoteCard/index.tsx
  6. 3
      src/components/RelayIcon/index.tsx
  7. 229
      src/components/SearchResult/FullTextSearchByRelay.tsx
  8. 8
      src/lib/relay-strikes.ts
  9. 30
      src/providers/FeedProvider.tsx
  10. 12
      src/providers/UserTrustProvider.tsx
  11. 2
      src/services/client.service.ts
  12. 29
      src/services/indexed-db.service.ts

7
eslint.config.js

@ -26,5 +26,12 @@ export default tseslint.config( @@ -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'
}
}
)

36
src/PageManager.tsx

@ -237,6 +237,15 @@ function mergePrimaryPageEntry( @@ -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( @@ -246,7 +255,11 @@ function renderActivePrimaryPageContent(
(primaryPages.length > 0 ? primaryPages[0] : undefined)
if (!entry) return null
try {
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,6 +977,11 @@ function MainContentArea({ @@ -964,6 +977,11 @@ function MainContentArea({
onPrimaryPanelRefresh: () => void
}) {
const [, forceUpdate] = useState(0)
const mainContentDebugRef = useRef({
currentPrimaryPage: '' as TPrimaryPageName,
pages: '',
noteView: false
})
// Listen for note page title updates
useEffect(() => {
@ -976,11 +994,25 @@ function MainContentArea({ @@ -976,11 +994,25 @@ function MainContentArea({
}
}, [])
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: !!primaryNoteView
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 (

28
src/components/Note/index.tsx

@ -119,11 +119,13 @@ function cacheEmbeddedRepostTarget(hostEvent: Event, targetEvent: Event) { @@ -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({ @@ -148,7 +150,7 @@ function StringifiedNostrEventPreviewCard({
userId={targetEvent.pubkey}
size="tiny"
className="mt-0.5 shrink-0"
deferRemoteAvatar={false}
deferRemoteAvatar={deferAuthorAvatar}
/>
<div className="min-w-0 flex-1">
<ContentPreview event={targetEvent} className="line-clamp-4" />
@ -164,7 +166,8 @@ function StringifiedNostrEventContent({ @@ -164,7 +166,8 @@ function StringifiedNostrEventContent({
className,
hideMetadata,
autoLoadMedia,
fullCalendarInvite
fullCalendarInvite,
deferAuthorAvatar = false
}: {
hostEvent: Event
match: StringifiedNostrEventMatch
@ -172,6 +175,7 @@ function StringifiedNostrEventContent({ @@ -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({ @@ -187,7 +191,11 @@ function StringifiedNostrEventContent({
fullCalendarInvite={fullCalendarInvite}
/>
) : null}
<StringifiedNostrEventPreviewCard hostEvent={hostEvent} targetEvent={match.event} />
<StringifiedNostrEventPreviewCard
hostEvent={hostEvent}
targetEvent={match.event}
deferAuthorAvatar={deferAuthorAvatar}
/>
</div>
)
}
@ -218,7 +226,8 @@ export default function Note({ @@ -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({ @@ -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({ @@ -317,6 +328,7 @@ export default function Note({
hideMetadata={hideMetadata}
autoLoadMedia={autoLoadMedia}
fullCalendarInvite={fullCalendarInvite}
deferAuthorAvatar={deferAuthorAvatar}
/>
)
}
@ -374,7 +386,7 @@ export default function Note({ @@ -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({ @@ -618,7 +630,7 @@ export default function Note({
userId={event.pubkey}
size={size === 'small' ? 'medium' : 'normal'}
maxFileSizeKb={showFull ? 2048 : 500}
deferRemoteAvatar={false}
deferRemoteAvatar={deferAuthorAvatar}
/>
<div className="flex min-w-0 flex-1 flex-nowrap items-center gap-2 overflow-hidden">
<Username
@ -670,7 +682,7 @@ export default function Note({ @@ -670,7 +682,7 @@ export default function Note({
userId={event.pubkey}
size={size === 'small' ? 'medium' : 'normal'}
maxFileSizeKb={showFull ? 2048 : 500}
deferRemoteAvatar={false}
deferRemoteAvatar={deferAuthorAvatar}
/>
<div className="flex-1 w-0">
<div className="flex gap-2 items-center">

37
src/components/NoteCard/MainNoteCard.tsx

@ -23,7 +23,10 @@ export default function MainNoteCard({ @@ -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({ @@ -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({ @@ -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 (
<div
@ -84,12 +96,12 @@ export default function MainNoteCard({ @@ -84,12 +96,12 @@ export default function MainNoteCard({
}}
>
<div
className={`clickable ${embedded ? 'not-prose p-2 sm:p-3 border rounded-lg' : 'py-3'}`}
className={`clickable ${embedded ? 'not-prose p-2 sm:p-3 border rounded-lg' : innerY}`}
style={embedded ? { position: 'relative', overflow: 'visible' } : undefined}
>
{pinned && !embedded && (
<div
className="flex items-center gap-1.5 px-4 pb-1 text-muted-foreground"
className={`flex items-center gap-1.5 ${notePadX} pb-1 text-muted-foreground`}
role="img"
aria-label={t('Pinned note')}
>
@ -97,10 +109,10 @@ export default function MainNoteCard({ @@ -97,10 +109,10 @@ export default function MainNoteCard({
</div>
)}
<Collapsible alwaysExpand={embedded || isCalendarNoteKind}>
<RepostDescription className={embedded ? '' : 'px-4'} reposter={reposter} />
<RepostDescription className={embedded ? '' : notePadX} reposter={reposter} />
<Note
className={embedded ? '' : 'px-4'}
size={embedded ? 'small' : 'normal'}
className={embedded ? '' : notePadX}
size={embedded || searchListPreview ? 'small' : 'normal'}
event={event}
embedded={embedded}
originalNoteId={originalNoteId}
@ -108,22 +120,23 @@ export default function MainNoteCard({ @@ -108,22 +120,23 @@ export default function MainNoteCard({
hideParentNotePreview={hideParentNotePreview}
zapPollVoteHighlightOption={zapPollVoteHighlightOption}
showFull={showFull}
deferAuthorAvatar={deferAuthorAvatar}
/>
</Collapsible>
{!embedded ? <NoteBoostBadges event={event} className="mt-2 px-4" /> : null}
{!embedded && !searchListPreview ? <NoteBoostBadges event={event} className={`mt-2 ${notePadX}`} /> : null}
{showNoteStatsRow ? (
<NoteStats
className={embedded ? 'mt-2 px-2 sm:px-3' : 'mt-3 px-4'}
className={embedded ? 'mt-2 px-2 sm:px-3' : `mt-3 ${notePadX}`}
event={event}
fetchIfNotExisting={true}
fetchIfNotExisting={fetchNoteStatsIfMissing}
displayTopZapsAndLikes={isZapFeedCard}
/>
) : null}
{!embedded && bottomNoteLabel ? (
<div className="px-4 pt-1 text-xs text-muted-foreground">{bottomNoteLabel}</div>
<div className={`${notePadX} pt-1 text-xs text-muted-foreground`}>{bottomNoteLabel}</div>
) : null}
</div>
{!embedded && <Separator />}
{!embedded && !searchListPreview ? <Separator /> : null}
</div>
)
}

16
src/components/NoteCard/index.tsx

@ -15,7 +15,10 @@ const NoteCard = memo(function NoteCard({ @@ -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({ @@ -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({ @@ -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({ @@ -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
)
})

3
src/components/RelayIcon/index.tsx

@ -2,7 +2,6 @@ import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' @@ -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({ @@ -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({ @@ -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
}

229
src/components/SearchResult/FullTextSearchByRelay.tsx

@ -1,17 +1,24 @@ @@ -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 @@ -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<string>()
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<string, TProfile>(),
pending: new Set<string>(),
version: 0
}))
const mergedHitsRef = useRef(mergedHits)
mergedHitsRef.current = mergedHits
const fetchAttemptedRef = useRef(new Set<string>())
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<NoteFeedProfileContextValue>(
() => ({
profiles: batch.profiles,
pendingPubkeys: batch.pending,
version: batch.version
}),
[batch.profiles, batch.pending, batch.version]
)
return <NoteFeedProfileContext.Provider value={ctxVal}>{children}</NoteFeedProfileContext.Provider>
}
/** 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<void> {
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<void>((resolve) => {
requestAnimationFrame(() => resolve())
})
}
}
type RelayFetchPhase = 'loading' | 'done' | 'error'
@ -33,11 +197,6 @@ type RelayFetchRow = { @@ -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({ @@ -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({ @@ -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({ @@ -310,8 +471,8 @@ export default function FullTextSearchByRelay({
}
return (
<div className="min-w-0 space-y-4" aria-busy={anyLoading}>
<p className="text-sm text-muted-foreground">
<div className="min-w-0 space-y-3" aria-busy={anyLoading}>
<p className="text-sm text-muted-foreground leading-snug">
{t('Full-text search merged intro', {
relayCount: normalizedRelays.length,
seconds: timeoutSec,
@ -332,42 +493,52 @@ export default function FullTextSearchByRelay({ @@ -332,42 +493,52 @@ export default function FullTextSearchByRelay({
</p>
)}
<div className="min-w-0 space-y-4">
<SearchMergedProfileProvider resetKey={searchProfileResetKey} mergedHits={mergedHits}>
<div className="min-w-0 space-y-2">
{anyLoading && mergedHits.length === 0 && (
<div className="space-y-3" aria-label={t('Full-text search relay querying')}>
<Skeleton className="h-24 w-full" />
<Skeleton className="h-24 w-full" />
<Skeleton className="h-20 w-full" />
<div className="space-y-2" aria-label={t('Full-text search relay querying')}>
<Skeleton className="h-16 w-full rounded-md" />
<Skeleton className="h-16 w-full rounded-md" />
<Skeleton className="h-14 w-full rounded-md" />
</div>
)}
{mergedHits.map((hit) => (
<Card key={hit.event.id} className="min-w-0 overflow-hidden">
<CardHeader className="pb-2 space-y-2 border-b bg-muted/30">
<article
key={hit.event.id}
className="min-w-0 overflow-hidden rounded-lg border border-border/60 bg-card/30 shadow-none transition-[border-color,box-shadow,background-color] duration-150 hover:border-border hover:bg-muted/15 hover:shadow-sm"
>
<div
className="flex flex-wrap items-center gap-1.5"
className="flex flex-wrap items-center gap-1 border-b border-border/40 px-2.5 py-1"
aria-label={t('Full-text search seen on relays')}
>
<span className="text-xs text-muted-foreground shrink-0 mr-1">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground/90 shrink-0">
{t('Full-text search seen on label')}
</span>
<div className="flex flex-wrap items-center gap-0.5">
{hit.relayUrls.map((url) => (
<span
key={`${hit.event.id}-${relayKey(url)}`}
title={relayHostForSubscribeLog(url)}
className="inline-flex shrink-0"
className="inline-flex shrink-0 opacity-90"
>
<RelayIcon url={url} skipRelayInfoFetch className="h-7 w-7 rounded-sm" iconSize={14} />
<RelayIcon url={url} skipRelayInfoFetch className="h-5 w-5 rounded-sm" iconSize={12} />
</span>
))}
</div>
</CardHeader>
<CardContent className="pt-4">
<NoteCard event={hit.event} className="w-full border-0 shadow-none p-0" filterMutedNotes />
</CardContent>
</Card>
</div>
<NoteCard
event={hit.event}
className="w-full border-0 bg-transparent shadow-none"
filterMutedNotes
fetchNoteStatsIfMissing={false}
deferAuthorAvatar
searchListPreview
/>
</article>
))}
</div>
</SearchMergedProfileProvider>
{allTerminal && mergedHits.length === 0 && (
<p className="text-sm text-muted-foreground" role="status">

8
src/lib/relay-strikes.ts

@ -65,6 +65,8 @@ function sessionKey(url: string): string { @@ -65,6 +65,8 @@ function sessionKey(url: string): string {
class RelaySessionStrikes {
private byKey = new Map<string, StrikeEntry>()
private cacheRelayKeys = new Set<string>()
/** Throttle debug spam when many parallel REQs hit the same dead relay (cache rows bypass strike debounce). */
private lastReadFailureDebugLogAt = new Map<string, number>()
setSessionCacheRelayKeysFromKind10432(ev: Event | null | undefined): void {
this.cacheRelayKeys.clear()
@ -168,9 +170,13 @@ class RelaySessionStrikes { @@ -168,9 +170,13 @@ class RelaySessionStrikes {
e.readStrikeSkipUntil = Math.max(e.readStrikeSkipUntil, now + STRIKE_COOLDOWN_MS)
logger.info('[RelayStrikes] read path strike skip', { key, readFailures: e.readFailures })
} else {
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 })
}
}
}
recordReadSuccess(url: string): void {
const key = sessionKey(url)
@ -180,6 +186,7 @@ class RelaySessionStrikes { @@ -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 { @@ -241,6 +248,7 @@ class RelaySessionStrikes {
reset(): void {
this.byKey.clear()
this.cacheRelayKeys.clear()
this.lastReadFailureDebugLogAt.clear()
}
}

30
src/providers/FeedProvider.tsx

@ -6,7 +6,7 @@ import logger from '@/lib/logger' @@ -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 }) { @@ -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 }) { @@ -133,10 +134,16 @@ export function FeedProvider({ children }: { children: ReactNode }) {
relayListMentionsNostrLand(aggrEligibleRelayUrls),
blockedRelays
)
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,7 +180,22 @@ export function FeedProvider({ children }: { children: ReactNode }) { @@ -173,7 +180,22 @@ export function FeedProvider({ children }: { children: ReactNode }) {
.join('|'),
[replyExtraRelayLayers]
)
const lastRelayInitDebugKey = useRef('')
const lastHadFavoriteRelaysRef = useRef<boolean | null>(null)
useEffect(() => {
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,
@ -185,8 +207,12 @@ export function FeedProvider({ children }: { children: ReactNode }) { @@ -185,8 +207,12 @@ export function FeedProvider({ children }: { children: ReactNode }) {
httpRelays: replyExtraRelayLayers.httpRelayUrls.length,
blockedRelays: blockedRelays.length
})
}
if (favoriteFeedRelayUrls.length === 0) {
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')
}

12
src/providers/UserTrustProvider.tsx

@ -1,6 +1,8 @@ @@ -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,6 +32,7 @@ export function UserTrustProvider({ children }: { children: ReactNode }) { @@ -30,6 +32,7 @@ export function UserTrustProvider({ children }: { children: ReactNode }) {
setIsTrustLoaded(false)
const initWoT = async () => {
try {
const followListEvent = await replaceableEventService.fetchReplaceableEvent(currentPubkey, kinds.Contacts)
const followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []
followings.forEach((pubkey) => wotSet.add(pubkey.toLowerCase()))
@ -39,8 +42,8 @@ export function UserTrustProvider({ children }: { children: ReactNode }) { @@ -39,8 +42,8 @@ export function UserTrustProvider({ children }: { children: ReactNode }) {
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) : []
const innerFollow = await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Contacts)
const _followings = innerFollow ? getPubkeysFromPTags(innerFollow.tags) : []
_followings.forEach((following) => {
wotSet.add(following.toLowerCase())
})
@ -48,8 +51,11 @@ export function UserTrustProvider({ children }: { children: ReactNode }) { @@ -48,8 +51,11 @@ export function UserTrustProvider({ children }: { children: ReactNode }) {
)
await new Promise((resolve) => setTimeout(resolve, 200))
}
} finally {
setIsTrustLoaded(true)
}
initWoT()
}
void initWoT()
}, [currentPubkey])
const isUserTrusted = useCallback(

2
src/services/client.service.ts

@ -3941,7 +3941,7 @@ class ClientService extends EventTarget { @@ -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
}

29
src/services/indexed-db.service.ts

@ -270,6 +270,28 @@ class IndexedDbService { @@ -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 { @@ -602,13 +624,16 @@ class IndexedDbService {
const request = store.get(key)
request.onsuccess = () => {
const allowDetailLog = this.takeReplaceableGetDebugLogSlot()
const row = request.result as TValue<Event> | undefined
if (!row) {
if (allowDetailLog) {
logger.debug('[IndexedDB] getReplaceableEvent - no row found', {
pubkey,
kind,
d
})
}
transaction.commit()
return resolve(undefined)
}
@ -619,6 +644,7 @@ class IndexedDbService { @@ -619,6 +644,7 @@ 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
if (allowDetailLog) {
logger.debug('[IndexedDB] Profile cache is stale but returning anyway', {
pubkey,
age: Date.now() - row.addedAt,
@ -626,12 +652,15 @@ class IndexedDbService { @@ -626,12 +652,15 @@ class IndexedDbService {
eventId: row.value?.id
})
}
}
if (allowDetailLog) {
logger.debug('[IndexedDB] getReplaceableEvent - found', {
pubkey,
kind,
eventId: row.value?.id,
addedAt: row.addedAt
})
}
transaction.commit()
resolve(row.value)
}

Loading…
Cancel
Save