+
-}
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)
+}