diff --git a/src/lib/mute-set.test.ts b/src/lib/mute-set.test.ts new file mode 100644 index 00000000..bbb93a9c --- /dev/null +++ b/src/lib/mute-set.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { kinds } from 'nostr-tools' +import { filterEventsExcludingMutedAuthors } from './mute-set' + +describe('filterEventsExcludingMutedAuthors', () => { + it('drops events from muted pubkeys', () => { + const muted = 'a'.repeat(64) + const other = 'b'.repeat(64) + const events = [ + { kind: kinds.ShortTextNote, pubkey: muted, id: '1'.repeat(64), sig: 's', tags: [], content: '', created_at: 1 }, + { kind: kinds.ShortTextNote, pubkey: other, id: '2'.repeat(64), sig: 's', tags: [], content: '', created_at: 1 } + ] + const out = filterEventsExcludingMutedAuthors(events, new Set([muted])) + expect(out).toHaveLength(1) + expect(out[0]?.pubkey).toBe(other) + }) +}) diff --git a/src/lib/mute-set.ts b/src/lib/mute-set.ts index b4815f7f..1e11a703 100644 --- a/src/lib/mute-set.ts +++ b/src/lib/mute-set.ts @@ -1,7 +1,24 @@ +import type { Event } from 'nostr-tools' + /** * Mute pubkey sets use lowercase hex so lookups match Nostr events and `p` tags regardless of casing. */ -export function muteSetHas(mutePubkeySet: Set, pubkey: string | undefined | null): boolean { +export function muteSetHas(mutePubkeySet: ReadonlySet, pubkey: string | undefined | null): boolean { if (!pubkey) return false return mutePubkeySet.has(pubkey.toLowerCase()) } + +/** Drop notes whose author is in the viewer's public or private mute list. */ +export function filterEventsExcludingMutedAuthors( + events: readonly Event[], + mutePubkeySet: ReadonlySet +): Event[] { + if (mutePubkeySet.size === 0) return [...events] + return events.filter((ev) => !muteSetHas(mutePubkeySet, ev.pubkey)) +} + +/** Stable SETTINGS / cache segment when mute lists change. */ +export function mutePubkeySetFingerprint(mutePubkeySet: ReadonlySet): string { + if (mutePubkeySet.size === 0) return '0' + return [...mutePubkeySet].sort().join('\n') +} diff --git a/src/lib/relay-thread-heat-cache.ts b/src/lib/relay-thread-heat-cache.ts index 18782c46..5f89caf9 100644 --- a/src/lib/relay-thread-heat-cache.ts +++ b/src/lib/relay-thread-heat-cache.ts @@ -26,7 +26,9 @@ export function relayThreadHeatMapSettingKey( relayUrls: readonly string[], followPubkeys: readonly string[], /** Serialized home kind-picker state so cache invalidates when feed filters change. */ - feedFilterKey: string + feedFilterKey: string, + /** Sorted mute pubkeys so cache invalidates when mutes change. */ + muteFingerprint: string ): string { const pk = pubkey.trim().toLowerCase() const relayKey = digestHeatMapKeyPart([...relayUrls].sort().join('\n')) @@ -38,7 +40,8 @@ export function relayThreadHeatMapSettingKey( .join('\n') ) const feedKey = digestHeatMapKeyPart(feedFilterKey) - return `relayHeatV${CACHE_V}:${pk}:${relayKey}:${followKey}:${feedKey}` + const muteKey = digestHeatMapKeyPart(muteFingerprint) + return `relayHeatV${CACHE_V}:${pk}:${relayKey}:${followKey}:${feedKey}:${muteKey}` } export function parseRelayThreadHeatMapCache(raw: string | null): TRelayThreadHeatMapCacheEnvelope | null { diff --git a/src/pages/primary/SpellsPage/ProfileInteractionsMap.test.ts b/src/pages/primary/SpellsPage/ProfileInteractionsMap.test.ts new file mode 100644 index 00000000..23921c0c --- /dev/null +++ b/src/pages/primary/SpellsPage/ProfileInteractionsMap.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' +import { kinds } from 'nostr-tools' +import { mergeInteractionEvents } from './ProfileInteractionsMap' + +function interaction(pubkey: string, pTags: string[]) { + return { + kind: kinds.ShortTextNote, + pubkey, + tags: pTags.map((p) => ['p', p]), + content: '', + id: `${pubkey.slice(0, 8)}${'c'.repeat(56)}`, + sig: 's'.repeat(128), + created_at: 1_700_000_000 + } +} + +describe('mergeInteractionEvents', () => { + it('excludes muted partners and events authored by muted pubkeys', () => { + const profile = 'a'.repeat(64) + const partner = 'b'.repeat(64) + const muted = 'f'.repeat(64) + const cards = mergeInteractionEvents( + profile, + [ + interaction(profile, [partner]), + interaction(profile, [muted]), + interaction(muted, [profile]), + interaction(partner, [profile]) + ], + new Set([muted]) + ) + expect(cards.map((c) => c.pubkey)).toEqual([partner]) + expect(cards[0]?.score).toBe(2) + }) +}) diff --git a/src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx b/src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx index 66f7a357..263ed91c 100644 --- a/src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx +++ b/src/pages/primary/SpellsPage/ProfileInteractionsMap.tsx @@ -4,7 +4,9 @@ import { Button } from '@/components/ui/button' import { Card } from '@/components/ui/card' import { Skeleton } from '@/components/ui/skeleton' import { ExtendedKind } from '@/constants' +import { useMuteList } from '@/contexts/mute-list-context' import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' +import { muteSetHas } from '@/lib/mute-set' import { toProfile } from '@/lib/link' import { formatPubkey } from '@/lib/pubkey' import { cn } from '@/lib/utils' @@ -57,12 +59,18 @@ function interactionFilters(pubkey: string, limit: number): TSubRequestFilter[] ] } -function mergeInteractionEvents(targetPubkey: string, events: Event[]): InteractionCard[] { +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 = { @@ -112,6 +120,7 @@ export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) { const { t } = useTranslation() const { push } = useSecondaryPage() const { relayList, cacheRelayListEvent } = useNostr() + const { mutePubkeySet } = useMuteList() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const [cards, setCards] = useState([]) const [loading, setLoading] = useState(true) @@ -168,12 +177,12 @@ export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) { try { const local = await load(false) if (cancelled) return - setCards(mergeInteractionEvents(pubkey, local)) + setCards(mergeInteractionEvents(pubkey, local, mutePubkeySet)) setLoading(false) const all = await load(true) if (cancelled) return - setCards(mergeInteractionEvents(pubkey, all)) + setCards(mergeInteractionEvents(pubkey, all, mutePubkeySet)) } catch (e) { if (cancelled) return setError(e instanceof Error ? e.message : String(e)) @@ -187,7 +196,7 @@ export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) { return () => { cancelled = true } - }, [pubkey, refreshKey, load]) + }, [pubkey, refreshKey, load, mutePubkeySet]) return (
@@ -203,7 +212,7 @@ export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) { onClick={() => { setRefreshing(true) void load(true) - .then((rows) => setCards(mergeInteractionEvents(pubkey, rows))) + .then((rows) => setCards(mergeInteractionEvents(pubkey, rows, mutePubkeySet))) .catch((e) => setError(e instanceof Error ? e.message : String(e))) .finally(() => setRefreshing(false)) }} @@ -221,7 +230,7 @@ export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) { {loading && cards.length === 0 ? (
{Array.from({ length: 9 }).map((_, i) => ( - + ))}
) : error && cards.length === 0 ? ( @@ -234,7 +243,7 @@ export default function ProfileInteractionsMap({ pubkey, refreshKey }: Props) {
) : (
-
+
{cards.map((card, index) => ( diff --git a/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx index 617a560b..efdb3663 100644 --- a/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx +++ b/src/pages/primary/SpellsPage/RelayThreadHeatMap.tsx @@ -4,6 +4,7 @@ import { SimpleUserAvatar } from '@/components/UserAvatar' import { ExtendedKind } from '@/constants' import { eventPassesNoteListKindPicker } from '@/lib/feed-kind-filter' import { filterEventsExcludingTombstones } from '@/lib/event' +import { filterEventsExcludingMutedAuthors, mutePubkeySetFingerprint, muteSetHas } from '@/lib/mute-set' import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' import { toNote } from '@/lib/link' import logger from '@/lib/logger' @@ -22,6 +23,7 @@ import { type TRelayThreadHeatEdge } from '@/lib/relay-thread-heat' import { usePrimaryPage } from '@/contexts/primary-page-context' +import { useMuteList } from '@/contexts/mute-list-context' import { useSmartNoteNavigation } from '@/PageManager' import { encodeProfileInteractionsSpellId } from './fauxSpellConfig' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' @@ -101,6 +103,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) const { navigate: navigatePrimary } = usePrimaryPage() const { navigateToNote } = useSmartNoteNavigation() const { pubkey, relayList, cacheRelayListEvent } = useNostr() + const { mutePubkeySet } = useMuteList() const { favoriteRelays, blockedRelays } = useFavoriteRelays() const { showKinds, showKind1OPs, showKind1Replies, showKind1111 } = useKindFilterOrDefaults() @@ -142,9 +145,14 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) const [error, setError] = useState(null) const [rescanTick, setRescanTick] = useState(0) + const muteFingerprint = useMemo(() => mutePubkeySetFingerprint(mutePubkeySet), [mutePubkeySet]) + const cacheSettingKey = useMemo( - () => (pubkey ? relayThreadHeatMapSettingKey(pubkey, relayUrls, followPubkeys, feedFilterKey) : ''), - [pubkey, relayUrls, followPubkeys, feedFilterKey] + () => + pubkey + ? relayThreadHeatMapSettingKey(pubkey, relayUrls, followPubkeys, feedFilterKey, muteFingerprint) + : '', + [pubkey, relayUrls, followPubkeys, feedFilterKey, muteFingerprint] ) const mergeHeatMapData = useCallback(async (includeRelay = true): Promise<{ @@ -204,7 +212,10 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) dedup.set(ev.id.toLowerCase(), ev) } } - const merged = filterEventsExcludingTombstones([...dedup.values()], tombstones) + const merged = filterEventsExcludingMutedAuthors( + filterEventsExcludingTombstones([...dedup.values()], tombstones), + mutePubkeySet + ) const feedNotes = merged.filter((e) => eventPassesNoteListKindPicker(e, showKinds, showKind1OPs, showKind1Replies, showKind1111) ) @@ -227,6 +238,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) for (const ev of archived) { if (!verifyEvent(ev)) continue if (ev.kind !== kinds.ShortTextNote && ev.kind !== ExtendedKind.DISCUSSION) continue + if (muteSetHas(mutePubkeySet, ev.pubkey)) continue rootById.set(ev.id.toLowerCase(), ev) } const stillMissing = missingRootIds.filter((id) => !rootById.has(id)) @@ -244,6 +256,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) for (const ev of fetched) { if (!verifyEvent(ev)) continue if (ev.kind !== kinds.ShortTextNote && ev.kind !== ExtendedKind.DISCUSSION) continue + if (muteSetHas(mutePubkeySet, ev.pubkey)) continue rootById.set(ev.id.toLowerCase(), ev) } } @@ -270,7 +283,7 @@ export default function RelayThreadHeatMap({ followPubkeys, refreshKey }: Props) edges: edges.length }) return { bubbles, edges } - }, [relayUrls, followSet, showKinds, showKind1OPs, showKind1Replies, showKind1111]) + }, [relayUrls, followSet, showKinds, showKind1OPs, showKind1Replies, showKind1111, mutePubkeySet]) useEffect(() => { let cancelled = false diff --git a/src/pages/primary/SpellsPage/TopicKeywordHeatMap.test.ts b/src/pages/primary/SpellsPage/TopicKeywordHeatMap.test.ts index be472c2c..30e50cf0 100644 --- a/src/pages/primary/SpellsPage/TopicKeywordHeatMap.test.ts +++ b/src/pages/primary/SpellsPage/TopicKeywordHeatMap.test.ts @@ -38,4 +38,25 @@ describe('buildTopicKeywordBubbles', () => { expect(nostr?.pubkeys).toContain(pkC) expect(nostr?.pubkeys).toContain(pkB) }) + + it('excludes muted authors from counts and bubble avatars', () => { + const pkA = 'a'.repeat(64) + const pkMuted = 'f'.repeat(64) + const pkB = 'b'.repeat(64) + const bubbles = buildTopicKeywordBubbles( + [ + note(pkA, [['t', 'nostr']]), + note(pkMuted, [['t', 'nostr']], 'muted #nostr'), + note(pkB, [['t', 'nostr']]) + ], + DEFAULT_FEED_SHOW_KINDS, + true, + true, + true, + new Set([pkMuted]) + ) + const nostr = bubbles.find((b) => b.key === 'nostr') + expect(nostr?.score).toBe(2) + expect(nostr?.pubkeys).not.toContain(pkMuted) + }) }) diff --git a/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx b/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx index 15f2cebd..e5f424f6 100644 --- a/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx +++ b/src/pages/primary/SpellsPage/TopicKeywordHeatMap.tsx @@ -1,7 +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 { filterEventsExcludingTombstones } from '@/lib/event' import { extractHashtagsFromContent, formatTopicMapBubbleLabel, isValidNormalizedTopicKey, normalizeTopic } from '@/lib/discussion-topics' import { getRelayUrlsWithFavoritesFastReadAndInbox, userReadInboxUrls, userWriteOutboxUrls } from '@/lib/favorites-feed-relays' @@ -50,8 +52,13 @@ type TopicKeyAccum = { pubkeyHits: Map } -function topPubkeysForTopic(hits: Map, limit: number): string[] { +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) @@ -148,7 +155,8 @@ export function buildTopicKeywordBubbles( showKinds: readonly number[], showKind1OPs: boolean, showKind1Replies: boolean, - showKind1111: boolean + showKind1111: boolean, + mutePubkeySet?: ReadonlySet ): TTopicKeywordBubble[] { const accum = new Map() @@ -168,6 +176,7 @@ export function buildTopicKeywordBubbles( } 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) { @@ -192,7 +201,7 @@ export function buildTopicKeywordBubbles( score, topicNoteCount: row.topicNoteCount, keywordNoteCount: row.keywordNoteCount, - pubkeys: topPubkeysForTopic(row.pubkeyHits, MAX_BUBBLE_AVATARS) + pubkeys: topPubkeysForTopic(row.pubkeyHits, MAX_BUBBLE_AVATARS, mutePubkeySet) }) } out.sort((x, y) => y.score - x.score || x.key.localeCompare(y.key)) @@ -205,6 +214,7 @@ type Props = { export default function TopicKeywordHeatMap({ refreshKey }: Props) { const { t } = useTranslation() + const { mutePubkeySet } = useMuteList() const { navigateToHashtag } = useSmartHashtagNavigation() const { relayList, cacheRelayListEvent } = useNostr() const { favoriteRelays, blockedRelays } = useFavoriteRelays() @@ -276,9 +286,12 @@ export default function TopicKeywordHeatMap({ refreshKey }: Props) { dedup.set(ev.id.toLowerCase(), ev) } } - const clean = filterEventsExcludingTombstones([...dedup.values()], tombstones) - return buildTopicKeywordBubbles(clean, showKinds, showKind1OPs, showKind1Replies, showKind1111) - }, [relayUrls, showKinds, showKind1OPs, showKind1Replies, showKind1111]) + const clean = filterEventsExcludingMutedAuthors( + filterEventsExcludingTombstones([...dedup.values()], tombstones), + mutePubkeySet + ) + return buildTopicKeywordBubbles(clean, showKinds, showKind1OPs, showKind1Replies, showKind1111, mutePubkeySet) + }, [relayUrls, showKinds, showKind1OPs, showKind1Replies, showKind1111, mutePubkeySet]) useEffect(() => { let cancelled = false