@remit/web-client 0.0.30 → 0.0.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.30",
3
+ "version": "0.0.32",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -174,7 +174,7 @@ export function DailyBrief({
174
174
  onSelectMessage,
175
175
  onSelectSearchResult,
176
176
  }: DailyBriefProps) {
177
- const { searchQuery } = useMailContext();
177
+ const { searchQuery, resultFolderIndex } = useMailContext();
178
178
  const tokenContext = useSearchTokenContext();
179
179
  const isDesktop = useIsDesktop();
180
180
 
@@ -346,8 +346,8 @@ export function DailyBrief({
346
346
  (selectedCategory === "all" || t.category === selectedCategory) &&
347
347
  predicates.every((p) => p(t)),
348
348
  )
349
- .map(rowToSearchResult);
350
- }, [filteredRows, selectedCategory, searchAttributes]);
349
+ .map((row) => rowToSearchResult(row, resultFolderIndex));
350
+ }, [filteredRows, selectedCategory, searchAttributes, resultFolderIndex]);
351
351
 
352
352
  // "Related" (semantic) spans every account here — the brief is the
353
353
  // cross-account view, so no mailbox scope. Dedupe against the literal "Top
@@ -362,8 +362,12 @@ export function DailyBrief({
362
362
  const literalThreadIds = searchResults
363
363
  .map((result) => threadByMessageId.get(result.id))
364
364
  .filter((id): id is string => id != null);
365
- return relatedSearchResults(semanticHits, literalThreadIds);
366
- }, [semanticHits, searchResults, threadsData]);
365
+ return relatedSearchResults(
366
+ semanticHits,
367
+ literalThreadIds,
368
+ resultFolderIndex,
369
+ );
370
+ }, [semanticHits, searchResults, threadsData, resultFolderIndex]);
367
371
 
368
372
  const searchFilterConfig = useMemo<Omit<FilterSheetProps, "children">>(() => {
369
373
  const preset = briefFilterConfig(
@@ -50,7 +50,7 @@ export function FlaggedList({
50
50
  selectedMessageId,
51
51
  onSelectMessage,
52
52
  }: FlaggedListProps) {
53
- const { searchQuery } = useMailContext();
53
+ const { searchQuery, resultFolderIndex } = useMailContext();
54
54
  const tokenContext = useSearchTokenContext();
55
55
  const isDesktop = useIsDesktop();
56
56
 
@@ -106,7 +106,10 @@ export function FlaggedList({
106
106
 
107
107
  const preset = useMemo(() => flaggedFilterConfig(), []);
108
108
 
109
- const searchResults = useMemo(() => rows.map(rowToSearchResult), [rows]);
109
+ const searchResults = useMemo(
110
+ () => rows.map((row) => rowToSearchResult(row, resultFolderIndex)),
111
+ [rows, resultFolderIndex],
112
+ );
110
113
 
111
114
  const unreadCount = useMemo(
112
115
  () => rows.filter((t) => !t.isRead).length,
@@ -21,6 +21,11 @@
21
21
  * drops out kit-side, so a semantic-only result still shows when the literal
22
22
  * search finds nothing. The hamburger opens the nav drawer via the enclosing
23
23
  * `AppShellSlotted`.
24
+ *
25
+ * This is also the one place the route's scope reaches the results list, so both
26
+ * tiers agree on it: a global search labels every row with the folder it came
27
+ * from and offers the spam it held out, and a scoped search does neither. See
28
+ * `resultsScopeForRoute` and `lib/spam-offer.ts`.
24
29
  */
25
30
  import {
26
31
  FilterSheet,
@@ -30,18 +35,23 @@ import {
30
35
  type SearchResult,
31
36
  type SearchResultSection,
32
37
  SearchResults,
38
+ type SearchScope,
33
39
  useAppShellLayout,
34
40
  } from "@remit/ui";
41
+ import { useNavigate, useRouterState } from "@tanstack/react-router";
35
42
  import { type ReactNode, useEffect, useRef, useState } from "react";
36
43
  import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
44
+ import { useSearchScope } from "@/hooks/useSearchScope";
37
45
  import { useSearchTokenContext } from "@/hooks/useSearchTokenContext";
38
46
  import { useMailContext } from "@/lib/mail-context";
39
47
  import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
48
+ import { resultsScopeForRoute, routeMailboxId } from "@/lib/search-scope";
40
49
  import {
41
50
  parseSearchTokens,
42
51
  removeSearchToken,
43
52
  searchTokenLabel,
44
53
  } from "@/lib/search-tokens";
54
+ import { spamOfferForResults } from "@/lib/spam-offer";
45
55
 
46
56
  interface MailListHeaderProps {
47
57
  title: string;
@@ -82,9 +92,17 @@ export function MailListHeader({
82
92
  searchResultsLabel = "Top matches",
83
93
  relatedResultsLabel = "Related",
84
94
  }: MailListHeaderProps) {
85
- const { searchInput, onSearchChange, onSearchClear, searchViewKey } =
86
- useMailContext();
95
+ const {
96
+ accounts,
97
+ resultFolderIndex,
98
+ searchQuery,
99
+ searchInput,
100
+ onSearchChange,
101
+ onSearchClear,
102
+ searchViewKey,
103
+ } = useMailContext();
87
104
  const tokenContext = useSearchTokenContext();
105
+ const navigate = useNavigate();
88
106
  const layout = useAppShellLayout();
89
107
  const tier = useLayoutTier();
90
108
  const [searchOpen, setSearchOpen] = useState(false);
@@ -130,6 +148,40 @@ export function MailListHeader({
130
148
  const resultsLoading =
131
149
  !hasAnyResult && (searchLoading === true || relatedLoading === true);
132
150
 
151
+ // The mailbox's appointed role is what lets a search scoped to Spam show its
152
+ // rows rather than drop them.
153
+ const { scope } = useSearchScope(accounts);
154
+ const matches = useRouterState({ select: (s) => s.matches });
155
+ const scopedMailboxId = routeMailboxId(matches);
156
+ const routeScope = resultsScopeForRoute(
157
+ matches,
158
+ scope,
159
+ scopedMailboxId ? resultFolderIndex.get(scopedMailboxId)?.role : undefined,
160
+ );
161
+
162
+ // The offer counts results for the *committed* query, so that is the query it
163
+ // carries into Spam. The count the banner states is the results list's own,
164
+ // over every row it held out; the app supplies only where "Go to Spam" goes.
165
+ const spamOffer =
166
+ routeScope.kind === "global"
167
+ ? spamOfferForResults([...topMatches, ...related])
168
+ : undefined;
169
+ const resultsScope: SearchScope = spamOffer
170
+ ? {
171
+ kind: "global",
172
+ onScopeToSpam: () =>
173
+ navigate({
174
+ to: "/mail/$mailboxId",
175
+ params: { mailboxId: spamOffer.mailboxId },
176
+ search: {
177
+ q: searchQuery || undefined,
178
+ selectedMessageId: undefined,
179
+ selectedThreadId: undefined,
180
+ },
181
+ }),
182
+ }
183
+ : routeScope;
184
+
133
185
  if (tier === "phone" && searchOpen) {
134
186
  const handleSelectResult = (result: SearchResult) => {
135
187
  setRecentSearches(saveRecentSearch(searchInput));
@@ -152,6 +204,7 @@ export function MailListHeader({
152
204
  loading={resultsLoading}
153
205
  onSelectResult={handleSelectResult}
154
206
  tokens={tokenChips}
207
+ scope={resultsScope}
155
208
  />
156
209
  );
157
210
  }
@@ -171,6 +224,7 @@ export function MailListHeader({
171
224
  loading={resultsLoading}
172
225
  onSelectResult={handleSelectInlineResult}
173
226
  tokens={tokenChips}
227
+ scope={resultsScope}
174
228
  />
175
229
  );
176
230
  const body = !showInlineResults ? (
@@ -947,7 +947,8 @@ function MailboxList() {
947
947
  onClearFilters,
948
948
  searchPredicate,
949
949
  } = useMailboxPane();
950
- const { searchQuery, searchInput, accounts } = useMailContext();
950
+ const { searchQuery, searchInput, accounts, resultFolderIndex } =
951
+ useMailContext();
951
952
  const tier = useLayoutTier();
952
953
  const navigate = useNavigate();
953
954
 
@@ -955,8 +956,9 @@ function MailboxList() {
955
956
  const preset = useMemo(() => inboxFilterConfig(), []);
956
957
 
957
958
  const searchResults = useMemo(
958
- () => threads.map(threadToSearchResult),
959
- [threads],
959
+ () =>
960
+ threads.map((thread) => threadToSearchResult(thread, resultFolderIndex)),
961
+ [threads, resultFolderIndex],
960
962
  );
961
963
  // The route scopes this view and the top bar's chip says so, so every engine
962
964
  // here respects it: the literal engine searches this mailbox, and the
@@ -972,8 +974,9 @@ function MailboxList() {
972
974
  relatedSearchResults(
973
975
  semanticHits,
974
976
  threads.map((t) => t.threadId),
977
+ resultFolderIndex,
975
978
  ),
976
- [semanticHits, threads],
979
+ [semanticHits, threads, resultFolderIndex],
977
980
  );
978
981
  const handleSelectSearchResult = useCallback(
979
982
  (result: SearchResult) =>
@@ -244,6 +244,7 @@ export const MessageList = ({
244
244
  setAnchor,
245
245
  selectAll,
246
246
  toggleAll,
247
+ intersectWith,
247
248
  } = useSelection();
248
249
 
249
250
  // Pending delete, awaiting confirmation. `null` means the dialog is closed.
@@ -829,28 +830,26 @@ export const MessageList = ({
829
830
  [],
830
831
  );
831
832
 
832
- // Clear selection when threads change (e.g., after delete). Skipped while
833
- // an escalated run is active: `selectedIds` there is a stale loaded-rows
834
- // snapshot from the moment escalation started (the real selection is the
835
- // predicate, D2), not something a background refetch reshuffling `threads`
836
- // should be allowed to blow away out from under a count or a delete in
837
- // progress that would silently exit selection mode mid-run.
833
+ // Narrow the selection when threads change (e.g., after delete), dropping
834
+ // only the ids that left and keeping every survivor K-9's
835
+ // `selected.intersect(uniqueIds)`, the reference behavior #92's D2 cites.
836
+ // Wiping the whole selection because one id left (#111) cost the other 49
837
+ // rows on an ordinary refresh, and could take the post-delete Retry
838
+ // selection with it: `processDeleteOutcome` materializes the failed ids as
839
+ // the new selection and resets this effect to live by returning escalation
840
+ // to idle, so the cache invalidation's refetch ran this same effect against
841
+ // the retry set — a clear here would have dropped the Retry notice
842
+ // (gated on `selectedCount > 0`) along with it.
843
+ // Skipped while an escalated run is active: `selectedIds` there is a stale
844
+ // loaded-rows snapshot from the moment escalation started (the real
845
+ // selection is the predicate, D2), not something a background refetch
846
+ // reshuffling `threads` should be allowed to narrow out from under a count
847
+ // or a delete in progress — that would silently exit selection mode
848
+ // mid-run.
838
849
  useEffect(() => {
839
850
  if (escalation.phase.kind !== "idle" || escalation.isDeleting) return;
840
- const threadIds = new Set(threads.map((t) => t.messageId));
841
- const hasOrphanedSelection = Array.from(selectedIds).some(
842
- (id) => !threadIds.has(id),
843
- );
844
- if (hasOrphanedSelection) {
845
- clearSelection();
846
- }
847
- }, [
848
- threads,
849
- selectedIds,
850
- clearSelection,
851
- escalation.phase,
852
- escalation.isDeleting,
853
- ]);
851
+ intersectWith(threads.map((t) => t.messageId));
852
+ }, [threads, intersectWith, escalation.phase, escalation.isDeleting]);
854
853
 
855
854
  // Load more when scrolling near the bottom
856
855
  useEffect(() => {
@@ -1210,6 +1209,14 @@ export const MessageList = ({
1210
1209
  : pendingDelete.total
1211
1210
  : 0;
1212
1211
 
1212
+ // The predicate case (#109): `pendingDelete.total` is `countMatches`'s
1213
+ // frozen page-through, and the delete itself re-pages the same predicate a
1214
+ // second, independent time. Mail arriving or leaving between the two can
1215
+ // make them differ, so the dialog says "about" instead of promising an
1216
+ // exact number it may not honour. A materialized (bounded) selection's
1217
+ // count is exact — it's the delete's own input, not an estimate of it.
1218
+ const pendingDeleteIsEstimate = pendingDelete?.source === "predicate";
1219
+
1213
1220
  return (
1214
1221
  <>
1215
1222
  {completionBanner && !isDesktop && (
@@ -1249,8 +1256,15 @@ export const MessageList = ({
1249
1256
  />
1250
1257
  <ConfirmDialog
1251
1258
  isOpen={pendingDelete !== null}
1252
- title={formatDeleteToTrashTitle(pendingDeleteCount)}
1253
- description="You can restore them from Trash later."
1259
+ title={formatDeleteToTrashTitle(
1260
+ pendingDeleteCount,
1261
+ pendingDeleteIsEstimate,
1262
+ )}
1263
+ description={
1264
+ pendingDeleteIsEstimate
1265
+ ? "This count is a snapshot — new mail arriving during the delete won't be included. You can restore what's deleted from Trash later."
1266
+ : "You can restore them from Trash later."
1267
+ }
1254
1268
  confirmLabel="Move to Trash"
1255
1269
  destructive
1256
1270
  isBusy={isDeleting}
@@ -34,6 +34,21 @@ export const OneMessage: Story = {
34
34
  },
35
35
  };
36
36
 
37
+ /**
38
+ * An escalated (search-predicate) delete: the count was paged once by
39
+ * `countMatches` and the delete re-pages the same predicate independently, so
40
+ * it is not provably the number that gets deleted (#109). "about" and the
41
+ * description say so up front rather than stating an exact number the run may
42
+ * not honour.
43
+ */
44
+ export const EscalatedEstimate: Story = {
45
+ args: {
46
+ title: "Move about 3,412 messages to Trash?",
47
+ description:
48
+ "This count is a snapshot — new mail arriving during the delete won't be included. You can restore what's deleted from Trash later.",
49
+ },
50
+ };
51
+
37
52
  /** The mutation is in flight: the confirm button disables rather than
38
53
  * allowing a second concurrent delete request. */
39
54
  export const Busy: Story = {
@@ -17,6 +17,7 @@ import {
17
17
  countMatches,
18
18
  type DeleteRunOutcome,
19
19
  type FetchIdsPage,
20
+ honestProgress,
20
21
  runChunkedDelete,
21
22
  runPredicateDelete,
22
23
  } from "@/lib/bulk-delete";
@@ -190,8 +191,11 @@ export const useEscalatedDelete = ({
190
191
  async (ids?: string[]): Promise<DeleteRunOutcome> => {
191
192
  cancelRef.current = false;
192
193
  setIsDeleting(true);
194
+ // `honestProgress` widens `total` if `done` overtakes it (#109) — the
195
+ // predicate can match more by the time the delete re-pages it than
196
+ // `countMatches` saw, and the bar must never show more done than out of.
193
197
  const onProgress = (progress: BulkDeleteProgress) =>
194
- setDeleteProgress(progress);
198
+ setDeleteProgress(honestProgress(progress));
195
199
 
196
200
  const outcome =
197
201
  ids !== undefined
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Fans out the per-account mailbox-list query (the same one `MailSidebarAdapter`
3
+ * and `useMailboxNameIndex` run — cached forever, react-query dedupes the
4
+ * identical key across call sites) and reduces it to the mailboxId → folder map
5
+ * search results resolve their provenance against. See `lib/result-folder.ts`.
6
+ */
7
+ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
+ import type {
9
+ RemitImapAccountResponse,
10
+ RemitImapMailboxResponse,
11
+ } from "@remit/api-http-client/types.gen.ts";
12
+ import { useQueries } from "@tanstack/react-query";
13
+ import { useMemo } from "react";
14
+ import {
15
+ buildResultFolderIndex,
16
+ type ResultFolderIndex,
17
+ } from "@/lib/result-folder";
18
+
19
+ type MailboxItems = RemitImapMailboxResponse[];
20
+
21
+ /**
22
+ * Hoisted, not inline: `useQueries` skips re-running `combine` when it is given
23
+ * the same function, so a stable reference is what makes this a memo rather than
24
+ * a per-render reduce. Without it the index — and every search-result memo keyed
25
+ * on it — rebuilds on every render.
26
+ */
27
+ const combineMailboxItems = (
28
+ results: { data?: { items: MailboxItems } }[],
29
+ ): MailboxItems[] => results.map((result) => result.data?.items ?? []);
30
+
31
+ export function useResultFolderIndex(
32
+ accounts: RemitImapAccountResponse[],
33
+ ): ResultFolderIndex {
34
+ const mailboxItems = useQueries({
35
+ queries: accounts.map((account) => ({
36
+ ...mailboxOperationsListMailboxesOptions({
37
+ path: { accountId: account.accountId },
38
+ }),
39
+ staleTime: Infinity,
40
+ })),
41
+ combine: combineMailboxItems,
42
+ });
43
+
44
+ return useMemo(
45
+ () =>
46
+ buildResultFolderIndex(
47
+ accounts.map((account, i) => ({
48
+ folderAppointments: account.folderAppointments,
49
+ mailboxes: mailboxItems[i] ?? [],
50
+ })),
51
+ ),
52
+ [accounts, mailboxItems],
53
+ );
54
+ }
@@ -1,6 +1,10 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
- import { computeRange, nextFocusId } from "./useSelection.js";
3
+ import {
4
+ computeRange,
5
+ intersectSelectedIds,
6
+ nextFocusId,
7
+ } from "./useSelection.js";
4
8
 
5
9
  const ids = ["a", "b", "c", "d", "e"];
6
10
 
@@ -73,3 +77,60 @@ describe("nextFocusId", () => {
73
77
  assert.strictEqual(nextFocusId([], "a", 1), undefined);
74
78
  });
75
79
  });
80
+
81
+ describe("intersectSelectedIds", () => {
82
+ test("a refresh that drops some selected ids and adds new rows keeps only the survivors (#111)", () => {
83
+ // Regression for #111: the effect used to clear the WHOLE selection the
84
+ // moment any single selected id left the list. Here "b" leaves (deleted
85
+ // elsewhere) while "f" arrives (new mail) — "a" and "c" must survive.
86
+ const selected = new Set(["a", "b", "c"]);
87
+ const refreshedThreadIds = ["a", "c", "d", "f"];
88
+ assert.deepStrictEqual(
89
+ intersectSelectedIds(selected, refreshedThreadIds),
90
+ new Set(["a", "c"]),
91
+ );
92
+ });
93
+
94
+ test("never adds an id that wasn't already selected", () => {
95
+ const selected = new Set(["a"]);
96
+ assert.deepStrictEqual(
97
+ intersectSelectedIds(selected, ["a", "b", "c"]),
98
+ new Set(["a"]),
99
+ );
100
+ });
101
+
102
+ test("every selected id surviving is a no-op (same members, not just same size)", () => {
103
+ const selected = new Set(["a", "b"]);
104
+ assert.deepStrictEqual(
105
+ intersectSelectedIds(selected, ["a", "b", "z"]),
106
+ new Set(["a", "b"]),
107
+ );
108
+ });
109
+
110
+ test("a post-delete retry selection survives a refetch that still contains it, minus what actually left", () => {
111
+ // Mirrors processDeleteOutcome materializing the failed ids as the new
112
+ // selection, then the cache-invalidation refetch running this same
113
+ // intersection against the freshly reloaded `threads`. One retry id
114
+ // ("fail-2") is momentarily missing from the refreshed page; the other
115
+ // two must stay selected so the Retry notice (gated on
116
+ // `selectedCount > 0`) doesn't disappear with it.
117
+ const retrySelection = new Set(["fail-1", "fail-2", "fail-3"]);
118
+ const refetchedThreadIds = ["fail-1", "fail-3", "unrelated-1"];
119
+ assert.deepStrictEqual(
120
+ intersectSelectedIds(retrySelection, refetchedThreadIds),
121
+ new Set(["fail-1", "fail-3"]),
122
+ );
123
+ });
124
+
125
+ test("everything in the selection leaving empties it, rather than leaving stale ids behind", () => {
126
+ const selected = new Set(["a", "b"]);
127
+ assert.deepStrictEqual(intersectSelectedIds(selected, ["z"]), new Set());
128
+ });
129
+
130
+ test("an empty selection stays empty", () => {
131
+ assert.deepStrictEqual(
132
+ intersectSelectedIds(new Set(), ["a", "b"]),
133
+ new Set(),
134
+ );
135
+ });
136
+ });
@@ -52,6 +52,12 @@ interface UseSelectionReturn {
52
52
  * nothing has been selected yet.
53
53
  */
54
54
  anchorId: string | undefined;
55
+ /**
56
+ * Narrows the selection to whatever in it is still present in `currentIds`
57
+ * — drops ids that left, keeps every survivor. Never adds anything, and
58
+ * never clears the selection just because one id is gone (#111).
59
+ */
60
+ intersectWith: (currentIds: readonly string[]) => void;
55
61
  }
56
62
 
57
63
  /**
@@ -79,6 +85,25 @@ export const computeRange = (
79
85
  return orderedIds.slice(start, end + 1);
80
86
  };
81
87
 
88
+ /**
89
+ * The ids from `selectedIds` that are still present in `currentIds` — the
90
+ * survivor set after a list refresh. Only ever narrows: an id absent from
91
+ * `selectedIds` is never added just because it's in `currentIds`. Pure so the
92
+ * "drop what left, keep the rest" behavior (K-9's `selected.intersect
93
+ * (uniqueIds)`, cited by #92 D2) can be unit-tested without a DOM.
94
+ */
95
+ export const intersectSelectedIds = (
96
+ selectedIds: ReadonlySet<string>,
97
+ currentIds: readonly string[],
98
+ ): Set<string> => {
99
+ const present = new Set(currentIds);
100
+ const next = new Set<string>();
101
+ for (const id of selectedIds) {
102
+ if (present.has(id)) next.add(id);
103
+ }
104
+ return next;
105
+ };
106
+
82
107
  /**
83
108
  * Compute the id one step from `focusId` in `orderedIds`, clamped at the ends.
84
109
  * Pure so the shift-arrow range-extend math can be unit-tested without a DOM.
@@ -174,6 +199,17 @@ export const useSelection = <T>(
174
199
  setAnchorId(id);
175
200
  }, []);
176
201
 
202
+ // Bails out to the same `prev` reference when nothing was dropped, so a
203
+ // caller can run this on every list refresh (e.g. an effect keyed on
204
+ // `threads`) without forcing a render each time.
205
+ const intersectWith = useCallback((currentIds: readonly string[]) => {
206
+ setSelectedIds((prev) => {
207
+ if (prev.size === 0) return prev;
208
+ const next = intersectSelectedIds(prev, currentIds);
209
+ return next.size === prev.size ? prev : next;
210
+ });
211
+ }, []);
212
+
177
213
  const selectRange = useCallback(
178
214
  (orderedIds: string[], targetId: string) => {
179
215
  setSelectedIds((prev) => {
@@ -207,5 +243,6 @@ export const useSelection = <T>(
207
243
  selectRange,
208
244
  setAnchor,
209
245
  anchorId,
246
+ intersectWith,
210
247
  };
211
248
  };
@@ -6,6 +6,7 @@ import {
6
6
  countMatches,
7
7
  type DeleteBatchResult,
8
8
  type FetchIdsPageResult,
9
+ honestProgress,
9
10
  resolveSelectionAfterDelete,
10
11
  runChunkedDelete,
11
12
  runPredicateDelete,
@@ -454,3 +455,36 @@ describe("resolveSelectionAfterDelete", () => {
454
455
  );
455
456
  });
456
457
  });
458
+
459
+ describe("honestProgress", () => {
460
+ // Regression for #109: `countMatches` and `runPredicateDelete` page the
461
+ // same predicate independently, so the delete can outrun the frozen
462
+ // `total` it started with when the result set grows in between.
463
+ test("leaves an on-track progress reading untouched", () => {
464
+ assert.deepEqual(honestProgress({ done: 50, total: 100 }), {
465
+ done: 50,
466
+ total: 100,
467
+ });
468
+ });
469
+
470
+ test("widens total to match done once done overtakes it, instead of reading past 100%", () => {
471
+ assert.deepEqual(honestProgress({ done: 1340, total: 1284 }), {
472
+ done: 1340,
473
+ total: 1340,
474
+ });
475
+ });
476
+
477
+ test("done equal to total stays put", () => {
478
+ assert.deepEqual(honestProgress({ done: 100, total: 100 }), {
479
+ done: 100,
480
+ total: 100,
481
+ });
482
+ });
483
+
484
+ test("never reports a total below zero-progress done", () => {
485
+ assert.deepEqual(honestProgress({ done: 0, total: 0 }), {
486
+ done: 0,
487
+ total: 0,
488
+ });
489
+ });
490
+ });
@@ -177,6 +177,24 @@ export const runPredicateDelete = async (
177
177
  return { done, failedIds, cancelled: false };
178
178
  };
179
179
 
180
+ /**
181
+ * Corrects a progress reading so its `total` can never read as less than
182
+ * `done` (#109). `runPredicateDelete`'s `total` is `countMatches`'s frozen
183
+ * page-through, taken before the delete re-pages the same predicate a second,
184
+ * independent time; if more matches arrived in between, `done` can overtake
185
+ * it mid-run and a raw "Deleting 1,340 of 1,284" would follow. Widening the
186
+ * denominator to match keeps the bar's ratio sane (never past 100%) without
187
+ * claiming the original count was exact — the honest fix is admitting the
188
+ * estimate grew, not re-paging to reconcile it (the result set is live; a
189
+ * second count taken any later is no less stale than the first).
190
+ */
191
+ export const honestProgress = (
192
+ progress: BulkDeleteProgress,
193
+ ): BulkDeleteProgress => ({
194
+ done: progress.done,
195
+ total: Math.max(progress.total, progress.done),
196
+ });
197
+
180
198
  export interface CountMatchesResult {
181
199
  total: number;
182
200
  cancelled: boolean;
@@ -119,4 +119,31 @@ describe("formatDeleteToTrashTitle", () => {
119
119
  "Move 3,412 messages to Trash?",
120
120
  );
121
121
  });
122
+
123
+ // #109: an escalated-predicate count is paged once by `countMatches` and
124
+ // re-paged independently by the delete itself — never provably the number
125
+ // that gets deleted. `isEstimate` says so instead of stating an exact
126
+ // number the run may not honour.
127
+ describe("isEstimate (#109 — an escalated-predicate count, not a materialized selection)", () => {
128
+ test("prefixes 'about' for a plural estimate", () => {
129
+ assert.strictEqual(
130
+ formatDeleteToTrashTitle(3412, true),
131
+ "Move about 3,412 messages to Trash?",
132
+ );
133
+ });
134
+
135
+ test("prefixes 'about' for a singular estimate", () => {
136
+ assert.strictEqual(
137
+ formatDeleteToTrashTitle(1, true),
138
+ "Move about 1 message to Trash?",
139
+ );
140
+ });
141
+
142
+ test("defaults to false — a bounded selection's count stays exact", () => {
143
+ assert.strictEqual(
144
+ formatDeleteToTrashTitle(3412),
145
+ formatDeleteToTrashTitle(3412, false),
146
+ );
147
+ });
148
+ });
122
149
  });
package/src/lib/format.ts CHANGED
@@ -195,8 +195,19 @@ export const formatList = (
195
195
  * moves messages to Trash (not a permanent delete) and pluralizes on count.
196
196
  * Thousands-separated: at escalated-selection scale this is exactly the digit
197
197
  * count someone checks against what they meant to select.
198
+ *
199
+ * `isEstimate` marks an escalated-predicate count (#109): it was paged to a
200
+ * total once, and the delete itself re-pages the same predicate independently
201
+ * — mail arriving or leaving between the two can make them differ. "about"
202
+ * says so up front instead of stating a number the run may not honour; a
203
+ * materialized (bounded) selection's count is exact and never passes it.
198
204
  */
199
- export const formatDeleteToTrashTitle = (count: number): string =>
200
- count === 1
201
- ? "Move 1 message to Trash?"
202
- : `Move ${formatNumber(count)} messages to Trash?`;
205
+ export const formatDeleteToTrashTitle = (
206
+ count: number,
207
+ isEstimate = false,
208
+ ): string => {
209
+ const quantity = count === 1 ? "1" : formatNumber(count);
210
+ const noun = count === 1 ? "message" : "messages";
211
+ const prefix = isEstimate ? "about " : "";
212
+ return `Move ${prefix}${quantity} ${noun} to Trash?`;
213
+ };
@@ -1,5 +1,9 @@
1
1
  import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
2
2
  import { createContext, useContext } from "react";
3
+ import {
4
+ EMPTY_RESULT_FOLDER_INDEX,
5
+ type ResultFolderIndex,
6
+ } from "./result-folder.js";
3
7
 
4
8
  /**
5
9
  * Shared mail-layout context. Lives in `lib/` — NOT in `routes/mail.tsx` — on
@@ -22,6 +26,13 @@ export interface MailContextValue {
22
26
  */
23
27
  mailboxNameIndex: ReadonlyMap<string, string>;
24
28
  accountNameIndex: ReadonlyMap<string, string>;
29
+ /**
30
+ * mailboxId → the folder a search result read from that mailbox came from.
31
+ * Computed once here for the same reason as the name indexes: the row labels,
32
+ * the spam hold-out and the scope chip must agree on which folder is which.
33
+ * See `lib/result-folder.ts`.
34
+ */
35
+ resultFolderIndex: ResultFolderIndex;
25
36
  searchQuery: string;
26
37
  /** Live (pre-debounce) search input — the search field binds this. */
27
38
  searchInput: string;
@@ -57,6 +68,7 @@ export const useMailContext = (): MailContextValue => {
57
68
  accounts: [],
58
69
  mailboxNameIndex: new Map(),
59
70
  accountNameIndex: new Map(),
71
+ resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
60
72
  searchQuery: "",
61
73
  searchInput: "",
62
74
  searchViewKey: "",
@@ -0,0 +1,115 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import {
4
+ type AccountMailboxes,
5
+ buildResultFolderIndex,
6
+ resolveResultFolder,
7
+ } from "./result-folder";
8
+
9
+ const account = (
10
+ appointments: { role: string; mailboxId: string }[],
11
+ mailboxes: { mailboxId: string; fullPath: string }[],
12
+ ): AccountMailboxes =>
13
+ ({
14
+ folderAppointments: appointments,
15
+ mailboxes,
16
+ }) as unknown as AccountMailboxes;
17
+
18
+ describe("buildResultFolderIndex", () => {
19
+ it("maps a mailbox to its appointed role and provider path", () => {
20
+ const index = buildResultFolderIndex([
21
+ account(
22
+ [{ role: "Junk", mailboxId: "mb-junk" }],
23
+ [
24
+ { mailboxId: "mb-junk", fullPath: "Bulk Mail" },
25
+ { mailboxId: "mb-work", fullPath: "Projects/Bookkeeping" },
26
+ ],
27
+ ),
28
+ ]);
29
+
30
+ assert.deepEqual(index.get("mb-junk"), {
31
+ role: "junk",
32
+ providerPath: "Bulk Mail",
33
+ });
34
+ assert.deepEqual(index.get("mb-work"), {
35
+ providerPath: "Projects/Bookkeeping",
36
+ });
37
+ });
38
+
39
+ it("keeps every account's mailboxes", () => {
40
+ const index = buildResultFolderIndex([
41
+ account(
42
+ [{ role: "Inbox", mailboxId: "a-inbox" }],
43
+ [{ mailboxId: "a-inbox", fullPath: "INBOX" }],
44
+ ),
45
+ account(
46
+ [{ role: "Inbox", mailboxId: "b-inbox" }],
47
+ [{ mailboxId: "b-inbox", fullPath: "INBOX" }],
48
+ ),
49
+ ]);
50
+
51
+ assert.equal(index.get("a-inbox")?.role, "inbox");
52
+ assert.equal(index.get("b-inbox")?.role, "inbox");
53
+ });
54
+ });
55
+
56
+ describe("resolveResultFolder", () => {
57
+ const index = buildResultFolderIndex([
58
+ account(
59
+ [
60
+ { role: "Inbox", mailboxId: "mb-inbox" },
61
+ { role: "All", mailboxId: "mb-all" },
62
+ { role: "Junk", mailboxId: "mb-junk" },
63
+ ],
64
+ [
65
+ { mailboxId: "mb-inbox", fullPath: "INBOX" },
66
+ { mailboxId: "mb-all", fullPath: "[Gmail]/All Mail" },
67
+ { mailboxId: "mb-junk", fullPath: "[Gmail]/Spam" },
68
+ ],
69
+ ),
70
+ ]);
71
+
72
+ it("skips a virtual folder for one that names a real place", () => {
73
+ assert.deepEqual(resolveResultFolder(index, ["mb-all", "mb-inbox"]), {
74
+ mailboxId: "mb-inbox",
75
+ folder: { role: "inbox", providerPath: "INBOX" },
76
+ });
77
+ });
78
+
79
+ it("resolves Spam by its appointed role, not its path", () => {
80
+ assert.equal(resolveResultFolder(index, ["mb-junk"]).folder?.role, "junk");
81
+ });
82
+
83
+ it("falls back to the first id when none names a real place", () => {
84
+ assert.deepEqual(resolveResultFolder(index, ["mb-all"]), {
85
+ mailboxId: "mb-all",
86
+ folder: { role: "all", providerPath: "[Gmail]/All Mail" },
87
+ });
88
+ });
89
+
90
+ it("returns the id but no folder when the mailbox is unknown", () => {
91
+ assert.deepEqual(resolveResultFolder(index, ["mb-gone"]), {
92
+ mailboxId: "mb-gone",
93
+ });
94
+ });
95
+
96
+ it("returns nothing for a result with no mailboxes", () => {
97
+ assert.deepEqual(resolveResultFolder(index, []), {});
98
+ });
99
+
100
+ it("still names a mailbox before the index has loaded", () => {
101
+ assert.deepEqual(resolveResultFolder(undefined, ["mb-inbox"]), {
102
+ mailboxId: "mb-inbox",
103
+ });
104
+ });
105
+ });
106
+
107
+ describe("buildResultFolderIndex before the mailbox list arrives", () => {
108
+ it("still knows the appointed role, so spam is never let through", () => {
109
+ const index = buildResultFolderIndex([
110
+ account([{ role: "Junk", mailboxId: "mb-junk" }], []),
111
+ ]);
112
+
113
+ assert.deepEqual(index.get("mb-junk"), { role: "junk" });
114
+ });
115
+ });
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Where a search result was read from.
3
+ *
4
+ * A global search returns rows from every folder of every account, so each row
5
+ * says where it came from and spam is held out of the list. Both questions are
6
+ * answered by joining the row's mailbox ids against the mailbox lists the
7
+ * sidebar already loads: the id → folder map here is the only thing either
8
+ * needs, so neither the search API nor the semantic index has to carry a folder.
9
+ *
10
+ * The role comes from the account's `folderAppointments` map (RFC 032), which
11
+ * the server resolves from the IMAP SPECIAL-USE attributes — so `junk` means the
12
+ * folder the account advertises as `\Junk`, whatever it is called. Names are
13
+ * never consulted.
14
+ */
15
+ import type {
16
+ RemitImapFolderAppointment,
17
+ RemitImapMailboxResponse,
18
+ } from "@remit/api-http-client/types.gen.ts";
19
+ import { provenanceFolderLabel, type ResultFolder } from "@remit/ui";
20
+ import { buildMailboxRoleMap } from "./folder-roles.js";
21
+
22
+ export type ResultFolderIndex = ReadonlyMap<string, ResultFolder>;
23
+
24
+ export const EMPTY_RESULT_FOLDER_INDEX: ResultFolderIndex = new Map();
25
+
26
+ export interface AccountMailboxes {
27
+ folderAppointments: readonly RemitImapFolderAppointment[];
28
+ mailboxes: readonly Pick<
29
+ RemitImapMailboxResponse,
30
+ "mailboxId" | "fullPath"
31
+ >[];
32
+ }
33
+
34
+ /**
35
+ * The appointments and the mailbox list arrive from two different queries, so
36
+ * the roles are seeded first and the provider paths laid over them. A role on
37
+ * its own is enough to hold spam out of a global search, which means the list
38
+ * loading late cannot let a Spam row through into the results.
39
+ */
40
+ export function buildResultFolderIndex(
41
+ accounts: readonly AccountMailboxes[],
42
+ ): ResultFolderIndex {
43
+ const index = new Map<string, ResultFolder>();
44
+ for (const account of accounts) {
45
+ const roles = buildMailboxRoleMap(account.folderAppointments);
46
+ for (const [mailboxId, role] of roles) index.set(mailboxId, { role });
47
+ for (const mailbox of account.mailboxes) {
48
+ const role = roles.get(mailbox.mailboxId);
49
+ index.set(mailbox.mailboxId, {
50
+ ...(role ? { role } : {}),
51
+ providerPath: mailbox.fullPath,
52
+ });
53
+ }
54
+ }
55
+ return index;
56
+ }
57
+
58
+ export interface ResolvedResultFolder {
59
+ mailboxId?: string;
60
+ folder?: ResultFolder;
61
+ }
62
+
63
+ /**
64
+ * The mailbox a result should be attributed to, and its folder.
65
+ *
66
+ * A message can sit in several mailboxes at once — Gmail files a copy of most
67
+ * mail in All Mail — and the order the ids arrive in carries no meaning, so the
68
+ * first id that names a real place wins. `provenanceFolderLabel` is the test:
69
+ * it is undefined exactly for the folders that are views rather than places.
70
+ * With nothing resolvable the first id is still returned, because opening the
71
+ * result needs a mailbox even when labelling it does not.
72
+ */
73
+ export function resolveResultFolder(
74
+ folders: ResultFolderIndex | undefined,
75
+ mailboxIds: readonly string[],
76
+ ): ResolvedResultFolder {
77
+ const first = mailboxIds[0];
78
+ if (!folders) return first ? { mailboxId: first } : {};
79
+
80
+ for (const mailboxId of mailboxIds) {
81
+ const folder = folders.get(mailboxId);
82
+ if (folder && provenanceFolderLabel(folder) !== undefined) {
83
+ return { mailboxId, folder };
84
+ }
85
+ }
86
+
87
+ if (!first) return {};
88
+ const folder = folders.get(first);
89
+ return { mailboxId: first, ...(folder ? { folder } : {}) };
90
+ }
@@ -11,6 +11,10 @@ import type {
11
11
  } from "@remit/api-http-client/types.gen.ts";
12
12
  import type { SearchResult, ThreadRowData } from "@remit/ui";
13
13
  import { formatEmailDate } from "./format.js";
14
+ import {
15
+ type ResultFolderIndex,
16
+ resolveResultFolder,
17
+ } from "./result-folder.js";
14
18
 
15
19
  /**
16
20
  * Plain-language label for the `matched: …` chip, so the user understands why
@@ -34,7 +38,9 @@ export function matchedChunkLabel(
34
38
 
35
39
  export function threadToSearchResult(
36
40
  thread: RemitImapThreadMessageResponse,
41
+ folders?: ResultFolderIndex,
37
42
  ): SearchResult {
43
+ const { folder } = resolveResultFolder(folders, [thread.mailboxId]);
38
44
  return {
39
45
  id: thread.messageId,
40
46
  sender: thread.fromName ?? thread.fromEmail ?? "Unknown",
@@ -45,10 +51,18 @@ export function threadToSearchResult(
45
51
  flagged: thread.hasStars === true,
46
52
  threadId: thread.threadId,
47
53
  mailboxId: thread.mailboxId,
54
+ ...(folder ? { folder } : {}),
48
55
  };
49
56
  }
50
57
 
51
- export function rowToSearchResult(row: ThreadRowData): SearchResult {
58
+ export function rowToSearchResult(
59
+ row: ThreadRowData,
60
+ folders?: ResultFolderIndex,
61
+ ): SearchResult {
62
+ const { folder } = resolveResultFolder(
63
+ folders,
64
+ row.mailboxId ? [row.mailboxId] : [],
65
+ );
52
66
  return {
53
67
  id: row.id,
54
68
  sender: row.fromName,
@@ -57,6 +71,8 @@ export function rowToSearchResult(row: ThreadRowData): SearchResult {
57
71
  date: row.timeLabel,
58
72
  unread: !row.isRead,
59
73
  flagged: row.starred === true,
74
+ ...(row.mailboxId ? { mailboxId: row.mailboxId } : {}),
75
+ ...(folder ? { folder } : {}),
60
76
  };
61
77
  }
62
78
 
@@ -68,7 +84,9 @@ export function rowToSearchResult(row: ThreadRowData): SearchResult {
68
84
  */
69
85
  export function semanticToSearchResult(
70
86
  hit: RemitImapSemanticSearchResult,
87
+ folders?: ResultFolderIndex,
71
88
  ): SearchResult {
89
+ const { mailboxId, folder } = resolveResultFolder(folders, hit.mailboxIds);
72
90
  return {
73
91
  id: hit.messageId,
74
92
  sender: hit.fromName ?? "Unknown",
@@ -78,7 +96,8 @@ export function semanticToSearchResult(
78
96
  threadId: hit.threadId,
79
97
  matchedChunkLabel: matchedChunkLabel(hit.matchedChunkType),
80
98
  score: hit.score,
81
- ...(hit.mailboxIds[0] != null ? { mailboxId: hit.mailboxIds[0] } : {}),
99
+ ...(mailboxId ? { mailboxId } : {}),
100
+ ...(folder ? { folder } : {}),
82
101
  };
83
102
  }
84
103
 
@@ -90,13 +109,14 @@ export function semanticToSearchResult(
90
109
  export function relatedSearchResults(
91
110
  hits: RemitImapSemanticSearchResult[],
92
111
  literalThreadIds: Iterable<string>,
112
+ folders?: ResultFolderIndex,
93
113
  ): SearchResult[] {
94
114
  const seenThreadIds = new Set<string>(literalThreadIds);
95
115
  const results: SearchResult[] = [];
96
116
  for (const hit of [...hits].sort((a, b) => b.score - a.score)) {
97
117
  if (seenThreadIds.has(hit.threadId)) continue;
98
118
  seenThreadIds.add(hit.threadId);
99
- results.push(semanticToSearchResult(hit));
119
+ results.push(semanticToSearchResult(hit, folders));
100
120
  }
101
121
  return results;
102
122
  }
@@ -8,6 +8,7 @@ import {
8
8
  } from "./mail-route";
9
9
  import {
10
10
  isScopedRoute,
11
+ resultsScopeForRoute,
11
12
  SEARCH_SCOPE_CHIP_ID,
12
13
  scopeLabelForMailboxName,
13
14
  searchScopeForRoute,
@@ -195,3 +196,57 @@ describe("semanticMailboxScope — a chip binds every engine on the route", () =
195
196
  }
196
197
  });
197
198
  });
199
+
200
+ describe("resultsScopeForRoute", () => {
201
+ it("passes the global search through as global", () => {
202
+ assert.deepEqual(
203
+ resultsScopeForRoute(matches(MAIL_BRIEF_ROUTE_ID), { kind: "global" }),
204
+ {
205
+ kind: "global",
206
+ },
207
+ );
208
+ });
209
+
210
+ it("treats a mailbox whose name has not loaded as a folder, not global", () => {
211
+ assert.deepEqual(
212
+ resultsScopeForRoute(matches(MAIL_MAILBOX_ROUTE_ID), { kind: "pending" }),
213
+ {
214
+ kind: "folder",
215
+ },
216
+ );
217
+ });
218
+
219
+ it("carries the mailbox's role so a Spam search shows its rows", () => {
220
+ assert.deepEqual(
221
+ resultsScopeForRoute(
222
+ matches(MAIL_MAILBOX_ROUTE_ID),
223
+ {
224
+ kind: "scoped",
225
+ chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:spam" },
226
+ },
227
+ "junk",
228
+ ),
229
+ { kind: "folder", role: "junk" },
230
+ );
231
+ });
232
+
233
+ it("calls starred a collection, so its spam is not dropped", () => {
234
+ assert.deepEqual(
235
+ resultsScopeForRoute(matches(MAIL_FLAGGED_ROUTE_ID), {
236
+ kind: "scoped",
237
+ chip: { id: SEARCH_SCOPE_CHIP_ID, label: "is:starred" },
238
+ }),
239
+ { kind: "collection" },
240
+ );
241
+ });
242
+
243
+ it("keeps the outbox a folder — it is one queue, and never holds spam", () => {
244
+ assert.deepEqual(
245
+ resultsScopeForRoute(matches(MAIL_OUTBOX_ROUTE_ID), {
246
+ kind: "scoped",
247
+ chip: { id: SEARCH_SCOPE_CHIP_ID, label: "in:outbox" },
248
+ }),
249
+ { kind: "folder" },
250
+ );
251
+ });
252
+ });
@@ -21,6 +21,7 @@
21
21
  *
22
22
  * Pure functions only. `useSearchScope` binds these to the router.
23
23
  */
24
+ import type { FolderRole, SearchScope as ResultsScope } from "@remit/ui";
24
25
  import {
25
26
  isBriefRoute,
26
27
  isFlaggedRoute,
@@ -152,3 +153,30 @@ export function searchScopeForRoute(
152
153
  }
153
154
  return { kind: "global" };
154
155
  }
156
+
157
+ /**
158
+ * The route's scope as the results list understands it.
159
+ *
160
+ * `is:starred` is a collection, not a folder: it spans every folder a star can
161
+ * be set in, so its rows carry provenance, and it is the user's own hand-picked
162
+ * mail, so a starred spam message is shown rather than held back. Only a search
163
+ * confined to a folder drops spam.
164
+ *
165
+ * The bar's third state, `pending`, is a mailbox route whose name has not
166
+ * loaded. It maps to `folder` with no role yet, because the list underneath is
167
+ * already one mailbox; calling it global for that one frame would flash folder
168
+ * labels and a spam offer and then retract them.
169
+ *
170
+ * `role` is the appointed role of the mailbox the route is on, which is what
171
+ * makes a search scoped to Spam show its rows instead of dropping them. A
172
+ * folder nobody appointed carries no role.
173
+ */
174
+ export function resultsScopeForRoute(
175
+ matches: readonly MailRouteMatch[],
176
+ state: SearchScopeState,
177
+ role?: FolderRole,
178
+ ): ResultsScope {
179
+ if (state.kind === "global") return { kind: "global" };
180
+ if (isFlaggedRoute(matches)) return { kind: "collection" };
181
+ return { kind: "folder", ...(role ? { role } : {}) };
182
+ }
@@ -0,0 +1,84 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import type { SearchResult } from "@remit/ui";
4
+ import { spamOfferForResults } from "./spam-offer";
5
+
6
+ const row = (
7
+ id: string,
8
+ mailboxId: string,
9
+ role?: "junk" | "inbox",
10
+ ): SearchResult => ({
11
+ id,
12
+ sender: "Someone",
13
+ subject: "Subject",
14
+ snippet: "",
15
+ date: "",
16
+ mailboxId,
17
+ ...(role ? { folder: { role } } : {}),
18
+ });
19
+
20
+ describe("spamOfferForResults", () => {
21
+ it("makes no offer when nothing came from Spam", () => {
22
+ assert.equal(
23
+ spamOfferForResults([row("1", "mb-inbox", "inbox"), row("2", "mb-a")]),
24
+ undefined,
25
+ );
26
+ });
27
+
28
+ it("counts the spam rows and names their mailbox", () => {
29
+ assert.deepEqual(
30
+ spamOfferForResults([
31
+ row("1", "mb-inbox", "inbox"),
32
+ row("2", "mb-junk", "junk"),
33
+ row("3", "mb-junk", "junk"),
34
+ ]),
35
+ { mailboxId: "mb-junk", count: 2 },
36
+ );
37
+ });
38
+
39
+ it("targets the Spam folder with the most matches when accounts compete", () => {
40
+ assert.deepEqual(
41
+ spamOfferForResults([
42
+ row("1", "mb-junk-a", "junk"),
43
+ row("2", "mb-junk-b", "junk"),
44
+ row("3", "mb-junk-b", "junk"),
45
+ ]),
46
+ { mailboxId: "mb-junk-b", count: 2 },
47
+ );
48
+ });
49
+
50
+ it("breaks a tie on result order, so the offer does not flip", () => {
51
+ assert.deepEqual(
52
+ spamOfferForResults([
53
+ row("1", "mb-junk-a", "junk"),
54
+ row("2", "mb-junk-b", "junk"),
55
+ ]),
56
+ { mailboxId: "mb-junk-a", count: 1 },
57
+ );
58
+ });
59
+
60
+ it("picks a destination without claiming to have counted every account", () => {
61
+ // Three spam rows across two accounts: the destination is the bigger folder,
62
+ // and its share is 2 — deliberately not the total, which the results list
63
+ // states for itself so the banner cannot under-report.
64
+ const offer = spamOfferForResults([
65
+ row("1", "mb-junk-a", "junk"),
66
+ row("2", "mb-junk-b", "junk"),
67
+ row("3", "mb-junk-b", "junk"),
68
+ ]);
69
+ assert.equal(offer?.mailboxId, "mb-junk-b");
70
+ assert.equal(offer?.count, 2);
71
+ });
72
+
73
+ it("ignores a spam row that carries no mailbox to navigate to", () => {
74
+ const orphan: SearchResult = {
75
+ id: "1",
76
+ sender: "Someone",
77
+ subject: "Subject",
78
+ snippet: "",
79
+ date: "",
80
+ folder: { role: "junk" },
81
+ };
82
+ assert.equal(spamOfferForResults([orphan]), undefined);
83
+ });
84
+ });
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Where taking the Spam offer lands.
3
+ *
4
+ * Scoping is a route and a route names one mailbox, so the offer has to pick a
5
+ * single Spam folder even when several accounts matched; it picks the one
6
+ * holding the most matches. `count` is that folder's share, used only to choose
7
+ * between folders — the banner states the full number of rows held out, which
8
+ * the results list counts for itself.
9
+ */
10
+ import { partitionSpamResults, type SearchResult } from "@remit/ui";
11
+
12
+ export interface SpamOffer {
13
+ mailboxId: string;
14
+ count: number;
15
+ }
16
+
17
+ export function spamOfferForResults(
18
+ results: readonly SearchResult[],
19
+ ): SpamOffer | undefined {
20
+ const { spam } = partitionSpamResults([...results]);
21
+ const countByMailbox = new Map<string, number>();
22
+ for (const result of spam) {
23
+ if (!result.mailboxId) continue;
24
+ countByMailbox.set(
25
+ result.mailboxId,
26
+ (countByMailbox.get(result.mailboxId) ?? 0) + 1,
27
+ );
28
+ }
29
+
30
+ let offer: SpamOffer | undefined;
31
+ // Insertion order is result order, so the first-seen folder wins a tie and
32
+ // the offer does not flip between renders of the same results.
33
+ for (const [mailboxId, count] of countByMailbox) {
34
+ if (!offer || count > offer.count) offer = { mailboxId, count };
35
+ }
36
+ return offer;
37
+ }
@@ -27,6 +27,7 @@ import { useDebouncedValue } from "@/hooks/useDebouncedValue";
27
27
  import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation";
28
28
  import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
29
29
  import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
30
+ import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
30
31
  import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
31
32
  import { writeIntelligencePref } from "@/lib/intelligence-pref";
32
33
  import { MailContext } from "@/lib/mail-context";
@@ -232,6 +233,7 @@ function MailLayout() {
232
233
 
233
234
  const accounts = config?.accounts ?? [];
234
235
  const mailboxNameIndex = useMailboxNameIndex(accounts);
236
+ const resultFolderIndex = useResultFolderIndex(accounts);
235
237
  const accountNameIndex = useMemo(
236
238
  () => buildAccountNameIndex(accounts),
237
239
  [accounts],
@@ -290,6 +292,7 @@ function MailLayout() {
290
292
  accounts,
291
293
  mailboxNameIndex,
292
294
  accountNameIndex,
295
+ resultFolderIndex,
293
296
  // The committed local value is the source of truth for search; it is
294
297
  // mirrored to the URL for shareable links.
295
298
  searchQuery: committedQuery,