@remit/web-client 0.0.162 → 0.0.164
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/BriefPane.tsx +11 -2
- package/src/components/mail/DailyBrief.scope.test.ts +76 -0
- package/src/components/mail/DailyBrief.tsx +6 -1
- package/src/components/mail/DraftsView.tsx +2 -0
- package/src/components/mail/FlaggedList.tsx +1 -1
- package/src/components/mail/FlaggedPane.tsx +11 -2
- package/src/components/mail/MailboxPane.tsx +10 -28
- package/src/components/mail/MessageActionMenu.tsx +2 -1
- package/src/components/mail/MessageList.tsx +3 -1
- package/src/components/mail/OutboxPane.tsx +7 -1
- package/src/components/mail/SelectionWizardHost.tsx +20 -18
- package/src/components/mail/SwipeableMessageRow.tsx +2 -0
- package/src/components/ui/KeyboardShortcutsModal.tsx +7 -2
- package/src/hooks/search-mirror-detail.render.test.ts +0 -1
- package/src/hooks/useSearchMirror.ts +11 -3
- package/src/lib/intelligence-pref.test.ts +77 -0
- package/src/lib/intelligence-pref.ts +33 -1
- package/src/lib/mail-context.ts +6 -6
- package/src/routes/mail.tsx +74 -30
- package/src/routing/fragment.test.ts +68 -0
- package/src/routing/fragment.ts +79 -16
- package/src/routing/index.ts +8 -2
- package/src/routing/nav-link.tsx +6 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.164",
|
|
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": {
|
|
@@ -41,7 +41,11 @@ import {
|
|
|
41
41
|
} from "@/hooks/useTriageLayer";
|
|
42
42
|
import type { ConversationTarget } from "@/lib/conversation-target";
|
|
43
43
|
import { useMailContext } from "@/lib/mail-context";
|
|
44
|
-
import
|
|
44
|
+
import {
|
|
45
|
+
type OpenThreadPath,
|
|
46
|
+
type OpenThreadTarget,
|
|
47
|
+
retainOpenPanels,
|
|
48
|
+
} from "@/routing";
|
|
45
49
|
|
|
46
50
|
/* ------------------------------------------------------------------ */
|
|
47
51
|
/* Context */
|
|
@@ -148,13 +152,18 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
|
|
|
148
152
|
// `searchInput`: a row can be tapped before the debounce settles, when
|
|
149
153
|
// the committed query is still empty.
|
|
150
154
|
search: (prev) => ({ ...prev, q: searchInput || undefined }),
|
|
155
|
+
hash: retainOpenPanels,
|
|
151
156
|
});
|
|
152
157
|
},
|
|
153
158
|
[navigate, searchInput],
|
|
154
159
|
);
|
|
155
160
|
|
|
156
161
|
const handleCloseThread = useCallback(() => {
|
|
157
|
-
navigate({
|
|
162
|
+
navigate({
|
|
163
|
+
to: "/mail/brief",
|
|
164
|
+
search: (prev) => prev,
|
|
165
|
+
hash: retainOpenPanels,
|
|
166
|
+
});
|
|
158
167
|
}, [navigate]);
|
|
159
168
|
|
|
160
169
|
const handleDeselectIfRemoved = useCallback(
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { ThreadRowData } from "@remit/ui";
|
|
4
|
+
import { wizardScopeFor } from "@remit/ui";
|
|
5
|
+
import { resolveBriefSelectionScope } from "./DailyBrief";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The brief spans accounts and folders, so it resolves both from the selection
|
|
9
|
+
* itself. Which of the two a selection spans decides what the wizard's folder
|
|
10
|
+
* and rule steps can say: told to pick a single account, a selection already
|
|
11
|
+
* inside one account has nothing to act on and the flow dead-ends (#525).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const row = (
|
|
15
|
+
id: string,
|
|
16
|
+
accountId: string,
|
|
17
|
+
mailboxId: string,
|
|
18
|
+
): ThreadRowData => ({
|
|
19
|
+
id,
|
|
20
|
+
accountId,
|
|
21
|
+
mailboxId,
|
|
22
|
+
fromName: "Booking.com",
|
|
23
|
+
fromEmail: "noreply@booking.com",
|
|
24
|
+
subject: "Booking confirmation",
|
|
25
|
+
snippet: "Your stay is confirmed",
|
|
26
|
+
timeLabel: "Jul 2",
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
const rows: ThreadRowData[] = [
|
|
30
|
+
row("m1", "acc-personal", "mbx-inbox"),
|
|
31
|
+
row("m2", "acc-personal", "mbx-archive"),
|
|
32
|
+
row("m3", "acc-work", "mbx-inbox"),
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const scopeOf = (...ids: string[]) =>
|
|
36
|
+
resolveBriefSelectionScope(rows, new Set(ids));
|
|
37
|
+
|
|
38
|
+
describe("the scope a brief selection resolves to", () => {
|
|
39
|
+
it("carries the folder restriction when one account holds every row", () => {
|
|
40
|
+
const scope = scopeOf("m1", "m2");
|
|
41
|
+
assert.equal(scope.restriction, "spansFolders");
|
|
42
|
+
assert.equal(scope.accountId, "acc-personal");
|
|
43
|
+
assert.equal(scope.mailboxId, undefined);
|
|
44
|
+
assert.match(scope.moveDisabledHint ?? "", /spans 2 folders/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("reaches the folder step told about folders, not accounts", () => {
|
|
48
|
+
const scope = scopeOf("m1", "m2");
|
|
49
|
+
const wizard = wizardScopeFor(scope.accountId, scope.restriction);
|
|
50
|
+
assert.match(wizard.destination ?? "", /within one folder/);
|
|
51
|
+
assert.doesNotMatch(wizard.destination ?? "", /account/);
|
|
52
|
+
// The account is settled, so the preview behind a widened door has one to
|
|
53
|
+
// count against and the match step keeps it.
|
|
54
|
+
assert.equal(wizard.accountId, "acc-personal");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("carries the account restriction when the rows span accounts", () => {
|
|
58
|
+
const scope = scopeOf("m1", "m3");
|
|
59
|
+
assert.equal(scope.restriction, "spansAccounts");
|
|
60
|
+
assert.equal(scope.accountId, undefined);
|
|
61
|
+
assert.match(
|
|
62
|
+
wizardScopeFor(scope.accountId, scope.restriction).destination ?? "",
|
|
63
|
+
/single account/,
|
|
64
|
+
);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("carries no restriction from one account and one folder", () => {
|
|
68
|
+
const scope = scopeOf("m1");
|
|
69
|
+
assert.equal(scope.restriction, undefined);
|
|
70
|
+
assert.equal(scope.mailboxId, "mbx-inbox");
|
|
71
|
+
assert.equal(
|
|
72
|
+
wizardScopeFor(scope.accountId, scope.restriction).destination,
|
|
73
|
+
undefined,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
});
|
|
@@ -54,6 +54,7 @@ import {
|
|
|
54
54
|
partitionSpamResults,
|
|
55
55
|
RefreshButton,
|
|
56
56
|
type SearchResult,
|
|
57
|
+
type SelectionRestriction,
|
|
57
58
|
SelectionTopBar,
|
|
58
59
|
SpamResultsOffer,
|
|
59
60
|
setBriefCategoryInQuery,
|
|
@@ -185,6 +186,8 @@ export interface BriefSelectionScope {
|
|
|
185
186
|
accountId?: string;
|
|
186
187
|
/** Source folder, when every selected row shares one. */
|
|
187
188
|
mailboxId?: string;
|
|
189
|
+
/** Which scope the selection spans more of than those verbs can take. */
|
|
190
|
+
restriction?: SelectionRestriction;
|
|
188
191
|
/** Why folder-scoped verbs are withheld, in the toolbar's own words. */
|
|
189
192
|
moveDisabledHint?: string;
|
|
190
193
|
}
|
|
@@ -214,6 +217,7 @@ export const resolveBriefSelectionScope = (
|
|
|
214
217
|
}
|
|
215
218
|
if (accountIds.size > 1) {
|
|
216
219
|
return {
|
|
220
|
+
restriction: "spansAccounts",
|
|
217
221
|
moveDisabledHint:
|
|
218
222
|
"Move only works within one account — clear selection or pick messages from a single account",
|
|
219
223
|
};
|
|
@@ -222,6 +226,7 @@ export const resolveBriefSelectionScope = (
|
|
|
222
226
|
if (mailboxIds.size > 1) {
|
|
223
227
|
return {
|
|
224
228
|
accountId,
|
|
229
|
+
restriction: "spansFolders",
|
|
225
230
|
moveDisabledHint: `Move only works within one folder — this selection spans ${mailboxIds.size} folders`,
|
|
226
231
|
};
|
|
227
232
|
}
|
|
@@ -362,7 +367,7 @@ function BriefSelectionChrome({
|
|
|
362
367
|
accountId={scope.accountId}
|
|
363
368
|
mailboxId={scope.mailboxId}
|
|
364
369
|
selection={wizardSelection}
|
|
365
|
-
|
|
370
|
+
selectionRestriction={scope.restriction}
|
|
366
371
|
onFinished={exitSelection}
|
|
367
372
|
/>
|
|
368
373
|
}
|
|
@@ -41,6 +41,7 @@ import { FileText, Inbox, Trash2 } from "lucide-react";
|
|
|
41
41
|
import { useMemo } from "react";
|
|
42
42
|
import { useCompose } from "@/components/compose/ComposeProvider";
|
|
43
43
|
import { groupDraftSections } from "@/lib/drafts";
|
|
44
|
+
import { retainOpenPanels } from "@/routing";
|
|
44
45
|
import { NavMenuButton } from "./NavMenuButton";
|
|
45
46
|
|
|
46
47
|
// ---------------------------------------------------------------------------
|
|
@@ -197,6 +198,7 @@ export function DraftsView({
|
|
|
197
198
|
to: "/mail/$mailboxId/$threadId/$messageId",
|
|
198
199
|
params: { mailboxId, threadId, messageId },
|
|
199
200
|
search: (prev) => prev,
|
|
201
|
+
hash: retainOpenPanels,
|
|
200
202
|
});
|
|
201
203
|
};
|
|
202
204
|
|
|
@@ -52,7 +52,11 @@ import {
|
|
|
52
52
|
} from "@/hooks/useTriageLayer";
|
|
53
53
|
import type { ConversationTarget } from "@/lib/conversation-target";
|
|
54
54
|
import { useMailContext } from "@/lib/mail-context";
|
|
55
|
-
import
|
|
55
|
+
import {
|
|
56
|
+
type OpenThreadPath,
|
|
57
|
+
type OpenThreadTarget,
|
|
58
|
+
retainOpenPanels,
|
|
59
|
+
} from "@/routing";
|
|
56
60
|
|
|
57
61
|
/* ------------------------------------------------------------------ */
|
|
58
62
|
/* Context */
|
|
@@ -154,13 +158,18 @@ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
|
|
|
154
158
|
// `searchInput`: a row can be tapped before the debounce settles, when
|
|
155
159
|
// the committed query is still empty.
|
|
156
160
|
search: (prev) => ({ ...prev, q: searchInput || undefined }),
|
|
161
|
+
hash: retainOpenPanels,
|
|
157
162
|
});
|
|
158
163
|
},
|
|
159
164
|
[navigate, searchInput],
|
|
160
165
|
);
|
|
161
166
|
|
|
162
167
|
const handleCloseThread = useCallback(() => {
|
|
163
|
-
navigate({
|
|
168
|
+
navigate({
|
|
169
|
+
to: "/mail/flagged",
|
|
170
|
+
search: (prev) => prev,
|
|
171
|
+
hash: retainOpenPanels,
|
|
172
|
+
});
|
|
164
173
|
}, [navigate]);
|
|
165
174
|
|
|
166
175
|
const handleDeselectIfRemoved = useCallback(
|
|
@@ -108,7 +108,6 @@ import {
|
|
|
108
108
|
inboxFilterParams,
|
|
109
109
|
sameInboxFilter,
|
|
110
110
|
} from "@/lib/inbox-filters";
|
|
111
|
-
import { readIntelligencePref } from "@/lib/intelligence-pref";
|
|
112
111
|
import { junkDestination } from "@/lib/junk-destination";
|
|
113
112
|
import { useMailContext } from "@/lib/mail-context";
|
|
114
113
|
import { useMailFreshness } from "@/lib/mail-freshness";
|
|
@@ -125,7 +124,11 @@ import {
|
|
|
125
124
|
applyResidualTokens,
|
|
126
125
|
threadSearchTokens,
|
|
127
126
|
} from "@/lib/thread-search-tokens";
|
|
128
|
-
import
|
|
127
|
+
import {
|
|
128
|
+
type OpenThreadPath,
|
|
129
|
+
type OpenThreadTarget,
|
|
130
|
+
retainOpenPanels,
|
|
131
|
+
} from "@/routing";
|
|
129
132
|
import { MailViewChrome } from "./MailViewChrome";
|
|
130
133
|
|
|
131
134
|
/* ------------------------------------------------------------------ */
|
|
@@ -263,16 +266,9 @@ function MailboxPaneProvider({
|
|
|
263
266
|
const navigate = useNavigate();
|
|
264
267
|
const threadId = thread?.threadId;
|
|
265
268
|
const pointedAtMessageId = thread?.messageId;
|
|
266
|
-
const tier = useLayoutTier();
|
|
267
|
-
const isDesktop = tier === "desktop";
|
|
268
269
|
const telemetry = useTelemetry();
|
|
269
|
-
const {
|
|
270
|
-
|
|
271
|
-
searchQuery,
|
|
272
|
-
intelligenceOpen,
|
|
273
|
-
onToggleIntelligence,
|
|
274
|
-
onSetIntelligenceOpen,
|
|
275
|
-
} = useMailContext();
|
|
270
|
+
const { accounts, searchQuery, intelligenceOpen, onToggleIntelligence } =
|
|
271
|
+
useMailContext();
|
|
276
272
|
const tokenContext = useSearchTokenContext();
|
|
277
273
|
|
|
278
274
|
const normalizedSearchQuery = normalizeSearchQuery(searchQuery);
|
|
@@ -471,6 +467,7 @@ function MailboxPaneProvider({
|
|
|
471
467
|
to: "/mail/$mailboxId",
|
|
472
468
|
params: { mailboxId },
|
|
473
469
|
search: (prev) => prev,
|
|
470
|
+
hash: retainOpenPanels,
|
|
474
471
|
});
|
|
475
472
|
}, [mailboxId, navigate]);
|
|
476
473
|
|
|
@@ -480,6 +477,7 @@ function MailboxPaneProvider({
|
|
|
480
477
|
to: "/mail/$mailboxId/$threadId/$messageId",
|
|
481
478
|
params: { mailboxId, ...target },
|
|
482
479
|
search: (prev) => prev,
|
|
480
|
+
hash: retainOpenPanels,
|
|
483
481
|
});
|
|
484
482
|
},
|
|
485
483
|
[mailboxId, navigate],
|
|
@@ -535,23 +533,6 @@ function MailboxPaneProvider({
|
|
|
535
533
|
onToggleIntelligence,
|
|
536
534
|
]);
|
|
537
535
|
|
|
538
|
-
// Desktop default-open (#782)
|
|
539
|
-
const appliedDefaultRef = useRef(false);
|
|
540
|
-
useEffect(() => {
|
|
541
|
-
if (appliedDefaultRef.current) return;
|
|
542
|
-
if (!isDesktop) return;
|
|
543
|
-
if (!selectedThread?.messageId) return;
|
|
544
|
-
appliedDefaultRef.current = true;
|
|
545
|
-
if (readIntelligencePref() && !intelligenceOpen) {
|
|
546
|
-
onSetIntelligenceOpen(true);
|
|
547
|
-
}
|
|
548
|
-
}, [
|
|
549
|
-
isDesktop,
|
|
550
|
-
selectedThread?.messageId,
|
|
551
|
-
intelligenceOpen,
|
|
552
|
-
onSetIntelligenceOpen,
|
|
553
|
-
]);
|
|
554
|
-
|
|
555
536
|
// The mailbox's own unseen total. A count over the loaded pages undercounts
|
|
556
537
|
// every mailbox larger than one page and creeps upward as the user scrolls,
|
|
557
538
|
// so there is no fallback: until the mailbox resolves there is no number.
|
|
@@ -1048,6 +1029,7 @@ function MailboxList() {
|
|
|
1048
1029
|
// `searchInput`: a row can be tapped before the debounce settles, when
|
|
1049
1030
|
// the committed query is still empty.
|
|
1050
1031
|
search: (prev) => ({ ...prev, q: searchInput || undefined }),
|
|
1032
|
+
hash: retainOpenPanels,
|
|
1051
1033
|
});
|
|
1052
1034
|
},
|
|
1053
1035
|
[mailboxId, navigate, searchInput, threads],
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
type ThreadListSnapshotEntry,
|
|
36
36
|
threadListCacheKeys,
|
|
37
37
|
} from "@/lib/thread-list-cache";
|
|
38
|
-
import { useOpenThreadPath } from "@/routing";
|
|
38
|
+
import { retainOpenPanels, useOpenThreadPath } from "@/routing";
|
|
39
39
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
40
40
|
|
|
41
41
|
interface ThreadMessagesData {
|
|
@@ -193,6 +193,7 @@ export const MessageActionMenu = ({
|
|
|
193
193
|
to: "/mail/$mailboxId",
|
|
194
194
|
params: { mailboxId },
|
|
195
195
|
search: (prev) => prev,
|
|
196
|
+
hash: retainOpenPanels,
|
|
196
197
|
});
|
|
197
198
|
},
|
|
198
199
|
[selectedMessageId, mailboxId, navigate],
|
|
@@ -53,6 +53,7 @@ import { listVerbRequest } from "@/lib/list-verb-request";
|
|
|
53
53
|
import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
|
|
54
54
|
import { cn } from "@/lib/utils";
|
|
55
55
|
import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
|
|
56
|
+
import { retainOpenPanels } from "@/routing";
|
|
56
57
|
import { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
57
58
|
import {
|
|
58
59
|
type EscalatedSelection,
|
|
@@ -634,6 +635,7 @@ export const MessageList = ({
|
|
|
634
635
|
to: "/mail/$mailboxId/$threadId/$messageId",
|
|
635
636
|
params: { mailboxId, threadId, messageId },
|
|
636
637
|
search: (prev) => prev,
|
|
638
|
+
hash: retainOpenPanels,
|
|
637
639
|
replace: options?.replace,
|
|
638
640
|
});
|
|
639
641
|
},
|
|
@@ -1349,7 +1351,7 @@ export const MessageList = ({
|
|
|
1349
1351
|
accountId={accountId}
|
|
1350
1352
|
mailboxId={mailboxId}
|
|
1351
1353
|
selection={wizardSelection}
|
|
1352
|
-
|
|
1354
|
+
selectionRestriction={moveDisabledHint ? "spansAccounts" : undefined}
|
|
1353
1355
|
escalated={escalatedSelection}
|
|
1354
1356
|
escalatedProgress={escalation.progress}
|
|
1355
1357
|
onFinished={exitSelection}
|
|
@@ -63,6 +63,7 @@ import { isOutboxListRow, isUnsendableStatus } from "@/lib/outbox-status";
|
|
|
63
63
|
import { normalizeSearchQuery } from "@/lib/search-query";
|
|
64
64
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
65
65
|
import { cn } from "@/lib/utils";
|
|
66
|
+
import { retainOpenPanels } from "@/routing";
|
|
66
67
|
|
|
67
68
|
/* ------------------------------------------------------------------ */
|
|
68
69
|
/* Helpers (shared between List, Reading, Phone sub-views) */
|
|
@@ -218,13 +219,18 @@ function OutboxPaneProvider({
|
|
|
218
219
|
to: "/mail/outbox/draft/$outboxMessageId",
|
|
219
220
|
params: { outboxMessageId },
|
|
220
221
|
search: (prev) => prev,
|
|
222
|
+
hash: retainOpenPanels,
|
|
221
223
|
});
|
|
222
224
|
},
|
|
223
225
|
[navigate],
|
|
224
226
|
);
|
|
225
227
|
|
|
226
228
|
const handleCloseMessage = useCallback(() => {
|
|
227
|
-
navigate({
|
|
229
|
+
navigate({
|
|
230
|
+
to: "/mail/outbox",
|
|
231
|
+
search: (prev) => prev,
|
|
232
|
+
hash: retainOpenPanels,
|
|
233
|
+
});
|
|
228
234
|
}, [navigate]);
|
|
229
235
|
|
|
230
236
|
const ctx: OutboxPaneContextValue = {
|
|
@@ -2,8 +2,6 @@ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@t
|
|
|
2
2
|
import {
|
|
3
3
|
type ClauseDraft,
|
|
4
4
|
type ClauseEditState,
|
|
5
|
-
crossAccountDestinationReason,
|
|
6
|
-
crossAccountRuleReason,
|
|
7
5
|
derivePropertyClauses,
|
|
8
6
|
deriveSenderClauses,
|
|
9
7
|
dominantSender,
|
|
@@ -14,6 +12,7 @@ import {
|
|
|
14
12
|
type RuleClause,
|
|
15
13
|
type RunState,
|
|
16
14
|
type SearchConversion,
|
|
15
|
+
type SelectionRestriction,
|
|
17
16
|
SelectionWizard,
|
|
18
17
|
type StepId,
|
|
19
18
|
searchConversionNotice,
|
|
@@ -25,6 +24,7 @@ import {
|
|
|
25
24
|
type Verb,
|
|
26
25
|
type WizardDraft,
|
|
27
26
|
type WizardMessage,
|
|
27
|
+
wizardScopeFor,
|
|
28
28
|
} from "@remit/ui";
|
|
29
29
|
import { useQuery } from "@tanstack/react-query";
|
|
30
30
|
import { useBlocker } from "@tanstack/react-router";
|
|
@@ -125,8 +125,12 @@ export interface SelectionWizardHostProps {
|
|
|
125
125
|
/** Where the selection was made, so a run invalidates the listing it changed. */
|
|
126
126
|
mailboxId?: string;
|
|
127
127
|
selection: readonly WizardSelectionMessage[];
|
|
128
|
-
/**
|
|
129
|
-
|
|
128
|
+
/**
|
|
129
|
+
* Which scope the ticked rows span more of than the folder and rule steps can
|
|
130
|
+
* take. The surface that resolved the selection knows which of the two it is,
|
|
131
|
+
* and the wizard states that one (#525).
|
|
132
|
+
*/
|
|
133
|
+
selectionRestriction?: SelectionRestriction;
|
|
130
134
|
/** Present when the selection is a predicate rather than the ticked rows. */
|
|
131
135
|
escalated?: EscalatedSelection;
|
|
132
136
|
/**
|
|
@@ -257,7 +261,7 @@ function SelectionWizardSession({
|
|
|
257
261
|
accountId,
|
|
258
262
|
mailboxId,
|
|
259
263
|
selection,
|
|
260
|
-
|
|
264
|
+
selectionRestriction,
|
|
261
265
|
escalated: escalatedSelection,
|
|
262
266
|
escalatedProgress,
|
|
263
267
|
searchConversion,
|
|
@@ -451,18 +455,16 @@ function SelectionWizardSession({
|
|
|
451
455
|
|
|
452
456
|
// A filter, a folder, and the preview that counts a widened door all belong to
|
|
453
457
|
// one account, so a selection spanning accounts reaches none of them and is
|
|
454
|
-
// told so on the step that asks (#477 5.5, #523).
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
const
|
|
459
|
-
? undefined
|
|
460
|
-
: crossAccountDestinationReason;
|
|
458
|
+
// told so on the step that asks (#477 5.5, #523). A selection spanning folders
|
|
459
|
+
// of one account is told about folders instead, and keeps its account: the
|
|
460
|
+
// preview behind a widened door has something to count against. The one-off
|
|
461
|
+
// scope and the ticked rows act on the messages themselves and are unaffected.
|
|
462
|
+
const wizardScope = wizardScopeFor(accountId, selectionRestriction);
|
|
461
463
|
const restrictionFor = (step: StepId): string | undefined => {
|
|
462
|
-
if (step === "folder") return
|
|
464
|
+
if (step === "folder") return wizardScope.destination;
|
|
463
465
|
if (step !== "rule") return undefined;
|
|
464
466
|
return named.scope === "standing" || named.scope === "until"
|
|
465
|
-
?
|
|
467
|
+
? wizardScope.rule
|
|
466
468
|
: undefined;
|
|
467
469
|
};
|
|
468
470
|
const blockedReason =
|
|
@@ -940,7 +942,7 @@ function SelectionWizardSession({
|
|
|
940
942
|
match={{
|
|
941
943
|
selectedCount: selection.length,
|
|
942
944
|
mode,
|
|
943
|
-
accountId:
|
|
945
|
+
accountId: wizardScope.accountId,
|
|
944
946
|
onModeChange: changeMode,
|
|
945
947
|
semanticUnavailable,
|
|
946
948
|
semanticErrorDetail:
|
|
@@ -977,13 +979,13 @@ function SelectionWizardSession({
|
|
|
977
979
|
onSelect: (moveMailboxId) =>
|
|
978
980
|
setDraft((held) => ({ ...held, moveMailboxId })),
|
|
979
981
|
onCreateFolder: accountId ? createFolderIn : undefined,
|
|
980
|
-
restriction:
|
|
982
|
+
restriction: wizardScope.destination,
|
|
981
983
|
}}
|
|
982
984
|
rule={{
|
|
983
985
|
draft: named,
|
|
984
986
|
onScopeChange: (scope) => setDraft((held) => ({ ...held, scope })),
|
|
985
987
|
onUntilChange: (until) => setDraft((held) => ({ ...held, until })),
|
|
986
|
-
restriction:
|
|
988
|
+
restriction: wizardScope.rule,
|
|
987
989
|
}}
|
|
988
990
|
name={{
|
|
989
991
|
name: named.name ?? "",
|
|
@@ -1056,7 +1058,7 @@ export function SelectionWizardHost(props: SelectionWizardHostProps) {
|
|
|
1056
1058
|
accounts,
|
|
1057
1059
|
),
|
|
1058
1060
|
selection: EMPTY_SELECTION,
|
|
1059
|
-
|
|
1061
|
+
selectionRestriction: undefined,
|
|
1060
1062
|
escalated: undefined,
|
|
1061
1063
|
searchConversion: conversion,
|
|
1062
1064
|
}
|
|
@@ -10,6 +10,7 @@ import { useNavigate } from "@tanstack/react-router";
|
|
|
10
10
|
import { useCallback, useState } from "react";
|
|
11
11
|
import { toDisplayCategory } from "@/lib/display-category";
|
|
12
12
|
import { formatEmailDate } from "@/lib/format";
|
|
13
|
+
import { retainOpenPanels } from "@/routing";
|
|
13
14
|
import { MessageListItem } from "./MessageListItem";
|
|
14
15
|
import { useModifierSelect } from "./useModifierSelect";
|
|
15
16
|
|
|
@@ -105,6 +106,7 @@ export const SwipeableMessageRow = ({
|
|
|
105
106
|
messageId: thread.messageId,
|
|
106
107
|
},
|
|
107
108
|
search: (prev) => prev,
|
|
109
|
+
hash: retainOpenPanels,
|
|
108
110
|
});
|
|
109
111
|
}, [navigate, mailboxId, thread.threadId, thread.messageId]);
|
|
110
112
|
|
|
@@ -22,6 +22,8 @@ export const KeyboardShortcutsModal = ({
|
|
|
22
22
|
(event: KeyboardEvent) => {
|
|
23
23
|
if (event.key === "Escape") {
|
|
24
24
|
event.preventDefault();
|
|
25
|
+
event.stopPropagation();
|
|
26
|
+
event.stopImmediatePropagation();
|
|
25
27
|
onClose();
|
|
26
28
|
}
|
|
27
29
|
},
|
|
@@ -30,8 +32,11 @@ export const KeyboardShortcutsModal = ({
|
|
|
30
32
|
|
|
31
33
|
useEffect(() => {
|
|
32
34
|
if (!isOpen) return;
|
|
33
|
-
|
|
34
|
-
|
|
35
|
+
// Capture phase, as `ConfirmDialog` does: the sheet is the topmost surface,
|
|
36
|
+
// so Escape dismisses it and nothing else. Shared with the list's own
|
|
37
|
+
// Escape, the one keystroke also closed the conversation underneath.
|
|
38
|
+
window.addEventListener("keydown", handleKeyDown, true);
|
|
39
|
+
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
|
35
40
|
}, [isOpen, handleKeyDown]);
|
|
36
41
|
|
|
37
42
|
if (!isOpen) return null;
|
|
@@ -71,16 +71,24 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
|
|
|
71
71
|
...prev,
|
|
72
72
|
q: committedQuery || undefined,
|
|
73
73
|
});
|
|
74
|
+
// A query is a mode of the view the reader is already in, so the panels
|
|
75
|
+
// they have up are not ones they navigated away from.
|
|
74
76
|
if (!queryGoesActive) {
|
|
75
|
-
navigate({ to: ".", search, replace: true });
|
|
77
|
+
navigate({ to: ".", search, hash: true, replace: true });
|
|
76
78
|
return;
|
|
77
79
|
}
|
|
78
80
|
if (to === "/mail/$mailboxId") {
|
|
79
81
|
if (!mailboxId) return;
|
|
80
|
-
navigate({
|
|
82
|
+
navigate({
|
|
83
|
+
to,
|
|
84
|
+
params: { mailboxId },
|
|
85
|
+
search,
|
|
86
|
+
hash: true,
|
|
87
|
+
replace: true,
|
|
88
|
+
});
|
|
81
89
|
return;
|
|
82
90
|
}
|
|
83
|
-
navigate({ to, search, replace: true });
|
|
91
|
+
navigate({ to, search, hash: true, replace: true });
|
|
84
92
|
}, [
|
|
85
93
|
searchInput,
|
|
86
94
|
committedQuery,
|
|
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
3
|
import {
|
|
4
4
|
readIntelligencePref,
|
|
5
|
+
resolveRailOpen,
|
|
5
6
|
writeIntelligencePref,
|
|
6
7
|
} from "./intelligence-pref.js";
|
|
7
8
|
|
|
@@ -55,3 +56,79 @@ describe("intelligence-pref (#782)", () => {
|
|
|
55
56
|
assert.doesNotThrow(() => writeIntelligencePref(false));
|
|
56
57
|
});
|
|
57
58
|
});
|
|
59
|
+
|
|
60
|
+
describe("resolveRailOpen (#722)", () => {
|
|
61
|
+
const withThread = { hasThread: true, isDesktop: true };
|
|
62
|
+
|
|
63
|
+
it("opens the rail with the thread where the address says nothing", () => {
|
|
64
|
+
assert.equal(
|
|
65
|
+
resolveRailOpen({ ...withThread, panels: [], prefersOpen: true }),
|
|
66
|
+
true,
|
|
67
|
+
);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("leaves the rail down where the address says nothing and the reader collapsed it", () => {
|
|
71
|
+
assert.equal(
|
|
72
|
+
resolveRailOpen({ ...withThread, panels: [], prefersOpen: false }),
|
|
73
|
+
false,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("never seeds the rail below the tier that has one", () => {
|
|
78
|
+
assert.equal(
|
|
79
|
+
resolveRailOpen({
|
|
80
|
+
panels: [],
|
|
81
|
+
prefersOpen: true,
|
|
82
|
+
isDesktop: false,
|
|
83
|
+
hasThread: true,
|
|
84
|
+
}),
|
|
85
|
+
false,
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
// Otherwise the address claims a pane the shell has nothing to put in it.
|
|
90
|
+
it("never seeds the rail with no conversation open", () => {
|
|
91
|
+
assert.equal(
|
|
92
|
+
resolveRailOpen({
|
|
93
|
+
panels: [],
|
|
94
|
+
prefersOpen: true,
|
|
95
|
+
isDesktop: true,
|
|
96
|
+
hasThread: false,
|
|
97
|
+
}),
|
|
98
|
+
false,
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// A shared link naming another panel is an address that has spoken: the
|
|
103
|
+
// recipient's own preference does not get to add the rail to it.
|
|
104
|
+
it("hands a shared link's panels to the reader who opened it", () => {
|
|
105
|
+
assert.equal(
|
|
106
|
+
resolveRailOpen({
|
|
107
|
+
...withThread,
|
|
108
|
+
panels: ["shortcuts"],
|
|
109
|
+
prefersOpen: true,
|
|
110
|
+
}),
|
|
111
|
+
false,
|
|
112
|
+
);
|
|
113
|
+
assert.equal(
|
|
114
|
+
resolveRailOpen({
|
|
115
|
+
...withThread,
|
|
116
|
+
panels: ["intelligence", "shortcuts"],
|
|
117
|
+
prefersOpen: false,
|
|
118
|
+
}),
|
|
119
|
+
true,
|
|
120
|
+
);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("opens the rail from an address on any tier", () => {
|
|
124
|
+
assert.equal(
|
|
125
|
+
resolveRailOpen({
|
|
126
|
+
panels: ["intelligence"],
|
|
127
|
+
prefersOpen: false,
|
|
128
|
+
isDesktop: false,
|
|
129
|
+
hasThread: true,
|
|
130
|
+
}),
|
|
131
|
+
true,
|
|
132
|
+
);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Persistence for the intelligence-pane open/closed preference (#782)
|
|
2
|
+
* Persistence for the intelligence-pane open/closed preference (#782), and the
|
|
3
|
+
* precedence between it and the address (#722).
|
|
3
4
|
*
|
|
4
5
|
* The pane opens with the thread by default; a manual collapse sticks across
|
|
5
6
|
* sessions. Storage failures (private mode / quota) fall back to the default
|
|
6
7
|
* (open) rather than crashing.
|
|
7
8
|
*/
|
|
9
|
+
import type { PanelFragment } from "@/routing";
|
|
8
10
|
|
|
9
11
|
export const INTELLIGENCE_PREF_KEY = "remit:intelligence-open";
|
|
10
12
|
|
|
@@ -23,3 +25,33 @@ export function writeIntelligencePref(open: boolean): void {
|
|
|
23
25
|
// Storage unavailable — the in-memory default stands.
|
|
24
26
|
}
|
|
25
27
|
}
|
|
28
|
+
|
|
29
|
+
export interface RailVisibility {
|
|
30
|
+
/** The panels the address names. */
|
|
31
|
+
panels: readonly PanelFragment[];
|
|
32
|
+
prefersOpen: boolean;
|
|
33
|
+
/** The rail is a pane of the desktop shell; narrower tiers have a drawer. */
|
|
34
|
+
isDesktop: boolean;
|
|
35
|
+
/** The rail reads a conversation, so with none open there is nothing to be up. */
|
|
36
|
+
hasThread: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Whether the rail is up.
|
|
41
|
+
*
|
|
42
|
+
* An address that names any panel is the only owner of what is open, so a
|
|
43
|
+
* shared link showing the shortcuts sheet is not overwritten by the recipient's
|
|
44
|
+
* own preference. The preference speaks only where the address is silent, and
|
|
45
|
+
* only with a conversation open on the tier that has a rail — it opens with the
|
|
46
|
+
* thread there (#782), while a phone would get a full-screen drawer over a
|
|
47
|
+
* message nobody asked to cover.
|
|
48
|
+
*/
|
|
49
|
+
export function resolveRailOpen({
|
|
50
|
+
panels,
|
|
51
|
+
prefersOpen,
|
|
52
|
+
isDesktop,
|
|
53
|
+
hasThread,
|
|
54
|
+
}: RailVisibility): boolean {
|
|
55
|
+
if (panels.length > 0) return panels.includes("intelligence");
|
|
56
|
+
return isDesktop && hasThread && prefersOpen;
|
|
57
|
+
}
|
package/src/lib/mail-context.ts
CHANGED
|
@@ -47,13 +47,14 @@ export interface MailContextValue {
|
|
|
47
47
|
onSearchClear: () => void;
|
|
48
48
|
/** Query-only clear (Esc): drops the query, keeps the thread open (#489). */
|
|
49
49
|
onSearchClearQuery: () => void;
|
|
50
|
-
/**
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
/**
|
|
51
|
+
* Whether pane 4 (intelligence) is up. Resolved once by the `/mail` layout
|
|
52
|
+
* from the address and this device's preference (`resolveRailOpen`), and
|
|
53
|
+
* handed down as the answer — a consumer re-deriving it from the fragment
|
|
54
|
+
* alone would lose the preference and disagree with the shell.
|
|
55
|
+
*/
|
|
53
56
|
intelligenceOpen: boolean;
|
|
54
57
|
onToggleIntelligence: () => void;
|
|
55
|
-
/** Set the pane open/closed and persist the choice (desktop default-open). */
|
|
56
|
-
onSetIntelligenceOpen: (open: boolean) => void;
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
export const MailContext = createContext<MailContextValue | null>(null);
|
|
@@ -77,7 +78,6 @@ export const useMailContext = (): MailContextValue => {
|
|
|
77
78
|
onSearchClearQuery: () => {},
|
|
78
79
|
intelligenceOpen: false,
|
|
79
80
|
onToggleIntelligence: () => {},
|
|
80
|
-
onSetIntelligenceOpen: () => {},
|
|
81
81
|
}
|
|
82
82
|
);
|
|
83
83
|
};
|
package/src/routes/mail.tsx
CHANGED
|
@@ -27,13 +27,24 @@ import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
|
|
|
27
27
|
import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
|
|
28
28
|
import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
|
|
29
29
|
import { hostsComposeSurface } from "@/lib/compose-routes";
|
|
30
|
-
import {
|
|
30
|
+
import {
|
|
31
|
+
readIntelligencePref,
|
|
32
|
+
resolveRailOpen,
|
|
33
|
+
writeIntelligencePref,
|
|
34
|
+
} from "@/lib/intelligence-pref";
|
|
31
35
|
import { MailContext } from "@/lib/mail-context";
|
|
32
36
|
import { MailFreshnessProvider } from "@/lib/mail-freshness";
|
|
33
37
|
import { mailViewKey } from "@/lib/mail-route";
|
|
34
38
|
import { buildAccountNameIndex } from "@/lib/search-token-index";
|
|
35
39
|
import { committedSearchQuery, searchInputForView } from "@/lib/search-view";
|
|
36
40
|
import { wizardEntryValue, wizardStepValue } from "@/lib/wizard-history";
|
|
41
|
+
import {
|
|
42
|
+
isOverlayPanel,
|
|
43
|
+
type OverlayPanel,
|
|
44
|
+
useOpenPanels,
|
|
45
|
+
useOpenThreadPath,
|
|
46
|
+
useSetOpenPanels,
|
|
47
|
+
} from "@/routing";
|
|
37
48
|
import "@/lib/client";
|
|
38
49
|
|
|
39
50
|
// `MailContext` / `useMailContext` live in `@/lib/mail-context` so the provider
|
|
@@ -79,19 +90,56 @@ function MailLayout() {
|
|
|
79
90
|
// tablet with no compose surface (compose lives in the reading pane, which
|
|
80
91
|
// tablet doesn't mount) — the "c" shortcut / FAB opened nothing.
|
|
81
92
|
const isSinglePane = isSinglePaneTier(tier);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
//
|
|
85
|
-
// the
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
// The panels the address carries (#722): the intelligence rail, the nav
|
|
94
|
+
// slide-over and the shortcuts sheet. The rail is a pane and the other two
|
|
95
|
+
// cover it, so the address holds a pane and an overlay at once — a sheet
|
|
96
|
+
// opening never takes the rail down — while two overlays cannot both be up.
|
|
97
|
+
const openPanels = useOpenPanels();
|
|
98
|
+
const setOpenPanels = useSetOpenPanels();
|
|
99
|
+
const openOverlay = openPanels.find(isOverlayPanel);
|
|
100
|
+
const showShortcuts = openOverlay === "shortcuts";
|
|
101
|
+
const drawerOpen = openOverlay === "nav";
|
|
102
|
+
// Pane 4 on desktop, the details drawer below it. `resolveRailOpen` is the
|
|
103
|
+
// one place the address and the stored preference meet: the address decides
|
|
104
|
+
// whenever it says anything at all, and the preference opens the rail with
|
|
105
|
+
// the thread where it is silent (#782).
|
|
106
|
+
const openThread = useOpenThreadPath();
|
|
107
|
+
// Held in state, not read back from storage each render: closing the rail
|
|
108
|
+
// where the address is silent changes nothing about the address, and the
|
|
109
|
+
// answer has to move anyway.
|
|
110
|
+
const [prefersRail, setPrefersRail] = useState(readIntelligencePref);
|
|
111
|
+
const intelligenceOpen = resolveRailOpen({
|
|
112
|
+
panels: openPanels,
|
|
113
|
+
prefersOpen: prefersRail,
|
|
114
|
+
isDesktop: tier === "desktop",
|
|
115
|
+
hasThread: openThread !== undefined,
|
|
116
|
+
});
|
|
117
|
+
// Every write states the whole set, because it is composed from what is
|
|
118
|
+
// showing rather than from what the address happens to spell: the rail open
|
|
119
|
+
// by preference alone is still open, and an overlay must not close it.
|
|
120
|
+
const showPanels = useCallback(
|
|
121
|
+
(rail: boolean, overlay: OverlayPanel | undefined) => {
|
|
122
|
+
setOpenPanels([
|
|
123
|
+
...(rail ? (["intelligence"] as const) : []),
|
|
124
|
+
...(overlay ? [overlay] : []),
|
|
125
|
+
]);
|
|
126
|
+
},
|
|
127
|
+
[setOpenPanels],
|
|
128
|
+
);
|
|
129
|
+
const handleSetIntelligenceOpen = useCallback(
|
|
130
|
+
(open: boolean) => {
|
|
131
|
+
writeIntelligencePref(open);
|
|
132
|
+
setPrefersRail(open);
|
|
133
|
+
showPanels(open, openOverlay);
|
|
134
|
+
},
|
|
135
|
+
[openOverlay, showPanels],
|
|
136
|
+
);
|
|
137
|
+
const showOverlay = useCallback(
|
|
138
|
+
(overlay: OverlayPanel | undefined) => {
|
|
139
|
+
showPanels(intelligenceOpen, overlay);
|
|
140
|
+
},
|
|
141
|
+
[intelligenceOpen, showPanels],
|
|
142
|
+
);
|
|
95
143
|
|
|
96
144
|
// Within one view, URL `q` seeds the input and is a one-directional write
|
|
97
145
|
// target: the debounced local value drives the search API and is mirrored
|
|
@@ -157,7 +205,7 @@ function MailLayout() {
|
|
|
157
205
|
bindings: [
|
|
158
206
|
{
|
|
159
207
|
key: "?",
|
|
160
|
-
handler: () =>
|
|
208
|
+
handler: () => showOverlay("shortcuts"),
|
|
161
209
|
noModifiers: false, // Allow shift+/
|
|
162
210
|
preventDefault: true,
|
|
163
211
|
},
|
|
@@ -193,12 +241,8 @@ function MailLayout() {
|
|
|
193
241
|
}, []);
|
|
194
242
|
|
|
195
243
|
const handleToggleIntelligence = useCallback(() => {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
writeIntelligencePref(next);
|
|
199
|
-
return next;
|
|
200
|
-
});
|
|
201
|
-
}, []);
|
|
244
|
+
handleSetIntelligenceOpen(!intelligenceOpen);
|
|
245
|
+
}, [handleSetIntelligenceOpen, intelligenceOpen]);
|
|
202
246
|
|
|
203
247
|
const accounts = config?.accounts ?? [];
|
|
204
248
|
const accountIds = useMemo(
|
|
@@ -218,11 +262,12 @@ function MailLayout() {
|
|
|
218
262
|
useStaleAccountSync(accounts);
|
|
219
263
|
|
|
220
264
|
const handleMailboxSelect = useCallback(() => {
|
|
221
|
-
// Auto-collapse the mobile drawer after the user picks an inbox
|
|
222
|
-
//
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
265
|
+
// Auto-collapse the mobile drawer after the user picks an inbox from the
|
|
266
|
+
// sidebar (#199). Above the slide-over the sidebar is a pane, and there is
|
|
267
|
+
// no overlay of its own to take down.
|
|
268
|
+
if (openOverlay !== "nav") return;
|
|
269
|
+
showOverlay(undefined);
|
|
270
|
+
}, [openOverlay, showOverlay]);
|
|
226
271
|
|
|
227
272
|
const mailContextValue = {
|
|
228
273
|
accounts,
|
|
@@ -239,7 +284,6 @@ function MailLayout() {
|
|
|
239
284
|
onSearchClearQuery: handleSearchClearQuery,
|
|
240
285
|
intelligenceOpen,
|
|
241
286
|
onToggleIntelligence: handleToggleIntelligence,
|
|
242
|
-
onSetIntelligenceOpen: handleSetIntelligenceOpen,
|
|
243
287
|
};
|
|
244
288
|
|
|
245
289
|
// Single nav node: the kit renders it as a pane (≥1024px) or inside its
|
|
@@ -259,8 +303,8 @@ function MailLayout() {
|
|
|
259
303
|
// top bar owns compose.
|
|
260
304
|
overlay: isSinglePane ? <ComposeFab /> : undefined,
|
|
261
305
|
navOpen: drawerOpen,
|
|
262
|
-
onOpenNav: () =>
|
|
263
|
-
onCloseNav: () =>
|
|
306
|
+
onOpenNav: () => showOverlay("nav"),
|
|
307
|
+
onCloseNav: () => showOverlay(undefined),
|
|
264
308
|
};
|
|
265
309
|
|
|
266
310
|
return (
|
|
@@ -284,7 +328,7 @@ function MailLayout() {
|
|
|
284
328
|
)}
|
|
285
329
|
<KeyboardShortcutsModal
|
|
286
330
|
isOpen={showShortcuts}
|
|
287
|
-
onClose={() =>
|
|
331
|
+
onClose={() => showOverlay(undefined)}
|
|
288
332
|
/>
|
|
289
333
|
</MailFreshnessProvider>
|
|
290
334
|
</MailContext.Provider>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
formatOpenPanels,
|
|
5
|
+
panelFragments,
|
|
6
|
+
parseOpenPanels,
|
|
7
|
+
retainOpenPanels,
|
|
8
|
+
} from "./fragment.js";
|
|
9
|
+
|
|
10
|
+
describe("parseOpenPanels", () => {
|
|
11
|
+
it("reads every panel the union names", () => {
|
|
12
|
+
for (const panel of panelFragments) {
|
|
13
|
+
assert.deepEqual(parseOpenPanels(panel), [panel]);
|
|
14
|
+
}
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("reads a pane and an overlay together", () => {
|
|
18
|
+
assert.deepEqual(parseOpenPanels("intelligence,shortcuts"), [
|
|
19
|
+
"intelligence",
|
|
20
|
+
"shortcuts",
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("spells one set of panels one way", () => {
|
|
25
|
+
assert.deepEqual(parseOpenPanels("shortcuts,intelligence,shortcuts"), [
|
|
26
|
+
"intelligence",
|
|
27
|
+
"shortcuts",
|
|
28
|
+
]);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("reads an empty, unknown or mis-cased fragment as no panel", () => {
|
|
32
|
+
assert.deepEqual(parseOpenPanels(""), []);
|
|
33
|
+
assert.deepEqual(parseOpenPanels("confirm-delete"), []);
|
|
34
|
+
assert.deepEqual(parseOpenPanels("Intelligence"), []);
|
|
35
|
+
assert.deepEqual(parseOpenPanels("form:input:to"), []);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("keeps the panels it recognises out of a fragment carrying junk", () => {
|
|
39
|
+
assert.deepEqual(parseOpenPanels("filters,intelligence"), ["intelligence"]);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe("formatOpenPanels", () => {
|
|
44
|
+
it("writes the address a set of panels is read back from", () => {
|
|
45
|
+
assert.equal(
|
|
46
|
+
formatOpenPanels(["shortcuts", "intelligence"]),
|
|
47
|
+
"intelligence,shortcuts",
|
|
48
|
+
);
|
|
49
|
+
assert.equal(formatOpenPanels([]), "");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
describe("retainOpenPanels", () => {
|
|
54
|
+
it("carries a pane across a navigation", () => {
|
|
55
|
+
assert.equal(retainOpenPanels("intelligence"), "intelligence");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("leaves the overlays behind, and keeps the pane under them", () => {
|
|
59
|
+
assert.equal(retainOpenPanels("nav"), "");
|
|
60
|
+
assert.equal(retainOpenPanels("shortcuts"), "");
|
|
61
|
+
assert.equal(retainOpenPanels("intelligence,shortcuts"), "intelligence");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("drops a fragment it does not recognise rather than passing it on", () => {
|
|
65
|
+
assert.equal(retainOpenPanels(), "");
|
|
66
|
+
assert.equal(retainOpenPanels("confirm-delete"), "");
|
|
67
|
+
});
|
|
68
|
+
});
|
package/src/routing/fragment.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { useLocation } from "@tanstack/react-router";
|
|
1
|
+
import { useLocation, useNavigate } from "@tanstack/react-router";
|
|
2
|
+
import { useCallback, useMemo } from "react";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -7,29 +8,91 @@ import { z } from "zod";
|
|
|
7
8
|
* independent of in-memory data, so a cold load of the URL reproduces it
|
|
8
9
|
* exactly.
|
|
9
10
|
*/
|
|
10
|
-
export const panelFragments = [
|
|
11
|
-
"intelligence",
|
|
12
|
-
"nav",
|
|
13
|
-
"shortcuts",
|
|
14
|
-
"filters",
|
|
15
|
-
] as const;
|
|
11
|
+
export const panelFragments = ["intelligence", "nav", "shortcuts"] as const;
|
|
16
12
|
|
|
17
13
|
export type PanelFragment = (typeof panelFragments)[number];
|
|
18
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The panels that cover the view rather than sit in it. The rail is a pane in
|
|
17
|
+
* the shell's row and the other two are modal over it, so a sheet opening can
|
|
18
|
+
* never take the rail down with it — the address holds the pane and the overlay
|
|
19
|
+
* at once. Two overlays cannot be up together, because the second one hides the
|
|
20
|
+
* first.
|
|
21
|
+
*/
|
|
22
|
+
export const overlayPanels = ["nav", "shortcuts"] as const;
|
|
23
|
+
|
|
24
|
+
export type OverlayPanel = (typeof overlayPanels)[number];
|
|
25
|
+
|
|
26
|
+
export const isOverlayPanel = (panel: PanelFragment): panel is OverlayPanel =>
|
|
27
|
+
(overlayPanels as readonly PanelFragment[]).includes(panel);
|
|
28
|
+
|
|
19
29
|
const panelFragmentSchema = z.enum(panelFragments);
|
|
20
30
|
|
|
21
31
|
/**
|
|
22
|
-
*
|
|
23
|
-
*
|
|
32
|
+
* The panels the address asks for, in the union's own order so one set of open
|
|
33
|
+
* panels has one spelling. A hand-edited or stale URL is a normal thing to
|
|
34
|
+
* receive, so an unrecognised name is dropped instead of throwing.
|
|
24
35
|
*/
|
|
25
|
-
export function
|
|
26
|
-
const
|
|
27
|
-
|
|
36
|
+
export function parseOpenPanels(hash: string): readonly PanelFragment[] {
|
|
37
|
+
const named = new Set(
|
|
38
|
+
hash
|
|
39
|
+
.split(",")
|
|
40
|
+
.map((token) => panelFragmentSchema.safeParse(token))
|
|
41
|
+
.filter((parsed) => parsed.success)
|
|
42
|
+
.map((parsed) => parsed.data),
|
|
43
|
+
);
|
|
44
|
+
return panelFragments.filter((panel) => named.has(panel));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The address a set of open panels is written as. */
|
|
48
|
+
export function formatOpenPanels(panels: readonly PanelFragment[]): string {
|
|
49
|
+
return panelFragments.filter((panel) => panels.includes(panel)).join(",");
|
|
28
50
|
}
|
|
29
51
|
|
|
30
|
-
/** The
|
|
31
|
-
export function
|
|
32
|
-
|
|
33
|
-
select: (location) =>
|
|
52
|
+
/** The panels the address currently asks for. */
|
|
53
|
+
export function useOpenPanels(): readonly PanelFragment[] {
|
|
54
|
+
const hash = useLocation({
|
|
55
|
+
select: (location) => formatOpenPanels(parseOpenPanels(location.hash)),
|
|
34
56
|
});
|
|
57
|
+
return useMemo(() => parseOpenPanels(hash), [hash]);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A destination's `hash`, for a navigation that keeps the reader where they
|
|
62
|
+
* are. The router drops the fragment on every navigation unless the destination
|
|
63
|
+
* asks for it, so this is what a link or a `navigate` call passes to state the
|
|
64
|
+
* rule rather than restate it: a pane is chrome a reader keeps up while they
|
|
65
|
+
* move from one conversation to the next, and an overlay is dismissed by going
|
|
66
|
+
* somewhere, which is what closing it means.
|
|
67
|
+
*
|
|
68
|
+
* The parameter is optional because the router hands a `hash` updater the
|
|
69
|
+
* previous fragment as `string | undefined`.
|
|
70
|
+
*/
|
|
71
|
+
export function retainOpenPanels(hash = ""): string {
|
|
72
|
+
return formatOpenPanels(
|
|
73
|
+
parseOpenPanels(hash).filter((panel) => !isOverlayPanel(panel)),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Puts a set of panels up, replacing whatever was up before.
|
|
79
|
+
*
|
|
80
|
+
* `replace`, because a panel is chrome over the current view rather than a place
|
|
81
|
+
* to go: Back belongs to the message the reader came from, not to the rail they
|
|
82
|
+
* just collapsed. The fragment names panels and never an element, so it is not
|
|
83
|
+
* an anchor to scroll to either.
|
|
84
|
+
*/
|
|
85
|
+
export function useSetOpenPanels(): (panels: readonly PanelFragment[]) => void {
|
|
86
|
+
const navigate = useNavigate();
|
|
87
|
+
return useCallback(
|
|
88
|
+
(panels: readonly PanelFragment[]) => {
|
|
89
|
+
navigate({
|
|
90
|
+
search: true,
|
|
91
|
+
hash: formatOpenPanels(panels),
|
|
92
|
+
replace: true,
|
|
93
|
+
hashScrollIntoView: false,
|
|
94
|
+
});
|
|
95
|
+
},
|
|
96
|
+
[navigate],
|
|
97
|
+
);
|
|
35
98
|
}
|
package/src/routing/index.ts
CHANGED
|
@@ -1,8 +1,14 @@
|
|
|
1
1
|
export {
|
|
2
|
+
formatOpenPanels,
|
|
3
|
+
isOverlayPanel,
|
|
4
|
+
type OverlayPanel,
|
|
5
|
+
overlayPanels,
|
|
2
6
|
type PanelFragment,
|
|
3
7
|
panelFragments,
|
|
4
|
-
|
|
5
|
-
|
|
8
|
+
parseOpenPanels,
|
|
9
|
+
retainOpenPanels,
|
|
10
|
+
useOpenPanels,
|
|
11
|
+
useSetOpenPanels,
|
|
6
12
|
} from "./fragment";
|
|
7
13
|
export { NavLink, type NavLinkProps } from "./nav-link";
|
|
8
14
|
export {
|
package/src/routing/nav-link.tsx
CHANGED
|
@@ -7,7 +7,7 @@ import type {
|
|
|
7
7
|
} from "@tanstack/react-router";
|
|
8
8
|
import { createLink } from "@tanstack/react-router";
|
|
9
9
|
import type { ReactElement } from "react";
|
|
10
|
-
import type
|
|
10
|
+
import { type PanelFragment, retainOpenPanels } from "./fragment";
|
|
11
11
|
|
|
12
12
|
const RouterNavLink = createLink(NavLinkSurface);
|
|
13
13
|
|
|
@@ -42,6 +42,10 @@ export type NavLinkProps<
|
|
|
42
42
|
* The application's only navigation link. `createLink` keeps the router's `to` /
|
|
43
43
|
* `params` / `search` inference, so a route that does not exist or a param set
|
|
44
44
|
* that does not match it fails to compile instead of rendering a blank pane.
|
|
45
|
+
*
|
|
46
|
+
* A link that names no panel carries the open panes across: the rail is up
|
|
47
|
+
* until the reader puts it down, and every row in a list is a link. Overlays
|
|
48
|
+
* are left behind, because going somewhere is what dismisses one.
|
|
45
49
|
*/
|
|
46
50
|
export function NavLink<
|
|
47
51
|
TRouter extends AnyRouter = RegisteredRouter,
|
|
@@ -54,5 +58,5 @@ export function NavLink({
|
|
|
54
58
|
fragment,
|
|
55
59
|
...props
|
|
56
60
|
}: CreateLinkProps & { fragment?: PanelFragment }): ReactElement {
|
|
57
|
-
return <RouterNavLink {...props} hash={fragment} />;
|
|
61
|
+
return <RouterNavLink {...props} hash={fragment ?? retainOpenPanels} />;
|
|
58
62
|
}
|