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:
Riyadh
2026-05-05 16:26:13 +00:00
parent 59394a69af
commit 881414bf9d
3 changed files with 71 additions and 2 deletions
+23 -2
View File
@@ -20,6 +20,7 @@ import {
import { useQueryClient } from "@tanstack/react-query";
import { useAuth } from "@/contexts/AuthContext";
import { useReceivedNotes } from "@/lib/notes-api";
import { useIncomingNotePopup } from "@/contexts/IncomingNotePopupContext";
import {
Bell,
Inbox,
@@ -98,9 +99,11 @@ function gradientForColor(color: string): string {
function AppIconContent({
app,
badgeCount = 0,
pulse = false,
}: {
app: { nameAr: string; nameEn: string; iconName: string; color: string };
badgeCount?: number;
pulse?: boolean;
}) {
const { i18n: i18nInstance } = useTranslation();
const lang = i18nInstance.language;
@@ -111,6 +114,13 @@ function AppIconContent({
return (
<>
<div className={`relative icon-tile w-[68px] h-[68px] sm:w-[72px] sm:h-[72px] ${gradientForColor(app.color)} group-hover:scale-105 group-active:scale-95`}>
{pulse && (
<span
data-testid={`app-icon-pulse-${app.iconName}`}
className="absolute inset-0 rounded-[inherit] ring-4 ring-amber-400/70 animate-ping pointer-events-none"
aria-hidden="true"
/>
)}
<Icon size={30} color="#FFFFFF" />
{badgeCount > 0 && (
<span className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground text-[10px] rounded-full min-w-[18px] h-[18px] px-1 flex items-center justify-center font-bold tabular-nums shadow">
@@ -129,10 +139,12 @@ function SortableAppIcon({
app,
onClick,
badgeCount = 0,
pulse = false,
}: {
app: App;
onClick: () => void;
badgeCount?: number;
pulse?: boolean;
}) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: `app-${app.id}` });
const style: React.CSSProperties = {
@@ -152,7 +164,7 @@ function SortableAppIcon({
{...attributes}
{...listeners}
>
<AppIconContent app={app} badgeCount={badgeCount} />
<AppIconContent app={app} badgeCount={badgeCount} pulse={pulse} />
</button>
);
}
@@ -436,6 +448,14 @@ export default function HomePage() {
const unreadNotesCount =
receivedNotes?.filter((n) => n.status === "unread").length ?? 0;
// Persistent visual cue while an incoming-note popup is queued: the Notes
// tile pulses + shows the queue size as its badge so the user knows there's
// an attention-grabbing note waiting even after they dismiss the modal or
// navigate elsewhere on the home screen.
const { queueLength: notePopupQueueLength } = useIncomingNotePopup();
const notesBadgeCount = Math.max(unreadNotesCount, notePopupQueueLength);
const notesPulse = notePopupQueueLength > 0;
const logout = useLogout();
const updateLanguage = useUpdateLanguage();
@@ -638,7 +658,8 @@ export default function HomePage() {
key={app.id}
app={app}
onClick={() => openApp(app)}
badgeCount={app.slug === "notes" ? unreadNotesCount : 0}
badgeCount={app.slug === "notes" ? notesBadgeCount : 0}
pulse={app.slug === "notes" && notesPulse}
/>
);
})}