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();
|
.returning();
|
||||||
await emitToUser(otherPartyUserId, "notification_created", { ...notif, type: "note" });
|
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", {
|
await emitToUser(otherPartyUserId, "note_replied", {
|
||||||
noteId: id.id,
|
noteId: id.id,
|
||||||
recipientUserId: isOwner ? otherPartyUserId : userId,
|
recipientUserId: isOwner ? otherPartyUserId : userId,
|
||||||
replyId: reply.id,
|
replyId: reply.id,
|
||||||
|
replyContent: replyContentSnippet,
|
||||||
|
replier: replier ?? null,
|
||||||
|
noteTitle: liveNote?.title ?? null,
|
||||||
|
color: liveNote?.color ?? "default",
|
||||||
});
|
});
|
||||||
|
|
||||||
res.status(201).json({
|
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 { useTranslation } from "react-i18next";
|
||||||
import { useLocation } from "wouter";
|
import { useLocation } from "wouter";
|
||||||
import { Reply, Check, ExternalLink, X } from "lucide-react";
|
import { Reply, Check, ExternalLink, X, GripVertical } from "lucide-react";
|
||||||
import {
|
|
||||||
AlertDialog,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
} from "@/components/ui/alert-dialog";
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
colorBg,
|
colorBg,
|
||||||
@@ -16,47 +16,216 @@ import {
|
|||||||
userDisplayName,
|
userDisplayName,
|
||||||
type UserSummary,
|
type UserSummary,
|
||||||
} from "@/lib/notes-api";
|
} 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 {
|
function avatarInitial(u: UserSummary | null, lang: string): string {
|
||||||
const name = userDisplayName(u, lang);
|
const name = userDisplayName(u, lang);
|
||||||
return name.trim().charAt(0).toUpperCase() || "?";
|
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() {
|
export function IncomingNotePopup() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const isRtl = i18n.language === "ar";
|
const isRtl = i18n.language === "ar";
|
||||||
const [, setLocation] = useLocation();
|
const [, setLocation] = useLocation();
|
||||||
const { current, queueLength, dismiss } = useIncomingNotePopup();
|
const { current, queueLength, dismiss } = useIncomingNotePopup();
|
||||||
const markRead = useMarkNoteRead();
|
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;
|
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 = () => {
|
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 = () => {
|
const handleMarkRead = () => {
|
||||||
acknowledge();
|
acknowledge();
|
||||||
handleDismiss();
|
handleDismiss();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReply = () => {
|
const handleReply = () => {
|
||||||
acknowledge();
|
acknowledge();
|
||||||
const noteId = current.noteId;
|
const noteId = current.noteId;
|
||||||
handleDismiss();
|
handleDismiss();
|
||||||
setLocation(`/notes?thread=${noteId}&reply=1`);
|
setLocation(`/notes?thread=${noteId}&reply=1`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOpen = () => {
|
const handleOpen = () => {
|
||||||
acknowledge();
|
acknowledge();
|
||||||
const noteId = current.noteId;
|
const noteId = current.noteId;
|
||||||
@@ -64,107 +233,217 @@ export function IncomingNotePopup() {
|
|||||||
setLocation(`/notes?thread=${noteId}`);
|
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 (
|
return (
|
||||||
<AlertDialog open={true}>
|
// Full-viewport invisible layer. `pointer-events-none` so clicks
|
||||||
<AlertDialogContent
|
// fall through to the app — the popup is "floating", not modal.
|
||||||
dir={isRtl ? "rtl" : "ltr"}
|
<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"
|
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>
|
<div
|
||||||
<AlertDialogTitle className="flex items-center gap-3">
|
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="relative shrink-0">
|
||||||
<span
|
<span
|
||||||
className="absolute inset-0 rounded-full bg-amber-400/60 animate-ping"
|
className="absolute inset-0 rounded-full bg-amber-400/60 animate-ping"
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
{current.sender?.avatarUrl ? (
|
{senderForHeader?.avatarUrl ? (
|
||||||
<img
|
<img
|
||||||
src={current.sender.avatarUrl}
|
src={senderForHeader.avatarUrl}
|
||||||
alt=""
|
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">
|
<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(current.sender, i18n.language)}
|
{avatarInitial(senderForHeader, i18n.language)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-base">{heading}</span>
|
<div className="flex-1 min-w-0">
|
||||||
</AlertDialogTitle>
|
<div className="text-sm font-semibold truncate">{heading}</div>
|
||||||
<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>
|
||||||
)}
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
onClick={handleDismiss}
|
||||||
{queueLength > 1 && (
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
<div
|
className="text-muted-foreground hover:text-foreground rounded-md p-1"
|
||||||
className="text-xs text-muted-foreground"
|
aria-label={t("notes.popup.dismiss")}
|
||||||
data-testid="incoming-note-popup-queue"
|
data-testid="incoming-note-popup-close"
|
||||||
>
|
>
|
||||||
{t("notes.popup.queueMore", { count: queueLength - 1 })}
|
<X size={16} />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<AlertDialogFooter className="flex flex-wrap gap-2 sm:flex-nowrap">
|
{/* Body */}
|
||||||
<Button
|
<div className="p-4 space-y-3">
|
||||||
type="button"
|
<div
|
||||||
variant="ghost"
|
className={`rounded-xl border border-black/5 shadow-sm p-3 ${colorBg(
|
||||||
size="sm"
|
current.color,
|
||||||
onClick={handleDismiss}
|
)}`}
|
||||||
data-testid="incoming-note-popup-dismiss"
|
data-testid="incoming-note-popup-body"
|
||||||
>
|
>
|
||||||
<X size={14} className="me-1" />
|
<div className="font-semibold text-sm text-foreground break-words">
|
||||||
{t("notes.popup.dismiss")}
|
{bodyTitle}
|
||||||
</Button>
|
</div>
|
||||||
<Button
|
{bodyContent && (
|
||||||
type="button"
|
<div className="text-sm text-foreground/80 mt-2 whitespace-pre-wrap break-words max-h-[40vh] overflow-y-auto">
|
||||||
variant="outline"
|
{bodyContent}
|
||||||
size="sm"
|
</div>
|
||||||
onClick={handleOpen}
|
)}
|
||||||
data-testid="incoming-note-popup-open"
|
</div>
|
||||||
>
|
|
||||||
<ExternalLink size={14} className="me-1" />
|
{queueLength > 1 && (
|
||||||
{t("notes.popup.openInNotes")}
|
<div
|
||||||
</Button>
|
className="text-xs text-muted-foreground"
|
||||||
<Button
|
data-testid="incoming-note-popup-queue"
|
||||||
type="button"
|
>
|
||||||
variant="outline"
|
{t("notes.popup.queueMore", { count: queueLength - 1 })}
|
||||||
size="sm"
|
</div>
|
||||||
onClick={handleMarkRead}
|
)}
|
||||||
data-testid="incoming-note-popup-mark-read"
|
|
||||||
>
|
<div className="flex flex-wrap gap-2 sm:flex-nowrap justify-end">
|
||||||
<Check size={14} className="me-1" />
|
<Button
|
||||||
{t("notes.popup.markRead")}
|
type="button"
|
||||||
</Button>
|
variant="ghost"
|
||||||
<Button
|
size="sm"
|
||||||
type="button"
|
onClick={handleDismiss}
|
||||||
size="sm"
|
data-testid="incoming-note-popup-dismiss"
|
||||||
autoFocus
|
>
|
||||||
onClick={handleReply}
|
<X size={14} className="me-1" />
|
||||||
data-testid="incoming-note-popup-reply"
|
{t("notes.popup.dismiss")}
|
||||||
>
|
</Button>
|
||||||
<Reply size={14} className="me-1" />
|
<Button
|
||||||
{t("notes.popup.reply")}
|
type="button"
|
||||||
</Button>
|
variant="outline"
|
||||||
</AlertDialogFooter>
|
size="sm"
|
||||||
</AlertDialogContent>
|
onClick={handleOpen}
|
||||||
</AlertDialog>
|
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,
|
applyDismiss,
|
||||||
applyEnqueue,
|
applyEnqueue,
|
||||||
shouldEnqueueNote,
|
shouldEnqueueNote,
|
||||||
type IncomingNotePayload,
|
type PopupPayload,
|
||||||
} from "@/lib/incoming-note-queue";
|
} 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 {
|
interface IncomingNotePopupContextValue {
|
||||||
current: IncomingNotePayload | null;
|
current: PopupPayload | null;
|
||||||
queueLength: number;
|
queueLength: number;
|
||||||
/**
|
/**
|
||||||
* Adds the payload to the FIFO popup queue. Returns true if the
|
* Adds the payload to the FIFO popup queue. Returns true if the
|
||||||
* payload was actually queued, false if it was suppressed (sender's
|
* payload was actually queued, false if it was suppressed (sender's
|
||||||
* own note, duplicate noteId, or malformed). Callers use the boolean
|
* own note/reply, duplicate id, or malformed). Callers use the
|
||||||
* to gate side-effects like the chime so we don't beep for our own
|
* boolean to gate side-effects like the chime so we don't beep for
|
||||||
* outgoing notes or for replayed socket events.
|
* our own outgoing actions or for replayed socket events.
|
||||||
*/
|
*/
|
||||||
enqueue: (
|
enqueue: (
|
||||||
payload: IncomingNotePayload,
|
payload: PopupPayload,
|
||||||
currentUserId: number | null,
|
currentUserId: number | null,
|
||||||
) => boolean;
|
) => boolean;
|
||||||
dismiss: (noteId: number) => void;
|
dismiss: (target: number | { replyId: number }) => void;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,7 +44,7 @@ const IncomingNotePopupContext =
|
|||||||
createContext<IncomingNotePopupContextValue | null>(null);
|
createContext<IncomingNotePopupContextValue | null>(null);
|
||||||
|
|
||||||
export function IncomingNotePopupProvider({ children }: { children: ReactNode }) {
|
export function IncomingNotePopupProvider({ children }: { children: ReactNode }) {
|
||||||
const [queue, setQueue] = useState<IncomingNotePayload[]>([]);
|
const [queue, setQueue] = useState<PopupPayload[]>([]);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
// Drop any queued popups whenever the active user identity changes
|
// 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
|
// Track the latest queue in a ref so `enqueue` can decide acceptance
|
||||||
// synchronously without depending on React's state-updater timing.
|
// synchronously without depending on React's state-updater timing.
|
||||||
const queueRef = useRef<IncomingNotePayload[]>(queue);
|
const queueRef = useRef<PopupPayload[]>(queue);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
queueRef.current = queue;
|
queueRef.current = queue;
|
||||||
}, [queue]);
|
}, [queue]);
|
||||||
|
|
||||||
const enqueue = useCallback(
|
const enqueue = useCallback(
|
||||||
(payload: IncomingNotePayload, currentUserId: number | null): boolean => {
|
(payload: PopupPayload, 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`.
|
|
||||||
const accepted = shouldEnqueueNote(
|
const accepted = shouldEnqueueNote(
|
||||||
queueRef.current,
|
queueRef.current,
|
||||||
payload,
|
payload,
|
||||||
@@ -76,8 +77,8 @@ export function IncomingNotePopupProvider({ children }: { children: ReactNode })
|
|||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const dismiss = useCallback((noteId: number) => {
|
const dismiss = useCallback((target: number | { replyId: number }) => {
|
||||||
setQueue((prev) => applyDismiss(prev, noteId));
|
setQueue((prev) => applyDismiss(prev, target));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const clear = useCallback(() => setQueue([]), []);
|
const clear = useCallback(() => setQueue([]), []);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import i18n from "@/i18n";
|
|||||||
import {
|
import {
|
||||||
useIncomingNotePopup,
|
useIncomingNotePopup,
|
||||||
type IncomingNotePayload,
|
type IncomingNotePayload,
|
||||||
|
type IncomingReplyPayload,
|
||||||
} from "@/contexts/IncomingNotePopupContext";
|
} from "@/contexts/IncomingNotePopupContext";
|
||||||
|
|
||||||
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
|
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
|
// doesn't beep twice for the same incoming note. Mirrors the
|
||||||
// `playedRef` pattern used by upcoming-meeting-alert.
|
// `playedRef` pattern used by upcoming-meeting-alert.
|
||||||
const playedNoteIdsRef = useRef<Set<number>>(new Set());
|
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(() => {
|
useEffect(() => {
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
@@ -170,14 +174,83 @@ export function useNotificationsSocket() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on("note_replied", () => {
|
socket.on(
|
||||||
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
"note_replied",
|
||||||
if (nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS) return;
|
(payload?: Partial<IncomingReplyPayload> & { recipientUserId?: number }) => {
|
||||||
toast({
|
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||||||
title: i18n.t("notes.toast.replied.title"),
|
const inWarmup = nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS;
|
||||||
description: i18n.t("notes.toast.replied.desc"),
|
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", () => {
|
socket.on("note_status_changed", () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import type { UserSummary } from "@/lib/notes-api";
|
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;
|
noteId: number;
|
||||||
recipientRowId?: number;
|
recipientRowId?: number;
|
||||||
title: string;
|
title: string;
|
||||||
@@ -11,20 +19,48 @@ export interface IncomingNotePayload {
|
|||||||
sender: UserSummary | null;
|
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.
|
* Pure FIFO + dedupe + sender-suppression for the incoming-note queue.
|
||||||
* Exported so unit tests can exercise the rules without React.
|
* 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(
|
export function applyEnqueue(
|
||||||
queue: IncomingNotePayload[],
|
queue: PopupPayload[],
|
||||||
payload: IncomingNotePayload,
|
payload: PopupPayload,
|
||||||
currentUserId: number | null,
|
currentUserId: number | null,
|
||||||
): IncomingNotePayload[] {
|
): PopupPayload[] {
|
||||||
if (!payload || typeof payload.noteId !== "number") return queue;
|
if (!payload || typeof payload.noteId !== "number") return queue;
|
||||||
if (currentUserId != null && payload.senderUserId === currentUserId) {
|
if (currentUserId != null && payload.senderUserId === currentUserId) {
|
||||||
return queue;
|
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];
|
return [...queue, payload];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,28 +71,35 @@ export function applyEnqueue(
|
|||||||
* suppression rules in {@link applyEnqueue} so the two stay in sync.
|
* suppression rules in {@link applyEnqueue} so the two stay in sync.
|
||||||
*/
|
*/
|
||||||
export function shouldEnqueueNote(
|
export function shouldEnqueueNote(
|
||||||
queue: IncomingNotePayload[],
|
queue: PopupPayload[],
|
||||||
payload: IncomingNotePayload,
|
payload: PopupPayload,
|
||||||
currentUserId: number | null,
|
currentUserId: number | null,
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!payload || typeof payload.noteId !== "number") return false;
|
if (!payload || typeof payload.noteId !== "number") return false;
|
||||||
if (currentUserId != null && payload.senderUserId === currentUserId) {
|
if (currentUserId != null && payload.senderUserId === currentUserId) {
|
||||||
return false;
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Pop the head of the queue if `noteId` matches it; otherwise filter it
|
* Pop the head of the queue if its identity matches; otherwise filter
|
||||||
* out from anywhere it appears. Pure for testability.
|
* 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(
|
export function applyDismiss(
|
||||||
queue: IncomingNotePayload[],
|
queue: PopupPayload[],
|
||||||
noteId: number,
|
target: number | { replyId: number },
|
||||||
): IncomingNotePayload[] {
|
): PopupPayload[] {
|
||||||
if (queue.length === 0) return queue;
|
if (queue.length === 0) return queue;
|
||||||
if (queue[0].noteId === noteId) return queue.slice(1);
|
const key =
|
||||||
const next = queue.filter((p) => p.noteId !== noteId);
|
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;
|
return next.length === queue.length ? queue : next;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1095,13 +1095,18 @@
|
|||||||
"popup": {
|
"popup": {
|
||||||
"heading": "ملاحظة جديدة من {{name}}",
|
"heading": "ملاحظة جديدة من {{name}}",
|
||||||
"headingNoSender": "وصلتك ملاحظة جديدة",
|
"headingNoSender": "وصلتك ملاحظة جديدة",
|
||||||
|
"replyHeading": "{{name}} ردّ على ملاحظتك",
|
||||||
|
"replyHeadingNoSender": "وصلك رد جديد على ملاحظتك",
|
||||||
"untitled": "(بدون عنوان)",
|
"untitled": "(بدون عنوان)",
|
||||||
"queueMore_one": "ملاحظة أخرى بالانتظار",
|
"queueMore_one": "ملاحظة أخرى بالانتظار",
|
||||||
"queueMore_other": "{{count}} ملاحظات أخرى بالانتظار",
|
"queueMore_other": "{{count}} ملاحظات أخرى بالانتظار",
|
||||||
"reply": "رد",
|
"reply": "رد",
|
||||||
|
"replyBack": "رد عليه",
|
||||||
"markRead": "تحديد كمقروءة",
|
"markRead": "تحديد كمقروءة",
|
||||||
"openInNotes": "فتح في الملاحظات",
|
"openInNotes": "فتح في الملاحظات",
|
||||||
"dismiss": "إغلاق"
|
"openThread": "فتح المحادثة",
|
||||||
|
"dismiss": "إغلاق",
|
||||||
|
"dragHint": "اسحب للتحريك"
|
||||||
},
|
},
|
||||||
"confirmSendTitle": "إرسال هذه الملاحظة؟",
|
"confirmSendTitle": "إرسال هذه الملاحظة؟",
|
||||||
"confirmSendBody_one": "سيتم إرسال هذه الملاحظة إلى شخص واحد.",
|
"confirmSendBody_one": "سيتم إرسال هذه الملاحظة إلى شخص واحد.",
|
||||||
|
|||||||
@@ -1001,13 +1001,18 @@
|
|||||||
"popup": {
|
"popup": {
|
||||||
"heading": "New note from {{name}}",
|
"heading": "New note from {{name}}",
|
||||||
"headingNoSender": "You received a new note",
|
"headingNoSender": "You received a new note",
|
||||||
|
"replyHeading": "{{name}} replied to your note",
|
||||||
|
"replyHeadingNoSender": "New reply to your note",
|
||||||
"untitled": "(no title)",
|
"untitled": "(no title)",
|
||||||
"queueMore_one": "{{count}} more note waiting",
|
"queueMore_one": "{{count}} more note waiting",
|
||||||
"queueMore_other": "{{count}} more notes waiting",
|
"queueMore_other": "{{count}} more notes waiting",
|
||||||
"reply": "Reply",
|
"reply": "Reply",
|
||||||
|
"replyBack": "Reply back",
|
||||||
"markRead": "Mark as read",
|
"markRead": "Mark as read",
|
||||||
"openInNotes": "Open in Notes",
|
"openInNotes": "Open in Notes",
|
||||||
"dismiss": "Dismiss"
|
"openThread": "Open thread",
|
||||||
|
"dismiss": "Dismiss",
|
||||||
|
"dragHint": "Drag to move"
|
||||||
},
|
},
|
||||||
"confirmSendTitle": "Send this note?",
|
"confirmSendTitle": "Send this note?",
|
||||||
"confirmSendBody_one": "This note will be sent to {{count}} person.",
|
"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 ({
|
test("recipient sees a popup modal when a note arrives in real time", async ({
|
||||||
browser,
|
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 sender = await createUser("popup_sender", "Popup Sender");
|
||||||
const recipient = await createUser("popup_recipient", "Popup Recipient");
|
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;
|
await sendPromise;
|
||||||
const sendCompletedAt = Date.now();
|
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
|
// Real-time SLA: socket-fanout popup must surface within ~3s of the
|
||||||
// send returning. Anything slower defeats the "can't miss it" goal.
|
// send returning. Anything slower defeats the "can't miss it" goal.
|
||||||
const popup = recipientPage.getByTestId("incoming-note-popup");
|
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(noteTitle);
|
||||||
await expect(popup).toContainText(noteContent);
|
await expect(popup).toContainText(noteContent);
|
||||||
await expect(popup).toContainText(sender.displayEn);
|
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.
|
// Sender does NOT see their own popup.
|
||||||
await expect(senderPage.getByTestId("incoming-note-popup")).toHaveCount(0);
|
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();
|
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