@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.
@@ -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
  );