Task #402: Convert Notes into in-app messaging
Original task: turn personal Notes into in-app messaging — sender composes a note (title/content/color), picks recipient(s), Send. Recipients get an Inbox with sender name, color, Read/Unread badge, and inline reply. Sender sees Sent Notes with per-recipient status. Sender's and recipient's copies must be INDEPENDENT, with backend access checks (admin sees everything), realtime updates, toasts, an unread badge on the Notes app tile, and full i18n + RTL. Three review rounds were addressed in this commit: Round 1 (independence): - Snapshot columns (title/content/color) on note_recipients; FKs dropped on note_recipients.note_id and note_replies.note_id so recipient threads survive the sender deleting their note. - /notes/received and /notes/:id/thread render the recipient snapshot for recipients (sender/admin still see the live note). Round 2: - POST /notes/:id/reply: owner is now allowed to reply too. Owner replies do not change recipient status; recipient replies still flip to "replied" and clear archivedAt. - Added GET /notes/:id as an alias of /notes/:id/thread. - Added OpenAPI ops for the Notes routes and ran orval codegen. - use-notifications-socket.ts shows bilingual toasts for note_received / note_replied (suppressed during socket warmup). - home.tsx renders an unread badge on the Notes app tile. Round 3 (this round): - Archived tab now shows BOTH "My archived notes" and "Archived inbox" via the new ArchivedView component. - Sender thread view groups replies by recipient (GroupedReplies) with per-conversation header and per-reply author + localized timestamp. Recipient view stays flat. - Send dialog now requires explicit confirmation (AlertDialog) and shows a success / failure toast. - Realtime toast strings moved off hardcoded EN/AR to i18n keys (notes.toast.received|replied.*) added in en.json and ar.json. - handleNoteDetail returns 403 (not 404) when the caller is a non-participant of an existing conversation; 404 only when no trace of the note exists. - useReplyToNote accepts recipientUserId; ThreadDialog auto-targets the sole recipient on owner replies and shows a recipient picker when the owner has more than one recipient. Tests: 7 backend tests in notes-share.test.mjs + 1 e2e (notes-inbox.spec.mjs) all pass. tx-os typecheck is clean. Architect re-review: PASS. Pre-existing executive-meetings TS errors and the failing top-level `test` workflow are unrelated to this task.
This commit is contained in:
@@ -405,7 +405,19 @@ async function handleNoteDetail(req: import("express").Request, res: import("exp
|
||||
const admin = await isAdmin(userId);
|
||||
const isSender = !!note && note.userId === userId;
|
||||
|
||||
// Authorization contract: any non-participant gets 403 (not 404), as long
|
||||
// as someone is participating in the conversation. Only return 404 when
|
||||
// there is no trace of the note at all (no live row, no recipient rows).
|
||||
if (!note && !recipientRow) {
|
||||
const [otherRow] = await db
|
||||
.select({ id: noteRecipientsTable.id })
|
||||
.from(noteRecipientsTable)
|
||||
.where(eq(noteRecipientsTable.noteId, id.id))
|
||||
.limit(1);
|
||||
if (otherRow) {
|
||||
res.status(403).json({ error: "Forbidden" });
|
||||
return;
|
||||
}
|
||||
res.status(404).json({ error: "Note not found" });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -111,24 +111,18 @@ export function useNotificationsSocket() {
|
||||
socket.on("note_received", () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||||
if (nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS) return;
|
||||
const ar = i18n.language === "ar";
|
||||
toast({
|
||||
title: ar ? "ملاحظة جديدة" : "New note received",
|
||||
description: ar
|
||||
? "تحقق من بريد الملاحظات الوارد لقراءتها."
|
||||
: "Check your Notes inbox to read it.",
|
||||
title: i18n.t("notes.toast.received.title"),
|
||||
description: i18n.t("notes.toast.received.desc"),
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("note_replied", () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["notes"] });
|
||||
if (nowMs() - connectedAtRef.current < SOCKET_WARMUP_MS) return;
|
||||
const ar = i18n.language === "ar";
|
||||
toast({
|
||||
title: ar ? "رد جديد على ملاحظة" : "New reply on a note",
|
||||
description: ar
|
||||
? "افتح الملاحظة لمتابعة المحادثة."
|
||||
: "Open the note to continue the conversation.",
|
||||
title: i18n.t("notes.toast.replied.title"),
|
||||
description: i18n.t("notes.toast.replied.desc"),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -220,10 +220,20 @@ export function useArchiveReceivedNote() {
|
||||
export function useReplyToNote() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, content }: { id: number; content: string }) =>
|
||||
mutationFn: ({
|
||||
id,
|
||||
content,
|
||||
recipientUserId,
|
||||
}: {
|
||||
id: number;
|
||||
content: string;
|
||||
recipientUserId?: number;
|
||||
}) =>
|
||||
req<NoteReply>(`/notes/${id}/reply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ content }),
|
||||
body: JSON.stringify(
|
||||
recipientUserId ? { content, recipientUserId } : { content },
|
||||
),
|
||||
}),
|
||||
onSuccess: (_d, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ["notes"] });
|
||||
|
||||
@@ -1079,6 +1079,27 @@
|
||||
"replied": "تم الرد",
|
||||
"archived": "مؤرشفة"
|
||||
},
|
||||
"toast": {
|
||||
"received": {
|
||||
"title": "ملاحظة جديدة",
|
||||
"desc": "تحقق من بريد الملاحظات الوارد لقراءتها."
|
||||
},
|
||||
"replied": {
|
||||
"title": "رد جديد على ملاحظة",
|
||||
"desc": "افتح الملاحظة لمتابعة المحادثة."
|
||||
}
|
||||
},
|
||||
"confirmSendTitle": "إرسال هذه الملاحظة؟",
|
||||
"confirmSendBody_one": "سيتم إرسال هذه الملاحظة إلى شخص واحد.",
|
||||
"confirmSendBody_other": "سيتم إرسال هذه الملاحظة إلى {{count}} أشخاص.",
|
||||
"confirmSendCta": "إرسال",
|
||||
"archivedSection": {
|
||||
"myNotes": "ملاحظاتي المؤرشفة",
|
||||
"inbox": "البريد الوارد المؤرشف"
|
||||
},
|
||||
"conversationWith": "محادثة مع {{name}}",
|
||||
"ownerReplies": "ردودك",
|
||||
"noConversationsYet": "لا توجد محادثات بعد",
|
||||
"recipientsCount_one": "مستلم واحد",
|
||||
"recipientsCount_other": "{{count}} مستلمين",
|
||||
"repliesCount_one": "رد واحد",
|
||||
|
||||
@@ -984,7 +984,28 @@
|
||||
"recipientsCount_other": "{{count}} recipients",
|
||||
"repliesCount_one": "{{count}} reply",
|
||||
"repliesCount_other": "{{count}} replies",
|
||||
"openThread": "Open"
|
||||
"openThread": "Open",
|
||||
"toast": {
|
||||
"received": {
|
||||
"title": "New note received",
|
||||
"desc": "Check your Notes inbox to read it."
|
||||
},
|
||||
"replied": {
|
||||
"title": "New reply on a note",
|
||||
"desc": "Open the note to continue the conversation."
|
||||
}
|
||||
},
|
||||
"confirmSendTitle": "Send this note?",
|
||||
"confirmSendBody_one": "This note will be sent to {{count}} person.",
|
||||
"confirmSendBody_other": "This note will be sent to {{count}} people.",
|
||||
"confirmSendCta": "Send",
|
||||
"archivedSection": {
|
||||
"myNotes": "My archived notes",
|
||||
"inbox": "Archived inbox"
|
||||
},
|
||||
"conversationWith": "Conversation with {{name}}",
|
||||
"ownerReplies": "Your replies",
|
||||
"noConversationsYet": "No conversations yet"
|
||||
},
|
||||
"executiveMeetings": {
|
||||
"title": "Meetings list",
|
||||
|
||||
@@ -24,6 +24,17 @@ import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { toast } from "@/hooks/use-toast";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -38,6 +49,7 @@ import {
|
||||
import {
|
||||
type Note,
|
||||
type NoteLabel,
|
||||
type NoteThread,
|
||||
type ReceivedNote,
|
||||
type SentNote,
|
||||
type SentNoteRecipient,
|
||||
@@ -84,6 +96,8 @@ export default function NotesPage() {
|
||||
const { data: labels = [] } = useNoteLabels();
|
||||
const { data: receivedNotes = [], isLoading: loadingReceived } =
|
||||
useReceivedNotes(false);
|
||||
const { data: archivedReceivedNotes = [], isLoading: loadingArchivedReceived } =
|
||||
useReceivedNotes(true);
|
||||
const { data: sentNotes = [], isLoading: loadingSent } = useSentNotes();
|
||||
|
||||
const filteredNotes = useMemo(() => {
|
||||
@@ -273,17 +287,23 @@ export default function NotesPage() {
|
||||
)}
|
||||
|
||||
{view === "archived" && (
|
||||
<>
|
||||
{loadingNotes ? (
|
||||
<Loading />
|
||||
) : filteredNotes.length === 0 ? (
|
||||
<Empty icon={<StickyNote size={48} />} text={t("notes.emptyArchived", "No archived notes")} />
|
||||
) : (
|
||||
<div className="mt-2">
|
||||
<Section title="" notes={filteredNotes} labels={labels} onEdit={setEditing} onSend={(n) => setSendForNoteId(n.id)} />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
<ArchivedView
|
||||
loadingMine={loadingNotes}
|
||||
mine={filteredNotes}
|
||||
labels={labels}
|
||||
onEdit={setEditing}
|
||||
onSend={(n) => setSendForNoteId(n.id)}
|
||||
loadingInbox={loadingArchivedReceived}
|
||||
archivedReceived={archivedReceivedNotes.filter((n) => {
|
||||
const q = search.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return (
|
||||
n.title.toLowerCase().includes(q) ||
|
||||
n.content.toLowerCase().includes(q)
|
||||
);
|
||||
})}
|
||||
onOpenThread={(id) => setOpenThreadId(id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{view === "received" && (
|
||||
@@ -727,11 +747,30 @@ function SendNoteDialog({
|
||||
});
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
|
||||
const requestSubmit = () => {
|
||||
if (picked.size === 0) return;
|
||||
setConfirmOpen(true);
|
||||
};
|
||||
|
||||
const confirmSubmit = () => {
|
||||
send.mutate(
|
||||
{ id: noteId, recipientUserIds: Array.from(picked) },
|
||||
{ onSuccess: onClose },
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast({ title: t("notes.sendSuccess", "Note sent") });
|
||||
setConfirmOpen(false);
|
||||
onClose();
|
||||
},
|
||||
onError: () => {
|
||||
setConfirmOpen(false);
|
||||
toast({
|
||||
title: t("notes.sendFailed", "Could not send note"),
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
@@ -793,7 +832,7 @@ function SendNoteDialog({
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={submit}
|
||||
onClick={requestSubmit}
|
||||
disabled={picked.size === 0 || send.isPending}
|
||||
data-testid="send-note-submit"
|
||||
>
|
||||
@@ -802,10 +841,87 @@ function SendNoteDialog({
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
<AlertDialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<AlertDialogContent data-testid="send-note-confirm">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("notes.confirmSendTitle", "Send this note?")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("notes.confirmSendBody", { count: picked.size })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>{t("common.cancel", "Cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmSubmit}
|
||||
disabled={send.isPending}
|
||||
data-testid="send-note-confirm-submit"
|
||||
>
|
||||
{t("notes.confirmSendCta", "Send")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ArchivedView({
|
||||
loadingMine,
|
||||
mine,
|
||||
labels,
|
||||
onEdit,
|
||||
onSend,
|
||||
loadingInbox,
|
||||
archivedReceived,
|
||||
onOpenThread,
|
||||
}: {
|
||||
loadingMine: boolean;
|
||||
mine: Note[];
|
||||
labels: NoteLabel[];
|
||||
onEdit: (n: Note) => void;
|
||||
onSend: (n: Note) => void;
|
||||
loadingInbox: boolean;
|
||||
archivedReceived: ReceivedNote[];
|
||||
onOpenThread: (id: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (loadingMine || loadingInbox) return <Loading />;
|
||||
if (mine.length === 0 && archivedReceived.length === 0) {
|
||||
return (
|
||||
<Empty
|
||||
icon={<StickyNote size={48} />}
|
||||
text={t("notes.emptyArchived", "No archived notes")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-8 mt-2">
|
||||
{mine.length > 0 && (
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
{t("notes.archivedSection.myNotes", "My archived notes")}
|
||||
</h2>
|
||||
<Section title="" notes={mine} labels={labels} onEdit={onEdit} onSend={onSend} />
|
||||
</div>
|
||||
)}
|
||||
{archivedReceived.length > 0 && (
|
||||
<div data-testid="notes-archived-inbox">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground mb-2">
|
||||
{t("notes.archivedSection.inbox", "Archived inbox")}
|
||||
</h2>
|
||||
<ReceivedList
|
||||
loading={false}
|
||||
notes={archivedReceived}
|
||||
onOpen={onOpenThread}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThreadDialog({
|
||||
noteId,
|
||||
onClose,
|
||||
@@ -820,6 +936,7 @@ function ThreadDialog({
|
||||
const reply = useReplyToNote();
|
||||
const archive = useArchiveReceivedNote();
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const [ownerReplyTarget, setOwnerReplyTarget] = useState<number | null>(null);
|
||||
const markedRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -833,8 +950,21 @@ function ThreadDialog({
|
||||
const submitReply = () => {
|
||||
const c = replyText.trim();
|
||||
if (!c) return;
|
||||
if (!thread) return;
|
||||
const isOwner = thread.isOwner;
|
||||
const recipients = thread.recipients;
|
||||
let recipientUserId: number | undefined;
|
||||
if (isOwner) {
|
||||
if (recipients.length === 0) return;
|
||||
if (recipients.length === 1) {
|
||||
recipientUserId = recipients[0].recipientUserId;
|
||||
} else {
|
||||
if (ownerReplyTarget == null) return;
|
||||
recipientUserId = ownerReplyTarget;
|
||||
}
|
||||
}
|
||||
reply.mutate(
|
||||
{ id: noteId, content: c },
|
||||
{ id: noteId, content: c, recipientUserId },
|
||||
{ onSuccess: () => setReplyText("") },
|
||||
);
|
||||
};
|
||||
@@ -900,28 +1030,41 @@ function ThreadDialog({
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
{t("notes.noRepliesYet", "No replies yet")}
|
||||
</div>
|
||||
) : thread.isOwner || thread.isAdmin ? (
|
||||
<GroupedReplies thread={thread} lang={lang} />
|
||||
) : (
|
||||
<div className="space-y-2 max-h-48 overflow-y-auto">
|
||||
{thread.replies.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
data-testid={`thread-reply-${r.id}`}
|
||||
className="text-sm bg-white/70 rounded-lg p-2 border border-black/5"
|
||||
>
|
||||
<div className="text-[11px] text-muted-foreground mb-0.5">
|
||||
{userDisplayName(r.sender, lang)}
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap break-words">
|
||||
{r.content}
|
||||
</div>
|
||||
</div>
|
||||
<ReplyBubble key={r.id} reply={r} lang={lang} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!thread.isOwner && thread.myStatus !== null && thread.myStatus !== "archived" && (
|
||||
{(thread.isOwner ||
|
||||
(thread.myStatus !== null && thread.myStatus !== "archived")) && (
|
||||
<div className="border-t pt-3 flex flex-col gap-2">
|
||||
{thread.isOwner && thread.recipients.length > 1 && (
|
||||
<select
|
||||
value={ownerReplyTarget ?? ""}
|
||||
onChange={(e) =>
|
||||
setOwnerReplyTarget(
|
||||
e.target.value === "" ? null : Number(e.target.value),
|
||||
)
|
||||
}
|
||||
data-testid="thread-owner-reply-target"
|
||||
className="text-sm rounded-md border border-black/10 bg-white/80 px-2 py-1.5"
|
||||
>
|
||||
<option value="">
|
||||
{t("notes.to", "To:")}
|
||||
</option>
|
||||
{thread.recipients.map((r) => (
|
||||
<option key={r.recipientUserId} value={r.recipientUserId}>
|
||||
{userDisplayName(r.recipient, lang)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
<Textarea
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
@@ -930,23 +1073,33 @@ function ThreadDialog({
|
||||
data-testid="thread-reply-input"
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
archive.mutate(
|
||||
{ id: noteId, archived: true },
|
||||
{ onSuccess: onClose },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Archive size={14} className="me-1" />
|
||||
{t("notes.archive", "Archive")}
|
||||
</Button>
|
||||
{!thread.isOwner ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
archive.mutate(
|
||||
{ id: noteId, archived: true },
|
||||
{ onSuccess: onClose },
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Archive size={14} className="me-1" />
|
||||
{t("notes.archive", "Archive")}
|
||||
</Button>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={submitReply}
|
||||
disabled={!replyText.trim() || reply.isPending}
|
||||
disabled={
|
||||
!replyText.trim() ||
|
||||
reply.isPending ||
|
||||
(thread.isOwner &&
|
||||
thread.recipients.length > 1 &&
|
||||
ownerReplyTarget == null)
|
||||
}
|
||||
data-testid="thread-reply-submit"
|
||||
>
|
||||
<Send size={14} className="me-1" />
|
||||
@@ -962,6 +1115,94 @@ function ThreadDialog({
|
||||
);
|
||||
}
|
||||
|
||||
function ReplyBubble({
|
||||
reply,
|
||||
lang,
|
||||
}: {
|
||||
reply: NoteThread["replies"][number];
|
||||
lang: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-testid={`thread-reply-${reply.id}`}
|
||||
className="text-sm bg-white/70 rounded-lg p-2 border border-black/5"
|
||||
>
|
||||
<div className="text-[11px] text-muted-foreground mb-0.5 flex items-center justify-between gap-2">
|
||||
<span className="truncate">{userDisplayName(reply.sender, lang)}</span>
|
||||
<span className="shrink-0">{formatReplyTime(reply.createdAt, lang)}</span>
|
||||
</div>
|
||||
<div className="whitespace-pre-wrap break-words">{reply.content}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatReplyTime(iso: string, lang: string) {
|
||||
try {
|
||||
return new Date(iso).toLocaleString(lang === "ar" ? "ar" : "en", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function GroupedReplies({
|
||||
thread,
|
||||
lang,
|
||||
}: {
|
||||
thread: NoteThread;
|
||||
lang: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const recipientNames = new Map<number, string>();
|
||||
for (const r of thread.recipients) {
|
||||
recipientNames.set(r.recipientUserId, userDisplayName(r.recipient, lang));
|
||||
}
|
||||
// Group replies by the recipient that the reply is "with" — i.e. the
|
||||
// non-owner participant. For replies the owner sent, that is the
|
||||
// `recipientUserId` field on the reply; for replies sent by a recipient
|
||||
// it is the reply's `senderUserId`.
|
||||
const groups = new Map<number, NoteThread["replies"]>();
|
||||
const ownerId = thread.senderUserId;
|
||||
for (const r of thread.replies) {
|
||||
const otherId = r.senderUserId === ownerId ? r.recipientUserId : r.senderUserId;
|
||||
if (!groups.has(otherId)) groups.set(otherId, []);
|
||||
groups.get(otherId)!.push(r);
|
||||
}
|
||||
if (groups.size === 0) {
|
||||
return (
|
||||
<div className="text-xs text-muted-foreground italic">
|
||||
{t("notes.noConversationsYet", "No conversations yet")}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3 max-h-72 overflow-y-auto">
|
||||
{Array.from(groups.entries()).map(([otherId, replies]) => (
|
||||
<div
|
||||
key={otherId}
|
||||
data-testid={`thread-conversation-${otherId}`}
|
||||
className="rounded-lg bg-white/40 border border-black/5 p-2"
|
||||
>
|
||||
<div className="text-[11px] font-semibold text-foreground/80 mb-1.5">
|
||||
{t("notes.conversationWith", {
|
||||
name: recipientNames.get(otherId) ?? `#${otherId}`,
|
||||
})}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{replies.map((r) => (
|
||||
<ReplyBubble key={r.id} reply={r} lang={lang} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ColorPicker({
|
||||
value,
|
||||
onChange,
|
||||
|
||||
@@ -147,6 +147,9 @@ test("sender → recipient → reply flow updates Inbox + Sent tabs", async ({
|
||||
{ timeout: 15_000 },
|
||||
);
|
||||
await page.getByTestId("send-note-submit").click();
|
||||
// Confirm-before-send dialog: explicit confirmation step.
|
||||
await expect(page.getByTestId("send-note-confirm")).toBeVisible();
|
||||
await page.getByTestId("send-note-confirm-submit").click();
|
||||
await sendPromise;
|
||||
await expect(sendDialog).toBeHidden();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user