4258aa092c
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.
450 lines
15 KiB
TypeScript
450 lines
15 KiB
TypeScript
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, GripVertical } from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
colorBg,
|
|
useMarkNoteRead,
|
|
userDisplayName,
|
|
type UserSummary,
|
|
} from "@/lib/notes-api";
|
|
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 acknowledge = () => {
|
|
// 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 = () => 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;
|
|
handleDismiss();
|
|
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 (
|
|
// 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"
|
|
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",
|
|
}}
|
|
>
|
|
<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"
|
|
/>
|
|
{senderForHeader?.avatarUrl ? (
|
|
<img
|
|
src={senderForHeader.avatarUrl}
|
|
alt=""
|
|
className="relative h-8 w-8 rounded-full object-cover 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(senderForHeader, i18n.language)}
|
|
</div>
|
|
)}
|
|
</span>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-semibold truncate">{heading}</div>
|
|
</div>
|
|
<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>
|
|
|
|
{/* 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>
|
|
);
|
|
}
|