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);