Browse Source

bug-fixes

imwald
Silberengel 1 month ago
parent
commit
c14f37ede0
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 34
      src/components/Sidebar/SidebarCalendarWeekWidget.tsx
  4. 2
      src/i18n/locales/de.ts
  5. 2
      src/i18n/locales/en.ts
  6. 93
      src/lib/relay-thread-heat-keywords.ts
  7. 17
      src/lib/relay-thread-heat.ts
  8. 16
      src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.5.2", "version": "23.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.5.2", "version": "23.6.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "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", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

34
src/components/Sidebar/SidebarCalendarWeekWidget.tsx

@ -134,7 +134,15 @@ export default function SidebarCalendarWeekWidget() {
return 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, relayUrls,
{ {
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME],
@ -147,14 +155,8 @@ export default function SidebarCalendarWeekWidget() {
firstRelayResultGraceMs: false firstRelayResultGraceMs: false
} }
) )
if (cancelled) return const chunkReqs = authorChunks.map((authors) =>
client.fetchEvents(
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, relayUrls,
{ {
kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME], kinds: [ExtendedKind.CALENDAR_EVENT_DATE, ExtendedKind.CALENDAR_EVENT_TIME],
@ -168,10 +170,20 @@ export default function SidebarCalendarWeekWidget() {
firstRelayResultGraceMs: false firstRelayResultGraceMs: false
} }
) )
if (cancelled) return )
fromFollowing.push(...chunk)
let batch: Event[] = []
const fromFollowing: Event[] = []
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( const fromSession = client.getSessionEventsMatchingSearch(
'', '',

2
src/i18n/locales/de.ts

@ -783,7 +783,7 @@ export default {
Favorites: "Favorites", Favorites: "Favorites",
"Heat map": "Thread-Heatmap", "Heat map": "Thread-Heatmap",
heatMapDescription: 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: heatMapLocalOnlyBanner:
"Keine Lese‑Relay‑Liste — es werden nur Sitzungs‑Cache und lokales Archiv gemischt (Relays in den Einstellungen ergänzen für Live‑Daten).", "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…", heatMapLoading: "Sitzungs‑Cache, Archiv und Relays werden zusammengeführt…",

2
src/i18n/locales/en.ts

@ -787,7 +787,7 @@ export default {
Favorites: "Favorites", Favorites: "Favorites",
"Heat map": "Thread heat map", "Heat map": "Thread heat map",
heatMapDescription: 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: 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).", "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…", heatMapLoading: "Merging session cache, archive, and relays…",

93
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<string>()
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 unionfind 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<string, string>()
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<string, string[]>()
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<string, TRelayThreadHeatBubble[]>()
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)
}

17
src/lib/relay-thread-heat.ts

@ -99,11 +99,18 @@ export function buildRelayThreadHeatBubbles(
e.id.toLowerCase() === rootId && e.id.toLowerCase() === rootId &&
(e.kind === kinds.ShortTextNote || e.kind === ExtendedKind.DISCUSSION) (e.kind === kinds.ShortTextNote || e.kind === ExtendedKind.DISCUSSION)
) )
const sortedByTime = [...posts].sort((a, b) => a.created_at - b.created_at) /** Text for preview / hover: always prefer the OP, never an early reply. */
const snippetSource = const opForSnippet = (() => {
rootEvent?.content?.trim() || if (rootEvent) return rootEvent
sortedByTime.find((e) => e.kind === kinds.ShortTextNote || e.kind === ExtendedKind.DISCUSSION)?.content || 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({ rows.push({
rootId, rootId,

16
src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx

@ -12,6 +12,7 @@ import {
relayThreadHeatMapSettingKey, relayThreadHeatMapSettingKey,
serializeRelayThreadHeatMapCache serializeRelayThreadHeatMapCache
} from '@/lib/relay-thread-heat-cache' } from '@/lib/relay-thread-heat-cache'
import { orderHeatBubblesByKeywordProximity } from '@/lib/relay-thread-heat-keywords'
import { import {
buildRelayThreadHeatBubbles, buildRelayThreadHeatBubbles,
buildRelayThreadHeatEdges, buildRelayThreadHeatEdges,
@ -273,7 +274,10 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
} }
}, [pubkey, cacheSettingKey, mergeHeatMapData, refreshKey, rescanTick, t]) }, [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<HTMLDivElement>(null) const graphAreaRef = useRef<HTMLDivElement>(null)
const bubbleRefs = useRef<Map<string, HTMLButtonElement>>(new Map()) const bubbleRefs = useRef<Map<string, HTMLButtonElement>>(new Map())
@ -288,7 +292,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
const recomputeConnectorLines = useCallback(() => { const recomputeConnectorLines = useCallback(() => {
const host = graphAreaRef.current const host = graphAreaRef.current
if (!host || rows.length === 0) { if (!host || layoutRows.length === 0) {
setLineSegs([]) setLineSegs([])
return 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 }) if (ca && cb) segs.push({ x1: ca.x, y1: ca.y, x2: cb.x, y2: cb.y })
} }
setLineSegs(segs) setLineSegs(segs)
}, [rows, edges]) }, [layoutRows, edges])
useLayoutEffect(() => { useLayoutEffect(() => {
recomputeConnectorLines() recomputeConnectorLines()
@ -319,12 +323,12 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
requestAnimationFrame(() => recomputeConnectorLines()) requestAnimationFrame(() => recomputeConnectorLines())
}) })
ro.observe(host) ro.observe(host)
for (const row of rows) { for (const row of layoutRows) {
const el = bubbleRefs.current.get(row.rootId) const el = bubbleRefs.current.get(row.rootId)
if (el) ro.observe(el) if (el) ro.observe(el)
} }
return () => ro.disconnect() return () => ro.disconnect()
}, [rows, edges, recomputeConnectorLines]) }, [layoutRows, edges, recomputeConnectorLines])
if (!pubkey) { if (!pubkey) {
return null return null
@ -401,7 +405,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props)
))} ))}
</svg> </svg>
<div className="relative z-10 flex flex-wrap content-start items-start justify-center gap-4"> <div className="relative z-10 flex flex-wrap content-start items-start justify-center gap-4">
{rows.map((row) => { {layoutRows.map((row) => {
const intensity = Math.min(1, row.heat / maxHeat) const intensity = Math.min(1, row.heat / maxHeat)
const size = Math.min(200, Math.max(76, 52 + Math.sqrt(row.heat) * 9)) const size = Math.min(200, Math.max(76, 52 + Math.sqrt(row.heat) * 9))
const statsLine = t('heatMapBubbleStats', { const statsLine = t('heatMapBubbleStats', {

Loading…
Cancel
Save