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:
@@ -754,10 +754,18 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise<void> =>
|
||||
})
|
||||
.returning();
|
||||
await emitToUser(otherPartyUserId, "notification_created", { ...notif, type: "note" });
|
||||
// Enrich the realtime payload so the recipient's client can render the
|
||||
// floating reply card without an extra round-trip. We truncate the body
|
||||
// to keep the socket frame small; the full reply is still in /notes.
|
||||
const replyContentSnippet = (reply.content ?? "").slice(0, 280);
|
||||
await emitToUser(otherPartyUserId, "note_replied", {
|
||||
noteId: id.id,
|
||||
recipientUserId: isOwner ? otherPartyUserId : userId,
|
||||
replyId: reply.id,
|
||||
replyContent: replyContentSnippet,
|
||||
replier: replier ?? null,
|
||||
noteTitle: liveNote?.title ?? null,
|
||||
color: liveNote?.color ?? "default",
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 25 KiB |
@@ -1,14 +1,14 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
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 { Reply, Check, ExternalLink, X, GripVertical } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
colorBg,
|
||||
@@ -16,47 +16,216 @@ import {
|
||||
userDisplayName,
|
||||
type UserSummary,
|
||||
} from "@/lib/notes-api";
|
||||
import { useIncomingNotePopup } from "@/contexts/IncomingNotePopupContext";
|
||||
import {
|
||||
useIncomingNotePopup,
|
||||
type PopupPayload,
|
||||
} from "@/contexts/IncomingNotePopupContext";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
|
||||
function avatarInitial(u: UserSummary | null, lang: string): string {
|
||||
const name = userDisplayName(u, lang);
|
||||
return name.trim().charAt(0).toUpperCase() || "?";
|
||||
}
|
||||
|
||||
interface StoredPos {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const CARD_WIDTH = 384;
|
||||
const CARD_HEIGHT_ESTIMATE = 280;
|
||||
const VIEWPORT_MARGIN = 8;
|
||||
const TOP_INSET = 80;
|
||||
const SIDE_INSET = 24;
|
||||
|
||||
function storageKey(userId: number | null | undefined): string {
|
||||
return `txos:floating-note-popup:pos:${userId ?? "anon"}`;
|
||||
}
|
||||
|
||||
function loadStoredPos(userId: number | null | undefined): StoredPos | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(storageKey(userId));
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as Partial<StoredPos>;
|
||||
if (typeof parsed.x !== "number" || typeof parsed.y !== "number") return null;
|
||||
return { x: parsed.x, y: parsed.y };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveStoredPos(
|
||||
userId: number | null | undefined,
|
||||
pos: StoredPos,
|
||||
): void {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
window.sessionStorage.setItem(storageKey(userId), JSON.stringify(pos));
|
||||
} catch {
|
||||
/* ignore quota / disabled storage */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Default anchor when the user hasn't dragged the card yet.
|
||||
* RTL → top-left edge of the viewport; LTR → top-right edge. Keeps
|
||||
* the card clear of typical header bars (top inset of 80px).
|
||||
*/
|
||||
function defaultAnchor(isRtl: boolean): StoredPos {
|
||||
if (typeof window === "undefined") return { x: SIDE_INSET, y: TOP_INSET };
|
||||
const x = isRtl
|
||||
? SIDE_INSET
|
||||
: Math.max(SIDE_INSET, window.innerWidth - CARD_WIDTH - SIDE_INSET);
|
||||
return { x, y: TOP_INSET };
|
||||
}
|
||||
|
||||
function clampToViewport(pos: StoredPos): StoredPos {
|
||||
if (typeof window === "undefined") return pos;
|
||||
const maxX = Math.max(
|
||||
VIEWPORT_MARGIN,
|
||||
window.innerWidth - CARD_WIDTH - VIEWPORT_MARGIN,
|
||||
);
|
||||
const maxY = Math.max(
|
||||
VIEWPORT_MARGIN,
|
||||
window.innerHeight - CARD_HEIGHT_ESTIMATE - VIEWPORT_MARGIN,
|
||||
);
|
||||
return {
|
||||
x: Math.min(Math.max(VIEWPORT_MARGIN, pos.x), maxX),
|
||||
y: Math.min(Math.max(VIEWPORT_MARGIN, pos.y), maxY),
|
||||
};
|
||||
}
|
||||
|
||||
export function IncomingNotePopup() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const isRtl = i18n.language === "ar";
|
||||
const [, setLocation] = useLocation();
|
||||
const { current, queueLength, dismiss } = useIncomingNotePopup();
|
||||
const markRead = useMarkNoteRead();
|
||||
const { user } = useAuth();
|
||||
const userId = user?.id ?? null;
|
||||
|
||||
const [position, setPosition] = useState<StoredPos>(() =>
|
||||
clampToViewport(loadStoredPos(userId) ?? defaultAnchor(isRtl)),
|
||||
);
|
||||
const [enterAnim, setEnterAnim] = useState(false);
|
||||
// Reactive flag mirrored from `dragRef`. The ref drives per-pixel
|
||||
// pointermove updates without re-rendering, but we still need a
|
||||
// reactive bit so React turns OFF the transform transition during
|
||||
// a drag — otherwise pointermove writes race the CSS transition
|
||||
// and the card stutters.
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// Pointer-event drag state. Kept in a ref so dragging doesn't churn
|
||||
// through React renders on every pointermove — only the inline style
|
||||
// is mutated until pointerup, when we commit the final position.
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startClientX: number;
|
||||
startClientY: number;
|
||||
startPosX: number;
|
||||
startPosY: number;
|
||||
} | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Re-anchor when identity / language direction changes so the previous
|
||||
// user's stored position doesn't leak into a new session and the
|
||||
// default corner flips correctly on LTR↔RTL switch.
|
||||
useEffect(() => {
|
||||
setPosition(clampToViewport(loadStoredPos(userId) ?? defaultAnchor(isRtl)));
|
||||
// Intentional: only re-anchor on identity / direction change.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [userId, isRtl]);
|
||||
|
||||
// Trigger the "scale-in + fade" enter animation on mount of each new
|
||||
// popup entry. We toggle from `false → true` on the next paint so the
|
||||
// CSS transition has a starting state to interpolate from.
|
||||
useEffect(() => {
|
||||
if (!current) return;
|
||||
setEnterAnim(false);
|
||||
const id = requestAnimationFrame(() => setEnterAnim(true));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [current]);
|
||||
|
||||
// Keep the card on-screen if the viewport shrinks (rotate / resize).
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const onResize = () => setPosition((p) => clampToViewport(p));
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, []);
|
||||
|
||||
const handleDismissCurrent = useCallback(() => {
|
||||
if (!current) return;
|
||||
if (current.kind === "reply") {
|
||||
dismiss({ replyId: current.replyId });
|
||||
} else {
|
||||
dismiss(current.noteId);
|
||||
}
|
||||
}, [current, dismiss]);
|
||||
|
||||
// ESC dismisses the current entry (parity with the old modal).
|
||||
useEffect(() => {
|
||||
if (!current) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
handleDismissCurrent();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [current, handleDismissCurrent]);
|
||||
|
||||
const isReply = current?.kind === "reply";
|
||||
const replyPayload = isReply
|
||||
? (current as Extract<PopupPayload, { kind: "reply" }>)
|
||||
: null;
|
||||
|
||||
const senderForHeader: UserSummary | null = useMemo(() => {
|
||||
if (!current) return null;
|
||||
if (replyPayload) return replyPayload.replier;
|
||||
return current.sender;
|
||||
}, [current, replyPayload]);
|
||||
|
||||
const senderName = senderForHeader
|
||||
? userDisplayName(senderForHeader, i18n.language)
|
||||
: null;
|
||||
|
||||
const heading = useMemo(() => {
|
||||
if (!current) return "";
|
||||
if (replyPayload) {
|
||||
return senderName
|
||||
? t("notes.popup.replyHeading", { name: senderName })
|
||||
: t("notes.popup.replyHeadingNoSender");
|
||||
}
|
||||
return senderName
|
||||
? t("notes.popup.heading", { name: senderName })
|
||||
: t("notes.popup.headingNoSender");
|
||||
}, [current, replyPayload, senderName, t]);
|
||||
|
||||
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);
|
||||
// Only the recipient (note variant) marks-as-read. The reply
|
||||
// variant goes to the *original sender* (note owner), who can't
|
||||
// mark their own outbound note read.
|
||||
if (!replyPayload) {
|
||||
markRead.mutate(current.noteId);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = () => dismiss(current.noteId);
|
||||
|
||||
const handleDismiss = () => handleDismissCurrent();
|
||||
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;
|
||||
@@ -64,107 +233,217 @@ export function IncomingNotePopup() {
|
||||
setLocation(`/notes?thread=${noteId}`);
|
||||
};
|
||||
|
||||
// ---- Drag handlers ----
|
||||
// Only the header (`.drag-handle`) starts a drag. The card itself
|
||||
// is positioned `fixed` and updated via inline style during move; on
|
||||
// pointerup the final position is committed to React state +
|
||||
// sessionStorage. Buttons inside the body get their own clicks
|
||||
// because we never call preventDefault on them.
|
||||
const onHandlePointerDown = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (e.button !== 0 && e.pointerType === "mouse") return;
|
||||
const target = e.currentTarget;
|
||||
target.setPointerCapture(e.pointerId);
|
||||
dragRef.current = {
|
||||
pointerId: e.pointerId,
|
||||
startClientX: e.clientX,
|
||||
startClientY: e.clientY,
|
||||
startPosX: position.x,
|
||||
startPosY: position.y,
|
||||
};
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const onHandlePointerMove = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== e.pointerId) return;
|
||||
const next = clampToViewport({
|
||||
x: drag.startPosX + (e.clientX - drag.startClientX),
|
||||
y: drag.startPosY + (e.clientY - drag.startClientY),
|
||||
});
|
||||
if (cardRef.current) {
|
||||
cardRef.current.style.transform = `translate3d(${next.x}px, ${next.y}px, 0)`;
|
||||
}
|
||||
};
|
||||
|
||||
const onHandlePointerEnd = (e: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== e.pointerId) return;
|
||||
const target = e.currentTarget;
|
||||
if (target.hasPointerCapture(e.pointerId)) {
|
||||
target.releasePointerCapture(e.pointerId);
|
||||
}
|
||||
const next = clampToViewport({
|
||||
x: drag.startPosX + (e.clientX - drag.startClientX),
|
||||
y: drag.startPosY + (e.clientY - drag.startClientY),
|
||||
});
|
||||
dragRef.current = null;
|
||||
setIsDragging(false);
|
||||
setPosition(next);
|
||||
saveStoredPos(userId, next);
|
||||
};
|
||||
|
||||
const bodyTitle = replyPayload
|
||||
? replyPayload.noteTitle?.trim() || t("notes.popup.untitled")
|
||||
: current.title?.trim() || t("notes.popup.untitled");
|
||||
const bodyContent = replyPayload
|
||||
? replyPayload.replyContent
|
||||
: current.content;
|
||||
|
||||
return (
|
||||
<AlertDialog open={true}>
|
||||
<AlertDialogContent
|
||||
dir={isRtl ? "rtl" : "ltr"}
|
||||
// Full-viewport invisible layer. `pointer-events-none` so clicks
|
||||
// fall through to the app — the popup is "floating", not modal.
|
||||
<div
|
||||
className="fixed inset-0 z-[110] pointer-events-none"
|
||||
aria-hidden="false"
|
||||
>
|
||||
<div
|
||||
ref={cardRef}
|
||||
key={replyPayload ? `reply-${replyPayload.replyId}` : `note-${current.noteId}`}
|
||||
role="dialog"
|
||||
aria-label={heading}
|
||||
data-testid="incoming-note-popup"
|
||||
className="max-w-md z-[110] ring-4 ring-amber-400/70 shadow-2xl shadow-amber-500/30 animate-in zoom-in-95 fade-in-0 duration-200"
|
||||
data-popup-kind={replyPayload ? "reply" : "note"}
|
||||
dir={isRtl ? "rtl" : "ltr"}
|
||||
className="pointer-events-auto absolute top-0 left-0"
|
||||
style={{
|
||||
width: CARD_WIDTH,
|
||||
maxWidth: "calc(100vw - 16px)",
|
||||
transform: `translate3d(${position.x}px, ${position.y}px, 0)`,
|
||||
transition: isDragging
|
||||
? "none"
|
||||
: "transform 220ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 200ms ease-out",
|
||||
opacity: enterAnim ? 1 : 0,
|
||||
willChange: "transform",
|
||||
}}
|
||||
>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-3">
|
||||
<div
|
||||
className={`rounded-2xl bg-background ring-4 ring-amber-400/70 shadow-2xl shadow-amber-500/30 overflow-hidden transition-transform duration-200 ${
|
||||
enterAnim ? "scale-100" : "scale-95"
|
||||
}`}
|
||||
>
|
||||
{/* Drag handle / header */}
|
||||
<div
|
||||
onPointerDown={onHandlePointerDown}
|
||||
onPointerMove={onHandlePointerMove}
|
||||
onPointerUp={onHandlePointerEnd}
|
||||
onPointerCancel={onHandlePointerEnd}
|
||||
className="flex items-center gap-3 px-4 py-3 cursor-grab active:cursor-grabbing select-none border-b bg-muted/40 touch-none"
|
||||
data-testid="incoming-note-popup-drag-handle"
|
||||
title={t("notes.popup.dragHint")}
|
||||
>
|
||||
<GripVertical
|
||||
size={16}
|
||||
className="text-muted-foreground shrink-0"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<span className="relative shrink-0">
|
||||
<span
|
||||
className="absolute inset-0 rounded-full bg-amber-400/60 animate-ping"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{current.sender?.avatarUrl ? (
|
||||
{senderForHeader?.avatarUrl ? (
|
||||
<img
|
||||
src={current.sender.avatarUrl}
|
||||
src={senderForHeader.avatarUrl}
|
||||
alt=""
|
||||
className="relative h-9 w-9 rounded-full object-cover ring-2 ring-amber-400"
|
||||
className="relative h-8 w-8 rounded-full object-cover ring-2 ring-amber-400"
|
||||
/>
|
||||
) : (
|
||||
<div className="relative h-9 w-9 rounded-full bg-amber-200 text-amber-900 font-semibold flex items-center justify-center ring-2 ring-amber-400">
|
||||
{avatarInitial(current.sender, i18n.language)}
|
||||
<div className="relative h-8 w-8 rounded-full bg-amber-200 text-amber-900 text-sm font-semibold flex items-center justify-center ring-2 ring-amber-400">
|
||||
{avatarInitial(senderForHeader, i18n.language)}
|
||||
</div>
|
||||
)}
|
||||
</span>
|
||||
<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 className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold truncate">{heading}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{queueLength > 1 && (
|
||||
<div
|
||||
className="text-xs text-muted-foreground"
|
||||
data-testid="incoming-note-popup-queue"
|
||||
>
|
||||
{t("notes.popup.queueMore", { count: queueLength - 1 })}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleDismiss}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-foreground rounded-md p-1"
|
||||
aria-label={t("notes.popup.dismiss")}
|
||||
data-testid="incoming-note-popup-close"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</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>
|
||||
{/* Body */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div
|
||||
className={`rounded-xl border border-black/5 shadow-sm p-3 ${colorBg(
|
||||
current.color,
|
||||
)}`}
|
||||
data-testid="incoming-note-popup-body"
|
||||
>
|
||||
<div className="font-semibold text-sm text-foreground break-words">
|
||||
{bodyTitle}
|
||||
</div>
|
||||
{bodyContent && (
|
||||
<div className="text-sm text-foreground/80 mt-2 whitespace-pre-wrap break-words max-h-[40vh] overflow-y-auto">
|
||||
{bodyContent}
|
||||
</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>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2 sm:flex-nowrap justify-end">
|
||||
<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" />
|
||||
{replyPayload
|
||||
? t("notes.popup.openThread")
|
||||
: t("notes.popup.openInNotes")}
|
||||
</Button>
|
||||
{!replyPayload && (
|
||||
<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" />
|
||||
{replyPayload ? t("notes.popup.replyBack") : t("notes.popup.reply")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,26 +13,30 @@ import {
|
||||
applyDismiss,
|
||||
applyEnqueue,
|
||||
shouldEnqueueNote,
|
||||
type IncomingNotePayload,
|
||||
type PopupPayload,
|
||||
} from "@/lib/incoming-note-queue";
|
||||
|
||||
export type { IncomingNotePayload } from "@/lib/incoming-note-queue";
|
||||
export type {
|
||||
PopupPayload,
|
||||
IncomingNotePayload,
|
||||
IncomingReplyPayload,
|
||||
} from "@/lib/incoming-note-queue";
|
||||
|
||||
interface IncomingNotePopupContextValue {
|
||||
current: IncomingNotePayload | null;
|
||||
current: PopupPayload | null;
|
||||
queueLength: number;
|
||||
/**
|
||||
* Adds the payload to the FIFO popup queue. Returns true if the
|
||||
* payload was actually queued, false if it was suppressed (sender's
|
||||
* own note, duplicate noteId, or malformed). Callers use the boolean
|
||||
* to gate side-effects like the chime so we don't beep for our own
|
||||
* outgoing notes or for replayed socket events.
|
||||
* own note/reply, duplicate id, or malformed). Callers use the
|
||||
* boolean to gate side-effects like the chime so we don't beep for
|
||||
* our own outgoing actions or for replayed socket events.
|
||||
*/
|
||||
enqueue: (
|
||||
payload: IncomingNotePayload,
|
||||
payload: PopupPayload,
|
||||
currentUserId: number | null,
|
||||
) => boolean;
|
||||
dismiss: (noteId: number) => void;
|
||||
dismiss: (target: number | { replyId: number }) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
@@ -40,7 +44,7 @@ const IncomingNotePopupContext =
|
||||
createContext<IncomingNotePopupContextValue | null>(null);
|
||||
|
||||
export function IncomingNotePopupProvider({ children }: { children: ReactNode }) {
|
||||
const [queue, setQueue] = useState<IncomingNotePayload[]>([]);
|
||||
const [queue, setQueue] = useState<PopupPayload[]>([]);
|
||||
const { user } = useAuth();
|
||||
|
||||
// Drop any queued popups whenever the active user identity changes
|
||||
@@ -53,16 +57,13 @@ export function IncomingNotePopupProvider({ children }: { children: ReactNode })
|
||||
|
||||
// Track the latest queue in a ref so `enqueue` can decide acceptance
|
||||
// synchronously without depending on React's state-updater timing.
|
||||
const queueRef = useRef<IncomingNotePayload[]>(queue);
|
||||
const queueRef = useRef<PopupPayload[]>(queue);
|
||||
useEffect(() => {
|
||||
queueRef.current = queue;
|
||||
}, [queue]);
|
||||
|
||||
const enqueue = useCallback(
|
||||
(payload: IncomingNotePayload, currentUserId: number | null): boolean => {
|
||||
// Use the pure predicate to decide acceptance against the current
|
||||
// queue snapshot, then commit via setQueue. Both sides share the
|
||||
// same suppression rules in `incoming-note-queue.ts`.
|
||||
(payload: PopupPayload, currentUserId: number | null): boolean => {
|
||||
const accepted = shouldEnqueueNote(
|
||||
queueRef.current,
|
||||
payload,
|
||||
@@ -76,8 +77,8 @@ export function IncomingNotePopupProvider({ children }: { children: ReactNode })
|
||||
[],
|
||||
);
|
||||
|
||||
const dismiss = useCallback((noteId: number) => {
|
||||
setQueue((prev) => applyDismiss(prev, noteId));
|
||||
const dismiss = useCallback((target: number | { replyId: number }) => {
|
||||
setQueue((prev) => applyDismiss(prev, target));
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => setQueue([]), []);
|
||||
|
||||
@@ -21,6 +21,7 @@ import i18n from "@/i18n";
|
||||
import {
|
||||
useIncomingNotePopup,
|
||||
type IncomingNotePayload,
|
||||
type IncomingReplyPayload,
|
||||
} from "@/contexts/IncomingNotePopupContext";
|
||||
|
||||
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
|
||||
@@ -43,6 +44,9 @@ export function useNotificationsSocket() {
|
||||
// doesn't beep twice for the same incoming note. Mirrors the
|
||||
// `playedRef` pattern used by upcoming-meeting-alert.
|
||||
const playedNoteIdsRef = useRef<Set<number>>(new Set());
|
||||
// Same dedupe shape as `playedNoteIdsRef`, but keyed on replyId so a
|
||||
// reconnect that replays a `note_replied` event doesn't re-chime.
|
||||
const playedReplyIdsRef = useRef<Set<number>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
@@ -170,14 +174,83 @@ export function useNotificationsSocket() {
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("note_replied", () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||||
if (nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS) return;
|
||||
toast({
|
||||
title: i18n.t("notes.toast.replied.title"),
|
||||
description: i18n.t("notes.toast.replied.desc"),
|
||||
});
|
||||
});
|
||||
socket.on(
|
||||
"note_replied",
|
||||
(payload?: Partial<IncomingReplyPayload> & { recipientUserId?: number }) => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||||
const inWarmup = nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS;
|
||||
let enqueued = false;
|
||||
// The server emits `note_replied` only to the *original note
|
||||
// owner* (i.e. the sender). Build a reply popup payload — the
|
||||
// queue dedupes on replyId so a socket reconnect that replays
|
||||
// the same event won't re-show or re-chime.
|
||||
if (
|
||||
payload &&
|
||||
typeof payload.noteId === "number" &&
|
||||
typeof payload.replyId === "number"
|
||||
) {
|
||||
// The "sender" of the *original* note is the current user
|
||||
// (the owner) — we don't have their full UserSummary on the
|
||||
// payload, but the floating card never renders it for the
|
||||
// reply variant; it shows the replier instead. Use the
|
||||
// replier's id as a stand-in for senderUserId so the
|
||||
// queue's "ignore my own outgoing" guard treats this event
|
||||
// as inbound (the replier is *not* the current user).
|
||||
const replierId = payload.replier?.id;
|
||||
const senderUserId =
|
||||
typeof replierId === "number" ? replierId : -1;
|
||||
const full: IncomingReplyPayload = {
|
||||
kind: "reply",
|
||||
noteId: payload.noteId,
|
||||
replyId: payload.replyId,
|
||||
replyContent:
|
||||
typeof payload.replyContent === "string"
|
||||
? payload.replyContent
|
||||
: "",
|
||||
replier: payload.replier ?? null,
|
||||
noteTitle:
|
||||
typeof payload.noteTitle === "string" ? payload.noteTitle : "",
|
||||
title:
|
||||
typeof payload.noteTitle === "string" ? payload.noteTitle : "",
|
||||
content:
|
||||
typeof payload.replyContent === "string"
|
||||
? payload.replyContent
|
||||
: "",
|
||||
color:
|
||||
typeof payload.color === "string" ? payload.color : "default",
|
||||
senderUserId,
|
||||
sender: payload.replier ?? null,
|
||||
};
|
||||
enqueued = enqueueNotePopupRef.current(
|
||||
full,
|
||||
currentUserIdRef.current,
|
||||
);
|
||||
}
|
||||
if (inWarmup) return;
|
||||
// Sound + vibration mirror the new-note alert. Reuse the same
|
||||
// notes channel prefs (notificationSoundNote /
|
||||
// vibrationEnabledNote / notifyNotesEnabled) — the user
|
||||
// confirmed they want a single sensory profile for both
|
||||
// events. Dedupe by replyId via a per-session set.
|
||||
const replyId = payload?.replyId;
|
||||
if (
|
||||
enqueued &&
|
||||
typeof replyId === "number" &&
|
||||
!user.notificationsMuted &&
|
||||
user.notifyNotesEnabled &&
|
||||
!playedReplyIdsRef.current.has(replyId)
|
||||
) {
|
||||
playedReplyIdsRef.current.add(replyId);
|
||||
notificationPlayer.play(user.notificationSoundNote, {
|
||||
vibrate: user.vibrationEnabledNote,
|
||||
});
|
||||
}
|
||||
toast({
|
||||
title: i18n.t("notes.toast.replied.title"),
|
||||
description: i18n.t("notes.toast.replied.desc"),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
socket.on("note_status_changed", () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
import type { UserSummary } from "@/lib/notes-api";
|
||||
|
||||
export interface IncomingNotePayload {
|
||||
/**
|
||||
* Common fields shared by every floating-card popup entry, regardless
|
||||
* of whether it represents a fresh incoming note or a reply on an
|
||||
* existing thread. The popup component switches on `kind` to render the
|
||||
* matching variant; the queue uses `noteId` for primary dedupe and
|
||||
* (for replies) `replyId` as a secondary dedupe key so a reconnect
|
||||
* storm can't replay the same reply card twice.
|
||||
*/
|
||||
interface BasePayload {
|
||||
noteId: number;
|
||||
recipientRowId?: number;
|
||||
title: string;
|
||||
@@ -11,20 +19,48 @@ export interface IncomingNotePayload {
|
||||
sender: UserSummary | null;
|
||||
}
|
||||
|
||||
export interface IncomingNotePayload extends BasePayload {
|
||||
kind?: "note";
|
||||
}
|
||||
|
||||
export interface IncomingReplyPayload extends BasePayload {
|
||||
kind: "reply";
|
||||
replyId: number;
|
||||
replyContent: string;
|
||||
replier: UserSummary | null;
|
||||
noteTitle: string;
|
||||
}
|
||||
|
||||
export type PopupPayload = IncomingNotePayload | IncomingReplyPayload;
|
||||
|
||||
function isReply(p: PopupPayload): p is IncomingReplyPayload {
|
||||
return p.kind === "reply";
|
||||
}
|
||||
|
||||
function payloadKey(p: PopupPayload): string {
|
||||
return isReply(p) ? `reply:${p.replyId}` : `note:${p.noteId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure FIFO + dedupe + sender-suppression for the incoming-note queue.
|
||||
* Exported so unit tests can exercise the rules without React.
|
||||
*
|
||||
* Reply entries are deduped by `replyId`; note entries by `noteId`.
|
||||
* A reply on the *same* underlying note as a queued note is allowed —
|
||||
* the user wants to see both events.
|
||||
*/
|
||||
export function applyEnqueue(
|
||||
queue: IncomingNotePayload[],
|
||||
payload: IncomingNotePayload,
|
||||
queue: PopupPayload[],
|
||||
payload: PopupPayload,
|
||||
currentUserId: number | null,
|
||||
): IncomingNotePayload[] {
|
||||
): PopupPayload[] {
|
||||
if (!payload || typeof payload.noteId !== "number") return queue;
|
||||
if (currentUserId != null && payload.senderUserId === currentUserId) {
|
||||
return queue;
|
||||
}
|
||||
if (queue.some((p) => p.noteId === payload.noteId)) return queue;
|
||||
if (isReply(payload) && typeof payload.replyId !== "number") return queue;
|
||||
const key = payloadKey(payload);
|
||||
if (queue.some((p) => payloadKey(p) === key)) return queue;
|
||||
return [...queue, payload];
|
||||
}
|
||||
|
||||
@@ -35,28 +71,35 @@ export function applyEnqueue(
|
||||
* suppression rules in {@link applyEnqueue} so the two stay in sync.
|
||||
*/
|
||||
export function shouldEnqueueNote(
|
||||
queue: IncomingNotePayload[],
|
||||
payload: IncomingNotePayload,
|
||||
queue: PopupPayload[],
|
||||
payload: PopupPayload,
|
||||
currentUserId: number | null,
|
||||
): boolean {
|
||||
if (!payload || typeof payload.noteId !== "number") return false;
|
||||
if (currentUserId != null && payload.senderUserId === currentUserId) {
|
||||
return false;
|
||||
}
|
||||
if (queue.some((p) => p.noteId === payload.noteId)) return false;
|
||||
if (isReply(payload) && typeof payload.replyId !== "number") return false;
|
||||
const key = payloadKey(payload);
|
||||
if (queue.some((p) => payloadKey(p) === key)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pop the head of the queue if `noteId` matches it; otherwise filter it
|
||||
* out from anywhere it appears. Pure for testability.
|
||||
* Pop the head of the queue if its identity matches; otherwise filter
|
||||
* the matching entry out from anywhere it appears. Pure for testability.
|
||||
*
|
||||
* Accepts either a noteId (for note entries) or `{ replyId }` (for
|
||||
* reply entries) so callers can dismiss either variant unambiguously.
|
||||
*/
|
||||
export function applyDismiss(
|
||||
queue: IncomingNotePayload[],
|
||||
noteId: number,
|
||||
): IncomingNotePayload[] {
|
||||
queue: PopupPayload[],
|
||||
target: number | { replyId: number },
|
||||
): PopupPayload[] {
|
||||
if (queue.length === 0) return queue;
|
||||
if (queue[0].noteId === noteId) return queue.slice(1);
|
||||
const next = queue.filter((p) => p.noteId !== noteId);
|
||||
const key =
|
||||
typeof target === "number" ? `note:${target}` : `reply:${target.replyId}`;
|
||||
if (payloadKey(queue[0]) === key) return queue.slice(1);
|
||||
const next = queue.filter((p) => payloadKey(p) !== key);
|
||||
return next.length === queue.length ? queue : next;
|
||||
}
|
||||
|
||||
@@ -1095,13 +1095,18 @@
|
||||
"popup": {
|
||||
"heading": "ملاحظة جديدة من {{name}}",
|
||||
"headingNoSender": "وصلتك ملاحظة جديدة",
|
||||
"replyHeading": "{{name}} ردّ على ملاحظتك",
|
||||
"replyHeadingNoSender": "وصلك رد جديد على ملاحظتك",
|
||||
"untitled": "(بدون عنوان)",
|
||||
"queueMore_one": "ملاحظة أخرى بالانتظار",
|
||||
"queueMore_other": "{{count}} ملاحظات أخرى بالانتظار",
|
||||
"reply": "رد",
|
||||
"replyBack": "رد عليه",
|
||||
"markRead": "تحديد كمقروءة",
|
||||
"openInNotes": "فتح في الملاحظات",
|
||||
"dismiss": "إغلاق"
|
||||
"openThread": "فتح المحادثة",
|
||||
"dismiss": "إغلاق",
|
||||
"dragHint": "اسحب للتحريك"
|
||||
},
|
||||
"confirmSendTitle": "إرسال هذه الملاحظة؟",
|
||||
"confirmSendBody_one": "سيتم إرسال هذه الملاحظة إلى شخص واحد.",
|
||||
|
||||
@@ -1001,13 +1001,18 @@
|
||||
"popup": {
|
||||
"heading": "New note from {{name}}",
|
||||
"headingNoSender": "You received a new note",
|
||||
"replyHeading": "{{name}} replied to your note",
|
||||
"replyHeadingNoSender": "New reply to your note",
|
||||
"untitled": "(no title)",
|
||||
"queueMore_one": "{{count}} more note waiting",
|
||||
"queueMore_other": "{{count}} more notes waiting",
|
||||
"reply": "Reply",
|
||||
"replyBack": "Reply back",
|
||||
"markRead": "Mark as read",
|
||||
"openInNotes": "Open in Notes",
|
||||
"dismiss": "Dismiss"
|
||||
"openThread": "Open thread",
|
||||
"dismiss": "Dismiss",
|
||||
"dragHint": "Drag to move"
|
||||
},
|
||||
"confirmSendTitle": "Send this note?",
|
||||
"confirmSendBody_one": "This note will be sent to {{count}} person.",
|
||||
|
||||
@@ -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