Browse Source

heat map

imwald
Silberengel 1 month ago
parent
commit
93e6fe299a
  1. 19
      src/components/NoteList/index.tsx
  2. 12
      src/components/Sidebar/FavoritesButton.tsx
  3. 2
      src/constants.ts
  4. 14
      src/i18n/locales/cs.ts
  5. 14
      src/i18n/locales/de.ts
  6. 14
      src/i18n/locales/en.ts
  7. 14
      src/i18n/locales/es.ts
  8. 14
      src/i18n/locales/fr.ts
  9. 14
      src/i18n/locales/nl.ts
  10. 14
      src/i18n/locales/pl.ts
  11. 14
      src/i18n/locales/ru.ts
  12. 14
      src/i18n/locales/tr.ts
  13. 14
      src/i18n/locales/zh.ts
  14. 26
      src/lib/feed-kind-filter.ts
  15. 66
      src/lib/relay-thread-heat-cache.ts
  16. 240
      src/lib/relay-thread-heat.ts
  17. 16
      src/pages/primary/NoteListPage/index.tsx
  18. 464
      src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx
  19. 190
      src/pages/primary/SpellsPage/index.tsx
  20. 53
      src/pages/secondary/ProfileInteractionDiagramPage/index.tsx
  21. 19
      src/services/client-events.service.ts
  22. 15
      src/services/client-query.service.ts
  23. 51
      src/services/indexed-db.service.ts

19
src/components/NoteList/index.tsx

@ -18,6 +18,7 @@ import { @@ -18,6 +18,7 @@ import {
} from '@/lib/spell-feed-request-identity'
import logger from '@/lib/logger'
import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata'
import { isTouchDevice } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
@ -124,24 +125,6 @@ const LOAD_MORE_IO_ROOT_MARGIN_BOTTOM_PX = 3200 @@ -124,24 +125,6 @@ const LOAD_MORE_IO_ROOT_MARGIN_BOTTOM_PX = 3200
*/
const LOAD_MORE_SCROLL_PREFETCH_VIEWPORT_MULT = 2.35
/** Same rules as visible-row filtering when the home kind picker applies (not {@link shouldHideEvent}). */
function eventPassesNoteListKindPicker(
event: Event,
effectiveShowKinds: readonly number[],
showKind1OPs: boolean,
showKind1Replies: boolean,
showKind1111: boolean
): boolean {
if (!effectiveShowKinds.includes(event.kind)) return false
if (event.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(event)
if (isReply && !showKind1Replies) return false
if (!isReply && !showKind1OPs) return false
}
if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false
if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) return false
return true
}
const LOAD_MORE_SCROLL_PREFETCH_MIN_PX = 960
/** Min ms between scroll-driven load-more attempts (loadMore also throttles internally). */
const LOAD_MORE_SCROLL_PREFETCH_COOLDOWN_MS = 180

12
src/components/Sidebar/FavoritesButton.tsx

@ -1,10 +1,12 @@ @@ -1,10 +1,12 @@
import { usePrimaryPage } from '@/contexts/primary-page-context'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useNostr } from '@/providers/NostrProvider'
import { Star } from 'lucide-react'
import { Flame } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import SidebarItem from './SidebarItem'
export default function FavoritesButton() {
const { t } = useTranslation()
const { navigate, current, currentPageProps, display } = usePrimaryPage()
const { primaryViewType } = usePrimaryNoteView()
const { pubkey } = useNostr()
@ -14,16 +16,16 @@ export default function FavoritesButton() { @@ -14,16 +16,16 @@ export default function FavoritesButton() {
return (
<SidebarItem
title="Favorites"
onClick={() => navigate('spells', { spell: 'favorites' })}
title={t('Heat map')}
onClick={() => navigate('spells', { spell: 'heatMap' })}
active={
display &&
current === 'spells' &&
primaryViewType === null &&
spell === 'favorites'
spell === 'heatMap'
}
>
<Star strokeWidth={3} />
<Flame strokeWidth={3} />
</SidebarItem>
)
}

2
src/constants.ts

@ -861,7 +861,7 @@ export const FAUX_SPELL_ORDER = [ @@ -861,7 +861,7 @@ export const FAUX_SPELL_ORDER = [
'notifications',
'discussions',
'following',
'favorites',
'heatMap',
'followPacks',
'media',
'interests',

14
src/i18n/locales/cs.ts

@ -761,6 +761,20 @@ export default { @@ -761,6 +761,20 @@ export default {
"articles and publications": "articles and publications",
Interests: "Interests",
Favorites: "Favorites",
"Heat map": "Thread heat map",
heatMapDescription:
"Only threads with at least five feed-filtered notes (last ~3 days) appear. Data merges this tab’s session cache, your on-device archive, and your relay stack. Bubble size and glow reflect activity. Lines connect threads when notes reference another thread's events (`e` / `E` / `q`) or when threads share a replaceable coordinate (`a` / `A`).",
heatMapLocalOnlyBanner:
"No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).",
heatMapLoading: "Merging session cache, archive, and relays…",
heatMapEmpty:
"Nothing meets the bar yet. Threads need at least five notes that pass your feed kind filter from about the last 72 hours. Browse feeds or Rescan after syncing.",
heatMapFetchError: "Could not load a thread snapshot from relays.",
heatMapNoRelays: "Add read relays (or favorites) in settings to query threads.",
heatMapRescan: "Rescan",
heatMapOpenThread: "Open thread",
heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows",
"Please login to view thread heat map": "Please log in to open the thread heat map.",
Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",
"No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

14
src/i18n/locales/de.ts

@ -781,6 +781,20 @@ export default { @@ -781,6 +781,20 @@ export default {
"articles and publications": "Artikel und Veröffentlichungen",
Interests: "Interessen",
Favorites: "Favorites",
"Heat map": "Thread-Heatmap",
heatMapDescription:
"Es erscheinen nur Threads mit mindestens fünf feed‑gefilterten Notes (ca. letzte 3 Tage), zusammengeführt aus Sitzungs‑Cache, lokalem Archiv und Relay‑Stack. Größe und Leuchten spiegeln Aktivität wider. Linien verbinden Threads, wenn Notes andere per e‑/E‑/q referenzieren oder dieselbe adressierbare Koordinate (a/A, NIP‑33) nutzen.",
heatMapLocalOnlyBanner:
"Keine Lese‑Relay‑Liste — es werden nur Sitzungs‑Cache und lokales Archiv gemischt (Relays in den Einstellungen ergänzen für Live‑Daten).",
heatMapLoading: "Sitzungs‑Cache, Archiv und Relays werden zusammengeführt…",
heatMapEmpty:
"Noch nichts passend. Threads brauchen mindestens fünf Notes, die deinen Feed‑Kind‑Filter passieren (ca. letzte 72 Stunden). Feeds lesen oder nach dem Sync erneut scannen.",
heatMapFetchError: "Thread-Snapshot von den Relays konnte nicht geladen werden.",
heatMapNoRelays: "Bitte Lese-Relays (oder Favoriten) in den Einstellungen hinzufügen.",
heatMapRescan: "Erneut scannen",
heatMapOpenThread: "Thread öffnen",
heatMapBubbleStats: "{{posts}} Notes · {{people}} Personen · {{follows}} Folge-Accounts im Thread",
"Please login to view thread heat map": "Bitte anmelden, um die Thread-Heatmap zu öffnen.",
Calendar: "Kalender",
"No subscribed interests yet.": "Noch keine Interessen abonniert. Themen in den Einstellungen hinzufügen, um sie hier zu sehen.",
"No bookmarked notes with id tags yet.": "Noch keine Lesezeichen mit Ereignis-IDs. Nur klassische (e-Tag-) Lesezeichen erscheinen in diesem Feed.",

14
src/i18n/locales/en.ts

@ -785,6 +785,20 @@ export default { @@ -785,6 +785,20 @@ export default {
"articles and publications": "articles and publications",
Interests: "Interests",
Favorites: "Favorites",
"Heat map": "Thread heat map",
heatMapDescription:
"Only threads with at least five feed-filtered notes (last ~3 days) appear. Data merges this tab’s session cache, your on-device archive, and your relay stack. Bubble size and glow reflect activity. Lines connect threads when notes reference another thread’s events (`e` / `E` / `q`) or when threads share a replaceable coordinate (`a` / `A`).",
heatMapLocalOnlyBanner:
"No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).",
heatMapLoading: "Merging session cache, archive, and relays…",
heatMapEmpty:
"Nothing meets the bar yet. Threads need at least five notes that pass your feed kind filter from about the last 72 hours. Browse feeds or Rescan after syncing.",
heatMapFetchError: "Could not load a thread snapshot from relays.",
heatMapNoRelays: "Add read relays (or favorites) in settings to query threads.",
heatMapRescan: "Rescan",
heatMapOpenThread: "Open thread",
heatMapBubbleStats: "{{posts}} notes · {{people}} people · {{follows}} follows in thread",
"Please login to view thread heat map": "Please log in to open the thread heat map.",
Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",
"No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

14
src/i18n/locales/es.ts

@ -761,6 +761,20 @@ export default { @@ -761,6 +761,20 @@ export default {
"articles and publications": "articles and publications",
Interests: "Interests",
Favorites: "Favorites",
"Heat map": "Thread heat map",
heatMapDescription:
"Only threads with at least five feed-filtered notes (last ~3 days) appear. Data merges this tab’s session cache, your on-device archive, and your relay stack. Bubble size and glow reflect activity. Lines connect threads when notes reference another thread's events (`e` / `E` / `q`) or when threads share a replaceable coordinate (`a` / `A`).",
heatMapLocalOnlyBanner:
"No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).",
heatMapLoading: "Merging session cache, archive, and relays…",
heatMapEmpty:
"Nothing meets the bar yet. Threads need at least five notes that pass your feed kind filter from about the last 72 hours. Browse feeds or Rescan after syncing.",
heatMapFetchError: "Could not load a thread snapshot from relays.",
heatMapNoRelays: "Add read relays (or favorites) in settings to query threads.",
heatMapRescan: "Rescan",
heatMapOpenThread: "Open thread",
heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows",
"Please login to view thread heat map": "Please log in to open the thread heat map.",
Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",
"No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

14
src/i18n/locales/fr.ts

@ -761,6 +761,20 @@ export default { @@ -761,6 +761,20 @@ export default {
"articles and publications": "articles and publications",
Interests: "Interests",
Favorites: "Favorites",
"Heat map": "Thread heat map",
heatMapDescription:
"Only threads with at least five feed-filtered notes (last ~3 days) appear. Data merges this tab’s session cache, your on-device archive, and your relay stack. Bubble size and glow reflect activity. Lines connect threads when notes reference another thread's events (`e` / `E` / `q`) or when threads share a replaceable coordinate (`a` / `A`).",
heatMapLocalOnlyBanner:
"No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).",
heatMapLoading: "Merging session cache, archive, and relays…",
heatMapEmpty:
"Nothing meets the bar yet. Threads need at least five notes that pass your feed kind filter from about the last 72 hours. Browse feeds or Rescan after syncing.",
heatMapFetchError: "Could not load a thread snapshot from relays.",
heatMapNoRelays: "Add read relays (or favorites) in settings to query threads.",
heatMapRescan: "Rescan",
heatMapOpenThread: "Open thread",
heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows",
"Please login to view thread heat map": "Please log in to open the thread heat map.",
Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",
"No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

14
src/i18n/locales/nl.ts

@ -761,6 +761,20 @@ export default { @@ -761,6 +761,20 @@ export default {
"articles and publications": "articles and publications",
Interests: "Interests",
Favorites: "Favorites",
"Heat map": "Thread heat map",
heatMapDescription:
"Only threads with at least five feed-filtered notes (last ~3 days) appear. Data merges this tab’s session cache, your on-device archive, and your relay stack. Bubble size and glow reflect activity. Lines connect threads when notes reference another thread's events (`e` / `E` / `q`) or when threads share a replaceable coordinate (`a` / `A`).",
heatMapLocalOnlyBanner:
"No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).",
heatMapLoading: "Merging session cache, archive, and relays…",
heatMapEmpty:
"Nothing meets the bar yet. Threads need at least five notes that pass your feed kind filter from about the last 72 hours. Browse feeds or Rescan after syncing.",
heatMapFetchError: "Could not load a thread snapshot from relays.",
heatMapNoRelays: "Add read relays (or favorites) in settings to query threads.",
heatMapRescan: "Rescan",
heatMapOpenThread: "Open thread",
heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows",
"Please login to view thread heat map": "Please log in to open the thread heat map.",
Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",
"No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

14
src/i18n/locales/pl.ts

@ -761,6 +761,20 @@ export default { @@ -761,6 +761,20 @@ export default {
"articles and publications": "articles and publications",
Interests: "Interests",
Favorites: "Favorites",
"Heat map": "Thread heat map",
heatMapDescription:
"Only threads with at least five feed-filtered notes (last ~3 days) appear. Data merges this tab’s session cache, your on-device archive, and your relay stack. Bubble size and glow reflect activity. Lines connect threads when notes reference another thread's events (`e` / `E` / `q`) or when threads share a replaceable coordinate (`a` / `A`).",
heatMapLocalOnlyBanner:
"No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).",
heatMapLoading: "Merging session cache, archive, and relays…",
heatMapEmpty:
"Nothing meets the bar yet. Threads need at least five notes that pass your feed kind filter from about the last 72 hours. Browse feeds or Rescan after syncing.",
heatMapFetchError: "Could not load a thread snapshot from relays.",
heatMapNoRelays: "Add read relays (or favorites) in settings to query threads.",
heatMapRescan: "Rescan",
heatMapOpenThread: "Open thread",
heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows",
"Please login to view thread heat map": "Please log in to open the thread heat map.",
Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",
"No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

14
src/i18n/locales/ru.ts

@ -761,6 +761,20 @@ export default { @@ -761,6 +761,20 @@ export default {
"articles and publications": "articles and publications",
Interests: "Interests",
Favorites: "Favorites",
"Heat map": "Thread heat map",
heatMapDescription:
"Only threads with at least five feed-filtered notes (last ~3 days) appear. Data merges this tab’s session cache, your on-device archive, and your relay stack. Bubble size and glow reflect activity. Lines connect threads when notes reference another thread's events (`e` / `E` / `q`) or when threads share a replaceable coordinate (`a` / `A`).",
heatMapLocalOnlyBanner:
"No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).",
heatMapLoading: "Merging session cache, archive, and relays…",
heatMapEmpty:
"Nothing meets the bar yet. Threads need at least five notes that pass your feed kind filter from about the last 72 hours. Browse feeds or Rescan after syncing.",
heatMapFetchError: "Could not load a thread snapshot from relays.",
heatMapNoRelays: "Add read relays (or favorites) in settings to query threads.",
heatMapRescan: "Rescan",
heatMapOpenThread: "Open thread",
heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows",
"Please login to view thread heat map": "Please log in to open the thread heat map.",
Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",
"No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

14
src/i18n/locales/tr.ts

@ -761,6 +761,20 @@ export default { @@ -761,6 +761,20 @@ export default {
"articles and publications": "articles and publications",
Interests: "Interests",
Favorites: "Favorites",
"Heat map": "Thread heat map",
heatMapDescription:
"Only threads with at least five feed-filtered notes (last ~3 days) appear. Data merges this tab’s session cache, your on-device archive, and your relay stack. Bubble size and glow reflect activity. Lines connect threads when notes reference another thread's events (`e` / `E` / `q`) or when threads share a replaceable coordinate (`a` / `A`).",
heatMapLocalOnlyBanner:
"No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).",
heatMapLoading: "Merging session cache, archive, and relays…",
heatMapEmpty:
"Nothing meets the bar yet. Threads need at least five notes that pass your feed kind filter from about the last 72 hours. Browse feeds or Rescan after syncing.",
heatMapFetchError: "Could not load a thread snapshot from relays.",
heatMapNoRelays: "Add read relays (or favorites) in settings to query threads.",
heatMapRescan: "Rescan",
heatMapOpenThread: "Open thread",
heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows",
"Please login to view thread heat map": "Please log in to open the thread heat map.",
Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",
"No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

14
src/i18n/locales/zh.ts

@ -761,6 +761,20 @@ export default { @@ -761,6 +761,20 @@ export default {
"articles and publications": "articles and publications",
Interests: "Interests",
Favorites: "Favorites",
"Heat map": "Thread heat map",
heatMapDescription:
"Only threads with at least five feed-filtered notes (last ~3 days) appear. Data merges this tab’s session cache, your on-device archive, and your relay stack. Bubble size and glow reflect activity. Lines connect threads when notes reference another thread's events (`e` / `E` / `q`) or when threads share a replaceable coordinate (`a` / `A`).",
heatMapLocalOnlyBanner:
"No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).",
heatMapLoading: "Merging session cache, archive, and relays…",
heatMapEmpty:
"Nothing meets the bar yet. Threads need at least five notes that pass your feed kind filter from about the last 72 hours. Browse feeds or Rescan after syncing.",
heatMapFetchError: "Could not load a thread snapshot from relays.",
heatMapNoRelays: "Add read relays (or favorites) in settings to query threads.",
heatMapRescan: "Rescan",
heatMapOpenThread: "Open thread",
heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows",
"Please login to view thread heat map": "Please log in to open the thread heat map.",
Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.",
"No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

26
src/lib/feed-kind-filter.ts

@ -0,0 +1,26 @@ @@ -0,0 +1,26 @@
import { ExtendedKind } from '@/constants'
import { isReplyNoteEvent } from '@/lib/event'
import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
/**
* Same rules as visible-row filtering when the home kind picker applies
* (not {@link shouldHideEvent} / mute / trust layers).
*/
export function eventPassesNoteListKindPicker(
event: Event,
effectiveShowKinds: readonly number[],
showKind1OPs: boolean,
showKind1Replies: boolean,
showKind1111: boolean
): boolean {
if (!effectiveShowKinds.includes(event.kind)) return false
if (event.kind === kinds.ShortTextNote) {
const isReply = isReplyNoteEvent(event)
if (isReply && !showKind1Replies) return false
if (!isReply && !showKind1OPs) return false
}
if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false
if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) return false
return true
}

66
src/lib/relay-thread-heat-cache.ts

@ -0,0 +1,66 @@ @@ -0,0 +1,66 @@
import type { TRelayThreadHeatBubble, TRelayThreadHeatEdge } from '@/lib/relay-thread-heat'
const CACHE_V = 1 as const
export type TRelayThreadHeatMapCacheEnvelope = {
v: typeof CACHE_V
/** When the merge finished (ms since epoch). */
builtAtMs: number
bubbles: TRelayThreadHeatBubble[]
/** Links between bubbles (same as live merge); absent in older cache payloads. */
edges?: TRelayThreadHeatEdge[]
}
/** Stable short digest for SETTINGS key segments (FNV-1a 32-bit). */
export function digestHeatMapKeyPart(s: string): string {
let h = 2166136261
for (let i = 0; i < s.length; i++) {
h ^= s.charCodeAt(i)
h = Math.imul(h, 16777619)
}
return (h >>> 0).toString(36).padStart(6, '0')
}
export function relayThreadHeatMapSettingKey(
pubkey: string,
relayUrls: readonly string[],
followPubkeys: readonly string[],
/** Serialized home kind-picker state so cache invalidates when feed filters change. */
feedFilterKey: string
): string {
const pk = pubkey.trim().toLowerCase()
const relayKey = digestHeatMapKeyPart([...relayUrls].sort().join('\n'))
const followKey = digestHeatMapKeyPart(
[...followPubkeys]
.map((p) => p.trim().toLowerCase())
.filter(Boolean)
.sort()
.join('\n')
)
const feedKey = digestHeatMapKeyPart(feedFilterKey)
return `relayHeatV${CACHE_V}:${pk}:${relayKey}:${followKey}:${feedKey}`
}
export function parseRelayThreadHeatMapCache(raw: string | null): TRelayThreadHeatMapCacheEnvelope | null {
if (raw == null || raw === '') return null
try {
const o = JSON.parse(raw) as Partial<TRelayThreadHeatMapCacheEnvelope>
if (o.v !== CACHE_V || !Array.isArray(o.bubbles)) return null
const edgesRaw = o.edges
const edges: TRelayThreadHeatEdge[] | undefined = Array.isArray(edgesRaw)
? edgesRaw.filter(
(e: unknown): e is TRelayThreadHeatEdge =>
e != null &&
typeof (e as TRelayThreadHeatEdge).a === 'string' &&
typeof (e as TRelayThreadHeatEdge).b === 'string'
)
: undefined
return { v: CACHE_V, builtAtMs: typeof o.builtAtMs === 'number' ? o.builtAtMs : 0, bubbles: o.bubbles, edges }
} catch {
return null
}
}
export function serializeRelayThreadHeatMapCache(envelope: TRelayThreadHeatMapCacheEnvelope): string {
return JSON.stringify(envelope)
}

240
src/lib/relay-thread-heat.ts

@ -0,0 +1,240 @@ @@ -0,0 +1,240 @@
import { ExtendedKind } from '@/constants'
import {
getParentEventHexId,
getRootEventHexId,
isReplyNoteEvent,
normalizeReplaceableCoordinateString,
resolveDeclaredThreadRootEventHex
} from '@/lib/event'
import type { Event } from 'nostr-tools'
import { kinds } from 'nostr-tools'
export type TRelayThreadHeatBubble = {
rootId: string
heat: number
postCount: number
uniqueAuthors: number
followAuthorsInThread: number
snippet: string
lastActivity: number
rootEvent?: Event
}
/** Undirected link between thread roots (cross-refs, OP-anchor refs, or shared `a`/`A` coordinates). */
export type TRelayThreadHeatEdge = { a: string; b: string }
/** Minimum feed-filtered notes in a thread to appear as a bubble. */
export const RELAY_THREAD_HEAT_MIN_INTERACTIONS = 5
function collapseSnippet(content: string, maxLen = 160): string {
const t = content.replace(/\s+/g, ' ').trim().slice(0, maxLen)
return t || '…'
}
/** Map kind 1 / 11 events to a thread root id for aggregation. */
export function threadRootIdForHeat(ev: Event): string | null {
if (ev.kind !== kinds.ShortTextNote && ev.kind !== ExtendedKind.DISCUSSION) return null
if (!ev.pubkey?.trim()) return null
if (ev.kind === kinds.ShortTextNote && !isReplyNoteEvent(ev)) {
return ev.id.toLowerCase()
}
const rootHex = getRootEventHexId(ev)?.trim().toLowerCase()
const parentHex = getParentEventHexId(ev)?.trim().toLowerCase()
const raw = (rootHex || parentHex || ev.id).toLowerCase()
if (!/^[0-9a-f]{64}$/i.test(raw)) return ev.id.toLowerCase()
return resolveDeclaredThreadRootEventHex(raw)
}
/**
* Group recent notes into thread roots; score activity + author spread + how many of your follows posted.
* `since` is the window start (unix seconds) for recency scoring only.
*
* **Inclusion:** every distinct thread root with at least one kind **1** or **11** event becomes one row
* (no minimum reply count). **Heat** only ranks rows: `postCount + 1.6×uniqueAuthors + 11×followAuthorsInThread + recencyBoost`.
*/
export function buildRelayThreadHeatBubbles(
events: Event[],
followPubkeys: Set<string>,
since: number
): TRelayThreadHeatBubble[] {
const follow = followPubkeys
const now = Math.floor(Date.now() / 1000)
const span = Math.max(3600, now - since)
const byRoot = new Map<string, { authors: Set<string>; posts: Event[] }>()
for (const ev of events) {
const rid = threadRootIdForHeat(ev)
if (!rid) continue
const bucket = byRoot.get(rid) ?? { authors: new Set<string>(), posts: [] }
const author = ev.pubkey?.trim().toLowerCase()
if (author) bucket.authors.add(author)
bucket.posts.push(ev)
byRoot.set(rid, bucket)
}
const rows: TRelayThreadHeatBubble[] = []
for (const [rootId, { authors, posts }] of byRoot) {
let followAuthorsInThread = 0
for (const a of authors) {
if (follow.has(a)) followAuthorsInThread++
}
const postCount = posts.length
const uniqueAuthors = authors.size
let lastActivity = 0
for (const p of posts) {
if (p.created_at > lastActivity) lastActivity = p.created_at
}
const recencyBoost = ((lastActivity - since) / span) * 14
const heat =
postCount +
uniqueAuthors * 1.6 +
followAuthorsInThread * 11 +
recencyBoost
const rootEvent = posts.find(
(e) =>
e.id.toLowerCase() === rootId &&
(e.kind === kinds.ShortTextNote || e.kind === ExtendedKind.DISCUSSION)
)
const sortedByTime = [...posts].sort((a, b) => a.created_at - b.created_at)
const snippetSource =
rootEvent?.content?.trim() ||
sortedByTime.find((e) => e.kind === kinds.ShortTextNote || e.kind === ExtendedKind.DISCUSSION)?.content ||
''
rows.push({
rootId,
heat,
postCount,
uniqueAuthors,
followAuthorsInThread,
snippet: collapseSnippet(snippetSource),
lastActivity,
rootEvent
})
}
rows.sort((a, b) => b.heat - a.heat)
return rows
}
const EVENT_REF_TAG_NAMES = new Set(['e', 'E', 'q'])
/**
* Hex ids that should count as this threads OP / anchor for link resolution: root id, top-level
* notes, `e`/`E` markers `root`, and declared root/parent hex from notes in the thread (so refs to
* the OP still connect when the OP event is missing from {@link feedNotes}).
*/
function collectOpIdCandidatesForRoot(r: string, feedNotes: Event[]): Set<string> {
const rLower = r.toLowerCase()
const out = new Set<string>()
out.add(rLower)
for (const ev of feedNotes) {
if (threadRootIdForHeat(ev) !== rLower) continue
out.add(ev.id.toLowerCase())
const rootHex = getRootEventHexId(ev)?.trim().toLowerCase()
if (rootHex && /^[0-9a-f]{64}$/.test(rootHex)) out.add(rootHex)
const parentHex = getParentEventHexId(ev)?.trim().toLowerCase()
if (parentHex && /^[0-9a-f]{64}$/.test(parentHex)) out.add(parentHex)
if (ev.kind === kinds.ShortTextNote && !isReplyNoteEvent(ev)) {
out.add(ev.id.toLowerCase())
}
for (const t of ev.tags ?? []) {
if ((t[0] === 'e' || t[0] === 'E') && t[1] && String(t[3] ?? '').toLowerCase() === 'root') {
const id = t[1].trim().toLowerCase()
if (/^[0-9a-f]{64}$/.test(id)) out.add(id)
}
}
}
return out
}
/**
* Build edges between thread roots that appear in {@link rootIdsInView} when:
* - some note references another note id via `e` / `E` / `q` (including another threads OP when
* that OP is only visible via tags, not as a loaded event), or
* - two threads each have a note tagging the same replaceable coordinate (`a` / `A`, NIP-33).
*/
export function buildRelayThreadHeatEdges(
feedNotes: Event[],
rootIdsInView: ReadonlySet<string>
): TRelayThreadHeatEdge[] {
const idToRoot = new Map<string, string>()
for (const ev of feedNotes) {
const r = threadRootIdForHeat(ev)
if (r) idToRoot.set(ev.id.toLowerCase(), r)
}
/** Note id / OP-anchor hex → thread root (for refs that never appear as `feedNotes` rows). */
const opHexToRoot = new Map<string, string>()
for (const r of rootIdsInView) {
for (const h of collectOpIdCandidatesForRoot(r, feedNotes)) {
opHexToRoot.set(h, r)
}
}
const seen = new Set<string>()
const out: TRelayThreadHeatEdge[] = []
const addEdge = (x: string, y: string) => {
if (x === y || !rootIdsInView.has(x) || !rootIdsInView.has(y)) return
const [a, b] = x < y ? [x, y] : [y, x]
const key = `${a}:${b}`
if (seen.has(key)) return
seen.add(key)
out.push({ a, b })
}
for (const ev of feedNotes) {
const r = threadRootIdForHeat(ev)
if (!r || !rootIdsInView.has(r)) continue
for (const tag of ev.tags ?? []) {
const name = tag[0]
if (typeof name !== 'string' || !EVENT_REF_TAG_NAMES.has(name)) continue
const ref = String(tag[1] ?? '')
.trim()
.toLowerCase()
if (!/^[0-9a-f]{64}$/.test(ref)) continue
let otherRoot = idToRoot.get(ref) ?? opHexToRoot.get(ref) ?? null
if (otherRoot == null && rootIdsInView.has(ref)) {
otherRoot = ref
}
if (otherRoot != null) addEdge(r, otherRoot)
}
}
/** Normalized `kind:pubkey:d` → thread roots that tag it on at least one note in the corpus. */
const coordToRoots = new Map<string, Set<string>>()
for (const ev of feedNotes) {
const r = threadRootIdForHeat(ev)
if (!r || !rootIdsInView.has(r)) continue
for (const tag of ev.tags ?? []) {
const name = tag[0]
if (name !== 'a' && name !== 'A') continue
const raw = tag[1]
if (typeof raw !== 'string' || !raw.trim()) continue
const norm = normalizeReplaceableCoordinateString(raw)
if (!norm) continue
let set = coordToRoots.get(norm)
if (!set) {
set = new Set()
coordToRoots.set(norm, set)
}
set.add(r)
}
}
for (const roots of coordToRoots.values()) {
if (roots.size < 2) continue
const arr = [...roots]
for (let i = 0; i < arr.length; i++) {
for (let j = i + 1; j < arr.length; j++) {
addEdge(arr[i], arr[j])
}
}
}
return out
}

16
src/pages/primary/NoteListPage/index.tsx

@ -8,7 +8,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' @@ -8,7 +8,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import type { TNoteListRef } from '@/components/NoteList'
import { NoteCardLoadingSkeleton } from '@/components/NoteCard'
import { TPageRef } from '@/types'
import { Calendar, Compass, Star, UsersRound } from 'lucide-react'
import { Calendar, Compass, Flame, UsersRound } from 'lucide-react'
import React, {
forwardRef,
useCallback,
@ -173,8 +173,8 @@ function NoteListPageTitlebar({ @@ -173,8 +173,8 @@ function NoteListPageTitlebar({
const spell = (currentPageProps as { spell?: string } | undefined)?.spell
const exploreActive = display && current === 'explore' && primaryViewType === null
const followsLatestActive = display && current === 'follows-latest' && primaryViewType === null
const favoritesActive =
display && current === 'spells' && spell === 'favorites' && primaryViewType === null
const heatMapActive =
display && current === 'spells' && spell === 'heatMap' && primaryViewType === null
const calendarActive = display && current === 'calendar' && primaryViewType === null
if (!isSmallScreen) {
@ -230,18 +230,18 @@ function NoteListPageTitlebar({ @@ -230,18 +230,18 @@ function NoteListPageTitlebar({
<Button
variant="ghost"
size="titlebar-icon"
title={t('Favorites')}
aria-label={t('Favorites')}
className={`shrink-0 ${favoritesActive ? 'bg-accent/50' : ''}`}
title={t('Heat map')}
aria-label={t('Heat map')}
className={`shrink-0 ${heatMapActive ? 'bg-accent/50' : ''}`}
onClick={(e) => {
e.stopPropagation()
if (primaryViewType !== null) {
setPrimaryNoteView(null)
}
navigate('spells', { spell: 'favorites' })
navigate('spells', { spell: 'heatMap' })
}}
>
<Star />
<Flame />
</Button>
</>
) : null}

464
src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx

@ -0,0 +1,464 @@ @@ -0,0 +1,464 @@
import { Button } from '@/components/ui/button'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { ExtendedKind } from '@/constants'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { filterEventsExcludingTombstones } from '@/lib/event'
import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays'
import { toNote, toProfileInteractionMap } from '@/lib/link'
import logger from '@/lib/logger'
import { mergeEventsById } from '@/lib/profile-interaction-partners'
import {
parseRelayThreadHeatMapCache,
relayThreadHeatMapSettingKey,
serializeRelayThreadHeatMapCache
} from '@/lib/relay-thread-heat-cache'
import {
buildRelayThreadHeatBubbles,
buildRelayThreadHeatEdges,
RELAY_THREAD_HEAT_MIN_INTERACTIONS,
type TRelayThreadHeatBubble,
type TRelayThreadHeatEdge
} from '@/lib/relay-thread-heat'
import { useSmartNoteNavigation, useSmartProfileInteractionsNavigation } from '@/PageManager'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider'
import { useNostr } from '@/providers/NostrProvider'
import client, { eventService } from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import { cn } from '@/lib/utils'
import { LayoutGrid, Loader2, RefreshCw } from 'lucide-react'
import type { Event } from 'nostr-tools'
import { kinds, verifyEvent } from 'nostr-tools'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
const HEAT_WINDOW_SEC = 72 * 3600
/** REQ without `since`: many relays return nothing for kind 1+`since`; we clip by `created_at` client-side. */
const HEAT_REQ_LIMIT = 1500
const MAX_BUBBLES = 48
const SESSION_HEAT_LIMIT = 2500
/** Cap rows scanned so the heat map stays responsive on large archives. */
const ARCHIVE_HEAT_MAX_SCAN = 30_000
const ARCHIVE_HEAT_MAX_MATCHES = 2000
const HEAT_KINDS = [kinds.ShortTextNote, ExtendedKind.DISCUSSION] as const
const ARCHIVE_SCAN_TIMEOUT_MS = 22_000
const RELAY_FETCH_TIMEOUT_MS = 28_000
const TOMBSTONES_TIMEOUT_MS = 8_000
function raceWithTimeout<T>(promise: Promise<T>, ms: number, fallback: T, label: string): Promise<T> {
let settled = false
return new Promise((resolve) => {
const to = setTimeout(() => {
if (settled) return
settled = true
logger.warn('[RelayThreadHeatMap] timed out', { label, ms })
resolve(fallback)
}, ms)
promise
.then((v) => {
if (settled) return
settled = true
clearTimeout(to)
resolve(v)
})
.catch((e) => {
if (settled) return
settled = true
clearTimeout(to)
logger.warn('[RelayThreadHeatMap] source failed', { label, err: e })
resolve(fallback)
})
})
}
type Props = {
followPubkeys: string[]
refreshKey: number
}
export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) {
const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigation()
const { navigateToProfileInteractions } = useSmartProfileInteractionsNavigation()
const { pubkey, relayList } = useNostr()
const { favoriteRelays, blockedRelays } = useFavoriteRelays()
const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults()
const feedFilterKey = useMemo(
() =>
[
[...showKinds].sort((a, b) => a - b).join(','),
showKind1OPs ? '1' : '0',
showKind1Replies ? '1' : '0',
showKind1111 ? '1' : '0'
].join('|'),
[showKinds, showKind1OPs, showKind1Replies, showKind1111]
)
const followSet = useMemo(
() => new Set(followPubkeys.map((p) => p.trim().toLowerCase()).filter(Boolean)),
[followPubkeys]
)
const relayUrls = useMemo(
() =>
getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
userReadRelaysWithHttp(relayList),
{
userWriteRelays: relayList?.write ?? [],
applySocialKindBlockedFilter: false
}
),
[favoriteRelays, blockedRelays, relayList]
)
const [rows, setRows] = useState<TRelayThreadHeatBubble[]>([])
const [edges, setEdges] = useState<TRelayThreadHeatEdge[]>([])
/** True until the first cache read + merge attempt finishes for this mount/context. */
const [loading, setLoading] = useState(true)
const [isMerging, setIsMerging] = useState(false)
const [error, setError] = useState<string | null>(null)
const [rescanTick, setRescanTick] = useState(0)
const cacheSettingKey = useMemo(
() => (pubkey ? relayThreadHeatMapSettingKey(pubkey, relayUrls, followPubkeys, feedFilterKey) : ''),
[pubkey, relayUrls, followPubkeys, feedFilterKey]
)
const mergeHeatMapData = useCallback(async (): Promise<{
bubbles: TRelayThreadHeatBubble[]
edges: TRelayThreadHeatEdge[]
}> => {
const windowStart = Math.floor(Date.now() / 1000) - HEAT_WINDOW_SEC
const sessionEv = eventService.listSessionEventsByKinds(HEAT_KINDS, { limit: SESSION_HEAT_LIMIT })
logger.info('[RelayThreadHeatMap] merge started', {
relayCount: relayUrls.length,
sessionEvents: sessionEv.length
})
const archiveScan = indexedDb.scanEventArchiveByKinds({
kinds: HEAT_KINDS,
since: windowStart,
maxRowsScanned: ARCHIVE_HEAT_MAX_SCAN,
maxMatches: ARCHIVE_HEAT_MAX_MATCHES
})
const relayFetch =
relayUrls.length > 0
? client.fetchEvents(
relayUrls,
{ kinds: [...HEAT_KINDS], limit: HEAT_REQ_LIMIT },
{ eoseTimeout: 8000, globalTimeout: 22000 }
)
: Promise.resolve([] as Event[])
const tombstonesPromise = indexedDb.getAllTombstones()
const [idbEv, relayRaw, tombstones] = await Promise.all([
raceWithTimeout(archiveScan, ARCHIVE_SCAN_TIMEOUT_MS, [] as Event[], 'archive-scan'),
raceWithTimeout(relayFetch, RELAY_FETCH_TIMEOUT_MS, [] as Event[], 'relay-fetch'),
raceWithTimeout(tombstonesPromise, TOMBSTONES_TIMEOUT_MS, new Set<string>(), 'tombstones')
])
logger.info('[RelayThreadHeatMap] merge sources ready', {
idb: idbEv.length,
relay: relayRaw.length,
tombstones: tombstones.size
})
const mergedById = mergeEventsById([...sessionEv, ...idbEv, ...relayRaw])
let timeFiltered = mergedById.filter((e) => e.created_at >= windowStart)
if (timeFiltered.length === 0 && mergedById.length > 0) {
timeFiltered = mergedById
}
const dedup = new Map<string, Event>()
for (const ev of timeFiltered) {
if (!verifyEvent(ev)) continue
dedup.set(ev.id.toLowerCase(), ev)
}
if (dedup.size === 0 && timeFiltered.length > 0) {
for (const ev of timeFiltered) {
if (!/^[0-9a-f]{64}$/i.test(ev.id) || !/^[0-9a-f]{64}$/i.test(ev.pubkey)) continue
dedup.set(ev.id.toLowerCase(), ev)
}
}
const merged = filterEventsExcludingTombstones([...dedup.values()], tombstones)
const feedNotes = merged.filter((e) =>
eventPassesNoteListKindPicker(e, showKinds, showKind1OPs, showKind1Replies, showKind1111)
)
const ranked = buildRelayThreadHeatBubbles(feedNotes, followSet, windowStart)
const bubbles = ranked
.filter((b) => b.postCount >= RELAY_THREAD_HEAT_MIN_INTERACTIONS)
.slice(0, MAX_BUBBLES)
const roots = new Set(bubbles.map((b) => b.rootId))
const edges = buildRelayThreadHeatEdges(feedNotes, roots)
logger.info('[RelayThreadHeatMap] merge finished', {
bubbles: bubbles.length,
merged: merged.length,
afterFeedFilter: feedNotes.length,
edges: edges.length
})
return { bubbles, edges }
}, [relayUrls, followSet, showKinds, showKind1OPs, showKind1Replies, showKind1111])
useEffect(() => {
let cancelled = false
if (!pubkey || !cacheSettingKey) {
setRows([])
setEdges([])
setLoading(false)
setIsMerging(false)
setError(null)
return
}
void (async () => {
setError(null)
let hadEnvelope = false
const raw = await indexedDb.getSetting(cacheSettingKey)
if (cancelled) return
const cached = parseRelayThreadHeatMapCache(raw)
if (cached) {
hadEnvelope = true
setRows(cached.bubbles)
setEdges(cached.edges ?? [])
setLoading(false)
} else {
setLoading(true)
}
setIsMerging(true)
try {
const { bubbles, edges: nextEdges } = await mergeHeatMapData()
if (cancelled) return
setRows(bubbles)
setEdges(nextEdges)
setError(null)
try {
await indexedDb.setSetting(
cacheSettingKey,
serializeRelayThreadHeatMapCache({
v: 1,
builtAtMs: Date.now(),
bubbles,
edges: nextEdges
})
)
} catch (persistErr) {
logger.warn('[RelayThreadHeatMap] cache persist failed', persistErr)
}
} catch (e) {
if (cancelled) return
logger.warn('[RelayThreadHeatMap] fetch failed', e)
setError(t('heatMapFetchError'))
if (!hadEnvelope) {
setRows([])
setEdges([])
}
} finally {
if (!cancelled) {
setIsMerging(false)
setLoading(false)
}
}
})()
return () => {
cancelled = true
}
}, [pubkey, cacheSettingKey, mergeHeatMapData, refreshKey, rescanTick, t])
const maxHeat = useMemo(() => rows.reduce((m, r) => Math.max(m, r.heat), 0) || 1, [rows])
const graphAreaRef = useRef<HTMLDivElement>(null)
const bubbleRefs = useRef<Map<string, HTMLButtonElement>>(new Map())
const [lineSegs, setLineSegs] = useState<
Array<{ x1: number; y1: number; x2: number; y2: number }>
>([])
const bindBubbleRef = useCallback((rootId: string) => (el: HTMLButtonElement | null) => {
if (el) bubbleRefs.current.set(rootId, el)
else bubbleRefs.current.delete(rootId)
}, [])
const recomputeConnectorLines = useCallback(() => {
const host = graphAreaRef.current
if (!host || rows.length === 0) {
setLineSegs([])
return
}
const br = host.getBoundingClientRect()
const centerOf = (rootId: string) => {
const el = bubbleRefs.current.get(rootId)
if (!el) return null
const r = el.getBoundingClientRect()
return { x: r.left - br.left + r.width / 2, y: r.top - br.top + r.height / 2 }
}
const segs: Array<{ x1: number; y1: number; x2: number; y2: number }> = []
for (const { a, b } of edges) {
const ca = centerOf(a)
const cb = centerOf(b)
if (ca && cb) segs.push({ x1: ca.x, y1: ca.y, x2: cb.x, y2: cb.y })
}
setLineSegs(segs)
}, [rows, edges])
useLayoutEffect(() => {
recomputeConnectorLines()
}, [recomputeConnectorLines])
useEffect(() => {
const host = graphAreaRef.current
if (!host || typeof ResizeObserver === 'undefined') return undefined
const ro = new ResizeObserver(() => {
requestAnimationFrame(() => recomputeConnectorLines())
})
ro.observe(host)
for (const row of rows) {
const el = bubbleRefs.current.get(row.rootId)
if (el) ro.observe(el)
}
return () => ro.disconnect()
}, [rows, edges, recomputeConnectorLines])
if (!pubkey) {
return null
}
return (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<div className="space-y-1 text-sm text-muted-foreground">
{relayUrls.length === 0 ? (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-950 dark:text-amber-100">
{t('heatMapLocalOnlyBanner')}
</p>
) : null}
<p>{t('heatMapDescription')}</p>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
disabled={isMerging && rows.length === 0}
onClick={() => setRescanTick((n) => n + 1)}
>
{isMerging ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<RefreshCw className="size-4" aria-hidden />
)}
{t('heatMapRescan')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => navigateToProfileInteractions(toProfileInteractionMap(pubkey))}
>
<LayoutGrid className="size-4 shrink-0" aria-hidden />
{t('interactionMapMenu')}
</Button>
</div>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{rows.length === 0 && (loading || isMerging) ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<Loader2 className="size-8 animate-spin" aria-hidden />
<p className="text-sm">{t('heatMapLoading')}</p>
</div>
) : !loading && rows.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/80 px-4 py-12 text-center text-sm text-muted-foreground">
{t('heatMapEmpty')}
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden pb-4">
<div ref={graphAreaRef} className="relative w-full min-h-[min(40vh,420px)] pt-2">
<svg
className="pointer-events-none absolute inset-0 z-0 h-full w-full overflow-visible text-primary"
aria-hidden
>
{lineSegs.map((s, i) => (
<line
key={`${s.x1}-${s.y1}-${s.x2}-${s.y2}-${i}`}
x1={s.x1}
y1={s.y1}
x2={s.x2}
y2={s.y2}
stroke="currentColor"
strokeOpacity={0.38}
strokeWidth={1.75}
vectorEffect="non-scaling-stroke"
/>
))}
</svg>
<div className="relative z-10 flex flex-wrap content-start items-start justify-center gap-4">
{rows.map((row) => {
const intensity = Math.min(1, row.heat / maxHeat)
const size = Math.min(200, Math.max(76, 52 + Math.sqrt(row.heat) * 9))
const statsLine = t('heatMapBubbleStats', {
posts: row.postCount,
people: row.uniqueAuthors,
follows: row.followAuthorsInThread
})
const ariaLabel = [row.snippet, statsLine, t('heatMapOpenThread')].filter(Boolean).join('. ')
return (
<HoverCard key={row.rootId} openDelay={180} closeDelay={80}>
<HoverCardTrigger asChild>
<button
ref={bindBubbleRef(row.rootId)}
type="button"
className={cn(
'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'
)}
style={{
width: size,
height: size,
boxShadow: `0 0 0 1px hsl(var(--border) / 0.35), inset 0 0 40px hsl(var(--primary) / ${0.06 + intensity * 0.28})`
}}
onClick={() => navigateToNote(toNote(row.rootId), row.rootEvent)}
aria-label={ariaLabel}
>
<span
className="rounded-full bg-primary/25 ring-2 ring-primary/35 transition-[width,height,opacity] group-hover:bg-primary/35"
style={{
width: `${22 + intensity * 48}%`,
height: `${22 + intensity * 48}%`,
opacity: 0.55 + intensity * 0.45
}}
aria-hidden
/>
</button>
</HoverCardTrigger>
<HoverCardContent
side="top"
align="center"
className="w-80 max-w-[min(92vw,22rem)] border-border/80 p-0 shadow-lg"
collisionPadding={12}
>
<div className="max-h-56 overflow-y-auto px-3 py-2.5 text-left">
<p className="whitespace-pre-wrap text-sm leading-snug text-foreground">{row.snippet}</p>
<p className="mt-2 border-t border-border/60 pt-2 text-xs text-muted-foreground">{statsLine}</p>
</div>
</HoverCardContent>
</HoverCard>
)
})}
</div>
</div>
</div>
)}
</div>
)
}

190
src/pages/primary/SpellsPage/index.tsx

@ -85,6 +85,7 @@ import { @@ -85,6 +85,7 @@ import {
ChevronLeft,
Copy,
FileText,
Flame,
Gift,
Hash,
Image as ImageIcon,
@ -102,6 +103,7 @@ import { kinds as nostrKinds, verifyEvent } from 'nostr-tools' @@ -102,6 +103,7 @@ import { kinds as nostrKinds, verifyEvent } from 'nostr-tools'
import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import CreateSpellDialog from './CreateSpellDialog'
import RelayThreadHeatMap from './RelayThreadHeatMap'
import {
applyFauxSpellCapsToSubRequests,
buildBookmarksSubRequests,
@ -281,8 +283,8 @@ function fauxSpellLabelKey(name: FauxSpellName): string { @@ -281,8 +283,8 @@ function fauxSpellLabelKey(name: FauxSpellName): string {
return 'Discussions'
case 'following':
return 'Following'
case 'favorites':
return 'Favorites'
case 'heatMap':
return 'Heat map'
case 'followPacks':
return 'Follow Packs'
case 'media':
@ -302,7 +304,7 @@ const FAUX_SPELL_ICON: Record<FauxSpellName, typeof Bell> = { @@ -302,7 +304,7 @@ const FAUX_SPELL_ICON: Record<FauxSpellName, typeof Bell> = {
notifications: Bell,
discussions: MessageSquare,
following: Users,
favorites: Star,
heatMap: Flame,
followPacks: Gift,
media: ImageIcon,
interests: Hash,
@ -356,6 +358,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -356,6 +358,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
/** Last processed {@link spellCatalogManualRefreshKey} so we only treat real bumps as “force sync”. */
const spellCatalogLastManualKeyRef = useRef(0)
const spellFeedListRef = useRef<TNoteListRef>(null)
const selectedFauxSpellRefreshRef = useRef<string | null>(null)
selectedFauxSpellRefreshRef.current = selectedFauxSpell
const [heatMapRefreshKey, setHeatMapRefreshKey] = useState(0)
const layoutRef = useRef<TPrimaryPageLayoutRef>(null)
const [spellPickerOpen, setSpellPickerOpen] = useState(false)
@ -388,6 +393,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -388,6 +393,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
/** Set when picker calls `navigatePrimary(..., { spell })` so URL effect does not log/bump token again. */
const fauxSpellUrlSyncFromPickerRef = useRef<string | null>(null)
useEffect(() => {
if (spellProp === 'favorites') {
navigatePrimary('spells', { spell: 'heatMap' })
return
}
if (spellProp && isSpellsPageFauxSpellParam(spellProp)) {
if (fauxSpellUrlSyncFromPickerRef.current === spellProp) {
fauxSpellUrlSyncFromPickerRef.current = null
@ -406,10 +415,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -406,10 +415,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
// URL / props no longer name a faux spell (e.g. bottom bar “Spells” → `/spells`) — leave the feed.
setSelectedFauxSpell(null)
}
}, [spellProp, logSpellFeedPickerSelection])
}, [spellProp, logSpellFeedPickerSelection, navigatePrimary])
const [followingSubRequests, setFollowingSubRequests] = useState<TFeedSubRequest[]>([])
const [favoritesSubRequests, setFavoritesSubRequests] = useState<TFeedSubRequest[]>([])
const loadSpells = useCallback(async () => {
const [events, ids] = await Promise.all([
@ -426,6 +434,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -426,6 +434,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
setSpellCatalogManualRefreshKey((k) => k + 1)
setFollowSetManualRefreshKey((k) => k + 1)
}
if (selectedFauxSpellRefreshRef.current === 'heatMap') {
setHeatMapRefreshKey((k) => k + 1)
}
spellFeedListRef.current?.refresh()
}, [loadSpells, pubkey])
@ -822,137 +833,6 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -822,137 +833,6 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
followListEvent?.id
])
const favoritesShowKinds = useMemo(() => {
const out = [...kindFilterShowKinds]
if (!out.includes(nostrKinds.Repost)) out.push(nostrKinds.Repost)
if (!out.includes(ExtendedKind.GENERIC_REPOST)) out.push(ExtendedKind.GENERIC_REPOST)
if (!out.includes(ExtendedKind.WEB_BOOKMARK)) out.push(ExtendedKind.WEB_BOOKMARK)
return out.sort((a, b) => a - b)
}, [kindFilterShowKinds])
const favoritesShowKindsKey = useMemo(() => JSON.stringify(favoritesShowKinds), [favoritesShowKinds])
useEffect(() => {
if (selectedFauxSpell !== 'favorites' || !pubkey) {
setFavoritesSubRequests([])
return
}
let cancelled = false
void (async () => {
try {
const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox(
favoriteRelays,
blockedRelays,
userReadRelaysWithHttp(relayList),
{
userWriteRelays: relayList?.write ?? [],
applySocialKindBlockedFilter: false
}
)
const topics = interestListEvent?.tags.filter((tag) => tag[0] === 't' && tag[1]).map((tag) => tag[1]!) ?? []
const interestReqs = buildInterestsSubRequests(feedUrls, topics, favoritesShowKinds).map((r) => ({
...r,
reasonLabel: t('Added from interests')
}))
const idReqs = buildBookmarksSubRequests(bookmarkListEvent, feedUrls).map((r) => ({
...r,
reasonLabel: t('Added from bookmarks list')
}))
const ownWebReqs = buildWebBookmarksSpellSubRequests(pubkey, feedUrls).map((r) => ({
...r,
reasonLabel: t('Added from your web bookmarks')
}))
const augmentFollow = (raw: TFeedSubRequest[]) =>
augmentSubRequestsWithFavoritesFastReadAndInbox(
raw,
favoriteRelays,
blockedRelays,
userReadRelaysWithHttp(relayList),
{ userWriteRelays: relayList?.write ?? [] }
).map((r) => ({ ...r, reasonLabel: t('Added from follows and contact lists') }))
const quickFollowRaw = await client.generateSubRequestsForPubkeys([pubkey], pubkey)
const quickFollowAug = augmentFollow(quickFollowRaw)
const followsWebQuick: TFeedSubRequest[] = [
{
urls: feedUrls,
filter: {
authors: [pubkey],
kinds: [ExtendedKind.WEB_BOOKMARK],
limit: FAUX_SPELL_EVENT_LIMIT
},
reasonLabel: t('Added from follows web bookmarks')
}
]
if (!cancelled) {
setFavoritesSubRequests([
...interestReqs,
...idReqs,
...ownWebReqs,
...followsWebQuick,
...quickFollowAug
])
}
const authorSet = new Set<string>([pubkey, ...contacts])
for (const ev of followSetListEvents) {
if (ev.pubkey !== pubkey) continue
for (const author of pubkeysFromFollowSetEvent(ev)) authorSet.add(author)
}
const authorPubkeys = [...authorSet]
const followAndContactReqs = authorPubkeys.length
? await client.generateSubRequestsForPubkeys(authorPubkeys, pubkey)
: []
const followAndContactAugmented = augmentFollow(followAndContactReqs)
const followsWebBookmarkReqs: TFeedSubRequest[] = authorPubkeys.length
? [
{
urls: feedUrls,
filter: {
authors: authorPubkeys,
kinds: [ExtendedKind.WEB_BOOKMARK],
limit: FAUX_SPELL_EVENT_LIMIT
},
reasonLabel: t('Added from follows web bookmarks')
}
]
: []
if (!cancelled) {
setFavoritesSubRequests([
...interestReqs,
...idReqs,
...ownWebReqs,
...followsWebBookmarkReqs,
...followAndContactAugmented
])
}
} catch {
if (!cancelled) setFavoritesSubRequests([])
}
})()
return () => {
cancelled = true
}
}, [
selectedFauxSpell,
pubkey,
contactsSyncKey,
followSetListStableKey,
sortedFavoriteRelaysKey,
sortedBlockedRelaysKey,
relayMailboxStableKey,
interestListEvent?.id,
bookmarkListEvent?.id,
favoritesShowKindsKey
])
const interestTagsStableKey = interestListEvent
? JSON.stringify(
[...interestListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))
@ -977,7 +857,12 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -977,7 +857,12 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
].join('\0')
const syncFauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!selectedFauxSpell || isFollowFeedFauxSpellId(selectedFauxSpell) || selectedFauxSpell === 'favorites') return []
if (
!selectedFauxSpell ||
isFollowFeedFauxSpellId(selectedFauxSpell) ||
selectedFauxSpell === 'heatMap'
)
return []
/** Widen relay pool: these faux spells do not target social kinds (1 / 11 / 1111); skipping strip keeps fast-read mirrors in the stack. */
const fauxSpellSkipSocialKindBlocked =
selectedFauxSpell === 'calendar' ||
@ -1035,14 +920,11 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1035,14 +920,11 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
}, [selectedFauxSpell, pubkey, notificationsFeedPubkey, fauxFeedRelaysDepsKey, relayMailboxStableKey])
const fauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
const base =
selectedFauxSpell === 'favorites'
? favoritesSubRequests
: isFollowFeedFauxSpellId(selectedFauxSpell ?? '')
const base = isFollowFeedFauxSpellId(selectedFauxSpell ?? '')
? followingSubRequests
: syncFauxSubRequests
return applyFauxSpellCapsToSubRequests(base)
}, [selectedFauxSpell, favoritesSubRequests, followingSubRequests, syncFauxSubRequests])
}, [selectedFauxSpell, followingSubRequests, syncFauxSubRequests])
const spellSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!selectedSpell) return []
@ -1248,9 +1130,6 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1248,9 +1130,6 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (selectedFauxSpell === 'interests') {
return [...DEFAULT_FEED_SHOW_KINDS]
}
if (selectedFauxSpell === 'favorites') {
return favoritesShowKinds
}
if (selectedFauxSpell === 'bookmarks') {
const out = [...DEFAULT_FEED_SHOW_KINDS]
if (!out.includes(ExtendedKind.WEB_BOOKMARK)) out.push(ExtendedKind.WEB_BOOKMARK)
@ -1262,7 +1141,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1262,7 +1141,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
.map((tag) => parseInt(tag[1], 10))
.filter((n) => !Number.isNaN(n))
return kinds.length ? kinds : [1]
}, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey, favoritesShowKindsKey])
}, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey])
const spellMenuLabel = useCallback(
(spell: Event) =>
@ -1379,16 +1258,13 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1379,16 +1258,13 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (selectedFauxSpell === 'interests') return t('No subscribed interests yet.')
if (selectedFauxSpell === 'bookmarks')
return t('No NIP-51 bookmarks or web bookmarks yet.')
if (selectedFauxSpell === 'favorites') return t('No favorites yet.')
if (selectedFauxSpell === 'following') return t('No follows or relays to load yet.')
if (isFollowSetSpellId(selectedFauxSpell)) return t('Follow set feed empty')
return t('Nothing to load for this feed.')
}, [selectedFauxSpell, fauxSubRequests.length, t])
const spellFauxMergeTimeline = useMemo(
() =>
selectedFauxSpell === 'favorites' ||
(!!selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)),
() => !!selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell),
[selectedFauxSpell]
)
@ -1423,7 +1299,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1423,7 +1299,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (
(name === 'notifications' ||
name === 'following' ||
name === 'favorites' ||
name === 'heatMap' ||
name === 'bookmarks' ||
name === 'interests') &&
!pubkey
@ -1830,9 +1706,13 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1830,9 +1706,13 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
<div className="py-8 text-center text-muted-foreground">
{t('Please login to view bookmarks')}
</div>
) : selectedFauxSpell === 'favorites' && !pubkey ? (
) : selectedFauxSpell === 'heatMap' && !pubkey ? (
<div className="py-8 text-center text-muted-foreground">
{t('Please login to view favorites')}
{t('Please login to view thread heat map')}
</div>
) : selectedFauxSpell === 'heatMap' && pubkey ? (
<div className="min-h-0 min-w-0 flex-1">
<RelayThreadHeatMap followPubkeys={contacts} refreshKey={heatMapRefreshKey} />
</div>
) : selectedFauxSpell && fauxSubRequests.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">{fauxFeedEmptyMessage}</div>
@ -1865,9 +1745,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage( @@ -1865,9 +1745,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
: undefined
}
clientSideKindFilter={
selectedFauxSpell === 'notifications' ||
selectedFauxSpell === 'bookmarks' ||
selectedFauxSpell === 'favorites'
selectedFauxSpell === 'notifications' || selectedFauxSpell === 'bookmarks'
}
useFilterAsIs={fauxNoteListUseFilterAsIs}
oneShotFetch={false}

53
src/pages/secondary/ProfileInteractionDiagramPage/index.tsx

@ -250,31 +250,28 @@ const ProfileInteractionDiagramPage = forwardRef< @@ -250,31 +250,28 @@ const ProfileInteractionDiagramPage = forwardRef<
return (
<div
key={p.pubkey}
className="relative rounded-lg border border-border min-w-0 transition hover:opacity-95"
className="relative isolate rounded-lg border border-border min-w-0 transition hover:opacity-95"
style={{
backgroundColor: `hsl(var(--primary) / ${bgAlpha})`,
borderColor: `hsl(var(--primary) / ${borderAlpha})`
}}
>
{showFollowControls && !selfCard ? (
<div className="absolute top-1.5 right-1.5 z-10 flex items-center gap-1 rounded bg-background/80 px-1 py-0.5 border border-border/60 shadow-sm">
<Checkbox
id={`interaction-follow-${p.pubkey}`}
checked={following}
disabled={followBusyPubkey === p.pubkey}
aria-label={t('interactionMapFollowingCheckbox')}
onCheckedChange={(v) => {
if (v === 'indeterminate') return
handleFollowToggle(p.pubkey, Boolean(v))
}}
/>
</div>
) : null}
<button
type="button"
className={`w-full min-w-0 flex flex-col items-center gap-1 text-left rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring p-2 ${showFollowControls && !selfCard ? 'pt-7' : ''}`}
{/*
Avoid a native <button> filling the card: it can steal hit-testing over the follow
checkbox (Radix also uses a button), which shows the global disabled cursor and blocks toggles.
*/}
<div
role="button"
tabIndex={0}
className={`w-full min-w-0 flex flex-col items-center gap-1 text-left rounded-lg cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring p-2 ${showFollowControls && !selfCard ? 'pt-7' : ''}`}
title={cellTitle}
onClick={() => push(toProfile(p.pubkey))}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
push(toProfile(p.pubkey))
}
}}
>
<UserAvatar userId={p.pubkey} className="h-10 w-10 shrink-0" />
<div className="w-full min-w-0 text-center">
@ -289,7 +286,25 @@ const ProfileInteractionDiagramPage = forwardRef< @@ -289,7 +286,25 @@ const ProfileInteractionDiagramPage = forwardRef<
<div className="text-[10px] text-muted-foreground truncate w-full text-center">
{p.lastReferencedAt > 0 ? dayjs.unix(p.lastReferencedAt).fromNow() : t('interactionMapRecencyUnknown')}
</div>
</button>
</div>
{showFollowControls && !selfCard ? (
<label
className="absolute top-1.5 right-1.5 z-30 flex cursor-pointer items-center gap-1 rounded border border-border/60 bg-background/95 px-1 py-0.5 shadow-sm backdrop-blur-[2px]"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Checkbox
id={`interaction-follow-${p.pubkey}`}
checked={following}
disabled={followBusyPubkey === p.pubkey}
aria-label={t('interactionMapFollowingCheckbox')}
onCheckedChange={(v) => {
if (v === 'indeterminate') return
handleFollowToggle(p.pubkey, Boolean(v))
}}
/>
</label>
) : null}
</div>
)
})}

19
src/services/client-events.service.ts

@ -679,6 +679,25 @@ export class EventService { @@ -679,6 +679,25 @@ export class EventService {
return out
}
/**
* Session LRU: events of the given `kinds` from this tab session, optionally `created_at >= since`, newest first.
* Used with IndexedDB + relays for aggregates (e.g. thread heat map) without depending on relay order alone.
*/
listSessionEventsByKinds(kinds: readonly number[], opts?: { since?: number; limit?: number }): NEvent[] {
const kindSet = new Set(kinds)
const since = opts?.since
const limit = Math.min(Math.max(opts?.limit ?? 2000, 1), 8000)
const buf: NEvent[] = []
for (const [, event] of this.sessionEventCache.entries()) {
if (shouldDropEventOnIngest(event)) continue
if (!kindSet.has(event.kind)) continue
if (since !== undefined && event.created_at < since) continue
buf.push(event)
}
buf.sort((a, b) => b.created_at - a.created_at)
return buf.slice(0, limit)
}
/**
* Session cache: NIP-32 citation kinds (3033) matched on title/summary/content and related tags
* (not NIP-50 relay semantics).

15
src/services/client-query.service.ts

@ -437,7 +437,12 @@ export class QueryService { @@ -437,7 +437,12 @@ export class QueryService {
const resolveWithEvents = () => {
if (resolved || queryFinalizing) return
queryFinalizing = true
void httpInflight.finally(() => {
/**
* Never block resolution on {@link httpInflight}: a hung HTTP index `fetch` can keep
* `Promise.allSettled` pending forever, so `globalTimeout` would fire but this callback
* would never run and the query promise would never resolve.
*/
const finalizeOnce = () => {
if (resolved) return
resolved = true
if (resolveTimeout) clearTimeout(resolveTimeout)
@ -451,6 +456,14 @@ export class QueryService { @@ -451,6 +456,14 @@ export class QueryService {
const resolvedList =
replaceableRace && events.length > 0 ? resolveReplaceableRaceEvents() : events
resolve(resolvedList)
}
const httpCapMs = Math.min(globalTimeout + 5000, 60_000)
void Promise.race([
httpInflight.catch(() => undefined),
new Promise<void>((r) => setTimeout(r, httpCapMs))
]).finally(() => {
finalizeOnce()
})
}

51
src/services/indexed-db.service.ts

@ -2986,6 +2986,57 @@ class IndexedDbService { @@ -2986,6 +2986,57 @@ class IndexedDbService {
})
}
/**
* Scan {@link StoreNames.EVENT_ARCHIVE} for events whose kind is in `kinds`.
* Cursor order follows the store key (not time), so `since` is applied **after** the scan: collect kind
* matches up to `maxRowsScanned`, then keep `created_at >= since` (when set), sort newest-first, cap.
*/
async scanEventArchiveByKinds(options: {
kinds: readonly number[]
since?: number
maxRowsScanned: number
maxMatches: number
}): Promise<Event[]> {
const kindSet = new Set(options.kinds)
const since = options.since
const maxRows = Math.min(Math.max(options.maxRowsScanned, 1), 50_000)
const maxMatches = Math.min(Math.max(options.maxMatches, 1), 3000)
await this.initPromise
if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return []
return new Promise((resolve, reject) => {
const buf: 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) {
tx.commit()
let picked = buf
if (since !== undefined) {
picked = buf.filter((e) => e.created_at >= since)
}
picked.sort((a, b) => b.created_at - a.created_at)
resolve(picked.slice(0, maxMatches))
return
}
scanned += 1
const row = cursor.value as TArchivedEventRow
const ev = row?.value
if (ev && isLikelyCachedNostrEvent(ev) && kindSet.has(ev.kind)) {
buf.push(ev)
}
cursor.continue()
}
req.onerror = (e) => {
tx.commit()
reject(idbEventToError(e))
}
})
}
async deleteArchivedEvent(eventId: string): Promise<void> {
const id = eventId.toLowerCase()
await this.initPromise

Loading…
Cancel
Save