From b6335f6d49a8aebbc284ab6c3535bd8c777c7b8f Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 7 May 2026 09:07:53 +0200 Subject: [PATCH] fix heat map bubbles pointing to thread content instead of OPs --- package-lock.json | 4 +- package.json | 2 +- src/lib/relay-thread-heat.ts | 8 +-- .../primary/SpellsPage/RelayThreadHeatMap.tsx | 54 ++++++++++++++++++- 4 files changed, 60 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 7f2472fb..6f7f841e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.6.0", + "version": "23.7.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.6.0", + "version": "23.7.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 61f39348..f93be2df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.6.0", + "version": "23.7.0", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "private": true, "type": "module", diff --git a/src/lib/relay-thread-heat.ts b/src/lib/relay-thread-heat.ts index 06a66253..b3c3353d 100644 --- a/src/lib/relay-thread-heat.ts +++ b/src/lib/relay-thread-heat.ts @@ -26,7 +26,7 @@ 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 { +export function collapseRelayThreadHeatSnippet(content: string, maxLen = 160): string { const t = content.replace(/\s+/g, ' ').trim().slice(0, maxLen) return t || '…' } @@ -107,8 +107,8 @@ export function buildRelayThreadHeatBubbles( ) const kind1TopLevel = kind1Or11.find((e) => e.kind === kinds.ShortTextNote && !isReplyNoteEvent(e)) if (kind1TopLevel) return kind1TopLevel - const sorted = [...kind1Or11].sort((a, b) => a.created_at - b.created_at) - return sorted[0] + // OP may be outside the heat window or not in this merge; never use an early reply as OP text. + return undefined })() const snippetSource = opForSnippet?.content?.trim() ?? '' @@ -118,7 +118,7 @@ export function buildRelayThreadHeatBubbles( postCount, uniqueAuthors, followAuthorsInThread, - snippet: collapseSnippet(snippetSource), + snippet: collapseRelayThreadHeatSnippet(snippetSource), lastActivity, rootEvent }) diff --git a/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx index ff7fc486..0cc0e2d0 100644 --- a/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx +++ b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx @@ -16,6 +16,7 @@ import { orderHeatBubblesByKeywordProximity } from '@/lib/relay-thread-heat-keyw import { buildRelayThreadHeatBubbles, buildRelayThreadHeatEdges, + collapseRelayThreadHeatSnippet, RELAY_THREAD_HEAT_MIN_INTERACTIONS, type TRelayThreadHeatBubble, type TRelayThreadHeatEdge @@ -46,6 +47,8 @@ const HEAT_KINDS = [kinds.ShortTextNote, ExtendedKind.DISCUSSION] as const const ARCHIVE_SCAN_TIMEOUT_MS = 22_000 const RELAY_FETCH_TIMEOUT_MS = 28_000 +/** Load thread roots that sit outside the heat time window so hover text is the OP, not a reply. */ +const ROOT_SNIPPET_FETCH_TIMEOUT_MS = 12_000 const TOMBSTONES_TIMEOUT_MS = 8_000 function raceWithTimeout(promise: Promise, ms: number, fallback: T, label: string): Promise { @@ -192,9 +195,58 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) eventPassesNoteListKindPicker(e, showKinds, showKind1OPs, showKind1Replies, showKind1111) ) const ranked = buildRelayThreadHeatBubbles(feedNotes, followSet, windowStart) - const bubbles = ranked + let bubbles = ranked .filter((b) => b.postCount >= RELAY_THREAD_HEAT_MIN_INTERACTIONS) .slice(0, MAX_BUBBLES) + + const missingRootIds = [ + ...new Set( + bubbles + .filter((b) => !b.rootEvent) + .map((b) => b.rootId.trim().toLowerCase()) + .filter((id) => /^[0-9a-f]{64}$/.test(id)) + ) + ] + if (missingRootIds.length > 0) { + const rootById = new Map() + const archived = await indexedDb.getArchivedEventsByIds(missingRootIds) + for (const ev of archived) { + if (!verifyEvent(ev)) continue + if (ev.kind !== kinds.ShortTextNote && ev.kind !== ExtendedKind.DISCUSSION) continue + rootById.set(ev.id.toLowerCase(), ev) + } + const stillMissing = missingRootIds.filter((id) => !rootById.has(id)) + if (stillMissing.length > 0 && relayUrls.length > 0) { + const fetched = await raceWithTimeout( + client.fetchEvents( + relayUrls, + { ids: stillMissing, kinds: [...HEAT_KINDS] }, + { eoseTimeout: 6000, globalTimeout: ROOT_SNIPPET_FETCH_TIMEOUT_MS } + ), + ROOT_SNIPPET_FETCH_TIMEOUT_MS, + [] as Event[], + 'heat-map-root-snippet' + ) + for (const ev of fetched) { + if (!verifyEvent(ev)) continue + if (ev.kind !== kinds.ShortTextNote && ev.kind !== ExtendedKind.DISCUSSION) continue + rootById.set(ev.id.toLowerCase(), ev) + } + } + if (rootById.size > 0) { + bubbles = bubbles.map((b) => { + if (b.rootEvent) return b + const ev = rootById.get(b.rootId.toLowerCase()) + if (!ev) return b + return { + ...b, + rootEvent: ev, + snippet: collapseRelayThreadHeatSnippet(ev.content?.trim() ?? '') + } + }) + } + } + const roots = new Set(bubbles.map((b) => b.rootId)) const edges = buildRelayThreadHeatEdges(feedNotes, roots) logger.info('[RelayThreadHeatMap] merge finished', {