diff --git a/docs/buzz-help.md b/docs/buzz-help.md index fae352100a..6399d179d0 100644 --- a/docs/buzz-help.md +++ b/docs/buzz-help.md @@ -63,6 +63,13 @@ the interface unless an available tool actually supports that action. stopped reading, or at the latest message once you had caught up. You and the person asking may have different channel access. A link alone never grants access. +- **Mute** is available from a channel or DM's sidebar context menu. Once + confirmed, a crossed-out bell appears immediately after its name. New messages + do not make muted channel names bold; muted DM names still become bold. + 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. - **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 278538d87c..65dc58e064 100644 --- a/docs/notifications.md +++ b/docs/notifications.md @@ -80,7 +80,14 @@ checks, not that an OS banner was displayed or read. preference, independently of whether Channels is mounted. Muted channels suppress DM and participating-thread alerts; explicit mentions still pass the channel-mute gate, not global off/category/read/access/permission gates. This does not introduce - a broadcast notification category. Confirmed mute quiets ordinary channel unread styling and offscreen cues without clearing unread evidence; manual unread, directed attention and unread thread activity remain visible. Solo and DM unread emphasis is preserved. Unread thread cues are distinct from participating-thread alerts, which mute suppresses. Unknown or failed + a broadcast notification category. Confirmed mute quiets ordinary channel unread + styling and offscreen cues without clearing unread evidence. Muted channel and + DM sidebar rows show a crossed-out bell immediately after their names and hide + unread dots, including thread dots. Manual unread can still emphasize + the name, and thread activity remains available. Muted DMs retain automatic + unread emphasis, offscreen unread counts and avatar previews. Solo chat labels + retain unread emphasis. Unread thread cues are distinct from participating-thread alerts, + which mute suppresses. Unknown or failed preference reads hold non-mention candidates until explicit retry; a confirmed mute cancels pending candidates, including an in-flight permission check. Unmute does not replay cancelled alerts. Existing shown OS banners are not withdrawn. diff --git a/docs/unread.md b/docs/unread.md index 3a57eda16d..d072e80114 100644 --- a/docs/unread.md +++ b/docs/unread.md @@ -165,10 +165,20 @@ timeline and the thread being read, without reading unopened threads' replies. The sidebar separates ordinary unread from directed attention. Ordinary backlog or local manual intent strengthens the channel label; protected attention alone -retains its dot without bolding a caught-up channel. Confirmed-muted channels suppress ordinary automatic unread styling and offscreen cues without clearing unread evidence; explicit manual unread, protected attention and unread thread activity remain visible. Pending or failed mute changes do not suppress these cues. DMs retain unread bolding. +retains its dot without bolding a caught-up, unmuted channel. Confirmed-muted channels +suppress ordinary automatic unread styling and offscreen cues without clearing +unread evidence; explicit manual unread can still strengthen the label. +Pending or failed mute changes do not suppress these cues. Muted DMs retain +automatic unread bolding, “N unread” offscreen counts and avatar previews. Muted +channel and DM sidebar rows show a crossed-out bell immediately after their names, +even with no unread activity. They hide all unread dots, including +thread activity dots. Thread activity remains available through the row's activity popover, +and its unread count remains accessible to assistive technology. Unmute restores +the dots from retained unread evidence. The promoted Bestie DM also hides muted +unread dots; its label keeps ordinary DM unread emphasis. Solo chat labels strengthen for any observed unread message, including mentions, broadcasts and relevant replies, even when muted, so Solo rows omit the attention dot. -Ordinary unread renders no row marker. DMs, mentions, broadcasts, and +Ordinary unread renders no row marker. Unmuted DMs, mentions, broadcasts, and relevant thread replies add one accent dot; non-DM row numerals are omitted and DM avatars are reserved for promoted offscreen cues. Thread activity reuses that dot: its hover/focus/click popover groups unread replies by canonical thread root and diff --git a/src/app/App.profile.test.tsx b/src/app/App.profile.test.tsx index 7bfa5dd073..4c39a320e6 100644 --- a/src/app/App.profile.test.tsx +++ b/src/app/App.profile.test.tsx @@ -438,7 +438,7 @@ it.each([ screen.getByRole("dialog", { name: "Couldn’t mute Planning" }), ).toHaveTextContent("Relay request failed (503)"); } - const returnToMessages = async () => { + const returnToMessages = async (muted = false) => { await act(async () => { await services?.navigation.open({ version: 1, @@ -450,7 +450,9 @@ it.each([ const sidebar = await screen.findByRole("complementary", { name: "Sidebar", }); - return within(sidebar).findByRole("button", { name: "Planning" }); + return within(sidebar).findByRole("button", { + name: muted ? /^Planning\s*Muted$/ : "Planning", + }); }; const returnedRow = from === "Messages" ? await returnToMessages() : undefined; @@ -488,7 +490,7 @@ it.each([ screen.getByRole("complementary", { name: "Beacon sidebar" }), ).toBeVisible(); expect(screen.getByText("Beacon page content")).toBeVisible(); - await returnToMessages(); + await returnToMessages(action === "Retry"); expect( screen.queryByRole("dialog", { name: "Couldn’t mute Planning" }), ).toBeNull(); diff --git a/src/app/shell/BestieNavigation.test.tsx b/src/app/shell/BestieNavigation.test.tsx index 379421b364..f2660e9d64 100644 --- a/src/app/shell/BestieNavigation.test.tsx +++ b/src/app/shell/BestieNavigation.test.tsx @@ -18,6 +18,7 @@ import { localAgentsFixture } from "../../features/agents/local-testing"; import type { RegisteredPage } from "../../features/pages/service"; import type { RelayData, RelaySnapshot } from "../../features/relay/service"; import { createRelaySession } from "../../features/relay/session"; +import { createSidebarPreferencesStore } from "../../features/relay/sidebar-preferences-store"; import { keypair, message, @@ -286,13 +287,25 @@ it("does not demand legacy inventory when the Bestie page is disabled", async () expect(f.libraryRead).not.toHaveBeenCalled(); }); -it("shows the promoted DM's shared unread pip, clears when read, and follows replacement and deletion", async () => { +it("honors the promoted DM's mute and unread state through reads and Bestie replacement", async () => { const f = await fixture(); const me = keypair(), relay = keypair(), agent = keypair(); const first = "11111111-1111-4111-8111-111111111111", second = "22222222-2222-4222-8222-222222222222"; + let muted = true; + const preferences = createSidebarPreferencesStore({ + available: true, + read: async () => ({ + sections: [], + assignments: {}, + starred: [], + muted: muted ? [first] : [], + }), + }); + disposals.push(() => preferences.dispose()); + await preferences.queries.refresh(); const discovery = [ metadata(relay, first, "Pollen DM", undefined, [["t", "dm"]]), roster(relay, first, [me.pubkey, pollen]), @@ -343,6 +356,7 @@ it("shows the promoted DM's shared unread pip, clears when read, and follows rep ...owner.session, live: { ...owner.session.live, snapshot: () => liveState }, agentChoices: f.relay.snapshot().session.agentChoices, + sidebarPreferences: preferences.queries, }; const currentScope = `https://example.test:${me.pubkey}`; f.update({ session, scope: currentScope, viewer: me.pubkey }); @@ -402,9 +416,8 @@ it("shows the promoted DM's shared unread pip, clears when read, and follows rep ]), ]); }); - await waitFor(() => - expect(row().querySelector("[data-channel-activity]")).not.toBeNull(), - ); + await waitFor(() => expect(row()).toHaveAccessibleName(/1 unread thread/)); + expect(row().querySelector("[data-channel-activity]")).toBeNull(); expect( within(row()).getByRole("img", { name: /1 observed unread messages/ }), ).toBeInTheDocument(); @@ -414,6 +427,26 @@ it("shows the promoted DM's shared unread pip, clears when read, and follows rep "true", ); expect(dmRow()).toBeNull(); + await act(async () => { + muted = false; + await preferences.queries.refresh(); + }); + expect(row().querySelector("[data-channel-activity]")).toBeInTheDocument(); + expect(row().querySelector("[data-bestie-unread]")).toHaveAttribute( + "data-bestie-unread", + "true", + ); + await act(async () => { + muted = true; + await preferences.queries.refresh(); + }); + expect(row().querySelector("[data-bestie-unread]")).toHaveAttribute( + "data-bestie-unread", + "true", + ); + expect(row().querySelector("[data-channel-activity]")).toBeNull(); + expect(row()).toHaveAccessibleName(/1 unread thread/); + expect(dmRow()).toBeNull(); view.rerender(tree()); expect(dmRow()).toBeNull(); await act(async () => { @@ -427,6 +460,10 @@ it("shows the promoted DM's shared unread pip, clears when read, and follows rep expect( within(row()).getByRole("img", { name: /Marked unread/ }), ).toBeInTheDocument(); + expect(row().querySelector("[data-bestie-unread]")).toHaveAttribute( + "data-bestie-unread", + "true", + ); act(() => { setDefaultAgent(currentScope, fizz); }); diff --git a/src/bundled/channels/ChannelSidebarItem.test.tsx b/src/bundled/channels/ChannelSidebarItem.test.tsx index 3162262c7f..dcf632e4f6 100644 --- a/src/bundled/channels/ChannelSidebarItem.test.tsx +++ b/src/bundled/channels/ChannelSidebarItem.test.tsx @@ -603,33 +603,72 @@ it("keeps Solo unread labels and markers independent of mute", () => { expect(view.container.querySelector("[data-channel-unread]")).toBeNull(); }); -it("quiets confirmed muted backlog, restores it on unmute, and preserves directed and manual attention", () => { - const state = owner(); - const view = render( - , - ); - const row = () => view.container.querySelector("[data-channel-sidebar-row]"); - act(() => state.unread(2)); - expect(row()).toHaveAttribute("data-ordinary"); - act(() => state.mute(true)); - expect(row()).not.toHaveAttribute("data-ordinary"); - expect(view.container.querySelector("[data-channel-unread]")).toBeNull(); - act(() => state.unread(3)); - expect(row()).not.toHaveAttribute("data-ordinary"); - act(() => state.mute(false)); - expect(row()).toHaveAttribute("data-ordinary"); - act(() => state.mute(true)); - act(() => state.unread(4, 1)); - expect(row()).not.toHaveAttribute("data-ordinary"); - expect( - view.container.querySelector("[data-channel-priority]"), - ).not.toBeNull(); - act(() => state.manual()); - expect(row()).toHaveAttribute("data-ordinary"); - expect( - screen.getByRole("img", { name: "Marked unread on this device only" }), - ).toBeInTheDocument(); -}); +it.each([ + { channelType: "stream", mutedIndicators: 0 }, + { channelType: "dm", mutedIndicators: 1 }, +] as const)( + "replaces the muted $channelType unread dot with a bell, restores it on unmute, and retains unread evidence", + ({ channelType, mutedIndicators }) => { + const state = owner(); + const view = render( + , + ); + const row = () => + view.container.querySelector("[data-channel-sidebar-row]"); + expect( + screen.queryByRole("img", { name: "Muted" }), + ).not.toBeInTheDocument(); + act(() => state.mute(true)); + expect(screen.getByRole("img", { name: "Muted" })).toBeInTheDocument(); + expect(row()).not.toHaveAttribute("data-ordinary"); + act(() => state.mute(false)); + act(() => state.unread(2)); + expect(row()).toHaveAttribute("data-ordinary"); + act(() => state.mute(true)); + expect(row()?.hasAttribute("data-ordinary")).toBe(channelType === "dm"); + act(() => state.unread(3, 0)); + expect(row()?.hasAttribute("data-ordinary")).toBe(channelType === "dm"); + expect( + view.container.querySelectorAll("[data-channel-unread]"), + ).toHaveLength(mutedIndicators); + expect( + view.container.querySelectorAll("[data-channel-priority]"), + ).toHaveLength(0); + expect(screen.getByRole("img", { name: "Muted" })).toBeInTheDocument(); + act(() => state.mute(false)); + expect(row()).toHaveAttribute("data-ordinary"); + expect( + screen.queryByRole("img", { name: "Muted" }), + ).not.toBeInTheDocument(); + expect( + view.container.querySelectorAll("[data-channel-priority]"), + ).toHaveLength(mutedIndicators); + act(() => state.mute(true)); + act(() => state.unread(4, 1)); + expect(row()?.hasAttribute("data-ordinary")).toBe(channelType === "dm"); + expect(view.container.querySelector("[data-channel-priority]")).toBeNull(); + expect( + screen.getByRole("img", { name: /4 observed unread messages/ }), + ).toBeInTheDocument(); + act(() => state.mute(false)); + expect( + view.container.querySelector("[data-channel-priority]"), + ).toBeInTheDocument(); + act(() => state.mute(true)); + act(() => state.manual()); + expect(row()).toHaveAttribute("data-ordinary"); + expect( + screen.getByRole("img", { name: "Marked unread on this device only" }), + ).toBeInTheDocument(); + expect(screen.getByRole("img", { name: "Muted" })).toBeInTheDocument(); + }, +); it("keeps cached mute from quieting unread but retains confirmed mute through read failures", async () => { const data: SidebarPreferences = { diff --git a/src/bundled/channels/ChannelSidebarItem.tsx b/src/bundled/channels/ChannelSidebarItem.tsx index 483992159e..509180c3bb 100644 --- a/src/bundled/channels/ChannelSidebarItem.tsx +++ b/src/bundled/channels/ChannelSidebarItem.tsx @@ -74,6 +74,7 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem( props: ItemProps, ) { const { channel, session, working } = props; + const muted = useConfirmedMute(session, channel.id); const { agents, agentProfiles, waiting } = useWorkingAgents( session, channel.id, @@ -83,6 +84,7 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem( session, channel.id, channel.channelType === "dm", + muted, ); return ( ); }); @@ -100,8 +103,8 @@ function useOrdinaryUnread( session: RelaySession, channelId: string, dm: boolean, + muted: boolean, ) { - const muted = useConfirmedMute(session, channelId); const target = useMemo( () => ({ kind: "channel" as const, channelId }), [channelId], @@ -141,11 +144,13 @@ function ChannelSidebarItemCore({ agentProfiles, waiting, ordinary, + muted, }: ItemProps & { waiting: boolean; agents: readonly string[]; agentProfiles: ReadonlyMap; ordinary: boolean; + muted: boolean; }) { const peer = channel.channelType === "dm" && channel.participants?.length === 1 @@ -163,6 +168,7 @@ function ChannelSidebarItemCore({ channel={channel} working={agents.length > 0} ordinary={ordinary} + muted={muted} dmVisualSpacing={channel.channelType === "dm"} icon={ channel.channelType === "dm" ? ( diff --git a/src/bundled/channels/ChannelSidebarRow.module.css b/src/bundled/channels/ChannelSidebarRow.module.css index 2f1fc2ff3a..3d3d53548a 100644 --- a/src/bundled/channels/ChannelSidebarRow.module.css +++ b/src/bundled/channels/ChannelSidebarRow.module.css @@ -107,6 +107,11 @@ overflow: hidden; white-space: nowrap; } +.muted { + display: inline-flex; + flex-shrink: 0; + color: var(--text-metadata); +} .label:only-child { width: 100%; } diff --git a/src/bundled/channels/ChannelSidebarRow.tsx b/src/bundled/channels/ChannelSidebarRow.tsx index dbfd9e9cfa..a8c2386e39 100644 --- a/src/bundled/channels/ChannelSidebarRow.tsx +++ b/src/bundled/channels/ChannelSidebarRow.tsx @@ -1,7 +1,7 @@ import { FadingLabel } from "./FadingLabel"; import { NavigationItem } from "../../shared/design-system/ui/NavigationItem"; import { IconButton } from "../../shared/design-system/ui/IconButton"; -import { XIcon } from "../../shared/design-system/icons/index"; +import { BellSlashIcon, XIcon } from "../../shared/design-system/icons/index"; import { useId, type ReactElement, type ReactNode } from "react"; import type { ChannelSummary } from "../../features/relay/contracts"; import styles from "./ChannelSidebarRow.module.css"; @@ -13,6 +13,7 @@ export function ChannelSidebarRow({ badge, working = false, ordinary = false, + muted = false, dmVisualSpacing = false, wrapSelect, selected, @@ -29,6 +30,7 @@ export function ChannelSidebarRow({ working?: boolean; /** Ordinary unread backlog emphasises the label. */ ordinary?: boolean; + muted?: boolean; dmVisualSpacing?: boolean; wrapSelect?: ((trigger: ReactElement) => ReactNode) | undefined; selected?: string | undefined; @@ -52,9 +54,14 @@ export function ChannelSidebarRow({ label={ {channel.name} + {muted && ( + + + + )} {nameAccessory} } diff --git a/src/bundled/channels/UnreadBadge.tsx b/src/bundled/channels/UnreadBadge.tsx index a67390bc29..6a3e8b9a57 100644 --- a/src/bundled/channels/UnreadBadge.tsx +++ b/src/bundled/channels/UnreadBadge.tsx @@ -74,7 +74,16 @@ export function useUnreadState( ((count ?? 0) > 0 && (dm || !muted || (snapshot.attentionCount ?? 0) > 0)); const ordinary = ordinaryUnread(snapshot, dm, muted); const threadCount = activity.items?.length ?? 0; - return { snapshot, activity, count, manual, unread, ordinary, threadCount }; + return { + snapshot, + activity, + count, + manual, + unread, + ordinary, + threadCount, + muted, + }; } export function UnreadBadge({ @@ -102,15 +111,20 @@ export function UnreadBadge({ unread: quietUnread, ordinary, threadCount, + muted, } = useUnreadState(session, channelId, dm); const unread = labelUnread === "all" || preserveUnread ? manual || (count ?? 0) > 0 : quietUnread; + const hideUnreadDots = muted && labelUnread === "ordinary" && !preserveUnread; if (!unread && !threadCount && label === undefined) return null; const priority = dm || (snapshot.attentionCount ?? 0) > 0; const showUnreadDot = - priority && threadCount === 0 && labelUnread === "ordinary"; + !hideUnreadDots && + priority && + threadCount === 0 && + labelUnread === "ordinary"; const unreadLabel = manual ? `Marked unread${snapshot.manual === "local-only" ? " on this device only" : ""}` : `${count} observed unread messages${snapshot.freshness === "stale" ? "; may be out of date" : ""}. Not an exact total.`; @@ -143,15 +157,18 @@ export function UnreadBadge({ aria-hidden="true" /> )} - {threadCount > 0 && ( - - )} + {threadCount > 0 && + (hideUnreadDots ? ( + {threadLabel} + ) : ( + + ))} ); }