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.
 
 
 
 

456 lines
16 KiB

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<string, number>
}
function topPubkeysForTopic(
hits: Map<string, number>,
limit: number,
mutePubkeySet?: ReadonlySet<string>
): 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 (
<div
className="pointer-events-none overflow-hidden rounded-full ring-2 ring-primary/35"
style={{ width: avatarPx * 1.35, height: avatarPx * 1.35 }}
aria-hidden
>
<SimpleUserAvatar
userId={pubkeys[0]!}
deferRemoteAvatar
maxFileSizeKb={400}
className="!size-full max-w-none"
/>
</div>
)
}
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 (
<div
key={pk}
className="pointer-events-none absolute overflow-hidden rounded-full ring-2 ring-background"
style={{
width: avatarPx,
height: avatarPx,
left,
top,
transform: 'translate(-50%, -50%)'
}}
aria-hidden
>
<SimpleUserAvatar
userId={pk}
deferRemoteAvatar
maxFileSizeKb={400}
className="!size-full max-w-none"
/>
</div>
)
})}
</>
)
}
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('[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<string>
): TTopicKeywordBubble[] {
const accum = new Map<string, TopicKeyAccum>()
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<string>()
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<TTopicKeywordBubble[]>([])
const [loading, setLoading] = useState(true)
const [isMerging, setIsMerging] = useState(false)
const [error, setError] = useState<string | null>(null)
const [rescanTick, setRescanTick] = useState(0)
const mergeData = useCallback(async (includeRelay = true): Promise<TTopicKeywordBubble[]> => {
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<string>(), 'tombstones')
])
const mergedById = new Map<string, Event>()
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<string, Event>()
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 (
<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('topicMapLocalOnlyBanner')}
</p>
) : null}
<p>{t('topicMapDescription')}</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('topicMapRescan')}
</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('topicMapLoading')}</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('topicMapEmpty')}
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden pb-4">
<div className="relative flex w-full flex-wrap content-start items-start justify-center gap-4 pt-2">
{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 (
<HoverCard key={row.key} openDelay={160} closeDelay={80}>
<HoverCardTrigger asChild>
<button
type="button"
className={cn(
'group relative shrink-0 rounded-full border shadow-sm transition-transform',
'flex items-center justify-center px-2 text-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={() => openMergedFeed(row.key)}
aria-label={ariaLabel}
>
{row.pubkeys.length > 0 ? (
<TopicBubbleAvatarRing pubkeys={row.pubkeys} bubbleSizePx={size} />
) : (
<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
/>
)}
<span
className={cn(
'pointer-events-none absolute inset-x-1 bottom-1.5 z-[1] rounded-md px-1 py-0.5',
'text-pretty text-center text-[10px] font-semibold leading-tight text-foreground sm:text-xs',
'bg-background/75 backdrop-blur-[2px] shadow-sm'
)}
>
{displayLabel(row.key)}
</span>
</button>
</HoverCardTrigger>
<HoverCardContent
side="top"
align="center"
className="w-72 max-w-[min(92vw,18rem)] border-border/80 p-3 text-sm shadow-lg"
collisionPadding={12}
>
<p className="font-medium text-foreground">{displayLabel(row.key)}</p>
<p className="mt-1 text-xs text-muted-foreground">{countsLine}</p>
<p className="mt-2 text-xs text-muted-foreground">{t('topicMapClickHint')}</p>
</HoverCardContent>
</HoverCard>
)
})}
</div>
</div>
)}
</div>
)
}