From 0d7112362d3e5b23821629d7367c78a93d760768 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 25 May 2026 23:10:01 +0200 Subject: [PATCH] bug-fixes --- src/PageManager.tsx | 17 ++- src/components/ReplyNoteList/index.tsx | 94 +++++----------- .../ReplyNoteList/reply-list-utils.ts | 99 ++++++++++++++++- src/hooks/useFetchThreadContextEvent.tsx | 54 +++------- src/lib/navigation-related-events.ts | 17 ++- src/lib/thread-context-local.ts | 100 ++++++++++++++++++ src/lib/thread-interaction-req.test.ts | 11 ++ src/lib/thread-interaction-req.ts | 51 ++++++++- src/services/client-events.service.ts | 7 +- 9 files changed, 332 insertions(+), 118 deletions(-) create mode 100644 src/lib/thread-context-local.ts diff --git a/src/PageManager.tsx b/src/PageManager.tsx index 2a1d74d5..1ec97689 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -60,6 +60,7 @@ import { isProfileDetailPathname } from '@/lib/document-meta' import { normalizeUrl } from './lib/url' +import { prefetchThreadContextForNavigation } from '@/lib/thread-context-local' import modalManager from './services/modal-manager.service' import { decodeRssArticlePathSegment, encodeRssArticlePathSegment } from '@/lib/rss-article' import { matchAppRoute } from './routes' @@ -469,7 +470,7 @@ export function useSmartNoteNavigation() { const { isSmallScreen } = useScreenSize() 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}) const parsed = parseNoteUrl(url) if (!parsed) { @@ -482,6 +483,12 @@ export function useSmartNoteNavigation() { if (event) { navigationEventStore.setEvent(event, noteId) 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. if (relatedEvents?.length) { @@ -542,7 +549,7 @@ export function useSmartNoteNavigationOptional() { const { isSmallScreen } = screenSize 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) if (!parsed) { logger.warn('navigateToNote (optional) ignored invalid note URL', { url }) @@ -553,6 +560,12 @@ export function useSmartNoteNavigationOptional() { if (event) { navigationEventStore.setEvent(event, noteId) client.addEventToCache(event) + await prefetchThreadContextForNavigation(event).then((prefetched) => { + for (const ev of prefetched) { + client.addEventToCache(ev) + navigationEventStore.setEvent(ev) + } + }) } if (relatedEvents?.length) { for (const ev of relatedEvents) { diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 502ba9c1..31af57c8 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -67,6 +67,7 @@ import { backlinkRunSectionClass, buildVisibleBacklinkRows, EA_THREAD_TAIL_REFERENCE_KINDS, + collectDisplayedThreadReplies, fetchPaymentAttestationsForRecipient, hydrateThreadRepliesFromStats, isEaThreadTailBacklinkCandidate, @@ -75,7 +76,9 @@ import { loadThreadRepliesFromLocalStores, mergeFetchedKind7ReactionsIntoRootNoteStats, moveReportsToEndPreserveOrder, + openNoteHexId, partitionAndSortBacklinkTail, + replyIsInSubtreeBelowOpenNote, replyFeedZapsFirst, replyIdPresentInRepliesMap, replyMatchesThreadForList, @@ -138,32 +141,16 @@ function ReplyNoteList({ }, [duplicateWebPreviewCleanedUrlHints, rootInfo]) const replies: NEvent[] = useMemo(() => { - const replyIdSet = new Set() - const replyEvents: NEvent[] = [] - const currentEventKey = isReplaceableEvent(event.kind) - ? getReplaceableCoordinateFromEvent(event) - : /^[0-9a-f]{64}$/i.test(event.id) ? event.id.toLowerCase() : event.id - // For replaceable events, also check the event ID in case replies are stored there - const eventIdKey = /^[0-9a-f]{64}$/i.test(event.id) ? event.id.toLowerCase() : event.id - let parentEventKeys = [currentEventKey] - if (isReplaceableEvent(event.kind) && currentEventKey !== eventIdKey) { - 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 replyEvents = collectDisplayedThreadReplies( + event, + rootInfo, + repliesMap, + isDiscussionRoot, + mutePubkeySet, + hideContentMentioningMutedUsers + ) + const replyIdSet = new Set(replyEvents.map((r) => r.id)) - - const processedEventIds = new Set() // Prevent infinite loops - let iterationCount = 0 - const MAX_ITERATIONS = 10 // Prevent infinite loops const threadWalkFromRepliesMap = new Map() for (const { events: bucket } of repliesMap.values()) { 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) => { if (isPollVoteKind(evt)) return false if ( @@ -225,6 +171,15 @@ function ReplyNoteList({ ) { return false } + const opHex = openNoteHexId(event) + if ( + opHex && + rootInfo?.type === 'E' && + rootInfo.id.trim().toLowerCase() !== opHex && + !replyIsInSubtreeBelowOpenNote(evt, opHex, threadWalkFromRepliesMap) + ) { + return false + } return true } @@ -236,7 +191,6 @@ function ReplyNoteList({ )) { replyIdSet.add(evt.id) replyEvents.push(evt) - threadWalkFromRepliesMap.set(evt.id.toLowerCase(), evt) } const { superchats, rest: nonZaps } = partitionAttestedSuperchats(replyEvents, attestedPaymentIds) @@ -638,7 +592,10 @@ function ReplyNoteList({ const init = async () => { // Session LRU (timeline / note-stats / prior panels): thread replies before relay round-trip if (rootInfo.type === 'E' || rootInfo.type === 'A') { - const fromSession = eventService.getSessionThreadInteractionEvents(rootInfo) + const fromSession = eventService.getSessionThreadInteractionEvents( + rootInfo, + openNoteHexId(event) + ) if (fromSession.length > 0) { addReplies(fromSession) } @@ -726,6 +683,7 @@ function ReplyNoteList({ const filters = buildThreadInteractionFilters({ root: rootInfo, opEventKind: event.kind, + opEventHexId: openNoteHexId(event), limit: THREAD_REPLY_LIMIT }) diff --git a/src/components/ReplyNoteList/reply-list-utils.ts b/src/components/ReplyNoteList/reply-list-utils.ts index 331d2171..cde5030c 100644 --- a/src/components/ReplyNoteList/reply-list-utils.ts +++ b/src/components/ReplyNoteList/reply-list-utils.ts @@ -1,7 +1,8 @@ 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 { eventReferencesThreadTarget } from '@/lib/op-reference-tags' +import type { TRepliesMap } from '@/lib/reply-index' import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' import { isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' import { shouldHideThreadResponseEvent } from '@/lib/thread-response-filter' @@ -24,6 +25,101 @@ export { THREAD_PROFILE_CHUNK } from './types' +export function openNoteHexId(event: Pick): 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 +): boolean { + let cur: string | undefined = getParentEventHexId(evt)?.toLowerCase() + if (!cur) return false + if (cur === opHexLower) return true + const seen = new Set() + 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() + 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, + hideContentMentioningMutedUsers: boolean | undefined +): NEvent[] { + const threadWalk = new Map() + 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() + 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() + 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. */ export async function loadThreadRepliesFromLocalStores( rootInfo: TRootInfo, @@ -35,6 +131,7 @@ export async function loadThreadRepliesFromLocalStores( const filters = buildThreadInteractionFilters({ root: rootInfo, opEventKind: opEvent.kind, + opEventHexId: openNoteHexId(opEvent), limit: THREAD_REPLY_LIMIT }) if (!filters.length) return [] diff --git a/src/hooks/useFetchThreadContextEvent.tsx b/src/hooks/useFetchThreadContextEvent.tsx index 5b6c18f7..074b422d 100644 --- a/src/hooks/useFetchThreadContextEvent.tsx +++ b/src/hooks/useFetchThreadContextEvent.tsx @@ -1,14 +1,14 @@ import { THREAD_CONTEXT_EVENT_FETCH_GLOBAL_TIMEOUT_MS } from '@/constants' import { getAggrAwareSearchRelayUrls } from '@/lib/nostr-land-relay-eligibility' import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' +import { resolveThreadContextEventFromLocalStores } from '@/lib/thread-context-local' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useIsEventDeleted } from '@/providers/DeletedEventProvider' import { useNostr } from '@/providers/NostrProvider' 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 client, { eventService } from '@/services/client.service' -import { navigationEventStore } from '@/services/navigation-event-store' import { Event } from 'nostr-tools' import { useCallback, useEffect, useMemo, useState } from 'react' @@ -83,44 +83,22 @@ export function useFetchThreadContextEvent( const skipShortcuts = refetchToken > 0 - const initialMatches = - 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) { - const navigationEvent = navigationEventStore.peekEvent(eventId) - if (navigationEvent && !isEventDeleted(navigationEvent)) { - setEvent(navigationEvent) - addReplies([navigationEvent]) - setIsFetching(false) - return () => { - cancelled = true + void (async () => { + if (!skipShortcuts) { + const local = await resolveThreadContextEventFromLocalStores(eventId, initialEvent) + if (cancelled) return + if (local && !isEventDeleted(local)) { + setEvent(local) + addReplies([local]) + setIsFetching(false) + return } } - } - setEvent(undefined) - setError(null) - setIsFetching(true) + setEvent(undefined) + setError(null) + setIsFetching(true) - const fetchWithFallback = async () => { try { const relayUrls = await buildThreadContextFetchRelayUrls( contextEvent, @@ -171,9 +149,7 @@ export function useFetchThreadContextEvent( setIsFetching(false) } } - } - - void fetchWithFallback() + })() return () => { cancelled = true diff --git a/src/lib/navigation-related-events.ts b/src/lib/navigation-related-events.ts index f3ef5c11..585af6d3 100644 --- a/src/lib/navigation-related-events.ts +++ b/src/lib/navigation-related-events.ts @@ -1,7 +1,9 @@ import { getParentBech32Id, getRootBech32Id } from '@/lib/event' import { toNote } from '@/lib/link' +import { prefetchThreadContextForNavigation } from '@/lib/thread-context-local' import client from '@/services/client.service' 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). @@ -29,15 +31,26 @@ export function resolveCachedNoteEvent(fetched: Event | undefined, noteId?: stri return client.peekSessionCachedEvent(noteId.trim()) } -export function openNoteFromFetchOrCache( +export async function openNoteFromFetchOrCache( navigateToNote: NavigateToNoteFn, noteId: string, fetched?: Event -): void { +): Promise { const resolved = resolveCachedNoteEvent(fetched, noteId) if (resolved) { + await seedThreadContextForNavigation(resolved) navigateToNote(toNote(resolved), resolved, getCachedThreadContextEvents(resolved)) return } navigateToNote(toNote(noteId)) } + +/** Seed navigation store with parent/root from disk before the note panel mounts. */ +export async function seedThreadContextForNavigation(event: Event): Promise { + const prefetched = await prefetchThreadContextForNavigation(event) + for (const ev of prefetched) { + client.addEventToCache(ev) + navigationEventStore.setEvent(ev) + } + return prefetched +} diff --git a/src/lib/thread-context-local.ts b/src/lib/thread-context-local.ts new file mode 100644 index 00000000..a170fb9f --- /dev/null +++ b/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 { + 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 { + const pointers = new Set() + 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() + 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 +} diff --git a/src/lib/thread-interaction-req.test.ts b/src/lib/thread-interaction-req.test.ts index 8f6a9674..bc514963 100644 --- a/src/lib/thread-interaction-req.test.ts +++ b/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['#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) + }) }) diff --git a/src/lib/thread-interaction-req.ts b/src/lib/thread-interaction-req.ts index 312599e4..d7aeb6af 100644 --- a/src/lib/thread-interaction-req.ts +++ b/src/lib/thread-interaction-req.ts @@ -21,6 +21,26 @@ export type BuildThreadInteractionFiltersInput = { /** Kind of the note/article the user opened (affects zap inclusion). */ opEventKind: 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. */ export function buildThreadInteractionFilters(input: BuildThreadInteractionFiltersInput): Filter[] { - const { root, opEventKind, limit } = input + const { root, opEventKind, limit, opEventHexId } = input const kindsNoteCommentVoiceZap = sortedUniqueKinds([ kinds.ShortTextNote, @@ -70,19 +90,31 @@ export function buildThreadInteractionFilters(input: BuildThreadInteractionFilte const filters: Filter[] = [] if (root.type === 'E') { - filters.push({ '#e': [root.id], kinds: kindsOnETag, limit }) - filters.push({ '#E': [root.id], kinds: kindsOnUpperETag, limit }) - filters.push({ '#q': [root.id], kinds: kindsOnQTag, limit }) + const rootHex = root.id.trim().toLowerCase() + filters.push({ '#e': [rootHex], kinds: kindsOnETag, limit }) + filters.push({ '#E': [rootHex], kinds: kindsOnUpperETag, limit }) + filters.push({ '#q': [rootHex], kinds: kindsOnQTag, limit }) 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 } filters.push({ '#a': [root.id], kinds: kindsOnETag, limit }) filters.push({ '#A': [root.id], kinds: kindsOnUpperETag, limit }) + const rootHexIds: string[] = [] if (/^[0-9a-f]{64}$/i.test(root.eventId)) { const eSnap = root.eventId.trim().toLowerCase() + rootHexIds.push(eSnap) filters.push({ '#e': [eSnap], kinds: kindsOnETag, limit }) filters.push({ '#E': [eSnap], kinds: kindsOnUpperETag, limit }) } @@ -92,6 +124,15 @@ export function buildThreadInteractionFilters(input: BuildThreadInteractionFilte if (qVals.length > 0) { filters.push({ '#q': qVals, kinds: kindsOnQTag, limit }) } + appendOpenNoteScopedEventFilters( + filters, + opEventHexId, + rootHexIds, + kindsOnETag, + kindsOnUpperETag, + kindsOnQTag, + limit + ) return filters } diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index 051539aa..6c9805d2 100644 --- a/src/services/client-events.service.ts +++ b/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. */ 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[] { if (root.type === 'I') return [] const threadKeys = new Set() + const openHex = openNoteHexId?.trim().toLowerCase() + if (openHex && /^[0-9a-f]{64}$/.test(openHex)) { + threadKeys.add(openHex) + } if (root.type === 'E') { const id = root.id.trim().toLowerCase() if (!/^[0-9a-f]{64}$/.test(id)) return []