Browse Source

fix panels

imwald
Silberengel 2 weeks ago
parent
commit
c618ef609b
  1. 111
      src/PageManager.tsx
  2. 1
      src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx
  3. 5
      src/components/NoteDrawer/index.tsx
  4. 2
      src/pages/primary/SpellsPage/ProfileInteractionsMap.test.ts
  5. 66
      src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx
  6. 2
      src/pages/primary/SpellsPage/TopicKeywordHeatMap.test.ts
  7. 108
      src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx
  8. 97
      src/pages/primary/SpellsPage/build-topic-keyword-bubbles.ts
  9. 65
      src/pages/primary/SpellsPage/merge-interaction-events.ts

111
src/PageManager.tsx

@ -463,10 +463,9 @@ function parseNoteUrl(url: string): { noteId: string; context?: string } | null
return 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() { export function useSmartNoteNavigation() {
const { push: pushSecondaryPage } = useSecondaryPage() const { push: pushSecondaryPage } = useSecondaryPage()
const { openDrawer } = useNoteDrawer()
const { isSmallScreen } = useScreenSize() const { isSmallScreen } = useScreenSize()
const { current: currentPrimaryPage } = usePrimaryPage() const { current: currentPrimaryPage } = usePrimaryPage()
@ -515,10 +514,8 @@ export function useSmartNoteNavigation() {
// Desktop: check panel mode // Desktop: check panel mode
const currentPanelMode = storage.getPanelMode() const currentPanelMode = storage.getPanelMode()
if (currentPanelMode === 'single') { if (currentPanelMode === 'single') {
// Always push so the secondary stack matches the drawer; otherwise the first note is not on // Single-pane desktop: one sheet driven by the secondary stack (same as relays/settings).
// the stack and Back after opening a quote only closes the drawer instead of the parent note.
pushSecondaryPage(contextualUrl) pushSecondaryPage(contextualUrl)
openDrawer(noteId, event)
} else { } else {
// Double-pane: use secondary panel // Double-pane: use secondary panel
pushSecondaryPage(contextualUrl) 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. */ /** Safe variant for createRoot trees (e.g. AsciidocArticle embedded notes). Returns no-op navigation when outside providers. */
export function useSmartNoteNavigationOptional() { export function useSmartNoteNavigationOptional() {
const pushSecondaryPage = useSecondaryPageOptional() const pushSecondaryPage = useSecondaryPageOptional()
const noteDrawer = useNoteDrawerOptional()
const screenSize = useScreenSizeOptional() const screenSize = useScreenSizeOptional()
const primaryPage = usePrimaryPageOptional() const primaryPage = usePrimaryPageOptional()
if (!pushSecondaryPage || !noteDrawer || !screenSize || !primaryPage) { if (!pushSecondaryPage || !screenSize || !primaryPage) {
return { return {
navigateToNote: (url: string, _event?: Event, _relatedEvents?: Event[]) => { navigateToNote: (url: string, _event?: Event, _relatedEvents?: Event[]) => {
window.location.href = url window.location.href = url
@ -545,7 +541,6 @@ export function useSmartNoteNavigationOptional() {
} }
const { push } = pushSecondaryPage const { push } = pushSecondaryPage
const { openDrawer } = noteDrawer
const { isSmallScreen } = screenSize const { isSmallScreen } = screenSize
const { current: currentPrimaryPage } = primaryPage const { current: currentPrimaryPage } = primaryPage
@ -582,7 +577,6 @@ export function useSmartNoteNavigationOptional() {
const currentPanelMode = storage.getPanelMode() const currentPanelMode = storage.getPanelMode()
if (currentPanelMode === 'single') { if (currentPanelMode === 'single') {
push(contextualUrl) push(contextualUrl)
openDrawer(noteId, event)
} else { } else {
push(contextualUrl) push(contextualUrl)
} }
@ -1375,9 +1369,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (isSmallScreen || panelMode === 'single') { if (isSmallScreen || panelMode === 'single') {
// Seed stack so in-note navigation (e.g. quotes → back) can pop to this note // Seed stack so in-note navigation (e.g. quotes → back) can pop to this note
pushNoteUrlOnStack(buildNoteUrl(noteId, resolved.name)) pushNoteUrlOnStack(buildNoteUrl(noteId, resolved.name))
if (!isSmallScreen) {
openDrawer(noteId)
}
setTimeout(() => { setTimeout(() => {
setCurrentPrimaryPage(resolved.name) setCurrentPrimaryPage(resolved.name)
@ -1398,9 +1389,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
if (isSmallScreen || panelMode === 'single') { if (isSmallScreen || panelMode === 'single') {
pushNoteUrlOnStack(contextualUrl) pushNoteUrlOnStack(contextualUrl)
if (!isSmallScreen) {
openDrawer(noteId)
}
return return
} else { } else {
pushNoteUrlOnStack(contextualUrl) 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 // 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) // (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) => { setSecondaryStack((prevStack) => {
if (isCurrentPage(prevStack, url)) return 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 window.location.pathname + window.location.search + window.location.hash
if (locUrl !== '/' && locUrl !== '') { if (locUrl !== '/' && locUrl !== '') {
const synced = syncSecondaryStackWhenPopStateStateIsNull(pre, 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 return synced
} }
state = { index: -1, url: '/' } 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] const noteId = noteUrlMatch[noteUrlMatch.length - 1].split('?')[0].split('#')[0]
if (noteId) { if (noteId) {
if (isSmallScreen || panelMode === 'single') { if (isSmallScreen || panelMode === 'single') {
if (!isSmallScreen) {
openDrawer(noteId)
}
const built = findAndCreateComponent(state.url, state.index) const built = findAndCreateComponent(state.url, state.index)
if (built.component) { if (built.component) {
return [ return [
@ -1848,29 +1818,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
closeDrawer() closeDrawer()
} }
// DO NOT update URL when closing panel - closing should NEVER affect the main page // 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 // If newStack.length === 0, we're closing - don't reopen the drawer
return newStack return newStack
@ -2215,16 +2162,6 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
return next 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 popSecondaryPage = () => {
const now = Date.now() const now = Date.now()
if (now - lastPopSecondaryPageAt < POP_SECONDARY_PAGE_DEBOUNCE_MS) return 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 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 (isSmallScreen || panelMode === 'single') {
if (stackLen > 1) { if (stackLen > 1) {
const next = popOneSecondaryStackFrame() popOneSecondaryStackFrame()
syncDrawerToSecondaryStackTop(next)
ignorePopStateRef.current = true ignorePopStateRef.current = true
window.history.back() window.history.back()
} else { } else {
@ -2308,10 +2244,9 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
const shouldBeOpen = const shouldBeOpen =
panelMode === 'single' && panelMode === 'single' &&
!isSmallScreen && !isSmallScreen &&
secondaryStack.length > 0 && secondaryStack.length > 0
!drawerOpen
setSinglePaneSheetOpen(shouldBeOpen) setSinglePaneSheetOpen(shouldBeOpen)
}, [panelMode, isSmallScreen, secondaryStack.length, drawerOpen]) }, [panelMode, isSmallScreen, secondaryStack.length])
const primaryObscured = const primaryObscured =
secondaryStack.length > 0 || drawerOpen || primaryNoteView != null 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.) */} {/* Generic drawer for secondary stack in single-pane mode (for relay pages, etc.) */}
{panelMode === 'single' && {panelMode === 'single' &&
!isSmallScreen && !isSmallScreen &&
secondaryStack.length > 0 && secondaryStack.length > 0 && (
!drawerOpen && (
<Sheet <Sheet
open={singlePaneSheetOpen} open={singlePaneSheetOpen}
registerWithModalManager={false} registerWithModalManager={false}
@ -2568,22 +2502,15 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
> >
<SheetContent <SheetContent
side="right" side="right"
className="w-full sm:max-w-[1042px] overflow-y-auto p-0" className="flex h-full w-full flex-col overflow-hidden p-0 sm:max-w-[1042px]"
hideClose hideClose
onPointerDownOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)} onPointerDownOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)}
onInteractOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)} onInteractOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)}
> >
<div className="h-full"> <TopSecondaryStackPane
{secondaryStack.map((item, index) => { item={secondaryStack[secondaryStack.length - 1]!}
const isLast = index === secondaryStack.length - 1 className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden"
if (!isLast) return null />
return (
<div key={item.index}>
{item.component}
</div>
)
})}
</div>
</SheetContent> </SheetContent>
</Sheet> </Sheet>
)} )}

1
src/components/FavoriteRelaysSetting/FavoriteRelayList.tsx

@ -16,7 +16,6 @@ import {
sortableKeyboardCoordinates, sortableKeyboardCoordinates,
verticalListSortingStrategy verticalListSortingStrategy
} from '@dnd-kit/sortable' } from '@dnd-kit/sortable'
import { DEFAULT_FAVORITE_RELAYS } from '@/constants'
import { ensureTrendingInFavoriteRelayList } from '@/lib/wisp-trending-relay' import { ensureTrendingInFavoriteRelayList } from '@/lib/wisp-trending-relay'
import { useMemo } from 'react' import { useMemo } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'

5
src/components/NoteDrawer/index.tsx

@ -72,7 +72,7 @@ export default function NoteDrawer({ open, onOpenChange, noteId, initialEvent }:
<Sheet open={open} onOpenChange={onOpenChange} registerWithModalManager={false}> <Sheet open={open} onOpenChange={onOpenChange} registerWithModalManager={false}>
<SheetContent <SheetContent
side="right" side="right"
className="relative w-full overscroll-contain sm:max-w-[1042px] overflow-y-auto p-0" className="relative flex h-full w-full flex-col overflow-hidden p-0 sm:max-w-[1042px]"
hideClose hideClose
onPointerDownOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)} onPointerDownOutside={(e) => preventRadixSheetCloseForPortaledOverlay(e)}
onInteractOutside={(e) => 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 }} style={{ width: MOBILE_SWIPE_BACK_EDGE_PX }}
aria-hidden aria-hidden
/> />
<div className="min-h-full touch-pan-y"> <div className="flex h-full min-h-0 min-w-0 flex-1 flex-col overflow-hidden touch-pan-y">
<NotePage <NotePage
key={displayNoteId}
id={displayNoteId} id={displayNoteId}
index={currentIndex} index={currentIndex}
hideTitlebar={false} hideTitlebar={false}

2
src/pages/primary/SpellsPage/ProfileInteractionsMap.test.ts

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import { mergeInteractionEvents } from './ProfileInteractionsMap' import { mergeInteractionEvents } from './merge-interaction-events'
function interaction(pubkey: string, pTags: string[]) { function interaction(pubkey: string, pTags: string[]) {
return { return {

66
src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx

@ -6,7 +6,6 @@ import { Skeleton } from '@/components/ui/skeleton'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays'
import { muteSetHas } from '@/lib/mute-set'
import { toProfile } from '@/lib/link' import { toProfile } from '@/lib/link'
import { formatPubkey } from '@/lib/pubkey' import { formatPubkey } from '@/lib/pubkey'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -16,10 +15,11 @@ import { useNostr } from '@/providers/NostrProvider'
import client from '@/services/client.service' import client from '@/services/client.service'
import type { TSubRequestFilter } from '@/types' import type { TSubRequestFilter } from '@/types'
import { Loader2, RefreshCw, UserRound } from 'lucide-react' import { Loader2, RefreshCw, UserRound } from 'lucide-react'
import type { Event, Filter } from 'nostr-tools' import type { Filter } from 'nostr-tools'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState } from 'react' import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { mergeInteractionEvents, type InteractionCard } from './merge-interaction-events'
const INTERACTION_KINDS = [ const INTERACTION_KINDS = [
kinds.ShortTextNote, kinds.ShortTextNote,
@ -36,16 +36,6 @@ const INTERACTION_KINDS = [
const LOCAL_LIMIT = 1200 const LOCAL_LIMIT = 1200
const RELAY_LIMIT = 700 const RELAY_LIMIT = 700
const MAX_CARDS = 80
type InteractionCard = {
pubkey: string
score: number
authoredByProfile: number
mentionsProfile: number
latestCreatedAt: number
eventIds: Set<string>
}
type Props = { type Props = {
pubkey: string pubkey: string
@ -59,58 +49,6 @@ function interactionFilters(pubkey: string, limit: number): TSubRequestFilter[]
] ]
} }
export function mergeInteractionEvents(
targetPubkey: string,
events: Event[],
mutePubkeySet: ReadonlySet<string>
): InteractionCard[] {
const target = targetPubkey.toLowerCase()
const byPubkey = new Map<string, InteractionCard>()
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 { function compactCount(n: number): string {
if (n >= 1000) return `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k` if (n >= 1000) return `${(n / 1000).toFixed(n >= 10_000 ? 0 : 1)}k`
return String(n) return String(n)

2
src/pages/primary/SpellsPage/TopicKeywordHeatMap.test.ts

@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { kinds } from 'nostr-tools' import { kinds } from 'nostr-tools'
import { DEFAULT_FEED_SHOW_KINDS } from '@/constants' 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 = '') { function note(pubkey: string, tags: string[][], content = '') {
return { return {

108
src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx

@ -1,11 +1,9 @@
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card' import { HoverCard, HoverCardContent, HoverCardTrigger } from '@/components/ui/hover-card'
import { ExtendedKind } from '@/constants'
import { useMuteList } from '@/contexts/mute-list-context' import { useMuteList } from '@/contexts/mute-list-context'
import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { filterEventsExcludingMutedAuthors } from '@/lib/mute-set'
import { filterEventsExcludingMutedAuthors, muteSetHas } from '@/lib/mute-set'
import { filterEventsExcludingTombstones } from '@/lib/event' 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 { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays'
import { toNoteList } from '@/lib/link' import { toNoteList } from '@/lib/link'
import logger from '@/lib/logger' import logger from '@/lib/logger'
@ -19,50 +17,24 @@ import { cn } from '@/lib/utils'
import { SimpleUserAvatar } from '@/components/UserAvatar' import { SimpleUserAvatar } from '@/components/UserAvatar'
import { Loader2, RefreshCw } from 'lucide-react' import { Loader2, RefreshCw } from 'lucide-react'
import type { Event } from 'nostr-tools' 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 { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' 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_WINDOW_SEC = 30 * 24 * 3600
const HEAT_REQ_LIMIT = 1500 const HEAT_REQ_LIMIT = 1500
const MAX_BUBBLES = 10
const SESSION_LIMIT = 4000 const SESSION_LIMIT = 4000
const ARCHIVE_MAX_SCAN = 35_000 const ARCHIVE_MAX_SCAN = 35_000
const ARCHIVE_MAX_MATCHES = 2500 const ARCHIVE_MAX_MATCHES = 2500
const MAP_KINDS = [kinds.ShortTextNote, ExtendedKind.DISCUSSION] as const
const ARCHIVE_SCAN_TIMEOUT_MS = 22_000 const ARCHIVE_SCAN_TIMEOUT_MS = 22_000
const RELAY_FETCH_TIMEOUT_MS = 26_000 const RELAY_FETCH_TIMEOUT_MS = 26_000
const TOMBSTONES_TIMEOUT_MS = 8_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<string, number>
}
function topPubkeysForTopic(
hits: Map<string, number>,
limit: number,
mutePubkeySet?: ReadonlySet<string>
): 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({ function TopicBubbleAvatarRing({
pubkeys, pubkeys,
@ -150,64 +122,6 @@ function raceWithTimeout<T>(promise: Promise<T>, ms: number, fallback: T, label:
}) })
} }
export function buildTopicKeywordBubbles(
events: Event[],
showKinds: readonly number[],
showKind1OPs: boolean,
showKind1Replies: boolean,
showKind1111: boolean,
mutePubkeySet?: ReadonlySet<string>
): TTopicKeywordBubble[] {
const accum = new Map<string, TopicKeyAccum>()
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<string>()
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 = { type Props = {
refreshKey: number refreshKey: number
} }
@ -242,10 +156,10 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) {
const mergeData = useCallback(async (includeRelay = true): Promise<TTopicKeywordBubble[]> => { const mergeData = useCallback(async (includeRelay = true): Promise<TTopicKeywordBubble[]> => {
const windowStart = Math.floor(Date.now() / 1000) - HEAT_WINDOW_SEC 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({ const archiveScan = indexedDb.scanEventArchiveByKinds({
kinds: [...MAP_KINDS], kinds: [...TOPIC_KEYWORD_MAP_KINDS],
since: windowStart, since: windowStart,
maxRowsScanned: ARCHIVE_MAX_SCAN, maxRowsScanned: ARCHIVE_MAX_SCAN,
maxMatches: ARCHIVE_MAX_MATCHES maxMatches: ARCHIVE_MAX_MATCHES
@ -254,7 +168,7 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) {
includeRelay && relayUrls.length > 0 includeRelay && relayUrls.length > 0
? client.fetchEvents( ? client.fetchEvents(
relayUrls, relayUrls,
{ kinds: [...MAP_KINDS], limit: HEAT_REQ_LIMIT }, { kinds: [...TOPIC_KEYWORD_MAP_KINDS], limit: HEAT_REQ_LIMIT },
{ eoseTimeout: 8000, globalTimeout: 20000 } { eoseTimeout: 8000, globalTimeout: 20000 }
) )
: Promise.resolve([] as Event[]) : Promise.resolve([] as Event[])

97
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<string, number>
}
function topPubkeysForTopic(
hits: Map<string, number>,
limit: number,
mutePubkeySet?: ReadonlySet<string>
): 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<string>
): TTopicKeywordBubble[] {
const accum = new Map<string, TopicKeyAccum>()
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<string>()
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

65
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<string>
}
export function mergeInteractionEvents(
targetPubkey: string,
events: Event[],
mutePubkeySet: ReadonlySet<string>
): InteractionCard[] {
const target = targetPubkey.toLowerCase()
const byPubkey = new Map<string, InteractionCard>()
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)
}
Loading…
Cancel
Save