@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 +1 -1
- package/src/components/compose/ComposeForm.tsx +38 -4
- package/src/components/compose/compose-inline-image.render.test.ts +542 -0
- package/src/hooks/useComposeAttachments.ts +6 -1
- package/src/hooks/useInlineImages.ts +93 -0
- package/src/lib/compose-attachment-controller.test.ts +110 -0
- package/src/lib/compose-attachment-controller.ts +120 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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.
|
|
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(() =>
|
|
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<
|
|
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
|
-
.
|
|
236
|
-
(
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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 (
|
|
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 (
|
|
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.
|
|
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
|
}
|