@remit/web-client 0.0.313 → 0.0.315
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/calendar/CalendarComposePane.tsx +15 -2
- package/src/components/calendar/CalendarWaiting.tsx +3 -0
- package/src/components/calendar/ChangeCalendarSuggestion.stories.tsx +184 -0
- package/src/components/calendar/ChangeCalendarSuggestion.tsx +159 -0
- package/src/components/calendar/PendingSuggestions.tsx +35 -5
- package/src/components/calendar/calendar-story-server.tsx +5 -0
- package/src/components/calendar/change-suggestion-first.render.test.ts +301 -0
- package/src/components/calendar/pending-suggestions.render.test.ts +70 -3
- package/src/components/mail/BriefPane.tsx +6 -1
- package/src/components/mail/FlaggedPane.tsx +6 -1
- package/src/components/mail/IntelligenceDrawer.tsx +4 -0
- package/src/components/mail/IntelligencePane.tsx +11 -2
- package/src/components/mail/MailboxPane.tsx +5 -0
- package/src/components/mail/offer-times-reply.render.test.ts +2 -0
- package/src/hooks/calendar/draft.ts +1 -1
- package/src/hooks/calendar/index.ts +7 -0
- package/src/hooks/calendar/suggestion-draft.ts +77 -0
- package/src/hooks/calendar/useCalendarSuggestions.ts +59 -1
- package/src/hooks/intelligence-calendar.render.test.ts +192 -3
- package/src/hooks/useIntelligenceCalendar.ts +46 -5
- package/src/lib/calendar-suggestion.test.ts +12 -1
- package/src/lib/calendar-suggestion.ts +12 -2
- package/src/lib/calendar-suggestion.west.test.ts +2 -0
- package/src/routeTree.gen.ts +23 -0
- package/src/routes/calendar/$view.$date/suggestion.$suggestionId.tsx +22 -0
- package/src/routes/calendar/$view.$date.tsx +9 -3
- package/src/routes/calendar/-calendar-tree.test.ts +18 -0
- package/src/routing/calendar.ts +36 -1
- package/src/routing/index.ts +2 -0
- package/src/test-support/thread-router.ts +54 -0
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
RemitImapAcceptCalendarSuggestionInput,
|
|
3
|
+
RemitImapCalendarSuggestionResponse,
|
|
4
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
+
import type { EventDraft } from "@remit/ui";
|
|
6
|
+
import {
|
|
7
|
+
type DraftRefusal,
|
|
8
|
+
type DraftZones,
|
|
9
|
+
emptyDraft,
|
|
10
|
+
patchFromDrafts,
|
|
11
|
+
timesFor,
|
|
12
|
+
} from "./draft";
|
|
13
|
+
import { addDays, isoOnClock } from "./window";
|
|
14
|
+
|
|
15
|
+
export type AcceptEditedInput =
|
|
16
|
+
| { ok: true; input: RemitImapAcceptCalendarSuggestionInput }
|
|
17
|
+
| DraftRefusal;
|
|
18
|
+
|
|
19
|
+
type Suggestion = Pick<
|
|
20
|
+
RemitImapCalendarSuggestionResponse,
|
|
21
|
+
"summary" | "location" | "dtStart" | "dtEnd" | "allDay" | "zoneCertainty"
|
|
22
|
+
>;
|
|
23
|
+
|
|
24
|
+
export function draftFromSuggestion(
|
|
25
|
+
suggestion: Suggestion,
|
|
26
|
+
calendarId: string,
|
|
27
|
+
clock: string,
|
|
28
|
+
): EventDraft {
|
|
29
|
+
const base = {
|
|
30
|
+
title: suggestion.summary,
|
|
31
|
+
location: suggestion.location,
|
|
32
|
+
};
|
|
33
|
+
if (suggestion.allDay) {
|
|
34
|
+
const date = suggestion.dtStart.slice(0, 10);
|
|
35
|
+
const end = suggestion.dtEnd.slice(0, 10);
|
|
36
|
+
return {
|
|
37
|
+
...emptyDraft(date, calendarId),
|
|
38
|
+
...base,
|
|
39
|
+
allDay: true,
|
|
40
|
+
startTime: "",
|
|
41
|
+
endDate: end > date ? addDays(end, -1) : date,
|
|
42
|
+
endTime: "",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const wall = (iso: string): string =>
|
|
46
|
+
suggestion.zoneCertainty === "Ambiguous" ? iso : isoOnClock(iso, clock);
|
|
47
|
+
const start = wall(suggestion.dtStart);
|
|
48
|
+
const end = wall(suggestion.dtEnd);
|
|
49
|
+
return {
|
|
50
|
+
...emptyDraft(start.slice(0, 10), calendarId),
|
|
51
|
+
...base,
|
|
52
|
+
startTime: start.slice(11, 16),
|
|
53
|
+
endDate: end.slice(0, 10),
|
|
54
|
+
endTime: end.slice(11, 16),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function acceptEditedInputFromDrafts(
|
|
59
|
+
suggestion: Suggestion,
|
|
60
|
+
seed: EventDraft,
|
|
61
|
+
draft: EventDraft,
|
|
62
|
+
zones: DraftZones,
|
|
63
|
+
): AcceptEditedInput {
|
|
64
|
+
const built = patchFromDrafts(seed, draft, zones);
|
|
65
|
+
if (!built.ok) return built;
|
|
66
|
+
const placed =
|
|
67
|
+
suggestion.zoneCertainty === "Ambiguous" || built.patch.start !== undefined;
|
|
68
|
+
const event = placed
|
|
69
|
+
? {
|
|
70
|
+
...built.patch,
|
|
71
|
+
...timesFor(draft, zones.clock),
|
|
72
|
+
allDay: draft.allDay,
|
|
73
|
+
timeZone: zones.anchor,
|
|
74
|
+
}
|
|
75
|
+
: built.patch;
|
|
76
|
+
return { ok: true, input: { calendarId: draft.calendarId, event } };
|
|
77
|
+
}
|
|
@@ -18,7 +18,11 @@ import {
|
|
|
18
18
|
calendarSuggestionOperationsListCalendarSuggestionsOptions,
|
|
19
19
|
messageCalendarSuggestionOperationsListMessageCalendarSuggestionsOptions,
|
|
20
20
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
21
|
-
import
|
|
21
|
+
import { calendarSuggestionOperationsListCalendarSuggestions } from "@remit/api-http-client/sdk.gen.ts";
|
|
22
|
+
import type {
|
|
23
|
+
RemitImapAcceptCalendarSuggestionInput,
|
|
24
|
+
RemitImapCalendarSuggestionResponse,
|
|
25
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
22
26
|
import {
|
|
23
27
|
type QueryClient,
|
|
24
28
|
useMutation,
|
|
@@ -89,6 +93,46 @@ export function usePendingCalendarSuggestions(): CalendarSuggestionsResult {
|
|
|
89
93
|
};
|
|
90
94
|
}
|
|
91
95
|
|
|
96
|
+
const findPending = async (
|
|
97
|
+
suggestionId: string,
|
|
98
|
+
signal: AbortSignal,
|
|
99
|
+
): Promise<RemitImapCalendarSuggestionResponse | null> => {
|
|
100
|
+
let continuationToken: string | undefined;
|
|
101
|
+
do {
|
|
102
|
+
const { data } = await calendarSuggestionOperationsListCalendarSuggestions({
|
|
103
|
+
query: { state: "Pending", continuationToken },
|
|
104
|
+
signal,
|
|
105
|
+
throwOnError: true,
|
|
106
|
+
});
|
|
107
|
+
const found = data.items.find((item) => item.suggestionId === suggestionId);
|
|
108
|
+
if (found) return found;
|
|
109
|
+
continuationToken = data.continuationToken;
|
|
110
|
+
} while (continuationToken);
|
|
111
|
+
return null;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export interface PendingCalendarSuggestionResult {
|
|
115
|
+
suggestion: RemitImapCalendarSuggestionResponse | null | undefined;
|
|
116
|
+
isLoading: boolean;
|
|
117
|
+
error: unknown;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function usePendingCalendarSuggestion(
|
|
121
|
+
suggestionId: string,
|
|
122
|
+
): PendingCalendarSuggestionResult {
|
|
123
|
+
const { data, isLoading, error } = useQuery({
|
|
124
|
+
queryKey: [
|
|
125
|
+
{
|
|
126
|
+
_id: "calendarSuggestionOperationsListCalendarSuggestions",
|
|
127
|
+
suggestionId,
|
|
128
|
+
},
|
|
129
|
+
],
|
|
130
|
+
queryFn: ({ signal }) => findPending(suggestionId, signal),
|
|
131
|
+
meta: SUGGESTION_META,
|
|
132
|
+
});
|
|
133
|
+
return { suggestion: data, isLoading, error: error ?? null };
|
|
134
|
+
}
|
|
135
|
+
|
|
92
136
|
export type SuggestionAnswer =
|
|
93
137
|
| { kind: "answered" }
|
|
94
138
|
| { kind: "refused"; message: string };
|
|
@@ -109,6 +153,10 @@ export interface CalendarSuggestionAnswers {
|
|
|
109
153
|
suggestionId: string,
|
|
110
154
|
calendarId: string,
|
|
111
155
|
) => Promise<SuggestionAnswer>;
|
|
156
|
+
acceptEdited: (
|
|
157
|
+
suggestionId: string,
|
|
158
|
+
input: RemitImapAcceptCalendarSuggestionInput,
|
|
159
|
+
) => Promise<SuggestionAnswer>;
|
|
112
160
|
decline: (suggestionId: string) => Promise<SuggestionAnswer>;
|
|
113
161
|
dismiss: (
|
|
114
162
|
suggestionId: string,
|
|
@@ -159,6 +207,14 @@ export function useCalendarSuggestionAnswers(): CalendarSuggestionAnswers {
|
|
|
159
207
|
.catch(refused),
|
|
160
208
|
[accept],
|
|
161
209
|
);
|
|
210
|
+
const acceptEditedSuggestion = useCallback(
|
|
211
|
+
(suggestionId: string, input: RemitImapAcceptCalendarSuggestionInput) =>
|
|
212
|
+
accept
|
|
213
|
+
.mutateAsync({ path: { suggestionId }, body: input })
|
|
214
|
+
.then(() => ANSWERED)
|
|
215
|
+
.catch(refused),
|
|
216
|
+
[accept],
|
|
217
|
+
);
|
|
162
218
|
const declineSuggestion = useCallback(
|
|
163
219
|
(suggestionId: string) =>
|
|
164
220
|
decline
|
|
@@ -180,6 +236,7 @@ export function useCalendarSuggestionAnswers(): CalendarSuggestionAnswers {
|
|
|
180
236
|
() => ({
|
|
181
237
|
accept: acceptSuggestion,
|
|
182
238
|
tentative: tentativeSuggestion,
|
|
239
|
+
acceptEdited: acceptEditedSuggestion,
|
|
183
240
|
decline: declineSuggestion,
|
|
184
241
|
dismiss: dismissSuggestion,
|
|
185
242
|
isAnswering: accept.isPending || decline.isPending || dismiss.isPending,
|
|
@@ -187,6 +244,7 @@ export function useCalendarSuggestionAnswers(): CalendarSuggestionAnswers {
|
|
|
187
244
|
[
|
|
188
245
|
acceptSuggestion,
|
|
189
246
|
tentativeSuggestion,
|
|
247
|
+
acceptEditedSuggestion,
|
|
190
248
|
declineSuggestion,
|
|
191
249
|
dismissSuggestion,
|
|
192
250
|
accept.isPending,
|
|
@@ -14,7 +14,16 @@ import type {
|
|
|
14
14
|
RemitImapCalendarSuggestionResponse,
|
|
15
15
|
} from "@remit/api-http-client/types.gen.ts";
|
|
16
16
|
import { type IntelligenceData, IntelligencePanel } from "@remit/ui";
|
|
17
|
+
import {
|
|
18
|
+
type AnyRouter,
|
|
19
|
+
createMemoryHistory,
|
|
20
|
+
createRootRoute,
|
|
21
|
+
createRoute,
|
|
22
|
+
createRouter,
|
|
23
|
+
RouterProvider,
|
|
24
|
+
} from "@tanstack/react-router";
|
|
17
25
|
import { createElement } from "react";
|
|
26
|
+
import { useOpenThread } from "@/routing";
|
|
18
27
|
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
19
28
|
import { makeThreadMessage } from "@/test-support/fixtures";
|
|
20
29
|
import {
|
|
@@ -23,6 +32,7 @@ import {
|
|
|
23
32
|
httpError,
|
|
24
33
|
mockFetch,
|
|
25
34
|
} from "@/test-support/http";
|
|
35
|
+
import { threadRouter } from "@/test-support/thread-router";
|
|
26
36
|
import { useIntelligenceCalendar } from "./useIntelligenceCalendar";
|
|
27
37
|
|
|
28
38
|
const CALENDAR = "11111111-1111-4111-8111-111111111111";
|
|
@@ -87,12 +97,14 @@ const invitation = (
|
|
|
87
97
|
organizer: "organizer@example.test",
|
|
88
98
|
zoneCertainty: "Explicit",
|
|
89
99
|
acceptedCalendarObjectId: "",
|
|
100
|
+
supersededByMessageId: "",
|
|
101
|
+
supersededByThreadId: "",
|
|
90
102
|
createdAt: 0,
|
|
91
103
|
updatedAt: 0,
|
|
92
104
|
});
|
|
93
105
|
|
|
94
106
|
const Harness = () => {
|
|
95
|
-
const calendar = useIntelligenceCalendar(thread);
|
|
107
|
+
const calendar = useIntelligenceCalendar(thread, undefined, useOpenThread());
|
|
96
108
|
return createElement(IntelligencePanel, {
|
|
97
109
|
data: sender,
|
|
98
110
|
calendar: calendar.surface,
|
|
@@ -138,14 +150,19 @@ const server = (
|
|
|
138
150
|
};
|
|
139
151
|
};
|
|
140
152
|
|
|
141
|
-
const mount = async (
|
|
153
|
+
const mount = async (
|
|
154
|
+
respond: (call: HttpCall) => unknown,
|
|
155
|
+
): Promise<AnyRouter> => {
|
|
142
156
|
http = mockFetch(respond);
|
|
143
157
|
harness = createDomHarness();
|
|
144
|
-
|
|
158
|
+
const router = threadRouter(Harness, "/mail/brief/thread-1/msg-1");
|
|
159
|
+
await router.load();
|
|
160
|
+
harness.renderApp(createElement(RouterProvider, { router }));
|
|
145
161
|
await harness.waitFor(
|
|
146
162
|
() => harness?.text().includes("Quarterly review") === true,
|
|
147
163
|
"the invitation to be drawn",
|
|
148
164
|
);
|
|
165
|
+
return router;
|
|
149
166
|
};
|
|
150
167
|
|
|
151
168
|
const press = (label: string) => {
|
|
@@ -261,3 +278,175 @@ describe("the invitation beside the open message", () => {
|
|
|
261
278
|
assert.deepEqual(dismissed[0].body, { muteSender: true });
|
|
262
279
|
});
|
|
263
280
|
});
|
|
281
|
+
|
|
282
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
283
|
+
|
|
284
|
+
const READING = "33333333-3333-4333-8333-333333333333";
|
|
285
|
+
|
|
286
|
+
const routedMount = async (
|
|
287
|
+
items: RemitImapCalendarSuggestionResponse[],
|
|
288
|
+
): Promise<AnyRouter> => {
|
|
289
|
+
http = mockFetch((call: HttpCall): unknown => {
|
|
290
|
+
if (call.path.endsWith("/calendars")) return { items: calendars };
|
|
291
|
+
if (call.path.endsWith("/messages/msg-1/calendar-suggestions"))
|
|
292
|
+
return { items };
|
|
293
|
+
return { items: [] };
|
|
294
|
+
});
|
|
295
|
+
const rootRoute = createRootRoute({ component: Harness });
|
|
296
|
+
const routeTree = rootRoute.addChildren([
|
|
297
|
+
createRoute({
|
|
298
|
+
getParentRoute: () => rootRoute,
|
|
299
|
+
path: "/mail",
|
|
300
|
+
component: () => null,
|
|
301
|
+
}),
|
|
302
|
+
createRoute({
|
|
303
|
+
getParentRoute: () => rootRoute,
|
|
304
|
+
path: "/calendar/$view/$date/suggestion/$suggestionId",
|
|
305
|
+
component: () => null,
|
|
306
|
+
}),
|
|
307
|
+
]);
|
|
308
|
+
const router = createRouter({
|
|
309
|
+
routeTree,
|
|
310
|
+
history: createMemoryHistory({ initialEntries: ["/mail"] }),
|
|
311
|
+
}) as unknown as AnyRouter;
|
|
312
|
+
await router.load();
|
|
313
|
+
harness = createDomHarness();
|
|
314
|
+
harness.renderApp(createElement(RouterProvider, { router }));
|
|
315
|
+
await harness.waitFor(
|
|
316
|
+
() => harness?.text().includes("Quarterly review") === true,
|
|
317
|
+
"the invitation to be drawn",
|
|
318
|
+
);
|
|
319
|
+
return router;
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const answered = (): HttpCall[] =>
|
|
323
|
+
(http?.calls ?? []).filter((call) => call.method === "POST");
|
|
324
|
+
|
|
325
|
+
describe("changing a suggestion beside the message before adding it", () => {
|
|
326
|
+
it("opens the editor for a reading from the thread on the day it falls", async () => {
|
|
327
|
+
const router = await routedMount([
|
|
328
|
+
invitation("Pending"),
|
|
329
|
+
{
|
|
330
|
+
...invitation("Pending"),
|
|
331
|
+
suggestionId: READING,
|
|
332
|
+
source: "TextHeuristic",
|
|
333
|
+
summary: "Lunch with Sam",
|
|
334
|
+
},
|
|
335
|
+
]);
|
|
336
|
+
await harness?.waitFor(
|
|
337
|
+
() => harness?.text().includes("Lunch with Sam") === true,
|
|
338
|
+
"the reading to be drawn",
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
press("Change first");
|
|
342
|
+
await harness?.waitFor(
|
|
343
|
+
() => router.state.location.pathname.includes(READING),
|
|
344
|
+
"the editor to be opened",
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
assert.equal(
|
|
348
|
+
router.state.location.pathname,
|
|
349
|
+
`/calendar/day/2026-09-01/suggestion/${READING}`,
|
|
350
|
+
);
|
|
351
|
+
assert.deepEqual(answered(), []);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("sends an invitation nobody could put on a clock to the editor instead of adding it", async () => {
|
|
355
|
+
const router = await routedMount([
|
|
356
|
+
{ ...invitation("Pending"), zoneCertainty: "Ambiguous" },
|
|
357
|
+
]);
|
|
358
|
+
|
|
359
|
+
press("Add to calendar");
|
|
360
|
+
await harness?.waitFor(
|
|
361
|
+
() => router.state.location.pathname.includes(SUGGESTION),
|
|
362
|
+
"the editor to be opened",
|
|
363
|
+
);
|
|
364
|
+
|
|
365
|
+
assert.equal(
|
|
366
|
+
router.state.location.pathname,
|
|
367
|
+
`/calendar/day/2026-09-01/suggestion/${SUGGESTION}`,
|
|
368
|
+
);
|
|
369
|
+
assert.deepEqual(answered(), []);
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it("applies a cancellation read out of the thread, and offers no editor for it", async () => {
|
|
373
|
+
const router = await routedMount([
|
|
374
|
+
invitation("Accepted"),
|
|
375
|
+
{
|
|
376
|
+
...invitation("Pending"),
|
|
377
|
+
suggestionId: READING,
|
|
378
|
+
source: "TextHeuristic",
|
|
379
|
+
method: "Cancel",
|
|
380
|
+
zoneCertainty: "Ambiguous",
|
|
381
|
+
summary: "Lunch with Sam",
|
|
382
|
+
},
|
|
383
|
+
]);
|
|
384
|
+
await harness?.waitFor(
|
|
385
|
+
() => harness?.text().includes("Lunch with Sam") === true,
|
|
386
|
+
"the reading to be drawn",
|
|
387
|
+
);
|
|
388
|
+
const card = harness?.query('article[aria-label="Lunch with Sam"]');
|
|
389
|
+
if (!card) throw new Error("the reading's card is not drawn");
|
|
390
|
+
const buttons = [...card.querySelectorAll("button")];
|
|
391
|
+
assert.equal(
|
|
392
|
+
buttons.some((button) => button.textContent?.includes("Change first")),
|
|
393
|
+
false,
|
|
394
|
+
);
|
|
395
|
+
const add = buttons.find((button) =>
|
|
396
|
+
button.textContent?.includes("Add to calendar"),
|
|
397
|
+
);
|
|
398
|
+
if (!add) throw new Error("the reading offers no Add");
|
|
399
|
+
harness?.click(add);
|
|
400
|
+
|
|
401
|
+
await harness?.waitFor(
|
|
402
|
+
() => answered().some((call) => call.path.endsWith(`/${READING}/accept`)),
|
|
403
|
+
"the cancellation to be applied",
|
|
404
|
+
);
|
|
405
|
+
assert.equal(router.state.location.pathname, "/mail");
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
describe("an invitation a newer revision replaced", () => {
|
|
410
|
+
const retired = (
|
|
411
|
+
supersededByThreadId: string,
|
|
412
|
+
): RemitImapCalendarSuggestionResponse => ({
|
|
413
|
+
...invitation("Superseded"),
|
|
414
|
+
supersededByMessageId: "msg-2",
|
|
415
|
+
supersededByThreadId,
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
const serving =
|
|
419
|
+
(suggestion: RemitImapCalendarSuggestionResponse) =>
|
|
420
|
+
(call: HttpCall): unknown => {
|
|
421
|
+
if (call.path.endsWith("/calendars")) return { items: calendars };
|
|
422
|
+
if (call.path.endsWith("/messages/msg-1/calendar-suggestions"))
|
|
423
|
+
return { items: [suggestion] };
|
|
424
|
+
return { items: [] };
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
it("opens the conversation that carries the newer revision", async () => {
|
|
428
|
+
const router = await mount(serving(retired("thread-2")));
|
|
429
|
+
assert.match(harness?.text() ?? "", /has sent a newer version of this/);
|
|
430
|
+
|
|
431
|
+
press("Open the newer invitation");
|
|
432
|
+
|
|
433
|
+
await harness?.waitFor(
|
|
434
|
+
() => router.state.location.pathname === "/mail/brief/thread-2/msg-2",
|
|
435
|
+
"the reading pane to move to the newer revision",
|
|
436
|
+
);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("offers no way there when the newer message sits in no conversation", async () => {
|
|
440
|
+
await mount(serving(retired("")));
|
|
441
|
+
|
|
442
|
+
assert.match(harness?.text() ?? "", /has sent a newer version of this/);
|
|
443
|
+
assert.equal(
|
|
444
|
+
harness
|
|
445
|
+
?.queryAll("button")
|
|
446
|
+
.some((button) =>
|
|
447
|
+
button.textContent?.includes("Open the newer invitation"),
|
|
448
|
+
),
|
|
449
|
+
false,
|
|
450
|
+
);
|
|
451
|
+
});
|
|
452
|
+
});
|
|
@@ -50,7 +50,11 @@ import {
|
|
|
50
50
|
toCalendarInvite,
|
|
51
51
|
toEventSuggestion,
|
|
52
52
|
} from "@/lib/calendar-suggestion";
|
|
53
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
type OpenThreadTarget,
|
|
55
|
+
useChangeSuggestionOnCalendar,
|
|
56
|
+
useOpenEventOnCalendar,
|
|
57
|
+
} from "@/routing";
|
|
54
58
|
|
|
55
59
|
/** What the reader is in the middle of, for one message and no other. */
|
|
56
60
|
interface Working {
|
|
@@ -92,6 +96,7 @@ export interface IntelligenceCalendar {
|
|
|
92
96
|
export function useIntelligenceCalendar(
|
|
93
97
|
thread: RemitImapThreadMessageResponse,
|
|
94
98
|
replyWithText?: ReplyWithText,
|
|
99
|
+
openThread?: (target: OpenThreadTarget) => void,
|
|
95
100
|
): IntelligenceCalendar {
|
|
96
101
|
const messageId = thread.messageId;
|
|
97
102
|
const { suggestions, isLoading, error } =
|
|
@@ -107,6 +112,7 @@ export function useIntelligenceCalendar(
|
|
|
107
112
|
const answers = useCalendarSuggestionAnswers();
|
|
108
113
|
|
|
109
114
|
const openEvent = useOpenEventOnCalendar();
|
|
115
|
+
const changeOnCalendar = useChangeSuggestionOnCalendar();
|
|
110
116
|
|
|
111
117
|
const [held, setHeld] = useState<Working>(() => fresh(messageId));
|
|
112
118
|
const working = held.messageId === messageId ? held : fresh(messageId);
|
|
@@ -238,14 +244,34 @@ export function useIntelligenceCalendar(
|
|
|
238
244
|
: "Couldn't read the invitations in this message. Reopen it to try again.";
|
|
239
245
|
|
|
240
246
|
const cancellation = invitation?.method === "Cancel";
|
|
247
|
+
const changeFirst = (suggestionId: string) => {
|
|
248
|
+
const suggestion = suggestions.find(
|
|
249
|
+
(candidate) => candidate.suggestionId === suggestionId,
|
|
250
|
+
);
|
|
251
|
+
if (suggestion) changeOnCalendar(suggestionDate(suggestion), suggestionId);
|
|
252
|
+
};
|
|
253
|
+
const unplaced = (suggestionId: string): boolean => {
|
|
254
|
+
const suggestion = suggestions.find(
|
|
255
|
+
(candidate) => candidate.suggestionId === suggestionId,
|
|
256
|
+
);
|
|
257
|
+
return (
|
|
258
|
+
suggestion?.zoneCertainty === "Ambiguous" &&
|
|
259
|
+
suggestion.method !== "Cancel"
|
|
260
|
+
);
|
|
261
|
+
};
|
|
241
262
|
const pickedText =
|
|
242
263
|
date === "" ? "" : slotsAsText(date, slots, working.picked);
|
|
243
264
|
|
|
244
265
|
const actions: IntelligenceCalendarActions = {
|
|
245
|
-
onAddInvite: () =>
|
|
266
|
+
onAddInvite: () => {
|
|
267
|
+
if (unplaced(invitationId)) {
|
|
268
|
+
changeFirst(invitationId);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
246
271
|
answer(invitationId, "add this to your calendar", () =>
|
|
247
272
|
answers.accept(invitationId, defaultCalendarId),
|
|
248
|
-
)
|
|
273
|
+
);
|
|
274
|
+
},
|
|
249
275
|
onTentativeInvite: cancellation
|
|
250
276
|
? undefined
|
|
251
277
|
: () =>
|
|
@@ -270,6 +296,14 @@ export function useIntelligenceCalendar(
|
|
|
270
296
|
answers.dismiss(invitationId, false),
|
|
271
297
|
)
|
|
272
298
|
: undefined,
|
|
299
|
+
onOpenNewerInvite:
|
|
300
|
+
openThread && invitation && invitation.supersededByThreadId !== ""
|
|
301
|
+
? () =>
|
|
302
|
+
openThread({
|
|
303
|
+
threadId: invitation.supersededByThreadId,
|
|
304
|
+
messageId: invitation.supersededByMessageId,
|
|
305
|
+
})
|
|
306
|
+
: undefined,
|
|
273
307
|
onOfferOtherTimes: () => update({ offering: true }),
|
|
274
308
|
onToggleSlot: (slot) =>
|
|
275
309
|
update({
|
|
@@ -281,10 +315,17 @@ export function useIntelligenceCalendar(
|
|
|
281
315
|
? () =>
|
|
282
316
|
replyWithText({ threadId: thread.threadId, messageId }, pickedText)
|
|
283
317
|
: undefined,
|
|
284
|
-
onAddSuggestion: (suggestionId) =>
|
|
318
|
+
onAddSuggestion: (suggestionId) => {
|
|
319
|
+
if (unplaced(suggestionId)) {
|
|
320
|
+
changeFirst(suggestionId);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
285
323
|
answer(suggestionId, "add this to your calendar", () =>
|
|
286
324
|
answers.accept(suggestionId, defaultCalendarId),
|
|
287
|
-
)
|
|
325
|
+
);
|
|
326
|
+
},
|
|
327
|
+
onReviewSuggestion:
|
|
328
|
+
readings[0]?.method === "Cancel" ? undefined : changeFirst,
|
|
288
329
|
onDismissSuggestion: (suggestionId) =>
|
|
289
330
|
answer(suggestionId, "dismiss this", () =>
|
|
290
331
|
answers.dismiss(suggestionId, false),
|
|
@@ -36,6 +36,8 @@ const suggestion = (
|
|
|
36
36
|
organizer: "organizer@example.test",
|
|
37
37
|
zoneCertainty: "Explicit",
|
|
38
38
|
acceptedCalendarObjectId: "",
|
|
39
|
+
supersededByMessageId: "",
|
|
40
|
+
supersededByThreadId: "",
|
|
39
41
|
createdAt: 0,
|
|
40
42
|
updatedAt: 0,
|
|
41
43
|
...over,
|
|
@@ -113,10 +115,19 @@ describe("toEventSuggestion", () => {
|
|
|
113
115
|
suggestion({ zoneCertainty: "Ambiguous", source: "TextHeuristic" }),
|
|
114
116
|
{ ...context, sender: "Organizer" },
|
|
115
117
|
);
|
|
116
|
-
assert.match(reading.ambiguity, /
|
|
118
|
+
assert.match(reading.ambiguity, /Add opens the editor/);
|
|
117
119
|
assert.equal(reading.zoneCertainty, "ambiguous");
|
|
118
120
|
assert.ok(reading.confidence < 1);
|
|
119
121
|
});
|
|
122
|
+
|
|
123
|
+
it("promises no editor for a cancellation, which Add applies as it is", () => {
|
|
124
|
+
const reading = toEventSuggestion(
|
|
125
|
+
suggestion({ zoneCertainty: "Ambiguous", method: "Cancel" }),
|
|
126
|
+
{ ...context, sender: "Organizer" },
|
|
127
|
+
);
|
|
128
|
+
assert.match(reading.ambiguity, /could not be resolved/);
|
|
129
|
+
assert.doesNotMatch(reading.ambiguity, /editor/);
|
|
130
|
+
});
|
|
120
131
|
});
|
|
121
132
|
|
|
122
133
|
describe("clashesOver", () => {
|
|
@@ -47,7 +47,10 @@ const ZONES: Record<CalendarSuggestion["zoneCertainty"], ZoneCertainty> = {
|
|
|
47
47
|
};
|
|
48
48
|
|
|
49
49
|
const AMBIGUOUS_ZONE =
|
|
50
|
-
"The invitation
|
|
50
|
+
"The invitation's time zone could not be resolved. Add opens the editor so you can set the time.";
|
|
51
|
+
|
|
52
|
+
const AMBIGUOUS_CANCELLATION =
|
|
53
|
+
"The invitation's time zone could not be resolved.";
|
|
51
54
|
|
|
52
55
|
/** Where the card stands, or nothing when there is no card left to draw. */
|
|
53
56
|
export interface InviteStanding {
|
|
@@ -163,6 +166,13 @@ export function toCalendarInvite(
|
|
|
163
166
|
};
|
|
164
167
|
}
|
|
165
168
|
|
|
169
|
+
function ambiguityOf(suggestion: CalendarSuggestion): string {
|
|
170
|
+
if (suggestion.zoneCertainty !== "Ambiguous") return "";
|
|
171
|
+
return suggestion.method === "Cancel"
|
|
172
|
+
? AMBIGUOUS_CANCELLATION
|
|
173
|
+
: AMBIGUOUS_ZONE;
|
|
174
|
+
}
|
|
175
|
+
|
|
166
176
|
export function toEventSuggestion(
|
|
167
177
|
suggestion: CalendarSuggestion,
|
|
168
178
|
context: Omit<InviteContext, "senderName"> & { sender: string },
|
|
@@ -179,7 +189,7 @@ export function toEventSuggestion(
|
|
|
179
189
|
sender: context.sender,
|
|
180
190
|
senderAddress: suggestion.organizer,
|
|
181
191
|
confidence: isFromInvitation(suggestion) ? 1 : 0.6,
|
|
182
|
-
ambiguity: suggestion
|
|
192
|
+
ambiguity: ambiguityOf(suggestion),
|
|
183
193
|
suggestedCalendarId: context.calendarId,
|
|
184
194
|
timeZone: "",
|
|
185
195
|
zoneCertainty: ZONES[suggestion.zoneCertainty],
|
package/src/routeTree.gen.ts
CHANGED
|
@@ -55,6 +55,7 @@ import { Route as MailOutboxComposeChar123OutboxMessageIdChar125RouteImport } fr
|
|
|
55
55
|
import { Route as MailOutboxDraftOutboxMessageIdRouteImport } from './routes/mail/outbox/draft/$outboxMessageId'
|
|
56
56
|
import { Route as CalendarViewDateCalendarObjectIdIndexRouteImport } from './routes/calendar/$view.$date/$calendarObjectId/index'
|
|
57
57
|
import { Route as CalendarViewDateCalendarObjectIdRecurrenceIdRouteImport } from './routes/calendar/$view.$date/$calendarObjectId/$recurrenceId'
|
|
58
|
+
import { Route as CalendarViewDateSuggestionSuggestionIdRouteImport } from './routes/calendar/$view.$date/suggestion.$suggestionId'
|
|
58
59
|
import { Route as MailMailboxIdThreadIdMessageIdModeChar123OutboxMessageIdChar125RouteImport } from './routes/mail/$mailboxId/$threadId/$messageId/$mode.{-$outboxMessageId}'
|
|
59
60
|
import { Route as MailBriefThreadIdMessageIdModeChar123OutboxMessageIdChar125RouteImport } from './routes/mail/brief/$threadId/$messageId/$mode.{-$outboxMessageId}'
|
|
60
61
|
import { Route as MailFlaggedThreadIdMessageIdModeChar123OutboxMessageIdChar125RouteImport } from './routes/mail/flagged/$threadId/$messageId/$mode.{-$outboxMessageId}'
|
|
@@ -302,6 +303,12 @@ const CalendarViewDateCalendarObjectIdRecurrenceIdRoute =
|
|
|
302
303
|
path: '/$recurrenceId',
|
|
303
304
|
getParentRoute: () => CalendarViewDateCalendarObjectIdRoute,
|
|
304
305
|
} as any)
|
|
306
|
+
const CalendarViewDateSuggestionSuggestionIdRoute =
|
|
307
|
+
CalendarViewDateSuggestionSuggestionIdRouteImport.update({
|
|
308
|
+
id: '/suggestion/$suggestionId',
|
|
309
|
+
path: '/suggestion/$suggestionId',
|
|
310
|
+
getParentRoute: () => CalendarViewDateRoute,
|
|
311
|
+
} as any)
|
|
305
312
|
const MailMailboxIdThreadIdMessageIdModeChar123OutboxMessageIdChar125Route =
|
|
306
313
|
MailMailboxIdThreadIdMessageIdModeChar123OutboxMessageIdChar125RouteImport.update(
|
|
307
314
|
{
|
|
@@ -373,6 +380,7 @@ export interface FileRoutesByFullPath {
|
|
|
373
380
|
'/mail/brief/$threadId/': typeof MailBriefThreadIdIndexRoute
|
|
374
381
|
'/mail/flagged/$threadId/': typeof MailFlaggedThreadIdIndexRoute
|
|
375
382
|
'/calendar/$view/$date/$calendarObjectId/$recurrenceId': typeof CalendarViewDateCalendarObjectIdRecurrenceIdRoute
|
|
383
|
+
'/calendar/$view/$date/suggestion/$suggestionId': typeof CalendarViewDateSuggestionSuggestionIdRoute
|
|
376
384
|
'/calendar/$view/$date/$calendarObjectId/': typeof CalendarViewDateCalendarObjectIdIndexRoute
|
|
377
385
|
'/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}': typeof MailMailboxIdThreadIdMessageIdModeChar123OutboxMessageIdChar125Route
|
|
378
386
|
'/mail/brief/$threadId/$messageId/$mode/{-$outboxMessageId}': typeof MailBriefThreadIdMessageIdModeChar123OutboxMessageIdChar125Route
|
|
@@ -412,6 +420,7 @@ export interface FileRoutesByTo {
|
|
|
412
420
|
'/mail/brief/$threadId': typeof MailBriefThreadIdIndexRoute
|
|
413
421
|
'/mail/flagged/$threadId': typeof MailFlaggedThreadIdIndexRoute
|
|
414
422
|
'/calendar/$view/$date/$calendarObjectId/$recurrenceId': typeof CalendarViewDateCalendarObjectIdRecurrenceIdRoute
|
|
423
|
+
'/calendar/$view/$date/suggestion/$suggestionId': typeof CalendarViewDateSuggestionSuggestionIdRoute
|
|
415
424
|
'/calendar/$view/$date/$calendarObjectId': typeof CalendarViewDateCalendarObjectIdIndexRoute
|
|
416
425
|
'/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}': typeof MailMailboxIdThreadIdMessageIdModeChar123OutboxMessageIdChar125Route
|
|
417
426
|
'/mail/brief/$threadId/$messageId/$mode/{-$outboxMessageId}': typeof MailBriefThreadIdMessageIdModeChar123OutboxMessageIdChar125Route
|
|
@@ -464,6 +473,7 @@ export interface FileRoutesById {
|
|
|
464
473
|
'/mail/brief/$threadId/': typeof MailBriefThreadIdIndexRoute
|
|
465
474
|
'/mail/flagged/$threadId/': typeof MailFlaggedThreadIdIndexRoute
|
|
466
475
|
'/calendar/$view/$date/$calendarObjectId/$recurrenceId': typeof CalendarViewDateCalendarObjectIdRecurrenceIdRoute
|
|
476
|
+
'/calendar/$view/$date/suggestion/$suggestionId': typeof CalendarViewDateSuggestionSuggestionIdRoute
|
|
467
477
|
'/calendar/$view/$date/$calendarObjectId/': typeof CalendarViewDateCalendarObjectIdIndexRoute
|
|
468
478
|
'/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}': typeof MailMailboxIdThreadIdMessageIdModeChar123OutboxMessageIdChar125Route
|
|
469
479
|
'/mail/brief/$threadId/$messageId/$mode/{-$outboxMessageId}': typeof MailBriefThreadIdMessageIdModeChar123OutboxMessageIdChar125Route
|
|
@@ -517,6 +527,7 @@ export interface FileRouteTypes {
|
|
|
517
527
|
| '/mail/brief/$threadId/'
|
|
518
528
|
| '/mail/flagged/$threadId/'
|
|
519
529
|
| '/calendar/$view/$date/$calendarObjectId/$recurrenceId'
|
|
530
|
+
| '/calendar/$view/$date/suggestion/$suggestionId'
|
|
520
531
|
| '/calendar/$view/$date/$calendarObjectId/'
|
|
521
532
|
| '/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}'
|
|
522
533
|
| '/mail/brief/$threadId/$messageId/$mode/{-$outboxMessageId}'
|
|
@@ -556,6 +567,7 @@ export interface FileRouteTypes {
|
|
|
556
567
|
| '/mail/brief/$threadId'
|
|
557
568
|
| '/mail/flagged/$threadId'
|
|
558
569
|
| '/calendar/$view/$date/$calendarObjectId/$recurrenceId'
|
|
570
|
+
| '/calendar/$view/$date/suggestion/$suggestionId'
|
|
559
571
|
| '/calendar/$view/$date/$calendarObjectId'
|
|
560
572
|
| '/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}'
|
|
561
573
|
| '/mail/brief/$threadId/$messageId/$mode/{-$outboxMessageId}'
|
|
@@ -607,6 +619,7 @@ export interface FileRouteTypes {
|
|
|
607
619
|
| '/mail/brief/$threadId/'
|
|
608
620
|
| '/mail/flagged/$threadId/'
|
|
609
621
|
| '/calendar/$view/$date/$calendarObjectId/$recurrenceId'
|
|
622
|
+
| '/calendar/$view/$date/suggestion/$suggestionId'
|
|
610
623
|
| '/calendar/$view/$date/$calendarObjectId/'
|
|
611
624
|
| '/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}'
|
|
612
625
|
| '/mail/brief/$threadId/$messageId/$mode/{-$outboxMessageId}'
|
|
@@ -946,6 +959,13 @@ declare module '@tanstack/react-router' {
|
|
|
946
959
|
preLoaderRoute: typeof CalendarViewDateCalendarObjectIdRecurrenceIdRouteImport
|
|
947
960
|
parentRoute: typeof CalendarViewDateCalendarObjectIdRoute
|
|
948
961
|
}
|
|
962
|
+
'/calendar/$view/$date/suggestion/$suggestionId': {
|
|
963
|
+
id: '/calendar/$view/$date/suggestion/$suggestionId'
|
|
964
|
+
path: '/suggestion/$suggestionId'
|
|
965
|
+
fullPath: '/calendar/$view/$date/suggestion/$suggestionId'
|
|
966
|
+
preLoaderRoute: typeof CalendarViewDateSuggestionSuggestionIdRouteImport
|
|
967
|
+
parentRoute: typeof CalendarViewDateRoute
|
|
968
|
+
}
|
|
949
969
|
'/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}': {
|
|
950
970
|
id: '/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}'
|
|
951
971
|
path: '/$mode/{-$outboxMessageId}'
|
|
@@ -992,6 +1012,7 @@ interface CalendarViewDateRouteChildren {
|
|
|
992
1012
|
CalendarViewDateCalendarObjectIdRoute: typeof CalendarViewDateCalendarObjectIdRouteWithChildren
|
|
993
1013
|
CalendarViewDateNewRoute: typeof CalendarViewDateNewRoute
|
|
994
1014
|
CalendarViewDateIndexRoute: typeof CalendarViewDateIndexRoute
|
|
1015
|
+
CalendarViewDateSuggestionSuggestionIdRoute: typeof CalendarViewDateSuggestionSuggestionIdRoute
|
|
995
1016
|
}
|
|
996
1017
|
|
|
997
1018
|
const CalendarViewDateRouteChildren: CalendarViewDateRouteChildren = {
|
|
@@ -999,6 +1020,8 @@ const CalendarViewDateRouteChildren: CalendarViewDateRouteChildren = {
|
|
|
999
1020
|
CalendarViewDateCalendarObjectIdRouteWithChildren,
|
|
1000
1021
|
CalendarViewDateNewRoute: CalendarViewDateNewRoute,
|
|
1001
1022
|
CalendarViewDateIndexRoute: CalendarViewDateIndexRoute,
|
|
1023
|
+
CalendarViewDateSuggestionSuggestionIdRoute:
|
|
1024
|
+
CalendarViewDateSuggestionSuggestionIdRoute,
|
|
1002
1025
|
}
|
|
1003
1026
|
|
|
1004
1027
|
const CalendarViewDateRouteWithChildren =
|