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 56ada03c..617a3660 100644 Binary files a/artifacts/tx-os/public/opengraph.jpg and b/artifacts/tx-os/public/opengraph.jpg differ 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(); + } +});