@remit/web-client 0.0.274 → 0.0.275
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.275",
|
|
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": {
|
|
@@ -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
|
}
|