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. 54
      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 { @@ -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() { @@ -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() { @@ -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() { @@ -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() { @@ -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) {

94
src/components/ReplyNoteList/index.tsx

@ -67,6 +67,7 @@ import { @@ -67,6 +67,7 @@ import {
backlinkRunSectionClass,
buildVisibleBacklinkRows,
EA_THREAD_TAIL_REFERENCE_KINDS,
collectDisplayedThreadReplies,
fetchPaymentAttestationsForRecipient,
hydrateThreadRepliesFromStats,
isEaThreadTailBacklinkCandidate,
@ -75,7 +76,9 @@ import { @@ -75,7 +76,9 @@ import {
loadThreadRepliesFromLocalStores,
mergeFetchedKind7ReactionsIntoRootNoteStats,
moveReportsToEndPreserveOrder,
openNoteHexId,
partitionAndSortBacklinkTail,
replyIsInSubtreeBelowOpenNote,
replyFeedZapsFirst,
replyIdPresentInRepliesMap,
replyMatchesThreadForList,
@ -138,32 +141,16 @@ function ReplyNoteList({ @@ -138,32 +141,16 @@ function ReplyNoteList({
}, [duplicateWebPreviewCleanedUrlHints, rootInfo])
const replies: NEvent[] = useMemo(() => {
const replyIdSet = new Set<string>()
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<string>() // Prevent infinite loops
let iterationCount = 0
const MAX_ITERATIONS = 10 // Prevent infinite loops
const threadWalkFromRepliesMap = new Map<string, NEvent>()
for (const { events: bucket } of repliesMap.values()) {
for (const e of bucket) {
@ -171,47 +158,6 @@ function ReplyNoteList({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -726,6 +683,7 @@ function ReplyNoteList({
const filters = buildThreadInteractionFilters({
root: rootInfo,
opEventKind: event.kind,
opEventHexId: openNoteHexId(event),
limit: THREAD_REPLY_LIMIT
})

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

@ -1,7 +1,8 @@ @@ -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 { @@ -24,6 +25,101 @@ export {
THREAD_PROFILE_CHUNK
} 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. */
export async function loadThreadRepliesFromLocalStores(
rootInfo: TRootInfo,
@ -35,6 +131,7 @@ export async function loadThreadRepliesFromLocalStores( @@ -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 []

54
src/hooks/useFetchThreadContextEvent.tsx

@ -1,14 +1,14 @@ @@ -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( @@ -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( @@ -171,9 +149,7 @@ export function useFetchThreadContextEvent(
setIsFetching(false)
}
}
}
void fetchWithFallback()
})()
return () => {
cancelled = true

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

@ -1,7 +1,9 @@ @@ -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 @@ -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<void> {
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<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 @@ @@ -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', () => { @@ -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)
})
})

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

@ -21,6 +21,26 @@ export type BuildThreadInteractionFiltersInput = { @@ -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 = { @@ -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 @@ -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 @@ -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
}

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

@ -1089,11 +1089,16 @@ export class EventService { @@ -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<string>()
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 []

Loading…
Cancel
Save