@remit/web-client 0.0.279 → 0.0.280

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.
@@ -14,20 +14,28 @@
14
14
  * below the newest page rendered an empty list however much of it the
15
15
  * collection held (#308); the server answers the predicate over the whole
16
16
  * collection instead, and a page comes back full of matches.
17
+ *
18
+ * Free text is one of those parameters, so a search is this same paginated
19
+ * query rather than a second, single-page one beside it. The separate one had
20
+ * no error state of its own — an expired session read as "nothing matched" —
21
+ * no continuation, so "Load more" was a dead button under a query, and its own
22
+ * loading state, so a cached listing flashed the empty state while it was in
23
+ * flight.
17
24
  */
18
25
  import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
19
26
  import { unifiedThreadOperationsListAllThreads } from "@remit/api-http-client/sdk.gen.ts";
20
27
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
21
28
  import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
22
29
  import { useMemo } from "react";
23
- import type { InboxFilterParams } from "@/lib/inbox-filters";
30
+ import type { ThreadSearchTokenParams } from "@/lib/thread-search-tokens";
24
31
 
25
32
  /** The criteria the Flagged view narrows its listing by, all server-applied. */
26
- export interface StarredCriteria extends InboxFilterParams {
27
- /** Free text, matched against subject and From over the whole collection. */
33
+ export interface StarredCriteria extends ThreadSearchTokenParams {
34
+ /**
35
+ * Free text, matched against subject, From and the body preview over the
36
+ * whole collection.
37
+ */
28
38
  query?: string;
29
- /** Page size, set only by the single-page text search. */
30
- limit?: number;
31
39
  }
32
40
 
33
41
  // `starred` and `order` are the view itself, not a criterion, so they are set
@@ -101,37 +109,6 @@ export function useStarredThreads(
101
109
  };
102
110
  }
103
111
 
104
- /**
105
- * The server's own text match over the whole starred collection, as one page.
106
- *
107
- * The free-text filter has two halves that no single request covers: the server
108
- * matches subject and From over every starred message, and the client can also
109
- * match a snippet, but only on rows it has already loaded. The server's set is
110
- * the filter — it is the half that sees the mail below the newest page — and
111
- * the snippet pass complements it (#308). Same shape as the daily brief's
112
- * unscoped search, which merges the same two halves.
113
- */
114
- export function useStarredTextSearch(
115
- criteria: StarredCriteria,
116
- limit: number,
117
- ): RemitImapThreadMessageResponse[] {
118
- const text = criteria.query ?? "";
119
- const query = { ...starredQuery(criteria), limit };
120
- const { data } = useQuery({
121
- queryKey: unifiedThreadOperationsListAllThreadsQueryKey({ query }),
122
- queryFn: async () => {
123
- const { data: page } = await unifiedThreadOperationsListAllThreads({
124
- query,
125
- throwOnError: true,
126
- });
127
- return page;
128
- },
129
- enabled: text.length > 0,
130
- staleTime: 30_000,
131
- });
132
- return useMemo(() => data?.items ?? [], [data]);
133
- }
134
-
135
112
  /**
136
113
  * How many of the matching starred messages are unread, counted by the server.
137
114
  *
@@ -21,8 +21,13 @@ const tokensOf = (query: string): SearchToken[] =>
21
21
  mailboxesByName: new Map([["sent", "mb2"]]),
22
22
  }).tokens;
23
23
 
24
- const criteriaOf = (query: string, attributes: string[] = []) =>
25
- briefCriteria("all", new Set(attributes), tokensOf(query)).criteria;
24
+ const criteriaOf = (
25
+ query: string,
26
+ attributes: string[] = [],
27
+ accountId?: string,
28
+ ) =>
29
+ briefCriteria("all", new Set(attributes), tokensOf(query), accountId)
30
+ .criteria;
26
31
 
27
32
  const residualTypes = (query: string, attributes: string[] = []) =>
28
33
  briefCriteria("all", new Set(attributes), tokensOf(query)).residual.map(
@@ -30,13 +35,16 @@ const residualTypes = (query: string, attributes: string[] = []) =>
30
35
  );
31
36
 
32
37
  describe("briefCriteria", () => {
33
- test("an unnarrowed brief asks for nothing in particular", () => {
34
- assert.deepEqual(criteriaOf(""), {});
38
+ // The brief renders no muted sender's mail, so every request it makes says
39
+ // so — the alternative is a header counting mail the list drops (#1137).
40
+ test("an unnarrowed brief still refuses muted senders", () => {
41
+ assert.deepEqual(criteriaOf(""), { muted: false });
35
42
  assert.deepEqual(residualTypes(""), []);
36
43
  });
37
44
 
38
45
  test("the chips travel as parameters", () => {
39
46
  assert.deepEqual(criteriaOf("", ["unread", "attachment"]), {
47
+ muted: false,
40
48
  unread: true,
41
49
  attachments: true,
42
50
  });
@@ -44,6 +52,7 @@ describe("briefCriteria", () => {
44
52
 
45
53
  test("a typed token asks for exactly what its chip asks for", () => {
46
54
  assert.deepEqual(criteriaOf("is:unread has:attachment"), {
55
+ muted: false,
47
56
  unread: true,
48
57
  attachments: true,
49
58
  });
@@ -51,20 +60,36 @@ describe("briefCriteria", () => {
51
60
  });
52
61
 
53
62
  test("is:read narrows the request rather than the rows", () => {
54
- assert.deepEqual(criteriaOf("is:read"), { unread: false });
63
+ assert.deepEqual(criteriaOf("is:read"), { muted: false, unread: false });
55
64
  assert.deepEqual(residualTypes("is:read"), []);
56
65
  });
57
66
 
58
67
  test("is:starred travels too", () => {
59
- assert.deepEqual(criteriaOf("is:starred"), { starred: true });
68
+ assert.deepEqual(criteriaOf("is:starred"), { muted: false, starred: true });
60
69
  assert.deepEqual(residualTypes("is:starred"), []);
61
70
  });
62
71
 
72
+ // #1136: the pill used to narrow the rows after they arrived, so a count
73
+ // taken over every account was not the size of the list showing one.
74
+ test("the account pill travels as a parameter", () => {
75
+ assert.deepEqual(criteriaOf("", [], "acc_1"), {
76
+ muted: false,
77
+ accountId: "acc_1",
78
+ });
79
+ });
80
+
81
+ test("the cross-account brief names no account", () => {
82
+ assert.equal(Object.hasOwn(criteriaOf(""), "accountId"), false);
83
+ });
84
+
63
85
  // The section supplies its own category, so the shared criteria must not
64
86
  // carry one — two categories on one request is a request for neither.
65
87
  test("the category never travels in the shared criteria", () => {
66
- assert.deepEqual(criteriaOf("category:personal"), {});
67
- assert.deepEqual(criteriaOf("", ["unread"]), { unread: true });
88
+ assert.deepEqual(criteriaOf("category:personal"), { muted: false });
89
+ assert.deepEqual(criteriaOf("", ["unread"]), {
90
+ muted: false,
91
+ unread: true,
92
+ });
68
93
  assert.equal(
69
94
  Object.hasOwn(
70
95
  briefCriteria("personal", new Set(), []).criteria,
@@ -74,13 +99,32 @@ describe("briefCriteria", () => {
74
99
  );
75
100
  });
76
101
 
77
- // `listAllThreads` has one text parameter matching subject and From at once,
78
- // so neither token can be asked for on its own and both stay residue.
79
- test("from: and subject: have no parameter here and come back as residue", () => {
80
- assert.deepEqual(criteriaOf("from:alice"), {});
81
- assert.deepEqual(residualTypes("from:alice"), ["from"]);
82
- assert.deepEqual(residualTypes("subject:invoice"), ["subject"]);
83
- assert.deepEqual(BRIEF_TOKEN_PARAMS.includes("from"), false);
102
+ // #1128: the free-text parameter matches subject and From at once, so
103
+ // neither token could be asked for on its own and both were applied over the
104
+ // rows a section had fetched. Each has its own parameter now.
105
+ test("from: and subject: travel as parameters", () => {
106
+ assert.deepEqual(criteriaOf("from:alice"), {
107
+ muted: false,
108
+ from: "alice",
109
+ });
110
+ assert.deepEqual(residualTypes("from:alice"), []);
111
+ assert.deepEqual(criteriaOf("subject:invoice"), {
112
+ muted: false,
113
+ subject: "invoice",
114
+ });
115
+ assert.deepEqual(residualTypes("subject:invoice"), []);
116
+ assert.equal(BRIEF_TOKEN_PARAMS.includes("from"), true);
117
+ assert.equal(BRIEF_TOKEN_PARAMS.includes("subject"), true);
118
+ });
119
+
120
+ // One parameter per field, so a second value cannot be expressed; it drops
121
+ // to the residue rather than being silently lost.
122
+ test("a second from: stays residue", () => {
123
+ assert.deepEqual(criteriaOf("from:alice from:bob"), {
124
+ muted: false,
125
+ from: "alice",
126
+ });
127
+ assert.deepEqual(residualTypes("from:alice from:bob"), ["from"]);
84
128
  });
85
129
 
86
130
  test("dates, the mailbox and the account stay residue", () => {
@@ -92,7 +136,10 @@ describe("briefCriteria", () => {
92
136
  });
93
137
 
94
138
  test("a chip beats the token that contradicts it", () => {
95
- assert.deepEqual(criteriaOf("is:read", ["unread"]), { unread: true });
139
+ assert.deepEqual(criteriaOf("is:read", ["unread"]), {
140
+ muted: false,
141
+ unread: true,
142
+ });
96
143
  assert.deepEqual(residualTypes("is:read", ["unread"]), ["isRead"]);
97
144
  });
98
145
  });
@@ -102,7 +149,6 @@ describe("briefCountsMatchRows", () => {
102
149
  briefCountsMatchRows({
103
150
  residual: [],
104
151
  attributes: new Set<string>(),
105
- accountScoped: false,
106
152
  ...over,
107
153
  });
108
154
 
@@ -112,11 +158,7 @@ describe("briefCountsMatchRows", () => {
112
158
  });
113
159
 
114
160
  test("a residual token makes the count wider than the list", () => {
115
- assert.equal(reach({ residual: tokensOf("from:alice") }), false);
116
- });
117
-
118
- test("the account pills scope the rows and nothing else", () => {
119
- assert.equal(reach({ accountScoped: true }), false);
161
+ assert.equal(reach({ residual: tokensOf("before:2024-01-01") }), false);
120
162
  });
121
163
 
122
164
  test("a chip with no parameter makes the count wider than the list", () => {
@@ -4,13 +4,15 @@
4
4
  * The brief's category scope is not a criterion here: every section is its own
5
5
  * category-scoped request, so the category travels per section rather than in
6
6
  * the shared criteria. What is shared is the attribute chips and the tokens
7
- * typed in the field — `is:unread`, `has:attachment`, `is:starred` — all of them
8
- * columns on the ThreadMessage row, so all of them travel as query parameters
9
- * over the whole scope instead of as a pass over the rows already fetched
10
- * (#312).
7
+ * typed in the field — `from:`, `subject:`, `is:unread`, `has:attachment`,
8
+ * `is:starred` — all of them fields on the ThreadMessage row, so all of them
9
+ * travel as query parameters over the whole scope instead of as a pass over the
10
+ * rows already fetched (#312, #1128).
11
11
  *
12
- * `from:` and `subject:` stay residue: `listAllThreads` has one text parameter
13
- * that matches both at once, so neither can be asked for on its own.
12
+ * The account pill and mute travel with them. Both used to narrow the rows after
13
+ * they arrived, which is what took the number off every section header: a count
14
+ * over every account, muted senders included, is not the size of a list showing
15
+ * one account without them (#1136, #1137).
14
16
  *
15
17
  * Two chips have no parameter anywhere — "From contacts" reads `senderTrust`,
16
18
  * which is off-row (design D7), and "Today" is a date, which no listing
@@ -24,11 +26,14 @@ import { inboxFilterParams } from "./inbox-filters.js";
24
26
  import type { SearchToken } from "./search-tokens.js";
25
27
  import {
26
28
  type ThreadSearchTokenParamName,
29
+ type ThreadSearchTokenParams,
27
30
  threadSearchTokens,
28
31
  } from "./thread-search-tokens.js";
29
32
 
30
33
  /** The token parameters `listAllThreads` carries. */
31
34
  export const BRIEF_TOKEN_PARAMS: readonly ThreadSearchTokenParamName[] = [
35
+ "from",
36
+ "subject",
32
37
  "category",
33
38
  "unread",
34
39
  "starred",
@@ -53,7 +58,12 @@ export const briefClientOnlyFilters = (
53
58
  new Set(BRIEF_CLIENT_ONLY_FILTERS.filter((id) => attributes.has(id)));
54
59
 
55
60
  /** Everything a section request narrows by except its own category. */
56
- export type BriefSectionParams = Omit<InboxFilterParams, "category">;
61
+ export type BriefSectionParams = Omit<ThreadSearchTokenParams, "category"> & {
62
+ /** The account the pills narrow to, or absent for the whole config. */
63
+ accountId?: string;
64
+ /** Always `false` here: the brief renders no muted sender's mail. */
65
+ muted?: boolean;
66
+ };
57
67
 
58
68
  export interface BriefCriteria {
59
69
  /** What every section request carries, chips and carried tokens together. */
@@ -66,19 +76,29 @@ export const briefCriteria = (
66
76
  category: string,
67
77
  attributes: ReadonlySet<string>,
68
78
  tokens: readonly SearchToken[],
79
+ accountId?: string,
69
80
  ): BriefCriteria => {
70
- const chipParams = inboxFilterParams({ category, attributes });
81
+ const chipParams: InboxFilterParams = inboxFilterParams({
82
+ category,
83
+ attributes,
84
+ });
71
85
  const { params, residual } = threadSearchTokens(
72
86
  tokens,
73
87
  chipParams,
74
88
  BRIEF_TOKEN_PARAMS,
75
89
  );
76
90
  const request = { ...params, ...chipParams };
77
- const criteria: BriefSectionParams = {};
91
+ // Mute is not a control the reader sets: the brief is the view that hides
92
+ // muted senders, so every request it makes says so and every count it shows
93
+ // is a count of what it renders.
94
+ const criteria: BriefSectionParams = { muted: false };
95
+ if (request.from !== undefined) criteria.from = request.from;
96
+ if (request.subject !== undefined) criteria.subject = request.subject;
78
97
  if (request.unread !== undefined) criteria.unread = request.unread;
79
98
  if (request.starred !== undefined) criteria.starred = request.starred;
80
99
  if (request.attachments !== undefined)
81
100
  criteria.attachments = request.attachments;
101
+ if (accountId !== undefined) criteria.accountId = accountId;
82
102
  return { criteria, residual };
83
103
  };
84
104
 
@@ -87,24 +107,23 @@ export interface BriefCountReach {
87
107
  residual: readonly SearchToken[];
88
108
  /** Attribute chips currently on. */
89
109
  attributes: ReadonlySet<string>;
90
- /** True while the reader has narrowed the brief to one account. */
91
- accountScoped: boolean;
92
110
  }
93
111
 
94
112
  /**
95
113
  * Whether a section's server count is a count of what the section shows.
96
114
  *
97
115
  * The count answers the request. Anything narrowing the rows after they arrive —
98
- * a residual token, a chip with no parameter, the account pills, which no
99
- * listing endpoint scopes by — makes the count the size of a wider set than the
100
- * list. A wider number rendered as the section's size is a worse reading than no
101
- * number, so the caller asks for none.
116
+ * a residual token, or a chip with no parameter — makes the count the size of a
117
+ * wider set than the list. A wider number rendered as the section's size is a
118
+ * worse reading than no number, so the caller asks for none.
119
+ *
120
+ * The account pills are not among them any more, and neither is mute: both are
121
+ * parameters the request carries, so the count narrows exactly as the list does
122
+ * (#1136, #1137).
102
123
  */
103
124
  export const briefCountsMatchRows = ({
104
125
  residual,
105
126
  attributes,
106
- accountScoped,
107
127
  }: BriefCountReach): boolean =>
108
128
  residual.length === 0 &&
109
- !accountScoped &&
110
129
  !BRIEF_CLIENT_ONLY_FILTERS.some((id) => attributes.has(id));
@@ -11,11 +11,7 @@ import {
11
11
  BRIEF_CATEGORIES,
12
12
  type BriefCategoryResult,
13
13
  briefSections,
14
- briefSectionTotal,
15
- excludeMutedSenders,
16
- matchesBriefSearch,
17
14
  matchesSearchTokens,
18
- mergeSearchRows,
19
15
  toThreadRowData,
20
16
  } from "./brief.js";
21
17
  import type { SearchToken } from "./search-tokens.js";
@@ -288,157 +284,6 @@ describe("briefSections", () => {
288
284
  });
289
285
  });
290
286
 
291
- // issue #301: `Address.flags.muted` is denormalized onto each row as
292
- // `muted`; the brief excludes those rows before grouping into sections.
293
- describe("excludeMutedSenders", () => {
294
- test("drops a row whose sender is muted", () => {
295
- const kept = threadResponse({ threadMessageId: "keep" });
296
- const muted = threadResponse({ threadMessageId: "mute-me", muted: true });
297
- const result = excludeMutedSenders([kept, muted]);
298
- assert.deepStrictEqual(
299
- result.map((t) => t.threadMessageId),
300
- ["keep"],
301
- );
302
- });
303
-
304
- test("keeps rows whose sender is not muted", () => {
305
- const rows = [
306
- threadResponse({ threadMessageId: "a", muted: false }),
307
- threadResponse({ threadMessageId: "b" }),
308
- ];
309
- assert.strictEqual(excludeMutedSenders(rows).length, 2);
310
- });
311
-
312
- test("returns an empty array when every sender is muted", () => {
313
- const rows = [
314
- threadResponse({ threadMessageId: "a", muted: true }),
315
- threadResponse({ threadMessageId: "b", muted: true }),
316
- ];
317
- assert.deepStrictEqual(excludeMutedSenders(rows), []);
318
- });
319
-
320
- test("a muted sender's message is excluded from every section, not folded into uncategorized", () => {
321
- const rows = [
322
- threadResponse({
323
- threadMessageId: "muted-personal",
324
- messageId: "muted-personal",
325
- category: "personal",
326
- muted: true,
327
- }),
328
- threadResponse({
329
- threadMessageId: "kept-personal",
330
- messageId: "kept-personal",
331
- category: "personal",
332
- }),
333
- ];
334
- const kept = excludeMutedSenders(rows).map(toThreadRowData);
335
- const sections = briefSections([
336
- {
337
- category: "personal",
338
- rows: kept,
339
- total: { kind: "exact", value: kept.length },
340
- atCap: false,
341
- loading: false,
342
- failed: false,
343
- },
344
- ]);
345
- const allIds = sections.flatMap((s) => s.threads.map((t) => t.id));
346
- assert.deepStrictEqual(allIds, ["kept-personal"]);
347
- });
348
-
349
- test("muting every candidate row leaves no sections (brief empty state)", () => {
350
- const rows = [
351
- threadResponse({
352
- threadMessageId: "a",
353
- messageId: "a",
354
- category: "personal",
355
- muted: true,
356
- }),
357
- threadResponse({
358
- threadMessageId: "b",
359
- messageId: "b",
360
- category: "newsletter",
361
- muted: true,
362
- }),
363
- ];
364
- const kept = excludeMutedSenders(rows).map(toThreadRowData);
365
- const sections = briefSections([
366
- {
367
- category: "personal",
368
- rows: kept,
369
- total: { kind: "exact", value: 0 },
370
- atCap: false,
371
- loading: false,
372
- failed: false,
373
- },
374
- ]);
375
- assert.deepStrictEqual(sections, []);
376
- });
377
- });
378
-
379
- // The count is the server's, taken over a scope that includes senders the reader
380
- // muted — `listAllThreads` has no `muted` parameter. The list drops their mail,
381
- // so the number would overstate what is on screen.
382
- describe("briefSectionTotal", () => {
383
- const counted: ResultCount = { kind: "exact", value: 4753 };
384
-
385
- test("keeps the count when no sender in the rows is muted", () => {
386
- assert.deepStrictEqual(
387
- briefSectionTotal(counted, [
388
- threadResponse({ threadMessageId: "a" }),
389
- threadResponse({ threadMessageId: "b", muted: false }),
390
- ]),
391
- counted,
392
- );
393
- });
394
-
395
- test("withholds the count when the rows show a muted sender", () => {
396
- assert.deepStrictEqual(
397
- briefSectionTotal(counted, [
398
- threadResponse({ threadMessageId: "a" }),
399
- threadResponse({ threadMessageId: "b", muted: true }),
400
- ]),
401
- { kind: "unknown" },
402
- );
403
- });
404
-
405
- test("a count nobody took stays absent", () => {
406
- assert.deepStrictEqual(briefSectionTotal({ kind: "unknown" }, []), {
407
- kind: "unknown",
408
- });
409
- });
410
- });
411
-
412
- describe("matchesBriefSearch", () => {
413
- const r = row({
414
- id: "1",
415
- fromName: "Alice Tan",
416
- fromEmail: "alice@example.com",
417
- subject: "Q3 roadmap",
418
- snippet: "See the attached deck",
419
- });
420
-
421
- test("matches on fromName (case-insensitive)", () => {
422
- assert.strictEqual(matchesBriefSearch(r, "alice"), true);
423
- });
424
-
425
- test("matches on fromEmail", () => {
426
- assert.strictEqual(matchesBriefSearch(r, "alice@example"), true);
427
- });
428
-
429
- test("matches on subject", () => {
430
- assert.strictEqual(matchesBriefSearch(r, "roadmap"), true);
431
- });
432
-
433
- test("matches on snippet", () => {
434
- assert.strictEqual(matchesBriefSearch(r, "deck"), true);
435
- });
436
-
437
- test("returns false when query matches nothing", () => {
438
- assert.strictEqual(matchesBriefSearch(r, "zyxwvuts"), false);
439
- });
440
- });
441
-
442
287
  describe("matchesSearchTokens", () => {
443
288
  const from = (value: string): SearchToken => ({
444
289
  type: "from",
@@ -646,82 +491,3 @@ describe("matchesSearchTokens", () => {
646
491
  );
647
492
  });
648
493
  });
649
-
650
- // #49: a listing complete under its own criteria still cannot see a snippet
651
- // match, and the server's cross-folder search cannot see one either. The two
652
- // halves are merged. The brief no longer uses this — its sections are pages, and
653
- // merging a page with a search window orders two truncated lists (#312).
654
- describe("mergeSearchRows", () => {
655
- test("keeps rows the server found in other folders", () => {
656
- const merged = mergeSearchRows(
657
- [row({ id: "inbox-hit", sentDate: 300 })],
658
- [
659
- row({ id: "archive-hit", sentDate: 200 }),
660
- row({ id: "spam-hit", sentDate: 100 }),
661
- ],
662
- );
663
-
664
- assert.deepEqual(
665
- merged.map((r) => r.id),
666
- ["inbox-hit", "archive-hit", "spam-hit"],
667
- );
668
- });
669
-
670
- test("keeps a snippet-only match the server cannot see", () => {
671
- const merged = mergeSearchRows(
672
- [row({ id: "snippet-only", sentDate: 100 })],
673
- [],
674
- );
675
-
676
- assert.deepEqual(
677
- merged.map((r) => r.id),
678
- ["snippet-only"],
679
- );
680
- });
681
-
682
- test("the two sources overlap on INBOX, so rows are deduped", () => {
683
- const merged = mergeSearchRows(
684
- [row({ id: "shared", sentDate: 200, subject: "from the brief" })],
685
- [
686
- row({ id: "shared", sentDate: 200, subject: "from the search" }),
687
- row({ id: "archive-hit", sentDate: 100 }),
688
- ],
689
- );
690
-
691
- assert.deepEqual(
692
- merged.map((r) => r.id),
693
- ["shared", "archive-hit"],
694
- );
695
- assert.equal(merged[0].subject, "from the brief");
696
- });
697
-
698
- test("the union reads newest first, interleaving both sources", () => {
699
- const merged = mergeSearchRows(
700
- [
701
- row({ id: "brief-new", sentDate: 400 }),
702
- row({ id: "brief-old", sentDate: 200 }),
703
- ],
704
- [
705
- row({ id: "search-mid", sentDate: 300 }),
706
- row({ id: "search-oldest", sentDate: 100 }),
707
- ],
708
- );
709
-
710
- assert.deepEqual(
711
- merged.map((r) => r.id),
712
- ["brief-new", "search-mid", "brief-old", "search-oldest"],
713
- );
714
- });
715
-
716
- test("a row without a sentDate sorts last", () => {
717
- const merged = mergeSearchRows(
718
- [row({ id: "undated" })],
719
- [row({ id: "dated", sentDate: 100 })],
720
- );
721
-
722
- assert.deepEqual(
723
- merged.map((r) => r.id),
724
- ["dated", "undated"],
725
- );
726
- });
727
- });