@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.
@@ -1,4 +1,5 @@
1
1
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
+ import type { ReplyWithText } from "@/components/compose/reply-with-times";
2
3
  import { Drawer } from "@/components/layout/Drawer";
3
4
  import { IntelligencePane } from "@/components/mail/IntelligencePane";
4
5
 
@@ -16,6 +17,7 @@ export function IntelligenceDrawer({
16
17
  mailboxId,
17
18
  accountId,
18
19
  onAfterOptimisticRemove,
20
+ onReplyWithText,
19
21
  }: {
20
22
  isOpen: boolean;
21
23
  onClose: () => void;
@@ -23,6 +25,7 @@ export function IntelligenceDrawer({
23
25
  mailboxId?: string;
24
26
  accountId?: string;
25
27
  onAfterOptimisticRemove?: (messageIds: string[]) => void;
28
+ onReplyWithText?: ReplyWithText;
26
29
  }) {
27
30
  return (
28
31
  <Drawer
@@ -41,6 +44,7 @@ export function IntelligenceDrawer({
41
44
  accountId={accountId}
42
45
  hideCloseButton
43
46
  onAfterOptimisticRemove={onAfterOptimisticRemove}
47
+ onReplyWithText={onReplyWithText}
44
48
  />
45
49
  </Drawer>
46
50
  );
@@ -20,6 +20,7 @@ import { isRescueCandidate } from "@/lib/rescue-candidates";
20
20
  import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
21
21
  import { useTelemetry } from "@/lib/telemetry-context";
22
22
  import { NavLink } from "@/routing";
23
+ import type { ReplyWithText } from "../compose/reply-with-times";
23
24
 
24
25
  export interface IntelligencePaneProps {
25
26
  onClose: () => void;
@@ -53,6 +54,7 @@ export interface IntelligencePaneProps {
53
54
  * occasional unneeded pane-close — not a bug; see `useReportSpam`.
54
55
  */
55
56
  onAfterOptimisticRemove?: (messageIds: string[]) => void;
57
+ onReplyWithText?: ReplyWithText;
56
58
  }
57
59
 
58
60
  /**
@@ -251,6 +253,7 @@ interface WiredPanelProps {
251
253
  accountId?: string;
252
254
  hideCloseButton?: boolean;
253
255
  onAfterOptimisticRemove?: (messageIds: string[]) => void;
256
+ onReplyWithText?: ReplyWithText;
254
257
  }
255
258
 
256
259
  /**
@@ -263,6 +266,7 @@ function WiredPanel({
263
266
  accountId,
264
267
  hideCloseButton,
265
268
  onAfterOptimisticRemove,
269
+ onReplyWithText,
266
270
  }: WiredPanelProps) {
267
271
  const {
268
272
  data,
@@ -273,7 +277,7 @@ function WiredPanel({
273
277
  similarErrorIsFatal,
274
278
  semanticEnabled,
275
279
  } = useIntelligenceData(thread, mailboxId);
276
- const calendar = useIntelligenceCalendar(thread);
280
+ const calendar = useIntelligenceCalendar(thread, onReplyWithText);
277
281
  const [reclassifyOpen, setReclassifyOpen] = useState(false);
278
282
  const senderEmail = thread.fromEmail ?? undefined;
279
283
 
@@ -432,6 +436,7 @@ export const IntelligencePane = ({
432
436
  accountId,
433
437
  hideCloseButton,
434
438
  onAfterOptimisticRemove,
439
+ onReplyWithText,
435
440
  }: IntelligencePaneProps) => {
436
441
  if (!thread) {
437
442
  return (
@@ -461,6 +466,7 @@ export const IntelligencePane = ({
461
466
  accountId={accountId}
462
467
  hideCloseButton={hideCloseButton}
463
468
  onAfterOptimisticRemove={onAfterOptimisticRemove}
469
+ onReplyWithText={onReplyWithText}
464
470
  />
465
471
  );
466
472
  };
@@ -45,6 +45,7 @@ import {
45
45
  useRef,
46
46
  useState,
47
47
  } from "react";
48
+ import { useReplyWithText } from "@/components/compose/reply-with-times";
48
49
  import { ConversationView } from "@/components/mail/ConversationView";
49
50
  import { DraftsView } from "@/components/mail/DraftsView";
50
51
  import { EmptyTrashBar } from "@/components/mail/EmptyTrashBar";
@@ -1115,6 +1116,7 @@ function MailboxList() {
1115
1116
  * Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
1116
1117
  */
1117
1118
  function MailboxReading() {
1119
+ const replyWithText = useReplyWithText();
1118
1120
  const {
1119
1121
  mailboxId,
1120
1122
  mailboxAccountId,
@@ -1204,6 +1206,7 @@ function MailboxReading() {
1204
1206
  mailboxId={mailboxId}
1205
1207
  accountId={mailboxAccountId}
1206
1208
  onAfterOptimisticRemove={handleDeselectIfRemoved}
1209
+ onReplyWithText={replyWithText}
1207
1210
  />
1208
1211
  </>
1209
1212
  );
@@ -1214,6 +1217,7 @@ function MailboxReading() {
1214
1217
  * Mount in the `intelligence` slot of `AppShellSlotted`. Only rendered ≥ 1280px.
1215
1218
  */
1216
1219
  function MailboxIntelligence() {
1220
+ const replyWithText = useReplyWithText();
1217
1221
  const {
1218
1222
  mailboxId,
1219
1223
  mailboxAccountId,
@@ -1229,6 +1233,7 @@ function MailboxIntelligence() {
1229
1233
  mailboxId={mailboxId}
1230
1234
  accountId={mailboxAccountId}
1231
1235
  onAfterOptimisticRemove={handleDeselectIfRemoved}
1236
+ onReplyWithText={replyWithText}
1232
1237
  />
1233
1238
  );
1234
1239
  }
@@ -1238,6 +1243,7 @@ function MailboxIntelligence() {
1238
1243
  * Use this on phones instead of the slot sub-views.
1239
1244
  */
1240
1245
  function MailboxPhone() {
1246
+ const replyWithText = useReplyWithText();
1241
1247
  const {
1242
1248
  mailboxId,
1243
1249
  mailboxAccountId,
@@ -1281,6 +1287,7 @@ function MailboxPhone() {
1281
1287
  mailboxId={mailboxId}
1282
1288
  accountId={mailboxAccountId}
1283
1289
  onAfterOptimisticRemove={handleDeselectIfRemoved}
1290
+ onReplyWithText={replyWithText}
1284
1291
  />
1285
1292
  </>
1286
1293
  );
@@ -0,0 +1,529 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, before, describe, it } from "node:test";
3
+ import type {
4
+ RemitImapCalendarResponse,
5
+ RemitImapCalendarSuggestionResponse,
6
+ RemitImapDescribeMessageResponse,
7
+ RemitImapThreadMessageResponse,
8
+ } from "@remit/api-http-client/types.gen.ts";
9
+ import { type IntelligenceData, IntelligencePanel } from "@remit/ui";
10
+ import {
11
+ type AnyRouter,
12
+ createMemoryHistory,
13
+ RouterContextProvider,
14
+ } from "@tanstack/react-router";
15
+ import { createElement } from "react";
16
+ import { ComposeProvider } from "@/components/compose/ComposeProvider";
17
+ import { useReplyWithText } from "@/components/compose/reply-with-times";
18
+ import { useIntelligenceCalendar } from "@/hooks/useIntelligenceCalendar";
19
+ import { noopTelemetry } from "@/lib/telemetry";
20
+ import { createAppRouter } from "@/router";
21
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
22
+ import {
23
+ makeAccount,
24
+ makeMailbox,
25
+ makeThreadMessage,
26
+ } from "@/test-support/fixtures";
27
+ import { type HttpCall, type HttpMock, mockFetch } from "@/test-support/http";
28
+ import { ConversationView } from "./ConversationView";
29
+
30
+ const ACCOUNT_ID = "acc-1";
31
+ const MAILBOX_ID = "mbx-inbox";
32
+ const THREAD_ID = "thread-1";
33
+ const MESSAGE_ID = "msg-1";
34
+ const OTHER_ID = "msg-2";
35
+ const DRAFT_ID = "ob-times";
36
+ const CALENDAR = "11111111-1111-4111-8111-111111111111";
37
+ const SUGGESTION = "22222222-2222-4222-8222-222222222222";
38
+ const AUTOSAVE_DEBOUNCE_MS = 2000;
39
+
40
+ const account = makeAccount({
41
+ accountId: ACCOUNT_ID,
42
+ email: "me@example.com",
43
+ username: "me@example.com",
44
+ smtpUsername: "me@example.com",
45
+ });
46
+
47
+ const inbox = makeMailbox({
48
+ mailboxId: MAILBOX_ID,
49
+ accountId: ACCOUNT_ID,
50
+ fullPath: "INBOX",
51
+ });
52
+
53
+ const thread: RemitImapThreadMessageResponse = makeThreadMessage({
54
+ messageId: MESSAGE_ID,
55
+ threadId: THREAD_ID,
56
+ mailboxId: MAILBOX_ID,
57
+ accountId: ACCOUNT_ID,
58
+ subject: "Quarterly review",
59
+ fromName: "Organizer",
60
+ fromEmail: "organizer@example.test",
61
+ isRead: true,
62
+ });
63
+
64
+ const otherThread: RemitImapThreadMessageResponse = {
65
+ ...thread,
66
+ messageId: OTHER_ID,
67
+ };
68
+
69
+ const describeMessage: RemitImapDescribeMessageResponse = {
70
+ message: {
71
+ messageId: MESSAGE_ID,
72
+ mailboxId: MAILBOX_ID,
73
+ uid: 1,
74
+ rfc822Size: 512,
75
+ internalDate: 1_767_225_600_000,
76
+ status: "active",
77
+ syncStatus: "pending",
78
+ abandonedMutation: "none",
79
+ },
80
+ envelope: {
81
+ messageId: MESSAGE_ID,
82
+ date: 1_767_225_600_000,
83
+ subject: "Quarterly review",
84
+ messageIdValue: "<review-1@example.test>",
85
+ from: [
86
+ {
87
+ addressId: "addr-organizer",
88
+ displayName: "Organizer",
89
+ normalizedEmail: "organizer@example.test",
90
+ addressRole: "from",
91
+ addressOrder: 0,
92
+ },
93
+ ],
94
+ to: [
95
+ {
96
+ addressId: "addr-me",
97
+ normalizedEmail: "me@example.com",
98
+ addressRole: "to",
99
+ addressOrder: 0,
100
+ },
101
+ ],
102
+ cc: [],
103
+ bcc: [],
104
+ replyTo: [],
105
+ category: "uncategorized",
106
+ senderTrust: "unknown",
107
+ },
108
+ flags: ["\\Seen"],
109
+ bodyParts: [],
110
+ references: [],
111
+ };
112
+
113
+ const calendars = [
114
+ {
115
+ calendarId: CALENDAR,
116
+ accountConfigId: "cfg-1",
117
+ urlSegment: "default",
118
+ displayName: "Personal",
119
+ color: "Cal2",
120
+ componentSet: "VeventOnly",
121
+ source: "Default",
122
+ timezone: "Europe/Amsterdam",
123
+ syncSequence: 1,
124
+ subscriptionUrl: "",
125
+ subscriptionEnabled: false,
126
+ subscriptionCheckedAt: 0,
127
+ subscriptionFetchedAt: 0,
128
+ subscriptionError: "",
129
+ createdAt: 0,
130
+ updatedAt: 0,
131
+ },
132
+ ] satisfies RemitImapCalendarResponse[];
133
+
134
+ const invitation: RemitImapCalendarSuggestionResponse = {
135
+ suggestionId: SUGGESTION,
136
+ accountConfigId: "cfg-1",
137
+ messageId: MESSAGE_ID,
138
+ bodyPartId: "part-1",
139
+ icalUid: "uid-1",
140
+ sequence: 0,
141
+ method: "Request",
142
+ source: "IcalendarPart",
143
+ state: "Pending",
144
+ summary: "Quarterly review",
145
+ dtStart: "2026-09-01T08:00:00+00:00",
146
+ dtEnd: "2026-09-01T09:00:00+00:00",
147
+ allDay: false,
148
+ location: "",
149
+ organizer: "organizer@example.test",
150
+ zoneCertainty: "Explicit",
151
+ acceptedCalendarObjectId: "",
152
+ createdAt: 0,
153
+ updatedAt: 0,
154
+ };
155
+
156
+ const sender: IntelligenceData = {
157
+ sender: {
158
+ name: "Organizer",
159
+ email: "organizer@example.test",
160
+ trust: "wellknown",
161
+ firstSeenLabel: "Jan 2025",
162
+ },
163
+ authenticity: {
164
+ verdict: "aligned",
165
+ fromDomain: "example.test",
166
+ summary: "Signed by example.test.",
167
+ },
168
+ category: { value: "personal" },
169
+ similar: [],
170
+ };
171
+
172
+ let harness: DomHarness | undefined;
173
+ let http: HttpMock | undefined;
174
+
175
+ before(() => {
176
+ Object.defineProperty(Range.prototype, "getBoundingClientRect", {
177
+ value: () => ({
178
+ top: 0,
179
+ bottom: 0,
180
+ left: 0,
181
+ right: 0,
182
+ width: 0,
183
+ height: 0,
184
+ x: 0,
185
+ y: 0,
186
+ }),
187
+ configurable: true,
188
+ });
189
+ });
190
+
191
+ afterEach(() => {
192
+ harness?.close();
193
+ harness = undefined;
194
+ http?.restore();
195
+ http = undefined;
196
+ });
197
+
198
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
199
+
200
+ const MESSAGE_PATH = `/mail/${MAILBOX_ID}/${THREAD_ID}/${MESSAGE_ID}`;
201
+
202
+ const describeOther: RemitImapDescribeMessageResponse = {
203
+ ...describeMessage,
204
+ message: { ...describeMessage.message, messageId: OTHER_ID, uid: 2 },
205
+ envelope: {
206
+ ...describeMessage.envelope,
207
+ messageId: OTHER_ID,
208
+ messageIdValue: "<review-2@example.test>",
209
+ },
210
+ };
211
+
212
+ const CalendarRail = ({
213
+ railThread,
214
+ }: {
215
+ railThread: RemitImapThreadMessageResponse;
216
+ }) => {
217
+ const calendar = useIntelligenceCalendar(railThread, useReplyWithText());
218
+ return createElement(IntelligencePanel, {
219
+ data: sender,
220
+ calendar: calendar.surface,
221
+ tab: calendar.tab,
222
+ onTabChange: calendar.onTabChange,
223
+ });
224
+ };
225
+
226
+ const Surface = ({
227
+ railThread,
228
+ }: {
229
+ railThread: RemitImapThreadMessageResponse;
230
+ }) =>
231
+ createElement(
232
+ ComposeProvider,
233
+ null,
234
+ createElement(ConversationView, {
235
+ threadId: THREAD_ID,
236
+ mailboxId: MAILBOX_ID,
237
+ subject: "Quarterly review",
238
+ selectedMessageId: MESSAGE_ID,
239
+ }),
240
+ createElement(CalendarRail, { railThread }),
241
+ );
242
+
243
+ const savedDraft = {
244
+ outboxMessageId: DRAFT_ID,
245
+ accountId: ACCOUNT_ID,
246
+ fromAddress: "me@example.com",
247
+ toAddresses: ["organizer@example.test"],
248
+ ccAddresses: [],
249
+ bccAddresses: [],
250
+ attachments: [],
251
+ references: [],
252
+ subject: "Re: Quarterly review",
253
+ textBody: "See you then",
254
+ htmlBody: "<p>See you then</p>",
255
+ status: "draft",
256
+ };
257
+
258
+ const creates = (): HttpCall[] =>
259
+ (http?.calls ?? []).filter(
260
+ (call) => call.method === "POST" && call.path.endsWith("/outbox"),
261
+ );
262
+
263
+ const mount = async (
264
+ href: string,
265
+ {
266
+ railThread = thread,
267
+ draftArrives = Promise.resolve(),
268
+ }: {
269
+ railThread?: RemitImapThreadMessageResponse;
270
+ draftArrives?: Promise<void>;
271
+ } = {},
272
+ ): Promise<{ mounted: DomHarness; router: AnyRouter; redraw: () => void }> => {
273
+ let saved: Record<string, unknown> = savedDraft;
274
+ http = mockFetch(async (call) => {
275
+ if (call.path.endsWith("/config")) return { accounts: [account] };
276
+ if (call.path.endsWith(`/accounts/${ACCOUNT_ID}/mailboxes`))
277
+ return { items: [inbox] };
278
+ if (call.path.endsWith(`/threads/${THREAD_ID}/messages`))
279
+ return { items: [thread, otherThread] };
280
+ if (call.path.endsWith(`/messages/${MESSAGE_ID}`)) return describeMessage;
281
+ if (call.path.endsWith(`/messages/${OTHER_ID}`)) return describeOther;
282
+ if (call.path.endsWith(`/messages/${OTHER_ID}/calendar-suggestions`))
283
+ return { items: [{ ...invitation, messageId: OTHER_ID }] };
284
+ if (call.path.endsWith("/calendars")) return { items: calendars };
285
+ if (call.path.endsWith(`/messages/${MESSAGE_ID}/calendar-suggestions`))
286
+ return { items: [invitation] };
287
+ if (call.method === "POST" && call.path.endsWith("/outbox")) {
288
+ saved = {
289
+ ...savedDraft,
290
+ ...(call.body as Record<string, unknown>),
291
+ };
292
+ return saved;
293
+ }
294
+ if (call.path.endsWith(`/outbox/${DRAFT_ID}`)) {
295
+ await draftArrives;
296
+ return saved;
297
+ }
298
+ return { items: [] };
299
+ });
300
+
301
+ const mounted = createDomHarness();
302
+ harness = mounted;
303
+ const router = createAppRouter(
304
+ mounted.queryClient,
305
+ noopTelemetry,
306
+ createMemoryHistory({ initialEntries: [href] }),
307
+ ) as unknown as AnyRouter;
308
+ await router.load();
309
+ const redraw = () =>
310
+ mounted.renderApp(
311
+ createElement(RouterContextProvider, {
312
+ router,
313
+ // biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
314
+ children: createElement(Surface, { railThread }),
315
+ }),
316
+ );
317
+ redraw();
318
+ await mounted.waitFor(
319
+ () => mounted.text().includes("Offer other times"),
320
+ "the invitation card to be drawn",
321
+ );
322
+ return { mounted, router, redraw };
323
+ };
324
+
325
+ const drawnUntil = async (
326
+ mounted: DomHarness,
327
+ redraw: () => void,
328
+ predicate: () => boolean,
329
+ description: string,
330
+ ): Promise<void> => {
331
+ const deadline = Date.now() + 5000;
332
+ while (!predicate()) {
333
+ if (Date.now() > deadline)
334
+ throw new Error(`gave up waiting for ${description}`);
335
+ redraw();
336
+ await mounted.wait(50);
337
+ }
338
+ };
339
+
340
+ const composerText = (mounted: DomHarness): string =>
341
+ mounted.query("[data-testid=compose-body]")?.textContent ?? "";
342
+
343
+ const plainBody = (mounted: DomHarness): HTMLTextAreaElement | null =>
344
+ mounted.query<HTMLTextAreaElement>("[data-testid=compose-body-plain]");
345
+
346
+ const freeSlot = (mounted: DomHarness): HTMLElement | undefined =>
347
+ mounted
348
+ .queryAll("button[aria-pressed]")
349
+ .find((button) =>
350
+ /\d{2}:\d{2} – \d{2}:\d{2}/.test(button.textContent ?? ""),
351
+ );
352
+
353
+ const pickAndReply = async (mounted: DomHarness): Promise<string> => {
354
+ mounted.click(mounted.byText("button", "Offer other times"));
355
+ await mounted.waitFor(
356
+ () => freeSlot(mounted) !== undefined,
357
+ "free half-hours to be offered",
358
+ );
359
+ const slot = freeSlot(mounted);
360
+ assert.ok(slot, "a free half-hour is offered");
361
+ const picked = (slot.textContent ?? "").trim();
362
+ mounted.click(slot);
363
+ await mounted.flush();
364
+ mounted.click(mounted.byText("button", "Reply with these times"));
365
+ return picked;
366
+ };
367
+
368
+ const seedInAddress = (router: AnyRouter): unknown =>
369
+ (router.state.location.search as Record<string, unknown>).body;
370
+
371
+ describe("offering other times from an invitation", () => {
372
+ it("opens the reply with the picked times written into it", async () => {
373
+ const { mounted, router, redraw } = await mount(MESSAGE_PATH);
374
+
375
+ const picked = await pickAndReply(mounted);
376
+
377
+ await drawnUntil(
378
+ mounted,
379
+ redraw,
380
+ () => composerText(mounted).includes(picked),
381
+ "the picked time to be written into the reply",
382
+ );
383
+ assert.match(composerText(mounted), /September/);
384
+ assert.equal(router.state.location.pathname, `${MESSAGE_PATH}/reply`);
385
+
386
+ await mounted.wait(AUTOSAVE_DEBOUNCE_MS + 300);
387
+ await mounted.waitFor(
388
+ () => creates().length === 1,
389
+ "the reply to be saved as a draft",
390
+ );
391
+ const draft = creates()[0].body as { textBody?: string };
392
+ assert.ok(
393
+ draft.textBody?.includes(picked),
394
+ `the saved draft holds the picked time, got ${JSON.stringify(draft.textBody)}`,
395
+ );
396
+
397
+ await drawnUntil(
398
+ mounted,
399
+ redraw,
400
+ () => router.state.location.pathname.endsWith(`/reply/${DRAFT_ID}`),
401
+ "the address to adopt the draft",
402
+ );
403
+ assert.equal(
404
+ seedInAddress(router),
405
+ undefined,
406
+ "once the draft holds the times, the address no longer carries them",
407
+ );
408
+ await drawnUntil(
409
+ mounted,
410
+ redraw,
411
+ () => composerText(mounted).includes(picked),
412
+ "the composer to keep the times once the draft is adopted",
413
+ );
414
+ });
415
+
416
+ it("keeps what was typed when the times go into a reply already open", async () => {
417
+ const { mounted, router } = await mount(`${MESSAGE_PATH}/reply`);
418
+ await mounted.waitFor(
419
+ () => mounted.query("[data-testid=compose-mode-toggle]") !== null,
420
+ "the reply composer to open",
421
+ 5000,
422
+ );
423
+ const toggle = mounted.query("[data-testid=compose-mode-toggle]");
424
+ assert.ok(toggle);
425
+ mounted.click(toggle);
426
+ await mounted.waitFor(
427
+ () => plainBody(mounted) !== null,
428
+ "the plain writing surface",
429
+ );
430
+ const typing = plainBody(mounted);
431
+ assert.ok(typing);
432
+ mounted.type(typing, "Hi");
433
+ await mounted.flush();
434
+
435
+ const picked = await pickAndReply(mounted);
436
+
437
+ await mounted.waitFor(
438
+ () => plainBody(mounted)?.value.includes(picked) === true,
439
+ "the picked time to go into the open reply",
440
+ );
441
+ assert.match(plainBody(mounted)?.value ?? "", /^Hi/);
442
+ assert.equal(router.state.location.pathname, `${MESSAGE_PATH}/reply`);
443
+ assert.equal(seedInAddress(router), undefined);
444
+ });
445
+
446
+ it("keeps an open reply-all a reply-all and adds the times to it", async () => {
447
+ const href = `${MESSAGE_PATH}/reply-all/${DRAFT_ID}`;
448
+ const { mounted, router } = await mount(href);
449
+ await mounted.waitFor(
450
+ () => composerText(mounted).includes("See you then"),
451
+ "the saved reply-all to reopen",
452
+ 5000,
453
+ );
454
+
455
+ const picked = await pickAndReply(mounted);
456
+
457
+ await mounted.waitFor(
458
+ () => composerText(mounted).includes(picked),
459
+ "the picked time to go into the open reply-all",
460
+ );
461
+ assert.ok(composerText(mounted).includes("See you then"));
462
+ assert.equal(router.state.location.pathname, href);
463
+ assert.equal(seedInAddress(router), undefined);
464
+ });
465
+
466
+ it("adds the times to a forward of the same message already open", async () => {
467
+ const href = `${MESSAGE_PATH}/forward`;
468
+ const { mounted, router } = await mount(href);
469
+ await mounted.waitFor(
470
+ () => mounted.query("[data-testid=compose-body]") !== null,
471
+ "the forward to open",
472
+ 5000,
473
+ );
474
+
475
+ const picked = await pickAndReply(mounted);
476
+
477
+ await mounted.waitFor(
478
+ () => composerText(mounted).includes(picked),
479
+ "the picked time to go into the open forward",
480
+ );
481
+ assert.equal(router.state.location.pathname, href);
482
+ });
483
+
484
+ it("opens a reply holding the times when the open reply answers another message", async () => {
485
+ const { mounted, router, redraw } = await mount(`${MESSAGE_PATH}/reply`, {
486
+ railThread: otherThread,
487
+ });
488
+ await mounted.waitFor(
489
+ () => mounted.query("[data-testid=compose-body]") !== null,
490
+ "the reply to the first message to open",
491
+ 5000,
492
+ );
493
+
494
+ const picked = await pickAndReply(mounted);
495
+
496
+ await drawnUntil(
497
+ mounted,
498
+ redraw,
499
+ () => composerText(mounted).includes(picked),
500
+ "a reply to the other message holding the times",
501
+ );
502
+ assert.equal(
503
+ router.state.location.pathname,
504
+ `/mail/${MAILBOX_ID}/${THREAD_ID}/${OTHER_ID}/reply`,
505
+ );
506
+ });
507
+
508
+ it("adds the times on top of a draft still loading when they were picked", async () => {
509
+ let deliver = () => {};
510
+ const draftArrives = new Promise<void>((resolve) => {
511
+ deliver = resolve;
512
+ });
513
+ const { mounted } = await mount(`${MESSAGE_PATH}/reply-all/${DRAFT_ID}`, {
514
+ draftArrives,
515
+ });
516
+
517
+ const picked = await pickAndReply(mounted);
518
+ await mounted.wait(100);
519
+ deliver();
520
+
521
+ await mounted.waitFor(
522
+ () =>
523
+ composerText(mounted).includes("See you then") &&
524
+ composerText(mounted).includes(picked),
525
+ "the loaded draft to hold the picked time",
526
+ 5000,
527
+ );
528
+ });
529
+ });
@@ -218,25 +218,6 @@ describe("the invitation beside the open message", () => {
218
218
  );
219
219
  });
220
220
 
221
- it("offers the picked times to select by hand where the page has no clipboard", async () => {
222
- await mount(server(() => "Accepted"));
223
- Object.defineProperty(navigator, "clipboard", {
224
- value: undefined,
225
- configurable: true,
226
- });
227
- press("Offer other times");
228
- const slot = harness?.query("[aria-pressed]");
229
- if (!slot) throw new Error("no slot offered");
230
- harness?.click(slot);
231
- press("Copy picked times");
232
-
233
- await harness?.waitFor(
234
- () => harness?.query('textarea[aria-label="Picked times"]') !== null,
235
- "the times to be offered for selecting",
236
- );
237
- assert.match(harness?.text() ?? "", /can't copy for you/);
238
- });
239
-
240
221
  it("declines through the API", async () => {
241
222
  await mount(server(() => "Declined"));
242
223
  press("Decline");
@@ -61,7 +61,7 @@ export const useComposeAttachments = ({
61
61
  complete: (outboxMessageId, outboxAttachmentId) =>
62
62
  completeMutation
63
63
  .mutateAsync({ path: { outboxMessageId, outboxAttachmentId } })
64
- .then(() => undefined),
64
+ .then((attachment) => ({ contentUrl: attachment.contentUrl })),
65
65
  keep: (outboxMessageId, attachmentIds) =>
66
66
  updateMutation
67
67
  .mutateAsync({
@@ -98,6 +98,11 @@ export const useComposeAttachments = ({
98
98
  remove: controller.remove,
99
99
  load: controller.load,
100
100
  reset: controller.reset,
101
+ owns: controller.owns,
102
+ isLoaded: controller.isLoaded,
103
+ refreshLinks: controller.refreshLinks,
104
+ storedContent: controller.storedContent,
105
+ onRemoved: controller.onRemoved,
101
106
  blockingReason: controller.blockingReason(),
102
107
  };
103
108
  };