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. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 3dcee04b-6717-4c1f-a172-b1f9c2febbe0 Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/qrzw3bH Replit-Helium-Checkpoint-Created: true
This commit is contained in:
@@ -134,18 +134,30 @@ async function notifyUser(
|
||||
})
|
||||
.returning();
|
||||
await emitToUser(userId, "notification_created", notification);
|
||||
void sendPushToUser(userId, {
|
||||
title: titleAr,
|
||||
body: bodyAr,
|
||||
titleAr,
|
||||
titleEn,
|
||||
bodyAr,
|
||||
bodyEn,
|
||||
type: "order",
|
||||
relatedId: orderId,
|
||||
tag: `order-${orderId}`,
|
||||
url: "/my-orders",
|
||||
});
|
||||
// #628: pass `ignoreConnected: true` so a stale/ghost Socket.IO
|
||||
// session on a forgotten laptop tab no longer suppresses the Web
|
||||
// Push to the receiver's iPad. Orders are time-critical exactly
|
||||
// like the 5-min meeting reminder — better to ring twice on the
|
||||
// foreground device than to ring zero times on the device the user
|
||||
// is actually carrying. The in-app chime path stays unchanged for
|
||||
// truly-foregrounded sockets; this just removes the silent-skip
|
||||
// when the "connected" tab is in fact idle/closed.
|
||||
void sendPushToUser(
|
||||
userId,
|
||||
{
|
||||
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[]) {
|
||||
|
||||
@@ -22,11 +22,18 @@ export function useAutoplayHint(): void {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent<AutoplayBlockedDetail>).detail;
|
||||
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({
|
||||
title: t("notifSettings.autoplayHintTitle"),
|
||||
description: isIos
|
||||
? t("notifSettings.autoplayHintIos")
|
||||
: t("notifSettings.autoplayHint"),
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
});
|
||||
};
|
||||
window.addEventListener(AUTOPLAY_BLOCKED_EVENT, handler);
|
||||
|
||||
@@ -26,7 +26,14 @@ import {
|
||||
|
||||
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 = () =>
|
||||
typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
|
||||
@@ -127,7 +134,18 @@ export function useNotificationsSocket() {
|
||||
const vibrate = isOrder
|
||||
? user.vibrationEnabledOrder
|
||||
: 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", () => {
|
||||
@@ -199,6 +217,7 @@ export function useNotificationsSocket() {
|
||||
playedNoteIdsRef.current.add(payload.noteId);
|
||||
notificationPlayer.play(user.notificationSoundNote, {
|
||||
vibrate: user.vibrationEnabledNote,
|
||||
throttleBucket: "note",
|
||||
});
|
||||
}
|
||||
// Only fall back to a toast when the floating popup did NOT
|
||||
@@ -284,6 +303,7 @@ export function useNotificationsSocket() {
|
||||
playedReplyIdsRef.current.add(replyId);
|
||||
notificationPlayer.play(user.notificationSoundNote, {
|
||||
vibrate: user.vibrationEnabledNote,
|
||||
throttleBucket: "note",
|
||||
});
|
||||
}
|
||||
// See `note_received` above: skip the redundant toast when the
|
||||
|
||||
@@ -118,7 +118,13 @@ class NotificationPlayer {
|
||||
|
||||
// ---- Shared state ----
|
||||
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 get useIos(): boolean {
|
||||
@@ -334,16 +340,34 @@ class NotificationPlayer {
|
||||
*/
|
||||
play(
|
||||
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 {
|
||||
// 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 =
|
||||
typeof performance !== "undefined" ? performance.now() : Date.now();
|
||||
if (!opts.bypassThrottle) {
|
||||
if (now - this.lastPlayAt < 3000) return;
|
||||
this.lastPlayAt = now;
|
||||
const window =
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user