Browse Source

more efficient note threads

imwald
Silberengel 4 weeks ago
parent
commit
7640ab05d7
  1. 269
      src/components/ReplyNoteList/index.tsx
  2. 2
      src/constants.ts
  3. 1
      src/hooks/index.tsx
  4. 234
      src/hooks/useQuoteEvents.tsx
  5. 50
      src/lib/thread-interaction-req.test.ts
  6. 98
      src/lib/thread-interaction-req.ts

269
src/components/ReplyNoteList/index.tsx

@ -1,8 +1,4 @@ @@ -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' @@ -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({ @@ -380,20 +369,6 @@ function ReplyNoteList({
const { relayUrls: browsingRelayUrls } = useCurrentRelays()
const [rootInfo, setRootInfo] = useState<TRootInfo | undefined>(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({ @@ -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<string>()
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({ @@ -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({ @@ -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({ @@ -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({ @@ -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<Set<string>>(new Set())
@ -783,8 +742,6 @@ function ReplyNoteList({ @@ -783,8 +742,6 @@ function ReplyNoteList({
parentNoteFeed?.pendingPubkeys
])
const [timelineKey] = useState<string | undefined>(undefined)
const [until, setUntil] = useState<number | undefined>(undefined)
const [loading, setLoading] = useState<boolean>(false)
const [showCount, setShowCount] = useState(SHOW_COUNT)
const [highlightReplyId, setHighlightReplyId] = useState<string | undefined>(undefined)
@ -1131,125 +1088,16 @@ function ReplyNoteList({ @@ -1131,125 +1088,16 @@ function ReplyNoteList({
}
}
const filters: Filter[] = []
const qKindsHex = Array.from(
new Set<number>([
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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -1660,14 +1472,6 @@ function ReplyNoteList({
<NoteFeedProfileContext.Provider value={threadNoteFeedProfileValue}>
<div className="min-h-[80vh] pb-12">
{loading && <LoadingBar />}
{!loading && until && (
<div
className={`text-sm text-center text-muted-foreground border-b py-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}
onClick={loadMore}
>
{t('load more older replies')}
</div>
)}
<div>
{displayRows.map((row, ri) => {
const prevRow = ri > 0 ? displayRows[ri - 1] : undefined
@ -1803,12 +1607,7 @@ function ReplyNoteList({ @@ -1803,12 +1607,7 @@ function ReplyNoteList({
)
})}
</div>
{quoteLoading && showQuotes && (
<div className="mt-4 space-y-2">
<ThreadQuoteBacklinkSkeleton />
</div>
)}
{!loading && !quoteLoading && (
{!loading && (
<div className="text-sm mt-2 mb-3 text-center text-muted-foreground">
{mergedFeed.length > 0 ? t('no more replies') : t('no replies')}
</div>

2
src/constants.ts

@ -636,7 +636,7 @@ export function isNip71ShortVideoKind(kind: number): boolean { @@ -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.
*/

1
src/hooks/index.tsx

@ -1,5 +1,4 @@ @@ -1,5 +1,4 @@
export * from './useFetchCalendarRsvps'
export * from './useQuoteEvents'
export * from './useFetchEvent'
export * from './useFetchFollowings'
export * from './useFetchNip05'

234
src/hooks/useQuoteEvents.tsx

@ -1,234 +0,0 @@ @@ -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<string | undefined>(undefined)
const [events, setEvents] = useState<Event[]>([])
const [loading, setLoading] = useState(true)
const [hasMore, setHasMore] = useState(true)
const receivedAnyQuotesRef = useRef(false)
const lastSubscribedEventIdRef = useRef<string | null>(null)
useEffect(() => {
if (!event || !enabled) {
setEvents([])
setLoading(false)
setHasMore(false)
lastSubscribedEventIdRef.current = null
return
}
const ev = event
let cancelled = false
let loadTimeoutId: ReturnType<typeof setTimeout> | 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<number>([
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 }
}

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

@ -0,0 +1,50 @@ @@ -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)
})
})

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

@ -0,0 +1,98 @@ @@ -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
}
Loading…
Cancel
Save