From d1bb4132d714617616759b3be4dd4e33ccf0edbd Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 7 May 2026 14:21:26 +0200 Subject: [PATCH] topic map --- src/constants.ts | 1 + src/i18n/locales/en.ts | 12 + .../primary/SpellsPage/RelayThreadHeatMap.tsx | 11 + .../SpellsPage/TopicKeywordHeatMap.tsx | 334 ++++++++++++++++++ .../primary/SpellsPage/fauxSpellConfig.ts | 4 + src/pages/primary/SpellsPage/index.tsx | 9 + .../primary/SpellsPage/useSpellsPageFeed.ts | 3 +- src/pages/secondary/NoteListPage/index.tsx | 59 +++- 8 files changed, 426 insertions(+), 7 deletions(-) create mode 100644 src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx diff --git a/src/constants.ts b/src/constants.ts index 9ffc904f..3b7d8a9d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -907,6 +907,7 @@ export const FAUX_SPELL_ORDER = [ 'discussions', 'following', 'heatMap', + 'topicMap', 'followPacks', 'media', 'interests', diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 4785fb4f..35a93dc6 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -800,6 +800,18 @@ export default { heatMapBubbleStats: "{{posts}} notes · {{people}} people · {{follows}} follows in thread", heatMapConnectorHint: "Linked threads — «{{left}}» ↔ «{{right}}»", "Please login to view thread heat map": "Please log in to open the thread heat map.", + "Topic map": "Topic map", + topicMapDescription: + "The ten largest bubbles combine how often a normalized string appears as a topic tag (·t·) and as a #hashtag in note text (last ~30 days). Data merges this tab’s session cache, your on-device archive, and your relay stack. Tap a bubble to open one feed that merges #t matches and NIP-50 full-text search.", + topicMapLocalOnlyBanner: + "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).", + topicMapLoading: "Merging session cache, archive, and relays…", + topicMapEmpty: "No topic or hashtag signals yet in the scanned window. Browse feeds or rescan after syncing.", + topicMapFetchError: "Could not build the topic map from your sources.", + topicMapRescan: "Rescan", + topicMapBubbleCounts: "{{topic}} notes with ·t· tag · {{kw}} with #hashtag in text", + topicMapOpenMergedFeed: "Open merged topic and keyword feed", + topicMapClickHint: "Opens a merged feed: same label as a ·t· filter plus NIP-50 search for the words.", 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/pages/primary/SpellsPage/RelayThreadHeatMap.tsx b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx index f97e4391..917fc797 100644 --- a/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx +++ b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx @@ -21,6 +21,7 @@ import { type TRelayThreadHeatBubble, type TRelayThreadHeatEdge } from '@/lib/relay-thread-heat' +import { usePrimaryPage } from '@/contexts/primary-page-context' import { useSmartNoteNavigation, useSmartProfileInteractionsNavigation } from '@/PageManager' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useKindFilterOrDefaults } from '@/providers/KindFilterProvider' @@ -84,6 +85,7 @@ type Props = { export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) { const { t } = useTranslation() + const { navigate: navigatePrimary } = usePrimaryPage() const { navigateToNote } = useSmartNoteNavigation() const { navigateToProfileInteractions } = useSmartProfileInteractionsNavigation() const { pubkey, relayList } = useNostr() @@ -445,6 +447,15 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) {t('interactionMapMenu')} + diff --git a/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx b/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx new file mode 100644 index 00000000..e13e9f1e --- /dev/null +++ b/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx @@ -0,0 +1,334 @@ +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 { extractHashtagsFromContent, normalizeTopic } from '@/lib/discussion-topics' +import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' +import { toNoteList } from '@/lib/link' +import logger from '@/lib/logger' +import { useSmartHashtagNavigation } 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 { Loader2, RefreshCw } from 'lucide-react' +import type { Event } from 'nostr-tools' +import { kinds, verifyEvent } from 'nostr-tools' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' + +const HEAT_WINDOW_SEC = 30 * 24 * 3600 +const HEAT_REQ_LIMIT = 1500 +const MAX_BUBBLES = 10 +const SESSION_LIMIT = 4000 +const ARCHIVE_MAX_SCAN = 35_000 +const ARCHIVE_MAX_MATCHES = 2500 + +const MAP_KINDS = [kinds.ShortTextNote, ExtendedKind.DISCUSSION] as const + +const ARCHIVE_SCAN_TIMEOUT_MS = 22_000 +const RELAY_FETCH_TIMEOUT_MS = 26_000 +const TOMBSTONES_TIMEOUT_MS = 8_000 + +export type TTopicKeywordBubble = { + key: string + score: number + topicNoteCount: number + keywordNoteCount: number +} + +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('[TopicKeywordHeatMap] 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('[TopicKeywordHeatMap] source failed', { label, err: e }) + resolve(fallback) + }) + }) +} + +function buildTopicKeywordBubbles( + events: Event[], + showKinds: readonly number[], + showKind1OPs: boolean, + showKind1Replies: boolean, + showKind1111: boolean +): TTopicKeywordBubble[] { + const topicHits = new Map() + const kwHits = new Map() + + for (const ev of events) { + if (!eventPassesNoteListKindPicker(ev, showKinds, showKind1OPs, showKind1Replies, showKind1111)) continue + const topics = new Set() + for (const row of ev.tags) { + if (row[0] === 't' && row[1]) { + const n = normalizeTopic(row[1]) + if (n) topics.add(n) + } + } + const kws = new Set(extractHashtagsFromContent(ev.content ?? '')) + + for (const k of topics) { + topicHits.set(k, (topicHits.get(k) ?? 0) + 1) + } + for (const k of kws) { + kwHits.set(k, (kwHits.get(k) ?? 0) + 1) + } + } + + const keys = new Set([...topicHits.keys(), ...kwHits.keys()]) + const out: TTopicKeywordBubble[] = [] + for (const key of keys) { + const a = topicHits.get(key) ?? 0 + const b = kwHits.get(key) ?? 0 + const score = a + b + if (score <= 0) continue + out.push({ key, score, topicNoteCount: a, keywordNoteCount: b }) + } + out.sort((x, y) => y.score - x.score || x.key.localeCompare(y.key)) + return out.slice(0, MAX_BUBBLES) +} + +type Props = { + refreshKey: number +} + +export default function TopicKeywordHeatMap({ refreshKey }: Props) { + const { t } = useTranslation() + const { navigateToHashtag } = useSmartHashtagNavigation() + const { relayList } = useNostr() + const { favoriteRelays, blockedRelays } = useFavoriteRelays() + const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults() + + const relayUrls = useMemo( + () => + getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays, + blockedRelays, + userReadRelaysWithHttp(relayList), + { + userWriteRelays: relayList?.write ?? [], + applySocialKindBlockedFilter: false + } + ), + [favoriteRelays, blockedRelays, relayList] + ) + + const [rows, setRows] = useState([]) + const [loading, setLoading] = useState(true) + const [isMerging, setIsMerging] = useState(false) + const [error, setError] = useState(null) + const [rescanTick, setRescanTick] = useState(0) + + const mergeData = useCallback(async (): Promise => { + const windowStart = Math.floor(Date.now() / 1000) - HEAT_WINDOW_SEC + const sessionEv = eventService.listSessionEventsByKinds(MAP_KINDS, { limit: SESSION_LIMIT }) + + const archiveScan = indexedDb.scanEventArchiveByKinds({ + kinds: [...MAP_KINDS], + since: windowStart, + maxRowsScanned: ARCHIVE_MAX_SCAN, + maxMatches: ARCHIVE_MAX_MATCHES + }) + const relayFetch = + relayUrls.length > 0 + ? client.fetchEvents( + relayUrls, + { kinds: [...MAP_KINDS], limit: HEAT_REQ_LIMIT }, + { eoseTimeout: 8000, globalTimeout: 20000 } + ) + : 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') + ]) + + const mergedById = new Map() + for (const ev of [...sessionEv, ...idbEv, ...relayRaw]) { + mergedById.set(ev.id.toLowerCase(), ev) + } + let merged = [...mergedById.values()].filter((e) => e.created_at >= windowStart) + if (merged.length === 0 && mergedById.size > 0) { + merged = [...mergedById.values()] + } + + const dedup = new Map() + for (const ev of merged) { + if (!verifyEvent(ev)) continue + dedup.set(ev.id.toLowerCase(), ev) + } + if (dedup.size === 0 && merged.length > 0) { + for (const ev of merged) { + 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 clean = filterEventsExcludingTombstones([...dedup.values()], tombstones) + return buildTopicKeywordBubbles(clean, showKinds, showKind1OPs, showKind1Replies, showKind1111) + }, [relayUrls, showKinds, showKind1OPs, showKind1Replies, showKind1111]) + + useEffect(() => { + let cancelled = false + setError(null) + setLoading(true) + setIsMerging(true) + void (async () => { + try { + const bubbles = await mergeData() + if (!cancelled) { + setRows(bubbles) + } + } catch (e) { + if (!cancelled) { + logger.warn('[TopicKeywordHeatMap] merge failed', { err: e }) + setError(t('topicMapFetchError')) + setRows([]) + } + } finally { + if (!cancelled) { + setLoading(false) + setIsMerging(false) + } + } + })() + return () => { + cancelled = true + } + }, [mergeData, refreshKey, rescanTick, t]) + + const maxScore = useMemo(() => rows.reduce((m, r) => Math.max(m, r.score), 0) || 1, [rows]) + + const openMergedFeed = useCallback( + (key: string) => { + const searchPhrase = key.replace(/-/g, ' ') + navigateToHashtag(toNoteList({ hashtag: key, search: searchPhrase })) + }, + [navigateToHashtag] + ) + + const displayLabel = (key: string) => `#${key.replace(/-/g, ' ')}` + + return ( +
+
+ {relayUrls.length === 0 ? ( +

+ {t('topicMapLocalOnlyBanner')} +

+ ) : null} +

{t('topicMapDescription')}

+
+ +
+
+ + {error ?

{error}

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

{t('topicMapLoading')}

+
+ ) : !loading && rows.length === 0 ? ( +
+ {t('topicMapEmpty')} +
+ ) : ( +
+
+ {rows.map((row) => { + const intensity = Math.min(1, row.score / maxScore) + const size = Math.min(200, Math.max(76, 52 + Math.sqrt(row.score) * 10)) + const countsLine = t('topicMapBubbleCounts', { + topic: row.topicNoteCount, + kw: row.keywordNoteCount + }) + const ariaLabel = [displayLabel(row.key), countsLine, t('topicMapOpenMergedFeed')].join('. ') + return ( + + + + + +

{displayLabel(row.key)}

+

{countsLine}

+

{t('topicMapClickHint')}

+
+
+ ) + })} +
+
+ )} +
+ ) +} diff --git a/src/pages/primary/SpellsPage/fauxSpellConfig.ts b/src/pages/primary/SpellsPage/fauxSpellConfig.ts index 69e42939..a073c360 100644 --- a/src/pages/primary/SpellsPage/fauxSpellConfig.ts +++ b/src/pages/primary/SpellsPage/fauxSpellConfig.ts @@ -11,6 +11,7 @@ import { Bookmark, CalendarDays, Flame, + Map as MapIcon, Gift, Hash, Image as ImageIcon, @@ -46,6 +47,8 @@ export function fauxSpellLabelKey(name: FauxSpellName): string { return 'Following' case 'heatMap': return 'Heat map' + case 'topicMap': + return 'Topic map' case 'followPacks': return 'Follow Packs' case 'media': @@ -66,6 +69,7 @@ export const FAUX_SPELL_ICON: Record = { discussions: MessageSquare, following: Users, heatMap: Flame, + topicMap: MapIcon, followPacks: Gift, media: ImageIcon, interests: Hash, diff --git a/src/pages/primary/SpellsPage/index.tsx b/src/pages/primary/SpellsPage/index.tsx index 693378bf..23433011 100644 --- a/src/pages/primary/SpellsPage/index.tsx +++ b/src/pages/primary/SpellsPage/index.tsx @@ -52,6 +52,7 @@ import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRe import { useTranslation } from 'react-i18next' import CreateSpellDialog from './CreateSpellDialog' import RelayThreadHeatMap from './RelayThreadHeatMap' +import TopicKeywordHeatMap from './TopicKeywordHeatMap' import type { TPageRef } from '@/types' import { decodeFollowSetSpellId, @@ -115,6 +116,7 @@ const SpellsPage = forwardRef(function SpellsPage( const selectedFauxSpellRefreshRef = useRef(null) selectedFauxSpellRefreshRef.current = selectedFauxSpell const [heatMapRefreshKey, setHeatMapRefreshKey] = useState(0) + const [topicMapRefreshKey, setTopicMapRefreshKey] = useState(0) const layoutRef = useRef(null) const [spellPickerOpen, setSpellPickerOpen] = useState(false) @@ -189,6 +191,9 @@ const SpellsPage = forwardRef(function SpellsPage( if (selectedFauxSpellRefreshRef.current === 'heatMap') { setHeatMapRefreshKey((k) => k + 1) } + if (selectedFauxSpellRefreshRef.current === 'topicMap') { + setTopicMapRefreshKey((k) => k + 1) + } spellFeedListRef.current?.refresh() }, [loadSpells, pubkey]) @@ -999,6 +1004,10 @@ const SpellsPage = forwardRef(function SpellsPage(
+ ) : selectedFauxSpell === 'topicMap' ? ( +
+ +
) : selectedFauxSpell && fauxSubRequests.length === 0 ? (
{fauxFeedEmptyMessage}
) : selectedFauxSpell && fauxSubRequests.length > 0 ? ( diff --git a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts index 670409a3..e4af534f 100644 --- a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts +++ b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts @@ -340,7 +340,8 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { if ( !selectedFauxSpell || isFollowFeedFauxSpellId(selectedFauxSpell) || - selectedFauxSpell === 'heatMap' + selectedFauxSpell === 'heatMap' || + selectedFauxSpell === 'topicMap' ) return [] const fauxSpellSkipSocialKindBlocked = diff --git a/src/pages/secondary/NoteListPage/index.tsx b/src/pages/secondary/NoteListPage/index.tsx index 9a81eb09..f698e6a4 100644 --- a/src/pages/secondary/NoteListPage/index.tsx +++ b/src/pages/secondary/NoteListPage/index.tsx @@ -3,7 +3,12 @@ import type { TNoteListRef } from '@/components/NoteList' import NormalFeed from '@/components/NormalFeed' import { RefreshButton } from '@/components/RefreshButton' import { Button } from '@/components/ui/button' -import { isSocialKindBlockedKind, NIP_SEARCH_DOCUMENT_KINDS, SEARCHABLE_RELAY_URLS } from '@/constants' +import { + isSocialKindBlockedKind, + NIP_SEARCH_DOCUMENT_KINDS, + NIP_SEARCH_PAGE_KINDS, + SEARCHABLE_RELAY_URLS +} from '@/constants' import { augmentSubRequestsWithFavoritesFastReadAndInbox, getRelayUrlsWithFavoritesFastReadAndInbox, @@ -44,7 +49,7 @@ const NoteListPage = forwardRef(({ index, hid const [controls, setControls] = useState(null) const [data, setData] = useState< | { - type: 'hashtag' | 'search' | 'externalContent' | 'dtag' + type: 'hashtag' | 'hashtagSearch' | 'search' | 'externalContent' | 'dtag' kinds?: number[] dtag?: string } @@ -59,7 +64,7 @@ const NoteListPage = forwardRef(({ index, hid // Get hashtag from URL if this is a hashtag page const hashtag = useMemo(() => { - if (data?.type === 'hashtag') { + if (data?.type === 'hashtag' || data?.type === 'hashtagSearch') { const searchParams = new URLSearchParams(window.location.search) return searchParams.get('t') } @@ -92,6 +97,46 @@ const NoteListPage = forwardRef(({ index, hid applySocialKindBlockedFilter: kinds.length === 0 || kinds.some(isSocialKindBlockedKind) } const hashtag = searchParams.get('t') + const searchFromUrl = searchParams.get('s') + if (hashtag && searchFromUrl) { + setData({ type: 'hashtagSearch' }) + setTitle(`${t('Search')}: #${hashtag} · ${searchFromUrl}`) + const relayUrls = getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays, + blockedRelays, + userReadRelaysWithHttp(relayList), + readUrlOpts + ) + const mergedSearchKinds = Array.from( + new Set([...NIP_SEARCH_PAGE_KINDS, ...(kinds.length > 0 ? kinds : [])]) + ).sort((a, b) => a - b) + setSubRequests([ + { + filter: { '#t': [hashtag], ...(kinds.length > 0 ? { kinds } : {}) }, + urls: relayUrls + }, + { + filter: { search: searchFromUrl, kinds: mergedSearchKinds }, + urls: [...new Set([...relayUrls, ...SEARCHABLE_RELAY_URLS])] + } + ]) + const isSubscribedToHashtag = isSubscribed(hashtag) + if (pubkey) { + setControls( + + ) + } else { + setControls(null) + } + return + } if (hashtag) { setData({ type: 'hashtag' }) setTitle(`# ${hashtag}`) @@ -267,7 +312,7 @@ const NoteListPage = forwardRef(({ index, hid // Update controls when subscription status changes useEffect(() => { - if (data?.type === 'hashtag' && pubkey) { + if ((data?.type === 'hashtag' || data?.type === 'hashtagSearch') && pubkey) { setControls(