v3: chat mentions stop going missing, and live events get a light sound — the server skips a mention notification for anyone with the chatbox open, on the grounds that they're reading it, but "open" also meant a chatbox left open in a background tab, and the same modal cleared the unread badge on every arrival, so a mention there left no bell, no badge and no trace. Reading the chat now means open and on screen: the focus flag is re-announced on every visibilitychange, and the unread count only clears while the tab is visible. A reconnect with the chatbox open refetches the latest page, since whatever was said while the socket was down never reached the live buffer. The server's mention regex excluded a backslash and the letter w rather than word characters (\\w inside a class), so an email address mentioned its domain; it now matches the autocomplete's rule. On top of that, sounds synthesised with Web Audio — a soft blip for chat you aren't looking at, throttled, and a two-note chime for notifications and for a mention you're reading live, which gets no notification of its own — claimed through localStorage so several tabs chime once, with an on/off toggle under a new Notifications section in settings. The tab title carries the unread count, composed with the page title in useDocumentTitle so navigation doesn't drop it.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
123
src/utils/sound.ts
Normal file
123
src/utils/sound.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// Light UI sounds for live notifications and chat, synthesised with Web Audio
|
||||
// so there's no asset to ship or cache. Two voices: a single soft blip for a
|
||||
// chat message, and a two-note rising chime for a notification (a mention, a
|
||||
// follow, an upvote…).
|
||||
|
||||
const STORAGE_KEY = "sounds";
|
||||
// Shared across tabs: the id of the last event that chimed, so a user with the
|
||||
// site open in several tabs hears one chime per event rather than one per tab.
|
||||
const CLAIM_KEY = "sounds-last-claim";
|
||||
// A burst of chat messages shouldn't machine-gun blips; one per window is
|
||||
// enough to say "the chat is moving".
|
||||
const CHAT_THROTTLE = 4_000;
|
||||
|
||||
export type ChimeKind = "chat" | "notify";
|
||||
|
||||
export function readSoundsEnabled(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) !== "off";
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeSoundsEnabled(enabled: boolean): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, enabled ? "on" : "off");
|
||||
} catch {
|
||||
// Private mode / blocked storage: the choice just won't persist.
|
||||
}
|
||||
}
|
||||
|
||||
let ctx: AudioContext | null = null;
|
||||
let lastChatAt = 0;
|
||||
|
||||
function audio(): AudioContext | null {
|
||||
if (ctx) return ctx;
|
||||
try {
|
||||
ctx = new AudioContext();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
// Browsers keep an AudioContext suspended until the page sees a user gesture.
|
||||
// Resume it on the first one so a chime arriving later — possibly while the
|
||||
// tab is in the background — can actually be heard.
|
||||
function unlock() {
|
||||
const ac = audio();
|
||||
if (ac?.state === "suspended") void ac.resume();
|
||||
globalThis.removeEventListener("pointerdown", unlock);
|
||||
globalThis.removeEventListener("keydown", unlock);
|
||||
}
|
||||
globalThis.addEventListener("pointerdown", unlock);
|
||||
globalThis.addEventListener("keydown", unlock);
|
||||
|
||||
function claim(eventId: string): boolean {
|
||||
try {
|
||||
if (localStorage.getItem(CLAIM_KEY) === eventId) return false;
|
||||
localStorage.setItem(CLAIM_KEY, eventId);
|
||||
} catch {
|
||||
// No storage: every tab chimes, which is the lesser evil.
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function tone(
|
||||
ac: AudioContext,
|
||||
freq: number,
|
||||
start: number,
|
||||
duration: number,
|
||||
peak: number,
|
||||
) {
|
||||
const osc = ac.createOscillator();
|
||||
const gain = ac.createGain();
|
||||
osc.type = "sine";
|
||||
osc.frequency.setValueAtTime(freq, start);
|
||||
// Fast attack, exponential tail: a struck, glassy note rather than a beep.
|
||||
gain.gain.setValueAtTime(0.0001, start);
|
||||
gain.gain.exponentialRampToValueAtTime(peak, start + 0.012);
|
||||
gain.gain.exponentialRampToValueAtTime(0.0001, start + duration);
|
||||
osc.connect(gain).connect(ac.destination);
|
||||
osc.start(start);
|
||||
osc.stop(start + duration + 0.02);
|
||||
}
|
||||
|
||||
function render(kind: ChimeKind) {
|
||||
const ac = audio();
|
||||
if (!ac || ac.state !== "running") return;
|
||||
const now = ac.currentTime;
|
||||
if (kind === "chat") {
|
||||
tone(ac, 1046.5, now, 0.14, 0.05); // C6
|
||||
} else {
|
||||
tone(ac, 659.25, now, 0.22, 0.07); // E5
|
||||
tone(ac, 987.77, now + 0.09, 0.32, 0.06); // B5
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays a chime for a live event, unless sounds are off, another tab already
|
||||
* chimed for the same event, or (for chat) one played a moment ago.
|
||||
*/
|
||||
export function playChime(kind: ChimeKind, eventId: string): void {
|
||||
if (!readSoundsEnabled()) return;
|
||||
if (kind === "chat") {
|
||||
const now = Date.now();
|
||||
if (now - lastChatAt < CHAT_THROTTLE) return;
|
||||
lastChatAt = now;
|
||||
}
|
||||
if (!claim(`${kind}:${eventId}`)) return;
|
||||
render(kind);
|
||||
}
|
||||
|
||||
/** Plays a chime unconditionally — used to preview it from the settings. */
|
||||
export function previewChime(kind: ChimeKind): void {
|
||||
const ac = audio();
|
||||
if (!ac) return;
|
||||
if (ac.state === "suspended") {
|
||||
void ac.resume().then(() => render(kind));
|
||||
} else {
|
||||
render(kind);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user