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