Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
550434d156
  1. 7
      eslint.config.js
  2. 48
      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. 257
      src/components/SearchResult/FullTextSearchByRelay.tsx
  8. 10
      src/lib/relay-strikes.ts
  9. 62
      src/providers/FeedProvider.tsx
  10. 38
      src/providers/UserTrustProvider.tsx
  11. 2
      src/services/client.service.ts
  12. 59
      src/services/indexed-db.service.ts

7
eslint.config.js

@ -26,5 +26,12 @@ export default tseslint.config(
'react-hooks/exhaustive-deps': 'off', 'react-hooks/exhaustive-deps': 'off',
'@typescript-eslint/no-unused-vars': ['error', { 'argsIgnorePattern': '^_' }] '@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'
}
} }
) )

48
src/PageManager.tsx

@ -237,6 +237,15 @@ function mergePrimaryPageEntry(
return [...prev, { name: entry.name, element, props: entry.props }] 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( function renderActivePrimaryPageContent(
primaryPages: TPrimaryPageStateEntry[], primaryPages: TPrimaryPageStateEntry[],
currentPrimaryPage: TPrimaryPageName currentPrimaryPage: TPrimaryPageName
@ -246,7 +255,11 @@ function renderActivePrimaryPageContent(
(primaryPages.length > 0 ? primaryPages[0] : undefined) (primaryPages.length > 0 ? primaryPages[0] : undefined)
if (!entry) return null if (!entry) return null
try { 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 return entry.props ? applyPrimaryPageProps(entry.element, entry.props) : entry.element
} catch (error) { } catch (error) {
logger.error(`Error rendering ${entry.name} component:`, error) logger.error(`Error rendering ${entry.name} component:`, error)
@ -964,7 +977,12 @@ function MainContentArea({
onPrimaryPanelRefresh: () => void onPrimaryPanelRefresh: () => void
}) { }) {
const [, forceUpdate] = useState(0) const [, forceUpdate] = useState(0)
const mainContentDebugRef = useRef({
currentPrimaryPage: '' as TPrimaryPageName,
pages: '',
noteView: false
})
// Listen for note page title updates // Listen for note page title updates
useEffect(() => { useEffect(() => {
const handleTitleUpdate = () => { const handleTitleUpdate = () => {
@ -975,12 +993,26 @@ function MainContentArea({
window.removeEventListener('notePageTitleUpdated', handleTitleUpdate) window.removeEventListener('notePageTitleUpdated', handleTitleUpdate)
} }
}, []) }, [])
logger.debug('MainContentArea rendering:', { const pagesKey = primaryPages.map((p) => p.name).join(',')
currentPrimaryPage, const noteView = !!primaryNoteView
primaryPages: primaryPages.map(p => p.name), const prevDbg = mainContentDebugRef.current
primaryNoteView: !!primaryNoteView 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). // flex + min-h-0 + min-w-0 so primary pages get a real height in flex parents and can shrink horizontally (double-pane).
return ( return (

28
src/components/Note/index.tsx

@ -119,11 +119,13 @@ function cacheEmbeddedRepostTarget(hostEvent: Event, targetEvent: Event) {
function StringifiedNostrEventPreviewCard({ function StringifiedNostrEventPreviewCard({
hostEvent, hostEvent,
targetEvent, targetEvent,
className className,
deferAuthorAvatar = false
}: { }: {
hostEvent: Event hostEvent: Event
targetEvent: Event targetEvent: Event
className?: string className?: string
deferAuthorAvatar?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
@ -148,7 +150,7 @@ function StringifiedNostrEventPreviewCard({
userId={targetEvent.pubkey} userId={targetEvent.pubkey}
size="tiny" size="tiny"
className="mt-0.5 shrink-0" className="mt-0.5 shrink-0"
deferRemoteAvatar={false} deferRemoteAvatar={deferAuthorAvatar}
/> />
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<ContentPreview event={targetEvent} className="line-clamp-4" /> <ContentPreview event={targetEvent} className="line-clamp-4" />
@ -164,7 +166,8 @@ function StringifiedNostrEventContent({
className, className,
hideMetadata, hideMetadata,
autoLoadMedia, autoLoadMedia,
fullCalendarInvite fullCalendarInvite,
deferAuthorAvatar = false
}: { }: {
hostEvent: Event hostEvent: Event
match: StringifiedNostrEventMatch match: StringifiedNostrEventMatch
@ -172,6 +175,7 @@ function StringifiedNostrEventContent({
hideMetadata?: boolean hideMetadata?: boolean
autoLoadMedia: boolean autoLoadMedia: boolean
fullCalendarInvite?: { event: Event; naddr: string } fullCalendarInvite?: { event: Event; naddr: string }
deferAuthorAvatar?: boolean
}) { }) {
const textEvent = match.textBefore.trim() const textEvent = match.textBefore.trim()
? { ...hostEvent, content: match.textBefore } ? { ...hostEvent, content: match.textBefore }
@ -187,7 +191,11 @@ function StringifiedNostrEventContent({
fullCalendarInvite={fullCalendarInvite} fullCalendarInvite={fullCalendarInvite}
/> />
) : null} ) : null}
<StringifiedNostrEventPreviewCard hostEvent={hostEvent} targetEvent={match.event} /> <StringifiedNostrEventPreviewCard
hostEvent={hostEvent}
targetEvent={match.event}
deferAuthorAvatar={deferAuthorAvatar}
/>
</div> </div>
) )
} }
@ -218,7 +226,8 @@ export default function Note({
embedded, embedded,
fullCalendarInvite, fullCalendarInvite,
zapPollVoteHighlightOption, zapPollVoteHighlightOption,
nip84HighlightEvents nip84HighlightEvents,
deferAuthorAvatar = false
}: { }: {
event: Event event: Event
originalNoteId?: string originalNoteId?: string
@ -234,6 +243,8 @@ export default function Note({
zapPollVoteHighlightOption?: number zapPollVoteHighlightOption?: number
/** Kind-9802 events that cite this note; when spans match {@link displayEvent.content}, render green marks (note page OP). */ /** Kind-9802 events that cite this note; when spans match {@link displayEvent.content}, render green marks (note page OP). */
nip84HighlightEvents?: Event[] 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 { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigationOptional() const { navigateToNote } = useSmartNoteNavigationOptional()
@ -317,6 +328,7 @@ export default function Note({
hideMetadata={hideMetadata} hideMetadata={hideMetadata}
autoLoadMedia={autoLoadMedia} autoLoadMedia={autoLoadMedia}
fullCalendarInvite={fullCalendarInvite} 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 let content: React.ReactNode
@ -618,7 +630,7 @@ export default function Note({
userId={event.pubkey} userId={event.pubkey}
size={size === 'small' ? 'medium' : 'normal'} size={size === 'small' ? 'medium' : 'normal'}
maxFileSizeKb={showFull ? 2048 : 500} maxFileSizeKb={showFull ? 2048 : 500}
deferRemoteAvatar={false} deferRemoteAvatar={deferAuthorAvatar}
/> />
<div className="flex min-w-0 flex-1 flex-nowrap items-center gap-2 overflow-hidden"> <div className="flex min-w-0 flex-1 flex-nowrap items-center gap-2 overflow-hidden">
<Username <Username
@ -670,7 +682,7 @@ export default function Note({
userId={event.pubkey} userId={event.pubkey}
size={size === 'small' ? 'medium' : 'normal'} size={size === 'small' ? 'medium' : 'normal'}
maxFileSizeKb={showFull ? 2048 : 500} maxFileSizeKb={showFull ? 2048 : 500}
deferRemoteAvatar={false} deferRemoteAvatar={deferAuthorAvatar}
/> />
<div className="flex-1 w-0"> <div className="flex-1 w-0">
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">

37
src/components/NoteCard/MainNoteCard.tsx

@ -23,7 +23,10 @@ export default function MainNoteCard({
hideParentNotePreview = false, hideParentNotePreview = false,
zapPollVoteHighlightOption, zapPollVoteHighlightOption,
bottomNoteLabel, bottomNoteLabel,
showFull = false showFull = false,
fetchNoteStatsIfMissing = true,
deferAuthorAvatar = false,
searchListPreview = false
}: { }: {
event: Event event: Event
className?: string className?: string
@ -37,6 +40,12 @@ export default function MainNoteCard({
zapPollVoteHighlightOption?: number zapPollVoteHighlightOption?: number
bottomNoteLabel?: string bottomNoteLabel?: string
showFull?: boolean 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 { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigationOptional() const { navigateToNote } = useSmartNoteNavigationOptional()
@ -44,7 +53,10 @@ export default function MainNoteCard({
event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === ExtendedKind.ZAP_REQUEST 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. */ /** NIP-52 kinds 31922 / 31923: card-level {@link Collapsible} clips the stats row; description collapses inside the card. */
const isCalendarNoteKind = isNip52CalendarCardKind(event.kind) 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 ( return (
<div <div
@ -84,12 +96,12 @@ export default function MainNoteCard({
}} }}
> >
<div <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} style={embedded ? { position: 'relative', overflow: 'visible' } : undefined}
> >
{pinned && !embedded && ( {pinned && !embedded && (
<div <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" role="img"
aria-label={t('Pinned note')} aria-label={t('Pinned note')}
> >
@ -97,10 +109,10 @@ export default function MainNoteCard({
</div> </div>
)} )}
<Collapsible alwaysExpand={embedded || isCalendarNoteKind}> <Collapsible alwaysExpand={embedded || isCalendarNoteKind}>
<RepostDescription className={embedded ? '' : 'px-4'} reposter={reposter} /> <RepostDescription className={embedded ? '' : notePadX} reposter={reposter} />
<Note <Note
className={embedded ? '' : 'px-4'} className={embedded ? '' : notePadX}
size={embedded ? 'small' : 'normal'} size={embedded || searchListPreview ? 'small' : 'normal'}
event={event} event={event}
embedded={embedded} embedded={embedded}
originalNoteId={originalNoteId} originalNoteId={originalNoteId}
@ -108,22 +120,23 @@ export default function MainNoteCard({
hideParentNotePreview={hideParentNotePreview} hideParentNotePreview={hideParentNotePreview}
zapPollVoteHighlightOption={zapPollVoteHighlightOption} zapPollVoteHighlightOption={zapPollVoteHighlightOption}
showFull={showFull} showFull={showFull}
deferAuthorAvatar={deferAuthorAvatar}
/> />
</Collapsible> </Collapsible>
{!embedded ? <NoteBoostBadges event={event} className="mt-2 px-4" /> : null} {!embedded && !searchListPreview ? <NoteBoostBadges event={event} className={`mt-2 ${notePadX}`} /> : null}
{showNoteStatsRow ? ( {showNoteStatsRow ? (
<NoteStats <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} event={event}
fetchIfNotExisting={true} fetchIfNotExisting={fetchNoteStatsIfMissing}
displayTopZapsAndLikes={isZapFeedCard} displayTopZapsAndLikes={isZapFeedCard}
/> />
) : null} ) : null}
{!embedded && bottomNoteLabel ? ( {!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} ) : null}
</div> </div>
{!embedded && <Separator />} {!embedded && !searchListPreview ? <Separator /> : null}
</div> </div>
) )
} }

16
src/components/NoteCard/index.tsx

@ -15,7 +15,10 @@ const NoteCard = memo(function NoteCard({
pinned = false, pinned = false,
hideParentNotePreview = false, hideParentNotePreview = false,
zapPollVoteHighlightOption, zapPollVoteHighlightOption,
bottomNoteLabel bottomNoteLabel,
fetchNoteStatsIfMissing = true,
deferAuthorAvatar = false,
searchListPreview = false
}: { }: {
event: Event event: Event
className?: string className?: string
@ -26,6 +29,9 @@ const NoteCard = memo(function NoteCard({
zapPollVoteHighlightOption?: number zapPollVoteHighlightOption?: number
/** Optional label rendered at the bottom of the card (e.g. why this event is in a composed feed). */ /** Optional label rendered at the bottom of the card (e.g. why this event is in a composed feed). */
bottomNoteLabel?: string bottomNoteLabel?: string
fetchNoteStatsIfMissing?: boolean
deferAuthorAvatar?: boolean
searchListPreview?: boolean
}) { }) {
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
@ -59,6 +65,9 @@ const NoteCard = memo(function NoteCard({
hideParentNotePreview={hideParentNotePreview} hideParentNotePreview={hideParentNotePreview}
zapPollVoteHighlightOption={zapPollVoteHighlightOption} zapPollVoteHighlightOption={zapPollVoteHighlightOption}
bottomNoteLabel={bottomNoteLabel} bottomNoteLabel={bottomNoteLabel}
fetchNoteStatsIfMissing={fetchNoteStatsIfMissing}
deferAuthorAvatar={deferAuthorAvatar}
searchListPreview={searchListPreview}
/> />
) )
}, (prevProps, nextProps) => { }, (prevProps, nextProps) => {
@ -71,7 +80,10 @@ const NoteCard = memo(function NoteCard({
prevProps.pinned === nextProps.pinned && prevProps.pinned === nextProps.pinned &&
prevProps.hideParentNotePreview === nextProps.hideParentNotePreview && prevProps.hideParentNotePreview === nextProps.hideParentNotePreview &&
prevProps.zapPollVoteHighlightOption === nextProps.zapPollVoteHighlightOption && 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'
import { useFetchRelayInfo } from '@/hooks' import { useFetchRelayInfo } from '@/hooks'
import { getRelayIconOverrideSrc, relayUrlFingerprintColors } from '@/lib/relay-icon-source' import { getRelayIconOverrideSrc, relayUrlFingerprintColors } from '@/lib/relay-icon-source'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import logger from '@/lib/logger'
import { Server } from 'lucide-react' import { Server } from 'lucide-react'
import { useMemo } from 'react' import { useMemo } from 'react'
@ -48,7 +47,6 @@ export default function RelayIcon({
const override = getRelayIconOverrideSrc(url) const override = getRelayIconOverrideSrc(url)
if (override) { if (override) {
logger.debug('[RelayIcon] using override icon', { url, override })
return override return override
} }
@ -56,7 +54,6 @@ export default function RelayIcon({
const rawIcon = relayInfo?.icon && typeof relayInfo.icon === 'string' ? relayInfo.icon : undefined const rawIcon = relayInfo?.icon && typeof relayInfo.icon === 'string' ? relayInfo.icon : undefined
const nip11Icon = rawIcon ? resolveRelayImageUrl(rawIcon, url) : undefined const nip11Icon = rawIcon ? resolveRelayImageUrl(rawIcon, url) : undefined
if (nip11Icon) { if (nip11Icon) {
logger.debug('[RelayIcon] using NIP-11 icon', { url, rawIcon, nip11Icon })
return nip11Icon return nip11Icon
} }

257
src/components/SearchResult/FullTextSearchByRelay.tsx

@ -1,17 +1,24 @@
import NoteCard from '@/components/NoteCard' import NoteCard from '@/components/NoteCard'
import RelayIcon from '@/components/RelayIcon' import RelayIcon from '@/components/RelayIcon'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { compareEventsForDTagQuery } from '@/lib/dtag-search' import { compareEventsForDTagQuery } from '@/lib/dtag-search'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import { NoteFeedProfileContext, type NoteFeedProfileContextValue } from '@/providers/NoteFeedProfileContext'
import client from '@/services/client.service' import client from '@/services/client.service'
import { relayHostForSubscribeLog } from '@/services/relay-operation-log.service' import { relayHostForSubscribeLog } from '@/services/relay-operation-log.service'
import type { TProfile } from '@/types'
import type { Event, Filter } from 'nostr-tools' import type { Event, Filter } from 'nostr-tools'
import { Loader2 } from 'lucide-react' 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' 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). */ /** 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 const FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS = 45_000
/** Avoid opening every index relay at once (pool + main thread); still completes all relays. */ /** 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 const FULL_TEXT_SEARCH_MAX_NOTES_PER_RELAY = 40
/** Max merged unique notes shown after deduping across relays. */ /** Max merged unique notes shown after deduping across relays. */
const FULL_TEXT_SEARCH_MAX_MERGED_EVENTS = 150 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' type RelayFetchPhase = 'loading' | 'done' | 'error'
@ -33,11 +197,6 @@ type RelayFetchRow = {
errorMessage?: string errorMessage?: string
} }
type MergedHit = {
event: Event
relayUrls: string[]
}
function normalizeRelayList(urls: readonly string[]): string[] { function normalizeRelayList(urls: readonly string[]): string[] {
return Array.from( return Array.from(
new Set(urls.map((u) => normalizeUrl(u) || u.trim()).filter((u): u is string => u.length > 0)) 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 q = searchQuery.trim()
const timeoutSec = Math.round(FULL_TEXT_SEARCH_PER_RELAY_TIMEOUT_MS / 1000) 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 doneRelayCount = relayRows.filter((r) => r.phase === 'done' || r.phase === 'error').length
const errorRelayCount = relayRows.filter((r) => r.phase === 'error').length const errorRelayCount = relayRows.filter((r) => r.phase === 'error').length
@ -183,10 +346,8 @@ export default function FullTextSearchByRelay({
const sorted = [...raw] const sorted = [...raw]
.sort((a, b) => compareEventsForDTagQuery(q, a, b)) .sort((a, b) => compareEventsForDTagQuery(q, a, b))
.slice(0, FULL_TEXT_SEARCH_MAX_NOTES_PER_RELAY) .slice(0, FULL_TEXT_SEARCH_MAX_NOTES_PER_RELAY)
for (const e of sorted) { await addSearchEventsToSessionCacheBatched(sorted, runGeneration, myRun)
client.addEventToCache(e, { explicitNoteLookupHexId: e.id }) if (myRun !== runGeneration.current) return
}
const ms = Math.round(performance.now() - t0) const ms = Math.round(performance.now() - t0)
if (sorted.length === 0 && connectionError) { if (sorted.length === 0 && connectionError) {
logger.debug('[NIP-50 full-text] card_end', { logger.debug('[NIP-50 full-text] card_end', {
@ -310,8 +471,8 @@ export default function FullTextSearchByRelay({
} }
return ( return (
<div className="min-w-0 space-y-4" aria-busy={anyLoading}> <div className="min-w-0 space-y-3" aria-busy={anyLoading}>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground leading-snug">
{t('Full-text search merged intro', { {t('Full-text search merged intro', {
relayCount: normalizedRelays.length, relayCount: normalizedRelays.length,
seconds: timeoutSec, seconds: timeoutSec,
@ -332,42 +493,52 @@ export default function FullTextSearchByRelay({
</p> </p>
)} )}
<div className="min-w-0 space-y-4"> <SearchMergedProfileProvider resetKey={searchProfileResetKey} mergedHits={mergedHits}>
{anyLoading && mergedHits.length === 0 && ( <div className="min-w-0 space-y-2">
<div className="space-y-3" aria-label={t('Full-text search relay querying')}> {anyLoading && mergedHits.length === 0 && (
<Skeleton className="h-24 w-full" /> <div className="space-y-2" aria-label={t('Full-text search relay querying')}>
<Skeleton className="h-24 w-full" /> <Skeleton className="h-16 w-full rounded-md" />
<Skeleton className="h-20 w-full" /> <Skeleton className="h-16 w-full rounded-md" />
</div> <Skeleton className="h-14 w-full rounded-md" />
)} </div>
)}
{mergedHits.map((hit) => (
<Card key={hit.event.id} className="min-w-0 overflow-hidden"> {mergedHits.map((hit) => (
<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 <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')} 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')} {t('Full-text search seen on label')}
</span> </span>
{hit.relayUrls.map((url) => ( <div className="flex flex-wrap items-center gap-0.5">
<span {hit.relayUrls.map((url) => (
key={`${hit.event.id}-${relayKey(url)}`} <span
title={relayHostForSubscribeLog(url)} key={`${hit.event.id}-${relayKey(url)}`}
className="inline-flex shrink-0" title={relayHostForSubscribeLog(url)}
> className="inline-flex shrink-0 opacity-90"
<RelayIcon url={url} skipRelayInfoFetch className="h-7 w-7 rounded-sm" iconSize={14} /> >
</span> <RelayIcon url={url} skipRelayInfoFetch className="h-5 w-5 rounded-sm" iconSize={12} />
))} </span>
))}
</div>
</div> </div>
</CardHeader> <NoteCard
<CardContent className="pt-4"> event={hit.event}
<NoteCard event={hit.event} className="w-full border-0 shadow-none p-0" filterMutedNotes /> className="w-full border-0 bg-transparent shadow-none"
</CardContent> filterMutedNotes
</Card> fetchNoteStatsIfMissing={false}
))} deferAuthorAvatar
</div> searchListPreview
/>
</article>
))}
</div>
</SearchMergedProfileProvider>
{allTerminal && mergedHits.length === 0 && ( {allTerminal && mergedHits.length === 0 && (
<p className="text-sm text-muted-foreground" role="status"> <p className="text-sm text-muted-foreground" role="status">

10
src/lib/relay-strikes.ts

@ -65,6 +65,8 @@ function sessionKey(url: string): string {
class RelaySessionStrikes { class RelaySessionStrikes {
private byKey = new Map<string, StrikeEntry>() private byKey = new Map<string, StrikeEntry>()
private cacheRelayKeys = new Set<string>() 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 { setSessionCacheRelayKeysFromKind10432(ev: Event | null | undefined): void {
this.cacheRelayKeys.clear() this.cacheRelayKeys.clear()
@ -168,7 +170,11 @@ class RelaySessionStrikes {
e.readStrikeSkipUntil = Math.max(e.readStrikeSkipUntil, now + STRIKE_COOLDOWN_MS) e.readStrikeSkipUntil = Math.max(e.readStrikeSkipUntil, now + STRIKE_COOLDOWN_MS)
logger.info('[RelayStrikes] read path strike skip', { key, readFailures: e.readFailures }) logger.info('[RelayStrikes] read path strike skip', { key, readFailures: e.readFailures })
} else { } 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.readFailures = 0
e.readStrikeSkipUntil = 0 e.readStrikeSkipUntil = 0
e.readLastStrikeIncrementAt = 0 e.readLastStrikeIncrementAt = 0
this.lastReadFailureDebugLogAt.delete(key)
} }
recordPublishFailure(url: string): void { recordPublishFailure(url: string): void {
@ -241,6 +248,7 @@ class RelaySessionStrikes {
reset(): void { reset(): void {
this.byKey.clear() this.byKey.clear()
this.cacheRelayKeys.clear() this.cacheRelayKeys.clear()
this.lastReadFailureDebugLogAt.clear()
} }
} }

62
src/providers/FeedProvider.tsx

@ -6,7 +6,7 @@ import logger from '@/lib/logger'
import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr' import { AGGR_NOSTR_LAND_WSS } from '@/lib/nostr-land-aggr'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { buildWispTrendingNotesRelayUrl } from '@/lib/wisp-trending-relay' 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 type { Dispatch, ReactNode, SetStateAction } from 'react'
import { FeedContext } from './feed-context' import { FeedContext } from './feed-context'
import { useFavoriteRelays } from './FavoriteRelaysProvider' import { useFavoriteRelays } from './FavoriteRelaysProvider'
@ -117,6 +117,7 @@ export function FeedProvider({ children }: { children: ReactNode }) {
[] []
) )
const lastHomeFeedUrlLogRef = useRef({ primary: '', reply: '' })
const updateFeedRelayUrls = useCallback(() => { const updateFeedRelayUrls = useCallback(() => {
const primaryRelays = buildAllFavoritesFeedRelayUrls(favoriteFeedRelayUrls, blockedRelays, primaryExtraRelayUrls) const primaryRelays = buildAllFavoritesFeedRelayUrls(favoriteFeedRelayUrls, blockedRelays, primaryExtraRelayUrls)
const aggrEligibleRelayUrls = [ const aggrEligibleRelayUrls = [
@ -133,10 +134,16 @@ export function FeedProvider({ children }: { children: ReactNode }) {
relayListMentionsNostrLand(aggrEligibleRelayUrls), relayListMentionsNostrLand(aggrEligibleRelayUrls),
blockedRelays blockedRelays
) )
logger.debug('Updating home feed relay URLs:', { const primaryId = relayUrlListIdentity(primaryRelays)
primaryRelays, const replyId = relayUrlListIdentity(replyRelays)
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(setRelayUrls, primaryRelays)
setUrlStateIfChanged(setReplyRelayUrls, replyRelays) setUrlStateIfChanged(setReplyRelayUrls, replyRelays)
}, [favoriteFeedRelayUrls, blockedRelays, primaryExtraRelayUrls, replyExtraRelayLayers, setUrlStateIfChanged]) }, [favoriteFeedRelayUrls, blockedRelays, primaryExtraRelayUrls, replyExtraRelayLayers, setUrlStateIfChanged])
@ -173,20 +180,39 @@ export function FeedProvider({ children }: { children: ReactNode }) {
.join('|'), .join('|'),
[replyExtraRelayLayers] [replyExtraRelayLayers]
) )
const lastRelayInitDebugKey = useRef('')
const lastHadFavoriteRelaysRef = useRef<boolean | null>(null)
useEffect(() => { useEffect(() => {
logger.debug('FeedProvider relay init:', { const initKey = [
isInitialized, isInitialized ? '1' : '0',
favoriteRelays: favoriteRelays.length, favoriteRelays.length,
relaySets: relaySets.length, relaySets.length,
relaySetRelays: favoriteFeedRelayUrls.length - favoriteRelays.length, favoriteFeedRelayUrls.length - favoriteRelays.length,
inboxRelays: replyExtraRelayLayers.inboxRelayUrls.length, replyExtraRelayLayers.inboxRelayUrls.length,
outboxRelays: replyExtraRelayLayers.outboxRelayUrls.length, replyExtraRelayLayers.outboxRelayUrls.length,
cacheRelays: replyExtraRelayLayers.cacheRelayUrls.length, replyExtraRelayLayers.cacheRelayUrls.length,
httpRelays: replyExtraRelayLayers.httpRelayUrls.length, replyExtraRelayLayers.httpRelayUrls.length,
blockedRelays: blockedRelays.length blockedRelays.length
}) ].join('\x1e')
if (initKey !== lastRelayInitDebugKey.current) {
if (favoriteFeedRelayUrls.length === 0) { 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') logger.debug('FeedProvider: no favorite or relay-set relays, using defaults')
} }

38
src/providers/UserTrustProvider.tsx

@ -1,6 +1,8 @@
import { getPubkeysFromPTags } from '@/lib/tag'
import storage from '@/services/local-storage.service' import storage from '@/services/local-storage.service'
import { replaceableEventService } from '@/services/client.service' import { replaceableEventService } from '@/services/client.service'
import { UserTrustContext } from '@/contexts/user-trust-context' import { UserTrustContext } from '@/contexts/user-trust-context'
import { kinds } from 'nostr-tools'
import { type ReactNode, useCallback, useEffect, useState } from 'react' import { type ReactNode, useCallback, useEffect, useState } from 'react'
import { useNostr } from './NostrProvider' import { useNostr } from './NostrProvider'
@ -30,26 +32,30 @@ export function UserTrustProvider({ children }: { children: ReactNode }) {
setIsTrustLoaded(false) setIsTrustLoaded(false)
const initWoT = async () => { const initWoT = async () => {
const followListEvent = await replaceableEventService.fetchReplaceableEvent(currentPubkey, kinds.Contacts) try {
const followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] const followListEvent = await replaceableEventService.fetchReplaceableEvent(currentPubkey, kinds.Contacts)
followings.forEach((pubkey) => wotSet.add(pubkey.toLowerCase())) const followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : []
followings.forEach((pubkey) => wotSet.add(pubkey.toLowerCase()))
const batchSize = 20 const batchSize = 20
for (let i = 0; i < followings.length; i += batchSize) { for (let i = 0; i < followings.length; i += batchSize) {
const batch = followings.slice(i, i + batchSize) const batch = followings.slice(i, i + batchSize)
await Promise.allSettled( await Promise.allSettled(
batch.map(async (pubkey) => { batch.map(async (pubkey) => {
const followListEvent = await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Contacts) const innerFollow = await replaceableEventService.fetchReplaceableEvent(pubkey, kinds.Contacts)
const _followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] const _followings = innerFollow ? getPubkeysFromPTags(innerFollow.tags) : []
_followings.forEach((following) => { _followings.forEach((following) => {
wotSet.add(following.toLowerCase()) 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]) }, [currentPubkey])
const isUserTrusted = useCallback( const isUserTrusted = useCallback(

2
src/services/client.service.ts

@ -3941,7 +3941,7 @@ class ClientService extends EventTarget {
const cacheKey = this.relayListRequestCacheKey(pubkey) const cacheKey = this.relayListRequestCacheKey(pubkey)
const existingRequest = this.relayListRequestCache.get(cacheKey) const existingRequest = this.relayListRequestCache.get(cacheKey)
if (existingRequest) { if (existingRequest) {
logger.debug('[FetchRelayList] Using cached in-flight request', { pubkey }) // Leader already logged `[FetchRelayList] Starting fetch`; joiners stay silent per burst.
return existingRequest return existingRequest
} }

59
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_TTL_MS = 45_000
private static readonly TOMBSTONE_NOT_CACHE_MAX = 4096 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). */ /** First TTL sweep after DB open (profile / relay list rows). */
private static readonly CLEANUP_INITIAL_DELAY_MS = 60 * 1000 private static readonly CLEANUP_INITIAL_DELAY_MS = 60 * 1000
/** Repeat TTL sweeps on this interval so pruning is not a one-shot. */ /** Repeat TTL sweeps on this interval so pruning is not a one-shot. */
@ -602,13 +624,16 @@ class IndexedDbService {
const request = store.get(key) const request = store.get(key)
request.onsuccess = () => { request.onsuccess = () => {
const allowDetailLog = this.takeReplaceableGetDebugLogSlot()
const row = request.result as TValue<Event> | undefined const row = request.result as TValue<Event> | undefined
if (!row) { if (!row) {
logger.debug('[IndexedDB] getReplaceableEvent - no row found', { if (allowDetailLog) {
pubkey, logger.debug('[IndexedDB] getReplaceableEvent - no row found', {
kind, pubkey,
d kind,
}) d
})
}
transaction.commit() transaction.commit()
return resolve(undefined) return resolve(undefined)
} }
@ -619,19 +644,23 @@ class IndexedDbService {
if (isProfileOrPayment && row.addedAt && Date.now() - row.addedAt > PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS) { 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 // 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 // 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, pubkey,
age: Date.now() - row.addedAt, kind,
maxAge: PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS, eventId: row.value?.id,
eventId: row.value?.id addedAt: row.addedAt
}) })
} }
logger.debug('[IndexedDB] getReplaceableEvent - found', {
pubkey,
kind,
eventId: row.value?.id,
addedAt: row.addedAt
})
transaction.commit() transaction.commit()
resolve(row.value) resolve(row.value)
} }

Loading…
Cancel
Save