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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user