import { Button } from '@/components/ui/button' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { ExtendedKind } from '@/constants' import { useMuteList } from '@/contexts/mute-list-context' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { filterEventsExcludingMutedAuthors, muteSetHas } from '@/lib/mute-set' import { filterEventsExcludingTombstones } from '@/lib/event' import { extractHashtagsFromContent, formatTopicMapBubbleLabel, isValidNormalizedTopicKey, normalizeTopic } from '@/lib/discussion-topics' import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { toNoteList } from '@/lib/link' import logger from '@/lib/logger' import { useSmartHashtagNavigation } 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 { SimpleUserAvatar } from '@/components/UserAvatar' import { Loader2, RefreshCw } from 'lucide-react' import type { Event } from 'nostr-tools' import { kinds, verifyEvent } from 'nostr-tools' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' const HEAT_WINDOW_SEC = 30 * 24 * 3600 const HEAT_REQ_LIMIT = 1500 const MAX_BUBBLES = 10 const SESSION_LIMIT = 4000 const ARCHIVE_MAX_SCAN = 35_000 const ARCHIVE_MAX_MATCHES = 2500 const MAP_KINDS = [kinds.ShortTextNote, ExtendedKind.DISCUSSION] as const const ARCHIVE_SCAN_TIMEOUT_MS = 22_000 const RELAY_FETCH_TIMEOUT_MS = 26_000 const TOMBSTONES_TIMEOUT_MS = 8_000 /** Max profile avatars shown around each topic bubble (by tag usage count). */ const MAX_BUBBLE_AVATARS = 7 export type TTopicKeywordBubble = { key: string score: number topicNoteCount: number keywordNoteCount: number pubkeys: string[] } type TopicKeyAccum = { topicNoteCount: number keywordNoteCount: number pubkeyHits: Map } function topPubkeysForTopic( hits: Map, limit: number, mutePubkeySet?: ReadonlySet ): string[] { return [...hits.entries()] .filter(([pk]) => !mutePubkeySet || !muteSetHas(mutePubkeySet, pk)) .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) .slice(0, limit) .map(([pk]) => pk) } function TopicBubbleAvatarRing({ pubkeys, bubbleSizePx }: { pubkeys: readonly string[] bubbleSizePx: number }) { if (pubkeys.length === 0) return null const avatarPx = Math.max(16, Math.min(26, Math.round(bubbleSizePx * 0.15))) const orbitR = bubbleSizePx * (pubkeys.length === 1 ? 0 : 0.34) if (pubkeys.length === 1) { return (
) } return ( <> {pubkeys.map((pk, i) => { const angle = (i / pubkeys.length) * Math.PI * 2 - Math.PI / 2 const left = bubbleSizePx / 2 + orbitR * Math.cos(angle) const top = bubbleSizePx / 2 + orbitR * Math.sin(angle) return (
) })} ) } 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('[TopicKeywordHeatMap] 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('[TopicKeywordHeatMap] source failed', { label, err: e }) resolve(fallback) }) }) } export function buildTopicKeywordBubbles( events: Event[], showKinds: readonly number[], showKind1OPs: boolean, showKind1Replies: boolean, showKind1111: boolean, mutePubkeySet?: ReadonlySet ): TTopicKeywordBubble[] { const accum = new Map() const bump = (key: string, ev: Event, viaTopicTag: boolean) => { if (!isValidNormalizedTopicKey(key)) return let row = accum.get(key) if (!row) { row = { topicNoteCount: 0, keywordNoteCount: 0, pubkeyHits: new Map() } accum.set(key, row) } if (viaTopicTag) row.topicNoteCount += 1 else row.keywordNoteCount += 1 const pk = ev.pubkey.trim().toLowerCase() if (/^[0-9a-f]{64}$/.test(pk)) { row.pubkeyHits.set(pk, (row.pubkeyHits.get(pk) ?? 0) + 1) } } for (const ev of events) { if (mutePubkeySet && muteSetHas(mutePubkeySet, ev.pubkey)) continue if (!eventPassesNoteListKindPicker(ev, showKinds, showKind1OPs, showKind1Replies, showKind1111)) continue const topics = new Set() for (const row of ev.tags) { if (row[0] === 't' && row[1]) { const n = normalizeTopic(row[1]) if (n && isValidNormalizedTopicKey(n)) topics.add(n) } } const kws = new Set(extractHashtagsFromContent(ev.content ?? '')) for (const k of topics) bump(k, ev, true) for (const k of kws) bump(k, ev, false) } const out: TTopicKeywordBubble[] = [] for (const [key, row] of accum) { if (!isValidNormalizedTopicKey(key)) continue const score = row.topicNoteCount + row.keywordNoteCount if (score <= 0) continue out.push({ key, score, topicNoteCount: row.topicNoteCount, keywordNoteCount: row.keywordNoteCount, pubkeys: topPubkeysForTopic(row.pubkeyHits, MAX_BUBBLE_AVATARS, mutePubkeySet) }) } out.sort((x, y) => y.score - x.score || x.key.localeCompare(y.key)) return out.slice(0, MAX_BUBBLES) } type Props = { refreshKey: number } export default function TopicKeywordHeatMap({ refreshKey }: Props) { const { t } = useTranslation() const { mutePubkeySet } = useMuteList() const { navigateToHashtag } = useSmartHashtagNavigation() const { relayList, cacheRelayListEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults() const relayUrls = useMemo( () => getRelayUrlsWithFavoritesFastReadAndInbox( favoriteRelays, blockedRelays, userReadInboxUrls(relayList, cacheRelayListEvent), { userWriteRelays: userWriteOutboxUrls(relayList, cacheRelayListEvent), applySocialKindBlockedFilter: false } ), [favoriteRelays, blockedRelays, relayList] ) const [rows, setRows] = useState([]) const [loading, setLoading] = useState(true) const [isMerging, setIsMerging] = useState(false) const [error, setError] = useState(null) const [rescanTick, setRescanTick] = useState(0) const mergeData = useCallback(async (includeRelay = true): Promise => { const windowStart = Math.floor(Date.now() / 1000) - HEAT_WINDOW_SEC const sessionEv = eventService.listSessionEventsByKinds(MAP_KINDS, { limit: SESSION_LIMIT }) const archiveScan = indexedDb.scanEventArchiveByKinds({ kinds: [...MAP_KINDS], since: windowStart, maxRowsScanned: ARCHIVE_MAX_SCAN, maxMatches: ARCHIVE_MAX_MATCHES }) const relayFetch = includeRelay && relayUrls.length > 0 ? client.fetchEvents( relayUrls, { kinds: [...MAP_KINDS], limit: HEAT_REQ_LIMIT }, { eoseTimeout: 8000, globalTimeout: 20000 } ) : 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') ]) const mergedById = new Map() for (const ev of [...sessionEv, ...idbEv, ...relayRaw]) { mergedById.set(ev.id.toLowerCase(), ev) } let merged = [...mergedById.values()].filter((e) => e.created_at >= windowStart) if (merged.length === 0 && mergedById.size > 0) { merged = [...mergedById.values()] } const dedup = new Map() for (const ev of merged) { if (!verifyEvent(ev)) continue dedup.set(ev.id.toLowerCase(), ev) } if (dedup.size === 0 && merged.length > 0) { for (const ev of merged) { 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 clean = filterEventsExcludingMutedAuthors( filterEventsExcludingTombstones([...dedup.values()], tombstones), mutePubkeySet ) return buildTopicKeywordBubbles(clean, showKinds, showKind1OPs, showKind1Replies, showKind1111, mutePubkeySet) }, [relayUrls, showKinds, showKind1OPs, showKind1Replies, showKind1111, mutePubkeySet]) useEffect(() => { let cancelled = false setError(null) setLoading(true) setIsMerging(true) void (async () => { try { const localBubbles = await mergeData(false) if (cancelled) return setRows(localBubbles) setLoading(false) const bubbles = await mergeData(true) if (!cancelled) setRows(bubbles) } catch (e) { if (!cancelled) { logger.warn('[TopicKeywordHeatMap] merge failed', { err: e }) setError(t('topicMapFetchError')) setRows([]) } } finally { if (!cancelled) { setLoading(false) setIsMerging(false) } } })() return () => { cancelled = true } }, [mergeData, refreshKey, rescanTick, t]) useEffect(() => { const pubkeys = [...new Set(rows.flatMap((r) => r.pubkeys))].slice(0, 48) if (pubkeys.length === 0) return void Promise.allSettled(pubkeys.map((pk) => client.fetchProfileEvent(pk).catch(() => {}))) }, [rows]) const maxScore = useMemo(() => rows.reduce((m, r) => Math.max(m, r.score), 0) || 1, [rows]) const openMergedFeed = useCallback( (key: string) => { navigateToHashtag(toNoteList({ hashtag: key })) }, [navigateToHashtag] ) const displayLabel = (key: string) => formatTopicMapBubbleLabel(key) return (
{relayUrls.length === 0 ? (

{t('topicMapLocalOnlyBanner')}

) : null}

{t('topicMapDescription')}

{error ?

{error}

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

{t('topicMapLoading')}

) : !loading && rows.length === 0 ? (
{t('topicMapEmpty')}
) : (
{rows.map((row) => { const intensity = Math.min(1, row.score / maxScore) const size = Math.min(200, Math.max(76, 52 + Math.sqrt(row.score) * 10)) const countsLine = t('topicMapBubbleCounts', { topic: row.topicNoteCount, kw: row.keywordNoteCount }) const ariaLabel = [displayLabel(row.key), countsLine, t('topicMapOpenMergedFeed')].join('. ') return (

{displayLabel(row.key)}

{countsLine}

{t('topicMapClickHint')}

) })}
)}
) }