@remit/web-client 0.0.311 → 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 +76 -13
- package/src/components/compose/ComposeProvider.tsx +35 -1
- package/src/components/compose/ConversationCompose.tsx +19 -0
- package/src/components/compose/compose-inline-image.render.test.ts +542 -0
- package/src/components/compose/compose.stories.tsx +13 -0
- package/src/components/compose/reply-with-times.ts +22 -0
- package/src/components/mail/BriefPane.tsx +7 -0
- package/src/components/mail/FlaggedPane.tsx +7 -0
- package/src/components/mail/IntelligenceDrawer.tsx +4 -0
- package/src/components/mail/IntelligencePane.tsx +7 -1
- package/src/components/mail/MailboxPane.tsx +7 -0
- package/src/components/mail/offer-times-reply.render.test.ts +529 -0
- package/src/hooks/intelligence-calendar.render.test.ts +0 -19
- package/src/hooks/useComposeAttachments.ts +6 -1
- package/src/hooks/useInlineImages.ts +93 -0
- package/src/hooks/useIntelligenceCalendar.ts +8 -19
- package/src/lib/compose-attachment-controller.test.ts +110 -0
- package/src/lib/compose-attachment-controller.ts +120 -16
- package/src/lib/mail-search.ts +17 -0
- package/src/routes/mail/$mailboxId/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +2 -1
- package/src/routes/mail/brief/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +2 -1
- package/src/routes/mail/flagged/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +2 -1
- package/src/routing/compose.ts +2 -1
- package/src/routing/outbox-draft.ts +2 -1
- package/src/routing/reply.ts +34 -8
|
@@ -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
|
+
});
|
|
@@ -174,6 +174,19 @@ export const Reply: Story = {
|
|
|
174
174
|
},
|
|
175
175
|
};
|
|
176
176
|
|
|
177
|
+
const OFFERED_TIMES = "Thursday 11 June: 12:30 – 13:00, 15:00 – 15:30";
|
|
178
|
+
|
|
179
|
+
export const ReplyWithOfferedTimes: Story = {
|
|
180
|
+
args: {
|
|
181
|
+
url: `${messageUrl("lunch")}/reply?body=${encodeURIComponent(OFFERED_TIMES)}`,
|
|
182
|
+
},
|
|
183
|
+
play: async () => {
|
|
184
|
+
const subject = await page().findByPlaceholderText("Subject");
|
|
185
|
+
await waitFor(() => expect(subject).toHaveValue(`Re: ${LUNCH_SUBJECT}`));
|
|
186
|
+
await expect(await writingSurface(OFFERED_TIMES)).toBeVisible();
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
|
|
177
190
|
export const Forward: Story = {
|
|
178
191
|
args: { url: `${messageUrl("lunch")}/forward` },
|
|
179
192
|
play: async () => {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { useCallback } from "react";
|
|
2
|
+
import { useOpenReply } from "@/routing";
|
|
3
|
+
import { useCompose } from "./ComposeProvider";
|
|
4
|
+
|
|
5
|
+
export interface AnsweredMessage {
|
|
6
|
+
threadId: string;
|
|
7
|
+
messageId: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ReplyWithText = (message: AnsweredMessage, text: string) => void;
|
|
11
|
+
|
|
12
|
+
export function useReplyWithText(): ReplyWithText {
|
|
13
|
+
const { insertIntoOpenReply } = useCompose();
|
|
14
|
+
const openReply = useOpenReply();
|
|
15
|
+
return useCallback(
|
|
16
|
+
(message: AnsweredMessage, text: string) => {
|
|
17
|
+
if (insertIntoOpenReply(message.messageId, text)) return;
|
|
18
|
+
openReply({ ...message, mode: "reply" }, { body: text });
|
|
19
|
+
},
|
|
20
|
+
[insertIntoOpenReply, openReply],
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
useContext,
|
|
24
24
|
useMemo,
|
|
25
25
|
} from "react";
|
|
26
|
+
import { useReplyWithText } from "@/components/compose/reply-with-times";
|
|
26
27
|
import { ConversationView } from "@/components/mail/ConversationView";
|
|
27
28
|
import { DailyBrief } from "@/components/mail/DailyBrief";
|
|
28
29
|
import { IntelligenceDrawer } from "@/components/mail/IntelligenceDrawer";
|
|
@@ -317,6 +318,7 @@ function BriefList() {
|
|
|
317
318
|
* Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
|
|
318
319
|
*/
|
|
319
320
|
function BriefReading() {
|
|
321
|
+
const replyWithText = useReplyWithText();
|
|
320
322
|
const {
|
|
321
323
|
conversation,
|
|
322
324
|
selectedThread,
|
|
@@ -374,6 +376,7 @@ function BriefReading() {
|
|
|
374
376
|
mailboxId={selectedThread?.mailboxId}
|
|
375
377
|
accountId={selectedThread?.accountId}
|
|
376
378
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
379
|
+
onReplyWithText={replyWithText}
|
|
377
380
|
/>
|
|
378
381
|
</>
|
|
379
382
|
);
|
|
@@ -384,6 +387,7 @@ function BriefReading() {
|
|
|
384
387
|
* Mount in the `intelligence` slot of `AppShellSlotted`. Only rendered ≥ 1280px.
|
|
385
388
|
*/
|
|
386
389
|
function BriefIntelligence() {
|
|
390
|
+
const replyWithText = useReplyWithText();
|
|
387
391
|
const { selectedThread, handleDeselectIfRemoved } = useBriefPane();
|
|
388
392
|
const { onToggleIntelligence } = useMailContext();
|
|
389
393
|
|
|
@@ -394,6 +398,7 @@ function BriefIntelligence() {
|
|
|
394
398
|
mailboxId={selectedThread?.mailboxId}
|
|
395
399
|
accountId={selectedThread?.accountId}
|
|
396
400
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
401
|
+
onReplyWithText={replyWithText}
|
|
397
402
|
/>
|
|
398
403
|
);
|
|
399
404
|
}
|
|
@@ -402,6 +407,7 @@ function BriefIntelligence() {
|
|
|
402
407
|
* Phone view: ConversationView when thread is open, or the DailyBrief list.
|
|
403
408
|
*/
|
|
404
409
|
function BriefPhone() {
|
|
410
|
+
const replyWithText = useReplyWithText();
|
|
405
411
|
const {
|
|
406
412
|
selectedThread,
|
|
407
413
|
conversation,
|
|
@@ -437,6 +443,7 @@ function BriefPhone() {
|
|
|
437
443
|
mailboxId={selectedThread?.mailboxId}
|
|
438
444
|
accountId={selectedThread?.accountId}
|
|
439
445
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
446
|
+
onReplyWithText={replyWithText}
|
|
440
447
|
/>
|
|
441
448
|
</>
|
|
442
449
|
);
|
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
useContext,
|
|
34
34
|
useMemo,
|
|
35
35
|
} from "react";
|
|
36
|
+
import { useReplyWithText } from "@/components/compose/reply-with-times";
|
|
36
37
|
import { ConversationView } from "@/components/mail/ConversationView";
|
|
37
38
|
import { FlaggedList } from "@/components/mail/FlaggedList";
|
|
38
39
|
import { IntelligenceDrawer } from "@/components/mail/IntelligenceDrawer";
|
|
@@ -320,6 +321,7 @@ function FlaggedListSlot() {
|
|
|
320
321
|
* Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
|
|
321
322
|
*/
|
|
322
323
|
function FlaggedReading() {
|
|
324
|
+
const replyWithText = useReplyWithText();
|
|
323
325
|
const {
|
|
324
326
|
conversation,
|
|
325
327
|
selectedThread,
|
|
@@ -377,6 +379,7 @@ function FlaggedReading() {
|
|
|
377
379
|
mailboxId={selectedThread?.mailboxId}
|
|
378
380
|
accountId={selectedThread?.accountId}
|
|
379
381
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
382
|
+
onReplyWithText={replyWithText}
|
|
380
383
|
/>
|
|
381
384
|
</>
|
|
382
385
|
);
|
|
@@ -387,6 +390,7 @@ function FlaggedReading() {
|
|
|
387
390
|
* Mount in the `intelligence` slot of `AppShellSlotted`. Only rendered ≥ 1280px.
|
|
388
391
|
*/
|
|
389
392
|
function FlaggedIntelligence() {
|
|
393
|
+
const replyWithText = useReplyWithText();
|
|
390
394
|
const { selectedThread, handleDeselectIfRemoved } = useFlaggedPane();
|
|
391
395
|
const { onToggleIntelligence } = useMailContext();
|
|
392
396
|
|
|
@@ -397,12 +401,14 @@ function FlaggedIntelligence() {
|
|
|
397
401
|
mailboxId={selectedThread?.mailboxId}
|
|
398
402
|
accountId={selectedThread?.accountId}
|
|
399
403
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
404
|
+
onReplyWithText={replyWithText}
|
|
400
405
|
/>
|
|
401
406
|
);
|
|
402
407
|
}
|
|
403
408
|
|
|
404
409
|
/** Phone view: ConversationView when a thread is open, else the flat list. */
|
|
405
410
|
function FlaggedPhone() {
|
|
411
|
+
const replyWithText = useReplyWithText();
|
|
406
412
|
const {
|
|
407
413
|
selectedThread,
|
|
408
414
|
conversation,
|
|
@@ -438,6 +444,7 @@ function FlaggedPhone() {
|
|
|
438
444
|
mailboxId={selectedThread?.mailboxId}
|
|
439
445
|
accountId={selectedThread?.accountId}
|
|
440
446
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
447
|
+
onReplyWithText={replyWithText}
|
|
441
448
|
/>
|
|
442
449
|
</>
|
|
443
450
|
);
|