You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

464 lines
17 KiB

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>
)
}