const evt = ce.detail
- if (!evt || !isReplyNoteEvent(evt)) return
- if (eventReplyMatchesThreadRoot(evt, rootInfo)) {
- onNewReply(evt)
- }
+ if (!evt || !eventReplyMatchesThreadRoot(evt, rootInfo)) return
+ onNewReply(evt)
}
client.addEventListener('newEvent', handleEventPublished)
@@ -577,6 +651,20 @@ function ReplyNoteList({
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) {
finalRelayUrls.push(rootInfo.relay)
}
@@ -593,7 +681,9 @@ function ReplyNoteList({
const regularReplies = allReplies.filter((evt) =>
rootInfo.type === 'I'
? 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)
@@ -679,13 +769,19 @@ function ReplyNoteList({
setLoading(true)
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) {
addReplies(olderEvents)
}
setUntil(events.length ? events[events.length - 1].created_at - 1 : undefined)
setLoading(false)
- }, [loading, until, timelineKey])
+ }, [loading, until, timelineKey, rootInfo?.type, rootInfo?.id])
const highlightReply = useCallback((eventId: string, scrollTo = true) => {
if (scrollTo) {
@@ -716,7 +812,7 @@ function ReplyNoteList({
)}
{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
// vanishing when wotSet is still empty (all non-self appear untrusted)
if (isTrustLoaded && hideUntrustedInteractions && !isUserTrusted(item.pubkey)) {
@@ -739,9 +835,11 @@ function ReplyNoteList({
const quoteLabel =
item.kind === kinds.Highlights
? t('highlighted this note')
- : item.kind === kinds.LongFormArticle
- ? t('cited in article')
- : t('quoted this note')
+ : item.kind === kinds.ShortTextNote
+ ? 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)
return (
({ max: 10000 }
const EVENT_EMBEDDED_PUBKEYS_CACHE = new LRUCache({ max: 10000 })
const EVENT_IS_REPLY_NOTE_CACHE = new LRUCache({ max: 10000 })
/** 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) {
return event.tags.some(
@@ -49,22 +49,12 @@ export function isReplyNoteEvent(event: Event) {
const cache = EVENT_IS_REPLY_NOTE_CACHE.get(cacheKey)
if (cache !== undefined) return cache
- // Include #q (quote) — many clients omit e-tags on quote-only notes; they still belong in the thread.
- const isReply =
- !!getParentETag(event) ||
- !!getParentATag(event) ||
- !!getQuotedEventHexIdFromQTags(event)
+ // NIP-18 `q` without `e`/`a` is a quote note (top-level for OP vs reply filters), not a thread reply.
+ const isReply = !!getParentETag(event) || !!getParentATag(event)
EVENT_IS_REPLY_NOTE_CACHE.set(cacheKey, 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) {
return (
kinds.isReplaceableKind(kind) ||
@@ -248,20 +238,28 @@ export function eventReferencesEventId(
? getReplaceableCoordinateFromEvent(targetEvent)
: undefined
+ const qRef = getQuotedReferenceFromQTags(event)
+
if (targetHexId) {
const rootId = getRootETag(event)?.[1]?.toLowerCase()
if (rootId === targetHexId) return true
const parentId = getParentETag(event)?.[1]?.toLowerCase()
if (parentId === targetHexId) return true
- const qTag = event.tags.find((t) => t[0] === 'q' || t[0] === 'Q')?.[1]?.toLowerCase()
- if (qTag === targetHexId) return true
+ if (qRef?.hexId === targetHexId) return true
const eTags = event.tags.filter((t) => t[0] === 'e' || t[0] === 'E')
if (eTags.some((t) => t[1]?.toLowerCase() === targetHexId)) return true
}
if (targetCoordinate) {
+ const targetCoordNorm = normalizeReplaceableCoordinateString(targetCoordinate)
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
@@ -288,6 +286,113 @@ export function getReplaceableCoordinateFromEvent(event: Event) {
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 `` or `` (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). */
export function isTombstoneKeyForEvent(event: Event, tombstones: Set): boolean {
if (tombstones.has(event.id)) return true
diff --git a/src/lib/kind-description.ts b/src/lib/kind-description.ts
index e7f68f19..195ed9c0 100644
--- a/src/lib/kind-description.ts
+++ b/src/lib/kind-description.ts
@@ -1,14 +1,28 @@
import { ExtendedKind } from '@/constants'
+import { getParentATag, getParentETag, getQuotedReferenceFromQTags } from '@/lib/event'
+import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
/**
* Get the description for a given 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
*/
-export function getKindDescription(kind: number): { number: number; description: string } {
+export function getKindDescription(
+ kind: number,
+ event?: Event
+): { number: number; description: string } {
switch (kind) {
case kinds.ShortTextNote:
+ if (
+ event &&
+ getQuotedReferenceFromQTags(event) &&
+ !getParentETag(event) &&
+ !getParentATag(event)
+ ) {
+ return { number: 1, description: 'Quote Note' }
+ }
return { number: 1, description: 'Short Text Note' }
case ExtendedKind.COMMENT:
return { number: 1111, description: 'Comment' }
diff --git a/src/lib/thread-reply-root-match.ts b/src/lib/thread-reply-root-match.ts
index 4042c164..ea667cb1 100644
--- a/src/lib/thread-reply-root-match.ts
+++ b/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 {
canonicalizeRssArticleUrl,
getArticleUrlFromCommentITags,
@@ -29,7 +29,8 @@ export function eventReplyMatchesThreadRoot(evt: Event, root: TThreadRootRef): b
if (coord === root.id) return true
const rootHex = getRootEventHexId(evt)
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)
}
diff --git a/src/providers/ReplyProvider.tsx b/src/providers/ReplyProvider.tsx
index 769234a4..c0892a8d 100644
--- a/src/providers/ReplyProvider.tsx
+++ b/src/providers/ReplyProvider.tsx
@@ -6,7 +6,7 @@ import {
import {
getParentATag,
getParentETag,
- getQuotedEventHexIdFromQTags,
+ getQuotedReferenceFromQTags,
getRootATag,
getRootETag,
isNip25ReactionKind
@@ -80,11 +80,12 @@ export function ReplyProvider({ children }: { children: React.ReactNode }) {
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) {
- const qid = getQuotedEventHexIdFromQTags(reply)
- if (qid) {
- newReplyEventMap.set(qid, [...(newReplyEventMap.get(qid) || []), reply])
+ const qref = getQuotedReferenceFromQTags(reply)
+ const keys = new Set([qref?.hexId, qref?.coordinate].filter(Boolean) as string[])
+ for (const key of keys) {
+ newReplyEventMap.set(key, [...(newReplyEventMap.get(key) || []), reply])
}
}
})
diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts
index bdf184a3..f6722d0b 100644
--- a/src/services/client-events.service.ts
+++ b/src/services/client-events.service.ts
@@ -3,12 +3,13 @@ import logger from '@/lib/logger'
import {
getParentATag,
getParentETag,
- getQuotedEventHexIdFromQTags,
+ getQuotedReferenceFromQTags,
getRootATag,
getRootETag,
isNip25ReactionKind,
isReplyNoteEvent,
- isReplaceableEvent
+ isReplaceableEvent,
+ kind1QuotesThreadRoot
} from '@/lib/event'
import { getFirstHexEventIdFromETags } from '@/lib/tag'
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),
* 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 []
const threadKeys = new Set()
@@ -453,6 +456,8 @@ export class EventService {
threadKeys.add(id)
} else {
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[] => {
@@ -463,7 +468,9 @@ export class EventService {
}
add(getParentETag(ev)?.[1])
add(getRootETag(ev)?.[1])
- add(getQuotedEventHexIdFromQTags(ev))
+ const qref = getQuotedReferenceFromQTags(ev)
+ add(qref?.hexId)
+ add(qref?.coordinate)
if (ev.kind === kinds.Zap) {
add(getFirstHexEventIdFromETags(ev.tags))
}
@@ -493,7 +500,9 @@ export class EventService {
let added = 0
for (const [, ev] of this.sessionEventCache.entries()) {
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 (seen.has(ev.id)) continue
if (!linkRefs(ev).some((id) => threadKeys.has(id))) continue