Task #433: incoming-note popup — render full note + 3-button action row

- Inner body now renders as a real sticky note: keeps colored
  surface, supports checklist (read-only) when noteKind === "checklist".
- Action row trimmed to exactly 3 buttons: Done (تم), Open note
  (فتح الملاحظة), Reply (رد). Removed the standalone Dismiss button;
  header X still closes.
- Reply variant uses the same 3-button row (Done is a dismiss-only
  no-op since the note owner can't mark their own note read).
- Wire kind + items from backend `note_received` payload into client
  IncomingNotePayload (noteKind/items) via use-notifications-socket.
- Locales: markRead → "Done"/"تم"; new openNote/done keys; openThread
  unified to "Open note"/"فتح الملاحظة".
- Tests: replaced removed `incoming-note-popup-dismiss` testid usages
  with header `-close`; updated reply variant assertions to expect the
  3-button row; fixed pre-existing pluralization bug in reply path
  (`/replies` → `/reply`) that was unrelated to this task but blocked
  the reply popup test from validating.

Labels and pin were not surfaced in the popup because they are
sender-private state (note_recipients only snapshots
title/content/color/kind/items); the recipient never receives the
sender's labelIds/isPinned, so showing them would be misleading.

All affected e2e tests pass (notes-popup-on-receive note + reply,
notes-popup-touch-tap both, notes-inbox); queue unit tests pass.
This commit is contained in:
riyadhafraa
2026-05-07 09:23:29 +00:00
parent 03a59572fa
commit ff3c567b3c
7 changed files with 119 additions and 43 deletions
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 25 KiB

@@ -14,6 +14,7 @@ import {
colorBg,
useMarkNoteRead,
userDisplayName,
type ChecklistItem,
type UserSummary,
} from "@/lib/notes-api";
import {
@@ -96,6 +97,46 @@ function clampToViewport(pos: StoredPos): StoredPos {
};
}
/**
* Read-only checklist render that mirrors the visual style of the
* NoteCard's `ChecklistView` (notes.tsx). Inlined here to keep the
* popup self-contained — the popup is purely a preview surface, so
* the checkboxes are not interactive (toggling is reserved for the
* full Notes page after "Open note").
*/
function PopupChecklistPreview({ items }: { items: ChecklistItem[] }) {
if (!items || items.length === 0) return null;
return (
<div
className="space-y-1 mt-2"
data-testid="incoming-note-popup-checklist"
>
{items.map((it) => (
<div
key={it.id}
className="flex items-start gap-2 text-sm"
data-testid={`incoming-note-popup-checklist-row-${it.id}`}
>
<input
type="checkbox"
checked={it.done}
disabled
readOnly
className="mt-0.5 h-4 w-4 rounded border-black/30 accent-amber-500 disabled:cursor-default"
/>
<span
className={`flex-1 break-words ${
it.done ? "line-through text-muted-foreground" : ""
}`}
>
{it.text || "\u00A0"}
</span>
</div>
))}
</div>
);
}
export function IncomingNotePopup() {
const { t, i18n } = useTranslation();
const isRtl = i18n.language === "ar";
@@ -181,6 +222,10 @@ export function IncomingNotePopup() {
const replyPayload = isReply
? (current as Extract<PopupPayload, { kind: "reply" }>)
: null;
const notePayload =
current && current.kind !== "reply"
? (current as Extract<PopupPayload, { kind?: "note" }>)
: null;
const senderForHeader: UserSummary | null = useMemo(() => {
if (!current) return null;
@@ -288,6 +333,16 @@ export function IncomingNotePopup() {
const bodyContent = replyPayload
? replyPayload.replyContent
: current.content;
// Render the inner sticky-note as faithfully as the NoteCard does:
// text body for "text" notes, read-only checklist for "checklist".
// Reply-variant popups always render the reply text as plain content.
const isChecklistNote =
!!notePayload &&
notePayload.noteKind === "checklist" &&
Array.isArray(notePayload.items);
const checklistItems = isChecklistNote
? (notePayload!.items as ChecklistItem[])
: [];
return (
// The popup is "floating", not modal: render the card directly as
@@ -368,22 +423,31 @@ export function IncomingNotePopup() {
</button>
</div>
{/* Body */}
{/* Body — render the inner note as a real sticky note. The
outer floating frame (header + actions) keeps its chrome;
this inner block matches the NoteCard surface so users see
the same artefact they'd see on the Notes page. */}
<div className="p-4 space-y-3">
<div
className={`rounded-xl border border-black/5 shadow-sm p-3 ${colorBg(
className={`rounded-xl border border-black/5 shadow-sm p-3 max-h-[40vh] overflow-y-auto ${colorBg(
current.color,
)}`}
data-testid="incoming-note-popup-body"
>
<div className="font-semibold text-sm text-foreground break-words">
{bodyTitle}
</div>
{bodyContent && (
<div className="text-sm text-foreground/80 mt-2 whitespace-pre-wrap break-words max-h-[40vh] overflow-y-auto">
{bodyContent}
{bodyTitle && (
<div className="font-semibold text-sm text-foreground break-words">
{bodyTitle}
</div>
)}
{isChecklistNote ? (
<PopupChecklistPreview items={checklistItems} />
) : (
bodyContent && (
<div className="text-sm text-foreground/80 mt-2 whitespace-pre-wrap break-words">
{bodyContent}
</div>
)
)}
</div>
{queueLength > 1 && (
@@ -395,16 +459,19 @@ export function IncomingNotePopup() {
</div>
)}
{/* Action row — exactly three buttons: Done, Open note,
Reply. The standalone Dismiss button was removed; the
X in the header still closes. */}
<div className="flex flex-wrap gap-2 sm:flex-nowrap justify-end">
<Button
type="button"
variant="ghost"
size="sm"
onClick={handleDismiss}
data-testid="incoming-note-popup-dismiss"
onClick={handleMarkRead}
data-testid="incoming-note-popup-mark-read"
>
<X size={14} className="me-1" />
{t("notes.popup.dismiss")}
<Check size={14} className="me-1" />
{t("notes.popup.done")}
</Button>
<Button
type="button"
@@ -414,22 +481,8 @@ export function IncomingNotePopup() {
data-testid="incoming-note-popup-open"
>
<ExternalLink size={14} className="me-1" />
{replyPayload
? t("notes.popup.openThread")
: t("notes.popup.openInNotes")}
{t("notes.popup.openNote")}
</Button>
{!replyPayload && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleMarkRead}
data-testid="incoming-note-popup-mark-read"
>
<Check size={14} className="me-1" />
{t("notes.popup.markRead")}
</Button>
)}
<Button
type="button"
size="sm"
@@ -153,6 +153,13 @@ export function useNotificationsSocket() {
typeof payload.noteId === "number" &&
typeof payload.senderUserId === "number"
) {
const rawKind = (payload as { kind?: unknown }).kind;
const noteKind: "text" | "checklist" | null =
rawKind === "checklist" || rawKind === "text" ? rawKind : null;
const rawItems = (payload as { items?: unknown }).items;
const items = Array.isArray(rawItems)
? (rawItems as IncomingNotePayload["items"])
: null;
const full: IncomingNotePayload = {
noteId: payload.noteId,
recipientRowId: payload.recipientRowId,
@@ -162,6 +169,8 @@ export function useNotificationsSocket() {
sentAt: payload.sentAt,
senderUserId: payload.senderUserId,
sender: payload.sender ?? null,
noteKind,
items,
};
enqueued = enqueueNotePopupRef.current(
full,
@@ -1,4 +1,4 @@
import type { UserSummary } from "@/lib/notes-api";
import type { ChecklistItem, UserSummary } from "@/lib/notes-api";
/**
* Common fields shared by every floating-card popup entry, regardless
@@ -21,6 +21,14 @@ interface BasePayload {
export interface IncomingNotePayload extends BasePayload {
kind?: "note";
/**
* Mirrors the underlying note's `kind` ("text" | "checklist") so the
* popup body can render the same surface as the NoteCard. Named
* `noteKind` to avoid clashing with the popup-variant discriminator
* (`kind: "note" | "reply"`) above.
*/
noteKind?: "text" | "checklist" | null;
items?: ChecklistItem[] | null;
}
export interface IncomingReplyPayload extends BasePayload {
+6 -4
View File
@@ -1136,10 +1136,12 @@
"queueMore_one": "ملاحظة أخرى بالانتظار",
"queueMore_other": "{{count}} ملاحظات أخرى بالانتظار",
"reply": "رد",
"replyBack": "رد عليه",
"markRead": حديد كمقروءة",
"openInNotes": "فتح في الملاحظات",
"openThread": "فتح المحادثة",
"replyBack": "رد",
"markRead": م",
"openInNotes": "فتح الملاحظة",
"openThread": "فتح الملاحظة",
"openNote": "فتح الملاحظة",
"done": "تم",
"dismiss": "إغلاق",
"dragHint": "اسحب للتحريك"
},
+6 -4
View File
@@ -1042,10 +1042,12 @@
"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",
"openThread": "Open thread",
"replyBack": "Reply",
"markRead": "Done",
"openInNotes": "Open note",
"openThread": "Open note",
"openNote": "Open note",
"done": "Done",
"dismiss": "Dismiss",
"dragHint": "Drag to move"
},
@@ -214,7 +214,7 @@ test("recipient sees a popup modal when a note arrives in real time", async ({
// Dismissing acknowledges (closes) the popup.
await recipientPage
.getByTestId("incoming-note-popup-dismiss")
.getByTestId("incoming-note-popup-close")
.click();
await expect(popup).toBeHidden();
@@ -314,7 +314,7 @@ test("sender sees a floating reply card when the recipient replies", async ({
const replyPromise = recipientPage.waitForResponse(
(r) =>
new URL(r.url()).pathname ===
`/api/notes/${createdNote.id}/replies` &&
`/api/notes/${createdNote.id}/reply` &&
r.request().method() === "POST" &&
r.status() >= 200 &&
r.status() < 300,
@@ -336,11 +336,13 @@ test("sender sees a floating reply card when the recipient replies", async ({
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.
// Reply card now uses the same three-button action row as the
// note variant — Done, Open note, Reply — so all three are
// present. The header X is also a dismiss path. Done on the reply
// variant only dismisses (markRead is a no-op for the note owner).
await expect(
senderPage.getByTestId("incoming-note-popup-mark-read"),
).toHaveCount(0);
).toBeVisible();
await expect(
senderPage.getByTestId("incoming-note-popup-open"),
).toBeVisible();
@@ -367,8 +369,8 @@ test("sender sees a floating reply card when the recipient replies", async ({
recipientPage.getByTestId("incoming-note-popup"),
).toHaveCount(0);
// Dismiss closes the floating card.
await senderPage.getByTestId("incoming-note-popup-dismiss").click();
// Header X closes the floating card.
await senderPage.getByTestId("incoming-note-popup-close").click();
await expect(popup).toBeHidden();
} finally {
await senderCtx.close();