@remit/web-client 0.0.43 → 0.0.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.43",
3
+ "version": "0.0.44",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -81,7 +81,7 @@ import {
81
81
  dropDeletedThreads,
82
82
  useDeleteMessages,
83
83
  } from "@/hooks/useDeleteMessages";
84
- import type { EscalationSearchQuery } from "@/hooks/useEscalatedDelete";
84
+ import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
85
85
  import { useIntelligenceData } from "@/hooks/useIntelligenceData";
86
86
  import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
87
87
  import { useLayoutTier } from "@/hooks/useLayoutTier";
@@ -50,3 +50,44 @@ describe("MessageList selection mode", () => {
50
50
  assert.match(source, /disabled: !hasSelection/);
51
51
  });
52
52
  });
53
+
54
+ /**
55
+ * An escalated selection is a predicate, so it has no id list to hand the
56
+ * optimistic move/mark-read mutations — before #114 the bar simply dropped
57
+ * both, leaving "select all 1,284 matching npm" able to delete and nothing
58
+ * else. All three actions now page the predicate through the same run.
59
+ */
60
+ describe("MessageList escalated actions", () => {
61
+ it("routes move and mark-read through the predicate run when escalated", () => {
62
+ assert.match(
63
+ source,
64
+ /escalation\.phase\.kind === "escalated"\)\s*\{\s*void runEscalatedAction\(MARK_READ_ACTION\);/,
65
+ );
66
+ assert.match(
67
+ source,
68
+ /escalation\.phase\.kind === "escalated"\)\s*\{\s*void runEscalatedAction\(\{ kind: "move", destinationMailboxId \}\);/,
69
+ );
70
+ });
71
+
72
+ it("keeps mark-read and the move slot on an escalated selection", () => {
73
+ assert.match(
74
+ source,
75
+ /onMarkRead=\{\s*escalation\.phase\.kind === "counting" \? undefined : handleMarkAsRead/,
76
+ );
77
+ assert.match(
78
+ source,
79
+ /moveSlot=\{\s*escalation\.phase\.kind !== "counting"/,
80
+ );
81
+ });
82
+
83
+ it("words progress and completion per action instead of per delete", () => {
84
+ assert.match(
85
+ source,
86
+ /bulkActionProgressLabel\(\s*escalation\.runningAction\.kind,/,
87
+ );
88
+ assert.match(
89
+ source,
90
+ /bulkActionCompletionText\(action\.kind, outcome\.done\)/,
91
+ );
92
+ });
93
+ });
@@ -13,9 +13,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13
13
  import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
14
14
  import { formatErrorMessage } from "@/components/ui/ErrorState";
15
15
  import {
16
+ type EscalatedAction,
16
17
  type EscalationSearchQuery,
17
- useEscalatedDelete,
18
- } from "@/hooks/useEscalatedDelete";
18
+ useEscalatedActions,
19
+ } from "@/hooks/useEscalatedActions";
19
20
  import { useToggleReadFor } from "@/hooks/useMarkAsRead";
20
21
  import { useIsDesktop } from "@/hooks/useMediaQuery";
21
22
  import {
@@ -25,9 +26,15 @@ import {
25
26
  } from "@/hooks/useSelection";
26
27
  import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
27
28
  import {
28
- BULK_DELETE_CHUNK_SIZE,
29
- resolveSelectionAfterDelete,
30
- } from "@/lib/bulk-delete";
29
+ bulkActionCompletionText,
30
+ bulkActionPartialText,
31
+ bulkActionProgressLabel,
32
+ bulkActionProgressTone,
33
+ } from "@/lib/bulk-action-copy";
34
+ import {
35
+ BULK_ACTION_CHUNK_SIZE,
36
+ resolveSelectionAfterRun,
37
+ } from "@/lib/bulk-actions";
31
38
  import {
32
39
  escalatedStatusLabel,
33
40
  escalationActionLabel,
@@ -88,7 +95,6 @@ interface MessageListProps {
88
95
  */
89
96
  searchPredicate?: EscalationSearchQuery;
90
97
  onDeleteMessages?: (messageIds: string[]) => void;
91
- onMarkAsRead?: (messageIds: string[]) => void;
92
98
  onMoveMessages?: (messageIds: string[], destinationMailboxId: string) => void;
93
99
  isDeleting?: boolean;
94
100
  isMoving?: boolean;
@@ -151,6 +157,11 @@ const COMPACT_ITEM_HEIGHT = 32;
151
157
  const OVERSCAN_COUNT = 5;
152
158
  const DENSITY_STORAGE_KEY = "remit:list-density";
153
159
 
160
+ // Stable action values for the two bulk actions that carry no parameters, so
161
+ // the callbacks running them keep stable dependencies.
162
+ const DELETE_ACTION: EscalatedAction = { kind: "delete" };
163
+ const MARK_READ_ACTION: EscalatedAction = { kind: "markRead" };
164
+
154
165
  const readStoredDensity = (): Density => {
155
166
  try {
156
167
  const stored = localStorage.getItem(DENSITY_STORAGE_KEY);
@@ -187,7 +198,6 @@ export const MessageList = ({
187
198
  searchQuery,
188
199
  searchPredicate,
189
200
  onDeleteMessages,
190
- onMarkAsRead,
191
201
  onMoveMessages,
192
202
  isDeleting = false,
193
203
  isMoving = false,
@@ -275,7 +285,7 @@ export const MessageList = ({
275
285
  // block.
276
286
  const escalationEnabled = !isDesktop && isSearching && !!searchPredicate;
277
287
  const predicateKey = `${mailboxId}|${JSON.stringify(searchPredicate ?? {})}`;
278
- const escalation = useEscalatedDelete({
288
+ const escalation = useEscalatedActions({
279
289
  mailboxId,
280
290
  accountId,
281
291
  enabled: escalationEnabled,
@@ -295,14 +305,18 @@ export const MessageList = ({
295
305
  }, [clearSelection, clearEscalation, escalationPhaseKind]);
296
306
 
297
307
  // Non-null exactly once a bounded/escalated run just ended with some ids
298
- // still not confirmed deleted: the count that DID succeed in that run, so
299
- // the partial-failure notice can say "N moved to Trash" alongside "Retry".
300
- // `selectedIds` (materialized to the failed ids) is the source of truth for
301
- // how many are left; this only supplies the other half of that sentence.
302
- const [lastRunSucceeded, setLastRunSucceeded] = useState<number | null>(null);
308
+ // the action never reached: which action ran and the count that DID succeed,
309
+ // so the partial-failure notice can say "N moved to Trash" alongside
310
+ // "Retry" and Retry can resend the same action. `selectedIds` (materialized
311
+ // to the failed ids) is the source of truth for how many are left; this only
312
+ // supplies the other half of that sentence.
313
+ const [lastRun, setLastRun] = useState<{
314
+ action: EscalatedAction;
315
+ succeeded: number;
316
+ } | null>(null);
303
317
  // Transient, manually-dismissed success banner shown in place of the
304
- // selection bar once a chunked/escalated delete finishes cleanly — see
305
- // `processDeleteOutcome`. Honest about IMAP's async catch-up rather than
318
+ // selection bar once a chunked/escalated run finishes cleanly — see
319
+ // `processRunOutcome`. Honest about IMAP's async catch-up rather than
306
320
  // claiming a finality the bulk endpoint's response doesn't have.
307
321
  const [completionBanner, setCompletionBanner] = useState<string | null>(null);
308
322
 
@@ -342,24 +356,27 @@ export const MessageList = ({
342
356
  // completion banner instead of silently claiming a finality the bulk
343
357
  // endpoint's response doesn't have (IMAP applies the move asynchronously).
344
358
  // Anything left over becomes the new bounded selection — precisely what
345
- // Retry resends, per `resolveSelectionAfterDelete`.
346
- const processDeleteOutcome = useCallback(
347
- (outcome: Parameters<typeof resolveSelectionAfterDelete>[0]) => {
348
- const { exit, retryIds } = resolveSelectionAfterDelete(outcome);
359
+ // Retry resends, per `resolveSelectionAfterRun`.
360
+ const processRunOutcome = useCallback(
361
+ (
362
+ action: EscalatedAction,
363
+ outcome: Parameters<typeof resolveSelectionAfterRun>[0],
364
+ ) => {
365
+ const { exit, retryIds } = resolveSelectionAfterRun(outcome);
349
366
  if (exit) {
350
367
  setCompletionBanner(
351
- `${formatNumber(outcome.done)} moved to Trash. Your mail server is still catching up.`,
368
+ bulkActionCompletionText(action.kind, outcome.done),
352
369
  );
353
- setLastRunSucceeded(null);
370
+ setLastRun(null);
354
371
  exitSelection();
355
372
  return;
356
373
  }
357
374
  if (retryIds.length > 0) {
358
- setLastRunSucceeded(outcome.done);
375
+ setLastRun({ action, succeeded: outcome.done });
359
376
  selectAll(retryIds);
360
377
  return;
361
378
  }
362
- setLastRunSucceeded(null);
379
+ setLastRun(null);
363
380
  },
364
381
  [exitSelection, selectAll],
365
382
  );
@@ -568,14 +585,25 @@ export const MessageList = ({
568
585
  // user's attention is on the progress bar, not the roving cursor, and
569
586
  // rows update via cache invalidation once the run ends rather than an
570
587
  // optimistic per-row removal.
588
+ // Runs one bulk action and hands its outcome to the single choke point that
589
+ // decides what selection looks like afterwards. `ids` omitted means the
590
+ // escalated predicate (#114) — delete, move and mark-read all take this
591
+ // path, so an escalated selection is never delete-only.
592
+ const runEscalatedAction = useCallback(
593
+ async (action: EscalatedAction, ids?: string[]) => {
594
+ const outcome = await escalation.runAction(action, ids);
595
+ processRunOutcome(action, outcome);
596
+ },
597
+ [escalation, processRunOutcome],
598
+ );
599
+
571
600
  const runChunkedConfirmDelete = useCallback(
572
601
  async (ids: string[] | undefined) => {
573
602
  setPendingDelete(null);
574
603
  focusBeforeConfirmRef.current = null;
575
- const outcome = await escalation.runDelete(ids);
576
- processDeleteOutcome(outcome);
604
+ await runEscalatedAction(DELETE_ACTION, ids);
577
605
  },
578
- [escalation, processDeleteOutcome],
606
+ [runEscalatedAction],
579
607
  );
580
608
 
581
609
  // Confirm handler: run the actual bulk delete, then clear selection and
@@ -593,9 +621,9 @@ export const MessageList = ({
593
621
  setPendingDelete(null);
594
622
  return;
595
623
  }
596
- if (ids.length > BULK_DELETE_CHUNK_SIZE) {
624
+ if (ids.length > BULK_ACTION_CHUNK_SIZE) {
597
625
  // Selection stays put (still `ids`) for the duration of the run —
598
- // `processDeleteOutcome` is the one place that clears it, on success,
626
+ // `processRunOutcome` is the one place that clears it, on success,
599
627
  // or replaces it with whatever's left to retry.
600
628
  void runChunkedConfirmDelete(ids);
601
629
  return;
@@ -665,21 +693,38 @@ export const MessageList = ({
665
693
  setFocusedMessageId(restoreTo);
666
694
  }, []);
667
695
 
668
- // Handle mark as read
696
+ // Mark read, bounded and escalated alike (#114). Both go through the same
697
+ // chunked run: the bulk flags call caps at 100 ids, so a selection past
698
+ // that would 400 as a single call, and an escalated selection has no id
699
+ // list at all — it pages its predicate instead.
669
700
  const handleMarkAsRead = useCallback(() => {
670
- if (onMarkAsRead && selectedCount > 0) {
671
- onMarkAsRead(Array.from(selectedIds));
701
+ if (escalation.phase.kind === "escalated") {
702
+ void runEscalatedAction(MARK_READ_ACTION);
703
+ return;
672
704
  }
673
- }, [onMarkAsRead, selectedCount, selectedIds]);
705
+ if (selectedCount === 0) return;
706
+ void runEscalatedAction(MARK_READ_ACTION, Array.from(selectedIds));
707
+ }, [escalation.phase, runEscalatedAction, selectedCount, selectedIds]);
674
708
 
675
- // Handle move
709
+ // Move to a chosen folder, escalated selections included (#114).
676
710
  const handleMoveSelected = useCallback(
677
711
  (destinationMailboxId: string) => {
712
+ if (escalation.phase.kind === "escalated") {
713
+ void runEscalatedAction({ kind: "move", destinationMailboxId });
714
+ return;
715
+ }
678
716
  if (!onMoveMessages || selectedCount === 0) return;
679
717
  onMoveMessages(Array.from(selectedIds), destinationMailboxId);
680
718
  exitSelection();
681
719
  },
682
- [onMoveMessages, selectedCount, selectedIds, exitSelection],
720
+ [
721
+ escalation.phase,
722
+ runEscalatedAction,
723
+ onMoveMessages,
724
+ selectedCount,
725
+ selectedIds,
726
+ exitSelection,
727
+ ],
683
728
  );
684
729
 
685
730
  // Cross-account guard: every selected thread row must belong to the
@@ -748,7 +793,7 @@ export const MessageList = ({
748
793
  // the next page boundary; an escalated-but-idle selection drops back to
749
794
  // bounded on the way out, same as tapping "Clear selection" first.
750
795
  const handleMobileCancel = useCallback(() => {
751
- if (escalation.isDeleting || escalation.phase.kind === "counting") {
796
+ if (escalation.isRunning || escalation.phase.kind === "counting") {
752
797
  escalation.stop();
753
798
  return;
754
799
  }
@@ -761,12 +806,20 @@ export const MessageList = ({
761
806
  escalation.clear();
762
807
  }, [escalation]);
763
808
 
764
- // Partial-failure notice's Retry: resend exactly the ids that didn't
765
- // confirm deleted last time (`selectedIds`, materialized there by
766
- // `processDeleteOutcome`) — never the original selection.
809
+ // Partial-failure notice's Retry: rerun the same action against exactly the
810
+ // ids it never reached last time (`selectedIds`, materialized there by
811
+ // `processRunOutcome`) — never the original selection. A retried delete
812
+ // keeps going through the confirmation path so the dialog's own state is
813
+ // cleared with it.
767
814
  const handleRetryFailed = useCallback(() => {
768
- void runChunkedConfirmDelete(Array.from(selectedIds));
769
- }, [runChunkedConfirmDelete, selectedIds]);
815
+ if (!lastRun) return;
816
+ const ids = Array.from(selectedIds);
817
+ if (lastRun.action.kind === "delete") {
818
+ void runChunkedConfirmDelete(ids);
819
+ return;
820
+ }
821
+ void runEscalatedAction(lastRun.action, ids);
822
+ }, [lastRun, runChunkedConfirmDelete, runEscalatedAction, selectedIds]);
770
823
 
771
824
  // Scroll the roving focus cursor into view as it moves (j/k). Falls back to
772
825
  // the open thread when nothing is focused yet.
@@ -849,7 +902,7 @@ export const MessageList = ({
849
902
  // `selected.intersect(uniqueIds)`, the reference behavior #92's D2 cites.
850
903
  // Wiping the whole selection because one id left (#111) cost the other 49
851
904
  // rows on an ordinary refresh, and could take the post-delete Retry
852
- // selection with it: `processDeleteOutcome` materializes the failed ids as
905
+ // selection with it: `processRunOutcome` materializes the failed ids as
853
906
  // the new selection and resets this effect to live by returning escalation
854
907
  // to idle, so the cache invalidation's refetch ran this same effect against
855
908
  // the retry set — a clear here would have dropped the Retry notice
@@ -861,9 +914,9 @@ export const MessageList = ({
861
914
  // or a delete in progress — that would silently exit selection mode
862
915
  // mid-run.
863
916
  useEffect(() => {
864
- if (escalation.phase.kind !== "idle" || escalation.isDeleting) return;
917
+ if (escalation.phase.kind !== "idle" || escalation.isRunning) return;
865
918
  intersectWith(threads.map((t) => t.messageId));
866
- }, [threads, intersectWith, escalation.phase, escalation.isDeleting]);
919
+ }, [threads, intersectWith, escalation.phase, escalation.isRunning]);
867
920
 
868
921
  // Switching mailboxes exits selection outright, instead of leaving it to the
869
922
  // intersect effect above to empty the set by coincidence — a different
@@ -1001,7 +1054,7 @@ export const MessageList = ({
1001
1054
  selectedCount={selectedCount}
1002
1055
  onDelete={handleDelete}
1003
1056
  onClearSelection={exitSelection}
1004
- onMarkAsRead={onMarkAsRead ? handleMarkAsRead : undefined}
1057
+ onMarkAsRead={handleMarkAsRead}
1005
1058
  onMove={onMoveMessages ? handleMoveSelected : undefined}
1006
1059
  onOrganize={() => setOrganizeOpen(true)}
1007
1060
  isDeleting={isDeleting}
@@ -1029,16 +1082,20 @@ export const MessageList = ({
1029
1082
  !isDeleting &&
1030
1083
  !isMoving &&
1031
1084
  escalation.phase.kind === "idle" &&
1032
- !escalation.isDeleting;
1085
+ !escalation.isRunning;
1033
1086
 
1034
- const mobileIsBusy = isDeleting || isMoving || escalation.isDeleting;
1087
+ const mobileIsBusy = isDeleting || isMoving || escalation.isRunning;
1035
1088
  const mobileCount =
1036
1089
  escalation.phase.kind === "escalated"
1037
1090
  ? escalation.phase.total
1038
1091
  : selectedCount;
1039
1092
 
1040
- const mobileStatusLabel = escalation.isDeleting
1041
- ? `Deleting ${formatNumber(escalation.deleteProgress?.done ?? 0)} of ${formatNumber(escalation.deleteProgress?.total ?? 0)}…`
1093
+ const mobileStatusLabel = escalation.runningAction
1094
+ ? bulkActionProgressLabel(
1095
+ escalation.runningAction.kind,
1096
+ escalation.progress?.done ?? 0,
1097
+ escalation.progress?.total ?? 0,
1098
+ )
1042
1099
  : escalation.phase.kind === "counting"
1043
1100
  ? escalation.phase.countSoFar >= 5000
1044
1101
  ? `Counting… ${formatNumber(escalation.phase.countSoFar)} so far. This is a big result set.`
@@ -1067,7 +1124,7 @@ export const MessageList = ({
1067
1124
  text: "",
1068
1125
  action: { label: "Stop", onClick: escalation.stop },
1069
1126
  }
1070
- : escalation.phase.kind === "escalated" && !escalation.isDeleting
1127
+ : escalation.phase.kind === "escalated" && !escalation.isRunning
1071
1128
  ? {
1072
1129
  tone: "info" as const,
1073
1130
  text: "",
@@ -1085,10 +1142,14 @@ export const MessageList = ({
1085
1142
  onClick: escalation.escalate,
1086
1143
  },
1087
1144
  }
1088
- : lastRunSucceeded !== null && selectedCount > 0
1145
+ : lastRun !== null && selectedCount > 0
1089
1146
  ? {
1090
1147
  tone: "danger" as const,
1091
- text: `${formatNumber(lastRunSucceeded)} moved to Trash. ${formatNumber(selectedCount)} couldn't be deleted.`,
1148
+ text: bulkActionPartialText(
1149
+ lastRun.action.kind,
1150
+ lastRun.succeeded,
1151
+ selectedCount,
1152
+ ),
1092
1153
  action: {
1093
1154
  label: `Retry ${formatNumber(selectedCount)}`,
1094
1155
  onClick: handleRetryFailed,
@@ -1106,32 +1167,30 @@ export const MessageList = ({
1106
1167
  onCancel={handleMobileCancel}
1107
1168
  onDelete={handleMobileDelete}
1108
1169
  onMarkRead={
1109
- onMarkAsRead && escalation.phase.kind === "idle"
1110
- ? handleMarkAsRead
1111
- : undefined
1170
+ escalation.phase.kind === "counting" ? undefined : handleMarkAsRead
1112
1171
  }
1113
1172
  isBusy={mobileIsBusy}
1114
1173
  isCounting={escalation.phase.kind === "counting"}
1115
1174
  statusLabel={mobileStatusLabel}
1116
1175
  selectAll={mobileSelectAll}
1117
1176
  progress={
1118
- escalation.isDeleting && escalation.deleteProgress
1177
+ escalation.runningAction && escalation.progress
1119
1178
  ? {
1120
- value: escalation.deleteProgress.done,
1121
- max: escalation.deleteProgress.total,
1122
- tone: "danger",
1179
+ value: escalation.progress.done,
1180
+ max: escalation.progress.total,
1181
+ tone: bulkActionProgressTone(escalation.runningAction.kind),
1123
1182
  }
1124
1183
  : undefined
1125
1184
  }
1126
1185
  notice={mobileNotice}
1127
1186
  moveSlot={
1128
- escalation.phase.kind === "idle" &&
1129
- !escalation.isDeleting &&
1130
- onMoveMessages &&
1187
+ escalation.phase.kind !== "counting" &&
1188
+ !escalation.isRunning &&
1189
+ (onMoveMessages || escalation.phase.kind === "escalated") &&
1131
1190
  accountId &&
1132
1191
  mailboxId ? (
1133
1192
  <>
1134
- {!moveDisabledHint && (
1193
+ {escalation.phase.kind === "idle" && !moveDisabledHint && (
1135
1194
  <button
1136
1195
  type="button"
1137
1196
  onClick={() => setOrganizeOpen(true)}
@@ -1206,7 +1265,7 @@ export const MessageList = ({
1206
1265
  // #92) — the number in the bar and the rows underneath
1207
1266
  // have to agree something is happening, and a row mid
1208
1267
  // -delete must not be openable.
1209
- escalation.isDeleting && "pointer-events-none opacity-50",
1268
+ escalation.isRunning && "pointer-events-none opacity-50",
1210
1269
  )}
1211
1270
  style={{ transform: `translateY(${virtualRow.start}px)` }}
1212
1271
  >