@remit/web-client 0.0.310 → 0.0.312
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 -9
- package/src/components/compose/ComposeProvider.tsx +35 -1
- package/src/components/compose/ConversationCompose.tsx +19 -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/calendar/useCalendarSuggestions.ts +17 -0
- package/src/hooks/intelligence-calendar.render.test.ts +0 -19
- package/src/hooks/useIntelligenceCalendar.ts +14 -19
- 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,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
|
+
});
|
|
@@ -105,6 +105,10 @@ export interface CalendarSuggestionAnswers {
|
|
|
105
105
|
suggestionId: string,
|
|
106
106
|
calendarId: string,
|
|
107
107
|
) => Promise<SuggestionAnswer>;
|
|
108
|
+
tentative: (
|
|
109
|
+
suggestionId: string,
|
|
110
|
+
calendarId: string,
|
|
111
|
+
) => Promise<SuggestionAnswer>;
|
|
108
112
|
decline: (suggestionId: string) => Promise<SuggestionAnswer>;
|
|
109
113
|
dismiss: (
|
|
110
114
|
suggestionId: string,
|
|
@@ -144,6 +148,17 @@ export function useCalendarSuggestionAnswers(): CalendarSuggestionAnswers {
|
|
|
144
148
|
.catch(refused),
|
|
145
149
|
[accept],
|
|
146
150
|
);
|
|
151
|
+
const tentativeSuggestion = useCallback(
|
|
152
|
+
(suggestionId: string, calendarId: string) =>
|
|
153
|
+
accept
|
|
154
|
+
.mutateAsync({
|
|
155
|
+
path: { suggestionId },
|
|
156
|
+
body: { calendarId, answer: "Tentative" },
|
|
157
|
+
})
|
|
158
|
+
.then(() => ANSWERED)
|
|
159
|
+
.catch(refused),
|
|
160
|
+
[accept],
|
|
161
|
+
);
|
|
147
162
|
const declineSuggestion = useCallback(
|
|
148
163
|
(suggestionId: string) =>
|
|
149
164
|
decline
|
|
@@ -164,12 +179,14 @@ export function useCalendarSuggestionAnswers(): CalendarSuggestionAnswers {
|
|
|
164
179
|
return useMemo(
|
|
165
180
|
() => ({
|
|
166
181
|
accept: acceptSuggestion,
|
|
182
|
+
tentative: tentativeSuggestion,
|
|
167
183
|
decline: declineSuggestion,
|
|
168
184
|
dismiss: dismissSuggestion,
|
|
169
185
|
isAnswering: accept.isPending || decline.isPending || dismiss.isPending,
|
|
170
186
|
}),
|
|
171
187
|
[
|
|
172
188
|
acceptSuggestion,
|
|
189
|
+
tentativeSuggestion,
|
|
173
190
|
declineSuggestion,
|
|
174
191
|
dismissSuggestion,
|
|
175
192
|
accept.isPending,
|
|
@@ -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");
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
calendarUnavailable,
|
|
23
23
|
calendarWriteGate,
|
|
24
24
|
} from "@/components/calendar/CalendarUnavailable";
|
|
25
|
+
import type { ReplyWithText } from "@/components/compose/reply-with-times";
|
|
25
26
|
import {
|
|
26
27
|
calendarWindowOfDays,
|
|
27
28
|
deviceTimeZone,
|
|
@@ -57,7 +58,6 @@ interface Working {
|
|
|
57
58
|
tab: IntelligenceTabId | undefined;
|
|
58
59
|
offering: boolean;
|
|
59
60
|
picked: string[];
|
|
60
|
-
copy: "idle" | "copied" | "failed";
|
|
61
61
|
answering: string;
|
|
62
62
|
/** The card the last refusal was about, and what it said. */
|
|
63
63
|
failure: { suggestionId: string; text: string };
|
|
@@ -68,7 +68,6 @@ const fresh = (messageId: string): Working => ({
|
|
|
68
68
|
tab: undefined,
|
|
69
69
|
offering: false,
|
|
70
70
|
picked: [],
|
|
71
|
-
copy: "idle",
|
|
72
71
|
answering: "",
|
|
73
72
|
failure: { suggestionId: "", text: "" },
|
|
74
73
|
});
|
|
@@ -92,6 +91,7 @@ export interface IntelligenceCalendar {
|
|
|
92
91
|
|
|
93
92
|
export function useIntelligenceCalendar(
|
|
94
93
|
thread: RemitImapThreadMessageResponse,
|
|
94
|
+
replyWithText?: ReplyWithText,
|
|
95
95
|
): IntelligenceCalendar {
|
|
96
96
|
const messageId = thread.messageId;
|
|
97
97
|
const { suggestions, isLoading, error } =
|
|
@@ -238,13 +238,20 @@ export function useIntelligenceCalendar(
|
|
|
238
238
|
: "Couldn't read the invitations in this message. Reopen it to try again.";
|
|
239
239
|
|
|
240
240
|
const cancellation = invitation?.method === "Cancel";
|
|
241
|
-
const
|
|
241
|
+
const pickedText =
|
|
242
|
+
date === "" ? "" : slotsAsText(date, slots, working.picked);
|
|
242
243
|
|
|
243
244
|
const actions: IntelligenceCalendarActions = {
|
|
244
245
|
onAddInvite: () =>
|
|
245
246
|
answer(invitationId, "add this to your calendar", () =>
|
|
246
247
|
answers.accept(invitationId, defaultCalendarId),
|
|
247
248
|
),
|
|
249
|
+
onTentativeInvite: cancellation
|
|
250
|
+
? undefined
|
|
251
|
+
: () =>
|
|
252
|
+
answer(invitationId, "add this as a maybe", () =>
|
|
253
|
+
answers.tentative(invitationId, defaultCalendarId),
|
|
254
|
+
),
|
|
248
255
|
onDeclineInvite: () =>
|
|
249
256
|
answer(invitationId, "decline this", () => answers.decline(invitationId)),
|
|
250
257
|
onMuteInvite: () =>
|
|
@@ -266,24 +273,14 @@ export function useIntelligenceCalendar(
|
|
|
266
273
|
onOfferOtherTimes: () => update({ offering: true }),
|
|
267
274
|
onToggleSlot: (slot) =>
|
|
268
275
|
update({
|
|
269
|
-
copy: "idle",
|
|
270
276
|
picked: working.picked.includes(slot.startTime)
|
|
271
277
|
? working.picked.filter((start) => start !== slot.startTime)
|
|
272
278
|
: [...working.picked, slot.startTime],
|
|
273
279
|
}),
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
if (!clipboard) {
|
|
279
|
-
update({ copy: "failed" });
|
|
280
|
-
return;
|
|
281
|
-
}
|
|
282
|
-
void Promise.resolve()
|
|
283
|
-
.then(() => clipboard.writeText(copyText))
|
|
284
|
-
.then(() => update({ copy: "copied" }))
|
|
285
|
-
.catch(() => update({ copy: "failed" }));
|
|
286
|
-
},
|
|
280
|
+
onReplyWithSlots: replyWithText
|
|
281
|
+
? () =>
|
|
282
|
+
replyWithText({ threadId: thread.threadId, messageId }, pickedText)
|
|
283
|
+
: undefined,
|
|
287
284
|
onAddSuggestion: (suggestionId) =>
|
|
288
285
|
answer(suggestionId, "add this to your calendar", () =>
|
|
289
286
|
answers.accept(suggestionId, defaultCalendarId),
|
|
@@ -328,8 +325,6 @@ export function useIntelligenceCalendar(
|
|
|
328
325
|
proposals: [],
|
|
329
326
|
slots,
|
|
330
327
|
picked: working.picked,
|
|
331
|
-
copy: working.copy,
|
|
332
|
-
copyText,
|
|
333
328
|
}
|
|
334
329
|
: undefined,
|
|
335
330
|
suggestions: deck,
|
package/src/lib/mail-search.ts
CHANGED
|
@@ -50,3 +50,20 @@ export const mailListSearchSchemas = {
|
|
|
50
50
|
"/mail/outbox": outboxSearchSchema,
|
|
51
51
|
"/mail/$mailboxId": mailboxSearchSchema,
|
|
52
52
|
} as const;
|
|
53
|
+
|
|
54
|
+
export const composeSeedSearch = z.object({ body: z.string().optional() });
|
|
55
|
+
|
|
56
|
+
export type ComposeSeed = Required<z.infer<typeof composeSeedSearch>>;
|
|
57
|
+
|
|
58
|
+
export const withoutComposeSeed = (
|
|
59
|
+
prev: Record<string, unknown>,
|
|
60
|
+
): Record<string, unknown> => {
|
|
61
|
+
const { body: _seed, ...rest } = prev;
|
|
62
|
+
return rest;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const replySearchSchemas = {
|
|
66
|
+
brief: briefSearchSchema.extend(composeSeedSearch.shape),
|
|
67
|
+
flagged: flaggedSearchSchema.extend(composeSeedSearch.shape),
|
|
68
|
+
mailbox: mailboxSearchSchema.extend(composeSeedSearch.shape),
|
|
69
|
+
} as const;
|