diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index b2e7404e..5a7283a6 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -18,6 +18,7 @@ import { } from '@/lib/spell-feed-request-identity' import logger from '@/lib/logger' import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' +import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' import { isTouchDevice } from '@/lib/utils' import { useContentPolicy } from '@/providers/ContentPolicyProvider' @@ -124,24 +125,6 @@ const LOAD_MORE_IO_ROOT_MARGIN_BOTTOM_PX = 3200 */ const LOAD_MORE_SCROLL_PREFETCH_VIEWPORT_MULT = 2.35 -/** Same rules as visible-row filtering when the home kind picker applies (not {@link shouldHideEvent}). */ -function eventPassesNoteListKindPicker( - event: Event, - effectiveShowKinds: readonly number[], - showKind1OPs: boolean, - showKind1Replies: boolean, - showKind1111: boolean -): boolean { - if (!effectiveShowKinds.includes(event.kind)) return false - if (event.kind === kinds.ShortTextNote) { - const isReply = isReplyNoteEvent(event) - if (isReply && !showKind1Replies) return false - if (!isReply && !showKind1OPs) return false - } - if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false - if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) return false - return true -} const LOAD_MORE_SCROLL_PREFETCH_MIN_PX = 960 /** Min ms between scroll-driven load-more attempts (loadMore also throttles internally). */ const LOAD_MORE_SCROLL_PREFETCH_COOLDOWN_MS = 180 diff --git a/src/components/Sidebar/FavoritesButton.tsx b/src/components/Sidebar/FavoritesButton.tsx index 7fb64754..066fb53c 100644 --- a/src/components/Sidebar/FavoritesButton.tsx +++ b/src/components/Sidebar/FavoritesButton.tsx @@ -1,10 +1,12 @@ import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { useNostr } from '@/providers/NostrProvider' -import { Star } from 'lucide-react' +import { Flame } from 'lucide-react' +import { useTranslation } from 'react-i18next' import SidebarItem from './SidebarItem' export default function FavoritesButton() { + const { t } = useTranslation() const { navigate, current, currentPageProps, display } = usePrimaryPage() const { primaryViewType } = usePrimaryNoteView() const { pubkey } = useNostr() @@ -14,16 +16,16 @@ export default function FavoritesButton() { return ( navigate('spells', { spell: 'favorites' })} + title={t('Heat map')} + onClick={() => navigate('spells', { spell: 'heatMap' })} active={ display && current === 'spells' && primaryViewType === null && - spell === 'favorites' + spell === 'heatMap' } > - + ) } diff --git a/src/constants.ts b/src/constants.ts index c93bdd38..1527e8b0 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -861,7 +861,7 @@ export const FAUX_SPELL_ORDER = [ 'notifications', 'discussions', 'following', - 'favorites', + 'heatMap', 'followPacks', 'media', 'interests', diff --git a/src/i18n/locales/cs.ts b/src/i18n/locales/cs.ts index 19b9cac0..82d8a64f 100644 --- a/src/i18n/locales/cs.ts +++ b/src/i18n/locales/cs.ts @@ -761,6 +761,20 @@ export default { "articles and publications": "articles and publications", Interests: "Interests", 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`).", + 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…", + heatMapEmpty: + "Nothing meets the bar yet. Threads need at least five notes that pass your feed kind filter from about the last 72 hours. Browse feeds or Rescan after syncing.", + heatMapFetchError: "Could not load a thread snapshot from relays.", + heatMapNoRelays: "Add read relays (or favorites) in settings to query threads.", + heatMapRescan: "Rescan", + heatMapOpenThread: "Open thread", + heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows", + "Please login to view thread heat map": "Please log in to open the thread heat map.", Calendar: "Calendar", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.", diff --git a/src/i18n/locales/de.ts b/src/i18n/locales/de.ts index 8158cb5d..b191e50f 100644 --- a/src/i18n/locales/de.ts +++ b/src/i18n/locales/de.ts @@ -781,6 +781,20 @@ export default { "articles and publications": "Artikel und Veröffentlichungen", Interests: "Interessen", 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.", + 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…", + heatMapEmpty: + "Noch nichts passend. Threads brauchen mindestens fünf Notes, die deinen Feed‑Kind‑Filter passieren (ca. letzte 72 Stunden). Feeds lesen oder nach dem Sync erneut scannen.", + heatMapFetchError: "Thread-Snapshot von den Relays konnte nicht geladen werden.", + heatMapNoRelays: "Bitte Lese-Relays (oder Favoriten) in den Einstellungen hinzufügen.", + heatMapRescan: "Erneut scannen", + heatMapOpenThread: "Thread öffnen", + heatMapBubbleStats: "{{posts}} Notes · {{people}} Personen · {{follows}} Folge-Accounts im Thread", + "Please login to view thread heat map": "Bitte anmelden, um die Thread-Heatmap zu öffnen.", Calendar: "Kalender", "No subscribed interests yet.": "Noch keine Interessen abonniert. Themen in den Einstellungen hinzufügen, um sie hier zu sehen.", "No bookmarked notes with id tags yet.": "Noch keine Lesezeichen mit Ereignis-IDs. Nur klassische (e-Tag-) Lesezeichen erscheinen in diesem Feed.", diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 57dcb575..af48a01c 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -785,6 +785,20 @@ export default { "articles and publications": "articles and publications", Interests: "Interests", 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`).", + 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…", + heatMapEmpty: + "Nothing meets the bar yet. Threads need at least five notes that pass your feed kind filter from about the last 72 hours. Browse feeds or Rescan after syncing.", + heatMapFetchError: "Could not load a thread snapshot from relays.", + heatMapNoRelays: "Add read relays (or favorites) in settings to query threads.", + heatMapRescan: "Rescan", + heatMapOpenThread: "Open thread", + heatMapBubbleStats: "{{posts}} notes · {{people}} people · {{follows}} follows in thread", + "Please login to view thread heat map": "Please log in to open the thread heat map.", Calendar: "Calendar", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.", diff --git a/src/i18n/locales/es.ts b/src/i18n/locales/es.ts index 327b1113..f2c515ff 100644 --- a/src/i18n/locales/es.ts +++ b/src/i18n/locales/es.ts @@ -761,6 +761,20 @@ export default { "articles and publications": "articles and publications", Interests: "Interests", 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`).", + 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…", + heatMapEmpty: + "Nothing meets the bar yet. Threads need at least five notes that pass your feed kind filter from about the last 72 hours. Browse feeds or Rescan after syncing.", + heatMapFetchError: "Could not load a thread snapshot from relays.", + heatMapNoRelays: "Add read relays (or favorites) in settings to query threads.", + heatMapRescan: "Rescan", + heatMapOpenThread: "Open thread", + heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows", + "Please login to view thread heat map": "Please log in to open the thread heat map.", Calendar: "Calendar", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.", diff --git a/src/i18n/locales/fr.ts b/src/i18n/locales/fr.ts index 4e0dfd27..9818d4cc 100644 --- a/src/i18n/locales/fr.ts +++ b/src/i18n/locales/fr.ts @@ -761,6 +761,20 @@ export default { "articles and publications": "articles and publications", Interests: "Interests", 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`).", + 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…", + heatMapEmpty: + "Nothing meets the bar yet. Threads need at least five notes that pass your feed kind filter from about the last 72 hours. Browse feeds or Rescan after syncing.", + heatMapFetchError: "Could not load a thread snapshot from relays.", + heatMapNoRelays: "Add read relays (or favorites) in settings to query threads.", + heatMapRescan: "Rescan", + heatMapOpenThread: "Open thread", + heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows", + "Please login to view thread heat map": "Please log in to open the thread heat map.", Calendar: "Calendar", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.", diff --git a/src/i18n/locales/nl.ts b/src/i18n/locales/nl.ts index a700fdd6..8d026857 100644 --- a/src/i18n/locales/nl.ts +++ b/src/i18n/locales/nl.ts @@ -761,6 +761,20 @@ export default { "articles and publications": "articles and publications", Interests: "Interests", 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`).", + 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…", + heatMapEmpty: + "Nothing meets the bar yet. Threads need at least five notes that pass your feed kind filter from about the last 72 hours. Browse feeds or Rescan after syncing.", + heatMapFetchError: "Could not load a thread snapshot from relays.", + heatMapNoRelays: "Add read relays (or favorites) in settings to query threads.", + heatMapRescan: "Rescan", + heatMapOpenThread: "Open thread", + heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows", + "Please login to view thread heat map": "Please log in to open the thread heat map.", Calendar: "Calendar", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.", diff --git a/src/i18n/locales/pl.ts b/src/i18n/locales/pl.ts index 501e0949..2faa38d7 100644 --- a/src/i18n/locales/pl.ts +++ b/src/i18n/locales/pl.ts @@ -761,6 +761,20 @@ export default { "articles and publications": "articles and publications", Interests: "Interests", 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`).", + 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…", + heatMapEmpty: + "Nothing meets the bar yet. Threads need at least five notes that pass your feed kind filter from about the last 72 hours. Browse feeds or Rescan after syncing.", + heatMapFetchError: "Could not load a thread snapshot from relays.", + heatMapNoRelays: "Add read relays (or favorites) in settings to query threads.", + heatMapRescan: "Rescan", + heatMapOpenThread: "Open thread", + heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows", + "Please login to view thread heat map": "Please log in to open the thread heat map.", Calendar: "Calendar", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.", diff --git a/src/i18n/locales/ru.ts b/src/i18n/locales/ru.ts index e3e879f8..af7bba44 100644 --- a/src/i18n/locales/ru.ts +++ b/src/i18n/locales/ru.ts @@ -761,6 +761,20 @@ export default { "articles and publications": "articles and publications", Interests: "Interests", 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`).", + 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…", + heatMapEmpty: + "Nothing meets the bar yet. Threads need at least five notes that pass your feed kind filter from about the last 72 hours. Browse feeds or Rescan after syncing.", + heatMapFetchError: "Could not load a thread snapshot from relays.", + heatMapNoRelays: "Add read relays (or favorites) in settings to query threads.", + heatMapRescan: "Rescan", + heatMapOpenThread: "Open thread", + heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows", + "Please login to view thread heat map": "Please log in to open the thread heat map.", Calendar: "Calendar", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.", diff --git a/src/i18n/locales/tr.ts b/src/i18n/locales/tr.ts index 803f32cc..c9528fa0 100644 --- a/src/i18n/locales/tr.ts +++ b/src/i18n/locales/tr.ts @@ -761,6 +761,20 @@ export default { "articles and publications": "articles and publications", Interests: "Interests", 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`).", + 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…", + heatMapEmpty: + "Nothing meets the bar yet. Threads need at least five notes that pass your feed kind filter from about the last 72 hours. Browse feeds or Rescan after syncing.", + heatMapFetchError: "Could not load a thread snapshot from relays.", + heatMapNoRelays: "Add read relays (or favorites) in settings to query threads.", + heatMapRescan: "Rescan", + heatMapOpenThread: "Open thread", + heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows", + "Please login to view thread heat map": "Please log in to open the thread heat map.", Calendar: "Calendar", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.", diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index 01483cfc..9f89c18b 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -761,6 +761,20 @@ export default { "articles and publications": "articles and publications", Interests: "Interests", 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`).", + 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…", + heatMapEmpty: + "Nothing meets the bar yet. Threads need at least five notes that pass your feed kind filter from about the last 72 hours. Browse feeds or Rescan after syncing.", + heatMapFetchError: "Could not load a thread snapshot from relays.", + heatMapNoRelays: "Add read relays (or favorites) in settings to query threads.", + heatMapRescan: "Rescan", + heatMapOpenThread: "Open thread", + heatMapBubbleStats: "{{posts}} notes · {{people}} authors · {{follows}} from follows", + "Please login to view thread heat map": "Please log in to open the thread heat map.", Calendar: "Calendar", "No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.", diff --git a/src/lib/feed-kind-filter.ts b/src/lib/feed-kind-filter.ts new file mode 100644 index 00000000..7c681674 --- /dev/null +++ b/src/lib/feed-kind-filter.ts @@ -0,0 +1,26 @@ +import { ExtendedKind } from '@/constants' +import { isReplyNoteEvent } from '@/lib/event' +import type { Event } from 'nostr-tools' +import { kinds } from 'nostr-tools' + +/** + * Same rules as visible-row filtering when the home kind picker applies + * (not {@link shouldHideEvent} / mute / trust layers). + */ +export function eventPassesNoteListKindPicker( + event: Event, + effectiveShowKinds: readonly number[], + showKind1OPs: boolean, + showKind1Replies: boolean, + showKind1111: boolean +): boolean { + if (!effectiveShowKinds.includes(event.kind)) return false + if (event.kind === kinds.ShortTextNote) { + const isReply = isReplyNoteEvent(event) + if (isReply && !showKind1Replies) return false + if (!isReply && !showKind1OPs) return false + } + if (event.kind === ExtendedKind.COMMENT && !showKind1111) return false + if (event.kind === ExtendedKind.GIT_RELEASE && !showKind1OPs) return false + return true +} diff --git a/src/lib/relay-thread-heat-cache.ts b/src/lib/relay-thread-heat-cache.ts new file mode 100644 index 00000000..18782c46 --- /dev/null +++ b/src/lib/relay-thread-heat-cache.ts @@ -0,0 +1,66 @@ +import type { TRelayThreadHeatBubble, TRelayThreadHeatEdge } from '@/lib/relay-thread-heat' + +const CACHE_V = 1 as const + +export type TRelayThreadHeatMapCacheEnvelope = { + v: typeof CACHE_V + /** When the merge finished (ms since epoch). */ + builtAtMs: number + bubbles: TRelayThreadHeatBubble[] + /** Links between bubbles (same as live merge); absent in older cache payloads. */ + edges?: TRelayThreadHeatEdge[] +} + +/** Stable short digest for SETTINGS key segments (FNV-1a 32-bit). */ +export function digestHeatMapKeyPart(s: string): string { + let h = 2166136261 + for (let i = 0; i < s.length; i++) { + h ^= s.charCodeAt(i) + h = Math.imul(h, 16777619) + } + return (h >>> 0).toString(36).padStart(6, '0') +} + +export function relayThreadHeatMapSettingKey( + pubkey: string, + relayUrls: readonly string[], + followPubkeys: readonly string[], + /** Serialized home kind-picker state so cache invalidates when feed filters change. */ + feedFilterKey: string +): string { + const pk = pubkey.trim().toLowerCase() + const relayKey = digestHeatMapKeyPart([...relayUrls].sort().join('\n')) + const followKey = digestHeatMapKeyPart( + [...followPubkeys] + .map((p) => p.trim().toLowerCase()) + .filter(Boolean) + .sort() + .join('\n') + ) + const feedKey = digestHeatMapKeyPart(feedFilterKey) + return `relayHeatV${CACHE_V}:${pk}:${relayKey}:${followKey}:${feedKey}` +} + +export function parseRelayThreadHeatMapCache(raw: string | null): TRelayThreadHeatMapCacheEnvelope | null { + if (raw == null || raw === '') return null + try { + const o = JSON.parse(raw) as Partial + if (o.v !== CACHE_V || !Array.isArray(o.bubbles)) return null + const edgesRaw = o.edges + const edges: TRelayThreadHeatEdge[] | undefined = Array.isArray(edgesRaw) + ? edgesRaw.filter( + (e: unknown): e is TRelayThreadHeatEdge => + e != null && + typeof (e as TRelayThreadHeatEdge).a === 'string' && + typeof (e as TRelayThreadHeatEdge).b === 'string' + ) + : undefined + return { v: CACHE_V, builtAtMs: typeof o.builtAtMs === 'number' ? o.builtAtMs : 0, bubbles: o.bubbles, edges } + } catch { + return null + } +} + +export function serializeRelayThreadHeatMapCache(envelope: TRelayThreadHeatMapCacheEnvelope): string { + return JSON.stringify(envelope) +} diff --git a/src/lib/relay-thread-heat.ts b/src/lib/relay-thread-heat.ts new file mode 100644 index 00000000..3cd63823 --- /dev/null +++ b/src/lib/relay-thread-heat.ts @@ -0,0 +1,240 @@ +import { ExtendedKind } from '@/constants' +import { + getParentEventHexId, + getRootEventHexId, + isReplyNoteEvent, + normalizeReplaceableCoordinateString, + resolveDeclaredThreadRootEventHex +} from '@/lib/event' +import type { Event } from 'nostr-tools' +import { kinds } from 'nostr-tools' + +export type TRelayThreadHeatBubble = { + rootId: string + heat: number + postCount: number + uniqueAuthors: number + followAuthorsInThread: number + snippet: string + lastActivity: number + rootEvent?: Event +} + +/** Undirected link between thread roots (cross-refs, OP-anchor refs, or shared `a`/`A` coordinates). */ +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 { + const t = content.replace(/\s+/g, ' ').trim().slice(0, maxLen) + return t || '…' +} + +/** Map kind 1 / 11 events to a thread root id for aggregation. */ +export function threadRootIdForHeat(ev: Event): string | null { + if (ev.kind !== kinds.ShortTextNote && ev.kind !== ExtendedKind.DISCUSSION) return null + if (!ev.pubkey?.trim()) return null + + if (ev.kind === kinds.ShortTextNote && !isReplyNoteEvent(ev)) { + return ev.id.toLowerCase() + } + + const rootHex = getRootEventHexId(ev)?.trim().toLowerCase() + const parentHex = getParentEventHexId(ev)?.trim().toLowerCase() + const raw = (rootHex || parentHex || ev.id).toLowerCase() + if (!/^[0-9a-f]{64}$/i.test(raw)) return ev.id.toLowerCase() + return resolveDeclaredThreadRootEventHex(raw) +} + +/** + * Group recent notes into thread roots; score activity + author spread + how many of your follows posted. + * `since` is the window start (unix seconds) for recency scoring only. + * + * **Inclusion:** every distinct thread root with at least one kind **1** or **11** event becomes one row + * (no minimum reply count). **Heat** only ranks rows: `postCount + 1.6×uniqueAuthors + 11×followAuthorsInThread + recencyBoost`. + */ +export function buildRelayThreadHeatBubbles( + events: Event[], + followPubkeys: Set, + since: number +): TRelayThreadHeatBubble[] { + const follow = followPubkeys + const now = Math.floor(Date.now() / 1000) + const span = Math.max(3600, now - since) + + const byRoot = new Map; posts: Event[] }>() + for (const ev of events) { + const rid = threadRootIdForHeat(ev) + if (!rid) continue + const bucket = byRoot.get(rid) ?? { authors: new Set(), posts: [] } + const author = ev.pubkey?.trim().toLowerCase() + if (author) bucket.authors.add(author) + bucket.posts.push(ev) + byRoot.set(rid, bucket) + } + + const rows: TRelayThreadHeatBubble[] = [] + for (const [rootId, { authors, posts }] of byRoot) { + let followAuthorsInThread = 0 + for (const a of authors) { + if (follow.has(a)) followAuthorsInThread++ + } + const postCount = posts.length + const uniqueAuthors = authors.size + let lastActivity = 0 + for (const p of posts) { + if (p.created_at > lastActivity) lastActivity = p.created_at + } + const recencyBoost = ((lastActivity - since) / span) * 14 + + const heat = + postCount + + uniqueAuthors * 1.6 + + followAuthorsInThread * 11 + + recencyBoost + + const rootEvent = posts.find( + (e) => + 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 || + '' + + rows.push({ + rootId, + heat, + postCount, + uniqueAuthors, + followAuthorsInThread, + snippet: collapseSnippet(snippetSource), + lastActivity, + rootEvent + }) + } + + rows.sort((a, b) => b.heat - a.heat) + return rows +} + +const EVENT_REF_TAG_NAMES = new Set(['e', 'E', 'q']) + +/** + * Hex ids that should count as “this thread’s OP / anchor” for link resolution: root id, top-level + * notes, `e`/`E` markers `root`, and declared root/parent hex from notes in the thread (so refs to + * the OP still connect when the OP event is missing from {@link feedNotes}). + */ +function collectOpIdCandidatesForRoot(r: string, feedNotes: Event[]): Set { + const rLower = r.toLowerCase() + const out = new Set() + out.add(rLower) + for (const ev of feedNotes) { + if (threadRootIdForHeat(ev) !== rLower) continue + out.add(ev.id.toLowerCase()) + const rootHex = getRootEventHexId(ev)?.trim().toLowerCase() + if (rootHex && /^[0-9a-f]{64}$/.test(rootHex)) out.add(rootHex) + const parentHex = getParentEventHexId(ev)?.trim().toLowerCase() + if (parentHex && /^[0-9a-f]{64}$/.test(parentHex)) out.add(parentHex) + if (ev.kind === kinds.ShortTextNote && !isReplyNoteEvent(ev)) { + out.add(ev.id.toLowerCase()) + } + for (const t of ev.tags ?? []) { + if ((t[0] === 'e' || t[0] === 'E') && t[1] && String(t[3] ?? '').toLowerCase() === 'root') { + const id = t[1].trim().toLowerCase() + if (/^[0-9a-f]{64}$/.test(id)) out.add(id) + } + } + } + return out +} + +/** + * Build edges between thread roots that appear in {@link rootIdsInView} when: + * - some note references another note id via `e` / `E` / `q` (including another thread’s OP when + * that OP is only visible via tags, not as a loaded event), or + * - two threads each have a note tagging the same replaceable coordinate (`a` / `A`, NIP-33). + */ +export function buildRelayThreadHeatEdges( + feedNotes: Event[], + rootIdsInView: ReadonlySet +): TRelayThreadHeatEdge[] { + const idToRoot = new Map() + for (const ev of feedNotes) { + const r = threadRootIdForHeat(ev) + if (r) idToRoot.set(ev.id.toLowerCase(), r) + } + + /** Note id / OP-anchor hex → thread root (for refs that never appear as `feedNotes` rows). */ + const opHexToRoot = new Map() + for (const r of rootIdsInView) { + for (const h of collectOpIdCandidatesForRoot(r, feedNotes)) { + opHexToRoot.set(h, r) + } + } + + const seen = new Set() + const out: TRelayThreadHeatEdge[] = [] + + const addEdge = (x: string, y: string) => { + if (x === y || !rootIdsInView.has(x) || !rootIdsInView.has(y)) return + const [a, b] = x < y ? [x, y] : [y, x] + const key = `${a}:${b}` + if (seen.has(key)) return + seen.add(key) + out.push({ a, b }) + } + + for (const ev of feedNotes) { + const r = threadRootIdForHeat(ev) + if (!r || !rootIdsInView.has(r)) continue + for (const tag of ev.tags ?? []) { + const name = tag[0] + if (typeof name !== 'string' || !EVENT_REF_TAG_NAMES.has(name)) continue + const ref = String(tag[1] ?? '') + .trim() + .toLowerCase() + if (!/^[0-9a-f]{64}$/.test(ref)) continue + let otherRoot = idToRoot.get(ref) ?? opHexToRoot.get(ref) ?? null + if (otherRoot == null && rootIdsInView.has(ref)) { + otherRoot = ref + } + if (otherRoot != null) addEdge(r, otherRoot) + } + } + + /** Normalized `kind:pubkey:d` → thread roots that tag it on at least one note in the corpus. */ + const coordToRoots = new Map>() + for (const ev of feedNotes) { + const r = threadRootIdForHeat(ev) + if (!r || !rootIdsInView.has(r)) continue + for (const tag of ev.tags ?? []) { + const name = tag[0] + if (name !== 'a' && name !== 'A') continue + const raw = tag[1] + if (typeof raw !== 'string' || !raw.trim()) continue + const norm = normalizeReplaceableCoordinateString(raw) + if (!norm) continue + let set = coordToRoots.get(norm) + if (!set) { + set = new Set() + coordToRoots.set(norm, set) + } + set.add(r) + } + } + for (const roots of coordToRoots.values()) { + if (roots.size < 2) continue + const arr = [...roots] + for (let i = 0; i < arr.length; i++) { + for (let j = i + 1; j < arr.length; j++) { + addEdge(arr[i], arr[j]) + } + } + } + + return out +} diff --git a/src/pages/primary/NoteListPage/index.tsx b/src/pages/primary/NoteListPage/index.tsx index fd1ffc9d..3e4e6a86 100644 --- a/src/pages/primary/NoteListPage/index.tsx +++ b/src/pages/primary/NoteListPage/index.tsx @@ -8,7 +8,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider' import type { TNoteListRef } from '@/components/NoteList' import { NoteCardLoadingSkeleton } from '@/components/NoteCard' import { TPageRef } from '@/types' -import { Calendar, Compass, Star, UsersRound } from 'lucide-react' +import { Calendar, Compass, Flame, UsersRound } from 'lucide-react' import React, { forwardRef, useCallback, @@ -173,8 +173,8 @@ function NoteListPageTitlebar({ const spell = (currentPageProps as { spell?: string } | undefined)?.spell const exploreActive = display && current === 'explore' && primaryViewType === null const followsLatestActive = display && current === 'follows-latest' && primaryViewType === null - const favoritesActive = - display && current === 'spells' && spell === 'favorites' && primaryViewType === null + const heatMapActive = + display && current === 'spells' && spell === 'heatMap' && primaryViewType === null const calendarActive = display && current === 'calendar' && primaryViewType === null if (!isSmallScreen) { @@ -230,18 +230,18 @@ function NoteListPageTitlebar({ ) : null} diff --git a/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx new file mode 100644 index 00000000..1b06be23 --- /dev/null +++ b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx @@ -0,0 +1,464 @@ +import { Button } from '@/components/ui/button' +import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' +import { ExtendedKind } from '@/constants' +import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' +import { filterEventsExcludingTombstones } from '@/lib/event' +import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { toNote, toProfileInteractionMap } from '@/lib/link' +import logger from '@/lib/logger' +import { mergeEventsById } from '@/lib/profile-interaction-partners' +import { + parseRelayThreadHeatMapCache, + relayThreadHeatMapSettingKey, + serializeRelayThreadHeatMapCache +} from '@/lib/relay-thread-heat-cache' +import { + buildRelayThreadHeatBubbles, + buildRelayThreadHeatEdges, + RELAY_THREAD_HEAT_MIN_INTERACTIONS, + type TRelayThreadHeatBubble, + type TRelayThreadHeatEdge +} from '@/lib/relay-thread-heat' +import { useSmartNoteNavigation, useSmartProfileInteractionsNavigation } 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 { LayoutGrid, Loader2, RefreshCw } from 'lucide-react' +import type { Event } from 'nostr-tools' +import { kinds, verifyEvent } from 'nostr-tools' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const HEAT_WINDOW_SEC = 72 * 3600 +/** REQ without `since`: many relays return nothing for kind 1+`since`; we clip by `created_at` client-side. */ +const HEAT_REQ_LIMIT = 1500 +const MAX_BUBBLES = 48 +const SESSION_HEAT_LIMIT = 2500 +/** Cap rows scanned so the heat map stays responsive on large archives. */ +const ARCHIVE_HEAT_MAX_SCAN = 30_000 +const ARCHIVE_HEAT_MAX_MATCHES = 2000 + +const HEAT_KINDS = [kinds.ShortTextNote, ExtendedKind.DISCUSSION] as const + +const ARCHIVE_SCAN_TIMEOUT_MS = 22_000 +const RELAY_FETCH_TIMEOUT_MS = 28_000 +const TOMBSTONES_TIMEOUT_MS = 8_000 + +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('[RelayThreadHeatMap] 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('[RelayThreadHeatMap] source failed', { label, err: e }) + resolve(fallback) + }) + }) +} + +type Props = { + followPubkeys: string[] + refreshKey: number +} + +export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) { + const { t } = useTranslation() + const { navigateToNote } = useSmartNoteNavigation() + const { navigateToProfileInteractions } = useSmartProfileInteractionsNavigation() + const { pubkey, relayList } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults() + + const feedFilterKey = useMemo( + () => + [ + [...showKinds].sort((a, b) => a - b).join(','), + showKind1OPs ? '1' : '0', + showKind1Replies ? '1' : '0', + showKind1111 ? '1' : '0' + ].join('|'), + [showKinds, showKind1OPs, showKind1Replies, showKind1111] + ) + + const followSet = useMemo( + () => new Set(followPubkeys.map((p) => p.trim().toLowerCase()).filter(Boolean)), + [followPubkeys] + ) + + const relayUrls = useMemo( + () => + getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays, + blockedRelays, + userReadRelaysWithHttp(relayList), + { + userWriteRelays: relayList?.write ?? [], + applySocialKindBlockedFilter: false + } + ), + [favoriteRelays, blockedRelays, relayList] + ) + + const [rows, setRows] = useState([]) + const [edges, setEdges] = useState([]) + /** True until the first cache read + merge attempt finishes for this mount/context. */ + const [loading, setLoading] = useState(true) + const [isMerging, setIsMerging] = useState(false) + const [error, setError] = useState(null) + const [rescanTick, setRescanTick] = useState(0) + + const cacheSettingKey = useMemo( + () => (pubkey ? relayThreadHeatMapSettingKey(pubkey, relayUrls, followPubkeys, feedFilterKey) : ''), + [pubkey, relayUrls, followPubkeys, feedFilterKey] + ) + + const mergeHeatMapData = useCallback(async (): Promise<{ + bubbles: TRelayThreadHeatBubble[] + edges: TRelayThreadHeatEdge[] + }> => { + const windowStart = Math.floor(Date.now() / 1000) - HEAT_WINDOW_SEC + const sessionEv = eventService.listSessionEventsByKinds(HEAT_KINDS, { limit: SESSION_HEAT_LIMIT }) + + logger.info('[RelayThreadHeatMap] merge started', { + relayCount: relayUrls.length, + sessionEvents: sessionEv.length + }) + + const archiveScan = indexedDb.scanEventArchiveByKinds({ + kinds: HEAT_KINDS, + since: windowStart, + maxRowsScanned: ARCHIVE_HEAT_MAX_SCAN, + maxMatches: ARCHIVE_HEAT_MAX_MATCHES + }) + const relayFetch = + relayUrls.length > 0 + ? client.fetchEvents( + relayUrls, + { kinds: [...HEAT_KINDS], limit: HEAT_REQ_LIMIT }, + { eoseTimeout: 8000, globalTimeout: 22000 } + ) + : 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') + ]) + + logger.info('[RelayThreadHeatMap] merge sources ready', { + idb: idbEv.length, + relay: relayRaw.length, + tombstones: tombstones.size + }) + + const mergedById = mergeEventsById([...sessionEv, ...idbEv, ...relayRaw]) + let timeFiltered = mergedById.filter((e) => e.created_at >= windowStart) + if (timeFiltered.length === 0 && mergedById.length > 0) { + timeFiltered = mergedById + } + + const dedup = new Map() + for (const ev of timeFiltered) { + if (!verifyEvent(ev)) continue + dedup.set(ev.id.toLowerCase(), ev) + } + if (dedup.size === 0 && timeFiltered.length > 0) { + for (const ev of timeFiltered) { + 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 merged = filterEventsExcludingTombstones([...dedup.values()], tombstones) + const feedNotes = merged.filter((e) => + eventPassesNoteListKindPicker(e, showKinds, showKind1OPs, showKind1Replies, showKind1111) + ) + const ranked = buildRelayThreadHeatBubbles(feedNotes, followSet, windowStart) + const bubbles = ranked + .filter((b) => b.postCount >= RELAY_THREAD_HEAT_MIN_INTERACTIONS) + .slice(0, MAX_BUBBLES) + const roots = new Set(bubbles.map((b) => b.rootId)) + const edges = buildRelayThreadHeatEdges(feedNotes, roots) + logger.info('[RelayThreadHeatMap] merge finished', { + bubbles: bubbles.length, + merged: merged.length, + afterFeedFilter: feedNotes.length, + edges: edges.length + }) + return { bubbles, edges } + }, [relayUrls, followSet, showKinds, showKind1OPs, showKind1Replies, showKind1111]) + + useEffect(() => { + let cancelled = false + if (!pubkey || !cacheSettingKey) { + setRows([]) + setEdges([]) + setLoading(false) + setIsMerging(false) + setError(null) + return + } + + void (async () => { + setError(null) + let hadEnvelope = false + + const raw = await indexedDb.getSetting(cacheSettingKey) + if (cancelled) return + const cached = parseRelayThreadHeatMapCache(raw) + if (cached) { + hadEnvelope = true + setRows(cached.bubbles) + setEdges(cached.edges ?? []) + setLoading(false) + } else { + setLoading(true) + } + + setIsMerging(true) + try { + const { bubbles, edges: nextEdges } = await mergeHeatMapData() + if (cancelled) return + setRows(bubbles) + setEdges(nextEdges) + setError(null) + try { + await indexedDb.setSetting( + cacheSettingKey, + serializeRelayThreadHeatMapCache({ + v: 1, + builtAtMs: Date.now(), + bubbles, + edges: nextEdges + }) + ) + } catch (persistErr) { + logger.warn('[RelayThreadHeatMap] cache persist failed', persistErr) + } + } catch (e) { + if (cancelled) return + logger.warn('[RelayThreadHeatMap] fetch failed', e) + setError(t('heatMapFetchError')) + if (!hadEnvelope) { + setRows([]) + setEdges([]) + } + } finally { + if (!cancelled) { + setIsMerging(false) + setLoading(false) + } + } + })() + + return () => { + cancelled = true + } + }, [pubkey, cacheSettingKey, mergeHeatMapData, refreshKey, rescanTick, t]) + + const maxHeat = useMemo(() => rows.reduce((m, r) => Math.max(m, r.heat), 0) || 1, [rows]) + + const graphAreaRef = useRef(null) + const bubbleRefs = useRef>(new Map()) + const [lineSegs, setLineSegs] = useState< + Array<{ x1: number; y1: number; x2: number; y2: number }> + >([]) + + const bindBubbleRef = useCallback((rootId: string) => (el: HTMLButtonElement | null) => { + if (el) bubbleRefs.current.set(rootId, el) + else bubbleRefs.current.delete(rootId) + }, []) + + const recomputeConnectorLines = useCallback(() => { + const host = graphAreaRef.current + if (!host || rows.length === 0) { + setLineSegs([]) + return + } + const br = host.getBoundingClientRect() + const centerOf = (rootId: string) => { + const el = bubbleRefs.current.get(rootId) + if (!el) return null + const r = el.getBoundingClientRect() + return { x: r.left - br.left + r.width / 2, y: r.top - br.top + r.height / 2 } + } + const segs: Array<{ x1: number; y1: number; x2: number; y2: number }> = [] + for (const { a, b } of edges) { + const ca = centerOf(a) + const cb = centerOf(b) + if (ca && cb) segs.push({ x1: ca.x, y1: ca.y, x2: cb.x, y2: cb.y }) + } + setLineSegs(segs) + }, [rows, edges]) + + useLayoutEffect(() => { + recomputeConnectorLines() + }, [recomputeConnectorLines]) + + useEffect(() => { + const host = graphAreaRef.current + if (!host || typeof ResizeObserver === 'undefined') return undefined + const ro = new ResizeObserver(() => { + requestAnimationFrame(() => recomputeConnectorLines()) + }) + ro.observe(host) + for (const row of rows) { + const el = bubbleRefs.current.get(row.rootId) + if (el) ro.observe(el) + } + return () => ro.disconnect() + }, [rows, edges, recomputeConnectorLines]) + + if (!pubkey) { + return null + } + + return ( +
+
+ {relayUrls.length === 0 ? ( +

+ {t('heatMapLocalOnlyBanner')} +

+ ) : null} +

{t('heatMapDescription')}

+
+ + +
+
+ + {error ?

{error}

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

{t('heatMapLoading')}

+
+ ) : !loading && rows.length === 0 ? ( +
+ {t('heatMapEmpty')} +
+ ) : ( +
+
+ + {lineSegs.map((s, i) => ( + + ))} + +
+ {rows.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', { + posts: row.postCount, + people: row.uniqueAuthors, + follows: row.followAuthorsInThread + }) + const ariaLabel = [row.snippet, statsLine, t('heatMapOpenThread')].filter(Boolean).join('. ') + return ( + + + + + +
+

{row.snippet}

+

{statsLine}

+
+
+
+ ) + })} +
+
+
+ )} +
+ ) +} diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 11ccba63..49bd1f83 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -85,6 +85,7 @@ import { ChevronLeft, Copy, FileText, + Flame, Gift, Hash, Image as ImageIcon, @@ -102,6 +103,7 @@ import { kinds as nostrKinds, verifyEvent } from 'nostr-tools' import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import CreateSpellDialog from './CreateSpellDialog' +import RelayThreadHeatMap from './RelayThreadHeatMap' import { applyFauxSpellCapsToSubRequests, buildBookmarksSubRequests, @@ -281,8 +283,8 @@ function fauxSpellLabelKey(name: FauxSpellName): string { return 'Discussions' case 'following': return 'Following' - case 'favorites': - return 'Favorites' + case 'heatMap': + return 'Heat map' case 'followPacks': return 'Follow Packs' case 'media': @@ -302,7 +304,7 @@ const FAUX_SPELL_ICON: Record = { notifications: Bell, discussions: MessageSquare, following: Users, - favorites: Star, + heatMap: Flame, followPacks: Gift, media: ImageIcon, interests: Hash, @@ -356,6 +358,9 @@ const SpellsPage = forwardRef(function SpellsPage( /** Last processed {@link spellCatalogManualRefreshKey} so we only treat real bumps as “force sync”. */ const spellCatalogLastManualKeyRef = useRef(0) const spellFeedListRef = useRef(null) + const selectedFauxSpellRefreshRef = useRef(null) + selectedFauxSpellRefreshRef.current = selectedFauxSpell + const [heatMapRefreshKey, setHeatMapRefreshKey] = useState(0) const layoutRef = useRef(null) const [spellPickerOpen, setSpellPickerOpen] = useState(false) @@ -388,6 +393,10 @@ const SpellsPage = forwardRef(function SpellsPage( /** Set when picker calls `navigatePrimary(..., { spell })` so URL effect does not log/bump token again. */ const fauxSpellUrlSyncFromPickerRef = useRef(null) useEffect(() => { + if (spellProp === 'favorites') { + navigatePrimary('spells', { spell: 'heatMap' }) + return + } if (spellProp && isSpellsPageFauxSpellParam(spellProp)) { if (fauxSpellUrlSyncFromPickerRef.current === spellProp) { fauxSpellUrlSyncFromPickerRef.current = null @@ -406,10 +415,9 @@ const SpellsPage = forwardRef(function SpellsPage( // URL / props no longer name a faux spell (e.g. bottom bar “Spells” → `/spells`) — leave the feed. setSelectedFauxSpell(null) } - }, [spellProp, logSpellFeedPickerSelection]) + }, [spellProp, logSpellFeedPickerSelection, navigatePrimary]) const [followingSubRequests, setFollowingSubRequests] = useState([]) - const [favoritesSubRequests, setFavoritesSubRequests] = useState([]) const loadSpells = useCallback(async () => { const [events, ids] = await Promise.all([ @@ -426,6 +434,9 @@ const SpellsPage = forwardRef(function SpellsPage( setSpellCatalogManualRefreshKey((k) => k + 1) setFollowSetManualRefreshKey((k) => k + 1) } + if (selectedFauxSpellRefreshRef.current === 'heatMap') { + setHeatMapRefreshKey((k) => k + 1) + } spellFeedListRef.current?.refresh() }, [loadSpells, pubkey]) @@ -822,137 +833,6 @@ const SpellsPage = forwardRef(function SpellsPage( followListEvent?.id ]) - const favoritesShowKinds = useMemo(() => { - const out = [...kindFilterShowKinds] - if (!out.includes(nostrKinds.Repost)) out.push(nostrKinds.Repost) - if (!out.includes(ExtendedKind.GENERIC_REPOST)) out.push(ExtendedKind.GENERIC_REPOST) - if (!out.includes(ExtendedKind.WEB_BOOKMARK)) out.push(ExtendedKind.WEB_BOOKMARK) - return out.sort((a, b) => a - b) - }, [kindFilterShowKinds]) - - const favoritesShowKindsKey = useMemo(() => JSON.stringify(favoritesShowKinds), [favoritesShowKinds]) - - useEffect(() => { - if (selectedFauxSpell !== 'favorites' || !pubkey) { - setFavoritesSubRequests([]) - return - } - - let cancelled = false - void (async () => { - try { - const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( - favoriteRelays, - blockedRelays, - userReadRelaysWithHttp(relayList), - { - userWriteRelays: relayList?.write ?? [], - applySocialKindBlockedFilter: false - } - ) - const topics = interestListEvent?.tags.filter((tag) => tag[0] === 't' && tag[1]).map((tag) => tag[1]!) ?? [] - const interestReqs = buildInterestsSubRequests(feedUrls, topics, favoritesShowKinds).map((r) => ({ - ...r, - reasonLabel: t('Added from interests') - })) - const idReqs = buildBookmarksSubRequests(bookmarkListEvent, feedUrls).map((r) => ({ - ...r, - reasonLabel: t('Added from bookmarks list') - })) - const ownWebReqs = buildWebBookmarksSpellSubRequests(pubkey, feedUrls).map((r) => ({ - ...r, - reasonLabel: t('Added from your web bookmarks') - })) - - const augmentFollow = (raw: TFeedSubRequest[]) => - augmentSubRequestsWithFavoritesFastReadAndInbox( - raw, - favoriteRelays, - blockedRelays, - userReadRelaysWithHttp(relayList), - { userWriteRelays: relayList?.write ?? [] } - ).map((r) => ({ ...r, reasonLabel: t('Added from follows and contact lists') })) - - const quickFollowRaw = await client.generateSubRequestsForPubkeys([pubkey], pubkey) - const quickFollowAug = augmentFollow(quickFollowRaw) - const followsWebQuick: TFeedSubRequest[] = [ - { - urls: feedUrls, - filter: { - authors: [pubkey], - kinds: [ExtendedKind.WEB_BOOKMARK], - limit: FAUX_SPELL_EVENT_LIMIT - }, - reasonLabel: t('Added from follows web bookmarks') - } - ] - - if (!cancelled) { - setFavoritesSubRequests([ - ...interestReqs, - ...idReqs, - ...ownWebReqs, - ...followsWebQuick, - ...quickFollowAug - ]) - } - - const authorSet = new Set([pubkey, ...contacts]) - for (const ev of followSetListEvents) { - if (ev.pubkey !== pubkey) continue - for (const author of pubkeysFromFollowSetEvent(ev)) authorSet.add(author) - } - - const authorPubkeys = [...authorSet] - const followAndContactReqs = authorPubkeys.length - ? await client.generateSubRequestsForPubkeys(authorPubkeys, pubkey) - : [] - const followAndContactAugmented = augmentFollow(followAndContactReqs) - - const followsWebBookmarkReqs: TFeedSubRequest[] = authorPubkeys.length - ? [ - { - urls: feedUrls, - filter: { - authors: authorPubkeys, - kinds: [ExtendedKind.WEB_BOOKMARK], - limit: FAUX_SPELL_EVENT_LIMIT - }, - reasonLabel: t('Added from follows web bookmarks') - } - ] - : [] - - if (!cancelled) { - setFavoritesSubRequests([ - ...interestReqs, - ...idReqs, - ...ownWebReqs, - ...followsWebBookmarkReqs, - ...followAndContactAugmented - ]) - } - } catch { - if (!cancelled) setFavoritesSubRequests([]) - } - })() - - return () => { - cancelled = true - } - }, [ - selectedFauxSpell, - pubkey, - contactsSyncKey, - followSetListStableKey, - sortedFavoriteRelaysKey, - sortedBlockedRelaysKey, - relayMailboxStableKey, - interestListEvent?.id, - bookmarkListEvent?.id, - favoritesShowKindsKey - ]) - const interestTagsStableKey = interestListEvent ? JSON.stringify( [...interestListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) @@ -977,7 +857,12 @@ const SpellsPage = forwardRef(function SpellsPage( ].join('\0') const syncFauxSubRequests = useMemo(() => { - if (!selectedFauxSpell || isFollowFeedFauxSpellId(selectedFauxSpell) || selectedFauxSpell === 'favorites') return [] + if ( + !selectedFauxSpell || + isFollowFeedFauxSpellId(selectedFauxSpell) || + selectedFauxSpell === 'heatMap' + ) + return [] /** Widen relay pool: these faux spells do not target social kinds (1 / 11 / 1111); skipping strip keeps fast-read mirrors in the stack. */ const fauxSpellSkipSocialKindBlocked = selectedFauxSpell === 'calendar' || @@ -1035,14 +920,11 @@ const SpellsPage = forwardRef(function SpellsPage( }, [selectedFauxSpell, pubkey, notificationsFeedPubkey, fauxFeedRelaysDepsKey, relayMailboxStableKey]) const fauxSubRequests = useMemo(() => { - const base = - selectedFauxSpell === 'favorites' - ? favoritesSubRequests - : isFollowFeedFauxSpellId(selectedFauxSpell ?? '') - ? followingSubRequests - : syncFauxSubRequests + const base = isFollowFeedFauxSpellId(selectedFauxSpell ?? '') + ? followingSubRequests + : syncFauxSubRequests return applyFauxSpellCapsToSubRequests(base) - }, [selectedFauxSpell, favoritesSubRequests, followingSubRequests, syncFauxSubRequests]) + }, [selectedFauxSpell, followingSubRequests, syncFauxSubRequests]) const spellSubRequests = useMemo(() => { if (!selectedSpell) return [] @@ -1248,9 +1130,6 @@ const SpellsPage = forwardRef(function SpellsPage( if (selectedFauxSpell === 'interests') { return [...DEFAULT_FEED_SHOW_KINDS] } - if (selectedFauxSpell === 'favorites') { - return favoritesShowKinds - } if (selectedFauxSpell === 'bookmarks') { const out = [...DEFAULT_FEED_SHOW_KINDS] if (!out.includes(ExtendedKind.WEB_BOOKMARK)) out.push(ExtendedKind.WEB_BOOKMARK) @@ -1262,7 +1141,7 @@ const SpellsPage = forwardRef(function SpellsPage( .map((tag) => parseInt(tag[1], 10)) .filter((n) => !Number.isNaN(n)) return kinds.length ? kinds : [1] - }, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey, favoritesShowKindsKey]) + }, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey]) const spellMenuLabel = useCallback( (spell: Event) => @@ -1379,16 +1258,13 @@ const SpellsPage = forwardRef(function SpellsPage( if (selectedFauxSpell === 'interests') return t('No subscribed interests yet.') if (selectedFauxSpell === 'bookmarks') return t('No NIP-51 bookmarks or web bookmarks yet.') - if (selectedFauxSpell === 'favorites') return t('No favorites yet.') if (selectedFauxSpell === 'following') return t('No follows or relays to load yet.') if (isFollowSetSpellId(selectedFauxSpell)) return t('Follow set feed empty') return t('Nothing to load for this feed.') }, [selectedFauxSpell, fauxSubRequests.length, t]) const spellFauxMergeTimeline = useMemo( - () => - selectedFauxSpell === 'favorites' || - (!!selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)), + () => !!selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell), [selectedFauxSpell] ) @@ -1423,7 +1299,7 @@ const SpellsPage = forwardRef(function SpellsPage( if ( (name === 'notifications' || name === 'following' || - name === 'favorites' || + name === 'heatMap' || name === 'bookmarks' || name === 'interests') && !pubkey @@ -1830,9 +1706,13 @@ const SpellsPage = forwardRef(function SpellsPage(
{t('Please login to view bookmarks')}
- ) : selectedFauxSpell === 'favorites' && !pubkey ? ( + ) : selectedFauxSpell === 'heatMap' && !pubkey ? (
- {t('Please login to view favorites')} + {t('Please login to view thread heat map')} +
+ ) : selectedFauxSpell === 'heatMap' && pubkey ? ( +
+
) : selectedFauxSpell && fauxSubRequests.length === 0 ? (
{fauxFeedEmptyMessage}
@@ -1865,9 +1745,7 @@ const SpellsPage = forwardRef(function SpellsPage( : undefined } clientSideKindFilter={ - selectedFauxSpell === 'notifications' || - selectedFauxSpell === 'bookmarks' || - selectedFauxSpell === 'favorites' + selectedFauxSpell === 'notifications' || selectedFauxSpell === 'bookmarks' } useFilterAsIs={fauxNoteListUseFilterAsIs} oneShotFetch={false} diff --git a/src/pages/secondary/ProfileInteractionDiagramPage/index.tsx b/src/pages/secondary/ProfileInteractionDiagramPage/index.tsx index fe9514cb..58c9376a 100644 --- a/src/pages/secondary/ProfileInteractionDiagramPage/index.tsx +++ b/src/pages/secondary/ProfileInteractionDiagramPage/index.tsx @@ -250,31 +250,28 @@ const ProfileInteractionDiagramPage = forwardRef< return (
- {showFollowControls && !selfCard ? ( -
- { - if (v === 'indeterminate') return - handleFollowToggle(p.pubkey, Boolean(v)) - }} - /> -
- ) : null} - +
+ {showFollowControls && !selfCard ? ( + + ) : null} ) })} diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index 815dc6cc..59abf4c7 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -679,6 +679,25 @@ export class EventService { return out } + /** + * Session LRU: events of the given `kinds` from this tab session, optionally `created_at >= since`, newest first. + * Used with IndexedDB + relays for aggregates (e.g. thread heat map) without depending on relay order alone. + */ + listSessionEventsByKinds(kinds: readonly number[], opts?: { since?: number; limit?: number }): NEvent[] { + const kindSet = new Set(kinds) + const since = opts?.since + const limit = Math.min(Math.max(opts?.limit ?? 2000, 1), 8000) + const buf: NEvent[] = [] + for (const [, event] of this.sessionEventCache.entries()) { + if (shouldDropEventOnIngest(event)) continue + if (!kindSet.has(event.kind)) continue + if (since !== undefined && event.created_at < since) continue + buf.push(event) + } + buf.sort((a, b) => b.created_at - a.created_at) + return buf.slice(0, limit) + } + /** * Session cache: NIP-32 citation kinds (30–33) matched on title/summary/content and related tags * (not NIP-50 relay semantics). diff --git a/src/services/client-query.service.ts b/src/services/client-query.service.ts index 4adddd08..9ad1ee77 100644 --- a/src/services/client-query.service.ts +++ b/src/services/client-query.service.ts @@ -437,7 +437,12 @@ export class QueryService { const resolveWithEvents = () => { if (resolved || queryFinalizing) return queryFinalizing = true - void httpInflight.finally(() => { + /** + * Never block resolution on {@link httpInflight}: a hung HTTP index `fetch` can keep + * `Promise.allSettled` pending forever, so `globalTimeout` would fire but this callback + * would never run and the query promise would never resolve. + */ + const finalizeOnce = () => { if (resolved) return resolved = true if (resolveTimeout) clearTimeout(resolveTimeout) @@ -451,6 +456,14 @@ export class QueryService { const resolvedList = replaceableRace && events.length > 0 ? resolveReplaceableRaceEvents() : events resolve(resolvedList) + } + + const httpCapMs = Math.min(globalTimeout + 5000, 60_000) + void Promise.race([ + httpInflight.catch(() => undefined), + new Promise((r) => setTimeout(r, httpCapMs)) + ]).finally(() => { + finalizeOnce() }) } diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index c27b3026..ebe1e325 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -2986,6 +2986,57 @@ class IndexedDbService { }) } + /** + * Scan {@link StoreNames.EVENT_ARCHIVE} for events whose kind is in `kinds`. + * Cursor order follows the store key (not time), so `since` is applied **after** the scan: collect kind + * matches up to `maxRowsScanned`, then keep `created_at >= since` (when set), sort newest-first, cap. + */ + async scanEventArchiveByKinds(options: { + kinds: readonly number[] + since?: number + maxRowsScanned: number + maxMatches: number + }): Promise { + const kindSet = new Set(options.kinds) + const since = options.since + const maxRows = Math.min(Math.max(options.maxRowsScanned, 1), 50_000) + const maxMatches = Math.min(Math.max(options.maxMatches, 1), 3000) + await this.initPromise + if (!this.db?.objectStoreNames.contains(StoreNames.EVENT_ARCHIVE)) return [] + + return new Promise((resolve, reject) => { + const buf: Event[] = [] + let scanned = 0 + const tx = this.db!.transaction(StoreNames.EVENT_ARCHIVE, 'readonly') + const store = tx.objectStore(StoreNames.EVENT_ARCHIVE) + const req = store.openCursor() + req.onsuccess = () => { + const cursor = req.result as IDBCursorWithValue | null + if (!cursor || scanned >= maxRows) { + tx.commit() + let picked = buf + if (since !== undefined) { + picked = buf.filter((e) => e.created_at >= since) + } + picked.sort((a, b) => b.created_at - a.created_at) + resolve(picked.slice(0, maxMatches)) + return + } + scanned += 1 + const row = cursor.value as TArchivedEventRow + const ev = row?.value + if (ev && isLikelyCachedNostrEvent(ev) && kindSet.has(ev.kind)) { + buf.push(ev) + } + cursor.continue() + } + req.onerror = (e) => { + tx.commit() + reject(idbEventToError(e)) + } + }) + } + async deleteArchivedEvent(eventId: string): Promise { const id = eventId.toLowerCase() await this.initPromise