Task #406: Pop the note onto the recipient's screen on arrival

- Backend: extend `note_received` socket payload with note snapshot
  (title, content, color, sentAt), recipientRowId, senderUserId, and
  sender summary so the recipient can render the popup without an extra
  fetch.
- Add IncomingNotePopupContext: FIFO queue, dedupe by note id, suppress
  events for the user's own sends, auto-clear on logout.
- Add IncomingNotePopup AlertDialog that shows the note (title/content/
  color) with a sender chip and Reply / Mark read / Open in Notes /
  Dismiss buttons. Acknowledge actions call /notes/:id/read.
- Wire the popup globally in App.tsx (alongside the socket bridge).
- Notes page accepts a `?thread=ID` deep link so the popup's Open and
  Reply buttons land on the inbox tab + thread dialog.
- Added bilingual `notes.popup.*` strings in EN + AR (RTL respected).
- New Playwright e2e (`notes-popup-on-receive.spec.mjs`) verifies the
  popup appears in a second browser context within seconds, sender
  doesn't see their own popup, and dismiss closes it.
This commit is contained in:
riyadhafraa
2026-05-05 15:39:39 +00:00
parent 3d54270fdd
commit 1e358ab32e
9 changed files with 518 additions and 3 deletions
+10 -1
View File
@@ -571,7 +571,16 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
})
.returning();
await emitToUser(r.recipientUserId, "notification_created", { ...notif, type: "note" });
await emitToUser(r.recipientUserId, "note_received", { noteId: note.id });
await emitToUser(r.recipientUserId, "note_received", {
noteId: note.id,
recipientRowId: r.id,
title: r.title,
content: r.content,
color: r.color,
sentAt: r.sentAt,
senderUserId: userId,
sender: sender ?? null,
});
}
res.json({ success: true, sent: inserted.length });
+5
View File
@@ -3,6 +3,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { AuthProvider } from "@/contexts/AuthContext";
import { IncomingNotePopupProvider } from "@/contexts/IncomingNotePopupContext";
import { IncomingNotePopup } from "@/components/notes/incoming-note-popup";
import { useNotificationsSocket } from "@/hooks/use-notifications-socket";
import { useAudioUnlock } from "@/hooks/use-audio-unlock";
import { useAutoplayHint } from "@/hooks/use-autoplay-hint";
@@ -41,8 +43,10 @@ function NotificationsSocketBridge() {
function Router() {
return (
<AuthProvider>
<IncomingNotePopupProvider>
<NotificationsSocketBridge />
<UpcomingMeetingAlert />
<IncomingNotePopup />
<Switch>
<Route path="/login" component={LoginPage} />
<Route path="/register" component={RegisterPage} />
@@ -59,6 +63,7 @@ function Router() {
<Route path="/" component={HomePage} />
<Route component={NotFound} />
</Switch>
</IncomingNotePopupProvider>
</AuthProvider>
);
}
@@ -0,0 +1,164 @@
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import { Reply, Check, ExternalLink, X } from "lucide-react";
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
} from "@/components/ui/alert-dialog";
import { Button } from "@/components/ui/button";
import {
colorBg,
useMarkNoteRead,
userDisplayName,
type UserSummary,
} from "@/lib/notes-api";
import { useIncomingNotePopup } from "@/contexts/IncomingNotePopupContext";
function avatarInitial(u: UserSummary | null, lang: string): string {
const name = userDisplayName(u, lang);
return name.trim().charAt(0).toUpperCase() || "?";
}
export function IncomingNotePopup() {
const { t, i18n } = useTranslation();
const isRtl = i18n.language === "ar";
const [, setLocation] = useLocation();
const { current, queueLength, dismiss } = useIncomingNotePopup();
const markRead = useMarkNoteRead();
if (!current) return null;
const senderName = current.sender
? userDisplayName(current.sender, i18n.language)
: null;
const heading = senderName
? t("notes.popup.heading", { name: senderName })
: t("notes.popup.headingNoSender");
const acknowledge = () => {
markRead.mutate(current.noteId);
};
const handleDismiss = () => dismiss(current.noteId);
const handleMarkRead = () => {
acknowledge();
handleDismiss();
};
const handleReply = () => {
acknowledge();
const noteId = current.noteId;
handleDismiss();
setLocation(`/notes?thread=${noteId}&reply=1`);
};
const handleOpen = () => {
acknowledge();
const noteId = current.noteId;
handleDismiss();
setLocation(`/notes?thread=${noteId}`);
};
return (
<AlertDialog open={true}>
<AlertDialogContent
dir={isRtl ? "rtl" : "ltr"}
data-testid="incoming-note-popup"
className="max-w-md"
>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-3">
{current.sender?.avatarUrl ? (
<img
src={current.sender.avatarUrl}
alt=""
className="h-9 w-9 rounded-full object-cover shrink-0"
/>
) : (
<div className="h-9 w-9 rounded-full bg-amber-200 text-amber-900 font-semibold flex items-center justify-center shrink-0">
{avatarInitial(current.sender, i18n.language)}
</div>
)}
<span className="text-base">{heading}</span>
</AlertDialogTitle>
<AlertDialogDescription className="sr-only">
{heading}
</AlertDialogDescription>
</AlertDialogHeader>
<div
className={`rounded-xl border border-black/5 shadow-sm p-4 ${colorBg(
current.color,
)}`}
data-testid="incoming-note-popup-body"
>
<div className="font-semibold text-sm text-foreground break-words">
{current.title?.trim() || t("notes.popup.untitled")}
</div>
{current.content && (
<div className="text-sm text-foreground/80 mt-2 whitespace-pre-wrap break-words max-h-[40vh] overflow-y-auto">
{current.content}
</div>
)}
</div>
{queueLength > 1 && (
<div
className="text-xs text-muted-foreground"
data-testid="incoming-note-popup-queue"
>
{t("notes.popup.queueMore", { count: queueLength - 1 })}
</div>
)}
<AlertDialogFooter className="flex flex-wrap gap-2 sm:flex-nowrap">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleDismiss}
data-testid="incoming-note-popup-dismiss"
>
<X size={14} className="me-1" />
{t("notes.popup.dismiss")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleOpen}
data-testid="incoming-note-popup-open"
>
<ExternalLink size={14} className="me-1" />
{t("notes.popup.openInNotes")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleMarkRead}
data-testid="incoming-note-popup-mark-read"
>
<Check size={14} className="me-1" />
{t("notes.popup.markRead")}
</Button>
<Button
type="button"
size="sm"
autoFocus
onClick={handleReply}
data-testid="incoming-note-popup-reply"
>
<Reply size={14} className="me-1" />
{t("notes.popup.reply")}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
@@ -0,0 +1,95 @@
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
import type { UserSummary } from "@/lib/notes-api";
import { useAuth } from "@/contexts/AuthContext";
export interface IncomingNotePayload {
noteId: number;
recipientRowId?: number;
title: string;
content: string;
color: string;
sentAt?: string;
senderUserId: number;
sender: UserSummary | null;
}
interface IncomingNotePopupContextValue {
current: IncomingNotePayload | null;
queueLength: number;
enqueue: (payload: IncomingNotePayload, currentUserId: number | null) => void;
dismiss: (noteId: number) => void;
clear: () => void;
}
const IncomingNotePopupContext =
createContext<IncomingNotePopupContextValue | null>(null);
export function IncomingNotePopupProvider({ children }: { children: ReactNode }) {
const [queue, setQueue] = useState<IncomingNotePayload[]>([]);
const { user } = useAuth();
// Drop any queued popups when the user logs out / switches accounts so
// the next user never sees the previous user's pending notes.
useEffect(() => {
if (!user) setQueue([]);
}, [user?.id]);
const enqueue = useCallback(
(payload: IncomingNotePayload, currentUserId: number | null) => {
if (!payload || typeof payload.noteId !== "number") return;
if (currentUserId != null && payload.senderUserId === currentUserId) return;
setQueue((prev) => {
if (prev.some((p) => p.noteId === payload.noteId)) return prev;
return [...prev, payload];
});
},
[],
);
const dismiss = useCallback((noteId: number) => {
setQueue((prev) => {
if (prev.length === 0) return prev;
if (prev[0].noteId !== noteId) {
return prev.filter((p) => p.noteId !== noteId);
}
return prev.slice(1);
});
}, []);
const clear = useCallback(() => setQueue([]), []);
const value = useMemo<IncomingNotePopupContextValue>(
() => ({
current: queue[0] ?? null,
queueLength: queue.length,
enqueue,
dismiss,
clear,
}),
[queue, enqueue, dismiss, clear],
);
return (
<IncomingNotePopupContext.Provider value={value}>
{children}
</IncomingNotePopupContext.Provider>
);
}
export function useIncomingNotePopup(): IncomingNotePopupContextValue {
const ctx = useContext(IncomingNotePopupContext);
if (!ctx) {
throw new Error(
"useIncomingNotePopup must be used within IncomingNotePopupProvider",
);
}
return ctx;
}
@@ -18,6 +18,10 @@ import { useAuth } from "@/contexts/AuthContext";
import { notificationPlayer } from "@/lib/notification-sounds";
import { toast } from "@/hooks/use-toast";
import i18n from "@/i18n";
import {
useIncomingNotePopup,
type IncomingNotePayload,
} from "@/contexts/IncomingNotePopupContext";
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
@@ -29,6 +33,11 @@ export function useNotificationsSocket() {
const { user } = useAuth();
const queryClient = useQueryClient();
const connectedAtRef = useRef<number>(0);
const { enqueue: enqueueNotePopup } = useIncomingNotePopup();
const enqueueNotePopupRef = useRef(enqueueNotePopup);
enqueueNotePopupRef.current = enqueueNotePopup;
const currentUserIdRef = useRef<number | null>(user?.id ?? null);
currentUserIdRef.current = user?.id ?? null;
useEffect(() => {
if (!user) return;
@@ -108,9 +117,27 @@ export function useNotificationsSocket() {
// Realtime note events: invalidate notes queries AND surface a toast so
// the recipient (or sender, on a reply) sees an immediate, dismissible
// confirmation regardless of which page they're on.
socket.on("note_received", () => {
socket.on("note_received", (payload?: Partial<IncomingNotePayload>) => {
queryClient.invalidateQueries({ queryKey: ["notes"] });
if (nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS) return;
const inWarmup = nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS;
if (
payload &&
typeof payload.noteId === "number" &&
typeof payload.senderUserId === "number"
) {
const full: IncomingNotePayload = {
noteId: payload.noteId,
recipientRowId: payload.recipientRowId,
title: typeof payload.title === "string" ? payload.title : "",
content: typeof payload.content === "string" ? payload.content : "",
color: typeof payload.color === "string" ? payload.color : "default",
sentAt: payload.sentAt,
senderUserId: payload.senderUserId,
sender: payload.sender ?? null,
};
enqueueNotePopupRef.current(full, currentUserIdRef.current);
}
if (inWarmup) return;
toast({
title: i18n.t("notes.toast.received.title"),
description: i18n.t("notes.toast.received.desc"),
+11
View File
@@ -1089,6 +1089,17 @@
"desc": "افتح الملاحظة لمتابعة المحادثة."
}
},
"popup": {
"heading": "ملاحظة جديدة من {{name}}",
"headingNoSender": "وصلتك ملاحظة جديدة",
"untitled": "(بدون عنوان)",
"queueMore_one": "ملاحظة أخرى بالانتظار",
"queueMore_other": "{{count}} ملاحظات أخرى بالانتظار",
"reply": "رد",
"markRead": "تحديد كمقروءة",
"openInNotes": "فتح في الملاحظات",
"dismiss": "إغلاق"
},
"confirmSendTitle": "إرسال هذه الملاحظة؟",
"confirmSendBody_one": "سيتم إرسال هذه الملاحظة إلى شخص واحد.",
"confirmSendBody_other": "سيتم إرسال هذه الملاحظة إلى {{count}} أشخاص.",
+11
View File
@@ -995,6 +995,17 @@
"desc": "Open the note to continue the conversation."
}
},
"popup": {
"heading": "New note from {{name}}",
"headingNoSender": "You received a new note",
"untitled": "(no title)",
"queueMore_one": "{{count}} more note waiting",
"queueMore_other": "{{count}} more notes waiting",
"reply": "Reply",
"markRead": "Mark as read",
"openInNotes": "Open in Notes",
"dismiss": "Dismiss"
},
"confirmSendTitle": "Send this note?",
"confirmSendBody_one": "This note will be sent to {{count}} person.",
"confirmSendBody_other": "This note will be sent to {{count}} people.",
+20
View File
@@ -92,6 +92,26 @@ export default function NotesPage() {
const [sendForNoteId, setSendForNoteId] = useState<number | null>(null);
const [openThreadId, setOpenThreadId] = useState<number | null>(null);
// Allow deep-links like /notes?thread=42 (from the incoming-note popup)
// to jump straight into the inbox tab + thread dialog.
useEffect(() => {
if (typeof window === "undefined") return;
const params = new URLSearchParams(window.location.search);
const threadParam = params.get("thread");
if (threadParam) {
const id = Number(threadParam);
if (Number.isInteger(id) && id > 0) {
setView("received");
setOpenThreadId(id);
params.delete("thread");
params.delete("reply");
const qs = params.toString();
const next = `${window.location.pathname}${qs ? `?${qs}` : ""}`;
window.history.replaceState(null, "", next);
}
}
}, []);
const { data: notes = [], isLoading: loadingNotes } = useNotes(view === "archived");
const { data: labels = [] } = useNoteLabels();
const { data: receivedNotes = [], isLoading: loadingReceived } =
@@ -0,0 +1,173 @@
// E2E test for Task #406: when a sender sends a note, the recipient (in
// another browser context) sees the note pop up as a centered modal on
// their screen — wherever they are in the app — within ~seconds.
import { test, expect } from "@playwright/test";
import pg from "pg";
const DATABASE_URL = process.env.DATABASE_URL;
if (!DATABASE_URL) {
throw new Error("DATABASE_URL must be set to run this UI test");
}
const TEST_PASSWORD = "TestPass123!";
const TEST_PASSWORD_HASH =
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
const pool = new pg.Pool({ connectionString: DATABASE_URL });
const createdUserIds = [];
const createdNoteIds = [];
function uniq() {
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
}
async function createUser(prefix, displayEn) {
const username = `${prefix}_${uniq()}`;
const { rows } = await pool.query(
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
[username, `${username}@example.com`, TEST_PASSWORD_HASH, displayEn],
);
const id = rows[0].id;
createdUserIds.push(id);
await pool.query(
`INSERT INTO user_roles (user_id, role_id)
SELECT $1, id FROM roles WHERE name = 'user'`,
[id],
);
return { id, username, displayEn };
}
test.afterAll(async () => {
if (createdNoteIds.length > 0) {
await pool.query(
`DELETE FROM note_replies WHERE note_id = ANY($1::int[])`,
[createdNoteIds],
);
await pool.query(
`DELETE FROM note_recipients WHERE note_id = ANY($1::int[])`,
[createdNoteIds],
);
await pool.query(`DELETE FROM notes WHERE id = ANY($1::int[])`, [
createdNoteIds,
]);
}
if (createdUserIds.length > 0) {
await pool.query(`DELETE FROM notes WHERE user_id = ANY($1::int[])`, [
createdUserIds,
]);
await pool.query(
`DELETE FROM notifications WHERE user_id = ANY($1::int[])`,
[createdUserIds],
);
await pool.query(
`DELETE FROM user_roles WHERE user_id = ANY($1::int[])`,
[createdUserIds],
);
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
createdUserIds,
]);
}
await pool.end();
});
async function loginViaUi(page, username) {
await page.goto("/login");
await page.locator("#username").fill(username);
await page.locator("#password").fill(TEST_PASSWORD);
await Promise.all([
page.waitForURL((url) => !url.pathname.endsWith("/login"), {
timeout: 15_000,
}),
page.locator('form button[type="submit"]').click(),
]);
}
test("recipient sees a popup modal when a note arrives in real time", async ({
browser,
}) => {
const sender = await createUser("popup_sender", "Popup Sender");
const recipient = await createUser("popup_recipient", "Popup Recipient");
// Two isolated browser contexts (separate cookies → separate sessions).
const senderCtx = await browser.newContext();
const recipientCtx = await browser.newContext();
const senderPage = await senderCtx.newPage();
const recipientPage = await recipientCtx.newPage();
try {
// Recipient signs in and sits on the home page (NOT /notes), to
// prove the popup surfaces wherever they are in the app.
await loginViaUi(recipientPage, recipient.username);
await recipientPage.goto("/");
// Give the socket time to connect + warm up.
await recipientPage.waitForTimeout(3500);
// Sender composes a note and sends it to the recipient.
await loginViaUi(senderPage, sender.username);
await senderPage.goto("/notes");
await expect(senderPage.getByTestId("notes-page")).toBeVisible();
const noteTitle = `Popup Note ${uniq()}`;
const noteContent = "Drop everything and read this.";
await senderPage.getByTestId("notes-composer-open").click();
await senderPage.getByTestId("notes-composer-title").fill(noteTitle);
await senderPage.getByTestId("notes-composer-content").fill(noteContent);
const createPromise = senderPage.waitForResponse(
(r) =>
new URL(r.url()).pathname === "/api/notes" &&
r.request().method() === "POST" &&
r.status() >= 200 &&
r.status() < 300,
{ timeout: 15_000 },
);
await senderPage.getByTestId("notes-composer-save").click();
const createResp = await createPromise;
const createdNote = await createResp.json();
createdNoteIds.push(createdNote.id);
const card = senderPage.getByTestId(`note-card-${createdNote.id}`);
await expect(card).toBeVisible();
await card.hover();
await senderPage.getByTestId(`note-card-send-${createdNote.id}`).click();
await expect(senderPage.getByTestId("send-note-dialog")).toBeVisible();
await senderPage
.getByTestId("send-note-search")
.fill(recipient.displayEn);
await senderPage
.getByTestId(`send-recipient-option-${recipient.id}`)
.click();
const sendPromise = senderPage.waitForResponse(
(r) =>
new URL(r.url()).pathname === `/api/notes/${createdNote.id}/send` &&
r.request().method() === "POST" &&
r.status() >= 200 &&
r.status() < 300,
{ timeout: 15_000 },
);
await senderPage.getByTestId("send-note-submit").click();
await expect(senderPage.getByTestId("send-note-confirm")).toBeVisible();
await senderPage.getByTestId("send-note-confirm-submit").click();
await sendPromise;
// ---- Recipient should now see the popup modal ----
const popup = recipientPage.getByTestId("incoming-note-popup");
await expect(popup).toBeVisible({ timeout: 10_000 });
await expect(popup).toContainText(noteTitle);
await expect(popup).toContainText(noteContent);
await expect(popup).toContainText(sender.displayEn);
// Sender does NOT see their own popup.
await expect(senderPage.getByTestId("incoming-note-popup")).toHaveCount(0);
// Dismissing acknowledges (closes) the popup.
await recipientPage
.getByTestId("incoming-note-popup-dismiss")
.click();
await expect(popup).toBeHidden();
} finally {
await senderCtx.close();
await recipientCtx.close();
}
});