Skip to content
Open
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
8 changes: 7 additions & 1 deletion docs/buzz-help.md
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,13 @@ revokes all of them, and a pairing unused for 30 days expires.
- **Sidebar activity**: rest the pointer on a sidebar row to list its unread
mentions of you and its unread threads, mentions first. Open one or mark it
read from the list; just looking marks nothing read.
- **Mute** is available from a channel or DM's sidebar context menu. Once
- **Notifications** in a channel's sidebar or header menu chooses what alerts
you: **All new posts** (every new top-level post, plus your usual mentions and
thread replies), **Just mentions** (the default) or **Mute**. All new posts is
saved on this device only and can be turned off for every channel with
**Channel posts** in Settings → Notifications. Mute wins over it.
- **Mute** is available from a channel's **Notifications** submenu or a DM's
sidebar context menu. Once
confirmed, a crossed-out bell appears immediately after its name. New messages
do not make muted channel or DM names bold.
Mute suppresses notifications for messages that do not mention you; mentions can still notify
Expand Down
14 changes: 12 additions & 2 deletions docs/notifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,7 @@ checks, not that an OS banner was displayed or read.
- Channel Mute/Unmute uses the session's confirmed, encrypted `channel-mutes`
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
gate, not global off/category/read/access/permission gates. 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
Expand All @@ -93,6 +92,17 @@ checks, not that an OS banner was displayed or read.
does not replay cancelled alerts. Existing shown OS banners are not withdrawn.
Hosts without preference decoding retain existing notification behavior; this
slice adds no native preference adapter or automatic cross-device synchronization.
- A channel's **Notifications** submenu offers All new posts, Just mentions
(default) and Mute; DMs keep the single Mute/Unmute item. Mute is the synced
`channel-mutes` preference above. All new posts is an account-local list in
the existing notification preferences (`allPosts`), so it does not sync across
devices and adds no relay coordinate. In those channels, someone else's
top-level post (unread attention `post: true`) alerts under the `post`
category, **Channel posts**, with its own switch and sound. Replies keep
thread rules, mentions stay mentions, and confirmed mute cancels pending post
candidates; switching back to Just mentions cancels them too. Muting keeps the
device's All new posts choice; leaving Mute applies the picked level only after
unmute is confirmed, so a failed unmute (and its Retry) keeps the earlier choice.
- Permission is requested explicitly from Settings where a browser needs a user
gesture. A fresh pending candidate is reconsidered after Allow; a newer off
choice still wins. Observable API errors are reported, never auto-retried.
Expand Down
7 changes: 6 additions & 1 deletion src/app/App.profile.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,12 @@ it.each([
name: "Planning",
});
fireEvent.keyDown(row, { key: "ContextMenu" });
await user.click(await screen.findByRole("menuitem", { name: "Mute" }));
await user.hover(
await screen.findByRole("menuitem", { name: "Notifications" }),
);
fireEvent.click(
await screen.findByRole("menuitemradio", { name: "Mute" }),
);
await waitFor(() =>
expect(write).toHaveBeenCalledExactlyOnceWith({
channelId,
Expand Down
5 changes: 4 additions & 1 deletion src/app/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,10 @@ function ConnectedAppContent({ services }: { services: AppServices }) {
communities={client.memberships.map((membership) => membership.id)}
/>
)}
<ChannelNavigationProvider relay={services.relay}>
<ChannelNavigationProvider
relay={services.relay}
alerts={services.notifications}
>
<AppShell
creationInSidebar={!workspace.page?.sidebar}
creationAction={(open, onOpenChange, triggerContainer) => (
Expand Down
1 change: 1 addition & 0 deletions src/app/NotificationSettings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,7 @@ it("selects and restores Silent for every category, stops previews, and can rest
direct: "silent",
mention: "silent",
thread: "silent",
post: "flutter",
agent: "flutter",
});
expect(play).toHaveBeenCalledOnce();
Expand Down
1 change: 1 addition & 0 deletions src/app/NotificationSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const SOUND_ROWS: readonly NotificationCategory[] = [
"direct",
"mention",
"thread",
"post",
"agent",
];

Expand Down
6 changes: 5 additions & 1 deletion src/bundled/channels/useOptimisticMute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,11 @@ export function useOptimisticMute(
channelId: string,
name: string,
muted: boolean,
/** Told whether the session confirmed this write. */
settled?: (saved: boolean) => void,
) {
const active = lifetime.current;
if (!active) return;
if (!active) return settled?.(false);
const intent: MuteIntent = { channelId, name, muted, pending: true };
setIntents((current) => new Map(current).set(channelId, intent));
const finish = (error?: string) => {
Expand All @@ -56,8 +58,10 @@ export function useOptimisticMute(
// The session owns saving; leaving Messages must not cancel the intent.
await active.write(channelId, muted);
finish();
settled?.(true);
} catch (error) {
finish(error instanceof Error ? error.message : String(error));
settled?.(false);
}
})();
}, []);
Expand Down
124 changes: 123 additions & 1 deletion src/features/channel-navigation/ChannelInventory.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ import {
ChannelNavigationProvider,
useChannelNavigation,
} from "./ChannelNavigationState";
import type { ChannelAlerts } from "./ChannelNavigationState";
import { DEFAULT_NOTIFICATION_PREFERENCES } from "../notifications/preferences";
import { readView, writeView } from "../../shared/view-state";
import styles from "../../app/shell/PrimarySidebar.module.css";
import { BestieNavigationProvider } from "../../app/shell/BestieNavigation";
Expand Down Expand Up @@ -222,9 +224,10 @@ function fixture(
</nav>
),
sidebarVisible = true,
alerts?: ChannelAlerts,
) => (
<ToastProvider>
<ChannelNavigationProvider relay={relay}>
<ChannelNavigationProvider relay={relay} alerts={alerts}>
{sidebarVisible && (
<PrimarySidebar
relay={relay}
Expand Down Expand Up @@ -497,6 +500,125 @@ it.each(["Retry", "Dismiss"] as const)(
},
);

/** Account-local alert choices, as NotificationsService exposes them. */
function alertsFixture() {
let preferences = DEFAULT_NOTIFICATION_PREFERENCES;
const listeners = new Set<() => void>();
let snapshot = { preferences };
return {
snapshot: () => snapshot,
subscribe(listener: () => void) {
listeners.add(listener);
return () => listeners.delete(listener);
},
updatePreferences(patch: Partial<typeof preferences>) {
preferences = { ...preferences, ...patch };
snapshot = { preferences };
act(() => {
for (const listener of listeners) listener();
});
return true;
},
} as unknown as ChannelAlerts;
}

it("chooses All new posts, Mute and Just mentions for a channel; DMs keep the Mute toggle", async () => {
let muted: string[] = [];
const write = vi.fn(
async ({ channelId, muted: next }: { channelId: string; muted: boolean }) =>
(muted = next
? [...muted, channelId]
: muted.filter((id) => id !== channelId)),
);
const preferences = createSidebarPreferencesStore({
read: async () => ({
sections: [],
assignments: {},
starred: [],
muted,
}),
available: true,
writeMute: write,
});
await preferences.queries.ensure();
const h = fixture(preferences.queries, "ready", [
{ id: "alpha", name: "alpha", channelType: "stream" },
{ id: "dm", name: "Alice", channelType: "dm" },
]);
const alerts = alertsFixture();
const user = userEvent.setup();
const level = async (name: string) => {
// A muted row's name also carries its crossed-out bell.
fireEvent.keyDown(screen.getByRole("button", { name: /^alpha/ }), {
key: "ContextMenu",
});
await user.hover(
await screen.findByRole("menuitem", { name: "Notifications" }),
);
expect(
await screen.findByRole("menu", { name: "Notifications for alpha" }),
).toHaveTextContent("Notify you about…");
const checked = screen
.getAllByRole("menuitemradio")
.filter((item) => item.getAttribute("aria-checked") === "true")
.map((item) => item.textContent);
fireEvent.click(screen.getByRole("menuitemradio", { name }));
await waitFor(() =>
expect(
screen.queryByRole("menu", { name: "Actions for alpha" }),
).toBeNull(),
);
return checked;
};
try {
render(h.view("alpha", undefined, true, alerts));
await screen.findByRole("button", { name: "alpha" });
expect(await level("All new posts")).toEqual(["Just mentions"]);
expect(alerts.snapshot().preferences.allPosts).toEqual(["alpha"]);
expect(write).not.toHaveBeenCalled();

expect(await level("Mute")).toEqual(["All new posts"]);
await waitFor(() =>
expect(write).toHaveBeenCalledExactlyOnceWith(
{ channelId: "alpha", muted: true },
expect.any(AbortSignal),
),
);
// Mute wins in the alert gate; the device keeps the earlier choice.
expect(alerts.snapshot().preferences.allPosts).toEqual(["alpha"]);

await waitFor(() =>
expect(preferences.queries.snapshot().data?.muted).toEqual(["alpha"]),
);
// A failed unmute stays muted and keeps the device's earlier choice.
write.mockRejectedValueOnce(new Error("offline"));
expect(await level("Just mentions")).toEqual(["Mute"]);
await screen.findByRole("dialog", { name: "Couldn’t unmute alpha" });
expect(alerts.snapshot().preferences.allPosts).toEqual(["alpha"]);
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));

expect(await level("Just mentions")).toEqual(["Mute"]);
await waitFor(() =>
expect(alerts.snapshot().preferences.allPosts).toEqual([]),
);
expect(write).toHaveBeenLastCalledWith(
{ channelId: "alpha", muted: false },
expect.any(AbortSignal),
);

// Every DM message already alerts, so a DM offers only Mute.
fireEvent.keyDown(screen.getByRole("button", { name: "Alice" }), {
key: "ContextMenu",
});
expect(await screen.findByRole("menuitem", { name: "Mute" })).toBeVisible();
expect(
screen.queryByRole("menuitem", { name: "Notifications" }),
).toBeNull();
} finally {
preferences.dispose();
}
});

it("uses relay section filters across navigation and remount, and offers only hidden channels", async () => {
const user = userEvent.setup();
let filters: Record<string, SidebarFilterMode> = {};
Expand Down
Loading
Loading