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:
riyadhafraa
2026-05-05 21:32:37 +00:00
parent 71cfa14bf6
commit 4258aa092c
9 changed files with 722 additions and 148 deletions
+8
View File
@@ -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"] });
+58 -15
View File
@@ -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;
}
+6 -1
View File
@@ -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": "سيتم إرسال هذه الملاحظة إلى شخص واحد.",
+6 -1
View File
@@ -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();
}
});