@remit/web-client 0.0.312 → 0.0.314
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 +182 -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 +299 -0
- package/src/components/calendar/pending-suggestions.render.test.ts +68 -3
- package/src/components/compose/ComposeForm.tsx +38 -4
- package/src/components/compose/compose-inline-image.render.test.ts +542 -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 +135 -0
- package/src/hooks/useComposeAttachments.ts +6 -1
- package/src/hooks/useInlineImages.ts +93 -0
- package/src/hooks/useIntelligenceCalendar.ts +36 -5
- package/src/lib/calendar-suggestion.test.ts +10 -1
- package/src/lib/calendar-suggestion.ts +12 -2
- package/src/lib/compose-attachment-controller.test.ts +110 -0
- package/src/lib/compose-attachment-controller.ts +120 -16
- 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
|
@@ -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,6 +14,14 @@ 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";
|
|
18
26
|
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
19
27
|
import { makeThreadMessage } from "@/test-support/fixtures";
|
|
@@ -261,3 +269,130 @@ describe("the invitation beside the open message", () => {
|
|
|
261
269
|
assert.deepEqual(dismissed[0].body, { muteSender: true });
|
|
262
270
|
});
|
|
263
271
|
});
|
|
272
|
+
|
|
273
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
274
|
+
|
|
275
|
+
const READING = "33333333-3333-4333-8333-333333333333";
|
|
276
|
+
|
|
277
|
+
const routedMount = async (
|
|
278
|
+
items: RemitImapCalendarSuggestionResponse[],
|
|
279
|
+
): Promise<AnyRouter> => {
|
|
280
|
+
http = mockFetch((call: HttpCall): unknown => {
|
|
281
|
+
if (call.path.endsWith("/calendars")) return { items: calendars };
|
|
282
|
+
if (call.path.endsWith("/messages/msg-1/calendar-suggestions"))
|
|
283
|
+
return { items };
|
|
284
|
+
return { items: [] };
|
|
285
|
+
});
|
|
286
|
+
const rootRoute = createRootRoute({ component: Harness });
|
|
287
|
+
const routeTree = rootRoute.addChildren([
|
|
288
|
+
createRoute({
|
|
289
|
+
getParentRoute: () => rootRoute,
|
|
290
|
+
path: "/mail",
|
|
291
|
+
component: () => null,
|
|
292
|
+
}),
|
|
293
|
+
createRoute({
|
|
294
|
+
getParentRoute: () => rootRoute,
|
|
295
|
+
path: "/calendar/$view/$date/suggestion/$suggestionId",
|
|
296
|
+
component: () => null,
|
|
297
|
+
}),
|
|
298
|
+
]);
|
|
299
|
+
const router = createRouter({
|
|
300
|
+
routeTree,
|
|
301
|
+
history: createMemoryHistory({ initialEntries: ["/mail"] }),
|
|
302
|
+
}) as unknown as AnyRouter;
|
|
303
|
+
await router.load();
|
|
304
|
+
harness = createDomHarness();
|
|
305
|
+
harness.renderApp(createElement(RouterProvider, { router }));
|
|
306
|
+
await harness.waitFor(
|
|
307
|
+
() => harness?.text().includes("Quarterly review") === true,
|
|
308
|
+
"the invitation to be drawn",
|
|
309
|
+
);
|
|
310
|
+
return router;
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
const answered = (): HttpCall[] =>
|
|
314
|
+
(http?.calls ?? []).filter((call) => call.method === "POST");
|
|
315
|
+
|
|
316
|
+
describe("changing a suggestion beside the message before adding it", () => {
|
|
317
|
+
it("opens the editor for a reading from the thread on the day it falls", async () => {
|
|
318
|
+
const router = await routedMount([
|
|
319
|
+
invitation("Pending"),
|
|
320
|
+
{
|
|
321
|
+
...invitation("Pending"),
|
|
322
|
+
suggestionId: READING,
|
|
323
|
+
source: "TextHeuristic",
|
|
324
|
+
summary: "Lunch with Sam",
|
|
325
|
+
},
|
|
326
|
+
]);
|
|
327
|
+
await harness?.waitFor(
|
|
328
|
+
() => harness?.text().includes("Lunch with Sam") === true,
|
|
329
|
+
"the reading to be drawn",
|
|
330
|
+
);
|
|
331
|
+
|
|
332
|
+
press("Change first");
|
|
333
|
+
await harness?.waitFor(
|
|
334
|
+
() => router.state.location.pathname.includes(READING),
|
|
335
|
+
"the editor to be opened",
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
assert.equal(
|
|
339
|
+
router.state.location.pathname,
|
|
340
|
+
`/calendar/day/2026-09-01/suggestion/${READING}`,
|
|
341
|
+
);
|
|
342
|
+
assert.deepEqual(answered(), []);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it("sends an invitation nobody could put on a clock to the editor instead of adding it", async () => {
|
|
346
|
+
const router = await routedMount([
|
|
347
|
+
{ ...invitation("Pending"), zoneCertainty: "Ambiguous" },
|
|
348
|
+
]);
|
|
349
|
+
|
|
350
|
+
press("Add to calendar");
|
|
351
|
+
await harness?.waitFor(
|
|
352
|
+
() => router.state.location.pathname.includes(SUGGESTION),
|
|
353
|
+
"the editor to be opened",
|
|
354
|
+
);
|
|
355
|
+
|
|
356
|
+
assert.equal(
|
|
357
|
+
router.state.location.pathname,
|
|
358
|
+
`/calendar/day/2026-09-01/suggestion/${SUGGESTION}`,
|
|
359
|
+
);
|
|
360
|
+
assert.deepEqual(answered(), []);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it("applies a cancellation read out of the thread, and offers no editor for it", async () => {
|
|
364
|
+
const router = await routedMount([
|
|
365
|
+
invitation("Accepted"),
|
|
366
|
+
{
|
|
367
|
+
...invitation("Pending"),
|
|
368
|
+
suggestionId: READING,
|
|
369
|
+
source: "TextHeuristic",
|
|
370
|
+
method: "Cancel",
|
|
371
|
+
zoneCertainty: "Ambiguous",
|
|
372
|
+
summary: "Lunch with Sam",
|
|
373
|
+
},
|
|
374
|
+
]);
|
|
375
|
+
await harness?.waitFor(
|
|
376
|
+
() => harness?.text().includes("Lunch with Sam") === true,
|
|
377
|
+
"the reading to be drawn",
|
|
378
|
+
);
|
|
379
|
+
const card = harness?.query('article[aria-label="Lunch with Sam"]');
|
|
380
|
+
if (!card) throw new Error("the reading's card is not drawn");
|
|
381
|
+
const buttons = [...card.querySelectorAll("button")];
|
|
382
|
+
assert.equal(
|
|
383
|
+
buttons.some((button) => button.textContent?.includes("Change first")),
|
|
384
|
+
false,
|
|
385
|
+
);
|
|
386
|
+
const add = buttons.find((button) =>
|
|
387
|
+
button.textContent?.includes("Add to calendar"),
|
|
388
|
+
);
|
|
389
|
+
if (!add) throw new Error("the reading offers no Add");
|
|
390
|
+
harness?.click(add);
|
|
391
|
+
|
|
392
|
+
await harness?.waitFor(
|
|
393
|
+
() => answered().some((call) => call.path.endsWith(`/${READING}/accept`)),
|
|
394
|
+
"the cancellation to be applied",
|
|
395
|
+
);
|
|
396
|
+
assert.equal(router.state.location.pathname, "/mail");
|
|
397
|
+
});
|
|
398
|
+
});
|
|
@@ -61,7 +61,7 @@ export const useComposeAttachments = ({
|
|
|
61
61
|
complete: (outboxMessageId, outboxAttachmentId) =>
|
|
62
62
|
completeMutation
|
|
63
63
|
.mutateAsync({ path: { outboxMessageId, outboxAttachmentId } })
|
|
64
|
-
.then(() =>
|
|
64
|
+
.then((attachment) => ({ contentUrl: attachment.contentUrl })),
|
|
65
65
|
keep: (outboxMessageId, attachmentIds) =>
|
|
66
66
|
updateMutation
|
|
67
67
|
.mutateAsync({
|
|
@@ -98,6 +98,11 @@ export const useComposeAttachments = ({
|
|
|
98
98
|
remove: controller.remove,
|
|
99
99
|
load: controller.load,
|
|
100
100
|
reset: controller.reset,
|
|
101
|
+
owns: controller.owns,
|
|
102
|
+
isLoaded: controller.isLoaded,
|
|
103
|
+
refreshLinks: controller.refreshLinks,
|
|
104
|
+
storedContent: controller.storedContent,
|
|
105
|
+
onRemoved: controller.onRemoved,
|
|
101
106
|
blockingReason: controller.blockingReason(),
|
|
102
107
|
};
|
|
103
108
|
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { RemitImapOutboxAttachmentResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import type { InlineImagePlacement, InlineImages } from "@remit/ui/rich-text";
|
|
3
|
+
import { useMemo, useRef } from "react";
|
|
4
|
+
import { useAuthProvider } from "@/auth/provider";
|
|
5
|
+
import {
|
|
6
|
+
attachmentFailureContent,
|
|
7
|
+
extractAttachmentFailureDetail,
|
|
8
|
+
extractAttachmentFailureReason,
|
|
9
|
+
fetchAttachment,
|
|
10
|
+
} from "@/lib/attachment-download";
|
|
11
|
+
import type { PushErrorInput } from "../components/ui/error-banners";
|
|
12
|
+
import type { StoredContent } from "../lib/compose-attachment-controller";
|
|
13
|
+
|
|
14
|
+
interface UseInlineImagesOptions {
|
|
15
|
+
attach: (files: File[], placement?: InlineImagePlacement) => Promise<void>;
|
|
16
|
+
owns: (contentId: string) => boolean;
|
|
17
|
+
isLoaded: () => boolean;
|
|
18
|
+
storedContent: (contentId: string) => StoredContent | null;
|
|
19
|
+
refreshLinks: () => Promise<void>;
|
|
20
|
+
onRemoved: (listener: (contentId: string) => void) => () => void;
|
|
21
|
+
draftAttachments: readonly RemitImapOutboxAttachmentResponse[];
|
|
22
|
+
pushError: (input: PushErrorInput) => string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const useInlineImages = (
|
|
26
|
+
options: UseInlineImagesOptions,
|
|
27
|
+
): InlineImages => {
|
|
28
|
+
const { getToken } = useAuthProvider();
|
|
29
|
+
const latest = useRef({ ...options, getToken });
|
|
30
|
+
latest.current = { ...options, getToken };
|
|
31
|
+
|
|
32
|
+
return useMemo<InlineImages>(() => {
|
|
33
|
+
const beforeLoad = (contentId: string): StoredContent | null => {
|
|
34
|
+
const current = latest.current;
|
|
35
|
+
if (current.isLoaded()) return null;
|
|
36
|
+
return (
|
|
37
|
+
current.draftAttachments.find(
|
|
38
|
+
(attachment) => attachment.contentId === contentId,
|
|
39
|
+
) ?? null
|
|
40
|
+
);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const stored = (contentId: string): StoredContent | null =>
|
|
44
|
+
latest.current.storedContent(contentId) ?? beforeLoad(contentId);
|
|
45
|
+
|
|
46
|
+
const fetchStored = (content: StoredContent): Promise<string> =>
|
|
47
|
+
fetchAttachment(content.contentUrl, latest.current.getToken).then(
|
|
48
|
+
(blob) =>
|
|
49
|
+
URL.createObjectURL(new Blob([blob], { type: content.contentType })),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
const fetchRenewed = (contentId: string): Promise<string> =>
|
|
53
|
+
latest.current.refreshLinks().then(() => {
|
|
54
|
+
const renewed = stored(contentId);
|
|
55
|
+
if (!renewed) {
|
|
56
|
+
throw new Error("the file is no longer on this draft");
|
|
57
|
+
}
|
|
58
|
+
return fetchStored(renewed);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
attach: (files, placement) => {
|
|
63
|
+
void latest.current.attach(files, placement);
|
|
64
|
+
},
|
|
65
|
+
owns: (contentId) =>
|
|
66
|
+
latest.current.owns(contentId) || beforeLoad(contentId) !== null,
|
|
67
|
+
onRemoved: (listener) => latest.current.onRemoved(listener),
|
|
68
|
+
resolve: (contentId) => {
|
|
69
|
+
const content = stored(contentId);
|
|
70
|
+
if (!content) return Promise.resolve(null);
|
|
71
|
+
return fetchStored(content)
|
|
72
|
+
.catch((error: unknown) => {
|
|
73
|
+
if (extractAttachmentFailureReason(error) !== "link-expired") {
|
|
74
|
+
throw error;
|
|
75
|
+
}
|
|
76
|
+
return fetchRenewed(contentId);
|
|
77
|
+
})
|
|
78
|
+
.catch((error: unknown) => {
|
|
79
|
+
const reason = extractAttachmentFailureReason(error);
|
|
80
|
+
latest.current.pushError({
|
|
81
|
+
...attachmentFailureContent(
|
|
82
|
+
reason,
|
|
83
|
+
content.filename,
|
|
84
|
+
extractAttachmentFailureDetail(error),
|
|
85
|
+
),
|
|
86
|
+
error: reason === "generic" ? error : undefined,
|
|
87
|
+
});
|
|
88
|
+
return null;
|
|
89
|
+
});
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}, []);
|
|
93
|
+
};
|
|
@@ -50,7 +50,10 @@ import {
|
|
|
50
50
|
toCalendarInvite,
|
|
51
51
|
toEventSuggestion,
|
|
52
52
|
} from "@/lib/calendar-suggestion";
|
|
53
|
-
import {
|
|
53
|
+
import {
|
|
54
|
+
useChangeSuggestionOnCalendar,
|
|
55
|
+
useOpenEventOnCalendar,
|
|
56
|
+
} from "@/routing";
|
|
54
57
|
|
|
55
58
|
/** What the reader is in the middle of, for one message and no other. */
|
|
56
59
|
interface Working {
|
|
@@ -107,6 +110,7 @@ export function useIntelligenceCalendar(
|
|
|
107
110
|
const answers = useCalendarSuggestionAnswers();
|
|
108
111
|
|
|
109
112
|
const openEvent = useOpenEventOnCalendar();
|
|
113
|
+
const changeOnCalendar = useChangeSuggestionOnCalendar();
|
|
110
114
|
|
|
111
115
|
const [held, setHeld] = useState<Working>(() => fresh(messageId));
|
|
112
116
|
const working = held.messageId === messageId ? held : fresh(messageId);
|
|
@@ -238,14 +242,34 @@ export function useIntelligenceCalendar(
|
|
|
238
242
|
: "Couldn't read the invitations in this message. Reopen it to try again.";
|
|
239
243
|
|
|
240
244
|
const cancellation = invitation?.method === "Cancel";
|
|
245
|
+
const changeFirst = (suggestionId: string) => {
|
|
246
|
+
const suggestion = suggestions.find(
|
|
247
|
+
(candidate) => candidate.suggestionId === suggestionId,
|
|
248
|
+
);
|
|
249
|
+
if (suggestion) changeOnCalendar(suggestionDate(suggestion), suggestionId);
|
|
250
|
+
};
|
|
251
|
+
const unplaced = (suggestionId: string): boolean => {
|
|
252
|
+
const suggestion = suggestions.find(
|
|
253
|
+
(candidate) => candidate.suggestionId === suggestionId,
|
|
254
|
+
);
|
|
255
|
+
return (
|
|
256
|
+
suggestion?.zoneCertainty === "Ambiguous" &&
|
|
257
|
+
suggestion.method !== "Cancel"
|
|
258
|
+
);
|
|
259
|
+
};
|
|
241
260
|
const pickedText =
|
|
242
261
|
date === "" ? "" : slotsAsText(date, slots, working.picked);
|
|
243
262
|
|
|
244
263
|
const actions: IntelligenceCalendarActions = {
|
|
245
|
-
onAddInvite: () =>
|
|
264
|
+
onAddInvite: () => {
|
|
265
|
+
if (unplaced(invitationId)) {
|
|
266
|
+
changeFirst(invitationId);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
246
269
|
answer(invitationId, "add this to your calendar", () =>
|
|
247
270
|
answers.accept(invitationId, defaultCalendarId),
|
|
248
|
-
)
|
|
271
|
+
);
|
|
272
|
+
},
|
|
249
273
|
onTentativeInvite: cancellation
|
|
250
274
|
? undefined
|
|
251
275
|
: () =>
|
|
@@ -281,10 +305,17 @@ export function useIntelligenceCalendar(
|
|
|
281
305
|
? () =>
|
|
282
306
|
replyWithText({ threadId: thread.threadId, messageId }, pickedText)
|
|
283
307
|
: undefined,
|
|
284
|
-
onAddSuggestion: (suggestionId) =>
|
|
308
|
+
onAddSuggestion: (suggestionId) => {
|
|
309
|
+
if (unplaced(suggestionId)) {
|
|
310
|
+
changeFirst(suggestionId);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
285
313
|
answer(suggestionId, "add this to your calendar", () =>
|
|
286
314
|
answers.accept(suggestionId, defaultCalendarId),
|
|
287
|
-
)
|
|
315
|
+
);
|
|
316
|
+
},
|
|
317
|
+
onReviewSuggestion:
|
|
318
|
+
readings[0]?.method === "Cancel" ? undefined : changeFirst,
|
|
288
319
|
onDismissSuggestion: (suggestionId) =>
|
|
289
320
|
answer(suggestionId, "dismiss this", () =>
|
|
290
321
|
answers.dismiss(suggestionId, false),
|
|
@@ -113,10 +113,19 @@ describe("toEventSuggestion", () => {
|
|
|
113
113
|
suggestion({ zoneCertainty: "Ambiguous", source: "TextHeuristic" }),
|
|
114
114
|
{ ...context, sender: "Organizer" },
|
|
115
115
|
);
|
|
116
|
-
assert.match(reading.ambiguity, /
|
|
116
|
+
assert.match(reading.ambiguity, /Add opens the editor/);
|
|
117
117
|
assert.equal(reading.zoneCertainty, "ambiguous");
|
|
118
118
|
assert.ok(reading.confidence < 1);
|
|
119
119
|
});
|
|
120
|
+
|
|
121
|
+
it("promises no editor for a cancellation, which Add applies as it is", () => {
|
|
122
|
+
const reading = toEventSuggestion(
|
|
123
|
+
suggestion({ zoneCertainty: "Ambiguous", method: "Cancel" }),
|
|
124
|
+
{ ...context, sender: "Organizer" },
|
|
125
|
+
);
|
|
126
|
+
assert.match(reading.ambiguity, /could not be resolved/);
|
|
127
|
+
assert.doesNotMatch(reading.ambiguity, /editor/);
|
|
128
|
+
});
|
|
120
129
|
});
|
|
121
130
|
|
|
122
131
|
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],
|
|
@@ -55,6 +55,7 @@ const createServer = () => {
|
|
|
55
55
|
},
|
|
56
56
|
complete: async (_draft, outboxAttachmentId) => {
|
|
57
57
|
completed.push(outboxAttachmentId);
|
|
58
|
+
return { contentUrl: `https://content.test/${outboxAttachmentId}` };
|
|
58
59
|
},
|
|
59
60
|
keep: async (_draft, attachmentIds) => {
|
|
60
61
|
keeps.push(attachmentIds);
|
|
@@ -66,12 +67,20 @@ const createServer = () => {
|
|
|
66
67
|
},
|
|
67
68
|
};
|
|
68
69
|
|
|
70
|
+
const refuseMint = (filename: string, error: unknown) => {
|
|
71
|
+
const answer = mints.get(filename);
|
|
72
|
+
if (!answer) throw new Error(`no reservation asked for ${filename}`);
|
|
73
|
+
answer.reject(error);
|
|
74
|
+
};
|
|
75
|
+
|
|
69
76
|
const answerMint = (filename: string, outboxAttachmentId: string) => {
|
|
70
77
|
const answer = mints.get(filename);
|
|
71
78
|
if (!answer) throw new Error(`no reservation asked for ${filename}`);
|
|
72
79
|
answer.resolve({
|
|
73
80
|
outboxAttachmentId,
|
|
74
81
|
filename,
|
|
82
|
+
contentType: "application/octet-stream",
|
|
83
|
+
contentId: `${outboxAttachmentId}@test`,
|
|
75
84
|
uploadUrl: `https://upload.test/${outboxAttachmentId}`,
|
|
76
85
|
});
|
|
77
86
|
};
|
|
@@ -79,6 +88,7 @@ const createServer = () => {
|
|
|
79
88
|
return {
|
|
80
89
|
transport,
|
|
81
90
|
answerMint,
|
|
91
|
+
refuseMint,
|
|
82
92
|
onDraft,
|
|
83
93
|
put,
|
|
84
94
|
completed,
|
|
@@ -128,6 +138,106 @@ describe("ComposeAttachmentController", () => {
|
|
|
128
138
|
assert.equal(controller.blockingReason(), undefined);
|
|
129
139
|
});
|
|
130
140
|
|
|
141
|
+
it("hands the content-id to whoever attached the file once it is stored", async () => {
|
|
142
|
+
const { server, controller } = setup();
|
|
143
|
+
const stored: [string, string][] = [];
|
|
144
|
+
const shot = file("shot.png");
|
|
145
|
+
|
|
146
|
+
const attaching = controller.attach([shot], {
|
|
147
|
+
stored: (attached, contentId) => {
|
|
148
|
+
stored.push([attached.name, contentId]);
|
|
149
|
+
},
|
|
150
|
+
failed: () => {
|
|
151
|
+
throw new Error("a stored file was reported failed");
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
await settle();
|
|
155
|
+
assert.deepEqual(stored, [], "named before the bytes were stored");
|
|
156
|
+
server.answerMint("shot.png", "att-shot");
|
|
157
|
+
await attaching;
|
|
158
|
+
|
|
159
|
+
assert.deepEqual(stored, [["shot.png", "att-shot@test"]]);
|
|
160
|
+
assert.deepEqual(controller.storedContent("att-shot@test"), {
|
|
161
|
+
filename: "shot.png",
|
|
162
|
+
contentType: "application/octet-stream",
|
|
163
|
+
contentUrl: "https://content.test/att-shot",
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("tells whoever attached a refused file that it did not land, and keeps the refusal on its row", async () => {
|
|
168
|
+
const { server, controller } = setup();
|
|
169
|
+
const failed: string[] = [];
|
|
170
|
+
|
|
171
|
+
const attaching = controller.attach([file("huge.png")], {
|
|
172
|
+
stored: () => {
|
|
173
|
+
throw new Error("a refused file was reported stored");
|
|
174
|
+
},
|
|
175
|
+
failed: (attached) => {
|
|
176
|
+
failed.push(attached.name);
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
await settle();
|
|
180
|
+
server.refuseMint("huge.png", {
|
|
181
|
+
code: "attachment_rejected",
|
|
182
|
+
reason: "FileTooLarge",
|
|
183
|
+
message: '"huge.png" is 30.0 MB, over the 25.0 MB a message can carry.',
|
|
184
|
+
});
|
|
185
|
+
await attaching;
|
|
186
|
+
|
|
187
|
+
assert.deepEqual(failed, ["huge.png"]);
|
|
188
|
+
assert.deepEqual(rowFor(controller, "huge.png")?.state, {
|
|
189
|
+
status: "failed",
|
|
190
|
+
reason: '"huge.png" is 30.0 MB, over the 25.0 MB a message can carry.',
|
|
191
|
+
retryable: false,
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("names the content-id of a file once the server has dropped it", async () => {
|
|
196
|
+
const { server, controller } = setup();
|
|
197
|
+
const removed: string[] = [];
|
|
198
|
+
controller.onRemoved((contentId) => removed.push(contentId));
|
|
199
|
+
|
|
200
|
+
const attaching = controller.attach([file("shot.png")]);
|
|
201
|
+
await settle();
|
|
202
|
+
server.answerMint("shot.png", "att-shot");
|
|
203
|
+
await attaching;
|
|
204
|
+
await controller.remove(controller.getSnapshot()[0].key);
|
|
205
|
+
|
|
206
|
+
assert.deepEqual(removed, ["att-shot@test"]);
|
|
207
|
+
assert.equal(controller.owns("att-shot@test"), false);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("keeps a file attached while the draft it joins was still loading", async () => {
|
|
211
|
+
const { server, controller } = setup();
|
|
212
|
+
|
|
213
|
+
const attaching = controller.attach([file("minutes.pdf")]);
|
|
214
|
+
await settle();
|
|
215
|
+
controller.load(DRAFT, [
|
|
216
|
+
{
|
|
217
|
+
outboxAttachmentId: "att-earlier",
|
|
218
|
+
outboxMessageId: DRAFT,
|
|
219
|
+
filename: "agenda.pdf",
|
|
220
|
+
contentType: "application/pdf",
|
|
221
|
+
sizeBytes: 5,
|
|
222
|
+
state: "Stored",
|
|
223
|
+
contentId: "att-earlier@remit",
|
|
224
|
+
contentUrl: "https://content.test/att-earlier",
|
|
225
|
+
},
|
|
226
|
+
]);
|
|
227
|
+
server.answerMint("minutes.pdf", "att-minutes");
|
|
228
|
+
await attaching;
|
|
229
|
+
|
|
230
|
+
assert.deepEqual(
|
|
231
|
+
controller
|
|
232
|
+
.getSnapshot()
|
|
233
|
+
.map((item) => [item.filename, item.state.status]),
|
|
234
|
+
[
|
|
235
|
+
["agenda.pdf", "attached"],
|
|
236
|
+
["minutes.pdf", "attached"],
|
|
237
|
+
],
|
|
238
|
+
);
|
|
239
|
+
});
|
|
240
|
+
|
|
131
241
|
it("takes a file removed while its reservation is out off the draft, and never uploads it", async () => {
|
|
132
242
|
const { server, controller } = setup();
|
|
133
243
|
|