@remit/web-client 0.0.195 → 0.0.197
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/mail/intelligence-auto-open-pref.render.test.ts +412 -0
- package/src/components/mail/intelligence-shortcut-surface.render.test.ts +40 -10
- package/src/components/settings/DeleteFolderDialog.render.test.ts +220 -5
- package/src/components/settings/DeleteFolderDialog.tsx +90 -13
- package/src/hooks/search-mirror-convergence.render.test.ts +1 -0
- package/src/hooks/search-mirror-detail.render.test.ts +1 -0
- package/src/hooks/useDeleteFolder.ts +113 -0
- package/src/hooks/useIntelligenceSurface.ts +9 -4
- package/src/hooks/useRailPanels.ts +128 -0
- package/src/lib/delete-folder.test.ts +14 -0
- package/src/lib/delete-folder.ts +6 -0
- package/src/lib/fresh-mailbox-count.test.ts +145 -0
- package/src/lib/fresh-mailbox-count.ts +114 -0
- package/src/lib/intelligence-pref.test.ts +68 -1
- package/src/lib/intelligence-pref.ts +20 -6
- package/src/lib/mail-context.ts +8 -0
- package/src/lib/mailbox-sync-wait.ts +6 -2
- package/src/routes/mail.tsx +15 -68
|
@@ -19,7 +19,11 @@ export interface IntelligenceSurface extends IntelligenceCommands {
|
|
|
19
19
|
closeDrawer: () => void;
|
|
20
20
|
/** Whether this width has room for the rail, so the rail is the surface. */
|
|
21
21
|
railFits: boolean;
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* Raise the rail if it fits and is down, for this thread only. The DKIM
|
|
24
|
+
* auto-open's way in, so it never stores a preference the reader never gave
|
|
25
|
+
* (#778).
|
|
26
|
+
*/
|
|
23
27
|
openRail: () => void;
|
|
24
28
|
}
|
|
25
29
|
|
|
@@ -39,7 +43,8 @@ export interface IntelligenceCommands {
|
|
|
39
43
|
export const useIntelligenceSurface = (
|
|
40
44
|
openThreadId: string | undefined,
|
|
41
45
|
): IntelligenceSurface => {
|
|
42
|
-
const { intelligenceOpen, onToggleIntelligence } =
|
|
46
|
+
const { intelligenceOpen, onToggleIntelligence, onRaiseIntelligence } =
|
|
47
|
+
useMailContext();
|
|
43
48
|
const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
|
|
44
49
|
const threadId = openThreadId ?? null;
|
|
45
50
|
const drawer = useIntelligenceDrawer(threadId);
|
|
@@ -57,8 +62,8 @@ export const useIntelligenceSurface = (
|
|
|
57
62
|
}, [railFits, onToggleIntelligence, toggleDrawer]);
|
|
58
63
|
const openRail = useCallback(() => {
|
|
59
64
|
if (!railFits || intelligenceOpen) return;
|
|
60
|
-
|
|
61
|
-
}, [railFits, intelligenceOpen,
|
|
65
|
+
onRaiseIntelligence();
|
|
66
|
+
}, [railFits, intelligenceOpen, onRaiseIntelligence]);
|
|
62
67
|
const open = useCallback(() => {
|
|
63
68
|
if (!railFits) {
|
|
64
69
|
openDrawer();
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The binding between the address, this device's stored preference and what the
|
|
3
|
+
* `/mail` shell has up: pane 4 and the one overlay that may cover it.
|
|
4
|
+
*
|
|
5
|
+
* The rail goes up two ways and they are not the same fact. The reader's own
|
|
6
|
+
* control states where they want the rail from now on, so it writes both the
|
|
7
|
+
* preference and the address. A raise — the DKIM auto-open, the authenticity
|
|
8
|
+
* banner's "Why?" — surfaces the rail for the message in front of them and is
|
|
9
|
+
* held here, in memory, against that message: it never writes the preference,
|
|
10
|
+
* and opening anything else ends it. One message signed by the wrong domain used
|
|
11
|
+
* to mean the rail was up for every later thread and every later session (#778).
|
|
12
|
+
*
|
|
13
|
+
* The raise stays out of the address because its whole copy is computed from the
|
|
14
|
+
* open message — reload the URL and the mismatch raises it again on its own, so
|
|
15
|
+
* putting it in the fragment would be a second owner of a fact the message
|
|
16
|
+
* already holds (`docs/architecture/url-state.md`, R6).
|
|
17
|
+
*/
|
|
18
|
+
import { useCallback, useState } from "react";
|
|
19
|
+
import { useLayoutTier } from "@/hooks/useLayoutTier";
|
|
20
|
+
import {
|
|
21
|
+
readIntelligencePref,
|
|
22
|
+
resolveRailOpen,
|
|
23
|
+
writeIntelligencePref,
|
|
24
|
+
} from "@/lib/intelligence-pref";
|
|
25
|
+
import {
|
|
26
|
+
isOverlayPanel,
|
|
27
|
+
type OverlayPanel,
|
|
28
|
+
useOpenPanels,
|
|
29
|
+
useOpenThreadPath,
|
|
30
|
+
useSetOpenPanels,
|
|
31
|
+
} from "@/routing";
|
|
32
|
+
|
|
33
|
+
export interface RailPanels {
|
|
34
|
+
/** The overlay the address holds: the nav slide-over or the shortcuts sheet. */
|
|
35
|
+
openOverlay: OverlayPanel | undefined;
|
|
36
|
+
/** Whether pane 4 is up, by the reader's standing answer or a raise. */
|
|
37
|
+
intelligenceOpen: boolean;
|
|
38
|
+
showOverlay: (overlay: OverlayPanel | undefined) => void;
|
|
39
|
+
/**
|
|
40
|
+
* The reader's own control, which is what a stored preference is made of —
|
|
41
|
+
* except over a rail only a raise has up, where it ends the raise and stores
|
|
42
|
+
* nothing.
|
|
43
|
+
*/
|
|
44
|
+
toggleIntelligence: () => void;
|
|
45
|
+
/** Puts the rail up for the message in front of the reader, and no further. */
|
|
46
|
+
raiseIntelligence: () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export const useRailPanels = (): RailPanels => {
|
|
50
|
+
const tier = useLayoutTier();
|
|
51
|
+
// The panels the address carries (#722): the intelligence rail, the nav
|
|
52
|
+
// slide-over and the shortcuts sheet. The rail is a pane and the other two
|
|
53
|
+
// cover it, so the address holds a pane and an overlay at once — a sheet
|
|
54
|
+
// opening never takes the rail down — while two overlays cannot both be up.
|
|
55
|
+
const openPanels = useOpenPanels();
|
|
56
|
+
const setOpenPanels = useSetOpenPanels();
|
|
57
|
+
const openOverlay = openPanels.find(isOverlayPanel);
|
|
58
|
+
// Pane 4 on desktop, the details drawer below it. `resolveRailOpen` is the
|
|
59
|
+
// one place the address and the stored preference meet: the address decides
|
|
60
|
+
// whenever it says anything at all, and the preference opens the rail with
|
|
61
|
+
// the thread where it is silent (#782).
|
|
62
|
+
const openThread = useOpenThreadPath();
|
|
63
|
+
// Held in state, not read back from storage each render: closing the rail
|
|
64
|
+
// where the address is silent changes nothing about the address, and the
|
|
65
|
+
// answer has to move anyway.
|
|
66
|
+
const [prefersRail, setPrefersRail] = useState(readIntelligencePref);
|
|
67
|
+
// The message a raise belongs to. Naming it by address is what ends the raise
|
|
68
|
+
// when the reader opens anything else, without a teardown that has to run.
|
|
69
|
+
const openMessage = openThread
|
|
70
|
+
? `${openThread.threadId}/${openThread.messageId ?? ""}`
|
|
71
|
+
: null;
|
|
72
|
+
const [raisedFor, setRaisedFor] = useState<string | null>(null);
|
|
73
|
+
const visibility = {
|
|
74
|
+
panels: openPanels,
|
|
75
|
+
prefersOpen: prefersRail,
|
|
76
|
+
isDesktop: tier === "desktop",
|
|
77
|
+
hasThread: openThread !== undefined,
|
|
78
|
+
openMessage,
|
|
79
|
+
};
|
|
80
|
+
const intelligenceOpen = resolveRailOpen({ ...visibility, raisedFor });
|
|
81
|
+
// What the reader themselves have the rail at, which is what an address write
|
|
82
|
+
// states. A raise is left out of it on purpose: a sheet opening over a
|
|
83
|
+
// surfaced rail must not write that rail into the address as a choice.
|
|
84
|
+
const chosenOpen = resolveRailOpen({ ...visibility, raisedFor: null });
|
|
85
|
+
// Every write states the whole set, because it is composed from what is
|
|
86
|
+
// showing rather than from what the address happens to spell: the rail open
|
|
87
|
+
// by preference alone is still open, and an overlay must not close it.
|
|
88
|
+
const showPanels = useCallback(
|
|
89
|
+
(rail: boolean, overlay: OverlayPanel | undefined) => {
|
|
90
|
+
setOpenPanels([
|
|
91
|
+
...(rail ? (["intelligence"] as const) : []),
|
|
92
|
+
...(overlay ? [overlay] : []),
|
|
93
|
+
]);
|
|
94
|
+
},
|
|
95
|
+
[setOpenPanels],
|
|
96
|
+
);
|
|
97
|
+
const showOverlay = useCallback(
|
|
98
|
+
(overlay: OverlayPanel | undefined) => {
|
|
99
|
+
showPanels(chosenOpen, overlay);
|
|
100
|
+
},
|
|
101
|
+
[chosenOpen, showPanels],
|
|
102
|
+
);
|
|
103
|
+
const toggleIntelligence = useCallback(() => {
|
|
104
|
+
// Putting away a rail that only a raise has up answers the surfacing, not
|
|
105
|
+
// the question of where the reader wants the rail — the answer they gave
|
|
106
|
+
// last stands, and the address never carried this rail to rewrite.
|
|
107
|
+
if (intelligenceOpen && !chosenOpen) {
|
|
108
|
+
setRaisedFor(null);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const open = !intelligenceOpen;
|
|
112
|
+
writeIntelligencePref(open);
|
|
113
|
+
setPrefersRail(open);
|
|
114
|
+
setRaisedFor(null);
|
|
115
|
+
showPanels(open, openOverlay);
|
|
116
|
+
}, [chosenOpen, intelligenceOpen, openOverlay, showPanels]);
|
|
117
|
+
const raiseIntelligence = useCallback(() => {
|
|
118
|
+
setRaisedFor(openMessage);
|
|
119
|
+
}, [openMessage]);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
openOverlay,
|
|
123
|
+
intelligenceOpen,
|
|
124
|
+
showOverlay,
|
|
125
|
+
toggleIntelligence,
|
|
126
|
+
raiseIntelligence,
|
|
127
|
+
};
|
|
128
|
+
};
|
|
@@ -3,6 +3,7 @@ import { describe, it } from "node:test";
|
|
|
3
3
|
import {
|
|
4
4
|
advanceMove,
|
|
5
5
|
beginMove,
|
|
6
|
+
elapsedLabel,
|
|
6
7
|
excludeFolder,
|
|
7
8
|
type FolderNode,
|
|
8
9
|
failMove,
|
|
@@ -262,6 +263,19 @@ describe("move progress", () => {
|
|
|
262
263
|
});
|
|
263
264
|
});
|
|
264
265
|
|
|
266
|
+
describe("elapsedLabel", () => {
|
|
267
|
+
it("counts a wait in minutes and padded seconds", () => {
|
|
268
|
+
assert.equal(elapsedLabel(0), "0:00");
|
|
269
|
+
assert.equal(elapsedLabel(9_400), "0:09");
|
|
270
|
+
assert.equal(elapsedLabel(65_000), "1:05");
|
|
271
|
+
assert.equal(elapsedLabel(600_000), "10:00");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
it("reads a backwards clock as no time at all", () => {
|
|
275
|
+
assert.equal(elapsedLabel(-5_000), "0:00");
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
265
279
|
describe("initialStage", () => {
|
|
266
280
|
it("opens on the empty confirm for a folder with no mail", () => {
|
|
267
281
|
assert.equal(initialStage(0), "confirm-empty");
|
package/src/lib/delete-folder.ts
CHANGED
|
@@ -172,6 +172,12 @@ export function moveProgressLabel(progress: MoveProgress): string {
|
|
|
172
172
|
return `Moved ${progress.moved} of ${progress.total}`;
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
+
/** How long a wait has run, as `m:ss` — clamped at zero so a clock skew reads as 0:00. */
|
|
176
|
+
export function elapsedLabel(ms: number): string {
|
|
177
|
+
const seconds = Math.max(0, Math.floor(ms / 1000));
|
|
178
|
+
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
175
181
|
/** Where the wizard opens: a straight confirm for an empty folder, otherwise the fate step. */
|
|
176
182
|
export function initialStage(
|
|
177
183
|
messageCount: number,
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* awaitFreshMailboxCount — the gate a folder delete holds behind while the
|
|
3
|
+
* server is asked what the folder actually holds. It reports a count only from
|
|
4
|
+
* a round that stamped past the baseline, reports `pending` rather than a count
|
|
5
|
+
* when the segment runs out, and refuses outright on a folder the account does
|
|
6
|
+
* not list.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import assert from "node:assert/strict";
|
|
10
|
+
import { describe, it } from "node:test";
|
|
11
|
+
import {
|
|
12
|
+
awaitFreshMailboxCount,
|
|
13
|
+
FRESH_COUNT_MISSING_MESSAGE,
|
|
14
|
+
type MailboxCountReading,
|
|
15
|
+
mailboxSyncStamp,
|
|
16
|
+
} from "./fresh-mailbox-count.js";
|
|
17
|
+
|
|
18
|
+
const reading = (
|
|
19
|
+
messagesTotal: number,
|
|
20
|
+
lastSyncedAt?: number,
|
|
21
|
+
): MailboxCountReading => ({
|
|
22
|
+
mailboxId: "mbx-1",
|
|
23
|
+
messagesTotal,
|
|
24
|
+
lastSyncedAt,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const noDelay = () => Promise.resolve();
|
|
28
|
+
|
|
29
|
+
/** A clock that jumps a minute per reading, so a segment expires in two polls. */
|
|
30
|
+
const impatientClock = () => {
|
|
31
|
+
let clock = 0;
|
|
32
|
+
return () => {
|
|
33
|
+
clock += 60_000;
|
|
34
|
+
return clock;
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe("mailboxSyncStamp", () => {
|
|
39
|
+
it("reads the folder's stamp, and zero for a folder never synced", () => {
|
|
40
|
+
assert.equal(mailboxSyncStamp([reading(0, 100)], "mbx-1"), 100);
|
|
41
|
+
assert.equal(mailboxSyncStamp([reading(0)], "mbx-1"), 0);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("refuses a folder the account does not list", () => {
|
|
45
|
+
assert.throws(() => mailboxSyncStamp([], "mbx-1"), {
|
|
46
|
+
message: FRESH_COUNT_MISSING_MESSAGE,
|
|
47
|
+
});
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("awaitFreshMailboxCount", () => {
|
|
52
|
+
it("resolves with the count a round stamped past the baseline", async () => {
|
|
53
|
+
const responses = [[reading(0, 100)], [reading(0, 100)], [reading(3, 200)]];
|
|
54
|
+
let call = 0;
|
|
55
|
+
const outcome = await awaitFreshMailboxCount({
|
|
56
|
+
mailboxId: "mbx-1",
|
|
57
|
+
since: 100,
|
|
58
|
+
readMailboxes: async () => responses[call++] as MailboxCountReading[],
|
|
59
|
+
delay: noDelay,
|
|
60
|
+
});
|
|
61
|
+
assert.deepEqual(outcome, { status: "fresh", messageCount: 3 });
|
|
62
|
+
assert.equal(call, 3);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it("never reports a count from a round older than the baseline", async () => {
|
|
66
|
+
// The stamp stands still — the folder was never re-read, so the zero
|
|
67
|
+
// sitting in the row is exactly the stale count that must not be trusted.
|
|
68
|
+
const outcome = await awaitFreshMailboxCount({
|
|
69
|
+
mailboxId: "mbx-1",
|
|
70
|
+
since: 100,
|
|
71
|
+
readMailboxes: async () => [reading(0, 100)],
|
|
72
|
+
delay: noDelay,
|
|
73
|
+
now: impatientClock(),
|
|
74
|
+
});
|
|
75
|
+
assert.deepEqual(outcome, { status: "pending" });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("resumes against the same baseline and then reports the count", async () => {
|
|
79
|
+
let stamp = 100;
|
|
80
|
+
const readMailboxes = async () => [reading(2, stamp)];
|
|
81
|
+
const first = await awaitFreshMailboxCount({
|
|
82
|
+
mailboxId: "mbx-1",
|
|
83
|
+
since: 100,
|
|
84
|
+
readMailboxes,
|
|
85
|
+
delay: noDelay,
|
|
86
|
+
now: impatientClock(),
|
|
87
|
+
});
|
|
88
|
+
assert.deepEqual(first, { status: "pending" });
|
|
89
|
+
|
|
90
|
+
stamp = 300;
|
|
91
|
+
const second = await awaitFreshMailboxCount({
|
|
92
|
+
mailboxId: "mbx-1",
|
|
93
|
+
since: 100,
|
|
94
|
+
readMailboxes,
|
|
95
|
+
delay: noDelay,
|
|
96
|
+
now: impatientClock(),
|
|
97
|
+
});
|
|
98
|
+
assert.deepEqual(second, { status: "fresh", messageCount: 2 });
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("refuses a folder the account no longer lists", async () => {
|
|
102
|
+
await assert.rejects(
|
|
103
|
+
awaitFreshMailboxCount({
|
|
104
|
+
mailboxId: "mbx-1",
|
|
105
|
+
since: 100,
|
|
106
|
+
readMailboxes: async () => [],
|
|
107
|
+
delay: noDelay,
|
|
108
|
+
}),
|
|
109
|
+
{ message: FRESH_COUNT_MISSING_MESSAGE },
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("propagates a failed read rather than counting it as zero", async () => {
|
|
114
|
+
await assert.rejects(
|
|
115
|
+
awaitFreshMailboxCount({
|
|
116
|
+
mailboxId: "mbx-1",
|
|
117
|
+
since: 100,
|
|
118
|
+
readMailboxes: async () => {
|
|
119
|
+
throw new Error("sync status 500");
|
|
120
|
+
},
|
|
121
|
+
delay: noDelay,
|
|
122
|
+
}),
|
|
123
|
+
{ message: "sync status 500" },
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it("stops on abort and never reports a count", async () => {
|
|
128
|
+
const controller = new AbortController();
|
|
129
|
+
let call = 0;
|
|
130
|
+
controller.abort();
|
|
131
|
+
await assert.rejects(
|
|
132
|
+
awaitFreshMailboxCount({
|
|
133
|
+
mailboxId: "mbx-1",
|
|
134
|
+
since: 100,
|
|
135
|
+
readMailboxes: async () => {
|
|
136
|
+
call += 1;
|
|
137
|
+
return [reading(0, 200)];
|
|
138
|
+
},
|
|
139
|
+
signal: controller.signal,
|
|
140
|
+
delay: noDelay,
|
|
141
|
+
}),
|
|
142
|
+
);
|
|
143
|
+
assert.equal(call, 0, "an aborted wait reads nothing");
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { abortableDelay } from "./mailbox-sync-wait";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* How many messages a folder holds *on the mail server*, rather than how many
|
|
5
|
+
* the last sync round left in the local row.
|
|
6
|
+
*
|
|
7
|
+
* Every count the client can read — the mailbox row's `messageCount`, and so
|
|
8
|
+
* the folder list and the sync-status projection over it — is whatever the last
|
|
9
|
+
* round wrote. Mail that arrived since is invisible in it, which is fine for a
|
|
10
|
+
* badge and fatal for a delete: `deleteMailbox` takes the folder's mail with it
|
|
11
|
+
* and IMAP has no undo.
|
|
12
|
+
*
|
|
13
|
+
* So the count is taken from a round asked for on the spot: trigger a sync,
|
|
14
|
+
* then wait for the folder's `lastSyncedAt` to advance past the stamp read
|
|
15
|
+
* before the trigger, and read the count that round wrote alongside it (every
|
|
16
|
+
* message-sync round writes both from the same IMAP STATUS).
|
|
17
|
+
*
|
|
18
|
+
* What the advancing stamp proves is that *some* round's write landed after the
|
|
19
|
+
* baseline read — not necessarily the round this triggered. A round already in
|
|
20
|
+
* flight can land first and satisfy the wait. That is accepted: its STATUS was
|
|
21
|
+
* taken within milliseconds of the baseline, and the error it can carry is a
|
|
22
|
+
* count from a moment too early, which either agrees with the trigger's round
|
|
23
|
+
* or reports mail the folder had and the delete then refuses. The mistake lands
|
|
24
|
+
* on the side of not deleting.
|
|
25
|
+
*
|
|
26
|
+
* Nothing here decides on a count read before the trigger, and every way out
|
|
27
|
+
* other than an advanced stamp throws or reports `pending`: a folder missing
|
|
28
|
+
* from the account, a failed read, an aborted wait. Uncertainty about what a
|
|
29
|
+
* folder holds is never permission to delete it.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** The read fields the wait needs off a sync-status entry. */
|
|
33
|
+
export interface MailboxCountReading {
|
|
34
|
+
mailboxId: string;
|
|
35
|
+
messagesTotal: number;
|
|
36
|
+
lastSyncedAt?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A count from a round that reported after the baseline, or no round yet. */
|
|
40
|
+
export type FreshCountOutcome =
|
|
41
|
+
| { status: "fresh"; messageCount: number }
|
|
42
|
+
| { status: "pending" };
|
|
43
|
+
|
|
44
|
+
export interface AwaitFreshMailboxCountOptions {
|
|
45
|
+
/** Reads every mailbox's sync-status entry; called once per poll. */
|
|
46
|
+
readMailboxes: () => Promise<readonly MailboxCountReading[]>;
|
|
47
|
+
/** The folder to count. */
|
|
48
|
+
mailboxId: string;
|
|
49
|
+
/** The folder's `lastSyncedAt` as read before the sync was triggered. */
|
|
50
|
+
since: number;
|
|
51
|
+
/** Aborts the wait; a round that lands afterwards resolves nothing. */
|
|
52
|
+
signal?: AbortSignal;
|
|
53
|
+
/** How long this stretch of waiting runs before reporting `pending`. */
|
|
54
|
+
segmentMs?: number;
|
|
55
|
+
pollIntervalMs?: number;
|
|
56
|
+
/** Injectable clock/sleep for tests. */
|
|
57
|
+
delay?: (ms: number, signal?: AbortSignal) => Promise<void>;
|
|
58
|
+
now?: () => number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* How long one stretch of waiting runs before handing the decision back to the
|
|
63
|
+
* user. An explicit sync fans the whole account out on one FIFO group with
|
|
64
|
+
* INBOX first, so a folder on a large account can sit behind minutes of other
|
|
65
|
+
* mailboxes: this is not long enough to conclude anything, only long enough
|
|
66
|
+
* that someone watching a spinner deserves to be asked whether to keep waiting.
|
|
67
|
+
*/
|
|
68
|
+
export const FRESH_COUNT_SEGMENT_MS = 120_000;
|
|
69
|
+
export const FRESH_COUNT_POLL_INTERVAL_MS = 2_000;
|
|
70
|
+
|
|
71
|
+
export const FRESH_COUNT_MISSING_MESSAGE =
|
|
72
|
+
"This folder is no longer in the account's folder list, so nothing was deleted.";
|
|
73
|
+
|
|
74
|
+
const entryFor = (
|
|
75
|
+
mailboxes: readonly MailboxCountReading[],
|
|
76
|
+
mailboxId: string,
|
|
77
|
+
): MailboxCountReading => {
|
|
78
|
+
const entry = mailboxes.find((mailbox) => mailbox.mailboxId === mailboxId);
|
|
79
|
+
if (!entry) throw new Error(FRESH_COUNT_MISSING_MESSAGE);
|
|
80
|
+
return entry;
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** The folder's last sync stamp, or a refusal when the account does not list it. */
|
|
84
|
+
export const mailboxSyncStamp = (
|
|
85
|
+
mailboxes: readonly MailboxCountReading[],
|
|
86
|
+
mailboxId: string,
|
|
87
|
+
): number => entryFor(mailboxes, mailboxId).lastSyncedAt ?? 0;
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Poll for one segment. Resolves `fresh` with the count once a round reports
|
|
91
|
+
* past `since`, `pending` when the segment runs out with the folder still
|
|
92
|
+
* unreported — the caller asks the user whether to wait on, and calling again
|
|
93
|
+
* with the same `since` resumes without triggering a second round.
|
|
94
|
+
*/
|
|
95
|
+
export async function awaitFreshMailboxCount({
|
|
96
|
+
readMailboxes,
|
|
97
|
+
mailboxId,
|
|
98
|
+
since,
|
|
99
|
+
signal,
|
|
100
|
+
segmentMs = FRESH_COUNT_SEGMENT_MS,
|
|
101
|
+
pollIntervalMs = FRESH_COUNT_POLL_INTERVAL_MS,
|
|
102
|
+
delay = abortableDelay,
|
|
103
|
+
now = Date.now,
|
|
104
|
+
}: AwaitFreshMailboxCountOptions): Promise<FreshCountOutcome> {
|
|
105
|
+
const deadline = now() + segmentMs;
|
|
106
|
+
for (;;) {
|
|
107
|
+
signal?.throwIfAborted();
|
|
108
|
+
const entry = entryFor(await readMailboxes(), mailboxId);
|
|
109
|
+
if ((entry.lastSyncedAt ?? 0) > since)
|
|
110
|
+
return { status: "fresh", messageCount: entry.messagesTotal };
|
|
111
|
+
if (now() >= deadline) return { status: "pending" };
|
|
112
|
+
await delay(pollIntervalMs, signal);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -58,7 +58,10 @@ describe("intelligence-pref (#782)", () => {
|
|
|
58
58
|
});
|
|
59
59
|
|
|
60
60
|
describe("resolveRailOpen (#722)", () => {
|
|
61
|
-
const
|
|
61
|
+
const OPEN_MESSAGE = "thread-1/msg-1";
|
|
62
|
+
// No raise live, and a message on screen for one to be measured against.
|
|
63
|
+
const unraised = { raisedFor: null, openMessage: OPEN_MESSAGE };
|
|
64
|
+
const withThread = { hasThread: true, isDesktop: true, ...unraised };
|
|
62
65
|
|
|
63
66
|
it("opens the rail with the thread where the address says nothing", () => {
|
|
64
67
|
assert.equal(
|
|
@@ -81,6 +84,7 @@ describe("resolveRailOpen (#722)", () => {
|
|
|
81
84
|
prefersOpen: true,
|
|
82
85
|
isDesktop: false,
|
|
83
86
|
hasThread: true,
|
|
87
|
+
...unraised,
|
|
84
88
|
}),
|
|
85
89
|
false,
|
|
86
90
|
);
|
|
@@ -94,6 +98,7 @@ describe("resolveRailOpen (#722)", () => {
|
|
|
94
98
|
prefersOpen: true,
|
|
95
99
|
isDesktop: true,
|
|
96
100
|
hasThread: false,
|
|
101
|
+
...unraised,
|
|
97
102
|
}),
|
|
98
103
|
false,
|
|
99
104
|
);
|
|
@@ -127,6 +132,68 @@ describe("resolveRailOpen (#722)", () => {
|
|
|
127
132
|
prefersOpen: false,
|
|
128
133
|
isDesktop: false,
|
|
129
134
|
hasThread: true,
|
|
135
|
+
...unraised,
|
|
136
|
+
}),
|
|
137
|
+
true,
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* A DKIM mismatch, or the banner's "Why?", puts the rail up for the message the
|
|
144
|
+
* reader is looking at. It is measured against that message rather than stored,
|
|
145
|
+
* so it reaches neither the address nor the preference, and the next thread the
|
|
146
|
+
* reader opens is not carrying it (#778).
|
|
147
|
+
*/
|
|
148
|
+
describe("a raised rail belongs to its message (#778)", () => {
|
|
149
|
+
const OPEN_MESSAGE = "thread-1/msg-1";
|
|
150
|
+
const collapsed = {
|
|
151
|
+
hasThread: true,
|
|
152
|
+
isDesktop: true,
|
|
153
|
+
panels: [] as const,
|
|
154
|
+
prefersOpen: false,
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
it("puts the rail up over a collapse for the message it was raised for", () => {
|
|
158
|
+
assert.equal(
|
|
159
|
+
resolveRailOpen({
|
|
160
|
+
...collapsed,
|
|
161
|
+
raisedFor: OPEN_MESSAGE,
|
|
162
|
+
openMessage: OPEN_MESSAGE,
|
|
163
|
+
}),
|
|
164
|
+
true,
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("ends with the message, so the next thread opens as the reader left it", () => {
|
|
169
|
+
assert.equal(
|
|
170
|
+
resolveRailOpen({
|
|
171
|
+
...collapsed,
|
|
172
|
+
raisedFor: OPEN_MESSAGE,
|
|
173
|
+
openMessage: "thread-2/msg-2",
|
|
174
|
+
}),
|
|
175
|
+
false,
|
|
176
|
+
);
|
|
177
|
+
assert.equal(
|
|
178
|
+
resolveRailOpen({
|
|
179
|
+
...collapsed,
|
|
180
|
+
hasThread: false,
|
|
181
|
+
raisedFor: OPEN_MESSAGE,
|
|
182
|
+
openMessage: null,
|
|
183
|
+
}),
|
|
184
|
+
false,
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// The reader is being shown something about the message in front of them, so
|
|
189
|
+
// a link that named other panels does not get to suppress it.
|
|
190
|
+
it("surfaces over an address that named another panel", () => {
|
|
191
|
+
assert.equal(
|
|
192
|
+
resolveRailOpen({
|
|
193
|
+
...collapsed,
|
|
194
|
+
panels: ["shortcuts"],
|
|
195
|
+
raisedFor: OPEN_MESSAGE,
|
|
196
|
+
openMessage: OPEN_MESSAGE,
|
|
130
197
|
}),
|
|
131
198
|
true,
|
|
132
199
|
);
|
|
@@ -34,24 +34,38 @@ export interface RailVisibility {
|
|
|
34
34
|
isDesktop: boolean;
|
|
35
35
|
/** The rail reads a conversation, so with none open there is nothing to be up. */
|
|
36
36
|
hasThread: boolean;
|
|
37
|
+
/** The message a transient raise was made for, if one is live. */
|
|
38
|
+
raisedFor: string | null;
|
|
39
|
+
/** The message on screen, which is what a raise is measured against. */
|
|
40
|
+
openMessage: string | null;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
/**
|
|
40
44
|
* Whether the rail is up.
|
|
41
45
|
*
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
46
|
+
* A raise answers first, and only for the message it was made for: a DKIM
|
|
47
|
+
* mismatch or the banner's "Why?" surfaces the rail over a collapse, because
|
|
48
|
+
* the reader is being shown something about what is in front of them — and
|
|
49
|
+
* opening anything else ends it, so one mismatched signature is not the chrome
|
|
50
|
+
* of the whole session (#778). It is measured rather than stored, which is why
|
|
51
|
+
* it never reaches the address or the preference.
|
|
52
|
+
*
|
|
53
|
+
* Otherwise an address that names any panel is the only owner of what is open,
|
|
54
|
+
* so a shared link showing the shortcuts sheet is not overwritten by the
|
|
55
|
+
* recipient's own preference. The preference speaks only where the address is
|
|
56
|
+
* silent, and only with a conversation open on the tier that has a rail — it
|
|
57
|
+
* opens with the thread there (#782), while a phone would get a full-screen
|
|
58
|
+
* drawer over a message nobody asked to cover.
|
|
48
59
|
*/
|
|
49
60
|
export function resolveRailOpen({
|
|
50
61
|
panels,
|
|
51
62
|
prefersOpen,
|
|
52
63
|
isDesktop,
|
|
53
64
|
hasThread,
|
|
65
|
+
raisedFor,
|
|
66
|
+
openMessage,
|
|
54
67
|
}: RailVisibility): boolean {
|
|
68
|
+
if (raisedFor !== null && raisedFor === openMessage) return true;
|
|
55
69
|
if (panels.length > 0) return panels.includes("intelligence");
|
|
56
70
|
return isDesktop && hasThread && prefersOpen;
|
|
57
71
|
}
|
package/src/lib/mail-context.ts
CHANGED
|
@@ -54,7 +54,14 @@ export interface MailContextValue {
|
|
|
54
54
|
* alone would lose the preference and disagree with the shell.
|
|
55
55
|
*/
|
|
56
56
|
intelligenceOpen: boolean;
|
|
57
|
+
/** The reader's own control over pane 4, which stores what they chose. */
|
|
57
58
|
onToggleIntelligence: () => void;
|
|
59
|
+
/**
|
|
60
|
+
* Puts pane 4 up for the thread in hand without touching the stored
|
|
61
|
+
* preference: a DKIM mismatch surfacing the rail is about this message, not
|
|
62
|
+
* about where the reader wants the rail from now on (#778).
|
|
63
|
+
*/
|
|
64
|
+
onRaiseIntelligence: () => void;
|
|
58
65
|
}
|
|
59
66
|
|
|
60
67
|
export const MailContext = createContext<MailContextValue | null>(null);
|
|
@@ -78,6 +85,7 @@ export const useMailContext = (): MailContextValue => {
|
|
|
78
85
|
onSearchClearQuery: () => {},
|
|
79
86
|
intelligenceOpen: false,
|
|
80
87
|
onToggleIntelligence: () => {},
|
|
88
|
+
onRaiseIntelligence: () => {},
|
|
81
89
|
}
|
|
82
90
|
);
|
|
83
91
|
};
|
|
@@ -50,7 +50,11 @@ export const MAILBOX_SYNC_FAILED_MESSAGE =
|
|
|
50
50
|
export const MAILBOX_SYNC_TIMEOUT_MESSAGE =
|
|
51
51
|
"The folder was created but the mail server hasn't confirmed it yet, so nothing was attached to it. It's in your folder list — try again in a moment.";
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
/** `setTimeout` that rejects with the signal's reason instead of outliving it. */
|
|
54
|
+
export const abortableDelay = (
|
|
55
|
+
ms: number,
|
|
56
|
+
signal?: AbortSignal,
|
|
57
|
+
): Promise<void> =>
|
|
54
58
|
new Promise((resolve, reject) => {
|
|
55
59
|
if (signal?.aborted) {
|
|
56
60
|
reject(signal.reason);
|
|
@@ -81,7 +85,7 @@ export async function waitForMailboxSynced<T extends MailboxSyncSignal>({
|
|
|
81
85
|
signal,
|
|
82
86
|
timeoutMs = MAILBOX_SYNC_TIMEOUT_MS,
|
|
83
87
|
pollIntervalMs = MAILBOX_SYNC_POLL_INTERVAL_MS,
|
|
84
|
-
delay =
|
|
88
|
+
delay = abortableDelay,
|
|
85
89
|
now = Date.now,
|
|
86
90
|
}: WaitForMailboxSyncedOptions<T>): Promise<T> {
|
|
87
91
|
const deadline = now() + timeoutMs;
|