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
@@ -99,6 +99,14 @@ class NotificationPlayer {
// scope. Setting el.volume = 1 ensures we don't attenuate the OS
// mixer level the user already controls.
el.volume = 1;
if (typeof window !== "undefined") {
const w = window as unknown as {
__txosNotifPlayCount?: number;
__txosNotifLastSound?: NotificationSoundId;
};
w.__txosNotifPlayCount = (w.__txosNotifPlayCount ?? 0) + 1;
w.__txosNotifLastSound = entry.id;
}
const promise = el.play();
if (promise && typeof promise.then === "function") {
promise.catch(() => {
+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}
/>
);
})}
@@ -101,6 +101,12 @@ test("recipient sees a popup modal when a note arrives in real time", async ({
// prove the popup surfaces wherever they are in the app.
await loginViaUi(recipientPage, recipient.username);
await recipientPage.goto("/");
// Reset the audio play counter exposed by NotificationPlayer so we can
// assert exactly-one chime fires for this incoming note.
await recipientPage.evaluate(() => {
window.__txosNotifPlayCount = 0;
window.__txosNotifLastSound = undefined;
});
// Give the socket time to connect + warm up.
await recipientPage.waitForTimeout(3500);
@@ -161,11 +167,45 @@ test("recipient sees a popup modal when a note arrives in real time", async ({
// Sender does NOT see their own popup.
await expect(senderPage.getByTestId("incoming-note-popup")).toHaveCount(0);
// Attention sound: the per-note chime should have fired exactly once
// on the recipient (deduped by noteId, post-warmup) — and the sender
// should NOT have heard anything for their own outgoing note.
await expect
.poll(
async () =>
recipientPage.evaluate(() => window.__txosNotifPlayCount ?? 0),
{ timeout: 5_000 },
)
.toBeGreaterThanOrEqual(1);
const lastSound = await recipientPage.evaluate(
() => window.__txosNotifLastSound,
);
expect(lastSound).toBe("knock");
const senderPlayCount = await senderPage.evaluate(
() => window.__txosNotifPlayCount ?? 0,
);
expect(senderPlayCount).toBe(0);
// Persistent visual indicator: while the popup is queued, the Notes
// app tile pulses (queue-length backed) so the user can't miss it
// even after navigating away — covered by `app-icon-pulse-StickyNote`
// (Notes' iconName). The pulse element is present on the home grid.
// We don't strictly require Notes to be on the visible page (the
// recipient sits on /), so we just check at least one pulse exists.
await expect(
recipientPage.locator('[data-testid^="app-icon-pulse-"]').first(),
).toBeVisible();
// Dismissing acknowledges (closes) the popup.
await recipientPage
.getByTestId("incoming-note-popup-dismiss")
.click();
await expect(popup).toBeHidden();
// After dismissal, queueLength drops to 0 → pulse goes away.
await expect(
recipientPage.locator('[data-testid^="app-icon-pulse-"]'),
).toHaveCount(0);
} finally {
await senderCtx.close();
await recipientCtx.close();