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:
@@ -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>
|
||||
|
||||
@@ -1070,7 +1070,13 @@
|
||||
"openNote": "فتح الملاحظة",
|
||||
"done": "تم",
|
||||
"dismiss": "إغلاق",
|
||||
"dragHint": "اسحب للتحريك"
|
||||
"dragHint": "اسحب للتحريك",
|
||||
"inlineReplyPlaceholder": "اكتب رداً…",
|
||||
"sendReply": "إرسال",
|
||||
"cancelReply": "إلغاء",
|
||||
"replySending": "جاري الإرسال…",
|
||||
"replySent": "تم إرسال الرد",
|
||||
"replyFailed": "تعذّر إرسال الرد"
|
||||
},
|
||||
"confirmSendTitle": "إرسال هذه الملاحظة؟",
|
||||
"confirmSendBody_one": "سيتم إرسال هذه الملاحظة إلى شخص واحد.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user