Improve notification sound throttling and delivery reliability

Refactor notification sound playback to use per-bucket throttling, adjust socket warmup, and ensure push notifications are sent even if the client is considered connected.
This commit is contained in:
Riyadh
2026-05-24 08:33:53 +00:00
parent 1096217420
commit 3ceabb85bc
4 changed files with 84 additions and 21 deletions
@@ -134,18 +134,30 @@ async function notifyUser(
}) })
.returning(); .returning();
await emitToUser(userId, "notification_created", notification); await emitToUser(userId, "notification_created", notification);
void sendPushToUser(userId, { // #628: pass `ignoreConnected: true` so a stale/ghost Socket.IO
title: titleAr, // session on a forgotten laptop tab no longer suppresses the Web
body: bodyAr, // Push to the receiver's iPad. Orders are time-critical exactly
titleAr, // like the 5-min meeting reminder — better to ring twice on the
titleEn, // foreground device than to ring zero times on the device the user
bodyAr, // is actually carrying. The in-app chime path stays unchanged for
bodyEn, // truly-foregrounded sockets; this just removes the silent-skip
type: "order", // when the "connected" tab is in fact idle/closed.
relatedId: orderId, void sendPushToUser(
tag: `order-${orderId}`, userId,
url: "/my-orders", {
}); title: titleAr,
body: bodyAr,
titleAr,
titleEn,
bodyAr,
bodyEn,
type: "order",
relatedId: orderId,
tag: `order-${orderId}`,
url: "/my-orders",
},
{ ignoreConnected: true },
);
} }
async function broadcastIncomingChanged(receiverIds: number[]) { async function broadcastIncomingChanged(receiverIds: number[]) {
@@ -22,11 +22,18 @@ export function useAutoplayHint(): void {
const handler = (e: Event) => { const handler = (e: Event) => {
const detail = (e as CustomEvent<AutoplayBlockedDetail>).detail; const detail = (e as CustomEvent<AutoplayBlockedDetail>).detail;
const isIos = !!detail?.isIos; const isIos = !!detail?.isIos;
// #628: the autoplay hint was previously an auto-dismissing
// toast that the user could easily miss while looking at the
// iPad from across the room. Make it sticky (no auto-dismiss)
// so it stays on screen until acknowledged — otherwise the
// first incoming order goes silently unnoticed and the user
// never realises notifications need a tap to unlock.
toast({ toast({
title: t("notifSettings.autoplayHintTitle"), title: t("notifSettings.autoplayHintTitle"),
description: isIos description: isIos
? t("notifSettings.autoplayHintIos") ? t("notifSettings.autoplayHintIos")
: t("notifSettings.autoplayHint"), : t("notifSettings.autoplayHint"),
duration: Number.POSITIVE_INFINITY,
}); });
}; };
window.addEventListener(AUTOPLAY_BLOCKED_EVENT, handler); window.addEventListener(AUTOPLAY_BLOCKED_EVENT, handler);
@@ -26,7 +26,14 @@ import {
const BASE = import.meta.env.BASE_URL.replace(/\/$/, ""); const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
const SOCKET_WARMUP_MS = 3000; // #628: warmup window after socket (re)connect during which chimes
// are suppressed so a page-load flush of pending events doesn't sound
// like an alarm. Lowered from 3000 → 1000 ms because on mobile the
// socket disconnects/reconnects often (background tab, tunnel hop,
// PWA wake-up); a 3-second window silently swallowed any order that
// arrived in the first few seconds after returning to the app. 1 s
// still covers the synchronous initial-burst at first connect.
const SOCKET_WARMUP_MS = 1000;
const nowMs = () => const nowMs = () =>
typeof performance !== "undefined" ? performance.now() : Date.now(); typeof performance !== "undefined" ? performance.now() : Date.now();
@@ -127,7 +134,18 @@ export function useNotificationsSocket() {
const vibrate = isOrder const vibrate = isOrder
? user.vibrationEnabledOrder ? user.vibrationEnabledOrder
: user.vibrationEnabledMeeting; : user.vibrationEnabledMeeting;
notificationPlayer.play(soundId, { vibrate }); // #628: order chimes use a tighter throttle window so a small
// burst of incoming orders still rings for each one. Meeting
// chimes keep the default 3s window — a single meeting event
// can fan out to several listeners and we don't want a
// buzzy double-chime. Buckets keep the two channels isolated
// so an order burst can't starve a meeting alert (or vice
// versa).
notificationPlayer.play(soundId, {
vibrate,
throttleMs: isOrder ? 800 : 3000,
throttleBucket: isOrder ? "order" : "meeting",
});
}); });
socket.on("order_updated", () => { socket.on("order_updated", () => {
@@ -199,6 +217,7 @@ export function useNotificationsSocket() {
playedNoteIdsRef.current.add(payload.noteId); playedNoteIdsRef.current.add(payload.noteId);
notificationPlayer.play(user.notificationSoundNote, { notificationPlayer.play(user.notificationSoundNote, {
vibrate: user.vibrationEnabledNote, vibrate: user.vibrationEnabledNote,
throttleBucket: "note",
}); });
} }
// Only fall back to a toast when the floating popup did NOT // Only fall back to a toast when the floating popup did NOT
@@ -284,6 +303,7 @@ export function useNotificationsSocket() {
playedReplyIdsRef.current.add(replyId); playedReplyIdsRef.current.add(replyId);
notificationPlayer.play(user.notificationSoundNote, { notificationPlayer.play(user.notificationSoundNote, {
vibrate: user.vibrationEnabledNote, vibrate: user.vibrationEnabledNote,
throttleBucket: "note",
}); });
} }
// See `note_received` above: skip the redundant toast when the // See `note_received` above: skip the redundant toast when the
+31 -7
View File
@@ -118,7 +118,13 @@ class NotificationPlayer {
// ---- Shared state ---- // ---- Shared state ----
private unlocked = false; private unlocked = false;
private lastPlayAt = 0; // #628: per-bucket throttle timestamps. Was a single global value,
// which coupled channels: lowering the order throttle to 800ms
// caused rapid order chimes to keep refreshing the global timestamp
// and starve meeting/note chimes (whose 3s window kept restarting).
// Bucketing isolates each channel so order bursts can't mute
// meeting/note alerts and vice versa.
private lastPlayAtByBucket = new Map<string, number>();
private hintDispatched = false; private hintDispatched = false;
private get useIos(): boolean { private get useIos(): boolean {
@@ -334,16 +340,34 @@ class NotificationPlayer {
*/ */
play( play(
soundId: NotificationSoundId, soundId: NotificationSoundId,
opts: { vibrate?: boolean; bypassThrottle?: boolean } = {}, opts: {
vibrate?: boolean;
bypassThrottle?: boolean;
// #628: per-call throttle override. Order chimes drop to 800ms
// so a small burst of incoming orders (rare but real during
// service rushes) still rings for each one instead of swallowing
// every order after the first for the next 3 seconds. Other
// channels keep the original 3000ms coalescing window so a
// single meeting fanout that touches several listeners doesn't
// produce a buzzy double-chime.
throttleMs?: number;
// #628: throttle bucket key. Defaults to "default" so legacy
// callers behave as before. Callers that want isolated coalescing
// (e.g. "order" vs "meeting" vs "note") pass distinct buckets so
// a burst on one channel can't extend the silence window of
// another channel.
throttleBucket?: string;
} = {},
): void { ): void {
// Throttle: at most one sound every 3s to coalesce bursts of
// notifications into a single chime. Settings "Test sound"
// bypasses this so users can audition multiple sounds quickly.
const now = const now =
typeof performance !== "undefined" ? performance.now() : Date.now(); typeof performance !== "undefined" ? performance.now() : Date.now();
if (!opts.bypassThrottle) { if (!opts.bypassThrottle) {
if (now - this.lastPlayAt < 3000) return; const window =
this.lastPlayAt = now; typeof opts.throttleMs === "number" ? opts.throttleMs : 3000;
const bucket = opts.throttleBucket ?? "default";
const last = this.lastPlayAtByBucket.get(bucket) ?? 0;
if (now - last < window) return;
this.lastPlayAtByBucket.set(bucket, now);
} }
const entry = SOUND_BY_ID.get(soundId) ?? SOUND_BY_ID.get("ding"); const entry = SOUND_BY_ID.get(soundId) ?? SOUND_BY_ID.get("ding");