Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
0d7112362d
  1. 17
      src/PageManager.tsx
  2. 94
      src/components/ReplyNoteList/index.tsx
  3. 99
      src/components/ReplyNoteList/reply-list-utils.ts
  4. 44
      src/hooks/useFetchThreadContextEvent.tsx
  5. 17
      src/lib/navigation-related-events.ts
  6. 100
      src/lib/thread-context-local.ts
  7. 11
      src/lib/thread-interaction-req.test.ts
  8. 51
      src/lib/thread-interaction-req.ts
  9. 7
      src/services/client-events.service.ts

17
src/PageManager.tsx

@ -60,6 +60,7 @@ import {
isProfileDetailPathname isProfileDetailPathname
} from '@/lib/document-meta' } from '@/lib/document-meta'
import { normalizeUrl } from './lib/url' import { normalizeUrl } from './lib/url'
import { prefetchThreadContextForNavigation } from '@/lib/thread-context-local'
import modalManager from './services/modal-manager.service' import modalManager from './services/modal-manager.service'
import { decodeRssArticlePathSegment, encodeRssArticlePathSegment } from '@/lib/rss-article' import { decodeRssArticlePathSegment, encodeRssArticlePathSegment } from '@/lib/rss-article'
import { matchAppRoute } from './routes' import { matchAppRoute } from './routes'
@ -469,7 +470,7 @@ export function useSmartNoteNavigation() {
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { current: currentPrimaryPage } = usePrimaryPage() const { current: currentPrimaryPage } = usePrimaryPage()
const navigateToNote = (url: string, event?: Event, relatedEvents?: Event[]) => { const navigateToNote = async (url: string, event?: Event, relatedEvents?: Event[]) => {
// Extract noteId from URL (handles both /notes/{id} and /{context}/notes/{id}) // Extract noteId from URL (handles both /notes/{id} and /{context}/notes/{id})
const parsed = parseNoteUrl(url) const parsed = parseNoteUrl(url)
if (!parsed) { if (!parsed) {
@ -482,6 +483,12 @@ export function useSmartNoteNavigation() {
if (event) { if (event) {
navigationEventStore.setEvent(event, noteId) navigationEventStore.setEvent(event, noteId)
client.addEventToCache(event) client.addEventToCache(event)
await prefetchThreadContextForNavigation(event).then((prefetched) => {
for (const ev of prefetched) {
client.addEventToCache(ev)
navigationEventStore.setEvent(ev)
}
})
} }
// Pre-cache related events (parent, root) and nostr embeds so NotePage avoids skeletons. // Pre-cache related events (parent, root) and nostr embeds so NotePage avoids skeletons.
if (relatedEvents?.length) { if (relatedEvents?.length) {
@ -542,7 +549,7 @@ export function useSmartNoteNavigationOptional() {
const { isSmallScreen } = screenSize const { isSmallScreen } = screenSize
const { current: currentPrimaryPage } = primaryPage const { current: currentPrimaryPage } = primaryPage
const navigateToNote = (url: string, event?: Event, relatedEvents?: Event[]) => { const navigateToNote = async (url: string, event?: Event, relatedEvents?: Event[]) => {
const parsed = parseNoteUrl(url) const parsed = parseNoteUrl(url)
if (!parsed) { if (!parsed) {
logger.warn('navigateToNote (optional) ignored invalid note URL', { url }) logger.warn('navigateToNote (optional) ignored invalid note URL', { url })
@ -553,6 +560,12 @@ export function useSmartNoteNavigationOptional() {
if (event) { if (event) {
navigationEventStore.setEvent(event, noteId) navigationEventStore.setEvent(event, noteId)
client.addEventToCache(event) client.addEventToCache(event)
await prefetchThreadContextForNavigation(event).then((prefetched) => {
for (const ev of prefetched) {
client.addEventToCache(ev)
navigationEventStore.setEvent(ev)
}
})
} }
if (relatedEvents?.length) { if (relatedEvents?.length) {
for (const ev of relatedEvents) { for (const ev of relatedEvents) {

94
src/components/ReplyNoteList/index.tsx

@ -67,6 +67,7 @@ import {
backlinkRunSectionClass, backlinkRunSectionClass,
buildVisibleBacklinkRows, buildVisibleBacklinkRows,
EA_THREAD_TAIL_REFERENCE_KINDS, EA_THREAD_TAIL_REFERENCE_KINDS,
collectDisplayedThreadReplies,
fetchPaymentAttestationsForRecipient, fetchPaymentAttestationsForRecipient,
hydrateThreadRepliesFromStats, hydrateThreadRepliesFromStats,
isEaThreadTailBacklinkCandidate, isEaThreadTailBacklinkCandidate,
@ -75,7 +76,9 @@ import {
loadThreadRepliesFromLocalStores, loadThreadRepliesFromLocalStores,
mergeFetchedKind7ReactionsIntoRootNoteStats, mergeFetchedKind7ReactionsIntoRootNoteStats,
moveReportsToEndPreserveOrder, moveReportsToEndPreserveOrder,
openNoteHexId,
partitionAndSortBacklinkTail, partitionAndSortBacklinkTail,
replyIsInSubtreeBelowOpenNote,
replyFeedZapsFirst, replyFeedZapsFirst,
replyIdPresentInRepliesMap, replyIdPresentInRepliesMap,
replyMatchesThreadForList, replyMatchesThreadForList,
@ -138,32 +141,16 @@ function ReplyNoteList({
}, [duplicateWebPreviewCleanedUrlHints, rootInfo]) }, [duplicateWebPreviewCleanedUrlHints, rootInfo])
const replies: NEvent[] = useMemo(() => { const replies: NEvent[] = useMemo(() => {
const replyIdSet = new Set<string>() const replyEvents = collectDisplayedThreadReplies(
const replyEvents: NEvent[] = [] event,
const currentEventKey = isReplaceableEvent(event.kind) rootInfo,
? getReplaceableCoordinateFromEvent(event) repliesMap,
: /^[0-9a-f]{64}$/i.test(event.id) ? event.id.toLowerCase() : event.id isDiscussionRoot,
// For replaceable events, also check the event ID in case replies are stored there mutePubkeySet,
const eventIdKey = /^[0-9a-f]{64}$/i.test(event.id) ? event.id.toLowerCase() : event.id hideContentMentioningMutedUsers
let parentEventKeys = [currentEventKey] )
if (isReplaceableEvent(event.kind) && currentEventKey !== eventIdKey) { const replyIdSet = new Set(replyEvents.map((r) => r.id))
parentEventKeys.push(eventIdKey)
}
// Web article threads: kind 1111 replies use #i (URL) only — ReplyProvider keys them by canonical URL, not synthetic root id.
if (event.kind === ExtendedKind.RSS_THREAD_ROOT) {
const u = getArticleUrlFromCommentITags(event)
if (u) {
const canon = canonicalizeRssArticleUrl(u)
if (!parentEventKeys.includes(canon)) {
parentEventKeys = [canon, ...parentEventKeys]
}
}
}
const processedEventIds = new Set<string>() // Prevent infinite loops
let iterationCount = 0
const MAX_ITERATIONS = 10 // Prevent infinite loops
const threadWalkFromRepliesMap = new Map<string, NEvent>() const threadWalkFromRepliesMap = new Map<string, NEvent>()
for (const { events: bucket } of repliesMap.values()) { for (const { events: bucket } of repliesMap.values()) {
for (const e of bucket) { for (const e of bucket) {
@ -171,47 +158,6 @@ function ReplyNoteList({
} }
} }
while (parentEventKeys.length > 0 && iterationCount < MAX_ITERATIONS) {
iterationCount++
const events = parentEventKeys.flatMap((id) => repliesMap.get(id)?.events || [])
events.forEach((evt) => {
if (replyIdSet.has(evt.id)) return
if (isPollVoteKind(evt)) return
if (
shouldHideThreadResponseEvent(
evt,
mutePubkeySet,
hideContentMentioningMutedUsers
)
) {
return
}
if (
rootInfo &&
!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, threadWalkFromRepliesMap)
) {
return
}
replyIdSet.add(evt.id)
replyEvents.push(evt)
threadWalkFromRepliesMap.set(evt.id.toLowerCase(), evt)
})
// Include reactions (and every other kind) so BFS can find notes keyed under reaction / zap ids.
const newParentEventKeys = events
.map((evt) => evt.id)
.filter((id) => !processedEventIds.has(id))
newParentEventKeys.forEach((id) => processedEventIds.add(id))
parentEventKeys = newParentEventKeys
}
if (iterationCount >= MAX_ITERATIONS) {
logger.warn('ReplyNoteList: Maximum iterations reached, possible circular reference in replies')
}
const includeThreadReply = (evt: NEvent) => { const includeThreadReply = (evt: NEvent) => {
if (isPollVoteKind(evt)) return false if (isPollVoteKind(evt)) return false
if ( if (
@ -225,6 +171,15 @@ function ReplyNoteList({
) { ) {
return false return false
} }
const opHex = openNoteHexId(event)
if (
opHex &&
rootInfo?.type === 'E' &&
rootInfo.id.trim().toLowerCase() !== opHex &&
!replyIsInSubtreeBelowOpenNote(evt, opHex, threadWalkFromRepliesMap)
) {
return false
}
return true return true
} }
@ -236,7 +191,6 @@ function ReplyNoteList({
)) { )) {
replyIdSet.add(evt.id) replyIdSet.add(evt.id)
replyEvents.push(evt) replyEvents.push(evt)
threadWalkFromRepliesMap.set(evt.id.toLowerCase(), evt)
} }
const { superchats, rest: nonZaps } = partitionAttestedSuperchats(replyEvents, attestedPaymentIds) const { superchats, rest: nonZaps } = partitionAttestedSuperchats(replyEvents, attestedPaymentIds)
@ -638,7 +592,10 @@ function ReplyNoteList({
const init = async () => { const init = async () => {
// Session LRU (timeline / note-stats / prior panels): thread replies before relay round-trip // Session LRU (timeline / note-stats / prior panels): thread replies before relay round-trip
if (rootInfo.type === 'E' || rootInfo.type === 'A') { if (rootInfo.type === 'E' || rootInfo.type === 'A') {
const fromSession = eventService.getSessionThreadInteractionEvents(rootInfo) const fromSession = eventService.getSessionThreadInteractionEvents(
rootInfo,
openNoteHexId(event)
)
if (fromSession.length > 0) { if (fromSession.length > 0) {
addReplies(fromSession) addReplies(fromSession)
} }
@ -726,6 +683,7 @@ function ReplyNoteList({
const filters = buildThreadInteractionFilters({ const filters = buildThreadInteractionFilters({
root: rootInfo, root: rootInfo,
opEventKind: event.kind, opEventKind: event.kind,
opEventHexId: openNoteHexId(event),
limit: THREAD_REPLY_LIMIT limit: THREAD_REPLY_LIMIT
}) })

99
src/components/ReplyNoteList/reply-list-utils.ts

@ -1,7 +1,8 @@
import { ExtendedKind, NOTE_STATS_OP_REFERENCE_KINDS } from '@/constants' import { ExtendedKind, NOTE_STATS_OP_REFERENCE_KINDS } from '@/constants'
import { isNip56ReportEvent, kind1QuotesThreadRoot } from '@/lib/event' import { getParentEventHexId, isNip56ReportEvent, kind1QuotesThreadRoot } from '@/lib/event'
import { isSuperchatKind, replyFeedSuperchatsFirst } from '@/lib/superchat' import { isSuperchatKind, replyFeedSuperchatsFirst } from '@/lib/superchat'
import { eventReferencesThreadTarget } from '@/lib/op-reference-tags' import { eventReferencesThreadTarget } from '@/lib/op-reference-tags'
import type { TRepliesMap } from '@/lib/reply-index'
import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match'
import { isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' import { isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed'
import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter' import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter'
@ -24,6 +25,101 @@ export {
THREAD_PROFILE_CHUNK THREAD_PROFILE_CHUNK
} from './types' } from './types'
export function openNoteHexId(event: Pick<NEvent, 'id'>): string | undefined {
const id = event.id?.trim().toLowerCase()
return id && /^[0-9a-f]{64}$/.test(id) ? id : undefined
}
/** Whether `evt` is a direct or nested reply under the opened note (not a sibling branch). */
export function replyIsInSubtreeBelowOpenNote(
evt: NEvent,
opHexLower: string,
threadWalk: ReadonlyMap<string, NEvent>
): boolean {
let cur: string | undefined = getParentEventHexId(evt)?.toLowerCase()
if (!cur) return false
if (cur === opHexLower) return true
const seen = new Set<string>()
for (let hop = 0; hop < 14 && cur; hop++) {
if (seen.has(cur)) return false
seen.add(cur)
if (cur === opHexLower) return true
const parentEv: NEvent | undefined =
threadWalk.get(cur) ?? client.peekSessionCachedEvent(cur)
if (!parentEv) return false
cur = getParentEventHexId(parentEv)?.toLowerCase()
}
return false
}
function dedupeEventsFromRepliesMap(repliesMap: TRepliesMap): NEvent[] {
const byId = new Map<string, NEvent>()
for (const { events } of repliesMap.values()) {
for (const evt of events) {
byId.set(evt.id, evt)
}
}
return [...byId.values()]
}
/** Replies to show under “Antworten” for the opened note (direct + nested, not sibling branches). */
export function collectDisplayedThreadReplies(
opEvent: NEvent,
rootInfo: TRootInfo | undefined,
repliesMap: TRepliesMap,
isDiscussionRoot: boolean,
mutePubkeySet: Set<string>,
hideContentMentioningMutedUsers: boolean | undefined
): NEvent[] {
const threadWalk = new Map<string, NEvent>()
for (const evt of dedupeEventsFromRepliesMap(repliesMap)) {
threadWalk.set(evt.id.toLowerCase(), evt)
}
if (rootInfo?.type === 'I') {
const opHex = openNoteHexId(opEvent)
const out: NEvent[] = []
const seen = new Set<string>()
for (const evt of threadWalk.values()) {
if (seen.has(evt.id)) continue
if (isPollVoteKind(evt)) continue
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) continue
if (!isRssArticleUrlThreadInteraction(evt, rootInfo.id)) continue
if (
opHex &&
opEvent.kind !== ExtendedKind.RSS_THREAD_ROOT &&
!replyIsInSubtreeBelowOpenNote(evt, opHex, threadWalk)
) {
continue
}
seen.add(evt.id)
out.push(evt)
}
return out
}
const opHex = openNoteHexId(opEvent)
if (!opHex) return []
const isThreadRootView =
rootInfo?.type === 'E' && rootInfo.id.trim().toLowerCase() === opHex
const out: NEvent[] = []
const seen = new Set<string>()
for (const evt of threadWalk.values()) {
if (seen.has(evt.id)) continue
if (isPollVoteKind(evt)) continue
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) continue
if (rootInfo && !replyMatchesThreadForList(evt, opEvent, rootInfo, isDiscussionRoot, threadWalk)) {
continue
}
if (!isThreadRootView && !replyIsInSubtreeBelowOpenNote(evt, opHex, threadWalk)) continue
seen.add(evt.id)
out.push(evt)
}
return out
}
/** Session LRU + publication store + archive: paint thread replies before relay round-trip. */ /** Session LRU + publication store + archive: paint thread replies before relay round-trip. */
export async function loadThreadRepliesFromLocalStores( export async function loadThreadRepliesFromLocalStores(
rootInfo: TRootInfo, rootInfo: TRootInfo,
@ -35,6 +131,7 @@ export async function loadThreadRepliesFromLocalStores(
const filters = buildThreadInteractionFilters({ const filters = buildThreadInteractionFilters({
root: rootInfo, root: rootInfo,
opEventKind: opEvent.kind, opEventKind: opEvent.kind,
opEventHexId: openNoteHexId(opEvent),
limit: THREAD_REPLY_LIMIT limit: THREAD_REPLY_LIMIT
}) })
if (!filters.length) return [] if (!filters.length) return []

44
src/hooks/useFetchThreadContextEvent.tsx

@ -1,14 +1,14 @@
import { THREAD_CONTEXT_EVENT_FETCH_GLOBAL_TIMEOUT_MS } from '@/constants' import { THREAD_CONTEXT_EVENT_FETCH_GLOBAL_TIMEOUT_MS } from '@/constants'
import { getAggrAwareSearchRelayUrls } from '@/lib/nostr-land-relay-eligibility' import { getAggrAwareSearchRelayUrls } from '@/lib/nostr-land-relay-eligibility'
import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
import { resolveThreadContextEventFromLocalStores } from '@/lib/thread-context-local'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useIsEventDeleted } from '@/providers/DeletedEventProvider' import { useIsEventDeleted } from '@/providers/DeletedEventProvider'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { useReplyIngress } from '@/hooks/useReplyIngress' import { useReplyIngress } from '@/hooks/useReplyIngress'
import { getNoteBech32Id, getParentETag, getRootETag } from '@/lib/event' import { getParentETag, getRootETag } from '@/lib/event'
import { buildThreadContextFetchRelayUrls } from '@/lib/thread-context-relays' import { buildThreadContextFetchRelayUrls } from '@/lib/thread-context-relays'
import client, { eventService } from '@/services/client.service' import client, { eventService } from '@/services/client.service'
import { navigationEventStore } from '@/services/navigation-event-store'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
@ -83,36 +83,15 @@ export function useFetchThreadContextEvent(
const skipShortcuts = refetchToken > 0 const skipShortcuts = refetchToken > 0
const initialMatches = void (async () => {
initialEvent &&
(initialEvent.id === eventId ||
(() => {
try {
return getNoteBech32Id(initialEvent) === eventId
} catch {
return false
}
})())
if (!skipShortcuts && initialMatches && initialEvent) {
if (!isEventDeleted(initialEvent)) {
setEvent(initialEvent)
addReplies([initialEvent])
setIsFetching(false)
}
return () => {
cancelled = true
}
}
if (!skipShortcuts) { if (!skipShortcuts) {
const navigationEvent = navigationEventStore.peekEvent(eventId) const local = await resolveThreadContextEventFromLocalStores(eventId, initialEvent)
if (navigationEvent && !isEventDeleted(navigationEvent)) { if (cancelled) return
setEvent(navigationEvent) if (local && !isEventDeleted(local)) {
addReplies([navigationEvent]) setEvent(local)
addReplies([local])
setIsFetching(false) setIsFetching(false)
return () => { return
cancelled = true
}
} }
} }
@ -120,7 +99,6 @@ export function useFetchThreadContextEvent(
setError(null) setError(null)
setIsFetching(true) setIsFetching(true)
const fetchWithFallback = async () => {
try { try {
const relayUrls = await buildThreadContextFetchRelayUrls( const relayUrls = await buildThreadContextFetchRelayUrls(
contextEvent, contextEvent,
@ -171,9 +149,7 @@ export function useFetchThreadContextEvent(
setIsFetching(false) setIsFetching(false)
} }
} }
} })()
void fetchWithFallback()
return () => { return () => {
cancelled = true cancelled = true

17
src/lib/navigation-related-events.ts

@ -1,7 +1,9 @@
import { getParentBech32Id, getRootBech32Id } from '@/lib/event' import { getParentBech32Id, getRootBech32Id } from '@/lib/event'
import { toNote } from '@/lib/link' import { toNote } from '@/lib/link'
import { prefetchThreadContextForNavigation } from '@/lib/thread-context-local'
import client from '@/services/client.service' import client from '@/services/client.service'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
import { navigationEventStore } from '@/services/navigation-event-store'
/** /**
* Parent / root events already in the session cache (e.g. from {@link ParentNotePreview} or the feed). * Parent / root events already in the session cache (e.g. from {@link ParentNotePreview} or the feed).
@ -29,15 +31,26 @@ export function resolveCachedNoteEvent(fetched: Event | undefined, noteId?: stri
return client.peekSessionCachedEvent(noteId.trim()) return client.peekSessionCachedEvent(noteId.trim())
} }
export function openNoteFromFetchOrCache( export async function openNoteFromFetchOrCache(
navigateToNote: NavigateToNoteFn, navigateToNote: NavigateToNoteFn,
noteId: string, noteId: string,
fetched?: Event fetched?: Event
): void { ): Promise<void> {
const resolved = resolveCachedNoteEvent(fetched, noteId) const resolved = resolveCachedNoteEvent(fetched, noteId)
if (resolved) { if (resolved) {
await seedThreadContextForNavigation(resolved)
navigateToNote(toNote(resolved), resolved, getCachedThreadContextEvents(resolved)) navigateToNote(toNote(resolved), resolved, getCachedThreadContextEvents(resolved))
return return
} }
navigateToNote(toNote(noteId)) navigateToNote(toNote(noteId))
} }
/** Seed navigation store with parent/root from disk before the note panel mounts. */
export async function seedThreadContextForNavigation(event: Event): Promise<Event[]> {
const prefetched = await prefetchThreadContextForNavigation(event)
for (const ev of prefetched) {
client.addEventToCache(ev)
navigationEventStore.setEvent(ev)
}
return prefetched
}

100
src/lib/thread-context-local.ts

@ -0,0 +1,100 @@
import {
getNoteBech32Id,
getParentBech32Id,
getParentEventHexId,
getRootBech32Id,
getRootEventHexId,
resolveDeclaredThreadRootEventHex
} from '@/lib/event'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import client, { eventService } from '@/services/client.service'
import { loadArchivedEventForFetch } from '@/services/event-archive.service'
import { candidateKeysForNoteUrlId } from '@/services/navigation-event-store'
import { navigationEventStore } from '@/services/navigation-event-store'
import type { Event } from 'nostr-tools'
export function resolveEventPointerToHex(eventId: string): string | undefined {
const trimmed = eventId.trim()
if (/^[0-9a-f]{64}$/i.test(trimmed)) return trimmed.toLowerCase()
for (const key of candidateKeysForNoteUrlId(trimmed)) {
if (/^[0-9a-f]{64}$/i.test(key)) return key.toLowerCase()
}
return undefined
}
function eventMatchesPointer(ev: Event, eventId: string): boolean {
if (ev.id === eventId) return true
try {
return getNoteBech32Id(ev) === eventId
} catch {
return false
}
}
/** Session, navigation store, archive, publication store, and timeline disk — for parent/root strips. */
export async function resolveThreadContextEventFromLocalStores(
eventId: string,
initialEvent?: Event
): Promise<Event | undefined> {
if (initialEvent && eventMatchesPointer(initialEvent, eventId)) {
return initialEvent
}
const fromNav = navigationEventStore.peekEvent(eventId)
if (fromNav) return fromNav
const fromSession = client.peekSessionCachedEvent(eventId)
if (fromSession) return fromSession
const hex = resolveEventPointerToHex(eventId)
if (!hex) return undefined
const fromArchive = await loadArchivedEventForFetch(hex)
if (fromArchive && !shouldDropEventOnIngest(fromArchive, { explicitNoteLookupHexId: hex })) {
client.addEventToCache(fromArchive, { explicitNoteLookupHexId: hex })
return fromArchive
}
const fromPublication = await eventService.peekPublicationStoreEvent(hex)
if (fromPublication) return fromPublication
const rows = await client.getLocalFeedEvents(
[{ urls: [], filter: { ids: [hex], limit: 1 } }],
{ maxMatches: 1, maxRowsScanned: 14_000 }
)
const hit = rows[0]
if (hit && !shouldDropEventOnIngest(hit, { explicitNoteLookupHexId: hex })) {
client.addEventToCache(hit, { explicitNoteLookupHexId: hex })
return hit
}
return undefined
}
/** Parent + root events from local stores before opening a note panel (avoids thread strip skeletons). */
export async function prefetchThreadContextForNavigation(forEvent: Event): Promise<Event[]> {
const pointers = new Set<string>()
const parentId = getParentBech32Id(forEvent)
const rootId = getRootBech32Id(forEvent)
if (parentId) pointers.add(parentId)
if (rootId) pointers.add(rootId)
const parentHex = getParentEventHexId(forEvent)?.toLowerCase()
const rootHex = getRootEventHexId(forEvent)?.toLowerCase()
if (parentHex && /^[0-9a-f]{64}$/i.test(parentHex)) pointers.add(parentHex)
if (rootHex && /^[0-9a-f]{64}$/i.test(rootHex)) {
pointers.add(resolveDeclaredThreadRootEventHex(rootHex))
}
pointers.delete(forEvent.id.toLowerCase())
const out: Event[] = []
const seen = new Set<string>()
for (const pointer of pointers) {
const ev = await resolveThreadContextEventFromLocalStores(pointer)
if (!ev || seen.has(ev.id.toLowerCase())) continue
seen.add(ev.id.toLowerCase())
out.push(ev)
}
return out
}

11
src/lib/thread-interaction-req.test.ts

@ -47,4 +47,15 @@ describe('buildThreadInteractionFilters', () => {
expect(filters.some((f) => f['#e']?.[0] === SNAP_HEX)).toBe(true) expect(filters.some((f) => f['#e']?.[0] === SNAP_HEX)).toBe(true)
expect(filters.some((f) => f['#a']?.length === 1)).toBe(true) expect(filters.some((f) => f['#a']?.length === 1)).toBe(true)
}) })
it('adds open-note #e filters when viewing a mid-thread reply', () => {
const opHex = 'd'.repeat(64)
const filters = buildThreadInteractionFilters({
root: { type: 'E', id: ROOT_HEX, pubkey: 'c'.repeat(64) },
opEventKind: kinds.ShortTextNote,
opEventHexId: opHex,
limit: 100
})
expect(filters.some((f) => f['#e']?.[0] === opHex)).toBe(true)
})
}) })

51
src/lib/thread-interaction-req.ts

@ -21,6 +21,26 @@ export type BuildThreadInteractionFiltersInput = {
/** Kind of the note/article the user opened (affects zap inclusion). */ /** Kind of the note/article the user opened (affects zap inclusion). */
opEventKind: number opEventKind: number
limit: number limit: number
/** Hex id of the note the user opened — also REQ `#e` when it is not the thread root. */
opEventHexId?: string
}
function appendOpenNoteScopedEventFilters(
filters: Filter[],
opEventHexId: string | undefined,
rootHexIds: readonly string[],
kindsOnETag: number[],
kindsOnUpperETag: number[],
kindsOnQTag: number[],
limit: number
): void {
const opHex = opEventHexId?.trim().toLowerCase()
if (!opHex || !/^[0-9a-f]{64}$/.test(opHex)) return
const rootSet = new Set(rootHexIds.map((id) => id.trim().toLowerCase()).filter(Boolean))
if (rootSet.has(opHex)) return
filters.push({ '#e': [opHex], kinds: kindsOnETag, limit })
filters.push({ '#E': [opHex], kinds: kindsOnUpperETag, limit })
filters.push({ '#q': [opHex], kinds: kindsOnQTag, limit })
} }
/** /**
@ -28,7 +48,7 @@ export type BuildThreadInteractionFiltersInput = {
* Client code classifies replies vs backlinks; {@link QueryService} splits only when over relay caps. * Client code classifies replies vs backlinks; {@link QueryService} splits only when over relay caps.
*/ */
export function buildThreadInteractionFilters(input: BuildThreadInteractionFiltersInput): Filter[] { export function buildThreadInteractionFilters(input: BuildThreadInteractionFiltersInput): Filter[] {
const { root, opEventKind, limit } = input const { root, opEventKind, limit, opEventHexId } = input
const kindsNoteCommentVoiceZap = sortedUniqueKinds([ const kindsNoteCommentVoiceZap = sortedUniqueKinds([
kinds.ShortTextNote, kinds.ShortTextNote,
@ -70,19 +90,31 @@ export function buildThreadInteractionFilters(input: BuildThreadInteractionFilte
const filters: Filter[] = [] const filters: Filter[] = []
if (root.type === 'E') { if (root.type === 'E') {
filters.push({ '#e': [root.id], kinds: kindsOnETag, limit }) const rootHex = root.id.trim().toLowerCase()
filters.push({ '#E': [root.id], kinds: kindsOnUpperETag, limit }) filters.push({ '#e': [rootHex], kinds: kindsOnETag, limit })
filters.push({ '#q': [root.id], kinds: kindsOnQTag, limit }) filters.push({ '#E': [rootHex], kinds: kindsOnUpperETag, limit })
filters.push({ '#q': [rootHex], kinds: kindsOnQTag, limit })
if (opEventKind === ExtendedKind.PUBLIC_MESSAGE) { if (opEventKind === ExtendedKind.PUBLIC_MESSAGE) {
filters.push({ '#q': [root.id], kinds: [ExtendedKind.PUBLIC_MESSAGE], limit }) filters.push({ '#q': [rootHex], kinds: [ExtendedKind.PUBLIC_MESSAGE], limit })
} }
appendOpenNoteScopedEventFilters(
filters,
opEventHexId,
[rootHex],
kindsOnETag,
kindsOnUpperETag,
kindsOnQTag,
limit
)
return filters return filters
} }
filters.push({ '#a': [root.id], kinds: kindsOnETag, limit }) filters.push({ '#a': [root.id], kinds: kindsOnETag, limit })
filters.push({ '#A': [root.id], kinds: kindsOnUpperETag, limit }) filters.push({ '#A': [root.id], kinds: kindsOnUpperETag, limit })
const rootHexIds: string[] = []
if (/^[0-9a-f]{64}$/i.test(root.eventId)) { if (/^[0-9a-f]{64}$/i.test(root.eventId)) {
const eSnap = root.eventId.trim().toLowerCase() const eSnap = root.eventId.trim().toLowerCase()
rootHexIds.push(eSnap)
filters.push({ '#e': [eSnap], kinds: kindsOnETag, limit }) filters.push({ '#e': [eSnap], kinds: kindsOnETag, limit })
filters.push({ '#E': [eSnap], kinds: kindsOnUpperETag, limit }) filters.push({ '#E': [eSnap], kinds: kindsOnUpperETag, limit })
} }
@ -92,6 +124,15 @@ export function buildThreadInteractionFilters(input: BuildThreadInteractionFilte
if (qVals.length > 0) { if (qVals.length > 0) {
filters.push({ '#q': qVals, kinds: kindsOnQTag, limit }) filters.push({ '#q': qVals, kinds: kindsOnQTag, limit })
} }
appendOpenNoteScopedEventFilters(
filters,
opEventHexId,
rootHexIds,
kindsOnETag,
kindsOnUpperETag,
kindsOnQTag,
limit
)
return filters return filters
} }

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

@ -1089,11 +1089,16 @@ export class EventService {
* found by BFS over e/E/q and (for `a`-root threads) a-tag links. Merges with relay fetches via ReplyProvider. * found by BFS over e/E/q and (for `a`-root threads) a-tag links. Merges with relay fetches via ReplyProvider.
*/ */
getSessionThreadInteractionEvents( getSessionThreadInteractionEvents(
root: { type: 'E'; id: string } | { type: 'A'; id: string; eventId: string } | { type: 'I'; id: string } root: { type: 'E'; id: string } | { type: 'A'; id: string; eventId: string } | { type: 'I'; id: string },
openNoteHexId?: string
): NEvent[] { ): NEvent[] {
if (root.type === 'I') return [] if (root.type === 'I') return []
const threadKeys = new Set<string>() const threadKeys = new Set<string>()
const openHex = openNoteHexId?.trim().toLowerCase()
if (openHex && /^[0-9a-f]{64}$/.test(openHex)) {
threadKeys.add(openHex)
}
if (root.type === 'E') { if (root.type === 'E') {
const id = root.id.trim().toLowerCase() const id = root.id.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(id)) return [] if (!/^[0-9a-f]{64}$/.test(id)) return []

Loading…
Cancel
Save