Browse Source

handle reply threads better

imwald
Silberengel 3 months ago
parent
commit
13f6b35e05
  1. 4
      src/components/ContentPreview/index.tsx
  2. 6
      src/components/Note/NoteKindLabel.tsx
  3. 6
      src/components/Note/UnknownNote.tsx
  4. 2
      src/components/Note/index.tsx
  5. 2
      src/components/ReplyNote/index.tsx
  6. 128
      src/components/ReplyNoteList/index.tsx
  7. 14
      src/hooks/useQuoteEvents.tsx
  8. 137
      src/lib/event.ts
  9. 16
      src/lib/kind-description.ts
  10. 7
      src/lib/thread-reply-root-match.ts
  11. 11
      src/providers/ReplyProvider.tsx
  12. 19
      src/services/client-events.service.ts

4
src/components/ContentPreview/index.tsx

@ -106,7 +106,7 @@ export default function ContentPreview({
const withKindRow = (node: React.ReactNode) => ( const withKindRow = (node: React.ReactNode) => (
<div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}> <div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}>
<NoteKindLabel kind={event.kind} size="small" /> <NoteKindLabel kind={event.kind} event={event} size="small" />
<div className={cn('min-w-0', previewBody)}>{node}</div> <div className={cn('min-w-0', previewBody)}>{node}</div>
</div> </div>
) )
@ -127,7 +127,7 @@ export default function ContentPreview({
if (event.kind === ExtendedKind.DISCUSSION) { if (event.kind === ExtendedKind.DISCUSSION) {
return ( return (
<div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}> <div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}>
<NoteKindLabel kind={event.kind} size="small" /> <NoteKindLabel kind={event.kind} event={event} size="small" />
<div className={cn('min-w-0', previewBody)}> <div className={cn('min-w-0', previewBody)}>
<DiscussionNote event={event} size="small" /> <DiscussionNote event={event} size="small" />
</div> </div>

6
src/components/Note/NoteKindLabel.tsx

@ -1,18 +1,22 @@
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { getKindDescription } from '@/lib/kind-description' import { getKindDescription } from '@/lib/kind-description'
import type { Event } from 'nostr-tools'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
export default function NoteKindLabel({ export default function NoteKindLabel({
kind, kind,
event,
className, className,
size = 'normal' size = 'normal'
}: { }: {
kind: number kind: number
/** When set, kind 1 can show “Quote Note” for NIP-18 `q`-only notes. */
event?: Event
className?: string className?: string
size?: 'normal' | 'small' size?: 'normal' | 'small'
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { description } = getKindDescription(kind) const { description } = getKindDescription(kind, event)
return ( return (
<p <p

6
src/components/Note/UnknownNote.tsx

@ -161,7 +161,7 @@ export default function UnknownNote({
.join(' ') .join(' ')
} }
const kindLabel = getKindDescription(event.kind) const kindLabel = getKindDescription(event.kind, event)
const contentRaw = event.content?.trim() ?? '' const contentRaw = event.content?.trim() ?? ''
const elevated = useMemo(() => extractElevatedTags(event.tags), [event.tags]) const elevated = useMemo(() => extractElevatedTags(event.tags), [event.tags])
@ -226,7 +226,9 @@ export default function UnknownNote({
<div> <div>
<h3 className="text-base font-semibold leading-tight text-foreground">{headline}</h3> <h3 className="text-base font-semibold leading-tight text-foreground">{headline}</h3>
{!omitKindLabel ? <NoteKindLabel kind={event.kind} size="small" className="mt-1" /> : null} {!omitKindLabel ? (
<NoteKindLabel kind={event.kind} event={event} size="small" className="mt-1" />
) : null}
{elevated.title?.trim() && !omitKindLabel ? ( {elevated.title?.trim() && !omitKindLabel ? (
<p className="mt-0.5 text-xs text-muted-foreground"> <p className="mt-0.5 text-xs text-muted-foreground">
<span className="text-foreground/80">{kindLabel.description}</span> <span className="text-foreground/80">{kindLabel.description}</span>

2
src/components/Note/index.tsx

@ -450,7 +450,7 @@ export default function Note({
)} )}
</div> </div>
</div> </div>
<NoteKindLabel kind={event.kind} size={size} className="mt-1" /> <NoteKindLabel kind={event.kind} event={event} size={size} className="mt-1" />
{webReactionParentUrl ? ( {webReactionParentUrl ? (
<div className="mt-2 not-prose max-w-full" data-parent-note-preview> <div className="mt-2 not-prose max-w-full" data-parent-note-preview>
<WebPreview url={webReactionParentUrl} className="w-full" /> <WebPreview url={webReactionParentUrl} className="w-full" />

2
src/components/ReplyNote/index.tsx

@ -125,7 +125,7 @@ export default function ReplyNote({
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" /> <NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
</div> </div>
</div> </div>
<NoteKindLabel kind={event.kind} size="small" className="mt-0.5" /> <NoteKindLabel kind={event.kind} event={event} size="small" className="mt-0.5" />
{webReactionParentUrl ? ( {webReactionParentUrl ? (
<div className="mt-2 not-prose max-w-full" data-parent-note-preview> <div className="mt-2 not-prose max-w-full" data-parent-note-preview>
<WebPreview url={webReactionParentUrl} className="w-full" /> <WebPreview url={webReactionParentUrl} className="w-full" />

128
src/components/ReplyNoteList/index.tsx

@ -7,6 +7,7 @@ import {
} from '@/lib/rss-article' } from '@/lib/rss-article'
import { import {
eventReferencesEventId, eventReferencesEventId,
getParentATag,
getParentETag, getParentETag,
getReplaceableCoordinateFromEvent, getReplaceableCoordinateFromEvent,
getRootATag, getRootATag,
@ -15,7 +16,8 @@ import {
isMentioningMutedUsers, isMentioningMutedUsers,
isNip25ReactionKind, isNip25ReactionKind,
isReplaceableEvent, isReplaceableEvent,
isReplyNoteEvent isReplyNoteEvent,
kind1QuotesThreadRoot
} from '@/lib/event' } from '@/lib/event'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getZapInfoFromEvent } from '@/lib/event-metadata'
@ -81,6 +83,29 @@ function replyFeedZapsFirst(sortedNonZapReplies: NEvent[], zaps: NEvent[]) {
return [...sortZapReceiptsBySatsDesc(zaps), ...sortedNonZapReplies] return [...sortZapReceiptsBySatsDesc(zaps), ...sortedNonZapReplies]
} }
/** Shown after thread replies for E/A roots (quote stream + kind 1 #q-only). */
const EA_THREAD_TAIL_REFERENCE_KINDS = new Set<number>([
kinds.Highlights,
kinds.LongFormArticle,
ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN,
ExtendedKind.PUBLICATION_CONTENT
])
/** Web (NIP-22) thread: tail = reference-style rows + URL-scoped reactions (same block order as E/A). */
const WEB_THREAD_EXTRA_TAIL_KINDS = new Set<number>([kinds.Reaction, ExtendedKind.EXTERNAL_REACTION])
function isWebThreadTailKind(kind: number): boolean {
return EA_THREAD_TAIL_REFERENCE_KINDS.has(kind) || WEB_THREAD_EXTRA_TAIL_KINDS.has(kind)
}
function isKind1QuoteOnlyOfEaRoot(evt: NEvent, root: TRootInfo): boolean {
if (root.type === 'I') return false
if (evt.kind !== kinds.ShortTextNote) return false
if (getParentETag(evt) || getParentATag(evt)) return false
return kind1QuotesThreadRoot(evt, root)
}
function ReplyNoteList({ function ReplyNoteList({
index, index,
event, event,
@ -296,8 +321,21 @@ function ReplyNoteList({
]) ])
const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies]) const replyIdSet = useMemo(() => new Set(replies.map((r) => r.id)), [replies])
/** Events that quote the note (from useQuoteEvents) — render with quote styling and without embedded quote. */ /** Render with quote card chrome (tail stream + kind 1 #q-only of E/A root). */
const quoteIdSet = useMemo(() => new Set(quoteEvents.map((e) => e.id)), [quoteEvents]) const quoteUiIdSet = useMemo(() => {
const s = new Set(quoteEvents.map((e) => e.id))
if (rootInfo?.type === 'E' || rootInfo?.type === 'A') {
for (const r of replies) {
if (isKind1QuoteOnlyOfEaRoot(r, rootInfo)) s.add(r.id)
}
}
if (rootInfo?.type === 'I') {
for (const r of replies) {
if (EA_THREAD_TAIL_REFERENCE_KINDS.has(r.kind)) s.add(r.id)
}
}
return s
}, [quoteEvents, replies, rootInfo])
const mergedFeed = useMemo(() => { const mergedFeed = useMemo(() => {
/** Quotes + time-sorted feeds must not interleave zap receipts chronologically */ /** Quotes + time-sorted feeds must not interleave zap receipts chronologically */
const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => { const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => {
@ -309,7 +347,45 @@ function ReplyNoteList({
} }
if (!showQuotes) return replies if (!showQuotes) return replies
const quoteOnly = quoteEvents.filter((e) => !replyIdSet.has(e.id)) const quoteOnly = quoteEvents.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)
const middle = nonZaps.filter((e) => !isKind1QuoteOnlyOfEaRoot(e, rootInfo))
const qOnlyFromReplies = nonZaps.filter((e) => isKind1QuoteOnlyOfEaRoot(e, rootInfo))
const tailSeen = new Set<string>()
const tail: NEvent[] = []
const pushTail = (e: NEvent) => {
if (tailSeen.has(e.id)) return
tailSeen.add(e.id)
tail.push(e)
}
for (const e of qOnlyFromReplies) pushTail(e)
for (const e of quoteOnly) pushTail(e)
tail.sort((a, b) => b.created_at - a.created_at)
return [...replyFeedZapsFirst(middle, zaps), ...tail]
}
// Web article / URL thread (NIP-22): same zaps → middle → tail layout as E/A
if (rootInfo?.type === 'I') {
const { zaps, nonZaps } = partitionZapReceipts(replies)
const middle = nonZaps.filter((e) => !isWebThreadTailKind(e.kind))
const tailFromReplies = nonZaps.filter((e) => isWebThreadTailKind(e.kind))
const tailSeen = new Set<string>()
const tail: NEvent[] = []
const pushTail = (e: NEvent) => {
if (tailSeen.has(e.id)) return
tailSeen.add(e.id)
tail.push(e)
}
for (const e of tailFromReplies) pushTail(e)
for (const e of quoteOnly) pushTail(e)
tail.sort((a, b) => b.created_at - a.created_at)
return [...replyFeedZapsFirst(middle, zaps), ...tail]
}
const merged = [...replies, ...quoteOnly] const merged = [...replies, ...quoteOnly]
if (sort === 'oldest') return zapsThenTimeSorted(merged, 'asc') if (sort === 'oldest') return zapsThenTimeSorted(merged, 'asc')
if (sort === 'newest') return zapsThenTimeSorted(merged, 'desc') if (sort === 'newest') return zapsThenTimeSorted(merged, 'desc')
@ -321,7 +397,7 @@ function ReplyNoteList({
return [...sortedReplies, ...sortedQuotes] return [...sortedReplies, ...sortedQuotes]
} }
return zapsThenTimeSorted(merged, 'desc') return zapsThenTimeSorted(merged, 'desc')
}, [replies, quoteEvents, showQuotes, sort, replyIdSet]) }, [replies, quoteEvents, showQuotes, sort, replyIdSet, rootInfo])
const [timelineKey] = useState<string | undefined>(undefined) const [timelineKey] = useState<string | undefined>(undefined)
const [until, setUntil] = useState<number | undefined>(undefined) const [until, setUntil] = useState<number | undefined>(undefined)
@ -468,10 +544,8 @@ function ReplyNoteList({
const handleEventPublished = (data: Event) => { const handleEventPublished = (data: Event) => {
const ce = data as CustomEvent<NEvent> const ce = data as CustomEvent<NEvent>
const evt = ce.detail const evt = ce.detail
if (!evt || !isReplyNoteEvent(evt)) return if (!evt || !eventReplyMatchesThreadRoot(evt, rootInfo)) return
if (eventReplyMatchesThreadRoot(evt, rootInfo)) { onNewReply(evt)
onNewReply(evt)
}
} }
client.addEventListener('newEvent', handleEventPublished) client.addEventListener('newEvent', handleEventPublished)
@ -577,6 +651,20 @@ function ReplyNoteList({
limit: LIMIT 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: [kinds.ShortTextNote],
limit: LIMIT
})
}
if (rootInfo.relay) { if (rootInfo.relay) {
finalRelayUrls.push(rootInfo.relay) finalRelayUrls.push(rootInfo.relay)
} }
@ -593,7 +681,9 @@ function ReplyNoteList({
const regularReplies = allReplies.filter((evt) => const regularReplies = allReplies.filter((evt) =>
rootInfo.type === 'I' rootInfo.type === 'I'
? isRssArticleUrlThreadInteraction(evt, rootInfo.id) ? isRssArticleUrlThreadInteraction(evt, rootInfo.id)
: isReplyNoteEvent(evt) : isReplyNoteEvent(evt) ||
((rootInfo.type === 'E' || rootInfo.type === 'A') &&
kind1QuotesThreadRoot(evt, rootInfo))
) )
// Store in cache (this merges with existing cached replies) // Store in cache (this merges with existing cached replies)
@ -679,13 +769,19 @@ function ReplyNoteList({
setLoading(true) setLoading(true)
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT) const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
const olderEvents = events.filter((evt) => isReplyNoteEvent(evt)) const olderEvents = events.filter(
(evt) =>
isReplyNoteEvent(evt) ||
((rootInfo?.type === 'E' || rootInfo?.type === 'A') &&
rootInfo &&
kind1QuotesThreadRoot(evt, rootInfo))
)
if (olderEvents.length > 0) { if (olderEvents.length > 0) {
addReplies(olderEvents) addReplies(olderEvents)
} }
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined) setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined)
setLoading(false) setLoading(false)
}, [loading, until, timelineKey]) }, [loading, until, timelineKey, rootInfo?.type, rootInfo?.id])
const highlightReply = useCallback((eventId: string, scrollTo = true) => { const highlightReply = useCallback((eventId: string, scrollTo = true) => {
if (scrollTo) { if (scrollTo) {
@ -716,7 +812,7 @@ function ReplyNoteList({
)} )}
<div> <div>
{mergedFeed.slice(0, showCount).map((item) => { {mergedFeed.slice(0, showCount).map((item) => {
const isQuote = quoteIdSet.has(item.id) const isQuote = quoteUiIdSet.has(item.id)
// Don't filter by trust until trust data is loaded - prevents replies from // Don't filter by trust until trust data is loaded - prevents replies from
// vanishing when wotSet is still empty (all non-self appear untrusted) // vanishing when wotSet is still empty (all non-self appear untrusted)
if (isTrustLoaded && hideUntrustedInteractions && !isUserTrusted(item.pubkey)) { if (isTrustLoaded && hideUntrustedInteractions && !isUserTrusted(item.pubkey)) {
@ -739,9 +835,11 @@ function ReplyNoteList({
const quoteLabel = const quoteLabel =
item.kind === kinds.Highlights item.kind === kinds.Highlights
? t('highlighted this note') ? t('highlighted this note')
: item.kind === kinds.LongFormArticle : item.kind === kinds.ShortTextNote
? t('cited in article') ? t('quoted this note')
: t('quoted this note') : EA_THREAD_TAIL_REFERENCE_KINDS.has(item.kind)
? t('cited in article')
: t('quoted this note')
const hideQuotedNote = eventReferencesEventId(item, event) const hideQuotedNote = eventReferencesEventId(item, event)
return ( return (
<SuppressEmbeddedNoteContext.Provider <SuppressEmbeddedNoteContext.Provider

14
src/hooks/useQuoteEvents.tsx

@ -1,5 +1,6 @@
import { import {
E_TAG_FILTER_BLOCKED_RELAY_URLS, E_TAG_FILTER_BLOCKED_RELAY_URLS,
ExtendedKind,
FAST_READ_RELAY_URLS, FAST_READ_RELAY_URLS,
SEARCHABLE_RELAY_URLS SEARCHABLE_RELAY_URLS
} from '@/constants' } from '@/constants'
@ -15,6 +16,15 @@ import { useEffect, useRef, useState } from 'react'
const LIMIT = 100 const LIMIT = 100
const INITIAL_QUOTE_LOAD_TIMEOUT_MS = 12_000 const INITIAL_QUOTE_LOAD_TIMEOUT_MS = 12_000
/** Kinds that reference the OP via #e / #a in the quote shard (with highlights). */
const QUOTE_STREAM_REFERENCE_KINDS: number[] = [
kinds.Highlights,
kinds.LongFormArticle,
ExtendedKind.WIKI_ARTICLE,
ExtendedKind.WIKI_ARTICLE_MARKDOWN,
ExtendedKind.PUBLICATION_CONTENT
]
/** Fetches events that quote or reference the given event (#q, #e, #a tags). */ /** Fetches events that quote or reference the given event (#q, #e, #a tags). */
export function useQuoteEvents(event: Event | null, enabled: boolean) { export function useQuoteEvents(event: Event | null, enabled: boolean) {
const { relayList: userRelayList } = useNostr() const { relayList: userRelayList } = useNostr()
@ -94,7 +104,7 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) {
urls: finalRelayUrls, urls: finalRelayUrls,
filter: { filter: {
'#e': [filterQeId], '#e': [filterQeId],
kinds: [kinds.Highlights, kinds.LongFormArticle], kinds: [...QUOTE_STREAM_REFERENCE_KINDS],
limit: LIMIT limit: LIMIT
} }
}, },
@ -102,7 +112,7 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) {
urls: finalRelayUrls, urls: finalRelayUrls,
filter: { filter: {
'#a': [eventCoordinate], '#a': [eventCoordinate],
kinds: [kinds.Highlights, kinds.LongFormArticle], kinds: [...QUOTE_STREAM_REFERENCE_KINDS],
limit: LIMIT limit: LIMIT
} }
} }

137
src/lib/event.ts

@ -24,7 +24,7 @@ const EVENT_EMBEDDED_NOTES_CACHE = new LRUCache<string, string[]>({ max: 10000 }
const EVENT_EMBEDDED_PUBKEYS_CACHE = new LRUCache<string, string[]>({ max: 10000 }) const EVENT_EMBEDDED_PUBKEYS_CACHE = new LRUCache<string, string[]>({ max: 10000 })
const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache<string, boolean>({ max: 10000 }) const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache<string, boolean>({ max: 10000 })
/** Bump when isReplyNoteEvent logic changes so cached booleans are not stale. */ /** Bump when isReplyNoteEvent logic changes so cached booleans are not stale. */
const IS_REPLY_NOTE_CACHE_KEY_SUFFIX = ':v2' const IS_REPLY_NOTE_CACHE_KEY_SUFFIX = ':v3'
export function isNsfwEvent(event: Event) { export function isNsfwEvent(event: Event) {
return event.tags.some( return event.tags.some(
@ -49,22 +49,12 @@ export function isReplyNoteEvent(event: Event) {
const cache = EVENT_IS_REPLY_NOTE_CACHE.get(cacheKey) const cache = EVENT_IS_REPLY_NOTE_CACHE.get(cacheKey)
if (cache !== undefined) return cache if (cache !== undefined) return cache
// Include #q (quote) — many clients omit e-tags on quote-only notes; they still belong in the thread. // NIP-18 `q` without `e`/`a` is a quote note (top-level for OP vs reply filters), not a thread reply.
const isReply = const isReply = !!getParentETag(event) || !!getParentATag(event)
!!getParentETag(event) ||
!!getParentATag(event) ||
!!getQuotedEventHexIdFromQTags(event)
EVENT_IS_REPLY_NOTE_CACHE.set(cacheKey, isReply) EVENT_IS_REPLY_NOTE_CACHE.set(cacheKey, isReply)
return isReply return isReply
} }
/** First hex event id from `q` / `Q` tags (NIP-18 quote). */
export function getQuotedEventHexIdFromQTags(event: Event): string | undefined {
const q = event.tags.find((t) => t[0] === 'q' || t[0] === 'Q')?.[1]
if (q && /^[0-9a-f]{64}$/i.test(q)) return q.toLowerCase()
return undefined
}
export function isReplaceableEvent(kind: number) { export function isReplaceableEvent(kind: number) {
return ( return (
kinds.isReplaceableKind(kind) || kinds.isReplaceableKind(kind) ||
@ -248,20 +238,28 @@ export function eventReferencesEventId(
? getReplaceableCoordinateFromEvent(targetEvent) ? getReplaceableCoordinateFromEvent(targetEvent)
: undefined : undefined
const qRef = getQuotedReferenceFromQTags(event)
if (targetHexId) { if (targetHexId) {
const rootId = getRootETag(event)?.[1]?.toLowerCase() const rootId = getRootETag(event)?.[1]?.toLowerCase()
if (rootId === targetHexId) return true if (rootId === targetHexId) return true
const parentId = getParentETag(event)?.[1]?.toLowerCase() const parentId = getParentETag(event)?.[1]?.toLowerCase()
if (parentId === targetHexId) return true if (parentId === targetHexId) return true
const qTag = event.tags.find((t) => t[0] === 'q' || t[0] === 'Q')?.[1]?.toLowerCase() if (qRef?.hexId === targetHexId) return true
if (qTag === targetHexId) return true
const eTags = event.tags.filter((t) => t[0] === 'e' || t[0] === 'E') const eTags = event.tags.filter((t) => t[0] === 'e' || t[0] === 'E')
if (eTags.some((t) => t[1]?.toLowerCase() === targetHexId)) return true if (eTags.some((t) => t[1]?.toLowerCase() === targetHexId)) return true
} }
if (targetCoordinate) { if (targetCoordinate) {
const targetCoordNorm = normalizeReplaceableCoordinateString(targetCoordinate)
const aTags = event.tags.filter((t) => t[0] === 'a' || t[0] === 'A') const aTags = event.tags.filter((t) => t[0] === 'a' || t[0] === 'A')
if (aTags.some((t) => t[1]?.toLowerCase() === targetCoordinate.toLowerCase())) return true if (aTags.some((t) => normalizeReplaceableCoordinateString(t[1] ?? '') === targetCoordNorm)) return true
if (
qRef?.coordinate &&
normalizeReplaceableCoordinateString(qRef.coordinate) === targetCoordNorm
) {
return true
}
} }
return false return false
@ -288,6 +286,113 @@ export function getReplaceableCoordinateFromEvent(event: Event) {
return getReplaceableCoordinate(event.kind, event.pubkey, d) return getReplaceableCoordinate(event.kind, event.pubkey, d)
} }
/** Normalize `kind:pubkey:d` for comparisons (lowercase pubkey; preserve d). */
export function normalizeReplaceableCoordinateString(coord: string): string {
const m = /^(\d+):([0-9a-f]{64}):(.*)$/i.exec(coord.trim())
if (!m) return coord.trim().toLowerCase()
return getReplaceableCoordinate(Number(m[1]), m[2].toLowerCase(), m[3])
}
function stripNostrUriScheme(s: string): string {
const t = s.trim()
if (t.toLowerCase().startsWith('nostr:')) return t.slice(6).trim()
return t
}
/**
* NIP-10 / NIP-18: `q` tag value is `<event-id>` or `<event-address>` (coordinate), or NIP-19 bech32.
*/
export function parseQTagReferenceValue(
raw: string | undefined | null
): { hexId?: string; coordinate?: string } | undefined {
if (raw == null) return undefined
const s0 = stripNostrUriScheme(raw)
if (!s0) return undefined
if (/^[0-9a-f]{64}$/i.test(s0)) {
return { hexId: s0.toLowerCase() }
}
const coordMatch = /^(\d+):([0-9a-f]{64}):(.*)$/i.exec(s0)
if (coordMatch) {
return {
coordinate: getReplaceableCoordinate(
Number(coordMatch[1]),
coordMatch[2].toLowerCase(),
coordMatch[3]
)
}
}
if (/^n(?:ote|event|addr)1/i.test(s0)) {
try {
const { type, data } = nip19.decode(s0)
if (type === 'note') {
const id = typeof data === 'string' ? data : (data as { id?: string }).id
if (id && /^[0-9a-f]{64}$/i.test(id)) return { hexId: id.toLowerCase() }
}
if (type === 'nevent') {
const id = (data as { id: string }).id
if (id && /^[0-9a-f]{64}$/i.test(id)) return { hexId: id.toLowerCase() }
}
if (type === 'naddr') {
const d = data as { kind: number; pubkey: string; identifier: string }
return {
coordinate: getReplaceableCoordinate(
d.kind,
d.pubkey.toLowerCase(),
d.identifier ?? ''
)
}
}
} catch {
/* invalid bech32 */
}
}
return undefined
}
/** Parsed first `q` / `Q` tag on the event (NIP-10). */
export function getQuotedReferenceFromQTags(event: Event): {
hexId?: string
coordinate?: string
} | undefined {
const q = event.tags.find((t) => t[0] === 'q' || t[0] === 'Q')?.[1]
return parseQTagReferenceValue(q)
}
/** Hex id from `q` when the reference resolves to a fixed id (not coordinate-only). */
export function getQuotedEventHexIdFromQTags(event: Event): string | undefined {
return getQuotedReferenceFromQTags(event)?.hexId
}
/** Kind 1 whose `q` points at this hex id (legacy helper). */
export function kind1QuotesEventHexId(event: Event, hexId: string): boolean {
if (event.kind !== kinds.ShortTextNote) return false
const ref = getQuotedReferenceFromQTags(event)
return !!ref?.hexId && ref.hexId === hexId.trim().toLowerCase()
}
/** Kind 1 quote-of-root: match `q` hex and/or replaceable coordinate (and bech32 decoding). */
export function kind1QuotesThreadRoot(
event: Event,
root: { type: 'E'; id: string } | { type: 'A'; id: string; eventId: string }
): boolean {
if (event.kind !== kinds.ShortTextNote) return false
const ref = getQuotedReferenceFromQTags(event)
if (!ref || (!ref.hexId && !ref.coordinate)) return false
if (root.type === 'E') {
const rid = root.id.trim().toLowerCase()
return !!ref.hexId && ref.hexId === rid
}
const eid = root.eventId.trim().toLowerCase()
const coordNorm = normalizeReplaceableCoordinateString(root.id)
if (ref.hexId && ref.hexId === eid) return true
if (ref.coordinate && normalizeReplaceableCoordinateString(ref.coordinate) === coordNorm) return true
return false
}
/** Whether an event matches a tombstone key from IndexedDB (e-tag id, a-tag coordinate, or k-tag kind:pubkey). */ /** Whether an event matches a tombstone key from IndexedDB (e-tag id, a-tag coordinate, or k-tag kind:pubkey). */
export function isTombstoneKeyForEvent(event: Event, tombstones: Set<string>): boolean { export function isTombstoneKeyForEvent(event: Event, tombstones: Set<string>): boolean {
if (tombstones.has(event.id)) return true if (tombstones.has(event.id)) return true

16
src/lib/kind-description.ts

@ -1,14 +1,28 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { getParentATag, getParentETag, getQuotedReferenceFromQTags } from '@/lib/event'
import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
/** /**
* Get the description for a given kind number * Get the description for a given kind number
* @param kind - The kind number * @param kind - The kind number
* @param event - When set, refines kind 1 (e.g. NIP-18 quote without thread parent "Quote Note")
* @returns An object with the kind number and description * @returns An object with the kind number and description
*/ */
export function getKindDescription(kind: number): { number: number; description: string } { export function getKindDescription(
kind: number,
event?: Event
): { number: number; description: string } {
switch (kind) { switch (kind) {
case kinds.ShortTextNote: case kinds.ShortTextNote:
if (
event &&
getQuotedReferenceFromQTags(event) &&
!getParentETag(event) &&
!getParentATag(event)
) {
return { number: 1, description: 'Quote Note' }
}
return { number: 1, description: 'Short Text Note' } return { number: 1, description: 'Short Text Note' }
case ExtendedKind.COMMENT: case ExtendedKind.COMMENT:
return { number: 1111, description: 'Comment' } return { number: 1111, description: 'Comment' }

7
src/lib/thread-reply-root-match.ts

@ -1,4 +1,4 @@
import { getRootATag, getRootEventHexId } from '@/lib/event' import { getRootATag, getRootEventHexId, kind1QuotesThreadRoot } from '@/lib/event'
import { import {
canonicalizeRssArticleUrl, canonicalizeRssArticleUrl,
getArticleUrlFromCommentITags, getArticleUrlFromCommentITags,
@ -29,7 +29,8 @@ export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): b
if (coord === root.id) return true if (coord === root.id) return true
const rootHex = getRootEventHexId(evt) const rootHex = getRootEventHexId(evt)
if (rootHex && (rootHex === root.eventId || rootHex === root.id)) return true if (rootHex && (rootHex === root.eventId || rootHex === root.id)) return true
return false return kind1QuotesThreadRoot(evt, root)
} }
return getRootEventHexId(evt) === root.id if (getRootEventHexId(evt) === root.id) return true
return kind1QuotesThreadRoot(evt, root)
} }

11
src/providers/ReplyProvider.tsx

@ -6,7 +6,7 @@ import {
import { import {
getParentATag, getParentATag,
getParentETag, getParentETag,
getQuotedEventHexIdFromQTags, getQuotedReferenceFromQTags,
getRootATag, getRootATag,
getRootETag, getRootETag,
isNip25ReactionKind isNip25ReactionKind
@ -80,11 +80,12 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) {
newReplyEventMap.set(parentId, [...(newReplyEventMap.get(parentId) || []), reply]) newReplyEventMap.set(parentId, [...(newReplyEventMap.get(parentId) || []), reply])
} }
// Quote-only notes (#q, no e-tags): still index under the quoted event id. // Quote-only notes (#q, no e-tags): index under quoted hex id and/or replaceable coordinate.
if (!rootId && !parentId) { if (!rootId && !parentId) {
const qid = getQuotedEventHexIdFromQTags(reply) const qref = getQuotedReferenceFromQTags(reply)
if (qid) { const keys = new Set([qref?.hexId, qref?.coordinate].filter(Boolean) as string[])
newReplyEventMap.set(qid, [...(newReplyEventMap.get(qid) || []), reply]) for (const key of keys) {
newReplyEventMap.set(key, [...(newReplyEventMap.get(key) || []), reply])
} }
} }
}) })

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

@ -3,12 +3,13 @@ import logger from '@/lib/logger'
import { import {
getParentATag, getParentATag,
getParentETag, getParentETag,
getQuotedEventHexIdFromQTags, getQuotedReferenceFromQTags,
getRootATag, getRootATag,
getRootETag, getRootETag,
isNip25ReactionKind, isNip25ReactionKind,
isReplyNoteEvent, isReplyNoteEvent,
isReplaceableEvent isReplaceableEvent,
kind1QuotesThreadRoot
} from '@/lib/event' } from '@/lib/event'
import { getFirstHexEventIdFromETags } from '@/lib/tag' import { getFirstHexEventIdFromETags } from '@/lib/tag'
import type { Event as NEvent, Filter } from 'nostr-tools' import type { Event as NEvent, Filter } from 'nostr-tools'
@ -443,7 +444,9 @@ export class EventService {
* Reply-shaped events already in the session LRU for this thread (notes, kind 1111, voice comments, zaps), * Reply-shaped events already in the session LRU for this thread (notes, kind 1111, voice comments, zaps),
* found by BFS over e/E/q and (for `a`-root threads) a-tag links. Merges with relay fetches via ReplyProvider. * 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' | 'A' | 'I'; id: string }): NEvent[] { getSessionThreadInteractionEvents(
root: { type: 'E'; id: string } | { type: 'A'; id: string; eventId: string } | { type: 'I'; id: string }
): NEvent[] {
if (root.type === 'I') return [] if (root.type === 'I') return []
const threadKeys = new Set<string>() const threadKeys = new Set<string>()
@ -453,6 +456,8 @@ export class EventService {
threadKeys.add(id) threadKeys.add(id)
} else { } else {
threadKeys.add(root.id.trim().toLowerCase()) threadKeys.add(root.id.trim().toLowerCase())
const aid = root.eventId.trim().toLowerCase()
if (/^[0-9a-f]{64}$/.test(aid)) threadKeys.add(aid)
} }
const linkRefs = (ev: NEvent): string[] => { const linkRefs = (ev: NEvent): string[] => {
@ -463,7 +468,9 @@ export class EventService {
} }
add(getParentETag(ev)?.[1]) add(getParentETag(ev)?.[1])
add(getRootETag(ev)?.[1]) add(getRootETag(ev)?.[1])
add(getQuotedEventHexIdFromQTags(ev)) const qref = getQuotedReferenceFromQTags(ev)
add(qref?.hexId)
add(qref?.coordinate)
if (ev.kind === kinds.Zap) { if (ev.kind === kinds.Zap) {
add(getFirstHexEventIdFromETags(ev.tags)) add(getFirstHexEventIdFromETags(ev.tags))
} }
@ -493,7 +500,9 @@ export class EventService {
let added = 0 let added = 0
for (const [, ev] of this.sessionEventCache.entries()) { for (const [, ev] of this.sessionEventCache.entries()) {
if (shouldDropEventOnIngest(ev)) continue if (shouldDropEventOnIngest(ev)) continue
if (!isReplyNoteEvent(ev)) continue const threadishKind1Quote =
(root.type === 'E' || root.type === 'A') && kind1QuotesThreadRoot(ev, root)
if (!isReplyNoteEvent(ev) && !threadishKind1Quote) continue
if (isNip25ReactionKind(ev.kind)) continue if (isNip25ReactionKind(ev.kind)) continue
if (seen.has(ev.id)) continue if (seen.has(ev.id)) continue
if (!linkRefs(ev).some((id) => threadKeys.has(id))) continue if (!linkRefs(ev).some((id) => threadKeys.has(id))) continue

Loading…
Cancel
Save