@@ -62,7 +227,11 @@ export function NotificationSettings({
@@ -115,23 +284,42 @@ export function NotificationSettings({
notifications.updatePreferences({ notifyWhileViewing })
}
/>
- {!state.systemManaged && (
-
- notifications.updatePreferences({ sound })
- }
- />
+
+ notifications.updatePreferences({ sound })
+ }
+ />
+ {desktopAlertsEnabled && preferences.sound && (
+
+ {SOUND_ROWS.map((category) => (
+
+ togglePreview(category, preferences.sounds[category])
+ }
+ onChange={(next) =>
+ notifications.updatePreferences({
+ sounds: { ...preferences.sounds, [category]: next },
+ })
+ }
+ />
+ ))}
+
)}
({
id: crypto.randomUUID(),
title: "Buzz",
body: "Hello",
- silent: true,
});
function callback(index = 0) {
const call = calls[index];
@@ -111,6 +110,25 @@ it("accepts a native activation before the command promise resolves", async () =
platform.dispose();
});
+it("accepts a native failure before the command promise resolves", async () => {
+ const platform = createNotifications();
+ const failed = vi.fn();
+ invoke.mockImplementationOnce(async (_command, args) => {
+ calls.push(JSON.parse(JSON.stringify(args)));
+ const { call, send } = callback();
+ send({
+ message: { id: call.id, kind: "failed", error: "backend failure" },
+ index: 0,
+ });
+ send({ end: true, index: 1 });
+ });
+ await platform.show(item(), vi.fn(), failed);
+ expect(failed).toHaveBeenCalledExactlyOnceWith(new Error("backend failure"));
+ expect(callbacks.size).toBe(0);
+ expect(invoke).toHaveBeenCalledOnce();
+ platform.dispose();
+});
+
it("disposal fences the real transport while a late native end still releases registration", async () => {
const platform = createNotifications();
const activate = vi.fn(),
diff --git a/src/features/notifications/desktop.test.ts b/src/features/notifications/desktop.test.ts
index d65654048..5b08f842f 100644
--- a/src/features/notifications/desktop.test.ts
+++ b/src/features/notifications/desktop.test.ts
@@ -120,7 +120,7 @@ it("observable SDK failures surface once without retry or a browser fallback", a
expect(sdk.show).toHaveBeenCalledTimes(2);
});
-it("desktop settings explain OS sound and running-app exact clicks", async () => {
+it("desktop settings expose app-owned sound with the reference desktop copy", async () => {
const { service } = setup();
await flush();
const html = renderToStaticMarkup(
@@ -128,16 +128,35 @@ it("desktop settings explain OS sound and running-app exact clicks", async () =>
);
expect(html).toContain("Desktop alerts");
expect(html).toContain("Mentions");
+ expect(html).toContain("Fine-tune what gets through below.");
expect(html).toContain(
- "Manage app permissions and sounds in your system settings.",
+ "Native desktop alerts are enabled for the categories you have armed below.",
);
- expect(html).toContain(
- "Opening an alert takes you to its message or thread.",
- );
- expect(html).not.toContain("Sound");
+ // Sound is app-owned on desktop: the switch and per-event sound rows render.
+ expect(html).toContain("Alert with a sound for the events below.");
+ expect(html).toContain("Direct messages");
+ expect(html).toContain("@Mentions");
+ expect(html).toContain("Thread replies");
+ expect(html).toContain("Preview flutter");
expect(html).not.toContain("Permission granted");
expect(html).not.toContain("Check permission");
expect(html).not.toContain("Allow notifications");
+ // Disabling desktop alerts swaps in the reference's disabled description.
+ service.updatePreferences({ enabled: false });
+ const disabled = renderToStaticMarkup(
+ createElement(NotificationSettings, {
+ notifications: service,
+ // Keep this a pure copy render: `active` gates only preview and toasts,
+ // and this harness has no Toast.Provider.
+ active: false,
+ }),
+ );
+ expect(disabled).toContain(
+ "Request OS permission and surface new mentions or needs-action items outside the app.",
+ );
+ expect(disabled).not.toContain(
+ "Native desktop alerts are enabled for the categories you have armed below.",
+ );
});
it("non-Tauri runs select the unchanged browser adapter, never the native SDK", async () => {
@@ -292,13 +311,13 @@ it("native presentation rejects at capacity before sending instead of evicting l
failed = vi.fn();
for (let i = 0; i < 128; i++)
await platform.show(
- { id: String(i), title: "Buzz", body: "Hi", silent: true },
+ { id: String(i), title: "Buzz", body: "Hi" },
activate,
failed,
);
await expect(
platform.show(
- { id: "overflow", title: "Buzz", body: "Hi", silent: true },
+ { id: "overflow", title: "Buzz", body: "Hi" },
activate,
failed,
),
@@ -308,7 +327,7 @@ it("native presentation rejects at capacity before sending instead of evicting l
first.onEvent.onmessage({ id: first.id, kind: "activated" });
expect(activate).toHaveBeenCalledOnce();
await platform.show(
- { id: "next", title: "Buzz", body: "Hi", silent: true },
+ { id: "next", title: "Buzz", body: "Hi" },
activate,
failed,
);
diff --git a/src/features/notifications/messages.test.ts b/src/features/notifications/messages.test.ts
index 938660960..68be6b601 100644
--- a/src/features/notifications/messages.test.ts
+++ b/src/features/notifications/messages.test.ts
@@ -592,6 +592,31 @@ it.each([
},
);
+it("classifies p-tagged DM messages as direct, not mention", async () => {
+ const h = await setup();
+ h.emit([profile(h.peer, { name: "Pinky" })]);
+ const now = Math.floor(Date.now() / 1000);
+ h.emit([
+ signed(h.relay, {
+ kind: 39000,
+ content: JSON.stringify({ name: "internal-dm-id", channel_type: "dm" }),
+ tags: [
+ ["d", "room"],
+ ["name", "internal-dm-id"],
+ ["t", "dm"],
+ ],
+ created_at: now,
+ }),
+ ]);
+ // Agent and CLI DM traffic p-tags the recipient; that must not reroute the
+ // message to the mention category (label, sound, and preference toggle).
+ h.emit([h.make("hello")], "live");
+ await vi.waitFor(() => expect(h.show).toHaveBeenCalledOnce());
+ expect(h.show.mock.calls[0]?.[0].title).toBe(
+ "Pinky sent you a direct message",
+ );
+});
+
function deferred() {
let release = () => {};
const promise = new Promise((resolve) => {
diff --git a/src/features/notifications/messages.ts b/src/features/notifications/messages.ts
index bce8a2a1a..1e5f22521 100644
--- a/src/features/notifications/messages.ts
+++ b/src/features/notifications/messages.ts
@@ -121,9 +121,11 @@ export function bindMessageNotifications(
message.messageId,
);
const sync = owned.unread.sync();
- // Mentions bypass channel mute, as in the legacy policy. Unknown
- // preferences must not briefly release ordinary alerts at startup.
- if (attention.category !== "mention") {
+ // Explicit mentions bypass channel mute, as in the legacy policy —
+ // including p-tagged messages in DM channels, whose category is
+ // "direct". Unknown preferences must not briefly release ordinary
+ // alerts at startup.
+ if (!attention.mentioned) {
const preferences = owned.sidebarPreferences.snapshot();
if (preferences.data?.muted.includes(message.channelId))
return false;
diff --git a/src/features/notifications/platform.test.ts b/src/features/notifications/platform.test.ts
index 3205e657d..1dc657301 100644
--- a/src/features/notifications/platform.test.ts
+++ b/src/features/notifications/platform.test.ts
@@ -42,7 +42,6 @@ it("browser activation focuses and routes once; close/dispose releases callbacks
id: "one",
title: "Pinky mentioned you in #Room",
body: "Hello Wes",
- silent: true,
},
activate,
vi.fn(),
@@ -51,6 +50,7 @@ it("browser activation focuses and routes once; close/dispose releases callbacks
expect(t.shown[0]?.options).toEqual({
body: "Hello Wes",
tag: "one",
+ // Sound is app-owned; the OS banner is always submitted silent.
silent: true,
});
t.shown[0]?.onclick?.();
@@ -58,7 +58,7 @@ it("browser activation focuses and routes once; close/dispose releases callbacks
expect(t.host.focus).toHaveBeenCalledTimes(1);
expect(t.shown[0]?.onclick).toBeNull();
await t.platform.show(
- { id: "two", title: "Buzz", body: "New mention", silent: false },
+ { id: "two", title: "Buzz", body: "New mention" },
activate,
vi.fn(),
);
@@ -72,7 +72,7 @@ it("bounded presentation closes the old banner instead of stranding its target",
const t = setup();
for (let i = 0; i < 129; i++)
await t.platform.show(
- { id: String(i), title: "Buzz", body: "New mention", silent: true },
+ { id: String(i), title: "Buzz", body: "New mention" },
() => {},
vi.fn(),
);
@@ -89,7 +89,7 @@ it("denial and a native WebView never silently use browser notification delivery
expect(await t.platform.permission()).toBe("denied");
await expect(
t.platform.show(
- { id: "one", title: "Buzz", body: "New mention", silent: true },
+ { id: "one", title: "Buzz", body: "New mention" },
() => {},
vi.fn(),
),
@@ -107,7 +107,7 @@ it("asynchronous browser errors report once and retire every callback without re
failed = vi.fn(),
activate = vi.fn();
await t.platform.show(
- { id: "failed", title: "Buzz", body: "New mention", silent: true },
+ { id: "failed", title: "Buzz", body: "New mention" },
activate,
failed,
);
diff --git a/src/features/notifications/platform.ts b/src/features/notifications/platform.ts
index dc98a598d..e5f7fbc4e 100644
--- a/src/features/notifications/platform.ts
+++ b/src/features/notifications/platform.ts
@@ -8,7 +8,6 @@ export type NotificationPresentation = Readonly<{
id: string;
title: string;
body: string;
- silent: boolean;
}>;
export interface NotificationPlatform {
readonly label: string;
@@ -115,7 +114,9 @@ export function createBrowserNotifications(
const notification = new api(item.title, {
body: item.body,
tag: item.id,
- silent: item.silent,
+ // Sound is app-owned: the notifications service plays the selected
+ // bundled sound after delivery, so the banner is always submitted silent.
+ silent: true,
});
active.set(item.id, notification);
notification.onclick = () => {
diff --git a/src/features/notifications/preferences.test.ts b/src/features/notifications/preferences.test.ts
new file mode 100644
index 000000000..13fcb4d90
--- /dev/null
+++ b/src/features/notifications/preferences.test.ts
@@ -0,0 +1,31 @@
+import { expect, it } from "vitest";
+import {
+ DEFAULT_NOTIFICATION_PREFERENCES,
+ parsePreferences,
+} from "./preferences";
+
+const stored = {
+ enabled: true,
+ notifyWhileViewing: false,
+ sound: true,
+ categories: { mention: true, direct: true, thread: false },
+};
+
+it("values saved before per-category sounds restore with the defaults", () => {
+ expect(parsePreferences(stored).sounds).toEqual(
+ DEFAULT_NOTIFICATION_PREFERENCES.sounds,
+ );
+});
+
+it("saved sound choices round-trip and unknown names reject the payload", () => {
+ const sounds = { mention: "ping", direct: "unison", thread: "doop" };
+ expect(
+ parsePreferences(JSON.parse(JSON.stringify({ ...stored, sounds }))).sounds,
+ ).toEqual(sounds);
+ expect(() => parsePreferences({ ...stored, sounds: "ping" })).toThrow(
+ "Invalid notification sounds",
+ );
+ expect(() =>
+ parsePreferences({ ...stored, sounds: { mention: "klaxon" } }),
+ ).toThrow("Invalid notification sounds");
+});
diff --git a/src/features/notifications/preferences.ts b/src/features/notifications/preferences.ts
index 0bc47e203..ebb244be1 100644
--- a/src/features/notifications/preferences.ts
+++ b/src/features/notifications/preferences.ts
@@ -1,3 +1,10 @@
+import {
+ DEFAULT_CATEGORY_SOUNDS,
+ isSoundName,
+ type CategorySounds,
+ type SoundName,
+} from "./sound";
+
/** Desired account-local policy is separate from system permission. */
export const NOTIFICATION_CATEGORIES = ["mention", "direct", "thread"] as const;
export type NotificationCategory = (typeof NOTIFICATION_CATEGORIES)[number];
@@ -5,6 +12,7 @@ export type NotificationPreferences = Readonly<{
enabled: boolean;
notifyWhileViewing: boolean;
sound: boolean;
+ sounds: CategorySounds;
categories: Readonly>;
}>;
export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences =
@@ -12,9 +20,28 @@ export const DEFAULT_NOTIFICATION_PREFERENCES: NotificationPreferences =
enabled: true,
notifyWhileViewing: false,
sound: true,
+ sounds: DEFAULT_CATEGORY_SOUNDS,
categories: Object.freeze({ mention: true, direct: true, thread: true }),
});
const KEY = "buzz-notification-preferences.v1";
+// Values saved before per-category sounds existed omit `sounds`; they get the
+// defaults rather than failing restore and pausing alerts.
+function parseSounds(raw: unknown): CategorySounds {
+ if (raw === undefined) return DEFAULT_CATEGORY_SOUNDS;
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
+ throw new Error("Invalid notification sounds");
+ const value = raw as Record;
+ const sounds: Record = {
+ ...DEFAULT_CATEGORY_SOUNDS,
+ };
+ for (const category of NOTIFICATION_CATEGORIES) {
+ const picked = value[category];
+ if (picked === undefined) continue;
+ if (!isSoundName(picked)) throw new Error("Invalid notification sounds");
+ sounds[category] = picked;
+ }
+ return Object.freeze(sounds);
+}
export function parsePreferences(raw: unknown): NotificationPreferences {
if (!raw || typeof raw !== "object" || Array.isArray(raw))
throw new Error("Invalid notification preferences");
@@ -42,6 +69,7 @@ export function parsePreferences(raw: unknown): NotificationPreferences {
enabled: value.enabled,
notifyWhileViewing: value.notifyWhileViewing,
sound: value.sound as boolean,
+ sounds: parseSounds(value.sounds),
categories: Object.freeze(Object.fromEntries(categories)),
});
}
diff --git a/src/features/notifications/service.test.ts b/src/features/notifications/service.test.ts
index 50b7d51af..e608409c3 100644
--- a/src/features/notifications/service.test.ts
+++ b/src/features/notifications/service.test.ts
@@ -51,11 +51,14 @@ function setup() {
}),
dispose: vi.fn(),
};
+ const plays: string[] = [];
const service = new NotificationsService(
ctx,
navigation.navigation,
platform,
preferences,
+ undefined,
+ (name) => plays.push(name),
);
service.selectViewer(viewer);
return {
@@ -68,6 +71,7 @@ function setup() {
failures,
values,
host,
+ plays,
permission(value: NotificationPermissionState) {
permission = value;
},
@@ -276,3 +280,117 @@ it("late platform errors report without retry and stay fenced to their account l
t.failures[0]?.(new Error("Disposed failure"));
expect(t.service.snapshot().error).toBeNull();
});
+it("plays the selected per-category sound once delivery is accepted", async () => {
+ const t = setup();
+ t.service.updatePreferences({
+ sounds: { mention: "ping", direct: "unison", thread: "doop" },
+ });
+ await t.submit("one");
+ await flush();
+ expect(t.platform.show).toHaveBeenCalledTimes(1);
+ expect(t.plays).toEqual(["ping"]);
+ // Plugin categories have no per-category choice; they use the default sound.
+ await t.service.admit(
+ "updates",
+ "Updates",
+ { sourceKey: "two", target },
+ () => true,
+ );
+ await flush();
+ expect(t.plays).toEqual(["ping", "flutter"]);
+});
+it("sound off delivers silently and a failed submission never plays", async () => {
+ const t = setup();
+ t.service.updatePreferences({ sound: false });
+ await t.submit("one");
+ await flush();
+ expect(t.platform.show).toHaveBeenCalledTimes(1);
+ expect(t.plays).toEqual([]);
+ t.service.updatePreferences({ sound: true });
+ vi.mocked(t.platform.show).mockRejectedValueOnce(
+ new Error("SDK unavailable"),
+ );
+ await t.submit("two");
+ await flush();
+ expect(t.platform.show).toHaveBeenCalledTimes(2);
+ expect(t.plays).toEqual([]);
+ expect(t.service.snapshot().error).toBe("SDK unavailable");
+});
+it("a deferred submission revalidates the sound decision before playing", async () => {
+ const t = setup();
+ const releases: (() => void)[] = [];
+ vi.mocked(t.platform.show).mockImplementation(
+ (_item, activate, failed) =>
+ new Promise((resolve) => {
+ t.clicks.push(activate);
+ t.failures.push(failed);
+ releases.push(resolve);
+ }),
+ );
+ const settle = async () => {
+ releases.shift()?.();
+ await flush();
+ };
+ // Signing out or switching accounts while the submission is outstanding
+ // must not leak the prior account's activity as audio.
+ await t.submit("switched");
+ await flush();
+ t.service.selectViewer("b".repeat(64));
+ await settle();
+ expect(t.plays).toEqual([]);
+ t.service.selectViewer(viewer);
+ // Turning Sound off mid-flight cancels the outstanding decision — stickily:
+ // restoring it before the submission resolves must not resurrect the sound.
+ await t.submit("muted");
+ await flush();
+ t.service.updatePreferences({ sound: false });
+ t.service.updatePreferences({ sound: true });
+ await settle();
+ expect(t.plays).toEqual([]);
+ // Master alerts off → on mid-flight stays cancelled.
+ await t.submit("alerts-toggled");
+ await flush();
+ t.service.updatePreferences({ enabled: false });
+ t.service.updatePreferences({ enabled: true });
+ await settle();
+ expect(t.plays).toEqual([]);
+ // A banner submitted while Sound was off stays silent after off → on.
+ t.service.updatePreferences({ sound: false });
+ await t.submit("resurrected");
+ await flush();
+ t.service.updatePreferences({ sound: true });
+ await settle();
+ expect(t.plays).toEqual([]);
+ // Disabling the category mid-flight cancels the sound, even if re-enabled.
+ await t.submit("category-off");
+ await flush();
+ t.service.updatePreferences({ categories: { mention: false } });
+ t.service.updatePreferences({ categories: { mention: true } });
+ await settle();
+ expect(t.plays).toEqual([]);
+ // Losing eligibility (access/producer revocation) mid-flight cancels it,
+ // even when eligibility is restored before the submission resolves.
+ let eligible: boolean | "wait" = true;
+ await t.submit("revoked", () => eligible);
+ await flush();
+ eligible = false;
+ t.service.revalidate();
+ eligible = true;
+ await settle();
+ expect(t.plays).toEqual([]);
+ // A native failure can arrive before the command promise resolves. It cancels
+ // audio without retrying the accepted candidate.
+ await t.submit("early-failure");
+ await flush();
+ t.failures.at(-1)?.(new Error("Backend rejected notification"));
+ await settle();
+ expect(t.plays).toEqual([]);
+ expect(t.service.snapshot().error).toBe("Backend rejected notification");
+ expect(t.platform.show).toHaveBeenCalledTimes(7);
+ // An undisturbed deferred submission still plays exactly once.
+ await t.submit("intact");
+ await flush();
+ expect(t.plays).toEqual([]);
+ await settle();
+ expect(t.plays).toEqual(["flutter"]);
+});
diff --git a/src/features/notifications/service.ts b/src/features/notifications/service.ts
index 21e546b89..2256057eb 100644
--- a/src/features/notifications/service.ts
+++ b/src/features/notifications/service.ts
@@ -13,6 +13,11 @@ import {
type NotificationPermissionState,
} from "./platform";
import { afterPresentation } from "./presentation";
+import {
+ playNotificationSound,
+ resolveCategorySound,
+ type SoundName,
+} from "./sound";
import type { NotificationText } from "./content";
export type NotificationCategoryDescriptor = Readonly<{
@@ -70,6 +75,12 @@ export class NotificationsService extends Service implements Notifications {
private readonly contributions;
private readonly listeners = new Set<() => void>();
private readonly pending = new Set();
+ // Outstanding audio decisions for submissions awaiting platform acceptance.
+ // Revalidation cancels them stickily; cancellation never affects the banner.
+ private readonly sounding = new Set<{
+ item: Candidate;
+ cancelled: boolean;
+ }>();
private readonly seen = new Map();
private closed = false;
private generation = 0;
@@ -84,6 +95,8 @@ export class NotificationsService extends Service implements Notifications {
private readonly preferences = createNotificationPreferences(),
private readonly authorized: (target: OpenTarget) => boolean = (target) =>
!("scope" in target && target.scope),
+ private readonly playSound: (name: SoundName) => void = (name) =>
+ void playNotificationSound(name),
) {
super(ctx, "notifications");
this.contributions =
@@ -231,6 +244,18 @@ export class NotificationsService extends Service implements Notifications {
this.pending.delete(item);
}
}
+ // Outstanding audio decisions stay under revalidation until the platform
+ // resolves them. Any interval of revoked policy/access/eligibility — or
+ // Sound turned off — cancels the sound for good; restoring the setting
+ // before the submission resolves must not resurrect it.
+ for (const decision of this.sounding) {
+ if (
+ !this.state.preferences.sound ||
+ !this.allowed(decision.item) ||
+ decision.item.eligible() === false
+ )
+ decision.cancelled = true;
+ }
this.schedule();
}
register(category: NotificationCategoryDescriptor) {
@@ -376,24 +401,55 @@ export class NotificationsService extends Service implements Notifications {
}
// One attempt. A rejected/unknown OS submission is reported, never retried.
this.pending.delete(item);
- await this.platform.show(
- {
- id: crypto.randomUUID(),
- ...item.text(),
- silent: !this.state.preferences.sound,
- },
- () => {
- if (this.closed || generation !== this.generation) return;
- // Opening may switch to an already joined community. Navigation owns
- // current membership/channel access; admission's selected-session gate
- // must not turn a still-valid prior notification into a dead click.
- void this.navigation.open(item.target).catch(this.reportError);
- },
- (error) => {
- if (!this.closed && generation === this.generation)
- this.reportError(error);
- },
- );
+ // The item is out of `pending`, so register its audio decision for
+ // sticky cancellation by `revalidate` while the submission is
+ // outstanding. Sound off at submission means no decision at all.
+ const decision = this.state.preferences.sound
+ ? { item, cancelled: false }
+ : null;
+ if (decision) this.sounding.add(decision);
+ try {
+ await this.platform.show(
+ {
+ id: crypto.randomUUID(),
+ ...item.text(),
+ },
+ () => {
+ if (this.closed || generation !== this.generation) return;
+ // Opening may switch to an already joined community. Navigation
+ // owns current membership/channel access; admission's
+ // selected-session gate must not turn a still-valid prior
+ // notification into a dead click.
+ void this.navigation.open(item.target).catch(this.reportError);
+ },
+ (error) => {
+ // Native failure can arrive before `show` resolves. Cancellation is
+ // about this submission's audio decision, not whether its account
+ // still owns visible error reporting.
+ if (decision) decision.cancelled = true;
+ if (!this.closed && generation === this.generation)
+ this.reportError(error);
+ },
+ );
+ } finally {
+ if (decision) this.sounding.delete(decision);
+ }
+ // Banners are always submitted silent; the selected per-category sound
+ // plays here once the platform accepted the presentation — but only if
+ // the decision survived: not stickily cancelled by any intervening
+ // revocation while the submission was outstanding, same account
+ // generation, and still allowed, eligible and Sound-enabled now.
+ if (
+ decision &&
+ !decision.cancelled &&
+ generation === this.generation &&
+ this.state.preferences.sound &&
+ this.allowed(item) &&
+ item.eligible() === true
+ )
+ this.playSound(
+ resolveCategorySound(this.state.preferences.sounds, item.category),
+ );
} catch (error) {
this.pending.delete(item);
this.reportError(error);
diff --git a/src/features/notifications/sound.test.ts b/src/features/notifications/sound.test.ts
new file mode 100644
index 000000000..3652d11fc
--- /dev/null
+++ b/src/features/notifications/sound.test.ts
@@ -0,0 +1,66 @@
+import { afterEach, expect, it, vi } from "vitest";
+import {
+ DEFAULT_CATEGORY_SOUNDS,
+ RECOMMENDED_SOUND_BY_CATEGORY,
+ SOUND_NAMES,
+ isSoundName,
+ playNotificationSound,
+ resolveCategorySound,
+} from "./sound";
+import { NOTIFICATION_CATEGORIES } from "./preferences";
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+it("every category has a default and a recommended sound from the bundled set", () => {
+ for (const category of NOTIFICATION_CATEGORIES) {
+ expect(isSoundName(DEFAULT_CATEGORY_SOUNDS[category])).toBe(true);
+ expect(isSoundName(RECOMMENDED_SOUND_BY_CATEGORY[category])).toBe(true);
+ expect(resolveCategorySound(DEFAULT_CATEGORY_SOUNDS, category)).toBe(
+ "flutter",
+ );
+ }
+ expect(isSoundName("klaxon")).toBe(false);
+});
+
+it("plugin categories resolve to the default sound", () => {
+ const sounds = { ...DEFAULT_CATEGORY_SOUNDS, mention: "ping" } as const;
+ expect(resolveCategorySound(sounds, "mention")).toBe("ping");
+ expect(resolveCategorySound(sounds, "updates")).toBe("flutter");
+});
+
+it("playback without an Audio constructor reports null, never throws", () => {
+ // Bare Node has no Audio global; delivery sound stays best-effort.
+ expect(playNotificationSound("doong")).toBeNull();
+});
+
+it("playback restarts a cached bundled clip", () => {
+ const instances: Array<{ src: string; currentTime: number; play: unknown }> =
+ [];
+ const play = vi.fn(async () => {});
+ vi.stubGlobal(
+ "Audio",
+ class {
+ currentTime = 5;
+ play = play;
+ constructor(public src: string) {
+ instances.push(this);
+ }
+ },
+ );
+ const first = playNotificationSound("ping");
+ expect(instances[0]?.src).toBe("/sounds/ping.mp3");
+ expect(first).toBe(instances[0]);
+ expect(first?.currentTime).toBe(0);
+ playNotificationSound("ping");
+ expect(instances).toHaveLength(1);
+ expect(play).toHaveBeenCalledTimes(2);
+ for (const name of SOUND_NAMES) playNotificationSound(name);
+ // "ping" is already cached from above, so it stays first and is not recreated.
+ expect(instances.map((audio) => audio.src)).toEqual(
+ ["ping", ...SOUND_NAMES.filter((name) => name !== "ping")].map(
+ (name) => `/sounds/${name}.mp3`,
+ ),
+ );
+});
diff --git a/src/features/notifications/sound.ts b/src/features/notifications/sound.ts
new file mode 100644
index 000000000..52b631d92
--- /dev/null
+++ b/src/features/notifications/sound.ts
@@ -0,0 +1,89 @@
+import type { NotificationCategory } from "./preferences";
+
+/** Bundled alert sounds under public/sounds, shared with the reference client. */
+export const SOUND_NAMES = [
+ "bong",
+ "boo",
+ "dng",
+ "doo",
+ "doodone",
+ "doong",
+ "doop",
+ "flirl",
+ "flutter",
+ "oh-no",
+ "ping",
+ "unison",
+] as const;
+export type SoundName = (typeof SOUND_NAMES)[number];
+const SOUND_NAME_SET: ReadonlySet = new Set(SOUND_NAMES);
+export function isSoundName(value: unknown): value is SoundName {
+ return typeof value === "string" && SOUND_NAME_SET.has(value);
+}
+
+export const DEFAULT_SOUND: SoundName = "flutter";
+
+export type CategorySounds = Readonly>;
+export const DEFAULT_CATEGORY_SOUNDS: CategorySounds = Object.freeze({
+ mention: DEFAULT_SOUND,
+ direct: DEFAULT_SOUND,
+ thread: DEFAULT_SOUND,
+});
+
+/** Row copy and recommendations reuse the reference client's event rows. */
+export const CATEGORY_SOUND_LABELS: Record = {
+ direct: "Direct messages",
+ mention: "@Mentions",
+ thread: "Thread replies",
+};
+export const CATEGORY_SOUND_DESCRIPTIONS: Record =
+ {
+ direct: "When someone messages you directly.",
+ mention: "When someone tags you in a channel.",
+ thread: "When someone replies in a thread you follow or posted in.",
+ };
+export const RECOMMENDED_SOUND_BY_CATEGORY: Record<
+ NotificationCategory,
+ SoundName
+> = {
+ direct: "unison",
+ mention: "ping",
+ thread: "doop",
+};
+
+/** Plugin categories have no per-category choice; they use the default sound. */
+export function resolveCategorySound(
+ sounds: CategorySounds,
+ category: string,
+): SoundName {
+ return (
+ (sounds as Partial>)[category] ?? DEFAULT_SOUND
+ );
+}
+
+const cache = new Map();
+
+function getAudio(name: SoundName): HTMLAudioElement {
+ let audio = cache.get(name);
+ if (!audio) {
+ audio = new Audio(`/sounds/${name}.mp3`);
+ cache.set(name, audio);
+ }
+ return audio;
+}
+
+export function playNotificationSound(
+ name: SoundName,
+): HTMLAudioElement | null {
+ try {
+ const audio = getAudio(name);
+ audio.currentTime = 0;
+ audio.play().catch(() => {
+ // Best-effort — user may not have interacted with the page yet.
+ });
+ return audio;
+ } catch {
+ // Best-effort only.
+ return null;
+ }
+}
diff --git a/src/features/relay/unread.test.ts b/src/features/relay/unread.test.ts
index 9833223bb..e67e66a35 100644
--- a/src/features/relay/unread.test.ts
+++ b/src/features/relay/unread.test.ts
@@ -1037,7 +1037,8 @@ it("projects event attention through the same mention, DM, participation and fro
status: "eligible",
category: "direct",
});
- expect(attention(mention.id).category).toBe("mention");
+ // DM events p-tag their recipient; direct wins over mention in DM channels.
+ expect(attention(mention.id).category).toBe("direct");
expect(h.snapshot().attentionCount).toBe(2);
expect(attention("f".repeat(64)).status).toBe("unknown");
lease.dispose();
diff --git a/src/features/relay/unread.ts b/src/features/relay/unread.ts
index 5e612f56c..b1eed5da1 100644
--- a/src/features/relay/unread.ts
+++ b/src/features/relay/unread.ts
@@ -31,6 +31,8 @@ export type UnreadSnapshot = Readonly<{
export type MessageAttention = Readonly<{
status: "unknown" | "ineligible" | "eligible";
category?: "mention" | "direct" | "thread";
+ /** The event explicitly p-tags the viewer, even inside DM channels. */
+ mentioned?: boolean;
rootId?: string;
unread: boolean;
viewing: boolean;
@@ -265,10 +267,12 @@ export function createUnread({
{ rootId, mentioned }: Evidence,
dm: boolean,
): MessageAttention["category"] {
- return mentioned
- ? "mention"
- : dm
- ? "direct"
+ // DM events p-tag the recipient, so a mention check would classify every
+ // direct message as a mention. Direct wins inside DM channels.
+ return dm
+ ? "direct"
+ : mentioned
+ ? "mention"
: rootId && participants.has(rootId)
? "thread"
: undefined;
@@ -315,6 +319,7 @@ export function createUnread({
? "unknown"
: "ineligible",
...(kind ? { category: kind } : {}),
+ ...(entry.mentioned ? { mentioned: true } : {}),
...(entry.rootId ? { rootId: entry.rootId } : {}),
unread: isUnread(entry, reads.state()),
viewing,
diff --git a/tests/browser/notification-settings.spec.mjs b/tests/browser/notification-settings.spec.mjs
index ed1cd58b5..c8e653265 100644
--- a/tests/browser/notification-settings.spec.mjs
+++ b/tests/browser/notification-settings.spec.mjs
@@ -46,6 +46,13 @@ test("Notifications keeps settings separated and button labels contained at supp
section.getByRole("button", { name: "Allow notifications" }),
).toBeVisible();
await expect(section.getByRole("switch")).toHaveCount(6);
+ // The per-event sound controls must be present for the geometry sweep.
+ for (const name of ["Direct messages", "@Mentions", "Thread replies"]) {
+ await expect(section.getByRole("combobox", { name })).toBeVisible();
+ }
+ await expect(
+ section.getByRole("button", { name: "Preview flutter" }),
+ ).toHaveCount(3);
await page.evaluate(() => document.fonts.ready);
for (const width of [800, 390]) {
await page.setViewportSize({ width, height: 900 });