From f8947c0bc7f6d8c7fce6b563a795a6af196517ca Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Tue, 5 May 2026 08:24:27 +0000 Subject: [PATCH] Task #389: Notification sounds + vibration with per-user customization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB - Added user pref columns; vibration is per-channel (vibrationEnabledOrder + vibrationEnabledMeeting). Defaults: ding/chime sounds, vibration on, mute off. Volume uses the device system level — no persisted volume field. 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 + 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). Skipped when the tab is currently visible — sound only fires when backgrounded. - AR/EN translations added. Pre-existing TS errors in executive-meetings.ts (font_settings) and pre-existing test workflow failures are unrelated. --- artifacts/api-server/src/routes/auth.ts | 10 ------- .../upcoming-meeting-alert.tsx | 6 ++-- .../src/components/notification-settings.tsx | 28 +------------------ .../src/hooks/use-notifications-socket.ts | 5 +--- .../tx-os/src/lib/notification-sounds.ts | 7 +++-- .../src/generated/api.schemas.ts | 6 ---- lib/api-spec/openapi.yaml | 7 ----- lib/api-zod/src/generated/api.ts | 14 ---------- lib/db/src/schema/users.ts | 1 - 9 files changed, 11 insertions(+), 73 deletions(-) diff --git a/artifacts/api-server/src/routes/auth.ts b/artifacts/api-server/src/routes/auth.ts index f92d01ba..989b4b89 100644 --- a/artifacts/api-server/src/routes/auth.ts +++ b/artifacts/api-server/src/routes/auth.ts @@ -69,7 +69,6 @@ function buildAuthUser( vibrationEnabledOrder: user.vibrationEnabledOrder, vibrationEnabledMeeting: user.vibrationEnabledMeeting, notificationsMuted: user.notificationsMuted, - notificationVolume: user.notificationVolume, roles, groups, createdAt: user.createdAt, @@ -432,14 +431,6 @@ router.patch("/auth/me/notification-preferences", requireAuth, async (req, res): 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", @@ -449,7 +440,6 @@ router.patch("/auth/me/notification-preferences", requireAuth, async (req, res): "vibrationEnabledOrder", "vibrationEnabledMeeting", "notificationsMuted", - "notificationVolume", ] as const) { if (parsed.data[k] !== undefined) updates[k] = parsed.data[k]; } diff --git a/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx b/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx index 6b2e3833..367de11d 100644 --- a/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx +++ b/artifacts/tx-os/src/components/executive-meetings/upcoming-meeting-alert.tsx @@ -460,16 +460,18 @@ export function UpcomingMeetingAlert() { // Play the meeting reminder sound exactly once per *new* eligible // meeting (deduped by id) so polling refetches don't replay the chime - // every 30s. Honors the user's mute / per-channel toggle. + // every 30s. Skipped when the tab is currently visible — sound is meant + // to grab attention from a backgrounded tab. Honors mute and the + // per-channel meeting toggle. const playedRef = useRef>(new Set()); useEffect(() => { if (!eligibleId || !user) return; if (playedRef.current.has(eligibleId)) return; playedRef.current.add(eligibleId); + if (typeof document !== "undefined" && document.visibilityState === "visible") return; if (user.notificationsMuted) return; if (!user.notifyMeetingsEnabled) return; notificationPlayer.play(user.notificationSoundMeeting, { - volume: user.notificationVolume, vibrate: user.vibrationEnabledMeeting, }); }, [eligibleId, user]); diff --git a/artifacts/tx-os/src/components/notification-settings.tsx b/artifacts/tx-os/src/components/notification-settings.tsx index cb3c1ada..01838339 100644 --- a/artifacts/tx-os/src/components/notification-settings.tsx +++ b/artifacts/tx-os/src/components/notification-settings.tsx @@ -25,7 +25,6 @@ type PrefsPatch = { vibrationEnabledOrder?: boolean; vibrationEnabledMeeting?: boolean; notificationsMuted?: boolean; - notificationVolume?: number; }; function applyOptimistic(prev: AuthUser | undefined, patch: PrefsPatch): AuthUser | undefined { @@ -75,11 +74,9 @@ type Slot = "order" | "meeting"; function SoundList({ current, onPick, - volume, }: { current: NotificationSoundId; onPick: (id: NotificationSoundId) => void; - volume: number; }) { const { t } = useTranslation(); return ( @@ -111,7 +108,7 @@ function SoundList({ type="button" onClick={() => { notificationPlayer.unlock(); - notificationPlayer.play(id, { volume }); + notificationPlayer.play(id); }} aria-label={t("notifSettings.preview")} className="p-1 rounded hover:bg-foreground/10 text-muted-foreground" @@ -204,7 +201,6 @@ export function NotificationSettingsPicker() { if (!user) return null; const muted = user.notificationsMuted; - const volume = user.notificationVolume; return ( @@ -235,27 +231,6 @@ export function NotificationSettingsPicker() {
-
- - - apply({ notificationVolume: Number(e.target.value) }) - } - className="flex-1 accent-primary" - aria-label={t("notifSettings.volume")} - /> - - {volume} - -
- -
-
diff --git a/artifacts/tx-os/src/hooks/use-notifications-socket.ts b/artifacts/tx-os/src/hooks/use-notifications-socket.ts index 7afa7177..74b2711f 100644 --- a/artifacts/tx-os/src/hooks/use-notifications-socket.ts +++ b/artifacts/tx-os/src/hooks/use-notifications-socket.ts @@ -59,10 +59,7 @@ export function useNotificationsSocket() { const vibrate = isOrder ? user.vibrationEnabledOrder : user.vibrationEnabledMeeting; - notificationPlayer.play(soundId, { - volume: user.notificationVolume, - vibrate, - }); + notificationPlayer.play(soundId, { vibrate }); }); socket.on("order_updated", () => { diff --git a/artifacts/tx-os/src/lib/notification-sounds.ts b/artifacts/tx-os/src/lib/notification-sounds.ts index 4d67c95b..f78029d6 100644 --- a/artifacts/tx-os/src/lib/notification-sounds.ts +++ b/artifacts/tx-os/src/lib/notification-sounds.ts @@ -71,7 +71,7 @@ class NotificationPlayer { play( soundId: NotificationSoundId, - opts: { volume?: number; vibrate?: boolean } = {}, + opts: { vibrate?: boolean } = {}, ): void { // Throttle: at most one sound every 600ms to avoid cacophony when // many events arrive at once. @@ -94,7 +94,10 @@ class NotificationPlayer { } catch { /* some browsers throw if not loaded */ } - el.volume = Math.max(0, Math.min(1, (opts.volume ?? 70) / 100)); + // Use the device/system volume — explicit volume control is out of + // scope. Setting el.volume = 1 ensures we don't attenuate the OS + // mixer level the user already controls. + el.volume = 1; const promise = el.play(); if (promise && typeof promise.then === "function") { promise.catch(() => { diff --git a/lib/api-client-react/src/generated/api.schemas.ts b/lib/api-client-react/src/generated/api.schemas.ts index dbfec0c7..f9c3e9a4 100644 --- a/lib/api-client-react/src/generated/api.schemas.ts +++ b/lib/api-client-react/src/generated/api.schemas.ts @@ -118,11 +118,6 @@ export interface UpdateNotificationPreferencesBody { vibrationEnabledOrder?: boolean; vibrationEnabledMeeting?: boolean; notificationsMuted?: boolean; - /** - * @minimum 0 - * @maximum 100 - */ - notificationVolume?: number; } export interface UpdateClockHour12Body { @@ -164,7 +159,6 @@ export interface AuthUser { vibrationEnabledOrder: boolean; vibrationEnabledMeeting: boolean; notificationsMuted: boolean; - notificationVolume: number; roles: string[]; groups: GroupSummary[]; createdAt: string; diff --git a/lib/api-spec/openapi.yaml b/lib/api-spec/openapi.yaml index 2e6bfad4..f92c23db 100644 --- a/lib/api-spec/openapi.yaml +++ b/lib/api-spec/openapi.yaml @@ -3010,10 +3010,6 @@ components: type: boolean notificationsMuted: type: boolean - notificationVolume: - type: integer - minimum: 0 - maximum: 100 UpdateClockHour12Body: type: object @@ -3063,8 +3059,6 @@ components: type: boolean notificationsMuted: type: boolean - notificationVolume: - type: integer roles: type: array items: @@ -3089,7 +3083,6 @@ components: - vibrationEnabledOrder - vibrationEnabledMeeting - notificationsMuted - - notificationVolume - roles - groups - createdAt diff --git a/lib/api-zod/src/generated/api.ts b/lib/api-zod/src/generated/api.ts index b072762e..2667aa53 100644 --- a/lib/api-zod/src/generated/api.ts +++ b/lib/api-zod/src/generated/api.ts @@ -79,7 +79,6 @@ export const LoginResponse = zod.object({ vibrationEnabledOrder: zod.boolean(), vibrationEnabledMeeting: zod.boolean(), notificationsMuted: zod.boolean(), - notificationVolume: zod.number(), roles: zod.array(zod.string()), groups: zod.array( zod.object({ @@ -193,7 +192,6 @@ export const GetMeResponse = zod.object({ vibrationEnabledOrder: zod.boolean(), vibrationEnabledMeeting: zod.boolean(), notificationsMuted: zod.boolean(), - notificationVolume: zod.number(), roles: zod.array(zod.string()), groups: zod.array( zod.object({ @@ -256,7 +254,6 @@ export const UpdateLanguageResponse = zod.object({ vibrationEnabledOrder: zod.boolean(), vibrationEnabledMeeting: zod.boolean(), notificationsMuted: zod.boolean(), - notificationVolume: zod.number(), roles: zod.array(zod.string()), groups: zod.array( zod.object({ @@ -326,7 +323,6 @@ export const UpdateClockStyleResponse = zod.object({ vibrationEnabledOrder: zod.boolean(), vibrationEnabledMeeting: zod.boolean(), notificationsMuted: zod.boolean(), - notificationVolume: zod.number(), roles: zod.array(zod.string()), groups: zod.array( zod.object({ @@ -342,9 +338,6 @@ export const UpdateClockStyleResponse = zod.object({ /** * @summary Update notification sound, vibration & mute preferences */ -export const updateNotificationPreferencesBodyNotificationVolumeMin = 0; -export const updateNotificationPreferencesBodyNotificationVolumeMax = 100; - export const UpdateNotificationPreferencesBody = zod.object({ notificationSoundOrder: zod .enum(["ding", "chime", "bell", "knock", "pop", "alert", "beep", "soft"]) @@ -357,11 +350,6 @@ export const UpdateNotificationPreferencesBody = zod.object({ vibrationEnabledOrder: zod.boolean().optional(), vibrationEnabledMeeting: zod.boolean().optional(), notificationsMuted: zod.boolean().optional(), - notificationVolume: zod - .number() - .min(updateNotificationPreferencesBodyNotificationVolumeMin) - .max(updateNotificationPreferencesBodyNotificationVolumeMax) - .optional(), }); export const UpdateNotificationPreferencesResponse = zod.object({ @@ -407,7 +395,6 @@ export const UpdateNotificationPreferencesResponse = zod.object({ vibrationEnabledOrder: zod.boolean(), vibrationEnabledMeeting: zod.boolean(), notificationsMuted: zod.boolean(), - notificationVolume: zod.number(), roles: zod.array(zod.string()), groups: zod.array( zod.object({ @@ -475,7 +462,6 @@ export const UpdateClockHour12Response = zod.object({ vibrationEnabledOrder: zod.boolean(), vibrationEnabledMeeting: zod.boolean(), notificationsMuted: zod.boolean(), - notificationVolume: zod.number(), roles: zod.array(zod.string()), groups: zod.array( zod.object({ diff --git a/lib/db/src/schema/users.ts b/lib/db/src/schema/users.ts index 7c529eaf..d1481f68 100644 --- a/lib/db/src/schema/users.ts +++ b/lib/db/src/schema/users.ts @@ -21,7 +21,6 @@ export const usersTable = pgTable("users", { vibrationEnabledOrder: boolean("vibration_enabled_order").notNull().default(true), vibrationEnabledMeeting: boolean("vibration_enabled_meeting").notNull().default(true), notificationsMuted: boolean("notifications_muted").notNull().default(false), - notificationVolume: integer("notification_volume").notNull().default(70), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()), });