Add ability to archive multiple incoming notes at once

Introduces a bulk archive feature for the received notes tab, including API integration, UI selection state, confirmation dialogs, and internationalization support.
This commit is contained in:
Riyadh
2026-05-12 10:25:41 +00:00
parent f84523902b
commit a51b49a427
4 changed files with 351 additions and 37 deletions
+39
View File
@@ -432,6 +432,45 @@ export function useArchiveReceivedNote() {
});
}
// Task #510: bulk archive for the Notes Inbox. Mirrors
// `useBulkDeleteSentNotes` — fans `BULK_DELETE_CONCURRENCY` workers over
// POST /notes/:id/archive so the request count is bounded even on large
// selections, and reports per-id success/failure so the caller can show
// a partial-failure toast. The recipient does not own the underlying
// note, so we archive (their recipient row) instead of deleting.
export function useBulkArchiveReceivedNotes() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (ids: number[]) => {
const ok: number[] = [];
const failed: number[] = [];
let cursor = 0;
const worker = async () => {
while (true) {
const i = cursor++;
if (i >= ids.length) return;
const id = ids[i];
try {
await req<{ success: true }>(`/notes/${id}/archive`, {
method: "POST",
body: JSON.stringify({ archived: true }),
});
ok.push(id);
} catch {
failed.push(id);
}
}
};
const workerCount = Math.min(BULK_DELETE_CONCURRENCY, ids.length);
await Promise.all(
Array.from({ length: workerCount }, () => worker()),
);
return { ok, failed };
},
onSettled: () => invalidateNotesAndFolders(qc),
});
}
export function useReplyToNote() {
const qc = useQueryClient();
return useMutation({
+9
View File
@@ -1117,6 +1117,15 @@
"bulkResultAll_one": "تم حذف ملاحظة واحدة",
"bulkResultPartial": "تم حذف {{ok}} من {{total}}، فشل {{failed}}",
"bulkResultNone": "تعذّر حذف الملاحظات المحددة",
"bulkArchive": "أرشفة",
"bulkArchiving": "جارٍ الأرشفة...",
"bulkArchiveConfirmTitle": "أرشفة {{n}} ملاحظة من الوارد؟",
"bulkArchiveConfirmTitle_one": "أرشفة ملاحظة واحدة من الوارد؟",
"bulkArchiveConfirmBody": "سيتم نقل هذه الملاحظات إلى الأرشيف. يمكنك إيجادها لاحقاً في تبويب المؤرشفة.",
"bulkArchivedAll": "تمت أرشفة {{n}} ملاحظة",
"bulkArchivedAll_one": "تمت أرشفة ملاحظة واحدة",
"bulkArchivePartial": "تمت أرشفة {{ok}} من {{total}}، فشل {{failed}}",
"bulkArchiveNone": "تعذّر أرشفة الملاحظات المحددة",
"moreActions": "خيارات أخرى",
"empty": "ستظهر ملاحظاتك هنا",
"emptyArchived": "لا توجد ملاحظات في الأرشيف",
+9
View File
@@ -1014,6 +1014,15 @@
"bulkResultAll_one": "Deleted 1 note",
"bulkResultPartial": "Deleted {{ok}} of {{total}}; {{failed}} failed",
"bulkResultNone": "Could not delete the selected notes",
"bulkArchive": "Archive",
"bulkArchiving": "Archiving...",
"bulkArchiveConfirmTitle": "Archive {{n}} inbox notes?",
"bulkArchiveConfirmTitle_one": "Archive 1 inbox note?",
"bulkArchiveConfirmBody": "These notes will be moved to your archive. You can find them later in the Archived tab.",
"bulkArchivedAll": "Archived {{n}} notes",
"bulkArchivedAll_one": "Archived 1 note",
"bulkArchivePartial": "Archived {{ok}} of {{total}}; {{failed}} failed",
"bulkArchiveNone": "Could not archive the selected notes",
"moreActions": "More actions",
"empty": "Your notes will appear here",
"emptyArchived": "No archived notes",
+294 -37
View File
@@ -80,6 +80,7 @@ import {
colorBg,
colorAccent,
useArchiveReceivedNote,
useBulkArchiveReceivedNotes,
useCreateLabel,
useCreateNote,
useDeleteLabel,
@@ -221,6 +222,34 @@ export default function NotesPage() {
exitSentSelection();
}
}, [view, sentSelectionMode, exitSentSelection]);
// Task #510: bulk-select + bulk-archive for the Inbox tab. Identical
// shape to the Sent tab's selection state above, but the destructive
// action is "archive" (the recipient doesn't own the underlying note,
// so we hide it from their inbox via POST /notes/:id/archive instead
// of DELETE).
const [inboxSelectionMode, setInboxSelectionMode] = useState(false);
const [selectedInboxIds, setSelectedInboxIds] = useState<Set<number>>(new Set());
const [bulkArchiveOpen, setBulkArchiveOpen] = useState(false);
const bulkArchiveInbox = useBulkArchiveReceivedNotes();
const toggleInboxSelected = useCallback((id: number) => {
setSelectedInboxIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}, []);
const exitInboxSelection = useCallback(() => {
setInboxSelectionMode(false);
setSelectedInboxIds(new Set());
setBulkArchiveOpen(false);
}, []);
useEffect(() => {
if (view !== "received" && inboxSelectionMode) {
exitInboxSelection();
}
}, [view, inboxSelectionMode, exitInboxSelection]);
// Allow deep-links like /notes?thread=42&reply=1 (from the incoming-note
// popup) to jump into the inbox tab + thread dialog. Reactive on the
// current query string so this still works when the user is already on
@@ -445,6 +474,27 @@ export default function NotesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visibleSentIdsKey, sentSelectionMode, exitSentSelection]);
// Same prune-to-visible guard for the Inbox bulk selection.
const visibleInboxIdsKey = filteredReceived.map((n) => n.id).join(",");
useEffect(() => {
if (!inboxSelectionMode) return;
if (filteredReceived.length === 0) {
exitInboxSelection();
return;
}
const visible = new Set(filteredReceived.map((n) => n.id));
setSelectedInboxIds((prev) => {
let changed = false;
const next = new Set<number>();
prev.forEach((id) => {
if (visible.has(id)) next.add(id);
else changed = true;
});
return changed ? next : prev;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [visibleInboxIdsKey, inboxSelectionMode, exitInboxSelection]);
const unreadInboxCount = receivedNotes.filter((n) => n.status === "unread").length;
const pinned = filteredNotes.filter((n) => n.isPinned);
const others = filteredNotes.filter((n) => !n.isPinned);
@@ -575,6 +625,27 @@ export default function NotesPage() {
mode swaps the cards' click behavior from "open thread"
to "toggle checkbox" and exposes the bulk action bar
below the header. */}
{view === "received" && filteredReceived.length > 0 && (
inboxSelectionMode ? (
<button
type="button"
onClick={exitInboxSelection}
data-testid="inbox-bulk-cancel"
className="text-sm px-3 py-1.5 rounded-lg border border-slate-300/60 bg-white/60 text-muted-foreground hover:text-foreground hover:bg-white transition-colors"
>
{t("notes.bulkCancel", "Cancel")}
</button>
) : (
<button
type="button"
onClick={() => setInboxSelectionMode(true)}
data-testid="inbox-bulk-select-toggle"
className="text-sm px-3 py-1.5 rounded-lg border border-slate-300/60 bg-white/60 text-muted-foreground hover:text-foreground hover:bg-white transition-colors"
>
{t("notes.bulkSelect", "Select")}
</button>
)
)}
{view === "sent" && filteredSent.length > 0 && (
sentSelectionMode ? (
<button
@@ -803,11 +874,73 @@ export default function NotesPage() {
)}
{view === "received" && (
<ReceivedList
loading={loadingReceived}
notes={filteredReceived}
onOpen={(id) => setOpenThreadId(id)}
/>
<>
{inboxSelectionMode && (
<div
className="sticky top-0 z-10 -mx-2 px-3 py-2 mb-2 flex items-center gap-2 rounded-lg border border-slate-300/60 bg-white/95 backdrop-blur shadow-sm"
data-testid="inbox-bulk-bar"
>
<span
className="text-sm font-medium text-foreground"
data-testid="inbox-bulk-count"
>
{t("notes.bulkCount", "{{n}} selected", {
n: selectedInboxIds.size,
})}
</span>
<button
type="button"
onClick={() => {
if (selectedInboxIds.size === filteredReceived.length) {
setSelectedInboxIds(new Set());
} else {
setSelectedInboxIds(
new Set(filteredReceived.map((n) => n.id)),
);
}
}}
data-testid="inbox-bulk-select-all"
className="text-sm px-2.5 py-1 rounded-md text-muted-foreground hover:text-foreground hover:bg-slate-100"
>
{selectedInboxIds.size === filteredReceived.length &&
filteredReceived.length > 0
? t("notes.bulkClear", "Clear selection")
: t("notes.bulkSelectAll", "Select all")}
</button>
<div className="ms-auto flex items-center gap-2">
<button
type="button"
onClick={exitInboxSelection}
disabled={bulkArchiveInbox.isPending}
data-testid="inbox-bulk-bar-cancel"
className="text-sm px-3 py-1.5 rounded-lg border border-slate-300/60 bg-white text-muted-foreground hover:text-foreground transition-colors disabled:opacity-50"
>
{t("notes.bulkCancel", "Cancel")}
</button>
<button
type="button"
onClick={() => setBulkArchiveOpen(true)}
disabled={
selectedInboxIds.size === 0 || bulkArchiveInbox.isPending
}
data-testid="inbox-bulk-archive"
className="inline-flex items-center gap-1.5 text-sm px-3 py-1.5 rounded-lg bg-slate-900 text-white hover:bg-slate-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Archive size={14} />
{t("notes.bulkArchive", "Archive")}
</button>
</div>
</div>
)}
<ReceivedList
loading={loadingReceived}
notes={filteredReceived}
onOpen={(id) => setOpenThreadId(id)}
selectionMode={inboxSelectionMode}
selectedIds={selectedInboxIds}
onToggle={toggleInboxSelected}
/>
</>
)}
{view === "sent" && (
@@ -985,6 +1118,94 @@ export default function NotesPage() {
</AlertDialogContent>
</AlertDialog>
{/* Task #510: confirmation for bulk-archiving selected inbox notes.
Mirrors the Sent bulk-delete dialog: parallel POST /notes/:id/archive
via worker pool, partial-failure aware toasts. */}
<AlertDialog
open={bulkArchiveOpen}
onOpenChange={(open) => {
if (!bulkArchiveInbox.isPending) setBulkArchiveOpen(open);
}}
>
<AlertDialogContent data-testid="inbox-bulk-confirm">
<AlertDialogHeader>
<AlertDialogTitle>
{t(
selectedInboxIds.size === 1
? "notes.bulkArchiveConfirmTitle_one"
: "notes.bulkArchiveConfirmTitle",
{ n: selectedInboxIds.size },
)}
</AlertDialogTitle>
<AlertDialogDescription>
{t(
"notes.bulkArchiveConfirmBody",
"These notes will be moved to your archive. You can find them later in the Archived tab.",
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={bulkArchiveInbox.isPending}>
{t("notes.bulkCancel", "Cancel")}
</AlertDialogCancel>
<AlertDialogAction
data-testid="inbox-bulk-confirm-archive"
disabled={bulkArchiveInbox.isPending}
className="bg-slate-900 hover:bg-slate-800 text-white"
onClick={async (e) => {
e.preventDefault();
const ids = Array.from(selectedInboxIds);
if (ids.length === 0) return;
try {
const res = await bulkArchiveInbox.mutateAsync(ids);
if (res.failed.length === 0) {
toast({
title: t(
res.ok.length === 1
? "notes.bulkArchivedAll_one"
: "notes.bulkArchivedAll",
{ n: res.ok.length },
),
});
} else if (res.ok.length === 0) {
toast({
title: t(
"notes.bulkArchiveNone",
"Could not archive the selected notes",
),
variant: "destructive",
});
} else {
toast({
title: t("notes.bulkArchivePartial", {
ok: res.ok.length,
total: ids.length,
failed: res.failed.length,
}),
variant: "destructive",
});
}
} catch {
toast({
title: t(
"notes.bulkArchiveNone",
"Could not archive the selected notes",
),
variant: "destructive",
});
} finally {
exitInboxSelection();
}
}}
>
{bulkArchiveInbox.isPending
? t("notes.bulkArchiving", "Archiving...")
: t("notes.bulkArchive", "Archive")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<LabelsDialog
open={labelsDialogOpen}
onClose={() => setLabelsDialogOpen(false)}
@@ -1756,10 +1977,16 @@ function ReceivedList({
loading,
notes,
onOpen,
selectionMode = false,
selectedIds,
onToggle,
}: {
loading: boolean;
notes: ReceivedNote[];
onOpen: (id: number) => void;
selectionMode?: boolean;
selectedIds?: Set<number>;
onToggle?: (id: number) => void;
}) {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -1778,40 +2005,70 @@ function ReceivedList({
style={{ columnWidth: 260 }}
data-testid="notes-received-list"
>
{notes.map((n) => (
<button
key={n.id}
onClick={() => onOpen(n.id)}
data-testid={`received-note-card-${n.id}`}
className={`text-start break-inside-avoid mb-3 w-full rounded-xl border border-black/5 shadow-sm p-3 cursor-pointer transition hover:shadow-md ${colorBg(
n.color,
)}`}
>
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="text-[11px] text-muted-foreground flex items-center gap-1 min-w-0">
<Mail size={12} className="shrink-0" />
<span className="truncate">
{t("notes.from", "From:")} {userDisplayName(n.sender, lang)}
{notes.map((n) => {
const checked = selectedIds?.has(n.id) ?? false;
const handleActivate = () => {
if (selectionMode) onToggle?.(n.id);
else onOpen(n.id);
};
// Use a div (not <button>) so the Checkbox inside selection mode
// isn't a nested interactive element.
return (
<div
key={n.id}
role="button"
tabIndex={0}
onClick={handleActivate}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleActivate();
}
}}
aria-pressed={selectionMode ? checked : undefined}
data-testid={`received-note-card-${n.id}`}
className={`relative text-start break-inside-avoid mb-3 w-full rounded-xl border border-black/5 shadow-sm p-3 cursor-pointer transition hover:shadow-md focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 ${colorBg(
n.color,
)} ${checked ? "ring-2 ring-sky-500" : ""}`}
>
{selectionMode && (
<span
className="absolute top-2 end-2 z-[1] flex items-center justify-center rounded-md bg-white/80 backdrop-blur p-0.5 shadow-sm pointer-events-none"
>
<Checkbox
checked={checked}
tabIndex={-1}
data-testid={`received-note-checkbox-${n.id}`}
aria-hidden="true"
/>
</span>
</div>
<StatusBadge status={n.status} />
</div>
{n.kind === "checklist" ? (
<ChecklistView
items={n.items ?? []}
testIdPrefix={`received-note-checklist-${n.id}`}
className="mt-1"
noteColor={n.color}
/>
) : (
n.content && (
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[8]">
{n.content}
)}
<div className="flex items-center justify-between gap-2 mb-1.5">
<div className="text-[11px] text-muted-foreground flex items-center gap-1 min-w-0">
<Mail size={12} className="shrink-0" />
<span className="truncate">
{t("notes.from", "From:")} {userDisplayName(n.sender, lang)}
</span>
</div>
)
)}
</button>
))}
{!selectionMode && <StatusBadge status={n.status} />}
</div>
{n.kind === "checklist" ? (
<ChecklistView
items={n.items ?? []}
testIdPrefix={`received-note-checklist-${n.id}`}
className="mt-1"
noteColor={n.color}
/>
) : (
n.content && (
<div className="text-sm text-foreground/80 mt-1 whitespace-pre-wrap break-words line-clamp-[8]">
{n.content}
</div>
)
)}
</div>
);
})}
</div>
);
}