diff --git a/desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs b/desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs index 4788bedbf2..06c8877122 100644 --- a/desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs +++ b/desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs @@ -12,6 +12,18 @@ const PUBKEY = "a".repeat(64); const OTHER = "b".repeat(64); const CHANNEL_ID = "channel-1"; const THREAD_ROOT = "c".repeat(64); +const THREAD_ROOT_2 = "d".repeat(64); + +const EMPTY_RELATIONSHIPS = { + participatedRootIds: new Set(), + followedRootIds: new Set(), + authoredRootIds: new Set(), + mutedRootIds: new Set(), +}; + +function readRelationships(overrides = {}) { + return () => ({ ...EMPTY_RELATIONSHIPS, ...overrides }); +} function event(overrides = {}) { return { @@ -35,6 +47,15 @@ function relayFor(filters) { }; } +// Helper: encode a mutes payload as JSON (decryptMutes stub returns content as-is) +function mutesContent(mutedIds) { + const channels = {}; + for (const id of mutedIds) { + channels[id] = { muted: true, updatedAt: 1 }; + } + return JSON.stringify({ version: 1, channels }); +} + test("extractMemberChannelIds deduplicates d tags", () => { assert.deepEqual( extractMemberChannelIds([ @@ -103,6 +124,7 @@ test("extractHiddenDmIds reads h tags from latest visibility snapshot", () => { test("fetchWorkspaceUnread returns dot and mention count without total unread count", async () => { const relay = relayFor([ + // 1. member events () => [ event({ tags: [ @@ -111,6 +133,7 @@ test("fetchWorkspaceUnread returns dot and mention count without total unread co ], }), ], + // 2. metadata events (parallel with visibility) () => [ event({ tags: [ @@ -119,8 +142,13 @@ test("fetchWorkspaceUnread returns dot and mention count without total unread co ], }), ], + // 3. visibility events (parallel with metadata) () => [], + // 4. read-state events (parallel with mutes) () => [], + // 5. mutes events (parallel with read-state) + () => [], + // 6. unread events () => [ event({ id: "unread".padEnd(64, "0"), @@ -128,6 +156,7 @@ test("fetchWorkspaceUnread returns dot and mention count without total unread co tags: [["h", CHANNEL_ID]], }), ], + // 7. mention events () => [ event({ id: "mention".padEnd(64, "0"), @@ -145,6 +174,8 @@ test("fetchWorkspaceUnread returns dot and mention count without total unread co pubkey: PUBKEY, nowSeconds: 100, decryptReadState: async (value) => value, + decryptMutes: async (value) => value, + readThreadRelationships: readRelationships(), }); assert.deepEqual(result, { hasUnread: true, mentionCount: 1 }); @@ -173,6 +204,7 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events" }); const relay = relayFor([ + // 1. member events () => [ event({ tags: [ @@ -181,6 +213,7 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events" ], }), ], + // 2. metadata events (parallel with visibility) () => [ event({ tags: [ @@ -189,7 +222,9 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events" ], }), ], + // 3. visibility events (parallel with metadata) () => [], + // 4. read-state events (parallel with mutes) () => [ event({ pubkey: PUBKEY, @@ -208,7 +243,11 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events" }), }), ], + // 5. mutes events (parallel with read-state) + () => [], + // 6. unread events () => [threadReply, selfMention], + // 7. mention events () => [threadReply, selfMention], ]); @@ -217,6 +256,386 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events" pubkey: PUBKEY, nowSeconds: 100, decryptReadState: async (value) => value, + decryptMutes: async (value) => value, + readThreadRelationships: readRelationships(), + }); + + assert.deepEqual(result, { hasUnread: false, mentionCount: 0 }); +}); + +test("fetchWorkspaceUnread excludes muted-only channel — returns hasUnread:false mentionCount:0", async () => { + const MUTED_CHANNEL = "muted-channel-1"; + + const relay = relayFor([ + // 1. member events — one muted channel + () => [ + event({ + tags: [ + ["d", MUTED_CHANNEL], + ["p", PUBKEY], + ], + }), + ], + // 2. metadata events (parallel with visibility) + () => [ + event({ + tags: [ + ["d", MUTED_CHANNEL], + ["t", "stream"], + ], + }), + ], + // 3. visibility events (parallel with metadata) + () => [], + // 4. read-state events (parallel with mutes) + () => [], + // 5. mutes events — MUTED_CHANNEL is muted + () => [ + event({ + pubkey: PUBKEY, + content: mutesContent([MUTED_CHANNEL]), + }), + ], + // No per-channel fetches should follow — muted channel is skipped + ]); + + const result = await fetchWorkspaceUnread({ + client: relay, + pubkey: PUBKEY, + nowSeconds: 100, + decryptReadState: async (value) => value, + decryptMutes: async (value) => value, + readThreadRelationships: readRelationships(), + }); + + assert.deepEqual(result, { hasUnread: false, mentionCount: 0 }); +}); + +test("fetchWorkspaceUnread counts unmuted channel but skips muted channel", async () => { + const UNMUTED_CHANNEL = "channel-unmuted"; + const MUTED_CHANNEL = "channel-muted"; + + const relay = relayFor([ + // 1. member events — two channels + () => [ + event({ + tags: [ + ["d", UNMUTED_CHANNEL], + ["d", MUTED_CHANNEL], + ["p", PUBKEY], + ], + }), + ], + // 2. metadata events (parallel with visibility) + () => [ + event({ + tags: [ + ["d", UNMUTED_CHANNEL], + ["t", "stream"], + ], + }), + event({ + tags: [ + ["d", MUTED_CHANNEL], + ["t", "stream"], + ], + }), + ], + // 3. visibility events (parallel with metadata) + () => [], + // 4. read-state events (parallel with mutes) + () => [], + // 5. mutes events — only MUTED_CHANNEL is muted + () => [ + event({ + pubkey: PUBKEY, + content: mutesContent([MUTED_CHANNEL]), + }), + ], + // 6. unread events for UNMUTED_CHANNEL (muted channel loop iteration never fires) + () => [ + event({ + id: "unread".padEnd(64, "0"), + created_at: 20, + tags: [["h", UNMUTED_CHANNEL]], + }), + ], + // 7. mention events for UNMUTED_CHANNEL + () => [ + event({ + id: "mention".padEnd(64, "0"), + created_at: 30, + tags: [ + ["h", UNMUTED_CHANNEL], + ["p", PUBKEY], + ], + }), + ], + ]); + + const result = await fetchWorkspaceUnread({ + client: relay, + pubkey: PUBKEY, + nowSeconds: 100, + decryptReadState: async (value) => value, + decryptMutes: async (value) => value, + readThreadRelationships: readRelationships(), + }); + + assert.deepEqual(result, { hasUnread: true, mentionCount: 1 }); +}); + +test("fetchWorkspaceUnread treats decryption failure as empty mutes set", async () => { + const relay = relayFor([ + // 1. member events + () => [ + event({ + tags: [ + ["d", CHANNEL_ID], + ["p", PUBKEY], + ], + }), + ], + // 2. metadata events (parallel with visibility) + () => [ + event({ + tags: [ + ["d", CHANNEL_ID], + ["t", "stream"], + ], + }), + ], + // 3. visibility events (parallel with metadata) + () => [], + // 4. read-state events (parallel with mutes) + () => [], + // 5. mutes events — present but decryption will throw + () => [ + event({ + pubkey: PUBKEY, + content: "corrupted-ciphertext", + }), + ], + // 6. unread events — channel is NOT muted (decryption failed → empty set) + () => [ + event({ + id: "unread".padEnd(64, "0"), + created_at: 20, + tags: [["h", CHANNEL_ID]], + }), + ], + // 7. mention events + () => [], + ]); + + const result = await fetchWorkspaceUnread({ + client: relay, + pubkey: PUBKEY, + nowSeconds: 100, + decryptReadState: async (value) => value, + decryptMutes: async () => { + throw new Error("decryption failed"); + }, + readThreadRelationships: readRelationships(), + }); + + // Channel counted as if no mutes + assert.deepEqual(result, { hasUnread: true, mentionCount: 0 }); +}); + +test("fetchWorkspaceUnread treats absent mutes blob as empty mutes set", async () => { + const relay = relayFor([ + // 1. member events + () => [ + event({ + tags: [ + ["d", CHANNEL_ID], + ["p", PUBKEY], + ], + }), + ], + // 2. metadata events (parallel with visibility) + () => [ + event({ + tags: [ + ["d", CHANNEL_ID], + ["t", "stream"], + ], + }), + ], + // 3. visibility events (parallel with metadata) + () => [], + // 4. read-state events (parallel with mutes) + () => [], + // 5. mutes events — none + () => [], + // 6. unread events + () => [ + event({ + id: "unread".padEnd(64, "0"), + created_at: 20, + tags: [["h", CHANNEL_ID]], + }), + ], + // 7. mention events + () => [], + ]); + + const result = await fetchWorkspaceUnread({ + client: relay, + pubkey: PUBKEY, + nowSeconds: 100, + decryptReadState: async (value) => value, + decryptMutes: async (value) => value, + readThreadRelationships: readRelationships(), + }); + + assert.deepEqual(result, { hasUnread: true, mentionCount: 0 }); +}); + +// ── Thread-relevance gate tests ──────────────────────────────────────────── + +function threadedReplyEvent(overrides = {}) { + return event({ + id: overrides.id ?? "reply".padEnd(64, "0"), + created_at: overrides.created_at ?? 20, + pubkey: overrides.pubkey ?? OTHER, + tags: [ + ["h", CHANNEL_ID], + ["e", THREAD_ROOT_2, "", "root"], + ["e", "parent".padEnd(64, "0"), "", "reply"], + ...(overrides.extraTags ?? []), + ], + ...overrides, + }); +} + +function baseRelay(unreadEvent, mutesPayload = null) { + return relayFor([ + // 1. member events + () => [ + event({ + tags: [ + ["d", CHANNEL_ID], + ["p", PUBKEY], + ], + }), + ], + // 2. metadata events (parallel with visibility) + () => [ + event({ + tags: [ + ["d", CHANNEL_ID], + ["t", "stream"], + ], + }), + ], + // 3. visibility events (parallel with metadata) + () => [], + // 4. read-state events (parallel with mutes) + () => [], + // 5. mutes events + () => + mutesPayload ? [event({ pubkey: PUBKEY, content: mutesPayload })] : [], + // 6. unread events — the single event under test + () => [unreadEvent], + // 7. mention events + () => [], + ]); +} + +test("fetchWorkspaceUnread threaded reply in untracked root → hasUnread:false", async () => { + const relay = baseRelay(threadedReplyEvent()); + + const result = await fetchWorkspaceUnread({ + client: relay, + pubkey: PUBKEY, + nowSeconds: 100, + decryptReadState: async (v) => v, + decryptMutes: async (v) => v, + // No root in any set → gate rejects the threaded reply + readThreadRelationships: readRelationships(), + }); + + assert.deepEqual(result, { hasUnread: false, mentionCount: 0 }); +}); + +test("fetchWorkspaceUnread threaded reply in participatedRootIds → hasUnread:true", async () => { + const relay = baseRelay(threadedReplyEvent()); + + const result = await fetchWorkspaceUnread({ + client: relay, + pubkey: PUBKEY, + nowSeconds: 100, + decryptReadState: async (v) => v, + decryptMutes: async (v) => v, + readThreadRelationships: readRelationships({ + participatedRootIds: new Set([THREAD_ROOT_2]), + }), + }); + + assert.deepEqual(result, { hasUnread: true, mentionCount: 0 }); +}); + +test("fetchWorkspaceUnread #p-mention reply in untracked root → hasUnread:true (mention overrides)", async () => { + // A @mention of the user bypasses the follow/participation gate + const relay = baseRelay( + threadedReplyEvent({ + id: "mention-reply".padEnd(64, "0"), + extraTags: [["p", PUBKEY]], + }), + ); + + const result = await fetchWorkspaceUnread({ + client: relay, + pubkey: PUBKEY, + nowSeconds: 100, + decryptReadState: async (v) => v, + decryptMutes: async (v) => v, + readThreadRelationships: readRelationships(), + }); + + assert.deepEqual(result, { hasUnread: true, mentionCount: 0 }); +}); + +test("fetchWorkspaceUnread top-level post → hasUnread:true (no thread gate)", async () => { + // Top-level posts have no parentId — shouldNotifyForEvent returns true + const relay = baseRelay( + event({ + id: "toplevel".padEnd(64, "0"), + created_at: 20, + tags: [["h", CHANNEL_ID]], + }), + ); + + const result = await fetchWorkspaceUnread({ + client: relay, + pubkey: PUBKEY, + nowSeconds: 100, + decryptReadState: async (v) => v, + decryptMutes: async (v) => v, + readThreadRelationships: readRelationships(), + }); + + assert.deepEqual(result, { hasUnread: true, mentionCount: 0 }); +}); + +test("fetchWorkspaceUnread threaded reply whose root is in mutedRootIds → hasUnread:false", async () => { + const relay = baseRelay( + threadedReplyEvent({ id: "muted-reply".padEnd(64, "0") }), + ); + + const result = await fetchWorkspaceUnread({ + client: relay, + pubkey: PUBKEY, + nowSeconds: 100, + decryptReadState: async (v) => v, + decryptMutes: async (v) => v, + // Root is participated but also muted — mute wins + readThreadRelationships: readRelationships({ + participatedRootIds: new Set([THREAD_ROOT_2]), + mutedRootIds: new Set([THREAD_ROOT_2]), + }), }); assert.deepEqual(result, { hasUnread: false, mentionCount: 0 }); diff --git a/desktop/src/features/workspaces/workspaceUnreadObserver.ts b/desktop/src/features/workspaces/workspaceUnreadObserver.ts index 8e8ea5e70c..2eb28a9e3c 100644 --- a/desktop/src/features/workspaces/workspaceUnreadObserver.ts +++ b/desktop/src/features/workspaces/workspaceUnreadObserver.ts @@ -1,3 +1,4 @@ +import { makeRootIdStore } from "@/features/channels/unreadRootIdStore"; import { DM_NOTIFIABLE_EVENT_KINDS } from "@/features/channels/isDmNotifiableKind"; import { mergeReadStateEvents } from "@/features/channels/readState/readStateSnapshot"; import { @@ -8,13 +9,20 @@ import { getThreadReference, isBroadcastReply, } from "@/features/messages/lib/threading"; +import { shouldNotifyForEvent } from "@/features/notifications/lib/shouldNotify"; +import { + mutedChannelIdsFromStore, + parseMutePayload, +} from "@/features/sidebar/lib/channelMutesStorage"; import type { Workspace } from "@/features/workspaces/types"; import { withReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient"; import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared"; +import { nip44DecryptFromSelf } from "@/shared/api/tauri"; import type { ChannelType, RelayEvent } from "@/shared/api/types"; import { CHANNEL_MESSAGE_EVENT_KINDS, HOME_MENTION_EVENT_KINDS, + KIND_CHANNEL_MUTES, KIND_DM_VISIBILITY, KIND_READ_STATE, } from "@/shared/constants/kinds"; @@ -22,6 +30,53 @@ import { const KIND_NIP29_GROUP_METADATA = 39000; const KIND_NIP29_GROUP_MEMBERS = 39002; +// Stores for thread-relationship sets. Keyed by pubkey only (no relay/workspace), +// so they read correctly from the same origin regardless of which workspace is active. +const participationStore = makeRootIdStore("buzz-thread-participation.v1"); +const authoredStore = makeRootIdStore("buzz-thread-authored.v1"); +const mutedRootsStore = makeRootIdStore("buzz-thread-muted.v1"); +const FOLLOWS_STORAGE_KEY_PREFIX = "buzz-thread-follows.v1"; + +export type ThreadRelationships = { + participatedRootIds: ReadonlySet; + followedRootIds: ReadonlySet; + authoredRootIds: ReadonlySet; + mutedRootIds: ReadonlySet; +}; + +function readFollowedRootIds(pubkey: string): Set { + try { + const raw = window.localStorage.getItem( + `${FOLLOWS_STORAGE_KEY_PREFIX}:${pubkey}`, + ); + if (!raw) return new Set(); + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return new Set(); + const ids = new Set(); + for (const entry of parsed) { + if ( + typeof entry === "object" && + entry !== null && + typeof entry.rootId === "string" + ) { + ids.add(entry.rootId); + } + } + return ids; + } catch { + return new Set(); + } +} + +function defaultReadThreadRelationships(pubkey: string): ThreadRelationships { + return { + participatedRootIds: participationStore.read(pubkey), + followedRootIds: readFollowedRootIds(pubkey), + authoredRootIds: authoredStore.read(pubkey), + mutedRootIds: mutedRootsStore.read(pubkey), + }; +} + const MEMBER_CHANNEL_LIMIT = 1000; const METADATA_LIMIT = 1000; const UNREAD_EXISTENCE_LIMIT = 50; @@ -98,32 +153,68 @@ export async function fetchWorkspaceUnread(args: { pubkey: string; nowSeconds?: number; decryptReadState?: (ciphertext: string) => Promise; + decryptMutes?: (ciphertext: string) => Promise; + readThreadRelationships?: (pubkey: string) => ThreadRelationships; }): Promise { const { client, pubkey } = args; const normalizedPubkey = pubkey.toLowerCase(); const nowSeconds = args.nowSeconds ?? Math.floor(Date.now() / 1_000); + const decryptMutes = args.decryptMutes ?? nip44DecryptFromSelf; + const readRelationships = + args.readThreadRelationships ?? defaultReadThreadRelationships; const channels = await fetchObservedChannels(client, pubkey); if (channels.length === 0) { return { hasUnread: false, mentionCount: 0 }; } - const readStateEvents = await client.fetchEvents({ - kinds: [KIND_READ_STATE], - authors: [pubkey], - "#t": ["read-state"], - since: nowSeconds - READ_STATE_HORIZON_SECONDS, - limit: READ_STATE_FETCH_LIMIT, - }); + + const [readStateEvents, mutesEvents] = await Promise.all([ + client.fetchEvents({ + kinds: [KIND_READ_STATE], + authors: [pubkey], + "#t": ["read-state"], + since: nowSeconds - READ_STATE_HORIZON_SECONDS, + limit: READ_STATE_FETCH_LIMIT, + }), + client.fetchEvents({ + kinds: [KIND_CHANNEL_MUTES], + authors: [pubkey], + "#d": ["channel-mutes"], + limit: 1, + }), + ]); + const readState = await mergeReadStateEvents( readStateEvents, pubkey, args.decryptReadState, ); + let mutedIds = new Set(); + if (mutesEvents.length > 0) { + try { + const plaintext = await decryptMutes(mutesEvents[0].content); + const store = parseMutePayload(JSON.parse(plaintext)); + if (store) { + mutedIds = mutedChannelIdsFromStore(store); + } + } catch { + // decryption failure → treat as empty mutes set + } + } + + const { + participatedRootIds, + followedRootIds, + authoredRootIds, + mutedRootIds, + } = readRelationships(normalizedPubkey); + let hasUnread = false; let mentionCount = 0; for (const channel of channels) { + if (mutedIds.has(channel.id)) continue; const readAt = readState.get(channel.id) ?? null; const since = readAt === null ? 0 : readAt + 1; const kinds = unreadKindsForChannel(channel.channelType); @@ -150,8 +241,17 @@ export async function fetchWorkspaceUnread(args: { ]); if (!hasUnread) { - hasUnread = unreadEvents.some((event) => - isUnreadExternalEvent(event, readState, readAt, normalizedPubkey), + hasUnread = unreadEvents.some( + (event) => + isUnreadExternalEvent(event, readState, readAt, normalizedPubkey) && + shouldNotifyForEvent(event, normalizedPubkey, { + participatedRootIds, + followedRootIds, + authoredRootIds, + mutedRootIds, + mutedChannelIds: mutedIds, + channelId: channel.id, + }), ); }