@remit/web-client 0.0.314 → 0.0.315

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.314",
3
+ "version": "0.0.315",
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,6 +33,8 @@ 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,
38
40
  ...overrides,
@@ -74,6 +74,8 @@ 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,
79
81
  ...overrides,
@@ -63,6 +63,8 @@ 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,
68
70
  };
@@ -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,6 +149,8 @@ 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,
154
156
  };
@@ -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,14 @@ 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,
100
104
  });
101
105
 
102
106
  const Harness = () => {
103
- const calendar = useIntelligenceCalendar(thread);
107
+ const calendar = useIntelligenceCalendar(thread, undefined, useOpenThread());
104
108
  return createElement(IntelligencePanel, {
105
109
  data: sender,
106
110
  calendar: calendar.surface,
@@ -146,14 +150,19 @@ const server = (
146
150
  };
147
151
  };
148
152
 
149
- const mount = async (respond: (call: HttpCall) => unknown) => {
153
+ const mount = async (
154
+ respond: (call: HttpCall) => unknown,
155
+ ): Promise<AnyRouter> => {
150
156
  http = mockFetch(respond);
151
157
  harness = createDomHarness();
152
- harness.renderApp(createElement(Harness));
158
+ const router = threadRouter(Harness, "/mail/brief/thread-1/msg-1");
159
+ await router.load();
160
+ harness.renderApp(createElement(RouterProvider, { router }));
153
161
  await harness.waitFor(
154
162
  () => harness?.text().includes("Quarterly review") === true,
155
163
  "the invitation to be drawn",
156
164
  );
165
+ return router;
157
166
  };
158
167
 
159
168
  const press = (label: string) => {
@@ -396,3 +405,48 @@ describe("changing a suggestion beside the message before adding it", () => {
396
405
  assert.equal(router.state.location.pathname, "/mail");
397
406
  });
398
407
  });
408
+
409
+ describe("an invitation a newer revision replaced", () => {
410
+ const retired = (
411
+ supersededByThreadId: string,
412
+ ): RemitImapCalendarSuggestionResponse => ({
413
+ ...invitation("Superseded"),
414
+ supersededByMessageId: "msg-2",
415
+ supersededByThreadId,
416
+ });
417
+
418
+ const serving =
419
+ (suggestion: RemitImapCalendarSuggestionResponse) =>
420
+ (call: HttpCall): unknown => {
421
+ if (call.path.endsWith("/calendars")) return { items: calendars };
422
+ if (call.path.endsWith("/messages/msg-1/calendar-suggestions"))
423
+ return { items: [suggestion] };
424
+ return { items: [] };
425
+ };
426
+
427
+ it("opens the conversation that carries the newer revision", async () => {
428
+ const router = await mount(serving(retired("thread-2")));
429
+ assert.match(harness?.text() ?? "", /has sent a newer version of this/);
430
+
431
+ press("Open the newer invitation");
432
+
433
+ await harness?.waitFor(
434
+ () => router.state.location.pathname === "/mail/brief/thread-2/msg-2",
435
+ "the reading pane to move to the newer revision",
436
+ );
437
+ });
438
+
439
+ it("offers no way there when the newer message sits in no conversation", async () => {
440
+ await mount(serving(retired("")));
441
+
442
+ assert.match(harness?.text() ?? "", /has sent a newer version of this/);
443
+ assert.equal(
444
+ harness
445
+ ?.queryAll("button")
446
+ .some((button) =>
447
+ button.textContent?.includes("Open the newer invitation"),
448
+ ),
449
+ false,
450
+ );
451
+ });
452
+ });
@@ -51,6 +51,7 @@ import {
51
51
  toEventSuggestion,
52
52
  } from "@/lib/calendar-suggestion";
53
53
  import {
54
+ type OpenThreadTarget,
54
55
  useChangeSuggestionOnCalendar,
55
56
  useOpenEventOnCalendar,
56
57
  } from "@/routing";
@@ -95,6 +96,7 @@ export interface IntelligenceCalendar {
95
96
  export function useIntelligenceCalendar(
96
97
  thread: RemitImapThreadMessageResponse,
97
98
  replyWithText?: ReplyWithText,
99
+ openThread?: (target: OpenThreadTarget) => void,
98
100
  ): IntelligenceCalendar {
99
101
  const messageId = thread.messageId;
100
102
  const { suggestions, isLoading, error } =
@@ -294,6 +296,14 @@ export function useIntelligenceCalendar(
294
296
  answers.dismiss(invitationId, false),
295
297
  )
296
298
  : undefined,
299
+ onOpenNewerInvite:
300
+ openThread && invitation && invitation.supersededByThreadId !== ""
301
+ ? () =>
302
+ openThread({
303
+ threadId: invitation.supersededByThreadId,
304
+ messageId: invitation.supersededByMessageId,
305
+ })
306
+ : undefined,
297
307
  onOfferOtherTimes: () => update({ offering: true }),
298
308
  onToggleSlot: (slot) =>
299
309
  update({
@@ -36,6 +36,8 @@ const suggestion = (
36
36
  organizer: "organizer@example.test",
37
37
  zoneCertainty: "Explicit",
38
38
  acceptedCalendarObjectId: "",
39
+ supersededByMessageId: "",
40
+ supersededByThreadId: "",
39
41
  createdAt: 0,
40
42
  updatedAt: 0,
41
43
  ...over,
@@ -31,6 +31,8 @@ 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,
36
38
  } as const;
@@ -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
+ };