@remit/web-client 0.0.252 → 0.0.253

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.252",
3
+ "version": "0.0.253",
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": {
@@ -17,6 +17,7 @@ import { AutoMovedIndicator } from "./AutoMovedIndicator";
17
17
  import { MessageActionMenu } from "./MessageActionMenu";
18
18
  import { MessageAttachments } from "./MessageAttachments";
19
19
  import { MessageBody } from "./MessageBody";
20
+ import { MessageSettlementAlert } from "./MessageSettlementAlert";
20
21
  import { MobileMessageBar } from "./MobileMessageBar";
21
22
  import { RawMessageView } from "./RawMessageView";
22
23
 
@@ -291,46 +292,53 @@ const ExpandedCard = ({
291
292
  ) : undefined
292
293
  }
293
294
  body={
294
- isLoading ? (
295
- <div className="mt-3 animate-pulse space-y-2">
296
- <div className="h-4 bg-surface-sunken rounded w-full" />
297
- <div className="h-4 bg-surface-sunken rounded w-3/4" />
298
- <div className="h-4 bg-surface-sunken rounded w-1/2" />
299
- </div>
300
- ) : isError && isMessageNotFoundError(error) ? (
301
- <div className="mt-3">
302
- <EmptyState message="This message has been deleted" />
303
- </div>
304
- ) : isError ? (
305
- <div className="mt-3">
306
- <ErrorState
307
- variant="inline"
308
- title="Couldn't load this message"
309
- error={error}
310
- onRetry={onRetry}
311
- />
312
- </div>
313
- ) : showRaw ? (
314
- <div className="mt-3">
315
- <RawMessageView messageId={threadMessage.messageId} />
316
- </div>
317
- ) : (
318
- <div className="mt-3">
319
- <MessageBody
320
- bodyParts={messageData?.bodyParts}
321
- messageId={threadMessage.messageId}
322
- fromAddressId={messageData?.envelope.from[0]?.addressId}
323
- isTrusted={isTrusted}
324
- category={toDisplayCategory(threadMessage.category)}
325
- />
326
- <MessageAttachments
327
- messageId={threadMessage.messageId}
328
- bodyParts={messageData?.bodyParts}
329
- hasAttachment={threadMessage.hasAttachment}
330
- className="mt-4"
331
- />
332
- </div>
333
- )
295
+ <>
296
+ <MessageSettlementAlert
297
+ threadMessage={threadMessage}
298
+ accountId={accountId}
299
+ className="mt-3"
300
+ />
301
+ {isLoading ? (
302
+ <div className="mt-3 animate-pulse space-y-2">
303
+ <div className="h-4 bg-surface-sunken rounded w-full" />
304
+ <div className="h-4 bg-surface-sunken rounded w-3/4" />
305
+ <div className="h-4 bg-surface-sunken rounded w-1/2" />
306
+ </div>
307
+ ) : isError && isMessageNotFoundError(error) ? (
308
+ <div className="mt-3">
309
+ <EmptyState message="This message has been deleted" />
310
+ </div>
311
+ ) : isError ? (
312
+ <div className="mt-3">
313
+ <ErrorState
314
+ variant="inline"
315
+ title="Couldn't load this message"
316
+ error={error}
317
+ onRetry={onRetry}
318
+ />
319
+ </div>
320
+ ) : showRaw ? (
321
+ <div className="mt-3">
322
+ <RawMessageView messageId={threadMessage.messageId} />
323
+ </div>
324
+ ) : (
325
+ <div className="mt-3">
326
+ <MessageBody
327
+ bodyParts={messageData?.bodyParts}
328
+ messageId={threadMessage.messageId}
329
+ fromAddressId={messageData?.envelope.from[0]?.addressId}
330
+ isTrusted={isTrusted}
331
+ category={toDisplayCategory(threadMessage.category)}
332
+ />
333
+ <MessageAttachments
334
+ messageId={threadMessage.messageId}
335
+ bodyParts={messageData?.bodyParts}
336
+ hasAttachment={threadMessage.hasAttachment}
337
+ className="mt-4"
338
+ />
339
+ </div>
340
+ )}
341
+ </>
334
342
  }
335
343
  />
336
344
  );
@@ -1,7 +1,9 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
3
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
4
+ import { toImapDraftRowData } from "@/lib/drafts";
4
5
  import { threadToRowData } from "./MessageListItem";
6
+ import { swipeableRowData } from "./SwipeableMessageRow";
5
7
 
6
8
  const baseThread = (
7
9
  overrides: Partial<RemitImapThreadMessageResponse> = {},
@@ -41,3 +43,55 @@ describe("threadToRowData — labels", () => {
41
43
  assert.equal(row.labels, undefined);
42
44
  });
43
45
  });
46
+
47
+ describe("threadToRowData — settlement (issue #1002)", () => {
48
+ it("marks a row whose delete gave up and came back", () => {
49
+ const row = threadToRowData(
50
+ baseThread({ status: "active", syncStatus: "failed" }),
51
+ );
52
+ assert.equal(row.settlement, "delete_failed");
53
+ });
54
+
55
+ it("says nothing about a move mid-retry, which writes the same failed flag", () => {
56
+ const row = threadToRowData(
57
+ baseThread({ status: "moving", syncStatus: "failed" }),
58
+ );
59
+ assert.equal(row.settlement, undefined);
60
+ });
61
+
62
+ it("says nothing about a delete still being pushed", () => {
63
+ const row = threadToRowData(
64
+ baseThread({ status: "deleting", syncStatus: "pending" }),
65
+ );
66
+ assert.equal(row.settlement, undefined);
67
+ });
68
+
69
+ it("leaves an ordinary inbound row unmarked, pending and all", () => {
70
+ const row = threadToRowData(
71
+ baseThread({ status: "active", syncStatus: "pending" }),
72
+ );
73
+ assert.equal(row.settlement, undefined);
74
+ });
75
+ });
76
+
77
+ describe("the mobile and drafts rows carry the same mark", () => {
78
+ it("marks the swipeable mobile row", () => {
79
+ const row = swipeableRowData(
80
+ baseThread({ status: "active", syncStatus: "failed" }),
81
+ );
82
+ assert.equal(row.settlement, "delete_failed");
83
+ });
84
+
85
+ it("marks the drafts row", () => {
86
+ const row = toImapDraftRowData(
87
+ baseThread({ status: "active", syncStatus: "failed" }),
88
+ );
89
+ assert.equal(row.settlement, "delete_failed");
90
+ });
91
+
92
+ it("leaves a move mid-retry unmarked on both", () => {
93
+ const thread = baseThread({ status: "moving", syncStatus: "failed" });
94
+ assert.equal(swipeableRowData(thread).settlement, undefined);
95
+ assert.equal(toImapDraftRowData(thread).settlement, undefined);
96
+ });
97
+ });
@@ -6,6 +6,7 @@
6
6
  * interactive lives in `MessageRow`, which the brief and Flagged render too.
7
7
  */
8
8
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
9
+
9
10
  import type {
10
11
  Density,
11
12
  SelectionModifiers,
@@ -15,6 +16,7 @@ import type {
15
16
  import { memo } from "react";
16
17
  import { toDisplayCategory } from "@/lib/display-category";
17
18
  import { formatEmailDate } from "@/lib/format";
19
+ import { rowSettlement } from "@/lib/row-settlement";
18
20
  import { MessageRow } from "./MessageRow";
19
21
 
20
22
  interface MessageListItemProps {
@@ -62,6 +64,7 @@ export const threadToRowData = (
62
64
  // misses.
63
65
  suspicious: thread.authenticity?.dkimMismatch === true,
64
66
  labels: thread.labels,
67
+ ...rowSettlement(thread),
65
68
  });
66
69
 
67
70
  const MessageListItemComponent = ({
@@ -0,0 +1,68 @@
1
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
+ import { hasAbandonedDelete } from "@remit/data-ports/message-settlement";
3
+ import { MessageSettlementNotice, messageSettlementCopy } from "@remit/ui";
4
+ import { useCallback, useMemo } from "react";
5
+ import { useDeleteMessages } from "@/hooks/useDeleteMessages";
6
+ import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
7
+
8
+ /**
9
+ * States, on the open message, that Remit abandoned its delete (issue #1002) —
10
+ * the failure the list can only chip.
11
+ *
12
+ * The retry is the ordinary delete endpoint, not a new one: abandoning put
13
+ * `status` back to `active`, so `settledPlacement` passes the row through and
14
+ * `POST /messages/delete` re-drives it against the folder the server actually
15
+ * holds it in. Every other give-up gets no treatment — see `hasAbandonedDelete`
16
+ * for the three the two fields cannot tell from a healthy row.
17
+ */
18
+ export function MessageSettlementAlert({
19
+ threadMessage,
20
+ accountId,
21
+ className,
22
+ }: {
23
+ threadMessage: RemitImapThreadMessageResponse;
24
+ accountId: string | undefined;
25
+ className?: string;
26
+ }) {
27
+ const abandoned = hasAbandonedDelete(threadMessage);
28
+ const { deleteMessages, isPending } = useDeleteMessages({
29
+ mailboxId: threadMessage.mailboxId,
30
+ threadId: threadMessage.threadId,
31
+ accountId,
32
+ messages: [threadMessage],
33
+ });
34
+
35
+ const onRetry = useCallback(() => {
36
+ deleteMessages([threadMessage.messageId]);
37
+ }, [deleteMessages, threadMessage.messageId]);
38
+
39
+ const reportHref = useMemo(
40
+ () =>
41
+ abandoned
42
+ ? buildGitHubIssueUrl(
43
+ buildBugReportContext({
44
+ title: messageSettlementCopy.delete_failed.title,
45
+ errorMessage: `Message ${threadMessage.messageId}: status=${threadMessage.status} syncStatus=${threadMessage.syncStatus}`,
46
+ }),
47
+ )
48
+ : undefined,
49
+ [
50
+ abandoned,
51
+ threadMessage.messageId,
52
+ threadMessage.status,
53
+ threadMessage.syncStatus,
54
+ ],
55
+ );
56
+
57
+ if (!abandoned) return null;
58
+
59
+ return (
60
+ <MessageSettlementNotice
61
+ settlement="delete_failed"
62
+ onRetry={onRetry}
63
+ retryPending={isPending}
64
+ reportHref={reportHref}
65
+ className={className}
66
+ />
67
+ );
68
+ }
@@ -9,6 +9,7 @@ import {
9
9
  import { useCallback, useState } from "react";
10
10
  import { toDisplayCategory } from "@/lib/display-category";
11
11
  import { formatEmailDate } from "@/lib/format";
12
+ import { rowSettlement } from "@/lib/row-settlement";
12
13
  import { useOpenThread } from "@/routing";
13
14
  import { MessageListItem } from "./MessageListItem";
14
15
  import { useModifierSelect } from "./useModifierSelect";
@@ -34,7 +35,13 @@ interface SwipeableMessageRowProps {
34
35
  density?: Density;
35
36
  }
36
37
 
37
- const toThreadRowData = (
38
+ /**
39
+ * The mobile list builds its own row rather than delegating to
40
+ * `MessageListItem` (see the `isDesktop || isMultiSelectMode` branch below),
41
+ * so every row signal has to be repeated here. Exported so a test can hold it
42
+ * to the same answers as the desktop mapper.
43
+ */
44
+ export const swipeableRowData = (
38
45
  thread: RemitImapThreadMessageResponse,
39
46
  ): ThreadRowData => {
40
47
  const suspicious = thread.authenticity?.dkimMismatch === true;
@@ -52,6 +59,7 @@ const toThreadRowData = (
52
59
  trust: thread.senderTrust,
53
60
  category: toDisplayCategory(thread.category),
54
61
  suspicious,
62
+ ...rowSettlement(thread),
55
63
  };
56
64
  };
57
65
 
@@ -136,7 +144,7 @@ export const SwipeableMessageRow = ({
136
144
  onContextMenu={modifierSelect.onContextMenu}
137
145
  >
138
146
  <SwipeableRow
139
- thread={toThreadRowData(thread)}
147
+ thread={swipeableRowData(thread)}
140
148
  selectionMode={false}
141
149
  checked={false}
142
150
  active={isSelected}
@@ -95,6 +95,8 @@ const describeMessage: RemitImapDescribeMessageResponse = {
95
95
  uid: 1,
96
96
  rfc822Size: 512,
97
97
  internalDate: 1_767_225_600_000,
98
+ status: "active",
99
+ syncStatus: "pending",
98
100
  },
99
101
  envelope: {
100
102
  messageId: MESSAGE_ID,
@@ -78,6 +78,8 @@ const describeMessage: RemitImapDescribeMessageResponse = {
78
78
  uid: 1,
79
79
  rfc822Size: 512,
80
80
  internalDate: 1_767_225_600_000,
81
+ status: "active",
82
+ syncStatus: "pending",
81
83
  },
82
84
  envelope: {
83
85
  messageId: MESSAGE_ID,
@@ -92,6 +92,8 @@ const describeMessage: RemitImapDescribeMessageResponse = {
92
92
  uid: 1,
93
93
  rfc822Size: 512,
94
94
  internalDate: LATEST_SENT,
95
+ status: "active",
96
+ syncStatus: "pending",
95
97
  },
96
98
  envelope: {
97
99
  messageId: MESSAGE_ID,
@@ -12,6 +12,8 @@ const make = (
12
12
  hasStars: boolean,
13
13
  ): RemitImapThreadMessageResponse => ({
14
14
  senderTrust: "unknown",
15
+ status: "active",
16
+ syncStatus: "pending",
15
17
  category: "uncategorized",
16
18
  threadId: "t1",
17
19
  threadMessageId: `tm-${messageId}`,
@@ -68,6 +68,8 @@ describe("patchDescribeMessage", () => {
68
68
  uid: 1,
69
69
  rfc822Size: 1,
70
70
  internalDate: 1,
71
+ status: "active",
72
+ syncStatus: "pending",
71
73
  },
72
74
  envelope: {
73
75
  messageId: "m",
@@ -35,6 +35,8 @@ function threadResponse(
35
35
  hasStars: false,
36
36
  star: "none",
37
37
  senderTrust: "unknown",
38
+ status: "active",
39
+ syncStatus: "pending",
38
40
  muted: false,
39
41
  createdAt: 0,
40
42
  updatedAt: 0,
package/src/lib/brief.ts CHANGED
@@ -46,6 +46,7 @@ import type {
46
46
  } from "@remit/ui";
47
47
  import { toDisplayCategory } from "./display-category.js";
48
48
  import { formatEmailDate } from "./format.js";
49
+ import { rowSettlement } from "./row-settlement.js";
49
50
  import type { SearchToken } from "./search-tokens.js";
50
51
 
51
52
  /**
@@ -74,6 +75,7 @@ export function toThreadRowData(
74
75
  trust: thread.senderTrust as SenderTrustLevel,
75
76
  category: toDisplayCategory(thread.category),
76
77
  suspicious,
78
+ ...rowSettlement(thread),
77
79
  };
78
80
  }
79
81
 
@@ -24,6 +24,8 @@ function threadMessage(
24
24
  hasStars: false,
25
25
  star: "none",
26
26
  senderTrust: "unknown",
27
+ status: "active",
28
+ syncStatus: "pending",
27
29
  muted: false,
28
30
  createdAt: 0,
29
31
  updatedAt: 0,
package/src/lib/drafts.ts CHANGED
@@ -26,6 +26,7 @@ import type {
26
26
  } from "@remit/api-http-client/types.gen.ts";
27
27
  import type { ThreadRowData, ThreadSection } from "@remit/ui";
28
28
  import { formatEmailDate } from "./format.js";
29
+ import { rowSettlement } from "./row-settlement.js";
29
30
 
30
31
  /**
31
32
  * Map a Remit outbox draft to the ThreadRowData shape used by remit-ui row
@@ -65,6 +66,7 @@ export function toImapDraftRowData(
65
66
  isRead: thread.isRead,
66
67
  hasAttachment: thread.hasAttachment,
67
68
  starred: thread.hasStars === true,
69
+ ...rowSettlement(thread),
68
70
  };
69
71
  }
70
72
 
@@ -0,0 +1,15 @@
1
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
+ import { hasAbandonedDelete } from "@remit/data-ports/message-settlement";
3
+ import type { ThreadRowData } from "@remit/ui";
4
+
5
+ /**
6
+ * The row treatment for a message whose delete Remit abandoned (issue #1002). The
7
+ * client decides nothing: `hasAbandonedDelete` owns which `status`/`syncStatus`
8
+ * pair proves a terminal give-up, and why no other pair does. Any other row —
9
+ * settled, mid-retry, or an undecidable move — carries no key at all and
10
+ * renders exactly as it always has.
11
+ */
12
+ export const rowSettlement = (
13
+ thread: Pick<RemitImapThreadMessageResponse, "status" | "syncStatus">,
14
+ ): Pick<ThreadRowData, "settlement"> =>
15
+ hasAbandonedDelete(thread) ? { settlement: "delete_failed" } : {};
@@ -41,6 +41,8 @@ const thread = (
41
41
  hasStars: false,
42
42
  star: "none",
43
43
  senderTrust: "unknown",
44
+ status: "active",
45
+ syncStatus: "pending",
44
46
  muted: false,
45
47
  createdAt: 0,
46
48
  updatedAt: 0,
@@ -78,6 +78,8 @@ export const makeThreadMessage = (
78
78
  star: "none",
79
79
  hasStars: false,
80
80
  isDeleted: false,
81
+ status: "active",
82
+ syncStatus: "pending",
81
83
  senderTrust: "unknown",
82
84
  muted: false,
83
85
  createdAt: 0,