@remit/web-client 0.0.225 → 0.0.227
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/layout/MailTopBar.tsx +24 -3
- package/src/components/settings/QuarantinePanel.tsx +21 -2
- package/src/hooks/useAccountDelimiters.ts +41 -0
- package/src/hooks/useRefreshControl.test.ts +159 -1
- package/src/hooks/useRefreshControl.ts +72 -16
- package/src/hooks/useStaleAccountSync.test.ts +55 -16
- package/src/hooks/useStaleAccountSync.ts +57 -15
- package/src/lib/hot-sync-window.test.ts +64 -0
- package/src/lib/hot-sync-window.ts +48 -0
- package/src/lib/mail-freshness.test.ts +31 -1
- package/src/lib/mail-freshness.tsx +45 -13
- package/src/lib/quarantine-entries.test.ts +9 -4
- package/src/lib/quarantine-entries.ts +6 -0
- package/src/lib/result-folder.test.ts +21 -3
- package/src/lib/result-folder.ts +9 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.227",
|
|
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": {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
10
10
|
import { RefreshButton, ShellTopBar, shortcutHintForAction } from "@remit/ui";
|
|
11
|
+
import { useQueryClient } from "@tanstack/react-query";
|
|
11
12
|
import { useMemo } from "react";
|
|
12
13
|
import { AccountMenu } from "@/auth/AccountMenu";
|
|
13
14
|
import { useRefreshControl } from "@/hooks/useRefreshControl";
|
|
@@ -15,6 +16,10 @@ import { useSearchScope } from "@/hooks/useSearchScope";
|
|
|
15
16
|
import { openBugReport } from "@/lib/bug-report";
|
|
16
17
|
import { useMailContext } from "@/lib/mail-context";
|
|
17
18
|
import { useMailFreshness } from "@/lib/mail-freshness";
|
|
19
|
+
import {
|
|
20
|
+
invalidateThreadListQueries,
|
|
21
|
+
threadListCacheKeys,
|
|
22
|
+
} from "@/lib/thread-list-cache";
|
|
18
23
|
import { useGoToSection, useOpenCompose } from "@/routing";
|
|
19
24
|
|
|
20
25
|
interface MailTopBarProps {
|
|
@@ -22,8 +27,14 @@ interface MailTopBarProps {
|
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export function MailTopBar({ accounts }: MailTopBarProps) {
|
|
25
|
-
const {
|
|
26
|
-
|
|
30
|
+
const {
|
|
31
|
+
searchInput,
|
|
32
|
+
onSearchChange,
|
|
33
|
+
onSearchClear,
|
|
34
|
+
onSearchClearQuery,
|
|
35
|
+
resultFolderIndex,
|
|
36
|
+
} = useMailContext();
|
|
37
|
+
const queryClient = useQueryClient();
|
|
27
38
|
const goToSection = useGoToSection();
|
|
28
39
|
const compose = useOpenCompose();
|
|
29
40
|
const { scope, clearScope } = useSearchScope(accounts);
|
|
@@ -43,7 +54,17 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
|
|
|
43
54
|
state: refreshState,
|
|
44
55
|
errorMessage: refreshError,
|
|
45
56
|
refresh,
|
|
46
|
-
} = useRefreshControl(allAccountIds
|
|
57
|
+
} = useRefreshControl(allAccountIds, {
|
|
58
|
+
// Without this the bar's refresh confirmed against a thread list it
|
|
59
|
+
// never reloaded: the sync landed, the button ticked, and the mail it
|
|
60
|
+
// fetched stayed off screen until something else invalidated the list.
|
|
61
|
+
// Every open account's folders, so whichever list is on screen reloads.
|
|
62
|
+
onSettled: () =>
|
|
63
|
+
invalidateThreadListQueries(
|
|
64
|
+
queryClient,
|
|
65
|
+
threadListCacheKeys(resultFolderIndex.keys()),
|
|
66
|
+
),
|
|
67
|
+
});
|
|
47
68
|
|
|
48
69
|
return (
|
|
49
70
|
<ShellTopBar
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
* failure to read it at all. None of them is an empty panel.
|
|
8
8
|
*/
|
|
9
9
|
import { meOperationsListQuarantineOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
10
|
+
import type { RemitImapQuarantineResponse } from "@remit/api-http-client/types.gen.ts";
|
|
10
11
|
import {
|
|
11
12
|
Button,
|
|
12
13
|
QuarantineBugDialog,
|
|
@@ -17,11 +18,15 @@ import {
|
|
|
17
18
|
} from "@remit/ui";
|
|
18
19
|
import { useQuery } from "@tanstack/react-query";
|
|
19
20
|
import { Bug } from "lucide-react";
|
|
20
|
-
import { useState } from "react";
|
|
21
|
+
import { useMemo, useState } from "react";
|
|
21
22
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
23
|
+
import { useAccountDelimiters } from "@/hooks/useAccountDelimiters";
|
|
22
24
|
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
23
25
|
import { toQuarantineEntry } from "@/lib/quarantine-entries";
|
|
24
26
|
|
|
27
|
+
/** Stable identity, so an empty list does not rebuild the memos below. */
|
|
28
|
+
const EMPTY_WIRE_ENTRIES: readonly RemitImapQuarantineResponse[] = [];
|
|
29
|
+
|
|
25
30
|
function openIssue(url: string): void {
|
|
26
31
|
window.open(url, "_blank", "noopener,noreferrer");
|
|
27
32
|
}
|
|
@@ -126,9 +131,23 @@ export function QuarantinePanel() {
|
|
|
126
131
|
meOperationsListQuarantineOptions(),
|
|
127
132
|
);
|
|
128
133
|
|
|
134
|
+
const wireEntries = data?.entries ?? EMPTY_WIRE_ENTRIES;
|
|
135
|
+
const accountIds = useMemo(
|
|
136
|
+
() => [...new Set(wireEntries.map((entry) => entry.accountId))],
|
|
137
|
+
[wireEntries],
|
|
138
|
+
);
|
|
139
|
+
const delimiters = useAccountDelimiters(accountIds);
|
|
140
|
+
const entries = useMemo(
|
|
141
|
+
() =>
|
|
142
|
+
wireEntries.map((entry) =>
|
|
143
|
+
toQuarantineEntry(entry, delimiters.get(entry.accountId) ?? "/"),
|
|
144
|
+
),
|
|
145
|
+
[wireEntries, delimiters],
|
|
146
|
+
);
|
|
147
|
+
|
|
129
148
|
return (
|
|
130
149
|
<QuarantinePanelView
|
|
131
|
-
entries={
|
|
150
|
+
entries={entries}
|
|
132
151
|
isPending={isPending}
|
|
133
152
|
error={error}
|
|
134
153
|
/>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The hierarchy separator each account's own server reports, for surfaces that
|
|
3
|
+
* hold a provider path but not the mailbox row it came from — quarantine, so
|
|
4
|
+
* far. Fans out the same per-account mailbox-list query as
|
|
5
|
+
* `useResultFolderIndex` (cached forever, react-query dedupes the identical key
|
|
6
|
+
* across call sites) and keeps only the delimiter.
|
|
7
|
+
*/
|
|
8
|
+
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
9
|
+
import type { RemitImapMailboxResponse } from "@remit/api-http-client/types.gen.ts";
|
|
10
|
+
import { useQueries } from "@tanstack/react-query";
|
|
11
|
+
import { useMemo } from "react";
|
|
12
|
+
import { folderDelimiter } from "@/lib/move-options";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Hoisted so `useQueries` treats this as a memo rather than a per-render
|
|
16
|
+
* reduce — see `useResultFolderIndex`.
|
|
17
|
+
*/
|
|
18
|
+
const combineDelimiters = (
|
|
19
|
+
results: { data?: { items: RemitImapMailboxResponse[] } }[],
|
|
20
|
+
): string[] =>
|
|
21
|
+
results.map((result) => folderDelimiter(result.data?.items ?? []));
|
|
22
|
+
|
|
23
|
+
export function useAccountDelimiters(
|
|
24
|
+
accountIds: readonly string[],
|
|
25
|
+
): ReadonlyMap<string, string> {
|
|
26
|
+
const delimiters = useQueries({
|
|
27
|
+
queries: accountIds.map((accountId) => ({
|
|
28
|
+
...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
|
|
29
|
+
staleTime: Infinity,
|
|
30
|
+
})),
|
|
31
|
+
combine: combineDelimiters,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
return useMemo(
|
|
35
|
+
() =>
|
|
36
|
+
new Map(
|
|
37
|
+
accountIds.map((accountId, i) => [accountId, delimiters[i] ?? "/"]),
|
|
38
|
+
),
|
|
39
|
+
[accountIds, delimiters],
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
inboxSyncedAt,
|
|
5
|
+
type SyncStatusReading,
|
|
6
|
+
waitForSettled,
|
|
7
|
+
} from "./useRefreshControl.js";
|
|
4
8
|
|
|
5
9
|
const BASELINE = 1_700_000_000_000;
|
|
6
10
|
const LATER = BASELINE + 5_000;
|
|
@@ -127,3 +131,157 @@ describe("waitForSettled", () => {
|
|
|
127
131
|
assert.deepEqual(outcome, { accountId: "a-1", message: "signed out" });
|
|
128
132
|
});
|
|
129
133
|
});
|
|
134
|
+
|
|
135
|
+
const inbox = (lastSyncedAt: number) => ({
|
|
136
|
+
fullPath: "INBOX",
|
|
137
|
+
lastSyncedAt,
|
|
138
|
+
});
|
|
139
|
+
const junk = (lastSyncedAt: number) => ({
|
|
140
|
+
fullPath: "Junk",
|
|
141
|
+
lastSyncedAt,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("inboxSyncedAt", () => {
|
|
145
|
+
test("reads the INBOX stamp whatever case the server spells it in", () => {
|
|
146
|
+
assert.equal(
|
|
147
|
+
inboxSyncedAt({
|
|
148
|
+
mailboxes: [junk(LATER), { fullPath: "Inbox", lastSyncedAt: BASELINE }],
|
|
149
|
+
}),
|
|
150
|
+
BASELINE,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("is zero for an account with no INBOX or no stamp yet", () => {
|
|
155
|
+
assert.equal(inboxSyncedAt({ mailboxes: [junk(LATER)] }), 0);
|
|
156
|
+
assert.equal(inboxSyncedAt({ mailboxes: [{ fullPath: "INBOX" }] }), 0);
|
|
157
|
+
assert.equal(inboxSyncedAt({}), 0);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("waitForSettled inbox handoff", () => {
|
|
162
|
+
// A round is every folder in queue order, but the mail somebody pressed
|
|
163
|
+
// refresh for is in their inbox. Waiting for the whole round meant the new
|
|
164
|
+
// mail sat unshown while Junk and Trash went by.
|
|
165
|
+
test("announces the inbox while the round is still running", async () => {
|
|
166
|
+
const announced: number[] = [];
|
|
167
|
+
const { state, readStatus } = scriptedReader([
|
|
168
|
+
{
|
|
169
|
+
syncPhase: "syncing_inbox",
|
|
170
|
+
lastSyncAt: LATER,
|
|
171
|
+
mailboxes: [inbox(BASELINE)],
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
syncPhase: "syncing_others",
|
|
175
|
+
lastSyncAt: LATER,
|
|
176
|
+
mailboxes: [inbox(LATER)],
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
syncPhase: "syncing_others",
|
|
180
|
+
lastSyncAt: LATER,
|
|
181
|
+
mailboxes: [inbox(LATER)],
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
syncPhase: "complete",
|
|
185
|
+
lastSyncAt: LATER,
|
|
186
|
+
mailboxes: [inbox(LATER)],
|
|
187
|
+
},
|
|
188
|
+
]);
|
|
189
|
+
|
|
190
|
+
const outcome = await waitForSettled({
|
|
191
|
+
accountId: "a-1",
|
|
192
|
+
readStatus,
|
|
193
|
+
baselineLastSyncAt: BASELINE,
|
|
194
|
+
baselineInboxSyncedAt: BASELINE,
|
|
195
|
+
onInboxSynced: () => announced.push(state.reads),
|
|
196
|
+
deadline: Date.now() + 10_000,
|
|
197
|
+
pollMs: 0,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
assert.equal(outcome, undefined);
|
|
201
|
+
// Second reading, two readings before the round confirmed.
|
|
202
|
+
assert.deepEqual(announced, [2]);
|
|
203
|
+
assert.equal(state.reads, 4);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("announces the inbox once, not on every poll", async () => {
|
|
207
|
+
let announced = 0;
|
|
208
|
+
const { readStatus } = scriptedReader([
|
|
209
|
+
{
|
|
210
|
+
syncPhase: "syncing_others",
|
|
211
|
+
lastSyncAt: LATER,
|
|
212
|
+
mailboxes: [inbox(LATER)],
|
|
213
|
+
},
|
|
214
|
+
{
|
|
215
|
+
syncPhase: "syncing_others",
|
|
216
|
+
lastSyncAt: LATER,
|
|
217
|
+
mailboxes: [inbox(LATER)],
|
|
218
|
+
},
|
|
219
|
+
{
|
|
220
|
+
syncPhase: "complete",
|
|
221
|
+
lastSyncAt: LATER,
|
|
222
|
+
mailboxes: [inbox(LATER)],
|
|
223
|
+
},
|
|
224
|
+
]);
|
|
225
|
+
|
|
226
|
+
await waitForSettled({
|
|
227
|
+
accountId: "a-1",
|
|
228
|
+
readStatus,
|
|
229
|
+
baselineLastSyncAt: BASELINE,
|
|
230
|
+
baselineInboxSyncedAt: BASELINE,
|
|
231
|
+
onInboxSynced: () => {
|
|
232
|
+
announced += 1;
|
|
233
|
+
},
|
|
234
|
+
deadline: Date.now() + 10_000,
|
|
235
|
+
pollMs: 0,
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
assert.equal(announced, 1);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
// The caller already treats a settled round as the confirmed state and
|
|
242
|
+
// reloads there, so announcing on the same reading would double the work.
|
|
243
|
+
test("does not announce when the round settles first", async () => {
|
|
244
|
+
let announced = 0;
|
|
245
|
+
|
|
246
|
+
await waitForSettled({
|
|
247
|
+
accountId: "a-1",
|
|
248
|
+
readStatus: async () => ({
|
|
249
|
+
syncPhase: "complete" as const,
|
|
250
|
+
lastSyncAt: LATER,
|
|
251
|
+
mailboxes: [inbox(LATER)],
|
|
252
|
+
}),
|
|
253
|
+
baselineLastSyncAt: BASELINE,
|
|
254
|
+
baselineInboxSyncedAt: BASELINE,
|
|
255
|
+
onInboxSynced: () => {
|
|
256
|
+
announced += 1;
|
|
257
|
+
},
|
|
258
|
+
deadline: Date.now() + 10_000,
|
|
259
|
+
pollMs: 0,
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
assert.equal(announced, 0);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
// A folder other than INBOX syncing is not the mail being waited for.
|
|
266
|
+
test("does not announce on another folder's stamp moving", async () => {
|
|
267
|
+
let announced = 0;
|
|
268
|
+
|
|
269
|
+
await waitForSettled({
|
|
270
|
+
accountId: "a-1",
|
|
271
|
+
readStatus: async () => ({
|
|
272
|
+
syncPhase: "syncing_others" as const,
|
|
273
|
+
lastSyncAt: LATER,
|
|
274
|
+
mailboxes: [inbox(BASELINE), junk(LATER)],
|
|
275
|
+
}),
|
|
276
|
+
baselineLastSyncAt: BASELINE,
|
|
277
|
+
baselineInboxSyncedAt: BASELINE,
|
|
278
|
+
onInboxSynced: () => {
|
|
279
|
+
announced += 1;
|
|
280
|
+
},
|
|
281
|
+
deadline: Date.now() - 1,
|
|
282
|
+
pollMs: 0,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
assert.equal(announced, 0);
|
|
286
|
+
});
|
|
287
|
+
});
|
|
@@ -10,6 +10,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
10
10
|
import { isSyncingPhase } from "@/hooks/useInitialSyncProgress";
|
|
11
11
|
import { shouldEscalate } from "@/lib/error-classifier";
|
|
12
12
|
import { reportFatalError } from "@/lib/fatal-error";
|
|
13
|
+
import { startHotSyncWindow } from "@/lib/hot-sync-window";
|
|
13
14
|
import { useMailFreshness } from "@/lib/mail-freshness";
|
|
14
15
|
import { useTelemetry } from "@/lib/telemetry-context";
|
|
15
16
|
|
|
@@ -68,18 +69,35 @@ export interface AccountOutcome {
|
|
|
68
69
|
message?: string;
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
/** The
|
|
72
|
-
*
|
|
72
|
+
/** The fields of `getSyncStatus` this wait reads, named so it can be driven
|
|
73
|
+
* from a scripted sequence of readings. */
|
|
73
74
|
export interface SyncStatusReading {
|
|
74
75
|
syncPhase?: RemitImapSyncPhase;
|
|
75
76
|
lastSyncAt?: number;
|
|
77
|
+
mailboxes?: readonly { fullPath?: string; lastSyncedAt?: number }[];
|
|
76
78
|
}
|
|
77
79
|
|
|
80
|
+
/**
|
|
81
|
+
* The account's INBOX stamp, or 0 when the account has no INBOX or has never
|
|
82
|
+
* synced one. `getSyncStatus` reports `lastSyncedAt` per mailbox, which is what
|
|
83
|
+
* lets the wait answer "the inbox is current" ahead of "the round is done".
|
|
84
|
+
*/
|
|
85
|
+
export const inboxSyncedAt = (reading: SyncStatusReading): number =>
|
|
86
|
+
reading.mailboxes?.find(
|
|
87
|
+
(mailbox) => mailbox.fullPath?.toUpperCase() === "INBOX",
|
|
88
|
+
)?.lastSyncedAt ?? 0;
|
|
89
|
+
|
|
78
90
|
export interface WaitForSettledOptions {
|
|
79
91
|
accountId: string;
|
|
80
92
|
readStatus: () => Promise<SyncStatusReading>;
|
|
81
93
|
/** `lastSyncAt` as it read immediately before this refresh triggered. */
|
|
82
94
|
baselineLastSyncAt: number;
|
|
95
|
+
/** The INBOX `lastSyncedAt` from that same pre-trigger reading. */
|
|
96
|
+
baselineInboxSyncedAt?: number;
|
|
97
|
+
/** Called at most once, the moment INBOX's own stamp passes the baseline
|
|
98
|
+
* while the round is still running — never after the round has settled,
|
|
99
|
+
* which the caller already treats as the confirmed state. */
|
|
100
|
+
onInboxSynced?: () => void;
|
|
83
101
|
deadline: number;
|
|
84
102
|
pollMs?: number;
|
|
85
103
|
}
|
|
@@ -101,6 +119,12 @@ export interface WaitForSettledOptions {
|
|
|
101
119
|
* still shows the previous round — including a previous round's `error` phase,
|
|
102
120
|
* which is why that phase only speaks once the stamp has moved.
|
|
103
121
|
*
|
|
122
|
+
* A round is the whole account — every folder, in queue order — but the mail
|
|
123
|
+
* a person pressed refresh for is in their inbox. `onInboxSynced` fires the
|
|
124
|
+
* moment that one mailbox's own stamp passes the baseline, so the list on
|
|
125
|
+
* screen reloads then rather than after Junk and Trash have also gone by. The
|
|
126
|
+
* round-level stamp still decides the button's confirmed state.
|
|
127
|
+
*
|
|
104
128
|
* Read-only (`getSyncStatus`, no IMAP call, no queue write), so waiting costs
|
|
105
129
|
* a handful of cheap GETs — never a refetch of the account's own message
|
|
106
130
|
* list.
|
|
@@ -109,9 +133,12 @@ export const waitForSettled = async ({
|
|
|
109
133
|
accountId,
|
|
110
134
|
readStatus,
|
|
111
135
|
baselineLastSyncAt,
|
|
136
|
+
baselineInboxSyncedAt = 0,
|
|
137
|
+
onInboxSynced,
|
|
112
138
|
deadline,
|
|
113
139
|
pollMs = REFRESH_POLL_MS,
|
|
114
140
|
}: WaitForSettledOptions): Promise<AccountOutcome | undefined> => {
|
|
141
|
+
let inboxAnnounced = false;
|
|
115
142
|
for (;;) {
|
|
116
143
|
const outcome = await readStatus()
|
|
117
144
|
.then((data) => ({ ok: true as const, data }))
|
|
@@ -127,6 +154,13 @@ export const waitForSettled = async ({
|
|
|
127
154
|
}
|
|
128
155
|
return undefined;
|
|
129
156
|
}
|
|
157
|
+
if (
|
|
158
|
+
!inboxAnnounced &&
|
|
159
|
+
inboxSyncedAt(outcome.data) > baselineInboxSyncedAt
|
|
160
|
+
) {
|
|
161
|
+
inboxAnnounced = true;
|
|
162
|
+
onInboxSynced?.();
|
|
163
|
+
}
|
|
130
164
|
if (Date.now() >= deadline) {
|
|
131
165
|
return { accountId, message: "Refresh is taking longer than usual" };
|
|
132
166
|
}
|
|
@@ -149,11 +183,13 @@ const refreshOneAccount = async (
|
|
|
149
183
|
telemetry: { recordEvent: (name: string) => void },
|
|
150
184
|
accountId: string,
|
|
151
185
|
deadline: number,
|
|
186
|
+
onInboxSynced: (accountId: string) => void,
|
|
152
187
|
): Promise<AccountResult> => {
|
|
153
188
|
const baseline = await fetchStatus(queryClient, accountId)
|
|
154
189
|
.then((data) => ({
|
|
155
190
|
ok: true as const,
|
|
156
191
|
lastSyncAt: data.lastSyncAt ?? 0,
|
|
192
|
+
inboxSyncedAt: inboxSyncedAt(data),
|
|
157
193
|
}))
|
|
158
194
|
.catch((error: unknown) => ({ ok: false as const, error }));
|
|
159
195
|
if (!baseline.ok) {
|
|
@@ -186,6 +222,8 @@ const refreshOneAccount = async (
|
|
|
186
222
|
accountId,
|
|
187
223
|
readStatus: () => fetchStatus(queryClient, accountId),
|
|
188
224
|
baselineLastSyncAt: baseline.lastSyncAt,
|
|
225
|
+
baselineInboxSyncedAt: baseline.inboxSyncedAt,
|
|
226
|
+
onInboxSynced: () => onInboxSynced(accountId),
|
|
189
227
|
deadline,
|
|
190
228
|
});
|
|
191
229
|
if (settled)
|
|
@@ -194,11 +232,14 @@ const refreshOneAccount = async (
|
|
|
194
232
|
};
|
|
195
233
|
|
|
196
234
|
export interface UseRefreshControlOptions {
|
|
197
|
-
/**
|
|
198
|
-
* the
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
*
|
|
235
|
+
/** The caller's chance to invalidate whatever view-specific query (a
|
|
236
|
+
* mailbox's thread list, the brief's unified list) the sync may have
|
|
237
|
+
* changed, alongside the invalidation of the account's own mailbox-list
|
|
238
|
+
* query.
|
|
239
|
+
*
|
|
240
|
+
* Called more than once per press: once per account the moment its INBOX
|
|
241
|
+
* is current, and again once its whole round has settled. It must
|
|
242
|
+
* therefore be idempotent — an invalidation is. */
|
|
202
243
|
onSettled?: () => void;
|
|
203
244
|
}
|
|
204
245
|
|
|
@@ -253,11 +294,33 @@ export const useRefreshControl = (
|
|
|
253
294
|
setState("refreshing");
|
|
254
295
|
setErrorMessage(undefined);
|
|
255
296
|
|
|
297
|
+
startHotSyncWindow();
|
|
298
|
+
|
|
299
|
+
// The account's own lists, reloaded. Called once as soon as that
|
|
300
|
+
// account's INBOX is current and again when its round confirms: the
|
|
301
|
+
// first is what puts the new mail on screen without waiting for Junk
|
|
302
|
+
// and Trash, the second is the settled state.
|
|
303
|
+
const showAccount = (accountId: string): void => {
|
|
304
|
+
if (runIdRef.current !== runId) return;
|
|
305
|
+
queryClient.invalidateQueries({
|
|
306
|
+
queryKey: mailboxOperationsListMailboxesQueryKey({
|
|
307
|
+
path: { accountId },
|
|
308
|
+
}),
|
|
309
|
+
});
|
|
310
|
+
optionsRef.current.onSettled?.();
|
|
311
|
+
};
|
|
312
|
+
|
|
256
313
|
void (async () => {
|
|
257
314
|
const deadline = Date.now() + REFRESH_TIMEOUT_MS;
|
|
258
315
|
const results = await Promise.all(
|
|
259
316
|
ids.map((accountId) =>
|
|
260
|
-
refreshOneAccount(
|
|
317
|
+
refreshOneAccount(
|
|
318
|
+
queryClient,
|
|
319
|
+
telemetry,
|
|
320
|
+
accountId,
|
|
321
|
+
deadline,
|
|
322
|
+
showAccount,
|
|
323
|
+
),
|
|
261
324
|
),
|
|
262
325
|
);
|
|
263
326
|
if (runIdRef.current !== runId) return;
|
|
@@ -265,15 +328,8 @@ export const useRefreshControl = (
|
|
|
265
328
|
const enqueued = results
|
|
266
329
|
.filter((result) => result.enqueued)
|
|
267
330
|
.map((result) => result.accountId);
|
|
268
|
-
for (const accountId of enqueued)
|
|
269
|
-
queryClient.invalidateQueries({
|
|
270
|
-
queryKey: mailboxOperationsListMailboxesQueryKey({
|
|
271
|
-
path: { accountId },
|
|
272
|
-
}),
|
|
273
|
-
});
|
|
274
|
-
}
|
|
331
|
+
for (const accountId of enqueued) showAccount(accountId);
|
|
275
332
|
if (enqueued.length > 0) {
|
|
276
|
-
optionsRef.current.onSettled?.();
|
|
277
333
|
acknowledge(enqueued);
|
|
278
334
|
}
|
|
279
335
|
|
|
@@ -2,10 +2,17 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { afterEach, describe, test } from "node:test";
|
|
3
3
|
import { ApiError } from "../lib/api.js";
|
|
4
4
|
import { __resetFatalError, subscribeFatalError } from "../lib/fatal-error.js";
|
|
5
|
+
import {
|
|
6
|
+
__resetHotSyncWindow,
|
|
7
|
+
HOT_POLL_INTERVAL_MS,
|
|
8
|
+
HOT_WINDOW_MS,
|
|
9
|
+
startHotSyncWindow,
|
|
10
|
+
} from "../lib/hot-sync-window.js";
|
|
5
11
|
import { NetworkError } from "../lib/network-error.js";
|
|
6
12
|
import {
|
|
7
13
|
__peekStaleAccountSyncGuard,
|
|
8
14
|
__resetStaleAccountSyncGuard,
|
|
15
|
+
currentPollIntervalMs,
|
|
9
16
|
handleBackgroundSyncFailure,
|
|
10
17
|
hasPollIntervalElapsed,
|
|
11
18
|
MIN_POLL_INTERVAL_MS,
|
|
@@ -102,8 +109,9 @@ describe("selectStaleAccountIds", () => {
|
|
|
102
109
|
});
|
|
103
110
|
|
|
104
111
|
describe("POLL_INTERVAL_MS", () => {
|
|
105
|
-
|
|
106
|
-
|
|
112
|
+
// How stale an open tab's mail may be with nobody touching anything.
|
|
113
|
+
test("defaults to 30 seconds when mailboxPollIntervalSeconds is unset in this test run", () => {
|
|
114
|
+
assert.equal(POLL_INTERVAL_MS, 30 * 1000);
|
|
107
115
|
});
|
|
108
116
|
});
|
|
109
117
|
|
|
@@ -111,19 +119,13 @@ describe("resolvePollIntervalMs", () => {
|
|
|
111
119
|
// This poll shares POST /sync with the refresh control, and that endpoint's
|
|
112
120
|
// triggers skip the server's per-mailbox freshness gate
|
|
113
121
|
// (MAILBOX_FRESHNESS_MS in imap-worker's sync-mailboxes fan-out). A timer is
|
|
114
|
-
// not a person:
|
|
115
|
-
// every
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
test("never returns less than the server's freshness window", () => {
|
|
122
|
+
// not a person: every tick is a full folder-by-folder re-enumeration for
|
|
123
|
+
// every open account, so the floor is what bounds that work against a config
|
|
124
|
+
// value.
|
|
125
|
+
test("never returns less than the floor", () => {
|
|
119
126
|
assert.equal(resolvePollIntervalMs("10"), MIN_POLL_INTERVAL_MS);
|
|
120
|
-
assert.equal(resolvePollIntervalMs("
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
test("matches the freshness window the fan-out gate uses", () => {
|
|
124
|
-
// Keep in step with MAILBOX_FRESHNESS_MS in
|
|
125
|
-
// packages/imap-worker/src/handlers/sync-mailboxes.ts.
|
|
126
|
-
assert.equal(MIN_POLL_INTERVAL_MS, 60_000);
|
|
127
|
+
assert.equal(resolvePollIntervalMs("29"), MIN_POLL_INTERVAL_MS);
|
|
128
|
+
assert.equal(MIN_POLL_INTERVAL_MS, 30_000);
|
|
127
129
|
});
|
|
128
130
|
|
|
129
131
|
test("honours a configured interval above the floor", () => {
|
|
@@ -131,8 +133,45 @@ describe("resolvePollIntervalMs", () => {
|
|
|
131
133
|
});
|
|
132
134
|
|
|
133
135
|
test("falls back to the default when unset or unparseable", () => {
|
|
134
|
-
assert.equal(resolvePollIntervalMs(undefined),
|
|
135
|
-
assert.equal(resolvePollIntervalMs("not-a-number"),
|
|
136
|
+
assert.equal(resolvePollIntervalMs(undefined), 30 * 1000);
|
|
137
|
+
assert.equal(resolvePollIntervalMs("not-a-number"), 30 * 1000);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("currentPollIntervalMs", () => {
|
|
142
|
+
afterEach(__resetHotSyncWindow);
|
|
143
|
+
|
|
144
|
+
test("is the configured ambient interval with no recent press", () => {
|
|
145
|
+
assert.equal(currentPollIntervalMs(NOW, 900_000), 900_000);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Pressing refresh means "and keep looking": the next few minutes run on the
|
|
149
|
+
// hot cadence even where the deployment has lengthened the ambient one.
|
|
150
|
+
test("drops to the hot cadence for the whole window after a press", () => {
|
|
151
|
+
startHotSyncWindow(NOW);
|
|
152
|
+
|
|
153
|
+
assert.equal(currentPollIntervalMs(NOW, 900_000), HOT_POLL_INTERVAL_MS);
|
|
154
|
+
assert.equal(
|
|
155
|
+
currentPollIntervalMs(NOW + HOT_WINDOW_MS - 1, 900_000),
|
|
156
|
+
HOT_POLL_INTERVAL_MS,
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("falls back to ambient once the window expires", () => {
|
|
161
|
+
startHotSyncWindow(NOW);
|
|
162
|
+
|
|
163
|
+
assert.equal(currentPollIntervalMs(NOW + HOT_WINDOW_MS, 900_000), 900_000);
|
|
164
|
+
assert.equal(
|
|
165
|
+
currentPollIntervalMs(NOW + HOT_WINDOW_MS + 60_000, 900_000),
|
|
166
|
+
900_000,
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// A deployment already polling faster than the hot cadence keeps its own.
|
|
171
|
+
test("never lengthens an ambient interval shorter than the hot one", () => {
|
|
172
|
+
startHotSyncWindow(NOW);
|
|
173
|
+
|
|
174
|
+
assert.equal(currentPollIntervalMs(NOW, 10_000), 10_000);
|
|
136
175
|
});
|
|
137
176
|
});
|
|
138
177
|
|
|
@@ -6,6 +6,11 @@ import pMap from "p-map";
|
|
|
6
6
|
import { useEffect, useRef } from "react";
|
|
7
7
|
import { shouldEscalate } from "@/lib/error-classifier";
|
|
8
8
|
import { reportFatalError } from "@/lib/fatal-error";
|
|
9
|
+
import {
|
|
10
|
+
HOT_POLL_INTERVAL_MS,
|
|
11
|
+
isHotSyncWindowActive,
|
|
12
|
+
subscribeHotSyncWindow,
|
|
13
|
+
} from "@/lib/hot-sync-window";
|
|
9
14
|
import { getRuntimeConfig } from "@/runtime-config";
|
|
10
15
|
|
|
11
16
|
/**
|
|
@@ -14,7 +19,7 @@ import { getRuntimeConfig } from "@/runtime-config";
|
|
|
14
19
|
*/
|
|
15
20
|
export const STALENESS_THRESHOLD_MS = 15 * 60 * 1000;
|
|
16
21
|
|
|
17
|
-
const DEFAULT_POLL_INTERVAL_SECONDS =
|
|
22
|
+
const DEFAULT_POLL_INTERVAL_SECONDS = 30;
|
|
18
23
|
|
|
19
24
|
const parsePositiveIntSeconds = (
|
|
20
25
|
raw: string | undefined,
|
|
@@ -26,19 +31,18 @@ const parsePositiveIntSeconds = (
|
|
|
26
31
|
};
|
|
27
32
|
|
|
28
33
|
/**
|
|
29
|
-
* Floor for the poll interval
|
|
30
|
-
* imap-worker's sync-mailboxes fan-out.
|
|
34
|
+
* Floor for the poll interval.
|
|
31
35
|
*
|
|
32
36
|
* This poll uses POST /sync, the same endpoint as a person pressing refresh,
|
|
33
|
-
* and that endpoint's triggers skip the server's per-mailbox freshness gate
|
|
34
|
-
* A timer is not a person:
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* and that endpoint's triggers skip the server's per-mailbox freshness gate
|
|
38
|
+
* (`MAILBOX_FRESHNESS_MS`, 60s). A timer is not a person: every tick is a full
|
|
39
|
+
* folder-by-folder re-enumeration for every open account, so the interval is
|
|
40
|
+
* what bounds that work. Thirty seconds is the answer to how stale an open
|
|
41
|
+
* tab's mail may be, and buys it at twice the fan-out the gate's own window
|
|
42
|
+
* would allow. `mailboxPollIntervalSeconds` can lengthen the interval, never
|
|
43
|
+
* shorten it past this.
|
|
40
44
|
*/
|
|
41
|
-
export const MIN_POLL_INTERVAL_MS =
|
|
45
|
+
export const MIN_POLL_INTERVAL_MS = 30 * 1000;
|
|
42
46
|
|
|
43
47
|
/**
|
|
44
48
|
* Resolve the configured poll interval, never returning less than
|
|
@@ -62,6 +66,21 @@ export const POLL_INTERVAL_MS = resolvePollIntervalMs(
|
|
|
62
66
|
getRuntimeConfig().mailboxPollIntervalSeconds,
|
|
63
67
|
);
|
|
64
68
|
|
|
69
|
+
/**
|
|
70
|
+
* The interval this tab is currently due on: the hot one while a refresh press
|
|
71
|
+
* is still recent, the configured ambient one otherwise. A deployment that has
|
|
72
|
+
* lengthened `mailboxPollIntervalSeconds` still gets the hot cadence for the
|
|
73
|
+
* few minutes after a press, which is what makes a press mean "and keep
|
|
74
|
+
* looking".
|
|
75
|
+
*/
|
|
76
|
+
export const currentPollIntervalMs = (
|
|
77
|
+
now: number = Date.now(),
|
|
78
|
+
ambientMs: number = POLL_INTERVAL_MS,
|
|
79
|
+
): number =>
|
|
80
|
+
isHotSyncWindowActive(now)
|
|
81
|
+
? Math.min(HOT_POLL_INTERVAL_MS, ambientMs)
|
|
82
|
+
: ambientMs;
|
|
83
|
+
|
|
65
84
|
/**
|
|
66
85
|
* Bounded concurrency for the per-poll fan-out across open accounts —
|
|
67
86
|
* mirrors `SCHEDULER_ENQUEUE_CONCURRENCY` in
|
|
@@ -186,6 +205,8 @@ const runBackgroundSync = (
|
|
|
186
205
|
* first time MailLayout mounts in a session, then keep it fresh with a
|
|
187
206
|
* recurring online poll for as long as the mail app stays open (#1251) —
|
|
188
207
|
* the client-side replacement for the server's removed "online" sync tier.
|
|
208
|
+
* The poll runs on the ambient interval, or the hot one for the few minutes
|
|
209
|
+
* after somebody presses refresh, and only while the tab is visible.
|
|
189
210
|
* Fire-and-forget: does not block render, and silently logs failures (this
|
|
190
211
|
* is a best-effort background refresh — the user-visible UI for triggering
|
|
191
212
|
* is the "Refresh mailboxes" button in Settings).
|
|
@@ -250,27 +271,48 @@ export const useStaleAccountSync = (
|
|
|
250
271
|
const tick = () => {
|
|
251
272
|
if (document.visibilityState === "visible") {
|
|
252
273
|
poll();
|
|
253
|
-
timeoutId = setTimeout(tick,
|
|
274
|
+
timeoutId = setTimeout(tick, currentPollIntervalMs());
|
|
254
275
|
return;
|
|
255
276
|
}
|
|
256
277
|
// Hidden: don't reschedule. `handleVisibilityChange` resumes the loop
|
|
257
278
|
// (with an immediate catch-up poll) once the tab is visible again.
|
|
279
|
+
// A hot window running out while the tab is away costs nothing —
|
|
280
|
+
// there is nobody watching a list to keep current.
|
|
258
281
|
};
|
|
259
282
|
|
|
260
283
|
const handleVisibilityChange = () => {
|
|
261
284
|
if (document.visibilityState !== "visible") return;
|
|
262
|
-
if (
|
|
285
|
+
if (
|
|
286
|
+
!hasPollIntervalElapsed(Date.now(), lastPollAt, currentPollIntervalMs())
|
|
287
|
+
) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
263
290
|
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
264
291
|
poll();
|
|
265
|
-
timeoutId = setTimeout(tick,
|
|
292
|
+
timeoutId = setTimeout(tick, currentPollIntervalMs());
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
// A press opens the hot window, but this loop may already be asleep on a
|
|
296
|
+
// long ambient interval — reschedule onto the hot one rather than sit
|
|
297
|
+
// out the rest of that wait. The press has just triggered its own sync,
|
|
298
|
+
// so this only moves the *next* tick: never a poll on the press itself,
|
|
299
|
+
// and never a tighter loop than the interval (POST /sync is deliberately
|
|
300
|
+
// never coalesced, #37).
|
|
301
|
+
const handleHotWindow = () => {
|
|
302
|
+
if (document.visibilityState !== "visible") return;
|
|
303
|
+
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
304
|
+
lastPollAt = Date.now();
|
|
305
|
+
timeoutId = setTimeout(tick, currentPollIntervalMs());
|
|
266
306
|
};
|
|
267
307
|
|
|
268
|
-
timeoutId = setTimeout(tick,
|
|
308
|
+
timeoutId = setTimeout(tick, currentPollIntervalMs());
|
|
269
309
|
document.addEventListener("visibilitychange", handleVisibilityChange);
|
|
310
|
+
const unsubscribeHotWindow = subscribeHotSyncWindow(handleHotWindow);
|
|
270
311
|
|
|
271
312
|
return () => {
|
|
272
313
|
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
|
273
314
|
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
|
315
|
+
unsubscribeHotWindow();
|
|
274
316
|
};
|
|
275
317
|
}, [stableKey, queryClient]);
|
|
276
318
|
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, describe, test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
__resetHotSyncWindow,
|
|
5
|
+
HOT_POLL_INTERVAL_MS,
|
|
6
|
+
HOT_WINDOW_MS,
|
|
7
|
+
isHotSyncWindowActive,
|
|
8
|
+
startHotSyncWindow,
|
|
9
|
+
subscribeHotSyncWindow,
|
|
10
|
+
} from "./hot-sync-window.js";
|
|
11
|
+
|
|
12
|
+
const NOW = 1_700_000_000_000;
|
|
13
|
+
|
|
14
|
+
describe("hot sync window", () => {
|
|
15
|
+
afterEach(__resetHotSyncWindow);
|
|
16
|
+
|
|
17
|
+
test("is closed until somebody presses refresh", () => {
|
|
18
|
+
assert.equal(isHotSyncWindowActive(NOW), false);
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("stays open for a few minutes after a press", () => {
|
|
22
|
+
startHotSyncWindow(NOW);
|
|
23
|
+
|
|
24
|
+
assert.equal(isHotSyncWindowActive(NOW), true);
|
|
25
|
+
assert.equal(isHotSyncWindowActive(NOW + HOT_WINDOW_MS - 1), true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("expires on its own", () => {
|
|
29
|
+
startHotSyncWindow(NOW);
|
|
30
|
+
|
|
31
|
+
assert.equal(isHotSyncWindowActive(NOW + HOT_WINDOW_MS), false);
|
|
32
|
+
assert.equal(isHotSyncWindowActive(NOW + HOT_WINDOW_MS + 1), false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// Someone still pressing refresh is still waiting on mail.
|
|
36
|
+
test("a second press extends the window from that press", () => {
|
|
37
|
+
startHotSyncWindow(NOW);
|
|
38
|
+
startHotSyncWindow(NOW + 60_000);
|
|
39
|
+
|
|
40
|
+
assert.equal(isHotSyncWindowActive(NOW + HOT_WINDOW_MS), true);
|
|
41
|
+
assert.equal(isHotSyncWindowActive(NOW + HOT_WINDOW_MS + 60_000), false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// The poll loop may already be asleep on a long ambient interval when the
|
|
45
|
+
// press lands; without this it would sit out the rest of that wait before
|
|
46
|
+
// the hot cadence took effect at all.
|
|
47
|
+
test("notifies subscribers on a press, and stops after unsubscribe", () => {
|
|
48
|
+
let presses = 0;
|
|
49
|
+
const unsubscribe = subscribeHotSyncWindow(() => {
|
|
50
|
+
presses += 1;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
startHotSyncWindow(NOW);
|
|
54
|
+
assert.equal(presses, 1);
|
|
55
|
+
|
|
56
|
+
unsubscribe();
|
|
57
|
+
startHotSyncWindow(NOW);
|
|
58
|
+
assert.equal(presses, 1);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("the hot cadence is thirty seconds", () => {
|
|
62
|
+
assert.equal(HOT_POLL_INTERVAL_MS, 30_000);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A short window of faster background syncing, opened by a person pressing
|
|
3
|
+
* refresh.
|
|
4
|
+
*
|
|
5
|
+
* Pressing refresh says "I am waiting on mail", and the reply people expect is
|
|
6
|
+
* not one round but the next few minutes: a reply lands, a colleague answers,
|
|
7
|
+
* the transfer confirms. The ambient cadence alone would leave that mail up to
|
|
8
|
+
* a full interval away, so the press widens the tab's own poll for a few
|
|
9
|
+
* minutes and then lets it fall back.
|
|
10
|
+
*
|
|
11
|
+
* Module state rather than context: the press happens in the refresh control
|
|
12
|
+
* and the polling happens in `useStaleAccountSync`, two hooks with no common
|
|
13
|
+
* ancestor that owns this, and the window belongs to the tab either way.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** How long a press keeps the tab polling on the hot cadence. */
|
|
17
|
+
export const HOT_WINDOW_MS = 3 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
/** The cadence inside the window. Never faster: `POST /sync` skips the
|
|
20
|
+
* server's per-mailbox freshness gate, so every tick is a real folder-by-folder
|
|
21
|
+
* fan-out for every open account. */
|
|
22
|
+
export const HOT_POLL_INTERVAL_MS = 30 * 1000;
|
|
23
|
+
|
|
24
|
+
let hotUntil = 0;
|
|
25
|
+
const listeners = new Set<() => void>();
|
|
26
|
+
|
|
27
|
+
/** Open (or re-open) the window. Called on a refresh press, never on a timer. */
|
|
28
|
+
export const startHotSyncWindow = (now: number = Date.now()): void => {
|
|
29
|
+
hotUntil = now + HOT_WINDOW_MS;
|
|
30
|
+
for (const listener of listeners) listener();
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const isHotSyncWindowActive = (now: number = Date.now()): boolean =>
|
|
34
|
+
now < hotUntil;
|
|
35
|
+
|
|
36
|
+
/** Notifies on every press so a poll loop already asleep on the ambient
|
|
37
|
+
* interval can reschedule onto the hot one instead of finishing its long wait
|
|
38
|
+
* first. */
|
|
39
|
+
export const subscribeHotSyncWindow = (listener: () => void): (() => void) => {
|
|
40
|
+
listeners.add(listener);
|
|
41
|
+
return () => {
|
|
42
|
+
listeners.delete(listener);
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const __resetHotSyncWindow = (): void => {
|
|
47
|
+
hotUntil = 0;
|
|
48
|
+
};
|
|
@@ -3,7 +3,7 @@ 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 { hasGrown, totalsFrom } from "./mail-freshness.js";
|
|
6
|
+
import { grownMailboxIds, hasGrown, totalsFrom } from "./mail-freshness.js";
|
|
7
7
|
|
|
8
8
|
const mailbox = (
|
|
9
9
|
mailboxId: string,
|
|
@@ -114,3 +114,33 @@ describe("totalsFrom", () => {
|
|
|
114
114
|
assert.equal(hasGrown(baseline, afterArrivalAndDelete), true);
|
|
115
115
|
});
|
|
116
116
|
});
|
|
117
|
+
|
|
118
|
+
describe("grownMailboxIds", () => {
|
|
119
|
+
// The invalidation half of the same reading: which folders' listings are
|
|
120
|
+
// now behind the server, so an open tab reloads exactly those.
|
|
121
|
+
test("names only the mailboxes whose totals moved up", () => {
|
|
122
|
+
const baseline = new Map([
|
|
123
|
+
["mb-1", 10],
|
|
124
|
+
["mb-2", 4],
|
|
125
|
+
["mb-3", 7],
|
|
126
|
+
]);
|
|
127
|
+
const current = new Map([
|
|
128
|
+
["mb-1", 12],
|
|
129
|
+
["mb-2", 4],
|
|
130
|
+
["mb-3", 6],
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
assert.deepEqual(grownMailboxIds(baseline, current), ["mb-1"]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("counts a folder absent from the baseline from zero", () => {
|
|
137
|
+
assert.deepEqual(grownMailboxIds(new Map(), new Map([["mb-new", 3]])), [
|
|
138
|
+
"mb-new",
|
|
139
|
+
]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("is empty when nothing moved", () => {
|
|
143
|
+
const totals = new Map([["mb-1", 10]]);
|
|
144
|
+
assert.deepEqual(grownMailboxIds(totals, new Map(totals)), []);
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
} from "react";
|
|
15
15
|
import { useMailContext } from "@/lib/mail-context";
|
|
16
16
|
import type { ResultFolderIndex } from "@/lib/result-folder";
|
|
17
|
+
import {
|
|
18
|
+
invalidateThreadListQueries,
|
|
19
|
+
threadListCacheKeys,
|
|
20
|
+
} from "@/lib/thread-list-cache";
|
|
17
21
|
|
|
18
22
|
/**
|
|
19
23
|
* How often the background poll re-reads each open account's sync status.
|
|
@@ -55,22 +59,29 @@ export const totalsFrom = (
|
|
|
55
59
|
};
|
|
56
60
|
|
|
57
61
|
/**
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
62
|
+
* Which mailboxes' totals grew since the baseline. Pure so the "did mail
|
|
63
|
+
* arrive" call is unit-testable without a DOM or a QueryClient. A mailbox
|
|
64
|
+
* missing from the baseline (new folder, first-ever reading) counts from zero
|
|
65
|
+
* rather than being skipped, so mail landing in a folder created since the last
|
|
66
|
+
* look still counts as an arrival.
|
|
63
67
|
*/
|
|
64
|
-
export const
|
|
68
|
+
export const grownMailboxIds = (
|
|
65
69
|
baseline: MailboxTotals,
|
|
66
70
|
current: MailboxTotals,
|
|
67
|
-
):
|
|
71
|
+
): string[] => {
|
|
72
|
+
const grown: string[] = [];
|
|
68
73
|
for (const [mailboxId, total] of current) {
|
|
69
|
-
if (total > (baseline.get(mailboxId) ?? 0))
|
|
74
|
+
if (total > (baseline.get(mailboxId) ?? 0)) grown.push(mailboxId);
|
|
70
75
|
}
|
|
71
|
-
return
|
|
76
|
+
return grown;
|
|
72
77
|
};
|
|
73
78
|
|
|
79
|
+
/** Whether any mailbox grew at all — the dot's own question. */
|
|
80
|
+
export const hasGrown = (
|
|
81
|
+
baseline: MailboxTotals,
|
|
82
|
+
current: MailboxTotals,
|
|
83
|
+
): boolean => grownMailboxIds(baseline, current).length > 0;
|
|
84
|
+
|
|
74
85
|
interface MailFreshnessContextValue {
|
|
75
86
|
/** True once any of these accounts has grown since it was last acknowledged. */
|
|
76
87
|
hasNewMail: (accountIds: readonly string[]) => boolean;
|
|
@@ -102,10 +113,13 @@ interface MailFreshnessProviderProps {
|
|
|
102
113
|
/**
|
|
103
114
|
* Watches every open account's sync status once a minute and flags which
|
|
104
115
|
* accounts have grown since they were last shown to the user (#582 clause
|
|
105
|
-
* 4). The flag is sticky until acknowledged
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
116
|
+
* 4). The flag is sticky until acknowledged: it lights the dot on whichever
|
|
117
|
+
* `RefreshButton` reads this context and stays lit until a refresh clears it.
|
|
118
|
+
*
|
|
119
|
+
* A tick that sees a folder's total move also invalidates that folder's thread
|
|
120
|
+
* listing. It reorders nothing and asks the mail server for nothing — the
|
|
121
|
+
* message is already on our side, so an open list showing it is a cache read,
|
|
122
|
+
* not IMAP work. The dot is still the account-level signal.
|
|
109
123
|
*/
|
|
110
124
|
export function MailFreshnessProvider({
|
|
111
125
|
accountIds,
|
|
@@ -115,6 +129,10 @@ export function MailFreshnessProvider({
|
|
|
115
129
|
const { resultFolderIndex } = useMailContext();
|
|
116
130
|
const baselineRef = useRef(new Map<string, MailboxTotals>());
|
|
117
131
|
const seededRef = useRef(new Set<string>());
|
|
132
|
+
// The previous tick's reading, distinct from the dot's baseline: the dot's
|
|
133
|
+
// baseline is sticky until acknowledged, so comparing against it would
|
|
134
|
+
// re-invalidate on every tick for as long as the dot stays lit.
|
|
135
|
+
const previousTotalsRef = useRef(new Map<string, MailboxTotals>());
|
|
118
136
|
const [newMail, setNewMail] = useState<ReadonlySet<string>>(new Set());
|
|
119
137
|
|
|
120
138
|
const queries = useQueries({
|
|
@@ -136,6 +154,20 @@ export function MailFreshnessProvider({
|
|
|
136
154
|
const data = queries[index]?.data;
|
|
137
155
|
if (!data) return;
|
|
138
156
|
const current = totalsFrom(data.mailboxes ?? [], resultFolderIndex);
|
|
157
|
+
|
|
158
|
+
// Mail that has already landed on the server costs nothing more to
|
|
159
|
+
// show, so a folder whose total moved since the last tick reloads
|
|
160
|
+
// its listing here. That caps how stale an open tab's list can get
|
|
161
|
+
// at this poll's own interval, without a single extra IMAP round.
|
|
162
|
+
const previous = previousTotalsRef.current.get(accountId);
|
|
163
|
+
previousTotalsRef.current.set(accountId, current);
|
|
164
|
+
if (previous) {
|
|
165
|
+
const grown = grownMailboxIds(previous, current);
|
|
166
|
+
if (grown.length > 0) {
|
|
167
|
+
invalidateThreadListQueries(queryClient, threadListCacheKeys(grown));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
139
171
|
if (!seededRef.current.has(accountId)) {
|
|
140
172
|
seededRef.current.add(accountId);
|
|
141
173
|
baselineRef.current.set(accountId, current);
|
|
@@ -34,16 +34,16 @@ const wire: RemitImapQuarantineResponse = {
|
|
|
34
34
|
|
|
35
35
|
describe("toQuarantineEntry", () => {
|
|
36
36
|
it("spells the folder role the way the kit does", () => {
|
|
37
|
-
assert.equal(toQuarantineEntry(wire).mailboxRole, "junk");
|
|
37
|
+
assert.equal(toQuarantineEntry(wire, "/").mailboxRole, "junk");
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
it("leaves the role absent when the folder has none appointed", () => {
|
|
41
41
|
const { mailboxRole: _dropped, ...roleless } = wire;
|
|
42
|
-
assert.equal(toQuarantineEntry(roleless).mailboxRole, undefined);
|
|
42
|
+
assert.equal(toQuarantineEntry(roleless, "/").mailboxRole, undefined);
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
it("carries every field the report and the row are built from", () => {
|
|
46
|
-
const entry = toQuarantineEntry(wire);
|
|
46
|
+
const entry = toQuarantineEntry(wire, "/");
|
|
47
47
|
assert.equal(entry.quarantineId, wire.quarantineId);
|
|
48
48
|
assert.equal(entry.uid, wire.uid);
|
|
49
49
|
assert.equal(entry.uidValidity, wire.uidValidity);
|
|
@@ -63,8 +63,13 @@ describe("toQuarantineEntry", () => {
|
|
|
63
63
|
assert.deepEqual(entry.structure, wire.structure);
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
it("carries the delimiter the caller resolved, which the wire has not", () => {
|
|
67
|
+
assert.equal("mailboxDelimiter" in wire, false);
|
|
68
|
+
assert.equal(toQuarantineEntry(wire, ".").mailboxDelimiter, ".");
|
|
69
|
+
});
|
|
70
|
+
|
|
66
71
|
it("drops the fields the settings surface has no use for", () => {
|
|
67
|
-
const entry = toQuarantineEntry(wire);
|
|
72
|
+
const entry = toQuarantineEntry(wire, "/");
|
|
68
73
|
assert.equal("accountConfigId" in entry, false);
|
|
69
74
|
assert.equal("createdAt" in entry, false);
|
|
70
75
|
assert.equal("updatedAt" in entry, false);
|
|
@@ -7,9 +7,14 @@ import { CANONICAL_TO_NAV_ROLE } from "./folder-roles";
|
|
|
7
7
|
* the settings surface has no use for, and spells the folder role in the
|
|
8
8
|
* canonical PascalCase the API uses everywhere. Both differences are resolved
|
|
9
9
|
* here rather than by widening the kit's entry type.
|
|
10
|
+
*
|
|
11
|
+
* The record carries no hierarchy delimiter either, so the caller resolves the
|
|
12
|
+
* account's own from its mailbox list and hands it in — the row shows a folder
|
|
13
|
+
* name, and a path cannot be cut into one without it.
|
|
10
14
|
*/
|
|
11
15
|
export function toQuarantineEntry(
|
|
12
16
|
wire: RemitImapQuarantineResponse,
|
|
17
|
+
mailboxDelimiter: string,
|
|
13
18
|
): QuarantineEntry {
|
|
14
19
|
return {
|
|
15
20
|
quarantineId: wire.quarantineId,
|
|
@@ -21,6 +26,7 @@ export function toQuarantineEntry(
|
|
|
21
26
|
? CANONICAL_TO_NAV_ROLE[wire.mailboxRole]
|
|
22
27
|
: undefined,
|
|
23
28
|
mailboxPath: wire.mailboxPath,
|
|
29
|
+
mailboxDelimiter,
|
|
24
30
|
failureStage: wire.failureStage,
|
|
25
31
|
failureCode: wire.failureCode,
|
|
26
32
|
failureMessage: wire.failureMessage,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { describe, it } from "node:test";
|
|
3
|
+
import { provenanceFolderLabel } from "@remit/ui";
|
|
3
4
|
import {
|
|
4
5
|
type AccountMailboxes,
|
|
5
6
|
buildResultFolderIndex,
|
|
@@ -9,10 +10,11 @@ import {
|
|
|
9
10
|
const account = (
|
|
10
11
|
appointments: { role: string; mailboxId: string }[],
|
|
11
12
|
mailboxes: { mailboxId: string; fullPath: string }[],
|
|
13
|
+
hierarchyDelimiter = "/",
|
|
12
14
|
): AccountMailboxes =>
|
|
13
15
|
({
|
|
14
16
|
folderAppointments: appointments,
|
|
15
|
-
mailboxes,
|
|
17
|
+
mailboxes: mailboxes.map((mailbox) => ({ ...mailbox, hierarchyDelimiter })),
|
|
16
18
|
}) as unknown as AccountMailboxes;
|
|
17
19
|
|
|
18
20
|
describe("buildResultFolderIndex", () => {
|
|
@@ -30,9 +32,11 @@ describe("buildResultFolderIndex", () => {
|
|
|
30
32
|
assert.deepEqual(index.get("mb-junk"), {
|
|
31
33
|
role: "junk",
|
|
32
34
|
providerPath: "Bulk Mail",
|
|
35
|
+
hierarchyDelimiter: "/",
|
|
33
36
|
});
|
|
34
37
|
assert.deepEqual(index.get("mb-work"), {
|
|
35
38
|
providerPath: "Projects/Bookkeeping",
|
|
39
|
+
hierarchyDelimiter: "/",
|
|
36
40
|
});
|
|
37
41
|
});
|
|
38
42
|
|
|
@@ -72,7 +76,7 @@ describe("resolveResultFolder", () => {
|
|
|
72
76
|
it("skips a virtual folder for one that names a real place", () => {
|
|
73
77
|
assert.deepEqual(resolveResultFolder(index, ["mb-all", "mb-inbox"]), {
|
|
74
78
|
mailboxId: "mb-inbox",
|
|
75
|
-
folder: { role: "inbox", providerPath: "INBOX" },
|
|
79
|
+
folder: { role: "inbox", providerPath: "INBOX", hierarchyDelimiter: "/" },
|
|
76
80
|
});
|
|
77
81
|
});
|
|
78
82
|
|
|
@@ -83,7 +87,11 @@ describe("resolveResultFolder", () => {
|
|
|
83
87
|
it("falls back to the first id when none names a real place", () => {
|
|
84
88
|
assert.deepEqual(resolveResultFolder(index, ["mb-all"]), {
|
|
85
89
|
mailboxId: "mb-all",
|
|
86
|
-
folder: {
|
|
90
|
+
folder: {
|
|
91
|
+
role: "all",
|
|
92
|
+
providerPath: "[Gmail]/All Mail",
|
|
93
|
+
hierarchyDelimiter: "/",
|
|
94
|
+
},
|
|
87
95
|
});
|
|
88
96
|
});
|
|
89
97
|
|
|
@@ -113,3 +121,13 @@ describe("buildResultFolderIndex before the mailbox list arrives", () => {
|
|
|
113
121
|
assert.deepEqual(index.get("mb-junk"), { role: "junk" });
|
|
114
122
|
});
|
|
115
123
|
});
|
|
124
|
+
|
|
125
|
+
describe("buildResultFolderIndex on a dotted server", () => {
|
|
126
|
+
it("carries the delimiter the account reports, so the chip reads the leaf", () => {
|
|
127
|
+
const index = buildResultFolderIndex([
|
|
128
|
+
account([], [{ mailboxId: "mb-q3", fullPath: "INBOX.Projects.Q3" }], "."),
|
|
129
|
+
]);
|
|
130
|
+
|
|
131
|
+
assert.equal(provenanceFolderLabel(index.get("mb-q3") ?? {}), "Q3");
|
|
132
|
+
});
|
|
133
|
+
});
|
package/src/lib/result-folder.ts
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* sidebar already loads: the id → folder map here is the only thing either
|
|
8
8
|
* needs, so neither the search API nor the semantic index has to carry a folder.
|
|
9
9
|
*
|
|
10
|
+
* The mailbox row carries the hierarchy delimiter its own server reports, and it
|
|
11
|
+
* is kept alongside the path: a chip labelled by path shows the folder's own
|
|
12
|
+
* name, which on a `.`-delimited account cannot be cut on a slash.
|
|
13
|
+
*
|
|
10
14
|
* The role comes from the account's `folderAppointments` map (RFC 032), which
|
|
11
15
|
* the server resolves from the IMAP SPECIAL-USE attributes — so `junk` means the
|
|
12
16
|
* folder the account advertises as `\Junk`, whatever it is called. Names are
|
|
@@ -27,7 +31,7 @@ export interface AccountMailboxes {
|
|
|
27
31
|
folderAppointments: readonly RemitImapFolderAppointment[];
|
|
28
32
|
mailboxes: readonly Pick<
|
|
29
33
|
RemitImapMailboxResponse,
|
|
30
|
-
"mailboxId" | "fullPath"
|
|
34
|
+
"mailboxId" | "fullPath" | "hierarchyDelimiter"
|
|
31
35
|
>[];
|
|
32
36
|
}
|
|
33
37
|
|
|
@@ -46,10 +50,11 @@ export function buildResultFolderIndex(
|
|
|
46
50
|
for (const [mailboxId, role] of roles) index.set(mailboxId, { role });
|
|
47
51
|
for (const mailbox of account.mailboxes) {
|
|
48
52
|
const role = roles.get(mailbox.mailboxId);
|
|
49
|
-
|
|
50
|
-
...(role ? { role } : {}),
|
|
53
|
+
const path = {
|
|
51
54
|
providerPath: mailbox.fullPath,
|
|
52
|
-
|
|
55
|
+
hierarchyDelimiter: mailbox.hierarchyDelimiter,
|
|
56
|
+
};
|
|
57
|
+
index.set(mailbox.mailboxId, role ? { role, ...path } : path);
|
|
53
58
|
}
|
|
54
59
|
}
|
|
55
60
|
return index;
|