diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts index 89d0d192..918e69eb 100644 --- a/artifacts/api-server/src/routes/auth.ts +++ b/artifacts/api-server/src/routes/auth.ts @@ -21,6 +21,7 @@ import { UpdateLanguageBody, UpdateClockStyleBody, UpdateClockHour12Body, + UpdateNotificationPreferencesBody, ForgotPasswordBody, ResetPasswordBody, VerifyResetTokenBody, @@ -61,6 +62,13 @@ function buildAuthUser( clockHour12: user.clockHour12, avatarUrl: user.avatarUrl, isActive: user.isActive, + notificationSoundOrder: user.notificationSoundOrder, + notificationSoundMeeting: user.notificationSoundMeeting, + notifyOrdersEnabled: user.notifyOrdersEnabled, + notifyMeetingsEnabled: user.notifyMeetingsEnabled, + vibrationEnabled: user.vibrationEnabled, + notificationsMuted: user.notificationsMuted, + notificationVolume: user.notificationVolume, roles, groups, createdAt: user.createdAt, @@ -417,4 +425,57 @@ router.patch("/auth/me/clock-style", requireAuth, async (req, res): Promise => { + const parsed = UpdateNotificationPreferencesBody.safeParse(req.body); + if (!parsed.success) { + res.status(400).json({ error: parsed.error.message }); + return; + } + if ( + parsed.data.notificationVolume !== undefined && + !Number.isInteger(parsed.data.notificationVolume) + ) { + res.status(400).json({ error: "notificationVolume must be an integer" }); + return; + } + + const updates: Record = {}; + for (const k of [ + "notificationSoundOrder", + "notificationSoundMeeting", + "notifyOrdersEnabled", + "notifyMeetingsEnabled", + "vibrationEnabled", + "notificationsMuted", + "notificationVolume", + ] as const) { + if (parsed.data[k] !== undefined) updates[k] = parsed.data[k]; + } + + if (Object.keys(updates).length === 0) { + const [user] = await db.select().from(usersTable).where(eq(usersTable.id, req.session.userId!)); + if (!user) { + res.status(401).json({ error: "User not found" }); + return; + } + const roles = await getUserRoles(user.id); + res.json(buildAuthUser(user, roles, await getUserGroupsForAuth(user.id))); + return; + } + + const [user] = await db + .update(usersTable) + .set(updates) + .where(eq(usersTable.id, req.session.userId!)) + .returning(); + + if (!user) { + res.status(401).json({ error: "User not found" }); + return; + } + + const roles = await getUserRoles(user.id); + res.json(buildAuthUser(user, roles, await getUserGroupsForAuth(user.id))); +}); + export default router; diff --git a/artifacts/tx-os/src/App.tsx b/artifacts/tx-os/src/App.tsx index b6f6afcc..6698e08b 100644 --- a/artifacts/tx-os/src/App.tsx +++ b/artifacts/tx-os/src/App.tsx @@ -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; } diff --git a/artifacts/tx-os/src/components/notification-settings.tsx b/artifacts/tx-os/src/components/notification-settings.tsx new file mode 100644 index 00000000..9472cf98 --- /dev/null +++ b/artifacts/tx-os/src/components/notification-settings.tsx @@ -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(getGetMeQueryKey()); + const next = applyOptimistic(previous, patch); + if (next) queryClient.setQueryData(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 ( +
+ {ALL_SOUND_IDS.map((id) => { + const isActive = id === current; + return ( +
+ + +
+ ); + })} +
+ ); +} + +function Toggle({ + active, + onClick, + label, +}: { + active: boolean; + onClick: () => void; + label: string; +}) { + return ( + + ); +} + +export function NotificationSettingsPicker() { + const { t } = useTranslation(); + const { user } = useAuth(); + const [open, setOpen] = useState(false); + const [slot, setSlot] = useState("order"); + const apply = useUpdatePrefs(); + + if (!user) return null; + const muted = user.notificationsMuted; + const volume = user.notificationVolume; + + return ( + + + + + +
+ {t("notifSettings.title")} +
+ + apply({ notificationsMuted: !muted })} + label={t("notifSettings.globalSound")} + /> + apply({ vibrationEnabled: !user.vibrationEnabled })} + label={t("notifSettings.vibration")} + /> + +
+ +
+ + + apply({ notificationVolume: Number(e.target.value) }) + } + className="flex-1 accent-primary" + aria-label={t("notifSettings.volume")} + /> + + {volume} + +
+ +
+ + + apply({ notifyOrdersEnabled: !user.notifyOrdersEnabled }) + } + label={t("notifSettings.ordersEnabled")} + /> + + apply({ notifyMeetingsEnabled: !user.notifyMeetingsEnabled }) + } + label={t("notifSettings.meetingsEnabled")} + /> + +
+ +
+ {(["order", "meeting"] as const).map((s) => { + const isActive = s === slot; + return ( + + ); + })} +
+ + + apply( + slot === "order" + ? { notificationSoundOrder: id } + : { notificationSoundMeeting: id }, + ) + } + volume={volume} + /> + + + ); +} diff --git a/artifacts/tx-os/src/hooks/use-audio-unlock.ts b/artifacts/tx-os/src/hooks/use-audio-unlock.ts new file mode 100644 index 00000000..dbdafe10 --- /dev/null +++ b/artifacts/tx-os/src/hooks/use-audio-unlock.ts @@ -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); + }; + }, []); +} diff --git a/artifacts/tx-os/src/hooks/use-notifications-socket.ts b/artifacts/tx-os/src/hooks/use-notifications-socket.ts index 01bb590f..d51a7898 100644 --- a/artifacts/tx-os/src/hooks/use-notifications-socket.ts +++ b/artifacts/tx-os/src/hooks/use-notifications-socket.ts @@ -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", () => { diff --git a/artifacts/tx-os/src/lib/notification-sounds.ts b/artifacts/tx-os/src/lib/notification-sounds.ts new file mode 100644 index 00000000..d88cd670 --- /dev/null +++ b/artifacts/tx-os/src/lib/notification-sounds.ts @@ -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 = [ + { + 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 = 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(); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 5ed9626a..e3f33366 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -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": "تحديد الكل كمقروء", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 398d2d28..bae3546b 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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", diff --git a/artifacts/tx-os/src/pages/home.tsx b/artifacts/tx-os/src/pages/home.tsx index 8afe6eab..32df00af 100644 --- a/artifacts/tx-os/src/pages/home.tsx +++ b/artifacts/tx-os/src/pages/home.tsx @@ -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} /> +