Task #515: Reply to a note from the popup without leaving the page

The floating "new note" popup's Reply button used to navigate to
/notes?thread=X&reply=1, yanking the user out of whatever page they
were on (Tasks, Calendar, etc.). Now Reply opens an inline composer
inside the popup card itself.

Changes (artifacts/tx-os/src/components/notes/incoming-note-popup.tsx):
- New composerOpen / replyText / justSent state, reset whenever the
  popup head rotates so a half-typed reply never leaks across payloads.
- handleReply now opens the inline composer and focuses the textarea
  (rAF) instead of routing. Acknowledge (mark-as-read) is deferred to
  successful send so Cancel keeps the note unread.
- handleSubmitReply uses the existing useReplyToNote hook. Resolves
  recipientUserId from replyPayload.replier.id for the reply variant
  (owner replying back), undefined for the note variant (recipient
  replying — server infers the original sender).
- On success: brief on-card "Reply sent" confirmation (aria-live),
  then auto-dismiss after 700ms. On failure: destructive toast with
  the textarea preserved.
- Composer textarea: Cmd/Ctrl+Enter to send, plain Enter for newline,
  Esc to cancel (stops propagation so the global Esc listener doesn't
  dismiss the whole popup with an unsent reply). dir="auto" so the
  user's reply renders RTL/LTR per its own characters.
- pointerdown/click stopPropagation on the composer wrapper so typing
  doesn't accidentally drag the card.

Locale keys added to ar.json + en.json under notes.popup:
inlineReplyPlaceholder, sendReply, cancelReply, replySending,
replySent, replyFailed.

Pre-existing failing tests (groups-crud, executive-meetings-
notifications, executive-meetings-postpone-race) are unrelated to
this change.
This commit is contained in:
riyadhafraa
2026-05-12 11:57:08 +00:00
parent 75b3eaf64a
commit a55fb16c4c
3 changed files with 210 additions and 40 deletions
@@ -5,11 +5,13 @@ import {
useRef,
useState,
type PointerEvent as ReactPointerEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from "react";
import { useTranslation } from "react-i18next";
import { useLocation } from "wouter";
import { Reply, Check, ExternalLink, X, GripVertical } from "lucide-react";
import { Reply, Check, ExternalLink, X, GripVertical, Send } from "lucide-react";
import { Button } from "@/components/ui/button";
import { toast } from "@/hooks/use-toast";
import {
colorBg,
colorAccent,
@@ -17,6 +19,7 @@ import {
colorAvatarRing,
colorPing,
useMarkNoteRead,
useReplyToNote,
useToggleChecklistItem,
userDisplayName,
type ChecklistItem,
@@ -237,9 +240,18 @@ export function IncomingNotePopup() {
const [, setLocation] = useLocation();
const { current, queueLength, dismiss } = useIncomingNotePopup();
const markRead = useMarkNoteRead();
const replyMutation = useReplyToNote();
const { user } = useAuth();
const userId = user?.id ?? null;
// Inline reply composer state. When `composerOpen` is true the action
// row is replaced with a textarea + Send/Cancel so the user can reply
// without navigating away from the current page (Task #515).
const [composerOpen, setComposerOpen] = useState(false);
const [replyText, setReplyText] = useState("");
const [justSent, setJustSent] = useState(false);
const replyTextareaRef = useRef<HTMLTextAreaElement | null>(null);
const [position, setPosition] = useState<StoredPos>(() =>
clampToViewport(loadStoredPos(userId) ?? defaultAnchor(isRtl)),
);
@@ -299,18 +311,37 @@ export function IncomingNotePopup() {
}
}, [current, dismiss]);
// ESC dismisses the current entry (parity with the old modal).
// ESC dismisses the current entry (parity with the old modal). When
// the inline reply composer is open we let the textarea's local
// keydown handler (which calls stopPropagation + closes the
// composer) run instead, so Esc cancels the composer rather than
// throwing away the popup with a half-typed reply.
useEffect(() => {
if (!current) return;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") {
if (composerOpen) return;
e.preventDefault();
handleDismissCurrent();
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [current, handleDismissCurrent]);
}, [current, composerOpen, handleDismissCurrent]);
// Reset the inline composer whenever the popup head changes (a new
// note/reply rotates in, or the queue empties) so a half-typed reply
// never leaks across payloads.
const currentKey = current
? current.kind === "reply"
? `reply:${current.replyId}`
: `note:${current.noteId}`
: null;
useEffect(() => {
setComposerOpen(false);
setReplyText("");
setJustSent(false);
}, [currentKey]);
const isReply = current?.kind === "reply";
const replyPayload = isReply
@@ -369,11 +400,73 @@ export function IncomingNotePopup() {
acknowledge();
handleDismiss();
};
// Open the inline reply composer in place of the action row. We
// intentionally do NOT acknowledge (mark-as-read) yet — that happens
// on successful send, so a user who opens the composer then cancels
// doesn't silently flip the note to "read".
const handleReply = () => {
acknowledge();
const noteId = current.noteId;
handleDismiss();
setLocation(`/notes?thread=${noteId}&reply=1`);
setComposerOpen(true);
// Defer focus until the textarea is mounted on the next paint.
requestAnimationFrame(() => {
replyTextareaRef.current?.focus();
});
};
const handleCancelReply = () => {
setComposerOpen(false);
setReplyText("");
};
const handleSubmitReply = () => {
if (!current) return;
const content = replyText.trim();
if (!content) return;
if (replyMutation.isPending) return;
// Resolve the reply target. Note-variant: I'm a recipient, the
// server infers the recipient (the original sender) so we leave
// recipientUserId undefined. Reply-variant: I'm the original
// owner replying back to the specific replier, so we must pass
// the replier's user id (the thread reply route requires it for
// owners with multiple recipients).
const recipientUserId = replyPayload?.replier?.id;
replyMutation.mutate(
{ id: current.noteId, content, recipientUserId },
{
onSuccess: () => {
// Mark-as-read for note variant only (mirrors acknowledge()
// semantics — owners can't mark their own outbound note read).
acknowledge();
setReplyText("");
setJustSent(true);
// Brief on-card "Sent" confirmation before the popup
// dismisses, so the user gets visible feedback without a
// toast taking the user's attention away from the page.
window.setTimeout(() => {
handleDismissCurrent();
}, 700);
},
onError: () => {
toast({
title: t("notes.popup.replyFailed"),
variant: "destructive",
});
},
},
);
};
const onComposerKeyDown = (
e: ReactKeyboardEvent<HTMLTextAreaElement>,
) => {
if (e.key === "Escape") {
e.stopPropagation();
e.preventDefault();
handleCancelReply();
return;
}
// Cmd/Ctrl+Enter sends; plain Enter inserts a newline so the user
// can compose multi-line replies. Matches the thread-dialog UX.
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmitReply();
}
};
const handleOpen = () => {
acknowledge();
@@ -557,39 +650,104 @@ export function IncomingNotePopup() {
)}
</div>
{/* Action row */}
{/* Action row — collapses into the inline reply composer
when the user clicks "Reply" so they can respond without
leaving the current page (Task #515). */}
<div className="px-4 py-3">
<div className="flex flex-wrap gap-2 sm:flex-nowrap justify-end">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleMarkRead}
data-testid="incoming-note-popup-mark-read"
{composerOpen ? (
<div
className="space-y-2"
data-testid="incoming-note-popup-inline-reply"
onPointerDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<Check size={14} className="me-1" />
{t("notes.popup.done")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleOpen}
data-testid="incoming-note-popup-open"
>
<ExternalLink size={14} className="me-1" />
{t("notes.popup.openNote")}
</Button>
<Button
type="button"
size="sm"
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>
<textarea
ref={replyTextareaRef}
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
onKeyDown={onComposerKeyDown}
rows={3}
placeholder={t("notes.popup.inlineReplyPlaceholder")}
disabled={replyMutation.isPending || justSent}
className="w-full resize-y rounded-md border border-black/15 bg-background/80 px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-black/20"
data-testid="incoming-note-popup-inline-reply-input"
dir="auto"
/>
<div className="flex items-center justify-between gap-2">
<div
className="text-xs text-muted-foreground min-h-[1rem]"
aria-live="polite"
data-testid="incoming-note-popup-inline-reply-status"
>
{justSent
? t("notes.popup.replySent")
: replyMutation.isPending
? t("notes.popup.replySending")
: ""}
</div>
<div className="flex gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleCancelReply}
disabled={replyMutation.isPending || justSent}
data-testid="incoming-note-popup-inline-reply-cancel"
>
{t("notes.popup.cancelReply")}
</Button>
<Button
type="button"
size="sm"
onClick={handleSubmitReply}
disabled={
!replyText.trim() ||
replyMutation.isPending ||
justSent
}
data-testid="incoming-note-popup-inline-reply-send"
>
<Send size={14} className="me-1" />
{t("notes.popup.sendReply")}
</Button>
</div>
</div>
</div>
) : (
<div className="flex flex-wrap gap-2 sm:flex-nowrap justify-end">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleMarkRead}
data-testid="incoming-note-popup-mark-read"
>
<Check size={14} className="me-1" />
{t("notes.popup.done")}
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleOpen}
data-testid="incoming-note-popup-open"
>
<ExternalLink size={14} className="me-1" />
{t("notes.popup.openNote")}
</Button>
<Button
type="button"
size="sm"
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>
+7 -1
View File
@@ -1070,7 +1070,13 @@
"openNote": "فتح الملاحظة",
"done": "تم",
"dismiss": "إغلاق",
"dragHint": "اسحب للتحريك"
"dragHint": "اسحب للتحريك",
"inlineReplyPlaceholder": "اكتب رداً…",
"sendReply": "إرسال",
"cancelReply": "إلغاء",
"replySending": "جاري الإرسال…",
"replySent": "تم إرسال الرد",
"replyFailed": "تعذّر إرسال الرد"
},
"confirmSendTitle": "إرسال هذه الملاحظة؟",
"confirmSendBody_one": "سيتم إرسال هذه الملاحظة إلى شخص واحد.",
+7 -1
View File
@@ -983,7 +983,13 @@
"openNote": "Open note",
"done": "Done",
"dismiss": "Dismiss",
"dragHint": "Drag to move"
"dragHint": "Drag to move",
"inlineReplyPlaceholder": "Write a reply…",
"sendReply": "Send",
"cancelReply": "Cancel",
"replySending": "Sending…",
"replySent": "Reply sent",
"replyFailed": "Could not send reply"
},
"confirmSendTitle": "Send this note?",
"confirmSendBody_one": "This note will be sent to {{count}} person.",