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

Task #409: incoming notes now mirror UpcomingMeetingAlert's attention
model — sound + vibration + visual pulse + persistent queue indicator —
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 (use-notifications-socket): play sound + vibrate on
  note_received, gated by mute/notifyNotesEnabled/socket warmup, with
  playedNoteIdsRef dedupe (mirrors upcoming-meeting-alert playedRef).
- IncomingNotePopupContext: enqueue() returns boolean acceptance so the
  socket hook suppresses sound on own-note/duplicate.
- Popup visual (incoming-note-popup): z-[110], ring-4 amber, shadow-2xl,
  animate-in zoom, avatar animate-ping pulse.
- Persistent indicator on Notes app tile (home.tsx): badge =
  max(unread, queueLength), animate-ping ring while queueLength > 0,
  via useIncomingNotePopup().queueLength + new pulse prop on AppIconContent.
- Settings UI: refactored to SLOT_KEYS map, added 3rd "Notes" slot
  (grid-cols-3) with enabled/vibration toggles + sound picker.
- Locales en/ar: added notifSettings.slot.note, notesEnabled, vibrationNote.
- Test instrumentation: NotificationPlayer exposes window.__txosNotifPlayCount
  and __txosNotifLastSound for E2E observability.
- Playwright spec (notes-popup-on-receive): asserts chime fires once on
  recipient with sound="knock", sender plays nothing, pulse visible while
  queued, pulse gone after dismiss.

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:28:20 +00:00
parent 8ca324a557
commit dcf90e1c2d
@@ -4,6 +4,7 @@ import {
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
@@ -11,6 +12,7 @@ import { useAuth } from "@/contexts/AuthContext";
import {
applyDismiss,
applyEnqueue,
shouldEnqueueNote,
type IncomingNotePayload,
} from "@/lib/incoming-note-queue";
@@ -49,18 +51,26 @@ export function IncomingNotePopupProvider({ children }: { children: ReactNode })
setQueue([]);
}, [userId]);
// Track the latest queue in a ref so `enqueue` can decide acceptance
// synchronously without depending on React's state-updater timing.
const queueRef = useRef<IncomingNotePayload[]>(queue);
useEffect(() => {
queueRef.current = queue;
}, [queue]);
const enqueue = useCallback(
(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;
});
// Use the pure predicate to decide acceptance against the current
// queue snapshot, then commit via setQueue. Both sides share the
// same suppression rules in `incoming-note-queue.ts`.
const accepted = shouldEnqueueNote(
queueRef.current,
payload,
currentUserId,
);
if (accepted) {
setQueue((prev) => applyEnqueue(prev, payload, currentUserId));
}
return accepted;
},
[],