23 changed files with 1107 additions and 210 deletions
@ -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 |
||||
} |
||||
@ -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) |
||||
} |
||||
@ -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 thread’s 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 thread’s 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 |
||||
} |
||||
@ -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> |
||||
) |
||||
} |
||||
Loading…
Reference in new issue