88cbbcfc46
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.
343 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|