@remit/web-client 0.0.187 → 0.0.189

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.
@@ -1,5 +1,5 @@
1
1
  import { useAppShellLayout } from "@remit/ui";
2
- import { useCallback } from "react";
2
+ import { type RefObject, useCallback, useEffect } from "react";
3
3
  import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
4
4
  import { useMailContext } from "@/lib/mail-context";
5
5
 
@@ -10,15 +10,11 @@ import { useMailContext } from "@/lib/mail-context";
10
10
  * rail's own gate as the answer for both tiers and offered a disabled control
11
11
  * with nothing behind it between 1024 and 1280 (#817).
12
12
  */
13
- export interface IntelligenceSurface {
13
+ export interface IntelligenceSurface extends IntelligenceCommands {
14
14
  /** There is a thread to say anything about, so the toolbar's control acts. */
15
15
  canToggle: boolean;
16
16
  /** What the toolbar reports: the rail above 1280, the drawer below it. */
17
17
  isShowing: boolean;
18
- /** The toolbar's control, over whichever surface this width has. */
19
- toggle: () => void;
20
- /** The banner's "Why?" — an open, never a close. */
21
- open: () => void;
22
18
  drawerOpen: boolean;
23
19
  closeDrawer: () => void;
24
20
  /** Whether this width has room for the rail, so the rail is the surface. */
@@ -27,6 +23,19 @@ export interface IntelligenceSurface {
27
23
  openRail: () => void;
28
24
  }
29
25
 
26
+ /**
27
+ * What a mounted intelligence surface answers for. The width gate is the
28
+ * shell's own measurement, so only a component inside `AppShellSlotted` knows
29
+ * which surface this tier has; a pane provider wrapping the shell reaches it
30
+ * through a ref the mounted surface publishes into.
31
+ */
32
+ export interface IntelligenceCommands {
33
+ /** The toolbar's control, over whichever surface this width has. */
34
+ toggle: () => void;
35
+ /** The banner's "Why?" — an open, never a close. */
36
+ open: () => void;
37
+ }
38
+
30
39
  export const useIntelligenceSurface = (
31
40
  openThreadId: string | undefined,
32
41
  ): IntelligenceSurface => {
@@ -50,15 +59,40 @@ export const useIntelligenceSurface = (
50
59
  if (!railFits || intelligenceOpen) return;
51
60
  onToggleIntelligence();
52
61
  }, [railFits, intelligenceOpen, onToggleIntelligence]);
62
+ const open = useCallback(() => {
63
+ if (!railFits) {
64
+ openDrawer();
65
+ return;
66
+ }
67
+ openRail();
68
+ }, [railFits, openDrawer, openRail]);
53
69
 
54
70
  return {
55
71
  canToggle: threadId !== null,
56
72
  isShowing: threadId !== null && (railFits ? intelligenceOpen : drawerOpen),
57
73
  toggle,
58
- open: railFits ? onToggleIntelligence : openDrawer,
74
+ open,
59
75
  drawerOpen,
60
76
  closeDrawer: drawer.close,
61
77
  railFits,
62
78
  openRail,
63
79
  };
64
80
  };
81
+
82
+ /**
83
+ * Publish the mounted surface for the pane provider's keyboard handlers. Null
84
+ * while nothing is mounted to serve them, so a shortcut fired with no surface
85
+ * reaches nothing rather than writing `#intelligence` at a width that cannot
86
+ * render it (`docs/architecture/url-state.md`, R6).
87
+ */
88
+ export const usePublishIntelligenceCommands = (
89
+ ref: RefObject<IntelligenceCommands | null>,
90
+ { toggle, open }: IntelligenceCommands,
91
+ ): void => {
92
+ useEffect(() => {
93
+ ref.current = { toggle, open };
94
+ return () => {
95
+ ref.current = null;
96
+ };
97
+ }, [ref, toggle, open]);
98
+ };
@@ -2,7 +2,9 @@ import assert from "node:assert/strict";
2
2
  import { describe, test } from "node:test";
3
3
  import {
4
4
  BULK_ACTION_CHUNK_SIZE,
5
+ type BulkActionTarget,
5
6
  chunkIds,
7
+ chunkTargets,
6
8
  type FetchIdsPageResult,
7
9
  honestProgress,
8
10
  runChunkedAction,
@@ -12,6 +14,13 @@ import {
12
14
  const ids = (count: number, prefix = "m"): string[] =>
13
15
  Array.from({ length: count }, (_, i) => `${prefix}${i}`);
14
16
 
17
+ /** Ids from one account, as a materialized selection hands them over. */
18
+ const targets = (
19
+ count: number,
20
+ accountId: string | undefined = undefined,
21
+ prefix = "m",
22
+ ): BulkActionTarget[] => ids(count, prefix).map((id) => ({ id, accountId }));
23
+
15
24
  describe("chunkIds", () => {
16
25
  test("empty input yields no chunks", () => {
17
26
  assert.deepEqual(chunkIds([]), []);
@@ -45,6 +54,63 @@ describe("chunkIds", () => {
45
54
  });
46
55
  });
47
56
 
57
+ describe("chunkTargets", () => {
58
+ // Regression for #872: the bulk endpoints reject a batch spanning accounts
59
+ // before applying any of it, and the brief and Flagged both span accounts.
60
+ test("never puts two accounts in one chunk", () => {
61
+ assert.deepEqual(
62
+ chunkTargets(
63
+ [
64
+ { id: "a1", accountId: "acct-a" },
65
+ { id: "b1", accountId: "acct-b" },
66
+ { id: "a2", accountId: "acct-a" },
67
+ ],
68
+ 100,
69
+ ),
70
+ [["a1", "a2"], ["b1"]],
71
+ );
72
+ });
73
+
74
+ test("chunks each account by size on its own", () => {
75
+ assert.deepEqual(
76
+ chunkTargets(
77
+ [
78
+ { id: "a1", accountId: "acct-a" },
79
+ { id: "a2", accountId: "acct-a" },
80
+ { id: "a3", accountId: "acct-a" },
81
+ { id: "b1", accountId: "acct-b" },
82
+ ],
83
+ 2,
84
+ ),
85
+ [["a1", "a2"], ["a3"], ["b1"]],
86
+ );
87
+ });
88
+
89
+ test("targets with no account keep their place in the run as a group of their own", () => {
90
+ assert.deepEqual(
91
+ chunkTargets(
92
+ [
93
+ { id: "u1", accountId: undefined },
94
+ { id: "a1", accountId: "acct-a" },
95
+ { id: "u2", accountId: undefined },
96
+ ],
97
+ 100,
98
+ ),
99
+ [["u1", "u2"], ["a1"]],
100
+ );
101
+ });
102
+
103
+ test("a single-account selection is one run of chunks, as before", () => {
104
+ const got = chunkTargets(targets(BULK_ACTION_CHUNK_SIZE + 1, "acct-a"));
105
+ assert.equal(got.length, 2);
106
+ assert.equal(got[0].length, BULK_ACTION_CHUNK_SIZE);
107
+ });
108
+
109
+ test("empty input yields no chunks", () => {
110
+ assert.deepEqual(chunkTargets([]), []);
111
+ });
112
+ });
113
+
48
114
  describe("runChunkedAction", () => {
49
115
  const neverCancelled = () => false;
50
116
  const noopProgress = () => undefined;
@@ -69,7 +135,7 @@ describe("runChunkedAction", () => {
69
135
  });
70
136
 
71
137
  test("sequences one call per 100-id chunk, in order", async () => {
72
- const input = ids(BULK_ACTION_CHUNK_SIZE + 1);
138
+ const input = targets(BULK_ACTION_CHUNK_SIZE + 1);
73
139
  const calls: string[][] = [];
74
140
  const outcome = await runChunkedAction(
75
141
  input,
@@ -88,7 +154,7 @@ describe("runChunkedAction", () => {
88
154
  });
89
155
 
90
156
  test("a returned batch counts every id in it as accepted", async () => {
91
- const input = ids(5);
157
+ const input = targets(5);
92
158
  const outcome = await runChunkedAction(
93
159
  input,
94
160
  async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
@@ -100,7 +166,7 @@ describe("runChunkedAction", () => {
100
166
  });
101
167
 
102
168
  test("cancelling mid-run folds every unreached chunk into failedIds", async () => {
103
- const input = ids(BULK_ACTION_CHUNK_SIZE * 3);
169
+ const input = targets(BULK_ACTION_CHUNK_SIZE * 3);
104
170
  let calls = 0;
105
171
  let cancelled = false;
106
172
  const outcome = await runChunkedAction(
@@ -121,7 +187,7 @@ describe("runChunkedAction", () => {
121
187
  });
122
188
 
123
189
  test("an infra failure mid-run stops the run and reports the error", async () => {
124
- const input = ids(BULK_ACTION_CHUNK_SIZE * 2);
190
+ const input = targets(BULK_ACTION_CHUNK_SIZE * 2);
125
191
  const boom = new Error("network blip");
126
192
  const outcome = await runChunkedAction(
127
193
  input,
@@ -136,8 +202,107 @@ describe("runChunkedAction", () => {
136
202
  assert.equal(outcome.failedIds.length, input.length);
137
203
  });
138
204
 
205
+ test("a selection spanning accounts is sent as one batch per account", async () => {
206
+ const calls: string[][] = [];
207
+ const outcome = await runChunkedAction(
208
+ [
209
+ { id: "a1", accountId: "acct-a" },
210
+ { id: "b1", accountId: "acct-b" },
211
+ { id: "a2", accountId: "acct-a" },
212
+ ],
213
+ async (chunk) => {
214
+ calls.push(chunk);
215
+ return { successCount: chunk.length, failureCount: 0 };
216
+ },
217
+ noopProgress,
218
+ neverCancelled,
219
+ );
220
+ assert.deepEqual(calls, [["a1", "a2"], ["b1"]]);
221
+ assert.equal(outcome.done, 3);
222
+ assert.deepEqual(outcome.failedIds, []);
223
+ });
224
+
225
+ test("progress counts toward the whole selection, not toward each account", async () => {
226
+ const seen: { done: number; total: number }[] = [];
227
+ await runChunkedAction(
228
+ [
229
+ { id: "a1", accountId: "acct-a" },
230
+ { id: "b1", accountId: "acct-b" },
231
+ ],
232
+ async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
233
+ (p) => seen.push(p),
234
+ neverCancelled,
235
+ );
236
+ assert.deepEqual(seen, [
237
+ { done: 1, total: 2 },
238
+ { done: 2, total: 2 },
239
+ ]);
240
+ });
241
+
242
+ test("cancelling at an account boundary hands back the accounts never reached", async () => {
243
+ let cancelled = false;
244
+ const outcome = await runChunkedAction(
245
+ [
246
+ { id: "a1", accountId: "acct-a" },
247
+ { id: "b1", accountId: "acct-b" },
248
+ { id: "c1", accountId: "acct-c" },
249
+ ],
250
+ async (chunk) => {
251
+ cancelled = true;
252
+ return { successCount: chunk.length, failureCount: 0 };
253
+ },
254
+ () => undefined,
255
+ () => cancelled,
256
+ );
257
+ assert.equal(outcome.cancelled, true);
258
+ assert.equal(outcome.done, 1);
259
+ assert.deepEqual(outcome.failedIds, ["b1", "c1"]);
260
+ });
261
+
262
+ test("one account's batch failing leaves the rest unsent and says how far it got", async () => {
263
+ const boom = new Error("500");
264
+ const outcome = await runChunkedAction(
265
+ [
266
+ { id: "a1", accountId: "acct-a" },
267
+ { id: "b1", accountId: "acct-b" },
268
+ ],
269
+ async (chunk) => {
270
+ if (chunk[0] === "b1") throw boom;
271
+ return { successCount: chunk.length, failureCount: 0 };
272
+ },
273
+ () => undefined,
274
+ neverCancelled,
275
+ );
276
+ assert.equal(outcome.error, boom);
277
+ assert.equal(outcome.done, 1);
278
+ assert.deepEqual(outcome.failedIds, ["b1"]);
279
+ });
280
+
281
+ test("the accounts behind a failed one are handed back whole, never half-sent", async () => {
282
+ const calls: string[][] = [];
283
+ const outcome = await runChunkedAction(
284
+ [
285
+ { id: "a1", accountId: "acct-a" },
286
+ { id: "b1", accountId: "acct-b" },
287
+ { id: "c1", accountId: "acct-c" },
288
+ ],
289
+ async (chunk) => {
290
+ calls.push(chunk);
291
+ throw new Error("auth expired");
292
+ },
293
+ () => undefined,
294
+ neverCancelled,
295
+ );
296
+ // The run stops where it threw rather than trying the accounts behind it:
297
+ // what it hands back is exactly what is still untouched, which is what a
298
+ // retry re-sends.
299
+ assert.deepEqual(calls, [["a1"]]);
300
+ assert.equal(outcome.done, 0);
301
+ assert.deepEqual(outcome.failedIds, ["a1", "b1", "c1"]);
302
+ });
303
+
139
304
  test("reports progress after each chunk", async () => {
140
- const input = ids(BULK_ACTION_CHUNK_SIZE + 1);
305
+ const input = targets(BULK_ACTION_CHUNK_SIZE + 1);
141
306
  const progressCalls: { done: number; total: number }[] = [];
142
307
  await runChunkedAction(
143
308
  input,
@@ -51,6 +51,53 @@ export const chunkIds = (
51
51
  return chunks;
52
52
  };
53
53
 
54
+ /** One message a run covers, as the surface that selected it knows it. */
55
+ export interface BulkActionTarget {
56
+ id: string;
57
+ /**
58
+ * Owning IMAP account — the `accountId` of the account API, never the
59
+ * caller's `accountConfigId` (#456). Undefined on a row from a per-mailbox
60
+ * listing, which does not carry one because every row in it shares the same
61
+ * account.
62
+ */
63
+ accountId: string | undefined;
64
+ }
65
+
66
+ /**
67
+ * Split `targets` into chunks of at most `size` that each carry exactly one
68
+ * account (#872).
69
+ *
70
+ * The bulk endpoints reject a batch spanning accounts outright, before applying
71
+ * any of it, and the daily brief and Flagged are both cross-account lists — so
72
+ * a selection ticked across two accounts sent as one batch deleted nothing.
73
+ * Account is a property of the batch, not of the verb: delete and mark-read
74
+ * cover whatever was ticked, and this is where that becomes one call per
75
+ * account.
76
+ *
77
+ * Targets with no account form a group of their own, which keeps the id in the
78
+ * run rather than dropping a message the user asked to be acted on. A surface
79
+ * that carries no account is single-account by construction; one that carries
80
+ * it for some rows and not others sends the unattributed ones together, and if
81
+ * the server refuses that batch the run reports it like any other failure.
82
+ *
83
+ * Accounts and ids keep the order they were selected in.
84
+ */
85
+ export const chunkTargets = (
86
+ targets: readonly BulkActionTarget[],
87
+ size = BULK_ACTION_CHUNK_SIZE,
88
+ ): string[][] => {
89
+ const byAccount = new Map<string | undefined, string[]>();
90
+ for (const target of targets) {
91
+ const held = byAccount.get(target.accountId);
92
+ if (held) {
93
+ held.push(target.id);
94
+ continue;
95
+ }
96
+ byAccount.set(target.accountId, [target.id]);
97
+ }
98
+ return [...byAccount.values()].flatMap((ids) => chunkIds(ids, size));
99
+ };
100
+
54
101
  export interface BatchResult {
55
102
  successCount: number;
56
103
  failureCount: number;
@@ -93,15 +140,20 @@ export interface BulkActionOutcome {
93
140
  * caller always gets back exactly the ids the action never reached, ready to
94
141
  * retry as-is: every action here is idempotent (re-trashing, re-moving or
95
142
  * re-marking a message it already applied to is a no-op).
143
+ *
144
+ * Chunks never mix accounts (see `chunkTargets`), and the run walks them as one
145
+ * sequence: cancellation lands at whichever boundary comes next, whether or not
146
+ * that is an account boundary, and progress counts toward the whole selection
147
+ * rather than restarting per account.
96
148
  */
97
149
  export const runChunkedAction = async (
98
- ids: readonly string[],
150
+ targets: readonly BulkActionTarget[],
99
151
  applyBatch: ApplyBatch,
100
152
  onProgress: (progress: BulkActionProgress) => void,
101
153
  isCancelled: () => boolean,
102
154
  ): Promise<BulkActionOutcome> => {
103
- const chunks = chunkIds(ids);
104
- const total = ids.length;
155
+ const chunks = chunkTargets(targets);
156
+ const total = targets.length;
105
157
  let done = 0;
106
158
  const failedIds: string[] = [];
107
159
 
@@ -137,13 +189,18 @@ export const runChunkedAction = async (
137
189
  * Each chunk is a full mutation of its own, so it keeps that hook's optimistic
138
190
  * patch, rollback and error banner. The run stops at the first rejected chunk
139
191
  * and reports nothing itself: the hook that owns the call has already raised it.
192
+ *
193
+ * Every call site on this path hands over ids from a single account — a
194
+ * focused row, or a selection the surface already scoped — so nothing here
195
+ * carries an account to split by. A caller with a cross-account selection
196
+ * belongs on `runChunkedAction`, whose targets name one (#872).
140
197
  */
141
198
  export const runChunkedMutation = async (
142
199
  ids: readonly string[],
143
200
  send: (chunk: string[]) => Promise<unknown>,
144
201
  ): Promise<void> => {
145
202
  await runChunkedAction(
146
- ids,
203
+ ids.map((id) => ({ id, accountId: undefined })),
147
204
  async (chunk) => {
148
205
  await send(chunk);
149
206
  return { successCount: chunk.length, failureCount: 0 };
@@ -0,0 +1,50 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { ThreadRowData } from "@remit/ui";
4
+ import { wizardSelectionFrom } from "./wizard-selection.js";
5
+
6
+ const row = (over: Partial<ThreadRowData> & { id: string }): ThreadRowData => ({
7
+ fromName: "Alice",
8
+ fromEmail: "alice@example.com",
9
+ subject: "Quarterly report",
10
+ snippet: "",
11
+ timeLabel: "Jan 1",
12
+ ...over,
13
+ });
14
+
15
+ describe("the ticked rows the wizard is handed", () => {
16
+ // Regression for #872: a bulk run batches per account, and a row that
17
+ // arrives without its own account is one the run cannot place.
18
+ it("carries each row's own account, not the first one it saw", () => {
19
+ const got = wizardSelectionFrom(
20
+ [
21
+ row({ id: "m1", accountId: "acc-work" }),
22
+ row({ id: "m2", accountId: "acc-personal" }),
23
+ ],
24
+ new Set(["m1", "m2"]),
25
+ );
26
+ assert.deepEqual(
27
+ got.map((message) => message.accountId),
28
+ ["acc-work", "acc-personal"],
29
+ );
30
+ });
31
+
32
+ it("keeps a row whose list carries no account rather than dropping it", () => {
33
+ const got = wizardSelectionFrom([row({ id: "m1" })], new Set(["m1"]));
34
+ assert.deepEqual(
35
+ got.map((message) => ({ id: message.id, accountId: message.accountId })),
36
+ [{ id: "m1", accountId: undefined }],
37
+ );
38
+ });
39
+
40
+ it("takes only the ticked rows", () => {
41
+ const got = wizardSelectionFrom(
42
+ [row({ id: "m1" }), row({ id: "m2" })],
43
+ new Set(["m2"]),
44
+ );
45
+ assert.deepEqual(
46
+ got.map((message) => message.id),
47
+ ["m2"],
48
+ );
49
+ });
50
+ });
@@ -0,0 +1,37 @@
1
+ import type { ThreadRowData, WizardMessage } from "@remit/ui";
2
+
3
+ /** A ticked row, as the wizard's samples and its clause prefill read it. */
4
+ export interface WizardSelectionMessage extends WizardMessage {
5
+ /** Sender address — the widen's literal fallback and the prefill match on it. */
6
+ email: string;
7
+ /**
8
+ * Owning account, which a bulk run splits its batches by (#872) — the bulk
9
+ * endpoints refuse a batch spanning accounts before applying any of it.
10
+ * Stated by every surface rather than inferred: a per-mailbox list has one
11
+ * account for all its rows and carries none on the row, while the brief and
12
+ * Flagged span accounts and carry each row's own. Never `accountConfigId`,
13
+ * which every account of one user shares (#456).
14
+ */
15
+ accountId: string | undefined;
16
+ }
17
+
18
+ /**
19
+ * The ticked rows of a thread list, as the wizard reads them. The brief and
20
+ * Flagged both list rows from every account and hand their selection over the
21
+ * same way, so they read it from here rather than each keeping a copy that has
22
+ * to learn about a new field twice.
23
+ */
24
+ export const wizardSelectionFrom = (
25
+ rows: readonly ThreadRowData[],
26
+ selectedIds: ReadonlySet<string>,
27
+ ): WizardSelectionMessage[] =>
28
+ rows
29
+ .filter((row) => selectedIds.has(row.id))
30
+ .map((row) => ({
31
+ id: row.id,
32
+ sender: row.fromName,
33
+ email: row.fromEmail,
34
+ subject: row.subject,
35
+ date: row.timeLabel,
36
+ accountId: row.accountId,
37
+ }));
@@ -0,0 +1,42 @@
1
+ /**
2
+ * What every intelligence-surface spec needs to poke one: the message the
3
+ * reading pane reads its headers from, the settle the router and the query
4
+ * client both need, and the drawer as the DOM names it.
5
+ *
6
+ * The thread row itself is each spec's own, because the DKIM mismatch that
7
+ * raises the authenticity banner also raises the rail wherever the rail fits.
8
+ */
9
+
10
+ import type { DomHarness } from "@/test-support/dom";
11
+
12
+ export const THREAD_ID = "thread-1";
13
+ export const MESSAGE_ID = "msg-1";
14
+
15
+ /** What the reading pane reads each message's own headers and body from. */
16
+ export const describedMessage = {
17
+ messageId: MESSAGE_ID,
18
+ envelope: {
19
+ from: [
20
+ {
21
+ addressId: "addr-1",
22
+ name: "Mondial Relay",
23
+ email: "delivery.notice@gmail.example",
24
+ },
25
+ ],
26
+ to: [],
27
+ cc: [],
28
+ bcc: [],
29
+ },
30
+ bodyParts: [],
31
+ };
32
+
33
+ /** Let the router commit, the queries land, and the render that follows run. */
34
+ export const settle = async (mounted: DomHarness): Promise<void> => {
35
+ await mounted.flush();
36
+ await mounted.wait(20);
37
+ await mounted.flush();
38
+ };
39
+
40
+ /** The intelligence drawer, by the role and label it publishes. */
41
+ export const intelligenceDrawer = (mounted: DomHarness): HTMLElement | null =>
42
+ mounted.query('[role="dialog"][aria-label="Message details"]');