Browse Source

heat map

imwald
Silberengel 1 month ago
parent
commit
93e6fe299a
  1. 19
      src/components/NoteList/index.tsx
  2. 12
      src/components/Sidebar/FavoritesButton.tsx
  3. 2
      src/constants.ts
  4. 14
      src/i18n/locales/cs.ts
  5. 14
      src/i18n/locales/de.ts
  6. 14
      src/i18n/locales/en.ts
  7. 14
      src/i18n/locales/es.ts
  8. 14
      src/i18n/locales/fr.ts
  9. 14
      src/i18n/locales/nl.ts
  10. 14
      src/i18n/locales/pl.ts
  11. 14
      src/i18n/locales/ru.ts
  12. 14
      src/i18n/locales/tr.ts
  13. 14
      src/i18n/locales/zh.ts
  14. 26
      src/lib/feed-kind-filter.ts
  15. 66
      src/lib/relay-thread-heat-cache.ts
  16. 240
      src/lib/relay-thread-heat.ts
  17. 16
      src/pages/primary/NoteListPage/index.tsx
  18. 464
      src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx
  19. 194
      src/pages/primary/SpellsPage/index.tsx
  20. 53
      src/pages/secondary/ProfileInteractionDiagramPage/index.tsx
  21. 19
      src/services/client-events.service.ts
  22. 15
      src/services/client-query.service.ts
  23. 51
      src/services/indexed-db.service.ts

19
src/components/NoteList/index.tsx

@ -18,6 +18,7 @@ import {
} from '@/lib/spell-feed-request-identity' } from '@/lib/spell-feed-request-identity'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url' import { isLocalNetworkUrl, normalizeAnyRelayUrl, normalizeUrl } from '@/lib/url'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter'
import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata' import { shouldIncludeZapReceiptAtReplyThreshold } from '@/lib/event-metadata'
import { isTouchDevice } from '@/lib/utils' import { isTouchDevice } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' 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 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 const LOAD_MORE_SCROLL_PREFETCH_MIN_PX = 960
/** Min ms between scroll-driven load-more attempts (loadMore also throttles internally). */ /** Min ms between scroll-driven load-more attempts (loadMore also throttles internally). */
const LOAD_MORE_SCROLL_PREFETCH_COOLDOWN_MS = 180 const LOAD_MORE_SCROLL_PREFETCH_COOLDOWN_MS = 180

12
src/components/Sidebar/FavoritesButton.tsx

@ -1,10 +1,12 @@
import { usePrimaryPage } from '@/contexts/primary-page-context' import { usePrimaryPage } from '@/contexts/primary-page-context'
import { usePrimaryNoteView } from '@/contexts/primary-note-view-context' import { usePrimaryNoteView } from '@/contexts/primary-note-view-context'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Star } from 'lucide-react' import { Flame } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import SidebarItem from './SidebarItem' import SidebarItem from './SidebarItem'
export default function FavoritesButton() { export default function FavoritesButton() {
const { t } = useTranslation()
const { navigate, current, currentPageProps, display } = usePrimaryPage() const { navigate, current, currentPageProps, display } = usePrimaryPage()
const { primaryViewType } = usePrimaryNoteView() const { primaryViewType } = usePrimaryNoteView()
const { pubkey } = useNostr() const { pubkey } = useNostr()
@ -14,16 +16,16 @@ export default function FavoritesButton() {
return ( return (
<SidebarItem <SidebarItem
title="Favorites" title={t('Heat map')}
onClick={() => navigate('spells', { spell: 'favorites' })} onClick={() => navigate('spells', { spell: 'heatMap' })}
active={ active={
display && display &&
current === 'spells' && current === 'spells' &&
primaryViewType === null && primaryViewType === null &&
spell === 'favorites' spell === 'heatMap'
} }
> >
<Star strokeWidth={3} /> <Flame strokeWidth={3} />
</SidebarItem> </SidebarItem>
) )
} }

2
src/constants.ts

@ -861,7 +861,7 @@ export const FAUX_SPELL_ORDER = [
'notifications', 'notifications',
'discussions', 'discussions',
'following', 'following',
'favorites', 'heatMap',
'followPacks', 'followPacks',
'media', 'media',
'interests', 'interests',

14
src/i18n/locales/cs.ts

@ -761,6 +761,20 @@ export default {
"articles and publications": "articles and publications", "articles and publications": "articles and publications",
Interests: "Interests", Interests: "Interests",
Favorites: "Favorites", 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", Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "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.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

14
src/i18n/locales/de.ts

@ -781,6 +781,20 @@ export default {
"articles and publications": "Artikel und Veröffentlichungen", "articles and publications": "Artikel und Veröffentlichungen",
Interests: "Interessen", Interests: "Interessen",
Favorites: "Favorites", 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", Calendar: "Kalender",
"No subscribed interests yet.": "Noch keine Interessen abonniert. Themen in den Einstellungen hinzufügen, um sie hier zu sehen.", "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.", "No bookmarked notes with id tags yet.": "Noch keine Lesezeichen mit Ereignis-IDs. Nur klassische (e-Tag-) Lesezeichen erscheinen in diesem Feed.",

14
src/i18n/locales/en.ts

@ -785,6 +785,20 @@ export default {
"articles and publications": "articles and publications", "articles and publications": "articles and publications",
Interests: "Interests", Interests: "Interests",
Favorites: "Favorites", 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", Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "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.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

14
src/i18n/locales/es.ts

@ -761,6 +761,20 @@ export default {
"articles and publications": "articles and publications", "articles and publications": "articles and publications",
Interests: "Interests", Interests: "Interests",
Favorites: "Favorites", 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", Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "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.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

14
src/i18n/locales/fr.ts

@ -761,6 +761,20 @@ export default {
"articles and publications": "articles and publications", "articles and publications": "articles and publications",
Interests: "Interests", Interests: "Interests",
Favorites: "Favorites", 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", Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "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.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

14
src/i18n/locales/nl.ts

@ -761,6 +761,20 @@ export default {
"articles and publications": "articles and publications", "articles and publications": "articles and publications",
Interests: "Interests", Interests: "Interests",
Favorites: "Favorites", 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", Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "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.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

14
src/i18n/locales/pl.ts

@ -761,6 +761,20 @@ export default {
"articles and publications": "articles and publications", "articles and publications": "articles and publications",
Interests: "Interests", Interests: "Interests",
Favorites: "Favorites", 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", Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "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.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

14
src/i18n/locales/ru.ts

@ -761,6 +761,20 @@ export default {
"articles and publications": "articles and publications", "articles and publications": "articles and publications",
Interests: "Interests", Interests: "Interests",
Favorites: "Favorites", 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", Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "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.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

14
src/i18n/locales/tr.ts

@ -761,6 +761,20 @@ export default {
"articles and publications": "articles and publications", "articles and publications": "articles and publications",
Interests: "Interests", Interests: "Interests",
Favorites: "Favorites", 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", Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "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.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

14
src/i18n/locales/zh.ts

@ -761,6 +761,20 @@ export default {
"articles and publications": "articles and publications", "articles and publications": "articles and publications",
Interests: "Interests", Interests: "Interests",
Favorites: "Favorites", 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", Calendar: "Calendar",
"No subscribed interests yet.": "No subscribed interests yet. Add topics in settings to see them here.", "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.", "No bookmarked notes with id tags yet.": "No bookmarked notes with id tags yet. Only classic (e-tag) bookmarks load in this feed.",

26
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
}

66
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<TRelayThreadHeatMapCacheEnvelope>
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)
}

240
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<string>,
since: number
): TRelayThreadHeatBubble[] {
const follow = followPubkeys
const now = Math.floor(Date.now() / 1000)
const span = Math.max(3600, now - since)
const byRoot = new Map<string, { authors: Set<string>; posts: Event[] }>()
for (const ev of events) {
const rid = threadRootIdForHeat(ev)
if (!rid) continue
const bucket = byRoot.get(rid) ?? { authors: new Set<string>(), 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 threads 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<string> {
const rLower = r.toLowerCase()
const out = new Set<string>()
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 threads 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<string>
): TRelayThreadHeatEdge[] {
const idToRoot = new Map<string, string>()
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<string, string>()
for (const r of rootIdsInView) {
for (const h of collectOpIdCandidatesForRoot(r, feedNotes)) {
opHexToRoot.set(h, r)
}
}
const seen = new Set<string>()
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<string, Set<string>>()
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
}

16
src/pages/primary/NoteListPage/index.tsx

@ -8,7 +8,7 @@ import { useScreenSize } from '@/providers/ScreenSizeProvider'
import type { TNoteListRef } from '@/components/NoteList' import type { TNoteListRef } from '@/components/NoteList'
import { NoteCardLoadingSkeleton } from '@/components/NoteCard' import { NoteCardLoadingSkeleton } from '@/components/NoteCard'
import { TPageRef } from '@/types' import { TPageRef } from '@/types'
import { Calendar, Compass, Star, UsersRound } from 'lucide-react' import { Calendar, Compass, Flame, UsersRound } from 'lucide-react'
import React, { import React, {
forwardRef, forwardRef,
useCallback, useCallback,
@ -173,8 +173,8 @@ function NoteListPageTitlebar({
const spell = (currentPageProps as { spell?: string } | undefined)?.spell const spell = (currentPageProps as { spell?: string } | undefined)?.spell
const exploreActive = display && current === 'explore' && primaryViewType === null const exploreActive = display && current === 'explore' && primaryViewType === null
const followsLatestActive = display && current === 'follows-latest' && primaryViewType === null const followsLatestActive = display && current === 'follows-latest' && primaryViewType === null
const favoritesActive = const heatMapActive =
display && current === 'spells' && spell === 'favorites' && primaryViewType === null display && current === 'spells' && spell === 'heatMap' && primaryViewType === null
const calendarActive = display && current === 'calendar' && primaryViewType === null const calendarActive = display && current === 'calendar' && primaryViewType === null
if (!isSmallScreen) { if (!isSmallScreen) {
@ -230,18 +230,18 @@ function NoteListPageTitlebar({
<Button <Button
variant="ghost" variant="ghost"
size="titlebar-icon" size="titlebar-icon"
title={t('Favorites')} title={t('Heat map')}
aria-label={t('Favorites')} aria-label={t('Heat map')}
className={`shrink-0 ${favoritesActive ? 'bg-accent/50' : ''}`} className={`shrink-0 ${heatMapActive ? 'bg-accent/50' : ''}`}
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (primaryViewType !== null) { if (primaryViewType !== null) {
setPrimaryNoteView(null) setPrimaryNoteView(null)
} }
navigate('spells', { spell: 'favorites' }) navigate('spells', { spell: 'heatMap' })
}} }}
> >
<Star /> <Flame />
</Button> </Button>
</> </>
) : null} ) : null}

464
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<T>(promise: Promise<T>, ms: number, fallback: T, label: string): Promise<T> {
let settled = false
return new Promise((resolve) => {
const to = setTimeout(() => {
if (settled) return
settled = true
logger.warn('[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<TRelayThreadHeatBubble[]>([])
const [edges, setEdges] = useState<TRelayThreadHeatEdge[]>([])
/** 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<string | null>(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<string>(), '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<string, Event>()
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<HTMLDivElement>(null)
const bubbleRefs = useRef<Map<string, HTMLButtonElement>>(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 (
<div className="flex min-h-0 flex-1 flex-col gap-4">
<div className="space-y-1 text-sm text-muted-foreground">
{relayUrls.length === 0 ? (
<p className="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-950 dark:text-amber-100">
{t('heatMapLocalOnlyBanner')}
</p>
) : null}
<p>{t('heatMapDescription')}</p>
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
disabled={isMerging && rows.length === 0}
onClick={() => setRescanTick((n) => n + 1)}
>
{isMerging ? (
<Loader2 className="size-4 animate-spin" aria-hidden />
) : (
<RefreshCw className="size-4" aria-hidden />
)}
{t('heatMapRescan')}
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="gap-1.5"
onClick={() => navigateToProfileInteractions(toProfileInteractionMap(pubkey))}
>
<LayoutGrid className="size-4 shrink-0" aria-hidden />
{t('interactionMapMenu')}
</Button>
</div>
</div>
{error ? <p className="text-sm text-destructive">{error}</p> : null}
{rows.length === 0 && (loading || isMerging) ? (
<div className="flex flex-1 flex-col items-center justify-center gap-2 py-16 text-muted-foreground">
<Loader2 className="size-8 animate-spin" aria-hidden />
<p className="text-sm">{t('heatMapLoading')}</p>
</div>
) : !loading && rows.length === 0 ? (
<div className="rounded-xl border border-dashed border-border/80 px-4 py-12 text-center text-sm text-muted-foreground">
{t('heatMapEmpty')}
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto overflow-x-hidden pb-4">
<div ref={graphAreaRef} className="relative w-full min-h-[min(40vh,420px)] pt-2">
<svg
className="pointer-events-none absolute inset-0 z-0 h-full w-full overflow-visible text-primary"
aria-hidden
>
{lineSegs.map((s, i) => (
<line
key={`${s.x1}-${s.y1}-${s.x2}-${s.y2}-${i}`}
x1={s.x1}
y1={s.y1}
x2={s.x2}
y2={s.y2}
stroke="currentColor"
strokeOpacity={0.38}
strokeWidth={1.75}
vectorEffect="non-scaling-stroke"
/>
))}
</svg>
<div className="relative z-10 flex flex-wrap content-start items-start justify-center gap-4">
{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 (
<HoverCard key={row.rootId} openDelay={180} closeDelay={80}>
<HoverCardTrigger asChild>
<button
ref={bindBubbleRef(row.rootId)}
type="button"
className={cn(
'group relative shrink-0 rounded-full border shadow-sm transition-transform',
'flex items-center justify-center',
'hover:z-10 hover:scale-[1.04] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',
'border-border/70 bg-card/90 backdrop-blur-sm'
)}
style={{
width: size,
height: size,
boxShadow: `0 0 0 1px hsl(var(--border) / 0.35), inset 0 0 40px hsl(var(--primary) / ${0.06 + intensity * 0.28})`
}}
onClick={() => navigateToNote(toNote(row.rootId), row.rootEvent)}
aria-label={ariaLabel}
>
<span
className="rounded-full bg-primary/25 ring-2 ring-primary/35 transition-[width,height,opacity] group-hover:bg-primary/35"
style={{
width: `${22 + intensity * 48}%`,
height: `${22 + intensity * 48}%`,
opacity: 0.55 + intensity * 0.45
}}
aria-hidden
/>
</button>
</HoverCardTrigger>
<HoverCardContent
side="top"
align="center"
className="w-80 max-w-[min(92vw,22rem)] border-border/80 p-0 shadow-lg"
collisionPadding={12}
>
<div className="max-h-56 overflow-y-auto px-3 py-2.5 text-left">
<p className="whitespace-pre-wrap text-sm leading-snug text-foreground">{row.snippet}</p>
<p className="mt-2 border-t border-border/60 pt-2 text-xs text-muted-foreground">{statsLine}</p>
</div>
</HoverCardContent>
</HoverCard>
)
})}
</div>
</div>
</div>
)}
</div>
)
}

194
src/pages/primary/SpellsPage/index.tsx

@ -85,6 +85,7 @@ import {
ChevronLeft, ChevronLeft,
Copy, Copy,
FileText, FileText,
Flame,
Gift, Gift,
Hash, Hash,
Image as ImageIcon, 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 { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import CreateSpellDialog from './CreateSpellDialog' import CreateSpellDialog from './CreateSpellDialog'
import RelayThreadHeatMap from './RelayThreadHeatMap'
import { import {
applyFauxSpellCapsToSubRequests, applyFauxSpellCapsToSubRequests,
buildBookmarksSubRequests, buildBookmarksSubRequests,
@ -281,8 +283,8 @@ function fauxSpellLabelKey(name: FauxSpellName): string {
return 'Discussions' return 'Discussions'
case 'following': case 'following':
return 'Following' return 'Following'
case 'favorites': case 'heatMap':
return 'Favorites' return 'Heat map'
case 'followPacks': case 'followPacks':
return 'Follow Packs' return 'Follow Packs'
case 'media': case 'media':
@ -302,7 +304,7 @@ const FAUX_SPELL_ICON: Record<FauxSpellName, typeof Bell> = {
notifications: Bell, notifications: Bell,
discussions: MessageSquare, discussions: MessageSquare,
following: Users, following: Users,
favorites: Star, heatMap: Flame,
followPacks: Gift, followPacks: Gift,
media: ImageIcon, media: ImageIcon,
interests: Hash, interests: Hash,
@ -356,6 +358,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
/** Last processed {@link spellCatalogManualRefreshKey} so we only treat real bumps as “force sync”. */ /** Last processed {@link spellCatalogManualRefreshKey} so we only treat real bumps as “force sync”. */
const spellCatalogLastManualKeyRef = useRef(0) const spellCatalogLastManualKeyRef = useRef(0)
const spellFeedListRef = useRef<TNoteListRef>(null) const spellFeedListRef = useRef<TNoteListRef>(null)
const selectedFauxSpellRefreshRef = useRef<string | null>(null)
selectedFauxSpellRefreshRef.current = selectedFauxSpell
const [heatMapRefreshKey, setHeatMapRefreshKey] = useState(0)
const layoutRef = useRef<TPrimaryPageLayoutRef>(null) const layoutRef = useRef<TPrimaryPageLayoutRef>(null)
const [spellPickerOpen, setSpellPickerOpen] = useState(false) const [spellPickerOpen, setSpellPickerOpen] = useState(false)
@ -388,6 +393,10 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
/** Set when picker calls `navigatePrimary(..., { spell })` so URL effect does not log/bump token again. */ /** Set when picker calls `navigatePrimary(..., { spell })` so URL effect does not log/bump token again. */
const fauxSpellUrlSyncFromPickerRef = useRef<string | null>(null) const fauxSpellUrlSyncFromPickerRef = useRef<string | null>(null)
useEffect(() => { useEffect(() => {
if (spellProp === 'favorites') {
navigatePrimary('spells', { spell: 'heatMap' })
return
}
if (spellProp && isSpellsPageFauxSpellParam(spellProp)) { if (spellProp && isSpellsPageFauxSpellParam(spellProp)) {
if (fauxSpellUrlSyncFromPickerRef.current === spellProp) { if (fauxSpellUrlSyncFromPickerRef.current === spellProp) {
fauxSpellUrlSyncFromPickerRef.current = null fauxSpellUrlSyncFromPickerRef.current = null
@ -406,10 +415,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
// URL / props no longer name a faux spell (e.g. bottom bar “Spells” → `/spells`) — leave the feed. // URL / props no longer name a faux spell (e.g. bottom bar “Spells” → `/spells`) — leave the feed.
setSelectedFauxSpell(null) setSelectedFauxSpell(null)
} }
}, [spellProp, logSpellFeedPickerSelection]) }, [spellProp, logSpellFeedPickerSelection, navigatePrimary])
const [followingSubRequests, setFollowingSubRequests] = useState<TFeedSubRequest[]>([]) const [followingSubRequests, setFollowingSubRequests] = useState<TFeedSubRequest[]>([])
const [favoritesSubRequests, setFavoritesSubRequests] = useState<TFeedSubRequest[]>([])
const loadSpells = useCallback(async () => { const loadSpells = useCallback(async () => {
const [events, ids] = await Promise.all([ const [events, ids] = await Promise.all([
@ -426,6 +434,9 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
setSpellCatalogManualRefreshKey((k) => k + 1) setSpellCatalogManualRefreshKey((k) => k + 1)
setFollowSetManualRefreshKey((k) => k + 1) setFollowSetManualRefreshKey((k) => k + 1)
} }
if (selectedFauxSpellRefreshRef.current === 'heatMap') {
setHeatMapRefreshKey((k) => k + 1)
}
spellFeedListRef.current?.refresh() spellFeedListRef.current?.refresh()
}, [loadSpells, pubkey]) }, [loadSpells, pubkey])
@ -822,137 +833,6 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
followListEvent?.id 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<string>([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 const interestTagsStableKey = interestListEvent
? JSON.stringify( ? JSON.stringify(
[...interestListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) [...interestListEvent.tags].sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b)))
@ -977,7 +857,12 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
].join('\0') ].join('\0')
const syncFauxSubRequests = useMemo<TFeedSubRequest[]>(() => { const syncFauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
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. */ /** 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 = const fauxSpellSkipSocialKindBlocked =
selectedFauxSpell === 'calendar' || selectedFauxSpell === 'calendar' ||
@ -1035,14 +920,11 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
}, [selectedFauxSpell, pubkey, notificationsFeedPubkey, fauxFeedRelaysDepsKey, relayMailboxStableKey]) }, [selectedFauxSpell, pubkey, notificationsFeedPubkey, fauxFeedRelaysDepsKey, relayMailboxStableKey])
const fauxSubRequests = useMemo<TFeedSubRequest[]>(() => { const fauxSubRequests = useMemo<TFeedSubRequest[]>(() => {
const base = const base = isFollowFeedFauxSpellId(selectedFauxSpell ?? '')
selectedFauxSpell === 'favorites' ? followingSubRequests
? favoritesSubRequests : syncFauxSubRequests
: isFollowFeedFauxSpellId(selectedFauxSpell ?? '')
? followingSubRequests
: syncFauxSubRequests
return applyFauxSpellCapsToSubRequests(base) return applyFauxSpellCapsToSubRequests(base)
}, [selectedFauxSpell, favoritesSubRequests, followingSubRequests, syncFauxSubRequests]) }, [selectedFauxSpell, followingSubRequests, syncFauxSubRequests])
const spellSubRequests = useMemo<TFeedSubRequest[]>(() => { const spellSubRequests = useMemo<TFeedSubRequest[]>(() => {
if (!selectedSpell) return [] if (!selectedSpell) return []
@ -1248,9 +1130,6 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (selectedFauxSpell === 'interests') { if (selectedFauxSpell === 'interests') {
return [...DEFAULT_FEED_SHOW_KINDS] return [...DEFAULT_FEED_SHOW_KINDS]
} }
if (selectedFauxSpell === 'favorites') {
return favoritesShowKinds
}
if (selectedFauxSpell === 'bookmarks') { if (selectedFauxSpell === 'bookmarks') {
const out = [...DEFAULT_FEED_SHOW_KINDS] const out = [...DEFAULT_FEED_SHOW_KINDS]
if (!out.includes(ExtendedKind.WEB_BOOKMARK)) out.push(ExtendedKind.WEB_BOOKMARK) if (!out.includes(ExtendedKind.WEB_BOOKMARK)) out.push(ExtendedKind.WEB_BOOKMARK)
@ -1262,7 +1141,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
.map((tag) => parseInt(tag[1], 10)) .map((tag) => parseInt(tag[1], 10))
.filter((n) => !Number.isNaN(n)) .filter((n) => !Number.isNaN(n))
return kinds.length ? kinds : [1] return kinds.length ? kinds : [1]
}, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey, favoritesShowKindsKey]) }, [selectedFauxSpell, selectedSpell?.id, showKindsTagKey, followingShowKindsKey])
const spellMenuLabel = useCallback( const spellMenuLabel = useCallback(
(spell: Event) => (spell: Event) =>
@ -1379,16 +1258,13 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if (selectedFauxSpell === 'interests') return t('No subscribed interests yet.') if (selectedFauxSpell === 'interests') return t('No subscribed interests yet.')
if (selectedFauxSpell === 'bookmarks') if (selectedFauxSpell === 'bookmarks')
return t('No NIP-51 bookmarks or web bookmarks yet.') 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 (selectedFauxSpell === 'following') return t('No follows or relays to load yet.')
if (isFollowSetSpellId(selectedFauxSpell)) return t('Follow set feed empty') if (isFollowSetSpellId(selectedFauxSpell)) return t('Follow set feed empty')
return t('Nothing to load for this feed.') return t('Nothing to load for this feed.')
}, [selectedFauxSpell, fauxSubRequests.length, t]) }, [selectedFauxSpell, fauxSubRequests.length, t])
const spellFauxMergeTimeline = useMemo( const spellFauxMergeTimeline = useMemo(
() => () => !!selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell),
selectedFauxSpell === 'favorites' ||
(!!selectedFauxSpell && isFollowFeedFauxSpellId(selectedFauxSpell)),
[selectedFauxSpell] [selectedFauxSpell]
) )
@ -1423,7 +1299,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
if ( if (
(name === 'notifications' || (name === 'notifications' ||
name === 'following' || name === 'following' ||
name === 'favorites' || name === 'heatMap' ||
name === 'bookmarks' || name === 'bookmarks' ||
name === 'interests') && name === 'interests') &&
!pubkey !pubkey
@ -1830,9 +1706,13 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
<div className="py-8 text-center text-muted-foreground"> <div className="py-8 text-center text-muted-foreground">
{t('Please login to view bookmarks')} {t('Please login to view bookmarks')}
</div> </div>
) : selectedFauxSpell === 'favorites' && !pubkey ? ( ) : selectedFauxSpell === 'heatMap' && !pubkey ? (
<div className="py-8 text-center text-muted-foreground"> <div className="py-8 text-center text-muted-foreground">
{t('Please login to view favorites')} {t('Please login to view thread heat map')}
</div>
) : selectedFauxSpell === 'heatMap' && pubkey ? (
<div className="min-h-0 min-w-0 flex-1">
<RelayThreadHeatMap followPubkeys={contacts} refreshKey={heatMapRefreshKey} />
</div> </div>
) : selectedFauxSpell && fauxSubRequests.length === 0 ? ( ) : selectedFauxSpell && fauxSubRequests.length === 0 ? (
<div className="py-8 text-center text-muted-foreground">{fauxFeedEmptyMessage}</div> <div className="py-8 text-center text-muted-foreground">{fauxFeedEmptyMessage}</div>
@ -1865,9 +1745,7 @@ const SpellsPage = forwardRef<TPageRef>(function SpellsPage(
: undefined : undefined
} }
clientSideKindFilter={ clientSideKindFilter={
selectedFauxSpell === 'notifications' || selectedFauxSpell === 'notifications' || selectedFauxSpell === 'bookmarks'
selectedFauxSpell === 'bookmarks' ||
selectedFauxSpell === 'favorites'
} }
useFilterAsIs={fauxNoteListUseFilterAsIs} useFilterAsIs={fauxNoteListUseFilterAsIs}
oneShotFetch={false} oneShotFetch={false}

53
src/pages/secondary/ProfileInteractionDiagramPage/index.tsx

@ -250,31 +250,28 @@ const ProfileInteractionDiagramPage = forwardRef<
return ( return (
<div <div
key={p.pubkey} key={p.pubkey}
className="relative rounded-lg border border-border min-w-0 transition hover:opacity-95" className="relative isolate rounded-lg border border-border min-w-0 transition hover:opacity-95"
style={{ style={{
backgroundColor: `hsl(var(--primary) / ${bgAlpha})`, backgroundColor: `hsl(var(--primary) / ${bgAlpha})`,
borderColor: `hsl(var(--primary) / ${borderAlpha})` borderColor: `hsl(var(--primary) / ${borderAlpha})`
}} }}
> >
{showFollowControls && !selfCard ? ( {/*
<div className="absolute top-1.5 right-1.5 z-10 flex items-center gap-1 rounded bg-background/80 px-1 py-0.5 border border-border/60 shadow-sm"> Avoid a native <button> filling the card: it can steal hit-testing over the follow
<Checkbox checkbox (Radix also uses a button), which shows the global disabled cursor and blocks toggles.
id={`interaction-follow-${p.pubkey}`} */}
checked={following} <div
disabled={followBusyPubkey === p.pubkey} role="button"
aria-label={t('interactionMapFollowingCheckbox')} tabIndex={0}
onCheckedChange={(v) => { className={`w-full min-w-0 flex flex-col items-center gap-1 text-left rounded-lg cursor-pointer focus:outline-none focus-visible:ring-2 focus-visible:ring-ring p-2 ${showFollowControls && !selfCard ? 'pt-7' : ''}`}
if (v === 'indeterminate') return
handleFollowToggle(p.pubkey, Boolean(v))
}}
/>
</div>
) : null}
<button
type="button"
className={`w-full min-w-0 flex flex-col items-center gap-1 text-left rounded-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-ring p-2 ${showFollowControls && !selfCard ? 'pt-7' : ''}`}
title={cellTitle} title={cellTitle}
onClick={() => push(toProfile(p.pubkey))} onClick={() => push(toProfile(p.pubkey))}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
push(toProfile(p.pubkey))
}
}}
> >
<UserAvatar userId={p.pubkey} className="h-10 w-10 shrink-0" /> <UserAvatar userId={p.pubkey} className="h-10 w-10 shrink-0" />
<div className="w-full min-w-0 text-center"> <div className="w-full min-w-0 text-center">
@ -289,7 +286,25 @@ const ProfileInteractionDiagramPage = forwardRef<
<div className="text-[10px] text-muted-foreground truncate w-full text-center"> <div className="text-[10px] text-muted-foreground truncate w-full text-center">
{p.lastReferencedAt > 0 ? dayjs.unix(p.lastReferencedAt).fromNow() : t('interactionMapRecencyUnknown')} {p.lastReferencedAt > 0 ? dayjs.unix(p.lastReferencedAt).fromNow() : t('interactionMapRecencyUnknown')}
</div> </div>
</button> </div>
{showFollowControls && !selfCard ? (
<label
className="absolute top-1.5 right-1.5 z-30 flex cursor-pointer items-center gap-1 rounded border border-border/60 bg-background/95 px-1 py-0.5 shadow-sm backdrop-blur-[2px]"
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
>
<Checkbox
id={`interaction-follow-${p.pubkey}`}
checked={following}
disabled={followBusyPubkey === p.pubkey}
aria-label={t('interactionMapFollowingCheckbox')}
onCheckedChange={(v) => {
if (v === 'indeterminate') return
handleFollowToggle(p.pubkey, Boolean(v))
}}
/>
</label>
) : null}
</div> </div>
) )
})} })}

19
src/services/client-events.service.ts

@ -679,6 +679,25 @@ export class EventService {
return out 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 (3033) matched on title/summary/content and related tags * Session cache: NIP-32 citation kinds (3033) matched on title/summary/content and related tags
* (not NIP-50 relay semantics). * (not NIP-50 relay semantics).

15
src/services/client-query.service.ts

@ -437,7 +437,12 @@ export class QueryService {
const resolveWithEvents = () => { const resolveWithEvents = () => {
if (resolved || queryFinalizing) return if (resolved || queryFinalizing) return
queryFinalizing = true 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 if (resolved) return
resolved = true resolved = true
if (resolveTimeout) clearTimeout(resolveTimeout) if (resolveTimeout) clearTimeout(resolveTimeout)
@ -451,6 +456,14 @@ export class QueryService {
const resolvedList = const resolvedList =
replaceableRace && events.length > 0 ? resolveReplaceableRaceEvents() : events replaceableRace && events.length > 0 ? resolveReplaceableRaceEvents() : events
resolve(resolvedList) resolve(resolvedList)
}
const httpCapMs = Math.min(globalTimeout + 5000, 60_000)
void Promise.race([
httpInflight.catch(() => undefined),
new Promise<void>((r) => setTimeout(r, httpCapMs))
]).finally(() => {
finalizeOnce()
}) })
} }

51
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<Event[]> {
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<void> { async deleteArchivedEvent(eventId: string): Promise<void> {
const id = eventId.toLowerCase() const id = eventId.toLowerCase()
await this.initPromise await this.initPromise

Loading…
Cancel
Save