+
{layoutRows.map((row) => {
const intensity = Math.min(1, row.heat / maxHeat)
const size = Math.min(200, Math.max(76, 52 + Math.sqrt(row.heat) * 9))
@@ -466,6 +522,9 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
follows: row.followAuthorsInThread
})
const ariaLabel = [row.snippet, statsLine, t('heatMapOpenThread')].filter(Boolean).join('. ')
+ const connectorHit =
+ hoveredConnector != null &&
+ (row.rootId === hoveredConnector.a || row.rootId === hoveredConnector.b)
return (
@@ -473,10 +532,11 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
ref={bindBubbleRef(row.rootId)}
type="button"
className={cn(
- 'group relative shrink-0 rounded-full border shadow-sm transition-transform',
+ 'pointer-events-auto group relative shrink-0 rounded-full border shadow-sm transition-transform',
'flex items-center justify-center',
'hover:z-10 hover:scale-[1.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
- 'border-border/70 bg-card/90 backdrop-blur-sm'
+ 'border-border/70 bg-card/90 backdrop-blur-sm',
+ connectorHit && 'z-[9] scale-[1.03] ring-2 ring-primary/70 ring-offset-2 ring-offset-background'
)}
style={{
width: size,
diff --git a/src/services/client.service.ts b/src/services/client.service.ts
index 83d46b97..3c008b19 100644
--- a/src/services/client.service.ts
+++ b/src/services/client.service.ts
@@ -180,6 +180,8 @@ export const JUMBLE_SESSION_RELAY_STRIKES_CHANGED = 'jumble:session-relay-strike
/** Live timeline REQ: EOSE caps “connected but silent” relays. */
const SUBSCRIBE_RELAY_EOSE_TIMEOUT_MS = 4800
+/** Coalesce pre-EOSE timeline snapshots; `setTimeout` so updates still run when rAF is throttled (background tab). */
+const TIMELINE_STREAMING_COALESCE_MS = 24
/**
* After initial timeline EOSE (incl. grace), events with `created_at` older than this many seconds
@@ -1542,6 +1544,17 @@ class ClientService extends EventTarget {
})
let hasResolved = false
let earlyGraceTimer: ReturnType | null = null
+ /**
+ * Live timelines listen for {@link emitNewEvent} on the first relay ACK — not after N/3 successes.
+ * Waiting for a third of many relays meant the profile/home feed stayed stale until a subscription
+ * picked the note up from the network (minutes later if few relays accepted the publish).
+ */
+ let newEventLiveFanoutEmitted = false
+ const maybeEmitNewEventForLiveFeeds = () => {
+ if (newEventLiveFanoutEmitted || successCount < 1) return
+ newEventLiveFanoutEmitted = true
+ client.emitNewEvent(event)
+ }
const globalTimeout = setTimeout(() => {
if (hasResolved) {
@@ -1574,6 +1587,7 @@ class ClientService extends EventTarget {
earlyGraceTimer = null
}
hasResolved = true
+ maybeEmitNewEventForLiveFeeds()
logger.debug('[PublishEvent] Resolving due to timeout', {
success: successCount >= uniqueRelayUrls.length / 3,
successCount,
@@ -1782,11 +1796,7 @@ class ClientService extends EventTarget {
successCount
})
- // If one third of the relays have accepted the event, consider it a success
- const isSuccess = successCount >= uniqueRelayUrls.length / 3
- if (isSuccess) {
- this.emitNewEvent(event)
- }
+ maybeEmitNewEventForLiveFeeds()
if (currentFinished >= uniqueRelayUrls.length && !hasResolved) {
if (earlyGraceTimer != null) {
clearTimeout(earlyGraceTimer)
@@ -2674,13 +2684,20 @@ class ClientService extends EventTarget {
/**
* Stream matching events to the UI immediately. Initial completion is either aggregate `oneose` from all
* relays, or {@link firstRelayResultGraceMs} after the first event (whichever comes first).
- * While still before EOSE, coalesce bursts onto one rAF so we do not sort the full buffer on every microtask.
+ * While still before EOSE, coalesce bursts with a short timeout (not rAF) so feeds still advance when the
+ * tab is in the background — browsers throttle rAF heavily there, which looked like a frozen timeline.
*/
- let streamFlushRafId: number | null = null
+ let streamFlushDelayId: ReturnType | null = null
+ const clearStreamFlushDelay = () => {
+ if (streamFlushDelayId != null) {
+ clearTimeout(streamFlushDelayId)
+ streamFlushDelayId = null
+ }
+ }
const flushStreamingSnapshot = () => {
if (eosedAt) return
const emit = () => {
- streamFlushRafId = null
+ streamFlushDelayId = null
if (eosedAt) return
if (needSort) {
const sorted = [...events].sort((a, b) => b.created_at - a.created_at).slice(0, filter.limit)
@@ -2690,15 +2707,12 @@ class ClientService extends EventTarget {
}
}
if (events.length <= 1) {
- if (streamFlushRafId != null) {
- cancelAnimationFrame(streamFlushRafId)
- streamFlushRafId = null
- }
+ clearStreamFlushDelay()
emit()
return
}
- if (streamFlushRafId == null) {
- streamFlushRafId = requestAnimationFrame(emit)
+ if (streamFlushDelayId == null) {
+ streamFlushDelayId = setTimeout(emit, TIMELINE_STREAMING_COALESCE_MS)
}
}
@@ -2786,8 +2800,7 @@ class ClientService extends EventTarget {
}
idx++
}
- if (idx >= timeline.refs.length) return
-
+ // idx === refs.length → strictly older than tail; splice appends (previous early-return dropped these).
timeline.refs.splice(idx, 0, [evt.id, evt.created_at])
that.scheduleTimelinePersist(key)
}
@@ -2834,10 +2847,7 @@ class ClientService extends EventTarget {
if (eosedAt != null) return
clearFirstResultGraceTimer()
- if (streamFlushRafId != null) {
- cancelAnimationFrame(streamFlushRafId)
- streamFlushRafId = null
- }
+ clearStreamFlushDelay()
eosedAt = dayjs().unix()
@@ -2924,10 +2934,7 @@ class ClientService extends EventTarget {
timelineKey: key,
closer: () => {
clearFirstResultGraceTimer()
- if (streamFlushRafId != null) {
- cancelAnimationFrame(streamFlushRafId)
- streamFlushRafId = null
- }
+ clearStreamFlushDelay()
clearHttpTimelinePoll()
onEvents = () => {}
onNew = () => {}
diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts
index ebe1e325..5cad992e 100644
--- a/src/services/indexed-db.service.ts
+++ b/src/services/indexed-db.service.ts
@@ -1,4 +1,4 @@
-import { ExtendedKind } from '@/constants'
+import { CALENDAR_EVENT_KINDS, ExtendedKind } from '@/constants'
import {
publicationCoordinateLookupKeys,
splitPublicationCoordinate
@@ -21,6 +21,7 @@ import {
} from '@/lib/event'
import { citationPickerMatchesQuery } from '@/lib/citation-picker-search'
import logger from '@/lib/logger'
+import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
/** Hot archive row in {@link StoreNames.EVENT_ARCHIVE}. */
export type TArchivedEventRow = {
@@ -3037,6 +3038,57 @@ class IndexedDbService {
})
}
+ /**
+ * Hot {@link StoreNames.EVENT_ARCHIVE} rows for NIP-52 calendar notes whose occurrence overlaps the range.
+ * Calendar kinds are no longer archived on ingest, but older builds could still have 31922/31923 in the archive.
+ */
+ async getArchivedCalendarEventsOverlappingWindow(
+ rangeStartMs: number,
+ rangeEndExclusiveMs: number,
+ maxRowsScanned = 30_000,
+ maxMatches = 800
+ ): Promise {
+ await this.initPromise
+ if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return []
+
+ const kindSet = new Set(CALENDAR_EVENT_KINDS as readonly number[])
+ const maxRows = Math.min(Math.max(maxRowsScanned, 1), 50_000)
+ const maxOut = Math.min(Math.max(maxMatches, 1), 3000)
+
+ return new Promise((resolve, reject) => {
+ const out: Event[] = []
+ let scanned = 0
+ const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly')
+ const store = tx.objectStore(StoreNames.EVENT_ARCHIVE)
+ const req = store.openCursor()
+ req.onsuccess = () => {
+ const cursor = req.result as IDBCursorWithValue | null
+ if (!cursor || scanned >= maxRows || out.length >= maxOut) {
+ tx.commit()
+ resolve(out)
+ return
+ }
+ scanned += 1
+ const row = cursor.value as TArchivedEventRow
+ const ev = row?.value
+ if (
+ ev &&
+ isLikelyCachedNostrEvent(ev) &&
+ kindSet.has(ev.kind) &&
+ !shouldDropEventOnIngest(ev) &&
+ calendarOccurrenceOverlapsRange(ev, rangeStartMs, rangeEndExclusiveMs)
+ ) {
+ out.push(ev)
+ }
+ cursor.continue()
+ }
+ req.onerror = (e) => {
+ tx.commit()
+ reject(idbEventToError(e))
+ }
+ })
+ }
+
async deleteArchivedEvent(eventId: string): Promise {
const id = eventId.toLowerCase()
await this.initPromise
diff --git a/src/services/note-stats.service.ts b/src/services/note-stats.service.ts
index c233cce1..cf51c4c2 100644
--- a/src/services/note-stats.service.ts
+++ b/src/services/note-stats.service.ts
@@ -56,6 +56,13 @@ export type TNoteStats = {
class NoteStatsService {
static instance: NoteStatsService
private noteStatsMap: Map> = new Map()
+ /** Bumped whenever {@link notifyNoteStats} runs so {@link useNoteStatsById} can rely on `Object.is` (map entries alone are not always a new reference). */
+ private noteStatsUiEpochByKey = new Map()
+ /** Last `{ stats, epoch }` object per note for {@link getNoteStatsExternalSnapshot} — must be stable across renders. */
+ private noteStatsExternalSnapCache = new Map<
+ string,
+ { stats: Partial | undefined; epoch: number; out: { stats: Partial | undefined; epoch: number } }
+ >()
private noteStatsSubscribers = new Map void>>()
/**
* Batched, microtask-deferred subscriber wakes. Without this, {@link updateNoteStatsByEvents} called from
@@ -276,7 +283,7 @@ class NoteStatsService {
return
}
- logger.info('[NoteStats] processBatch: running', {
+ logger.debug('[NoteStats] processBatch: running', {
pendingForeground: this.pendingForeground.size,
pendingBackground: this.pendingEvents.size
})
@@ -288,7 +295,7 @@ class NoteStatsService {
try {
const eventsToProcess = this.takeNextStatsSlice()
- logger.info('[NoteStats] processBatch slice', {
+ logger.debug('[NoteStats] processBatch slice', {
count: eventsToProcess.length,
ids: eventsToProcess.map((id) => `${id.slice(0, 12)}…`),
remainingForeground: this.pendingForeground.size,
@@ -330,7 +337,7 @@ class NoteStatsService {
updatedAt: dayjs().unix()
})
const subscriberCount = this.noteStatsSubscribers.get(statsKey)?.size ?? 0
- logger.info('[NoteStats] processSingleEvent: snapshot published', {
+ logger.debug('[NoteStats] processSingleEvent: snapshot published', {
statsKey: `${statsKey.slice(0, 12)}…`,
reason,
subscriberCount
@@ -637,6 +644,16 @@ class NoteStatsService {
this.noteStatsSubscribers.set(key, set)
}
set.add(callback)
+ // Stats may have been merged while this note was off-screen (subscriberCount was 0 on publish).
+ // One microtask ping lets `useSyncExternalStore` re-read after mount so counts are not stuck blank.
+ queueMicrotask(() => {
+ if (!set?.has(callback)) return
+ try {
+ callback()
+ } catch (e) {
+ logger.warn('[NoteStatsService] subscribeNoteStats ping failed', { err: e })
+ }
+ })
return () => {
set?.delete(callback)
if (set?.size === 0) this.noteStatsSubscribers.delete(key)
@@ -662,6 +679,7 @@ class NoteStatsService {
private notifyNoteStats(noteId: string) {
const key = this.statsKey(noteId)
+ this.noteStatsUiEpochByKey.set(key, (this.noteStatsUiEpochByKey.get(key) ?? 0) + 1)
this.subscriberNotifyKeys.add(key)
if (this.subscriberNotifyMicrotaskQueued) return
this.subscriberNotifyMicrotaskQueued = true
@@ -674,6 +692,26 @@ class NoteStatsService {
return this.noteStatsMap.get(this.statsKey(id))
}
+ /**
+ * Snapshot for {@link useNoteStatsById} / `useSyncExternalStore`: `epoch` changes on every stats notify so React
+ * always re-renders when counts update (avoids stale UI when the map entry reference is reused or updates race mount).
+ */
+ getNoteStatsExternalSnapshot(noteId: string): {
+ stats: Partial | undefined
+ epoch: number
+ } {
+ const key = this.statsKey(noteId)
+ const stats = this.noteStatsMap.get(key)
+ const epoch = this.noteStatsUiEpochByKey.get(key) ?? 0
+ const prev = this.noteStatsExternalSnapCache.get(key)
+ if (prev && prev.stats === stats && prev.epoch === epoch) {
+ return prev.out
+ }
+ const out = { stats, epoch }
+ this.noteStatsExternalSnapCache.set(key, { stats, epoch, out })
+ return out
+ }
+
addZap(
pubkey: string,
eventId: string,