@remit/web-client 0.0.31 → 0.0.33
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/auth/better-auth-config.ts +100 -6
- package/src/auth/better-auth-token.test.ts +72 -3
- package/src/components/mail/MessageList.tsx +36 -22
- package/src/components/ui/ConfirmDialog.stories.tsx +15 -0
- package/src/hooks/useEscalatedDelete.ts +5 -1
- package/src/hooks/useSelection.test.ts +62 -1
- package/src/hooks/useSelection.ts +37 -0
- package/src/lib/bulk-delete.test.ts +34 -0
- package/src/lib/bulk-delete.ts +18 -0
- package/src/lib/format.test.ts +27 -0
- package/src/lib/format.ts +15 -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.33",
|
|
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": {
|
|
@@ -29,12 +29,78 @@ export const authClient = createAuthClient();
|
|
|
29
29
|
|
|
30
30
|
const EXPIRY_SKEW_SECONDS = 60;
|
|
31
31
|
|
|
32
|
+
// How long to stop reaching for the token endpoint after a mint is throttled.
|
|
33
|
+
// A near-expiry token is still usable for `EXPIRY_SKEW_SECONDS`, so backing off
|
|
34
|
+
// keeps a throttled window from turning every request into another mint attempt
|
|
35
|
+
// against an endpoint that already said no.
|
|
36
|
+
const REFRESH_BACKOFF_SECONDS = 30;
|
|
37
|
+
|
|
38
|
+
// Survives page reloads and in-tab navigations so a fresh page load reuses the
|
|
39
|
+
// session's token instead of minting a new one. Cleared on sign-out. Scoped to
|
|
40
|
+
// the tab, not the origin: the token is short-lived and the httpOnly session
|
|
41
|
+
// cookie remains the real credential.
|
|
42
|
+
const STORAGE_KEY = "remit.better-auth.token";
|
|
43
|
+
|
|
32
44
|
interface CachedToken {
|
|
33
45
|
value: string;
|
|
34
46
|
expiresAt: number;
|
|
35
47
|
}
|
|
36
48
|
|
|
49
|
+
const tokenStore = (): Storage | null => {
|
|
50
|
+
try {
|
|
51
|
+
return globalThis.sessionStorage ?? null;
|
|
52
|
+
} catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const readStore = (): CachedToken | null => {
|
|
58
|
+
const raw = tokenStore()?.getItem(STORAGE_KEY);
|
|
59
|
+
if (!raw) return null;
|
|
60
|
+
try {
|
|
61
|
+
const parsed: unknown = JSON.parse(raw);
|
|
62
|
+
if (
|
|
63
|
+
parsed &&
|
|
64
|
+
typeof parsed === "object" &&
|
|
65
|
+
typeof (parsed as CachedToken).value === "string" &&
|
|
66
|
+
typeof (parsed as CachedToken).expiresAt === "number"
|
|
67
|
+
) {
|
|
68
|
+
return {
|
|
69
|
+
value: (parsed as CachedToken).value,
|
|
70
|
+
expiresAt: (parsed as CachedToken).expiresAt,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
} catch {
|
|
74
|
+
// A corrupt entry is treated as no token.
|
|
75
|
+
}
|
|
76
|
+
return null;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const writeStore = (token: CachedToken | null): void => {
|
|
80
|
+
const store = tokenStore();
|
|
81
|
+
if (!store) return;
|
|
82
|
+
try {
|
|
83
|
+
if (!token) {
|
|
84
|
+
store.removeItem(STORAGE_KEY);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
store.setItem(STORAGE_KEY, JSON.stringify(token));
|
|
88
|
+
} catch {
|
|
89
|
+
// Storage being unavailable (private mode, quota) is not fatal: the
|
|
90
|
+
// in-memory cache still serves the current page.
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
37
94
|
let cached: CachedToken | null = null;
|
|
95
|
+
let backoffUntil = 0;
|
|
96
|
+
|
|
97
|
+
// Hydrate the in-memory cache from storage on first read of a page load, so a
|
|
98
|
+
// token minted before a reload is reused rather than re-minted.
|
|
99
|
+
const currentCache = (): CachedToken | null => {
|
|
100
|
+
if (cached) return cached;
|
|
101
|
+
cached = readStore();
|
|
102
|
+
return cached;
|
|
103
|
+
};
|
|
38
104
|
|
|
39
105
|
const nowSeconds = (): number => Math.floor(Date.now() / 1000);
|
|
40
106
|
|
|
@@ -103,6 +169,8 @@ const mint = (): Promise<string> => {
|
|
|
103
169
|
inFlight = requestToken()
|
|
104
170
|
.then((token) => {
|
|
105
171
|
cached = { value: token, expiresAt: decodeExp(token) };
|
|
172
|
+
writeStore(cached);
|
|
173
|
+
backoffUntil = 0;
|
|
106
174
|
return token;
|
|
107
175
|
})
|
|
108
176
|
.finally(() => {
|
|
@@ -111,23 +179,49 @@ const mint = (): Promise<string> => {
|
|
|
111
179
|
return inFlight;
|
|
112
180
|
};
|
|
113
181
|
|
|
182
|
+
const isFresh = (token: CachedToken): boolean =>
|
|
183
|
+
token.expiresAt - EXPIRY_SKEW_SECONDS > nowSeconds();
|
|
184
|
+
|
|
185
|
+
const isUsable = (token: CachedToken): boolean =>
|
|
186
|
+
token.expiresAt > nowSeconds();
|
|
187
|
+
|
|
114
188
|
/**
|
|
115
189
|
* Return a valid RS256 JWT for the current session, minting a fresh one only
|
|
116
190
|
* when the cached token is missing or within the skew window of expiry. The API
|
|
117
191
|
* interceptor attaches it as a Bearer token; the backend verifies it against the
|
|
118
192
|
* JWKS.
|
|
119
193
|
*
|
|
120
|
-
*
|
|
121
|
-
* request
|
|
122
|
-
*
|
|
194
|
+
* A throttled or failed refresh never discards a token that is still usable: the
|
|
195
|
+
* request keeps the session it already holds and the endpoint is left alone
|
|
196
|
+
* until the backoff clears. Only a mint with nothing usable to fall back to
|
|
197
|
+
* throws — returning null would put the request on the wire without an
|
|
198
|
+
* Authorization header, and the backend answers that with a 401 that names a
|
|
199
|
+
* missing token rather than the mint that failed.
|
|
123
200
|
*/
|
|
124
201
|
export const fetchBetterAuthToken = async (): Promise<string> => {
|
|
125
|
-
|
|
126
|
-
|
|
202
|
+
const current = currentCache();
|
|
203
|
+
|
|
204
|
+
if (current && isFresh(current)) {
|
|
205
|
+
return current.value;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (current && isUsable(current) && nowSeconds() < backoffUntil) {
|
|
209
|
+
return current.value;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
return await mint();
|
|
214
|
+
} catch (error) {
|
|
215
|
+
if (current && isUsable(current)) {
|
|
216
|
+
backoffUntil = nowSeconds() + REFRESH_BACKOFF_SECONDS;
|
|
217
|
+
return current.value;
|
|
218
|
+
}
|
|
219
|
+
throw error;
|
|
127
220
|
}
|
|
128
|
-
return mint();
|
|
129
221
|
};
|
|
130
222
|
|
|
131
223
|
export const resetBetterAuthTokenCache = (): void => {
|
|
224
|
+
backoffUntil = 0;
|
|
225
|
+
writeStore(null);
|
|
132
226
|
cached = null;
|
|
133
227
|
};
|
|
@@ -13,12 +13,21 @@ const base64url = (value: string): string =>
|
|
|
13
13
|
.replace(/\//g, "_")
|
|
14
14
|
.replace(/=+$/, "");
|
|
15
15
|
|
|
16
|
-
/** A token shaped like the one better-auth mints,
|
|
17
|
-
const
|
|
16
|
+
/** A token shaped like the one better-auth mints, expiring after `ttl` seconds. */
|
|
17
|
+
const jwtExpiringIn = (label: string, ttl: number): string =>
|
|
18
18
|
`header.${base64url(
|
|
19
|
-
JSON.stringify({ exp: Math.floor(Date.now() / 1000) +
|
|
19
|
+
JSON.stringify({ exp: Math.floor(Date.now() / 1000) + ttl, label }),
|
|
20
20
|
)}.signature`;
|
|
21
21
|
|
|
22
|
+
/** A token shaped like the one better-auth mints, valid for an hour. */
|
|
23
|
+
const jwt = (label: string): string => jwtExpiringIn(label, 3600);
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A token past its comfortable-refresh window but not yet expired: `getToken`
|
|
27
|
+
* treats it as needing a refresh while still holding it as a usable fallback.
|
|
28
|
+
*/
|
|
29
|
+
const nearExpiryJwt = (label: string): string => jwtExpiringIn(label, 30);
|
|
30
|
+
|
|
22
31
|
const realFetch = globalThis.fetch;
|
|
23
32
|
|
|
24
33
|
interface Stub {
|
|
@@ -149,4 +158,64 @@ describe("fetchBetterAuthToken", () => {
|
|
|
149
158
|
/could not be completed|Failed to fetch/i,
|
|
150
159
|
);
|
|
151
160
|
});
|
|
161
|
+
|
|
162
|
+
test("a throttled refresh keeps the still-valid token instead of discarding it", async () => {
|
|
163
|
+
const seed = stubTokenEndpoint(() =>
|
|
164
|
+
tokenResponse(nearExpiryJwt("still-valid")),
|
|
165
|
+
);
|
|
166
|
+
seed.release();
|
|
167
|
+
const held = await fetchBetterAuthToken();
|
|
168
|
+
|
|
169
|
+
const throttled = stubTokenEndpoint(
|
|
170
|
+
() => new Response("", { status: 429, statusText: "Too Many Requests" }),
|
|
171
|
+
);
|
|
172
|
+
throttled.release();
|
|
173
|
+
const afterThrottle = await fetchBetterAuthToken();
|
|
174
|
+
|
|
175
|
+
assert.equal(throttled.calls, 1);
|
|
176
|
+
assert.equal(afterThrottle, held);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("after a throttled refresh it backs off rather than hammering the endpoint", async () => {
|
|
180
|
+
const seed = stubTokenEndpoint(() =>
|
|
181
|
+
tokenResponse(nearExpiryJwt("still-valid")),
|
|
182
|
+
);
|
|
183
|
+
seed.release();
|
|
184
|
+
const held = await fetchBetterAuthToken();
|
|
185
|
+
|
|
186
|
+
const throttled = stubTokenEndpoint(
|
|
187
|
+
() => new Response("", { status: 429, statusText: "Too Many Requests" }),
|
|
188
|
+
);
|
|
189
|
+
throttled.release();
|
|
190
|
+
await fetchBetterAuthToken();
|
|
191
|
+
|
|
192
|
+
const first = await fetchBetterAuthToken();
|
|
193
|
+
const second = await fetchBetterAuthToken();
|
|
194
|
+
|
|
195
|
+
assert.equal(throttled.calls, 1);
|
|
196
|
+
assert.equal(first, held);
|
|
197
|
+
assert.equal(second, held);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("a throttled refresh with no usable token to fall back on still throws", async () => {
|
|
201
|
+
const seed = stubTokenEndpoint(() =>
|
|
202
|
+
tokenResponse(jwtExpiringIn("already-expired", -10)),
|
|
203
|
+
);
|
|
204
|
+
seed.release();
|
|
205
|
+
await fetchBetterAuthToken();
|
|
206
|
+
|
|
207
|
+
const throttled = stubTokenEndpoint(
|
|
208
|
+
() => new Response("", { status: 429, statusText: "Too Many Requests" }),
|
|
209
|
+
);
|
|
210
|
+
throttled.release();
|
|
211
|
+
|
|
212
|
+
await assert.rejects(
|
|
213
|
+
() => fetchBetterAuthToken(),
|
|
214
|
+
(error: unknown) => {
|
|
215
|
+
assert.ok(error instanceof AuthTokenError);
|
|
216
|
+
assert.equal(error.status, 429);
|
|
217
|
+
return true;
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
});
|
|
152
221
|
});
|
|
@@ -244,6 +244,7 @@ export const MessageList = ({
|
|
|
244
244
|
setAnchor,
|
|
245
245
|
selectAll,
|
|
246
246
|
toggleAll,
|
|
247
|
+
intersectWith,
|
|
247
248
|
} = useSelection();
|
|
248
249
|
|
|
249
250
|
// Pending delete, awaiting confirmation. `null` means the dialog is closed.
|
|
@@ -829,28 +830,26 @@ export const MessageList = ({
|
|
|
829
830
|
[],
|
|
830
831
|
);
|
|
831
832
|
|
|
832
|
-
//
|
|
833
|
-
//
|
|
834
|
-
//
|
|
835
|
-
//
|
|
836
|
-
//
|
|
837
|
-
//
|
|
833
|
+
// Narrow the selection when threads change (e.g., after delete), dropping
|
|
834
|
+
// only the ids that left and keeping every survivor — K-9's
|
|
835
|
+
// `selected.intersect(uniqueIds)`, the reference behavior #92's D2 cites.
|
|
836
|
+
// Wiping the whole selection because one id left (#111) cost the other 49
|
|
837
|
+
// rows on an ordinary refresh, and could take the post-delete Retry
|
|
838
|
+
// selection with it: `processDeleteOutcome` materializes the failed ids as
|
|
839
|
+
// the new selection and resets this effect to live by returning escalation
|
|
840
|
+
// to idle, so the cache invalidation's refetch ran this same effect against
|
|
841
|
+
// the retry set — a clear here would have dropped the Retry notice
|
|
842
|
+
// (gated on `selectedCount > 0`) along with it.
|
|
843
|
+
// Skipped while an escalated run is active: `selectedIds` there is a stale
|
|
844
|
+
// loaded-rows snapshot from the moment escalation started (the real
|
|
845
|
+
// selection is the predicate, D2), not something a background refetch
|
|
846
|
+
// reshuffling `threads` should be allowed to narrow out from under a count
|
|
847
|
+
// or a delete in progress — that would silently exit selection mode
|
|
848
|
+
// mid-run.
|
|
838
849
|
useEffect(() => {
|
|
839
850
|
if (escalation.phase.kind !== "idle" || escalation.isDeleting) return;
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
(id) => !threadIds.has(id),
|
|
843
|
-
);
|
|
844
|
-
if (hasOrphanedSelection) {
|
|
845
|
-
clearSelection();
|
|
846
|
-
}
|
|
847
|
-
}, [
|
|
848
|
-
threads,
|
|
849
|
-
selectedIds,
|
|
850
|
-
clearSelection,
|
|
851
|
-
escalation.phase,
|
|
852
|
-
escalation.isDeleting,
|
|
853
|
-
]);
|
|
851
|
+
intersectWith(threads.map((t) => t.messageId));
|
|
852
|
+
}, [threads, intersectWith, escalation.phase, escalation.isDeleting]);
|
|
854
853
|
|
|
855
854
|
// Load more when scrolling near the bottom
|
|
856
855
|
useEffect(() => {
|
|
@@ -1210,6 +1209,14 @@ export const MessageList = ({
|
|
|
1210
1209
|
: pendingDelete.total
|
|
1211
1210
|
: 0;
|
|
1212
1211
|
|
|
1212
|
+
// The predicate case (#109): `pendingDelete.total` is `countMatches`'s
|
|
1213
|
+
// frozen page-through, and the delete itself re-pages the same predicate a
|
|
1214
|
+
// second, independent time. Mail arriving or leaving between the two can
|
|
1215
|
+
// make them differ, so the dialog says "about" instead of promising an
|
|
1216
|
+
// exact number it may not honour. A materialized (bounded) selection's
|
|
1217
|
+
// count is exact — it's the delete's own input, not an estimate of it.
|
|
1218
|
+
const pendingDeleteIsEstimate = pendingDelete?.source === "predicate";
|
|
1219
|
+
|
|
1213
1220
|
return (
|
|
1214
1221
|
<>
|
|
1215
1222
|
{completionBanner && !isDesktop && (
|
|
@@ -1249,8 +1256,15 @@ export const MessageList = ({
|
|
|
1249
1256
|
/>
|
|
1250
1257
|
<ConfirmDialog
|
|
1251
1258
|
isOpen={pendingDelete !== null}
|
|
1252
|
-
title={formatDeleteToTrashTitle(
|
|
1253
|
-
|
|
1259
|
+
title={formatDeleteToTrashTitle(
|
|
1260
|
+
pendingDeleteCount,
|
|
1261
|
+
pendingDeleteIsEstimate,
|
|
1262
|
+
)}
|
|
1263
|
+
description={
|
|
1264
|
+
pendingDeleteIsEstimate
|
|
1265
|
+
? "This count is a snapshot — new mail arriving during the delete won't be included. You can restore what's deleted from Trash later."
|
|
1266
|
+
: "You can restore them from Trash later."
|
|
1267
|
+
}
|
|
1254
1268
|
confirmLabel="Move to Trash"
|
|
1255
1269
|
destructive
|
|
1256
1270
|
isBusy={isDeleting}
|
|
@@ -34,6 +34,21 @@ export const OneMessage: Story = {
|
|
|
34
34
|
},
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* An escalated (search-predicate) delete: the count was paged once by
|
|
39
|
+
* `countMatches` and the delete re-pages the same predicate independently, so
|
|
40
|
+
* it is not provably the number that gets deleted (#109). "about" and the
|
|
41
|
+
* description say so up front rather than stating an exact number the run may
|
|
42
|
+
* not honour.
|
|
43
|
+
*/
|
|
44
|
+
export const EscalatedEstimate: Story = {
|
|
45
|
+
args: {
|
|
46
|
+
title: "Move about 3,412 messages to Trash?",
|
|
47
|
+
description:
|
|
48
|
+
"This count is a snapshot — new mail arriving during the delete won't be included. You can restore what's deleted from Trash later.",
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
37
52
|
/** The mutation is in flight: the confirm button disables rather than
|
|
38
53
|
* allowing a second concurrent delete request. */
|
|
39
54
|
export const Busy: Story = {
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
countMatches,
|
|
18
18
|
type DeleteRunOutcome,
|
|
19
19
|
type FetchIdsPage,
|
|
20
|
+
honestProgress,
|
|
20
21
|
runChunkedDelete,
|
|
21
22
|
runPredicateDelete,
|
|
22
23
|
} from "@/lib/bulk-delete";
|
|
@@ -190,8 +191,11 @@ export const useEscalatedDelete = ({
|
|
|
190
191
|
async (ids?: string[]): Promise<DeleteRunOutcome> => {
|
|
191
192
|
cancelRef.current = false;
|
|
192
193
|
setIsDeleting(true);
|
|
194
|
+
// `honestProgress` widens `total` if `done` overtakes it (#109) — the
|
|
195
|
+
// predicate can match more by the time the delete re-pages it than
|
|
196
|
+
// `countMatches` saw, and the bar must never show more done than out of.
|
|
193
197
|
const onProgress = (progress: BulkDeleteProgress) =>
|
|
194
|
-
setDeleteProgress(progress);
|
|
198
|
+
setDeleteProgress(honestProgress(progress));
|
|
195
199
|
|
|
196
200
|
const outcome =
|
|
197
201
|
ids !== undefined
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import assert from "node:assert";
|
|
2
2
|
import { describe, test } from "node:test";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
computeRange,
|
|
5
|
+
intersectSelectedIds,
|
|
6
|
+
nextFocusId,
|
|
7
|
+
} from "./useSelection.js";
|
|
4
8
|
|
|
5
9
|
const ids = ["a", "b", "c", "d", "e"];
|
|
6
10
|
|
|
@@ -73,3 +77,60 @@ describe("nextFocusId", () => {
|
|
|
73
77
|
assert.strictEqual(nextFocusId([], "a", 1), undefined);
|
|
74
78
|
});
|
|
75
79
|
});
|
|
80
|
+
|
|
81
|
+
describe("intersectSelectedIds", () => {
|
|
82
|
+
test("a refresh that drops some selected ids and adds new rows keeps only the survivors (#111)", () => {
|
|
83
|
+
// Regression for #111: the effect used to clear the WHOLE selection the
|
|
84
|
+
// moment any single selected id left the list. Here "b" leaves (deleted
|
|
85
|
+
// elsewhere) while "f" arrives (new mail) — "a" and "c" must survive.
|
|
86
|
+
const selected = new Set(["a", "b", "c"]);
|
|
87
|
+
const refreshedThreadIds = ["a", "c", "d", "f"];
|
|
88
|
+
assert.deepStrictEqual(
|
|
89
|
+
intersectSelectedIds(selected, refreshedThreadIds),
|
|
90
|
+
new Set(["a", "c"]),
|
|
91
|
+
);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("never adds an id that wasn't already selected", () => {
|
|
95
|
+
const selected = new Set(["a"]);
|
|
96
|
+
assert.deepStrictEqual(
|
|
97
|
+
intersectSelectedIds(selected, ["a", "b", "c"]),
|
|
98
|
+
new Set(["a"]),
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("every selected id surviving is a no-op (same members, not just same size)", () => {
|
|
103
|
+
const selected = new Set(["a", "b"]);
|
|
104
|
+
assert.deepStrictEqual(
|
|
105
|
+
intersectSelectedIds(selected, ["a", "b", "z"]),
|
|
106
|
+
new Set(["a", "b"]),
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("a post-delete retry selection survives a refetch that still contains it, minus what actually left", () => {
|
|
111
|
+
// Mirrors processDeleteOutcome materializing the failed ids as the new
|
|
112
|
+
// selection, then the cache-invalidation refetch running this same
|
|
113
|
+
// intersection against the freshly reloaded `threads`. One retry id
|
|
114
|
+
// ("fail-2") is momentarily missing from the refreshed page; the other
|
|
115
|
+
// two must stay selected so the Retry notice (gated on
|
|
116
|
+
// `selectedCount > 0`) doesn't disappear with it.
|
|
117
|
+
const retrySelection = new Set(["fail-1", "fail-2", "fail-3"]);
|
|
118
|
+
const refetchedThreadIds = ["fail-1", "fail-3", "unrelated-1"];
|
|
119
|
+
assert.deepStrictEqual(
|
|
120
|
+
intersectSelectedIds(retrySelection, refetchedThreadIds),
|
|
121
|
+
new Set(["fail-1", "fail-3"]),
|
|
122
|
+
);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("everything in the selection leaving empties it, rather than leaving stale ids behind", () => {
|
|
126
|
+
const selected = new Set(["a", "b"]);
|
|
127
|
+
assert.deepStrictEqual(intersectSelectedIds(selected, ["z"]), new Set());
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("an empty selection stays empty", () => {
|
|
131
|
+
assert.deepStrictEqual(
|
|
132
|
+
intersectSelectedIds(new Set(), ["a", "b"]),
|
|
133
|
+
new Set(),
|
|
134
|
+
);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -52,6 +52,12 @@ interface UseSelectionReturn {
|
|
|
52
52
|
* nothing has been selected yet.
|
|
53
53
|
*/
|
|
54
54
|
anchorId: string | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* Narrows the selection to whatever in it is still present in `currentIds`
|
|
57
|
+
* — drops ids that left, keeps every survivor. Never adds anything, and
|
|
58
|
+
* never clears the selection just because one id is gone (#111).
|
|
59
|
+
*/
|
|
60
|
+
intersectWith: (currentIds: readonly string[]) => void;
|
|
55
61
|
}
|
|
56
62
|
|
|
57
63
|
/**
|
|
@@ -79,6 +85,25 @@ export const computeRange = (
|
|
|
79
85
|
return orderedIds.slice(start, end + 1);
|
|
80
86
|
};
|
|
81
87
|
|
|
88
|
+
/**
|
|
89
|
+
* The ids from `selectedIds` that are still present in `currentIds` — the
|
|
90
|
+
* survivor set after a list refresh. Only ever narrows: an id absent from
|
|
91
|
+
* `selectedIds` is never added just because it's in `currentIds`. Pure so the
|
|
92
|
+
* "drop what left, keep the rest" behavior (K-9's `selected.intersect
|
|
93
|
+
* (uniqueIds)`, cited by #92 D2) can be unit-tested without a DOM.
|
|
94
|
+
*/
|
|
95
|
+
export const intersectSelectedIds = (
|
|
96
|
+
selectedIds: ReadonlySet<string>,
|
|
97
|
+
currentIds: readonly string[],
|
|
98
|
+
): Set<string> => {
|
|
99
|
+
const present = new Set(currentIds);
|
|
100
|
+
const next = new Set<string>();
|
|
101
|
+
for (const id of selectedIds) {
|
|
102
|
+
if (present.has(id)) next.add(id);
|
|
103
|
+
}
|
|
104
|
+
return next;
|
|
105
|
+
};
|
|
106
|
+
|
|
82
107
|
/**
|
|
83
108
|
* Compute the id one step from `focusId` in `orderedIds`, clamped at the ends.
|
|
84
109
|
* Pure so the shift-arrow range-extend math can be unit-tested without a DOM.
|
|
@@ -174,6 +199,17 @@ export const useSelection = <T>(
|
|
|
174
199
|
setAnchorId(id);
|
|
175
200
|
}, []);
|
|
176
201
|
|
|
202
|
+
// Bails out to the same `prev` reference when nothing was dropped, so a
|
|
203
|
+
// caller can run this on every list refresh (e.g. an effect keyed on
|
|
204
|
+
// `threads`) without forcing a render each time.
|
|
205
|
+
const intersectWith = useCallback((currentIds: readonly string[]) => {
|
|
206
|
+
setSelectedIds((prev) => {
|
|
207
|
+
if (prev.size === 0) return prev;
|
|
208
|
+
const next = intersectSelectedIds(prev, currentIds);
|
|
209
|
+
return next.size === prev.size ? prev : next;
|
|
210
|
+
});
|
|
211
|
+
}, []);
|
|
212
|
+
|
|
177
213
|
const selectRange = useCallback(
|
|
178
214
|
(orderedIds: string[], targetId: string) => {
|
|
179
215
|
setSelectedIds((prev) => {
|
|
@@ -207,5 +243,6 @@ export const useSelection = <T>(
|
|
|
207
243
|
selectRange,
|
|
208
244
|
setAnchor,
|
|
209
245
|
anchorId,
|
|
246
|
+
intersectWith,
|
|
210
247
|
};
|
|
211
248
|
};
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
countMatches,
|
|
7
7
|
type DeleteBatchResult,
|
|
8
8
|
type FetchIdsPageResult,
|
|
9
|
+
honestProgress,
|
|
9
10
|
resolveSelectionAfterDelete,
|
|
10
11
|
runChunkedDelete,
|
|
11
12
|
runPredicateDelete,
|
|
@@ -454,3 +455,36 @@ describe("resolveSelectionAfterDelete", () => {
|
|
|
454
455
|
);
|
|
455
456
|
});
|
|
456
457
|
});
|
|
458
|
+
|
|
459
|
+
describe("honestProgress", () => {
|
|
460
|
+
// Regression for #109: `countMatches` and `runPredicateDelete` page the
|
|
461
|
+
// same predicate independently, so the delete can outrun the frozen
|
|
462
|
+
// `total` it started with when the result set grows in between.
|
|
463
|
+
test("leaves an on-track progress reading untouched", () => {
|
|
464
|
+
assert.deepEqual(honestProgress({ done: 50, total: 100 }), {
|
|
465
|
+
done: 50,
|
|
466
|
+
total: 100,
|
|
467
|
+
});
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
test("widens total to match done once done overtakes it, instead of reading past 100%", () => {
|
|
471
|
+
assert.deepEqual(honestProgress({ done: 1340, total: 1284 }), {
|
|
472
|
+
done: 1340,
|
|
473
|
+
total: 1340,
|
|
474
|
+
});
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test("done equal to total stays put", () => {
|
|
478
|
+
assert.deepEqual(honestProgress({ done: 100, total: 100 }), {
|
|
479
|
+
done: 100,
|
|
480
|
+
total: 100,
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
test("never reports a total below zero-progress done", () => {
|
|
485
|
+
assert.deepEqual(honestProgress({ done: 0, total: 0 }), {
|
|
486
|
+
done: 0,
|
|
487
|
+
total: 0,
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
});
|
package/src/lib/bulk-delete.ts
CHANGED
|
@@ -177,6 +177,24 @@ export const runPredicateDelete = async (
|
|
|
177
177
|
return { done, failedIds, cancelled: false };
|
|
178
178
|
};
|
|
179
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Corrects a progress reading so its `total` can never read as less than
|
|
182
|
+
* `done` (#109). `runPredicateDelete`'s `total` is `countMatches`'s frozen
|
|
183
|
+
* page-through, taken before the delete re-pages the same predicate a second,
|
|
184
|
+
* independent time; if more matches arrived in between, `done` can overtake
|
|
185
|
+
* it mid-run and a raw "Deleting 1,340 of 1,284" would follow. Widening the
|
|
186
|
+
* denominator to match keeps the bar's ratio sane (never past 100%) without
|
|
187
|
+
* claiming the original count was exact — the honest fix is admitting the
|
|
188
|
+
* estimate grew, not re-paging to reconcile it (the result set is live; a
|
|
189
|
+
* second count taken any later is no less stale than the first).
|
|
190
|
+
*/
|
|
191
|
+
export const honestProgress = (
|
|
192
|
+
progress: BulkDeleteProgress,
|
|
193
|
+
): BulkDeleteProgress => ({
|
|
194
|
+
done: progress.done,
|
|
195
|
+
total: Math.max(progress.total, progress.done),
|
|
196
|
+
});
|
|
197
|
+
|
|
180
198
|
export interface CountMatchesResult {
|
|
181
199
|
total: number;
|
|
182
200
|
cancelled: boolean;
|
package/src/lib/format.test.ts
CHANGED
|
@@ -119,4 +119,31 @@ describe("formatDeleteToTrashTitle", () => {
|
|
|
119
119
|
"Move 3,412 messages to Trash?",
|
|
120
120
|
);
|
|
121
121
|
});
|
|
122
|
+
|
|
123
|
+
// #109: an escalated-predicate count is paged once by `countMatches` and
|
|
124
|
+
// re-paged independently by the delete itself — never provably the number
|
|
125
|
+
// that gets deleted. `isEstimate` says so instead of stating an exact
|
|
126
|
+
// number the run may not honour.
|
|
127
|
+
describe("isEstimate (#109 — an escalated-predicate count, not a materialized selection)", () => {
|
|
128
|
+
test("prefixes 'about' for a plural estimate", () => {
|
|
129
|
+
assert.strictEqual(
|
|
130
|
+
formatDeleteToTrashTitle(3412, true),
|
|
131
|
+
"Move about 3,412 messages to Trash?",
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("prefixes 'about' for a singular estimate", () => {
|
|
136
|
+
assert.strictEqual(
|
|
137
|
+
formatDeleteToTrashTitle(1, true),
|
|
138
|
+
"Move about 1 message to Trash?",
|
|
139
|
+
);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("defaults to false — a bounded selection's count stays exact", () => {
|
|
143
|
+
assert.strictEqual(
|
|
144
|
+
formatDeleteToTrashTitle(3412),
|
|
145
|
+
formatDeleteToTrashTitle(3412, false),
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
122
149
|
});
|
package/src/lib/format.ts
CHANGED
|
@@ -195,8 +195,19 @@ export const formatList = (
|
|
|
195
195
|
* moves messages to Trash (not a permanent delete) and pluralizes on count.
|
|
196
196
|
* Thousands-separated: at escalated-selection scale this is exactly the digit
|
|
197
197
|
* count someone checks against what they meant to select.
|
|
198
|
+
*
|
|
199
|
+
* `isEstimate` marks an escalated-predicate count (#109): it was paged to a
|
|
200
|
+
* total once, and the delete itself re-pages the same predicate independently
|
|
201
|
+
* — mail arriving or leaving between the two can make them differ. "about"
|
|
202
|
+
* says so up front instead of stating a number the run may not honour; a
|
|
203
|
+
* materialized (bounded) selection's count is exact and never passes it.
|
|
198
204
|
*/
|
|
199
|
-
export const formatDeleteToTrashTitle = (
|
|
200
|
-
count
|
|
201
|
-
|
|
202
|
-
|
|
205
|
+
export const formatDeleteToTrashTitle = (
|
|
206
|
+
count: number,
|
|
207
|
+
isEstimate = false,
|
|
208
|
+
): string => {
|
|
209
|
+
const quantity = count === 1 ? "1" : formatNumber(count);
|
|
210
|
+
const noun = count === 1 ? "message" : "messages";
|
|
211
|
+
const prefix = isEstimate ? "about " : "";
|
|
212
|
+
return `Move ${prefix}${quantity} ${noun} to Trash?`;
|
|
213
|
+
};
|