9 changed files with 199 additions and 258 deletions
@ -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 |
||||||
@ -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…
Reference in new issue