Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 8 additions & 1 deletion docs/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
14 changes: 12 additions & 2 deletions docs/unread.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions src/app/App.profile.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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();
Expand Down
45 changes: 41 additions & 4 deletions src/app/shell/BestieNavigation.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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]),
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -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();
Expand All @@ -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 () => {
Expand All @@ -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);
});
Expand Down
93 changes: 66 additions & 27 deletions src/bundled/channels/ChannelSidebarItem.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<ChannelSidebarItem {...itemProps(state.session, false)} />,
);
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(
<ChannelSidebarItem
{...itemProps(state.session, false, {
id: "alpha",
name: "Alpha",
channelType,
})}
/>,
);
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 = {
Expand Down
8 changes: 7 additions & 1 deletion src/bundled/channels/ChannelSidebarItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -83,6 +84,7 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem(
session,
channel.id,
channel.channelType === "dm",
muted,
);
return (
<ChannelSidebarItemCore
Expand All @@ -91,6 +93,7 @@ export const ChannelSidebarItem = memo(function ChannelSidebarItem(
agentProfiles={agentProfiles}
waiting={waiting}
ordinary={ordinary}
muted={muted}
/>
);
});
Expand All @@ -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],
Expand Down Expand Up @@ -141,11 +144,13 @@ function ChannelSidebarItemCore({
agentProfiles,
waiting,
ordinary,
muted,
}: ItemProps & {
waiting: boolean;
agents: readonly string[];
agentProfiles: ReadonlyMap<string, Profile>;
ordinary: boolean;
muted: boolean;
}) {
const peer =
channel.channelType === "dm" && channel.participants?.length === 1
Expand All @@ -163,6 +168,7 @@ function ChannelSidebarItemCore({
channel={channel}
working={agents.length > 0}
ordinary={ordinary}
muted={muted}
dmVisualSpacing={channel.channelType === "dm"}
icon={
channel.channelType === "dm" ? (
Expand Down
5 changes: 5 additions & 0 deletions src/bundled/channels/ChannelSidebarRow.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%;
}
Expand Down
11 changes: 9 additions & 2 deletions src/bundled/channels/ChannelSidebarRow.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -13,6 +13,7 @@ export function ChannelSidebarRow({
badge,
working = false,
ordinary = false,
muted = false,
dmVisualSpacing = false,
wrapSelect,
selected,
Expand All @@ -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;
Expand All @@ -52,9 +54,14 @@ export function ChannelSidebarRow({
label={
<span
className={styles.nameContent}
data-name-accessory={nameAccessory ? "" : undefined}
data-name-accessory={muted || nameAccessory ? "" : undefined}
>
<FadingLabel className={styles.label}>{channel.name}</FadingLabel>
{muted && (
<span className={styles.muted} role="img" aria-label="Muted">
<BellSlashIcon size={14} />
</span>
)}
{nameAccessory}
</span>
}
Expand Down
Loading
Loading