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:
@@ -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;
|
||||
},
|
||||
[],
|
||||
|
||||
Reference in New Issue
Block a user