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:
@@ -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 });
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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}} أشخاص.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user