diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index daf7c5a6..bf3e4181 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -1,8 +1,4 @@ -import { - ExtendedKind, - NOTE_STATS_OP_REFERENCE_KINDS, - NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT -} from '@/constants' +import { ExtendedKind, NOTE_STATS_OP_REFERENCE_KINDS } from '@/constants' import { isDiscussionDownvoteEmoji, isDiscussionUpvoteEmoji } from '@/lib/discussion-votes' import { canonicalizeRssArticleUrl, @@ -47,27 +43,20 @@ import noteStatsService from '@/services/note-stats.service' import discussionFeedCache from '@/services/discussion-feed-cache.service' import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' +import { buildThreadInteractionFilters } from '@/lib/thread-interaction-req' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { eventReferencesThreadTarget } from '@/lib/op-reference-tags' import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' -import { - buildRssArticleUrlThreadInteractionFilters, - buildRssWebNostrQueryRelayUrls, - isRssArticleUrlThreadInteraction -} from '@/lib/rss-web-feed' +import { buildRssWebNostrQueryRelayUrls, isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' import type { TProfile } from '@/types' import { Filter, Event as NEvent, kinds } from 'nostr-tools' import { useNoteStatsById } from '@/hooks/useNoteStatsById' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { TFunction } from 'i18next' import { useTranslation } from 'react-i18next' -import { useQuoteEvents } from '@/hooks' import { LoadingBar } from '../LoadingBar' import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote' -import ThreadQuoteBacklink, { - BacklinkAvatarStrip, - ThreadQuoteBacklinkSkeleton -} from './ThreadQuoteBacklink' +import ThreadQuoteBacklink, { BacklinkAvatarStrip } from './ThreadQuoteBacklink' type TRootInfo = | { type: 'E'; id: string; pubkey: string } @@ -380,20 +369,6 @@ function ReplyNoteList({ const { relayUrls: browsingRelayUrls } = useCurrentRelays() const [rootInfo, setRootInfo] = useState(undefined) const { repliesMap, addReplies } = useReply() - const { quoteEvents, quoteLoading } = useQuoteEvents(event, true) - const filteredQuoteEvents = useMemo( - () => - quoteEvents.filter( - (e) => - !shouldHideThreadResponseEvent( - e, - mutePubkeySet, - hideContentMentioningMutedUsers - ) - ), - [quoteEvents, mutePubkeySet, hideContentMentioningMutedUsers] - ) - const isDiscussionRoot = event.kind === ExtendedKind.DISCUSSION const replyDuplicateWebPreviewHints = useMemo(() => { @@ -570,7 +545,7 @@ function ReplyNoteList({ const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies]) /** Render with quote card chrome (tail stream + kind 1 #q-only of E/A root). */ const quoteUiIdSet = useMemo(() => { - const s = new Set(filteredQuoteEvents.map((e) => e.id)) + const s = new Set() if (rootInfo?.type === 'E' || rootInfo?.type === 'A') { for (const r of replies) { if (isEaThreadTailBacklinkCandidate(r, rootInfo)) s.add(r.id) @@ -582,7 +557,7 @@ function ReplyNoteList({ } } return s - }, [filteredQuoteEvents, replies, rootInfo]) + }, [replies, rootInfo]) const mergedFeed = useMemo(() => { /** Quotes + time-sorted feeds must not interleave zap receipts chronologically */ const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => { @@ -596,8 +571,6 @@ function ReplyNoteList({ if (!showQuotes) return replies - const quoteOnly = filteredQuoteEvents.filter((e) => !replyIdSet.has(e.id)) - // E/A: zaps (sats desc) → thread replies (1 / 1111 / 1244, excluding #q-only) → tail (quotes, highlights, long-form refs) if (rootInfo?.type === 'E' || rootInfo?.type === 'A') { const { zaps, nonZaps } = partitionZapReceipts(replies) @@ -612,7 +585,6 @@ function ReplyNoteList({ tail.push(e) } for (const e of tailFromReplies) pushTail(e) - for (const e of quoteOnly) pushTail(e) const tailSorted = partitionAndSortBacklinkTail(tail) return [...replyFeedZapsFirst(middle, zapsShown), ...tailSorted] } @@ -631,31 +603,18 @@ function ReplyNoteList({ tail.push(e) } for (const e of tailFromReplies) pushTail(e) - for (const e of quoteOnly) pushTail(e) const tailSorted = partitionAndSortBacklinkTail(tail) return [...replyFeedZapsFirst(middle, zapsShownI), ...tailSorted] } - const merged = [...replies, ...quoteOnly] + const merged = [...replies] if (sort === 'oldest') return zapsThenTimeSorted(merged, 'asc') if (sort === 'newest') return zapsThenTimeSorted(merged, 'desc') if (sort === 'top' || sort === 'controversial' || sort === 'most-zapped') { - const replyIds = new Set(replies.map((r) => r.id)) - const sortedReplies = [...replies] - const qo = merged.filter((e) => !replyIds.has(e.id)) - const sortedQuotes = partitionAndSortBacklinkTail([...qo]) - return [...sortedReplies, ...sortedQuotes] + return [...replies] } return zapsThenTimeSorted(merged, 'desc') - }, [replies, filteredQuoteEvents, showQuotes, sort, replyIdSet, rootInfo, event.kind]) - - useEffect(() => { - if (!rootInfo) return - const toAdd = filteredQuoteEvents.filter((evt) => - replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) - ) - if (toAdd.length > 0) addReplies(toAdd) - }, [filteredQuoteEvents, rootInfo, event, isDiscussionRoot, addReplies]) + }, [replies, showQuotes, sort, replyIdSet, rootInfo, event.kind]) const parentNoteFeed = useNoteFeedProfileContext() const threadProfileLoadedRef = useRef>(new Set()) @@ -783,8 +742,6 @@ function ReplyNoteList({ parentNoteFeed?.pendingPubkeys ]) - const [timelineKey] = useState(undefined) - const [until, setUntil] = useState(undefined) const [loading, setLoading] = useState(false) const [showCount, setShowCount] = useState(SHOW_COUNT) const [highlightReplyId, setHighlightReplyId] = useState(undefined) @@ -1131,125 +1088,16 @@ function ReplyNoteList({ } } - const filters: Filter[] = [] - const qKindsHex = Array.from( - new Set([ - kinds.ShortTextNote, - ExtendedKind.COMMENT, - ExtendedKind.VOICE_COMMENT, - ...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT - ]) - ).sort((a, b) => a - b) - const opRefKinds = [...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT] - const kindsNoteCommentVoiceZap: number[] = [ - kinds.ShortTextNote, - ExtendedKind.COMMENT, - ExtendedKind.VOICE_COMMENT, - kinds.Zap - ] - const kindsNoteCommentVoice: number[] = [ - kinds.ShortTextNote, - ExtendedKind.COMMENT, - ExtendedKind.VOICE_COMMENT - ] - const kindsPrimaryThread = - event.kind === ExtendedKind.ZAP_POLL ? kindsNoteCommentVoice : kindsNoteCommentVoiceZap - const kindsUpperEThread: number[] = - event.kind === ExtendedKind.ZAP_POLL - ? [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT] - : [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap] - - if (rootInfo.type === 'E') { - filters.push({ - '#e': [rootInfo.id], - kinds: kindsPrimaryThread, - limit: LIMIT - }) - // Also fetch with uppercase E tag for replaceable events - filters.push({ - '#E': [rootInfo.id], - kinds: kindsUpperEThread, - limit: LIMIT - }) - filters.push({ - '#e': [rootInfo.id], - kinds: [kinds.Reaction], - limit: LIMIT - }) - filters.push({ - '#q': [rootInfo.id], - kinds: qKindsHex, - limit: LIMIT - }) - // For public messages (kind 24), also look for replies using 'q' tags - if (event.kind === ExtendedKind.PUBLIC_MESSAGE) { - filters.push({ - '#q': [rootInfo.id], - kinds: [ExtendedKind.PUBLIC_MESSAGE], - limit: LIMIT - }) - } - filters.push({ '#e': [rootInfo.id], kinds: opRefKinds, limit: LIMIT }) - filters.push({ '#E': [rootInfo.id], kinds: opRefKinds, limit: LIMIT }) - } else if (rootInfo.type === 'A') { - // Fetch all reply types for replaceable event-based replies - filters.push( - { - '#a': [rootInfo.id], - kinds: kindsPrimaryThread, - limit: LIMIT - }, - { - '#A': [rootInfo.id], - kinds: kindsUpperEThread, - 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': [eSnap], - kinds: kindsPrimaryThread, - limit: LIMIT - }) - filters.push({ - '#E': [eSnap], - kinds: kindsUpperEThread, - limit: LIMIT - }) - filters.push({ - '#e': [eSnap], - kinds: [kinds.Reaction], - limit: LIMIT - }) - filters.push({ '#e': [eSnap], kinds: opRefKinds, limit: LIMIT }) - filters.push({ '#E': [eSnap], kinds: opRefKinds, limit: LIMIT }) - } - const qVals = Array.from( - new Set( - [rootInfo.eventId, rootInfo.id] - .map((x) => (typeof x === 'string' ? x.trim() : '')) - .filter(Boolean) - ) - ) - if (qVals.length > 0) { - filters.push({ - '#q': qVals, - kinds: qKindsHex, - limit: LIMIT - }) - } - if (rootInfo.relay) { - finalRelayUrls.push(rootInfo.relay) - } - filters.push({ '#a': [rootInfo.id], kinds: opRefKinds, limit: LIMIT }) - filters.push({ '#A': [rootInfo.id], kinds: opRefKinds, limit: LIMIT }) - } else if (rootInfo.type === 'I') { - filters.push(...buildRssArticleUrlThreadInteractionFilters(rootInfo.id, LIMIT)) + if (rootInfo.type === 'A' && rootInfo.relay) { + finalRelayUrls.push(rootInfo.relay) } + const filters = buildThreadInteractionFilters({ + root: rootInfo, + opEventKind: event.kind, + limit: LIMIT + }) + const relayUrlsForThreadReq = feedRelayPolicyUrls([{ source: 'fallback', urls: finalRelayUrls }], { operation: 'read', blockedRelays: replyBlockedRelays, @@ -1274,11 +1122,11 @@ function ReplyNoteList({ : undefined // Use fetchEvents instead of subscribeTimeline for one-time fetching - const allReplies = await queryService.fetchEvents( - relayUrlsForThreadReq, - filters, - urlThreadOnevent ? { onevent: urlThreadOnevent } : undefined - ) + const allReplies = await queryService.fetchEvents(relayUrlsForThreadReq, filters, { + ...(urlThreadOnevent ? { onevent: urlThreadOnevent } : {}), + foreground: statsForeground, + relayOpSource: 'ReplyNoteList.thread' + }) if (fetchGeneration !== replyFetchGenRef.current) return @@ -1341,12 +1189,15 @@ function ReplyNoteList({ : rootInfo.type === 'A' && /^[0-9a-f]{64}$/i.test(rootInfo.eventId) ? rootInfo.eventId.toLowerCase() : undefined - void noteStatsService.fetchThreadReplyNoteStatsBatch( - repliesForStatsPrime, - relayUrlsForThreadReq, - userPubkey ?? null, - { foreground: statsForeground, threadRootHexId } - ) + window.setTimeout(() => { + if (fetchGeneration !== replyFetchGenRef.current) return + void noteStatsService.fetchThreadReplyNoteStatsBatch( + repliesForStatsPrime, + relayUrlsForThreadReq, + userPubkey ?? null, + { foreground: statsForeground, threadRootHexId } + ) + }, 0) } if (!hasCache) { @@ -1500,15 +1351,10 @@ function ReplyNoteList({ addReplies, mutePubkeySet, hideContentMentioningMutedUsers, - isDiscussionRoot + isDiscussionRoot, + statsForeground ]) - useEffect(() => { - if (replies.length === 0 && !loading && timelineKey) { - loadMore() - } - }, [replies.length, loading, timelineKey]) // More specific dependencies to prevent infinite loops - useEffect(() => { const options = { root: null, @@ -1535,40 +1381,6 @@ function ReplyNoteList({ } }, [mergedFeed.length, showCount]) - const loadMore = useCallback(async () => { - if (loading || !until || !timelineKey) return - - setLoading(true) - const events = await client.loadMoreTimeline(timelineKey, until, LIMIT) - const olderEvents = events.filter((evt) => { - if (isPollVoteKind(evt)) return false - if (isZapPollThreadZapReceipt(evt, event)) return false - if (!rootInfo) return false - const matchesThread = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) - if (!matchesThread) return false - return !shouldHideThreadResponseEvent( - evt, - mutePubkeySet, - hideContentMentioningMutedUsers - ) - }) - if (olderEvents.length > 0) { - addReplies(olderEvents) - } - setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined) - setLoading(false) - }, [ - loading, - until, - timelineKey, - rootInfo, - event, - mutePubkeySet, - hideContentMentioningMutedUsers, - addReplies, - isDiscussionRoot - ]) - const highlightReply = useCallback((eventId: string, scrollTo = true) => { if (scrollTo) { const ref = replyRefs.current[eventId] @@ -1660,14 +1472,6 @@ function ReplyNoteList({
{loading && } - {!loading && until && ( -
- {t('load more older replies')} -
- )}
{displayRows.map((row, ri) => { const prevRow = ri > 0 ? displayRows[ri - 1] : undefined @@ -1803,12 +1607,7 @@ function ReplyNoteList({ ) })}
- {quoteLoading && showQuotes && ( -
- -
- )} - {!loading && !quoteLoading && ( + {!loading && (
{mergedFeed.length > 0 ? t('no more replies') : t('no replies')}
diff --git a/src/constants.ts b/src/constants.ts index 8ea3a6a1..356301de 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -636,7 +636,7 @@ export function isNip71ShortVideoKind(kind: number): boolean { export const MAX_SIGNED_CUSTOM_EVENT_KIND = 40000 /** - * Kinds subscribed on `#e` / `#a` for the OP in {@link useQuoteEvents} (thread “backlinks” shard), + * Kinds on `#e` / `#a` / `#q` in {@link buildThreadInteractionFilters} (thread backlinks), * alongside kind-1 `#q` quotes. Covers highlights, long-form, NIP-32 labels, NIP-56 reports, * NIP-51 lists (bookmarks, pins, generic/bookmark/curation sets), and NIP-58 badge awards. */ diff --git a/src/hooks/index.tsx b/src/hooks/index.tsx index b68a2e17..351e5bfd 100644 --- a/src/hooks/index.tsx +++ b/src/hooks/index.tsx @@ -1,5 +1,4 @@ export * from './useFetchCalendarRsvps' -export * from './useQuoteEvents' export * from './useFetchEvent' export * from './useFetchFollowings' export * from './useFetchNip05' diff --git a/src/hooks/useQuoteEvents.tsx b/src/hooks/useQuoteEvents.tsx deleted file mode 100644 index 91f0f65d..00000000 --- a/src/hooks/useQuoteEvents.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { - ExtendedKind, - FAST_READ_RELAY_URLS, - NOTE_STATS_OP_REFERENCE_KINDS, - SEARCHABLE_RELAY_URLS -} from '@/constants' -import { getReplaceableCoordinateFromEvent, isReplaceableEvent } from '@/lib/event' -import { buildNormalizedBlockedRelaySet } from '@/lib/thread-response-filter' -import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' -import { useCurrentRelays } from '@/providers/CurrentRelaysProvider' -import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' -import { useNostr } from '@/providers/NostrProvider' -import client from '@/services/client.service' -import type { TSubRequestFilter } from '@/types' -import dayjs from 'dayjs' -import { Event, kinds } from 'nostr-tools' -import { useEffect, useMemo, useRef, useState } from 'react' - -const LIMIT = 100 -const INITIAL_QUOTE_LOAD_TIMEOUT_MS = 12_000 - -/** Fetches events that quote or reference the given event (#q, #e, #a tags). */ -export function useQuoteEvents(event: Event | null, enabled: boolean) { - const { relayList: userRelayList } = useNostr() - const { relayUrls: browsingRelayUrls } = useCurrentRelays() - const { blockedRelays } = useFavoriteRelays() - const userBlockedRelaysNorm = useMemo( - () => buildNormalizedBlockedRelaySet(blockedRelays), - [blockedRelays] - ) - const [timelineKey, setTimelineKey] = useState(undefined) - const [events, setEvents] = useState([]) - const [loading, setLoading] = useState(true) - const [hasMore, setHasMore] = useState(true) - const receivedAnyQuotesRef = useRef(false) - const lastSubscribedEventIdRef = useRef(null) - - useEffect(() => { - if (!event || !enabled) { - setEvents([]) - setLoading(false) - setHasMore(false) - lastSubscribedEventIdRef.current = null - return - } - - const ev = event - let cancelled = false - let loadTimeoutId: ReturnType | undefined - - async function init() { - const noteRowId = ev.id - const isNewTarget = lastSubscribedEventIdRef.current !== noteRowId - lastSubscribedEventIdRef.current = noteRowId - - setLoading(true) - if (isNewTarget) { - setEvents([]) - receivedAnyQuotesRef.current = false - } - setHasMore(true) - - loadTimeoutId = setTimeout(() => { - if (cancelled) return - setLoading(false) - if (!receivedAnyQuotesRef.current) { - setHasMore(false) - } - }, INITIAL_QUOTE_LOAD_TIMEOUT_MS) - - const userRelays = userRelayList?.read || [] - const fromFeed = browsingRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) - const seenOn = client.getSeenEventRelayUrls(ev.id) - const finalRelayUrls = Array.from( - new Set([ - ...fromFeed, - ...userRelays.map((url) => normalizeUrl(url) || url), - ...seenOn, - ...SEARCHABLE_RELAY_URLS.map((url) => normalizeUrl(url) || url), - ...FAST_READ_RELAY_URLS.map((url) => normalizeUrl(url) || url) - ]) - ) - .filter(Boolean) - .filter((u) => !userBlockedRelaysNorm.has((normalizeUrl(u) || u).toLowerCase())) - - const filterQeId = isReplaceableEvent(ev.kind) - ? getReplaceableCoordinateFromEvent(ev) - : ev.id - const qeIdForTagFilter = - /^[0-9a-f]{64}$/i.test(filterQeId) ? filterQeId.toLowerCase() : filterQeId - const qeIdIsHexEventId = /^[0-9a-f]{64}$/i.test(qeIdForTagFilter) - const eventCoordinate = isReplaceableEvent(ev.kind) - ? getReplaceableCoordinateFromEvent(ev) - : `${ev.kind}:${ev.pubkey}:${ev.id}` - - const qKindsBroad = Array.from( - new Set([ - kinds.ShortTextNote, - ExtendedKind.COMMENT, - ExtendedKind.VOICE_COMMENT, - ...NOTE_STATS_OP_REFERENCE_KINDS - ]) - ).sort((a, b) => a - b) - const opRefKinds = [...NOTE_STATS_OP_REFERENCE_KINDS] - const qValsReplaceable = Array.from( - new Set( - [ev.id, eventCoordinate] - .map((x) => (typeof x === 'string' ? x.trim() : '')) - .filter(Boolean) - ) - ) - - const subRequests: { urls: string[]; filter: TSubRequestFilter }[] = [ - { - urls: finalRelayUrls, - filter: { - '#q': isReplaceableEvent(ev.kind) ? qValsReplaceable : [qeIdForTagFilter], - kinds: qKindsBroad, - limit: LIMIT - } - }, - { - urls: finalRelayUrls, - filter: { - '#a': [eventCoordinate], - kinds: opRefKinds, - limit: LIMIT - } - } - ] - if (isReplaceableEvent(ev.kind)) { - subRequests.push({ - urls: finalRelayUrls, - filter: { '#A': [eventCoordinate], kinds: opRefKinds, limit: LIMIT } - }) - } - // `#e` tag filters must use 64-hex event ids. For replaceable roots we use `#a`/`#q` only. - if (qeIdIsHexEventId) { - subRequests.push( - { - urls: finalRelayUrls, - filter: { - '#e': [qeIdForTagFilter], - kinds: opRefKinds, - limit: LIMIT - } - }, - { - urls: finalRelayUrls, - filter: { - '#E': [qeIdForTagFilter], - kinds: opRefKinds, - limit: LIMIT - } - } - ) - } - - const { closer, timelineKey } = await client.subscribeTimeline( - subRequests, - { - onEvents: (batch, eosed) => { - if (cancelled) return - if (batch.length > 0) { - receivedAnyQuotesRef.current = true - setEvents(batch) - } - if (batch.length > 0 || eosed) { - setLoading(false) - if (loadTimeoutId) { - clearTimeout(loadTimeoutId) - loadTimeoutId = undefined - } - } - if (eosed) { - setHasMore(batch.length > 0) - } - }, - onNew: (newEvt) => { - if (cancelled) return - receivedAnyQuotesRef.current = true - setLoading(false) - if (loadTimeoutId) { - clearTimeout(loadTimeoutId) - loadTimeoutId = undefined - } - setHasMore(true) - setEvents((oldEvents) => - [newEvt, ...oldEvents].sort((a, b) => b.created_at - a.created_at) - ) - } - } - ) - if (cancelled) { - closer() - return undefined - } - setTimelineKey(timelineKey) - return closer - } - - const promise = init() - return () => { - cancelled = true - if (loadTimeoutId) clearTimeout(loadTimeoutId) - promise.then((closer) => closer?.()) - } - }, [event, enabled, browsingRelayUrls, userRelayList?.read, userBlockedRelaysNorm]) - - const loadMore = async () => { - if (!timelineKey || loading || !hasMore) return - setLoading(true) - try { - const newEvents = await client.loadMoreTimeline( - timelineKey, - events.length ? events[events.length - 1].created_at - 1 : dayjs().unix(), - LIMIT - ) - if (newEvents.length === 0) { - const until = events.length ? events[events.length - 1].created_at - 1 : dayjs().unix() - const hasMoreCached = client.hasMoreTimelineEvents?.(timelineKey, until) ?? false - if (!hasMoreCached) setHasMore(false) - } else { - setEvents((old) => [...old, ...newEvents]) - } - } catch { - setHasMore(false) - } finally { - setLoading(false) - } - } - - return { quoteEvents: events, quoteLoading: loading, quoteHasMore: hasMore, loadMoreQuotes: loadMore } -} diff --git a/src/lib/thread-interaction-req.test.ts b/src/lib/thread-interaction-req.test.ts new file mode 100644 index 00000000..8f6a9674 --- /dev/null +++ b/src/lib/thread-interaction-req.test.ts @@ -0,0 +1,50 @@ +import { ExtendedKind } from '@/constants' +import { buildThreadInteractionFilters } from '@/lib/thread-interaction-req' +import { kinds } from 'nostr-tools' +import { describe, expect, it } from 'vitest' + +const ROOT_HEX = 'a'.repeat(64) +const SNAP_HEX = 'b'.repeat(64) + +describe('buildThreadInteractionFilters', () => { + it('merges E-root thread filters into few tag-scoped REQs', () => { + const filters = buildThreadInteractionFilters({ + root: { type: 'E', id: ROOT_HEX, pubkey: 'c'.repeat(64) }, + opEventKind: kinds.ShortTextNote, + limit: 100 + }) + expect(filters.length).toBeLessThanOrEqual(4) + const eLower = filters.find((f) => f['#e']?.[0] === ROOT_HEX) + expect(eLower?.kinds).toContain(kinds.ShortTextNote) + expect(eLower?.kinds).toContain(kinds.Reaction) + expect(filters.some((f) => f['#q']?.includes(ROOT_HEX))).toBe(true) + }) + + it('adds public-message #q filter for kind 24 OP', () => { + const filters = buildThreadInteractionFilters({ + root: { type: 'E', id: ROOT_HEX, pubkey: 'c'.repeat(64) }, + opEventKind: ExtendedKind.PUBLIC_MESSAGE, + limit: 50 + }) + expect( + filters.some( + (f) => f['#q']?.[0] === ROOT_HEX && f.kinds?.length === 1 && f.kinds[0] === ExtendedKind.PUBLIC_MESSAGE + ) + ).toBe(true) + }) + + it('includes snapshot #e filters for replaceable A-root', () => { + const filters = buildThreadInteractionFilters({ + root: { + type: 'A', + id: `30023:${'c'.repeat(64)}:note`, + eventId: SNAP_HEX, + pubkey: 'c'.repeat(64) + }, + opEventKind: kinds.LongFormArticle, + limit: 80 + }) + expect(filters.some((f) => f['#e']?.[0] === SNAP_HEX)).toBe(true) + expect(filters.some((f) => f['#a']?.length === 1)).toBe(true) + }) +}) diff --git a/src/lib/thread-interaction-req.ts b/src/lib/thread-interaction-req.ts new file mode 100644 index 00000000..b06b0a75 --- /dev/null +++ b/src/lib/thread-interaction-req.ts @@ -0,0 +1,98 @@ +import { + ExtendedKind, + NOTE_STATS_OP_REFERENCE_KINDS, + NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT +} from '@/constants' +import { buildRssArticleUrlThreadInteractionFilters } from '@/lib/rss-web-feed' +import { kinds, type Filter } from 'nostr-tools' + +/** Thread root shapes used by {@link buildThreadInteractionFilters} (matches ReplyNoteList `rootInfo`). */ +export type ThreadInteractionRootInfo = + | { type: 'E'; id: string; pubkey: string } + | { type: 'A'; id: string; eventId: string; pubkey: string; relay?: string } + | { type: 'I'; id: string } + +function sortedUniqueKinds(kindsList: readonly number[]): number[] { + return Array.from(new Set(kindsList)).sort((a, b) => a - b) +} + +export type BuildThreadInteractionFiltersInput = { + root: ThreadInteractionRootInfo + /** Kind of the note/article the user opened (affects zap inclusion). */ + opEventKind: number + limit: number +} + +/** + * One relay wave per thread: minimal tag-scoped filters with merged `kinds` arrays. + * 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 isZapPoll = opEventKind === ExtendedKind.ZAP_POLL + + const kindsNoteCommentVoiceZap = sortedUniqueKinds([ + kinds.ShortTextNote, + ExtendedKind.COMMENT, + ExtendedKind.VOICE_COMMENT, + kinds.Zap + ]) + const kindsNoteCommentVoice = sortedUniqueKinds([ + kinds.ShortTextNote, + ExtendedKind.COMMENT, + ExtendedKind.VOICE_COMMENT + ]) + const kindsPrimaryThread = isZapPoll ? kindsNoteCommentVoice : kindsNoteCommentVoiceZap + const kindsUpperEThread = sortedUniqueKinds( + isZapPoll + ? [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT] + : [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap] + ) + + const kindsOnETag = sortedUniqueKinds([ + ...kindsPrimaryThread, + kinds.Reaction, + ...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT + ]) + const kindsOnUpperETag = sortedUniqueKinds([ + ...kindsUpperEThread, + ...NOTE_STATS_OP_REFERENCE_KINDS_WITHOUT_HIGHLIGHT + ]) + const kindsOnQTag = sortedUniqueKinds([ + kinds.ShortTextNote, + ExtendedKind.COMMENT, + ExtendedKind.VOICE_COMMENT, + ...NOTE_STATS_OP_REFERENCE_KINDS + ]) + + if (root.type === 'I') { + return buildRssArticleUrlThreadInteractionFilters(root.id, limit) + } + + 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 }) + if (opEventKind === ExtendedKind.PUBLIC_MESSAGE) { + filters.push({ '#q': [root.id], kinds: [ExtendedKind.PUBLIC_MESSAGE], limit }) + } + return filters + } + + filters.push({ '#a': [root.id], kinds: kindsOnETag, limit }) + filters.push({ '#A': [root.id], kinds: kindsOnUpperETag, limit }) + if (/^[0-9a-f]{64}$/i.test(root.eventId)) { + const eSnap = root.eventId.trim().toLowerCase() + filters.push({ '#e': [eSnap], kinds: kindsOnETag, limit }) + filters.push({ '#E': [eSnap], kinds: kindsOnUpperETag, limit }) + } + const qVals = Array.from( + new Set([root.eventId, root.id].map((x) => (typeof x === 'string' ? x.trim() : '')).filter(Boolean)) + ) + if (qVals.length > 0) { + filters.push({ '#q': qVals, kinds: kindsOnQTag, limit }) + } + return filters +}