@remit/web-client 0.0.314 → 0.0.316
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/ChangeCalendarSuggestion.stories.tsx +3 -0
- package/src/components/calendar/change-suggestion-first.render.test.ts +3 -0
- package/src/components/calendar/pending-suggestions.render.test.ts +3 -0
- 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 +3 -0
- package/src/hooks/calendar/useCalendarSuggestions.ts +24 -1
- package/src/hooks/intelligence-calendar.render.test.ts +109 -10
- package/src/hooks/useIntelligenceCalendar.ts +20 -1
- package/src/lib/calendar-suggestion.test.ts +12 -2
- package/src/lib/calendar-suggestion.ts +6 -4
- package/src/lib/calendar-suggestion.west.test.ts +3 -0
- package/src/test-support/thread-router.ts +54 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.316",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -33,8 +33,11 @@ const suggestion = (
|
|
|
33
33
|
organizer: "priya@example.invalid",
|
|
34
34
|
zoneCertainty: "Explicit",
|
|
35
35
|
acceptedCalendarObjectId: "",
|
|
36
|
+
supersededByMessageId: "",
|
|
37
|
+
supersededByThreadId: "",
|
|
36
38
|
createdAt: 0,
|
|
37
39
|
updatedAt: 0,
|
|
40
|
+
answerOvertakenBy: "None",
|
|
38
41
|
...overrides,
|
|
39
42
|
});
|
|
40
43
|
|
|
@@ -74,8 +74,11 @@ const reading = (
|
|
|
74
74
|
organizer: "priya@example.test",
|
|
75
75
|
zoneCertainty: "Explicit",
|
|
76
76
|
acceptedCalendarObjectId: "",
|
|
77
|
+
supersededByMessageId: "",
|
|
78
|
+
supersededByThreadId: "",
|
|
77
79
|
createdAt: 0,
|
|
78
80
|
updatedAt: 0,
|
|
81
|
+
answerOvertakenBy: "None",
|
|
79
82
|
...overrides,
|
|
80
83
|
});
|
|
81
84
|
|
|
@@ -63,8 +63,11 @@ const pending: RemitImapCalendarSuggestionResponse = {
|
|
|
63
63
|
organizer: "organizer@example.test",
|
|
64
64
|
zoneCertainty: "Explicit",
|
|
65
65
|
acceptedCalendarObjectId: "",
|
|
66
|
+
supersededByMessageId: "",
|
|
67
|
+
supersededByThreadId: "",
|
|
66
68
|
createdAt: 0,
|
|
67
69
|
updatedAt: 0,
|
|
70
|
+
answerOvertakenBy: "None",
|
|
68
71
|
};
|
|
69
72
|
|
|
70
73
|
let harness: DomHarness | undefined;
|
|
@@ -325,6 +325,7 @@ function BriefReading() {
|
|
|
325
325
|
actions,
|
|
326
326
|
onReply,
|
|
327
327
|
handleDeselectIfRemoved,
|
|
328
|
+
onOpenThread,
|
|
328
329
|
} = useBriefPane();
|
|
329
330
|
const hasThread = Boolean(conversation);
|
|
330
331
|
const intelligence = useIntelligenceSurface(conversation?.threadId);
|
|
@@ -377,6 +378,7 @@ function BriefReading() {
|
|
|
377
378
|
accountId={selectedThread?.accountId}
|
|
378
379
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
379
380
|
onReplyWithText={replyWithText}
|
|
381
|
+
onOpenThread={onOpenThread}
|
|
380
382
|
/>
|
|
381
383
|
</>
|
|
382
384
|
);
|
|
@@ -388,7 +390,8 @@ function BriefReading() {
|
|
|
388
390
|
*/
|
|
389
391
|
function BriefIntelligence() {
|
|
390
392
|
const replyWithText = useReplyWithText();
|
|
391
|
-
const { selectedThread, handleDeselectIfRemoved } =
|
|
393
|
+
const { selectedThread, handleDeselectIfRemoved, onOpenThread } =
|
|
394
|
+
useBriefPane();
|
|
392
395
|
const { onToggleIntelligence } = useMailContext();
|
|
393
396
|
|
|
394
397
|
return (
|
|
@@ -399,6 +402,7 @@ function BriefIntelligence() {
|
|
|
399
402
|
accountId={selectedThread?.accountId}
|
|
400
403
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
401
404
|
onReplyWithText={replyWithText}
|
|
405
|
+
onOpenThread={onOpenThread}
|
|
402
406
|
/>
|
|
403
407
|
);
|
|
404
408
|
}
|
|
@@ -444,6 +448,7 @@ function BriefPhone() {
|
|
|
444
448
|
accountId={selectedThread?.accountId}
|
|
445
449
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
446
450
|
onReplyWithText={replyWithText}
|
|
451
|
+
onOpenThread={onOpenThread}
|
|
447
452
|
/>
|
|
448
453
|
</>
|
|
449
454
|
);
|
|
@@ -328,6 +328,7 @@ function FlaggedReading() {
|
|
|
328
328
|
actions,
|
|
329
329
|
onReply,
|
|
330
330
|
handleDeselectIfRemoved,
|
|
331
|
+
onOpenThread,
|
|
331
332
|
} = useFlaggedPane();
|
|
332
333
|
const hasThread = Boolean(conversation);
|
|
333
334
|
const intelligence = useIntelligenceSurface(conversation?.threadId);
|
|
@@ -380,6 +381,7 @@ function FlaggedReading() {
|
|
|
380
381
|
accountId={selectedThread?.accountId}
|
|
381
382
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
382
383
|
onReplyWithText={replyWithText}
|
|
384
|
+
onOpenThread={onOpenThread}
|
|
383
385
|
/>
|
|
384
386
|
</>
|
|
385
387
|
);
|
|
@@ -391,7 +393,8 @@ function FlaggedReading() {
|
|
|
391
393
|
*/
|
|
392
394
|
function FlaggedIntelligence() {
|
|
393
395
|
const replyWithText = useReplyWithText();
|
|
394
|
-
const { selectedThread, handleDeselectIfRemoved } =
|
|
396
|
+
const { selectedThread, handleDeselectIfRemoved, onOpenThread } =
|
|
397
|
+
useFlaggedPane();
|
|
395
398
|
const { onToggleIntelligence } = useMailContext();
|
|
396
399
|
|
|
397
400
|
return (
|
|
@@ -402,6 +405,7 @@ function FlaggedIntelligence() {
|
|
|
402
405
|
accountId={selectedThread?.accountId}
|
|
403
406
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
404
407
|
onReplyWithText={replyWithText}
|
|
408
|
+
onOpenThread={onOpenThread}
|
|
405
409
|
/>
|
|
406
410
|
);
|
|
407
411
|
}
|
|
@@ -445,6 +449,7 @@ function FlaggedPhone() {
|
|
|
445
449
|
accountId={selectedThread?.accountId}
|
|
446
450
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
447
451
|
onReplyWithText={replyWithText}
|
|
452
|
+
onOpenThread={onOpenThread}
|
|
448
453
|
/>
|
|
449
454
|
</>
|
|
450
455
|
);
|
|
@@ -2,6 +2,7 @@ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/type
|
|
|
2
2
|
import type { ReplyWithText } from "@/components/compose/reply-with-times";
|
|
3
3
|
import { Drawer } from "@/components/layout/Drawer";
|
|
4
4
|
import { IntelligencePane } from "@/components/mail/IntelligencePane";
|
|
5
|
+
import type { OpenThreadTarget } from "@/routing";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* The intelligence pane as a modal drawer — the surface every list pane uses
|
|
@@ -18,6 +19,7 @@ export function IntelligenceDrawer({
|
|
|
18
19
|
accountId,
|
|
19
20
|
onAfterOptimisticRemove,
|
|
20
21
|
onReplyWithText,
|
|
22
|
+
onOpenThread,
|
|
21
23
|
}: {
|
|
22
24
|
isOpen: boolean;
|
|
23
25
|
onClose: () => void;
|
|
@@ -26,6 +28,7 @@ export function IntelligenceDrawer({
|
|
|
26
28
|
accountId?: string;
|
|
27
29
|
onAfterOptimisticRemove?: (messageIds: string[]) => void;
|
|
28
30
|
onReplyWithText?: ReplyWithText;
|
|
31
|
+
onOpenThread?: (target: OpenThreadTarget) => void;
|
|
29
32
|
}) {
|
|
30
33
|
return (
|
|
31
34
|
<Drawer
|
|
@@ -45,6 +48,7 @@ export function IntelligenceDrawer({
|
|
|
45
48
|
hideCloseButton
|
|
46
49
|
onAfterOptimisticRemove={onAfterOptimisticRemove}
|
|
47
50
|
onReplyWithText={onReplyWithText}
|
|
51
|
+
onOpenThread={onOpenThread}
|
|
48
52
|
/>
|
|
49
53
|
</Drawer>
|
|
50
54
|
);
|
|
@@ -19,7 +19,7 @@ import { useUpdateAddressFlags } from "@/hooks/useUpdateAddressFlags";
|
|
|
19
19
|
import { isRescueCandidate } from "@/lib/rescue-candidates";
|
|
20
20
|
import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
|
|
21
21
|
import { useTelemetry } from "@/lib/telemetry-context";
|
|
22
|
-
import { NavLink } from "@/routing";
|
|
22
|
+
import { NavLink, type OpenThreadTarget } from "@/routing";
|
|
23
23
|
import type { ReplyWithText } from "../compose/reply-with-times";
|
|
24
24
|
|
|
25
25
|
export interface IntelligencePaneProps {
|
|
@@ -55,6 +55,7 @@ export interface IntelligencePaneProps {
|
|
|
55
55
|
*/
|
|
56
56
|
onAfterOptimisticRemove?: (messageIds: string[]) => void;
|
|
57
57
|
onReplyWithText?: ReplyWithText;
|
|
58
|
+
onOpenThread?: (target: OpenThreadTarget) => void;
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
/**
|
|
@@ -254,6 +255,7 @@ interface WiredPanelProps {
|
|
|
254
255
|
hideCloseButton?: boolean;
|
|
255
256
|
onAfterOptimisticRemove?: (messageIds: string[]) => void;
|
|
256
257
|
onReplyWithText?: ReplyWithText;
|
|
258
|
+
onOpenThread?: (target: OpenThreadTarget) => void;
|
|
257
259
|
}
|
|
258
260
|
|
|
259
261
|
/**
|
|
@@ -267,6 +269,7 @@ function WiredPanel({
|
|
|
267
269
|
hideCloseButton,
|
|
268
270
|
onAfterOptimisticRemove,
|
|
269
271
|
onReplyWithText,
|
|
272
|
+
onOpenThread,
|
|
270
273
|
}: WiredPanelProps) {
|
|
271
274
|
const {
|
|
272
275
|
data,
|
|
@@ -277,7 +280,11 @@ function WiredPanel({
|
|
|
277
280
|
similarErrorIsFatal,
|
|
278
281
|
semanticEnabled,
|
|
279
282
|
} = useIntelligenceData(thread, mailboxId);
|
|
280
|
-
const calendar = useIntelligenceCalendar(
|
|
283
|
+
const calendar = useIntelligenceCalendar(
|
|
284
|
+
thread,
|
|
285
|
+
onReplyWithText,
|
|
286
|
+
onOpenThread,
|
|
287
|
+
);
|
|
281
288
|
const [reclassifyOpen, setReclassifyOpen] = useState(false);
|
|
282
289
|
const senderEmail = thread.fromEmail ?? undefined;
|
|
283
290
|
|
|
@@ -437,6 +444,7 @@ export const IntelligencePane = ({
|
|
|
437
444
|
hideCloseButton,
|
|
438
445
|
onAfterOptimisticRemove,
|
|
439
446
|
onReplyWithText,
|
|
447
|
+
onOpenThread,
|
|
440
448
|
}: IntelligencePaneProps) => {
|
|
441
449
|
if (!thread) {
|
|
442
450
|
return (
|
|
@@ -467,6 +475,7 @@ export const IntelligencePane = ({
|
|
|
467
475
|
hideCloseButton={hideCloseButton}
|
|
468
476
|
onAfterOptimisticRemove={onAfterOptimisticRemove}
|
|
469
477
|
onReplyWithText={onReplyWithText}
|
|
478
|
+
onOpenThread={onOpenThread}
|
|
470
479
|
/>
|
|
471
480
|
);
|
|
472
481
|
};
|
|
@@ -1130,6 +1130,7 @@ function MailboxReading() {
|
|
|
1130
1130
|
onToolbarMove,
|
|
1131
1131
|
handleDeselectIfRemoved,
|
|
1132
1132
|
intelligenceRef,
|
|
1133
|
+
onOpenThread,
|
|
1133
1134
|
} = useMailboxPane();
|
|
1134
1135
|
const hasThread = Boolean(conversation);
|
|
1135
1136
|
const intelligence = useIntelligenceSurface(conversation?.threadId);
|
|
@@ -1207,6 +1208,7 @@ function MailboxReading() {
|
|
|
1207
1208
|
accountId={mailboxAccountId}
|
|
1208
1209
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
1209
1210
|
onReplyWithText={replyWithText}
|
|
1211
|
+
onOpenThread={onOpenThread}
|
|
1210
1212
|
/>
|
|
1211
1213
|
</>
|
|
1212
1214
|
);
|
|
@@ -1224,6 +1226,7 @@ function MailboxIntelligence() {
|
|
|
1224
1226
|
selectedThread,
|
|
1225
1227
|
onToggleIntelligence,
|
|
1226
1228
|
handleDeselectIfRemoved,
|
|
1229
|
+
onOpenThread,
|
|
1227
1230
|
} = useMailboxPane();
|
|
1228
1231
|
|
|
1229
1232
|
return (
|
|
@@ -1234,6 +1237,7 @@ function MailboxIntelligence() {
|
|
|
1234
1237
|
accountId={mailboxAccountId}
|
|
1235
1238
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
1236
1239
|
onReplyWithText={replyWithText}
|
|
1240
|
+
onOpenThread={onOpenThread}
|
|
1237
1241
|
/>
|
|
1238
1242
|
);
|
|
1239
1243
|
}
|
|
@@ -1288,6 +1292,7 @@ function MailboxPhone() {
|
|
|
1288
1292
|
accountId={mailboxAccountId}
|
|
1289
1293
|
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
1290
1294
|
onReplyWithText={replyWithText}
|
|
1295
|
+
onOpenThread={onOpenThread}
|
|
1291
1296
|
/>
|
|
1292
1297
|
</>
|
|
1293
1298
|
);
|
|
@@ -149,8 +149,11 @@ const invitation: RemitImapCalendarSuggestionResponse = {
|
|
|
149
149
|
organizer: "organizer@example.test",
|
|
150
150
|
zoneCertainty: "Explicit",
|
|
151
151
|
acceptedCalendarObjectId: "",
|
|
152
|
+
supersededByMessageId: "",
|
|
153
|
+
supersededByThreadId: "",
|
|
152
154
|
createdAt: 0,
|
|
153
155
|
updatedAt: 0,
|
|
156
|
+
answerOvertakenBy: "None",
|
|
154
157
|
};
|
|
155
158
|
|
|
156
159
|
const sender: IntelligenceData = {
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
calendarSuggestionActionOperationsAcceptCalendarSuggestionMutation,
|
|
16
16
|
calendarSuggestionActionOperationsDeclineCalendarSuggestionMutation,
|
|
17
17
|
calendarSuggestionActionOperationsDismissCalendarSuggestionMutation,
|
|
18
|
+
calendarSuggestionActionOperationsReopenCalendarSuggestionMutation,
|
|
18
19
|
calendarSuggestionOperationsListCalendarSuggestionsOptions,
|
|
19
20
|
messageCalendarSuggestionOperationsListMessageCalendarSuggestionsOptions,
|
|
20
21
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
@@ -162,6 +163,7 @@ export interface CalendarSuggestionAnswers {
|
|
|
162
163
|
suggestionId: string,
|
|
163
164
|
muteSender: boolean,
|
|
164
165
|
) => Promise<SuggestionAnswer>;
|
|
166
|
+
reopen: (suggestionId: string) => Promise<SuggestionAnswer>;
|
|
165
167
|
isAnswering: boolean;
|
|
166
168
|
}
|
|
167
169
|
|
|
@@ -188,6 +190,12 @@ export function useCalendarSuggestionAnswers(): CalendarSuggestionAnswers {
|
|
|
188
190
|
onSuccess,
|
|
189
191
|
});
|
|
190
192
|
|
|
193
|
+
const reopen = useMutation({
|
|
194
|
+
...calendarSuggestionActionOperationsReopenCalendarSuggestionMutation(),
|
|
195
|
+
meta: SUGGESTION_META,
|
|
196
|
+
onSuccess,
|
|
197
|
+
});
|
|
198
|
+
|
|
191
199
|
const acceptSuggestion = useCallback(
|
|
192
200
|
(suggestionId: string, calendarId: string) =>
|
|
193
201
|
accept
|
|
@@ -231,6 +239,14 @@ export function useCalendarSuggestionAnswers(): CalendarSuggestionAnswers {
|
|
|
231
239
|
.catch(refused),
|
|
232
240
|
[dismiss],
|
|
233
241
|
);
|
|
242
|
+
const reopenSuggestion = useCallback(
|
|
243
|
+
(suggestionId: string) =>
|
|
244
|
+
reopen
|
|
245
|
+
.mutateAsync({ path: { suggestionId } })
|
|
246
|
+
.then(() => ANSWERED)
|
|
247
|
+
.catch(refused),
|
|
248
|
+
[reopen],
|
|
249
|
+
);
|
|
234
250
|
|
|
235
251
|
return useMemo(
|
|
236
252
|
() => ({
|
|
@@ -239,7 +255,12 @@ export function useCalendarSuggestionAnswers(): CalendarSuggestionAnswers {
|
|
|
239
255
|
acceptEdited: acceptEditedSuggestion,
|
|
240
256
|
decline: declineSuggestion,
|
|
241
257
|
dismiss: dismissSuggestion,
|
|
242
|
-
|
|
258
|
+
reopen: reopenSuggestion,
|
|
259
|
+
isAnswering:
|
|
260
|
+
accept.isPending ||
|
|
261
|
+
decline.isPending ||
|
|
262
|
+
dismiss.isPending ||
|
|
263
|
+
reopen.isPending,
|
|
243
264
|
}),
|
|
244
265
|
[
|
|
245
266
|
acceptSuggestion,
|
|
@@ -247,9 +268,11 @@ export function useCalendarSuggestionAnswers(): CalendarSuggestionAnswers {
|
|
|
247
268
|
acceptEditedSuggestion,
|
|
248
269
|
declineSuggestion,
|
|
249
270
|
dismissSuggestion,
|
|
271
|
+
reopenSuggestion,
|
|
250
272
|
accept.isPending,
|
|
251
273
|
decline.isPending,
|
|
252
274
|
dismiss.isPending,
|
|
275
|
+
reopen.isPending,
|
|
253
276
|
],
|
|
254
277
|
);
|
|
255
278
|
}
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
RouterProvider,
|
|
24
24
|
} from "@tanstack/react-router";
|
|
25
25
|
import { createElement } from "react";
|
|
26
|
+
import { useOpenThread } from "@/routing";
|
|
26
27
|
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
27
28
|
import { makeThreadMessage } from "@/test-support/fixtures";
|
|
28
29
|
import {
|
|
@@ -31,6 +32,7 @@ import {
|
|
|
31
32
|
httpError,
|
|
32
33
|
mockFetch,
|
|
33
34
|
} from "@/test-support/http";
|
|
35
|
+
import { threadRouter } from "@/test-support/thread-router";
|
|
34
36
|
import { useIntelligenceCalendar } from "./useIntelligenceCalendar";
|
|
35
37
|
|
|
36
38
|
const CALENDAR = "11111111-1111-4111-8111-111111111111";
|
|
@@ -95,12 +97,15 @@ const invitation = (
|
|
|
95
97
|
organizer: "organizer@example.test",
|
|
96
98
|
zoneCertainty: "Explicit",
|
|
97
99
|
acceptedCalendarObjectId: "",
|
|
100
|
+
supersededByMessageId: "",
|
|
101
|
+
supersededByThreadId: "",
|
|
98
102
|
createdAt: 0,
|
|
99
103
|
updatedAt: 0,
|
|
104
|
+
answerOvertakenBy: "None",
|
|
100
105
|
});
|
|
101
106
|
|
|
102
107
|
const Harness = () => {
|
|
103
|
-
const calendar = useIntelligenceCalendar(thread);
|
|
108
|
+
const calendar = useIntelligenceCalendar(thread, undefined, useOpenThread());
|
|
104
109
|
return createElement(IntelligencePanel, {
|
|
105
110
|
data: sender,
|
|
106
111
|
calendar: calendar.surface,
|
|
@@ -146,14 +151,19 @@ const server = (
|
|
|
146
151
|
};
|
|
147
152
|
};
|
|
148
153
|
|
|
149
|
-
const mount = async (
|
|
154
|
+
const mount = async (
|
|
155
|
+
respond: (call: HttpCall) => unknown,
|
|
156
|
+
): Promise<AnyRouter> => {
|
|
150
157
|
http = mockFetch(respond);
|
|
151
158
|
harness = createDomHarness();
|
|
152
|
-
|
|
159
|
+
const router = threadRouter(Harness, "/mail/brief/thread-1/msg-1");
|
|
160
|
+
await router.load();
|
|
161
|
+
harness.renderApp(createElement(RouterProvider, { router }));
|
|
153
162
|
await harness.waitFor(
|
|
154
163
|
() => harness?.text().includes("Quarterly review") === true,
|
|
155
164
|
"the invitation to be drawn",
|
|
156
165
|
);
|
|
166
|
+
return router;
|
|
157
167
|
};
|
|
158
168
|
|
|
159
169
|
const press = (label: string) => {
|
|
@@ -255,19 +265,63 @@ describe("the invitation beside the open message", () => {
|
|
|
255
265
|
press("Stop offering invitations from Alice");
|
|
256
266
|
|
|
257
267
|
await harness?.waitFor(
|
|
258
|
-
() => harness?.text().includes("
|
|
259
|
-
"the card to
|
|
260
|
-
);
|
|
261
|
-
assert.match(
|
|
262
|
-
harness?.text() ?? "",
|
|
263
|
-
/Nothing in this message is about a time/,
|
|
264
|
-
"answering the last card keeps the Calendar tab",
|
|
268
|
+
() => harness?.text().includes("You dismissed this") === true,
|
|
269
|
+
"the card to read back the dismissed state",
|
|
265
270
|
);
|
|
271
|
+
assert.doesNotMatch(harness?.text() ?? "", /Add to calendar/);
|
|
266
272
|
const dismissed =
|
|
267
273
|
http?.to(`/calendar-suggestions/${SUGGESTION}/dismiss`) ?? [];
|
|
268
274
|
assert.equal(dismissed.length, 1);
|
|
269
275
|
assert.deepEqual(dismissed[0].body, { muteSender: true });
|
|
270
276
|
});
|
|
277
|
+
|
|
278
|
+
for (const [label, state] of [
|
|
279
|
+
["Add to calendar", "Accepted"],
|
|
280
|
+
["Maybe", "Tentative"],
|
|
281
|
+
["Decline", "Declined"],
|
|
282
|
+
["Stop offering invitations from Alice", "Dismissed"],
|
|
283
|
+
] as const) {
|
|
284
|
+
it(`takes a ${state} answer back through the API, and asks again`, async () => {
|
|
285
|
+
await mount(
|
|
286
|
+
server((call) => (call.path.endsWith("/reopen") ? "Pending" : state)),
|
|
287
|
+
);
|
|
288
|
+
press(label);
|
|
289
|
+
await harness?.waitFor(
|
|
290
|
+
() => harness?.text().includes("Change") === true,
|
|
291
|
+
"the card to offer Change once answered",
|
|
292
|
+
);
|
|
293
|
+
|
|
294
|
+
press("Change");
|
|
295
|
+
|
|
296
|
+
await harness?.waitFor(
|
|
297
|
+
() => harness?.text().includes("Add to calendar") === true,
|
|
298
|
+
"the answers to be offered again once the server reopened it",
|
|
299
|
+
);
|
|
300
|
+
assert.equal(
|
|
301
|
+
http?.to(`/calendar-suggestions/${SUGGESTION}/reopen`).length,
|
|
302
|
+
1,
|
|
303
|
+
);
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
it("offers no Change on an answer its cancellation was answered over", async () => {
|
|
308
|
+
await mount((call) => {
|
|
309
|
+
if (call.path.endsWith("/calendars")) return { items: calendars };
|
|
310
|
+
if (call.path.endsWith("/messages/msg-1/calendar-suggestions"))
|
|
311
|
+
return {
|
|
312
|
+
items: [
|
|
313
|
+
{ ...invitation("Accepted"), answerOvertakenBy: "Cancellation" },
|
|
314
|
+
],
|
|
315
|
+
};
|
|
316
|
+
return { items: [] };
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
await harness?.waitFor(
|
|
320
|
+
() => harness?.text().includes("On your calendar") === true,
|
|
321
|
+
"the card to read back the accepted state",
|
|
322
|
+
);
|
|
323
|
+
assert.equal(harness?.text().includes("Change"), false);
|
|
324
|
+
});
|
|
271
325
|
});
|
|
272
326
|
|
|
273
327
|
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
@@ -396,3 +450,48 @@ describe("changing a suggestion beside the message before adding it", () => {
|
|
|
396
450
|
assert.equal(router.state.location.pathname, "/mail");
|
|
397
451
|
});
|
|
398
452
|
});
|
|
453
|
+
|
|
454
|
+
describe("an invitation a newer revision replaced", () => {
|
|
455
|
+
const retired = (
|
|
456
|
+
supersededByThreadId: string,
|
|
457
|
+
): RemitImapCalendarSuggestionResponse => ({
|
|
458
|
+
...invitation("Superseded"),
|
|
459
|
+
supersededByMessageId: "msg-2",
|
|
460
|
+
supersededByThreadId,
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const serving =
|
|
464
|
+
(suggestion: RemitImapCalendarSuggestionResponse) =>
|
|
465
|
+
(call: HttpCall): unknown => {
|
|
466
|
+
if (call.path.endsWith("/calendars")) return { items: calendars };
|
|
467
|
+
if (call.path.endsWith("/messages/msg-1/calendar-suggestions"))
|
|
468
|
+
return { items: [suggestion] };
|
|
469
|
+
return { items: [] };
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
it("opens the conversation that carries the newer revision", async () => {
|
|
473
|
+
const router = await mount(serving(retired("thread-2")));
|
|
474
|
+
assert.match(harness?.text() ?? "", /has sent a newer version of this/);
|
|
475
|
+
|
|
476
|
+
press("Open the newer invitation");
|
|
477
|
+
|
|
478
|
+
await harness?.waitFor(
|
|
479
|
+
() => router.state.location.pathname === "/mail/brief/thread-2/msg-2",
|
|
480
|
+
"the reading pane to move to the newer revision",
|
|
481
|
+
);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
it("offers no way there when the newer message sits in no conversation", async () => {
|
|
485
|
+
await mount(serving(retired("")));
|
|
486
|
+
|
|
487
|
+
assert.match(harness?.text() ?? "", /has sent a newer version of this/);
|
|
488
|
+
assert.equal(
|
|
489
|
+
harness
|
|
490
|
+
?.queryAll("button")
|
|
491
|
+
.some((button) =>
|
|
492
|
+
button.textContent?.includes("Open the newer invitation"),
|
|
493
|
+
),
|
|
494
|
+
false,
|
|
495
|
+
);
|
|
496
|
+
});
|
|
497
|
+
});
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* outlives the message it is about.
|
|
10
10
|
*/
|
|
11
11
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
12
|
+
import { CalendarAnswerOvertaken } from "@remit/domain-enums";
|
|
12
13
|
import type {
|
|
13
14
|
CalendarDayEntry,
|
|
14
15
|
CalendarSlotPick,
|
|
@@ -51,6 +52,7 @@ import {
|
|
|
51
52
|
toEventSuggestion,
|
|
52
53
|
} from "@/lib/calendar-suggestion";
|
|
53
54
|
import {
|
|
55
|
+
type OpenThreadTarget,
|
|
54
56
|
useChangeSuggestionOnCalendar,
|
|
55
57
|
useOpenEventOnCalendar,
|
|
56
58
|
} from "@/routing";
|
|
@@ -95,6 +97,7 @@ export interface IntelligenceCalendar {
|
|
|
95
97
|
export function useIntelligenceCalendar(
|
|
96
98
|
thread: RemitImapThreadMessageResponse,
|
|
97
99
|
replyWithText?: ReplyWithText,
|
|
100
|
+
openThread?: (target: OpenThreadTarget) => void,
|
|
98
101
|
): IntelligenceCalendar {
|
|
99
102
|
const messageId = thread.messageId;
|
|
100
103
|
const { suggestions, isLoading, error } =
|
|
@@ -259,6 +262,9 @@ export function useIntelligenceCalendar(
|
|
|
259
262
|
};
|
|
260
263
|
const pickedText =
|
|
261
264
|
date === "" ? "" : slotsAsText(date, slots, working.picked);
|
|
265
|
+
const overtaken =
|
|
266
|
+
invitation !== undefined &&
|
|
267
|
+
invitation.answerOvertakenBy !== CalendarAnswerOvertaken.None;
|
|
262
268
|
|
|
263
269
|
const actions: IntelligenceCalendarActions = {
|
|
264
270
|
onAddInvite: () => {
|
|
@@ -293,7 +299,20 @@ export function useIntelligenceCalendar(
|
|
|
293
299
|
answer(invitationId, "keep this on your calendar", () =>
|
|
294
300
|
answers.dismiss(invitationId, false),
|
|
295
301
|
)
|
|
296
|
-
:
|
|
302
|
+
: overtaken
|
|
303
|
+
? undefined
|
|
304
|
+
: () =>
|
|
305
|
+
answer(invitationId, "take this answer back", () =>
|
|
306
|
+
answers.reopen(invitationId),
|
|
307
|
+
),
|
|
308
|
+
onOpenNewerInvite:
|
|
309
|
+
openThread && invitation && invitation.supersededByThreadId !== ""
|
|
310
|
+
? () =>
|
|
311
|
+
openThread({
|
|
312
|
+
threadId: invitation.supersededByThreadId,
|
|
313
|
+
messageId: invitation.supersededByMessageId,
|
|
314
|
+
})
|
|
315
|
+
: undefined,
|
|
297
316
|
onOfferOtherTimes: () => update({ offering: true }),
|
|
298
317
|
onToggleSlot: (slot) =>
|
|
299
318
|
update({
|
|
@@ -36,8 +36,11 @@ 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,
|
|
43
|
+
answerOvertakenBy: "None",
|
|
41
44
|
...over,
|
|
42
45
|
});
|
|
43
46
|
|
|
@@ -68,8 +71,15 @@ describe("inviteStanding", () => {
|
|
|
68
71
|
});
|
|
69
72
|
});
|
|
70
73
|
|
|
71
|
-
it("
|
|
72
|
-
assert.
|
|
74
|
+
it("keeps a card the reader waved away, so it can be taken back", () => {
|
|
75
|
+
assert.deepEqual(inviteStanding(suggestion({ state: "Dismissed" })), {
|
|
76
|
+
state: "dismissed",
|
|
77
|
+
rsvp: "noReply",
|
|
78
|
+
});
|
|
79
|
+
assert.equal(
|
|
80
|
+
inviteStanding(suggestion({ method: "Cancel", state: "Dismissed" })),
|
|
81
|
+
undefined,
|
|
82
|
+
);
|
|
73
83
|
});
|
|
74
84
|
|
|
75
85
|
it("asks about a cancellation only until it is taken or kept", () => {
|
|
@@ -61,18 +61,20 @@ export interface InviteStanding {
|
|
|
61
61
|
/**
|
|
62
62
|
* What a suggestion's state means for its card.
|
|
63
63
|
*
|
|
64
|
-
* A dismissed card was waved away
|
|
65
|
-
* answer only while it is pending: once
|
|
66
|
-
* settled and there is nothing left to
|
|
64
|
+
* A dismissed card was waved away without an answer, and stays so it can be
|
|
65
|
+
* taken back. A cancellation asks for an answer only while it is pending: once
|
|
66
|
+
* taken or kept, the event it named is settled and there is nothing left to
|
|
67
|
+
* say beside the message.
|
|
67
68
|
*/
|
|
68
69
|
export function inviteStanding(
|
|
69
70
|
suggestion: CalendarSuggestion,
|
|
70
71
|
): InviteStanding | undefined {
|
|
71
|
-
if (suggestion.state === "Dismissed") return undefined;
|
|
72
72
|
if (suggestion.method === "Cancel")
|
|
73
73
|
return suggestion.state === "Pending"
|
|
74
74
|
? { state: "cancelled", rsvp: "noReply" }
|
|
75
75
|
: undefined;
|
|
76
|
+
if (suggestion.state === "Dismissed")
|
|
77
|
+
return { state: "dismissed", rsvp: "noReply" };
|
|
76
78
|
if (suggestion.state === "Superseded")
|
|
77
79
|
return { state: "superseded", rsvp: "noReply" };
|
|
78
80
|
if (suggestion.state === "Accepted")
|
|
@@ -31,8 +31,11 @@ const allDay = {
|
|
|
31
31
|
organizer: "organizer@example.test",
|
|
32
32
|
zoneCertainty: "Explicit",
|
|
33
33
|
acceptedCalendarObjectId: "",
|
|
34
|
+
supersededByMessageId: "",
|
|
35
|
+
supersededByThreadId: "",
|
|
34
36
|
createdAt: 0,
|
|
35
37
|
updatedAt: 0,
|
|
38
|
+
answerOvertakenBy: "None",
|
|
36
39
|
} as const;
|
|
37
40
|
|
|
38
41
|
describe("an all-day invitation, west of where it was sent", () => {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyRouter,
|
|
3
|
+
createMemoryHistory,
|
|
4
|
+
createRootRoute,
|
|
5
|
+
createRoute,
|
|
6
|
+
createRouter,
|
|
7
|
+
Outlet,
|
|
8
|
+
} from "@tanstack/react-router";
|
|
9
|
+
import { createElement, type FunctionComponent } from "react";
|
|
10
|
+
|
|
11
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The brief's thread routes, with `surface` mounted above them so it stays on
|
|
15
|
+
* screen while the address moves. A surface that opens a conversation is
|
|
16
|
+
* asserted by where the router lands, not by the callback it was handed.
|
|
17
|
+
*/
|
|
18
|
+
export const threadRouter = (
|
|
19
|
+
surface: FunctionComponent,
|
|
20
|
+
path: string,
|
|
21
|
+
): AnyRouter => {
|
|
22
|
+
const rootRoute = createRootRoute({
|
|
23
|
+
component: () =>
|
|
24
|
+
createElement("div", null, createElement(surface), createElement(Outlet)),
|
|
25
|
+
});
|
|
26
|
+
const mailRoute = createRoute({
|
|
27
|
+
getParentRoute: () => rootRoute,
|
|
28
|
+
path: "/mail",
|
|
29
|
+
component: Outlet,
|
|
30
|
+
});
|
|
31
|
+
const briefRoute = createRoute({
|
|
32
|
+
getParentRoute: () => mailRoute,
|
|
33
|
+
path: "/brief",
|
|
34
|
+
component: Outlet,
|
|
35
|
+
});
|
|
36
|
+
const threadRoute = createRoute({
|
|
37
|
+
getParentRoute: () => briefRoute,
|
|
38
|
+
path: "$threadId",
|
|
39
|
+
component: Outlet,
|
|
40
|
+
});
|
|
41
|
+
const messageRoute = createRoute({
|
|
42
|
+
getParentRoute: () => threadRoute,
|
|
43
|
+
path: "$messageId",
|
|
44
|
+
component: Outlet,
|
|
45
|
+
});
|
|
46
|
+
return createRouter({
|
|
47
|
+
routeTree: rootRoute.addChildren([
|
|
48
|
+
mailRoute.addChildren([
|
|
49
|
+
briefRoute.addChildren([threadRoute.addChildren([messageRoute])]),
|
|
50
|
+
]),
|
|
51
|
+
]),
|
|
52
|
+
history: createMemoryHistory({ initialEntries: [path] }),
|
|
53
|
+
}) as unknown as AnyRouter;
|
|
54
|
+
};
|