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; 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(() => 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(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) : 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) => { 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) => { 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) => { 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.
{/* Drag handle / header */}
{/* Body */}
{bodyTitle}
{bodyContent && (
{bodyContent}
)}
{queueLength > 1 && (
{t("notes.popup.queueMore", { count: queueLength - 1 })}
)}
{!replyPayload && ( )}
); }