@remit/web-client 0.0.222 → 0.0.224
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/compose/MobileComposeSheet.tsx +5 -1
- package/src/components/layout/Drawer.tsx +16 -7
- package/src/components/mail/ConversationView.tsx +44 -7
- package/src/components/mail/DailyBrief.tsx +0 -1
- package/src/components/mail/FlaggedList.tsx +0 -1
- package/src/components/mail/IntelligenceDrawer.tsx +3 -0
- package/src/components/mail/MailboxPane.tsx +0 -1
- package/src/components/mail/MessageList.tsx +2 -20
- package/src/components/mail/MoveToTrigger.render.test.ts +13 -2
- package/src/components/mail/MoveToTrigger.tsx +10 -16
- package/src/components/mail/SelectionWizardHost.tsx +5 -0
- package/src/components/mail/ThreadListInteraction.tsx +0 -10
- package/src/components/mail/two-window-listeners-one-key.render.test.ts +0 -1
- package/src/components/overlay-escape.render.test.ts +151 -0
- package/src/components/ui/DropdownMenu.tsx +9 -15
- package/src/components/ui/FatalErrorOverlay.tsx +4 -0
- package/src/components/ui/KeyboardShortcutsModal.tsx +13 -23
- package/src/hooks/useKeyboardNavigation.ts +11 -0
- package/src/hooks/useRefreshControl.test.ts +129 -0
- package/src/hooks/useRefreshControl.ts +52 -45
- package/src/hooks/useTriageLayer.ts +7 -10
- package/src/routes/mail.tsx +4 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.224",
|
|
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": {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import { ConfirmDialog } from "@remit/ui";
|
|
2
|
+
import { ConfirmDialog, useOverlayScope } from "@remit/ui";
|
|
3
3
|
import { useQuery } from "@tanstack/react-query";
|
|
4
4
|
import { useCallback, useRef, useState } from "react";
|
|
5
5
|
import { Drawer } from "vaul";
|
|
@@ -75,6 +75,10 @@ export const MobileComposeSheet = () => {
|
|
|
75
75
|
setShowConfirm(false);
|
|
76
76
|
}, []);
|
|
77
77
|
|
|
78
|
+
// vaul answers Escape itself; declaring the scope is what keeps the rest of
|
|
79
|
+
// the keyboard off the list behind the sheet.
|
|
80
|
+
useOverlayScope({ id: "mobile-compose", open: true });
|
|
81
|
+
|
|
78
82
|
return (
|
|
79
83
|
<Drawer.Root
|
|
80
84
|
open
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cn } from "@remit/ui";
|
|
1
|
+
import { cn, type OverlayAnswers, useOverlayScope } from "@remit/ui";
|
|
2
2
|
import { X } from "lucide-react";
|
|
3
3
|
import { type ReactNode, useEffect, useRef } from "react";
|
|
4
4
|
|
|
@@ -40,6 +40,13 @@ interface DrawerProps {
|
|
|
40
40
|
ariaLabel?: string;
|
|
41
41
|
side?: "left" | "right";
|
|
42
42
|
widthClassName?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Shortcuts the drawer keeps serving while it is up, beyond Escape. The key
|
|
45
|
+
* that opened it is the one that has to reach it — `i` closes the
|
|
46
|
+
* intelligence drawer it opened — and everything left undeclared is
|
|
47
|
+
* contained, so no verb acts on the list behind the scrim.
|
|
48
|
+
*/
|
|
49
|
+
answers?: OverlayAnswers;
|
|
43
50
|
}
|
|
44
51
|
|
|
45
52
|
/**
|
|
@@ -58,21 +65,23 @@ export const Drawer = ({
|
|
|
58
65
|
ariaLabel = "Navigation",
|
|
59
66
|
side = "left",
|
|
60
67
|
widthClassName = "w-[80vw] max-w-[320px]",
|
|
68
|
+
answers,
|
|
61
69
|
}: DrawerProps) => {
|
|
62
70
|
const drawerRef = useRef<HTMLDivElement>(null);
|
|
63
71
|
const previouslyFocusedRef = useRef<HTMLElement | null>(null);
|
|
64
72
|
|
|
73
|
+
useOverlayScope({
|
|
74
|
+
id: "drawer",
|
|
75
|
+
open: isOpen,
|
|
76
|
+
answers: { back: onClose, ...answers },
|
|
77
|
+
});
|
|
78
|
+
|
|
65
79
|
useEffect(() => {
|
|
66
80
|
if (!isOpen) return;
|
|
67
81
|
|
|
68
82
|
previouslyFocusedRef.current = document.activeElement as HTMLElement | null;
|
|
69
83
|
|
|
70
84
|
const handleKey = (event: KeyboardEvent) => {
|
|
71
|
-
if (event.key === "Escape") {
|
|
72
|
-
event.preventDefault();
|
|
73
|
-
onClose();
|
|
74
|
-
return;
|
|
75
|
-
}
|
|
76
85
|
// Trap Tab inside the drawer's ring: walking off either end cycles
|
|
77
86
|
// round, and focus that is in the drawer but outside the ring — a
|
|
78
87
|
// control behind a nested dialog's backdrop — is pulled in, so Tab
|
|
@@ -115,7 +124,7 @@ export const Drawer = ({
|
|
|
115
124
|
document.body.style.overflow = previousOverflow;
|
|
116
125
|
previouslyFocusedRef.current?.focus();
|
|
117
126
|
};
|
|
118
|
-
}, [isOpen
|
|
127
|
+
}, [isOpen]);
|
|
119
128
|
|
|
120
129
|
if (!isOpen) return null;
|
|
121
130
|
|
|
@@ -292,25 +292,62 @@ export const ConversationView = ({
|
|
|
292
292
|
...(listOnScreen
|
|
293
293
|
? []
|
|
294
294
|
: [
|
|
295
|
-
{
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
295
|
+
{
|
|
296
|
+
key: "j",
|
|
297
|
+
action: "focusNext" as const,
|
|
298
|
+
handler: focusNext,
|
|
299
|
+
preventDefault: true,
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
key: "ArrowDown",
|
|
303
|
+
action: "focusNext" as const,
|
|
304
|
+
handler: focusNext,
|
|
305
|
+
preventDefault: true,
|
|
306
|
+
},
|
|
307
|
+
{
|
|
308
|
+
key: "k",
|
|
309
|
+
action: "focusPrevious" as const,
|
|
310
|
+
handler: focusPrevious,
|
|
311
|
+
preventDefault: true,
|
|
312
|
+
},
|
|
313
|
+
{
|
|
314
|
+
key: "ArrowUp",
|
|
315
|
+
action: "focusPrevious" as const,
|
|
316
|
+
handler: focusPrevious,
|
|
317
|
+
preventDefault: true,
|
|
318
|
+
},
|
|
299
319
|
{
|
|
300
320
|
key: "Enter",
|
|
321
|
+
action: "openFocused" as const,
|
|
322
|
+
handler: toggleFocusedMessage,
|
|
323
|
+
preventDefault: true,
|
|
324
|
+
},
|
|
325
|
+
{
|
|
326
|
+
key: "o",
|
|
327
|
+
action: "openFocused" as const,
|
|
301
328
|
handler: toggleFocusedMessage,
|
|
302
329
|
preventDefault: true,
|
|
303
330
|
},
|
|
304
|
-
{ key: "o", handler: toggleFocusedMessage, preventDefault: true },
|
|
305
331
|
]),
|
|
306
|
-
{
|
|
332
|
+
{
|
|
333
|
+
key: "r",
|
|
334
|
+
action: "reply" as const,
|
|
335
|
+
handler: handleReply,
|
|
336
|
+
preventDefault: true,
|
|
337
|
+
},
|
|
307
338
|
{
|
|
308
339
|
key: "R",
|
|
340
|
+
action: "replyAll" as const,
|
|
309
341
|
handler: handleReplyAll,
|
|
310
342
|
noModifiers: false,
|
|
311
343
|
preventDefault: true,
|
|
312
344
|
},
|
|
313
|
-
{
|
|
345
|
+
{
|
|
346
|
+
key: "f",
|
|
347
|
+
action: "forward" as const,
|
|
348
|
+
handler: handleForward,
|
|
349
|
+
preventDefault: true,
|
|
350
|
+
},
|
|
314
351
|
],
|
|
315
352
|
});
|
|
316
353
|
|
|
@@ -30,6 +30,9 @@ export function IntelligenceDrawer({
|
|
|
30
30
|
onClose={onClose}
|
|
31
31
|
ariaLabel="Message details"
|
|
32
32
|
side="right"
|
|
33
|
+
// The key that raised it takes it away again (#840); every other verb
|
|
34
|
+
// stays with the list behind the scrim, which is not the subject here.
|
|
35
|
+
answers={{ toggleIntelligence: onClose }}
|
|
33
36
|
>
|
|
34
37
|
<IntelligencePane
|
|
35
38
|
onClose={onClose}
|
|
@@ -214,7 +214,6 @@ interface MailboxPaneContextValue {
|
|
|
214
214
|
focusedMessageId: string | undefined;
|
|
215
215
|
selectedIds: string[];
|
|
216
216
|
hasList: boolean;
|
|
217
|
-
blocksKeyboard: boolean;
|
|
218
217
|
}) => void;
|
|
219
218
|
/** Where the list publishes the commands the keyboard layer drives. */
|
|
220
219
|
listCommandsRef: RefObject<MessageListCommands | null>;
|
|
@@ -160,14 +160,6 @@ interface MessageListProps {
|
|
|
160
160
|
* owns (Enter, Space, ⌘A) are left to the browser everywhere else.
|
|
161
161
|
*/
|
|
162
162
|
hasList: boolean;
|
|
163
|
-
/**
|
|
164
|
-
* Whether the list has a modal open that owns the keyboard — the delete
|
|
165
|
-
* confirmation, or the wizard. The route suspends the whole triage layer
|
|
166
|
-
* while it does, so no shortcut can act behind it: a second Delete press
|
|
167
|
-
* must not reach a delete, and none may start a second flow behind the
|
|
168
|
-
* screen already asking about one.
|
|
169
|
-
*/
|
|
170
|
-
blocksKeyboard: boolean;
|
|
171
163
|
}) => void;
|
|
172
164
|
/**
|
|
173
165
|
* Ref the list publishes its {@link MessageListCommands} into, so the route's
|
|
@@ -432,9 +424,8 @@ export const MessageList = ({
|
|
|
432
424
|
// Whether the list can serve keyboard commands at all. It stays true while
|
|
433
425
|
// the delete confirmation is open — withdrawing the commands there would let
|
|
434
426
|
// the route fall through to its own unconfirmed delete on a second Delete
|
|
435
|
-
// press. The
|
|
427
|
+
// press. The dialog is on the overlay stack, which suspends every layer.
|
|
436
428
|
const commandsAvailable = !isLoading && threads.length > 0;
|
|
437
|
-
const confirmOpen = pendingDelete !== null;
|
|
438
429
|
|
|
439
430
|
const virtualizer = useVirtualizer({
|
|
440
431
|
count: threads.length,
|
|
@@ -908,16 +899,8 @@ export const MessageList = ({
|
|
|
908
899
|
focusedMessageId,
|
|
909
900
|
selectedIds: Array.from(selectedIds),
|
|
910
901
|
hasList: commandsAvailable,
|
|
911
|
-
blocksKeyboard: confirmOpen || wizard.isOpen,
|
|
912
902
|
});
|
|
913
|
-
}, [
|
|
914
|
-
focusedMessageId,
|
|
915
|
-
selectedIds,
|
|
916
|
-
commandsAvailable,
|
|
917
|
-
confirmOpen,
|
|
918
|
-
wizard.isOpen,
|
|
919
|
-
onTriageContextChange,
|
|
920
|
-
]);
|
|
903
|
+
}, [focusedMessageId, selectedIds, commandsAvailable, onTriageContextChange]);
|
|
921
904
|
|
|
922
905
|
// Retract the context when the list goes away (drafts view, phone reading
|
|
923
906
|
// view). Without this the route keeps its list key handlers registered
|
|
@@ -931,7 +914,6 @@ export const MessageList = ({
|
|
|
931
914
|
focusedMessageId: undefined,
|
|
932
915
|
selectedIds: [],
|
|
933
916
|
hasList: false,
|
|
934
|
-
blocksKeyboard: false,
|
|
935
917
|
}),
|
|
936
918
|
[],
|
|
937
919
|
);
|
|
@@ -174,13 +174,24 @@ describe("MoveToTrigger", () => {
|
|
|
174
174
|
const isOpen = () =>
|
|
175
175
|
dom.byLabel("Move to folder").getAttribute("aria-expanded") === "true";
|
|
176
176
|
|
|
177
|
+
// The window-level triage layer maps Escape to `back`, which closes the
|
|
178
|
+
// conversation behind the picker; one press must not do both (#732, #958).
|
|
179
|
+
const seen: string[] = [];
|
|
180
|
+
const listener = (event: Event) => seen.push((event as KeyboardEvent).key);
|
|
181
|
+
dom.window.addEventListener("keydown", listener);
|
|
182
|
+
|
|
177
183
|
dom.click(dom.byLabel("Move to folder"));
|
|
178
184
|
assert.equal(isOpen(), true);
|
|
179
185
|
dom.dispatch(
|
|
180
|
-
dom.window.document,
|
|
181
|
-
new dom.window.KeyboardEvent("keydown", {
|
|
186
|
+
dom.window.document.body,
|
|
187
|
+
new dom.window.KeyboardEvent("keydown", {
|
|
188
|
+
key: "Escape",
|
|
189
|
+
bubbles: true,
|
|
190
|
+
}),
|
|
182
191
|
);
|
|
183
192
|
assert.equal(isOpen(), false);
|
|
193
|
+
assert.deepEqual(seen, [], "the layer behind the picker saw the press");
|
|
194
|
+
dom.window.removeEventListener("keydown", listener);
|
|
184
195
|
|
|
185
196
|
dom.click(dom.byLabel("Move to folder"));
|
|
186
197
|
assert.equal(isOpen(), true);
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type FolderTreeNode,
|
|
6
6
|
FolderTreePicker,
|
|
7
7
|
PopoverMenuPortal,
|
|
8
|
+
useOverlayScope,
|
|
8
9
|
} from "@remit/ui";
|
|
9
10
|
import { useQuery } from "@tanstack/react-query";
|
|
10
11
|
import { FolderInput } from "lucide-react";
|
|
@@ -145,7 +146,14 @@ export const MoveToTrigger = ({
|
|
|
145
146
|
onMove(picked.id);
|
|
146
147
|
}, [picked, close, onMove]);
|
|
147
148
|
|
|
148
|
-
//
|
|
149
|
+
// The picker owns Escape while it is open (#732): on the shared overlay stack,
|
|
150
|
+
// so the triage layer underneath never maps the same press to `back` and
|
|
151
|
+
// closes the conversation with it. A second Escape closes as usual. Both
|
|
152
|
+
// surfaces register — the phone's vaul drawer is a modal like any other, and
|
|
153
|
+
// a narrow window with a keyboard attached is an ordinary way to meet it.
|
|
154
|
+
useOverlayScope({ id: "move-to", open: isOpen, answers: { back: close } });
|
|
155
|
+
|
|
156
|
+
// Desktop popover: dismiss on outside click.
|
|
149
157
|
useEffect(() => {
|
|
150
158
|
if (!isOpen || !isDesktop) return;
|
|
151
159
|
const handlePointer = (event: MouseEvent) => {
|
|
@@ -157,22 +165,8 @@ export const MoveToTrigger = ({
|
|
|
157
165
|
if (panelRef.current?.contains(target)) return;
|
|
158
166
|
close();
|
|
159
167
|
};
|
|
160
|
-
// The popover owns Escape while it is open (#732). The global triage
|
|
161
|
-
// layer listens on window and maps Escape to `back`, which closes the
|
|
162
|
-
// conversation; stopping propagation keeps one press from dismissing
|
|
163
|
-
// both. A second Escape then reaches the layer and closes as usual.
|
|
164
|
-
const handleKey = (event: KeyboardEvent) => {
|
|
165
|
-
if (event.key === "Escape") {
|
|
166
|
-
event.stopPropagation();
|
|
167
|
-
close();
|
|
168
|
-
}
|
|
169
|
-
};
|
|
170
168
|
document.addEventListener("mousedown", handlePointer);
|
|
171
|
-
document.
|
|
172
|
-
return () => {
|
|
173
|
-
document.removeEventListener("mousedown", handlePointer);
|
|
174
|
-
document.removeEventListener("keydown", handleKey);
|
|
175
|
-
};
|
|
169
|
+
return () => document.removeEventListener("mousedown", handlePointer);
|
|
176
170
|
}, [isOpen, isDesktop, close]);
|
|
177
171
|
|
|
178
172
|
const anchorRect = () =>
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
stepIndex,
|
|
23
23
|
stepsFor,
|
|
24
24
|
suggestRuleName,
|
|
25
|
+
useOverlayScope,
|
|
25
26
|
type Verb,
|
|
26
27
|
type WizardDraft,
|
|
27
28
|
type WizardMessage,
|
|
@@ -1097,6 +1098,10 @@ export function SelectionWizardHost(props: SelectionWizardHostProps) {
|
|
|
1097
1098
|
const { step, goToStep, goBack, closeWizard } = useWizardStep(
|
|
1098
1099
|
fromSearch ? "properties" : "match",
|
|
1099
1100
|
);
|
|
1101
|
+
// A walk in progress owns the keyboard. It answers nothing itself — Back and
|
|
1102
|
+
// Close are on screen, and Escape abandoning a half-run selection is not what
|
|
1103
|
+
// the reader asked for — so every triage key is contained until it closes.
|
|
1104
|
+
useOverlayScope({ id: "selection-wizard", open: step !== undefined });
|
|
1100
1105
|
if (!step) return null;
|
|
1101
1106
|
const conversion = fromSearch
|
|
1102
1107
|
? (searchConversion ?? NO_CONVERSION)
|
|
@@ -177,8 +177,6 @@ interface ThreadListInteractionProps {
|
|
|
177
177
|
* second unreviewed route to the same verb.
|
|
178
178
|
*/
|
|
179
179
|
onSelectionVerb: (verb: Verb) => void;
|
|
180
|
-
/** The wizard owns the screen, so this list's keyboard layer stands down. */
|
|
181
|
-
wizardOpen?: boolean;
|
|
182
180
|
isDeleting?: boolean;
|
|
183
181
|
commandsRef?: RefObject<MessageListCommands | null>;
|
|
184
182
|
onTriageContextChange?: (context: TriageContextUpdate) => void;
|
|
@@ -191,7 +189,6 @@ export function ThreadListInteraction({
|
|
|
191
189
|
onOpen,
|
|
192
190
|
onDeleteMessages,
|
|
193
191
|
onSelectionVerb,
|
|
194
|
-
wizardOpen = false,
|
|
195
192
|
isDeleting = false,
|
|
196
193
|
commandsRef,
|
|
197
194
|
onTriageContextChange,
|
|
@@ -408,11 +405,6 @@ export function ThreadListInteraction({
|
|
|
408
405
|
selectedIds: selectedIdList,
|
|
409
406
|
orderedIds,
|
|
410
407
|
hasList,
|
|
411
|
-
// The dialog and the wizard each own the keyboard while they are up, so
|
|
412
|
-
// the triage layer suspends rather than acting behind them: a second
|
|
413
|
-
// Delete must not reach a delete, and no shortcut may start a second flow
|
|
414
|
-
// behind the screen already asking about one.
|
|
415
|
-
blocksKeyboard: confirmOpen || wizardOpen,
|
|
416
408
|
});
|
|
417
409
|
}, [
|
|
418
410
|
onTriageContextChange,
|
|
@@ -420,8 +412,6 @@ export function ThreadListInteraction({
|
|
|
420
412
|
selectedIdList,
|
|
421
413
|
orderedIds,
|
|
422
414
|
hasList,
|
|
423
|
-
confirmOpen,
|
|
424
|
-
wizardOpen,
|
|
425
415
|
]);
|
|
426
416
|
|
|
427
417
|
const tabStop = tabStopId(orderedIds, focusedMessageId);
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Escape on an open overlay closes the overlay and nothing else (#958), and no
|
|
3
|
+
* other shortcut acts through one (#959).
|
|
4
|
+
*
|
|
5
|
+
* The window-level triage layer maps Escape to `back` — close the conversation,
|
|
6
|
+
* or the list behind it — and `c` to compose. The client's three own overlays
|
|
7
|
+
* used to let both reach it, so one Escape dismissed two things and `c` opened
|
|
8
|
+
* compose out from under a modal. They share the kit's overlay stack now, and
|
|
9
|
+
* the reading is the same for each: a listener on `window` never sees the key.
|
|
10
|
+
*/
|
|
11
|
+
import "@remit/test-dom";
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
14
|
+
import { ConfirmDialog, useTriageKeyboard } from "@remit/ui";
|
|
15
|
+
import { act, createElement, type ReactNode } from "react";
|
|
16
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
17
|
+
import { Drawer } from "./layout/Drawer";
|
|
18
|
+
import { DropdownMenu, DropdownMenuItem } from "./ui/DropdownMenu";
|
|
19
|
+
import { KeyboardShortcutsModal } from "./ui/KeyboardShortcutsModal";
|
|
20
|
+
|
|
21
|
+
let root: Root;
|
|
22
|
+
let container: HTMLElement;
|
|
23
|
+
let seen: string[];
|
|
24
|
+
let listener: (event: KeyboardEvent) => void;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
container = document.createElement("div");
|
|
28
|
+
document.body.appendChild(container);
|
|
29
|
+
root = createRoot(container);
|
|
30
|
+
seen = [];
|
|
31
|
+
listener = (event) => seen.push(event.key);
|
|
32
|
+
window.addEventListener("keydown", listener);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
window.removeEventListener("keydown", listener);
|
|
37
|
+
act(() => root.unmount());
|
|
38
|
+
container.remove();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const render = (element: ReactNode) => {
|
|
42
|
+
act(() => root.render(element));
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const press = (key: string) => {
|
|
46
|
+
act(() => {
|
|
47
|
+
document.body.dispatchEvent(
|
|
48
|
+
new window.KeyboardEvent("keydown", { key, bubbles: true }),
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
const assertScoped = (closed: number) => {
|
|
54
|
+
assert.equal(closed, 1, "the overlay did not close itself");
|
|
55
|
+
assert.deepEqual(seen, [], "the layer behind the overlay saw the same press");
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
describe("Escape is scoped to the overlay it lands on (#958)", () => {
|
|
59
|
+
it("Drawer closes and swallows the key", () => {
|
|
60
|
+
let closed = 0;
|
|
61
|
+
render(
|
|
62
|
+
createElement(Drawer, {
|
|
63
|
+
isOpen: true,
|
|
64
|
+
onClose: () => closed++,
|
|
65
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
|
|
66
|
+
children: createElement("button", { type: "button" }, "Inbox"),
|
|
67
|
+
}),
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
press("Escape");
|
|
71
|
+
|
|
72
|
+
assertScoped(closed);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("KeyboardShortcutsModal closes and swallows the key", () => {
|
|
76
|
+
let closed = 0;
|
|
77
|
+
render(
|
|
78
|
+
createElement(KeyboardShortcutsModal, {
|
|
79
|
+
isOpen: true,
|
|
80
|
+
onClose: () => closed++,
|
|
81
|
+
}),
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
press("Escape");
|
|
85
|
+
|
|
86
|
+
assertScoped(closed);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("DropdownMenu closes and swallows the key", () => {
|
|
90
|
+
render(
|
|
91
|
+
createElement(DropdownMenu, {
|
|
92
|
+
trigger: "More",
|
|
93
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
|
|
94
|
+
children: createElement(DropdownMenuItem, {
|
|
95
|
+
onClick: () => undefined,
|
|
96
|
+
// biome-ignore lint/correctness/noChildrenProp: same
|
|
97
|
+
children: "Mark as read",
|
|
98
|
+
}),
|
|
99
|
+
}),
|
|
100
|
+
);
|
|
101
|
+
const trigger = container.querySelector("button") as HTMLButtonElement;
|
|
102
|
+
act(() => trigger.click());
|
|
103
|
+
assert.ok(container.textContent?.includes("Mark as read"));
|
|
104
|
+
|
|
105
|
+
press("Escape");
|
|
106
|
+
|
|
107
|
+
assert.equal(
|
|
108
|
+
container.textContent?.includes("Mark as read"),
|
|
109
|
+
false,
|
|
110
|
+
"the menu did not close itself",
|
|
111
|
+
);
|
|
112
|
+
assert.deepEqual(seen, [], "the layer behind the menu saw the same press");
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The route-level compose layer, mounted the way `/mail` mounts it: `c` off a
|
|
118
|
+
* mailbox is served there and nowhere else, so on the brief and on Flagged it
|
|
119
|
+
* was the only layer answering the key — and the only one with no guard.
|
|
120
|
+
*/
|
|
121
|
+
function ComposeLayer({
|
|
122
|
+
modal,
|
|
123
|
+
onCompose,
|
|
124
|
+
}: {
|
|
125
|
+
modal: boolean;
|
|
126
|
+
onCompose: () => void;
|
|
127
|
+
}) {
|
|
128
|
+
useTriageKeyboard({ handlers: { compose: onCompose } });
|
|
129
|
+
return createElement(ConfirmDialog, {
|
|
130
|
+
isOpen: modal,
|
|
131
|
+
title: "Delete 3 messages?",
|
|
132
|
+
confirmLabel: "Delete",
|
|
133
|
+
onConfirm: () => undefined,
|
|
134
|
+
onCancel: () => undefined,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
describe("c under a confirmation on the brief (#959)", () => {
|
|
139
|
+
it("stays inert while the dialog is up, and works once it is gone", () => {
|
|
140
|
+
const composed: string[] = [];
|
|
141
|
+
const onCompose = () => composed.push("c");
|
|
142
|
+
|
|
143
|
+
render(createElement(ComposeLayer, { modal: true, onCompose }));
|
|
144
|
+
press("c");
|
|
145
|
+
assert.deepEqual(composed, [], "c opened compose from under the dialog");
|
|
146
|
+
|
|
147
|
+
render(createElement(ComposeLayer, { modal: false, onCompose }));
|
|
148
|
+
press("c");
|
|
149
|
+
assert.deepEqual(composed, ["c"], "c stayed dead after the dialog closed");
|
|
150
|
+
});
|
|
151
|
+
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { cn } from "@remit/ui";
|
|
1
|
+
import { cn, useOverlayScope } from "@remit/ui";
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
3
3
|
|
|
4
4
|
interface DropdownMenuProps {
|
|
@@ -21,22 +21,16 @@ export const DropdownMenu = ({
|
|
|
21
21
|
}
|
|
22
22
|
}, []);
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
28
|
-
}
|
|
24
|
+
useOverlayScope({
|
|
25
|
+
id: "dropdown-menu",
|
|
26
|
+
open: isOpen,
|
|
27
|
+
answers: { back: () => setIsOpen(false) },
|
|
28
|
+
});
|
|
29
29
|
|
|
30
30
|
useEffect(() => {
|
|
31
|
-
if (isOpen)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
}
|
|
35
|
-
return () => {
|
|
36
|
-
document.removeEventListener("mousedown", handleClickOutside);
|
|
37
|
-
document.removeEventListener("keydown", handleKeyDown);
|
|
38
|
-
};
|
|
39
|
-
}, [isOpen, handleClickOutside, handleKeyDown]);
|
|
31
|
+
if (isOpen) document.addEventListener("mousedown", handleClickOutside);
|
|
32
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
33
|
+
}, [isOpen, handleClickOutside]);
|
|
40
34
|
|
|
41
35
|
return (
|
|
42
36
|
<div ref={menuRef} className="relative">
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useOverlayScope } from "@remit/ui";
|
|
1
2
|
import { AlertOctagon } from "lucide-react";
|
|
2
3
|
import { useEffect, useState } from "react";
|
|
3
4
|
import {
|
|
@@ -65,6 +66,9 @@ const seedFromFatal = (fatal: FatalError): BugReportSeed => ({
|
|
|
65
66
|
* by the route-level error boundary without going through the subscription.
|
|
66
67
|
*/
|
|
67
68
|
export const FatalErrorScreen = ({ fatal, onRetry }: FatalErrorScreenProps) => {
|
|
69
|
+
// Nothing under it is in a state to act, and there is no dismissal to offer:
|
|
70
|
+
// the screen's own controls are the only way out.
|
|
71
|
+
useOverlayScope({ id: "fatal-error", open: true });
|
|
68
72
|
const [copied, setCopied] = useState(false);
|
|
69
73
|
|
|
70
74
|
const handleReload = () => {
|
|
@@ -1,6 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
cn,
|
|
3
|
+
DialogBackdrop,
|
|
4
|
+
Kbd,
|
|
5
|
+
KEY_HINT_GROUPS,
|
|
6
|
+
useOverlayScope,
|
|
7
|
+
} from "@remit/ui";
|
|
2
8
|
import { X } from "lucide-react";
|
|
3
|
-
import { Fragment
|
|
9
|
+
import { Fragment } from "react";
|
|
4
10
|
|
|
5
11
|
interface KeyboardShortcutsModalProps {
|
|
6
12
|
isOpen: boolean;
|
|
@@ -16,27 +22,11 @@ export const KeyboardShortcutsModal = ({
|
|
|
16
22
|
isOpen,
|
|
17
23
|
onClose,
|
|
18
24
|
}: KeyboardShortcutsModalProps) => {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
event.stopPropagation();
|
|
25
|
-
event.stopImmediatePropagation();
|
|
26
|
-
onClose();
|
|
27
|
-
}
|
|
28
|
-
},
|
|
29
|
-
[onClose],
|
|
30
|
-
);
|
|
31
|
-
|
|
32
|
-
useEffect(() => {
|
|
33
|
-
if (!isOpen) return;
|
|
34
|
-
// Capture phase, as `ConfirmDialog` does: the sheet is the topmost surface,
|
|
35
|
-
// so Escape dismisses it and nothing else. Shared with the list's own
|
|
36
|
-
// Escape, the one keystroke also closed the conversation underneath.
|
|
37
|
-
window.addEventListener("keydown", handleKeyDown, true);
|
|
38
|
-
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
|
39
|
-
}, [isOpen, handleKeyDown]);
|
|
25
|
+
useOverlayScope({
|
|
26
|
+
id: "shortcuts",
|
|
27
|
+
open: isOpen,
|
|
28
|
+
answers: { back: onClose },
|
|
29
|
+
});
|
|
40
30
|
|
|
41
31
|
if (!isOpen) return null;
|
|
42
32
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolveAgainstOverlays, type TriageAction } from "@remit/ui";
|
|
1
2
|
import { useCallback, useEffect } from "react";
|
|
2
3
|
|
|
3
4
|
type KeyHandler = (event: KeyboardEvent) => void;
|
|
@@ -29,6 +30,14 @@ interface KeyBinding {
|
|
|
29
30
|
* pre-empt a route-level Esc that would also navigate).
|
|
30
31
|
*/
|
|
31
32
|
stopPropagation?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Which triage action this key means. Naming it puts the binding under the
|
|
35
|
+
* overlay stack: an open overlay answers the action itself or contains it,
|
|
36
|
+
* and either way this binding stands down. Every key that also exists in the
|
|
37
|
+
* global key map owes one, or it acts through whatever is on top of it —
|
|
38
|
+
* `r` opening a reply behind the Move popover (#959) is that bug.
|
|
39
|
+
*/
|
|
40
|
+
action?: TriageAction;
|
|
32
41
|
}
|
|
33
42
|
|
|
34
43
|
interface UseKeyboardNavigationOptions {
|
|
@@ -71,6 +80,8 @@ export const useKeyboardNavigation = ({
|
|
|
71
80
|
event.key.toLowerCase() === binding.key.toLowerCase();
|
|
72
81
|
if (!keyMatches) continue;
|
|
73
82
|
|
|
83
|
+
if (binding.action && resolveAgainstOverlays(binding.action)) continue;
|
|
84
|
+
|
|
74
85
|
// Shift gate: when a binding pins shift on/off, it only matches that
|
|
75
86
|
// exact state so plain and shift variants stay distinct.
|
|
76
87
|
if (
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { type SyncStatusReading, waitForSettled } from "./useRefreshControl.js";
|
|
4
|
+
|
|
5
|
+
const BASELINE = 1_700_000_000_000;
|
|
6
|
+
const LATER = BASELINE + 5_000;
|
|
7
|
+
|
|
8
|
+
/** Replays `readings` in order and holds on the last one, counting reads so a
|
|
9
|
+
* test can say when the wait made up its mind. */
|
|
10
|
+
const scriptedReader = (readings: readonly SyncStatusReading[]) => {
|
|
11
|
+
const state = { reads: 0 };
|
|
12
|
+
const readStatus = async (): Promise<SyncStatusReading> => {
|
|
13
|
+
const reading = readings[Math.min(state.reads, readings.length - 1)];
|
|
14
|
+
state.reads += 1;
|
|
15
|
+
if (!reading) throw new Error("no reading scripted");
|
|
16
|
+
return reading;
|
|
17
|
+
};
|
|
18
|
+
return { state, readStatus };
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const wait = (
|
|
22
|
+
readings: readonly SyncStatusReading[],
|
|
23
|
+
deadline = Date.now() + 10_000,
|
|
24
|
+
) => {
|
|
25
|
+
const { state, readStatus } = scriptedReader(readings);
|
|
26
|
+
return {
|
|
27
|
+
state,
|
|
28
|
+
outcome: waitForSettled({
|
|
29
|
+
accountId: "a-1",
|
|
30
|
+
readStatus,
|
|
31
|
+
baselineLastSyncAt: BASELINE,
|
|
32
|
+
deadline,
|
|
33
|
+
pollMs: 0,
|
|
34
|
+
}),
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
describe("waitForSettled", () => {
|
|
39
|
+
// #953: the tab's own background poll, another tab, and every GET /config
|
|
40
|
+
// start rounds the user never clicked for. Seeing one of those in flight and
|
|
41
|
+
// then complete used to be read as confirmation, so the button showed a
|
|
42
|
+
// checkmark and invalidated the list with the clicked round still unrun —
|
|
43
|
+
// new mail stayed unshown until the next poll. Only the account's own
|
|
44
|
+
// round-start stamp moving past the pre-trigger reading counts.
|
|
45
|
+
test("does not confirm on a round that was already in flight at the trigger", async () => {
|
|
46
|
+
const { state, outcome } = wait([
|
|
47
|
+
{ syncPhase: "syncing_inbox", lastSyncAt: BASELINE },
|
|
48
|
+
{ syncPhase: "complete", lastSyncAt: BASELINE },
|
|
49
|
+
{ syncPhase: "complete", lastSyncAt: LATER },
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
assert.equal(await outcome, undefined);
|
|
53
|
+
assert.equal(state.reads, 3);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("a settled foreign round times out rather than confirming", async () => {
|
|
57
|
+
const { outcome } = wait(
|
|
58
|
+
[{ syncPhase: "complete", lastSyncAt: BASELINE }],
|
|
59
|
+
Date.now() - 1,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
assert.deepEqual(await outcome, {
|
|
63
|
+
accountId: "a-1",
|
|
64
|
+
message: "Refresh is taking longer than usual",
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("confirms once the account's own round has started and settled", async () => {
|
|
69
|
+
const { state, outcome } = wait([
|
|
70
|
+
{ syncPhase: "complete", lastSyncAt: LATER },
|
|
71
|
+
]);
|
|
72
|
+
|
|
73
|
+
assert.equal(await outcome, undefined);
|
|
74
|
+
assert.equal(state.reads, 1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("reports the failure of a round that started after the trigger", async () => {
|
|
78
|
+
const { outcome } = wait([{ syncPhase: "error", lastSyncAt: LATER }]);
|
|
79
|
+
|
|
80
|
+
assert.deepEqual(await outcome, {
|
|
81
|
+
accountId: "a-1",
|
|
82
|
+
message: "Sync failed for this account",
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// The stale-phase half of the same rule: POST /sync only enqueues, so the
|
|
87
|
+
// first reading back can still carry the previous round's error phase.
|
|
88
|
+
test("does not borrow the error phase of a round that predates the trigger", async () => {
|
|
89
|
+
const { outcome } = wait(
|
|
90
|
+
[{ syncPhase: "error", lastSyncAt: BASELINE }],
|
|
91
|
+
Date.now() - 1,
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
assert.deepEqual(await outcome, {
|
|
95
|
+
accountId: "a-1",
|
|
96
|
+
message: "Refresh is taking longer than usual",
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("an account that has never synced confirms on its first stamp", async () => {
|
|
101
|
+
const { state, readStatus } = scriptedReader([
|
|
102
|
+
{ syncPhase: "idle", lastSyncAt: undefined },
|
|
103
|
+
{ syncPhase: "complete", lastSyncAt: LATER },
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
const outcome = await waitForSettled({
|
|
107
|
+
accountId: "a-1",
|
|
108
|
+
readStatus,
|
|
109
|
+
baselineLastSyncAt: 0,
|
|
110
|
+
deadline: Date.now() + 10_000,
|
|
111
|
+
pollMs: 0,
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
assert.equal(outcome, undefined);
|
|
115
|
+
assert.equal(state.reads, 2);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("surfaces a failed status read", async () => {
|
|
119
|
+
const outcome = await waitForSettled({
|
|
120
|
+
accountId: "a-1",
|
|
121
|
+
readStatus: () => Promise.reject(new Error("signed out")),
|
|
122
|
+
baselineLastSyncAt: BASELINE,
|
|
123
|
+
deadline: Date.now() + 10_000,
|
|
124
|
+
pollMs: 0,
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
assert.deepEqual(outcome, { accountId: "a-1", message: "signed out" });
|
|
128
|
+
});
|
|
129
|
+
});
|
|
@@ -3,7 +3,7 @@ import {
|
|
|
3
3
|
syncOperationsGetSyncStatusOptions,
|
|
4
4
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
5
|
import { syncOperationsTriggerSync } from "@remit/api-http-client/sdk.gen.ts";
|
|
6
|
-
import type {
|
|
6
|
+
import type { RemitImapSyncPhase } from "@remit/api-http-client/types.gen.ts";
|
|
7
7
|
import type { RefreshControlState } from "@remit/ui";
|
|
8
8
|
import { type QueryClient, useQueryClient } from "@tanstack/react-query";
|
|
9
9
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
@@ -41,14 +41,6 @@ const escalateIfFatal = (error: unknown): void => {
|
|
|
41
41
|
}
|
|
42
42
|
};
|
|
43
43
|
|
|
44
|
-
const maxLastSynced = (
|
|
45
|
-
mailboxes: readonly RemitImapMailboxSyncProgress[],
|
|
46
|
-
): number =>
|
|
47
|
-
mailboxes.reduce(
|
|
48
|
-
(max, mailbox) => Math.max(max, mailbox.lastSyncedAt ?? 0),
|
|
49
|
-
0,
|
|
50
|
-
);
|
|
51
|
-
|
|
52
44
|
/**
|
|
53
45
|
* `getSyncStatus` for one account, always as a real network read: `staleTime:
|
|
54
46
|
* 0` means even a reading `MailFreshnessProvider` fetched a moment ago is
|
|
@@ -71,59 +63,74 @@ const fetchStatus = (queryClient: QueryClient, accountId: string) =>
|
|
|
71
63
|
staleTime: 0,
|
|
72
64
|
});
|
|
73
65
|
|
|
74
|
-
interface AccountOutcome {
|
|
66
|
+
export interface AccountOutcome {
|
|
75
67
|
accountId: string;
|
|
76
68
|
message?: string;
|
|
77
69
|
}
|
|
78
70
|
|
|
71
|
+
/** The two fields of `getSyncStatus` that identify a round, named so the wait
|
|
72
|
+
* below can be driven from a scripted sequence of readings. */
|
|
73
|
+
export interface SyncStatusReading {
|
|
74
|
+
syncPhase?: RemitImapSyncPhase;
|
|
75
|
+
lastSyncAt?: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface WaitForSettledOptions {
|
|
79
|
+
accountId: string;
|
|
80
|
+
readStatus: () => Promise<SyncStatusReading>;
|
|
81
|
+
/** `lastSyncAt` as it read immediately before this refresh triggered. */
|
|
82
|
+
baselineLastSyncAt: number;
|
|
83
|
+
deadline: number;
|
|
84
|
+
pollMs?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
79
87
|
/**
|
|
80
|
-
* Poll one account's sync status until a round that started after
|
|
81
|
-
*
|
|
88
|
+
* Poll one account's sync status until a round that started at or after this
|
|
89
|
+
* refresh triggered has settled, or `deadline` passes.
|
|
82
90
|
*
|
|
83
|
-
* `
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* the
|
|
89
|
-
*
|
|
91
|
+
* `account.lastSyncAt` is the worker's own once-per-round stamp, and it is the
|
|
92
|
+
* only thing here that tells one round from another. A phase seen mid-flight
|
|
93
|
+
* does not: the tab's background poll, another tab, and every `GET /config`
|
|
94
|
+
* trigger rounds nobody clicked for, so a click landing while one of those is
|
|
95
|
+
* in flight used to confirm on it — a checkmark and an invalidated list with
|
|
96
|
+
* the clicked round still unrun, and the new mail it would have fetched unseen
|
|
97
|
+
* until the next poll (#953). Requiring the stamp to pass the reading taken
|
|
98
|
+
* before the trigger admits only a round that started after it.
|
|
99
|
+
*
|
|
100
|
+
* `POST /sync` enqueues and returns, so the first reading after triggering
|
|
101
|
+
* still shows the previous round — including a previous round's `error` phase,
|
|
102
|
+
* which is why that phase only speaks once the stamp has moved.
|
|
90
103
|
*
|
|
91
104
|
* Read-only (`getSyncStatus`, no IMAP call, no queue write), so waiting costs
|
|
92
105
|
* a handful of cheap GETs — never a refetch of the account's own message
|
|
93
106
|
* list.
|
|
94
107
|
*/
|
|
95
|
-
const waitForSettled = async (
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
deadline
|
|
100
|
-
|
|
101
|
-
|
|
108
|
+
export const waitForSettled = async ({
|
|
109
|
+
accountId,
|
|
110
|
+
readStatus,
|
|
111
|
+
baselineLastSyncAt,
|
|
112
|
+
deadline,
|
|
113
|
+
pollMs = REFRESH_POLL_MS,
|
|
114
|
+
}: WaitForSettledOptions): Promise<AccountOutcome | undefined> => {
|
|
102
115
|
for (;;) {
|
|
103
|
-
const outcome = await
|
|
116
|
+
const outcome = await readStatus()
|
|
104
117
|
.then((data) => ({ ok: true as const, data }))
|
|
105
118
|
.catch((error: unknown) => ({ ok: false as const, error }));
|
|
106
119
|
if (!outcome.ok) {
|
|
107
120
|
return { accountId, message: messageFor(outcome.error) };
|
|
108
121
|
}
|
|
109
|
-
const { syncPhase,
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
observedInProgress ||
|
|
115
|
-
maxLastSynced(mailboxes ?? []) > baselineMaxLastSynced;
|
|
116
|
-
if (confirmed) {
|
|
117
|
-
if (syncPhase === "error") {
|
|
118
|
-
return { accountId, message: "Sync failed for this account" };
|
|
119
|
-
}
|
|
120
|
-
return undefined;
|
|
122
|
+
const { syncPhase, lastSyncAt } = outcome.data;
|
|
123
|
+
const startedAfterTrigger = (lastSyncAt ?? 0) > baselineLastSyncAt;
|
|
124
|
+
if (startedAfterTrigger && !isSyncingPhase(syncPhase)) {
|
|
125
|
+
if (syncPhase === "error") {
|
|
126
|
+
return { accountId, message: "Sync failed for this account" };
|
|
121
127
|
}
|
|
128
|
+
return undefined;
|
|
122
129
|
}
|
|
123
130
|
if (Date.now() >= deadline) {
|
|
124
131
|
return { accountId, message: "Refresh is taking longer than usual" };
|
|
125
132
|
}
|
|
126
|
-
await sleep(
|
|
133
|
+
await sleep(pollMs);
|
|
127
134
|
}
|
|
128
135
|
};
|
|
129
136
|
|
|
@@ -146,7 +153,7 @@ const refreshOneAccount = async (
|
|
|
146
153
|
const baseline = await fetchStatus(queryClient, accountId)
|
|
147
154
|
.then((data) => ({
|
|
148
155
|
ok: true as const,
|
|
149
|
-
|
|
156
|
+
lastSyncAt: data.lastSyncAt ?? 0,
|
|
150
157
|
}))
|
|
151
158
|
.catch((error: unknown) => ({ ok: false as const, error }));
|
|
152
159
|
if (!baseline.ok) {
|
|
@@ -175,12 +182,12 @@ const refreshOneAccount = async (
|
|
|
175
182
|
}
|
|
176
183
|
telemetry.recordEvent("sync.triggered");
|
|
177
184
|
|
|
178
|
-
const settled = await waitForSettled(
|
|
179
|
-
queryClient,
|
|
185
|
+
const settled = await waitForSettled({
|
|
180
186
|
accountId,
|
|
181
|
-
|
|
187
|
+
readStatus: () => fetchStatus(queryClient, accountId),
|
|
188
|
+
baselineLastSyncAt: baseline.lastSyncAt,
|
|
182
189
|
deadline,
|
|
183
|
-
);
|
|
190
|
+
});
|
|
184
191
|
if (settled)
|
|
185
192
|
return { accountId, enqueued: true, ok: false, message: settled.message };
|
|
186
193
|
return { accountId, enqueued: true, ok: true };
|
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
* context comes first, the pane builds its handlers from it, and the keyboard
|
|
15
15
|
* registration comes last.
|
|
16
16
|
*
|
|
17
|
+
* A modal open over the pane is not this layer's problem. Every overlay declares
|
|
18
|
+
* itself on the shared stack (`@remit/ui`'s `overlay-scope`) and `useTriageKeyboard`
|
|
19
|
+
* contains what the top frame does not serve, so the pane no longer reports which
|
|
20
|
+
* of its own dialogs is up and every other layer on the page suspends with it.
|
|
21
|
+
*
|
|
17
22
|
* While a thread is open, `ConversationView` binds its own window listener for
|
|
18
23
|
* the messages inside it, and both used to act on one press (#723). The split
|
|
19
24
|
* is the one `shortcut-tree` already declares: the cursor keys belong to
|
|
@@ -30,7 +35,6 @@ export interface TriageContextUpdate {
|
|
|
30
35
|
focusedMessageId: string | undefined;
|
|
31
36
|
selectedIds: string[];
|
|
32
37
|
hasList: boolean;
|
|
33
|
-
blocksKeyboard: boolean;
|
|
34
38
|
/** Row ids in display order, when the list knows them. Feeds adjacency. */
|
|
35
39
|
orderedIds?: string[];
|
|
36
40
|
}
|
|
@@ -45,7 +49,6 @@ export interface TriageContext {
|
|
|
45
49
|
/** Row ids in display order, as last reported by the list. */
|
|
46
50
|
orderedIds: string[];
|
|
47
51
|
hasList: boolean;
|
|
48
|
-
blocksKeyboard: boolean;
|
|
49
52
|
}
|
|
50
53
|
|
|
51
54
|
export const useTriageContext = (): TriageContext => {
|
|
@@ -58,14 +61,12 @@ export const useTriageContext = (): TriageContext => {
|
|
|
58
61
|
// keyboard layer then simply has nothing to drive.
|
|
59
62
|
const listCommandsRef = useRef<MessageListCommands | null>(null);
|
|
60
63
|
const [hasList, setHasList] = useState(false);
|
|
61
|
-
const [blocksKeyboard, setBlocksKeyboard] = useState(false);
|
|
62
64
|
|
|
63
65
|
const onTriageContextChange = useCallback((context: TriageContextUpdate) => {
|
|
64
66
|
setFocusedMessageId(context.focusedMessageId);
|
|
65
67
|
setSelectedIds(context.selectedIds);
|
|
66
68
|
if (context.orderedIds) setOrderedIds(context.orderedIds);
|
|
67
69
|
setHasList(context.hasList);
|
|
68
|
-
setBlocksKeyboard(context.blocksKeyboard);
|
|
69
70
|
}, []);
|
|
70
71
|
|
|
71
72
|
return {
|
|
@@ -75,7 +76,6 @@ export const useTriageContext = (): TriageContext => {
|
|
|
75
76
|
selectedIds,
|
|
76
77
|
orderedIds,
|
|
77
78
|
hasList,
|
|
78
|
-
blocksKeyboard,
|
|
79
79
|
};
|
|
80
80
|
};
|
|
81
81
|
|
|
@@ -118,7 +118,7 @@ export const useTriageLayer = ({
|
|
|
118
118
|
handlers,
|
|
119
119
|
hasOpenThread = false,
|
|
120
120
|
}: UseTriageLayerOptions): TriageLayer => {
|
|
121
|
-
const { listCommandsRef, hasList
|
|
121
|
+
const { listCommandsRef, hasList } = context;
|
|
122
122
|
|
|
123
123
|
const goBack = useCallback(() => {
|
|
124
124
|
if (listCommandsRef.current?.clearSelection()) return;
|
|
@@ -132,10 +132,7 @@ export const useTriageLayer = ({
|
|
|
132
132
|
const { reply, replyAll, forward, ...restHandlers } = handlers;
|
|
133
133
|
|
|
134
134
|
useTriageKeyboard({
|
|
135
|
-
|
|
136
|
-
// second Delete press from reaching a delete while the confirmation for the
|
|
137
|
-
// first one is still on screen.
|
|
138
|
-
enabled: enabled && !blocksKeyboard,
|
|
135
|
+
enabled,
|
|
139
136
|
handlers: {
|
|
140
137
|
// Registered only while a list is mounted to serve them. An unregistered
|
|
141
138
|
// action is never preventDefault-ed, so with no list the browser keeps
|
package/src/routes/mail.tsx
CHANGED
|
@@ -128,6 +128,7 @@ function MailLayout() {
|
|
|
128
128
|
bindings: [
|
|
129
129
|
{
|
|
130
130
|
key: "?",
|
|
131
|
+
action: "help",
|
|
131
132
|
handler: () => showOverlay("shortcuts"),
|
|
132
133
|
noModifiers: false, // Allow shift+/
|
|
133
134
|
preventDefault: true,
|
|
@@ -138,7 +139,9 @@ function MailLayout() {
|
|
|
138
139
|
// `c` / ⌘N off the mailbox routes — the brief, Flagged, the outbox. On a
|
|
139
140
|
// mailbox the pane's own triage layer owns the key, so this is disabled there
|
|
140
141
|
// rather than firing a second compose alongside it, and while the composer is
|
|
141
|
-
// up the key belongs to whatever is being typed.
|
|
142
|
+
// up the key belongs to whatever is being typed. An open modal contains the
|
|
143
|
+
// key through the shared overlay stack, so this layer never opens compose out
|
|
144
|
+
// from under one (#959).
|
|
142
145
|
const openCompose = useOpenCompose();
|
|
143
146
|
const isComposing = useIsComposing();
|
|
144
147
|
const onMailbox = useRouterState({
|