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:
@@ -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"),
|
||||
|
||||
Reference in New Issue
Block a user