From b95e216ca00d078db2cacd7b4c361859debbb0a4 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 16 May 2026 10:47:49 +0200 Subject: [PATCH] bug-fix --- src/components/NoteStats/RepostButton.tsx | 6 +- src/components/NoteStats/ZapButton.tsx | 6 +- src/components/ReplyNoteList/index.tsx | 31 ++++++---- src/services/note-stats.service.ts | 74 ++++++++++++++++++++--- 4 files changed, 93 insertions(+), 24 deletions(-) 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