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(promise: Promise, ms: number, fallback: T, label: string): Promise { 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([]) const [edges, setEdges] = useState([]) /** 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(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(), '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() 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(null) const bubbleRefs = useRef>(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 (
{relayUrls.length === 0 ? (

{t('heatMapLocalOnlyBanner')}

) : null}

{t('heatMapDescription')}

{error ?

{error}

: null} {rows.length === 0 && (loading || isMerging) ? (

{t('heatMapLoading')}

) : !loading && rows.length === 0 ? (
{t('heatMapEmpty')}
) : (
{lineSegs.map((s, i) => ( ))}
{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 (

{row.snippet}

{statsLine}

) })}
)}
) }