From 992f98418cd285de48223af6f9154ac485038e13 Mon Sep 17 00:00:00 2001 From: riyadhafraa <49757212-riyadhafraa@users.noreply.replit.com> Date: Tue, 12 May 2026 10:25:41 +0000 Subject: [PATCH] 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. Replit-Commit-Author: Agent Replit-Commit-Session-Id: 77cfe984-2c65-4152-bb7a-0df28274fe66 Replit-Commit-Checkpoint-Type: full_checkpoint Replit-Commit-Event-Id: 7c13c05c-e8c2-461e-a5dc-94bbc8e114ac Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/c3c252e4-c83d-40ca-9fff-99a3ea60701e/77cfe984-2c65-4152-bb7a-0df28274fe66/O07Jndv Replit-Helium-Checkpoint-Created: true --- artifacts/tx-os/src/lib/notes-api.ts | 39 ++++ artifacts/tx-os/src/locales/ar.json | 9 + artifacts/tx-os/src/locales/en.json | 9 + artifacts/tx-os/src/pages/notes.tsx | 331 ++++++++++++++++++++++++--- 4 files changed, 351 insertions(+), 37 deletions(-) diff --git a/artifacts/tx-os/src/lib/notes-api.ts b/artifacts/tx-os/src/lib/notes-api.ts index 999122c7..dc0425dd 100644 --- a/artifacts/tx-os/src/lib/notes-api.ts +++ b/artifacts/tx-os/src/lib/notes-api.ts @@ -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({ diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 064762f1..3dea7087 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -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": "لا توجد ملاحظات في الأرشيف", diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index b08bc9c2..a11d4b13 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -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", diff --git a/artifacts/tx-os/src/pages/notes.tsx b/artifacts/tx-os/src/pages/notes.tsx index 357c74d0..54d9c205 100644 --- a/artifacts/tx-os/src/pages/notes.tsx +++ b/artifacts/tx-os/src/pages/notes.tsx @@ -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>(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(); + 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 ? ( + + ) : ( + + ) + )} {view === "sent" && filteredSent.length > 0 && ( sentSelectionMode ? ( +
+ + +
+ + )} + setOpenThreadId(id)} + selectionMode={inboxSelectionMode} + selectedIds={selectedInboxIds} + onToggle={toggleInboxSelected} + /> + )} {view === "sent" && ( @@ -985,6 +1118,94 @@ export default function NotesPage() { + {/* 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. */} + { + if (!bulkArchiveInbox.isPending) setBulkArchiveOpen(open); + }} + > + + + + {t( + selectedInboxIds.size === 1 + ? "notes.bulkArchiveConfirmTitle_one" + : "notes.bulkArchiveConfirmTitle", + { n: selectedInboxIds.size }, + )} + + + {t( + "notes.bulkArchiveConfirmBody", + "These notes will be moved to your archive. You can find them later in the Archived tab.", + )} + + + + + {t("notes.bulkCancel", "Cancel")} + + { + 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")} + + + + + 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; + 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) => ( - - ))} + {!selectionMode && } + + {n.kind === "checklist" ? ( + + ) : ( + n.content && ( +
+ {n.content} +
+ ) + )} + + ); + })} ); }