From ccff73ed2bdb7740cdcc75cd33e53bea11b08e7e Mon Sep 17 00:00:00 2001 From: Aaron Young Date: Tue, 3 Nov 2026 13:47:14 -0500 Subject: [PATCH] fix(notifications): keep muted channel backlog off the Dock dot #2004 quieted ordinary unread for confirmed-muted channels in the sidebar but left the macOS Dock dot counting it, so a muted channel's backlog could keep the dot on with nothing unread in the sidebar. The Dock binding now uses the sidebar's own row rule, moved to features/channels/channel-unread.ts and shared with UnreadBadge: confirmed mute quiets ordinary backlog, while manual unread and attention still count. DMs and Solo chats ignore mute, as their sidebar rows do. The binding also follows sidebar preferences, so mute and unmute recompute the dot without a new message. Co-authored-by: Robert the Fabricator <56cd60af5ced82e809b11f226df24e5972e251b0e20ca1af4141d2b276410ee0@buzz.block.builderlab.xyz> Co-Authored-By: Claude Opus 5.5 Signed-off-by: Aaron Young --- docs/buzz-help.md | 4 +- docs/notifications.md | 7 +- src/bundled/channels/UnreadBadge.tsx | 17 +++-- src/features/channels/channel-unread.ts | 23 ++++++ .../notifications/indicator-unread.test.ts | 76 +++++++++++++++++++ .../notifications/indicator-unread.ts | 56 ++++++++------ 6 files changed, 150 insertions(+), 33 deletions(-) create mode 100644 src/features/channels/channel-unread.ts diff --git a/docs/buzz-help.md b/docs/buzz-help.md index 0581e07edf..89b640522e 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -91,7 +91,9 @@ the interface unless an available tool actually supports that action. Mute suppresses notifications for messages that do not mention you; mentions can still notify you. Unmute restores ordinary unread emphasis and dots. Manual **Mark as unread** can still make the name bold. Muted rows show no unread dots, including thread - dots; muting does not mark messages or threads as read. + dots; muting does not mark messages or threads as read. On macOS, a muted + channel lights the Dock dot only for messages that need your attention, such + as mentions, or when you mark it unread; muted DMs and Solo chats still light it. - **Manage members** shows **General** access first in the member list. **Public** lets people in the community read and new people participate; **View only** lets them read; **Private** limits access to people added to the channel. diff --git a/docs/notifications.md b/docs/notifications.md index 65dc58e064..d66b5c258c 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -200,7 +200,12 @@ observed unread messages (including thread replies) or explicit channel-unread intent. It is not an exact message count or evidence of complete history. Unknown and observed-zero both omit the dot. Existing bounded evidence/read-state owns startup and updates; this projection adds no relay reads, network subscriptions, -or storage. Channel mute does not remove retained unread evidence from this community-wide dot, so muted ordinary backlog can keep it on while the channel's ordinary sidebar cues are quiet. Personal space, account/session changes, access loss and host disposal +or storage. Each channel counts only when the sidebar treats it as unread: confirmed +channel mute quiets ordinary backlog, while manual unread and observed attention +(mentions, broadcasts and replies in the viewer's conversations) still light the dot. Muted DMs and Solo chats keep +lighting it, as they keep their sidebar unread state. Mute and unmute recompute the +dot without a new message; a pending mute change, or preferences not yet loaded, +quiet nothing. Personal space, account/session changes, access loss and host disposal clear or recompute the indicator. Disabling Channels does not stop host ownership. Desktop alert preferences do not alter this unread indicator. diff --git a/src/bundled/channels/UnreadBadge.tsx b/src/bundled/channels/UnreadBadge.tsx index 6a3e8b9a57..0877176600 100644 --- a/src/bundled/channels/UnreadBadge.tsx +++ b/src/bundled/channels/UnreadBadge.tsx @@ -6,6 +6,10 @@ import { useSyncExternalStore, type ReactNode, } from "react"; +import { + channelUnread, + confirmedMute, +} from "../../features/channels/channel-unread"; import type { RelaySession } from "../../features/relay/session"; import styles from "./Channels.module.css"; @@ -24,12 +28,11 @@ export function ordinaryUnread( ); } -/** Observe only confirmed preferences; optimistic menu intents do not quiet unread. */ export function useConfirmedMute(session: RelaySession, channelId: string) { - const get = useCallback(() => { - const snapshot = session.sidebarPreferences.snapshot(); - return !snapshot.cached && !!snapshot.data?.muted.includes(channelId); - }, [session, channelId]); + const get = useCallback( + () => confirmedMute(session.sidebarPreferences.snapshot(), channelId), + [session, channelId], + ); return useSyncExternalStore(session.sidebarPreferences.subscribe, get, get); } @@ -69,9 +72,7 @@ export function useUnreadState( const muted = useConfirmedMute(session, channelId); const count = snapshot.observedCount; const manual = snapshot.manual !== "none"; - const unread = - manual || - ((count ?? 0) > 0 && (dm || !muted || (snapshot.attentionCount ?? 0) > 0)); + const unread = channelUnread(snapshot, !dm && muted); const ordinary = ordinaryUnread(snapshot, dm, muted); const threadCount = activity.items?.length ?? 0; return { diff --git a/src/features/channels/channel-unread.ts b/src/features/channels/channel-unread.ts new file mode 100644 index 0000000000..6d10010a3e --- /dev/null +++ b/src/features/channels/channel-unread.ts @@ -0,0 +1,23 @@ +import type { RelaySession } from "../relay/session"; +import type { UnreadSnapshot } from "../relay/unread"; + +type PreferencesSnapshot = ReturnType< + RelaySession["sidebarPreferences"]["snapshot"] +>; + +/** Only confirmed preferences count; optimistic menu intents do not quiet unread. */ +export function confirmedMute( + preferences: PreferencesSnapshot, + channelId: string, +) { + return !preferences.cached && !!preferences.data?.muted.includes(channelId); +} + +/** A row's unread state: mute quiets ordinary backlog, never manual unread or attention. */ +export function channelUnread(snapshot: UnreadSnapshot, muted: boolean) { + return ( + snapshot.manual !== "none" || + ((snapshot.observedCount ?? 0) > 0 && + (!muted || (snapshot.attentionCount ?? 0) > 0)) + ); +} diff --git a/src/features/notifications/indicator-unread.test.ts b/src/features/notifications/indicator-unread.test.ts index eea7131811..4b4b81203a 100644 --- a/src/features/notifications/indicator-unread.test.ts +++ b/src/features/notifications/indicator-unread.test.ts @@ -3,6 +3,7 @@ import type { Communities } from "../communities/service"; import { createRelaySession } from "../relay/session"; import type { RelayEvent } from "../relay/events"; import { readJournal, type ReadJournal } from "../relay/read-state-storage"; +import { createSidebarPreferencesStore } from "../relay/sidebar-preferences-store"; import { keypair, message, metadata, roster, signed } from "../relay/testing"; import { bindUnreadIndicator } from "./indicator-unread"; @@ -100,6 +101,37 @@ function setup() { }, }; } +/** Confirms "room" muted; the returned setter replaces the confirmed list. */ +async function withMute( + h: ReturnType, + channelType?: "dm" | "solo", +) { + let muted = ["room"]; + const preferences = createSidebarPreferencesStore({ + available: true, + read: async () => ({ sections: [], assignments: {}, starred: [], muted }), + }); + cleanups.push(() => preferences.dispose()); + await preferences.queries.refresh(); + const original = h.session; + h.relayState.session = { + ...original, + sidebarPreferences: preferences.queries, + channels: { + ...original.channels, + list: () => ({ + ...original.channels.list(), + channels: original.channels + .list() + .channels.map((c) => (channelType ? { ...c, channelType } : c)), + }), + }, + }; + return async (next: string[]) => { + muted = next; + await preferences.queries.refresh(); + }; +} const target = { kind: "channel", channelId: "room" } as const; it("starts clear for unknown evidence; projects arrivals and explicit read clearing without new reads", async () => { const h = setup(); @@ -239,3 +271,47 @@ it("stays off for coordination between agents and lights for the next message to h.emit([message(h.peer, "room", "Done", 12)]); expect(h.project).toHaveBeenLastCalledWith(true); }); + +it("keeps confirmed-muted backlog off the Dock; unmute and re-mute flip it without new messages", async () => { + const h = setup(); + h.grant(); + const setMuted = await withMute(h); + h.bind(); + h.emit([message(h.peer, "room", "muted backlog", 11)]); + expect(h.session.unread.snapshot(target).observedCount).toBe(1); + expect(h.project.mock.calls).toEqual([[false]]); + await setMuted([]); + expect(h.project).toHaveBeenLastCalledWith(true); + await setMuted(["room"]); + expect(h.project).toHaveBeenLastCalledWith(false); +}); + +it("lights a muted channel for attention and manual unread", async () => { + const h = setup(); + h.grant(); + await withMute(h); + h.bind(); + h.emit([message(h.peer, "room", "ordinary", 11)]); + expect(h.project.mock.calls).toEqual([[false]]); + const mention = message(h.peer, "room", "mention", 12, [ + ["p", h.viewer.pubkey], + ]); + h.emit([mention]); + expect(h.project).toHaveBeenLastCalledWith(true); + await h.session.unread.markThrough(target, mention.id); + expect(h.project).toHaveBeenLastCalledWith(false); + await h.session.unread.markUnreadLocal(target); + expect(h.project).toHaveBeenLastCalledWith(true); +}); + +it.each(["dm", "solo"] as const)( + "keeps muted %s backlog on the Dock, as the sidebar does", + async (channelType) => { + const h = setup(); + h.grant(); + await withMute(h, channelType); + h.bind(); + h.emit([message(h.peer, "room", "arrival", 11)]); + expect(h.project).toHaveBeenLastCalledWith(true); + }, +); diff --git a/src/features/notifications/indicator-unread.ts b/src/features/notifications/indicator-unread.ts index 5d81ad0455..aaec602570 100644 --- a/src/features/notifications/indicator-unread.ts +++ b/src/features/notifications/indicator-unread.ts @@ -1,3 +1,4 @@ +import { channelUnread, confirmedMute } from "../channels/channel-unread"; import type { Communities } from "../communities/service"; import type { RelaySession } from "../relay/session"; @@ -10,17 +11,21 @@ export function bindUnreadIndicator( let session: RelaySession | undefined; let identity = ""; let previous: boolean | undefined; - let stopRoster = () => {}; + let detach = () => {}; const channels = new Map void>(); + // DMs and Solo chats keep unread emphasis when muted, as in the sidebar. + const mutable = new Set(); const publish = (unread: boolean) => { if (unread === previous) return; previous = unread; project(unread); }; const clear = () => { - stopRoster(); + detach(); + detach = () => {}; for (const stop of channels.values()) stop(); channels.clear(); + mutable.clear(); }; const update = () => { if (closed) return; @@ -44,31 +49,31 @@ export function bindUnreadIndicator( const valid = () => !closed && session === owned && identity === next; const changed = () => { if (!valid()) return; + const preferences = owned.sidebarPreferences.snapshot(); publish( - [...channels.keys()].some((channelId) => { - const snapshot = owned.unread.snapshot({ - kind: "channel", - channelId, - }); - return ( - snapshot.manual !== "none" || (snapshot.observedCount ?? 0) > 0 - ); - }), + [...channels.keys()].some((channelId) => + channelUnread( + owned.unread.snapshot({ kind: "channel", channelId }), + mutable.has(channelId) && confirmedMute(preferences, channelId), + ), + ), ); }; const roster = () => { if (!valid()) return; - const ids = new Set( - owned.channels - .list() - .channels.filter( - (channel) => - !channel.huddle && - !channel.metadataPending && - channel.members?.includes(viewer), - ) - .map((channel) => channel.id), - ); + const accessible = owned.channels + .list() + .channels.filter( + (channel) => + !channel.huddle && + !channel.metadataPending && + channel.members?.includes(viewer), + ); + const ids = new Set(accessible.map((channel) => channel.id)); + mutable.clear(); + for (const channel of accessible) + if (channel.channelType !== "dm" && channel.channelType !== "solo") + mutable.add(channel.id); for (const [id, stop] of channels) { if (ids.has(id)) continue; stop(); @@ -83,7 +88,12 @@ export function bindUnreadIndicator( } changed(); }; - stopRoster = owned.channels.subscribeList(roster); + const stopList = owned.channels.subscribeList(roster); + const stopPreferences = owned.sidebarPreferences.subscribe(changed); + detach = () => { + stopList(); + stopPreferences(); + }; roster(); }; const stop = communities.subscribe(update);