Task #410: floating draggable note popup + reply alert at sender
Convert the incoming-note popup from a centered AlertDialog modal into
a floating, draggable, animated card (no backdrop) and surface the
same card variant at the original sender when the recipient replies.
UI / popup:
- Rewrite IncomingNotePopup as a fixed-positioned card with custom
pointer-event drag handler, viewport clamping, sessionStorage-
persisted position keyed per user, RTL-aware default anchor, ESC
dismissal, scale-in/fade enter animation, and click-through layer
(pointer-events-none wrapper). Initial framer-motion impl crashed
in vite (useRef-of-null / Invalid hook call); rewrote without
framer-motion using plain CSS transitions for stability.
- isDragging tracked in state so the transform transition is reliably
disabled during drag (per architect review).
Reply variant:
- Generalize incoming-note-queue with PopupPayload discriminated union
(note | reply); reply dedupe by replyId, note dedupe by noteId;
applyDismiss accepts number | {replyId}.
- Floating card switches heading + actions for reply variant: shows
replier name, reply text, and openThread / replyBack actions; the
recipient-only mark-read action is suppressed (owner can't mark own
outbound note read).
Sound + socket:
- New note_replied client handler enqueues the reply payload and plays
notificationSoundNote + vibrationEnabledNote (gated by
notificationsMuted + notifyNotesEnabled — no new prefs), deduped
via playedReplyIdsRef.
- Server emit at /notes/:id/reply enriched with replyContent (≤280
chars), replier (UserSummary), noteTitle, color so the client can
render without an extra round-trip.
i18n + tests:
- Add en/ar keys: replyHeading, replyHeadingNoSender, replyBack,
openThread, dragHint.
- Extend notes-popup-on-receive.spec.mjs: first test asserts
no [role=alertdialog], drag-handle visible, data-popup-kind="note";
new reply test asserts data-popup-kind="reply", reply text +
replier name visible, mark-read action absent, chime fires once
for sender on reply.
Drift from plan:
- Used custom pointer-event drag instead of framer-motion drag — the
framer-motion impl crashed in vite (React-instance null in dev).
Same UX (drag from header only, click-through behind card).
- E2E: api-server unit test failures (executive-meetings, pre-existing
and unrelated) abort the test workflow before playwright runs;
could not get a clean green run during this session. Tx-os
typecheck passes; browser console clean; popup interface (testIds,
data attrs, text) is identical to the previously-working
framer-motion run, so no functional regression expected.
This commit is contained in:
@@ -87,6 +87,9 @@ async function loginViaUi(page, username) {
|
||||
test("recipient sees a popup modal when a note arrives in real time", async ({
|
||||
browser,
|
||||
}) => {
|
||||
// Two-context flow with socket warmup + login UI + send confirmation
|
||||
// routinely runs ~25-35s; default 30s leaves no margin.
|
||||
test.setTimeout(90_000);
|
||||
const sender = await createUser("popup_sender", "Popup Sender");
|
||||
const recipient = await createUser("popup_recipient", "Popup Recipient");
|
||||
|
||||
@@ -158,7 +161,7 @@ test("recipient sees a popup modal when a note arrives in real time", async ({
|
||||
await sendPromise;
|
||||
const sendCompletedAt = Date.now();
|
||||
|
||||
// ---- Recipient should now see the popup modal ----
|
||||
// ---- Recipient should now see the floating popup card ----
|
||||
// Real-time SLA: socket-fanout popup must surface within ~3s of the
|
||||
// send returning. Anything slower defeats the "can't miss it" goal.
|
||||
const popup = recipientPage.getByTestId("incoming-note-popup");
|
||||
@@ -167,6 +170,15 @@ test("recipient sees a popup modal when a note arrives in real time", async ({
|
||||
await expect(popup).toContainText(noteTitle);
|
||||
await expect(popup).toContainText(noteContent);
|
||||
await expect(popup).toContainText(sender.displayEn);
|
||||
// Floating card — NOT a modal: must not render as an alertdialog
|
||||
// and must not block clicks behind it (no full-viewport overlay).
|
||||
await expect(popup).toHaveAttribute("data-popup-kind", "note");
|
||||
await expect(recipientPage.locator('[role="alertdialog"]')).toHaveCount(0);
|
||||
// The drag handle is the only pointer-event surface that initiates
|
||||
// a drag; presence is enough to confirm the floating-card affordance.
|
||||
await expect(
|
||||
recipientPage.getByTestId("incoming-note-popup-drag-handle"),
|
||||
).toBeVisible();
|
||||
|
||||
// Sender does NOT see their own popup.
|
||||
await expect(senderPage.getByTestId("incoming-note-popup")).toHaveCount(0);
|
||||
@@ -215,3 +227,151 @@ test("recipient sees a popup modal when a note arrives in real time", async ({
|
||||
await recipientCtx.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Task #410: when the recipient replies on a note, the *original sender*
|
||||
// (note owner) should see the same floating-card popup variant — pre-
|
||||
// filled with the reply text + replier name — wherever they are in the
|
||||
// app, with the same chime/vibration profile as the new-note alert.
|
||||
test("sender sees a floating reply card when the recipient replies", async ({
|
||||
browser,
|
||||
}) => {
|
||||
// Even longer than the new-note test: TWO send flows + reply submit +
|
||||
// socket warmups across two contexts.
|
||||
test.setTimeout(120_000);
|
||||
const sender = await createUser("reply_sender", "Reply Sender");
|
||||
const recipient = await createUser("reply_recipient", "Reply Recipient");
|
||||
|
||||
const senderCtx = await browser.newContext();
|
||||
const recipientCtx = await browser.newContext();
|
||||
const senderPage = await senderCtx.newPage();
|
||||
const recipientPage = await recipientCtx.newPage();
|
||||
|
||||
try {
|
||||
// Sender composes + sends the original note.
|
||||
await loginViaUi(senderPage, sender.username);
|
||||
await senderPage.goto("/notes");
|
||||
await expect(senderPage.getByTestId("notes-page")).toBeVisible();
|
||||
|
||||
const noteTitle = `Reply Note ${uniq()}`;
|
||||
const noteContent = "Please reply with your thoughts.";
|
||||
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;
|
||||
|
||||
// Sender navigates AWAY from /notes to prove the reply popup
|
||||
// surfaces wherever they are — same guarantee as the new-note popup.
|
||||
await senderPage.goto("/");
|
||||
await senderPage.evaluate(() => {
|
||||
window.__txosNotifPlayCount = 0;
|
||||
window.__txosNotifLastSound = undefined;
|
||||
});
|
||||
await senderPage.waitForTimeout(3500); // socket warmup window
|
||||
|
||||
// Recipient signs in, opens the inbox, replies on the thread.
|
||||
await loginViaUi(recipientPage, recipient.username);
|
||||
await recipientPage.goto(`/notes?thread=${createdNote.id}&reply=1`);
|
||||
|
||||
const replyText = `Reply body ${uniq()}`;
|
||||
const replyInput = recipientPage.getByTestId("thread-reply-input");
|
||||
await expect(replyInput).toBeVisible({ timeout: 10_000 });
|
||||
await replyInput.fill(replyText);
|
||||
const replyPromise = recipientPage.waitForResponse(
|
||||
(r) =>
|
||||
new URL(r.url()).pathname ===
|
||||
`/api/notes/${createdNote.id}/replies` &&
|
||||
r.request().method() === "POST" &&
|
||||
r.status() >= 200 &&
|
||||
r.status() < 300,
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await recipientPage.getByTestId("thread-reply-submit").click();
|
||||
await replyPromise;
|
||||
const replySentAt = Date.now();
|
||||
|
||||
// ---- Original sender sees the floating REPLY card ----
|
||||
const popup = senderPage.getByTestId("incoming-note-popup");
|
||||
await expect(popup).toBeVisible({ timeout: 5_000 });
|
||||
expect(Date.now() - replySentAt).toBeLessThan(5_000);
|
||||
await expect(popup).toHaveAttribute("data-popup-kind", "reply");
|
||||
await expect(popup).toContainText(replyText);
|
||||
await expect(popup).toContainText(recipient.displayEn);
|
||||
// Same floating-card guarantees: no modal backdrop, drag handle present.
|
||||
await expect(senderPage.locator('[role="alertdialog"]')).toHaveCount(0);
|
||||
await expect(
|
||||
senderPage.getByTestId("incoming-note-popup-drag-handle"),
|
||||
).toBeVisible();
|
||||
// Reply card uses the "open thread" + "reply back" affordances —
|
||||
// the recipient-only "mark read" action MUST be absent.
|
||||
await expect(
|
||||
senderPage.getByTestId("incoming-note-popup-mark-read"),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
senderPage.getByTestId("incoming-note-popup-open"),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
senderPage.getByTestId("incoming-note-popup-reply"),
|
||||
).toBeVisible();
|
||||
|
||||
// Same chime profile as new-note alert: notes channel, "knock" sound,
|
||||
// exactly one play (deduped by replyId).
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
senderPage.evaluate(() => window.__txosNotifPlayCount ?? 0),
|
||||
{ timeout: 5_000 },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1);
|
||||
const lastSound = await senderPage.evaluate(
|
||||
() => window.__txosNotifLastSound,
|
||||
);
|
||||
expect(lastSound).toBe("knock");
|
||||
|
||||
// The replier (recipient) does NOT see their own outgoing reply card.
|
||||
await expect(
|
||||
recipientPage.getByTestId("incoming-note-popup"),
|
||||
).toHaveCount(0);
|
||||
|
||||
// Dismiss closes the floating card.
|
||||
await senderPage.getByTestId("incoming-note-popup-dismiss").click();
|
||||
await expect(popup).toBeHidden();
|
||||
} finally {
|
||||
await senderCtx.close();
|
||||
await recipientCtx.close();
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user