@remit/web-client 0.0.297 → 0.0.299

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.
Files changed (36) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/CalendarEventPane.stories.tsx +16 -0
  3. package/src/components/calendar/OpenCalendarEvent.tsx +23 -6
  4. package/src/components/calendar/WriteCalendarEvent.tsx +8 -4
  5. package/src/components/calendar/calendar-write-flow.render.test.ts +39 -22
  6. package/src/components/compose/ComposeForm.tsx +116 -0
  7. package/src/components/compose/attachment-only-source.render.test.ts +1 -0
  8. package/src/components/compose/compose-send-stops-autosave.render.test.ts +1 -0
  9. package/src/components/compose/compose-starts-a-second-message.render.test.ts +1 -0
  10. package/src/components/compose/forward-autosave-is-not-fatal.render.test.ts +1 -0
  11. package/src/components/compose/quote-leaves-with-the-send.render.test.ts +1 -0
  12. package/src/components/compose/reopened-draft-writes-nothing.render.test.ts +1 -0
  13. package/src/components/compose/reply-all-on-resumed-draft.render.test.ts +1 -0
  14. package/src/hooks/calendar/calendar-collections.render.test.ts +23 -1
  15. package/src/hooks/calendar/draft.test.ts +179 -30
  16. package/src/hooks/calendar/draft.ts +59 -33
  17. package/src/hooks/calendar/index.ts +5 -1
  18. package/src/hooks/calendar/instance.test.ts +70 -0
  19. package/src/hooks/calendar/instance.ts +28 -15
  20. package/src/hooks/calendar/recurrence-rule.test.ts +43 -1
  21. package/src/hooks/calendar/recurrence-rule.ts +44 -0
  22. package/src/hooks/calendar/resource.test.ts +44 -1
  23. package/src/hooks/calendar/resource.ts +30 -0
  24. package/src/hooks/calendar/selection.ts +7 -4
  25. package/src/hooks/calendar/useDraftClashes.ts +3 -1
  26. package/src/hooks/calendar/window.test.ts +28 -0
  27. package/src/hooks/calendar/window.ts +20 -0
  28. package/src/hooks/useComposeAttachments.ts +103 -0
  29. package/src/hooks/useIntelligenceCalendar.ts +2 -0
  30. package/src/hooks/useIntelligenceData.ts +2 -2
  31. package/src/lib/calendar-format.ts +7 -2
  32. package/src/lib/compose-attachment-controller.test.ts +242 -0
  33. package/src/lib/compose-attachment-controller.ts +415 -0
  34. package/src/routes/settings/-accounts.test.ts +18 -0
  35. package/src/routes/settings/accounts.tsx +8 -1
  36. package/src/test-support/fixtures.ts +1 -0
@@ -0,0 +1,242 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ type AttachmentTransport,
5
+ ComposeAttachmentController,
6
+ type MintedAttachment,
7
+ } from "./compose-attachment-controller";
8
+
9
+ const DRAFT = "draft-1";
10
+
11
+ interface Deferred<T> {
12
+ promise: Promise<T>;
13
+ resolve: (value: T) => void;
14
+ reject: (error: unknown) => void;
15
+ }
16
+
17
+ const deferred = <T>(): Deferred<T> => {
18
+ let resolve: (value: T) => void = () => {};
19
+ let reject: (error: unknown) => void = () => {};
20
+ const promise = new Promise<T>((res, rej) => {
21
+ resolve = res;
22
+ reject = rej;
23
+ });
24
+ return { promise, resolve, reject };
25
+ };
26
+
27
+ const settle = () => new Promise((resolve) => setImmediate(resolve));
28
+
29
+ /**
30
+ * A server that answers each request only when the test says so, and remembers
31
+ * what the draft holds the way `retainOnly` does: a keep list drops every
32
+ * reservation it does not name.
33
+ */
34
+ const createServer = () => {
35
+ const mints = new Map<string, Deferred<MintedAttachment>>();
36
+ const onDraft = new Set<string>();
37
+ const put: string[] = [];
38
+ const completed: string[] = [];
39
+ const keeps: string[][] = [];
40
+ let keepFailure: Error | null = null;
41
+ let keepGate: Promise<void> | null = null;
42
+
43
+ const transport: AttachmentTransport = {
44
+ mint: (_draft, file) => {
45
+ const answer = deferred<MintedAttachment>();
46
+ mints.set(file.name, answer);
47
+ return answer.promise.then((minted) => {
48
+ onDraft.add(minted.outboxAttachmentId);
49
+ return minted;
50
+ });
51
+ },
52
+ put: async (uploadUrl, _file, signal) => {
53
+ if (signal.aborted) throw new Error("aborted");
54
+ put.push(uploadUrl);
55
+ },
56
+ complete: async (_draft, outboxAttachmentId) => {
57
+ completed.push(outboxAttachmentId);
58
+ },
59
+ keep: async (_draft, attachmentIds) => {
60
+ keeps.push(attachmentIds);
61
+ if (keepGate) await keepGate;
62
+ if (keepFailure) throw keepFailure;
63
+ for (const id of [...onDraft]) {
64
+ if (!attachmentIds.includes(id)) onDraft.delete(id);
65
+ }
66
+ },
67
+ };
68
+
69
+ const answerMint = (filename: string, outboxAttachmentId: string) => {
70
+ const answer = mints.get(filename);
71
+ if (!answer) throw new Error(`no reservation asked for ${filename}`);
72
+ answer.resolve({
73
+ outboxAttachmentId,
74
+ filename,
75
+ uploadUrl: `https://upload.test/${outboxAttachmentId}`,
76
+ });
77
+ };
78
+
79
+ return {
80
+ transport,
81
+ answerMint,
82
+ onDraft,
83
+ put,
84
+ completed,
85
+ keeps,
86
+ failKeeps: (error: Error | null) => {
87
+ keepFailure = error;
88
+ },
89
+ holdKeeps: () => {
90
+ const gate = deferred<void>();
91
+ keepGate = gate.promise;
92
+ return () => {
93
+ keepGate = null;
94
+ gate.resolve();
95
+ };
96
+ },
97
+ };
98
+ };
99
+
100
+ const file = (name: string) => new File(["bytes"], name);
101
+
102
+ const setup = () => {
103
+ const server = createServer();
104
+ const controller = new ComposeAttachmentController({
105
+ transport: () => server.transport,
106
+ ensureDraft: async () => ({ outcome: "ready", outboxMessageId: DRAFT }),
107
+ });
108
+ return { server, controller };
109
+ };
110
+
111
+ const rowFor = (controller: ComposeAttachmentController, filename: string) =>
112
+ controller.getSnapshot().find((item) => item.filename === filename);
113
+
114
+ describe("ComposeAttachmentController", () => {
115
+ it("attaches a file through reserve, upload and confirm", async () => {
116
+ const { server, controller } = setup();
117
+
118
+ const attaching = controller.attach([file("report.pdf")]);
119
+ await settle();
120
+ server.answerMint("report.pdf", "att-1");
121
+ await attaching;
122
+
123
+ assert.deepEqual(rowFor(controller, "report.pdf")?.state, {
124
+ status: "attached",
125
+ });
126
+ assert.deepEqual(server.put, ["https://upload.test/att-1"]);
127
+ assert.deepEqual(server.completed, ["att-1"]);
128
+ assert.equal(controller.blockingReason(), undefined);
129
+ });
130
+
131
+ it("takes a file removed while its reservation is out off the draft, and never uploads it", async () => {
132
+ const { server, controller } = setup();
133
+
134
+ const attaching = controller.attach([file("payroll.xlsx")]);
135
+ await settle();
136
+ const removing = controller.remove(controller.getSnapshot()[0].key);
137
+ assert.equal(controller.getSnapshot().length, 0, "the row goes at once");
138
+
139
+ server.answerMint("payroll.xlsx", "att-payroll");
140
+ await Promise.all([attaching, removing]);
141
+
142
+ assert.deepEqual(server.put, [], "nothing was uploaded");
143
+ assert.deepEqual(server.completed, [], "nothing was confirmed");
144
+ assert.deepEqual(server.keeps, [[]], "the draft was told it keeps nothing");
145
+ assert.equal(server.onDraft.has("att-payroll"), false);
146
+ assert.equal(controller.blockingReason(), undefined);
147
+ });
148
+
149
+ it("keeps a file whose reservation is still out when another one is removed", async () => {
150
+ const { server, controller } = setup();
151
+
152
+ const first = controller.attach([file("first.pdf")]);
153
+ await settle();
154
+ server.answerMint("first.pdf", "att-first");
155
+ await first;
156
+
157
+ const second = controller.attach([file("second.pdf")]);
158
+ await settle();
159
+ const removing = controller.remove(
160
+ rowFor(controller, "first.pdf")?.key ?? "",
161
+ );
162
+ await settle();
163
+ assert.deepEqual(
164
+ server.keeps,
165
+ [],
166
+ "the keep list waits for the reservation",
167
+ );
168
+
169
+ server.answerMint("second.pdf", "att-second");
170
+ await Promise.all([second, removing]);
171
+
172
+ assert.deepEqual(server.keeps, [["att-second"]]);
173
+ assert.equal(server.onDraft.has("att-second"), true);
174
+ assert.equal(server.onDraft.has("att-first"), false);
175
+ assert.deepEqual(rowFor(controller, "second.pdf")?.state, {
176
+ status: "attached",
177
+ });
178
+ });
179
+
180
+ it("puts a file back, marked, when the server would not remove it", async () => {
181
+ const { server, controller } = setup();
182
+
183
+ const attaching = controller.attach([file("contract.pdf")]);
184
+ await settle();
185
+ server.answerMint("contract.pdf", "att-contract");
186
+ await attaching;
187
+
188
+ server.failKeeps(new Error("the server answered 500"));
189
+ await controller.remove(controller.getSnapshot()[0].key);
190
+
191
+ const row = rowFor(controller, "contract.pdf");
192
+ assert.equal(row?.state.status, "failed");
193
+ assert.match(
194
+ row?.state.status === "failed" ? row.state.reason : "",
195
+ /"contract\.pdf" could not be removed: the server answered 500\. It is still on the draft/,
196
+ );
197
+ assert.match(
198
+ controller.blockingReason() ?? "",
199
+ /"contract\.pdf" is not attached/,
200
+ );
201
+
202
+ server.failKeeps(null);
203
+ await controller.remove(controller.getSnapshot()[0].key);
204
+ assert.deepEqual(server.keeps.at(-1), []);
205
+ assert.equal(controller.getSnapshot().length, 0);
206
+ });
207
+
208
+ it("keeps Send refused, naming the file, until the server has dropped it", async () => {
209
+ const { server, controller } = setup();
210
+
211
+ const attaching = controller.attach([file("payroll.xlsx")]);
212
+ await settle();
213
+ server.answerMint("payroll.xlsx", "att-payroll");
214
+ await attaching;
215
+
216
+ const release = server.holdKeeps();
217
+ const removing = controller.remove(controller.getSnapshot()[0].key);
218
+ await settle();
219
+ assert.equal(controller.getSnapshot().length, 0, "the row goes at once");
220
+ assert.equal(controller.blockingReason(), 'Removing "payroll.xlsx"…');
221
+
222
+ release();
223
+ await removing;
224
+ assert.equal(controller.blockingReason(), undefined);
225
+ assert.equal(server.onDraft.has("att-payroll"), false);
226
+ });
227
+
228
+ it("blocks sending while a file uploads, naming it", async () => {
229
+ const { server, controller } = setup();
230
+
231
+ const attaching = controller.attach([file("minutes.docx")]);
232
+ await settle();
233
+ assert.equal(
234
+ controller.blockingReason(),
235
+ '"minutes.docx" is still uploading.',
236
+ );
237
+
238
+ server.answerMint("minutes.docx", "att-minutes");
239
+ await attaching;
240
+ assert.equal(controller.blockingReason(), undefined);
241
+ });
242
+ });
@@ -0,0 +1,415 @@
1
+ import type {
2
+ RemitImapOutboxAttachmentRejectionReason,
3
+ RemitImapOutboxAttachmentResponse,
4
+ } from "@remit/api-http-client/types.gen.ts";
5
+ import type { ComposeAttachmentItem, ComposeAttachmentState } from "@remit/ui";
6
+ import { formatErrorDetail } from "../components/ui/error-banners";
7
+
8
+ export type DraftForAttachment =
9
+ | { outcome: "ready"; outboxMessageId: string }
10
+ | { outcome: "refused"; reason: string };
11
+
12
+ export interface MintedAttachment {
13
+ outboxAttachmentId: string;
14
+ filename: string;
15
+ uploadUrl: string;
16
+ }
17
+
18
+ /** The four requests a file on a draft is made of. Each throws on failure. */
19
+ export interface AttachmentTransport {
20
+ mint: (outboxMessageId: string, file: File) => Promise<MintedAttachment>;
21
+ put: (uploadUrl: string, file: File, signal: AbortSignal) => Promise<void>;
22
+ complete: (
23
+ outboxMessageId: string,
24
+ outboxAttachmentId: string,
25
+ ) => Promise<void>;
26
+ keep: (outboxMessageId: string, attachmentIds: string[]) => Promise<void>;
27
+ }
28
+
29
+ export interface ComposeAttachmentControllerDeps {
30
+ transport: () => AttachmentTransport;
31
+ ensureDraft: () => Promise<DraftForAttachment>;
32
+ }
33
+
34
+ interface Entry extends ComposeAttachmentItem {
35
+ serverId: string | null;
36
+ file: File | null;
37
+ }
38
+
39
+ interface AttachmentRejection {
40
+ code: "attachment_rejected";
41
+ reason: RemitImapOutboxAttachmentRejectionReason;
42
+ message: string;
43
+ }
44
+
45
+ const isRejection = (error: unknown): error is AttachmentRejection =>
46
+ typeof error === "object" &&
47
+ error !== null &&
48
+ "code" in error &&
49
+ error.code === "attachment_rejected" &&
50
+ "message" in error &&
51
+ typeof error.message === "string";
52
+
53
+ /**
54
+ * Refusals about the file itself. Pressing again sends the same file and is
55
+ * refused the same way, so the row offers removal and nothing else.
56
+ */
57
+ const FINAL_REASONS: ReadonlySet<RemitImapOutboxAttachmentRejectionReason> =
58
+ new Set([
59
+ "EmptyFile",
60
+ "UnusableFilename",
61
+ "FileTooLarge",
62
+ "TooManyAttachments",
63
+ ]);
64
+
65
+ const UPLOADING: ComposeAttachmentState = { status: "uploading" };
66
+ const ATTACHED: ComposeAttachmentState = { status: "attached" };
67
+
68
+ const failed = (
69
+ reason: string,
70
+ retryable: boolean,
71
+ ): ComposeAttachmentState => ({
72
+ status: "failed",
73
+ reason,
74
+ retryable,
75
+ });
76
+
77
+ const refusalOf = (
78
+ filename: string,
79
+ error: unknown,
80
+ ): ComposeAttachmentState => {
81
+ if (isRejection(error)) {
82
+ return failed(error.message, !FINAL_REASONS.has(error.reason));
83
+ }
84
+ return failed(
85
+ `"${filename}" could not be attached: ${formatErrorDetail(error) ?? "the request failed"}. Try again.`,
86
+ true,
87
+ );
88
+ };
89
+
90
+ const fromServer = (attachment: RemitImapOutboxAttachmentResponse): Entry => ({
91
+ key: attachment.outboxAttachmentId,
92
+ serverId: attachment.outboxAttachmentId,
93
+ file: null,
94
+ filename: attachment.filename,
95
+ sizeBytes: attachment.sizeBytes,
96
+ state:
97
+ attachment.state === "Stored"
98
+ ? ATTACHED
99
+ : failed(
100
+ `"${attachment.filename}" did not finish uploading. Remove it and attach it again.`,
101
+ false,
102
+ ),
103
+ });
104
+
105
+ /**
106
+ * The files on the draft being written, and the requests that put each one
107
+ * there: reserve room, upload the bytes, confirm them.
108
+ *
109
+ * What the draft keeps is stated to the server as a whole list, so a removal
110
+ * waits for every reservation still in flight: a list sent before one comes
111
+ * back would not name it, and the server would drop a file the writer kept.
112
+ * A file removed mid-upload is left out of that list once its reservation
113
+ * lands, which is what takes it off the draft — pulling the row alone would
114
+ * leave the upload to finish and the file to go out unseen.
115
+ */
116
+ export class ComposeAttachmentController {
117
+ private entries: Entry[] = [];
118
+ private snapshot: ComposeAttachmentItem[] = [];
119
+ private readonly listeners = new Set<() => void>();
120
+ private generation = 0;
121
+ private draftId: string | undefined;
122
+ private nextKey = 0;
123
+ private readonly reservationsInFlight = new Set<Promise<void>>();
124
+ private readonly inFlight = new Map<string, AbortController>();
125
+ private readonly mintedAfterRemoval = new Map<string, string>();
126
+ private readonly removing = new Map<string, string>();
127
+
128
+ constructor(private readonly deps: ComposeAttachmentControllerDeps) {}
129
+
130
+ subscribe = (listener: () => void): (() => void) => {
131
+ this.listeners.add(listener);
132
+ return () => this.listeners.delete(listener);
133
+ };
134
+
135
+ getSnapshot = (): ComposeAttachmentItem[] => this.snapshot;
136
+
137
+ blockingReason = (): string | undefined => {
138
+ const [removing] = this.removing.values();
139
+ if (removing !== undefined) return `Removing "${removing}"…`;
140
+ const uploading = this.entries.find(
141
+ (entry) => entry.state.status === "uploading",
142
+ );
143
+ if (uploading) return `"${uploading.filename}" is still uploading.`;
144
+ const broken = this.entries.find(
145
+ (entry) => entry.state.status === "failed",
146
+ );
147
+ if (broken) {
148
+ return `"${broken.filename}" is not attached. Try it again or remove it before sending.`;
149
+ }
150
+ return undefined;
151
+ };
152
+
153
+ private set(next: Entry[]): void {
154
+ this.entries = next;
155
+ this.snapshot = next.map(({ key, filename, sizeBytes, state }) => ({
156
+ key,
157
+ filename,
158
+ sizeBytes,
159
+ state,
160
+ }));
161
+ for (const listener of this.listeners) listener();
162
+ }
163
+
164
+ private patch(generation: number, key: string, change: Partial<Entry>): void {
165
+ if (generation !== this.generation) return;
166
+ if (!this.entries.some((entry) => entry.key === key)) return;
167
+ this.set(
168
+ this.entries.map((entry) =>
169
+ entry.key === key ? { ...entry, ...change } : entry,
170
+ ),
171
+ );
172
+ }
173
+
174
+ private reserve(
175
+ outboxMessageId: string,
176
+ file: File,
177
+ ): { minted: Promise<MintedAttachment>; settle: () => void } {
178
+ let settle = () => {};
179
+ const slot = new Promise<void>((resolve) => {
180
+ settle = () => {
181
+ this.reservationsInFlight.delete(slot);
182
+ resolve();
183
+ };
184
+ });
185
+ this.reservationsInFlight.add(slot);
186
+ return {
187
+ minted: this.deps.transport().mint(outboxMessageId, file),
188
+ settle,
189
+ };
190
+ }
191
+
192
+ private upload = async (
193
+ generation: number,
194
+ key: string,
195
+ file: File,
196
+ outboxMessageId: string,
197
+ signal: AbortSignal,
198
+ ): Promise<void> => {
199
+ if (signal.aborted) return;
200
+ const transport = this.deps.transport();
201
+ const { minted: minting, settle } = this.reserve(outboxMessageId, file);
202
+ const minted = await minting.catch((error: unknown) => {
203
+ this.patch(generation, key, { state: refusalOf(file.name, error) });
204
+ return null;
205
+ });
206
+ if (minted !== null && signal.aborted) {
207
+ this.mintedAfterRemoval.set(key, minted.outboxAttachmentId);
208
+ }
209
+ if (minted !== null && !signal.aborted) {
210
+ this.patch(generation, key, {
211
+ serverId: minted.outboxAttachmentId,
212
+ filename: minted.filename,
213
+ });
214
+ }
215
+ settle();
216
+ if (minted === null || signal.aborted) return;
217
+
218
+ const putFailure = await transport.put(minted.uploadUrl, file, signal).then(
219
+ () => null,
220
+ (error: unknown) => formatErrorDetail(error) ?? "the connection dropped",
221
+ );
222
+ if (signal.aborted) return;
223
+ if (putFailure !== null) {
224
+ this.patch(generation, key, {
225
+ state: failed(
226
+ `"${minted.filename}" did not finish uploading: ${putFailure}. Try again.`,
227
+ true,
228
+ ),
229
+ });
230
+ return;
231
+ }
232
+
233
+ const completed = await transport
234
+ .complete(outboxMessageId, minted.outboxAttachmentId)
235
+ .then(
236
+ () => true,
237
+ (error: unknown) => {
238
+ this.patch(generation, key, {
239
+ state: refusalOf(minted.filename, error),
240
+ });
241
+ return false;
242
+ },
243
+ );
244
+ if (!completed || signal.aborted) return;
245
+ this.patch(generation, key, { state: ATTACHED });
246
+ };
247
+
248
+ private run = async (
249
+ generation: number,
250
+ key: string,
251
+ file: File,
252
+ outboxMessageId: string,
253
+ signal: AbortSignal,
254
+ ): Promise<void> => {
255
+ await this.upload(generation, key, file, outboxMessageId, signal);
256
+ if (this.inFlight.get(key)?.signal === signal) this.inFlight.delete(key);
257
+ };
258
+
259
+ /**
260
+ * Tell the server what the draft keeps, once no reservation is still out.
261
+ * Answers whether the server took it.
262
+ */
263
+ private syncKept = async (generation: number): Promise<unknown> => {
264
+ while (this.reservationsInFlight.size > 0) {
265
+ await Promise.all([...this.reservationsInFlight]);
266
+ }
267
+ const outboxMessageId = this.draftId;
268
+ if (generation !== this.generation || outboxMessageId === undefined) {
269
+ return null;
270
+ }
271
+ return this.deps
272
+ .transport()
273
+ .keep(
274
+ outboxMessageId,
275
+ this.entries.flatMap((entry) =>
276
+ entry.serverId === null ? [] : [entry.serverId],
277
+ ),
278
+ )
279
+ .then(
280
+ () => null,
281
+ (error: unknown) => error ?? new Error("the request failed"),
282
+ );
283
+ };
284
+
285
+ attach = async (files: File[]): Promise<void> => {
286
+ const generation = this.generation;
287
+ const added = files.map((file) => {
288
+ this.nextKey += 1;
289
+ const entry: Entry = {
290
+ key: `local-${this.nextKey}`,
291
+ serverId: null,
292
+ file,
293
+ filename: file.name,
294
+ sizeBytes: file.size,
295
+ state: UPLOADING,
296
+ };
297
+ const controller = new AbortController();
298
+ this.inFlight.set(entry.key, controller);
299
+ return { entry, file, signal: controller.signal };
300
+ });
301
+ this.set([...this.entries, ...added.map(({ entry }) => entry)]);
302
+
303
+ const draft = await this.deps.ensureDraft();
304
+ if (draft.outcome === "refused") {
305
+ for (const { entry } of added) {
306
+ this.inFlight.delete(entry.key);
307
+ this.patch(generation, entry.key, {
308
+ state: failed(draft.reason, true),
309
+ });
310
+ }
311
+ return;
312
+ }
313
+ if (generation !== this.generation) return;
314
+ this.draftId = draft.outboxMessageId;
315
+
316
+ await Promise.all(
317
+ added.map(({ entry, file, signal }) =>
318
+ this.run(generation, entry.key, file, draft.outboxMessageId, signal),
319
+ ),
320
+ );
321
+ };
322
+
323
+ retry = async (key: string): Promise<void> => {
324
+ const entry = this.entries.find((candidate) => candidate.key === key);
325
+ if (!entry || entry.file === null || this.inFlight.has(key)) return;
326
+ const file = entry.file;
327
+ const generation = this.generation;
328
+ const controller = new AbortController();
329
+ this.inFlight.set(key, controller);
330
+ this.patch(generation, key, { state: UPLOADING, serverId: null });
331
+
332
+ const draft = await this.deps.ensureDraft();
333
+ if (draft.outcome === "refused") {
334
+ this.inFlight.delete(key);
335
+ this.patch(generation, key, { state: failed(draft.reason, true) });
336
+ return;
337
+ }
338
+ this.draftId = draft.outboxMessageId;
339
+
340
+ if (entry.serverId !== null) {
341
+ const failure = await this.syncKept(generation);
342
+ if (failure !== null) {
343
+ this.inFlight.delete(key);
344
+ this.patch(generation, key, {
345
+ serverId: entry.serverId,
346
+ state: refusalOf(file.name, failure),
347
+ });
348
+ return;
349
+ }
350
+ }
351
+
352
+ await this.run(
353
+ generation,
354
+ key,
355
+ file,
356
+ draft.outboxMessageId,
357
+ controller.signal,
358
+ );
359
+ };
360
+
361
+ remove = async (key: string): Promise<void> => {
362
+ const entry = this.entries.find((candidate) => candidate.key === key);
363
+ if (!entry) return;
364
+ const generation = this.generation;
365
+ const uploading = this.inFlight.get(key);
366
+ this.set(this.entries.filter((candidate) => candidate.key !== key));
367
+ if (uploading) {
368
+ this.inFlight.delete(key);
369
+ uploading.abort();
370
+ }
371
+ if (entry.serverId === null && !uploading) return;
372
+
373
+ // Until the server has dropped it the file is still on the draft, and a
374
+ // Send pressed in that window would carry it.
375
+ this.removing.set(key, entry.filename);
376
+ this.set(this.entries);
377
+ const failure = await this.syncKept(generation);
378
+ this.removing.delete(key);
379
+ this.set(this.entries);
380
+ const serverId = entry.serverId ?? this.mintedAfterRemoval.get(key) ?? null;
381
+ this.mintedAfterRemoval.delete(key);
382
+ if (failure === null || generation !== this.generation) return;
383
+ if (serverId === null) return;
384
+ this.set([
385
+ ...this.entries,
386
+ {
387
+ ...entry,
388
+ serverId,
389
+ file: null,
390
+ state: failed(
391
+ `"${entry.filename}" could not be removed: ${formatErrorDetail(failure) ?? "the request failed"}. It is still on the draft — remove it again.`,
392
+ false,
393
+ ),
394
+ },
395
+ ]);
396
+ };
397
+
398
+ load = (
399
+ outboxMessageId: string,
400
+ attachments: readonly RemitImapOutboxAttachmentResponse[],
401
+ ): void => {
402
+ this.draftId = outboxMessageId;
403
+ this.set(attachments.map(fromServer));
404
+ };
405
+
406
+ reset = (outboxMessageId: string | undefined): void => {
407
+ this.generation += 1;
408
+ for (const controller of this.inFlight.values()) controller.abort();
409
+ this.inFlight.clear();
410
+ this.mintedAfterRemoval.clear();
411
+ this.removing.clear();
412
+ this.draftId = outboxMessageId;
413
+ this.set([]);
414
+ };
415
+ }
@@ -34,6 +34,13 @@ describe("mapOauthError", () => {
34
34
  );
35
35
  });
36
36
 
37
+ test("scope_not_granted sends the person back through the consent", () => {
38
+ assert.match(
39
+ mapOauthError("scope_not_granted"),
40
+ /did not grant access to every service you picked/,
41
+ );
42
+ });
43
+
37
44
  test("consent_required returns admin consent message", () => {
38
45
  const result = mapOauthError("consent_required");
39
46
  assert.ok(
@@ -99,6 +106,7 @@ const REAUTH_ACCOUNT = {
99
106
  displayName: "Matthijs",
100
107
  authType: "oauthMicrosoft",
101
108
  connectionState: "reauth_required",
109
+ syncedServices: ["Mail", "Calendar"],
102
110
  };
103
111
 
104
112
  // The router reads `self` at construction; the shared jsdom globals stop at
@@ -195,6 +203,16 @@ describe("the Reconnect button and the redirect it starts", () => {
195
203
  Reflect.deleteProperty(document, "visibilityState");
196
204
  });
197
205
 
206
+ test("asks Microsoft again for every service the account syncs", async () => {
207
+ const dom = await mountAccounts();
208
+ await startReconnect(dom);
209
+
210
+ assert.deepEqual(http?.to("/oauth/microsoft/start")[0]?.body, {
211
+ email: REAUTH_ACCOUNT.email,
212
+ services: ["Mail", "Calendar"],
213
+ });
214
+ });
215
+
198
216
  test("goes busy and stays busy while the redirect is in flight", async () => {
199
217
  const dom = await mountAccounts();
200
218
  await startReconnect(dom);