Files
TX/artifacts/tx-os/src/components/notification-settings.tsx
T
Riyadh 88cbbcfc46 Task #389: Notification sounds + vibration with per-user customization
DB
- Added 7 user pref columns; vibration is now per-channel
  (vibrationEnabledOrder + vibrationEnabledMeeting). Defaults: ding/chime
  sounds, vibration on, mute off, volume 70.

API
- PATCH /auth/me/notification-preferences (requireAuth, whitelisted partial
  update, integer guard for volume). buildAuthUser exposes all new fields.
- OpenAPI spec updated; orval client + zod regenerated.

Frontend
- Static sound library: 8 short WAV assets under public/sounds/ + manifest
  (notification-sound-manifest.ts) mapping id -> labelKey -> URL.
- HTMLAudio-based player (notification-sounds.ts) with throttle, unlock,
  cache, and AUTOPLAY_BLOCKED_EVENT dispatch when play is denied pre-gesture.
- Settings popover: global mute, volume, slot tabs (orders/meetings) with
  per-channel sound + per-channel vibration + per-channel toggle, sound list
  with one-tap previews. Concurrency-safe optimistic updates (monotonic seq
  counter). RTL-correct toggle knob transforms.
- QuickMuteButton: one-tap topbar mute control (Volume2/VolumeX) with
  confirmation toast.
- useAudioUnlock: unlocks audio on first interaction.
- useAutoplayHint: toasts a localized "click anywhere to enable" hint when
  the player reports a blocked play attempt.
- Socket hook plays the right per-channel sound + vibration on
  notification_created (orders, executive_meeting) when the tab is hidden.
- UpcomingMeetingAlert: plays meeting reminder once per new eligible
  meeting (deduped by meetingId, survives 30s polling refetches).
- AR/EN translations added.

Pre-existing TS errors in executive-meetings.ts (font_settings) and
pre-existing test workflow failures are unrelated.
2026-05-05 08:20:54 +00:00

343 lines
11 KiB
TypeScript

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, VolumeX, Play } from "lucide-react";
import { useToast } from "@/hooks/use-toast";
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;
vibrationEnabledOrder?: boolean;
vibrationEnabledMeeting?: 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>
);
}
/**
* One-tap mute toggle for the topbar (separate from the settings popover
* trigger). Shows a confirmation toast so the user knows the change took
* effect even though there's no visual focus shift.
*/
export function QuickMuteButton() {
const { t } = useTranslation();
const { user } = useAuth();
const { toast } = useToast();
const apply = useUpdatePrefs();
if (!user) return null;
const muted = user.notificationsMuted;
return (
<button
type="button"
aria-label={t(muted ? "notifSettings.quickUnmute" : "notifSettings.quickMute")}
aria-pressed={muted}
data-testid="notif-quick-mute"
onClick={() => {
const next = !muted;
apply({ notificationsMuted: next });
toast({
title: t(next ? "notifSettings.mutedToast" : "notifSettings.unmutedToast"),
});
}}
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"
>
{muted ? (
<VolumeX className="w-[18px] h-[18px] md:w-6 md:h-6" />
) : (
<Volume2 className="w-[18px] h-[18px] md:w-6 md:h-6" />
)}
</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("notifSettings.title")}
aria-haspopup="dialog"
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"
>
{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")}
/>
<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" />
<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>
<Toggle
active={
slot === "order"
? user.notifyOrdersEnabled
: user.notifyMeetingsEnabled
}
onClick={() =>
apply(
slot === "order"
? { notifyOrdersEnabled: !user.notifyOrdersEnabled }
: { notifyMeetingsEnabled: !user.notifyMeetingsEnabled },
)
}
label={t(
slot === "order"
? "notifSettings.ordersEnabled"
: "notifSettings.meetingsEnabled",
)}
/>
<Toggle
active={
slot === "order"
? user.vibrationEnabledOrder
: user.vibrationEnabledMeeting
}
onClick={() =>
apply(
slot === "order"
? { vibrationEnabledOrder: !user.vibrationEnabledOrder }
: { vibrationEnabledMeeting: !user.vibrationEnabledMeeting },
)
}
label={t(
slot === "order"
? "notifSettings.vibrationOrder"
: "notifSettings.vibrationMeeting",
)}
/>
<div className="h-px bg-foreground/10 mx-1 my-1" />
<SoundList
current={
slot === "order"
? user.notificationSoundOrder
: user.notificationSoundMeeting
}
onPick={(id) =>
apply(
slot === "order"
? { notificationSoundOrder: id }
: { notificationSoundMeeting: id },
)
}
volume={volume}
/>
</PopoverContent>
</Popover>
);
}