@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.274",
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": {
@@ -55,6 +55,7 @@ const status = (
55
55
  phase: "syncing",
56
56
  messagesSynced: mailbox.synced,
57
57
  messagesTotal: mailbox.total,
58
+ highWaterMarkUid: mailbox.synced,
58
59
  })),
59
60
  });
60
61
 
@@ -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 { grownMailboxIds, hasGrown, totalsFrom } from "./mail-freshness.js";
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", 10]]);
29
- const current = new Map([["mb-1", 10]]);
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's total increased", () => {
34
- const baseline = new Map([["mb-1", 10]]);
35
- const current = new Map([["mb-1", 11]]);
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 with zero new messages is not growth", () => {
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", 10],
60
- ["mb-2", 5],
59
+ ["mb-1", 100],
60
+ ["mb-2", 50],
61
61
  ]);
62
62
  const current = new Map([
63
- ["mb-1", 10],
64
- ["mb-2", 6],
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("totalsFrom", () => {
70
+ describe("highWaterMarksFrom", () => {
71
71
  test("keeps a mailbox with no resolved role (a custom folder)", () => {
72
- const totals = totalsFrom([mailbox("mb-custom", 5)], roles({}));
73
- assert.deepEqual([...totals], [["mb-custom", 5]]);
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 totals = totalsFrom(
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(totals.keys()), new Set(["mb-inbox", "mb-junk"]));
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 total growing is the user's doing, not an arrival`, () => {
86
- const totals = totalsFrom([mailbox("mb-1", 5)], roles({ "mb-1": role }));
87
- assert.equal(totals.has("mb-1"), false);
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 growth once Trash is excluded", () => {
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 = totalsFrom(
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 = totalsFrom(
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
- test("real new mail in Inbox still reads as growth alongside an unrelated delete", () => {
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 = totalsFrom(
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 = totalsFrom(
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 whose totals moved up", () => {
140
+ test("names only the mailboxes that took a higher UID", () => {
122
141
  const baseline = new Map([
123
- ["mb-1", 10],
124
- ["mb-2", 4],
125
- ["mb-3", 7],
142
+ ["mb-1", 100],
143
+ ["mb-2", 40],
144
+ ["mb-3", 70],
126
145
  ]);
127
146
  const current = new Map([
128
- ["mb-1", 12],
129
- ["mb-2", 4],
130
- ["mb-3", 6],
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 totals = new Map([["mb-1", 10]]);
144
- assert.deepEqual(grownMailboxIds(totals, new Map(totals)), []);
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 total growing is the user's doing, not an arrival: sending
33
- * grows Sent, deleting or archiving grows Trash/Archive, and a Drafts count is
34
- * autosaves. Counting any of these as "new mail" would light the dot on a
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
- type MailboxTotals = ReadonlyMap<string, number>;
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 totalsFrom = (
56
+ export const highWaterMarksFrom = (
49
57
  mailboxes: readonly RemitImapMailboxSyncProgress[],
50
58
  resultFolderIndex: ResultFolderIndex,
51
- ): MailboxTotals => {
52
- const totals = new Map<string, number>();
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
- totals.set(mailbox.mailboxId, mailbox.messagesTotal);
64
+ marks.set(mailbox.mailboxId, mailbox.highWaterMarkUid);
57
65
  }
58
- return totals;
66
+ return marks;
59
67
  };
60
68
 
61
69
  /**
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.
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: MailboxTotals,
70
- current: MailboxTotals,
77
+ baseline: MailboxHighWaterMarks,
78
+ current: MailboxHighWaterMarks,
71
79
  ): string[] => {
72
80
  const grown: string[] = [];
73
- for (const [mailboxId, total] of current) {
74
- if (total > (baseline.get(mailboxId) ?? 0)) grown.push(mailboxId);
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 grew at all — the dot's own question. */
87
+ /** Whether any mailbox took a new message at all — the dot's own question. */
80
88
  export const hasGrown = (
81
- baseline: MailboxTotals,
82
- current: MailboxTotals,
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'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.
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, MailboxTotals>());
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 previousTotalsRef = useRef(new Map<string, MailboxTotals>());
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 = totalsFrom(data.mailboxes ?? [], resultFolderIndex);
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 whose total moved since the last tick reloads
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 = previousTotalsRef.current.get(accountId);
163
- previousTotalsRef.current.set(accountId, current);
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
- totalsFrom(data?.mailboxes ?? [], resultFolderIndex),
222
+ highWaterMarksFrom(data?.mailboxes ?? [], resultFolderIndex),
212
223
  );
213
224
  seededRef.current.add(accountId);
214
225
  }