@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.
Files changed (29) hide show
  1. package/package.json +1 -1
  2. package/src/components/calendar/CalendarComposePane.tsx +15 -2
  3. package/src/components/calendar/CalendarWaiting.tsx +3 -0
  4. package/src/components/calendar/ChangeCalendarSuggestion.stories.tsx +182 -0
  5. package/src/components/calendar/ChangeCalendarSuggestion.tsx +159 -0
  6. package/src/components/calendar/PendingSuggestions.tsx +35 -5
  7. package/src/components/calendar/calendar-story-server.tsx +5 -0
  8. package/src/components/calendar/change-suggestion-first.render.test.ts +299 -0
  9. package/src/components/calendar/pending-suggestions.render.test.ts +68 -3
  10. package/src/components/compose/ComposeForm.tsx +38 -4
  11. package/src/components/compose/compose-inline-image.render.test.ts +542 -0
  12. package/src/hooks/calendar/draft.ts +1 -1
  13. package/src/hooks/calendar/index.ts +7 -0
  14. package/src/hooks/calendar/suggestion-draft.ts +77 -0
  15. package/src/hooks/calendar/useCalendarSuggestions.ts +59 -1
  16. package/src/hooks/intelligence-calendar.render.test.ts +135 -0
  17. package/src/hooks/useComposeAttachments.ts +6 -1
  18. package/src/hooks/useInlineImages.ts +93 -0
  19. package/src/hooks/useIntelligenceCalendar.ts +36 -5
  20. package/src/lib/calendar-suggestion.test.ts +10 -1
  21. package/src/lib/calendar-suggestion.ts +12 -2
  22. package/src/lib/compose-attachment-controller.test.ts +110 -0
  23. package/src/lib/compose-attachment-controller.ts +120 -16
  24. package/src/routeTree.gen.ts +23 -0
  25. package/src/routes/calendar/$view.$date/suggestion.$suggestionId.tsx +22 -0
  26. package/src/routes/calendar/$view.$date.tsx +9 -3
  27. package/src/routes/calendar/-calendar-tree.test.ts +18 -0
  28. package/src/routing/calendar.ts +36 -1
  29. 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 type { RemitImapCalendarSuggestionResponse } from "@remit/api-http-client/types.gen.ts";
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(() => undefined),
64
+ .then((attachment) => ({ contentUrl: attachment.contentUrl })),
65
65
  keep: (outboxMessageId, attachmentIds) =>
66
66
  updateMutation
67
67
  .mutateAsync({
@@ -98,6 +98,11 @@ export const useComposeAttachments = ({
98
98
  remove: controller.remove,
99
99
  load: controller.load,
100
100
  reset: controller.reset,
101
+ owns: controller.owns,
102
+ isLoaded: controller.isLoaded,
103
+ refreshLinks: controller.refreshLinks,
104
+ storedContent: controller.storedContent,
105
+ onRemoved: controller.onRemoved,
101
106
  blockingReason: controller.blockingReason(),
102
107
  };
103
108
  };
@@ -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 { useOpenEventOnCalendar } from "@/routing";
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, /hours out/);
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 named a time zone nothing could resolve, so this time may be hours out.";
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.zoneCertainty === "Ambiguous" ? AMBIGUOUS_ZONE : "",
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