From c618ef609b75ea18b094f3b9aff62be9423f87c2 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 2 Jun 2026 08:19:48 +0200 Subject: [PATCH] fix panels --- src/PageManager.tsx | 111 +++--------------- .../FavoriteRelayList.tsx | 1 - src/components/NoteDrawer/index.tsx | 5 +- .../SpellsPage/ProfileInteractionsMap.test.ts | 2 +- .../SpellsPage/ProfileInteractionsMap.tsx | 66 +---------- .../SpellsPage/TopicKeywordHeatMap.test.ts | 2 +- .../SpellsPage/TopicKeywordHeatMap.tsx | 108 ++--------------- .../SpellsPage/build-topic-keyword-bubbles.ts | 97 +++++++++++++++ .../SpellsPage/merge-interaction-events.ts | 65 ++++++++++ 9 files changed, 199 insertions(+), 258 deletions(-) create mode 100644 src/pages/primary/SpellsPage/build-topic-keyword-bubbles.ts create mode 100644 src/pages/primary/SpellsPage/merge-interaction-events.ts diff --git a/src/PageManager.tsx b/src/PageManager.tsx index d9db12e1..d73e99b9 100644 --- a/src/PageManager.tsx +++ b/src/PageManager.tsx @@ -463,10 +463,9 @@ function parseNoteUrl(url: string): { noteId: string; context?: string } | null return null } -// Fixed: Note navigation uses drawer on mobile/single-pane, secondary panel on double-pane desktop +// Fixed: Note navigation uses full-screen stack on mobile, sheet (single-pane) or side panel (double-pane) on desktop export function useSmartNoteNavigation() { const { push: pushSecondaryPage } = useSecondaryPage() - const { openDrawer } = useNoteDrawer() const { isSmallScreen } = useScreenSize() const { current: currentPrimaryPage } = usePrimaryPage() @@ -515,10 +514,8 @@ export function useSmartNoteNavigation() { // Desktop: check panel mode const currentPanelMode = storage.getPanelMode() if (currentPanelMode === 'single') { - // Always push so the secondary stack matches the drawer; otherwise the first note is not on - // the stack and Back after opening a quote only closes the drawer instead of the parent note. + // Single-pane desktop: one sheet driven by the secondary stack (same as relays/settings). pushSecondaryPage(contextualUrl) - openDrawer(noteId, event) } else { // Double-pane: use secondary panel pushSecondaryPage(contextualUrl) @@ -532,11 +529,10 @@ export function useSmartNoteNavigation() { /** Safe variant for createRoot trees (e.g. AsciidocArticle embedded notes). Returns no-op navigation when outside providers. */ export function useSmartNoteNavigationOptional() { const pushSecondaryPage = useSecondaryPageOptional() - const noteDrawer = useNoteDrawerOptional() const screenSize = useScreenSizeOptional() const primaryPage = usePrimaryPageOptional() - if (!pushSecondaryPage || !noteDrawer || !screenSize || !primaryPage) { + if (!pushSecondaryPage || !screenSize || !primaryPage) { return { navigateToNote: (url: string, _event?: Event, _relatedEvents?: Event[]) => { window.location.href = url @@ -545,7 +541,6 @@ export function useSmartNoteNavigationOptional() { } const { push } = pushSecondaryPage - const { openDrawer } = noteDrawer const { isSmallScreen } = screenSize const { current: currentPrimaryPage } = primaryPage @@ -582,7 +577,6 @@ export function useSmartNoteNavigationOptional() { const currentPanelMode = storage.getPanelMode() if (currentPanelMode === 'single') { push(contextualUrl) - openDrawer(noteId, event) } else { push(contextualUrl) } @@ -1375,9 +1369,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { if (isSmallScreen || panelMode === 'single') { // Seed stack so in-note navigation (e.g. quotes → back) can pop to this note pushNoteUrlOnStack(buildNoteUrl(noteId, resolved.name)) - if (!isSmallScreen) { - openDrawer(noteId) - } setTimeout(() => { setCurrentPrimaryPage(resolved.name) @@ -1398,9 +1389,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { if (isSmallScreen || panelMode === 'single') { pushNoteUrlOnStack(contextualUrl) - if (!isSmallScreen) { - openDrawer(noteId) - } return } else { pushNoteUrlOnStack(contextualUrl) @@ -1502,6 +1490,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { // For relay URLs and other non-note URLs, push to secondary stack // (will be rendered in drawer in single-pane mode, side panel in double-pane mode) + const pathOnlyForSecondary = pathname.split('?')[0].split('#')[0] + if (pathOnlyForSecondary.startsWith('/settings/') && pathOnlyForSecondary !== '/settings') { + setCurrentPrimaryPage('settings') + setPrimaryPages((prev) => mergePrimaryPageEntry(prev, { name: 'settings' })) + } + setSecondaryStack((prevStack) => { if (isCurrentPage(prevStack, url)) return prevStack @@ -1688,27 +1682,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { window.location.pathname + window.location.search + window.location.hash if (locUrl !== '/' && locUrl !== '') { const synced = syncSecondaryStackWhenPopStateStateIsNull(pre, locUrl) - if ((panelMode === 'single' && !isSmallScreen) && drawerOpen && drawerNoteId && synced.length > 0) { - const topItemUrl = synced[synced.length - 1]?.url - if (topItemUrl) { - const topNoteUrlMatch = - topItemUrl.match( - /\/(discussions|search|profile|home|feed|spells|explore|rss|calendar)\/notes\/(.+)$/ - ) || topItemUrl.match(/\/notes\/(.+)$/) - if (topNoteUrlMatch) { - const topNoteId = topNoteUrlMatch[topNoteUrlMatch.length - 1] - .split('?')[0] - .split('#')[0] - if (topNoteId && topNoteId !== drawerNoteId) { - setTimeout(() => { - if (drawerOpen) { - openDrawer(topNoteId) - } - }, 0) - } - } - } - } return synced } state = { index: -1, url: '/' } @@ -1803,9 +1776,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0] if (noteId) { if (isSmallScreen || panelMode === 'single') { - if (!isSmallScreen) { - openDrawer(noteId) - } const built = findAndCreateComponent(state.url, state.index) if (built.component) { return [ @@ -1848,29 +1818,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { closeDrawer() } // DO NOT update URL when closing panel - closing should NEVER affect the main page - } else if (newStack.length > 0) { - // Stack still has items - update drawer to show the top item's note (for mobile/single-pane) - // Only update drawer if drawer is currently open (not in the process of closing) - if (panelMode === 'single' && !isSmallScreen && drawerOpen && drawerNoteId) { - // Extract noteId from top item's URL or from state.url - const topItemUrl = newStack[newStack.length - 1]?.url || state?.url - if (topItemUrl) { - const topNoteUrlMatch = topItemUrl.match(/\/(discussions|search|profile|home|feed|spells|explore|rss|calendar)\/notes\/(.+)$/) || - topItemUrl.match(/\/notes\/(.+)$/) - if (topNoteUrlMatch) { - const topNoteId = topNoteUrlMatch[topNoteUrlMatch.length - 1].split('?')[0].split('#')[0] - if (topNoteId && topNoteId !== drawerNoteId) { - // Use setTimeout to ensure drawer update happens after stack state is committed - setTimeout(() => { - // Double-check drawer is still open before updating - if (drawerOpen) { - openDrawer(topNoteId) - } - }, 0) - } - } - } - } } // If newStack.length === 0, we're closing - don't reopen the drawer return newStack @@ -2215,16 +2162,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { return next } - const syncDrawerToSecondaryStackTop = (stack: TStackItem[]) => { - if (isSmallScreen || panelMode !== 'single') return - const top = stack[stack.length - 1] - if (!top) return - const noteId = noteHexIdFromSecondaryNoteUrl(top.url) - if (!noteId) return - openDrawer(noteId, navigationEventStore.peekEvent(noteId)) - } - - /** UI-first back: sync stack / drawer immediately, then align browser history. */ const popSecondaryPage = () => { const now = Date.now() if (now - lastPopSecondaryPageAt < POP_SECONDARY_PAGE_DEBOUNCE_MS) return @@ -2237,11 +2174,10 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const stackLen = secondaryStackRef.current.length - // Mobile / single-pane: one code path — drawer + stack share the same close behavior + // Mobile / single-pane: one code path — stack drives the overlay (sheet on desktop, full-screen on mobile) if (isSmallScreen || panelMode === 'single') { if (stackLen > 1) { - const next = popOneSecondaryStackFrame() - syncDrawerToSecondaryStackTop(next) + popOneSecondaryStackFrame() ignorePopStateRef.current = true window.history.back() } else { @@ -2308,10 +2244,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { const shouldBeOpen = panelMode === 'single' && !isSmallScreen && - secondaryStack.length > 0 && - !drawerOpen + secondaryStack.length > 0 setSinglePaneSheetOpen(shouldBeOpen) - }, [panelMode, isSmallScreen, secondaryStack.length, drawerOpen]) + }, [panelMode, isSmallScreen, secondaryStack.length]) const primaryObscured = secondaryStack.length > 0 || drawerOpen || primaryNoteView != null @@ -2553,8 +2488,7 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) { {/* Generic drawer for secondary stack in single-pane mode (for relay pages, etc.) */} {panelMode === 'single' && !isSmallScreen && - secondaryStack.length > 0 && - !drawerOpen && ( + secondaryStack.length > 0 && ( preventRadixSheetCloseForPortaledOverlay(e)} onInteractOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)} > -
- {secondaryStack.map((item, index) => { - const isLast = index === secondaryStack.length - 1 - if (!isLast) return null - return ( -
- {item.component} -
- ) - })} -
+
)} diff --git a/src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx b/src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx index 29ffccfd..2358521f 100644 --- a/src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx +++ b/src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx @@ -16,7 +16,6 @@ import { sortableKeyboardCoordinates, verticalListSortingStrategy } from '@dnd-kit/sortable' -import { DEFAULT_FAVORITE_RELAYS } from '@/constants' import { ensureTrendingInFavoriteRelayList } from '@/lib/wisp-trending-relay' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' diff --git a/src/components/NoteDrawer/index.tsx b/src/components/NoteDrawer/index.tsx index fa8286ef..8a288d37 100644 --- a/src/components/NoteDrawer/index.tsx +++ b/src/components/NoteDrawer/index.tsx @@ -72,7 +72,7 @@ export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }: preventRadixSheetCloseForPortaledOverlay(e)} onInteractOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)} @@ -83,8 +83,9 @@ export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }: style={{ width: MOBILE_SWIPE_BACK_EDGE_PX }} aria-hidden /> -
+
-} type Props = { pubkey: string @@ -59,58 +49,6 @@ function interactionFilters(pubkey: string, limit: number): TSubRequestFilter[] ] } -export function mergeInteractionEvents( - targetPubkey: string, - events: Event[], - mutePubkeySet: ReadonlySet -): InteractionCard[] { - const target = targetPubkey.toLowerCase() - const byPubkey = new Map() - const add = (partnerRaw: string | undefined, event: Event, direction: 'out' | 'in') => { - if (muteSetHas(mutePubkeySet, event.pubkey)) return - const partner = partnerRaw?.trim().toLowerCase() - if (!partner || partner === target || !/^[0-9a-f]{64}$/.test(partner)) return - if (muteSetHas(mutePubkeySet, partner)) return - let row = byPubkey.get(partner) - if (!row) { - row = { - pubkey: partner, - score: 0, - authoredByProfile: 0, - mentionsProfile: 0, - latestCreatedAt: 0, - eventIds: new Set() - } - byPubkey.set(partner, row) - } - if (row.eventIds.has(event.id)) return - row.eventIds.add(event.id) - row.score += 1 - row.latestCreatedAt = Math.max(row.latestCreatedAt, event.created_at) - if (direction === 'out') row.authoredByProfile += 1 - else row.mentionsProfile += 1 - } - - for (const event of events) { - const pTags = [ - ...new Set( - event.tags - .filter((tag) => tag[0] === 'p' && /^[0-9a-f]{64}$/i.test(tag[1] ?? '')) - .map((tag) => tag[1]!.toLowerCase()) - ) - ] - if (event.pubkey.toLowerCase() === target) { - for (const partner of pTags) add(partner, event, 'out') - } else if (pTags.includes(target)) { - add(event.pubkey, event, 'in') - } - } - - return [...byPubkey.values()] - .sort((a, b) => b.score - a.score || b.latestCreatedAt - a.latestCreatedAt || a.pubkey.localeCompare(b.pubkey)) - .slice(0, MAX_CARDS) -} - function compactCount(n: number): string { if (n >= 1000) return `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` return String(n) diff --git a/src/pages/primary/SpellsPage/TopicKeywordHeatMap.test.ts b/src/pages/primary/SpellsPage/TopicKeywordHeatMap.test.ts index 30e50cf0..4ce214cd 100644 --- a/src/pages/primary/SpellsPage/TopicKeywordHeatMap.test.ts +++ b/src/pages/primary/SpellsPage/TopicKeywordHeatMap.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { kinds } from 'nostr-tools' import { DEFAULT_FEED_SHOW_KINDS } from '@/constants' -import { buildTopicKeywordBubbles } from './TopicKeywordHeatMap' +import { buildTopicKeywordBubbles } from './build-topic-keyword-bubbles' function note(pubkey: string, tags: string[][], content = '') { return { diff --git a/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx b/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx index e5f424f6..73b84531 100644 --- a/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx +++ b/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx @@ -1,11 +1,9 @@ import { Button } from '@/components/ui/button' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' -import { ExtendedKind } from '@/constants' import { useMuteList } from '@/contexts/mute-list-context' -import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' -import { filterEventsExcludingMutedAuthors, muteSetHas } from '@/lib/mute-set' +import { filterEventsExcludingMutedAuthors } from '@/lib/mute-set' import { filterEventsExcludingTombstones } from '@/lib/event' -import { extractHashtagsFromContent, formatTopicMapBubbleLabel, isValidNormalizedTopicKey, normalizeTopic } from '@/lib/discussion-topics' +import { formatTopicMapBubbleLabel } from '@/lib/discussion-topics' import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { toNoteList } from '@/lib/link' import logger from '@/lib/logger' @@ -19,50 +17,24 @@ import { cn } from '@/lib/utils' import { SimpleUserAvatar } from '@/components/UserAvatar' import { Loader2, RefreshCw } from 'lucide-react' import type { Event } from 'nostr-tools' -import { kinds, verifyEvent } from 'nostr-tools' +import { verifyEvent } from 'nostr-tools' import { useCallback, useEffect, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' +import { + buildTopicKeywordBubbles, + TOPIC_KEYWORD_MAP_KINDS, + type TTopicKeywordBubble +} from './build-topic-keyword-bubbles' 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 -/** Max profile avatars shown around each topic bubble (by tag usage count). */ -const MAX_BUBBLE_AVATARS = 7 - -export type TTopicKeywordBubble = { - key: string - score: number - topicNoteCount: number - keywordNoteCount: number - pubkeys: string[] -} - -type TopicKeyAccum = { - topicNoteCount: number - keywordNoteCount: number - pubkeyHits: Map -} - -function topPubkeysForTopic( - hits: Map, - limit: number, - mutePubkeySet?: ReadonlySet -): string[] { - return [...hits.entries()] - .filter(([pk]) => !mutePubkeySet || !muteSetHas(mutePubkeySet, pk)) - .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) - .slice(0, limit) - .map(([pk]) => pk) -} function TopicBubbleAvatarRing({ pubkeys, @@ -150,64 +122,6 @@ function raceWithTimeout(promise: Promise, ms: number, fallback: T, label: }) } -export function buildTopicKeywordBubbles( - events: Event[], - showKinds: readonly number[], - showKind1OPs: boolean, - showKind1Replies: boolean, - showKind1111: boolean, - mutePubkeySet?: ReadonlySet -): TTopicKeywordBubble[] { - const accum = new Map() - - const bump = (key: string, ev: Event, viaTopicTag: boolean) => { - if (!isValidNormalizedTopicKey(key)) return - let row = accum.get(key) - if (!row) { - row = { topicNoteCount: 0, keywordNoteCount: 0, pubkeyHits: new Map() } - accum.set(key, row) - } - if (viaTopicTag) row.topicNoteCount += 1 - else row.keywordNoteCount += 1 - const pk = ev.pubkey.trim().toLowerCase() - if (/^[0-9a-f]{64}$/.test(pk)) { - row.pubkeyHits.set(pk, (row.pubkeyHits.get(pk) ?? 0) + 1) - } - } - - for (const ev of events) { - if (mutePubkeySet && muteSetHas(mutePubkeySet, ev.pubkey)) continue - 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 && isValidNormalizedTopicKey(n)) topics.add(n) - } - } - const kws = new Set(extractHashtagsFromContent(ev.content ?? '')) - - for (const k of topics) bump(k, ev, true) - for (const k of kws) bump(k, ev, false) - } - - const out: TTopicKeywordBubble[] = [] - for (const [key, row] of accum) { - if (!isValidNormalizedTopicKey(key)) continue - const score = row.topicNoteCount + row.keywordNoteCount - if (score <= 0) continue - out.push({ - key, - score, - topicNoteCount: row.topicNoteCount, - keywordNoteCount: row.keywordNoteCount, - pubkeys: topPubkeysForTopic(row.pubkeyHits, MAX_BUBBLE_AVATARS, mutePubkeySet) - }) - } - out.sort((x, y) => y.score - x.score || x.key.localeCompare(y.key)) - return out.slice(0, MAX_BUBBLES) -} - type Props = { refreshKey: number } @@ -242,10 +156,10 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) { const mergeData = useCallback(async (includeRelay = true): Promise => { const windowStart = Math.floor(Date.now() / 1000) - HEAT_WINDOW_SEC - const sessionEv = eventService.listSessionEventsByKinds(MAP_KINDS, { limit: SESSION_LIMIT }) + const sessionEv = eventService.listSessionEventsByKinds(TOPIC_KEYWORD_MAP_KINDS, { limit: SESSION_LIMIT }) const archiveScan = indexedDb.scanEventArchiveByKinds({ - kinds: [...MAP_KINDS], + kinds: [...TOPIC_KEYWORD_MAP_KINDS], since: windowStart, maxRowsScanned: ARCHIVE_MAX_SCAN, maxMatches: ARCHIVE_MAX_MATCHES @@ -254,7 +168,7 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) { includeRelay && relayUrls.length > 0 ? client.fetchEvents( relayUrls, - { kinds: [...MAP_KINDS], limit: HEAT_REQ_LIMIT }, + { kinds: [...TOPIC_KEYWORD_MAP_KINDS], limit: HEAT_REQ_LIMIT }, { eoseTimeout: 8000, globalTimeout: 20000 } ) : Promise.resolve([] as Event[]) diff --git a/src/pages/primary/SpellsPage/build-topic-keyword-bubbles.ts b/src/pages/primary/SpellsPage/build-topic-keyword-bubbles.ts new file mode 100644 index 00000000..f732076a --- /dev/null +++ b/src/pages/primary/SpellsPage/build-topic-keyword-bubbles.ts @@ -0,0 +1,97 @@ +import { ExtendedKind } from '@/constants' +import { extractHashtagsFromContent, isValidNormalizedTopicKey, normalizeTopic } from '@/lib/discussion-topics' +import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' +import { muteSetHas } from '@/lib/mute-set' +import type { Event } from 'nostr-tools' +import { kinds } from 'nostr-tools' + +const MAX_BUBBLES = 10 +/** Max profile avatars shown around each topic bubble (by tag usage count). */ +const MAX_BUBBLE_AVATARS = 7 + +export type TTopicKeywordBubble = { + key: string + score: number + topicNoteCount: number + keywordNoteCount: number + pubkeys: string[] +} + +type TopicKeyAccum = { + topicNoteCount: number + keywordNoteCount: number + pubkeyHits: Map +} + +function topPubkeysForTopic( + hits: Map, + limit: number, + mutePubkeySet?: ReadonlySet +): string[] { + return [...hits.entries()] + .filter(([pk]) => !mutePubkeySet || !muteSetHas(mutePubkeySet, pk)) + .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])) + .slice(0, limit) + .map(([pk]) => pk) +} + +export function buildTopicKeywordBubbles( + events: Event[], + showKinds: readonly number[], + showKind1OPs: boolean, + showKind1Replies: boolean, + showKind1111: boolean, + mutePubkeySet?: ReadonlySet +): TTopicKeywordBubble[] { + const accum = new Map() + + const bump = (key: string, ev: Event, viaTopicTag: boolean) => { + if (!isValidNormalizedTopicKey(key)) return + let row = accum.get(key) + if (!row) { + row = { topicNoteCount: 0, keywordNoteCount: 0, pubkeyHits: new Map() } + accum.set(key, row) + } + if (viaTopicTag) row.topicNoteCount += 1 + else row.keywordNoteCount += 1 + const pk = ev.pubkey.trim().toLowerCase() + if (/^[0-9a-f]{64}$/.test(pk)) { + row.pubkeyHits.set(pk, (row.pubkeyHits.get(pk) ?? 0) + 1) + } + } + + for (const ev of events) { + if (mutePubkeySet && muteSetHas(mutePubkeySet, ev.pubkey)) continue + 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 && isValidNormalizedTopicKey(n)) topics.add(n) + } + } + const kws = new Set(extractHashtagsFromContent(ev.content ?? '')) + + for (const k of topics) bump(k, ev, true) + for (const k of kws) bump(k, ev, false) + } + + const out: TTopicKeywordBubble[] = [] + for (const [key, row] of accum) { + if (!isValidNormalizedTopicKey(key)) continue + const score = row.topicNoteCount + row.keywordNoteCount + if (score <= 0) continue + out.push({ + key, + score, + topicNoteCount: row.topicNoteCount, + keywordNoteCount: row.keywordNoteCount, + pubkeys: topPubkeysForTopic(row.pubkeyHits, MAX_BUBBLE_AVATARS, mutePubkeySet) + }) + } + out.sort((x, y) => y.score - x.score || x.key.localeCompare(y.key)) + return out.slice(0, MAX_BUBBLES) +} + +/** Kinds scanned for the topic keyword heat map (exported for the component fetch layer). */ +export const TOPIC_KEYWORD_MAP_KINDS = [kinds.ShortTextNote, ExtendedKind.DISCUSSION] as const diff --git a/src/pages/primary/SpellsPage/merge-interaction-events.ts b/src/pages/primary/SpellsPage/merge-interaction-events.ts new file mode 100644 index 00000000..a3de3a24 --- /dev/null +++ b/src/pages/primary/SpellsPage/merge-interaction-events.ts @@ -0,0 +1,65 @@ +import { muteSetHas } from '@/lib/mute-set' +import type { Event } from 'nostr-tools' + +const MAX_CARDS = 80 + +export type InteractionCard = { + pubkey: string + score: number + authoredByProfile: number + mentionsProfile: number + latestCreatedAt: number + eventIds: Set +} + +export function mergeInteractionEvents( + targetPubkey: string, + events: Event[], + mutePubkeySet: ReadonlySet +): InteractionCard[] { + const target = targetPubkey.toLowerCase() + const byPubkey = new Map() + const add = (partnerRaw: string | undefined, event: Event, direction: 'out' | 'in') => { + if (muteSetHas(mutePubkeySet, event.pubkey)) return + const partner = partnerRaw?.trim().toLowerCase() + if (!partner || partner === target || !/^[0-9a-f]{64}$/.test(partner)) return + if (muteSetHas(mutePubkeySet, partner)) return + let row = byPubkey.get(partner) + if (!row) { + row = { + pubkey: partner, + score: 0, + authoredByProfile: 0, + mentionsProfile: 0, + latestCreatedAt: 0, + eventIds: new Set() + } + byPubkey.set(partner, row) + } + if (row.eventIds.has(event.id)) return + row.eventIds.add(event.id) + row.score += 1 + row.latestCreatedAt = Math.max(row.latestCreatedAt, event.created_at) + if (direction === 'out') row.authoredByProfile += 1 + else row.mentionsProfile += 1 + } + + for (const event of events) { + const pTags = [ + ...new Set( + event.tags + .filter((tag) => tag[0] === 'p' && /^[0-9a-f]{64}$/i.test(tag[1] ?? '')) + .map((tag) => tag[1]!.toLowerCase()) + ) + ] + if (event.pubkey.toLowerCase() === target) { + for (const partner of pTags) add(partner, event, 'out') + } else if (pTags.includes(target)) { + add(event.pubkey, event, 'in') + } + } + + return [...byPubkey.values()] + .sort((a, b) => b.score - a.score || b.latestCreatedAt - a.latestCreatedAt || a.pubkey.localeCompare(b.pubkey)) + .slice(0, MAX_CARDS) +}