From c14f37ede0f87a853c20aaea853bbff2ddef941f Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 7 May 2026 01:46:19 +0200 Subject: [PATCH] bug-fixes --- package-lock.json | 4 +- package.json | 2 +- .../Sidebar/SidebarCalendarWeekWidget.tsx | 56 ++++++----- src/i18n/locales/de.ts | 2 +- src/i18n/locales/en.ts | 2 +- src/lib/relay-thread-heat-keywords.ts | 93 +++++++++++++++++++ src/lib/relay-thread-heat.ts | 17 +++- .../primary/SpellsPage/RelayThreadHeatMap.tsx | 16 ++-- 8 files changed, 154 insertions(+), 38 deletions(-) create mode 100644 src/lib/relay-thread-heat-keywords.ts diff --git a/package-lock.json b/package-lock.json index afb5b0c9..0bdc4021 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "imwald", - "version": "23.5.2", + "version": "23.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "imwald", - "version": "23.5.2", + "version": "23.6.0", "license": "MIT", "dependencies": { "@asciidoctor/core": "^3.0.4", diff --git a/package.json b/package.json index 1fcbf20f..61f39348 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "imwald", - "version": "23.5.2", + "version": "23.6.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/components/Sidebar/SidebarCalendarWeekWidget.tsx b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx index ed403d5c..35af0ae2 100644 --- a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx +++ b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx @@ -134,7 +134,15 @@ export default function SidebarCalendarWeekWidget() { return } - const batch = await client.fetchEvents( + const authorList = followAuthorsKey + ? followAuthorsKey.split('|').filter(Boolean).slice(0, FOLLOWING_CALENDAR_AUTHORS_CAP) + : [] + const authorChunks: string[][] = [] + for (let i = 0; i < authorList.length; i += FOLLOWING_CALENDAR_AUTHORS_CHUNK) { + authorChunks.push(authorList.slice(i, i + FOLLOWING_CALENDAR_AUTHORS_CHUNK)) + } + + const mainReq = client.fetchEvents( relayUrls, { kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], @@ -147,31 +155,35 @@ export default function SidebarCalendarWeekWidget() { firstRelayResultGraceMs: false } ) - if (cancelled) return + const chunkReqs = authorChunks.map((authors) => + client.fetchEvents( + relayUrls, + { + kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], + authors, + limit: FOLLOWING_CALENDAR_CHUNK_LIMIT + }, + { + cache: true, + globalTimeout: 16_000, + eoseTimeout: 2800, + firstRelayResultGraceMs: false + } + ) + ) + let batch: Event[] = [] const fromFollowing: Event[] = [] - if (followAuthorsKey) { - const authorList = followAuthorsKey.split('|').filter(Boolean).slice(0, FOLLOWING_CALENDAR_AUTHORS_CAP) - for (let i = 0; i < authorList.length; i += FOLLOWING_CALENDAR_AUTHORS_CHUNK) { - const authors = authorList.slice(i, i + FOLLOWING_CALENDAR_AUTHORS_CHUNK) - const chunk = await client.fetchEvents( - relayUrls, - { - kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], - authors, - limit: FOLLOWING_CALENDAR_CHUNK_LIMIT - }, - { - cache: true, - globalTimeout: 16_000, - eoseTimeout: 2800, - firstRelayResultGraceMs: false - } - ) - if (cancelled) return - fromFollowing.push(...chunk) + try { + const merged = await Promise.all([mainReq, ...chunkReqs]) + batch = merged[0] ?? [] + for (let i = 1; i < merged.length; i++) { + fromFollowing.push(...(merged[i] ?? [])) } + } catch { + /* keep IndexedDB + session; relays may be slow or unreachable */ } + if (cancelled) return const fromSession = client.getSessionEventsMatchingSearch( '', diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index b191e50f..3d6630e1 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -783,7 +783,7 @@ export default { Favorites: "Favorites", "Heat map": "Thread-Heatmap", heatMapDescription: - "Es erscheinen nur Threads mit mindestens fünf feed‑gefilterten Notes (ca. letzte 3 Tage), zusammengeführt aus Sitzungs‑Cache, lokalem Archiv und Relay‑Stack. Größe und Leuchten spiegeln Aktivität wider. Linien verbinden Threads, wenn Notes andere per e‑/E‑/q referenzieren oder dieselbe adressierbare Koordinate (a/A, NIP‑33) nutzen.", + "Es erscheinen nur Threads mit mindestens fünf feed‑gefilterten Notes (ca. letzte 3 Tage), zusammengeführt aus Sitzungs‑Cache, lokalem Archiv und Relay‑Stack. Größe und Leuchten spiegeln Aktivität wider. Linien verbinden Threads, wenn Notes andere per e‑/E‑/q referenzieren oder dieselbe adressierbare Koordinate (a/A, NIP‑33) nutzen. Threads mit gemeinsamen markanten Wörtern in der Vorschau (oder Beginn der Root‑Note) werden nach Möglichkeit nebeneinander angeordnet.", heatMapLocalOnlyBanner: "Keine Lese‑Relay‑Liste — es werden nur Sitzungs‑Cache und lokales Archiv gemischt (Relays in den Einstellungen ergänzen für Live‑Daten).", heatMapLoading: "Sitzungs‑Cache, Archiv und Relays werden zusammengeführt…", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index af48a01c..c8615708 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -787,7 +787,7 @@ export default { Favorites: "Favorites", "Heat map": "Thread heat map", heatMapDescription: - "Only threads with at least five feed-filtered notes (last ~3 days) appear. Data merges this tab’s session cache, your on-device archive, and your relay stack. Bubble size and glow reflect activity. Lines connect threads when notes reference another thread’s events (`e` / `E` / `q`) or when threads share a replaceable coordinate (`a` / `A`).", + "Only threads with at least five feed-filtered notes (last ~3 days) appear. Data merges this tab’s session cache, your on-device archive, and your relay stack. Bubble size and glow reflect activity. Lines connect threads when notes reference another thread’s events (`e` / `E` / `q`) or when threads share a replaceable coordinate (`a` / `A`). Threads that share a distinctive word in the preview (or start of the root note) are laid out next to each other when possible.", heatMapLocalOnlyBanner: "No read relay stack — only this session’s cache and your on-device archive are merged (add relays in settings for a live relay mix).", heatMapLoading: "Merging session cache, archive, and relays…", diff --git a/src/lib/relay-thread-heat-keywords.ts b/src/lib/relay-thread-heat-keywords.ts new file mode 100644 index 00000000..21f4ccc4 --- /dev/null +++ b/src/lib/relay-thread-heat-keywords.ts @@ -0,0 +1,93 @@ +import type { TRelayThreadHeatBubble } from '@/lib/relay-thread-heat' + +/** Very common English tokens to ignore when clustering (cheap overlap signal). */ +const STOPWORDS = new Set( + `a about after again all also an and any are as at be been before being between both but by can could did do does doing done each even every few for from further had has have having he her here hers him his how i if in into is it its just like made make many may me more most much must my no nor not now of off on once only or other our ours out over own same she should so some such than that the their them then there these they this those through to too under until up very was we were what when where which while who whom why will with would you your` + .split(/\s+/) +) + +function heatBubbleTextForKeywords(row: TRelayThreadHeatBubble): string { + const parts = [row.snippet] + const c = row.rootEvent?.content?.trim() + if (c) parts.push(c.slice(0, 500)) + return parts.join('\n') +} + +/** Distinct keywords (length ≥ 4, not stopword) from snippet + start of root note. */ +export function extractHeatKeywords(row: TRelayThreadHeatBubble): string[] { + const raw = heatBubbleTextForKeywords(row) + const lower = raw.toLowerCase() + const words = lower.match(/[a-zäöüßåæøéèêëáíóúñç0-9]{4,}/gi) ?? [] + const tags = [...lower.matchAll(/#([a-zäöüßåæøéèêëáíóúñç0-9_]{3,})/gi)].map((m) => m[1]) + const out: string[] = [] + const seen = new Set() + for (const w of [...words, ...tags]) { + const t = w.toLowerCase() + if (STOPWORDS.has(t)) continue + if (seen.has(t)) continue + seen.add(t) + out.push(t) + if (out.length >= 22) break + } + return out +} + +/** + * Re-order bubbles so threads that share at least one keyword appear next to each other in DOM + * order (flex-wrap packs them closer). Uses union–find on roots per shared keyword; components + * are ordered by their strongest thread, then by heat inside the component. + */ +export function orderHeatBubblesByKeywordProximity(rows: TRelayThreadHeatBubble[]): TRelayThreadHeatBubble[] { + if (rows.length <= 1) return rows + + const parent = new Map() + const find = (x: string): string => { + if (!parent.has(x)) parent.set(x, x) + const p = parent.get(x)! + if (p === x) return x + const r = find(p) + parent.set(x, r) + return r + } + const union = (a: string, b: string) => { + const ra = find(a) + const rb = find(b) + if (ra !== rb) parent.set(ra, rb) + } + + for (const row of rows) { + parent.set(row.rootId, row.rootId) + } + + const keywordToRoots = new Map() + for (const row of rows) { + for (const kw of extractHeatKeywords(row)) { + const arr = keywordToRoots.get(kw) ?? [] + arr.push(row.rootId) + keywordToRoots.set(kw, arr) + } + } + + for (const ids of keywordToRoots.values()) { + if (ids.length < 2) continue + const head = ids[0] + for (let i = 1; i < ids.length; i++) { + union(head, ids[i]) + } + } + + const componentToMembers = new Map() + for (const row of rows) { + const c = find(row.rootId) + const arr = componentToMembers.get(c) ?? [] + arr.push(row) + componentToMembers.set(c, arr) + } + + const components = [...componentToMembers.values()].map((members) => ({ + members: [...members].sort((a, b) => b.heat - a.heat), + maxHeat: Math.max(...members.map((m) => m.heat), 0) + })) + components.sort((a, b) => b.maxHeat - a.maxHeat) + return components.flatMap((c) => c.members) +} diff --git a/src/lib/relay-thread-heat.ts b/src/lib/relay-thread-heat.ts index 3cd63823..06a66253 100644 --- a/src/lib/relay-thread-heat.ts +++ b/src/lib/relay-thread-heat.ts @@ -99,11 +99,18 @@ export function buildRelayThreadHeatBubbles( 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 || - '' + /** Text for preview / hover: always prefer the OP, never an early reply. */ + const opForSnippet = (() => { + if (rootEvent) return rootEvent + const kind1Or11 = posts.filter( + (e) => e.kind === kinds.ShortTextNote || e.kind === ExtendedKind.DISCUSSION + ) + 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] + })() + const snippetSource = opForSnippet?.content?.trim() ?? '' rows.push({ rootId, diff --git a/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx index 1b06be23..35fe9818 100644 --- a/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx +++ b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx @@ -12,6 +12,7 @@ import { relayThreadHeatMapSettingKey, serializeRelayThreadHeatMapCache } from '@/lib/relay-thread-heat-cache' +import { orderHeatBubblesByKeywordProximity } from '@/lib/relay-thread-heat-keywords' import { buildRelayThreadHeatBubbles, buildRelayThreadHeatEdges, @@ -273,7 +274,10 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) } }, [pubkey, cacheSettingKey, mergeHeatMapData, refreshKey, rescanTick, t]) - const maxHeat = useMemo(() => rows.reduce((m, r) => Math.max(m, r.heat), 0) || 1, [rows]) + /** Pack threads that share a keyword next to each other in the flex grid (see {@link orderHeatBubblesByKeywordProximity}). */ + const layoutRows = useMemo(() => orderHeatBubblesByKeywordProximity(rows), [rows]) + + const maxHeat = useMemo(() => layoutRows.reduce((m, r) => Math.max(m, r.heat), 0) || 1, [layoutRows]) const graphAreaRef = useRef(null) const bubbleRefs = useRef>(new Map()) @@ -288,7 +292,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) const recomputeConnectorLines = useCallback(() => { const host = graphAreaRef.current - if (!host || rows.length === 0) { + if (!host || layoutRows.length === 0) { setLineSegs([]) return } @@ -306,7 +310,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) if (ca && cb) segs.push({ x1: ca.x, y1: ca.y, x2: cb.x, y2: cb.y }) } setLineSegs(segs) - }, [rows, edges]) + }, [layoutRows, edges]) useLayoutEffect(() => { recomputeConnectorLines() @@ -319,12 +323,12 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) requestAnimationFrame(() => recomputeConnectorLines()) }) ro.observe(host) - for (const row of rows) { + for (const row of layoutRows) { const el = bubbleRefs.current.get(row.rootId) if (el) ro.observe(el) } return () => ro.disconnect() - }, [rows, edges, recomputeConnectorLines]) + }, [layoutRows, edges, recomputeConnectorLines]) if (!pubkey) { return null @@ -401,7 +405,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) ))}
- {rows.map((row) => { + {layoutRows.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', {