@remit/web-client 0.0.165 → 0.0.166
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 +6 -5
- package/src/components/mail/DraftsView.tsx +3 -2
- package/src/components/mail/FlaggedPane.tsx +6 -5
- package/src/components/mail/MailboxPane.tsx +9 -7
- package/src/components/mail/MessageActionMenu.tsx +4 -3
- package/src/components/mail/MessageList.tsx +4 -3
- package/src/components/mail/OutboxPane.tsx +6 -5
- package/src/components/mail/SwipeableMessageRow.tsx +4 -3
- package/src/components/mail/intelligence-drawer.stories.tsx +208 -0
- package/src/routing/fragment.render.test.ts +98 -0
- package/src/routing/fragment.test.ts +54 -9
- package/src/routing/fragment.ts +37 -9
- package/src/routing/index.ts +2 -1
- package/src/routing/nav-link.tsx +5 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.166",
|
|
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": {
|
|
@@ -44,7 +44,7 @@ import { useMailContext } from "@/lib/mail-context";
|
|
|
44
44
|
import {
|
|
45
45
|
type OpenThreadPath,
|
|
46
46
|
type OpenThreadTarget,
|
|
47
|
-
|
|
47
|
+
useRetainOpenPanels,
|
|
48
48
|
} from "@/routing";
|
|
49
49
|
|
|
50
50
|
/* ------------------------------------------------------------------ */
|
|
@@ -102,6 +102,7 @@ interface BriefPaneProps {
|
|
|
102
102
|
|
|
103
103
|
function BriefPaneProvider({ thread, children }: BriefPaneProps) {
|
|
104
104
|
const navigate = useNavigate();
|
|
105
|
+
const retainPanels = useRetainOpenPanels();
|
|
105
106
|
const { searchInput } = useMailContext();
|
|
106
107
|
const threadId = thread?.threadId;
|
|
107
108
|
const pointedAtMessageId = thread?.messageId;
|
|
@@ -152,19 +153,19 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
|
|
|
152
153
|
// `searchInput`: a row can be tapped before the debounce settles, when
|
|
153
154
|
// the committed query is still empty.
|
|
154
155
|
search: (prev) => ({ ...prev, q: searchInput || undefined }),
|
|
155
|
-
hash:
|
|
156
|
+
hash: retainPanels,
|
|
156
157
|
});
|
|
157
158
|
},
|
|
158
|
-
[navigate, searchInput],
|
|
159
|
+
[navigate, retainPanels, searchInput],
|
|
159
160
|
);
|
|
160
161
|
|
|
161
162
|
const handleCloseThread = useCallback(() => {
|
|
162
163
|
navigate({
|
|
163
164
|
to: "/mail/brief",
|
|
164
165
|
search: (prev) => prev,
|
|
165
|
-
hash:
|
|
166
|
+
hash: retainPanels,
|
|
166
167
|
});
|
|
167
|
-
}, [navigate]);
|
|
168
|
+
}, [navigate, retainPanels]);
|
|
168
169
|
|
|
169
170
|
const handleDeselectIfRemoved = useCallback(
|
|
170
171
|
(removedIds: string[]) => {
|
|
@@ -41,7 +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 {
|
|
44
|
+
import { useRetainOpenPanels } from "@/routing";
|
|
45
45
|
import { NavMenuButton } from "./NavMenuButton";
|
|
46
46
|
|
|
47
47
|
// ---------------------------------------------------------------------------
|
|
@@ -156,6 +156,7 @@ export function DraftsView({
|
|
|
156
156
|
}: DraftsViewProps) {
|
|
157
157
|
const { openCompose, state: composeState } = useCompose();
|
|
158
158
|
const navigate = useNavigate();
|
|
159
|
+
const retainPanels = useRetainOpenPanels();
|
|
159
160
|
const queryClient = useQueryClient();
|
|
160
161
|
|
|
161
162
|
// Fetch the full outbox list — both sources are already fetched by the
|
|
@@ -198,7 +199,7 @@ export function DraftsView({
|
|
|
198
199
|
to: "/mail/$mailboxId/$threadId/$messageId",
|
|
199
200
|
params: { mailboxId, threadId, messageId },
|
|
200
201
|
search: (prev) => prev,
|
|
201
|
-
hash:
|
|
202
|
+
hash: retainPanels,
|
|
202
203
|
});
|
|
203
204
|
};
|
|
204
205
|
|
|
@@ -55,7 +55,7 @@ import { useMailContext } from "@/lib/mail-context";
|
|
|
55
55
|
import {
|
|
56
56
|
type OpenThreadPath,
|
|
57
57
|
type OpenThreadTarget,
|
|
58
|
-
|
|
58
|
+
useRetainOpenPanels,
|
|
59
59
|
} from "@/routing";
|
|
60
60
|
|
|
61
61
|
/* ------------------------------------------------------------------ */
|
|
@@ -113,6 +113,7 @@ interface FlaggedPaneProps {
|
|
|
113
113
|
|
|
114
114
|
function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
|
|
115
115
|
const navigate = useNavigate();
|
|
116
|
+
const retainPanels = useRetainOpenPanels();
|
|
116
117
|
const { searchInput } = useMailContext();
|
|
117
118
|
const threadId = thread?.threadId;
|
|
118
119
|
const pointedAtMessageId = thread?.messageId;
|
|
@@ -158,19 +159,19 @@ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
|
|
|
158
159
|
// `searchInput`: a row can be tapped before the debounce settles, when
|
|
159
160
|
// the committed query is still empty.
|
|
160
161
|
search: (prev) => ({ ...prev, q: searchInput || undefined }),
|
|
161
|
-
hash:
|
|
162
|
+
hash: retainPanels,
|
|
162
163
|
});
|
|
163
164
|
},
|
|
164
|
-
[navigate, searchInput],
|
|
165
|
+
[navigate, retainPanels, searchInput],
|
|
165
166
|
);
|
|
166
167
|
|
|
167
168
|
const handleCloseThread = useCallback(() => {
|
|
168
169
|
navigate({
|
|
169
170
|
to: "/mail/flagged",
|
|
170
171
|
search: (prev) => prev,
|
|
171
|
-
hash:
|
|
172
|
+
hash: retainPanels,
|
|
172
173
|
});
|
|
173
|
-
}, [navigate]);
|
|
174
|
+
}, [navigate, retainPanels]);
|
|
174
175
|
|
|
175
176
|
const handleDeselectIfRemoved = useCallback(
|
|
176
177
|
(removedIds: string[]) => {
|
|
@@ -127,7 +127,7 @@ import {
|
|
|
127
127
|
import {
|
|
128
128
|
type OpenThreadPath,
|
|
129
129
|
type OpenThreadTarget,
|
|
130
|
-
|
|
130
|
+
useRetainOpenPanels,
|
|
131
131
|
} from "@/routing";
|
|
132
132
|
import { MailViewChrome } from "./MailViewChrome";
|
|
133
133
|
|
|
@@ -264,6 +264,7 @@ function MailboxPaneProvider({
|
|
|
264
264
|
children,
|
|
265
265
|
}: MailboxPaneProps) {
|
|
266
266
|
const navigate = useNavigate();
|
|
267
|
+
const retainPanels = useRetainOpenPanels();
|
|
267
268
|
const threadId = thread?.threadId;
|
|
268
269
|
const pointedAtMessageId = thread?.messageId;
|
|
269
270
|
const telemetry = useTelemetry();
|
|
@@ -467,9 +468,9 @@ function MailboxPaneProvider({
|
|
|
467
468
|
to: "/mail/$mailboxId",
|
|
468
469
|
params: { mailboxId },
|
|
469
470
|
search: (prev) => prev,
|
|
470
|
-
hash:
|
|
471
|
+
hash: retainPanels,
|
|
471
472
|
});
|
|
472
|
-
}, [mailboxId, navigate]);
|
|
473
|
+
}, [mailboxId, navigate, retainPanels]);
|
|
473
474
|
|
|
474
475
|
const handleOpenThread = useCallback(
|
|
475
476
|
(target: OpenThreadTarget) => {
|
|
@@ -477,10 +478,10 @@ function MailboxPaneProvider({
|
|
|
477
478
|
to: "/mail/$mailboxId/$threadId/$messageId",
|
|
478
479
|
params: { mailboxId, ...target },
|
|
479
480
|
search: (prev) => prev,
|
|
480
|
-
hash:
|
|
481
|
+
hash: retainPanels,
|
|
481
482
|
});
|
|
482
483
|
},
|
|
483
|
-
[mailboxId, navigate],
|
|
484
|
+
[mailboxId, navigate, retainPanels],
|
|
484
485
|
);
|
|
485
486
|
|
|
486
487
|
const handleDeselectIfRemoved = useCallback(
|
|
@@ -942,6 +943,7 @@ function MailboxList() {
|
|
|
942
943
|
useMailContext();
|
|
943
944
|
const tier = useLayoutTier();
|
|
944
945
|
const navigate = useNavigate();
|
|
946
|
+
const retainPanels = useRetainOpenPanels();
|
|
945
947
|
|
|
946
948
|
const listTitle = mailboxName ?? "Inbox";
|
|
947
949
|
const preset = useMemo(() => inboxFilterConfig(), []);
|
|
@@ -1029,10 +1031,10 @@ function MailboxList() {
|
|
|
1029
1031
|
// `searchInput`: a row can be tapped before the debounce settles, when
|
|
1030
1032
|
// the committed query is still empty.
|
|
1031
1033
|
search: (prev) => ({ ...prev, q: searchInput || undefined }),
|
|
1032
|
-
hash:
|
|
1034
|
+
hash: retainPanels,
|
|
1033
1035
|
});
|
|
1034
1036
|
},
|
|
1035
|
-
[mailboxId, navigate, searchInput, threads],
|
|
1037
|
+
[mailboxId, navigate, retainPanels, searchInput, threads],
|
|
1036
1038
|
);
|
|
1037
1039
|
|
|
1038
1040
|
// Drafts keep their own dedicated view (and header); they don't carry the
|
|
@@ -35,7 +35,7 @@ import {
|
|
|
35
35
|
type ThreadListSnapshotEntry,
|
|
36
36
|
threadListCacheKeys,
|
|
37
37
|
} from "@/lib/thread-list-cache";
|
|
38
|
-
import {
|
|
38
|
+
import { useOpenThreadPath, useRetainOpenPanels } from "@/routing";
|
|
39
39
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
40
40
|
|
|
41
41
|
interface ThreadMessagesData {
|
|
@@ -103,6 +103,7 @@ export const MessageActionMenu = ({
|
|
|
103
103
|
}: MessageActionMenuProps) => {
|
|
104
104
|
const queryClient = useQueryClient();
|
|
105
105
|
const navigate = useNavigate();
|
|
106
|
+
const retainPanels = useRetainOpenPanels();
|
|
106
107
|
const { pushError } = useErrorBanners();
|
|
107
108
|
const selectedMessageId = useOpenThreadPath()?.messageId;
|
|
108
109
|
|
|
@@ -193,10 +194,10 @@ export const MessageActionMenu = ({
|
|
|
193
194
|
to: "/mail/$mailboxId",
|
|
194
195
|
params: { mailboxId },
|
|
195
196
|
search: (prev) => prev,
|
|
196
|
-
hash:
|
|
197
|
+
hash: retainPanels,
|
|
197
198
|
});
|
|
198
199
|
},
|
|
199
|
-
[selectedMessageId, mailboxId, navigate],
|
|
200
|
+
[selectedMessageId, mailboxId, navigate, retainPanels],
|
|
200
201
|
);
|
|
201
202
|
|
|
202
203
|
const { deleteMessages, isPending: isDeleting } = useDeleteMessages({
|
|
@@ -53,7 +53,7 @@ import { useListHeaderChrome } from "@/lib/list-header-chrome";
|
|
|
53
53
|
import { listVerbRequest } from "@/lib/list-verb-request";
|
|
54
54
|
import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
|
|
55
55
|
import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
|
|
56
|
-
import {
|
|
56
|
+
import { useRetainOpenPanels } from "@/routing";
|
|
57
57
|
import { LabelApplyTrigger } from "./LabelApplyTrigger";
|
|
58
58
|
import {
|
|
59
59
|
type EscalatedSelection,
|
|
@@ -234,6 +234,7 @@ export const MessageList = ({
|
|
|
234
234
|
}: MessageListProps) => {
|
|
235
235
|
const parentRef = useRef<HTMLDivElement>(null);
|
|
236
236
|
const navigate = useNavigate();
|
|
237
|
+
const retainPanels = useRetainOpenPanels();
|
|
237
238
|
const isDesktop = useIsDesktop();
|
|
238
239
|
const wizard = useSelectionWizard();
|
|
239
240
|
const { verb: wizardVerb, start: startWizard, startFromSearch } = wizard;
|
|
@@ -635,11 +636,11 @@ export const MessageList = ({
|
|
|
635
636
|
to: "/mail/$mailboxId/$threadId/$messageId",
|
|
636
637
|
params: { mailboxId, threadId, messageId },
|
|
637
638
|
search: (prev) => prev,
|
|
638
|
-
hash:
|
|
639
|
+
hash: retainPanels,
|
|
639
640
|
replace: options?.replace,
|
|
640
641
|
});
|
|
641
642
|
},
|
|
642
|
-
[navigate, mailboxId, threads],
|
|
643
|
+
[navigate, retainPanels, mailboxId, threads],
|
|
643
644
|
);
|
|
644
645
|
|
|
645
646
|
// Enter: open the focused row in the reading pane. This is the focus→open
|
|
@@ -63,7 +63,7 @@ import {
|
|
|
63
63
|
import { isOutboxListRow, isUnsendableStatus } from "@/lib/outbox-status";
|
|
64
64
|
import { normalizeSearchQuery } from "@/lib/search-query";
|
|
65
65
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
66
|
-
import {
|
|
66
|
+
import { useRetainOpenPanels } from "@/routing";
|
|
67
67
|
|
|
68
68
|
/* ------------------------------------------------------------------ */
|
|
69
69
|
/* Helpers (shared between List, Reading, Phone sub-views) */
|
|
@@ -174,6 +174,7 @@ function OutboxPaneProvider({
|
|
|
174
174
|
children,
|
|
175
175
|
}: OutboxPaneProps) {
|
|
176
176
|
const navigate = useNavigate();
|
|
177
|
+
const retainPanels = useRetainOpenPanels();
|
|
177
178
|
|
|
178
179
|
const { data: outboxResponse, isLoading } = useQuery(
|
|
179
180
|
outboxOperationsListOutboxMessagesOptions(),
|
|
@@ -219,19 +220,19 @@ function OutboxPaneProvider({
|
|
|
219
220
|
to: "/mail/outbox/draft/$outboxMessageId",
|
|
220
221
|
params: { outboxMessageId },
|
|
221
222
|
search: (prev) => prev,
|
|
222
|
-
hash:
|
|
223
|
+
hash: retainPanels,
|
|
223
224
|
});
|
|
224
225
|
},
|
|
225
|
-
[navigate],
|
|
226
|
+
[navigate, retainPanels],
|
|
226
227
|
);
|
|
227
228
|
|
|
228
229
|
const handleCloseMessage = useCallback(() => {
|
|
229
230
|
navigate({
|
|
230
231
|
to: "/mail/outbox",
|
|
231
232
|
search: (prev) => prev,
|
|
232
|
-
hash:
|
|
233
|
+
hash: retainPanels,
|
|
233
234
|
});
|
|
234
|
-
}, [navigate]);
|
|
235
|
+
}, [navigate, retainPanels]);
|
|
235
236
|
|
|
236
237
|
const ctx: OutboxPaneContextValue = {
|
|
237
238
|
messages,
|
|
@@ -10,7 +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 {
|
|
13
|
+
import { useRetainOpenPanels } from "@/routing";
|
|
14
14
|
import { MessageListItem } from "./MessageListItem";
|
|
15
15
|
import { useModifierSelect } from "./useModifierSelect";
|
|
16
16
|
|
|
@@ -75,6 +75,7 @@ export const SwipeableMessageRow = ({
|
|
|
75
75
|
}: SwipeableMessageRowProps) => {
|
|
76
76
|
const [peek, setPeek] = useState<SwipePeek>("none");
|
|
77
77
|
const navigate = useNavigate();
|
|
78
|
+
const retainPanels = useRetainOpenPanels();
|
|
78
79
|
|
|
79
80
|
const handleAct = useCallback(
|
|
80
81
|
(side: "leading" | "trailing") => {
|
|
@@ -106,9 +107,9 @@ export const SwipeableMessageRow = ({
|
|
|
106
107
|
messageId: thread.messageId,
|
|
107
108
|
},
|
|
108
109
|
search: (prev) => prev,
|
|
109
|
-
hash:
|
|
110
|
+
hash: retainPanels,
|
|
110
111
|
});
|
|
111
|
-
}, [navigate, mailboxId, thread.threadId, thread.messageId]);
|
|
112
|
+
}, [navigate, retainPanels, mailboxId, thread.threadId, thread.messageId]);
|
|
112
113
|
|
|
113
114
|
const modifierSelect = useModifierSelect(thread.messageId, onRowSelect);
|
|
114
115
|
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { type IntelligenceData, IntelligencePanel } from "@remit/ui";
|
|
2
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
3
|
+
import { ChevronLeft, Info } from "lucide-react";
|
|
4
|
+
import { useState } from "react";
|
|
5
|
+
import { expect, userEvent, within } from "storybook/test";
|
|
6
|
+
import { Drawer } from "@/components/layout/Drawer";
|
|
7
|
+
import { resolveRailOpen } from "@/lib/intelligence-pref";
|
|
8
|
+
import { parseOpenPanels, retainOpenPanelsAtTier } from "@/routing";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The intelligence drawer below the desktop tier (#777).
|
|
12
|
+
*
|
|
13
|
+
* There is no fourth pane here: the rail is a full-screen drawer over the open
|
|
14
|
+
* message, so it belongs to the thread it was opened for. The address is the
|
|
15
|
+
* only place its open state lives, and a navigation drops it — otherwise a DKIM
|
|
16
|
+
* mismatch on one message leaves the drawer covering every message opened
|
|
17
|
+
* after it.
|
|
18
|
+
*
|
|
19
|
+
* Both rules are the app's own: the fragment goes through
|
|
20
|
+
* `retainOpenPanelsAtTier`, and what is up is `resolveRailOpen`.
|
|
21
|
+
*/
|
|
22
|
+
const meta: Meta = {
|
|
23
|
+
title: "Flows/Reading/Intelligence Drawer",
|
|
24
|
+
parameters: { layout: "centered" },
|
|
25
|
+
};
|
|
26
|
+
export default meta;
|
|
27
|
+
|
|
28
|
+
type Story = StoryObj;
|
|
29
|
+
|
|
30
|
+
const PHONE_WIDTH = 390;
|
|
31
|
+
const PHONE_HEIGHT = 720;
|
|
32
|
+
|
|
33
|
+
const retainOnPhone = retainOpenPanelsAtTier(false);
|
|
34
|
+
|
|
35
|
+
const intelligence: IntelligenceData = {
|
|
36
|
+
sender: {
|
|
37
|
+
name: "Mondial Relay",
|
|
38
|
+
email: "delivery.notice.4421@gmail.example",
|
|
39
|
+
trust: "unknown",
|
|
40
|
+
firstSeenLabel: "today",
|
|
41
|
+
inboundCount: 1,
|
|
42
|
+
replyCount: 0,
|
|
43
|
+
},
|
|
44
|
+
authenticity: {
|
|
45
|
+
verdict: "mismatch",
|
|
46
|
+
fromDomain: "mondialrelay.fr",
|
|
47
|
+
dkimDomain: "gmail.example",
|
|
48
|
+
claimedBrand: "Mondial Relay",
|
|
49
|
+
summary:
|
|
50
|
+
"The display name claims “Mondial Relay”, but the message was sent and signed by a personal gmail.example mailbox — not mondialrelay.fr.",
|
|
51
|
+
similarCount: 15,
|
|
52
|
+
},
|
|
53
|
+
category: { value: "automated" },
|
|
54
|
+
similar: [],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
interface Message {
|
|
58
|
+
id: string;
|
|
59
|
+
from: string;
|
|
60
|
+
subject: string;
|
|
61
|
+
body: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const messages: readonly Message[] = [
|
|
65
|
+
{
|
|
66
|
+
id: "msg-parcel",
|
|
67
|
+
from: "Mondial Relay",
|
|
68
|
+
subject: "Your parcel could not be delivered",
|
|
69
|
+
body: "We attempted to deliver your parcel today and nobody was home. Pay the outstanding €2.40 redelivery fee within 24 hours.",
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
id: "msg-standup",
|
|
73
|
+
from: "Alex Rivera",
|
|
74
|
+
subject: "Standup moved to 10:15",
|
|
75
|
+
body: "Pushing today's standup back fifteen minutes — the room is taken until then.",
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The phone reader, driven by the address alone: `hash` is the whole state,
|
|
81
|
+
* and every navigation writes it through the same helper the panes use.
|
|
82
|
+
*/
|
|
83
|
+
const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
|
|
84
|
+
const [hash, setHash] = useState(initialHash);
|
|
85
|
+
const [openId, setOpenId] = useState<string | undefined>(messages[0]?.id);
|
|
86
|
+
const open = messages.find((message) => message.id === openId);
|
|
87
|
+
const drawerOpen = resolveRailOpen({
|
|
88
|
+
panels: parseOpenPanels(hash),
|
|
89
|
+
prefersOpen: true,
|
|
90
|
+
isDesktop: false,
|
|
91
|
+
hasThread: open !== undefined,
|
|
92
|
+
});
|
|
93
|
+
const navigate = (nextId: string | undefined) => {
|
|
94
|
+
setHash(retainOnPhone(hash));
|
|
95
|
+
setOpenId(nextId);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
return (
|
|
99
|
+
<div className="flex flex-col gap-2">
|
|
100
|
+
<div
|
|
101
|
+
className="flex flex-col overflow-hidden rounded-lg border border-line bg-canvas"
|
|
102
|
+
// The transform makes the frame a containing block, so the drawer's
|
|
103
|
+
// `position: fixed` resolves against the phone instead of the canvas.
|
|
104
|
+
style={{
|
|
105
|
+
width: PHONE_WIDTH,
|
|
106
|
+
height: PHONE_HEIGHT,
|
|
107
|
+
transform: "translateZ(0)",
|
|
108
|
+
}}
|
|
109
|
+
>
|
|
110
|
+
{open ? (
|
|
111
|
+
<>
|
|
112
|
+
<header className="flex items-center gap-1 border-b border-line px-2 py-2">
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
aria-label="Back"
|
|
116
|
+
onClick={() => navigate(undefined)}
|
|
117
|
+
className="inline-flex size-11 items-center justify-center rounded-md text-fg hover:bg-surface-raised"
|
|
118
|
+
>
|
|
119
|
+
<ChevronLeft className="size-5" />
|
|
120
|
+
</button>
|
|
121
|
+
<h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
|
|
122
|
+
{open.subject}
|
|
123
|
+
</h1>
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
aria-label="Message details"
|
|
127
|
+
onClick={() => setHash("intelligence")}
|
|
128
|
+
className="inline-flex size-11 items-center justify-center rounded-md text-fg hover:bg-surface-raised"
|
|
129
|
+
>
|
|
130
|
+
<Info className="size-5" />
|
|
131
|
+
</button>
|
|
132
|
+
</header>
|
|
133
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-4 py-3 text-sm text-fg">
|
|
134
|
+
<p className="mb-2 text-2xs text-fg-subtle">{open.from}</p>
|
|
135
|
+
<p>{open.body}</p>
|
|
136
|
+
</div>
|
|
137
|
+
</>
|
|
138
|
+
) : (
|
|
139
|
+
<ul className="flex flex-col">
|
|
140
|
+
{messages.map((message) => (
|
|
141
|
+
<li key={message.id}>
|
|
142
|
+
<button
|
|
143
|
+
type="button"
|
|
144
|
+
onClick={() => navigate(message.id)}
|
|
145
|
+
className="flex w-full flex-col items-start gap-0.5 border-b border-line px-4 py-3 text-left hover:bg-surface-raised"
|
|
146
|
+
>
|
|
147
|
+
<span className="text-sm font-semibold text-fg">
|
|
148
|
+
{message.from}
|
|
149
|
+
</span>
|
|
150
|
+
<span className="text-sm text-fg-muted">
|
|
151
|
+
{message.subject}
|
|
152
|
+
</span>
|
|
153
|
+
</button>
|
|
154
|
+
</li>
|
|
155
|
+
))}
|
|
156
|
+
</ul>
|
|
157
|
+
)}
|
|
158
|
+
<Drawer
|
|
159
|
+
isOpen={drawerOpen}
|
|
160
|
+
onClose={() => setHash("")}
|
|
161
|
+
ariaLabel="Message details"
|
|
162
|
+
side="right"
|
|
163
|
+
>
|
|
164
|
+
<IntelligencePanel data={intelligence} hideCloseButton />
|
|
165
|
+
</Drawer>
|
|
166
|
+
</div>
|
|
167
|
+
<p className="font-mono text-2xs text-fg-subtle">
|
|
168
|
+
{`/mail/inbox${open ? `/${open.id}` : ""}${hash ? `#${hash}` : ""}`}
|
|
169
|
+
</p>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** A message with no drawer over it: the address names no panel. */
|
|
175
|
+
export const Closed: Story = {
|
|
176
|
+
render: () => <PhoneReader />,
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The drawer over the message it was opened for — here by a DKIM mismatch,
|
|
181
|
+
* which opens it without being asked.
|
|
182
|
+
*/
|
|
183
|
+
export const OpenOverThread: Story = {
|
|
184
|
+
render: () => <PhoneReader initialHash="intelligence" />,
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The same drawer, then Back and another message. The fragment is dropped on
|
|
189
|
+
* the way out, so the second message is not covered by a panel opened for the
|
|
190
|
+
* first. Press Back with the drawer up to walk it.
|
|
191
|
+
*/
|
|
192
|
+
export const DroppedByNavigation: Story = {
|
|
193
|
+
render: () => <PhoneReader initialHash="intelligence" />,
|
|
194
|
+
play: async ({ canvasElement }) => {
|
|
195
|
+
const canvas = within(canvasElement);
|
|
196
|
+
await userEvent.click(canvas.getByLabelText("Back"));
|
|
197
|
+
await userEvent.click(canvas.getByText("Standup moved to 10:15"));
|
|
198
|
+
await expect(canvas.queryByRole("dialog")).toBeNull();
|
|
199
|
+
await expect(canvas.getByText("/mail/inbox/msg-standup")).toBeVisible();
|
|
200
|
+
},
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/** The drawer over the dark theme. */
|
|
204
|
+
export const OpenOverThreadDark: Story = {
|
|
205
|
+
name: "Open Over Thread (dark)",
|
|
206
|
+
parameters: { theme: "dark" },
|
|
207
|
+
render: () => <PhoneReader initialHash="intelligence" />,
|
|
208
|
+
};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useRetainOpenPanels` reads the tier off the app's own desktop breakpoint
|
|
3
|
+
* (#777), so a navigation below it lands without the rail in the address —
|
|
4
|
+
* which is what keeps the drawer with the thread it was opened for.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { afterEach, describe, it } from "node:test";
|
|
9
|
+
import {
|
|
10
|
+
type AnyRouter,
|
|
11
|
+
createMemoryHistory,
|
|
12
|
+
createRootRoute,
|
|
13
|
+
createRoute,
|
|
14
|
+
createRouter,
|
|
15
|
+
RouterProvider,
|
|
16
|
+
useNavigate,
|
|
17
|
+
} from "@tanstack/react-router";
|
|
18
|
+
import { createElement } from "react";
|
|
19
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
20
|
+
import { useRetainOpenPanels } from "./fragment";
|
|
21
|
+
|
|
22
|
+
const PHONE_WIDTH = 390;
|
|
23
|
+
const DESKTOP_WIDTH = 1440;
|
|
24
|
+
|
|
25
|
+
let harness: DomHarness | undefined;
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
harness?.close();
|
|
29
|
+
harness = undefined;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
33
|
+
// `window`.
|
|
34
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
35
|
+
|
|
36
|
+
function CloseThread() {
|
|
37
|
+
const navigate = useNavigate();
|
|
38
|
+
const retainPanels = useRetainOpenPanels();
|
|
39
|
+
return createElement(
|
|
40
|
+
"button",
|
|
41
|
+
{
|
|
42
|
+
type: "button",
|
|
43
|
+
onClick: () => {
|
|
44
|
+
void navigate({ to: "/mail", hash: retainPanels });
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
"Back",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const rootRoute = createRootRoute();
|
|
52
|
+
const listRoute = createRoute({
|
|
53
|
+
getParentRoute: () => rootRoute,
|
|
54
|
+
path: "/mail",
|
|
55
|
+
});
|
|
56
|
+
const threadRoute = createRoute({
|
|
57
|
+
getParentRoute: () => rootRoute,
|
|
58
|
+
path: "/mail/thread",
|
|
59
|
+
component: CloseThread,
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const openDrawerOverThread = (): AnyRouter =>
|
|
63
|
+
createRouter({
|
|
64
|
+
routeTree: rootRoute.addChildren([listRoute, threadRoute]),
|
|
65
|
+
history: createMemoryHistory({
|
|
66
|
+
initialEntries: ["/mail/thread#intelligence"],
|
|
67
|
+
}),
|
|
68
|
+
}) as unknown as AnyRouter;
|
|
69
|
+
|
|
70
|
+
const walkUpToTheList = async (viewportWidth: number): Promise<AnyRouter> => {
|
|
71
|
+
const router = openDrawerOverThread();
|
|
72
|
+
harness = createDomHarness({ viewportWidth });
|
|
73
|
+
// Resolve the first match before mounting: `RouterProvider` renders its
|
|
74
|
+
// pending state until the router has loaded, and nothing here waits for it.
|
|
75
|
+
await router.load();
|
|
76
|
+
harness.render(createElement(RouterProvider, { router }));
|
|
77
|
+
await harness.flush();
|
|
78
|
+
harness.click(harness.byText("button", "Back"));
|
|
79
|
+
await harness.flush();
|
|
80
|
+
await harness.wait(20);
|
|
81
|
+
return router;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
describe("useRetainOpenPanels (#777)", () => {
|
|
85
|
+
it("leaves the drawer behind on a phone", async () => {
|
|
86
|
+
const router = await walkUpToTheList(PHONE_WIDTH);
|
|
87
|
+
|
|
88
|
+
assert.equal(router.state.location.pathname, "/mail");
|
|
89
|
+
assert.equal(router.state.location.hash, "");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("carries the rail across on desktop, where it is a pane", async () => {
|
|
93
|
+
const router = await walkUpToTheList(DESKTOP_WIDTH);
|
|
94
|
+
|
|
95
|
+
assert.equal(router.state.location.pathname, "/mail");
|
|
96
|
+
assert.equal(router.state.location.hash, "intelligence");
|
|
97
|
+
});
|
|
98
|
+
});
|
|
@@ -4,9 +4,12 @@ import {
|
|
|
4
4
|
formatOpenPanels,
|
|
5
5
|
panelFragments,
|
|
6
6
|
parseOpenPanels,
|
|
7
|
-
|
|
7
|
+
retainOpenPanelsAtTier,
|
|
8
8
|
} from "./fragment.js";
|
|
9
9
|
|
|
10
|
+
const retainOnDesktop = retainOpenPanelsAtTier(true);
|
|
11
|
+
const retainOnPhone = retainOpenPanelsAtTier(false);
|
|
12
|
+
|
|
10
13
|
describe("parseOpenPanels", () => {
|
|
11
14
|
it("reads every panel the union names", () => {
|
|
12
15
|
for (const panel of panelFragments) {
|
|
@@ -50,19 +53,61 @@ describe("formatOpenPanels", () => {
|
|
|
50
53
|
});
|
|
51
54
|
});
|
|
52
55
|
|
|
53
|
-
describe("
|
|
54
|
-
it("carries a pane across a navigation", () => {
|
|
55
|
-
assert.equal(
|
|
56
|
+
describe("retainOpenPanelsAtTier", () => {
|
|
57
|
+
it("carries a pane across a navigation on the tier that has one", () => {
|
|
58
|
+
assert.equal(retainOnDesktop("intelligence"), "intelligence");
|
|
56
59
|
});
|
|
57
60
|
|
|
58
61
|
it("leaves the overlays behind, and keeps the pane under them", () => {
|
|
59
|
-
assert.equal(
|
|
60
|
-
assert.equal(
|
|
61
|
-
assert.equal(
|
|
62
|
+
assert.equal(retainOnDesktop("nav"), "");
|
|
63
|
+
assert.equal(retainOnDesktop("shortcuts"), "");
|
|
64
|
+
assert.equal(retainOnDesktop("intelligence,shortcuts"), "intelligence");
|
|
62
65
|
});
|
|
63
66
|
|
|
64
67
|
it("drops a fragment it does not recognise rather than passing it on", () => {
|
|
65
|
-
assert.equal(
|
|
66
|
-
assert.equal(
|
|
68
|
+
assert.equal(retainOnDesktop(), "");
|
|
69
|
+
assert.equal(retainOnDesktop("confirm-delete"), "");
|
|
70
|
+
assert.equal(retainOnPhone("confirm-delete"), "");
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Below desktop the rail is a full-screen drawer over the open message, so it
|
|
76
|
+
* belongs to the thread it was opened for. Every navigation that handed the
|
|
77
|
+
* fragment on put the drawer over the next message the reader opened.
|
|
78
|
+
*
|
|
79
|
+
* Each case walks the fragment through the navigations of one reachable path,
|
|
80
|
+
* starting from the address the open drawer wrote.
|
|
81
|
+
*/
|
|
82
|
+
describe("the drawer stays with its thread below desktop (#777)", () => {
|
|
83
|
+
const walk = (
|
|
84
|
+
retain: (hash?: string) => string,
|
|
85
|
+
hash: string,
|
|
86
|
+
steps: number,
|
|
87
|
+
): string => {
|
|
88
|
+
let current = hash;
|
|
89
|
+
for (let step = 0; step < steps; step += 1) current = retain(current);
|
|
90
|
+
return current;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
// A DKIM mismatch opens the drawer; the reader taps the back arrow instead
|
|
94
|
+
// of closing it, then taps another row.
|
|
95
|
+
it("drops the rail between the back arrow and the next row", () => {
|
|
96
|
+
assert.equal(walk(retainOnPhone, "intelligence", 2), "");
|
|
97
|
+
assert.equal(walk(retainOnDesktop, "intelligence", 2), "intelligence");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// "Report spam" inside the open drawer removes the thread the drawer is
|
|
101
|
+
// reading, which closes it up to the list — one navigation, no back button.
|
|
102
|
+
it("drops the rail when a quick action in the drawer removes the thread", () => {
|
|
103
|
+
assert.equal(retainOnPhone("intelligence"), "");
|
|
104
|
+
assert.equal(retainOnDesktop("intelligence"), "intelligence");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// The brief and the flagged list open a thread from a cross-mailbox row,
|
|
108
|
+
// and the shortcuts sheet may be up over the drawer when they do.
|
|
109
|
+
it("drops the rail when the brief or the flagged list opens a thread", () => {
|
|
110
|
+
assert.equal(retainOnPhone("intelligence,shortcuts"), "");
|
|
111
|
+
assert.equal(retainOnDesktop("intelligence,shortcuts"), "intelligence");
|
|
67
112
|
});
|
|
68
113
|
});
|
package/src/routing/fragment.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useLocation, useNavigate } from "@tanstack/react-router";
|
|
2
2
|
import { useCallback, useMemo } from "react";
|
|
3
3
|
import { z } from "zod";
|
|
4
|
+
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* The fragment tier carries panel visibility and nothing else — no identity, no
|
|
@@ -57,21 +58,48 @@ export function useOpenPanels(): readonly PanelFragment[] {
|
|
|
57
58
|
return useMemo(() => parseOpenPanels(hash), [hash]);
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Whether a navigation carries this panel at this tier.
|
|
63
|
+
*
|
|
64
|
+
* A pane is chrome a reader keeps up while they move from one conversation to
|
|
65
|
+
* the next; an overlay is dismissed by going somewhere, which is what closing
|
|
66
|
+
* it means. Below desktop the rail is not a pane at all — it is a full-screen
|
|
67
|
+
* drawer over the open message — so it belongs to the thread it was opened for
|
|
68
|
+
* and a navigation leaves it behind, rather than covering a message nobody
|
|
69
|
+
* asked to cover (#777).
|
|
70
|
+
*/
|
|
71
|
+
const isRetainedPanel = (panel: PanelFragment, isDesktop: boolean): boolean =>
|
|
72
|
+
!isOverlayPanel(panel) && isDesktop;
|
|
73
|
+
|
|
60
74
|
/**
|
|
61
75
|
* A destination's `hash`, for a navigation that keeps the reader where they
|
|
62
76
|
* are. The router drops the fragment on every navigation unless the destination
|
|
63
77
|
* asks for it, so this is what a link or a `navigate` call passes to state the
|
|
64
|
-
* rule rather than restate it
|
|
65
|
-
* move from one conversation to the next, and an overlay is dismissed by going
|
|
66
|
-
* somewhere, which is what closing it means.
|
|
78
|
+
* rule rather than restate it.
|
|
67
79
|
*
|
|
68
|
-
* The
|
|
69
|
-
* previous fragment
|
|
80
|
+
* The tier is bound here rather than passed at the call site, so the updater
|
|
81
|
+
* still has the shape the router hands a previous fragment to. That parameter
|
|
82
|
+
* is optional because the router types it `string | undefined`.
|
|
70
83
|
*/
|
|
71
|
-
export function
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
)
|
|
84
|
+
export function retainOpenPanelsAtTier(
|
|
85
|
+
isDesktop: boolean,
|
|
86
|
+
): (hash?: string) => string {
|
|
87
|
+
return (hash = "") =>
|
|
88
|
+
formatOpenPanels(
|
|
89
|
+
parseOpenPanels(hash).filter((panel) =>
|
|
90
|
+
isRetainedPanel(panel, isDesktop),
|
|
91
|
+
),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The `hash` updater for a navigation from the surface the reader is on. The
|
|
97
|
+
* layout tier is read here, once, so no call site decides for itself what a
|
|
98
|
+
* navigation keeps.
|
|
99
|
+
*/
|
|
100
|
+
export function useRetainOpenPanels(): (hash?: string) => string {
|
|
101
|
+
const isDesktop = useIsDesktop();
|
|
102
|
+
return useMemo(() => retainOpenPanelsAtTier(isDesktop), [isDesktop]);
|
|
75
103
|
}
|
|
76
104
|
|
|
77
105
|
/**
|
package/src/routing/index.ts
CHANGED
|
@@ -6,8 +6,9 @@ export {
|
|
|
6
6
|
type PanelFragment,
|
|
7
7
|
panelFragments,
|
|
8
8
|
parseOpenPanels,
|
|
9
|
-
|
|
9
|
+
retainOpenPanelsAtTier,
|
|
10
10
|
useOpenPanels,
|
|
11
|
+
useRetainOpenPanels,
|
|
11
12
|
useSetOpenPanels,
|
|
12
13
|
} from "./fragment";
|
|
13
14
|
export { NavLink, type NavLinkProps } from "./nav-link";
|
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 PanelFragment,
|
|
10
|
+
import { type PanelFragment, useRetainOpenPanels } from "./fragment";
|
|
11
11
|
|
|
12
12
|
const RouterNavLink = createLink(NavLinkSurface);
|
|
13
13
|
|
|
@@ -45,7 +45,8 @@ export type NavLinkProps<
|
|
|
45
45
|
*
|
|
46
46
|
* A link that names no panel carries the open panes across: the rail is up
|
|
47
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
|
|
48
|
+
* are left behind, because going somewhere is what dismisses one — and below
|
|
49
|
+
* desktop so is the rail, which is a drawer over the message there.
|
|
49
50
|
*/
|
|
50
51
|
export function NavLink<
|
|
51
52
|
TRouter extends AnyRouter = RegisteredRouter,
|
|
@@ -58,5 +59,6 @@ export function NavLink({
|
|
|
58
59
|
fragment,
|
|
59
60
|
...props
|
|
60
61
|
}: CreateLinkProps & { fragment?: PanelFragment }): ReactElement {
|
|
61
|
-
|
|
62
|
+
const retainPanels = useRetainOpenPanels();
|
|
63
|
+
return <RouterNavLink {...props} hash={fragment ?? retainPanels} />;
|
|
62
64
|
}
|