From 536391cc6b12f3ff64d2379b3adf72289b8e09b0 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 13 Apr 2026 14:57:21 +0200 Subject: [PATCH] bug-fixes --- src/components/NoteList/index.tsx | 3 +- src/components/NoteStats/LikeButton.tsx | 15 +- src/components/NoteStats/ReplyButton.tsx | 10 +- src/components/NoteStats/index.tsx | 6 + src/components/PostEditor/PostContent.tsx | 23 +- src/hooks/useFetchProfile.tsx | 65 +++++- src/lib/error-suppression.ts | 4 +- .../client-replaceable-events.service.ts | 9 +- src/services/note-stats.service.ts | 219 +++++++++++++----- src/types/index.d.ts | 5 + 10 files changed, 278 insertions(+), 81 deletions(-) diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index c5d4f24e..e7779bad 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -1294,7 +1294,8 @@ const NoteList = forwardRef( next.set(pk, { pubkey: pk, npub: pubkeyToNpub(pk) ?? '', - username: formatPubkey(pk) + username: formatPubkey(pk), + batchPlaceholder: true }) } } diff --git a/src/components/NoteStats/LikeButton.tsx b/src/components/NoteStats/LikeButton.tsx index 08fe4694..0bc0452e 100644 --- a/src/components/NoteStats/LikeButton.tsx +++ b/src/components/NoteStats/LikeButton.tsx @@ -34,7 +34,6 @@ import logger from '@/lib/logger' import { useTranslation } from 'react-i18next' import Emoji from '../Emoji' import EmojiPicker, { EMOJI_PICKER_REACTIONS } from '../EmojiPicker' -import { formatCount } from './utils' import { type RelayStatus, showPublishingError, @@ -58,6 +57,8 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; const isReplyToDiscussion = useReplyUnderDiscussionRoot(event) const showDiscussionVotes = isDiscussion || isReplyToDiscussion + const statsLoaded = noteStats?.updatedAt != null + const { myLastEmoji, likeCount, upVoteCount, downVoteCount } = useMemo(() => { const stats = noteStats || {} const likes = hideUntrustedInteractions @@ -234,12 +235,20 @@ export default function LikeButton({ event, hideCount = false }: { event: Event; ) : myLastEmoji ? ( <> - {!hideCount && !!likeCount &&
{formatCount(likeCount)}
} + {!hideCount && statsLoaded && ( +
+ {(likeCount ?? 0) >= 100 ? '99+' : String(likeCount ?? 0)} +
+ )} ) : ( <> - {!hideCount && !!likeCount &&
{formatCount(likeCount)}
} + {!hideCount && statsLoaded && ( +
+ {(likeCount ?? 0) >= 100 ? '99+' : String(likeCount ?? 0)} +
+ )} )} diff --git a/src/components/NoteStats/ReplyButton.tsx b/src/components/NoteStats/ReplyButton.tsx index 1ede701f..eb0f3401 100644 --- a/src/components/NoteStats/ReplyButton.tsx +++ b/src/components/NoteStats/ReplyButton.tsx @@ -26,6 +26,12 @@ export default function ReplyButton({ event, hideCount = false }: { event: Event hasReplied } }, [noteStats, event.id, hideUntrustedInteractions, isUserTrusted, pubkey]) + const statsLoaded = noteStats?.updatedAt != null + const replyCountLabel = statsLoaded + ? replyCount >= 100 + ? '99+' + : String(replyCount) + : formatCount(replyCount) const [open, setOpen] = useState(false) return ( @@ -44,7 +50,9 @@ export default function ReplyButton({ event, hideCount = false }: { event: Event title={t('Reply')} > - {!hideCount && !!replyCount &&
{formatCount(replyCount)}
} + {!hideCount && replyCountLabel !== '' && ( +
{replyCountLabel}
+ )} diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx index 024953e2..ae870628 100644 --- a/src/components/NoteStats/index.tsx +++ b/src/components/NoteStats/index.tsx @@ -7,6 +7,7 @@ import noteStatsService from '@/services/note-stats.service' import { ExtendedKind } from '@/constants' import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot' import { shouldHideInteractions } from '@/lib/event-filtering' +import logger from '@/lib/logger' import { Event } from 'nostr-tools' import { useEffect, useState } from 'react' import BookmarkButton from '../BookmarkButton' @@ -57,6 +58,11 @@ export default function NoteStats({ useEffect(() => { if (!fetchIfNotExisting) return + logger.debug('[NoteStats] UI: scheduling fetchNoteStats', { + eventId: `${event.id.slice(0, 12)}…`, + kind: event.kind, + hintRelayCount: statsRelays.length + }) setLoading(true) noteStatsService.fetchNoteStats(event, pubkey, statsRelays).finally(() => setLoading(false)) // Intentionally omit `event` object: parent feeds often pass new references each render; diff --git a/src/components/PostEditor/PostContent.tsx b/src/components/PostEditor/PostContent.tsx index 0e1afe38..5ff67abd 100644 --- a/src/components/PostEditor/PostContent.tsx +++ b/src/components/PostEditor/PostContent.tsx @@ -1627,7 +1627,7 @@ export default function PostContent({ // Note: URL will be inserted when upload completes in handleMediaUploadSuccess } } - // Root composer: native media kind is set in processMediaUpload after kind detection (ambiguous types use the dialog). + // Root composer: video/voice kinds are set in processMediaUpload; images stay kind 1 with imeta (ambiguous types use the dialog). } } @@ -1708,14 +1708,17 @@ export default function PostContent({ let resolvedKind: number if (selectedKind !== undefined) { resolvedKind = selectedKind - setMediaNoteKind(resolvedKind) } else { resolvedKind = await getMediaKindFromFile(uploadingFile, false) - const isRootComposer = !parentEvent && !isPublicMessage && !(isDiscussionThread && !parentEvent) - if (isRootComposer) { - setMediaNoteKind(resolvedKind) - setMediaUrl(url) - } + } + + // New-post composer: images stay kind 1 (short text + imeta + URL), not kind 20 picture notes. + if (resolvedKind === ExtendedKind.PICTURE) { + setMediaNoteKind(null) + setMediaUrl('') + } else { + setMediaNoteKind(resolvedKind) + setMediaUrl(url) } const imetaTag = mediaUpload.getImetaTagByUrl(url) @@ -1754,12 +1757,6 @@ export default function PostContent({ appendComposerImetaTag(newImetaTag) - if (selectedKind !== undefined) { - setMediaUrl(url) - } else if (mediaNoteKindRef.current !== null) { - setMediaUrl((prev) => prev || url) - } - setTimeout(() => { if (textareaRef.current) { const currentText = textareaRef.current.getText() diff --git a/src/hooks/useFetchProfile.tsx b/src/hooks/useFetchProfile.tsx index 7bf058b7..5c15db99 100644 --- a/src/hooks/useFetchProfile.tsx +++ b/src/hooks/useFetchProfile.tsx @@ -1,14 +1,44 @@ import { PROFILE_FETCH_PROMISE_TIMEOUT_MS } from '@/constants' +import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter' import { getProfileFromEvent } from '@/lib/event-metadata' import { getSeededProfileForNavigation } from '@/lib/profile-navigation-seed' import { userIdToPubkey } from '@/lib/pubkey' import { useNostrOptional } from '@/providers/nostr-context' import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext' -import { replaceableEventService } from '@/services/client.service' +import { eventService, replaceableEventService } from '@/services/client.service' +import indexedDb from '@/services/indexed-db.service' import { TProfile } from '@/types' +import { kinds } from 'nostr-tools' import { useEffect, useState, useRef, useCallback } from 'react' import logger from '@/lib/logger' +/** + * Session LRU + IndexedDB kind 0 without ReplaceableEventService / batched DataLoader. + * Used when the hook's fetch race times out or the batch path is slow while disk/session already has metadata. + */ +async function tryHydrateProfileFromLocalCaches( + pubkey: string, + skipCache: boolean +): Promise { + if (skipCache) return null + const pk = pubkey.toLowerCase() + + const sessionEv = eventService.getSessionMetadataForPubkey(pk) + if (sessionEv) { + return getProfileFromEvent(sessionEv) + } + + try { + const idbEv = await indexedDb.getReplaceableEvent(pk, kinds.Metadata) + if (idbEv && !shouldDropEventOnIngest(idbEv)) { + return getProfileFromEvent(idbEv) + } + } catch { + /* IDB not ready */ + } + return null +} + // CRITICAL: Global deduplication - shared across ALL hook instances // This prevents multiple components from fetching the same profile simultaneously const globalFetchPromises = new Map>() @@ -158,7 +188,16 @@ export function useFetchProfile(id?: string, skipCache = false) { try { globalFetchingPubkeys.add(pubkey) const startTime = Date.now() - + + const quick = await tryHydrateProfileFromLocalCaches(pubkey, skipCache) + if (quick) { + logger.debug('[useFetchProfile] Profile from session/IndexedDB (fast path)', { + pubkey: pubkey.substring(0, 8), + hasAvatar: !!quick.avatar + }) + return quick + } + // CRITICAL: Add timeout to prevent infinite hangs (must exceed batched metadata query globalTimeout) const timeoutPromise = new Promise((_, reject) => { setTimeout(() => { @@ -210,6 +249,14 @@ export function useFetchProfile(id?: string, skipCache = false) { fetchTime: `${fetchTime}ms` }) } + const afterMiss = await tryHydrateProfileFromLocalCaches(pubkey, skipCache) + if (afterMiss) { + logger.debug('[useFetchProfile] Profile from session/IndexedDB after network miss', { + pubkey: pubkey.substring(0, 8), + hasAvatar: !!afterMiss.avatar + }) + return afterMiss + } return null } catch (err) { const isTimeout = err instanceof Error && err.message.includes('timeout') @@ -220,6 +267,14 @@ export function useFetchProfile(id?: string, skipCache = false) { }) // Set cooldown period after timeout to prevent cascade of duplicate fetches globalFetchCooldowns.set(pubkey, Date.now() + 10000) // 10 second cooldown + const fallback = await tryHydrateProfileFromLocalCaches(pubkey, skipCache) + if (fallback) { + logger.debug('[useFetchProfile] Profile from session/IndexedDB after fetch timeout', { + pubkey: pubkey.substring(0, 8), + hasAvatar: !!fallback.avatar + }) + return fallback + } // Return null on timeout instead of throwing - allows UI to show fallback return null } @@ -293,10 +348,12 @@ export function useFetchProfile(id?: string, skipCache = false) { // Extract pubkey early to check if id has changed const extractedPubkey = userIdToPubkey(id) - // Note feeds: profiles are batch-fetched in NoteList — skip per-row relay storms while pending + // Note feeds: profiles are batch-fetched in NoteList — skip per-row relay storms while pending. + // Batch may only synthesize a pubkey row when kind 0 is missing; those must not skip fetchProfileEvent + // or avatars stay on identicons forever. if (extractedPubkey && noteFeed && !skipCache) { const fromBatch = noteFeed.profiles.get(extractedPubkey) - if (fromBatch) { + if (fromBatch && !fromBatch.batchPlaceholder) { setProfile(fromBatch) setPubkey(extractedPubkey) setIsFetching(false) diff --git a/src/lib/error-suppression.ts b/src/lib/error-suppression.ts index 0810bcd5..6355c0c8 100644 --- a/src/lib/error-suppression.ts +++ b/src/lib/error-suppression.ts @@ -299,8 +299,8 @@ function suppressExpectedErrors() { return } - // Suppress Workbox logs - if (message.includes('workbox') || message.includes('[NoteStats]')) { + // Suppress Workbox logs (do not filter [NoteStats] — app diagnostics use that tag) + if (message.includes('workbox')) { return } diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 4a975203..ffecbc30 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -570,6 +570,11 @@ export class ReplaceableEventService { }) } const isSlowReplaceableBatch = kind === kinds.Metadata || kind === 10001 + const multiAuthorBatch = pubkeys.length > 1 + // replaceableRace + default grace closes the REQ shortly after the first EVENT. For batched kind-0 + // (many `authors` in one filter) that stops the subscription while most profiles are still in flight. + const useReplaceableRace = + !isSlowReplaceableBatch || !multiAuthorBatch const events = await this.queryService.query( relayUrls, { @@ -578,7 +583,7 @@ export class ReplaceableEventService { }, undefined, { - replaceableRace: true, + replaceableRace: useReplaceableRace, eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100, globalTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS : 2000 } @@ -876,7 +881,7 @@ export class ReplaceableEventService { setTimeout(() => { logger.debug('[ReplaceableEventService] fetchRelayList timeout, giving up', { pubkey }) resolve(null) - }, 2000) + }, 10_000) }) authorRelayList = await Promise.race([relayListPromise, timeoutPromise]) } catch (error) { diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts index 53884de3..3c5b3256 100644 --- a/src/services/note-stats.service.ts +++ b/src/services/note-stats.service.ts @@ -54,6 +54,12 @@ class NoteStatsService { private noteStatsMap: Map> = new Map() private noteStatsSubscribers = new Map void>>() private processingCache = new Set() + private readonly hexNoteStatsIdRe = /^[0-9a-f]{64}$/i + + /** Canonical map key: reactions often copy `e` tag casing; UI uses lowercase ids from parsed events. */ + private statsKey(id: string): string { + return this.hexNoteStatsIdRe.test(id) ? id.toLowerCase() : id + } // Batch processing private pendingEvents = new Set() @@ -65,9 +71,14 @@ class NoteStatsService { /** Prevents overlapping processBatch runs (reentrant calls corrupted pendingEvents). */ private processBatchRunning = false private readonly BATCH_DELAY = 200 - private readonly MAX_BATCH_SIZE = 24 + /** Small slices so a slow batch does not block newer cards (e.g. spell feed swaps placeholder rows → discussions). */ + private readonly MAX_BATCH_SIZE = 8 + /** Avoid 20+ simultaneous stats REQs (relay strikes / hangs); each slice runs in waves. */ + private readonly STATS_SLICE_CONCURRENCY = 4 /** Client-only RSS/Web thread roots are not on relays; use the event passed into {@link fetchNoteStats}. */ private pendingSyntheticRootById = new Map() + /** Root event from {@link fetchNoteStats} (feed/card already has it; avoids fetchEvent miss → no stats UI). */ + private pendingStatsRootEventById = new Map() constructor() { if (!NoteStatsService.instance) { @@ -102,15 +113,35 @@ class NoteStatsService { } async fetchNoteStats(event: Event, _pubkey?: string | null, favoriteRelays?: string[] | null) { - const eventId = event.id + const eventId = this.statsKey(event.id) + const idShort = `${eventId.slice(0, 12)}…` if (this.pendingEvents.has(eventId)) { this.mergeFavoriteRelaysIntoPending(eventId, favoriteRelays) + if (event.kind === ExtendedKind.RSS_THREAD_ROOT) { + this.pendingSyntheticRootById.set(eventId, event) + } else { + this.pendingStatsRootEventById.set(eventId, event) + } + logger.debug('[NoteStats] fetchNoteStats: merged into existing pending batch', { + eventId: idShort, + kind: event.kind, + pendingSize: this.pendingEvents.size + }) return } if (this.processingCache.has(eventId)) { this.mergeFavoriteRelaysIntoDeferred(eventId, favoriteRelays) + if (event.kind === ExtendedKind.RSS_THREAD_ROOT) { + this.pendingSyntheticRootById.set(eventId, event) + } else { + this.pendingStatsRootEventById.set(eventId, event) + } + logger.debug('[NoteStats] fetchNoteStats: deferred (already processing same id)', { + eventId: idShort, + kind: event.kind + }) return } @@ -118,8 +149,17 @@ class NoteStatsService { this.pendingEvents.add(eventId) if (event.kind === ExtendedKind.RSS_THREAD_ROOT) { this.pendingSyntheticRootById.set(eventId, event) + } else { + this.pendingStatsRootEventById.set(eventId, event) } + logger.debug('[NoteStats] fetchNoteStats: queued new id', { + eventId: idShort, + kind: event.kind, + pendingSize: this.pendingEvents.size, + immediateBatch: this.pendingEvents.size >= this.MAX_BATCH_SIZE + }) + this.armStatsBatchTimer() if (this.pendingEvents.size >= this.MAX_BATCH_SIZE && !this.processBatchRunning) { if (this.batchTimeout) { @@ -132,12 +172,16 @@ class NoteStatsService { private async processBatch() { if (this.processBatchRunning) { + logger.debug('[NoteStats] processBatch: skipped (already running)', { + pendingSize: this.pendingEvents.size + }) return } if (this.pendingEvents.size === 0) { return } + logger.info('[NoteStats] processBatch: running', { pendingSize: this.pendingEvents.size }) this.processBatchRunning = true if (this.batchTimeout) { clearTimeout(this.batchTimeout) @@ -150,7 +194,16 @@ class NoteStatsService { for (const id of eventsToProcess) { this.pendingEvents.delete(id) } - await Promise.all(eventsToProcess.map((eventId) => this.processSingleEvent(eventId))) + logger.info('[NoteStats] processBatch slice', { + count: eventsToProcess.length, + ids: eventsToProcess.map((id) => `${id.slice(0, 12)}…`), + remainingPending: this.pendingEvents.size, + concurrency: this.STATS_SLICE_CONCURRENCY + }) + for (let i = 0; i < eventsToProcess.length; i += this.STATS_SLICE_CONCURRENCY) { + const chunk = eventsToProcess.slice(i, i + this.STATS_SLICE_CONCURRENCY) + await Promise.all(chunk.map((eventId) => this.processSingleEvent(eventId))) + } } } finally { this.processBatchRunning = false @@ -162,32 +215,65 @@ class NoteStatsService { private async processSingleEvent(eventId: string) { if (this.processingCache.has(eventId)) { - logger.debug('[NoteStats] Skipping concurrent fetch for event', eventId.substring(0, 8)) + logger.debug('[NoteStats] processSingleEvent: skip (concurrent in-flight)', { + eventId: `${eventId.slice(0, 12)}…` + }) return } - + this.processingCache.add(eventId) const favoriteRelays = this.pendingFetchFavoriteRelays.get(eventId) this.pendingFetchFavoriteRelays.delete(eventId) + let publishedStatsSnapshot = false + const markStatsLoaded = (rawStatsKey: string, reason: string) => { + if (publishedStatsSnapshot) return + publishedStatsSnapshot = true + const statsKey = this.statsKey(rawStatsKey) + this.noteStatsMap.set(statsKey, { + ...(this.noteStatsMap.get(statsKey) ?? {}), + updatedAt: dayjs().unix() + }) + const subscriberCount = this.noteStatsSubscribers.get(statsKey)?.size ?? 0 + logger.info('[NoteStats] processSingleEvent: snapshot published', { + statsKey: `${statsKey.slice(0, 12)}…`, + reason, + subscriberCount + }) + this.notifyNoteStats(statsKey) + } + + let resolvedEvent: Event | undefined try { + logger.debug('[NoteStats] processSingleEvent: start', { eventId: `${eventId.slice(0, 12)}…` }) // Synthetic RSS/Web thread parents are not published; use the instance from fetchNoteStats. const synthetic = this.pendingSyntheticRootById.get(eventId) this.pendingSyntheticRootById.delete(eventId) - const event = synthetic ?? (await eventService.fetchEvent(eventId)) - if (!event) { - logger.debug('[NoteStats] Event not found:', eventId.substring(0, 8)) + const callerRoot = this.pendingStatsRootEventById.get(eventId) + this.pendingStatsRootEventById.delete(eventId) + resolvedEvent = synthetic ?? callerRoot ?? (await eventService.fetchEvent(eventId)) + const rootSource = synthetic ? 'synthetic-rss' : callerRoot ? 'caller-card' : resolvedEvent ? 'fetchEvent' : 'none' + logger.debug('[NoteStats] processSingleEvent: root resolution', { + eventId: `${eventId.slice(0, 12)}…`, + rootSource, + resolvedKind: resolvedEvent?.kind + }) + if (!resolvedEvent) { + logger.debug('[NoteStats] processSingleEvent: no root event — publishing empty snapshot', { + eventId: `${eventId.slice(0, 12)}…` + }) + markStatsLoaded(eventId, 'no-root-event') return } - const finalRelayUrls = await this.buildNoteStatsRelayList(event, favoriteRelays) - - const replaceableCoordinate = isReplaceableEvent(event.kind) - ? getReplaceableCoordinateFromEvent(event) + const finalRelayUrls = await this.buildNoteStatsRelayList(resolvedEvent, favoriteRelays) + + const replaceableCoordinate = isReplaceableEvent(resolvedEvent.kind) + ? getReplaceableCoordinateFromEvent(resolvedEvent) : undefined - const { nonSocial, social } = this.buildFilterGroups(event, replaceableCoordinate) + const { nonSocial, social } = this.buildFilterGroups(resolvedEvent, replaceableCoordinate) const fetchOpts = { eoseTimeout: 10_000, globalTimeout: 28_000, @@ -195,11 +281,17 @@ class NoteStatsService { } const events: Event[] = [] - logger.debug('[NoteStats] Fetching stats for event', event.id.substring(0, 8), 'from', finalRelayUrls.length, 'relays') + logger.debug( + '[NoteStats] Fetching stats for event', + resolvedEvent.id.substring(0, 8), + 'from', + finalRelayUrls.length, + 'relays' + ) const { queryService } = await import('@/services/client.service') const onStatsEvent = (evt: Event) => { - this.updateNoteStatsByEvents([evt], event.pubkey) + this.updateNoteStatsByEvents([evt], resolvedEvent!.pubkey) events.push(evt) } if (nonSocial.length > 0) { @@ -214,16 +306,23 @@ class NoteStatsService { onevent: onStatsEvent }) } - - logger.debug('[NoteStats] Fetched', events.length, 'events for stats') - this.noteStatsMap.set(event.id, { - ...(this.noteStatsMap.get(event.id) ?? {}), - updatedAt: dayjs().unix() + logger.debug('[NoteStats] processSingleEvent: relay fetch finished', { + eventId: `${resolvedEvent.id.slice(0, 12)}…`, + interactionEventsReceived: events.length }) - // Always notify: when relays return 0 rows, no updateNoteStatsByEvents ran — subscribers would never re-render. - this.notifyNoteStats(event.id) + + markStatsLoaded(resolvedEvent.id, 'fetch-ok') + } catch (err) { + logger.warn('[NoteStats] processSingleEvent failed', { + eventId: eventId.substring(0, 8), + error: err instanceof Error ? err.message : String(err) + }) + markStatsLoaded(resolvedEvent?.id ?? eventId, 'catch-after-error') } finally { + if (!publishedStatsSnapshot) { + markStatsLoaded(resolvedEvent?.id ?? eventId, 'finally-fallback') + } this.processingCache.delete(eventId) if (this.inFlightDeferredFavoriteRelays.has(eventId)) { const deferred = this.inFlightDeferredFavoriteRelays.get(eventId)! @@ -338,14 +437,15 @@ class NoteStatsService { return { nonSocial, social } } + const rootId = this.statsKey(event.id) const nonSocial: Filter[] = [ - { '#e': [event.id], kinds: [kinds.Reaction], limit: reactionLimit }, - { '#e': [event.id], kinds: [kinds.Zap], limit: 100 } + { '#e': [rootId], kinds: [kinds.Reaction], limit: reactionLimit }, + { '#e': [rootId], kinds: [kinds.Zap], limit: 100 } ] const social: Filter[] = [ { - '#e': [event.id], + '#e': [rootId], kinds: [ ...nip18RepostKinds, kinds.ShortTextNote, @@ -356,7 +456,7 @@ class NoteStatsService { limit: interactionLimit }, { - '#q': [event.id], + '#q': [rootId], kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], limit: 50 } @@ -392,27 +492,28 @@ class NoteStatsService { subscribeNoteStats(noteId: string, callback: () => void) { - let set = this.noteStatsSubscribers.get(noteId) + const key = this.statsKey(noteId) + let set = this.noteStatsSubscribers.get(key) if (!set) { set = new Set() - this.noteStatsSubscribers.set(noteId, set) + this.noteStatsSubscribers.set(key, set) } set.add(callback) return () => { set?.delete(callback) - if (set?.size === 0) this.noteStatsSubscribers.delete(noteId) + if (set?.size === 0) this.noteStatsSubscribers.delete(key) } } private notifyNoteStats(noteId: string) { - const set = this.noteStatsSubscribers.get(noteId) + const set = this.noteStatsSubscribers.get(this.statsKey(noteId)) if (set) { set.forEach((cb) => cb()) } } getNoteStats(id: string): Partial | undefined { - return this.noteStatsMap.get(id) + return this.noteStatsMap.get(this.statsKey(id)) } addZap( @@ -424,18 +525,19 @@ class NoteStatsService { created_at: number = dayjs().unix(), notify: boolean = true ) { - const old = this.noteStatsMap.get(eventId) || {} + const key = this.statsKey(eventId) + const old = this.noteStatsMap.get(key) || {} const zapPrSet = old.zapPrSet || new Set() const zaps = old.zaps || [] if (zapPrSet.has(pr)) return zapPrSet.add(pr) zaps.push({ pr, pubkey, amount, comment, created_at }) - this.noteStatsMap.set(eventId, { ...old, zapPrSet, zaps }) + this.noteStatsMap.set(key, { ...old, zapPrSet, zaps }) if (notify) { - this.notifyNoteStats(eventId) + this.notifyNoteStats(key) } - return eventId + return key } /** @@ -465,7 +567,7 @@ class NoteStatsService { } updatedEventIdSet.forEach((eventId) => { - this.notifyNoteStats(eventId) + this.notifyNoteStats(this.statsKey(eventId)) }) } @@ -547,6 +649,7 @@ class NoteStatsService { } } if (!targetEventId) return + targetEventId = this.statsKey(targetEventId) const old = this.noteStatsMap.get(targetEventId) || {} const likeIdSet = old.likeIdSet || new Set() @@ -574,8 +677,9 @@ class NoteStatsService { const url = getWebExternalReactionTargetUrl(evt) if (!url) return - const targetEventId = + const targetEventId = this.statsKey( forcedTargetEventId ?? rssArticleStableEventId(canonicalizeRssArticleUrl(url)) + ) const old = this.noteStatsMap.get(targetEventId) || {} const likeIdSet = old.likeIdSet || new Set() @@ -595,17 +699,18 @@ class NoteStatsService { } removeLike(eventId: string, reactionEventId: string) { - const old = this.noteStatsMap.get(eventId) || {} + const key = this.statsKey(eventId) + const old = this.noteStatsMap.get(key) || {} const likeIdSet = old.likeIdSet || new Set() const likes = old.likes || [] - if (!likeIdSet.has(reactionEventId)) return eventId + if (!likeIdSet.has(reactionEventId)) return key likeIdSet.delete(reactionEventId) const newLikes = likes.filter(like => like.id !== reactionEventId) - this.noteStatsMap.set(eventId, { ...old, likeIdSet, likes: newLikes }) - this.notifyNoteStats(eventId) - return eventId + this.noteStatsMap.set(key, { ...old, likeIdSet, likes: newLikes }) + this.notifyNoteStats(key) + return key } /** Target id for repost stats: `e` first (NIP-18 for both kind 6 and 16), then embedded JSON, then `a` (generic only). */ @@ -638,8 +743,9 @@ class NoteStatsService { } private addRepostByEvent(evt: Event, originalEventAuthor?: string, forcedTargetEventId?: string) { - const eventId = this.repostStatsTargetId(evt, forcedTargetEventId) - if (!eventId) return + const rawId = this.repostStatsTargetId(evt, forcedTargetEventId) + if (!rawId) return + const eventId = this.statsKey(rawId) const old = this.noteStatsMap.get(eventId) || {} const repostPubkeySet = old.repostPubkeySet || new Set() @@ -707,8 +813,9 @@ class NoteStatsService { } if (!originalEventId) return + const replyKey = this.statsKey(originalEventId) - const old = this.noteStatsMap.get(originalEventId) || {} + const old = this.noteStatsMap.get(replyKey) || {} const replyIdSet = old.replyIdSet || new Set() const replies = old.replies || [] @@ -720,8 +827,8 @@ class NoteStatsService { replyIdSet.add(evt.id) replies.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at }) - this.noteStatsMap.set(originalEventId, { ...old, replyIdSet, replies }) - return originalEventId + this.noteStatsMap.set(replyKey, { ...old, replyIdSet, replies }) + return replyKey } private isQuoteByEvent(evt: Event): boolean { @@ -731,8 +838,9 @@ class NoteStatsService { private addQuoteByEvent(evt: Event, originalEventAuthor?: string) { const quotedEventId = evt.tags.find(tag => tag[0] === 'q')?.[1] if (!quotedEventId) return + const quoteKey = this.statsKey(quotedEventId) - const old = this.noteStatsMap.get(quotedEventId) || {} + const old = this.noteStatsMap.get(quoteKey) || {} const quoteIdSet = old.quoteIdSet || new Set() const quotes = old.quotes || [] @@ -744,8 +852,8 @@ class NoteStatsService { quoteIdSet.add(evt.id) quotes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at }) - this.noteStatsMap.set(quotedEventId, { ...old, quoteIdSet, quotes }) - return quotedEventId + this.noteStatsMap.set(quoteKey, { ...old, quoteIdSet, quotes }) + return quoteKey } private addHighlightByEvent(evt: Event, originalEventAuthor?: string) { @@ -757,8 +865,9 @@ class NoteStatsService { } } if (!highlightedEventId) return + const highlightKey = this.statsKey(highlightedEventId) - const old = this.noteStatsMap.get(highlightedEventId) || {} + const old = this.noteStatsMap.get(highlightKey) || {} const highlightIdSet = old.highlightIdSet || new Set() const highlights = old.highlights || [] @@ -770,15 +879,15 @@ class NoteStatsService { highlightIdSet.add(evt.id) highlights.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at }) - this.noteStatsMap.set(highlightedEventId, { ...old, highlightIdSet, highlights }) - return highlightedEventId + this.noteStatsMap.set(highlightKey, { ...old, highlightIdSet, highlights }) + return highlightKey } /** Kind 39701: count one bookmark per pubkey for this article URL (synthetic thread id). */ private addWebBookmarkByArticleUrlEvent(evt: Event): string | undefined { const url = getWebBookmarkArticleUrl(evt) if (!url) return - const targetId = rssArticleStableEventId(canonicalizeRssArticleUrl(url)) + const targetId = this.statsKey(rssArticleStableEventId(canonicalizeRssArticleUrl(url))) const old = this.noteStatsMap.get(targetId) || {} const bookmarkPubkeySet = old.bookmarkPubkeySet ?? new Set() if (bookmarkPubkeySet.has(evt.pubkey)) return targetId @@ -792,7 +901,7 @@ class NoteStatsService { private addBookmarkListRefsByEvent(evt: Event) { for (const tag of evt.tags) { if (tag[0] !== 'e' || !tag[1]) continue - const targetId = tag[1] + const targetId = this.statsKey(tag[1]) const old = this.noteStatsMap.get(targetId) || {} const bookmarkPubkeySet = old.bookmarkPubkeySet ?? new Set() if (bookmarkPubkeySet.has(evt.pubkey)) continue diff --git a/src/types/index.d.ts b/src/types/index.d.ts index 2784a9b8..32477e39 100644 --- a/src/types/index.d.ts +++ b/src/types/index.d.ts @@ -24,6 +24,11 @@ export type TProfile = { avatar?: string /** File size of the profile picture in bytes, sourced from a matching imeta tag in the kind-0 event. */ pictureSize?: number + /** + * Synthesized in feed profile batch when kind 0 was missing; {@link useFetchProfile} should still + * run a per-pubkey fetch so avatars and display names can load. + */ + batchPlaceholder?: boolean nip05?: string nip05List?: string[] about?: string