From 4258aa092c852870618eca72f2f3ade3f812bb2e Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Tue, 5 May 2026 21:32:37 +0000 Subject: [PATCH] Task #410: floating draggable note popup + reply alert at sender MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- artifacts/api-server/src/routes/notes.ts | 8 + artifacts/tx-os/public/opengraph.jpg | Bin 25978 -> 25986 bytes .../components/notes/incoming-note-popup.tsx | 491 ++++++++++++++---- .../src/contexts/IncomingNotePopupContext.tsx | 33 +- .../src/hooks/use-notifications-socket.ts | 89 +++- .../tx-os/src/lib/incoming-note-queue.ts | 73 ++- artifacts/tx-os/src/locales/ar.json | 7 +- artifacts/tx-os/src/locales/en.json | 7 +- .../tests/notes-popup-on-receive.spec.mjs | 162 +++++- 9 files changed, 722 insertions(+), 148 deletions(-) diff --git a/artifacts/api-server/src/routes/notes.ts b/artifacts/api-server/src/routes/notes.ts index bedda60a..2840dd01 100644 --- a/artifacts/api-server/src/routes/notes.ts +++ b/artifacts/api-server/src/routes/notes.ts @@ -754,10 +754,18 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise => }) .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({ diff --git a/artifacts/tx-os/public/opengraph.jpg b/artifacts/tx-os/public/opengraph.jpg index 56ada03cd682db405fec7ebc1a9db9576d7a8e9c..617a3660f9cd57ab2367a577ffa4376428672966 100644 GIT binary patch literal 25986 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 diff --git a/artifacts/tx-os/src/components/notes/incoming-note-popup.tsx b/artifacts/tx-os/src/components/notes/incoming-note-popup.tsx index 716b1b98..04066c9c 100644 --- a/artifacts/tx-os/src/components/notes/incoming-note-popup.tsx +++ b/artifacts/tx-os/src/components/notes/incoming-note-popup.tsx @@ -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; + 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 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) => { + 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 ( - - +
- - +
+ {/* Drag handle / header */} +
+
); } diff --git a/artifacts/tx-os/src/contexts/IncomingNotePopupContext.tsx b/artifacts/tx-os/src/contexts/IncomingNotePopupContext.tsx index e5b06866..a47de66a 100644 --- a/artifacts/tx-os/src/contexts/IncomingNotePopupContext.tsx +++ b/artifacts/tx-os/src/contexts/IncomingNotePopupContext.tsx @@ -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(null); export function IncomingNotePopupProvider({ children }: { children: ReactNode }) { - const [queue, setQueue] = useState([]); + const [queue, setQueue] = useState([]); 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(queue); + const queueRef = useRef(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([]), []); diff --git a/artifacts/tx-os/src/hooks/use-notifications-socket.ts b/artifacts/tx-os/src/hooks/use-notifications-socket.ts index c51fd601..69e7ec21 100644 --- a/artifacts/tx-os/src/hooks/use-notifications-socket.ts +++ b/artifacts/tx-os/src/hooks/use-notifications-socket.ts @@ -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>(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>(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 & { 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"] }); diff --git a/artifacts/tx-os/src/lib/incoming-note-queue.ts b/artifacts/tx-os/src/lib/incoming-note-queue.ts index b68b71e0..8b29892d 100644 --- a/artifacts/tx-os/src/lib/incoming-note-queue.ts +++ b/artifacts/tx-os/src/lib/incoming-note-queue.ts @@ -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; } diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 7accc6b3..4cdd1a26 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -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": "سيتم إرسال هذه الملاحظة إلى شخص واحد.", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 1f257627..95ca4408 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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.", diff --git a/artifacts/tx-os/tests/notes-popup-on-receive.spec.mjs b/artifacts/tx-os/tests/notes-popup-on-receive.spec.mjs index a50f540c..07867122 100644 --- a/artifacts/tx-os/tests/notes-popup-on-receive.spec.mjs +++ b/artifacts/tx-os/tests/notes-popup-on-receive.spec.mjs @@ -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(); + } +});