Browse Source

bug-fix

imwald
Silberengel 1 month ago
parent
commit
271fd54c34
  1. 508
      src/components/Embedded/EmbeddedNote.tsx
  2. 2
      src/components/Note/LongFormCard.tsx
  3. 2
      src/components/Note/PublicationCard.tsx
  4. 2
      src/components/Note/WikiCard.tsx
  5. 2
      src/components/Note/Zap.tsx
  6. 7
      src/components/NoteCard/MainNoteCard.tsx
  7. 73
      src/components/NoteList/index.tsx
  8. 6
      src/components/Profile/ProfileHeaderInteractions.tsx
  9. 21
      src/components/ReplyNoteList/index.tsx
  10. 19
      src/lib/link.ts
  11. 27
      src/services/client-events.service.ts
  12. 39
      src/services/indexed-db.service.ts

508
src/components/Embedded/EmbeddedNote.tsx

@ -1,26 +1,33 @@ @@ -1,26 +1,33 @@
import { Skeleton } from '@/components/ui/skeleton'
import ExternalLink from '@/components/ExternalLink'
import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS, ExtendedKind } from '@/constants'
import {
FAST_READ_RELAY_URLS,
FAST_WRITE_RELAY_URLS,
PROFILE_RELAY_URLS,
SEARCHABLE_RELAY_URLS,
ExtendedKind
} from '@/constants'
import { getFavoritesFeedRelayUrls } from '@/lib/favorites-feed-relays'
import { LIVE_ACTIVITY_KINDS } from '@/lib/live-activities'
import { isRenderableNoteKind } from '@/lib/note-renderable-kinds'
import { useFetchEvent } from '@/hooks'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { normalizeUrl } from '@/lib/url'
import { cn } from '@/lib/utils'
import client from '@/services/client.service'
import client, { eventService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import nip66Service from '@/services/nip66.service'
import { navigationEventStore } from '@/services/navigation-event-store'
import { useFavoriteRelays } from '@/providers/favorite-relays-context'
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useReply } from '@/providers/ReplyProvider'
import { useTranslation } from 'react-i18next'
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { relayHintWssUrlsFromEvent } from '@/lib/event'
import { Event, nip19 } from 'nostr-tools'
import ClientSelect from '../ClientSelect'
import MainNoteCard from '../NoteCard/MainNoteCard'
import UnknownNote from '../Note/UnknownNote'
import { Button } from '../ui/button'
import { EmbeddedCalendarEvent } from './EmbeddedCalendarEvent'
import { Search } from 'lucide-react'
import logger from '@/lib/logger'
import { extractBookMetadata } from '@/lib/bookstr-parser'
import { contentParserService } from '@/services/content-parser.service'
@ -186,8 +193,8 @@ function SuppressedLiveStreamEmbed({ noteId, className }: { noteId: string; clas @@ -186,8 +193,8 @@ function SuppressedLiveStreamEmbed({ noteId, className }: { noteId: string; clas
}
/**
* Fetches and renders an embedded note. Split out so we never call {@link useFetchEvent} with `undefined`
* (skipping fetch for suppressed live naddrs is handled in the parent without that hook).
* Fetches and renders an embedded note: wide-relay REQ immediately in parallel with the normal
* fetch path (no try external gate embeds are always worth the index-relay fan-out).
*/
function EmbeddedNoteFetched({
noteId,
@ -202,19 +209,167 @@ function EmbeddedNoteFetched({ @@ -202,19 +209,167 @@ function EmbeddedNoteFetched({
showFull: boolean
allowLiveEmbeds: boolean
}) {
const relayHints = useMemo(
const { isEventDeleted } = useDeletedEvent()
const { addReplies } = useReply()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const [event, setEvent] = useState<Event | undefined>(undefined)
const [isFetching, setIsFetching] = useState(true)
const eventRef = useRef<Event | undefined>(undefined)
const retryIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
eventRef.current = event
const relayHintsFromParent = useMemo(
() => relayHintWssUrlsFromEvent(containingEvent),
[containingEvent?.id]
)
const menuRelayUrls = useMemo(
() =>
getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)
.map((url) => normalizeUrl(url))
.filter((url): url is string => Boolean(url)),
[favoriteRelays, blockedRelays]
)
const wideRelaysStatic = useMemo(
() => buildEmbedWideRelayUrlsStatic(menuRelayUrls, relayHintsFromParent),
[menuRelayUrls, relayHintsFromParent]
)
const fetchRelayOpts = useMemo(
() => (relayHints.length > 0 ? { relayHints } : undefined),
[relayHints]
() => (relayHintsFromParent.length > 0 ? { relayHints: relayHintsFromParent } : undefined),
[relayHintsFromParent]
)
const { event, isFetching } = useFetchEvent(noteId, undefined, fetchRelayOpts)
/** Filled when “Try external relays” / IndexedDB recovery finds the event after the hook missed. */
const [resolvedEvent, setResolvedEvent] = useState<Event | undefined>(undefined)
const finalEvent = event || resolvedEvent
const resolveAndSet = useCallback(
(ev: Event | undefined) => {
if (!ev || isEventDeleted(ev) || shouldDropEventOnIngest(ev)) return false
if (retryIntervalRef.current) {
clearInterval(retryIntervalRef.current)
retryIntervalRef.current = null
}
client.addEventToCache(ev)
setEvent(ev)
addReplies([ev])
return true
},
[addReplies, isEventDeleted]
)
/** Latest relay lists without listing them as effect deps (favorites context churn would re-run the effect and `setEvent(undefined)` wiped loaded embeds → loop / “crash”). */
const embedFetchCtxRef = useRef({
fetchRelayOpts: undefined as { relayHints?: string[] } | undefined,
wideRelaysStatic: [] as string[]
})
embedFetchCtxRef.current = { fetchRelayOpts, wideRelaysStatic }
const resolveAndSetRef = useRef(resolveAndSet)
resolveAndSetRef.current = resolveAndSet
const isEventDeletedRef = useRef(isEventDeleted)
isEventDeletedRef.current = isEventDeleted
const containingEventRef = useRef(containingEvent)
containingEventRef.current = containingEvent
useEffect(() => {
let cancelled = false
const noteKey = noteId.trim()
eventRef.current = undefined
setEvent(undefined)
setIsFetching(true)
const resolve = (ev: Event | undefined) => resolveAndSetRef.current(ev)
const tryShortcuts = (): boolean => {
const nav = navigationEventStore.getEvent(noteKey)
if (nav && resolve(nav)) return true
const peek = client.peekSessionCachedEvent(noteKey)
if (peek && resolve(peek)) return true
return false
}
const runWidePass = async (relayUrls: string[]) => {
if (!canSearchOnExternalRelays(noteKey) || relayUrls.length === 0) return undefined
const ev = await client.fetchEventWithExternalRelays(noteKey, relayUrls)
return ev
}
const runParallelFetch = async () => {
const { fetchRelayOpts: opts, wideRelaysStatic: wide0 } = embedFetchCtxRef.current
const hex = hexEventIdFromNoteId(noteKey)
const primary = client.fetchEvent(noteKey, opts)
const wide = runWidePass(wide0)
const idb =
hex && /^[0-9a-f]{64}$/i.test(hex)
? indexedDb.getEventFromPublicationStore(hex.toLowerCase()).catch(() => undefined)
: Promise.resolve(undefined)
const [p, w, db] = await Promise.all([primary, wide, idb])
if (cancelled) return
const chosen = pickUsableEvent([p, w, db], isEventDeletedRef.current)
if (chosen) {
resolve(chosen)
setIsFetching(false)
return
}
setIsFetching(false)
}
if (tryShortcuts()) {
setIsFetching(false)
} else {
void runParallelFetch()
}
void (async () => {
if (tryShortcuts() || eventRef.current) return
const extra = await loadAsyncEmbedRelayHints(noteKey, containingEventRef.current)
if (cancelled || eventRef.current) return
const wide0 = embedFetchCtxRef.current.wideRelaysStatic
const wideMerged = preferPublicIndexRelaysFirst(dedupeRelayUrls([...wide0, ...extra]))
const ev = await runWidePass(wideMerged)
if (cancelled || !ev) return
resolve(ev)
})()
if (eventRef.current) {
return () => {
cancelled = true
if (retryIntervalRef.current) {
clearInterval(retryIntervalRef.current)
retryIntervalRef.current = null
}
setIsFetching(false)
}
}
retryIntervalRef.current = setInterval(() => {
if (cancelled || eventRef.current) return
void (async () => {
const opts = embedFetchCtxRef.current.fetchRelayOpts
const ev = await client.fetchEventForceRetry(noteKey, opts)
if (!cancelled && ev) resolve(ev)
})()
}, 8000)
return () => {
cancelled = true
if (retryIntervalRef.current) {
clearInterval(retryIntervalRef.current)
retryIntervalRef.current = null
}
setIsFetching(false)
}
/** Only the embed pointer and parent note identity — not relay arrays / callbacks (avoids wipe+refetch loops). */
}, [noteId, containingEvent?.id])
useEffect(() => {
if (!noteId.trim() || event !== undefined) return undefined
const id = noteId.trim()
return eventService.subscribeWhenSessionHasEvent(id, () => {
const peek = client.peekSessionCachedEvent(id)
if (peek) resolveAndSetRef.current(peek)
})
}, [noteId, event])
const finalEvent = event
if (isFetching && !finalEvent) {
return <EmbeddedNoteSkeleton className={className} />
@ -222,12 +377,14 @@ function EmbeddedNoteFetched({ @@ -222,12 +377,14 @@ function EmbeddedNoteFetched({
if (!finalEvent) {
return (
<EmbeddedNoteNotFound
className={className}
noteId={noteId}
onEventFound={setResolvedEvent}
containingEvent={containingEvent}
/>
<div
className={cn('not-prose max-w-full text-left p-3 border rounded-lg border-dashed', className)}
onClick={(e) => e.stopPropagation()}
data-embedded-note-loading
>
<EmbeddedNoteSkeleton className="border-0 p-0 shadow-none" />
<ClientSelect className="w-full mt-3" originalNoteId={noteId.trim() || undefined} />
</div>
)
}
@ -347,53 +504,25 @@ function preferPublicIndexRelaysFirst(urls: readonly string[]): string[] { @@ -347,53 +504,25 @@ function preferPublicIndexRelaysFirst(urls: readonly string[]): string[] {
return [...urls].sort((a, b) => score(a) - score(b) || a.localeCompare(b))
}
function EmbeddedNoteSkeleton({ className }: { className?: string }) {
return (
<div
className={cn('not-prose max-w-full text-left p-2 sm:p-3 border rounded-lg', className)}
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center space-x-2">
<Skeleton className="w-9 h-9 rounded-full" />
<div>
<Skeleton className="h-3 w-16 my-1" />
<Skeleton className="h-3 w-16 my-1" />
</div>
</div>
<Skeleton className="w-full h-4 my-1 mt-2" />
<Skeleton className="w-2/3 h-4 my-1" />
</div>
/** Static + menu favorites: REQ immediately on embed mount (no NIP-65 round-trip first). */
function buildEmbedWideRelayUrlsStatic(menuRelayUrls: string[], relayHintsFromParent: string[]): string[] {
return preferPublicIndexRelaysFirst(
dedupeRelayUrls([
...relayHintsFromParent,
...nip66Service.getSearchableRelayUrls(),
...SEARCHABLE_RELAY_URLS,
...FAST_READ_RELAY_URLS,
...FAST_WRITE_RELAY_URLS,
...PROFILE_RELAY_URLS,
...menuRelayUrls,
])
)
}
function EmbeddedNoteNotFound({
noteId,
className,
onEventFound,
containingEvent
}: {
noteId: string
className?: string
onEventFound?: (event: Event) => void
containingEvent?: Event // Event that contains this embedded note - use its author's relays and relay hints
}) {
const { t } = useTranslation()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const menuRelayUrls = useMemo(
() =>
getFavoritesFeedRelayUrls(favoriteRelays, blockedRelays)
.map((url) => normalizeUrl(url))
.filter((url): url is string => Boolean(url)),
[favoriteRelays, blockedRelays]
)
const [isSearchingExternal, setIsSearchingExternal] = useState(false)
const [triedExternal, setTriedExternal] = useState(false)
const [asyncHintRelays, setAsyncHintRelays] = useState<string[]>([])
const [externalSearchDetail, setExternalSearchDetail] = useState<
null | 'unparseable' | 'no_relays' | 'searched'
>(null)
const resolvedHexId = useMemo(() => {
/** NIP-65 / nevent relays / seen-on — merged into a second wide REQ if the first pass missed. */
async function loadAsyncEmbedRelayHints(noteId: string, containingEvent?: Event): Promise<string[]> {
const hintRelays: string[] = []
const resolvedHexId = (() => {
const h = hexEventIdFromNoteId(noteId)
if (h) return h
try {
@ -401,53 +530,22 @@ function EmbeddedNoteNotFound({ @@ -401,53 +530,22 @@ function EmbeddedNoteNotFound({
if (type === 'nevent') return data.id
if (type === 'note') return data
} catch {
/* plain hex handled above */
return null
}
return null
}, [noteId])
/** Always available immediately: static searchable + fast-read + favorites + NIP-66 search-capable relays. */
const coreExternalRelays = useMemo(
() =>
preferPublicIndexRelaysFirst(
dedupeRelayUrls([
...nip66Service.getSearchableRelayUrls(),
...SEARCHABLE_RELAY_URLS,
...FAST_READ_RELAY_URLS,
...menuRelayUrls,
])
),
[menuRelayUrls]
)
const externalRelays = useMemo(
() => preferPublicIndexRelaysFirst(dedupeRelayUrls([...asyncHintRelays, ...coreExternalRelays])),
[asyncHintRelays, coreExternalRelays]
)
// Extra hints (parent tags, NIP-65, nevent/naddr relay lists, “seen on”) — merged on top of {@link coreExternalRelays}.
useEffect(() => {
let cancelled = false
const loadHints = async () => {
const hintRelays: string[] = []
})()
if (containingEvent) {
for (const tag of containingEvent.tags) {
if (['e', 'a', 'q'].includes(tag[0]) && tag.length > 2 && typeof tag[2] === 'string') {
const hint = tag[2]
if (hint.startsWith('wss://') || hint.startsWith('ws://')) hintRelays.push(hint)
}
}
try {
const containingAuthorRelayList = await client
.fetchRelayList(containingEvent.pubkey)
.catch(() => ({ read: [] as string[], write: [] as string[] }))
hintRelays.push(
...(containingAuthorRelayList.read ?? []).slice(0, 10),
...(containingAuthorRelayList.write ?? []).slice(0, 10)
...(containingAuthorRelayList.read ?? []).slice(0, 12),
...(containingAuthorRelayList.write ?? []).slice(0, 12)
)
} catch (err) {
logger.debug('Failed to fetch containing event author relays', { error: err })
logger.debug('[EmbeddedNote] containing author relays failed', { error: err })
}
}
@ -460,8 +558,8 @@ function EmbeddedNoteNotFound({ @@ -460,8 +558,8 @@ function EmbeddedNoteNotFound({
.fetchRelayList(data.author)
.catch(() => ({ read: [] as string[], write: [] as string[] }))
hintRelays.push(
...(authorRelayList.read ?? []).slice(0, 10),
...(authorRelayList.write ?? []).slice(0, 10)
...(authorRelayList.read ?? []).slice(0, 12),
...(authorRelayList.write ?? []).slice(0, 12)
)
}
} else if (type === 'naddr') {
@ -470,189 +568,46 @@ function EmbeddedNoteNotFound({ @@ -470,189 +568,46 @@ function EmbeddedNoteNotFound({
.fetchRelayList(data.pubkey)
.catch(() => ({ read: [] as string[], write: [] as string[] }))
hintRelays.push(
...(authorRelayList.read ?? []).slice(0, 10),
...(authorRelayList.write ?? []).slice(0, 10)
...(authorRelayList.read ?? []).slice(0, 12),
...(authorRelayList.write ?? []).slice(0, 12)
)
}
} catch {
/* invalid bech32 */
/* invalid */
}
const seenOn = resolvedHexId ? client.getSeenEventRelayUrls(resolvedHexId) : []
hintRelays.push(...seenOn)
if (!cancelled) {
setAsyncHintRelays(dedupeRelayUrls(hintRelays))
logger.debug('External relay hints merged', {
noteId,
hintCount: hintRelays.length,
totalRelays: dedupeRelayUrls([...hintRelays, ...coreExternalRelays]).length
})
}
}
void loadHints()
return () => {
cancelled = true
}
}, [noteId, containingEvent?.id, resolvedHexId, coreExternalRelays])
const handleTryExternalRelays = async () => {
if (isSearchingExternal) return
if (!canSearchOnExternalRelays(noteId)) {
logger.warn('External relay search skipped: unsupported note id', { noteId })
setExternalSearchDetail('unparseable')
setTriedExternal(true)
return
}
if (externalRelays.length === 0) {
logger.warn('No external relays to search', { noteId })
setExternalSearchDetail('no_relays')
setTriedExternal(true)
return
}
setIsSearchingExternal(true)
setExternalSearchDetail(null)
let found: Event | undefined
try {
const idHex = resolvedHexId ?? hexEventIdFromNoteId(noteId)
if (idHex) {
const fromDb = await indexedDb.getEventFromPublicationStore(idHex)
if (fromDb) {
client.addEventToCache(fromDb)
found = fromDb
onEventFound?.(fromDb)
logger.info('Event found in IndexedDB (try-harder)', { noteId })
}
}
if (!found) {
const retried = await client.fetchEventForceRetry(noteId)
if (retried) {
found = retried
onEventFound?.(retried)
logger.info('Event found after fetchEventForceRetry', { noteId })
}
}
if (!found) {
const idLog = idHex ?? noteId.slice(0, 16)
logger.info('Searching external relays', {
noteId,
hexOrHint: idLog,
relayCount: externalRelays.length,
relays: externalRelays.slice(0, 5)
})
const event = await client.fetchEventWithExternalRelays(noteId, externalRelays)
if (event) {
logger.info('Event found on external relay', { noteId })
found = event
client.addEventToCache(event)
onEventFound?.(event)
}
if (resolvedHexId) {
hintRelays.push(...client.getSeenEventRelayUrls(resolvedHexId))
}
return dedupeRelayUrls(hintRelays)
}
if (!found) {
logger.info('Event not found on external relays', {
noteId,
relayCount: externalRelays.length
})
setExternalSearchDetail('searched')
}
} catch (error) {
logger.error('External relay fetch failed', { error, noteId, externalRelays })
setExternalSearchDetail('searched')
} finally {
setIsSearchingExternal(false)
if (!found) {
setTriedExternal(true)
function pickUsableEvent(
candidates: (Event | undefined)[],
isEventDeleted: (e: Event) => boolean
): Event | undefined {
for (const e of candidates) {
if (!e || isEventDeleted(e) || shouldDropEventOnIngest(e)) continue
return e
}
}
}
const hasExternalRelays = externalRelays.length > 0
const showExternalTryButton =
!triedExternal && hasExternalRelays && canSearchOnExternalRelays(noteId)
return undefined
}
function EmbeddedNoteSkeleton({ className }: { className?: string }) {
return (
<div className={cn('not-prose max-w-full text-left p-3 border rounded-lg', className)}>
<div className="flex flex-col items-center text-muted-foreground gap-3">
<div className="text-sm font-medium">{t('Note not found')}</div>
{showExternalTryButton && (
<div className="flex flex-col items-center gap-2 w-full">
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation()
e.preventDefault()
void handleTryExternalRelays()
}}
disabled={isSearchingExternal}
className="gap-2 w-full"
<div
className={cn('not-prose max-w-full text-left p-2 sm:p-3 border rounded-lg', className)}
onClick={(e) => e.stopPropagation()}
>
{isSearchingExternal ? (
<>
<Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />
{t('Searching...')}
</>
) : (
<>
<Search className="w-4 h-4" />
{t('Try external relays')} ({externalRelays.length})
</>
)}
</Button>
<details className="text-xs text-muted-foreground w-full">
<summary className="cursor-pointer hover:text-foreground text-center list-none">
{t('Show relays')}
</summary>
<div className="mt-2 space-y-1 max-h-24 overflow-y-auto">
{externalRelays.map((relay, i) => (
<div key={i} className="font-mono text-[10px] truncate px-2 py-0.5 bg-muted/50 rounded">
{relay}
</div>
))}
</div>
</details>
</div>
)}
{!triedExternal && !hasExternalRelays && (
<div className="text-xs text-center">{t('No external relay hints available')}</div>
)}
{!triedExternal && hasExternalRelays && !canSearchOnExternalRelays(noteId) && (
<div className="text-xs text-center text-muted-foreground">
{t('External relay search is not available for this link type')}
</div>
)}
{triedExternal && externalSearchDetail === 'unparseable' && (
<div className="text-xs text-center">{t('External relay search is not available for this link type')}</div>
)}
{triedExternal && externalSearchDetail === 'no_relays' && (
<div className="text-xs text-center">{t('No external relay hints available')}</div>
)}
{triedExternal && externalSearchDetail === 'searched' && (
<div className="text-xs text-center">
{t('Searched external relays not found', { count: externalRelays.length })}
<div className="flex items-center space-x-2">
<Skeleton className="w-9 h-9 rounded-full" />
<div>
<Skeleton className="h-3 w-16 my-1" />
<Skeleton className="h-3 w-16 my-1" />
</div>
)}
{triedExternal && !externalSearchDetail && (
<div className="text-xs text-center">{t('Note could not be found anywhere')}</div>
)}
<ClientSelect className="w-full" originalNoteId={noteId} />
</div>
<Skeleton className="w-full h-4 my-1 mt-2" />
<Skeleton className="w-2/3 h-4 my-1" />
</div>
)
}
@ -703,7 +658,12 @@ function EmbeddedBookstrEvent({ event, originalNoteId, className }: { event: Eve @@ -703,7 +658,12 @@ function EmbeddedBookstrEvent({ event, originalNoteId, className }: { event: Eve
return
}
e.stopPropagation()
const noteUrl = toNote(originalNoteId ?? event)
const noteUrl = toNote(
originalNoteId ?? event,
typeof originalNoteId === 'string' && /^[0-9a-f]{64}$/i.test(originalNoteId.trim())
? event
: undefined
)
navigateToNote(noteUrl, event, getCachedThreadContextEvents(event))
}}
>

2
src/components/Note/LongFormCard.tsx

@ -42,7 +42,7 @@ export default function LongFormCard({ @@ -42,7 +42,7 @@ export default function LongFormCard({
const handleCardClick = (e: React.MouseEvent) => {
if (!interactive) return
e.stopPropagation()
push(toNote(event.id))
push(toNote(event))
}
const titleComponent = (

2
src/components/Note/PublicationCard.tsx

@ -32,7 +32,7 @@ export default function PublicationCard({ @@ -32,7 +32,7 @@ export default function PublicationCard({
const handleCardClick = (e: React.MouseEvent) => {
e.stopPropagation()
push(toNote(event.id))
push(toNote(event))
}
const titleComponent = metadata.title ? <div className="text-xl font-semibold break-words min-w-0 sm:line-clamp-2">{metadata.title}</div> : null

2
src/components/Note/WikiCard.tsx

@ -27,7 +27,7 @@ export default function WikiCard({ @@ -27,7 +27,7 @@ export default function WikiCard({
const handleCardClick = (e: React.MouseEvent) => {
e.stopPropagation()
push(toNote(event.id))
push(toNote(event))
}
const titleComponent = <div className="text-xl font-semibold break-words min-w-0 sm:line-clamp-2">{metadata.title}</div>

2
src/components/Note/Zap.tsx

@ -80,7 +80,7 @@ export default function Zap({ @@ -80,7 +80,7 @@ export default function Zap({
e.stopPropagation()
if (isEventZap) {
if (targetEvent) {
navigateToNote(toNote(targetEvent.id), targetEvent)
navigateToNote(toNote(targetEvent), targetEvent)
} else if (zapInfo.eventId) {
navigateToNote(toNote(zapInfo.eventId))
}

7
src/components/NoteCard/MainNoteCard.tsx

@ -72,7 +72,12 @@ export default function MainNoteCard({ @@ -72,7 +72,12 @@ export default function MainNoteCard({
}
e.stopPropagation()
client.addEventToCache(event)
const noteUrl = toNote(originalNoteId ?? event)
const noteUrl = toNote(
originalNoteId ?? event,
typeof originalNoteId === 'string' && /^[0-9a-f]{64}$/i.test(originalNoteId.trim())
? event
: undefined
)
navigateToNote(noteUrl, event, getCachedThreadContextEvents(event))
}}
>

73
src/components/NoteList/index.tsx

@ -235,6 +235,56 @@ function feedTimelineAlreadyRepresentsNip18Target(targetId: string | undefined, @@ -235,6 +235,56 @@ function feedTimelineAlreadyRepresentsNip18Target(targetId: string | undefined,
return false
}
/**
* `mergeEventBatchesById` only dedupes by event id; multiple kind-6/16 reposts of the same target stay
* separate. Collapse to one timeline row per target (first row in array order wins live merges are
* newest-first). Dropped rows still update `noteStatsService` for boosted by aggregation, same as
* `onNew` / `showNewEvents`.
*/
function collapseDuplicateNip18RepostTimelineRows(sortedNewestFirst: Event[]): Event[] {
const kept: Event[] = []
const statsOnly: Event[] = []
for (const e of sortedNewestFirst) {
if (isNip18RepostKind(e.kind)) {
const t = getNip18RepostTargetId(e)
if (t && feedTimelineAlreadyRepresentsNip18Target(t, kept)) {
statsOnly.push(e)
continue
}
kept.push(e)
continue
}
const idKey = normalizeFeedRepostTargetKey(e.id)
const coveredByRepost = kept.some((k) => {
if (!isNip18RepostKind(k.kind)) return false
const rt = getNip18RepostTargetId(k)
return Boolean(rt && normalizeFeedRepostTargetKey(rt) === idKey)
})
if (coveredByRepost) {
statsOnly.push(e)
continue
}
if (isReplaceableEvent(e.kind)) {
const coord = getReplaceableCoordinateFromEvent(e)
const coordNorm = normalizeFeedRepostTargetKey(coord)
const coveredByCoordRepost = kept.some((k) => {
if (!isNip18RepostKind(k.kind)) return false
const rt = getNip18RepostTargetId(k)
return Boolean(rt && normalizeFeedRepostTargetKey(rt) === coordNorm)
})
if (coveredByCoordRepost) {
statsOnly.push(e)
continue
}
}
kept.push(e)
}
if (statsOnly.length > 0) {
noteStatsService.updateNoteStatsByEvents(statsOnly, undefined)
}
return kept
}
const FEED_PROFILE_PREFETCH_MAX_P_TAGS = 64
const FEED_STATS_PROFILE_REPOSTS_CAP = 48
const FEED_STATS_PROFILE_LIKES_PER_NOTE = 8
@ -1801,8 +1851,9 @@ const NoteList = forwardRef( @@ -1801,8 +1851,9 @@ const NoteList = forwardRef(
if (!keepExistingTimelineEvents) {
if (restoredFromSession && sessionSnap) {
feedPaintSessionPendingRef.current = true
setEvents(sessionSnap)
lastEventsForTimelinePrefetchRef.current = sessionSnap
const restored = collapseDuplicateNip18RepostTimelineRows(sessionSnap)
setEvents(restored)
lastEventsForTimelinePrefetchRef.current = restored
setNewEvents([])
setShowCount(revealBatchSize ?? SHOW_COUNT)
setLoading(!!oneShotFetch)
@ -1949,6 +2000,7 @@ const NoteList = forwardRef( @@ -1949,6 +2000,7 @@ const NoteList = forwardRef(
if (mergeCmp) {
next = [...next].sort(mergeCmp)
}
next = collapseDuplicateNip18RepostTimelineRows(next)
lastEventsForTimelinePrefetchRef.current = next
return next
})
@ -1979,8 +2031,9 @@ const NoteList = forwardRef( @@ -1979,8 +2031,9 @@ const NoteList = forwardRef(
: {})
})
}
setEvents(merged)
lastEventsForTimelinePrefetchRef.current = merged
const collapsed = collapseDuplicateNip18RepostTimelineRows(merged)
setEvents(collapsed)
lastEventsForTimelinePrefetchRef.current = collapsed
}
if (oneShotDebugLabel && isProgressiveLayers) {
const f0 = mappedSubRequests[0]?.filter
@ -2170,7 +2223,9 @@ const NoteList = forwardRef( @@ -2170,7 +2223,9 @@ const NoteList = forwardRef(
narrowed,
oneShotAfterMergeComparatorRef.current
)
: mergeEventBatchesById(prev, narrowed, eventCap, areAlgoRelays)
: collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(prev, narrowed, eventCap, areAlgoRelays)
)
lastEventsForTimelinePrefetchRef.current = next
return next
})
@ -2535,7 +2590,9 @@ const NoteList = forwardRef( @@ -2535,7 +2590,9 @@ const NoteList = forwardRef(
if (batch.length > 0) {
if (narrowed.length > 0) {
setEvents((prev) => {
const next = mergeEventBatchesById(prev, narrowed, eventCapDelta, areAlgoRelays)
const next = collapseDuplicateNip18RepostTimelineRows(
mergeEventBatchesById(prev, narrowed, eventCapDelta, areAlgoRelays)
)
lastEventsForTimelinePrefetchRef.current = next
return next
})
@ -3009,7 +3066,9 @@ const NoteList = forwardRef( @@ -3009,7 +3066,9 @@ const NoteList = forwardRef(
consecutiveEmptyRef.current = 0
setEvents((oldEvents) => [...oldEvents, ...toAppend])
setEvents((oldEvents) =>
collapseDuplicateNip18RepostTimelineRows([...oldEvents, ...toAppend])
)
// After appending, the bottom sentinel may have moved below the fold. Re-check after
// paint: if it's still in/near view, trigger loadMore again so user doesn't have to scroll.

6
src/components/Profile/ProfileHeaderInteractions.tsx

@ -103,7 +103,7 @@ function CommentBadge({ event }: { event: Event }) { @@ -103,7 +103,7 @@ function CommentBadge({ event }: { event: Event }) {
<button
type="button"
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/80 border cursor-pointer text-left min-w-0 w-full"
onClick={() => push(toNote(event.id))}
onClick={() => push(toNote(event))}
>
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" />
<MessageCircle className="size-3 shrink-0 text-primary" aria-hidden />
@ -125,7 +125,7 @@ function ReportBadge({ event }: { event: Event }) { @@ -125,7 +125,7 @@ function ReportBadge({ event }: { event: Event }) {
<button
type="button"
className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-muted/80 border border-destructive/25 hover:bg-muted cursor-pointer text-left min-w-0 w-full"
onClick={() => push(toNote(event.id))}
onClick={() => push(toNote(event))}
title={summary}
>
<UserAvatar userId={event.pubkey} size="tiny" className="shrink-0" />
@ -143,7 +143,7 @@ function FollowPackBadge({ pack }: { pack: TProfileFollowPack }) { @@ -143,7 +143,7 @@ function FollowPackBadge({ pack }: { pack: TProfileFollowPack }) {
<button
type="button"
className="flex flex-col gap-1 px-2 py-1.5 rounded-md bg-muted/80 border hover:bg-muted cursor-pointer text-left min-w-0 w-full"
onClick={() => push(toNote(pack.event.id))}
onClick={() => push(toNote(pack.event))}
title={pack.title}
>
<div className="flex min-w-0 items-center gap-1.5">

21
src/components/ReplyNoteList/index.tsx

@ -1166,12 +1166,29 @@ function ReplyNoteList({ @@ -1166,12 +1166,29 @@ function ReplyNoteList({
limit: LIMIT
}
)
// Many clients tag only `#e` with the published snapshot id (not `#a`). Mirror the E-root
// filters so kind-1 threads and op-reference kinds are not missed on longform/wiki URLs.
if (/^[0-9a-f]{64}$/i.test(rootInfo.eventId)) {
const eSnap = rootInfo.eventId.trim().toLowerCase()
filters.push({
'#e': [rootInfo.eventId],
'#e': [eSnap],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap],
limit: LIMIT
})
filters.push({
'#E': [eSnap],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap],
limit: LIMIT
})
filters.push({
'#e': [eSnap],
kinds: [kinds.Reaction],
limit: LIMIT
})
for (const chunk of opRefChunks) {
filters.push({ '#e': [eSnap], kinds: chunk, limit: LIMIT })
filters.push({ '#E': [eSnap], kinds: chunk, limit: LIMIT })
}
}
const qVals = Array.from(
new Set(
@ -1564,7 +1581,7 @@ function ReplyNoteList({ @@ -1564,7 +1581,7 @@ function ReplyNoteList({
highlightReply(parentEventHexId)
}}
onClickReply={belongsToSameThread ? (replyEvent) => {
const replyNoteUrl = toNote(replyEvent.id)
const replyNoteUrl = toNote(replyEvent)
window.history.pushState(null, '', replyNoteUrl)
const replyIndex = mergedFeed.findIndex((r) => r.id === replyEvent.id)
if (replyIndex >= 0 && replyIndex >= showCount) {

19
src/lib/link.ts

@ -1,9 +1,22 @@ @@ -1,9 +1,22 @@
import { Event, nip19 } from 'nostr-tools'
import { getNoteBech32Id } from './event'
import { getNoteBech32Id, isReplaceableEvent } from './event'
import { TSearchParams } from '@/types'
export const toNote = (eventOrId: Event | string) => {
if (typeof eventOrId === 'string') return `/notes/${eventOrId}`
/**
* Note URL path segment. When `eventOrId` is a 64-char hex id and `hexResolutionEvent` is a loaded
* replaceable/addressable event for that note, use its naddr/nevent so links stay canonical.
*/
export const toNote = (eventOrId: Event | string, hexResolutionEvent?: Event) => {
if (typeof eventOrId === 'string') {
if (
hexResolutionEvent &&
/^[0-9a-f]{64}$/i.test(eventOrId.trim()) &&
isReplaceableEvent(hexResolutionEvent.kind)
) {
return `/notes/${getNoteBech32Id(hexResolutionEvent)}`
}
return `/notes/${eventOrId}`
}
const nevent = getNoteBech32Id(eventOrId)
return `/notes/${nevent}`
}

27
src/services/client-events.service.ts

@ -159,7 +159,10 @@ export class EventService { @@ -159,7 +159,10 @@ export class EventService {
private notifySessionEventWaiters(hexId: string): void {
const waiters = this.sessionEventWaiters.get(hexId)
if (!waiters?.size) return
for (const cb of [...waiters]) {
/** Snapshot + remove before invoking: callbacks often call {@link addEventToCache} again (e.g. embed → addReplies), which would re-enter and stack-overflow otherwise. */
const snapshot = [...waiters]
this.sessionEventWaiters.delete(hexId)
for (const cb of snapshot) {
try {
cb()
} catch (e) {
@ -174,7 +177,9 @@ export class EventService { @@ -174,7 +177,9 @@ export class EventService {
const key = `${ev.kind}:${ev.pubkey.toLowerCase()}:${dTag}`
const waiters = this.sessionReplaceableWaiters.get(key)
if (!waiters?.size) return
for (const cb of [...waiters]) {
const snapshot = [...waiters]
this.sessionReplaceableWaiters.delete(key)
for (const cb of snapshot) {
try {
cb()
} catch (e) {
@ -217,6 +222,8 @@ export class EventService { @@ -217,6 +222,8 @@ export class EventService {
if (hex) {
if (this.getSessionEventIfAllowed(hex)) {
queueMicrotask(() => callback())
/** Already in cache: do not register a waiter — the next {@link addEventToCache} would notify again and double-fire embeds / useFetchEvent. */
return () => {}
}
let set = this.sessionEventWaiters.get(hex)
@ -245,6 +252,7 @@ export class EventService { @@ -245,6 +252,7 @@ export class EventService {
})
) {
queueMicrotask(() => callback())
return () => {}
}
const key = `${data.kind}:${data.pubkey.toLowerCase()}:${identifier}`
let rset = this.sessionReplaceableWaiters.get(key)
@ -519,11 +527,22 @@ export class EventService { @@ -519,11 +527,22 @@ export class EventService {
cleanEvent.kind === ExtendedKind.PUBLICATION_CONTENT
) {
// Keep publication replaceables durable for profile/publication builder cache hits.
void indexedDb.putReplaceableEvent(cleanEvent as NEvent).catch((error) => {
void indexedDb.putReplaceableEvent(cleanEvent as NEvent).catch((error: unknown) => {
const err = error instanceof Error ? error : new Error(String(error))
const q = err.name === 'QuotaExceededError' || /quota|storage/i.test(err.message)
if (q) {
logger.debug('[EventService] Skipped publication IndexedDB persist (storage quota)', {
kind: cleanEvent.kind,
eventId: id
})
return
}
logger.warn('[EventService] Failed to persist publication event to IndexedDB', {
kind: cleanEvent.kind,
eventId: id,
error
errorMessage: err.message,
errorName: err.name,
error: err
})
})
}

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

@ -410,8 +410,12 @@ class IndexedDbService { @@ -410,8 +410,12 @@ class IndexedDbService {
: event.id
const isTombstoned = await this.isTombstoned(tombstoneKey)
if (isTombstoned) {
logger.debug('[IndexedDB] Skipping tombstoned event', { tombstoneKey, eventId: event.id })
return Promise.reject(new Error('Event is tombstoned'))
logger.debug('[IndexedDB] Skipping tombstoned replaceable (not persisted)', {
tombstoneKey,
eventId: event.id
})
// Optional cache: absence is expected — do not reject (callers would log false-positive failures).
return event
}
// Remove relayStatuses before storing (it's metadata for logging, not part of the event)
@ -525,24 +529,29 @@ class IndexedDbService { @@ -525,24 +529,29 @@ class IndexedDbService {
resolve(cleanEvent)
}
putRequest.onerror = (event) => {
putRequest.onerror = (ev) => {
const err = idbEventToError(ev)
logger.error('[IndexedDB] Error putting event!', {
storeName,
key,
error: event,
target: (event.target as any)?.error,
errorMessage: (event.target as any)?.error?.message
errorMessage: err.message,
errorName: err.name
})
logger.error('[IndexedDB] Error putting event', { storeName, key, error: event })
transaction.commit()
reject(event)
reject(err)
}
}
getRequest.onerror = (event) => {
logger.error('[IndexedDB] Error getting existing event', { storeName, key, error: event })
getRequest.onerror = (ev) => {
const err = idbEventToError(ev)
logger.error('[IndexedDB] Error getting existing event', {
storeName,
key,
errorMessage: err.message,
errorName: err.name
})
transaction.commit()
reject(event)
reject(err)
}
})
}
@ -1196,15 +1205,15 @@ class IndexedDbService { @@ -1196,15 +1205,15 @@ class IndexedDbService {
resolve(cleanEvent)
}
putRequest.onerror = (event) => {
putRequest.onerror = (ev) => {
transaction.commit()
reject(event)
reject(idbEventToError(ev))
}
}
getRequest.onerror = (event) => {
getRequest.onerror = (ev) => {
transaction.commit()
reject(event)
reject(idbEventToError(ev))
}
})
}

Loading…
Cancel
Save