@remit/web-client 0.0.321 → 0.0.323

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 (190) hide show
  1. package/package.json +4 -4
  2. package/src/auth/SignInAgain.tsx +18 -0
  3. package/src/auth/amplify-config.test.ts +1 -107
  4. package/src/auth/auth-interceptor.test.ts +0 -6
  5. package/src/auth/cognito/cognito-token.test.ts +2 -4
  6. package/src/components/calendar/AgendaStrip.tsx +1 -1
  7. package/src/components/calendar/CalendarWorkspace.tsx +1 -1
  8. package/src/components/calendar/ChangeCalendarSuggestion.tsx +1 -1
  9. package/src/components/calendar/OpenCalendarEvent.tsx +1 -18
  10. package/src/components/compose/ComposeForm.tsx +11 -6
  11. package/src/components/compose/FullCompose.tsx +1 -1
  12. package/src/components/compose/MobileComposeSheet.tsx +1 -2
  13. package/src/components/compose/compose.stories.tsx +18 -0
  14. package/src/components/compose/draft-discard-failure.render.test.ts +138 -0
  15. package/src/components/config-import/config-import.fixtures.ts +8 -9
  16. package/src/components/config-import/config-import.stories.tsx +2 -2
  17. package/src/components/layout/MailTopBar.tsx +3 -0
  18. package/src/components/mail/BriefPane.tsx +0 -3
  19. package/src/components/mail/ConversationView.tsx +1 -3
  20. package/src/components/mail/DailyBrief.tsx +5 -28
  21. package/src/components/mail/FlaggedList.tsx +3 -4
  22. package/src/components/mail/FlaggedPane.tsx +0 -3
  23. package/src/components/mail/IntelligenceDrawer.tsx +0 -3
  24. package/src/components/mail/IntelligencePane.render.test.ts +0 -1
  25. package/src/components/mail/IntelligencePane.test.ts +1 -95
  26. package/src/components/mail/IntelligencePane.tsx +3 -93
  27. package/src/components/mail/MailListHeader.tsx +12 -43
  28. package/src/components/mail/MailNav.tsx +6 -2
  29. package/src/components/mail/MailViewChrome.tsx +1 -11
  30. package/src/components/mail/MailboxPane.tsx +4 -34
  31. package/src/components/mail/MessageActionMenu.tsx +6 -6
  32. package/src/components/mail/MessageAttachments.tsx +35 -0
  33. package/src/components/mail/MessageBody.tsx +1 -0
  34. package/src/components/mail/MessageBodyErrorBanner.test.ts +0 -30
  35. package/src/components/mail/MessageCard.tsx +16 -4
  36. package/src/components/mail/MessageList.run-reporting.test.ts +2 -46
  37. package/src/components/mail/MessageList.tsx +23 -11
  38. package/src/components/mail/MessageRow.render.test.ts +0 -7
  39. package/src/components/mail/MoveToTrigger.tsx +1 -1
  40. package/src/components/mail/RawMessageView.tsx +1 -1
  41. package/src/components/mail/SelectionWizardHost.search-entry.test.ts +0 -1
  42. package/src/components/mail/SelectionWizardHost.tsx +16 -102
  43. package/src/components/mail/authenticity-warning.stories.tsx +0 -21
  44. package/src/components/mail/daily-brief.stories.tsx +31 -17
  45. package/src/components/mail/enter-cursor-late-open.render.test.ts +197 -0
  46. package/src/components/mail/intelligence-drawer.stories.tsx +0 -2
  47. package/src/components/mail/message-gone.stories.tsx +44 -0
  48. package/src/components/mail/offer-times-reply.render.test.ts +25 -1
  49. package/src/components/mail/reclassify-remove-override.render.test.ts +2 -6
  50. package/src/components/overlay-escape.render.test.ts +7 -3
  51. package/src/components/settings/AccountFormPanel.render.test.ts +26 -3
  52. package/src/components/settings/AccountFormPanel.test.ts +1 -36
  53. package/src/components/settings/AccountFormPanel.tsx +5 -25
  54. package/src/components/settings/AccountServices.tsx +1 -1
  55. package/src/components/settings/AdvancedNavIcon.tsx +3 -3
  56. package/src/components/settings/CalendarFeedCard.tsx +2 -1
  57. package/src/components/settings/CalendarSubscribeCard.tsx +1 -1
  58. package/src/components/settings/CalendarSubscriptionStatus.tsx +1 -2
  59. package/src/components/settings/FilterActionError.tsx +1 -1
  60. package/src/components/settings/FilterEditor.render.test.ts +3 -27
  61. package/src/components/settings/FilterEditor.tsx +4 -24
  62. package/src/components/settings/FilterEditorSurface.tsx +0 -3
  63. package/src/components/settings/FiltersList.render.test.ts +5 -24
  64. package/src/components/settings/FiltersList.stories.tsx +0 -1
  65. package/src/components/settings/FiltersList.tsx +10 -35
  66. package/src/components/settings/WarningsNavIcon.tsx +12 -0
  67. package/src/components/settings/{QuarantinePanel.delimiter.test.ts → WarningsPanel.dom.test.ts} +64 -5
  68. package/src/components/settings/WarningsPanel.render.test.ts +56 -0
  69. package/src/components/settings/WarningsPanel.tsx +244 -0
  70. package/src/components/settings/accounts.stories.tsx +18 -0
  71. package/src/components/settings/advanced.stories.tsx +3 -125
  72. package/src/components/settings/settings-filter.stories.tsx +2 -42
  73. package/src/components/settings/warnings.stories.tsx +290 -0
  74. package/src/components/ui/ErrorBannerProvider.tsx +5 -3
  75. package/src/components/ui/error-banners.test.ts +1 -22
  76. package/src/components/ui/error-banners.ts +9 -20
  77. package/src/hooks/calendar/useCalendarCollectionWrites.ts +2 -1
  78. package/src/hooks/calendar/useCalendarSuggestions.ts +2 -1
  79. package/src/hooks/calendar/useCalendarWrites.ts +2 -1
  80. package/src/hooks/intelligence-calendar.render.test.ts +0 -1
  81. package/src/hooks/useDeleteMessages.test.ts +1 -42
  82. package/src/hooks/useFilters.test.ts +0 -8
  83. package/src/hooks/useFollowFocusOpen.render.test.ts +1 -11
  84. package/src/hooks/useIntelligenceData.test.ts +9 -47
  85. package/src/hooks/useIntelligenceData.ts +7 -100
  86. package/src/hooks/useLayoutTier.test.ts +0 -48
  87. package/src/hooks/useReportSpam.ts +1 -1
  88. package/src/hooks/useRescueCandidates.test.ts +0 -23
  89. package/src/hooks/useRulePreview.ts +8 -7
  90. package/src/hooks/useSearchTokenContext.ts +2 -3
  91. package/src/hooks/useStaleAccountSync.test.ts +0 -17
  92. package/src/hooks/useStarredThreads.test.ts +0 -10
  93. package/src/hooks/useTriggerSync.test.ts +0 -6
  94. package/src/hooks/useVisualViewport.render.test.ts +63 -0
  95. package/src/hooks/useWarningsAttention.ts +37 -0
  96. package/src/lib/account-order.test.ts +0 -10
  97. package/src/lib/adjacent-message.test.ts +0 -9
  98. package/src/lib/app-info.ts +0 -3
  99. package/src/lib/autodiscovery.test.ts +0 -47
  100. package/src/lib/bug-report.test.ts +38 -0
  101. package/src/lib/bug-report.ts +10 -7
  102. package/src/lib/bulk-action-copy.ts +1 -1
  103. package/src/lib/calendar-route.test.ts +0 -5
  104. package/src/lib/config-import.test.ts +3 -39
  105. package/src/lib/dedupe-thread-messages.test.ts +0 -4
  106. package/src/lib/desktop-breakpoint.test.ts +0 -8
  107. package/src/lib/drafts.test.ts +0 -23
  108. package/src/lib/fatal-error.test.ts +0 -19
  109. package/src/lib/folder-roles.test.ts +0 -4
  110. package/src/lib/hot-sync-window.test.ts +0 -5
  111. package/src/lib/list-header-chrome.ts +1 -1
  112. package/src/lib/mail-context.ts +1 -1
  113. package/src/lib/message-attachments.test.ts +77 -0
  114. package/src/lib/message-attachments.ts +31 -15
  115. package/src/lib/message-body-source.test.ts +21 -4
  116. package/src/lib/message-body-source.ts +5 -2
  117. package/src/lib/onboarding-completion.test.ts +0 -16
  118. package/src/lib/organize/filter-edit-model.test.ts +2 -32
  119. package/src/lib/organize/filter-edit-model.ts +6 -16
  120. package/src/lib/organize/organize-model.test.ts +7 -40
  121. package/src/lib/organize/organize-model.ts +16 -18
  122. package/src/lib/organize/rule-model.test.ts +24 -202
  123. package/src/lib/organize/rule-model.ts +19 -166
  124. package/src/lib/organize/search-to-rule.test.ts +2 -36
  125. package/src/lib/organize/search-to-rule.ts +1 -16
  126. package/src/lib/provider-presets.test.ts +1 -77
  127. package/src/lib/query-error-handler.test.ts +0 -61
  128. package/src/lib/query-escalation.integration.test.ts +4 -4
  129. package/src/lib/result-count.ts +1 -1
  130. package/src/lib/result-folder.ts +1 -1
  131. package/src/lib/rum-adapter.test.ts +0 -104
  132. package/src/lib/search-query.test.ts +0 -11
  133. package/src/lib/search-result.test.ts +2 -142
  134. package/src/lib/search-result.ts +3 -73
  135. package/src/lib/search-scope.test.ts +1 -92
  136. package/src/lib/search-scope.ts +0 -29
  137. package/src/lib/search-surface.ts +2 -2
  138. package/src/lib/search-token-index.test.ts +0 -8
  139. package/src/lib/search-tokens.ts +15 -16
  140. package/src/lib/starred-rows.test.ts +0 -4
  141. package/src/lib/sync-warning-entries.ts +33 -0
  142. package/src/lib/wizard-selection.ts +1 -1
  143. package/src/mocks/story-frame/mail-handlers.ts +12 -8
  144. package/src/mocks/story-frame/mail-world.ts +5 -0
  145. package/src/routeTree.gen.ts +21 -0
  146. package/src/routes/calendar/$view.$date/index.tsx +1 -1
  147. package/src/routes/calendar.tsx +2 -1
  148. package/src/routes/mail/brief.tsx +1 -1
  149. package/src/routes/mail/flagged.tsx +1 -1
  150. package/src/routes/mail.tsx +5 -3
  151. package/src/routes/settings/-accounts.test.ts +0 -5
  152. package/src/routes/settings/-filters.test.ts +0 -1
  153. package/src/routes/settings/-shared/help-copy.tsx +15 -0
  154. package/src/routes/settings/accounts.tsx +1 -1
  155. package/src/routes/settings/advanced.tsx +1 -4
  156. package/src/routes/settings/filters.tsx +1 -2
  157. package/src/routes/settings/folders.tsx +1 -1
  158. package/src/routes/settings/labels.tsx +1 -1
  159. package/src/routes/settings/senders.tsx +1 -1
  160. package/src/routes/settings/warnings.tsx +36 -0
  161. package/src/routes/settings.tsx +7 -0
  162. package/src/routing/wizard-history.test.ts +1 -45
  163. package/src/runtime-config.ts +5 -0
  164. package/src/test-support/fixtures.ts +1 -1
  165. package/src/components/compose/draft-discard-banner.test.ts +0 -76
  166. package/src/components/mail/ReadingPaneEmpty.render.test.ts +0 -42
  167. package/src/components/mail/SpamRescue.render.test.ts +0 -35
  168. package/src/components/mail/intelligence-semantic-off.render.test.ts +0 -111
  169. package/src/components/mail/selection-surface-rollcall.test.ts +0 -270
  170. package/src/components/settings/QuarantinePanel.render.test.ts +0 -71
  171. package/src/components/settings/QuarantinePanel.tsx +0 -178
  172. package/src/components/ui/DropdownMenu.tsx +0 -99
  173. package/src/components/ui/EmptyState.tsx +0 -11
  174. package/src/components/ui/ErrorBanner.render.test.ts +0 -104
  175. package/src/components/ui/ErrorBanner.stories.tsx +0 -132
  176. package/src/components/ui/ErrorBanner.tsx +0 -106
  177. package/src/components/ui/ErrorBannerStack.tsx +0 -35
  178. package/src/components/ui/ErrorState.render.test.ts +0 -82
  179. package/src/components/ui/ErrorState.tsx +0 -80
  180. package/src/components/ui/KeyboardShortcutsModal.tsx +0 -113
  181. package/src/hooks/useMoveMessages.test.ts +0 -63
  182. package/src/hooks/useOrganizeWiden.test.ts +0 -217
  183. package/src/hooks/useOrganizeWiden.ts +0 -112
  184. package/src/hooks/useSemanticSearch.ts +0 -132
  185. package/src/hooks/useSemanticSearchEnabled.ts +0 -23
  186. package/src/hooks/useVisualViewport.test.ts +0 -126
  187. package/src/lib/organize/sender-fallback.test.ts +0 -52
  188. package/src/lib/organize/sender-fallback.ts +0 -38
  189. package/src/lib/rescue-telemetry.test.ts +0 -75
  190. package/src/router.test.ts +0 -81
@@ -9,7 +9,6 @@ import { describe, it } from "node:test";
9
9
  import type { FilterRule } from "@remit/ui";
10
10
  import { buildOrganizeInput } from "./organize-model";
11
11
  import {
12
- buildInitialRule,
13
12
  derivePreview,
14
13
  isEvaluablePredicate,
15
14
  normalizeClauseValue,
@@ -17,21 +16,8 @@ import {
17
16
  predicateSignature,
18
17
  rulePredicate,
19
18
  ruleToDraft,
20
- SUPPORTED_CLAUSE_FIELDS,
21
19
  } from "./rule-model";
22
20
 
23
- describe("SUPPORTED_CLAUSE_FIELDS", () => {
24
- it("offers every field the backend matcher now evaluates, ListId and FromDomain included (#262)", () => {
25
- assert.deepEqual(SUPPORTED_CLAUSE_FIELDS, [
26
- "From",
27
- "Subject",
28
- "HasWords",
29
- "ListId",
30
- "FromDomain",
31
- ]);
32
- });
33
- });
34
-
35
21
  describe("normalizeListId", () => {
36
22
  it("strips the bracketed identifier, trims, and case-folds — matching the backend", () => {
37
23
  assert.equal(
@@ -55,121 +41,24 @@ describe("normalizeListId", () => {
55
41
  });
56
42
  });
57
43
 
58
- describe("buildInitialRule", () => {
59
- it("opens a semantic-capable rule on the widen anchor with no literal clauses", () => {
60
- const rule = buildInitialRule({
61
- anchorMessageId: "msg-1",
62
- semanticUnavailable: false,
63
- senders: [],
64
- selectionCount: 3,
65
- });
66
- assert.equal(rule.clauses.length, 0);
67
- assert.deepEqual(rule.widen, { anchorCount: 3 });
68
- assert.equal(rule.matchOperator, "all");
69
- assert.equal(rule.scope, "once");
70
- });
71
-
72
- it("opens the sender fallback on visible, editable derived From chips (#251)", () => {
73
- const rule = buildInitialRule({
74
- anchorMessageId: "msg-1",
75
- semanticUnavailable: true,
76
- senders: ["a@x.com", "b@y.com"],
77
- selectionCount: 2,
78
- });
79
- assert.equal(rule.widen, undefined);
80
- assert.equal(rule.matchOperator, "any");
81
- assert.deepEqual(
82
- rule.clauses.map((clause) => ({
83
- field: clause.field,
84
- value: clause.value,
85
- derived: clause.derived,
86
- })),
87
- [
88
- { field: "From", value: "a@x.com", derived: true },
89
- { field: "From", value: "b@y.com", derived: true },
90
- ],
91
- );
92
- });
93
-
94
- it("collapses senders sharing a registrable domain to one derived FromDomain chip (#262)", () => {
95
- const rule = buildInitialRule({
96
- semanticUnavailable: true,
97
- senders: ["npm@github.com", "ci@sub.github.com"],
98
- selectionCount: 2,
99
- });
100
- assert.equal(rule.matchOperator, "any");
101
- assert.deepEqual(
102
- rule.clauses.map((clause) => ({
103
- field: clause.field,
104
- value: clause.value,
105
- derived: clause.derived,
106
- })),
107
- [{ field: "FromDomain", value: "github.com", derived: true }],
108
- );
109
- });
110
-
111
- it("carries a Something-else seed's folder and scope", () => {
112
- const rule = buildInitialRule({
113
- anchorMessageId: "msg-1",
114
- semanticUnavailable: false,
115
- senders: [],
116
- selectionCount: 1,
117
- seedMailboxId: "mbx-archive",
118
- seedScope: "standing",
119
- });
120
- assert.equal(rule.moveMailboxId, "mbx-archive");
121
- assert.equal(rule.scope, "standing");
122
- });
123
-
124
- it("offers no widen when there is no anchor to ride on", () => {
125
- const rule = buildInitialRule({
126
- semanticUnavailable: false,
127
- senders: [],
128
- selectionCount: 0,
129
- });
130
- assert.equal(rule.widen, undefined);
131
- });
132
- });
133
-
134
- const semanticRule: FilterRule = {
44
+ const rule: FilterRule = {
135
45
  clauses: [{ id: "c1", field: "Subject", value: "receipt" }],
136
46
  matchOperator: "all",
137
- widen: { anchorCount: 2 },
138
47
  moveMailboxId: "mbx-archive",
139
48
  scope: "once",
140
49
  };
141
50
 
142
51
  describe("rulePredicate", () => {
143
- it("includes the anchor while the widen is present and active", () => {
144
- const predicate = rulePredicate(semanticRule, "msg-1");
145
- assert.equal(predicate.anchorMessageId, "msg-1");
52
+ it("carries the operator and the literal clauses", () => {
53
+ const predicate = rulePredicate(rule);
146
54
  assert.equal(predicate.matchOperator, "And");
147
55
  assert.deepEqual(predicate.literalClauses, [
148
56
  { field: "Subject", value: "receipt" },
149
57
  ]);
150
58
  });
151
59
 
152
- it("drops the anchor once the widen is removed", () => {
153
- const predicate = rulePredicate(
154
- { ...semanticRule, widen: undefined },
155
- "msg-1",
156
- );
157
- assert.equal(predicate.anchorMessageId, undefined);
158
- });
159
-
160
- it("drops the anchor for a widen the deployment cannot evaluate (D4 inactive)", () => {
161
- const predicate = rulePredicate(
162
- { ...semanticRule, widen: { anchorCount: 2, inactive: true } },
163
- "msg-1",
164
- );
165
- assert.equal(predicate.anchorMessageId, undefined);
166
- });
167
-
168
60
  it("maps the any operator to Or", () => {
169
- const predicate = rulePredicate(
170
- { ...semanticRule, matchOperator: "any" },
171
- "msg-1",
172
- );
61
+ const predicate = rulePredicate({ ...rule, matchOperator: "any" });
173
62
  assert.equal(predicate.matchOperator, "Or");
174
63
  });
175
64
  });
@@ -177,28 +66,19 @@ describe("rulePredicate", () => {
177
66
  describe("predicateSignature", () => {
178
67
  it("is stable across identical predicates", () => {
179
68
  assert.equal(
180
- predicateSignature(rulePredicate(semanticRule, "msg-1")),
181
- predicateSignature(rulePredicate(semanticRule, "msg-1")),
69
+ predicateSignature(rulePredicate(rule)),
70
+ predicateSignature(rulePredicate(rule)),
182
71
  );
183
72
  });
184
73
 
185
74
  it("changes when a clause value changes", () => {
186
75
  const changed: FilterRule = {
187
- ...semanticRule,
76
+ ...rule,
188
77
  clauses: [{ id: "c1", field: "Subject", value: "invoice" }],
189
78
  };
190
79
  assert.notEqual(
191
- predicateSignature(rulePredicate(semanticRule, "msg-1")),
192
- predicateSignature(rulePredicate(changed, "msg-1")),
193
- );
194
- });
195
-
196
- it("changes when the widen is removed", () => {
197
- assert.notEqual(
198
- predicateSignature(rulePredicate(semanticRule, "msg-1")),
199
- predicateSignature(
200
- rulePredicate({ ...semanticRule, widen: undefined }, "msg-1"),
201
- ),
80
+ predicateSignature(rulePredicate(rule)),
81
+ predicateSignature(rulePredicate(changed)),
202
82
  );
203
83
  });
204
84
  });
@@ -206,52 +86,35 @@ describe("predicateSignature", () => {
206
86
  describe("the previewed set equals the applied set", () => {
207
87
  it("carries exactly the previewed predicate into the commit draft", () => {
208
88
  const preview = buildOrganizeInput({
209
- ...rulePredicate(semanticRule, "msg-1"),
210
- matchOperator: rulePredicate(semanticRule, "msg-1").matchOperator,
211
- literalClauses: rulePredicate(semanticRule, "msg-1").literalClauses,
89
+ ...rulePredicate(rule),
90
+ matchOperator: rulePredicate(rule).matchOperator,
91
+ literalClauses: rulePredicate(rule).literalClauses,
212
92
  });
213
- const apply = buildOrganizeInput(ruleToDraft(semanticRule, "msg-1"));
214
- assert.equal(apply.anchorMessageId, preview.anchorMessageId);
93
+ const apply = buildOrganizeInput(ruleToDraft(rule));
215
94
  assert.equal(apply.matchOperator, preview.matchOperator);
216
95
  assert.deepEqual(apply.literalClauses, preview.literalClauses);
217
96
  });
218
97
 
219
- it("keeps them equal for the sender fallback (no anchor, Or)", () => {
220
- const rule = buildInitialRule({
221
- semanticUnavailable: true,
222
- senders: ["a@x.com", "b@y.com"],
223
- selectionCount: 2,
224
- });
225
- const predicate = rulePredicate(rule);
226
- const apply = buildOrganizeInput(ruleToDraft(rule));
227
- assert.equal(apply.anchorMessageId, undefined);
228
- assert.equal(apply.matchOperator, "Or");
229
- assert.deepEqual(apply.literalClauses, predicate.literalClauses);
230
- });
231
-
232
98
  it("adds the expiry only for the until scope, never touching the match set", () => {
233
- const once = ruleToDraft(semanticRule, "msg-1");
99
+ const once = ruleToDraft(rule);
234
100
  assert.equal(once.expiresAt, undefined);
235
101
 
236
- const until = ruleToDraft(
237
- { ...semanticRule, scope: "until", until: "2999-01-02" },
238
- "msg-1",
239
- );
102
+ const until = ruleToDraft({
103
+ ...rule,
104
+ scope: "until",
105
+ until: "2999-01-02",
106
+ });
240
107
  assert.ok(until.expiresAt?.startsWith("2999-01-02"));
241
108
  assert.deepEqual(until.literalClauses, once.literalClauses);
242
- assert.equal(until.anchorMessageId, once.anchorMessageId);
243
109
  });
244
110
 
245
111
  it("carries the move destination into the draft", () => {
246
- assert.equal(
247
- ruleToDraft(semanticRule, "msg-1").moveMailboxId,
248
- "mbx-archive",
249
- );
112
+ assert.equal(ruleToDraft(rule).moveMailboxId, "mbx-archive");
250
113
  });
251
114
 
252
115
  it("carries the label target into the draft (issue #26)", () => {
253
- const labeled = { ...semanticRule, labelId: "lbl-receipts" };
254
- assert.equal(ruleToDraft(labeled, "msg-1").labelId, "lbl-receipts");
116
+ const labeled = { ...rule, labelId: "lbl-receipts" };
117
+ assert.equal(ruleToDraft(labeled).labelId, "lbl-receipts");
255
118
  });
256
119
  });
257
120
 
@@ -276,36 +139,6 @@ describe("derivePreview", () => {
276
139
  );
277
140
  });
278
141
 
279
- it("carries the empty-index answer through, so a zero can say why (#452)", () => {
280
- assert.deepEqual(
281
- derivePreview(
282
- { count: 0, indexEmpty: true, previewedSignature: signature },
283
- signature,
284
- ),
285
- { status: "ready", count: 0, indexEmpty: true },
286
- );
287
- });
288
-
289
- it("keeps the empty-index answer on a stale count", () => {
290
- assert.deepEqual(
291
- derivePreview(
292
- { count: 0, indexEmpty: true, previewedSignature: "sig-old" },
293
- signature,
294
- ),
295
- { status: "ready", count: 0, stale: true, indexEmpty: true },
296
- );
297
- });
298
-
299
- it("leaves the flag off when the index answered", () => {
300
- assert.deepEqual(
301
- derivePreview(
302
- { count: 0, indexEmpty: false, previewedSignature: signature },
303
- signature,
304
- ),
305
- { status: "ready", count: 0 },
306
- );
307
- });
308
-
309
142
  it("surfaces an error raised for the current predicate", () => {
310
143
  assert.deepEqual(
311
144
  derivePreview(
@@ -333,7 +166,7 @@ describe("derivePreview", () => {
333
166
  });
334
167
 
335
168
  describe("isEvaluablePredicate", () => {
336
- it("accepts literal clauses the vector-free matcher can read off the index", () => {
169
+ it("accepts literal clauses the matcher can read off the thread rows", () => {
337
170
  assert.equal(
338
171
  isEvaluablePredicate({
339
172
  matchOperator: "And",
@@ -346,7 +179,7 @@ describe("isEvaluablePredicate", () => {
346
179
  );
347
180
  });
348
181
 
349
- it("rejects a body clause with no anchor — the shape that answered 500", () => {
182
+ it("rejects a body clause — the shape that answered 500", () => {
350
183
  assert.equal(
351
184
  isEvaluablePredicate({
352
185
  matchOperator: "And",
@@ -369,17 +202,6 @@ describe("isEvaluablePredicate", () => {
369
202
  );
370
203
  });
371
204
 
372
- it("accepts a body clause once an anchor puts the matcher on the semantic arm", () => {
373
- assert.equal(
374
- isEvaluablePredicate({
375
- anchorMessageId: "msg-1",
376
- matchOperator: "And",
377
- literalClauses: [{ field: "HasWords", value: "receipt" }],
378
- }),
379
- true,
380
- );
381
- });
382
-
383
205
  it("accepts an empty predicate", () => {
384
206
  assert.equal(
385
207
  isEvaluablePredicate({ matchOperator: "And", literalClauses: [] }),
@@ -1,16 +1,11 @@
1
- import {
2
- type ClauseField,
3
- derivePropertyClauses,
4
- type FilterRule,
5
- type MatchOperator,
6
- type PreviewCount,
7
- type RuleClause,
8
- type RuleMatchMode,
9
- type RuleScope,
1
+ import type {
2
+ ClauseField,
3
+ FilterRule,
4
+ MatchOperator,
5
+ PreviewCount,
10
6
  } from "@remit/ui";
11
7
  import { pickedDateToExpiresAt } from "./filter-status";
12
- import type { OrganizeDraft } from "./organize-model";
13
- import type { OrganizeMatchPredicate } from "./sender-fallback";
8
+ import type { OrganizeDraft, OrganizeMatchPredicate } from "./organize-model";
14
9
 
15
10
  /**
16
11
  * The clause fields the Organize editor may offer (RFC 038 D2). Gated on what
@@ -49,163 +44,31 @@ export const normalizeClauseValue = (
49
44
  const toApiOperator = (operator: MatchOperator): "And" | "Or" =>
50
45
  operator === "all" ? "And" : "Or";
51
46
 
52
- export interface InitialRuleInput {
53
- /** The semantic anchor — the first selected message. */
54
- anchorMessageId?: string;
55
- /**
56
- * The deployment ships no vector pipeline, so the widen cannot run. The rule
57
- * opens on the property-derived literal clauses instead of an anchor.
58
- */
59
- semanticUnavailable: boolean;
60
- /**
61
- * How the rule opens. Defaults to `properties` when the deployment cannot
62
- * serve the widen, `similar` otherwise — semantic stays the default wherever
63
- * it can run.
64
- */
65
- matchMode?: RuleMatchMode;
66
- /** Distinct sender addresses of the selection, for the derived clauses. */
67
- senders: readonly string[];
68
- /** Subjects of the selected messages, for the shared-subject prefill. */
69
- subjects?: readonly string[];
70
- /** How many messages the selection holds — the widen's anchor count. */
71
- selectionCount: number;
72
- /** A folder a "Something else" shortcut pre-picked. */
73
- seedMailboxId?: string;
74
- /** A scope a "Something else" shortcut pre-picked. */
75
- seedScope?: RuleScope;
76
- }
77
-
78
- export const defaultMatchMode = (
79
- semanticUnavailable: boolean,
80
- ): RuleMatchMode => (semanticUnavailable ? "properties" : "similar");
81
-
82
- /** The property prefill as editor chips, flagged as derived so a mode switch
83
- * can take back its own guesses without touching a clause the user wrote. */
84
- const derivedClauses = (
85
- senders: readonly string[],
86
- subjects: readonly string[],
87
- ): RuleClause[] =>
88
- derivePropertyClauses(senders, subjects).map((clause, index) => ({
89
- id: `derived-${index}`,
90
- field: clause.field,
91
- value: clause.value,
92
- derived: true,
93
- }));
94
-
95
- export interface RuleMatchers {
96
- clauses: RuleClause[];
97
- matchOperator: MatchOperator;
98
- widen?: FilterRule["widen"];
99
- }
100
-
101
- export interface MatchersInput {
102
- anchorMessageId?: string;
103
- senders: readonly string[];
104
- subjects: readonly string[];
105
- selectionCount: number;
106
- /** Clauses the user wrote, carried across a mode switch untouched. */
107
- keepClauses?: RuleClause[];
108
- /** The operator to keep when the user already has clauses of their own. */
109
- currentOperator?: MatchOperator;
110
- }
111
-
112
- /**
113
- * What a rule matches on in a given mode, with the action and scope left alone.
114
- *
115
- * - `similar` — the semantic widen chip, the anchor doing the work.
116
- * - `properties` — no anchor at all: the selection's own sender or shared
117
- * subject as ordinary literal chips ({@link derivePropertyClauses}).
118
- *
119
- * Both keep clauses the user added by hand, so switching mode is never
120
- * destructive of their own edits.
121
- */
122
- export const matchersForMode = (
123
- mode: RuleMatchMode,
124
- input: MatchersInput,
125
- ): RuleMatchers => {
126
- const kept = input.keepClauses ?? [];
127
- if (mode === "properties") {
128
- const prefill = derivedClauses(input.senders, input.subjects).filter(
129
- (clause) =>
130
- !kept.some(
131
- (existing) =>
132
- existing.field === clause.field && existing.value === clause.value,
133
- ),
134
- );
135
- return {
136
- clauses: [...kept, ...prefill],
137
- matchOperator: kept.length > 0 ? (input.currentOperator ?? "any") : "any",
138
- widen: undefined,
139
- };
140
- }
141
- return {
142
- clauses: kept,
143
- matchOperator: kept.length > 0 ? (input.currentOperator ?? "all") : "all",
144
- widen: input.anchorMessageId
145
- ? { anchorCount: Math.max(input.selectionCount, 1) }
146
- : undefined,
147
- };
148
- };
149
-
150
47
  /**
151
- * The rule the editor opens on, built from the widen probe. A semantic-capable
152
- * deployment opens on the widen chip (the anchor, no literal clauses); one
153
- * without the vector pipeline, or a user who picked properties matching, opens
154
- * on the derived `From` / `FromDomain` / `Subject` chips (#251), visible and
155
- * editable, matched with `Or`. Either way the move destination and scope come
156
- * from any "Something else" seed.
157
- */
158
- export const buildInitialRule = (input: InitialRuleInput): FilterRule => {
159
- const scope = input.seedScope ?? "once";
160
- const mode = input.matchMode ?? defaultMatchMode(input.semanticUnavailable);
161
- return {
162
- ...matchersForMode(mode, {
163
- anchorMessageId: input.anchorMessageId,
164
- senders: input.senders,
165
- subjects: input.subjects ?? [],
166
- selectionCount: input.selectionCount,
167
- }),
168
- moveMailboxId: input.seedMailboxId,
169
- scope,
170
- name: "",
171
- };
172
- };
173
-
174
- /**
175
- * The match-relevant projection of a rule — the anchor (only while the widen is
176
- * present and active), the operator, and the literal clauses. Preview and apply
48
+ * The match-relevant projection of a rule — the operator and the literal
49
+ * clauses. Preview and apply
177
50
  * both derive from this, so the set the editor counts is the set a commit acts
178
51
  * on. Fields that do not change the match set (folder, scope, name, date) are
179
52
  * deliberately absent.
180
53
  */
181
- export const rulePredicate = (
182
- rule: FilterRule,
183
- anchorMessageId?: string,
184
- ): OrganizeMatchPredicate => {
185
- const widenActive = rule.widen !== undefined && !rule.widen.inactive;
186
- const literalClauses = rule.clauses.map((clause) => ({
54
+ export const rulePredicate = (rule: FilterRule): OrganizeMatchPredicate => ({
55
+ matchOperator: toApiOperator(rule.matchOperator),
56
+ literalClauses: rule.clauses.map((clause) => ({
187
57
  field: clause.field,
188
58
  value: clause.value,
189
- }));
190
- return {
191
- ...(widenActive && anchorMessageId ? { anchorMessageId } : {}),
192
- matchOperator: toApiOperator(rule.matchOperator),
193
- literalClauses,
194
- };
195
- };
59
+ })),
60
+ });
196
61
 
197
62
  /**
198
63
  * Whether the matcher behind `/organize/preview` can evaluate this predicate at
199
- * all. Without an anchor it takes the vector-free literal arm, which reads no
200
- * message body and rejects a `HasWords` clause rather than narrowing the match
201
- * silently (`bodyContentRejection`, backend/service/organize.ts). Asking it
64
+ * all. It reads no message body and rejects a `HasWords` clause rather than
65
+ * narrowing the match silently (`bodyContentRejection`, backend/service/organize.ts). Asking it
202
66
  * anyway is refused with a 400, on preview and on a back-apply alike, so the
203
67
  * caller must not ask.
204
68
  */
205
69
  export const isEvaluablePredicate = (
206
70
  predicate: OrganizeMatchPredicate,
207
71
  ): boolean =>
208
- predicate.anchorMessageId !== undefined ||
209
72
  !predicate.literalClauses.some((clause) => clause.field === "HasWords");
210
73
 
211
74
  /**
@@ -215,7 +78,6 @@ export const isEvaluablePredicate = (
215
78
  */
216
79
  export const predicateSignature = (predicate: OrganizeMatchPredicate): string =>
217
80
  JSON.stringify({
218
- anchor: predicate.anchorMessageId ?? null,
219
81
  operator: predicate.matchOperator,
220
82
  clauses: predicate.literalClauses.map(
221
83
  (clause) => `${clause.field} ${clause.value}`,
@@ -228,15 +90,9 @@ export const predicateSignature = (predicate: OrganizeMatchPredicate): string =>
228
90
  * and, for the `until` scope, the derived expiry added. The expiry never enters
229
91
  * the match set, so preview and apply still count and act on the same messages.
230
92
  */
231
- export const ruleToDraft = (
232
- rule: FilterRule,
233
- anchorMessageId?: string,
234
- ): OrganizeDraft => {
235
- const predicate = rulePredicate(rule, anchorMessageId);
93
+ export const ruleToDraft = (rule: FilterRule): OrganizeDraft => {
94
+ const predicate = rulePredicate(rule);
236
95
  return {
237
- ...(predicate.anchorMessageId
238
- ? { anchorMessageId: predicate.anchorMessageId }
239
- : {}),
240
96
  matchOperator: predicate.matchOperator,
241
97
  literalClauses: predicate.literalClauses,
242
98
  moveMailboxId: rule.moveMailboxId,
@@ -253,8 +109,6 @@ export interface PreviewState {
253
109
  count?: number;
254
110
  /** The ids that count was counted over, as the server bounded them. */
255
111
  matchedIds?: readonly string[];
256
- /** The server said the semantic index had nothing to answer with (#452). */
257
- indexEmpty?: boolean;
258
112
  /** The signature the {@link count} was counted for. */
259
113
  previewedSignature?: string;
260
114
  /** The signature the last error was raised for. */
@@ -276,11 +130,10 @@ export const derivePreview = (
276
130
  return { status: "error", reason: state.error };
277
131
  }
278
132
  if (state.count === undefined) return { status: "loading" };
279
- const indexEmpty = state.indexEmpty === true ? { indexEmpty: true } : {};
280
133
  if (state.previewedSignature === currentSignature) {
281
- return { status: "ready", count: state.count, ...indexEmpty };
134
+ return { status: "ready", count: state.count };
282
135
  }
283
- return { status: "ready", count: state.count, stale: true, ...indexEmpty };
136
+ return { status: "ready", count: state.count, stale: true };
284
137
  };
285
138
 
286
139
  /** How long a rule change settles before the next preview fires. */
@@ -9,13 +9,8 @@ const CONTEXT: SearchTokenContext = {
9
9
  accountsByName: new Map([["work", "acc-work"]]),
10
10
  };
11
11
 
12
- const convert = (
13
- query: string,
14
- options: { searchHadSemanticReach?: boolean } = {},
15
- ) =>
16
- convertSearchToRule(parseSearchTokens(query, CONTEXT), {
17
- searchHadSemanticReach: options.searchHadSemanticReach ?? false,
18
- });
12
+ const convert = (query: string) =>
13
+ convertSearchToRule(parseSearchTokens(query, CONTEXT));
19
14
 
20
15
  describe("convertSearchToRule — facet → clause mapping", () => {
21
16
  it("maps literal terms to a HasWords clause", () => {
@@ -82,35 +77,6 @@ describe("convertSearchToRule — facets with no clause equivalent", () => {
82
77
  });
83
78
  });
84
79
 
85
- describe("convertSearchToRule — semantic honesty (RFC 038 D5)", () => {
86
- it("states the dropped reach when the search surfaced similar mail", () => {
87
- // The literal filter cannot reproduce the reach the search just showed.
88
- const conversion = convert("things like this", {
89
- searchHadSemanticReach: true,
90
- });
91
- assert.equal(conversion.droppedSemantic, true);
92
- // The literal words are still kept.
93
- assert.deepEqual(conversion.clauses, [
94
- { field: "HasWords", value: "things like this" },
95
- ]);
96
- });
97
-
98
- it("says nothing when the search had no semantic reach to drop", () => {
99
- const conversion = convert("things like this", {
100
- searchHadSemanticReach: false,
101
- });
102
- assert.equal(conversion.droppedSemantic, false);
103
- });
104
-
105
- it("never reports dropped semantics for a facet-only search", () => {
106
- const conversion = convert("from:a@b.com", {
107
- searchHadSemanticReach: true,
108
- });
109
- assert.equal(conversion.keptTerms, false);
110
- assert.equal(conversion.droppedSemantic, false);
111
- });
112
- });
113
-
114
80
  describe("convertSearchToRule — nothing left to make a filter from", () => {
115
81
  it("yields no clause for a query of only dropped facets", () => {
116
82
  assert.equal(isConvertible(convert("has:attachment")), false);
@@ -19,22 +19,9 @@ import {
19
19
  * equivalent is never silently folded into the rule: a folder scope is reported
20
20
  * as dropped-and-kept-out (the filter matches everywhere, not just there), the
21
21
  * attribute facets (attachment / read state / starred / category / date) are
22
- * reported as left out, and a free-text query kept as a literal `HasWords` clause
23
- * is reported as having lost its semantic "similar mail" reach on a deployment
24
- * that cannot embed the query (D5). Pure functions only — the capability is
25
- * injected, not probed here.
22
+ * reported as left out. Pure functions only.
26
23
  */
27
24
 
28
- interface ConvertOptions {
29
- /**
30
- * Whether the current search surfaced semantically-similar mail (a non-empty
31
- * "Related" section). This is the reach the literal filter cannot reproduce —
32
- * the direct existing signal, read from the search's own semantic results, not
33
- * a probe of deployment capability.
34
- */
35
- searchHadSemanticReach: boolean;
36
- }
37
-
38
25
  type NoClauseFacet = Extract<SearchToken["type"], DroppedFacetType>;
39
26
 
40
27
  const FACET_HAS_NO_CLAUSE: ReadonlySet<SearchToken["type"]> =
@@ -61,7 +48,6 @@ const hasNoClauseEquivalent = (
61
48
  */
62
49
  export const convertSearchToRule = (
63
50
  parsed: ParsedSearchQuery,
64
- { searchHadSemanticReach }: ConvertOptions,
65
51
  ): SearchConversion => {
66
52
  const clauses: SearchConversion["clauses"] = [];
67
53
  const droppedFacets: DroppedFacet[] = [];
@@ -101,7 +87,6 @@ export const convertSearchToRule = (
101
87
  droppedFacets,
102
88
  targetAccountId,
103
89
  keptTerms,
104
- droppedSemantic: keptTerms && searchHadSemanticReach,
105
90
  };
106
91
  };
107
92