6 changed files with 183 additions and 471 deletions
@ -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 } |
|
||||||
} |
|
||||||
@ -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) |
||||||
|
}) |
||||||
|
}) |
||||||
@ -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…
Reference in new issue