Task #389: Notification sounds + vibration with per-user customization

- DB: 7 new user prefs (sound per slot, per-type toggles, vibration, mute, volume).
- API: PATCH /auth/me/notification-preferences with whitelisted partial update,
  integer guard for volume, and hydrated AuthUser response. AuthUser now exposes
  the 7 new fields.
- Frontend: Web Audio synth library (8 sounds), settings popover with global
  mute/vibration/volume/per-type toggles, slot tabs, sound preview buttons,
  shift+click on bell for quick mute. Concurrency-safe optimistic updates with
  monotonic seq counter. RTL-correct toggle knob transforms.
- Socket hook plays sound on notification_created (orders/meetings only) when
  tab is hidden, respecting global mute and per-type toggles.
- Audio unlock hook mounted globally to satisfy autoplay restrictions.
- AR/EN translations added.

Pre-existing TS errors in executive-meetings.ts (font_settings) and pre-existing
test failures in test workflow are unrelated to this task.
This commit is contained in:
riyadhafraa
2026-05-05 08:13:38 +00:00
parent 477bcbcce9
commit 242f63ab26
14 changed files with 1042 additions and 1 deletions
+2
View File
@@ -4,6 +4,7 @@ import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { AuthProvider } from "@/contexts/AuthContext";
import { useNotificationsSocket } from "@/hooks/use-notifications-socket";
import { useAudioUnlock } from "@/hooks/use-audio-unlock";
import { UpcomingMeetingAlert } from "@/components/executive-meetings/upcoming-meeting-alert";
import NotFound from "@/pages/not-found";
import LoginPage from "@/pages/login";
@@ -31,6 +32,7 @@ const queryClient = new QueryClient({
function NotificationsSocketBridge() {
useNotificationsSocket();
useAudioUnlock();
return null;
}
@@ -0,0 +1,292 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import {
useUpdateNotificationPreferences,
getGetMeQueryKey,
type AuthUser,
type NotificationSoundId,
} from "@workspace/api-client-react";
import { Bell, BellOff, Check, Volume2, Play } from "lucide-react";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { ALL_SOUND_IDS, notificationPlayer } from "@/lib/notification-sounds";
import { useAuth } from "@/contexts/AuthContext";
type PrefsPatch = {
notificationSoundOrder?: NotificationSoundId;
notificationSoundMeeting?: NotificationSoundId;
notifyOrdersEnabled?: boolean;
notifyMeetingsEnabled?: boolean;
vibrationEnabled?: boolean;
notificationsMuted?: boolean;
notificationVolume?: number;
};
function applyOptimistic(prev: AuthUser | undefined, patch: PrefsPatch): AuthUser | undefined {
if (!prev) return prev;
return { ...prev, ...patch };
}
export function useUpdatePrefs() {
const queryClient = useQueryClient();
const mutation = useUpdateNotificationPreferences();
// Monotonic counter so out-of-order responses from rapid clicks (or a
// dragged slider) cannot overwrite newer optimistic state.
const seqRef = useRef(0);
const lastAppliedRef = useRef(0);
return (patch: PrefsPatch) => {
const seq = ++seqRef.current;
queryClient.cancelQueries({ queryKey: getGetMeQueryKey() }).catch(() => {});
const previous = queryClient.getQueryData<AuthUser>(getGetMeQueryKey());
const next = applyOptimistic(previous, patch);
if (next) queryClient.setQueryData<AuthUser>(getGetMeQueryKey(), next);
mutation.mutate(
{ data: patch },
{
onSuccess: (data) => {
// Only the latest in-flight mutation may publish server state,
// otherwise a slow earlier response would clobber a newer one.
if (seq < lastAppliedRef.current) return;
lastAppliedRef.current = seq;
if (seq === seqRef.current) {
queryClient.setQueryData(getGetMeQueryKey(), data);
}
},
onError: () => {
// Only roll back if no newer write has been initiated; otherwise
// the newer optimistic state should stand.
if (seq !== seqRef.current) return;
if (previous) queryClient.setQueryData(getGetMeQueryKey(), previous);
else queryClient.invalidateQueries({ queryKey: getGetMeQueryKey() });
},
},
);
};
}
type Slot = "order" | "meeting";
function SoundList({
current,
onPick,
volume,
}: {
current: NotificationSoundId;
onPick: (id: NotificationSoundId) => void;
volume: number;
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-0.5 max-h-56 overflow-y-auto">
{ALL_SOUND_IDS.map((id) => {
const isActive = id === current;
return (
<div
key={id}
className={`flex items-center justify-between gap-2 px-2 py-1.5 rounded-lg ${
isActive ? "bg-primary/10" : "hover:bg-foreground/5"
}`}
>
<button
type="button"
onClick={() => onPick(id)}
className="flex-1 flex items-center gap-2 text-start min-w-0"
>
{isActive ? (
<Check size={14} className="text-primary shrink-0" />
) : (
<span className="w-3.5 shrink-0" />
)}
<span className="text-xs text-foreground font-medium truncate">
{t(`notifSettings.sounds.${id}`)}
</span>
</button>
<button
type="button"
onClick={() => {
notificationPlayer.unlock();
notificationPlayer.play(id, { volume });
}}
aria-label={t("notifSettings.preview")}
className="p-1 rounded hover:bg-foreground/10 text-muted-foreground"
>
<Play size={12} />
</button>
</div>
);
})}
</div>
);
}
function Toggle({
active,
onClick,
label,
}: {
active: boolean;
onClick: () => void;
label: string;
}) {
return (
<button
type="button"
onClick={onClick}
className="w-full flex items-center justify-between gap-3 px-2 py-2 rounded-lg text-start hover:bg-foreground/5 transition-colors"
>
<span className="text-xs text-foreground font-medium truncate">{label}</span>
<span
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
active ? "bg-primary" : "bg-foreground/15"
}`}
aria-hidden="true"
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
active
? "translate-x-[18px] rtl:translate-x-0.5"
: "translate-x-0.5 rtl:translate-x-[18px]"
}`}
/>
</span>
</button>
);
}
export function NotificationSettingsPicker() {
const { t } = useTranslation();
const { user } = useAuth();
const [open, setOpen] = useState(false);
const [slot, setSlot] = useState<Slot>("order");
const apply = useUpdatePrefs();
if (!user) return null;
const muted = user.notificationsMuted;
const volume = user.notificationVolume;
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
aria-label={t(muted ? "notifSettings.unmute" : "notifSettings.mute")}
aria-pressed={muted}
className="text-muted-foreground hover:text-foreground transition-colors p-1.5 md:p-2.5 rounded-lg md:rounded-xl hover:bg-foreground/5 relative"
onClick={(e) => {
// Shift+click toggles mute quickly without opening the popover.
if (e.shiftKey) {
e.preventDefault();
apply({ notificationsMuted: !muted });
}
}}
>
{muted ? (
<BellOff className="w-[18px] h-[18px] md:w-6 md:h-6" />
) : (
<Bell className="w-[18px] h-[18px] md:w-6 md:h-6" />
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-80 p-2" align="end">
<div className="px-2 py-1.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
{t("notifSettings.title")}
</div>
<Toggle
active={!muted}
onClick={() => apply({ notificationsMuted: !muted })}
label={t("notifSettings.globalSound")}
/>
<Toggle
active={user.vibrationEnabled}
onClick={() => apply({ vibrationEnabled: !user.vibrationEnabled })}
label={t("notifSettings.vibration")}
/>
<div className="h-px bg-foreground/10 mx-1 my-1" />
<div className="flex items-center gap-2 px-2 py-1.5">
<Volume2 size={14} className="text-muted-foreground" />
<input
type="range"
min={0}
max={100}
step={5}
value={volume}
onChange={(e) =>
apply({ notificationVolume: Number(e.target.value) })
}
className="flex-1 accent-primary"
aria-label={t("notifSettings.volume")}
/>
<span className="text-xs text-muted-foreground w-8 text-end tabular-nums">
{volume}
</span>
</div>
<div className="h-px bg-foreground/10 mx-1 my-1" />
<Toggle
active={user.notifyOrdersEnabled}
onClick={() =>
apply({ notifyOrdersEnabled: !user.notifyOrdersEnabled })
}
label={t("notifSettings.ordersEnabled")}
/>
<Toggle
active={user.notifyMeetingsEnabled}
onClick={() =>
apply({ notifyMeetingsEnabled: !user.notifyMeetingsEnabled })
}
label={t("notifSettings.meetingsEnabled")}
/>
<div className="h-px bg-foreground/10 mx-1 my-1" />
<div
className="grid grid-cols-2 gap-1 mx-1 mb-1 p-1 rounded-lg bg-foreground/5"
role="group"
>
{(["order", "meeting"] as const).map((s) => {
const isActive = s === slot;
return (
<button
key={s}
type="button"
onClick={() => setSlot(s)}
aria-pressed={isActive}
className={`text-xs font-medium px-2 py-1.5 rounded-md transition-colors ${
isActive
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t(`notifSettings.slot.${s}`)}
</button>
);
})}
</div>
<SoundList
current={
slot === "order"
? user.notificationSoundOrder
: user.notificationSoundMeeting
}
onPick={(id) =>
apply(
slot === "order"
? { notificationSoundOrder: id }
: { notificationSoundMeeting: id },
)
}
volume={volume}
/>
</PopoverContent>
</Popover>
);
}
@@ -0,0 +1,30 @@
import { useEffect } from "react";
import { notificationPlayer } from "@/lib/notification-sounds";
/**
* Browsers require a user gesture before audio can play. Listen for the
* first interaction anywhere on the page and unlock the shared audio
* context so subsequent socket-driven sounds work.
*/
export function useAudioUnlock(): void {
useEffect(() => {
if (typeof window === "undefined") return;
let done = false;
const unlock = () => {
if (done) return;
done = true;
notificationPlayer.unlock();
window.removeEventListener("pointerdown", unlock);
window.removeEventListener("keydown", unlock);
window.removeEventListener("touchstart", unlock);
};
window.addEventListener("pointerdown", unlock, { once: true });
window.addEventListener("keydown", unlock, { once: true });
window.addEventListener("touchstart", unlock, { once: true });
return () => {
window.removeEventListener("pointerdown", unlock);
window.removeEventListener("keydown", unlock);
window.removeEventListener("touchstart", unlock);
};
}, []);
}
@@ -15,6 +15,7 @@ import {
getGetRoleUsageQueryKey,
} from "@workspace/api-client-react";
import { useAuth } from "@/contexts/AuthContext";
import { notificationPlayer } from "@/lib/notification-sounds";
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
@@ -40,6 +41,25 @@ export function useNotificationsSocket() {
queryKey: getListIncomingServiceOrdersQueryKey(),
});
}
// Per-user sound + vibration. Stay silent when the tab is in the
// foreground (the visible UI already conveys the change), when the
// user has globally muted notifications, or when this event type
// is opted-out.
if (typeof document !== "undefined" && document.visibilityState === "visible") return;
if (user.notificationsMuted) return;
const isOrder = payload?.type === "order";
const isMeeting = payload?.type === "executive_meeting";
if (isOrder && !user.notifyOrdersEnabled) return;
if (isMeeting && !user.notifyMeetingsEnabled) return;
if (!isOrder && !isMeeting) return;
const soundId = isOrder
? user.notificationSoundOrder
: user.notificationSoundMeeting;
notificationPlayer.play(soundId, {
volume: user.notificationVolume,
vibrate: user.vibrationEnabled,
});
});
socket.on("order_updated", () => {
@@ -0,0 +1,179 @@
import type { NotificationSoundId } from "@workspace/api-client-react";
type ToneStep = {
freq: number;
duration: number;
delay?: number;
type?: OscillatorType;
gain?: number;
};
type SoundDef = {
id: NotificationSoundId;
steps: ToneStep[];
};
export const SOUND_LIBRARY: ReadonlyArray<SoundDef> = [
{
id: "ding",
steps: [
{ freq: 1318.5, duration: 0.35, type: "sine", gain: 0.5 },
{ freq: 880, duration: 0.45, delay: 0.05, type: "sine", gain: 0.35 },
],
},
{
id: "chime",
steps: [
{ freq: 523.25, duration: 0.3, type: "sine" },
{ freq: 659.25, duration: 0.3, delay: 0.12, type: "sine" },
{ freq: 783.99, duration: 0.5, delay: 0.24, type: "sine" },
],
},
{
id: "bell",
steps: [
{ freq: 1760, duration: 0.6, type: "triangle", gain: 0.45 },
{ freq: 880, duration: 0.6, delay: 0.0, type: "sine", gain: 0.25 },
],
},
{
id: "knock",
steps: [
{ freq: 180, duration: 0.12, type: "square", gain: 0.55 },
{ freq: 180, duration: 0.12, delay: 0.18, type: "square", gain: 0.55 },
],
},
{
id: "pop",
steps: [
{ freq: 600, duration: 0.08, type: "sine", gain: 0.55 },
{ freq: 1200, duration: 0.12, delay: 0.04, type: "sine", gain: 0.45 },
],
},
{
id: "alert",
steps: [
{ freq: 988, duration: 0.18, type: "square", gain: 0.4 },
{ freq: 988, duration: 0.18, delay: 0.22, type: "square", gain: 0.4 },
{ freq: 988, duration: 0.18, delay: 0.44, type: "square", gain: 0.4 },
],
},
{
id: "beep",
steps: [{ freq: 1000, duration: 0.25, type: "sine", gain: 0.5 }],
},
{
id: "soft",
steps: [
{ freq: 392, duration: 0.5, type: "sine", gain: 0.35 },
{ freq: 587.33, duration: 0.5, delay: 0.1, type: "sine", gain: 0.3 },
],
},
];
const SOUND_BY_ID = new Map(SOUND_LIBRARY.map((s) => [s.id, s]));
const ALL_SOUND_IDS: ReadonlyArray<NotificationSoundId> = SOUND_LIBRARY.map(
(s) => s.id,
);
export { ALL_SOUND_IDS };
type AnyAudioContext = AudioContext;
class NotificationPlayer {
private ctx: AnyAudioContext | null = null;
private unlocked = false;
private lastPlayAt = 0;
private getCtx(): AnyAudioContext | null {
if (typeof window === "undefined") return null;
if (this.ctx) return this.ctx;
const Ctor =
window.AudioContext ||
(window as unknown as { webkitAudioContext?: typeof AudioContext })
.webkitAudioContext;
if (!Ctor) return null;
try {
this.ctx = new Ctor();
} catch {
this.ctx = null;
}
return this.ctx;
}
/**
* Browsers require a user gesture before audio can play. Call this once
* the user interacts with the app to "unlock" the AudioContext.
*/
unlock(): void {
if (this.unlocked) return;
const ctx = this.getCtx();
if (!ctx) return;
if (ctx.state === "suspended") {
ctx.resume().catch(() => {});
}
// Play a tiny silent buffer to satisfy iOS/Safari unlock semantics.
try {
const buffer = ctx.createBuffer(1, 1, 22050);
const src = ctx.createBufferSource();
src.buffer = buffer;
src.connect(ctx.destination);
src.start(0);
} catch {
/* ignore */
}
this.unlocked = true;
}
play(
soundId: NotificationSoundId,
opts: { volume?: number; vibrate?: boolean } = {},
): void {
// Throttle: at most one sound every 600ms to avoid cacophony when
// many events arrive at once.
const now =
typeof performance !== "undefined" ? performance.now() : Date.now();
if (now - this.lastPlayAt < 600) return;
this.lastPlayAt = now;
const def = SOUND_BY_ID.get(soundId) ?? SOUND_BY_ID.get("ding");
if (!def) return;
const ctx = this.getCtx();
if (ctx) {
if (ctx.state === "suspended") ctx.resume().catch(() => {});
const baseGain = Math.max(0, Math.min(1, (opts.volume ?? 70) / 100));
const t0 = ctx.currentTime + 0.01;
for (const step of def.steps) {
try {
const osc = ctx.createOscillator();
const g = ctx.createGain();
osc.type = step.type ?? "sine";
osc.frequency.value = step.freq;
const start = t0 + (step.delay ?? 0);
const peak = baseGain * (step.gain ?? 0.4);
g.gain.setValueAtTime(0.0001, start);
g.gain.exponentialRampToValueAtTime(
Math.max(peak, 0.0002),
start + 0.01,
);
g.gain.exponentialRampToValueAtTime(0.0001, start + step.duration);
osc.connect(g).connect(ctx.destination);
osc.start(start);
osc.stop(start + step.duration + 0.05);
} catch {
/* ignore single-step failures */
}
}
}
if (opts.vibrate && typeof navigator !== "undefined" && navigator.vibrate) {
try {
navigator.vibrate([60, 40, 60]);
} catch {
/* ignore */
}
}
}
}
export const notificationPlayer = new NotificationPlayer();
+25
View File
@@ -287,6 +287,31 @@
"adminPromotedAfterLeave": "تمت ترقية {{target}} إلى مشرف بعد مغادرة المشرف السابق"
}
},
"notifSettings": {
"title": "أصوات الإشعارات",
"mute": "كتم الإشعارات",
"unmute": "إلغاء كتم الإشعارات",
"globalSound": "تشغيل صوت عند التنبيه",
"vibration": "اهتزاز عند التنبيه",
"volume": "مستوى الصوت",
"ordersEnabled": "صوت للطلبات الجديدة",
"meetingsEnabled": "صوت لتذكيرات الاجتماعات",
"preview": "تجربة",
"slot": {
"order": "الطلبات",
"meeting": "الاجتماعات"
},
"sounds": {
"ding": "رنين",
"chime": "نغمة",
"bell": "جرس",
"knock": "طرق",
"pop": "نقرة",
"alert": "تنبيه",
"beep": "صفير",
"soft": "هادئ"
}
},
"notifications": {
"title": "الإشعارات",
"markAllRead": "تحديد الكل كمقروء",
+25
View File
@@ -290,6 +290,31 @@
"noNotifications": "No new notifications",
"unread": "Unread"
},
"notifSettings": {
"title": "Notification sounds",
"mute": "Mute notifications",
"unmute": "Unmute notifications",
"globalSound": "Play sound on alerts",
"vibration": "Vibrate on alerts",
"volume": "Volume",
"ordersEnabled": "Sound for new orders",
"meetingsEnabled": "Sound for meeting reminders",
"preview": "Preview",
"slot": {
"order": "Orders",
"meeting": "Meetings"
},
"sounds": {
"ding": "Ding",
"chime": "Chime",
"bell": "Bell",
"knock": "Knock",
"pop": "Pop",
"alert": "Alert",
"beep": "Beep",
"soft": "Soft"
}
},
"admin": {
"title": "Admin Dashboard",
"manageApps": "Manage Apps",
+2
View File
@@ -65,6 +65,7 @@ import {
type HomeClockSize,
} from "@/components/clock";
import { ClockStylePicker } from "@/components/clock-style-picker";
import { NotificationSettingsPicker } from "@/components/notification-settings";
type IconName = keyof typeof LucideIcons;
function isIconName(x: string): x is IconName {
@@ -495,6 +496,7 @@ export default function HomePage() {
currentStyle={user?.clockStyle ?? null}
currentHour12={user?.clockHour12 ?? null}
/>
<NotificationSettingsPicker />
<button
onClick={toggleLanguage}
aria-label="Toggle language"