@remit/web-client 0.0.312 → 0.0.313

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.312",
3
+ "version": "0.0.313",
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": {
@@ -31,7 +31,7 @@ import {
31
31
  unwrapLanguage,
32
32
  } from "@remit/ui";
33
33
  import type { ComposeBodyMode, ComposeInsertion } from "@remit/ui/rich-text";
34
- import { useMutation, useQuery } from "@tanstack/react-query";
34
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
35
35
  import type { RefObject } from "react";
36
36
  import {
37
37
  lazy,
@@ -46,6 +46,7 @@ import {
46
46
  type DraftForAttachment,
47
47
  useComposeAttachments,
48
48
  } from "../../hooks/useComposeAttachments";
49
+ import { useInlineImages } from "../../hooks/useInlineImages";
49
50
  import { useMessageBodyContent } from "../../hooks/useMessageBodyContent";
50
51
  import { useSaveDraft } from "../../hooks/useSaveDraft";
51
52
  import { useSignature } from "../../hooks/useSignature.js";
@@ -462,9 +463,7 @@ export const ComposeForm = ({
462
463
  * failure to `draft` (#933), so opening a Failed message and pressing Escape
463
464
  * would take it out of the Outbox with the reader having changed nothing. The
464
465
  * first run after a load records what was loaded; only a payload that differs
465
- * from it is an edit. Nothing updates it afterwards — once the reader has
466
- * touched the document, every later run saves as it always did, retries of a
467
- * failed write included.
466
+ * from it is an edit.
468
467
  */
469
468
  const loadedPayloadRef = useRef<string | undefined>(undefined);
470
469
  const captureLoadedPayloadRef = useRef(false);
@@ -877,11 +876,41 @@ export const ComposeForm = ({
877
876
  remove: removeAttachment,
878
877
  load: loadAttachments,
879
878
  reset: resetAttachments,
879
+ owns: ownsAttachment,
880
+ isLoaded: attachmentsLoaded,
881
+ refreshLinks: refreshAttachmentLinks,
882
+ storedContent,
883
+ onRemoved: onAttachmentRemoved,
880
884
  blockingReason: attachmentBlockingReason,
881
885
  } = useComposeAttachments({
882
886
  ensureDraft: ensureDraftForAttachment,
883
887
  });
884
888
 
889
+ const queryClient = useQueryClient();
890
+ const inlineImages = useInlineImages({
891
+ attach,
892
+ owns: ownsAttachment,
893
+ isLoaded: attachmentsLoaded,
894
+ storedContent,
895
+ refreshLinks: () => {
896
+ if (!outboxMessageId) return Promise.resolve();
897
+ return queryClient
898
+ .fetchQuery({
899
+ ...outboxDetailOperationsGetOutboxMessageOptions({
900
+ path: { outboxMessageId },
901
+ }),
902
+ staleTime: 0,
903
+ })
904
+ .then((draft) => refreshAttachmentLinks(draft.attachments));
905
+ },
906
+ onRemoved: onAttachmentRemoved,
907
+ draftAttachments:
908
+ draftData && draftData.outboxMessageId === outboxMessageId
909
+ ? draftData.attachments
910
+ : [],
911
+ pushError,
912
+ });
913
+
885
914
  const attachmentDocumentRef = useRef(outboxMessageId);
886
915
  useEffect(() => {
887
916
  const previous = attachmentDocumentRef.current;
@@ -1086,6 +1115,7 @@ export const ComposeForm = ({
1086
1115
  return;
1087
1116
  }
1088
1117
  if (loadedPayloadRef.current === fingerprint) return;
1118
+ loadedPayloadRef.current = undefined;
1089
1119
 
1090
1120
  saveDraft(payload);
1091
1121
  }, [
@@ -1283,6 +1313,9 @@ export const ComposeForm = ({
1283
1313
  return (
1284
1314
  <ComposeFormShell
1285
1315
  layout={layout}
1316
+ onDropFiles={(files) => {
1317
+ void attach(files);
1318
+ }}
1286
1319
  banner={
1287
1320
  <>
1288
1321
  {selectedAccount && selectedAccountMissingSmtp ? (
@@ -1381,6 +1414,7 @@ export const ComposeForm = ({
1381
1414
  spellcheck={spellcheck}
1382
1415
  insertion={pendingInsertion}
1383
1416
  onInserted={setInsertedVersion}
1417
+ inlineImages={inlineImages}
1384
1418
  />
1385
1419
  </Suspense>
1386
1420
  </ComposeFormShell>
@@ -0,0 +1,542 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, before, describe, it, mock } from "node:test";
3
+ import type {
4
+ RemitImapAccountResponse,
5
+ RemitImapDescribeMessageResponse,
6
+ } from "@remit/api-http-client/types.gen.ts";
7
+ import {
8
+ type AnyRouter,
9
+ createMemoryHistory,
10
+ createRootRoute,
11
+ createRoute,
12
+ createRouter,
13
+ RouterContextProvider,
14
+ } from "@tanstack/react-router";
15
+ import { createElement } from "react";
16
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
17
+ import {
18
+ type HttpCall,
19
+ type HttpMock,
20
+ mockFetch,
21
+ } from "../../test-support/http";
22
+ import { ComposeForm } from "./ComposeForm";
23
+ import { ComposeProvider } from "./ComposeProvider";
24
+
25
+ const ACCOUNT_ID = "acc-1";
26
+ const OUTBOX_MESSAGE_ID = "ob-1307";
27
+ const AUTOSAVE_DEBOUNCE_MS = 2000;
28
+
29
+ const account = {
30
+ accountId: ACCOUNT_ID,
31
+ email: "me@example.com",
32
+ smtpEnabled: true,
33
+ } as unknown as RemitImapAccountResponse;
34
+
35
+ const sourceMessage = {
36
+ message: { messageId: "msg-1" },
37
+ envelope: {
38
+ subject: "The chart",
39
+ messageIdValue: "<m1@example.com>",
40
+ from: [{ normalizedEmail: "them@example.com", displayName: "Them" }],
41
+ replyTo: [],
42
+ to: [],
43
+ cc: [],
44
+ },
45
+ references: [],
46
+ bodyParts: [],
47
+ } as unknown as RemitImapDescribeMessageResponse;
48
+
49
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
50
+
51
+ class JsdomClipboardEvent extends Event {
52
+ readonly clipboardData: DataTransfer;
53
+
54
+ constructor(type: string, clipboardData: DataTransfer) {
55
+ super(type, { bubbles: true, cancelable: true });
56
+ this.clipboardData = clipboardData;
57
+ }
58
+ }
59
+
60
+ const clipboardGlobal = globalThis as { ClipboardEvent?: unknown };
61
+ clipboardGlobal.ClipboardEvent ??= JsdomClipboardEvent;
62
+ const ClipboardEventClass = clipboardGlobal.ClipboardEvent as new (
63
+ type: string,
64
+ clipboardData: DataTransfer,
65
+ ) => Event;
66
+
67
+ const transferOf = (files: File[], text = ""): DataTransfer =>
68
+ ({
69
+ types: ["Files"],
70
+ files,
71
+ getData: (type: string) => (type === "text/plain" ? text : ""),
72
+ }) as unknown as DataTransfer;
73
+
74
+ before(() => {
75
+ Object.defineProperty(Range.prototype, "getBoundingClientRect", {
76
+ value: () => ({
77
+ top: 0,
78
+ bottom: 0,
79
+ left: 0,
80
+ right: 0,
81
+ width: 0,
82
+ height: 0,
83
+ x: 0,
84
+ y: 0,
85
+ }),
86
+ configurable: true,
87
+ });
88
+ });
89
+
90
+ const rootRoute = createRootRoute();
91
+ const mailboxRoute = createRoute({
92
+ getParentRoute: () => rootRoute,
93
+ path: "/mail/$mailboxId",
94
+ validateSearch: (search: Record<string, unknown>) => search,
95
+ });
96
+
97
+ const testRouter = (): AnyRouter =>
98
+ createRouter({
99
+ routeTree: rootRoute.addChildren([mailboxRoute]),
100
+ history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
101
+ }) as unknown as AnyRouter;
102
+
103
+ let harness: DomHarness | undefined;
104
+ let http: HttpMock | undefined;
105
+
106
+ interface ServerAttachment {
107
+ outboxAttachmentId: string;
108
+ filename: string;
109
+ contentType: string;
110
+ sizeBytes: number;
111
+ state: "Pending" | "Stored";
112
+ }
113
+
114
+ interface Server {
115
+ htmlBody: string;
116
+ attachments: ServerAttachment[];
117
+ refuseMints: boolean;
118
+ minted: number;
119
+ failPuts: number;
120
+ linkGeneration: number;
121
+ expireBefore: number;
122
+ contentStatus: number;
123
+ }
124
+
125
+ let server: Server;
126
+
127
+ const freshServer = (): Server => ({
128
+ htmlBody: "<p>Here it is</p>",
129
+ attachments: [],
130
+ refuseMints: false,
131
+ minted: 0,
132
+ failPuts: 0,
133
+ linkGeneration: 0,
134
+ expireBefore: 0,
135
+ contentStatus: 200,
136
+ });
137
+
138
+ afterEach(() => {
139
+ harness?.close();
140
+ harness = undefined;
141
+ http?.restore();
142
+ http = undefined;
143
+ mock.restoreAll();
144
+ });
145
+
146
+ const CONTENT_HOST = "https://content.test";
147
+
148
+ const toResponse = (attachment: ServerAttachment) => ({
149
+ ...attachment,
150
+ outboxMessageId: OUTBOX_MESSAGE_ID,
151
+ contentId: `${attachment.outboxAttachmentId}@remit`,
152
+ contentUrl: `${CONTENT_HOST}/${attachment.outboxAttachmentId}?g=${server.linkGeneration}`,
153
+ });
154
+
155
+ const outboxEntry = () => ({
156
+ outboxMessageId: OUTBOX_MESSAGE_ID,
157
+ accountId: ACCOUNT_ID,
158
+ fromAddress: account.email,
159
+ toAddresses: ["them@example.com"],
160
+ attachments: server.attachments.map(toResponse),
161
+ ccAddresses: [],
162
+ bccAddresses: [],
163
+ references: [],
164
+ subject: "Re: The chart",
165
+ textBody: "Here it is",
166
+ htmlBody: server.htmlBody,
167
+ status: "draft",
168
+ });
169
+
170
+ const refusal = (filename: string): Response =>
171
+ new Response(
172
+ JSON.stringify({
173
+ code: "attachment_rejected",
174
+ reason: "FileTooLarge",
175
+ message: `"${filename}" is 30.0 MB, over the 25.0 MB a message can carry.`,
176
+ limitBytes: 26_214_400,
177
+ usedBytes: 0,
178
+ }),
179
+ { status: 413, headers: { "content-type": "application/json" } },
180
+ );
181
+
182
+ const answer = (call: HttpCall): unknown => {
183
+ if (call.url.startsWith(CONTENT_HOST)) {
184
+ const generation = Number(new URL(call.url).searchParams.get("g"));
185
+ if (generation < server.expireBefore) {
186
+ return new Response("", {
187
+ status: 403,
188
+ headers: { "x-remit-403-reason": "expired" },
189
+ });
190
+ }
191
+ if (server.contentStatus !== 200) {
192
+ return new Response("", { status: server.contentStatus });
193
+ }
194
+ return new Response(new Uint8Array([0x89, 0x50, 0x4e, 0x47]), {
195
+ status: 200,
196
+ });
197
+ }
198
+ if (call.url.startsWith("https://upload.test")) {
199
+ if (server.failPuts === 0) return {};
200
+ server.failPuts -= 1;
201
+ return new Response("", { status: 500 });
202
+ }
203
+ if (call.path.endsWith("/config")) return { accounts: [account] };
204
+ if (call.method === "POST" && call.path.endsWith("/attachments")) {
205
+ const filename = String(call.body?.filename);
206
+ if (server.refuseMints) return refusal(filename);
207
+ server.minted += 1;
208
+ const attachment: ServerAttachment = {
209
+ outboxAttachmentId: `att-${server.minted}`,
210
+ filename,
211
+ contentType: String(call.body?.contentType),
212
+ sizeBytes: Number(call.body?.sizeBytes),
213
+ state: "Pending",
214
+ };
215
+ server.attachments.push(attachment);
216
+ return {
217
+ ...toResponse(attachment),
218
+ uploadUrl: `https://upload.test/${attachment.outboxAttachmentId}`,
219
+ uploadExpiresAt: 4_000_000_000,
220
+ };
221
+ }
222
+ if (call.path.endsWith("/complete")) {
223
+ const outboxAttachmentId = call.path.split("/").at(-2);
224
+ const attachment = server.attachments.find(
225
+ (candidate) => candidate.outboxAttachmentId === outboxAttachmentId,
226
+ );
227
+ if (!attachment)
228
+ throw new Error(`completed ${outboxAttachmentId} unminted`);
229
+ attachment.state = "Stored";
230
+ return toResponse(attachment);
231
+ }
232
+ if (
233
+ call.method === "GET" &&
234
+ call.path.endsWith(`/outbox/${OUTBOX_MESSAGE_ID}`)
235
+ ) {
236
+ server.linkGeneration += 1;
237
+ }
238
+ if (call.method === "PATCH") {
239
+ if (typeof call.body?.htmlBody === "string") {
240
+ server.htmlBody = call.body.htmlBody;
241
+ }
242
+ const kept = call.body?.attachmentIds;
243
+ if (Array.isArray(kept)) {
244
+ server.attachments = server.attachments.filter((attachment) =>
245
+ kept.includes(attachment.outboxAttachmentId),
246
+ );
247
+ }
248
+ }
249
+ return outboxEntry();
250
+ };
251
+
252
+ let objectUrls = 0;
253
+
254
+ const mount = async (): Promise<DomHarness> => {
255
+ if (!http) {
256
+ server = freshServer();
257
+ objectUrls = 0;
258
+ mock.method(URL, "createObjectURL", () => {
259
+ objectUrls += 1;
260
+ return `blob:object-${objectUrls}`;
261
+ });
262
+ mock.method(URL, "revokeObjectURL", () => {});
263
+ http = mockFetch(answer);
264
+ }
265
+ const dom = createDomHarness();
266
+ harness = dom;
267
+ dom.renderApp(
268
+ createElement(RouterContextProvider, {
269
+ router: testRouter(),
270
+ // biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
271
+ children: createElement(
272
+ ComposeProvider,
273
+ null,
274
+ createElement(ComposeForm, {
275
+ mode: "reply",
276
+ account,
277
+ sourceMessage,
278
+ outboxMessageId: OUTBOX_MESSAGE_ID,
279
+ onDraftCreated: () => {},
280
+ onClose: () => {},
281
+ }),
282
+ ),
283
+ }),
284
+ );
285
+ await dom.flush();
286
+ await dom.waitFor(
287
+ () =>
288
+ dom
289
+ .query("[data-testid=compose-body]")
290
+ ?.textContent?.includes("Here it is") === true,
291
+ "the draft to load into the writing surface",
292
+ 30_000,
293
+ );
294
+ return dom;
295
+ };
296
+
297
+ const bodyOf = (dom: DomHarness): HTMLElement => {
298
+ const body = dom.query("[data-testid=compose-body]");
299
+ if (!body) throw new Error("the compose body is not mounted");
300
+ return body;
301
+ };
302
+
303
+ const attachedRows = (dom: DomHarness): [string, string | null][] =>
304
+ dom
305
+ .queryAll("[data-testid=compose-attachment]")
306
+ .map((row) => [
307
+ row.querySelector("bdi")?.textContent ?? "",
308
+ row.getAttribute("data-state"),
309
+ ]);
310
+
311
+ const rowIn = (dom: DomHarness, state: string): boolean =>
312
+ attachedRows(dom).some(([, seen]) => seen === state);
313
+
314
+ const png = (name: string): File =>
315
+ new File(["\u0089PNG"], name, { type: "image/png" });
316
+
317
+ const paste = (dom: DomHarness, files: File[]): void => {
318
+ dom.dispatch(
319
+ bodyOf(dom),
320
+ new ClipboardEventClass("paste", transferOf(files)),
321
+ );
322
+ };
323
+
324
+ const pasteAndStore = async (dom: DomHarness): Promise<void> => {
325
+ paste(dom, [png("chart.png")]);
326
+ await dom.waitFor(() => rowIn(dom, "attached"), "the pasted image to store");
327
+ await dom.wait(AUTOSAVE_DEBOUNCE_MS + 200);
328
+ };
329
+
330
+ describe("a file pasted or dropped into the composer (#1307)", () => {
331
+ it("puts a pasted image in the body, referred to by the content-id it was stored under", async () => {
332
+ const dom = await mount();
333
+
334
+ await pasteAndStore(dom);
335
+
336
+ assert.deepEqual(attachedRows(dom), [["chart.png", "attached"]]);
337
+ assert.equal(
338
+ bodyOf(dom).querySelector("img")?.getAttribute("src"),
339
+ "blob:object-1",
340
+ "the composer does not show the picture it was handed",
341
+ );
342
+ assert.ok(
343
+ server.htmlBody.includes('src="cid:att-1@remit"'),
344
+ `the saved body does not refer to the stored image: ${server.htmlBody}`,
345
+ );
346
+ });
347
+
348
+ it("shows the stored picture again when the draft is reopened", async () => {
349
+ await pasteAndStore(await mount());
350
+ harness?.close();
351
+
352
+ const reopened = await mount();
353
+ await reopened.waitFor(
354
+ () =>
355
+ bodyOf(reopened).querySelector("img")?.getAttribute("src") ===
356
+ "blob:object-2",
357
+ "the reopened draft to resolve its picture",
358
+ );
359
+
360
+ assert.ok(
361
+ http?.calls.some((call) => call.url.startsWith(`${CONTENT_HOST}/att-1?`)),
362
+ "the picture was not read back from where the file is stored",
363
+ );
364
+ assert.deepEqual(attachedRows(reopened), [["chart.png", "attached"]]);
365
+ });
366
+
367
+ it("never saves a picture whose file was removed, even after an undo", async () => {
368
+ await pasteAndStore(await mount());
369
+ harness?.close();
370
+
371
+ const reopened = await mount();
372
+ await reopened.waitFor(
373
+ () =>
374
+ bodyOf(reopened).querySelector("img")?.getAttribute("src") ===
375
+ "blob:object-2",
376
+ "the reopened draft to resolve its picture",
377
+ );
378
+ reopened.click(reopened.byLabel("Remove chart.png"));
379
+ await reopened.waitFor(
380
+ () => bodyOf(reopened).querySelector("img") === null,
381
+ "the removed file's picture to leave the body",
382
+ );
383
+ reopened.dispatch(
384
+ bodyOf(reopened),
385
+ new reopened.window.KeyboardEvent("keydown", {
386
+ key: "z",
387
+ code: "KeyZ",
388
+ ctrlKey: true,
389
+ bubbles: true,
390
+ cancelable: true,
391
+ }),
392
+ );
393
+ await reopened.wait(AUTOSAVE_DEBOUNCE_MS + 200);
394
+
395
+ assert.equal(
396
+ server.htmlBody.includes("cid:att-1@remit"),
397
+ false,
398
+ server.htmlBody,
399
+ );
400
+ assert.equal(
401
+ bodyOf(reopened).querySelector("img"),
402
+ null,
403
+ "a picture is on screen for a file the draft no longer carries",
404
+ );
405
+ });
406
+
407
+ it("renews an expired picture link and shows the picture", async () => {
408
+ await pasteAndStore(await mount());
409
+ harness?.close();
410
+ server.expireBefore = server.linkGeneration + 2;
411
+
412
+ const reopened = await mount();
413
+ await reopened.waitFor(
414
+ () =>
415
+ bodyOf(reopened).querySelector("img")?.getAttribute("src") ===
416
+ "blob:object-2",
417
+ "the renewed link to resolve the picture",
418
+ );
419
+
420
+ const reads = (http?.calls ?? []).filter((call) =>
421
+ call.url.startsWith(`${CONTENT_HOST}/att-1?`),
422
+ );
423
+ assert.equal(
424
+ reads.length,
425
+ 2,
426
+ "the expired link was not read, then renewed",
427
+ );
428
+ });
429
+
430
+ it("says the session ended when the picture is refused for authentication", async () => {
431
+ await pasteAndStore(await mount());
432
+ harness?.close();
433
+ server.contentStatus = 401;
434
+
435
+ const reopened = await mount();
436
+ await reopened.waitFor(
437
+ () => reopened.text().includes("Your session expired"),
438
+ "the signed-out notice",
439
+ );
440
+
441
+ assert.match(reopened.text(), /Sign in again/);
442
+ });
443
+
444
+ it("keeps a retried pasted image inline once its upload goes through", async () => {
445
+ const dom = await mount();
446
+ server.failPuts = 1;
447
+
448
+ paste(dom, [png("chart.png")]);
449
+ await dom.waitFor(() => rowIn(dom, "failed"), "the upload to fail");
450
+ await dom.waitFor(
451
+ () => bodyOf(dom).querySelector("img") === null,
452
+ "the failed picture to leave the body",
453
+ );
454
+ dom.click(dom.byText("button", "Try again"));
455
+ await dom.waitFor(() => rowIn(dom, "attached"), "the retry to store");
456
+ await dom.wait(AUTOSAVE_DEBOUNCE_MS + 200);
457
+
458
+ assert.ok(
459
+ bodyOf(dom).querySelector("img"),
460
+ "the retried picture is not in the body",
461
+ );
462
+ assert.ok(
463
+ server.htmlBody.includes('src="cid:att-2@remit"'),
464
+ `the saved body does not refer to the retried image: ${server.htmlBody}`,
465
+ );
466
+ });
467
+
468
+ it("takes a refused image back out of the body and says why on its row", async () => {
469
+ const dom = await mount();
470
+ server.refuseMints = true;
471
+
472
+ paste(dom, [png("huge.png")]);
473
+ await dom.waitFor(() => rowIn(dom, "failed"), "the refusal to land");
474
+ await dom.waitFor(
475
+ () => bodyOf(dom).querySelector("img") === null,
476
+ "the refused picture to leave the body",
477
+ );
478
+ await dom.wait(AUTOSAVE_DEBOUNCE_MS + 200);
479
+
480
+ assert.match(
481
+ dom.query("[data-testid=compose-attachment-error]")?.textContent ?? "",
482
+ /huge\.png/,
483
+ );
484
+ assert.equal(server.htmlBody.includes("<img"), false, server.htmlBody);
485
+ });
486
+
487
+ it("takes the picture out of the body when its file is removed", async () => {
488
+ const dom = await mount();
489
+ await pasteAndStore(dom);
490
+
491
+ dom.click(dom.byLabel("Remove chart.png"));
492
+ await dom.waitFor(
493
+ () => bodyOf(dom).querySelector("img") === null,
494
+ "the removed file's picture to leave the body",
495
+ );
496
+ await dom.wait(AUTOSAVE_DEBOUNCE_MS + 200);
497
+
498
+ assert.deepEqual(attachedRows(dom), []);
499
+ assert.equal(server.htmlBody.includes("cid:"), false, server.htmlBody);
500
+ });
501
+
502
+ it("attaches a pasted file that is not a picture, leaving the body alone", async () => {
503
+ const dom = await mount();
504
+
505
+ paste(dom, [
506
+ new File(["%PDF"], "minutes.pdf", { type: "application/pdf" }),
507
+ ]);
508
+ await dom.waitFor(
509
+ () => rowIn(dom, "attached"),
510
+ "the pasted file to attach",
511
+ );
512
+
513
+ assert.deepEqual(attachedRows(dom), [["minutes.pdf", "attached"]]);
514
+ assert.equal(bodyOf(dom).querySelector("img"), null);
515
+ });
516
+
517
+ it("attaches a dropped file the way the picker does, leaving the body alone", async () => {
518
+ const dom = await mount();
519
+ const area = dom.query("[data-testid=compose-body-area]");
520
+ if (!area) throw new Error("the compose body area is not mounted");
521
+
522
+ const drop = new Event("drop", { bubbles: true, cancelable: true });
523
+ Object.defineProperty(drop, "dataTransfer", {
524
+ value: transferOf([
525
+ new File(["%PDF"], "minutes.pdf", { type: "application/pdf" }),
526
+ ]),
527
+ });
528
+ dom.dispatch(area, drop);
529
+ await dom.waitFor(
530
+ () => rowIn(dom, "attached"),
531
+ "the dropped file to attach",
532
+ );
533
+
534
+ assert.deepEqual(attachedRows(dom), [["minutes.pdf", "attached"]]);
535
+ assert.equal(drop.defaultPrevented, true, "the browser opened the file");
536
+ assert.equal(
537
+ bodyOf(dom).querySelector("img"),
538
+ null,
539
+ "a dropped file went into the body",
540
+ );
541
+ });
542
+ });
@@ -61,7 +61,7 @@ export const useComposeAttachments = ({
61
61
  complete: (outboxMessageId, outboxAttachmentId) =>
62
62
  completeMutation
63
63
  .mutateAsync({ path: { outboxMessageId, outboxAttachmentId } })
64
- .then(() => undefined),
64
+ .then((attachment) => ({ contentUrl: attachment.contentUrl })),
65
65
  keep: (outboxMessageId, attachmentIds) =>
66
66
  updateMutation
67
67
  .mutateAsync({
@@ -98,6 +98,11 @@ export const useComposeAttachments = ({
98
98
  remove: controller.remove,
99
99
  load: controller.load,
100
100
  reset: controller.reset,
101
+ owns: controller.owns,
102
+ isLoaded: controller.isLoaded,
103
+ refreshLinks: controller.refreshLinks,
104
+ storedContent: controller.storedContent,
105
+ onRemoved: controller.onRemoved,
101
106
  blockingReason: controller.blockingReason(),
102
107
  };
103
108
  };
@@ -0,0 +1,93 @@
1
+ import type { RemitImapOutboxAttachmentResponse } from "@remit/api-http-client/types.gen.ts";
2
+ import type { InlineImagePlacement, InlineImages } from "@remit/ui/rich-text";
3
+ import { useMemo, useRef } from "react";
4
+ import { useAuthProvider } from "@/auth/provider";
5
+ import {
6
+ attachmentFailureContent,
7
+ extractAttachmentFailureDetail,
8
+ extractAttachmentFailureReason,
9
+ fetchAttachment,
10
+ } from "@/lib/attachment-download";
11
+ import type { PushErrorInput } from "../components/ui/error-banners";
12
+ import type { StoredContent } from "../lib/compose-attachment-controller";
13
+
14
+ interface UseInlineImagesOptions {
15
+ attach: (files: File[], placement?: InlineImagePlacement) => Promise<void>;
16
+ owns: (contentId: string) => boolean;
17
+ isLoaded: () => boolean;
18
+ storedContent: (contentId: string) => StoredContent | null;
19
+ refreshLinks: () => Promise<void>;
20
+ onRemoved: (listener: (contentId: string) => void) => () => void;
21
+ draftAttachments: readonly RemitImapOutboxAttachmentResponse[];
22
+ pushError: (input: PushErrorInput) => string;
23
+ }
24
+
25
+ export const useInlineImages = (
26
+ options: UseInlineImagesOptions,
27
+ ): InlineImages => {
28
+ const { getToken } = useAuthProvider();
29
+ const latest = useRef({ ...options, getToken });
30
+ latest.current = { ...options, getToken };
31
+
32
+ return useMemo<InlineImages>(() => {
33
+ const beforeLoad = (contentId: string): StoredContent | null => {
34
+ const current = latest.current;
35
+ if (current.isLoaded()) return null;
36
+ return (
37
+ current.draftAttachments.find(
38
+ (attachment) => attachment.contentId === contentId,
39
+ ) ?? null
40
+ );
41
+ };
42
+
43
+ const stored = (contentId: string): StoredContent | null =>
44
+ latest.current.storedContent(contentId) ?? beforeLoad(contentId);
45
+
46
+ const fetchStored = (content: StoredContent): Promise<string> =>
47
+ fetchAttachment(content.contentUrl, latest.current.getToken).then(
48
+ (blob) =>
49
+ URL.createObjectURL(new Blob([blob], { type: content.contentType })),
50
+ );
51
+
52
+ const fetchRenewed = (contentId: string): Promise<string> =>
53
+ latest.current.refreshLinks().then(() => {
54
+ const renewed = stored(contentId);
55
+ if (!renewed) {
56
+ throw new Error("the file is no longer on this draft");
57
+ }
58
+ return fetchStored(renewed);
59
+ });
60
+
61
+ return {
62
+ attach: (files, placement) => {
63
+ void latest.current.attach(files, placement);
64
+ },
65
+ owns: (contentId) =>
66
+ latest.current.owns(contentId) || beforeLoad(contentId) !== null,
67
+ onRemoved: (listener) => latest.current.onRemoved(listener),
68
+ resolve: (contentId) => {
69
+ const content = stored(contentId);
70
+ if (!content) return Promise.resolve(null);
71
+ return fetchStored(content)
72
+ .catch((error: unknown) => {
73
+ if (extractAttachmentFailureReason(error) !== "link-expired") {
74
+ throw error;
75
+ }
76
+ return fetchRenewed(contentId);
77
+ })
78
+ .catch((error: unknown) => {
79
+ const reason = extractAttachmentFailureReason(error);
80
+ latest.current.pushError({
81
+ ...attachmentFailureContent(
82
+ reason,
83
+ content.filename,
84
+ extractAttachmentFailureDetail(error),
85
+ ),
86
+ error: reason === "generic" ? error : undefined,
87
+ });
88
+ return null;
89
+ });
90
+ },
91
+ };
92
+ }, []);
93
+ };
@@ -55,6 +55,7 @@ const createServer = () => {
55
55
  },
56
56
  complete: async (_draft, outboxAttachmentId) => {
57
57
  completed.push(outboxAttachmentId);
58
+ return { contentUrl: `https://content.test/${outboxAttachmentId}` };
58
59
  },
59
60
  keep: async (_draft, attachmentIds) => {
60
61
  keeps.push(attachmentIds);
@@ -66,12 +67,20 @@ const createServer = () => {
66
67
  },
67
68
  };
68
69
 
70
+ const refuseMint = (filename: string, error: unknown) => {
71
+ const answer = mints.get(filename);
72
+ if (!answer) throw new Error(`no reservation asked for ${filename}`);
73
+ answer.reject(error);
74
+ };
75
+
69
76
  const answerMint = (filename: string, outboxAttachmentId: string) => {
70
77
  const answer = mints.get(filename);
71
78
  if (!answer) throw new Error(`no reservation asked for ${filename}`);
72
79
  answer.resolve({
73
80
  outboxAttachmentId,
74
81
  filename,
82
+ contentType: "application/octet-stream",
83
+ contentId: `${outboxAttachmentId}@test`,
75
84
  uploadUrl: `https://upload.test/${outboxAttachmentId}`,
76
85
  });
77
86
  };
@@ -79,6 +88,7 @@ const createServer = () => {
79
88
  return {
80
89
  transport,
81
90
  answerMint,
91
+ refuseMint,
82
92
  onDraft,
83
93
  put,
84
94
  completed,
@@ -128,6 +138,106 @@ describe("ComposeAttachmentController", () => {
128
138
  assert.equal(controller.blockingReason(), undefined);
129
139
  });
130
140
 
141
+ it("hands the content-id to whoever attached the file once it is stored", async () => {
142
+ const { server, controller } = setup();
143
+ const stored: [string, string][] = [];
144
+ const shot = file("shot.png");
145
+
146
+ const attaching = controller.attach([shot], {
147
+ stored: (attached, contentId) => {
148
+ stored.push([attached.name, contentId]);
149
+ },
150
+ failed: () => {
151
+ throw new Error("a stored file was reported failed");
152
+ },
153
+ });
154
+ await settle();
155
+ assert.deepEqual(stored, [], "named before the bytes were stored");
156
+ server.answerMint("shot.png", "att-shot");
157
+ await attaching;
158
+
159
+ assert.deepEqual(stored, [["shot.png", "att-shot@test"]]);
160
+ assert.deepEqual(controller.storedContent("att-shot@test"), {
161
+ filename: "shot.png",
162
+ contentType: "application/octet-stream",
163
+ contentUrl: "https://content.test/att-shot",
164
+ });
165
+ });
166
+
167
+ it("tells whoever attached a refused file that it did not land, and keeps the refusal on its row", async () => {
168
+ const { server, controller } = setup();
169
+ const failed: string[] = [];
170
+
171
+ const attaching = controller.attach([file("huge.png")], {
172
+ stored: () => {
173
+ throw new Error("a refused file was reported stored");
174
+ },
175
+ failed: (attached) => {
176
+ failed.push(attached.name);
177
+ },
178
+ });
179
+ await settle();
180
+ server.refuseMint("huge.png", {
181
+ code: "attachment_rejected",
182
+ reason: "FileTooLarge",
183
+ message: '"huge.png" is 30.0 MB, over the 25.0 MB a message can carry.',
184
+ });
185
+ await attaching;
186
+
187
+ assert.deepEqual(failed, ["huge.png"]);
188
+ assert.deepEqual(rowFor(controller, "huge.png")?.state, {
189
+ status: "failed",
190
+ reason: '"huge.png" is 30.0 MB, over the 25.0 MB a message can carry.',
191
+ retryable: false,
192
+ });
193
+ });
194
+
195
+ it("names the content-id of a file once the server has dropped it", async () => {
196
+ const { server, controller } = setup();
197
+ const removed: string[] = [];
198
+ controller.onRemoved((contentId) => removed.push(contentId));
199
+
200
+ const attaching = controller.attach([file("shot.png")]);
201
+ await settle();
202
+ server.answerMint("shot.png", "att-shot");
203
+ await attaching;
204
+ await controller.remove(controller.getSnapshot()[0].key);
205
+
206
+ assert.deepEqual(removed, ["att-shot@test"]);
207
+ assert.equal(controller.owns("att-shot@test"), false);
208
+ });
209
+
210
+ it("keeps a file attached while the draft it joins was still loading", async () => {
211
+ const { server, controller } = setup();
212
+
213
+ const attaching = controller.attach([file("minutes.pdf")]);
214
+ await settle();
215
+ controller.load(DRAFT, [
216
+ {
217
+ outboxAttachmentId: "att-earlier",
218
+ outboxMessageId: DRAFT,
219
+ filename: "agenda.pdf",
220
+ contentType: "application/pdf",
221
+ sizeBytes: 5,
222
+ state: "Stored",
223
+ contentId: "att-earlier@remit",
224
+ contentUrl: "https://content.test/att-earlier",
225
+ },
226
+ ]);
227
+ server.answerMint("minutes.pdf", "att-minutes");
228
+ await attaching;
229
+
230
+ assert.deepEqual(
231
+ controller
232
+ .getSnapshot()
233
+ .map((item) => [item.filename, item.state.status]),
234
+ [
235
+ ["agenda.pdf", "attached"],
236
+ ["minutes.pdf", "attached"],
237
+ ],
238
+ );
239
+ });
240
+
131
241
  it("takes a file removed while its reservation is out off the draft, and never uploads it", async () => {
132
242
  const { server, controller } = setup();
133
243
 
@@ -12,9 +12,26 @@ export type DraftForAttachment =
12
12
  export interface MintedAttachment {
13
13
  outboxAttachmentId: string;
14
14
  filename: string;
15
+ contentType: string;
16
+ contentId: string;
15
17
  uploadUrl: string;
16
18
  }
17
19
 
20
+ export interface CompletedAttachment {
21
+ contentUrl: string;
22
+ }
23
+
24
+ export interface AttachmentPlacement {
25
+ stored: (file: File, contentId: string) => void;
26
+ failed: (file: File) => void;
27
+ }
28
+
29
+ export interface StoredContent {
30
+ filename: string;
31
+ contentType: string;
32
+ contentUrl: string;
33
+ }
34
+
18
35
  /** The four requests a file on a draft is made of. Each throws on failure. */
19
36
  export interface AttachmentTransport {
20
37
  mint: (outboxMessageId: string, file: File) => Promise<MintedAttachment>;
@@ -22,7 +39,7 @@ export interface AttachmentTransport {
22
39
  complete: (
23
40
  outboxMessageId: string,
24
41
  outboxAttachmentId: string,
25
- ) => Promise<void>;
42
+ ) => Promise<CompletedAttachment>;
26
43
  keep: (outboxMessageId: string, attachmentIds: string[]) => Promise<void>;
27
44
  }
28
45
 
@@ -34,6 +51,10 @@ export interface ComposeAttachmentControllerDeps {
34
51
  interface Entry extends ComposeAttachmentItem {
35
52
  serverId: string | null;
36
53
  file: File | null;
54
+ placement: AttachmentPlacement | null;
55
+ contentId: string | null;
56
+ contentType: string;
57
+ contentUrl: string | null;
37
58
  }
38
59
 
39
60
  interface AttachmentRejection {
@@ -91,6 +112,10 @@ const fromServer = (attachment: RemitImapOutboxAttachmentResponse): Entry => ({
91
112
  key: attachment.outboxAttachmentId,
92
113
  serverId: attachment.outboxAttachmentId,
93
114
  file: null,
115
+ placement: null,
116
+ contentId: attachment.contentId,
117
+ contentType: attachment.contentType,
118
+ contentUrl: attachment.contentUrl,
94
119
  filename: attachment.filename,
95
120
  sizeBytes: attachment.sizeBytes,
96
121
  state:
@@ -124,6 +149,8 @@ export class ComposeAttachmentController {
124
149
  private readonly inFlight = new Map<string, AbortController>();
125
150
  private readonly mintedAfterRemoval = new Map<string, string>();
126
151
  private readonly removing = new Map<string, string>();
152
+ private readonly removedListeners = new Set<(contentId: string) => void>();
153
+ private loaded = false;
127
154
 
128
155
  constructor(private readonly deps: ComposeAttachmentControllerDeps) {}
129
156
 
@@ -134,6 +161,47 @@ export class ComposeAttachmentController {
134
161
 
135
162
  getSnapshot = (): ComposeAttachmentItem[] => this.snapshot;
136
163
 
164
+ owns = (contentId: string): boolean =>
165
+ this.entries.some((entry) => entry.contentId === contentId);
166
+
167
+ storedContent = (contentId: string): StoredContent | null => {
168
+ const entry = this.entries.find(
169
+ (candidate) =>
170
+ candidate.contentId === contentId && candidate.contentUrl !== null,
171
+ );
172
+ if (!entry?.contentUrl) return null;
173
+ return {
174
+ filename: entry.filename,
175
+ contentType: entry.contentType,
176
+ contentUrl: entry.contentUrl,
177
+ };
178
+ };
179
+
180
+ isLoaded = (): boolean => this.loaded;
181
+
182
+ refreshLinks = (
183
+ attachments: readonly RemitImapOutboxAttachmentResponse[],
184
+ ): void => {
185
+ const links = new Map(
186
+ attachments.map((attachment) => [
187
+ attachment.outboxAttachmentId,
188
+ attachment.contentUrl,
189
+ ]),
190
+ );
191
+ this.set(
192
+ this.entries.map((entry) => {
193
+ const contentUrl =
194
+ entry.serverId === null ? undefined : links.get(entry.serverId);
195
+ return contentUrl === undefined ? entry : { ...entry, contentUrl };
196
+ }),
197
+ );
198
+ };
199
+
200
+ onRemoved = (listener: (contentId: string) => void): (() => void) => {
201
+ this.removedListeners.add(listener);
202
+ return () => this.removedListeners.delete(listener);
203
+ };
204
+
137
205
  blockingReason = (): string | undefined => {
138
206
  const [removing] = this.removing.values();
139
207
  if (removing !== undefined) return `Removing "${removing}"…`;
@@ -210,6 +278,8 @@ export class ComposeAttachmentController {
210
278
  this.patch(generation, key, {
211
279
  serverId: minted.outboxAttachmentId,
212
280
  filename: minted.filename,
281
+ contentId: minted.contentId,
282
+ contentType: minted.contentType,
213
283
  });
214
284
  }
215
285
  settle();
@@ -232,19 +302,29 @@ export class ComposeAttachmentController {
232
302
 
233
303
  const completed = await transport
234
304
  .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 });
305
+ .catch((error: unknown) => {
306
+ this.patch(generation, key, {
307
+ state: refusalOf(minted.filename, error),
308
+ });
309
+ return null;
310
+ });
311
+ if (completed === null || signal.aborted) return;
312
+ this.patch(generation, key, {
313
+ state: ATTACHED,
314
+ contentUrl: completed.contentUrl,
315
+ });
316
+ if (generation !== this.generation) return;
317
+ this.entries
318
+ .find((entry) => entry.key === key)
319
+ ?.placement?.stored(file, minted.contentId);
246
320
  };
247
321
 
322
+ private settleFailure(generation: number, key: string, file: File): void {
323
+ if (generation !== this.generation) return;
324
+ const entry = this.entries.find((candidate) => candidate.key === key);
325
+ if (entry?.state.status === "failed") entry.placement?.failed(file);
326
+ }
327
+
248
328
  private run = async (
249
329
  generation: number,
250
330
  key: string,
@@ -254,6 +334,7 @@ export class ComposeAttachmentController {
254
334
  ): Promise<void> => {
255
335
  await this.upload(generation, key, file, outboxMessageId, signal);
256
336
  if (this.inFlight.get(key)?.signal === signal) this.inFlight.delete(key);
337
+ this.settleFailure(generation, key, file);
257
338
  };
258
339
 
259
340
  /**
@@ -282,7 +363,10 @@ export class ComposeAttachmentController {
282
363
  );
283
364
  };
284
365
 
285
- attach = async (files: File[]): Promise<void> => {
366
+ attach = async (
367
+ files: File[],
368
+ placement?: AttachmentPlacement,
369
+ ): Promise<void> => {
286
370
  const generation = this.generation;
287
371
  const added = files.map((file) => {
288
372
  this.nextKey += 1;
@@ -290,6 +374,10 @@ export class ComposeAttachmentController {
290
374
  key: `local-${this.nextKey}`,
291
375
  serverId: null,
292
376
  file,
377
+ placement: placement ?? null,
378
+ contentId: null,
379
+ contentType: file.type || "application/octet-stream",
380
+ contentUrl: null,
293
381
  filename: file.name,
294
382
  sizeBytes: file.size,
295
383
  state: UPLOADING,
@@ -302,11 +390,12 @@ export class ComposeAttachmentController {
302
390
 
303
391
  const draft = await this.deps.ensureDraft();
304
392
  if (draft.outcome === "refused") {
305
- for (const { entry } of added) {
393
+ for (const { entry, file } of added) {
306
394
  this.inFlight.delete(entry.key);
307
395
  this.patch(generation, entry.key, {
308
396
  state: failed(draft.reason, true),
309
397
  });
398
+ this.settleFailure(generation, entry.key, file);
310
399
  }
311
400
  return;
312
401
  }
@@ -379,7 +468,12 @@ export class ComposeAttachmentController {
379
468
  this.set(this.entries);
380
469
  const serverId = entry.serverId ?? this.mintedAfterRemoval.get(key) ?? null;
381
470
  this.mintedAfterRemoval.delete(key);
382
- if (failure === null || generation !== this.generation) return;
471
+ if (generation !== this.generation) return;
472
+ if (failure === null) {
473
+ if (entry.contentId === null) return;
474
+ for (const listener of this.removedListeners) listener(entry.contentId);
475
+ return;
476
+ }
383
477
  if (serverId === null) return;
384
478
  this.set([
385
479
  ...this.entries,
@@ -400,7 +494,16 @@ export class ComposeAttachmentController {
400
494
  attachments: readonly RemitImapOutboxAttachmentResponse[],
401
495
  ): void => {
402
496
  this.draftId = outboxMessageId;
403
- this.set(attachments.map(fromServer));
497
+ this.loaded = true;
498
+ const listed = new Set(
499
+ attachments.map((attachment) => attachment.outboxAttachmentId),
500
+ );
501
+ const attachedHere = this.entries.filter(
502
+ (entry) =>
503
+ entry.file !== null &&
504
+ (entry.serverId === null || !listed.has(entry.serverId)),
505
+ );
506
+ this.set([...attachments.map(fromServer), ...attachedHere]);
404
507
  };
405
508
 
406
509
  reset = (outboxMessageId: string | undefined): void => {
@@ -410,6 +513,7 @@ export class ComposeAttachmentController {
410
513
  this.mintedAfterRemoval.clear();
411
514
  this.removing.clear();
412
515
  this.draftId = outboxMessageId;
516
+ this.loaded = false;
413
517
  this.set([]);
414
518
  };
415
519
  }