Files
TX/artifacts/tx-os/src/components/notes/incoming-note-popup.tsx
T
riyadhafraa 4258aa092c 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.
2026-05-05 21:32:37 +00:00

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>
);
}