feat(notes): make incoming-note popup attention-grabbing

Task #409: incoming notes now mirror UpcomingMeetingAlert's attention model
so recipients can't miss them.

Changes:
- DB: add `notification_sound_note` (default "knock"), `notify_notes_enabled`,
  `vibration_enabled_note` to users schema; pushed via drizzle.
- API spec: add 3 new fields to AuthUser + UpdateNotificationPreferencesBody;
  regenerated codegen.
- Auth route: include new fields in buildAuthUser + PATCH allowlist.
- Socket hook: play notification sound + vibrate on `note_received`,
  gated by mute/notifyNotesEnabled/socket warmup, with playedNoteIdsRef
  dedupe (mirrors upcoming-meeting-alert playedRef).
- IncomingNotePopupContext: enqueue() now returns boolean acceptance so
  the socket hook can suppress sound on own-note/duplicate.
- Popup visual: z-[110], ring-4 amber, shadow-2xl, animate-in zoom,
  avatar animate-ping pulse.
- Settings UI: refactored to SLOT_KEYS map, added 3rd "Notes" slot tab
  (grid-cols-3) with enabled/vibration toggles + sound picker.
- Locales en/ar: added notifSettings.slot.note, notesEnabled, vibrationNote.

Pre-existing typecheck errors in executive-meetings.ts (font settings
scope) are unrelated to this task.
This commit is contained in:
riyadhafraa
2026-05-05 16:21:14 +00:00
parent 33038106b4
commit aaadd9b520
12 changed files with 260 additions and 60 deletions
+6
View File
@@ -64,10 +64,13 @@ function buildAuthUser(
isActive: user.isActive,
notificationSoundOrder: user.notificationSoundOrder,
notificationSoundMeeting: user.notificationSoundMeeting,
notificationSoundNote: user.notificationSoundNote,
notifyOrdersEnabled: user.notifyOrdersEnabled,
notifyMeetingsEnabled: user.notifyMeetingsEnabled,
notifyNotesEnabled: user.notifyNotesEnabled,
vibrationEnabledOrder: user.vibrationEnabledOrder,
vibrationEnabledMeeting: user.vibrationEnabledMeeting,
vibrationEnabledNote: user.vibrationEnabledNote,
notificationsMuted: user.notificationsMuted,
roles,
groups,
@@ -435,10 +438,13 @@ router.patch("/auth/me/notification-preferences", requireAuth, async (req, res):
for (const k of [
"notificationSoundOrder",
"notificationSoundMeeting",
"notificationSoundNote",
"notifyOrdersEnabled",
"notifyMeetingsEnabled",
"notifyNotesEnabled",
"vibrationEnabledOrder",
"vibrationEnabledMeeting",
"vibrationEnabledNote",
"notificationsMuted",
] as const) {
if (parsed.data[k] !== undefined) updates[k] = parsed.data[k];
@@ -69,21 +69,27 @@ export function IncomingNotePopup() {
<AlertDialogContent
dir={isRtl ? "rtl" : "ltr"}
data-testid="incoming-note-popup"
className="max-w-md"
className="max-w-md z-[110] ring-4 ring-amber-400/70 shadow-2xl shadow-amber-500/30 animate-in zoom-in-95 fade-in-0 duration-200"
>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-3">
{current.sender?.avatarUrl ? (
<img
src={current.sender.avatarUrl}
alt=""
className="h-9 w-9 rounded-full object-cover shrink-0"
<span className="relative shrink-0">
<span
className="absolute inset-0 rounded-full bg-amber-400/60 animate-ping"
aria-hidden="true"
/>
) : (
<div className="h-9 w-9 rounded-full bg-amber-200 text-amber-900 font-semibold flex items-center justify-center shrink-0">
{avatarInitial(current.sender, i18n.language)}
</div>
)}
{current.sender?.avatarUrl ? (
<img
src={current.sender.avatarUrl}
alt=""
className="relative h-9 w-9 rounded-full object-cover ring-2 ring-amber-400"
/>
) : (
<div className="relative h-9 w-9 rounded-full bg-amber-200 text-amber-900 font-semibold flex items-center justify-center ring-2 ring-amber-400">
{avatarInitial(current.sender, i18n.language)}
</div>
)}
</span>
<span className="text-base">{heading}</span>
</AlertDialogTitle>
<AlertDialogDescription className="sr-only">
@@ -20,10 +20,13 @@ import { useAuth } from "@/contexts/AuthContext";
type PrefsPatch = {
notificationSoundOrder?: NotificationSoundId;
notificationSoundMeeting?: NotificationSoundId;
notificationSoundNote?: NotificationSoundId;
notifyOrdersEnabled?: boolean;
notifyMeetingsEnabled?: boolean;
notifyNotesEnabled?: boolean;
vibrationEnabledOrder?: boolean;
vibrationEnabledMeeting?: boolean;
vibrationEnabledNote?: boolean;
notificationsMuted?: boolean;
};
@@ -69,7 +72,51 @@ export function useUpdatePrefs() {
};
}
type Slot = "order" | "meeting";
type Slot = "order" | "meeting" | "note";
const SLOT_KEYS: Record<
Slot,
{
enabledKey: keyof Pick<
AuthUser,
"notifyOrdersEnabled" | "notifyMeetingsEnabled" | "notifyNotesEnabled"
>;
soundKey: keyof Pick<
AuthUser,
| "notificationSoundOrder"
| "notificationSoundMeeting"
| "notificationSoundNote"
>;
vibrateKey: keyof Pick<
AuthUser,
"vibrationEnabledOrder" | "vibrationEnabledMeeting" | "vibrationEnabledNote"
>;
enabledLabel: string;
vibrateLabel: string;
}
> = {
order: {
enabledKey: "notifyOrdersEnabled",
soundKey: "notificationSoundOrder",
vibrateKey: "vibrationEnabledOrder",
enabledLabel: "notifSettings.ordersEnabled",
vibrateLabel: "notifSettings.vibrationOrder",
},
meeting: {
enabledKey: "notifyMeetingsEnabled",
soundKey: "notificationSoundMeeting",
vibrateKey: "vibrationEnabledMeeting",
enabledLabel: "notifSettings.meetingsEnabled",
vibrateLabel: "notifSettings.vibrationMeeting",
},
note: {
enabledKey: "notifyNotesEnabled",
soundKey: "notificationSoundNote",
vibrateKey: "vibrationEnabledNote",
enabledLabel: "notifSettings.notesEnabled",
vibrateLabel: "notifSettings.vibrationNote",
},
};
function SoundList({
current,
@@ -201,6 +248,7 @@ export function NotificationSettingsPicker() {
if (!user) return null;
const muted = user.notificationsMuted;
const cfg = SLOT_KEYS[slot];
return (
<Popover open={open} onOpenChange={setOpen}>
@@ -232,10 +280,10 @@ export function NotificationSettingsPicker() {
<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"
className="grid grid-cols-3 gap-1 mx-1 mb-1 p-1 rounded-lg bg-foreground/5"
role="group"
>
{(["order", "meeting"] as const).map((s) => {
{(["order", "meeting", "note"] as const).map((s) => {
const isActive = s === slot;
return (
<button
@@ -256,58 +304,26 @@ export function NotificationSettingsPicker() {
</div>
<Toggle
active={
slot === "order"
? user.notifyOrdersEnabled
: user.notifyMeetingsEnabled
}
active={user[cfg.enabledKey]}
onClick={() =>
apply(
slot === "order"
? { notifyOrdersEnabled: !user.notifyOrdersEnabled }
: { notifyMeetingsEnabled: !user.notifyMeetingsEnabled },
)
apply({ [cfg.enabledKey]: !user[cfg.enabledKey] } as PrefsPatch)
}
label={t(
slot === "order"
? "notifSettings.ordersEnabled"
: "notifSettings.meetingsEnabled",
)}
label={t(cfg.enabledLabel)}
/>
<Toggle
active={
slot === "order"
? user.vibrationEnabledOrder
: user.vibrationEnabledMeeting
}
active={user[cfg.vibrateKey]}
onClick={() =>
apply(
slot === "order"
? { vibrationEnabledOrder: !user.vibrationEnabledOrder }
: { vibrationEnabledMeeting: !user.vibrationEnabledMeeting },
)
apply({ [cfg.vibrateKey]: !user[cfg.vibrateKey] } as PrefsPatch)
}
label={t(
slot === "order"
? "notifSettings.vibrationOrder"
: "notifSettings.vibrationMeeting",
)}
label={t(cfg.vibrateLabel)}
/>
<div className="h-px bg-foreground/10 mx-1 my-1" />
<SoundList
current={
slot === "order"
? user.notificationSoundOrder
: user.notificationSoundMeeting
}
current={user[cfg.soundKey]}
onPick={(id) =>
apply(
slot === "order"
? { notificationSoundOrder: id }
: { notificationSoundMeeting: id },
)
apply({ [cfg.soundKey]: id } as PrefsPatch)
}
/>
</PopoverContent>
@@ -19,7 +19,17 @@ export type { IncomingNotePayload } from "@/lib/incoming-note-queue";
interface IncomingNotePopupContextValue {
current: IncomingNotePayload | null;
queueLength: number;
enqueue: (payload: IncomingNotePayload, currentUserId: number | null) => void;
/**
* Adds the payload to the FIFO popup queue. Returns true if the
* payload was actually queued, false if it was suppressed (sender's
* own note, duplicate noteId, or malformed). Callers use the boolean
* to gate side-effects like the chime so we don't beep for our own
* outgoing notes or for replayed socket events.
*/
enqueue: (
payload: IncomingNotePayload,
currentUserId: number | null,
) => boolean;
dismiss: (noteId: number) => void;
clear: () => void;
}
@@ -40,8 +50,18 @@ export function IncomingNotePopupProvider({ children }: { children: ReactNode })
}, [userId]);
const enqueue = useCallback(
(payload: IncomingNotePayload, currentUserId: number | null) => {
setQueue((prev) => applyEnqueue(prev, payload, currentUserId));
(payload: IncomingNotePayload, currentUserId: number | null): boolean => {
// The state updater runs synchronously inside setQueue, so the
// captured `accepted` is populated before this function returns —
// safe to read on the next line. Mirrors applyEnqueue's exact
// suppression rules without duplicating them.
let accepted = false;
setQueue((prev) => {
const next = applyEnqueue(prev, payload, currentUserId);
accepted = next !== prev;
return next;
});
return accepted;
},
[],
);
@@ -38,6 +38,11 @@ export function useNotificationsSocket() {
enqueueNotePopupRef.current = enqueueNotePopup;
const currentUserIdRef = useRef<number | null>(user?.id ?? null);
currentUserIdRef.current = user?.id ?? null;
// Dedupe note chimes by noteId so a socket reconnect that replays the
// same `note_received` event (or a duplicate emit from the server)
// doesn't beep twice for the same incoming note. Mirrors the
// `playedRef` pattern used by upcoming-meeting-alert.
const playedNoteIdsRef = useRef<Set<number>>(new Set());
useEffect(() => {
if (!user) return;
@@ -120,6 +125,7 @@ export function useNotificationsSocket() {
socket.on("note_received", (payload?: Partial<IncomingNotePayload>) => {
queryClient.invalidateQueries({ queryKey: ["notes"] });
const inWarmup = nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS;
let enqueued = false;
if (
payload &&
typeof payload.noteId === "number" &&
@@ -135,9 +141,29 @@ export function useNotificationsSocket() {
senderUserId: payload.senderUserId,
sender: payload.sender ?? null,
};
enqueueNotePopupRef.current(full, currentUserIdRef.current);
enqueued = enqueueNotePopupRef.current(
full,
currentUserIdRef.current,
);
}
if (inWarmup) return;
// Sound + vibration for incoming notes — gated by global mute and
// the per-channel notes toggle. Deduped by noteId so a socket
// reconnect that replays the same event doesn't re-chime. Only
// plays when the popup actually enqueued (i.e. recipient !== sender).
if (
enqueued &&
payload &&
typeof payload.noteId === "number" &&
!user.notificationsMuted &&
user.notifyNotesEnabled &&
!playedNoteIdsRef.current.has(payload.noteId)
) {
playedNoteIdsRef.current.add(payload.noteId);
notificationPlayer.play(user.notificationSoundNote, {
vibrate: user.vibrationEnabledNote,
});
}
toast({
title: i18n.t("notes.toast.received.title"),
description: i18n.t("notes.toast.received.desc"),
@@ -28,6 +28,25 @@ export function applyEnqueue(
return [...queue, payload];
}
/**
* Predicate: would this payload actually land in the queue, or is it
* silently dropped (sender's own note, duplicate, malformed)? Used by
* the socket layer to decide whether to play the chime. Mirrors the
* suppression rules in {@link applyEnqueue} so the two stay in sync.
*/
export function shouldEnqueueNote(
queue: IncomingNotePayload[],
payload: IncomingNotePayload,
currentUserId: number | null,
): boolean {
if (!payload || typeof payload.noteId !== "number") return false;
if (currentUserId != null && payload.senderUserId === currentUserId) {
return false;
}
if (queue.some((p) => p.noteId === payload.noteId)) return false;
return true;
}
/**
* Pop the head of the queue if `noteId` matches it; otherwise filter it
* out from anywhere it appears. Pure for testability.
+4 -1
View File
@@ -311,8 +311,10 @@
"volume": "مستوى الصوت",
"ordersEnabled": "صوت للطلبات الجديدة",
"meetingsEnabled": "صوت لتذكيرات الاجتماعات",
"notesEnabled": "صوت للملاحظات الواردة",
"vibrationOrder": "اهتزاز عند الطلبات الجديدة",
"vibrationMeeting": "اهتزاز عند تذكيرات الاجتماعات",
"vibrationNote": "اهتزاز عند الملاحظات الواردة",
"preview": "تجربة",
"quickMute": "كتم الإشعارات",
"quickUnmute": "إلغاء كتم الإشعارات",
@@ -322,7 +324,8 @@
"autoplayHint": "انقر في أي مكان لتفعيل أصوات الإشعارات.",
"slot": {
"order": "الطلبات",
"meeting": "الاجتماعات"
"meeting": "الاجتماعات",
"note": "الملاحظات"
},
"sounds": {
"ding": "رنين",
+4 -1
View File
@@ -314,8 +314,10 @@
"volume": "Volume",
"ordersEnabled": "Sound for new orders",
"meetingsEnabled": "Sound for meeting reminders",
"notesEnabled": "Sound for incoming notes",
"vibrationOrder": "Vibrate on new orders",
"vibrationMeeting": "Vibrate on meeting reminders",
"vibrationNote": "Vibrate on incoming notes",
"preview": "Preview",
"quickMute": "Mute notifications",
"quickUnmute": "Unmute notifications",
@@ -325,7 +327,8 @@
"autoplayHint": "Click anywhere on the page to enable notification sounds.",
"slot": {
"order": "Orders",
"meeting": "Meetings"
"meeting": "Meetings",
"note": "Notes"
},
"sounds": {
"ding": "Ding",
@@ -113,10 +113,13 @@ export const NotificationSoundId = {
export interface UpdateNotificationPreferencesBody {
notificationSoundOrder?: NotificationSoundId;
notificationSoundMeeting?: NotificationSoundId;
notificationSoundNote?: NotificationSoundId;
notifyOrdersEnabled?: boolean;
notifyMeetingsEnabled?: boolean;
notifyNotesEnabled?: boolean;
vibrationEnabledOrder?: boolean;
vibrationEnabledMeeting?: boolean;
vibrationEnabledNote?: boolean;
notificationsMuted?: boolean;
}
@@ -154,10 +157,13 @@ export interface AuthUser {
isActive: boolean;
notificationSoundOrder: NotificationSoundId;
notificationSoundMeeting: NotificationSoundId;
notificationSoundNote: NotificationSoundId;
notifyOrdersEnabled: boolean;
notifyMeetingsEnabled: boolean;
notifyNotesEnabled: boolean;
vibrationEnabledOrder: boolean;
vibrationEnabledMeeting: boolean;
vibrationEnabledNote: boolean;
notificationsMuted: boolean;
roles: string[];
groups: GroupSummary[];
+15
View File
@@ -3209,14 +3209,20 @@ components:
$ref: "#/components/schemas/NotificationSoundId"
notificationSoundMeeting:
$ref: "#/components/schemas/NotificationSoundId"
notificationSoundNote:
$ref: "#/components/schemas/NotificationSoundId"
notifyOrdersEnabled:
type: boolean
notifyMeetingsEnabled:
type: boolean
notifyNotesEnabled:
type: boolean
vibrationEnabledOrder:
type: boolean
vibrationEnabledMeeting:
type: boolean
vibrationEnabledNote:
type: boolean
notificationsMuted:
type: boolean
@@ -3258,14 +3264,20 @@ components:
$ref: "#/components/schemas/NotificationSoundId"
notificationSoundMeeting:
$ref: "#/components/schemas/NotificationSoundId"
notificationSoundNote:
$ref: "#/components/schemas/NotificationSoundId"
notifyOrdersEnabled:
type: boolean
notifyMeetingsEnabled:
type: boolean
notifyNotesEnabled:
type: boolean
vibrationEnabledOrder:
type: boolean
vibrationEnabledMeeting:
type: boolean
vibrationEnabledNote:
type: boolean
notificationsMuted:
type: boolean
roles:
@@ -3287,10 +3299,13 @@ components:
- isActive
- notificationSoundOrder
- notificationSoundMeeting
- notificationSoundNote
- notifyOrdersEnabled
- notifyMeetingsEnabled
- notifyNotesEnabled
- vibrationEnabledOrder
- vibrationEnabledMeeting
- vibrationEnabledNote
- notificationsMuted
- roles
- groups
+77
View File
@@ -74,10 +74,22 @@ export const LoginResponse = zod.object({
"beep",
"soft",
]),
notificationSoundNote: zod.enum([
"ding",
"chime",
"bell",
"knock",
"pop",
"alert",
"beep",
"soft",
]),
notifyOrdersEnabled: zod.boolean(),
notifyMeetingsEnabled: zod.boolean(),
notifyNotesEnabled: zod.boolean(),
vibrationEnabledOrder: zod.boolean(),
vibrationEnabledMeeting: zod.boolean(),
vibrationEnabledNote: zod.boolean(),
notificationsMuted: zod.boolean(),
roles: zod.array(zod.string()),
groups: zod.array(
@@ -187,10 +199,22 @@ export const GetMeResponse = zod.object({
"beep",
"soft",
]),
notificationSoundNote: zod.enum([
"ding",
"chime",
"bell",
"knock",
"pop",
"alert",
"beep",
"soft",
]),
notifyOrdersEnabled: zod.boolean(),
notifyMeetingsEnabled: zod.boolean(),
notifyNotesEnabled: zod.boolean(),
vibrationEnabledOrder: zod.boolean(),
vibrationEnabledMeeting: zod.boolean(),
vibrationEnabledNote: zod.boolean(),
notificationsMuted: zod.boolean(),
roles: zod.array(zod.string()),
groups: zod.array(
@@ -249,10 +273,22 @@ export const UpdateLanguageResponse = zod.object({
"beep",
"soft",
]),
notificationSoundNote: zod.enum([
"ding",
"chime",
"bell",
"knock",
"pop",
"alert",
"beep",
"soft",
]),
notifyOrdersEnabled: zod.boolean(),
notifyMeetingsEnabled: zod.boolean(),
notifyNotesEnabled: zod.boolean(),
vibrationEnabledOrder: zod.boolean(),
vibrationEnabledMeeting: zod.boolean(),
vibrationEnabledNote: zod.boolean(),
notificationsMuted: zod.boolean(),
roles: zod.array(zod.string()),
groups: zod.array(
@@ -318,10 +354,22 @@ export const UpdateClockStyleResponse = zod.object({
"beep",
"soft",
]),
notificationSoundNote: zod.enum([
"ding",
"chime",
"bell",
"knock",
"pop",
"alert",
"beep",
"soft",
]),
notifyOrdersEnabled: zod.boolean(),
notifyMeetingsEnabled: zod.boolean(),
notifyNotesEnabled: zod.boolean(),
vibrationEnabledOrder: zod.boolean(),
vibrationEnabledMeeting: zod.boolean(),
vibrationEnabledNote: zod.boolean(),
notificationsMuted: zod.boolean(),
roles: zod.array(zod.string()),
groups: zod.array(
@@ -345,10 +393,15 @@ export const UpdateNotificationPreferencesBody = zod.object({
notificationSoundMeeting: zod
.enum(["ding", "chime", "bell", "knock", "pop", "alert", "beep", "soft"])
.optional(),
notificationSoundNote: zod
.enum(["ding", "chime", "bell", "knock", "pop", "alert", "beep", "soft"])
.optional(),
notifyOrdersEnabled: zod.boolean().optional(),
notifyMeetingsEnabled: zod.boolean().optional(),
notifyNotesEnabled: zod.boolean().optional(),
vibrationEnabledOrder: zod.boolean().optional(),
vibrationEnabledMeeting: zod.boolean().optional(),
vibrationEnabledNote: zod.boolean().optional(),
notificationsMuted: zod.boolean().optional(),
});
@@ -390,10 +443,22 @@ export const UpdateNotificationPreferencesResponse = zod.object({
"beep",
"soft",
]),
notificationSoundNote: zod.enum([
"ding",
"chime",
"bell",
"knock",
"pop",
"alert",
"beep",
"soft",
]),
notifyOrdersEnabled: zod.boolean(),
notifyMeetingsEnabled: zod.boolean(),
notifyNotesEnabled: zod.boolean(),
vibrationEnabledOrder: zod.boolean(),
vibrationEnabledMeeting: zod.boolean(),
vibrationEnabledNote: zod.boolean(),
notificationsMuted: zod.boolean(),
roles: zod.array(zod.string()),
groups: zod.array(
@@ -457,10 +522,22 @@ export const UpdateClockHour12Response = zod.object({
"beep",
"soft",
]),
notificationSoundNote: zod.enum([
"ding",
"chime",
"bell",
"knock",
"pop",
"alert",
"beep",
"soft",
]),
notifyOrdersEnabled: zod.boolean(),
notifyMeetingsEnabled: zod.boolean(),
notifyNotesEnabled: zod.boolean(),
vibrationEnabledOrder: zod.boolean(),
vibrationEnabledMeeting: zod.boolean(),
vibrationEnabledNote: zod.boolean(),
notificationsMuted: zod.boolean(),
roles: zod.array(zod.string()),
groups: zod.array(
+3
View File
@@ -16,10 +16,13 @@ export const usersTable = pgTable("users", {
isActive: boolean("is_active").notNull().default(true),
notificationSoundOrder: varchar("notification_sound_order", { length: 30 }).notNull().default("ding"),
notificationSoundMeeting: varchar("notification_sound_meeting", { length: 30 }).notNull().default("chime"),
notificationSoundNote: varchar("notification_sound_note", { length: 30 }).notNull().default("knock"),
notifyOrdersEnabled: boolean("notify_orders_enabled").notNull().default(true),
notifyMeetingsEnabled: boolean("notify_meetings_enabled").notNull().default(true),
notifyNotesEnabled: boolean("notify_notes_enabled").notNull().default(true),
vibrationEnabledOrder: boolean("vibration_enabled_order").notNull().default(true),
vibrationEnabledMeeting: boolean("vibration_enabled_meeting").notNull().default(true),
vibrationEnabledNote: boolean("vibration_enabled_note").notNull().default(true),
notificationsMuted: boolean("notifications_muted").notNull().default(false),
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date()),