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.
This commit is contained in:
@@ -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<Set<number>>(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<Set<number>>(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<IncomingReplyPayload> & { 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"] });
|
||||
|
||||
Reference in New Issue
Block a user