diff --git a/src/components/NoteStats/RepostButton.tsx b/src/components/NoteStats/RepostButton.tsx
index 5bcbba98..9b1b24e6 100644
--- a/src/components/NoteStats/RepostButton.tsx
+++ b/src/components/NoteStats/RepostButton.tsx
@@ -42,6 +42,7 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
const [reposting, setReposting] = useState(false)
const [isPostDialogOpen, setIsPostDialogOpen] = useState(false)
const [isDrawerOpen, setIsDrawerOpen] = useState(false)
+ const statsLoaded = noteStats?.updatedAt != null
const { repostCount, hasReposted } = useMemo(() => {
return {
repostCount: hideUntrustedInteractions
@@ -49,7 +50,8 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
: noteStats?.reposts?.length,
hasReposted: pubkey ? noteStats?.repostPubkeySet?.has(pubkey) : false
}
- }, [noteStats, event.id, hideUntrustedInteractions])
+ }, [noteStats, event.id, hideUntrustedInteractions, isUserTrusted])
+ const showRepostCount = !hideCount && (statsLoaded || (repostCount ?? 0) > 0)
const canRepost = !hasReposted && !reposting
const repost = async () => {
@@ -112,7 +114,7 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
}}
>
{reposting ? : }
- {!hideCount && !!repostCount &&
{formatCount(repostCount)}
}
+ {showRepostCount && {formatCount(repostCount ?? 0)}
}
)
diff --git a/src/components/NoteStats/ZapButton.tsx b/src/components/NoteStats/ZapButton.tsx
index e294aed3..edd12e07 100644
--- a/src/components/NoteStats/ZapButton.tsx
+++ b/src/components/NoteStats/ZapButton.tsx
@@ -30,12 +30,14 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null)
const [openZapDialog, setOpenZapDialog] = useState(false)
const [zapping, setZapping] = useState(false)
+ const statsLoaded = noteStats?.updatedAt != null
const { zapAmount, hasZapped } = useMemo(() => {
return {
zapAmount: noteStats?.zaps?.reduce((acc, zap) => acc + zap.amount, 0),
hasZapped: pubkey ? noteStats?.zaps?.some((zap) => zap.pubkey === pubkey) : false
}
}, [noteStats, pubkey])
+ const showZapAmount = !hideCount && (statsLoaded || (zapAmount ?? 0) > 0)
const [disable, setDisable] = useState(true)
const timerRef = useRef | null>(null)
const isLongPressRef = useRef(false)
@@ -170,14 +172,14 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
)}
/>
)}
- {!hideCount && !!zapAmount && (
+ {showZapAmount && (
- {formatAmount(zapAmount)}
+ {formatAmount(zapAmount ?? 0)}
)}
diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx
index 62faf7cb..ff1571b3 100644
--- a/src/components/ReplyNoteList/index.tsx
+++ b/src/components/ReplyNoteList/index.tsx
@@ -300,6 +300,13 @@ function replyMatchesThreadForList(
return true
}
if (replyBelongsToNoteThread(evt, opEvent, rootInfo, threadWalkLocal)) return true
+ if (
+ evt.kind === kinds.Zap &&
+ (rootInfo.type === 'E' || rootInfo.type === 'A') &&
+ eventReferencesThreadTarget(evt, rootInfo)
+ ) {
+ return true
+ }
if (
(rootInfo.type === 'E' || rootInfo.type === 'A') &&
evt.kind !== kinds.ShortTextNote &&
@@ -343,17 +350,10 @@ function threadBacklinkRelationLabel(item: NEvent, t: TFunction): string {
return t('referenced this note')
}
-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)
-}
-
-/** E/A roots: #q-only kind 1 + relay “reply” rows for {@link NOTE_STATS_OP_REFERENCE_KINDS} belong in backlinks tail, not the chronological middle. */
+/** E/A roots: kind-1 #q quotes + op-reference kinds belong in backlinks tail, not the chronological middle. */
function isEaThreadTailBacklinkCandidate(evt: NEvent, root: TRootInfo): boolean {
if (root.type !== 'E' && root.type !== 'A') return false
- if (isKind1QuoteOnlyOfEaRoot(evt, root)) return true
+ if (evt.kind === kinds.ShortTextNote && kind1QuotesThreadRoot(evt, root)) return true
return EA_THREAD_TAIL_REFERENCE_KINDS.has(evt.kind)
}
@@ -1371,11 +1371,17 @@ function ReplyNoteList({
noteStatsService.updateNoteStatsByEvents(sessionEdge, reply.pubkey)
}
}
+ const threadRootHexId =
+ rootInfo.type === 'E'
+ ? rootInfo.id
+ : rootInfo.type === 'A' && /^[0-9a-f]{64}$/i.test(rootInfo.eventId)
+ ? rootInfo.eventId.toLowerCase()
+ : undefined
void noteStatsService.fetchThreadReplyNoteStatsBatch(
repliesForStatsPrime,
relayUrlsForThreadReq,
userPubkey ?? null,
- { foreground: statsForeground }
+ { foreground: statsForeground, threadRootHexId }
)
}
@@ -1634,8 +1640,11 @@ function ReplyNoteList({
return false
}
const isQuote = quoteUiIdSet.has(item.id)
+ // Zap receipts are public payment records — always show when they passed mute filters.
+ if (item.kind === kinds.Zap) return true
+ // Backlink rows (quotes, highlights, …): show even when author is not in the trust list.
+ if (isQuote) return true
if (isTrustLoaded && hideUntrustedInteractions && !isUserTrusted(item.pubkey)) {
- if (isQuote) return false
if (rootInfo?.type !== 'I') {
const repliesForThisReply = repliesMap.get(item.id)
if (
diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts
index 825f2dcb..c4486b50 100644
--- a/src/services/note-stats.service.ts
+++ b/src/services/note-stats.service.ts
@@ -14,8 +14,6 @@ import {
isNip25ReactionKind,
isReplaceableEvent
} from '@/lib/event'
-import { eventReferencesThreadTarget, threadRootRefFromStatsRootEvent } from '@/lib/op-reference-tags'
-import type { TThreadRootRef } from '@/lib/thread-reply-root-match'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import logger from '@/lib/logger'
import {
@@ -28,9 +26,15 @@ import {
getWebExternalReactionTargetUrl,
rssArticleStableEventId
} from '@/lib/rss-article'
+import { eventReferencesThreadTarget, threadRootRefFromStatsRootEvent } from '@/lib/op-reference-tags'
+import type { TThreadRootRef } from '@/lib/thread-reply-root-match'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
import { userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
-import { getEmojiInfosFromEmojiTags, getFirstHexEventIdFromETags, tagNameEquals } from '@/lib/tag'
+import {
+ getEmojiInfosFromEmojiTags,
+ getNip25ReactionTargetHexFromTags,
+ tagNameEquals
+} from '@/lib/tag'
import { normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import client, { eventService } from '@/services/client.service'
import { TEmoji, type TRelayList } from '@/types'
@@ -246,7 +250,7 @@ class NoteStatsService {
replies: Event[],
relayUrls: string[],
_pubkey?: string | null,
- opts?: { foreground?: boolean }
+ opts?: { foreground?: boolean; threadRootHexId?: string }
): Promise {
const urls = (relayUrls ?? []).filter(Boolean)
const hexReplies: Event[] = []
@@ -273,6 +277,10 @@ class NoteStatsService {
hexIdsSet.add(this.statsKey(parentHex))
}
}
+ const rootHex = opts?.threadRootHexId?.trim().toLowerCase()
+ if (rootHex && this.hexNoteStatsIdRe.test(rootHex)) {
+ hexIdsSet.add(this.statsKey(rootHex))
+ }
const hexIds = [...hexIdsSet]
for (const r of hexReplies) {
@@ -365,6 +373,7 @@ class NoteStatsService {
const ch = hexIds.slice(off, off + this.THREAD_REPLY_STATS_BATCH_HEX_CHUNK)
nonSocial.push(
{ '#e': ch, kinds: [kinds.Reaction], limit: reactionLimit },
+ { '#E': ch, kinds: [kinds.Reaction], limit: reactionLimit },
{ '#e': ch, kinds: [kinds.Zap], limit: 100 }
)
social.push(
@@ -514,9 +523,12 @@ class NoteStatsService {
}
const { queryService } = await import('@/services/client.service')
+ const rootHex = this.statsKey(resolvedEvent.id)
const onStatsEvent = (evt: Event) => {
this.updateNoteStatsByEvents([evt], resolvedEvent!.pubkey, {
- statsRootEvent: resolvedEvent!
+ statsRootEvent: resolvedEvent!,
+ interactionTargetNoteId:
+ evt.kind === kinds.Reaction && /^[0-9a-f]{64}$/i.test(rootHex) ? rootHex : undefined
})
}
await Promise.all([
@@ -534,6 +546,13 @@ class NoteStatsService {
: Promise.resolve([] as Event[])
])
+ if (resolvedEvent.kind !== ExtendedKind.RSS_THREAD_ROOT) {
+ const likeCount = this.noteStatsMap.get(rootHex)?.likes?.length ?? 0
+ if (likeCount === 0) {
+ await this.fetchReactionsForNoteTarget(resolvedEvent, finalRelayUrls)
+ }
+ }
+
markStatsLoaded(resolvedEvent.id)
} catch (err) {
logger.warn('[NoteStats] processSingleEvent failed', {
@@ -692,6 +711,7 @@ class NoteStatsService {
const rootId = this.statsKey(event.id)
const nonSocial: Filter[] = [
{ '#e': [rootId], kinds: [kinds.Reaction], limit: reactionLimit },
+ { '#E': [rootId], kinds: [kinds.Reaction], limit: reactionLimit },
{ '#e': [rootId], kinds: [kinds.Zap], limit: 100 }
]
@@ -743,6 +763,7 @@ class NoteStatsService {
)
nonSocial.push(
{ '#a': [replaceableCoordinate], kinds: [kinds.Reaction], limit: reactionLimit },
+ { '#A': [replaceableCoordinate], kinds: [kinds.Reaction], limit: reactionLimit },
{ '#a': [replaceableCoordinate], kinds: [kinds.Zap], limit: 100 }
)
social.push(
@@ -1002,11 +1023,11 @@ class NoteStatsService {
private reactionTargetHexForLike(evt: Event, forcedTargetEventId?: string): string | undefined {
const forced = forcedTargetEventId?.trim()
- if (forced) return forced
+ if (forced) return this.statsKey(forced)
+ const nip25 = getNip25ReactionTargetHexFromTags(evt.tags)
+ if (nip25 && /^[0-9a-f]{64}$/i.test(nip25)) return nip25.toLowerCase()
const parentHex = getParentEventHexId(evt)
- if (parentHex && /^[0-9a-f]{64}$/i.test(parentHex)) return parentHex
- const firstE = getFirstHexEventIdFromETags(evt.tags)
- if (firstE) return firstE
+ if (parentHex && /^[0-9a-f]{64}$/i.test(parentHex)) return parentHex.toLowerCase()
if (evt.kind === kinds.Reaction) {
const pageUrl = getReactionPageUrlFromRTags(evt)
if (pageUrl) {
@@ -1016,6 +1037,41 @@ class NoteStatsService {
return undefined
}
+ /** Second pass when the main stats wave returned no kind-7 rows (common on direct note open). */
+ private async fetchReactionsForNoteTarget(rootEvent: Event, relayUrls: string[]): Promise {
+ const rootHex = this.statsKey(rootEvent.id)
+ if (!/^[0-9a-f]{64}$/i.test(rootHex)) return
+
+ const hintRelays = client.eventService.getSessionRelayHintsForHexTarget(rootHex)
+ const urls = feedRelayPolicyUrls(
+ [{ source: 'fallback', urls: [...new Set([...hintRelays, ...relayUrls])] }],
+ {
+ operation: 'read',
+ applySocialKindBlockedFilter: false,
+ allowThirdPartyLocalRelays: true
+ }
+ )
+ if (!urls.length) return
+
+ const filters: Filter[] = [
+ { '#e': [rootHex], kinds: [kinds.Reaction], limit: 500 },
+ { '#E': [rootHex], kinds: [kinds.Reaction], limit: 500 }
+ ]
+ const { queryService } = await import('@/services/client.service')
+ await queryService.fetchEvents(urls, filters, {
+ eoseTimeout: 12_000,
+ globalTimeout: 22_000,
+ firstRelayResultGraceMs: false,
+ onevent: (evt: Event) => {
+ this.updateNoteStatsByEvents([evt], rootEvent.pubkey, {
+ statsRootEvent: rootEvent,
+ interactionTargetNoteId: rootHex
+ })
+ }
+ })
+ this.notifyNoteStats(rootHex)
+ }
+
private addLikeByEvent(evt: Event, _originalEventAuthor?: string, forcedTargetEventId?: string) {
const targetEventIdRaw = this.reactionTargetHexForLike(evt, forcedTargetEventId)
if (!targetEventIdRaw) return