Skip to content

Commit db65f2f

Browse files
committed
feat(settings): add random notification sound options
Add "Random (all)" and "Random (custom)" to the completion sound dropdown. Random-all picks a fresh sound per notification from the built-ins plus any installed custom sounds; random-custom draws from custom sounds only and is offered once at least one is installed. Removing the last custom sound while random-custom is active falls back to silence, matching the existing deleted-sound behaviour. Generated-By: PostHog Code Task-Id: 2e7e511c-6b31-4480-8ac1-37863ddd7c3b
1 parent 9725323 commit db65f2f

5 files changed

Lines changed: 86 additions & 11 deletions

File tree

packages/ui/src/features/settings/sections/NotificationsSettings.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,10 @@ export function NotificationsSettings() {
242242
<Select.Trigger className="min-w-[100px]" />
243243
<Select.Content>
244244
<Select.Item value="none">None</Select.Item>
245+
<Select.Item value="random-all">Random (all)</Select.Item>
246+
{customSounds.length > 0 && (
247+
<Select.Item value="random-custom">Random (custom)</Select.Item>
248+
)}
245249
<Select.Item value="guitar">Guitar solo</Select.Item>
246250
<Select.Item value="danilo">I'm ready</Select.Item>
247251
<Select.Item value="revi">Cute noise</Select.Item>

packages/ui/src/features/settings/settingsStore.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,11 @@ describe("feature settingsStore custom sounds", () => {
195195
activeSound: "meep" as CompletionSound,
196196
expectedSound: "meep" as CompletionSound,
197197
},
198+
{
199+
label: "last sound feeding random-custom",
200+
activeSound: "random-custom" as CompletionSound,
201+
expectedSound: "none" as CompletionSound,
202+
},
198203
])(
199204
"removing the $label leaves completionSound as $expectedSound",
200205
({ activeSound, expectedSound }) => {
@@ -206,6 +211,14 @@ describe("feature settingsStore custom sounds", () => {
206211
},
207212
);
208213

214+
it("keeps random-custom active while other custom sounds remain", () => {
215+
useSettingsStore.getState().addCustomSound(sound);
216+
useSettingsStore.getState().addCustomSound({ ...sound, id: "def" });
217+
useSettingsStore.getState().setCompletionSound("random-custom");
218+
useSettingsStore.getState().removeCustomSound("abc");
219+
expect(useSettingsStore.getState().completionSound).toBe("random-custom");
220+
});
221+
209222
it("persists custom sounds", async () => {
210223
useSettingsStore.getState().addCustomSound(sound);
211224

packages/ui/src/features/settings/settingsStore.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,11 @@ export type BuiltInCompletionSound =
4545
| "icq";
4646

4747
// A user-installed sound is selected by referencing its id as `custom:<id>`.
48-
export type CompletionSound = BuiltInCompletionSound | `custom:${string}`;
48+
export type CompletionSound =
49+
| BuiltInCompletionSound
50+
| "random-all"
51+
| "random-custom"
52+
| `custom:${string}`;
4953

5054
// A notification sound the user recorded or imported. The clip is stored inline
5155
// as a base64 data URL so it persists with the rest of the settings (no host
@@ -276,14 +280,21 @@ export const useSettingsStore = create<SettingsStore>()(
276280
addCustomSound: (sound) =>
277281
set((state) => ({ customSounds: [...state.customSounds, sound] })),
278282
removeCustomSound: (id) =>
279-
set((state) => ({
280-
customSounds: state.customSounds.filter((s) => s.id !== id),
281-
// If the deleted sound was the active one, fall back to silence.
282-
completionSound:
283-
state.completionSound === `custom:${id}`
283+
set((state) => {
284+
const customSounds = state.customSounds.filter((s) => s.id !== id);
285+
// If the deleted sound was the active one, or it was the last sound
286+
// feeding "random-custom", fall back to silence.
287+
const soundNowUnplayable =
288+
state.completionSound === `custom:${id}` ||
289+
(state.completionSound === "random-custom" &&
290+
customSounds.length === 0);
291+
return {
292+
customSounds,
293+
completionSound: soundNowUnplayable
284294
? "none"
285295
: state.completionSound,
286-
})),
296+
};
297+
}),
287298
renameCustomSound: (id, name) =>
288299
set((state) => ({
289300
customSounds: state.customSounds.map((s) =>

packages/ui/src/utils/sounds.test.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type {
22
CompletionSound,
33
CustomSound,
44
} from "@posthog/ui/features/settings/settingsStore";
5-
import { describe, expect, it } from "vitest";
5+
import { afterEach, describe, expect, it, vi } from "vitest";
66
import { playbackRateForTaskDuration, resolveSoundUrl } from "./sounds";
77

88
const customs: CustomSound[] = [
@@ -39,6 +39,41 @@ describe("resolveSoundUrl", () => {
3939
// e.g. the active sound was deleted from the library.
4040
expect(resolveSoundUrl("custom:gone", customs)).toBeNull();
4141
});
42+
43+
describe("random modes", () => {
44+
afterEach(() => {
45+
vi.restoreAllMocks();
46+
});
47+
48+
it("'random-all' with no custom sounds picks a built-in asset", () => {
49+
const url = resolveSoundUrl("random-all", []);
50+
expect(url).toBeTruthy();
51+
expect(url).not.toMatch(/^data:/);
52+
});
53+
54+
it("'random-custom' with a single custom sound picks it", () => {
55+
expect(resolveSoundUrl("random-custom", customs)).toBe(
56+
"data:audio/wav;base64,AAAA",
57+
);
58+
});
59+
60+
it("'random-custom' with no custom sounds returns null", () => {
61+
expect(resolveSoundUrl("random-custom", [])).toBeNull();
62+
});
63+
64+
it.each([
65+
["lowest roll picks a built-in", 0, false],
66+
["highest roll picks a custom sound", 0.999999, true],
67+
])(
68+
"'random-all' spans built-ins and customs: %s",
69+
(_label, roll, expectCustom) => {
70+
vi.spyOn(Math, "random").mockReturnValue(roll);
71+
const url = resolveSoundUrl("random-all", customs);
72+
expect(url === "data:audio/wav;base64,AAAA").toBe(expectCustom);
73+
expect(url).toBeTruthy();
74+
},
75+
);
76+
});
4277
});
4378

4479
describe("playbackRateForTaskDuration", () => {

packages/ui/src/utils/sounds.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,15 +68,27 @@ export function playbackRateForTaskDuration(durationMs: number): number {
6868

6969
let currentAudio: HTMLAudioElement | null = null;
7070

71+
function pickRandom(pool: string[]): string | null {
72+
if (pool.length === 0) return null;
73+
return pool[Math.floor(Math.random() * pool.length)];
74+
}
75+
7176
// Resolves the playable URL for a completion sound: a bundled asset URL for the
72-
// built-ins, or the inline data URL of a user-installed custom sound. Returns
73-
// null for `none`, an unknown built-in, or a `custom:` id no longer installed
74-
// (e.g. the active sound was deleted) — callers then play nothing.
77+
// built-ins, the inline data URL of a user-installed custom sound, or a fresh
78+
// random pick per call for the `random-*` modes. Returns null for `none`, an
79+
// unknown built-in, a `custom:` id no longer installed (e.g. the active sound
80+
// was deleted), or `random-custom` with no sounds installed — callers then
81+
// play nothing.
7582
export function resolveSoundUrl(
7683
sound: CompletionSound,
7784
customSounds: CustomSound[],
7885
): string | null {
7986
if (sound === "none") return null;
87+
const customUrls = customSounds.map((s) => s.dataUrl);
88+
if (sound === "random-all") {
89+
return pickRandom([...Object.values(SOUND_URLS), ...customUrls]);
90+
}
91+
if (sound === "random-custom") return pickRandom(customUrls);
8092
if (sound.startsWith(CUSTOM_SOUND_PREFIX)) {
8193
const id = sound.slice(CUSTOM_SOUND_PREFIX.length);
8294
return customSounds.find((s) => s.id === id)?.dataUrl ?? null;

0 commit comments

Comments
 (0)