@remit/web-client 0.0.82 → 0.0.84

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.82",
3
+ "version": "0.0.84",
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": {
@@ -28,13 +28,13 @@ import {
28
28
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
29
29
  import {
30
30
  inboxFilterConfig,
31
+ type MessageListFilter,
31
32
  ReadingPaneEmpty,
32
33
  type RescueCandidate,
33
34
  type SearchResult,
34
35
  useAppShellLayout,
35
36
  } from "@remit/ui";
36
37
  import {
37
- keepPreviousData,
38
38
  useInfiniteQuery,
39
39
  useMutation,
40
40
  useQueryClient,
@@ -100,13 +100,20 @@ import {
100
100
  type ConversationTarget,
101
101
  } from "@/lib/conversation-target";
102
102
  import { dedupeThreadMessages } from "@/lib/dedupe-thread-messages";
103
- import { applyInboxFilters } from "@/lib/inbox-filters";
103
+ import {
104
+ filterReach,
105
+ hasInboxFilter,
106
+ type InboxFilterCriteria,
107
+ inboxFilterParams,
108
+ sameInboxFilter,
109
+ } from "@/lib/inbox-filters";
104
110
  import { readIntelligencePref } from "@/lib/intelligence-pref";
105
111
  import { useMailContext } from "@/lib/mail-context";
106
112
  import { isRescueCandidate } from "@/lib/rescue-candidates";
107
113
  import { recordRescueSentToJunk } from "@/lib/rescue-telemetry";
108
114
  import {
109
115
  isSearchPending as computeIsSearchPending,
116
+ resolveOpenThread,
110
117
  resolveSelectedThread,
111
118
  } from "@/lib/search-pending";
112
119
  import { normalizeSearchQuery } from "@/lib/search-query";
@@ -137,19 +144,25 @@ interface MailboxPaneContextValue {
137
144
  unreadCount: number;
138
145
  isDraftsMailbox: boolean;
139
146
  // Rescue-from-Spam: true on the account's Junk/Spam folder, with the
140
- // suspected-safe messages over the loaded pages. Drives the rescue banner
141
- // + flow above the spam list.
147
+ // suspected-safe messages `useRescueCandidates` fetched. Drives the rescue
148
+ // banner + flow above the spam list.
142
149
  isSpamFolder: boolean;
143
150
  rescueCandidates: RescueCandidate[];
144
- // Inbox filter (category + Unread/Flagged/Attachment), applied client-side
145
- // over the loaded threads. Owned here so the list, triage and adjacency all
146
- // see the same filtered set; the open thread still resolves against the raw
147
- // set so a filter never closes the reading pane.
151
+ // Inbox filter (category + Unread/Flagged/Attachment). The chips are search
152
+ // parameters: the server returns the filtered page, so `threads` is the
153
+ // answer to the active predicate over the whole mailbox rather than a
154
+ // narrowed copy of the loaded window (#306).
148
155
  filterCategory: string;
149
156
  filterAttributes: ReadonlySet<string>;
150
157
  onSelectFilterCategory: (id: string) => void;
151
158
  onToggleFilterAttribute: (id: string) => void;
152
159
  onClearFilters: () => void;
160
+ /**
161
+ * The active category filter as the empty state renders it — its label, the
162
+ * way out of it, and how much of the mailbox the request reached. Undefined
163
+ * when no category is selected.
164
+ */
165
+ listFilter: MessageListFilter | undefined;
153
166
  intelligenceOpen: boolean;
154
167
  onToggleIntelligence: () => void;
155
168
  /**
@@ -196,6 +209,18 @@ interface MailboxPaneContextValue {
196
209
  previousMessageId: string | undefined;
197
210
  }
198
211
 
212
+ /** The server's own default page size (`DEFAULT_THREADS_PAGE_SIZE`), sent so the
213
+ * filtered path pages like the unfiltered one. */
214
+ const THREADS_PAGE_SIZE = 50;
215
+
216
+ /** Chip id → the label the empty state names the filter by. `all` is absent:
217
+ * it is how the category is cleared, not a category. */
218
+ const CATEGORY_LABELS = new Map(
219
+ inboxFilterConfig()
220
+ .categories.filter((category) => category.id !== "all")
221
+ .map((category) => [category.id, category.label]),
222
+ );
223
+
199
224
  const MailboxPaneCtx = createContext<MailboxPaneContextValue | null>(null);
200
225
 
201
226
  function useMailboxPane(): MailboxPaneContextValue {
@@ -246,8 +271,31 @@ function MailboxPaneProvider({
246
271
  tokenContext,
247
272
  );
248
273
  const fromToken = searchTokens.find((t) => t.type === "from");
274
+
275
+ const [filterCategory, setFilterCategory] = useState("all");
276
+ const [filterAttributes, setFilterAttributes] = useState<ReadonlySet<string>>(
277
+ new Set(),
278
+ );
279
+ const filterCriteria: InboxFilterCriteria = useMemo(
280
+ () => ({ category: filterCategory, attributes: filterAttributes }),
281
+ [filterCategory, filterAttributes],
282
+ );
283
+ const filterParams = useMemo(
284
+ () => inboxFilterParams(filterCriteria),
285
+ [filterCriteria],
286
+ );
287
+
288
+ // The chips are query parameters, not a browser-side pass over the loaded
289
+ // pages: a category whose mail sits below the newest page is why the filter
290
+ // showed an empty inbox at all (#306). `listThreads` takes no filters, so any
291
+ // active chip routes the listing through `searchThreads` — one predicate, one
292
+ // query key, so the key and the branch below cannot diverge.
293
+ const hasServerFilter = hasSearchQuery || hasInboxFilter(filterCriteria);
249
294
  const searchThreadsQuery = {
250
295
  order: "desc" as const,
296
+ // Explicit: an unspecified limit clamps to THREAD_SEARCH_MAX_LIMIT (500),
297
+ // so switching paths without it multiplies the page size by ten.
298
+ limit: THREADS_PAGE_SIZE,
251
299
  ...(freeText ? { query: freeText } : {}),
252
300
  ...(fromToken ? { from: fromToken.value } : {}),
253
301
  ...(searchTokens.some((t) => t.type === "hasAttachment")
@@ -256,9 +304,10 @@ function MailboxPaneProvider({
256
304
  ...(searchTokens.some((t) => t.type === "isUnread")
257
305
  ? { unread: true }
258
306
  : {}),
307
+ ...filterParams,
259
308
  };
260
309
 
261
- const queryKey = hasSearchQuery
310
+ const queryKey = hasServerFilter
262
311
  ? threadOperationsSearchThreadsQueryKey({
263
312
  path: { mailboxId },
264
313
  query: searchThreadsQuery,
@@ -280,7 +329,7 @@ function MailboxPaneProvider({
280
329
  } = useInfiniteQuery({
281
330
  queryKey,
282
331
  queryFn: async ({ pageParam }) => {
283
- if (hasSearchQuery) {
332
+ if (hasServerFilter) {
284
333
  const { data } = await threadOperationsSearchThreads({
285
334
  path: { mailboxId },
286
335
  query: {
@@ -300,8 +349,14 @@ function MailboxPaneProvider({
300
349
  },
301
350
  initialPageParam: undefined as string | undefined,
302
351
  getNextPageParam: (lastPage) => lastPage.continuationToken,
303
- enabled: hasSearchQuery ? normalizedSearchQuery.length > 0 : true,
304
- placeholderData: keepPreviousData,
352
+ // Previous rows while the next page is in flight, but only under the
353
+ // filter that fetched them — a chip change restarts the list on the
354
+ // skeleton rather than showing the old predicate's mail under the new
355
+ // chip for one round trip.
356
+ placeholderData: (previousData, previousQuery) =>
357
+ sameInboxFilter(previousQuery?.queryKey, filterParams)
358
+ ? previousData
359
+ : undefined,
305
360
  });
306
361
 
307
362
  const handleDeselectIfRemoved = useCallback(
@@ -337,21 +392,15 @@ function MailboxPaneProvider({
337
392
  onAfterOptimisticRemove: handleDeselectIfRemoved,
338
393
  });
339
394
 
340
- const rawThreads = dropDeletedThreads(
395
+ // The server answered the active predicate, so these rows are the list: the
396
+ // dedupe spans pages and the deleted drop repeats the server's own
397
+ // `excludeDeleted`, and neither result changes when another page loads.
398
+ const threads = dropDeletedThreads(
341
399
  dedupeThreadMessages(
342
400
  threadsData?.pages.flatMap((page) => page.items ?? []) ?? [],
343
401
  ),
344
402
  );
345
403
 
346
- const [filterCategory, setFilterCategory] = useState("all");
347
- const [filterAttributes, setFilterAttributes] = useState<ReadonlySet<string>>(
348
- new Set(),
349
- );
350
- const threads = useMemo(
351
- () => applyInboxFilters(rawThreads, filterCategory, filterAttributes),
352
- [rawThreads, filterCategory, filterAttributes],
353
- );
354
-
355
404
  const onSelectFilterCategory = useCallback((id: string) => {
356
405
  setFilterCategory(id);
357
406
  }, []);
@@ -368,11 +417,36 @@ function MailboxPaneProvider({
368
417
  setFilterAttributes(new Set());
369
418
  }, []);
370
419
 
420
+ // The empty state has to say how much was read, and the reach comes off the
421
+ // request rather than the call site: the day a chip is answered over a window
422
+ // instead of the whole mailbox, the sentence changes with it.
423
+ const filterLabel = CATEGORY_LABELS.get(filterCategory);
424
+ const listFilter: MessageListFilter | undefined = filterLabel
425
+ ? {
426
+ label: filterLabel,
427
+ reach: filterReach(searchThreadsQuery),
428
+ onClear: onClearFilters,
429
+ }
430
+ : undefined;
431
+
371
432
  const isSearchPending = computeIsSearchPending(searchInput, searchQuery);
372
- // Resolve the open thread against the raw set so an active filter never
373
- // empties the reading pane on a message the user explicitly opened.
374
- const selectedThread = resolveSelectedThread(
375
- rawThreads,
433
+ const listedThread = resolveSelectedThread(
434
+ threads,
435
+ selectedMessageId,
436
+ isSearchPending,
437
+ );
438
+ // There is no unfiltered set in the client any more, so a chip the open
439
+ // message does not match would otherwise close the reading pane under the
440
+ // user. Snapshot what they opened and let it answer for itself.
441
+ const [openedThread, setOpenedThread] = useState<
442
+ RemitImapThreadMessageResponse | undefined
443
+ >(undefined);
444
+ useEffect(() => {
445
+ if (listedThread) setOpenedThread(listedThread);
446
+ }, [listedThread]);
447
+ const selectedThread = resolveOpenThread(
448
+ listedThread,
449
+ openedThread,
376
450
  selectedMessageId,
377
451
  isSearchPending,
378
452
  );
@@ -444,9 +518,10 @@ function MailboxPaneProvider({
444
518
  onSetIntelligenceOpen,
445
519
  ]);
446
520
 
447
- const mailboxUnseenCount = useCurrentMailboxUnseenCount({ accounts });
448
- const unreadCount =
449
- mailboxUnseenCount ?? rawThreads.filter((t) => !t.isRead).length;
521
+ // The mailbox's own unseen total. A count over the loaded pages undercounts
522
+ // every mailbox larger than one page and creeps upward as the user scrolls,
523
+ // so there is no fallback: until the mailbox resolves there is no number.
524
+ const unreadCount = useCurrentMailboxUnseenCount({ accounts }) ?? 0;
450
525
 
451
526
  const queryClient = useQueryClient();
452
527
  const { pushError } = useErrorBanners();
@@ -745,6 +820,7 @@ function MailboxPaneProvider({
745
820
  onSelectFilterCategory,
746
821
  onToggleFilterAttribute,
747
822
  onClearFilters,
823
+ listFilter,
748
824
  intelligenceOpen,
749
825
  onToggleIntelligence,
750
826
  searchPredicate: hasSearchQuery ? searchThreadsQuery : undefined,
@@ -817,6 +893,7 @@ function MailboxList() {
817
893
  onSelectFilterCategory,
818
894
  onToggleFilterAttribute,
819
895
  onClearFilters,
896
+ listFilter,
820
897
  searchPredicate,
821
898
  } = useMailboxPane();
822
899
  const { searchQuery, searchInput, accounts, resultFolderIndex } =
@@ -909,6 +986,8 @@ function MailboxList() {
909
986
  isLoadingMore={isLoadingMore}
910
987
  accountId={mailboxAccountId}
911
988
  listTitle={listTitle}
989
+ listFilter={listFilter}
990
+ listScopeLabel={listTitle}
912
991
  hideHeader
913
992
  onTriageContextChange={onTriageContextChange}
914
993
  commandsRef={listCommandsRef}
@@ -2,6 +2,8 @@ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/type
2
2
  import {
3
3
  Banner,
4
4
  type Density,
5
+ type MessageListFilter,
6
+ MessageListLoadingMore,
5
7
  MessageListPane,
6
8
  SELECTION_SHEET_TEASER_HEIGHT,
7
9
  SelectionSheet,
@@ -123,6 +125,15 @@ interface MessageListProps {
123
125
  * Optional subtitle (e.g. "3 unread") shown alongside the title.
124
126
  */
125
127
  listMeta?: string;
128
+ /**
129
+ * The active category filter, when the view has one. The empty state needs
130
+ * it to say it is filtered and how much of the collection the request
131
+ * reached; without it a narrowed list renders as an empty mailbox, which is
132
+ * the shape #315's bug hid behind.
133
+ */
134
+ listFilter?: MessageListFilter;
135
+ /** Name of the collection being listed, e.g. "Inbox", for the empty state. */
136
+ listScopeLabel?: string;
126
137
  /**
127
138
  * Triage-layer context bridge (#429). The roving focus cursor and the
128
139
  * multi-selection live here; the parent route's global keyboard dispatcher
@@ -179,17 +190,17 @@ const readStoredDensity = (): Density => {
179
190
  return "comfortable";
180
191
  };
181
192
 
182
- const SearchResultsHeader = ({
183
- query,
184
- count,
185
- }: {
186
- query: string;
187
- count: number;
188
- }) => (
193
+ /**
194
+ * Names what the list is showing. No number: the only figure available here is
195
+ * the length of the loaded pages, and a page length presented as a result total
196
+ * contradicts the completeness the filtered empty state states in the same view
197
+ * (#306). The exact count is #307's.
198
+ */
199
+ const SearchResultsHeader = ({ query }: { query: string }) => (
189
200
  <div className="flex items-center gap-2 px-3 py-2 border-b border-line bg-surface-sunken/30">
190
201
  <Search className="size-4 text-fg-muted" />
191
202
  <span className="text-sm text-fg-muted">
192
- {count} {count === 1 ? "result" : "results"} for &ldquo;{query}&rdquo;
203
+ Results for &ldquo;{query}&rdquo;
193
204
  </span>
194
205
  </div>
195
206
  );
@@ -214,6 +225,8 @@ export const MessageList = ({
214
225
  accountId,
215
226
  listTitle,
216
227
  listMeta,
228
+ listFilter,
229
+ listScopeLabel,
217
230
  onTriageContextChange,
218
231
  commandsRef,
219
232
  hideHeader = false,
@@ -1363,7 +1376,7 @@ export const MessageList = ({
1363
1376
  const virtualBody = (
1364
1377
  <>
1365
1378
  {isSearching && searchQuery && (
1366
- <SearchResultsHeader query={searchQuery} count={threads.length} />
1379
+ <SearchResultsHeader query={searchQuery} />
1367
1380
  )}
1368
1381
  <div
1369
1382
  ref={parentRef}
@@ -1427,11 +1440,7 @@ export const MessageList = ({
1427
1440
  );
1428
1441
  })}
1429
1442
  </div>
1430
- {isLoadingMore && (
1431
- <div className="flex justify-center py-4">
1432
- <div className="h-5 w-5 animate-spin rounded-full border-2 border-fg-muted border-t-transparent" />
1433
- </div>
1434
- )}
1443
+ {isLoadingMore && <MessageListLoadingMore />}
1435
1444
  </div>
1436
1445
  </>
1437
1446
  );
@@ -1469,6 +1478,8 @@ export const MessageList = ({
1469
1478
  flatList
1470
1479
  listState={listState}
1471
1480
  searchQuery={isSearching ? searchQuery : undefined}
1481
+ listFilter={listFilter}
1482
+ listScopeLabel={listScopeLabel}
1472
1483
  errorMessage={errorMessage}
1473
1484
  onRetry={onRetry}
1474
1485
  onReportError={handleReportError}
@@ -187,9 +187,10 @@ const MessageRowComponent = ({
187
187
  onLongPress?.(messageId);
188
188
  }, [onLongPress, messageId]);
189
189
 
190
+ // Threshold comes from useLongPress' default so the plain row and the
191
+ // swipeable row can't drift apart on timing.
190
192
  const { longPressProps } = useLongPress({
191
193
  onLongPress: handleLongPress,
192
- delayMs: 500,
193
194
  accessibilityDescription: isChecked ? "Deselect message" : "Select message",
194
195
  });
195
196
 
@@ -33,7 +33,14 @@ import {
33
33
  * filters the visible list is searching with, minus pagination/count knobs. */
34
34
  export type EscalationSearchQuery = Pick<
35
35
  NonNullable<ThreadOperationsSearchThreadsData["query"]>,
36
- "order" | "query" | "subject" | "from" | "unread" | "starred" | "attachments"
36
+ | "order"
37
+ | "query"
38
+ | "subject"
39
+ | "from"
40
+ | "unread"
41
+ | "starred"
42
+ | "attachments"
43
+ | "category"
37
44
  >;
38
45
 
39
46
  /**
@@ -1,106 +1,134 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, test } from "node:test";
3
- import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
4
- import { applyInboxFilters } from "./inbox-filters.js";
3
+ import {
4
+ filterReach,
5
+ hasInboxFilter,
6
+ type InboxFilterCriteria,
7
+ inboxFilterParams,
8
+ sameInboxFilter,
9
+ } from "./inbox-filters.js";
5
10
 
6
- // Only the fields the filter reads, following the fixture idiom in
7
- // starred-rows.test.ts.
8
- const thread = (
9
- fields: Partial<RemitImapThreadMessageResponse> & { messageId: string },
10
- ): RemitImapThreadMessageResponse =>
11
- ({ isRead: false, ...fields }) as unknown as RemitImapThreadMessageResponse;
11
+ const criteria = (
12
+ category: string,
13
+ attributes: string[] = [],
14
+ ): InboxFilterCriteria => ({ category, attributes: new Set(attributes) });
12
15
 
13
- const personal = thread({ messageId: "m1", category: "personal" });
14
- const unclassified = thread({ messageId: "m2", category: "uncategorized" });
15
- const preClassification = thread({ messageId: "m3" });
16
+ /** The shape `threadOperationsSearchThreadsQueryKey` produces. */
17
+ const key = (query: Record<string, unknown>): unknown => [
18
+ { _id: "threadOperationsSearchThreads", path: { mailboxId: "mb1" }, query },
19
+ ];
16
20
 
17
- const ids = (threads: RemitImapThreadMessageResponse[]): string[] =>
18
- threads.map((t) => t.messageId);
21
+ describe("hasInboxFilter", () => {
22
+ test("an untouched chip row narrows nothing", () => {
23
+ assert.equal(hasInboxFilter(criteria("all")), false);
24
+ });
19
25
 
20
- describe("applyInboxFilters", () => {
21
- test("returns the loaded list when nothing narrows it", () => {
22
- const threads = [personal, unclassified, preClassification];
23
- assert.deepEqual(applyInboxFilters(threads, "all", new Set()), threads);
26
+ test("a category or an attribute is a filter", () => {
27
+ assert.equal(hasInboxFilter(criteria("personal")), true);
28
+ assert.equal(hasInboxFilter(criteria("all", ["unread"])), true);
24
29
  });
25
30
 
26
- test("matches a thread whose category is set", () => {
27
- assert.deepEqual(
28
- ids(applyInboxFilters([personal, unclassified], "personal", new Set())),
29
- ["m1"],
30
- );
31
+ test("an id that names no category narrows nothing", () => {
32
+ assert.equal(hasInboxFilter(criteria("nonsense")), false);
31
33
  });
34
+ });
32
35
 
33
- test("counts a thread with no category as unclassified (#45)", () => {
34
- // A pre-classification thread already renders an `uncategorized` badge,
35
- // so the Unclassified chip has to find the row the user can see. Reading
36
- // the response field raw made that row vanish under its own chip.
37
- assert.deepEqual(
38
- ids(
39
- applyInboxFilters(
40
- [personal, unclassified, preClassification],
41
- "uncategorized",
42
- new Set(),
43
- ),
44
- ),
45
- ["m2", "m3"],
46
- );
36
+ describe("inboxFilterParams", () => {
37
+ test("sends the category to the server instead of filtering here (#306)", () => {
38
+ assert.deepEqual(inboxFilterParams(criteria("personal")), {
39
+ category: ["personal"],
40
+ });
47
41
  });
48
42
 
49
- test("never lets unclassified mail answer to personal (#45)", () => {
43
+ test("asks for unclassified mail by name, never as absence (#45)", () => {
44
+ assert.deepEqual(inboxFilterParams(criteria("uncategorized")), {
45
+ category: ["uncategorized"],
46
+ });
47
+ });
48
+
49
+ test("sets no category parameter for `all`", () => {
50
+ assert.deepEqual(inboxFilterParams(criteria("all")), {});
51
+ });
52
+
53
+ test("maps each attribute chip onto the parameter the API names", () => {
50
54
  assert.deepEqual(
51
- applyInboxFilters(
52
- [unclassified, preClassification],
53
- "personal",
54
- new Set(),
55
- ),
56
- [],
55
+ inboxFilterParams(criteria("all", ["unread", "flagged", "attachment"])),
56
+ { unread: true, starred: true, attachments: true },
57
57
  );
58
58
  });
59
59
 
60
- test("applies attribute predicates alongside a category", () => {
61
- const read = thread({
62
- messageId: "m4",
63
- category: "personal",
64
- isRead: true,
60
+ test("carries a category and its attributes together", () => {
61
+ assert.deepEqual(inboxFilterParams(criteria("social", ["unread"])), {
62
+ category: ["social"],
63
+ unread: true,
65
64
  });
66
- assert.deepEqual(
67
- ids(applyInboxFilters([personal, read], "personal", new Set(["unread"]))),
68
- ["m1"],
65
+ });
66
+
67
+ test("ignores an attribute id with no parameter behind it", () => {
68
+ assert.deepEqual(inboxFilterParams(criteria("all", ["nonsense"])), {});
69
+ });
70
+ });
71
+
72
+ describe("filterReach", () => {
73
+ test("a category is a column on the row, so the whole folder was read", () => {
74
+ assert.equal(
75
+ filterReach({ category: ["personal"], unread: true }),
76
+ "whole-folder",
69
77
  );
70
78
  });
71
79
 
72
- test("applies attribute predicates without a category", () => {
73
- const read = thread({
74
- messageId: "m4",
75
- category: "newsletter",
76
- isRead: true,
77
- });
78
- assert.deepEqual(
79
- ids(applyInboxFilters([personal, read], "all", new Set(["unread"]))),
80
- ["m1"],
80
+ test("an unfiltered request still reaches the whole folder", () => {
81
+ assert.equal(filterReach({ order: "desc" }), "whole-folder");
82
+ });
83
+
84
+ test("an off-row criterion bounds the read, whatever else is set", () => {
85
+ assert.equal(
86
+ filterReach({ category: ["personal"], senderTrust: ["unknown"] }),
87
+ "loaded-pages",
81
88
  );
89
+ assert.equal(filterReach({ dkimMismatch: true }), "loaded-pages");
90
+ assert.equal(filterReach({ dkimMismatch: false }), "loaded-pages");
82
91
  });
83
92
 
84
- test("matches starred and attachment threads", () => {
85
- const starred = thread({ messageId: "m5", hasStars: true });
86
- const withAttachment = thread({ messageId: "m6", hasAttachment: true });
87
- const plain = thread({ messageId: "m7" });
88
- const threads = [starred, withAttachment, plain];
89
- assert.deepEqual(
90
- ids(applyInboxFilters(threads, "all", new Set(["flagged"]))),
91
- ["m5"],
93
+ test("an empty trust list bounds nothing", () => {
94
+ assert.equal(filterReach({ senderTrust: [] }), "whole-folder");
95
+ });
96
+ });
97
+
98
+ describe("sameInboxFilter", () => {
99
+ test("holds across a query the user is still typing", () => {
100
+ assert.equal(
101
+ sameInboxFilter(key({ query: "inv", category: ["personal"] }), {
102
+ category: ["personal"],
103
+ }),
104
+ true,
92
105
  );
93
- assert.deepEqual(
94
- ids(applyInboxFilters(threads, "all", new Set(["attachment"]))),
95
- ["m6"],
106
+ });
107
+
108
+ test("fails the moment the category changes", () => {
109
+ assert.equal(
110
+ sameInboxFilter(key({ category: ["personal"] }), {
111
+ category: ["social"],
112
+ }),
113
+ false,
96
114
  );
97
115
  });
98
116
 
99
- test("ignores an attribute id with no predicate behind it", () => {
100
- const threads = [personal, unclassified];
101
- assert.deepEqual(
102
- applyInboxFilters(threads, "all", new Set(["nonsense"])),
103
- threads,
117
+ test("fails when a chip is cleared", () => {
118
+ assert.equal(sameInboxFilter(key({ category: ["personal"] }), {}), false);
119
+ assert.equal(
120
+ sameInboxFilter(key({ unread: true }), { category: ["personal"] }),
121
+ false,
104
122
  );
105
123
  });
124
+
125
+ test("holds between the unfiltered listing and an unfiltered search", () => {
126
+ assert.equal(sameInboxFilter(key({ order: "desc" }), {}), true);
127
+ });
128
+
129
+ test("answers false for a key it cannot read", () => {
130
+ assert.equal(sameInboxFilter(undefined, {}), false);
131
+ assert.equal(sameInboxFilter([], {}), false);
132
+ assert.equal(sameInboxFilter([{ path: { mailboxId: "mb1" } }], {}), false);
133
+ });
106
134
  });
@@ -1,44 +1,123 @@
1
- import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
- import { toDisplayCategory } from "./display-category.js";
1
+ import type {
2
+ RemitImapMessageCategory,
3
+ ThreadOperationsSearchThreadsData,
4
+ } from "@remit/api-http-client/types.gen.ts";
5
+ import { MessageCategory } from "@remit/domain-enums";
6
+ import type { FilterReach } from "@remit/ui";
7
+
8
+ /** The request `threadOperationsSearchThreads` takes, whole. */
9
+ export type ThreadSearchQuery = NonNullable<
10
+ ThreadOperationsSearchThreadsData["query"]
11
+ >;
12
+
13
+ /** The parameters the inbox chips set on that request. */
14
+ export type InboxFilterParams = Pick<
15
+ ThreadSearchQuery,
16
+ "category" | "unread" | "starred" | "attachments"
17
+ >;
18
+
19
+ /** The chip state the inbox holds: one category, any number of attributes. */
20
+ export interface InboxFilterCriteria {
21
+ /** A category id, or `"all"` when the category is cleared. */
22
+ category: string;
23
+ attributes: ReadonlySet<string>;
24
+ }
3
25
 
4
26
  /**
5
- * Inbox filter predicates the inbox preset offers Unread / Starred / Has
6
- * attachment (never accounts; an inbox is one account already). The `flagged`
7
- * id is the wire name for IMAP \Flagged; the label is "Starred".
27
+ * The search parameter each attribute chip sets. `flagged` is the wire name for
28
+ * IMAP \Flagged, which the API calls `starred`; the label is "Starred".
8
29
  */
9
- const INBOX_FILTER_PREDICATES: Record<
10
- string,
11
- (t: RemitImapThreadMessageResponse) => boolean
12
- > = {
13
- unread: (t) => !t.isRead,
14
- flagged: (t) => t.hasStars === true,
15
- attachment: (t) => Boolean(t.hasAttachment),
30
+ const ATTRIBUTE_PARAMS: Record<string, "unread" | "starred" | "attachments"> = {
31
+ unread: "unread",
32
+ flagged: "starred",
33
+ attachment: "attachments",
16
34
  };
17
35
 
36
+ const CATEGORY_VALUES = new Set<string>(Object.values(MessageCategory));
37
+
38
+ const isMessageCategory = (id: string): id is RemitImapMessageCategory =>
39
+ CATEGORY_VALUES.has(id);
40
+
41
+ /** Whether the chips narrow the list at all — `"all"` and no attributes do not. */
42
+ export const hasInboxFilter = (criteria: InboxFilterCriteria): boolean =>
43
+ isMessageCategory(criteria.category) || criteria.attributes.size > 0;
44
+
18
45
  /**
19
- * Narrow the loaded threads to one category and a set of attributes. Applied
20
- * over the loaded pages until #306 moves the predicate into the query.
46
+ * The chips as search parameters, so the server returns a page of matches
47
+ * rather than a page the browser then thins out. A category chip that names no
48
+ * category — `"all"`, or an id the enum no longer carries — sets no parameter,
49
+ * and `uncategorized` is a category like any other: the pending state's name,
50
+ * never the absence of one (issue #45).
21
51
  *
22
- * The category comparison goes through `toDisplayCategory` so the inbox agrees
23
- * with Starred and the brief, which filter mapped rows. `category` is optional
24
- * on the response and absent reads as `uncategorized` everywhere else — a
25
- * pre-classification thread renders an `uncategorized` badge, so a raw
26
- * comparison made the row you can see vanish under its own chip (#45).
52
+ * Only the parameters a chip switches on are present, so this merges with the
53
+ * search tokens (`is:unread`, `has:attachment`) as a union: where a chip and a
54
+ * token set the same parameter they agree on `true`.
27
55
  */
28
- export function applyInboxFilters(
29
- threads: RemitImapThreadMessageResponse[],
30
- category: string,
31
- attributes: ReadonlySet<string>,
32
- ): RemitImapThreadMessageResponse[] {
33
- const predicates = Array.from(attributes)
34
- .map((id) => INBOX_FILTER_PREDICATES[id])
35
- .filter(
36
- (p): p is (t: RemitImapThreadMessageResponse) => boolean => p != null,
37
- );
38
- if (category === "all" && predicates.length === 0) return threads;
39
- return threads.filter(
40
- (t) =>
41
- (category === "all" || toDisplayCategory(t.category) === category) &&
42
- predicates.every((p) => p(t)),
43
- );
44
- }
56
+ export const inboxFilterParams = (
57
+ criteria: InboxFilterCriteria,
58
+ ): InboxFilterParams => {
59
+ const params: InboxFilterParams = {};
60
+ if (isMessageCategory(criteria.category)) {
61
+ params.category = [criteria.category];
62
+ }
63
+ for (const id of criteria.attributes) {
64
+ const param = ATTRIBUTE_PARAMS[id];
65
+ if (param) params[param] = true;
66
+ }
67
+ return params;
68
+ };
69
+
70
+ /**
71
+ * How much of the mailbox a request reaches, read off the request itself.
72
+ *
73
+ * Every criterion the inbox sends today is a column on the row, so the server
74
+ * answers with a `where` over the whole mailbox and an empty result means the
75
+ * folder holds no matching mail. `senderTrust` and `dkimMismatch` are resolved
76
+ * by enriching a bounded window instead (design D7, D10), so a request carrying
77
+ * either has only seen the pages it read. Derived rather than declared: the
78
+ * completeness sentence the empty state renders is true of the query that was
79
+ * issued, not of the criteria that happen to ship today.
80
+ */
81
+ export const filterReach = (query: ThreadSearchQuery): FilterReach =>
82
+ (query.senderTrust?.length ?? 0) > 0 || query.dkimMismatch !== undefined
83
+ ? "loaded-pages"
84
+ : "whole-folder";
85
+
86
+ const FILTER_PARAM_NAMES = [
87
+ "category",
88
+ "unread",
89
+ "starred",
90
+ "attachments",
91
+ ] as const;
92
+
93
+ const filterIdentity = (query: Record<string, unknown> | undefined): string =>
94
+ JSON.stringify(FILTER_PARAM_NAMES.map((name) => query?.[name] ?? null));
95
+
96
+ const queryOf = (queryKey: unknown): Record<string, unknown> | undefined => {
97
+ if (!Array.isArray(queryKey)) return undefined;
98
+ const [entry] = queryKey;
99
+ if (typeof entry !== "object" || entry === null) return undefined;
100
+ const query: unknown = (entry as { query?: unknown }).query;
101
+ if (typeof query !== "object" || query === null) return undefined;
102
+ return query as Record<string, unknown>;
103
+ };
104
+
105
+ /**
106
+ * Whether a cached list query was fetched under the same filter the request
107
+ * being made now carries.
108
+ *
109
+ * `keepPreviousData` renders the previous key's rows while a new page is in
110
+ * flight. That is right for a query the user is still typing and wrong the
111
+ * moment the filter changes: the previous predicate's mail would show under the
112
+ * new chip for one round trip, which is the failure this correction is about
113
+ * (design D18). An unrecognized key answers `false`, so the doubtful case
114
+ * restarts the list rather than rendering rows it cannot vouch for.
115
+ */
116
+ export const sameInboxFilter = (
117
+ queryKey: unknown,
118
+ params: InboxFilterParams,
119
+ ): boolean => {
120
+ const previous = queryOf(queryKey);
121
+ if (!previous) return false;
122
+ return filterIdentity(previous) === filterIdentity(params);
123
+ };
@@ -10,7 +10,11 @@
10
10
 
11
11
  import assert from "node:assert";
12
12
  import { describe, test } from "node:test";
13
- import { isSearchPending, resolveSelectedThread } from "./search-pending.ts";
13
+ import {
14
+ isSearchPending,
15
+ resolveOpenThread,
16
+ resolveSelectedThread,
17
+ } from "./search-pending.ts";
14
18
 
15
19
  describe("isSearchPending (reading-pane suppression guard)", () => {
16
20
  test("no search: both empty — not pending", () => {
@@ -59,3 +63,37 @@ describe("resolveSelectedThread (#623 reading-pane resolution)", () => {
59
63
  assert.equal(selectedThread, undefined);
60
64
  });
61
65
  });
66
+
67
+ describe("resolveOpenThread (#306 filtered list)", () => {
68
+ const listed = { messageId: "msg-001", subject: "listed" };
69
+ const snapshot = { messageId: "msg-001", subject: "snapshot" };
70
+
71
+ test("prefers the row the list returned", () => {
72
+ assert.equal(resolveOpenThread(listed, snapshot, "msg-001", false), listed);
73
+ });
74
+
75
+ test("keeps the open thread when a filter pages it out of the list", () => {
76
+ assert.equal(
77
+ resolveOpenThread(undefined, snapshot, "msg-001", false),
78
+ snapshot,
79
+ );
80
+ });
81
+
82
+ test("drops a snapshot of a message that is no longer selected", () => {
83
+ assert.equal(
84
+ resolveOpenThread(undefined, snapshot, "msg-002", false),
85
+ undefined,
86
+ );
87
+ assert.equal(
88
+ resolveOpenThread(undefined, snapshot, undefined, false),
89
+ undefined,
90
+ );
91
+ });
92
+
93
+ test("a pending search still closes the pane (#539)", () => {
94
+ assert.equal(
95
+ resolveOpenThread(undefined, snapshot, "msg-001", true),
96
+ undefined,
97
+ );
98
+ });
99
+ });
@@ -22,3 +22,28 @@ export const resolveSelectedThread = <T extends { messageId: string }>(
22
22
  if (pending || !selectedMessageId) return undefined;
23
23
  return threads.find((t) => t.messageId === selectedMessageId);
24
24
  };
25
+
26
+ /**
27
+ * The thread the reading pane shows, once the list is a server-side query
28
+ * (#306).
29
+ *
30
+ * The list used to hold every loaded row and filter a copy, so an open thread
31
+ * could always be found again in the unfiltered set. A filtered list is now the
32
+ * server's answer to one predicate, and a chip the open message does not match
33
+ * pages it out — so what the user opened is kept as a snapshot and answers for
34
+ * itself until they open something else. That is a derivation over the user's
35
+ * own selection, which stays on this side of the boundary.
36
+ *
37
+ * The snapshot is ignored while a search debounce is pending, so the pane still
38
+ * clears the instant a new search starts (#539).
39
+ */
40
+ export const resolveOpenThread = <T extends { messageId: string }>(
41
+ listed: T | undefined,
42
+ opened: T | undefined,
43
+ selectedMessageId: string | undefined,
44
+ pending: boolean,
45
+ ): T | undefined => {
46
+ if (listed) return listed;
47
+ if (pending || !selectedMessageId) return undefined;
48
+ return opened?.messageId === selectedMessageId ? opened : undefined;
49
+ };