@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
@@ -1,63 +0,0 @@
1
- import assert from "node:assert";
2
- import { describe, test } from "node:test";
3
- import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
4
- import { removeMovedMessagesFromItems } from "./useMoveMessages.js";
5
-
6
- const make = (
7
- overrides: Partial<RemitImapThreadMessageResponse> & {
8
- messageId: string;
9
- threadMessageId: string;
10
- },
11
- ): RemitImapThreadMessageResponse =>
12
- ({
13
- threadId: "t1",
14
- mailboxId: "mb1",
15
- accountConfigId: "acc-1",
16
- subject: "s",
17
- fromName: "n",
18
- fromEmail: "e",
19
- sentDate: "2025-01-01T00:00:00Z",
20
- snippet: "",
21
- hasAttachment: false,
22
- hasStars: false,
23
- isRead: true,
24
- ...overrides,
25
- }) as RemitImapThreadMessageResponse;
26
-
27
- describe("removeMovedMessagesFromItems (#236)", () => {
28
- test("drops items whose messageId is in the set", () => {
29
- const items = [
30
- make({ messageId: "m1", threadMessageId: "tm1" }),
31
- make({ messageId: "m2", threadMessageId: "tm2" }),
32
- make({ messageId: "m3", threadMessageId: "tm3" }),
33
- ];
34
- const got = removeMovedMessagesFromItems(items, new Set(["m2"]));
35
- assert.deepStrictEqual(
36
- got.map((i) => i.messageId),
37
- ["m1", "m3"],
38
- );
39
- });
40
-
41
- test("returns the input unchanged when nothing matches", () => {
42
- const items = [
43
- make({ messageId: "m1", threadMessageId: "tm1" }),
44
- make({ messageId: "m2", threadMessageId: "tm2" }),
45
- ];
46
- const got = removeMovedMessagesFromItems(items, new Set(["nope"]));
47
- assert.equal(got.length, 2);
48
- });
49
-
50
- test("returns an empty array when all items match", () => {
51
- const items = [
52
- make({ messageId: "m1", threadMessageId: "tm1" }),
53
- make({ messageId: "m2", threadMessageId: "tm2" }),
54
- ];
55
- const got = removeMovedMessagesFromItems(items, new Set(["m1", "m2"]));
56
- assert.deepStrictEqual(got, []);
57
- });
58
-
59
- test("returns an empty array for empty input", () => {
60
- const got = removeMovedMessagesFromItems([], new Set(["m1"]));
61
- assert.deepStrictEqual(got, []);
62
- });
63
- });
@@ -1,217 +0,0 @@
1
- /**
2
- * The widen step's two-phase preview. On a semantic-capable deployment it runs
3
- * the anchor preview once and stops. On a deployment that reports
4
- * `semanticUnavailable` (no vector pipeline — semantic-capability.ts) it
5
- * re-previews with what the whole selection agrees on (#458) — one `From` or
6
- * `FromDomain` clause when senders agree, the shared subject when they don't,
7
- * combined with `Or`, no anchor — and reports the literal match count, so
8
- * every commit scope acts on the same set the fallback derived.
9
- */
10
-
11
- import assert from "node:assert/strict";
12
- import { afterEach, describe, it } from "node:test";
13
- import { createElement, useEffect } from "react";
14
- import { createDomHarness, type DomHarness } from "../test-support/dom";
15
- import { type HttpMock, mockFetch } from "../test-support/http";
16
- import { useOrganizeWiden } from "./useOrganizeWiden";
17
-
18
- let harness: DomHarness | undefined;
19
- let http: HttpMock | undefined;
20
-
21
- afterEach(() => {
22
- harness?.close();
23
- harness = undefined;
24
- http?.restore();
25
- http = undefined;
26
- });
27
-
28
- type Widen = ReturnType<typeof useOrganizeWiden>;
29
- type Responder = Parameters<typeof mockFetch>[0];
30
-
31
- const PREVIEW_PATH = "/organize/preview";
32
-
33
- const mount = (
34
- senders: string[],
35
- responder: Responder,
36
- subjects: string[] = [],
37
- ) => {
38
- http = mockFetch(responder);
39
- harness = createDomHarness();
40
- const holder: { current: Widen | undefined } = { current: undefined };
41
- function Probe() {
42
- const widen = useOrganizeWiden("acc-1", "msg-1", senders, subjects);
43
- holder.current = widen;
44
- const { preview } = widen;
45
- useEffect(() => {
46
- preview();
47
- }, [preview]);
48
- return null;
49
- }
50
- harness.renderApp(createElement(Probe));
51
- return holder;
52
- };
53
-
54
- async function settle(minCalls: number): Promise<void> {
55
- const dom = harness;
56
- const mock = http;
57
- if (!dom || !mock) throw new Error("not mounted");
58
- for (let attempt = 0; attempt < 40; attempt += 1) {
59
- await dom.flush();
60
- if (mock.to(PREVIEW_PATH).length >= minCalls) {
61
- await dom.flush();
62
- await dom.wait(1);
63
- await dom.flush();
64
- return;
65
- }
66
- await dom.wait(1);
67
- }
68
- }
69
-
70
- describe("useOrganizeWiden — semantic-capable deployment", () => {
71
- it("previews the anchor once and reports the semantic match, no fallback", async () => {
72
- const holder = mount([], () => ({
73
- matchedCount: 42,
74
- messageIds: ["a", "b"],
75
- semanticUnavailable: false,
76
- }));
77
- await settle(1);
78
-
79
- assert.equal(http?.to(PREVIEW_PATH).length, 1);
80
- const [call] = http?.to(PREVIEW_PATH) ?? [];
81
- assert.equal(call.body?.anchorMessageId, "msg-1");
82
- assert.deepEqual(call.body?.literalClauses, []);
83
-
84
- const widen = holder.current;
85
- assert.equal(widen?.matchedCount, 42);
86
- assert.equal(widen?.semanticUnavailable, false);
87
- assert.equal(widen?.senderFallback, false);
88
- assert.deepEqual(widen?.matchPredicate, {
89
- anchorMessageId: "msg-1",
90
- matchOperator: "And",
91
- literalClauses: [],
92
- });
93
- });
94
- });
95
-
96
- describe("useOrganizeWiden — no vector pipeline, senders present", () => {
97
- it("re-previews with distinct sender From clauses combined with Or and no anchor", async () => {
98
- const holder = mount(
99
- ["npm@github.com", "npm@github.com", "a@x.com"],
100
- (call) =>
101
- call.body?.anchorMessageId
102
- ? { matchedCount: 0, messageIds: [], semanticUnavailable: true }
103
- : {
104
- matchedCount: 128,
105
- messageIds: ["m"],
106
- semanticUnavailable: false,
107
- },
108
- );
109
- await settle(2);
110
-
111
- const calls = http?.to(PREVIEW_PATH) ?? [];
112
- assert.equal(calls.length, 2);
113
- // The second preview is the literal fallback: no anchor, Or, distinct
114
- // sender From clauses.
115
- const second = calls[1];
116
- assert.equal(second.body?.anchorMessageId, undefined);
117
- assert.equal(second.body?.matchOperator, "Or");
118
- assert.deepEqual(second.body?.literalClauses, [
119
- { field: "From", value: "npm@github.com" },
120
- { field: "From", value: "a@x.com" },
121
- ]);
122
-
123
- const widen = holder.current;
124
- assert.equal(widen?.matchedCount, 128);
125
- assert.equal(widen?.semanticUnavailable, true);
126
- assert.equal(widen?.senderFallback, true);
127
- assert.deepEqual(widen?.senders, ["npm@github.com", "a@x.com"]);
128
- assert.deepEqual(widen?.matchPredicate, {
129
- matchOperator: "Or",
130
- literalClauses: [
131
- { field: "From", value: "npm@github.com" },
132
- { field: "From", value: "a@x.com" },
133
- ],
134
- });
135
- });
136
- });
137
-
138
- describe("useOrganizeWiden — no vector pipeline, mixed senders sharing a subject", () => {
139
- it("re-previews with the shared subject rather than one From clause per sender (#458)", async () => {
140
- const holder = mount(
141
- ["billing@acme.test", "accounts@globex.test"],
142
- (call) =>
143
- call.body?.anchorMessageId
144
- ? { matchedCount: 0, messageIds: [], semanticUnavailable: true }
145
- : {
146
- matchedCount: 64,
147
- messageIds: ["m"],
148
- semanticUnavailable: false,
149
- },
150
- ["Invoice 1841", "Invoice 1902"],
151
- );
152
- await settle(2);
153
-
154
- const calls = http?.to(PREVIEW_PATH) ?? [];
155
- assert.equal(calls.length, 2);
156
- const second = calls[1];
157
- assert.equal(second.body?.anchorMessageId, undefined);
158
- assert.equal(second.body?.matchOperator, "Or");
159
- assert.deepEqual(second.body?.literalClauses, [
160
- { field: "Subject", value: "Invoice" },
161
- ]);
162
-
163
- const widen = holder.current;
164
- assert.deepEqual(widen?.matchPredicate, {
165
- matchOperator: "Or",
166
- literalClauses: [{ field: "Subject", value: "Invoice" }],
167
- });
168
- });
169
- });
170
-
171
- describe("useOrganizeWiden — no vector pipeline, no senders", () => {
172
- it("does not re-preview and stays capability-absent with the anchor predicate", async () => {
173
- const holder = mount([], () => ({
174
- matchedCount: 0,
175
- messageIds: [],
176
- semanticUnavailable: true,
177
- }));
178
- await settle(1);
179
- // Give any spurious second preview a chance to fire before asserting none did.
180
- await harness?.flush();
181
-
182
- assert.equal(http?.to(PREVIEW_PATH).length, 1);
183
- const widen = holder.current;
184
- assert.equal(widen?.semanticUnavailable, true);
185
- assert.equal(widen?.senderFallback, false);
186
- assert.deepEqual(widen?.senders, []);
187
- assert.equal(widen?.matchPredicate.anchorMessageId, "msg-1");
188
- });
189
-
190
- it("still re-previews on a shared subject alone (#458)", async () => {
191
- const holder = mount(
192
- [],
193
- (call) =>
194
- call.body?.anchorMessageId
195
- ? { matchedCount: 0, messageIds: [], semanticUnavailable: true }
196
- : {
197
- matchedCount: 12,
198
- messageIds: ["m"],
199
- semanticUnavailable: false,
200
- },
201
- ["Invoice 1841", "Invoice 1902"],
202
- );
203
- await settle(2);
204
-
205
- const calls = http?.to(PREVIEW_PATH) ?? [];
206
- assert.equal(calls.length, 2);
207
- const second = calls[1];
208
- assert.equal(second.body?.anchorMessageId, undefined);
209
- assert.deepEqual(second.body?.literalClauses, [
210
- { field: "Subject", value: "Invoice" },
211
- ]);
212
-
213
- const widen = holder.current;
214
- assert.equal(widen?.semanticUnavailable, true);
215
- assert.equal(widen?.senderFallback, true);
216
- });
217
- });
@@ -1,112 +0,0 @@
1
- import { organizeOperationsPreviewOrganizeMutation } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import { derivePropertyClauses, distinctSenders } from "@remit/ui";
3
- import { useMutation } from "@tanstack/react-query";
4
- import { useCallback, useEffect, useMemo, useState } from "react";
5
- import { buildOrganizeInput } from "@/lib/organize/organize-model";
6
- import {
7
- buildSenderFallbackDraft,
8
- type OrganizeMatchPredicate,
9
- } from "@/lib/organize/sender-fallback";
10
-
11
- /**
12
- * "Select similar messages" (the widen step) with a selection-agreement
13
- * fallback for deployments without the vector pipeline.
14
- *
15
- * The first preview runs the semantic anchor (POST /organize/preview). When the
16
- * server reports `semanticUnavailable` — no vector pipeline on this deployment
17
- * (semantic-capability.ts) — the widen re-previews with the literal vocabulary
18
- * that matches vector-free: what the whole selection agrees on (#458) — one
19
- * sender/domain clause, or the subject the selection shares when senders don't
20
- * — combined with `Or`, no anchor. The counts shown, the one-time back-apply,
21
- * and any standing filter then all carry that same literal predicate, so the
22
- * previewed set equals the set every commit scope acts on.
23
- *
24
- * A semantic-capable deployment never fires the second preview and keeps exactly
25
- * the anchor behaviour.
26
- */
27
- export const useOrganizeWiden = (
28
- accountId: string | undefined,
29
- anchorMessageId: string | undefined,
30
- senders: readonly string[],
31
- subjects: readonly string[],
32
- ) => {
33
- const mutation = useMutation(organizeOperationsPreviewOrganizeMutation());
34
- const { mutate, reset: mutationReset } = mutation;
35
- const [fellBack, setFellBack] = useState(false);
36
-
37
- const distinct = useMemo(() => distinctSenders(senders), [senders]);
38
- const agreementClauses = useMemo(
39
- () => derivePropertyClauses(distinct, subjects),
40
- [distinct, subjects],
41
- );
42
-
43
- const preview = useCallback(() => {
44
- if (!accountId || !anchorMessageId) return;
45
- setFellBack(false);
46
- mutate({
47
- path: { accountId },
48
- body: buildOrganizeInput({
49
- anchorMessageId,
50
- matchOperator: "And",
51
- literalClauses: [],
52
- }),
53
- });
54
- }, [accountId, anchorMessageId, mutate]);
55
-
56
- const reset = useCallback(() => {
57
- setFellBack(false);
58
- mutationReset();
59
- }, [mutationReset]);
60
-
61
- const data = mutation.data;
62
- // The first preview came back capability-absent and the selection agrees on
63
- // something to match on — a sender, a domain, or just a shared subject, not
64
- // senders alone (#458): re-preview with the literal clauses before showing
65
- // any count, so the count the user sees is the agreement's match count,
66
- // never a flash of the empty semantic result.
67
- const willFallBack =
68
- data?.semanticUnavailable === true &&
69
- !fellBack &&
70
- agreementClauses.length > 0;
71
-
72
- useEffect(() => {
73
- if (!willFallBack || !accountId) return;
74
- setFellBack(true);
75
- mutate({
76
- path: { accountId },
77
- body: buildOrganizeInput(buildSenderFallbackDraft(distinct, subjects)),
78
- });
79
- }, [willFallBack, accountId, distinct, subjects, mutate]);
80
-
81
- // The literal re-preview has no anchor, so its response reports
82
- // `semanticUnavailable: false`; `fellBack` is what remembers the capability
83
- // was absent once the fallback has run.
84
- const capabilityAbsent = fellBack || (data?.semanticUnavailable ?? false);
85
- const isPending = mutation.isPending || willFallBack;
86
-
87
- const matchPredicate: OrganizeMatchPredicate = fellBack
88
- ? { matchOperator: "Or", literalClauses: agreementClauses }
89
- : {
90
- ...(anchorMessageId ? { anchorMessageId } : {}),
91
- matchOperator: "And",
92
- literalClauses: [],
93
- };
94
-
95
- return {
96
- preview,
97
- reset,
98
- matchedCount: isPending ? undefined : data?.matchedCount,
99
- messageIds: isPending ? undefined : data?.messageIds,
100
- // The deployment ships no vector pipeline — the widen matched on senders
101
- // (or, with no senders, on nothing) rather than semantic similarity.
102
- semanticUnavailable: capabilityAbsent,
103
- // The widen fell back to sender matching and produced a usable literal
104
- // match set: the commit scopes reach it, and the copy names the senders.
105
- senderFallback: fellBack,
106
- senders: distinct,
107
- matchPredicate,
108
- isPending,
109
- isError: mutation.isError,
110
- error: mutation.error,
111
- };
112
- };
@@ -1,132 +0,0 @@
1
- import { semanticSearchOperationsSemanticSearchOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import type { RemitImapSemanticSearchResult } from "@remit/api-http-client/types.gen.ts";
3
- import { MessageCategory } from "@remit/domain-enums";
4
- import { useQuery } from "@tanstack/react-query";
5
- import { useMailContext } from "@/lib/mail-context";
6
- import { normalizeSearchQuery } from "@/lib/search-query";
7
- import { semanticMailboxScope } from "@/lib/search-scope";
8
- import { parseSearchTokens, type SearchToken } from "@/lib/search-tokens";
9
- import { useBrowsedList } from "@/routing";
10
- import { useSearchTokenContext } from "./useSearchTokenContext";
11
-
12
- /** Cap the "Related" section; the literal "Top matches" is the primary surface. */
13
- const SEMANTIC_RESULT_LIMIT = 20;
14
-
15
- const MESSAGE_CATEGORIES = new Set<string>(Object.values(MessageCategory));
16
-
17
- /** Narrow a filter-chip id to a real category, or `undefined` for `"all"` / any unknown value. */
18
- const toCategoryParam = (
19
- filterCategory: string | undefined,
20
- ): (typeof MessageCategory)[keyof typeof MessageCategory] | undefined =>
21
- filterCategory !== undefined && MESSAGE_CATEGORIES.has(filterCategory)
22
- ? (filterCategory as (typeof MessageCategory)[keyof typeof MessageCategory])
23
- : undefined;
24
-
25
- /**
26
- * `isRead` for the read-state tokens. `is:unread` and `is:read` are opposites,
27
- * so a query carrying both asks for nothing; unread wins, being the narrower
28
- * ask and the one the chips also offer.
29
- */
30
- const readStateParam = (tokens: SearchToken[]): boolean | undefined => {
31
- if (tokens.some((t) => t.type === "isUnread")) return false;
32
- if (tokens.some((t) => t.type === "isRead")) return true;
33
- return undefined;
34
- };
35
-
36
- interface UseSemanticSearchParams {
37
- /**
38
- * Restrict results to a single mailbox. A mailbox route pins the scope to its
39
- * own mailbox regardless of what is passed here, so this only carries a scope
40
- * a route does not already imply.
41
- */
42
- mailboxId?: string;
43
- /**
44
- * Restrict results to a single header-derived category. The sentinel `"all"`
45
- * means no category scope — the param is omitted from the request entirely.
46
- */
47
- filterCategory?: string;
48
- }
49
-
50
- /**
51
- * Fetch semantic-search hits for the active query (one source of truth: the
52
- * debounced `searchQuery` in `MailContext`). Scoping mirrors the literal search
53
- * and is taken from the route (`semanticMailboxScope`): no chip means global, a
54
- * chip means this engine respects it like every other. Disabled until the query
55
- * is non-empty so an empty field issues no request.
56
- *
57
- * Filter tokens (`has:attachment`, `is:unread`/`is:read`, `is:starred`,
58
- * `category:`, `before:`/`after:`) parsed from the query map onto the search
59
- * API's own filter params. `from:`, `subject:` and `account:` have no
60
- * equivalent on `GET /search/semantic` (no sender, subject or
61
- * account filter) — all still render as chips and narrow the literal engine,
62
- * but never reach the semantic request (`account:` is a documented gap, see
63
- * doc/design/flows/06-search.md — the semantic index is per account config).
64
- * `in:` resolves to a mailboxId, so typing `in:archive` re-scopes the search
65
- * from the unscoped daily brief. It cannot contradict a scoped view because
66
- * `useSearchTokenContext` does not resolve the term there at all — the term
67
- * stays free text and no engine, and no chip, treats it as a filter.
68
- *
69
- * With no resolved mailbox the request spans every mailbox of every account the
70
- * caller owns: the backend partitions the vector index by accountConfigId (one
71
- * per signed-in user, not per mail account), so unscoped is genuinely global.
72
- */
73
- export function useSemanticSearch({
74
- mailboxId,
75
- filterCategory,
76
- }: UseSemanticSearchParams = {}): {
77
- hits: RemitImapSemanticSearchResult[];
78
- isLoading: boolean;
79
- } {
80
- const { searchQuery } = useMailContext();
81
- const tokenContext = useSearchTokenContext();
82
- const browsed = useBrowsedList();
83
- const normalizedQuery = normalizeSearchQuery(searchQuery);
84
- const { freeText, tokens } = parseSearchTokens(normalizedQuery, tokenContext);
85
- // `freeText`, not the raw query: a query of nothing but tokens
86
- // (`has:attachment`, `in:archive`) parses to empty free text, and asking a
87
- // vector index what an empty string means has no answer to give. The literal
88
- // engines still apply those tokens; the semantic section simply has nothing
89
- // to rank.
90
- const enabled = freeText.length > 0;
91
-
92
- // The chip wins over the token: it is the visible narrowing, and the two can
93
- // only disagree when the user set both.
94
- const categoryToken = tokens.find((t) => t.type === "category");
95
- const category = toCategoryParam(filterCategory) ?? categoryToken?.category;
96
- const hasAttachment = tokens.some((t) => t.type === "hasAttachment")
97
- ? true
98
- : undefined;
99
- const hasStars = tokens.some((t) => t.type === "isStarred")
100
- ? true
101
- : undefined;
102
- const isRead = readStateParam(tokens);
103
- const afterToken = tokens.find((t) => t.type === "after");
104
- const beforeToken = tokens.find((t) => t.type === "before");
105
- const inToken = tokens.find((t) => t.type === "in");
106
- // The route decides the scope, not the call site — see `semanticMailboxScope`.
107
- const effectiveMailboxId = semanticMailboxScope({
108
- browsed,
109
- callerMailboxId: mailboxId,
110
- inTokenMailboxId: inToken?.mailboxId,
111
- });
112
-
113
- const { data, isLoading } = useQuery({
114
- ...semanticSearchOperationsSemanticSearchOptions({
115
- query: {
116
- query: freeText,
117
- mailboxId: effectiveMailboxId,
118
- limit: SEMANTIC_RESULT_LIMIT,
119
- ...(category !== undefined ? { category } : {}),
120
- ...(hasAttachment !== undefined ? { hasAttachment } : {}),
121
- ...(hasStars !== undefined ? { hasStars } : {}),
122
- ...(isRead !== undefined ? { isRead } : {}),
123
- ...(afterToken ? { sentDateFrom: afterToken.epochSeconds } : {}),
124
- ...(beforeToken ? { sentDateTo: beforeToken.epochSeconds } : {}),
125
- },
126
- }),
127
- enabled,
128
- staleTime: 60_000,
129
- });
130
-
131
- return { hits: data?.items ?? [], isLoading: enabled && isLoading };
132
- }
@@ -1,23 +0,0 @@
1
- import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import { useQuery } from "@tanstack/react-query";
3
-
4
- /**
5
- * Whether this instance stores message vectors (#1068).
6
- *
7
- * A property of the deployment, so it rides the config read every surface
8
- * already makes rather than a probe of its own: the semantic surfaces need the
9
- * answer before they have a query to send, and an empty semantic result is
10
- * indistinguishable from a mailbox with nothing similar in it.
11
- *
12
- * `undefined` while the config is in flight, and for a server that predates the
13
- * field. Callers must therefore branch on `=== false`, so an instance whose
14
- * answer has not arrived shows what it showed before rather than an off state
15
- * it may not be in.
16
- */
17
- export const useSemanticSearchEnabled = (): boolean | undefined => {
18
- const { data } = useQuery({
19
- ...configOperationsGetConfigOptions(),
20
- staleTime: Infinity,
21
- });
22
- return data?.semanticSearchEnabled;
23
- };
@@ -1,126 +0,0 @@
1
- import assert from "node:assert";
2
- import { beforeEach, describe, test } from "node:test";
3
-
4
- /**
5
- * Unit test for useVisualViewport hook logic.
6
- * Tests the core behaviour without React.
7
- */
8
-
9
- const KEYBOARD_THRESHOLD = 150;
10
-
11
- interface ViewportState {
12
- viewportHeight: number;
13
- isKeyboardOpen: boolean;
14
- }
15
-
16
- /**
17
- * Simulates the viewport-tracking logic from useVisualViewport.
18
- */
19
- class VisualViewportSimulator {
20
- private innerHeight: number;
21
- private viewportHeight: number;
22
- private listeners: Array<() => void> = [];
23
-
24
- constructor(innerHeight: number) {
25
- this.innerHeight = innerHeight;
26
- this.viewportHeight = innerHeight;
27
- }
28
-
29
- getState(): ViewportState {
30
- return {
31
- viewportHeight: this.viewportHeight,
32
- isKeyboardOpen:
33
- this.innerHeight - this.viewportHeight > KEYBOARD_THRESHOLD,
34
- };
35
- }
36
-
37
- /** Simulate a visualViewport resize (e.g. keyboard appearing). */
38
- resize(newViewportHeight: number): ViewportState {
39
- this.viewportHeight = newViewportHeight;
40
- for (const fn of this.listeners) fn();
41
- return this.getState();
42
- }
43
-
44
- addListener(fn: () => void): void {
45
- this.listeners.push(fn);
46
- }
47
-
48
- removeListener(fn: () => void): void {
49
- this.listeners = this.listeners.filter((l) => l !== fn);
50
- }
51
-
52
- get listenerCount(): number {
53
- return this.listeners.length;
54
- }
55
- }
56
-
57
- describe("useVisualViewport logic", () => {
58
- let sim: VisualViewportSimulator;
59
-
60
- beforeEach(() => {
61
- // iPhone-like viewport: 844px tall
62
- sim = new VisualViewportSimulator(844);
63
- });
64
-
65
- test("initial state has keyboard closed", () => {
66
- const state = sim.getState();
67
- assert.strictEqual(state.viewportHeight, 844);
68
- assert.strictEqual(state.isKeyboardOpen, false);
69
- });
70
-
71
- test("keyboard open when viewport shrinks by more than threshold", () => {
72
- // Typical iOS keyboard: viewport shrinks to ~400px
73
- const state = sim.resize(400);
74
- assert.strictEqual(state.isKeyboardOpen, true);
75
- assert.strictEqual(state.viewportHeight, 400);
76
- });
77
-
78
- test("keyboard stays closed for small viewport changes", () => {
79
- // Address bar collapsing — only ~50px change
80
- const state = sim.resize(794);
81
- assert.strictEqual(state.isKeyboardOpen, false);
82
- });
83
-
84
- test("keyboard closes when viewport restores", () => {
85
- sim.resize(400);
86
- const state = sim.resize(844);
87
- assert.strictEqual(state.isKeyboardOpen, false);
88
- assert.strictEqual(state.viewportHeight, 844);
89
- });
90
-
91
- test("exactly at threshold boundary — not open", () => {
92
- // innerHeight - viewportHeight === 150, not > 150
93
- const state = sim.resize(844 - KEYBOARD_THRESHOLD);
94
- assert.strictEqual(state.isKeyboardOpen, false);
95
- });
96
-
97
- test("one pixel past threshold — keyboard open", () => {
98
- const state = sim.resize(844 - KEYBOARD_THRESHOLD - 1);
99
- assert.strictEqual(state.isKeyboardOpen, true);
100
- });
101
-
102
- test("listeners are called on resize", () => {
103
- let callCount = 0;
104
- sim.addListener(() => {
105
- callCount++;
106
- });
107
- sim.resize(400);
108
- sim.resize(844);
109
- assert.strictEqual(callCount, 2);
110
- });
111
-
112
- test("removeListener stops notifications", () => {
113
- let callCount = 0;
114
- const fn = () => {
115
- callCount++;
116
- };
117
- sim.addListener(fn);
118
- sim.resize(400);
119
- assert.strictEqual(callCount, 1);
120
-
121
- sim.removeListener(fn);
122
- sim.resize(844);
123
- assert.strictEqual(callCount, 1);
124
- assert.strictEqual(sim.listenerCount, 0);
125
- });
126
- });