Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
536391cc6b
  1. 3
      src/components/NoteList/index.tsx
  2. 15
      src/components/NoteStats/LikeButton.tsx
  3. 10
      src/components/NoteStats/ReplyButton.tsx
  4. 6
      src/components/NoteStats/index.tsx
  5. 23
      src/components/PostEditor/PostContent.tsx
  6. 65
      src/hooks/useFetchProfile.tsx
  7. 4
      src/lib/error-suppression.ts
  8. 9
      src/services/client-replaceable-events.service.ts
  9. 219
      src/services/note-stats.service.ts
  10. 5
      src/types/index.d.ts

3
src/components/NoteList/index.tsx

@ -1294,7 +1294,8 @@ const NoteList = forwardRef(
next.set(pk, { next.set(pk, {
pubkey: pk, pubkey: pk,
npub: pubkeyToNpub(pk) ?? '', npub: pubkeyToNpub(pk) ?? '',
username: formatPubkey(pk) username: formatPubkey(pk),
batchPlaceholder: true
}) })
} }
} }

15
src/components/NoteStats/LikeButton.tsx

@ -34,7 +34,6 @@ import logger from '@/lib/logger'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import Emoji from '../Emoji' import Emoji from '../Emoji'
import EmojiPicker, { EMOJI_PICKER_REACTIONS } from '../EmojiPicker' import EmojiPicker, { EMOJI_PICKER_REACTIONS } from '../EmojiPicker'
import { formatCount } from './utils'
import { import {
type RelayStatus, type RelayStatus,
showPublishingError, showPublishingError,
@ -58,6 +57,8 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
const isReplyToDiscussion = useReplyUnderDiscussionRoot(event) const isReplyToDiscussion = useReplyUnderDiscussionRoot(event)
const showDiscussionVotes = isDiscussion || isReplyToDiscussion const showDiscussionVotes = isDiscussion || isReplyToDiscussion
const statsLoaded = noteStats?.updatedAt != null
const { myLastEmoji, likeCount, upVoteCount, downVoteCount } = useMemo(() => { const { myLastEmoji, likeCount, upVoteCount, downVoteCount } = useMemo(() => {
const stats = noteStats || {} const stats = noteStats || {}
const likes = hideUntrustedInteractions const likes = hideUntrustedInteractions
@ -234,12 +235,20 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
) : myLastEmoji ? ( ) : myLastEmoji ? (
<> <>
<Emoji emoji={inQuietMode ? '+' : myLastEmoji} classNames={{ img: 'size-4' }} /> <Emoji emoji={inQuietMode ? '+' : myLastEmoji} classNames={{ img: 'size-4' }} />
{!hideCount && !!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>} {!hideCount && statsLoaded && (
<div className="text-sm tabular-nums">
{(likeCount ?? 0) >= 100 ? '99+' : String(likeCount ?? 0)}
</div>
)}
</> </>
) : ( ) : (
<> <>
<SmilePlus /> <SmilePlus />
{!hideCount && !!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>} {!hideCount && statsLoaded && (
<div className="text-sm tabular-nums">
{(likeCount ?? 0) >= 100 ? '99+' : String(likeCount ?? 0)}
</div>
)}
</> </>
)} )}
</button> </button>

10
src/components/NoteStats/ReplyButton.tsx

@ -26,6 +26,12 @@ export default function ReplyButton({ event, hideCount = false }: { event: Event
hasReplied hasReplied
} }
}, [noteStats, event.id, hideUntrustedInteractions, isUserTrusted, pubkey]) }, [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) const [open, setOpen] = useState(false)
return ( return (
@ -44,7 +50,9 @@ export default function ReplyButton({ event, hideCount = false }: { event: Event
title={t('Reply')} title={t('Reply')}
> >
<MessageCircle /> <MessageCircle />
{!hideCount && !!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>} {!hideCount && replyCountLabel !== '' && (
<div className="text-sm tabular-nums">{replyCountLabel}</div>
)}
</button> </button>
<PostEditor parentEvent={event} open={open} setOpen={setOpen} /> <PostEditor parentEvent={event} open={open} setOpen={setOpen} />
</> </>

6
src/components/NoteStats/index.tsx

@ -7,6 +7,7 @@ import noteStatsService from '@/services/note-stats.service'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot' import { useReplyUnderDiscussionRoot } from '@/hooks/useReplyUnderDiscussionRoot'
import { shouldHideInteractions } from '@/lib/event-filtering' import { shouldHideInteractions } from '@/lib/event-filtering'
import logger from '@/lib/logger'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import BookmarkButton from '../BookmarkButton' import BookmarkButton from '../BookmarkButton'
@ -57,6 +58,11 @@ export default function NoteStats({
useEffect(() => { useEffect(() => {
if (!fetchIfNotExisting) return if (!fetchIfNotExisting) return
logger.debug('[NoteStats] UI: scheduling fetchNoteStats', {
eventId: `${event.id.slice(0, 12)}`,
kind: event.kind,
hintRelayCount: statsRelays.length
})
setLoading(true) setLoading(true)
noteStatsService.fetchNoteStats(event, pubkey, statsRelays).finally(() => setLoading(false)) noteStatsService.fetchNoteStats(event, pubkey, statsRelays).finally(() => setLoading(false))
// Intentionally omit `event` object: parent feeds often pass new references each render; // Intentionally omit `event` object: parent feeds often pass new references each render;

23
src/components/PostEditor/PostContent.tsx

@ -1627,7 +1627,7 @@ export default function PostContent({
// Note: URL will be inserted when upload completes in handleMediaUploadSuccess // 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 let resolvedKind: number
if (selectedKind !== undefined) { if (selectedKind !== undefined) {
resolvedKind = selectedKind resolvedKind = selectedKind
setMediaNoteKind(resolvedKind)
} else { } else {
resolvedKind = await getMediaKindFromFile(uploadingFile, false) resolvedKind = await getMediaKindFromFile(uploadingFile, false)
const isRootComposer = !parentEvent && !isPublicMessage && !(isDiscussionThread && !parentEvent) }
if (isRootComposer) {
setMediaNoteKind(resolvedKind) // New-post composer: images stay kind 1 (short text + imeta + URL), not kind 20 picture notes.
setMediaUrl(url) if (resolvedKind === ExtendedKind.PICTURE) {
} setMediaNoteKind(null)
setMediaUrl('')
} else {
setMediaNoteKind(resolvedKind)
setMediaUrl(url)
} }
const imetaTag = mediaUpload.getImetaTagByUrl(url) const imetaTag = mediaUpload.getImetaTagByUrl(url)
@ -1754,12 +1757,6 @@ export default function PostContent({
appendComposerImetaTag(newImetaTag) appendComposerImetaTag(newImetaTag)
if (selectedKind !== undefined) {
setMediaUrl(url)
} else if (mediaNoteKindRef.current !== null) {
setMediaUrl((prev) => prev || url)
}
setTimeout(() => { setTimeout(() => {
if (textareaRef.current) { if (textareaRef.current) {
const currentText = textareaRef.current.getText() const currentText = textareaRef.current.getText()

65
src/hooks/useFetchProfile.tsx

@ -1,14 +1,44 @@
import { PROFILE_FETCH_PROMISE_TIMEOUT_MS } from '@/constants' import { PROFILE_FETCH_PROMISE_TIMEOUT_MS } from '@/constants'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { getProfileFromEvent } from '@/lib/event-metadata' import { getProfileFromEvent } from '@/lib/event-metadata'
import { getSeededProfileForNavigation } from '@/lib/profile-navigation-seed' import { getSeededProfileForNavigation } from '@/lib/profile-navigation-seed'
import { userIdToPubkey } from '@/lib/pubkey' import { userIdToPubkey } from '@/lib/pubkey'
import { useNostrOptional } from '@/providers/nostr-context' import { useNostrOptional } from '@/providers/nostr-context'
import { useNoteFeedProfileContext } from '@/providers/NoteFeedProfileContext' 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 { TProfile } from '@/types'
import { kinds } from 'nostr-tools'
import { useEffect, useState, useRef, useCallback } from 'react' import { useEffect, useState, useRef, useCallback } from 'react'
import logger from '@/lib/logger' 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<TProfile | null> {
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 // CRITICAL: Global deduplication - shared across ALL hook instances
// This prevents multiple components from fetching the same profile simultaneously // This prevents multiple components from fetching the same profile simultaneously
const globalFetchPromises = new Map<string, Promise<TProfile | null>>() const globalFetchPromises = new Map<string, Promise<TProfile | null>>()
@ -158,7 +188,16 @@ export function useFetchProfile(id?: string, skipCache = false) {
try { try {
globalFetchingPubkeys.add(pubkey) globalFetchingPubkeys.add(pubkey)
const startTime = Date.now() 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) // CRITICAL: Add timeout to prevent infinite hangs (must exceed batched metadata query globalTimeout)
const timeoutPromise = new Promise<never>((_, reject) => { const timeoutPromise = new Promise<never>((_, reject) => {
setTimeout(() => { setTimeout(() => {
@ -210,6 +249,14 @@ export function useFetchProfile(id?: string, skipCache = false) {
fetchTime: `${fetchTime}ms` 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 return null
} catch (err) { } catch (err) {
const isTimeout = err instanceof Error && err.message.includes('timeout') 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 // Set cooldown period after timeout to prevent cascade of duplicate fetches
globalFetchCooldowns.set(pubkey, Date.now() + 10000) // 10 second cooldown 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 on timeout instead of throwing - allows UI to show fallback
return null return null
} }
@ -293,10 +348,12 @@ export function useFetchProfile(id?: string, skipCache = false) {
// Extract pubkey early to check if id has changed // Extract pubkey early to check if id has changed
const extractedPubkey = userIdToPubkey(id) 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) { if (extractedPubkey && noteFeed && !skipCache) {
const fromBatch = noteFeed.profiles.get(extractedPubkey) const fromBatch = noteFeed.profiles.get(extractedPubkey)
if (fromBatch) { if (fromBatch && !fromBatch.batchPlaceholder) {
setProfile(fromBatch) setProfile(fromBatch)
setPubkey(extractedPubkey) setPubkey(extractedPubkey)
setIsFetching(false) setIsFetching(false)

4
src/lib/error-suppression.ts

@ -299,8 +299,8 @@ function suppressExpectedErrors() {
return return
} }
// Suppress Workbox logs // Suppress Workbox logs (do not filter [NoteStats] — app diagnostics use that tag)
if (message.includes('workbox') || message.includes('[NoteStats]')) { if (message.includes('workbox')) {
return return
} }

9
src/services/client-replaceable-events.service.ts

@ -570,6 +570,11 @@ export class ReplaceableEventService {
}) })
} }
const isSlowReplaceableBatch = kind === kinds.Metadata || kind === 10001 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( const events = await this.queryService.query(
relayUrls, relayUrls,
{ {
@ -578,7 +583,7 @@ export class ReplaceableEventService {
}, },
undefined, undefined,
{ {
replaceableRace: true, replaceableRace: useReplaceableRace,
eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100, eoseTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_EOSE_TIMEOUT_MS : 100,
globalTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS : 2000 globalTimeout: isSlowReplaceableBatch ? METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS : 2000
} }
@ -876,7 +881,7 @@ export class ReplaceableEventService {
setTimeout(() => { setTimeout(() => {
logger.debug('[ReplaceableEventService] fetchRelayList timeout, giving up', { pubkey }) logger.debug('[ReplaceableEventService] fetchRelayList timeout, giving up', { pubkey })
resolve(null) resolve(null)
}, 2000) }, 10_000)
}) })
authorRelayList = await Promise.race([relayListPromise, timeoutPromise]) authorRelayList = await Promise.race([relayListPromise, timeoutPromise])
} catch (error) { } catch (error) {

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

@ -54,6 +54,12 @@ class NoteStatsService {
private noteStatsMap: Map<string, Partial<TNoteStats>> = new Map() private noteStatsMap: Map<string, Partial<TNoteStats>> = new Map()
private noteStatsSubscribers = new Map<string, Set<() => void>>() private noteStatsSubscribers = new Map<string, Set<() => void>>()
private processingCache = new Set<string>() private processingCache = new Set<string>()
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 // Batch processing
private pendingEvents = new Set<string>() private pendingEvents = new Set<string>()
@ -65,9 +71,14 @@ class NoteStatsService {
/** Prevents overlapping processBatch runs (reentrant calls corrupted pendingEvents). */ /** Prevents overlapping processBatch runs (reentrant calls corrupted pendingEvents). */
private processBatchRunning = false private processBatchRunning = false
private readonly BATCH_DELAY = 200 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}. */ /** Client-only RSS/Web thread roots are not on relays; use the event passed into {@link fetchNoteStats}. */
private pendingSyntheticRootById = new Map<string, Event>() private pendingSyntheticRootById = new Map<string, Event>()
/** Root event from {@link fetchNoteStats} (feed/card already has it; avoids fetchEvent miss → no stats UI). */
private pendingStatsRootEventById = new Map<string, Event>()
constructor() { constructor() {
if (!NoteStatsService.instance) { if (!NoteStatsService.instance) {
@ -102,15 +113,35 @@ class NoteStatsService {
} }
async fetchNoteStats(event: Event, _pubkey?: string | null, favoriteRelays?: string[] | null) { 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)) { if (this.pendingEvents.has(eventId)) {
this.mergeFavoriteRelaysIntoPending(eventId, favoriteRelays) 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 return
} }
if (this.processingCache.has(eventId)) { if (this.processingCache.has(eventId)) {
this.mergeFavoriteRelaysIntoDeferred(eventId, favoriteRelays) 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 return
} }
@ -118,8 +149,17 @@ class NoteStatsService {
this.pendingEvents.add(eventId) this.pendingEvents.add(eventId)
if (event.kind === ExtendedKind.RSS_THREAD_ROOT) { if (event.kind === ExtendedKind.RSS_THREAD_ROOT) {
this.pendingSyntheticRootById.set(eventId, event) 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() this.armStatsBatchTimer()
if (this.pendingEvents.size >= this.MAX_BATCH_SIZE && !this.processBatchRunning) { if (this.pendingEvents.size >= this.MAX_BATCH_SIZE && !this.processBatchRunning) {
if (this.batchTimeout) { if (this.batchTimeout) {
@ -132,12 +172,16 @@ class NoteStatsService {
private async processBatch() { private async processBatch() {
if (this.processBatchRunning) { if (this.processBatchRunning) {
logger.debug('[NoteStats] processBatch: skipped (already running)', {
pendingSize: this.pendingEvents.size
})
return return
} }
if (this.pendingEvents.size === 0) { if (this.pendingEvents.size === 0) {
return return
} }
logger.info('[NoteStats] processBatch: running', { pendingSize: this.pendingEvents.size })
this.processBatchRunning = true this.processBatchRunning = true
if (this.batchTimeout) { if (this.batchTimeout) {
clearTimeout(this.batchTimeout) clearTimeout(this.batchTimeout)
@ -150,7 +194,16 @@ class NoteStatsService {
for (const id of eventsToProcess) { for (const id of eventsToProcess) {
this.pendingEvents.delete(id) 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 { } finally {
this.processBatchRunning = false this.processBatchRunning = false
@ -162,32 +215,65 @@ class NoteStatsService {
private async processSingleEvent(eventId: string) { private async processSingleEvent(eventId: string) {
if (this.processingCache.has(eventId)) { 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 return
} }
this.processingCache.add(eventId) this.processingCache.add(eventId)
const favoriteRelays = this.pendingFetchFavoriteRelays.get(eventId) const favoriteRelays = this.pendingFetchFavoriteRelays.get(eventId)
this.pendingFetchFavoriteRelays.delete(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 { try {
logger.debug('[NoteStats] processSingleEvent: start', { eventId: `${eventId.slice(0, 12)}` })
// Synthetic RSS/Web thread parents are not published; use the instance from fetchNoteStats. // Synthetic RSS/Web thread parents are not published; use the instance from fetchNoteStats.
const synthetic = this.pendingSyntheticRootById.get(eventId) const synthetic = this.pendingSyntheticRootById.get(eventId)
this.pendingSyntheticRootById.delete(eventId) this.pendingSyntheticRootById.delete(eventId)
const event = synthetic ?? (await eventService.fetchEvent(eventId)) const callerRoot = this.pendingStatsRootEventById.get(eventId)
if (!event) { this.pendingStatsRootEventById.delete(eventId)
logger.debug('[NoteStats] Event not found:', eventId.substring(0, 8)) 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 return
} }
const finalRelayUrls = await this.buildNoteStatsRelayList(event, favoriteRelays) const finalRelayUrls = await this.buildNoteStatsRelayList(resolvedEvent, favoriteRelays)
const replaceableCoordinate = isReplaceableEvent(event.kind) const replaceableCoordinate = isReplaceableEvent(resolvedEvent.kind)
? getReplaceableCoordinateFromEvent(event) ? getReplaceableCoordinateFromEvent(resolvedEvent)
: undefined : undefined
const { nonSocial, social } = this.buildFilterGroups(event, replaceableCoordinate) const { nonSocial, social } = this.buildFilterGroups(resolvedEvent, replaceableCoordinate)
const fetchOpts = { const fetchOpts = {
eoseTimeout: 10_000, eoseTimeout: 10_000,
globalTimeout: 28_000, globalTimeout: 28_000,
@ -195,11 +281,17 @@ class NoteStatsService {
} }
const events: Event[] = [] 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 { queryService } = await import('@/services/client.service')
const onStatsEvent = (evt: Event) => { const onStatsEvent = (evt: Event) => {
this.updateNoteStatsByEvents([evt], event.pubkey) this.updateNoteStatsByEvents([evt], resolvedEvent!.pubkey)
events.push(evt) events.push(evt)
} }
if (nonSocial.length > 0) { if (nonSocial.length > 0) {
@ -214,16 +306,23 @@ class NoteStatsService {
onevent: onStatsEvent onevent: onStatsEvent
}) })
} }
logger.debug('[NoteStats] Fetched', events.length, 'events for stats')
this.noteStatsMap.set(event.id, { logger.debug('[NoteStats] processSingleEvent: relay fetch finished', {
...(this.noteStatsMap.get(event.id) ?? {}), eventId: `${resolvedEvent.id.slice(0, 12)}`,
updatedAt: dayjs().unix() 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 { } finally {
if (!publishedStatsSnapshot) {
markStatsLoaded(resolvedEvent?.id ?? eventId, 'finally-fallback')
}
this.processingCache.delete(eventId) this.processingCache.delete(eventId)
if (this.inFlightDeferredFavoriteRelays.has(eventId)) { if (this.inFlightDeferredFavoriteRelays.has(eventId)) {
const deferred = this.inFlightDeferredFavoriteRelays.get(eventId)! const deferred = this.inFlightDeferredFavoriteRelays.get(eventId)!
@ -338,14 +437,15 @@ class NoteStatsService {
return { nonSocial, social } return { nonSocial, social }
} }
const rootId = this.statsKey(event.id)
const nonSocial: Filter[] = [ const nonSocial: Filter[] = [
{ '#e': [event.id], kinds: [kinds.Reaction], limit: reactionLimit }, { '#e': [rootId], kinds: [kinds.Reaction], limit: reactionLimit },
{ '#e': [event.id], kinds: [kinds.Zap], limit: 100 } { '#e': [rootId], kinds: [kinds.Zap], limit: 100 }
] ]
const social: Filter[] = [ const social: Filter[] = [
{ {
'#e': [event.id], '#e': [rootId],
kinds: [ kinds: [
...nip18RepostKinds, ...nip18RepostKinds,
kinds.ShortTextNote, kinds.ShortTextNote,
@ -356,7 +456,7 @@ class NoteStatsService {
limit: interactionLimit limit: interactionLimit
}, },
{ {
'#q': [event.id], '#q': [rootId],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT], kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
limit: 50 limit: 50
} }
@ -392,27 +492,28 @@ class NoteStatsService {
subscribeNoteStats(noteId: string, callback: () => void) { subscribeNoteStats(noteId: string, callback: () => void) {
let set = this.noteStatsSubscribers.get(noteId) const key = this.statsKey(noteId)
let set = this.noteStatsSubscribers.get(key)
if (!set) { if (!set) {
set = new Set() set = new Set()
this.noteStatsSubscribers.set(noteId, set) this.noteStatsSubscribers.set(key, set)
} }
set.add(callback) set.add(callback)
return () => { return () => {
set?.delete(callback) set?.delete(callback)
if (set?.size === 0) this.noteStatsSubscribers.delete(noteId) if (set?.size === 0) this.noteStatsSubscribers.delete(key)
} }
} }
private notifyNoteStats(noteId: string) { private notifyNoteStats(noteId: string) {
const set = this.noteStatsSubscribers.get(noteId) const set = this.noteStatsSubscribers.get(this.statsKey(noteId))
if (set) { if (set) {
set.forEach((cb) => cb()) set.forEach((cb) => cb())
} }
} }
getNoteStats(id: string): Partial<TNoteStats> | undefined { getNoteStats(id: string): Partial<TNoteStats> | undefined {
return this.noteStatsMap.get(id) return this.noteStatsMap.get(this.statsKey(id))
} }
addZap( addZap(
@ -424,18 +525,19 @@ class NoteStatsService {
created_at: number = dayjs().unix(), created_at: number = dayjs().unix(),
notify: boolean = true 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 zapPrSet = old.zapPrSet || new Set()
const zaps = old.zaps || [] const zaps = old.zaps || []
if (zapPrSet.has(pr)) return if (zapPrSet.has(pr)) return
zapPrSet.add(pr) zapPrSet.add(pr)
zaps.push({ pr, pubkey, amount, comment, created_at }) zaps.push({ pr, pubkey, amount, comment, created_at })
this.noteStatsMap.set(eventId, { ...old, zapPrSet, zaps }) this.noteStatsMap.set(key, { ...old, zapPrSet, zaps })
if (notify) { if (notify) {
this.notifyNoteStats(eventId) this.notifyNoteStats(key)
} }
return eventId return key
} }
/** /**
@ -465,7 +567,7 @@ class NoteStatsService {
} }
updatedEventIdSet.forEach((eventId) => { updatedEventIdSet.forEach((eventId) => {
this.notifyNoteStats(eventId) this.notifyNoteStats(this.statsKey(eventId))
}) })
} }
@ -547,6 +649,7 @@ class NoteStatsService {
} }
} }
if (!targetEventId) return if (!targetEventId) return
targetEventId = this.statsKey(targetEventId)
const old = this.noteStatsMap.get(targetEventId) || {} const old = this.noteStatsMap.get(targetEventId) || {}
const likeIdSet = old.likeIdSet || new Set() const likeIdSet = old.likeIdSet || new Set()
@ -574,8 +677,9 @@ class NoteStatsService {
const url = getWebExternalReactionTargetUrl(evt) const url = getWebExternalReactionTargetUrl(evt)
if (!url) return if (!url) return
const targetEventId = const targetEventId = this.statsKey(
forcedTargetEventId ?? rssArticleStableEventId(canonicalizeRssArticleUrl(url)) forcedTargetEventId ?? rssArticleStableEventId(canonicalizeRssArticleUrl(url))
)
const old = this.noteStatsMap.get(targetEventId) || {} const old = this.noteStatsMap.get(targetEventId) || {}
const likeIdSet = old.likeIdSet || new Set() const likeIdSet = old.likeIdSet || new Set()
@ -595,17 +699,18 @@ class NoteStatsService {
} }
removeLike(eventId: string, reactionEventId: string) { 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 likeIdSet = old.likeIdSet || new Set()
const likes = old.likes || [] const likes = old.likes || []
if (!likeIdSet.has(reactionEventId)) return eventId if (!likeIdSet.has(reactionEventId)) return key
likeIdSet.delete(reactionEventId) likeIdSet.delete(reactionEventId)
const newLikes = likes.filter(like => like.id !== reactionEventId) const newLikes = likes.filter(like => like.id !== reactionEventId)
this.noteStatsMap.set(eventId, { ...old, likeIdSet, likes: newLikes }) this.noteStatsMap.set(key, { ...old, likeIdSet, likes: newLikes })
this.notifyNoteStats(eventId) this.notifyNoteStats(key)
return eventId return key
} }
/** Target id for repost stats: `e` first (NIP-18 for both kind 6 and 16), then embedded JSON, then `a` (generic only). */ /** 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) { private addRepostByEvent(evt: Event, originalEventAuthor?: string, forcedTargetEventId?: string) {
const eventId = this.repostStatsTargetId(evt, forcedTargetEventId) const rawId = this.repostStatsTargetId(evt, forcedTargetEventId)
if (!eventId) return if (!rawId) return
const eventId = this.statsKey(rawId)
const old = this.noteStatsMap.get(eventId) || {} const old = this.noteStatsMap.get(eventId) || {}
const repostPubkeySet = old.repostPubkeySet || new Set() const repostPubkeySet = old.repostPubkeySet || new Set()
@ -707,8 +813,9 @@ class NoteStatsService {
} }
if (!originalEventId) return 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 replyIdSet = old.replyIdSet || new Set()
const replies = old.replies || [] const replies = old.replies || []
@ -720,8 +827,8 @@ class NoteStatsService {
replyIdSet.add(evt.id) replyIdSet.add(evt.id)
replies.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at }) replies.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at })
this.noteStatsMap.set(originalEventId, { ...old, replyIdSet, replies }) this.noteStatsMap.set(replyKey, { ...old, replyIdSet, replies })
return originalEventId return replyKey
} }
private isQuoteByEvent(evt: Event): boolean { private isQuoteByEvent(evt: Event): boolean {
@ -731,8 +838,9 @@ class NoteStatsService {
private addQuoteByEvent(evt: Event, originalEventAuthor?: string) { private addQuoteByEvent(evt: Event, originalEventAuthor?: string) {
const quotedEventId = evt.tags.find(tag => tag[0] === 'q')?.[1] const quotedEventId = evt.tags.find(tag => tag[0] === 'q')?.[1]
if (!quotedEventId) return 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 quoteIdSet = old.quoteIdSet || new Set()
const quotes = old.quotes || [] const quotes = old.quotes || []
@ -744,8 +852,8 @@ class NoteStatsService {
quoteIdSet.add(evt.id) quoteIdSet.add(evt.id)
quotes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at }) quotes.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at })
this.noteStatsMap.set(quotedEventId, { ...old, quoteIdSet, quotes }) this.noteStatsMap.set(quoteKey, { ...old, quoteIdSet, quotes })
return quotedEventId return quoteKey
} }
private addHighlightByEvent(evt: Event, originalEventAuthor?: string) { private addHighlightByEvent(evt: Event, originalEventAuthor?: string) {
@ -757,8 +865,9 @@ class NoteStatsService {
} }
} }
if (!highlightedEventId) return 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 highlightIdSet = old.highlightIdSet || new Set()
const highlights = old.highlights || [] const highlights = old.highlights || []
@ -770,15 +879,15 @@ class NoteStatsService {
highlightIdSet.add(evt.id) highlightIdSet.add(evt.id)
highlights.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at }) highlights.push({ id: evt.id, pubkey: evt.pubkey, created_at: evt.created_at })
this.noteStatsMap.set(highlightedEventId, { ...old, highlightIdSet, highlights }) this.noteStatsMap.set(highlightKey, { ...old, highlightIdSet, highlights })
return highlightedEventId return highlightKey
} }
/** Kind 39701: count one bookmark per pubkey for this article URL (synthetic thread id). */ /** Kind 39701: count one bookmark per pubkey for this article URL (synthetic thread id). */
private addWebBookmarkByArticleUrlEvent(evt: Event): string | undefined { private addWebBookmarkByArticleUrlEvent(evt: Event): string | undefined {
const url = getWebBookmarkArticleUrl(evt) const url = getWebBookmarkArticleUrl(evt)
if (!url) return if (!url) return
const targetId = rssArticleStableEventId(canonicalizeRssArticleUrl(url)) const targetId = this.statsKey(rssArticleStableEventId(canonicalizeRssArticleUrl(url)))
const old = this.noteStatsMap.get(targetId) || {} const old = this.noteStatsMap.get(targetId) || {}
const bookmarkPubkeySet = old.bookmarkPubkeySet ?? new Set<string>() const bookmarkPubkeySet = old.bookmarkPubkeySet ?? new Set<string>()
if (bookmarkPubkeySet.has(evt.pubkey)) return targetId if (bookmarkPubkeySet.has(evt.pubkey)) return targetId
@ -792,7 +901,7 @@ class NoteStatsService {
private addBookmarkListRefsByEvent(evt: Event) { private addBookmarkListRefsByEvent(evt: Event) {
for (const tag of evt.tags) { for (const tag of evt.tags) {
if (tag[0] !== 'e' || !tag[1]) continue if (tag[0] !== 'e' || !tag[1]) continue
const targetId = tag[1] const targetId = this.statsKey(tag[1])
const old = this.noteStatsMap.get(targetId) || {} const old = this.noteStatsMap.get(targetId) || {}
const bookmarkPubkeySet = old.bookmarkPubkeySet ?? new Set<string>() const bookmarkPubkeySet = old.bookmarkPubkeySet ?? new Set<string>()
if (bookmarkPubkeySet.has(evt.pubkey)) continue if (bookmarkPubkeySet.has(evt.pubkey)) continue

5
src/types/index.d.ts vendored

@ -24,6 +24,11 @@ export type TProfile = {
avatar?: string avatar?: string
/** File size of the profile picture in bytes, sourced from a matching imeta tag in the kind-0 event. */ /** File size of the profile picture in bytes, sourced from a matching imeta tag in the kind-0 event. */
pictureSize?: number 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 nip05?: string
nip05List?: string[] nip05List?: string[]
about?: string about?: string

Loading…
Cancel
Save