Browse Source

bug-fix

imwald
Silberengel 1 month ago
parent
commit
b95e216ca0
  1. 6
      src/components/NoteStats/RepostButton.tsx
  2. 6
      src/components/NoteStats/ZapButton.tsx
  3. 31
      src/components/ReplyNoteList/index.tsx
  4. 74
      src/services/note-stats.service.ts

6
src/components/NoteStats/RepostButton.tsx

@ -42,6 +42,7 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R @@ -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 @@ -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 @@ -112,7 +114,7 @@ export function RepostButtonWithStats({ event, hideCount = false, noteStats }: R
}}
>
{reposting ? <Skeleton className="size-4 shrink-0 rounded-full" aria-hidden /> : <Repeat />}
{!hideCount && !!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
{showRepostCount && <div className="text-sm tabular-nums">{formatCount(repostCount ?? 0)}</div>}
</button>
)

6
src/components/NoteStats/ZapButton.tsx

@ -30,12 +30,14 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB @@ -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<ReturnType<typeof setTimeout> | null>(null)
const isLongPressRef = useRef(false)
@ -170,14 +172,14 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB @@ -170,14 +172,14 @@ export function ZapButtonWithStats({ event, hideCount = false, noteStats }: ZapB
)}
/>
)}
{!hideCount && !!zapAmount && (
{showZapAmount && (
<div
className={cn(
'text-sm tabular-nums',
hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
)}
>
{formatAmount(zapAmount)}
{formatAmount(zapAmount ?? 0)}
</div>
)}
</button>

31
src/components/ReplyNoteList/index.tsx

@ -300,6 +300,13 @@ function replyMatchesThreadForList( @@ -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 { @@ -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({ @@ -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({ @@ -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 (

74
src/services/note-stats.service.ts

@ -14,8 +14,6 @@ import { @@ -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 { @@ -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 { @@ -246,7 +250,7 @@ class NoteStatsService {
replies: Event[],
relayUrls: string[],
_pubkey?: string | null,
opts?: { foreground?: boolean }
opts?: { foreground?: boolean; threadRootHexId?: string }
): Promise<void> {
const urls = (relayUrls ?? []).filter(Boolean)
const hexReplies: Event[] = []
@ -273,6 +277,10 @@ class NoteStatsService { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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<void> {
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

Loading…
Cancel
Save