@remit/web-client 0.0.61 → 0.0.62

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.61",
3
+ "version": "0.0.62",
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": {
@@ -8,7 +8,7 @@ interface AutoMovedIndicatorProps {
8
8
  threadId: string;
9
9
  mailboxId: string;
10
10
  autoMoved: RemitImapAutoMovedInfo | undefined;
11
- /** `md` (reading view) adds the inline Undo action; `sm` (list row) is icon + label only. */
11
+ /** `md` (reading view) adds the inline actions (Undo, and the Manage-filter link for a filter move); `sm` (list row) shows the icon + label. */
12
12
  size?: "sm" | "md";
13
13
  }
14
14
 
@@ -43,6 +43,7 @@ export function AutoMovedIndicator({
43
43
  size={size}
44
44
  onUndo={badge.onUndo}
45
45
  undoLabel={badge.isUndoing ? "Undoing…" : "Undo"}
46
+ filtersHref={size === "md" ? badge.filtersHref : undefined}
46
47
  />
47
48
  );
48
49
  }
@@ -1,10 +1,13 @@
1
+ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
1
2
  import type { RemitImapAutoMovedInfo } from "@remit/api-http-client/types.gen.ts";
3
+ import { useQuery } from "@tanstack/react-query";
2
4
  import { useCallback } from "react";
3
5
  import {
4
6
  autoMovedLabel,
5
7
  isAutoMoveInEffect,
6
8
  resolveUndoTargetMailboxId,
7
9
  } from "@/lib/auto-moved";
10
+ import { getMailboxDisplayName } from "@/lib/folder-roles";
8
11
  import { useInboxMailbox, useJunkMailbox } from "./useArchiveMailbox";
9
12
  import { useMoveMessages } from "./useMoveMessages";
10
13
 
@@ -24,14 +27,26 @@ export interface AutoMovedBadgeState {
24
27
  /** Present only when the undo target mailbox resolved. */
25
28
  onUndo?: () => void;
26
29
  isUndoing: boolean;
30
+ /**
31
+ * Settings › Filters link, present only for a standing-filter move — undo
32
+ * returns the message but never disables the filter, so the badge points to
33
+ * where the filter can be managed.
34
+ */
35
+ filtersHref?: string;
27
36
  }
28
37
 
29
38
  /**
30
- * Composes the account's Inbox/Junk mailboxes with the message's `autoMoved`
31
- * projection into everything the `AutoMovedBadge` kit component needs: the
32
- * derived "still in effect" gate, the plain-language label, and a one-click
33
- * undo bound to the existing `moveMessages` mutation (no new endpoint — moves
34
- * back through the same bulk move operation, the other direction).
39
+ * Composes the account's mailboxes with the message's `autoMoved` projection
40
+ * into everything the `AutoMovedBadge` kit component needs: the derived "still
41
+ * in effect" gate, the plain-language label, and a one-click undo bound to the
42
+ * existing `moveMessages` mutation (no new endpoint — moves back through the
43
+ * same bulk move operation, the other direction).
44
+ *
45
+ * Both auto-move shapes are handled. A classifier move resolves its
46
+ * Inbox/Junk role mailboxes; a standing-filter move names an arbitrary source
47
+ * folder, whose display name is resolved from the account's mailbox list, and
48
+ * carries a Settings › Filters link so the filter that keeps moving mail is one
49
+ * tap away — undo does not disable it.
35
50
  *
36
51
  * `show` re-derives on every render from `mailboxId` — no local dismissed
37
52
  * flag. Once `moveMessages` settles, its query invalidation refetches the
@@ -53,11 +68,20 @@ export const useAutoMovedBadge = ({
53
68
  accountId,
54
69
  });
55
70
 
71
+ const { data: mailboxes } = useQuery({
72
+ ...mailboxOperationsListMailboxesOptions({
73
+ path: { accountId: accountId ?? "" },
74
+ }),
75
+ staleTime: Infinity,
76
+ enabled: !!accountId && autoMoved?.fromMailboxId !== undefined,
77
+ });
78
+
56
79
  const roleMailboxes = { inboxMailboxId, junkMailboxId };
57
80
  const show = isAutoMoveInEffect(autoMoved, mailboxId, roleMailboxes);
58
- const undoTargetMailboxId = autoMoved
59
- ? resolveUndoTargetMailboxId(autoMoved.fromPlacement, roleMailboxes)
60
- : undefined;
81
+ const undoTargetMailboxId = resolveUndoTargetMailboxId(
82
+ autoMoved,
83
+ roleMailboxes,
84
+ );
61
85
 
62
86
  const handleUndo = useCallback(() => {
63
87
  if (!undoTargetMailboxId) return;
@@ -68,10 +92,17 @@ export const useAutoMovedBadge = ({
68
92
  return { show: false, label: "", isUndoing: false };
69
93
  }
70
94
 
95
+ const sourceFolderName = autoMoved.fromMailboxId
96
+ ? mailboxes?.items
97
+ .filter((mailbox) => mailbox.mailboxId === autoMoved.fromMailboxId)
98
+ .map((mailbox) => getMailboxDisplayName(mailbox.fullPath))[0]
99
+ : undefined;
100
+
71
101
  return {
72
102
  show: true,
73
- label: autoMovedLabel(autoMoved.fromPlacement),
103
+ label: autoMovedLabel(autoMoved, sourceFolderName),
74
104
  onUndo: undoTargetMailboxId ? handleUndo : undefined,
75
105
  isUndoing: isPending,
106
+ ...(autoMoved.filterId ? { filtersHref: "/settings/filters" } : {}),
76
107
  };
77
108
  };
@@ -14,23 +14,65 @@ const ROLE_MAILBOXES: AutoMovedRoleMailboxes = {
14
14
  junkMailboxId: "mb-junk",
15
15
  };
16
16
 
17
+ const classifierMove = (
18
+ action: RemitImapAutoMovedInfo["action"],
19
+ fromPlacement: string,
20
+ ): RemitImapAutoMovedInfo => ({ action, fromPlacement });
21
+
22
+ const filterMove = (
23
+ fromMailboxId: string,
24
+ destinationMailboxId: string,
25
+ ): RemitImapAutoMovedInfo => ({
26
+ fromMailboxId,
27
+ destinationMailboxId,
28
+ filterId: "flt-1",
29
+ });
30
+
17
31
  describe("autoMovedLabel", () => {
18
- test("reads 'Moved from Junk by Remit'", () => {
19
- assert.equal(autoMovedLabel("junk"), "Moved from Junk by Remit");
32
+ test("classifier move reads 'Moved from Junk by Remit'", () => {
33
+ assert.equal(
34
+ autoMovedLabel(classifierMove(PlacementAction.MoveToInbox, "junk")),
35
+ "Moved from Junk by Remit",
36
+ );
20
37
  });
21
38
 
22
- test("reads 'Moved from Inbox by Remit'", () => {
23
- assert.equal(autoMovedLabel("inbox"), "Moved from Inbox by Remit");
39
+ test("classifier move reads 'Moved from Inbox by Remit'", () => {
40
+ assert.equal(
41
+ autoMovedLabel(classifierMove(PlacementAction.MoveToJunk, "inbox")),
42
+ "Moved from Inbox by Remit",
43
+ );
24
44
  });
25
45
 
26
- test("falls back to plain language for an unrecognized placement", () => {
27
- assert.equal(autoMovedLabel("other"), "Moved from another folder by Remit");
46
+ test("classifier move falls back to plain language for an unrecognized placement", () => {
47
+ assert.equal(
48
+ autoMovedLabel(classifierMove(PlacementAction.MoveToInbox, "other")),
49
+ "Moved from another folder by Remit",
50
+ );
51
+ });
52
+
53
+ test("filter move names the resolved source folder", () => {
54
+ assert.equal(
55
+ autoMovedLabel(filterMove("mb-inbox", "mb-travel"), "Travel"),
56
+ "Moved from Travel by Remit",
57
+ );
58
+ });
59
+
60
+ test("filter move falls back to 'another folder' before the name resolves", () => {
61
+ assert.equal(
62
+ autoMovedLabel(filterMove("mb-inbox", "mb-travel")),
63
+ "Moved from another folder by Remit",
64
+ );
28
65
  });
29
66
 
30
67
  test("never leaks verdict jargon", () => {
31
- for (const placement of ["inbox", "junk", "other"]) {
68
+ const cases = [
69
+ classifierMove(PlacementAction.MoveToInbox, "junk"),
70
+ classifierMove(PlacementAction.MoveToJunk, "inbox"),
71
+ filterMove("mb-inbox", "mb-travel"),
72
+ ];
73
+ for (const autoMoved of cases) {
32
74
  assert.doesNotMatch(
33
- autoMovedLabel(placement),
75
+ autoMovedLabel(autoMoved),
34
76
  /confiden|dry.?run|verdict/i,
35
77
  );
36
78
  }
@@ -46,94 +88,138 @@ describe("isAutoMoveInEffect", () => {
46
88
  });
47
89
 
48
90
  test("false when currentMailboxId is absent", () => {
49
- const autoMoved: RemitImapAutoMovedInfo = {
50
- action: PlacementAction.MoveToInbox,
51
- fromPlacement: "junk",
52
- };
53
91
  assert.equal(
54
- isAutoMoveInEffect(autoMoved, undefined, ROLE_MAILBOXES),
92
+ isAutoMoveInEffect(
93
+ classifierMove(PlacementAction.MoveToInbox, "junk"),
94
+ undefined,
95
+ ROLE_MAILBOXES,
96
+ ),
55
97
  false,
56
98
  );
57
99
  });
58
100
 
59
- test("true when a MoveToInbox message currently sits in the Inbox mailbox", () => {
60
- const autoMoved: RemitImapAutoMovedInfo = {
61
- action: PlacementAction.MoveToInbox,
62
- fromPlacement: "junk",
63
- };
101
+ test("classifier: true when a MoveToInbox message sits in the Inbox mailbox", () => {
64
102
  assert.equal(
65
- isAutoMoveInEffect(autoMoved, "mb-inbox", ROLE_MAILBOXES),
103
+ isAutoMoveInEffect(
104
+ classifierMove(PlacementAction.MoveToInbox, "junk"),
105
+ "mb-inbox",
106
+ ROLE_MAILBOXES,
107
+ ),
66
108
  true,
67
109
  );
68
110
  });
69
111
 
70
- test("true when a MoveToJunk message currently sits in the Junk mailbox", () => {
71
- const autoMoved: RemitImapAutoMovedInfo = {
72
- action: PlacementAction.MoveToJunk,
73
- fromPlacement: "inbox",
74
- };
112
+ test("classifier: true when a MoveToJunk message sits in the Junk mailbox", () => {
75
113
  assert.equal(
76
- isAutoMoveInEffect(autoMoved, "mb-junk", ROLE_MAILBOXES),
114
+ isAutoMoveInEffect(
115
+ classifierMove(PlacementAction.MoveToJunk, "inbox"),
116
+ "mb-junk",
117
+ ROLE_MAILBOXES,
118
+ ),
77
119
  true,
78
120
  );
79
121
  });
80
122
 
81
- test("false once the message has moved elsewhere (self-hide on reconcile)", () => {
82
- const autoMoved: RemitImapAutoMovedInfo = {
83
- action: PlacementAction.MoveToInbox,
84
- fromPlacement: "junk",
85
- };
123
+ test("classifier: false once the message has moved elsewhere", () => {
124
+ const autoMoved = classifierMove(PlacementAction.MoveToInbox, "junk");
86
125
  assert.equal(
87
126
  isAutoMoveInEffect(autoMoved, "mb-archive", ROLE_MAILBOXES),
88
127
  false,
89
128
  );
90
- // Undone back to Junk — no longer in the implied destination.
91
129
  assert.equal(
92
130
  isAutoMoveInEffect(autoMoved, "mb-junk", ROLE_MAILBOXES),
93
131
  false,
94
132
  );
95
133
  });
96
134
 
97
- test("false when the implied destination mailbox hasn't resolved yet", () => {
98
- const autoMoved: RemitImapAutoMovedInfo = {
99
- action: PlacementAction.MoveToInbox,
100
- fromPlacement: "junk",
101
- };
135
+ test("filter: true while the message sits in the filter's destination", () => {
136
+ assert.equal(
137
+ isAutoMoveInEffect(
138
+ filterMove("mb-inbox", "mb-travel"),
139
+ "mb-travel",
140
+ ROLE_MAILBOXES,
141
+ ),
142
+ true,
143
+ );
144
+ });
145
+
146
+ test("filter: false once undone back to the source folder", () => {
147
+ assert.equal(
148
+ isAutoMoveInEffect(
149
+ filterMove("mb-inbox", "mb-travel"),
150
+ "mb-inbox",
151
+ ROLE_MAILBOXES,
152
+ ),
153
+ false,
154
+ );
155
+ });
156
+
157
+ test("filter: in-effect does not depend on the Inbox/Junk role mailboxes", () => {
102
158
  assert.equal(
103
- isAutoMoveInEffect(autoMoved, "mb-inbox", {
159
+ isAutoMoveInEffect(filterMove("mb-inbox", "mb-travel"), "mb-travel", {
104
160
  inboxMailboxId: undefined,
105
- junkMailboxId: "mb-junk",
161
+ junkMailboxId: undefined,
106
162
  }),
163
+ true,
164
+ );
165
+ });
166
+
167
+ test("classifier: false when the implied destination mailbox hasn't resolved yet", () => {
168
+ assert.equal(
169
+ isAutoMoveInEffect(
170
+ classifierMove(PlacementAction.MoveToInbox, "junk"),
171
+ "mb-inbox",
172
+ { inboxMailboxId: undefined, junkMailboxId: "mb-junk" },
173
+ ),
107
174
  false,
108
175
  );
109
176
  });
110
177
  });
111
178
 
112
179
  describe("resolveUndoTargetMailboxId", () => {
113
- test("resolves the Junk mailbox for fromPlacement='junk'", () => {
114
- assert.equal(resolveUndoTargetMailboxId("junk", ROLE_MAILBOXES), "mb-junk");
180
+ test("classifier: resolves the Junk mailbox for fromPlacement='junk'", () => {
181
+ assert.equal(
182
+ resolveUndoTargetMailboxId(
183
+ classifierMove(PlacementAction.MoveToInbox, "junk"),
184
+ ROLE_MAILBOXES,
185
+ ),
186
+ "mb-junk",
187
+ );
115
188
  });
116
189
 
117
- test("resolves the Inbox mailbox for fromPlacement='inbox'", () => {
190
+ test("classifier: resolves the Inbox mailbox for fromPlacement='inbox'", () => {
118
191
  assert.equal(
119
- resolveUndoTargetMailboxId("inbox", ROLE_MAILBOXES),
192
+ resolveUndoTargetMailboxId(
193
+ classifierMove(PlacementAction.MoveToJunk, "inbox"),
194
+ ROLE_MAILBOXES,
195
+ ),
120
196
  "mb-inbox",
121
197
  );
122
198
  });
123
199
 
124
- test("undefined for an unresolvable placement", () => {
200
+ test("classifier: undefined when the role mailbox hasn't resolved yet", () => {
125
201
  assert.equal(
126
- resolveUndoTargetMailboxId("other", ROLE_MAILBOXES),
202
+ resolveUndoTargetMailboxId(
203
+ classifierMove(PlacementAction.MoveToInbox, "junk"),
204
+ { inboxMailboxId: "mb-inbox", junkMailboxId: undefined },
205
+ ),
127
206
  undefined,
128
207
  );
129
208
  });
130
209
 
131
- test("undefined when the role mailbox hasn't resolved yet", () => {
210
+ test("filter: undo targets the recorded source mailbox verbatim", () => {
132
211
  assert.equal(
133
- resolveUndoTargetMailboxId("junk", {
134
- inboxMailboxId: "mb-inbox",
135
- junkMailboxId: undefined,
136
- }),
212
+ resolveUndoTargetMailboxId(
213
+ filterMove("mb-work", "mb-travel"),
214
+ ROLE_MAILBOXES,
215
+ ),
216
+ "mb-work",
217
+ );
218
+ });
219
+
220
+ test("undefined when autoMoved is absent", () => {
221
+ assert.equal(
222
+ resolveUndoTargetMailboxId(undefined, ROLE_MAILBOXES),
137
223
  undefined,
138
224
  );
139
225
  });
@@ -12,17 +12,35 @@ const FROM_PLACEMENT_LABEL: Record<string, string> = {
12
12
  junk: "Junk",
13
13
  };
14
14
 
15
+ /** A standing-filter move names an arbitrary destination via `destinationMailboxId`; a classifier move names a direction via `action`. */
16
+ const isFilterMove = (autoMoved: RemitImapAutoMovedInfo): boolean =>
17
+ autoMoved.destinationMailboxId !== undefined;
18
+
15
19
  /**
16
20
  * Plain-language badge text, e.g. "Moved from Junk by Remit". No jargon
17
21
  * (verdict/confidence/dryRun never surface) — mirrors the no-jargon precedent
18
22
  * in `rescue-candidates.ts`.
23
+ *
24
+ * A classifier move reads its source from the `fromPlacement` role. A
25
+ * standing-filter move moved from an arbitrary folder, so the caller resolves
26
+ * that folder's display name and passes it as `sourceFolderName`; absent (name
27
+ * not resolved yet) falls back to "another folder".
19
28
  */
20
- export const autoMovedLabel = (fromPlacement: string | undefined): string =>
21
- `Moved from ${FROM_PLACEMENT_LABEL[fromPlacement ?? ""] ?? "another folder"} by Remit`;
29
+ export const autoMovedLabel = (
30
+ autoMoved: RemitImapAutoMovedInfo,
31
+ sourceFolderName?: string,
32
+ ): string => {
33
+ if (isFilterMove(autoMoved)) {
34
+ return `Moved from ${sourceFolderName ?? "another folder"} by Remit`;
35
+ }
36
+ const from = autoMoved.fromPlacement ?? "";
37
+ return `Moved from ${FROM_PLACEMENT_LABEL[from] ?? "another folder"} by Remit`;
38
+ };
22
39
 
23
40
  /**
24
- * The mailbox the verdict's `action` implies as the destination — where the
25
- * message should currently sit for the move to still be "in effect".
41
+ * The mailbox the verdict's `action` implies as the destination — where a
42
+ * classifier-moved message should currently sit for the move to still be "in
43
+ * effect".
26
44
  */
27
45
  const impliedDestinationMailboxId = (
28
46
  action: string | undefined,
@@ -34,12 +52,13 @@ const impliedDestinationMailboxId = (
34
52
  };
35
53
 
36
54
  /**
37
- * True only while the auto-move is still in effect: the message currently
38
- * sits in the mailbox the verdict's `action` implied. Once the message moves
39
- * elsewhere (an undo, or any other move), this returns false — the badge has
40
- * no local "dismissed" flag; it re-derives from current placement every
41
- * render, per the data-flow rule that a projection never forks from its
42
- * source (doc/rules/data-flow.md).
55
+ * True only while the auto-move is still in effect: the message currently sits
56
+ * in the mailbox the move targeted. A standing-filter move targets the
57
+ * `destinationMailboxId` it recorded; a classifier move targets the mailbox its
58
+ * `action` implies. Once the message moves elsewhere (an undo, or any other
59
+ * move), this returns false the badge has no local "dismissed" flag; it
60
+ * re-derives from current placement every render, per the data-flow rule that a
61
+ * projection never forks from its source (doc/rules/data-flow.md).
43
62
  */
44
63
  export const isAutoMoveInEffect = (
45
64
  autoMoved: RemitImapAutoMovedInfo | undefined,
@@ -47,22 +66,29 @@ export const isAutoMoveInEffect = (
47
66
  mailboxes: AutoMovedRoleMailboxes,
48
67
  ): boolean => {
49
68
  if (!autoMoved || !currentMailboxId) return false;
50
- const destination = impliedDestinationMailboxId(autoMoved.action, mailboxes);
69
+ const destination = isFilterMove(autoMoved)
70
+ ? autoMoved.destinationMailboxId
71
+ : impliedDestinationMailboxId(autoMoved.action, mailboxes);
51
72
  return destination !== undefined && destination === currentMailboxId;
52
73
  };
53
74
 
54
75
  /**
55
- * Resolve the undo destination: the mailbox filling the role the message was
56
- * moved from. `fromPlacement` is always `inbox` or `junk` for an actionable
57
- * verdict (the Tier 0 classifier only fires move-to-inbox from junk and
58
- * move-to-junk from inbox see `classifyPlacement.ts`); `undefined` when the
59
- * account has no mailbox appointed to that role.
76
+ * Resolve the undo destination: where the message was before the move. A
77
+ * standing-filter move recorded the exact source mailbox (`fromMailboxId`),
78
+ * used verbatim. A classifier move recorded only a role (`fromPlacement`,
79
+ * always `inbox` or `junk` for an actionable verdict — the Tier 0 classifier
80
+ * only fires move-to-inbox from junk and move-to-junk from inbox, see
81
+ * `classifyPlacement.ts`), resolved to the account's mailbox for that role.
82
+ * `undefined` when the account has no mailbox for that role, or the source
83
+ * cannot be resolved.
60
84
  */
61
85
  export const resolveUndoTargetMailboxId = (
62
- fromPlacement: string | undefined,
86
+ autoMoved: RemitImapAutoMovedInfo | undefined,
63
87
  mailboxes: AutoMovedRoleMailboxes,
64
88
  ): string | undefined => {
65
- if (fromPlacement === "inbox") return mailboxes.inboxMailboxId;
66
- if (fromPlacement === "junk") return mailboxes.junkMailboxId;
89
+ if (!autoMoved) return undefined;
90
+ if (isFilterMove(autoMoved)) return autoMoved.fromMailboxId;
91
+ if (autoMoved.fromPlacement === "inbox") return mailboxes.inboxMailboxId;
92
+ if (autoMoved.fromPlacement === "junk") return mailboxes.junkMailboxId;
67
93
  return undefined;
68
94
  };