@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.314",
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 } = useBriefPane();
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 } = useFlaggedPane();
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(thread, onReplyWithText);
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
- isAnswering: accept.isPending || decline.isPending || dismiss.isPending,
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 (respond: (call: HttpCall) => unknown) => {
154
+ const mount = async (
155
+ respond: (call: HttpCall) => unknown,
156
+ ): Promise<AnyRouter> => {
150
157
  http = mockFetch(respond);
151
158
  harness = createDomHarness();
152
- harness.renderApp(createElement(Harness));
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("Add to calendar") === false,
259
- "the card to leave once the server dismissed it",
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
- : undefined,
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("draws no card for one the reader waved away", () => {
72
- assert.equal(inviteStanding(suggestion({ state: "Dismissed" })), undefined);
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 and is gone. A cancellation asks for an
65
- * answer only while it is pending: once taken or kept, the event it named is
66
- * settled and there is nothing left to say beside the message.
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
+ };