@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
@@ -97,21 +97,6 @@ describe("buildSenderIntel", () => {
97
97
  const result = buildSenderIntel(thread, address);
98
98
  assert.equal(result.inboundCount, 34);
99
99
  });
100
-
101
- test("surfaces replyCount from address", () => {
102
- const thread = makeThread();
103
- const address = makeAddress({ inboundCount: 34, replyCount: 12 });
104
- const result = buildSenderIntel(thread, address);
105
- assert.equal(result.replyCount, 12);
106
- });
107
-
108
- test("surfaces both counters together", () => {
109
- const thread = makeThread();
110
- const address = makeAddress({ inboundCount: 34, replyCount: 12 });
111
- const result = buildSenderIntel(thread, address);
112
- assert.equal(result.inboundCount, 34);
113
- assert.equal(result.replyCount, 12);
114
- });
115
100
  });
116
101
 
117
102
  describe("suppression — counters absent", () => {
@@ -123,13 +108,6 @@ describe("buildSenderIntel", () => {
123
108
  assert.equal(result.inboundCount, undefined);
124
109
  });
125
110
 
126
- test("replyCount is undefined when address has no replyCount", () => {
127
- const thread = makeThread();
128
- const address = makeAddress({ inboundCount: 5 }); // no replyCount
129
- const result = buildSenderIntel(thread, address);
130
- assert.equal(result.replyCount, undefined);
131
- });
132
-
133
111
  test("both counters are undefined when address is undefined", () => {
134
112
  const thread = makeThread();
135
113
  const result = buildSenderIntel(thread, undefined);
@@ -173,12 +151,6 @@ describe("buildSenderIntel", () => {
173
151
  assert.equal(result.name, "Unknown");
174
152
  });
175
153
 
176
- test("passes senderTrust through", () => {
177
- const thread = makeThread({ senderTrust: "vip" });
178
- const result = buildSenderIntel(thread, undefined);
179
- assert.equal(result.trust, "vip");
180
- });
181
-
182
154
  test("firstSeenLabel is 'unknown' when no address", () => {
183
155
  const thread = makeThread();
184
156
  const result = buildSenderIntel(thread, undefined);
@@ -225,7 +197,7 @@ describe("buildSenderIntel", () => {
225
197
  describe("buildAuthenticityIntel", () => {
226
198
  test("caution when there is no authenticity signal", () => {
227
199
  const thread = makeThread({ fromEmail: "alice@example.com" });
228
- const result = buildAuthenticityIntel(thread, 0);
200
+ const result = buildAuthenticityIntel(thread);
229
201
  assert.equal(result.verdict, "caution");
230
202
  assert.match(result.summary, /can't verify the sender/);
231
203
  });
@@ -239,7 +211,7 @@ describe("buildAuthenticityIntel", () => {
239
211
  dkimMismatch: false,
240
212
  },
241
213
  } as Partial<RemitImapThreadMessageResponse>);
242
- const result = buildAuthenticityIntel(thread, 0);
214
+ const result = buildAuthenticityIntel(thread);
243
215
  assert.equal(result.verdict, "aligned");
244
216
  });
245
217
 
@@ -253,19 +225,15 @@ describe("buildAuthenticityIntel", () => {
253
225
  dkimMismatch: true,
254
226
  },
255
227
  } as Partial<RemitImapThreadMessageResponse>);
256
- const result = buildAuthenticityIntel(thread, 3);
228
+ const result = buildAuthenticityIntel(thread);
257
229
  assert.equal(result.verdict, "mismatch");
258
230
  assert.equal(result.addressUnreadable, undefined);
259
- assert.equal(result.similarCount, 3);
260
231
  });
261
232
 
262
233
  test("no user-facing copy names the verification mechanism", () => {
263
234
  const verdicts = [
264
- buildAuthenticityIntel(makeThread(), 0),
265
- buildAuthenticityIntel(
266
- makeThread({ fromEmail: "x@no-signal.example" }),
267
- 0,
268
- ),
235
+ buildAuthenticityIntel(makeThread()),
236
+ buildAuthenticityIntel(makeThread({ fromEmail: "x@no-signal.example" })),
269
237
  buildAuthenticityIntel(
270
238
  makeThread({
271
239
  fromEmail: "a@bank.example",
@@ -275,7 +243,6 @@ describe("buildAuthenticityIntel", () => {
275
243
  dkimMismatch: true,
276
244
  },
277
245
  } as Partial<RemitImapThreadMessageResponse>),
278
- 0,
279
246
  ),
280
247
  ];
281
248
  for (const v of verdicts) {
@@ -305,7 +272,7 @@ describe("buildAuthenticityIntel", () => {
305
272
  } as Partial<RemitImapThreadMessageResponse>);
306
273
 
307
274
  test("is never presented as verified", () => {
308
- const result = buildAuthenticityIntel(infoMedics, 0);
275
+ const result = buildAuthenticityIntel(infoMedics);
309
276
  assert.notEqual(result.verdict, "aligned");
310
277
  assert.doesNotMatch(result.summary, /We verified/i);
311
278
  });
@@ -315,7 +282,7 @@ describe("buildAuthenticityIntel", () => {
315
282
  // signed by a relay or ESP infrastructure domain must not read as
316
283
  // "the name looks nothing like <that other party>".
317
284
  test("leads with the concern, naming the domain the name was actually compared to, and the link destination", () => {
318
- const result = buildAuthenticityIntel(infoMedics, 0);
285
+ const result = buildAuthenticityIntel(infoMedics);
319
286
  assert.equal(result.verdict, "caution");
320
287
  assert.match(result.summary, /^The name it shows/);
321
288
  assert.match(result.summary, /"InfoMedics"/);
@@ -338,7 +305,6 @@ describe("buildAuthenticityIntel", () => {
338
305
  offDomainLinkDomains: [],
339
306
  },
340
307
  } as Partial<RemitImapThreadMessageResponse>),
341
- 0,
342
308
  );
343
309
  assert.equal(result.verdict, "caution");
344
310
  assert.match(result.summary, /only looks like/);
@@ -357,7 +323,6 @@ describe("buildAuthenticityIntel", () => {
357
323
  offDomainLinkDomains: [],
358
324
  },
359
325
  } as Partial<RemitImapThreadMessageResponse>),
360
- 0,
361
326
  );
362
327
  assert.equal(result.verdict, "caution");
363
328
  assert.match(result.summary, /"matthijs@ischen\.nl"/);
@@ -377,7 +342,6 @@ describe("buildAuthenticityIntel", () => {
377
342
  offDomainLinkDomains: [],
378
343
  },
379
344
  } as Partial<RemitImapThreadMessageResponse>),
380
- 0,
381
345
  );
382
346
  assert.equal(result.verdict, "aligned");
383
347
  });
@@ -392,7 +356,6 @@ describe("buildAuthenticityIntel", () => {
392
356
  dkimMismatch: false,
393
357
  },
394
358
  } as Partial<RemitImapThreadMessageResponse>),
395
- 0,
396
359
  );
397
360
  assert.equal(result.verdict, "aligned");
398
361
  });
@@ -409,7 +372,6 @@ describe("buildAuthenticityIntel", () => {
409
372
  offDomainLinkDomains: "elsewhere.example",
410
373
  },
411
374
  } as unknown as Partial<RemitImapThreadMessageResponse>),
412
- 0,
413
375
  );
414
376
  assert.equal(result.verdict, "aligned");
415
377
  });
@@ -420,7 +382,7 @@ describe("buildAuthenticityIntel", () => {
420
382
  const thread = makeThread({
421
383
  fromEmail: "missing_mailbox@missing_domain",
422
384
  });
423
- const result = buildAuthenticityIntel(thread, 0);
385
+ const result = buildAuthenticityIntel(thread);
424
386
  assert.equal(result.verdict, "mismatch");
425
387
  assert.equal(result.addressUnreadable, true);
426
388
  });
@@ -433,7 +395,7 @@ describe("buildAuthenticityIntel", () => {
433
395
  dkimMismatch: false,
434
396
  },
435
397
  } as Partial<RemitImapThreadMessageResponse>);
436
- const result = buildAuthenticityIntel(thread, 0);
398
+ const result = buildAuthenticityIntel(thread);
437
399
  assert.equal(result.verdict, "mismatch");
438
400
  assert.equal(result.addressUnreadable, true);
439
401
  });
@@ -1,7 +1,4 @@
1
- import {
2
- addressOperationsSearchAddressesOptions,
3
- semanticSearchOperationsSemanticSearchOptions,
4
- } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
1
+ import { addressOperationsSearchAddressesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
2
  import type {
6
3
  RemitImapAddressResponse,
7
4
  RemitImapThreadMessageResponse,
@@ -10,17 +7,14 @@ import { DisplayNameCorrespondence } from "@remit/domain-enums";
10
7
  import type {
11
8
  AuthenticityIntel,
12
9
  IntelligenceData,
13
- MatchedChunk,
14
10
  SenderFlagsIntel,
15
11
  SenderIntel,
16
- SimilarMessageIntel,
17
12
  } from "@remit/ui";
18
13
  import { isThreadCategory } from "@remit/ui";
19
14
  import { useQuery } from "@tanstack/react-query";
20
15
  import { useMemo } from "react";
21
- import { useSemanticSearchEnabled } from "@/hooks/useSemanticSearchEnabled";
22
16
  import { isServerError } from "@/lib/error-classifier";
23
- import { formatDate, formatEmailDate } from "@/lib/format";
17
+ import { formatDate } from "@/lib/format";
24
18
  import {
25
19
  pickSenderAddress,
26
20
  senderAddressSearchQuery,
@@ -194,27 +188,16 @@ function readDomainList(value: unknown): string[] {
194
188
  );
195
189
  }
196
190
 
197
- /**
198
- * Build authenticity intel from the thread message's authenticity field.
199
- *
200
- * `similarCount` is the number of semantic-search lookalikes; the panel uses
201
- * it to render the "N similar messages" campaign-reveal CTA inside the
202
- * danger panel. Passed in because it is derived from the semantic-search
203
- * result, not the thread row.
204
- */
191
+ /** Build authenticity intel from the thread message's authenticity field. */
205
192
  export function buildAuthenticityIntel(
206
193
  thread: RemitImapThreadMessageResponse,
207
- similarCount: number,
208
194
  ): AuthenticityIntel {
209
- const surfacedSimilar = similarCount > 0 ? similarCount : undefined;
210
-
211
195
  if (isSenderAddressUnverifiable(thread.fromEmail)) {
212
196
  return {
213
197
  verdict: "mismatch",
214
198
  fromDomain: "",
215
199
  addressUnreadable: true,
216
200
  summary: UNREADABLE_SENDER_SUMMARY,
217
- similarCount: surfacedSimilar,
218
201
  };
219
202
  }
220
203
 
@@ -268,35 +251,11 @@ export function buildAuthenticityIntel(
268
251
  dkimDomain,
269
252
  claimedBrand,
270
253
  summary,
271
- // Surface the lookalike count so the panel renders the "N similar
272
- // messages" campaign-reveal button.
273
- similarCount: surfacedSimilar,
274
254
  };
275
255
  }
276
256
 
277
257
  export interface UseIntelligenceDataResult {
278
258
  data: IntelligenceData | null;
279
- /**
280
- * True while similar-messages fetch is in flight. The rest of the data
281
- * (sender, authenticity, category, flags) arrives synchronously from the
282
- * already-loaded thread row.
283
- */
284
- isSimilarLoading: boolean;
285
- /** Non-null when the similar-messages fetch failed. */
286
- similarError: unknown;
287
- /**
288
- * True when the similar-messages failure is a fatal first-party 5xx. The
289
- * global escalation overlay handles it; the panel must NOT degrade it to the
290
- * benign grey "Similarity search unavailable" label. A soft (non-fatal)
291
- * error or an empty result keeps the panel quiet.
292
- */
293
- similarErrorIsFatal: boolean;
294
- /**
295
- * False on an instance with semantic search off (#1068): nothing is embedded,
296
- * so the query is never sent and the section states the setting instead of
297
- * rendering an empty list. `undefined` until the config read answers.
298
- */
299
- semanticEnabled: boolean | undefined;
300
259
  /**
301
260
  * True when the address lookup failed with a fatal first-party 5xx. The panel
302
261
  * must not silently render the sender's flags (VIP/muted/blocked) as absent —
@@ -318,20 +277,16 @@ export interface UseIntelligenceDataResult {
318
277
 
319
278
  /**
320
279
  * Maps a `RemitImapThreadMessageResponse` into `IntelligenceData` for the
321
- * `IntelligencePanel` component. Three data sources:
280
+ * `IntelligencePanel` component. Two data sources:
322
281
  *
323
282
  * 1. The thread row itself (already loaded) → sender basics, trust, authenticity, category.
324
283
  * 2. `GET /addresses/search?q=<email>` → address flags (VIP/muted/blocked/etc.)
325
284
  * and first-seen timestamp. One round-trip per unique sender.
326
- * 3. `GET /search/semantic` → similar messages. Last to arrive; sidebar still
327
- * renders if this fails.
328
285
  */
329
286
  export function useIntelligenceData(
330
287
  thread: RemitImapThreadMessageResponse | null | undefined,
331
- currentMailboxId?: string,
332
288
  ): UseIntelligenceDataResult {
333
289
  const senderEmail = thread?.fromEmail ?? null;
334
- const subject = thread?.subject ?? "";
335
290
 
336
291
  // --- Address lookup: flags + first-seen timestamp ---
337
292
  const { data: addressSearchResult, error: addressError } = useQuery({
@@ -347,70 +302,22 @@ export function useIntelligenceData(
347
302
  senderEmail ?? undefined,
348
303
  );
349
304
 
350
- // --- Semantic search: similar messages ---
351
- const semanticEnabled = useSemanticSearchEnabled();
352
- const semanticQuery = [subject, senderEmail].filter(Boolean).join(" ");
353
- const {
354
- data: semanticResult,
355
- isLoading: isSimilarLoading,
356
- error: similarError,
357
- } = useQuery({
358
- ...semanticSearchOperationsSemanticSearchOptions({
359
- query: { query: semanticQuery, limit: 5 },
360
- }),
361
- // Waits for the config read rather than racing it. An off instance answers
362
- // this query with an empty list, which the panel cannot tell from a sender
363
- // nothing resembles, so the request is not worth sending on the chance the
364
- // instance turns out to be on. `undefined` is that read still in flight;
365
- // it is cached process-wide and every other surface already made it.
366
- enabled:
367
- Boolean(thread && semanticQuery.length > 3) && semanticEnabled === true,
368
- staleTime: 60_000,
369
- // A non-fatal failure (e.g. 404, no index yet) is acceptable — the sidebar
370
- // degrades gracefully. A 5xx is NOT acceptable: it escalates globally and
371
- // the panel must not present it as a benign "unavailable" state.
372
- retry: 1,
373
- });
374
-
375
305
  const data = useMemo((): IntelligenceData | null => {
376
306
  if (!thread) return null;
377
307
 
378
- const similar: SimilarMessageIntel[] = (semanticResult?.items ?? [])
379
- // Exclude the current message itself
380
- .filter((r) => r.messageId !== thread.messageId)
381
- .map((r) => ({
382
- id: r.messageId,
383
- threadId: r.threadId,
384
- // Prefer the mailbox the user is already viewing when the message also
385
- // lives there, so opening a similar message stays in the same folder;
386
- // otherwise fall back to the first mailbox it lives in.
387
- mailboxId:
388
- currentMailboxId != null && r.mailboxIds.includes(currentMailboxId)
389
- ? currentMailboxId
390
- : (r.mailboxIds[0] ?? ""),
391
- fromName: r.fromName ?? "",
392
- subject: r.subject ?? "(No subject)",
393
- timeLabel: r.sentDate != null ? formatEmailDate(r.sentDate * 1000) : "",
394
- matched: r.matchedChunkType as MatchedChunk,
395
- }));
396
-
397
308
  const sender = buildSenderIntel(thread, address);
398
309
 
399
- const authenticity = buildAuthenticityIntel(thread, similar.length);
310
+ const authenticity = buildAuthenticityIntel(thread);
400
311
 
401
312
  const category = buildCategoryIntel(thread, address);
402
313
 
403
314
  const flags = buildSenderFlags(address);
404
315
 
405
- return { sender, authenticity, category, flags, similar };
406
- }, [thread, address, semanticResult, currentMailboxId]);
316
+ return { sender, authenticity, category, flags };
317
+ }, [thread, address]);
407
318
 
408
319
  return {
409
320
  data,
410
- isSimilarLoading,
411
- similarError,
412
- similarErrorIsFatal: isServerError(similarError),
413
- semanticEnabled,
414
321
  addressErrorIsFatal: isServerError(addressError),
415
322
  addressId: address?.addressId,
416
323
  address,
@@ -93,52 +93,4 @@ describe("isSinglePaneTier — compose surface must mount below desktop", () =>
93
93
  it("is NOT single-pane at desktop (reading pane hosts compose)", () => {
94
94
  assert.equal(isSinglePaneTier("desktop"), false);
95
95
  });
96
-
97
- it("treats every below-desktop width as single-pane via resolveLayoutTier", () => {
98
- assert.equal(isSinglePaneTier(resolveLayoutTier(mouse(390))), true);
99
- assert.equal(isSinglePaneTier(resolveLayoutTier(mouse(768))), true);
100
- assert.equal(isSinglePaneTier(resolveLayoutTier(mouse(1023))), true);
101
- assert.equal(isSinglePaneTier(resolveLayoutTier(mouse(1024))), false);
102
- assert.equal(
103
- isSinglePaneTier(resolveLayoutTier(tabletHeldUpright(1024))),
104
- true,
105
- );
106
- });
107
- });
108
-
109
- describe("exactly one search field is mounted at every width", () => {
110
- // Two call sites decide this from the same tier: `mail.tsx` mounts the top
111
- // bar's field when the layout is not single-pane, and `MailListHeader`
112
- // passes `showSearch` to keep the header's field otherwise. Both read
113
- // `isSinglePaneTier`, so they are each other's complement by construction —
114
- // if either is rewritten to its own expression this fails. Two mounted
115
- // fields compete for "/" and for focus (#59); zero leaves no way to search.
116
- const mountsTopBarField = (viewport: ViewportProfile): boolean =>
117
- !isSinglePaneTier(resolveLayoutTier(viewport));
118
- const mountsHeaderField = (viewport: ViewportProfile): boolean =>
119
- isSinglePaneTier(resolveLayoutTier(viewport));
120
-
121
- for (const viewport of [
122
- mouse(0),
123
- mouse(390),
124
- mouse(767),
125
- mouse(768),
126
- mouse(1023),
127
- mouse(1024),
128
- mouse(1440),
129
- tabletHeldUpright(1024),
130
- ]) {
131
- it(`mounts one field at ${viewport.width}px ${viewport.orientation}/${viewport.pointer}`, () => {
132
- const fields =
133
- Number(mountsTopBarField(viewport)) +
134
- Number(mountsHeaderField(viewport));
135
- assert.equal(fields, 1, `${viewport.width}px mounts ${fields} fields`);
136
- });
137
- }
138
-
139
- it("puts the field in the top bar only from desktop up", () => {
140
- assert.equal(mountsTopBarField(mouse(1023)), false);
141
- assert.equal(mountsTopBarField(mouse(1024)), true);
142
- assert.equal(mountsTopBarField(tabletHeldUpright(1024)), false);
143
- });
144
96
  });
@@ -54,7 +54,7 @@ export const GENERIC_SPAM_ACTION_FAILURE =
54
54
  "This message could not be processed. Please try again. If it keeps failing, report it.";
55
55
 
56
56
  /**
57
- * The server's designed failure text names the message by embedding its raw
57
+ * The server's designed failure text names the message by putting its raw
58
58
  * id as a possessive subject — e.g. "Message 4kv0xxyfhg4dhzqvxd105v840's move
59
59
  * to Junk has not settled yet; try again in a moment." (message ids here are
60
60
  * 25-char base36, `translator.generate()` in `packages/data-ports/src/id.ts`
@@ -139,16 +139,6 @@ describe("useRescueCandidates: candidate mapping from real hook output", () => {
139
139
  senderTrust: "wellknown",
140
140
  });
141
141
 
142
- test("maps messageId to candidate id", () => {
143
- const { candidates } = renderHookWithResponse({ items: [vipThread] });
144
- assert.equal(candidates[0].id, "m1");
145
- });
146
-
147
- test("maps fromName as senderName", () => {
148
- const { candidates } = renderHookWithResponse({ items: [vipThread] });
149
- assert.equal(candidates[0].senderName, "Anna de Vries");
150
- });
151
-
152
142
  test("falls back to fromEmail when fromName is absent", () => {
153
143
  const thread = makeThread({
154
144
  messageId: "m3",
@@ -193,19 +183,6 @@ describe("useRescueCandidates: candidate mapping from real hook output", () => {
193
183
  assert.equal(candidates[0].trustSubReason, "You've emailed them before");
194
184
  });
195
185
 
196
- test("sets the constant trustReason headline", () => {
197
- const { candidates } = renderHookWithResponse({ items: [vipThread] });
198
- assert.equal(candidates[0].trustReason, "We can verify this sender");
199
- });
200
-
201
- test("preserves senderTrust on the candidate", () => {
202
- const { candidates } = renderHookWithResponse({
203
- items: [vipThread, wellknownThread],
204
- });
205
- assert.equal(candidates[0].senderTrust, "vip");
206
- assert.equal(candidates[1].senderTrust, "wellknown");
207
- });
208
-
209
186
  test("maps all response items to candidates in order", () => {
210
187
  const { candidates } = renderHookWithResponse({
211
188
  items: [vipThread, wellknownThread],
@@ -2,7 +2,10 @@ import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-clien
2
2
  import { type PreviewCount, UNCOUNTABLE_PREDICATE_REASON } from "@remit/ui";
3
3
  import { useMutation } from "@tanstack/react-query";
4
4
  import { useEffect, useRef, useState } from "react";
5
- import { buildOrganizeInput } from "@/lib/organize/organize-model";
5
+ import {
6
+ buildOrganizeInput,
7
+ type OrganizeMatchPredicate,
8
+ } from "@/lib/organize/organize-model";
6
9
  import {
7
10
  derivePreview,
8
11
  isEvaluablePredicate,
@@ -10,13 +13,12 @@ import {
10
13
  type PreviewState,
11
14
  predicateSignature,
12
15
  } from "@/lib/organize/rule-model";
13
- import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
14
16
 
15
17
  /**
16
18
  * The live match count for the edited rule (RFC 038 D1). Every predicate change
17
19
  * schedules a debounced `POST /organize/preview` and, until it lands, reports
18
20
  * the last count as stale so the count never blanks and the commit gate can
19
- * hold apply. Seeded with the widen probe's count, so opening the editor shows a
21
+ * hold apply. Seeded with a count already known, so opening the editor shows a
20
22
  * settled number without an immediate re-fetch.
21
23
  *
22
24
  * Out-of-order responses are dropped: only the reply to the most recent request
@@ -24,9 +26,9 @@ import type { OrganizeMatchPredicate } from "@/lib/organize/sender-fallback";
24
26
  * server bounds the match, so a broad predicate over a large mailbox stays a
25
27
  * single cheap request; the debounce is what keeps rapid edits from spamming it.
26
28
  *
27
- * `seedCount` is the opening count a widen probe already knows; omit it (as the
28
- * settings editor does, having no probe) to open on `loading` and preview the
29
- * loaded predicate immediately.
29
+ * `seedCount` is an opening count already known; omit it (as the settings
30
+ * editor does) to open on `loading` and preview the loaded predicate
31
+ * immediately.
30
32
  *
31
33
  * `matchedIds` are the ids that count was counted over — the server's own
32
34
  * answer, bounded to the same cap the apply pass uses. A caller applying an
@@ -78,7 +80,6 @@ export const useRulePreview = (
78
80
  setState({
79
81
  count: response.matchedCount,
80
82
  matchedIds: response.messageIds,
81
- indexEmpty: response.semanticIndexEmpty === true,
82
83
  previewedSignature: signature,
83
84
  });
84
85
  })
@@ -8,9 +8,8 @@ import { useBrowsedList } from "@/routing";
8
8
  *
9
9
  * `in:` is recognized only where the route carries no scope. On a scoped view
10
10
  * the top bar's chip already answers "which mailbox", and a typed `in:` is a
11
- * second, competing answer that no engine there acts on — the literal search is
12
- * pinned to the route's mailbox and the semantic request takes the caller's
13
- * scope. Leaving the term unresolved means it stays ordinary words: it is
11
+ * second, competing answer that no engine there acts on — the search is pinned
12
+ * to the route's mailbox. Leaving the term unresolved means it stays ordinary words: it is
14
13
  * matched as text and never chipped, so the field can't advertise a filter that
15
14
  * does nothing. To search another folder, drop the scope chip or go to it.
16
15
  *
@@ -25,12 +25,6 @@ import {
25
25
 
26
26
  const NOW = 1_700_000_000_000;
27
27
 
28
- describe("STALENESS_THRESHOLD_MS", () => {
29
- test("equals 15 minutes in milliseconds", () => {
30
- assert.equal(STALENESS_THRESHOLD_MS, 15 * 60 * 1000);
31
- });
32
- });
33
-
34
28
  describe("selectStaleAccountIds", () => {
35
29
  test("treats accounts with undefined lastSyncAt as stale", () => {
36
30
  const ids = selectStaleAccountIds(
@@ -92,10 +86,6 @@ describe("selectStaleAccountIds", () => {
92
86
  assert.deepEqual(ids, ["a-1"]);
93
87
  });
94
88
 
95
- test("returns an empty list for an empty input", () => {
96
- assert.deepEqual(selectStaleAccountIds([], NOW), []);
97
- });
98
-
99
89
  test("equality on the threshold is treated as fresh", () => {
100
90
  // `now - lastSyncAt > thresholdMs` — strict greater-than means an
101
91
  // account synced exactly threshold ms ago is still fresh. Pinning
@@ -108,13 +98,6 @@ describe("selectStaleAccountIds", () => {
108
98
  });
109
99
  });
110
100
 
111
- describe("POLL_INTERVAL_MS", () => {
112
- // How stale an open tab's mail may be with nobody touching anything.
113
- test("defaults to 30 seconds when mailboxPollIntervalSeconds is unset in this test run", () => {
114
- assert.equal(POLL_INTERVAL_MS, 30 * 1000);
115
- });
116
- });
117
-
118
101
  describe("resolvePollIntervalMs", () => {
119
102
  // This poll shares POST /sync with the refresh control, and that endpoint's
120
103
  // triggers skip the server's per-mailbox freshness gate
@@ -96,16 +96,6 @@ function renderHook(
96
96
  return captured;
97
97
  }
98
98
 
99
- describe("starredThreadsQueryKey", () => {
100
- test("asks for the starred listing, newest first", () => {
101
- const [params] = starredThreadsQueryKey() as [
102
- { query?: { starred?: boolean; order?: string } },
103
- ];
104
- assert.equal(params.query?.starred, true);
105
- assert.equal(params.query?.order, "desc");
106
- });
107
- });
108
-
109
99
  describe("useStarredThreads", () => {
110
100
  test("returns no threads when the listing is empty", () => {
111
101
  assert.deepEqual(renderHook([{ items: [] }]), []);
@@ -15,10 +15,4 @@ describe("buildMailboxListKey", () => {
15
15
  });
16
16
  assert.deepEqual(got, want);
17
17
  });
18
-
19
- test("returns a different key for a different accountId", () => {
20
- const a = buildMailboxListKey("acc-1");
21
- const b = buildMailboxListKey("acc-2");
22
- assert.notDeepEqual(a, b);
23
- });
24
18
  });
@@ -0,0 +1,63 @@
1
+ import assert from "node:assert/strict";
2
+ import { afterEach, describe, it } from "node:test";
3
+ import { createElement } from "react";
4
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
5
+ import { useVisualViewport } from "./useVisualViewport";
6
+
7
+ let harness: DomHarness | undefined;
8
+
9
+ function KeyboardProbe() {
10
+ const { isKeyboardOpen } = useVisualViewport();
11
+ return createElement("p", null, isKeyboardOpen ? "open" : "closed");
12
+ }
13
+
14
+ const mount = (): {
15
+ dom: DomHarness;
16
+ fullHeight: number;
17
+ resize: (height: number) => void;
18
+ } => {
19
+ const fullHeight = window.innerHeight;
20
+ const viewport = Object.assign(new window.EventTarget(), {
21
+ height: fullHeight,
22
+ });
23
+ Object.defineProperty(window, "visualViewport", {
24
+ configurable: true,
25
+ value: viewport,
26
+ });
27
+ const dom = createDomHarness();
28
+ harness = dom;
29
+ dom.render(createElement(KeyboardProbe));
30
+ return {
31
+ dom,
32
+ fullHeight,
33
+ resize: (height) => {
34
+ viewport.height = height;
35
+ dom.dispatch(viewport, new window.Event("resize"));
36
+ },
37
+ };
38
+ };
39
+
40
+ afterEach(() => {
41
+ harness?.close();
42
+ harness = undefined;
43
+ Reflect.deleteProperty(window, "visualViewport");
44
+ });
45
+
46
+ describe("the software keyboard", () => {
47
+ it("reads as open once the visual viewport shrinks past the threshold, and closed again when it restores", () => {
48
+ const { dom, fullHeight, resize } = mount();
49
+ assert.equal(dom.text(), "closed");
50
+
51
+ resize(fullHeight - 400);
52
+ assert.equal(dom.text(), "open");
53
+
54
+ resize(fullHeight);
55
+ assert.equal(dom.text(), "closed");
56
+ });
57
+
58
+ it("stays closed when only the address bar collapses", () => {
59
+ const { dom, fullHeight, resize } = mount();
60
+ resize(fullHeight - 50);
61
+ assert.equal(dom.text(), "closed");
62
+ });
63
+ });