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:
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user