@remit/web-client 0.0.274 → 0.0.276
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 +1 -1
- package/src/components/mail/MailSidebarAdapter.tsx +3 -46
- package/src/hooks/useInitialSyncProgress.render.test.ts +1 -0
- package/src/lib/mail-freshness.test.ts +64 -45
- package/src/lib/mail-freshness.tsx +43 -32
- package/src/lib/search-view.test.ts +1 -1
- package/src/routing/search-mirror-convergence.render.test.ts +1 -1
- package/src/routing/search-navigation.ts +3 -4
- package/src/lib/saved-searches.test.ts +0 -80
- package/src/lib/saved-searches.ts +0 -52
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.276",
|
|
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": {
|
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
import type { NavAccount, NavLinkComponent, NavMailboxRole } from "@remit/ui";
|
|
11
11
|
import { NavSidebar } from "@remit/ui";
|
|
12
12
|
import { useQueries, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
13
|
-
import { useCallback, useMemo
|
|
13
|
+
import { useCallback, useMemo } from "react";
|
|
14
14
|
import { useTranslation } from "react-i18next";
|
|
15
15
|
import {
|
|
16
16
|
buildMailboxRoleMap,
|
|
@@ -19,12 +19,7 @@ import {
|
|
|
19
19
|
} from "@/lib/folder-roles";
|
|
20
20
|
import { useMailContext } from "@/lib/mail-context";
|
|
21
21
|
import { isOutboxListRow } from "@/lib/outbox-status";
|
|
22
|
-
import {
|
|
23
|
-
loadSavedSearches,
|
|
24
|
-
removeSavedSearch,
|
|
25
|
-
saveSearch,
|
|
26
|
-
} from "@/lib/saved-searches";
|
|
27
|
-
import { NavLink, useSearchEverything, useSelectedNavId } from "@/routing";
|
|
22
|
+
import { NavLink, useSelectedNavId } from "@/routing";
|
|
28
23
|
|
|
29
24
|
interface MailSidebarAdapterProps {
|
|
30
25
|
accounts: RemitImapAccountResponse[];
|
|
@@ -123,10 +118,8 @@ export function MailSidebarAdapter({
|
|
|
123
118
|
variant = "desktop",
|
|
124
119
|
}: MailSidebarAdapterProps) {
|
|
125
120
|
const queryClient = useQueryClient();
|
|
126
|
-
const searchEverything = useSearchEverything();
|
|
127
121
|
const selectedNavId = useSelectedNavId();
|
|
128
|
-
const {
|
|
129
|
-
const [savedSearches, setSavedSearches] = useState(loadSavedSearches);
|
|
122
|
+
const { onSearchChange } = useMailContext();
|
|
130
123
|
const { t } = useTranslation("mail", { useSuspense: false });
|
|
131
124
|
const translator: Translator = useCallback(
|
|
132
125
|
(key, fallback) => t(key, { defaultValue: fallback }),
|
|
@@ -311,37 +304,6 @@ export function MailSidebarAdapter({
|
|
|
311
304
|
onMailboxSelect?.();
|
|
312
305
|
};
|
|
313
306
|
|
|
314
|
-
// Saved searches (#428 follow-up, local-only MVP — see
|
|
315
|
-
// doc/design/flows/06-search.md). A saved query is the raw typed text; the
|
|
316
|
-
// existing token parser re-derives its chips on reselect, so there's nothing
|
|
317
|
-
// else to persist.
|
|
318
|
-
const trimmedSearchInput = searchInput.trim();
|
|
319
|
-
const saveableQuery =
|
|
320
|
-
trimmedSearchInput.length > 0 && !savedSearches.includes(trimmedSearchInput)
|
|
321
|
-
? trimmedSearchInput
|
|
322
|
-
: undefined;
|
|
323
|
-
|
|
324
|
-
const handleSaveCurrentSearch = useCallback(() => {
|
|
325
|
-
if (!saveableQuery) return;
|
|
326
|
-
setSavedSearches(saveSearch(saveableQuery));
|
|
327
|
-
}, [saveableQuery]);
|
|
328
|
-
|
|
329
|
-
const handleRemoveSavedSearch = useCallback((query: string) => {
|
|
330
|
-
setSavedSearches(removeSavedSearch(query));
|
|
331
|
-
}, []);
|
|
332
|
-
|
|
333
|
-
// Running a saved search re-uses the daily brief as the search surface (the
|
|
334
|
-
// cross-account default view) — the same destination the search field
|
|
335
|
-
// itself lands results in once a query is active.
|
|
336
|
-
const handleSelectSavedSearch = useCallback(
|
|
337
|
-
(query: string) => {
|
|
338
|
-
onSearchChange(query);
|
|
339
|
-
searchEverything(query);
|
|
340
|
-
onMailboxSelect?.();
|
|
341
|
-
},
|
|
342
|
-
[onSearchChange, searchEverything, onMailboxSelect],
|
|
343
|
-
);
|
|
344
|
-
|
|
345
307
|
return (
|
|
346
308
|
<NavSidebar
|
|
347
309
|
accounts={navAccounts}
|
|
@@ -350,11 +312,6 @@ export function MailSidebarAdapter({
|
|
|
350
312
|
linkComponent={linkComponent}
|
|
351
313
|
calendarNav="shown"
|
|
352
314
|
variant={variant}
|
|
353
|
-
savedSearches={savedSearches}
|
|
354
|
-
saveableQuery={saveableQuery}
|
|
355
|
-
onSaveCurrentSearch={handleSaveCurrentSearch}
|
|
356
|
-
onSelectSavedSearch={handleSelectSavedSearch}
|
|
357
|
-
onRemoveSavedSearch={handleRemoveSavedSearch}
|
|
358
315
|
/>
|
|
359
316
|
);
|
|
360
317
|
}
|
|
@@ -3,17 +3,23 @@ import { describe, test } from "node:test";
|
|
|
3
3
|
import type { RemitImapMailboxSyncProgress } from "@remit/api-http-client/types.gen.ts";
|
|
4
4
|
import type { NavMailboxRole } from "@remit/ui";
|
|
5
5
|
import type { ResultFolderIndex } from "@/lib/result-folder";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
grownMailboxIds,
|
|
8
|
+
hasGrown,
|
|
9
|
+
highWaterMarksFrom,
|
|
10
|
+
} from "./mail-freshness.js";
|
|
7
11
|
|
|
8
12
|
const mailbox = (
|
|
9
13
|
mailboxId: string,
|
|
10
14
|
messagesTotal: number,
|
|
15
|
+
highWaterMarkUid: number,
|
|
11
16
|
): RemitImapMailboxSyncProgress => ({
|
|
12
17
|
mailboxId,
|
|
13
18
|
fullPath: mailboxId,
|
|
14
19
|
phase: "complete",
|
|
15
20
|
messagesTotal,
|
|
16
21
|
messagesSynced: messagesTotal,
|
|
22
|
+
highWaterMarkUid,
|
|
17
23
|
});
|
|
18
24
|
|
|
19
25
|
const roles = (
|
|
@@ -25,30 +31,24 @@ const roles = (
|
|
|
25
31
|
|
|
26
32
|
describe("hasGrown", () => {
|
|
27
33
|
test("false when nothing changed", () => {
|
|
28
|
-
const baseline = new Map([["mb-1",
|
|
29
|
-
const current = new Map([["mb-1",
|
|
34
|
+
const baseline = new Map([["mb-1", 100]]);
|
|
35
|
+
const current = new Map([["mb-1", 100]]);
|
|
30
36
|
assert.equal(hasGrown(baseline, current), false);
|
|
31
37
|
});
|
|
32
38
|
|
|
33
|
-
test("true when a mailbox
|
|
34
|
-
const baseline = new Map([["mb-1",
|
|
35
|
-
const current = new Map([["mb-1",
|
|
39
|
+
test("true when a mailbox took a UID above the highest one seen", () => {
|
|
40
|
+
const baseline = new Map([["mb-1", 100]]);
|
|
41
|
+
const current = new Map([["mb-1", 101]]);
|
|
36
42
|
assert.equal(hasGrown(baseline, current), true);
|
|
37
43
|
});
|
|
38
44
|
|
|
39
|
-
test("false when a mailbox's total dropped (reads, deletes) — not an arrival", () => {
|
|
40
|
-
const baseline = new Map([["mb-1", 10]]);
|
|
41
|
-
const current = new Map([["mb-1", 9]]);
|
|
42
|
-
assert.equal(hasGrown(baseline, current), false);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
45
|
test("a mailbox missing from the baseline counts from zero", () => {
|
|
46
46
|
const baseline = new Map<string, number>();
|
|
47
47
|
const current = new Map([["mb-1", 1]]);
|
|
48
48
|
assert.equal(hasGrown(baseline, current), true);
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
-
test("a mailbox
|
|
51
|
+
test("a mailbox that has never taken a message is not growth", () => {
|
|
52
52
|
const baseline = new Map<string, number>();
|
|
53
53
|
const current = new Map([["mb-1", 0]]);
|
|
54
54
|
assert.equal(hasGrown(baseline, current), false);
|
|
@@ -56,59 +56,78 @@ describe("hasGrown", () => {
|
|
|
56
56
|
|
|
57
57
|
test("one mailbox growing is enough even if others are unchanged", () => {
|
|
58
58
|
const baseline = new Map([
|
|
59
|
-
["mb-1",
|
|
60
|
-
["mb-2",
|
|
59
|
+
["mb-1", 100],
|
|
60
|
+
["mb-2", 50],
|
|
61
61
|
]);
|
|
62
62
|
const current = new Map([
|
|
63
|
-
["mb-1",
|
|
64
|
-
["mb-2",
|
|
63
|
+
["mb-1", 100],
|
|
64
|
+
["mb-2", 51],
|
|
65
65
|
]);
|
|
66
66
|
assert.equal(hasGrown(baseline, current), true);
|
|
67
67
|
});
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
-
describe("
|
|
70
|
+
describe("highWaterMarksFrom", () => {
|
|
71
71
|
test("keeps a mailbox with no resolved role (a custom folder)", () => {
|
|
72
|
-
const
|
|
73
|
-
assert.deepEqual([...
|
|
72
|
+
const marks = highWaterMarksFrom([mailbox("mb-custom", 5, 42)], roles({}));
|
|
73
|
+
assert.deepEqual([...marks], [["mb-custom", 42]]);
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
test("keeps Inbox and Junk — both are places new mail actually arrives", () => {
|
|
77
|
-
const
|
|
78
|
-
[mailbox("mb-inbox", 5), mailbox("mb-junk", 2)],
|
|
77
|
+
const marks = highWaterMarksFrom(
|
|
78
|
+
[mailbox("mb-inbox", 5, 42), mailbox("mb-junk", 2, 7)],
|
|
79
79
|
roles({ "mb-inbox": "inbox", "mb-junk": "junk" }),
|
|
80
80
|
);
|
|
81
|
-
assert.deepEqual(new Set(
|
|
81
|
+
assert.deepEqual(new Set(marks.keys()), new Set(["mb-inbox", "mb-junk"]));
|
|
82
82
|
});
|
|
83
83
|
|
|
84
84
|
for (const role of ["sent", "drafts", "trash", "archive"] as const) {
|
|
85
|
-
test(`drops ${role} — its own
|
|
86
|
-
const
|
|
87
|
-
|
|
85
|
+
test(`drops ${role} — its own new messages are the user's doing, not an arrival`, () => {
|
|
86
|
+
const marks = highWaterMarksFrom(
|
|
87
|
+
[mailbox("mb-1", 5, 42)],
|
|
88
|
+
roles({ "mb-1": role }),
|
|
89
|
+
);
|
|
90
|
+
assert.equal(marks.has("mb-1"), false);
|
|
88
91
|
});
|
|
89
92
|
}
|
|
90
93
|
|
|
91
|
-
test("a delete (Inbox shrinks, Trash grows) never reads as
|
|
94
|
+
test("a delete (Inbox shrinks, Trash grows) never reads as an arrival", () => {
|
|
92
95
|
const folders = roles({ "mb-inbox": "inbox", "mb-trash": "trash" });
|
|
93
|
-
const baseline =
|
|
94
|
-
[mailbox("mb-inbox", 10), mailbox("mb-trash", 3)],
|
|
96
|
+
const baseline = highWaterMarksFrom(
|
|
97
|
+
[mailbox("mb-inbox", 10, 100), mailbox("mb-trash", 3, 20)],
|
|
95
98
|
folders,
|
|
96
99
|
);
|
|
97
|
-
const afterDelete =
|
|
98
|
-
[mailbox("mb-inbox", 9), mailbox("mb-trash", 4)],
|
|
100
|
+
const afterDelete = highWaterMarksFrom(
|
|
101
|
+
[mailbox("mb-inbox", 9, 100), mailbox("mb-trash", 4, 21)],
|
|
99
102
|
folders,
|
|
100
103
|
);
|
|
101
104
|
assert.equal(hasGrown(baseline, afterDelete), false);
|
|
102
105
|
});
|
|
103
106
|
|
|
104
|
-
|
|
107
|
+
// Issue #771: the arrival and the departure land in the same sync round, so
|
|
108
|
+
// the Inbox total nets to zero and the count-based reading saw nothing. The
|
|
109
|
+
// arrival is a UID above the highest one seen, and that is what surfaces it.
|
|
110
|
+
test("an arrival alongside a departure surfaces, though the total is unchanged", () => {
|
|
111
|
+
const folders = roles({ "mb-inbox": "inbox", "mb-trash": "trash" });
|
|
112
|
+
const baseline = highWaterMarksFrom(
|
|
113
|
+
[mailbox("mb-inbox", 10, 100), mailbox("mb-trash", 3, 20)],
|
|
114
|
+
folders,
|
|
115
|
+
);
|
|
116
|
+
const afterArrivalAndDeparture = highWaterMarksFrom(
|
|
117
|
+
[mailbox("mb-inbox", 10, 101), mailbox("mb-trash", 4, 21)],
|
|
118
|
+
folders,
|
|
119
|
+
);
|
|
120
|
+
assert.equal(hasGrown(baseline, afterArrivalAndDeparture), true);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("real new mail in Inbox still reads as an arrival alongside an unrelated delete", () => {
|
|
105
124
|
const folders = roles({ "mb-inbox": "inbox", "mb-trash": "trash" });
|
|
106
|
-
const baseline =
|
|
107
|
-
[mailbox("mb-inbox", 10), mailbox("mb-trash", 3)],
|
|
125
|
+
const baseline = highWaterMarksFrom(
|
|
126
|
+
[mailbox("mb-inbox", 10, 100), mailbox("mb-trash", 3, 20)],
|
|
108
127
|
folders,
|
|
109
128
|
);
|
|
110
|
-
const afterArrivalAndDelete =
|
|
111
|
-
[mailbox("mb-inbox", 11), mailbox("mb-trash", 4)],
|
|
129
|
+
const afterArrivalAndDelete = highWaterMarksFrom(
|
|
130
|
+
[mailbox("mb-inbox", 11, 101), mailbox("mb-trash", 4, 21)],
|
|
112
131
|
folders,
|
|
113
132
|
);
|
|
114
133
|
assert.equal(hasGrown(baseline, afterArrivalAndDelete), true);
|
|
@@ -118,16 +137,16 @@ describe("totalsFrom", () => {
|
|
|
118
137
|
describe("grownMailboxIds", () => {
|
|
119
138
|
// The invalidation half of the same reading: which folders' listings are
|
|
120
139
|
// now behind the server, so an open tab reloads exactly those.
|
|
121
|
-
test("names only the mailboxes
|
|
140
|
+
test("names only the mailboxes that took a higher UID", () => {
|
|
122
141
|
const baseline = new Map([
|
|
123
|
-
["mb-1",
|
|
124
|
-
["mb-2",
|
|
125
|
-
["mb-3",
|
|
142
|
+
["mb-1", 100],
|
|
143
|
+
["mb-2", 40],
|
|
144
|
+
["mb-3", 70],
|
|
126
145
|
]);
|
|
127
146
|
const current = new Map([
|
|
128
|
-
["mb-1",
|
|
129
|
-
["mb-2",
|
|
130
|
-
["mb-3",
|
|
147
|
+
["mb-1", 120],
|
|
148
|
+
["mb-2", 40],
|
|
149
|
+
["mb-3", 70],
|
|
131
150
|
]);
|
|
132
151
|
|
|
133
152
|
assert.deepEqual(grownMailboxIds(baseline, current), ["mb-1"]);
|
|
@@ -140,7 +159,7 @@ describe("grownMailboxIds", () => {
|
|
|
140
159
|
});
|
|
141
160
|
|
|
142
161
|
test("is empty when nothing moved", () => {
|
|
143
|
-
const
|
|
144
|
-
assert.deepEqual(grownMailboxIds(
|
|
162
|
+
const marks = new Map([["mb-1", 100]]);
|
|
163
|
+
assert.deepEqual(grownMailboxIds(marks, new Map(marks)), []);
|
|
145
164
|
});
|
|
146
165
|
});
|
|
@@ -29,9 +29,9 @@ import {
|
|
|
29
29
|
export const FRESHNESS_POLL_MS = 60_000;
|
|
30
30
|
|
|
31
31
|
/**
|
|
32
|
-
* Roles whose own
|
|
33
|
-
*
|
|
34
|
-
*
|
|
32
|
+
* Roles whose own new messages are the user's doing, not an arrival: sending
|
|
33
|
+
* appends to Sent, deleting or archiving appends to Trash/Archive, and a Draft
|
|
34
|
+
* is an autosave. Counting any of these as "new mail" would light the dot on a
|
|
35
35
|
* message the user just moved themselves. Junk is kept in — spam is still an
|
|
36
36
|
* arrival, just an unwanted one — and a mailbox with no resolved role (a
|
|
37
37
|
* custom folder, or one a filter files mail into directly) is never excluded.
|
|
@@ -43,43 +43,51 @@ const EXCLUDED_GROWTH_ROLES: ReadonlySet<NavMailboxRole> = new Set([
|
|
|
43
43
|
"archive",
|
|
44
44
|
]);
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
/**
|
|
47
|
+
* Each watched mailbox's highest UID. A total cannot answer "did mail arrive":
|
|
48
|
+
* a message landing in the round that another one leaves nets to zero, so the
|
|
49
|
+
* arrival was invisible (#771). The read model widens that window, because a
|
|
50
|
+
* local delete does not decrement the stored count — only the next sync does,
|
|
51
|
+
* and that same sync carries the arrival. The highest UID only moves up, so it
|
|
52
|
+
* names the arrival on its own.
|
|
53
|
+
*/
|
|
54
|
+
type MailboxHighWaterMarks = ReadonlyMap<string, number>;
|
|
47
55
|
|
|
48
|
-
export const
|
|
56
|
+
export const highWaterMarksFrom = (
|
|
49
57
|
mailboxes: readonly RemitImapMailboxSyncProgress[],
|
|
50
58
|
resultFolderIndex: ResultFolderIndex,
|
|
51
|
-
):
|
|
52
|
-
const
|
|
59
|
+
): MailboxHighWaterMarks => {
|
|
60
|
+
const marks = new Map<string, number>();
|
|
53
61
|
for (const mailbox of mailboxes) {
|
|
54
62
|
const role = resultFolderIndex.get(mailbox.mailboxId)?.role;
|
|
55
63
|
if (role && EXCLUDED_GROWTH_ROLES.has(role)) continue;
|
|
56
|
-
|
|
64
|
+
marks.set(mailbox.mailboxId, mailbox.highWaterMarkUid);
|
|
57
65
|
}
|
|
58
|
-
return
|
|
66
|
+
return marks;
|
|
59
67
|
};
|
|
60
68
|
|
|
61
69
|
/**
|
|
62
|
-
* Which mailboxes
|
|
63
|
-
* arrive" call is unit-testable without a DOM or a QueryClient. A
|
|
64
|
-
* missing from the baseline (new folder, first-ever reading) counts
|
|
65
|
-
* rather than being skipped, so mail landing in a folder created
|
|
66
|
-
* look still counts as an arrival.
|
|
70
|
+
* Which mailboxes took a UID above the highest one in the baseline. Pure so the
|
|
71
|
+
* "did mail arrive" call is unit-testable without a DOM or a QueryClient. A
|
|
72
|
+
* mailbox missing from the baseline (new folder, first-ever reading) counts
|
|
73
|
+
* from zero rather than being skipped, so mail landing in a folder created
|
|
74
|
+
* since the last look still counts as an arrival.
|
|
67
75
|
*/
|
|
68
76
|
export const grownMailboxIds = (
|
|
69
|
-
baseline:
|
|
70
|
-
current:
|
|
77
|
+
baseline: MailboxHighWaterMarks,
|
|
78
|
+
current: MailboxHighWaterMarks,
|
|
71
79
|
): string[] => {
|
|
72
80
|
const grown: string[] = [];
|
|
73
|
-
for (const [mailboxId,
|
|
74
|
-
if (
|
|
81
|
+
for (const [mailboxId, uid] of current) {
|
|
82
|
+
if (uid > (baseline.get(mailboxId) ?? 0)) grown.push(mailboxId);
|
|
75
83
|
}
|
|
76
84
|
return grown;
|
|
77
85
|
};
|
|
78
86
|
|
|
79
|
-
/** Whether any mailbox
|
|
87
|
+
/** Whether any mailbox took a new message at all — the dot's own question. */
|
|
80
88
|
export const hasGrown = (
|
|
81
|
-
baseline:
|
|
82
|
-
current:
|
|
89
|
+
baseline: MailboxHighWaterMarks,
|
|
90
|
+
current: MailboxHighWaterMarks,
|
|
83
91
|
): boolean => grownMailboxIds(baseline, current).length > 0;
|
|
84
92
|
|
|
85
93
|
interface MailFreshnessContextValue {
|
|
@@ -116,10 +124,10 @@ interface MailFreshnessProviderProps {
|
|
|
116
124
|
* 4). The flag is sticky until acknowledged: it lights the dot on whichever
|
|
117
125
|
* `RefreshButton` reads this context and stays lit until a refresh clears it.
|
|
118
126
|
*
|
|
119
|
-
* A tick that sees a folder
|
|
120
|
-
* listing. It reorders nothing and asks the mail server for
|
|
121
|
-
* message is already on our side, so an open list showing it is a
|
|
122
|
-
* not IMAP work. The dot is still the account-level signal.
|
|
127
|
+
* A tick that sees a folder take a new highest UID also invalidates that
|
|
128
|
+
* folder's thread listing. It reorders nothing and asks the mail server for
|
|
129
|
+
* nothing — the message is already on our side, so an open list showing it is a
|
|
130
|
+
* cache read, not IMAP work. The dot is still the account-level signal.
|
|
123
131
|
*/
|
|
124
132
|
export function MailFreshnessProvider({
|
|
125
133
|
accountIds,
|
|
@@ -127,12 +135,12 @@ export function MailFreshnessProvider({
|
|
|
127
135
|
}: MailFreshnessProviderProps) {
|
|
128
136
|
const queryClient = useQueryClient();
|
|
129
137
|
const { resultFolderIndex } = useMailContext();
|
|
130
|
-
const baselineRef = useRef(new Map<string,
|
|
138
|
+
const baselineRef = useRef(new Map<string, MailboxHighWaterMarks>());
|
|
131
139
|
const seededRef = useRef(new Set<string>());
|
|
132
140
|
// The previous tick's reading, distinct from the dot's baseline: the dot's
|
|
133
141
|
// baseline is sticky until acknowledged, so comparing against it would
|
|
134
142
|
// re-invalidate on every tick for as long as the dot stays lit.
|
|
135
|
-
const
|
|
143
|
+
const previousMarksRef = useRef(new Map<string, MailboxHighWaterMarks>());
|
|
136
144
|
const [newMail, setNewMail] = useState<ReadonlySet<string>>(new Set());
|
|
137
145
|
|
|
138
146
|
const queries = useQueries({
|
|
@@ -153,14 +161,17 @@ export function MailFreshnessProvider({
|
|
|
153
161
|
accountIds.forEach((accountId, index) => {
|
|
154
162
|
const data = queries[index]?.data;
|
|
155
163
|
if (!data) return;
|
|
156
|
-
const current =
|
|
164
|
+
const current = highWaterMarksFrom(
|
|
165
|
+
data.mailboxes ?? [],
|
|
166
|
+
resultFolderIndex,
|
|
167
|
+
);
|
|
157
168
|
|
|
158
169
|
// Mail that has already landed on the server costs nothing more to
|
|
159
|
-
// show, so a folder
|
|
170
|
+
// show, so a folder that took a UID above the last tick's reloads
|
|
160
171
|
// its listing here. That caps how stale an open tab's list can get
|
|
161
172
|
// at this poll's own interval, without a single extra IMAP round.
|
|
162
|
-
const previous =
|
|
163
|
-
|
|
173
|
+
const previous = previousMarksRef.current.get(accountId);
|
|
174
|
+
previousMarksRef.current.set(accountId, current);
|
|
164
175
|
if (previous) {
|
|
165
176
|
const grown = grownMailboxIds(previous, current);
|
|
166
177
|
if (grown.length > 0) {
|
|
@@ -208,7 +219,7 @@ export function MailFreshnessProvider({
|
|
|
208
219
|
| undefined;
|
|
209
220
|
baselineRef.current.set(
|
|
210
221
|
accountId,
|
|
211
|
-
|
|
222
|
+
highWaterMarksFrom(data?.mailboxes ?? [], resultFolderIndex),
|
|
212
223
|
);
|
|
213
224
|
seededRef.current.add(accountId);
|
|
214
225
|
}
|
|
@@ -38,7 +38,7 @@ describe("searchInputForView", () => {
|
|
|
38
38
|
);
|
|
39
39
|
});
|
|
40
40
|
|
|
41
|
-
it("seeds from the destination's own query (deep link
|
|
41
|
+
it("seeds from the destination's own query (deep link)", () => {
|
|
42
42
|
assert.equal(
|
|
43
43
|
searchInputForView(
|
|
44
44
|
"/mail/$mailboxId:inbox-1",
|
|
@@ -184,7 +184,7 @@ describe("an address that drifts from the settled query", () => {
|
|
|
184
184
|
});
|
|
185
185
|
|
|
186
186
|
it("does not fight a query arriving mid-debounce", async () => {
|
|
187
|
-
// A deep link
|
|
187
|
+
// A deep link lands while the field is still catching
|
|
188
188
|
// up. The committed query is the previous one, so the mirror has nothing
|
|
189
189
|
// settled to write and must not strip what just arrived.
|
|
190
190
|
const router = buildRouter(`${LIST_PATH}?q=receipts`);
|
|
@@ -5,10 +5,9 @@ import { useCallback } from "react";
|
|
|
5
5
|
* Search everything, carrying the query.
|
|
6
6
|
*
|
|
7
7
|
* "Everything" is the daily brief — the cross-account view whose scope is
|
|
8
|
-
* nothing — so dropping the scope chip
|
|
9
|
-
*
|
|
10
|
-
* the
|
|
11
|
-
* would leave the chip up and the list still narrowed.
|
|
8
|
+
* nothing — so dropping the scope chip is a navigation rather than an edit of
|
|
9
|
+
* the query text: the chip mirrors the route (see `lib/search-scope.ts`), so
|
|
10
|
+
* editing the text would leave the chip up and the list still narrowed.
|
|
12
11
|
*/
|
|
13
12
|
export function useSearchEverything(): (query: string) => void {
|
|
14
13
|
const navigate = useNavigate();
|
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import assert from "node:assert";
|
|
2
|
-
import { beforeEach, describe, test } from "node:test";
|
|
3
|
-
import {
|
|
4
|
-
isSearchSaved,
|
|
5
|
-
loadSavedSearches,
|
|
6
|
-
removeSavedSearch,
|
|
7
|
-
saveSearch,
|
|
8
|
-
} from "./saved-searches.js";
|
|
9
|
-
|
|
10
|
-
function installMemoryStorage(): void {
|
|
11
|
-
const store = new Map<string, string>();
|
|
12
|
-
globalThis.localStorage = {
|
|
13
|
-
getItem: (k: string) => store.get(k) ?? null,
|
|
14
|
-
setItem: (k: string, v: string) => void store.set(k, v),
|
|
15
|
-
removeItem: (k: string) => void store.delete(k),
|
|
16
|
-
clear: () => store.clear(),
|
|
17
|
-
key: () => null,
|
|
18
|
-
length: 0,
|
|
19
|
-
} as Storage;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
describe("saved-searches", () => {
|
|
23
|
-
beforeEach(installMemoryStorage);
|
|
24
|
-
|
|
25
|
-
test("starts empty", () => {
|
|
26
|
-
assert.deepEqual(loadSavedSearches(), []);
|
|
27
|
-
});
|
|
28
|
-
|
|
29
|
-
test("saves and loads a query", () => {
|
|
30
|
-
assert.deepEqual(saveSearch("from:alice has:attachment"), [
|
|
31
|
-
"from:alice has:attachment",
|
|
32
|
-
]);
|
|
33
|
-
assert.deepEqual(loadSavedSearches(), ["from:alice has:attachment"]);
|
|
34
|
-
});
|
|
35
|
-
|
|
36
|
-
test("most recent save first, deduplicated", () => {
|
|
37
|
-
saveSearch("alice");
|
|
38
|
-
saveSearch("bob");
|
|
39
|
-
assert.deepEqual(saveSearch("alice"), ["alice", "bob"]);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
test("ignores blank queries", () => {
|
|
43
|
-
saveSearch("alice");
|
|
44
|
-
assert.deepEqual(saveSearch(" "), ["alice"]);
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
test("trims before storing", () => {
|
|
48
|
-
assert.deepEqual(saveSearch(" bob "), ["bob"]);
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
test("survives corrupt storage", () => {
|
|
52
|
-
globalThis.localStorage.setItem("remit.savedSearches", "{not json");
|
|
53
|
-
assert.deepEqual(loadSavedSearches(), []);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test("removeSavedSearch drops the exact query", () => {
|
|
57
|
-
saveSearch("alice");
|
|
58
|
-
saveSearch("bob");
|
|
59
|
-
assert.deepEqual(removeSavedSearch("alice"), ["bob"]);
|
|
60
|
-
assert.deepEqual(loadSavedSearches(), ["bob"]);
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
test("removeSavedSearch is a no-op for a query that isn't saved", () => {
|
|
64
|
-
saveSearch("alice");
|
|
65
|
-
assert.deepEqual(removeSavedSearch("nonexistent"), ["alice"]);
|
|
66
|
-
});
|
|
67
|
-
|
|
68
|
-
test("isSearchSaved reflects the persisted set, trimmed", () => {
|
|
69
|
-
saveSearch("alice");
|
|
70
|
-
assert.equal(isSearchSaved("alice"), true);
|
|
71
|
-
assert.equal(isSearchSaved(" alice "), true);
|
|
72
|
-
assert.equal(isSearchSaved("bob"), false);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
test("caps at MAX_SAVED entries", () => {
|
|
76
|
-
for (let i = 0; i < 30; i++) saveSearch(`query-${i}`);
|
|
77
|
-
assert.equal(loadSavedSearches().length, 25);
|
|
78
|
-
assert.deepEqual(loadSavedSearches()[0], "query-29");
|
|
79
|
-
});
|
|
80
|
-
});
|
|
@@ -1,52 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Saved searches for the mail search bar, persisted in localStorage (#428
|
|
3
|
-
* follow-up, local-only MVP — see doc/design/flows/06-search.md; no backend
|
|
4
|
-
* preference storage yet). Mirrors `recent-searches.ts`: a query already
|
|
5
|
-
* carries its filter chips (`parseSearchTokens` re-derives them), so saving
|
|
6
|
-
* the raw query text is enough to reproduce the full search. Best-effort:
|
|
7
|
-
* storage failures (private mode, quota) degrade to an empty list rather than
|
|
8
|
-
* crashing the search surface.
|
|
9
|
-
*/
|
|
10
|
-
const SAVED_SEARCHES_KEY = "remit.savedSearches";
|
|
11
|
-
const MAX_SAVED = 25;
|
|
12
|
-
|
|
13
|
-
export function loadSavedSearches(): string[] {
|
|
14
|
-
try {
|
|
15
|
-
const raw = localStorage.getItem(SAVED_SEARCHES_KEY);
|
|
16
|
-
if (!raw) return [];
|
|
17
|
-
const parsed: unknown = JSON.parse(raw);
|
|
18
|
-
if (!Array.isArray(parsed)) return [];
|
|
19
|
-
return parsed.filter((q): q is string => typeof q === "string");
|
|
20
|
-
} catch {
|
|
21
|
-
return [];
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function saveSearch(query: string): string[] {
|
|
26
|
-
const trimmed = query.trim();
|
|
27
|
-
if (!trimmed) return loadSavedSearches();
|
|
28
|
-
const next = [
|
|
29
|
-
trimmed,
|
|
30
|
-
...loadSavedSearches().filter((q) => q !== trimmed),
|
|
31
|
-
].slice(0, MAX_SAVED);
|
|
32
|
-
try {
|
|
33
|
-
localStorage.setItem(SAVED_SEARCHES_KEY, JSON.stringify(next));
|
|
34
|
-
} catch {
|
|
35
|
-
return next;
|
|
36
|
-
}
|
|
37
|
-
return next;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function removeSavedSearch(query: string): string[] {
|
|
41
|
-
const next = loadSavedSearches().filter((q) => q !== query);
|
|
42
|
-
try {
|
|
43
|
-
localStorage.setItem(SAVED_SEARCHES_KEY, JSON.stringify(next));
|
|
44
|
-
} catch {
|
|
45
|
-
return next;
|
|
46
|
-
}
|
|
47
|
-
return next;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function isSearchSaved(query: string): boolean {
|
|
51
|
-
return loadSavedSearches().includes(query.trim());
|
|
52
|
-
}
|