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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

- Include the selected message as quoted HTML and plain text in replies so email clients can
collapse and expand the previous content.
- Restore notification sounds on iPhone by unlocking a local audio player during the first
interaction and reusing it for mail and toast sounds.

## 0.1.15

Expand Down
119 changes: 92 additions & 27 deletions app/lib/notification-sounds.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { tiks } from "@rexa-developer/tiks";

export type NotificationSound =
| "incoming-email"
| "outgoing-email"
Expand All @@ -18,7 +16,21 @@ export type ToastSoundType =
| "success"
| "warning";

const SOUND_VOLUME = 0.55;
const UNLOCK_SOURCE = "/sounds/unlock.wav";
const UNLOCK_EVENTS = ["touchend", "click", "keydown"] as const;
const SOUND_SOURCES: Record<NotificationSound, string> = {
"incoming-email": "/sounds/incoming-email.wav",
"outgoing-email": "/sounds/outgoing-email.wav",
"toast-error": "/sounds/toast-error.wav",
"toast-information": "/sounds/toast-information.wav",
"toast-success": "/sounds/toast-success.wav",
"toast-warning": "/sounds/toast-warning.wav"
};

let initialized = false;
let unlockListenersBound = false;
let player: HTMLAudioElement | null = null;

export function notificationSoundForToastType(
type: ToastSoundType | undefined
Expand All @@ -40,37 +52,90 @@ export function notificationSoundForToast(
}

export function initializeNotificationSounds(): void {
try {
if (!initialized) {
tiks.init({
theme: "soft",
volume: 0.18,
respectReducedMotion: true
});
initialized = true;
}
} catch {
// Audible feedback must never interrupt application startup.
}
if (initialized || typeof document === "undefined" || typeof window === "undefined") return;
initialized = true;
bindUnlockListeners();

document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "visible") resetPlayer();
});
window.addEventListener("pageshow", resetPlayer);
}

export function playNotificationSound(sound: NotificationSound): void {
try {
initializeNotificationSounds();
if (sound === "incoming-email") {
tiks.notify();
} else if (sound === "outgoing-email") {
tiks.swoosh();
} else if (sound === "toast-success") {
tiks.success();
} else if (sound === "toast-warning") {
tiks.warning();
} else if (sound === "toast-error") {
tiks.error();
} else {
tiks.pop();
}
if (!player || prefersReducedMotion()) return;

player.pause();
player.src = SOUND_SOURCES[sound];
player.currentTime = 0;
player.volume = SOUND_VOLUME;
void player.play().catch(() => {
// The next explicit interaction can unlock playback again.
bindUnlockListeners();
});
} catch {
// Audible feedback must never interrupt the underlying action.
}
}

function bindUnlockListeners(): void {
if (unlockListenersBound || typeof document === "undefined") return;
unlockListenersBound = true;
for (const event of UNLOCK_EVENTS) {
document.addEventListener(event, unlockPlayer, { capture: true, passive: true });
}
}

function unbindUnlockListeners(): void {
if (!unlockListenersBound || typeof document === "undefined") return;
unlockListenersBound = false;
for (const event of UNLOCK_EVENTS) {
document.removeEventListener(event, unlockPlayer, true);
}
}

function unlockPlayer(): void {
try {
if (typeof Audio === "undefined") return;
player ??= new Audio(UNLOCK_SOURCE);
player.preload = "auto";
player.src = UNLOCK_SOURCE;
player.currentTime = 0;

void player.play().then(
() => {
player?.pause();
if (player) {
player.currentTime = 0;
player.volume = SOUND_VOLUME;
}
unbindUnlockListeners();
},
() => {
// Keep listeners attached so the next interaction can retry.
}
);
} catch {
// Keep listeners attached so the next interaction can retry.
}
}

function resetPlayer(): void {
try {
player?.pause();
} catch {
// Resetting audio must never interrupt returning to the application.
}
player = null;
bindUnlockListeners();
}

function prefersReducedMotion(): boolean {
return (
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
1 change: 0 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,6 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@rexa-developer/tiks": "^0.3.0",
"@tiptap/extension-link": "^3.27.3",
"@tiptap/extension-placeholder": "^3.27.3",
"@tiptap/react": "^3.27.3",
Expand Down
18 changes: 0 additions & 18 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added public/sounds/incoming-email.wav
Binary file not shown.
Binary file added public/sounds/outgoing-email.wav
Binary file not shown.
Binary file added public/sounds/toast-error.wav
Binary file not shown.
Binary file added public/sounds/toast-information.wav
Binary file not shown.
Binary file added public/sounds/toast-success.wav
Binary file not shown.
Binary file added public/sounds/toast-warning.wav
Binary file not shown.
Binary file added public/sounds/unlock.wav
Binary file not shown.
1 change: 1 addition & 0 deletions scripts/build-pwa.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const allowedPrecachePaths = [
/^\/assets\//,
/^\/fonts\//,
/^\/icons\//,
/^\/sounds\//,
/^\/logo\.svg$/,
/^\/manifest\.webmanifest$/,
/^\/offline\.html$/
Expand Down
82 changes: 82 additions & 0 deletions scripts/generate-notification-sounds.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";

const sampleRate = 22_050;
const outputDirectory = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
"../public/sounds"
);

const sounds = {
"incoming-email": [
{ from: 660, to: 660, duration: 90 },
{ gap: 45 },
{ from: 880, to: 880, duration: 150 }
],
"outgoing-email": [{ from: 420, to: 920, duration: 180 }],
"toast-error": [{ from: 260, to: 170, duration: 170 }],
"toast-information": [{ from: 700, to: 700, duration: 100 }],
"toast-success": [
{ from: 520, to: 520, duration: 75 },
{ gap: 35 },
{ from: 780, to: 780, duration: 120 }
],
"toast-warning": [
{ from: 440, to: 440, duration: 65 },
{ gap: 45 },
{ from: 440, to: 440, duration: 65 }
],
unlock: [{ gap: 30 }]
};

function renderPcm(segments) {
const samples = [];
for (const segment of segments) {
const sampleCount = Math.ceil(((segment.duration ?? segment.gap ?? 0) / 1000) * sampleRate);
for (let index = 0; index < sampleCount; index += 1) {
if ("gap" in segment) {
samples.push(0);
continue;
}

const progress = index / Math.max(1, sampleCount - 1);
const frequency = segment.from + (segment.to - segment.from) * progress;
const seconds = index / sampleRate;
const fade = Math.min(1, progress * 14, (1 - progress) * 10);
samples.push(Math.sin(2 * Math.PI * frequency * seconds) * fade * 0.24);
}
}
return samples;
}

function encodeWav(samples) {
const bytesPerSample = 2;
const buffer = Buffer.alloc(44 + samples.length * bytesPerSample);
buffer.write("RIFF", 0);
buffer.writeUInt32LE(buffer.length - 8, 4);
buffer.write("WAVE", 8);
buffer.write("fmt ", 12);
buffer.writeUInt32LE(16, 16);
buffer.writeUInt16LE(1, 20);
buffer.writeUInt16LE(1, 22);
buffer.writeUInt32LE(sampleRate, 24);
buffer.writeUInt32LE(sampleRate * bytesPerSample, 28);
buffer.writeUInt16LE(bytesPerSample, 32);
buffer.writeUInt16LE(16, 34);
buffer.write("data", 36);
buffer.writeUInt32LE(samples.length * bytesPerSample, 40);

for (let index = 0; index < samples.length; index += 1) {
const value = Math.max(-1, Math.min(1, samples[index]));
buffer.writeInt16LE(Math.round(value * 32_767), 44 + index * bytesPerSample);
}
return buffer;
}

await mkdir(outputDirectory, { recursive: true });
await Promise.all(
Object.entries(sounds).map(([name, segments]) =>
writeFile(path.join(outputDirectory, `${name}.wav`), encodeWav(renderPcm(segments)))
)
);
2 changes: 2 additions & 0 deletions scripts/test-pwa.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ try {
});
assert.ok(cacheState.names.some((name) => name.startsWith("hqbase-pwa-")));
assert.ok(cacheState.urls.includes("/offline.html"));
assert.ok(cacheState.urls.includes("/sounds/incoming-email.wav"));
assert.ok(cacheState.urls.includes("/sounds/unlock.wav"));
assert.equal(
cacheState.urls.some((url) => url.startsWith("/api/")),
false
Expand Down
71 changes: 42 additions & 29 deletions test/unit/app/notification-sounds.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,6 @@
import { describe, expect, it, vi } from "vitest";

const tiks = vi.hoisted(() => ({
error: vi.fn(),
init: vi.fn(),
notify: vi.fn(),
pop: vi.fn(),
success: vi.fn(),
swoosh: vi.fn(),
warning: vi.fn()
}));

vi.mock("@rexa-developer/tiks", () => ({ tiks }));
const audioInstances: MockAudio[] = [];

import {
initializeNotificationSounds,
Expand All @@ -20,7 +10,15 @@ import {
} from "@/lib/notification-sounds";

describe("notification sounds", () => {
it("maps mail and toast events to quiet, non-blocking synthesized sounds", () => {
it("maps events and plays local audio after an explicit interaction", async () => {
const documentTarget = Object.assign(new EventTarget(), { visibilityState: "visible" });
const windowTarget = Object.assign(new EventTarget(), {
matchMedia: vi.fn(() => ({ matches: false }))
});
vi.stubGlobal("document", documentTarget);
vi.stubGlobal("window", windowTarget);
vi.stubGlobal("Audio", MockAudio);

expect(notificationSoundForToastType("success")).toBe("toast-success");
expect(notificationSoundForToastType("warning")).toBe("toast-warning");
expect(notificationSoundForToastType("error")).toBe("toast-error");
Expand All @@ -31,29 +29,44 @@ describe("notification sounds", () => {

initializeNotificationSounds();
initializeNotificationSounds();
expect(audioInstances).toHaveLength(0);

documentTarget.dispatchEvent(new Event("touchend"));
await Promise.resolve();
expect(audioInstances).toHaveLength(1);
const audio = audioInstances[0];
if (!audio) throw new Error("Expected the interaction to create an audio player.");
expect(audio.src).toBe("/sounds/unlock.wav");
expect(audio.play).toHaveBeenCalledOnce();

playNotificationSound("incoming-email");
expect(audio.src).toBe("/sounds/incoming-email.wav");
playNotificationSound("outgoing-email");
expect(audio.src).toBe("/sounds/outgoing-email.wav");
playNotificationSound("toast-success");
expect(audio.src).toBe("/sounds/toast-success.wav");
playNotificationSound("toast-warning");
expect(audio.src).toBe("/sounds/toast-warning.wav");
playNotificationSound("toast-error");
expect(audio.src).toBe("/sounds/toast-error.wav");
playNotificationSound("toast-information");
expect(audio.src).toBe("/sounds/toast-information.wav");

expect(tiks.init).toHaveBeenCalledOnce();
expect(tiks.init).toHaveBeenCalledWith({
theme: "soft",
volume: 0.18,
respectReducedMotion: true
});
expect(tiks.notify).toHaveBeenCalledOnce();
expect(tiks.swoosh).toHaveBeenCalledOnce();
expect(tiks.success).toHaveBeenCalledOnce();
expect(tiks.warning).toHaveBeenCalledOnce();
expect(tiks.error).toHaveBeenCalledOnce();
expect(tiks.pop).toHaveBeenCalledOnce();

tiks.error.mockImplementationOnce(() => {
throw new Error("Audio unavailable");
});
expect(() => playNotificationSound("toast-error")).not.toThrow();
expect(audio.play).toHaveBeenCalledTimes(7);
expect(audio.volume).toBe(0.55);
});
});

class MockAudio {
currentTime = 0;
pause = vi.fn();
play = vi.fn(() => Promise.resolve());
preload = "";
src: string;
volume = 1;

constructor(src = "") {
this.src = src;
audioInstances.push(this);
}
}
Loading