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:
@@ -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(() => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user