@remit/web-client 0.0.187 → 0.0.188
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/MailboxPane.tsx +35 -9
- package/src/components/mail/intelligence-shortcut-surface.render.test.ts +343 -0
- package/src/components/mail/intelligence-surface-reach.render.test.ts +9 -32
- package/src/hooks/useIntelligenceSurface.ts +41 -7
- package/src/test-support/intelligence-surface.ts +42 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.188",
|
|
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": {
|
|
@@ -72,7 +72,11 @@ import {
|
|
|
72
72
|
import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
|
|
73
73
|
import { useIntelligenceData } from "@/hooks/useIntelligenceData";
|
|
74
74
|
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
75
|
-
import {
|
|
75
|
+
import {
|
|
76
|
+
type IntelligenceCommands,
|
|
77
|
+
useIntelligenceSurface,
|
|
78
|
+
usePublishIntelligenceCommands,
|
|
79
|
+
} from "@/hooks/useIntelligenceSurface";
|
|
76
80
|
import { useLayoutTier } from "@/hooks/useLayoutTier";
|
|
77
81
|
import { useMailboxAccount } from "@/hooks/useMailboxAccount";
|
|
78
82
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
@@ -168,8 +172,14 @@ interface MailboxPaneContextValue {
|
|
|
168
172
|
* when no category is selected.
|
|
169
173
|
*/
|
|
170
174
|
listFilter: MessageListFilter | undefined;
|
|
171
|
-
intelligenceOpen: boolean;
|
|
172
175
|
onToggleIntelligence: () => void;
|
|
176
|
+
/**
|
|
177
|
+
* Where the mounted reading surface publishes the intelligence commands the
|
|
178
|
+
* keyboard layer drives. The rail's width gate is the shell's own
|
|
179
|
+
* measurement and this provider sits above the shell, so the surface that is
|
|
180
|
+
* mounted answers for its own tier rather than this one guessing.
|
|
181
|
+
*/
|
|
182
|
+
intelligenceRef: RefObject<IntelligenceCommands | null>;
|
|
173
183
|
/**
|
|
174
184
|
* Deselects the open message when it's the one a mutation just removed
|
|
175
185
|
* from this mailbox's list — wired into every mutation that can take the
|
|
@@ -260,8 +270,7 @@ function MailboxPaneProvider({
|
|
|
260
270
|
const threadId = thread?.threadId;
|
|
261
271
|
const pointedAtMessageId = thread?.messageId;
|
|
262
272
|
const telemetry = useTelemetry();
|
|
263
|
-
const { accounts, searchQuery,
|
|
264
|
-
useMailContext();
|
|
273
|
+
const { accounts, searchQuery, onToggleIntelligence } = useMailContext();
|
|
265
274
|
const tokenContext = useSearchTokenContext();
|
|
266
275
|
|
|
267
276
|
const normalizedSearchQuery = normalizeSearchQuery(searchQuery);
|
|
@@ -499,6 +508,8 @@ function MailboxPaneProvider({
|
|
|
499
508
|
onAfterOptimisticRemove: handleDeselectIfRemoved,
|
|
500
509
|
});
|
|
501
510
|
|
|
511
|
+
const intelligenceRef = useRef<IntelligenceCommands | null>(null);
|
|
512
|
+
|
|
502
513
|
const triage = useTriageContext();
|
|
503
514
|
const {
|
|
504
515
|
listCommandsRef,
|
|
@@ -691,9 +702,14 @@ function MailboxPaneProvider({
|
|
|
691
702
|
updateFocusedSenderFlags({ vip: { value: next } });
|
|
692
703
|
}, [focusedAddressId, focusedAddress, updateFocusedSenderFlags]);
|
|
693
704
|
|
|
705
|
+
// Block sender lives in intelligence, so the key raises whichever surface
|
|
706
|
+
// this width has: the rail above 1280, the drawer below it. Reaching for
|
|
707
|
+
// `onToggleIntelligence` from here wrote `#intelligence` at every tier, and
|
|
708
|
+
// a panel the address names with no renderer behind it is a panel that opens
|
|
709
|
+
// nothing (`docs/architecture/url-state.md`, R6).
|
|
694
710
|
const triageBlock = useCallback(() => {
|
|
695
|
-
|
|
696
|
-
}, [
|
|
711
|
+
intelligenceRef.current?.open();
|
|
712
|
+
}, []);
|
|
697
713
|
|
|
698
714
|
const goToRoute = useCallback(
|
|
699
715
|
(to: "/mail/brief" | "/mail/flagged" | "/settings") => {
|
|
@@ -736,10 +752,12 @@ function MailboxPaneProvider({
|
|
|
736
752
|
toggleStar: triageStar,
|
|
737
753
|
toggleRead: triageToggleRead,
|
|
738
754
|
muteSender: triageMute,
|
|
739
|
-
blockSender: triageBlock,
|
|
755
|
+
blockSender: selectedThread ? triageBlock : undefined,
|
|
740
756
|
vipSender: triageVip,
|
|
741
757
|
markJunk: triageMarkJunk,
|
|
742
|
-
toggleIntelligence: selectedThread
|
|
758
|
+
toggleIntelligence: selectedThread
|
|
759
|
+
? () => intelligenceRef.current?.toggle()
|
|
760
|
+
: undefined,
|
|
743
761
|
compose: openCompose,
|
|
744
762
|
goBrief: () => goToRoute("/mail/brief"),
|
|
745
763
|
goInbox: () => goToRoute("/mail/brief"),
|
|
@@ -783,8 +801,8 @@ function MailboxPaneProvider({
|
|
|
783
801
|
onToggleFilterAttribute,
|
|
784
802
|
onClearFilters,
|
|
785
803
|
listFilter,
|
|
786
|
-
intelligenceOpen,
|
|
787
804
|
onToggleIntelligence,
|
|
805
|
+
intelligenceRef,
|
|
788
806
|
handleDeselectIfRemoved,
|
|
789
807
|
// Escalation ("select all N matching") re-issues this predicate server-side
|
|
790
808
|
// and acts on every match, so it is only offered when the predicate IS the
|
|
@@ -1064,9 +1082,11 @@ function MailboxReading() {
|
|
|
1064
1082
|
onToolbarStar,
|
|
1065
1083
|
onToolbarMove,
|
|
1066
1084
|
handleDeselectIfRemoved,
|
|
1085
|
+
intelligenceRef,
|
|
1067
1086
|
} = useMailboxPane();
|
|
1068
1087
|
const hasThread = Boolean(conversation);
|
|
1069
1088
|
const intelligence = useIntelligenceSurface(conversation?.threadId);
|
|
1089
|
+
usePublishIntelligenceCommands(intelligenceRef, intelligence);
|
|
1070
1090
|
|
|
1071
1091
|
// The rail opens itself on a DKIM mismatch. It lives here, behind the rail's
|
|
1072
1092
|
// own width gate, because raising it writes `#intelligence` into the address
|
|
@@ -1182,8 +1202,14 @@ function MailboxPhone() {
|
|
|
1182
1202
|
nextThread,
|
|
1183
1203
|
previousThread,
|
|
1184
1204
|
handleDeselectIfRemoved,
|
|
1205
|
+
intelligenceRef,
|
|
1185
1206
|
} = useMailboxPane();
|
|
1207
|
+
// The drawer directly, not `useIntelligenceSurface`: this view is what the
|
|
1208
|
+
// shell mounts where it has one pane, so there is no rail to choose between
|
|
1209
|
+
// — and the rail's own width gate answers yes on a wide portrait tablet the
|
|
1210
|
+
// shell still put here.
|
|
1186
1211
|
const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
|
|
1212
|
+
usePublishIntelligenceCommands(intelligenceRef, drawer);
|
|
1187
1213
|
|
|
1188
1214
|
if (conversation) {
|
|
1189
1215
|
return (
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mailbox's intelligence keys act on the surface the width has (#840).
|
|
3
|
+
*
|
|
4
|
+
* `i` and `b` are registered by the pane provider, which sits above the shell
|
|
5
|
+
* and so cannot see the rail's own width gate. Both reached for the rail's
|
|
6
|
+
* toggle directly, and below 1280 that wrote `#intelligence` into the address
|
|
7
|
+
* with no rail mounted to render it — a panel the address names and nothing
|
|
8
|
+
* answers (`docs/architecture/url-state.md`, R6). The drawer is the surface at
|
|
9
|
+
* those widths, and it is what the toolbar's own control already used.
|
|
10
|
+
*
|
|
11
|
+
* Mounted in the real `AppShellSlotted` at all three tiers, with the `/mail`
|
|
12
|
+
* layout's binding between the fragment and the rail: which panes a width has
|
|
13
|
+
* is the shell's own answer here, and what the address holds is the router's.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { afterEach, describe, it } from "node:test";
|
|
18
|
+
import { AppShellSlotted } from "@remit/ui";
|
|
19
|
+
import {
|
|
20
|
+
type AnyRouter,
|
|
21
|
+
createMemoryHistory,
|
|
22
|
+
createRootRoute,
|
|
23
|
+
createRoute,
|
|
24
|
+
createRouter,
|
|
25
|
+
Outlet,
|
|
26
|
+
RouterProvider,
|
|
27
|
+
} from "@tanstack/react-router";
|
|
28
|
+
import { createElement, type ReactNode, useCallback } from "react";
|
|
29
|
+
import { ComposeProvider } from "@/components/compose/ComposeProvider";
|
|
30
|
+
import { MailContext, type MailContextValue } from "@/lib/mail-context";
|
|
31
|
+
import { EMPTY_RESULT_FOLDER_INDEX } from "@/lib/result-folder";
|
|
32
|
+
import { useOpenPanels, useOpenThreadPath, useSetOpenPanels } from "@/routing";
|
|
33
|
+
import {
|
|
34
|
+
createDomHarness,
|
|
35
|
+
type DomHarness,
|
|
36
|
+
type DomOptions,
|
|
37
|
+
} from "@/test-support/dom";
|
|
38
|
+
import { makeThreadMessage } from "@/test-support/fixtures";
|
|
39
|
+
import { type HttpMock, mockFetch } from "@/test-support/http";
|
|
40
|
+
import {
|
|
41
|
+
describedMessage,
|
|
42
|
+
intelligenceDrawer,
|
|
43
|
+
MESSAGE_ID,
|
|
44
|
+
settle,
|
|
45
|
+
THREAD_ID,
|
|
46
|
+
} from "@/test-support/intelligence-surface";
|
|
47
|
+
import { MailboxPane } from "./MailboxPane";
|
|
48
|
+
|
|
49
|
+
/** Below the rail's 1280px gate, above the reading pane's 1024px one. */
|
|
50
|
+
const TWO_PANE_WIDTH = 1100;
|
|
51
|
+
/** Wide enough for the rail, which is where the fragment raises it. */
|
|
52
|
+
const RAIL_WIDTH = 1400;
|
|
53
|
+
/** One pane, where the list route mounts its phone view instead of the slots. */
|
|
54
|
+
const PHONE_WIDTH = 420;
|
|
55
|
+
|
|
56
|
+
const MAILBOX_ID = "mailbox-1";
|
|
57
|
+
const MESSAGE_PATH = `/mail/${MAILBOX_ID}/${THREAD_ID}/${MESSAGE_ID}`;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* No DKIM mismatch: the auto-open raises the rail on its own where the rail
|
|
61
|
+
* fits, which would put `#intelligence` in the address before any key is
|
|
62
|
+
* pressed.
|
|
63
|
+
*/
|
|
64
|
+
const row = makeThreadMessage({
|
|
65
|
+
messageId: MESSAGE_ID,
|
|
66
|
+
threadId: THREAD_ID,
|
|
67
|
+
subject: "Your parcel could not be delivered",
|
|
68
|
+
fromName: "Mondial Relay",
|
|
69
|
+
fromEmail: "delivery.notice@gmail.example",
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
let harness: DomHarness | undefined;
|
|
73
|
+
let http: HttpMock | undefined;
|
|
74
|
+
|
|
75
|
+
afterEach(() => {
|
|
76
|
+
harness?.close();
|
|
77
|
+
harness = undefined;
|
|
78
|
+
http?.restore();
|
|
79
|
+
http = undefined;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
83
|
+
// `window`.
|
|
84
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* What `routes/mail.tsx` binds: the rail's open state is the fragment, and the
|
|
88
|
+
* toggle rewrites the fragment. The device preference is left out — nothing
|
|
89
|
+
* here is about the tier the address is silent on.
|
|
90
|
+
*/
|
|
91
|
+
function MailLayout({ children }: { children: ReactNode }) {
|
|
92
|
+
const openPanels = useOpenPanels();
|
|
93
|
+
const setOpenPanels = useSetOpenPanels();
|
|
94
|
+
const intelligenceOpen = openPanels.includes("intelligence");
|
|
95
|
+
const onToggleIntelligence = useCallback(() => {
|
|
96
|
+
setOpenPanels(intelligenceOpen ? [] : ["intelligence"]);
|
|
97
|
+
}, [intelligenceOpen, setOpenPanels]);
|
|
98
|
+
|
|
99
|
+
const value: MailContextValue = {
|
|
100
|
+
accounts: [],
|
|
101
|
+
mailboxNameIndex: new Map(),
|
|
102
|
+
accountNameIndex: new Map(),
|
|
103
|
+
resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
|
|
104
|
+
searchQuery: "",
|
|
105
|
+
searchInput: "",
|
|
106
|
+
searchViewKey: "",
|
|
107
|
+
onSearchChange: () => {},
|
|
108
|
+
onSearchClear: () => {},
|
|
109
|
+
onSearchClearQuery: () => {},
|
|
110
|
+
intelligenceOpen,
|
|
111
|
+
onToggleIntelligence,
|
|
112
|
+
};
|
|
113
|
+
return createElement(MailContext.Provider, { value }, children);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The shell as `MailShell` mounts it: one pane carrying the phone view below
|
|
118
|
+
* the reading boundary, the slots above it, and the rail's visibility taken
|
|
119
|
+
* from the address the same way the layout takes it.
|
|
120
|
+
*/
|
|
121
|
+
function Shell({ width }: { width: number }) {
|
|
122
|
+
const intelligenceOpen = useOpenPanels().includes("intelligence");
|
|
123
|
+
if (width < 1024) {
|
|
124
|
+
return createElement(AppShellSlotted, {
|
|
125
|
+
initialWidth: width,
|
|
126
|
+
nav: null,
|
|
127
|
+
list: createElement(MailboxPane.Phone),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
return createElement(AppShellSlotted, {
|
|
131
|
+
initialWidth: width,
|
|
132
|
+
nav: null,
|
|
133
|
+
list: null,
|
|
134
|
+
reading: createElement(MailboxPane.Reading),
|
|
135
|
+
intelligence: createElement(MailboxPane.Intelligence),
|
|
136
|
+
intelligenceOpen,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const testRouter = (width: number, href: string): AnyRouter => {
|
|
141
|
+
const rootRoute = createRootRoute({
|
|
142
|
+
component: () =>
|
|
143
|
+
createElement(
|
|
144
|
+
ComposeProvider,
|
|
145
|
+
null,
|
|
146
|
+
createElement(MailLayout, {
|
|
147
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
|
|
148
|
+
children: createElement(Outlet),
|
|
149
|
+
}),
|
|
150
|
+
),
|
|
151
|
+
});
|
|
152
|
+
const mailRoute = createRoute({
|
|
153
|
+
getParentRoute: () => rootRoute,
|
|
154
|
+
path: "/mail",
|
|
155
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
156
|
+
component: Outlet,
|
|
157
|
+
});
|
|
158
|
+
const mailboxRoute = createRoute({
|
|
159
|
+
getParentRoute: () => mailRoute,
|
|
160
|
+
path: "/$mailboxId",
|
|
161
|
+
component: () =>
|
|
162
|
+
createElement(MailboxPane, {
|
|
163
|
+
mailboxId: MAILBOX_ID,
|
|
164
|
+
thread: useOpenThreadPath(),
|
|
165
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
|
|
166
|
+
children: createElement(Outlet),
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
const threadRoute = createRoute({
|
|
170
|
+
getParentRoute: () => mailboxRoute,
|
|
171
|
+
path: "$threadId",
|
|
172
|
+
component: Outlet,
|
|
173
|
+
});
|
|
174
|
+
const messageRoute = createRoute({
|
|
175
|
+
getParentRoute: () => threadRoute,
|
|
176
|
+
path: "$messageId",
|
|
177
|
+
component: () => createElement(Shell, { width }),
|
|
178
|
+
});
|
|
179
|
+
const routeTree = rootRoute.addChildren([
|
|
180
|
+
mailRoute.addChildren([
|
|
181
|
+
mailboxRoute.addChildren([threadRoute.addChildren([messageRoute])]),
|
|
182
|
+
]),
|
|
183
|
+
]);
|
|
184
|
+
return createRouter({
|
|
185
|
+
routeTree,
|
|
186
|
+
history: createMemoryHistory({ initialEntries: [href] }),
|
|
187
|
+
}) as unknown as AnyRouter;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const mountAt = async (
|
|
191
|
+
width: number,
|
|
192
|
+
options: DomOptions = {},
|
|
193
|
+
href: string = MESSAGE_PATH,
|
|
194
|
+
): Promise<[DomHarness, AnyRouter]> => {
|
|
195
|
+
http = mockFetch((call) => {
|
|
196
|
+
if (call.path.endsWith("/config")) return { accounts: [] };
|
|
197
|
+
if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
|
|
198
|
+
return { items: [row] };
|
|
199
|
+
}
|
|
200
|
+
if (call.path.includes("/messages/")) return describedMessage;
|
|
201
|
+
if (call.path.includes("/threads")) return { items: [row] };
|
|
202
|
+
return { items: [] };
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
const router = testRouter(width, href);
|
|
206
|
+
await router.load();
|
|
207
|
+
const mounted = createDomHarness({ viewportWidth: width, ...options });
|
|
208
|
+
harness = mounted;
|
|
209
|
+
mounted.renderApp(createElement(RouterProvider, { router }));
|
|
210
|
+
await settle(mounted);
|
|
211
|
+
return [mounted, router];
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const press = async (mounted: DomHarness, key: string): Promise<void> => {
|
|
215
|
+
mounted.dispatch(
|
|
216
|
+
mounted.window,
|
|
217
|
+
new mounted.window.KeyboardEvent("keydown", { key, bubbles: true }),
|
|
218
|
+
);
|
|
219
|
+
await settle(mounted);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* The rail: the intelligence panel mounted in the shell's own row rather than
|
|
224
|
+
* inside the drawer, which puts the same panel behind a scrim.
|
|
225
|
+
*/
|
|
226
|
+
const rail = (mounted: DomHarness): HTMLElement | null => {
|
|
227
|
+
const pane = mounted.query("aside");
|
|
228
|
+
if (!pane) return null;
|
|
229
|
+
return pane.closest('[role="dialog"]') ? null : pane;
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
describe("the intelligence keys reach the surface the width has (#840)", () => {
|
|
233
|
+
for (const [tier, width, options] of [
|
|
234
|
+
["between the reading pane and the rail", TWO_PANE_WIDTH, {}],
|
|
235
|
+
[
|
|
236
|
+
"on the phone",
|
|
237
|
+
PHONE_WIDTH,
|
|
238
|
+
{ pointer: "coarse", orientation: "portrait" } as DomOptions,
|
|
239
|
+
],
|
|
240
|
+
] as const) {
|
|
241
|
+
it(`${tier}, i opens the drawer and leaves the address alone`, async () => {
|
|
242
|
+
const [mounted, router] = await mountAt(width, options);
|
|
243
|
+
|
|
244
|
+
await press(mounted, "i");
|
|
245
|
+
|
|
246
|
+
assert.ok(intelligenceDrawer(mounted), "pressing i opened nothing");
|
|
247
|
+
assert.equal(
|
|
248
|
+
router.state.location.hash,
|
|
249
|
+
"",
|
|
250
|
+
"the address named a panel this width cannot render",
|
|
251
|
+
);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it(`${tier}, a second i puts the drawer away`, async () => {
|
|
255
|
+
const [mounted] = await mountAt(width, options);
|
|
256
|
+
|
|
257
|
+
await press(mounted, "i");
|
|
258
|
+
assert.ok(intelligenceDrawer(mounted), "pressing i opened nothing");
|
|
259
|
+
|
|
260
|
+
await press(mounted, "i");
|
|
261
|
+
assert.equal(
|
|
262
|
+
intelligenceDrawer(mounted),
|
|
263
|
+
null,
|
|
264
|
+
"the second press left it up",
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it(`${tier}, b reaches block sender through the same drawer`, async () => {
|
|
269
|
+
const [mounted, router] = await mountAt(width, options);
|
|
270
|
+
|
|
271
|
+
await press(mounted, "b");
|
|
272
|
+
|
|
273
|
+
assert.ok(intelligenceDrawer(mounted), "pressing b opened nothing");
|
|
274
|
+
assert.equal(
|
|
275
|
+
router.state.location.hash,
|
|
276
|
+
"",
|
|
277
|
+
"the address named a panel this width cannot render",
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
it("still writes the fragment where the rail is the surface", async () => {
|
|
283
|
+
const [mounted, router] = await mountAt(RAIL_WIDTH);
|
|
284
|
+
|
|
285
|
+
await press(mounted, "i");
|
|
286
|
+
|
|
287
|
+
assert.equal(
|
|
288
|
+
router.state.location.hash,
|
|
289
|
+
"intelligence",
|
|
290
|
+
"the rail's own tier stopped writing the fragment",
|
|
291
|
+
);
|
|
292
|
+
assert.equal(
|
|
293
|
+
intelligenceDrawer(mounted),
|
|
294
|
+
null,
|
|
295
|
+
"the drawer came up where the rail is the surface",
|
|
296
|
+
);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("writes the fragment for block sender where the rail is the surface", async () => {
|
|
300
|
+
const [mounted, router] = await mountAt(RAIL_WIDTH);
|
|
301
|
+
|
|
302
|
+
await press(mounted, "b");
|
|
303
|
+
|
|
304
|
+
assert.equal(router.state.location.hash, "intelligence");
|
|
305
|
+
assert.equal(
|
|
306
|
+
intelligenceDrawer(mounted),
|
|
307
|
+
null,
|
|
308
|
+
"the drawer came up where the rail is the surface",
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* What the fragment is worth at each width, on a cold load. The rail is the one
|
|
315
|
+
* renderer the name has, which is why the keys above must not write the name
|
|
316
|
+
* anywhere else.
|
|
317
|
+
*/
|
|
318
|
+
describe("#intelligence names a renderer only where the rail fits", () => {
|
|
319
|
+
it("mounts the rail where the address carries it and the width has room", async () => {
|
|
320
|
+
const [mounted] = await mountAt(
|
|
321
|
+
RAIL_WIDTH,
|
|
322
|
+
{},
|
|
323
|
+
`${MESSAGE_PATH}#intelligence`,
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
assert.ok(rail(mounted), "the fragment raised no rail");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("mounts nothing for the same address below the rail's width", async () => {
|
|
330
|
+
const [mounted] = await mountAt(
|
|
331
|
+
TWO_PANE_WIDTH,
|
|
332
|
+
{},
|
|
333
|
+
`${MESSAGE_PATH}#intelligence`,
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
assert.equal(rail(mounted), null, "a rail rendered where none fits");
|
|
337
|
+
assert.equal(
|
|
338
|
+
intelligenceDrawer(mounted),
|
|
339
|
+
null,
|
|
340
|
+
"the fragment raised the drawer, which belongs to the thread",
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
});
|
|
@@ -32,15 +32,19 @@ import { type OpenThreadPath, useOpenThreadPath } from "@/routing";
|
|
|
32
32
|
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
33
33
|
import { makeThreadMessage } from "@/test-support/fixtures";
|
|
34
34
|
import { type HttpMock, mockFetch } from "@/test-support/http";
|
|
35
|
+
import {
|
|
36
|
+
describedMessage,
|
|
37
|
+
intelligenceDrawer,
|
|
38
|
+
MESSAGE_ID,
|
|
39
|
+
settle,
|
|
40
|
+
THREAD_ID,
|
|
41
|
+
} from "@/test-support/intelligence-surface";
|
|
35
42
|
import { BriefPane } from "./BriefPane";
|
|
36
43
|
import { FlaggedPane } from "./FlaggedPane";
|
|
37
44
|
|
|
38
45
|
/** Below the rail's 1280px gate, above the reading pane's 1024px one. */
|
|
39
46
|
const TWO_PANE_WIDTH = 1100;
|
|
40
47
|
|
|
41
|
-
const THREAD_ID = "thread-1";
|
|
42
|
-
const MESSAGE_ID = "msg-1";
|
|
43
|
-
|
|
44
48
|
const SHOW_INTELLIGENCE = "Show intelligence sidebar";
|
|
45
49
|
const HIDE_INTELLIGENCE = "Hide intelligence sidebar";
|
|
46
50
|
|
|
@@ -60,24 +64,6 @@ const row = makeThreadMessage({
|
|
|
60
64
|
},
|
|
61
65
|
});
|
|
62
66
|
|
|
63
|
-
/** What the reading pane reads each message's own headers and body from. */
|
|
64
|
-
const describedMessage = {
|
|
65
|
-
messageId: MESSAGE_ID,
|
|
66
|
-
envelope: {
|
|
67
|
-
from: [
|
|
68
|
-
{
|
|
69
|
-
addressId: "addr-1",
|
|
70
|
-
name: "Mondial Relay",
|
|
71
|
-
email: "delivery.notice@gmail.example",
|
|
72
|
-
},
|
|
73
|
-
],
|
|
74
|
-
to: [],
|
|
75
|
-
cc: [],
|
|
76
|
-
bcc: [],
|
|
77
|
-
},
|
|
78
|
-
bodyParts: [],
|
|
79
|
-
};
|
|
80
|
-
|
|
81
67
|
let harness: DomHarness | undefined;
|
|
82
68
|
let http: HttpMock | undefined;
|
|
83
69
|
|
|
@@ -197,15 +183,6 @@ const mount = async (pane: PaneUnderTest): Promise<DomHarness> => {
|
|
|
197
183
|
return mounted;
|
|
198
184
|
};
|
|
199
185
|
|
|
200
|
-
const settle = async (mounted: DomHarness): Promise<void> => {
|
|
201
|
-
await mounted.flush();
|
|
202
|
-
await mounted.wait(20);
|
|
203
|
-
await mounted.flush();
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
const drawer = (mounted: DomHarness): HTMLElement | null =>
|
|
207
|
-
mounted.query('[role="dialog"][aria-label="Message details"]');
|
|
208
|
-
|
|
209
186
|
describe("intelligence is reachable wherever the reading pane mounts (#817)", () => {
|
|
210
187
|
for (const pane of panes) {
|
|
211
188
|
it(`${pane.name} offers a live toolbar control below the rail's width`, async () => {
|
|
@@ -221,7 +198,7 @@ describe("intelligence is reachable wherever the reading pane mounts (#817)", ()
|
|
|
221
198
|
mounted.click(toggle);
|
|
222
199
|
await settle(mounted);
|
|
223
200
|
|
|
224
|
-
assert.ok(
|
|
201
|
+
assert.ok(intelligenceDrawer(mounted), "pressing it opened nothing");
|
|
225
202
|
assert.ok(
|
|
226
203
|
mounted.query(`[aria-label="${HIDE_INTELLIGENCE}"]`),
|
|
227
204
|
"the toolbar still reports the surface as closed",
|
|
@@ -235,7 +212,7 @@ describe("intelligence is reachable wherever the reading pane mounts (#817)", ()
|
|
|
235
212
|
await settle(mounted);
|
|
236
213
|
|
|
237
214
|
assert.ok(
|
|
238
|
-
|
|
215
|
+
intelligenceDrawer(mounted),
|
|
239
216
|
"the banner's Why? reached no intelligence surface",
|
|
240
217
|
);
|
|
241
218
|
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useAppShellLayout } from "@remit/ui";
|
|
2
|
-
import { useCallback } from "react";
|
|
2
|
+
import { type RefObject, useCallback, useEffect } from "react";
|
|
3
3
|
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
4
4
|
import { useMailContext } from "@/lib/mail-context";
|
|
5
5
|
|
|
@@ -10,15 +10,11 @@ import { useMailContext } from "@/lib/mail-context";
|
|
|
10
10
|
* rail's own gate as the answer for both tiers and offered a disabled control
|
|
11
11
|
* with nothing behind it between 1024 and 1280 (#817).
|
|
12
12
|
*/
|
|
13
|
-
export interface IntelligenceSurface {
|
|
13
|
+
export interface IntelligenceSurface extends IntelligenceCommands {
|
|
14
14
|
/** There is a thread to say anything about, so the toolbar's control acts. */
|
|
15
15
|
canToggle: boolean;
|
|
16
16
|
/** What the toolbar reports: the rail above 1280, the drawer below it. */
|
|
17
17
|
isShowing: boolean;
|
|
18
|
-
/** The toolbar's control, over whichever surface this width has. */
|
|
19
|
-
toggle: () => void;
|
|
20
|
-
/** The banner's "Why?" — an open, never a close. */
|
|
21
|
-
open: () => void;
|
|
22
18
|
drawerOpen: boolean;
|
|
23
19
|
closeDrawer: () => void;
|
|
24
20
|
/** Whether this width has room for the rail, so the rail is the surface. */
|
|
@@ -27,6 +23,19 @@ export interface IntelligenceSurface {
|
|
|
27
23
|
openRail: () => void;
|
|
28
24
|
}
|
|
29
25
|
|
|
26
|
+
/**
|
|
27
|
+
* What a mounted intelligence surface answers for. The width gate is the
|
|
28
|
+
* shell's own measurement, so only a component inside `AppShellSlotted` knows
|
|
29
|
+
* which surface this tier has; a pane provider wrapping the shell reaches it
|
|
30
|
+
* through a ref the mounted surface publishes into.
|
|
31
|
+
*/
|
|
32
|
+
export interface IntelligenceCommands {
|
|
33
|
+
/** The toolbar's control, over whichever surface this width has. */
|
|
34
|
+
toggle: () => void;
|
|
35
|
+
/** The banner's "Why?" — an open, never a close. */
|
|
36
|
+
open: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
30
39
|
export const useIntelligenceSurface = (
|
|
31
40
|
openThreadId: string | undefined,
|
|
32
41
|
): IntelligenceSurface => {
|
|
@@ -50,15 +59,40 @@ export const useIntelligenceSurface = (
|
|
|
50
59
|
if (!railFits || intelligenceOpen) return;
|
|
51
60
|
onToggleIntelligence();
|
|
52
61
|
}, [railFits, intelligenceOpen, onToggleIntelligence]);
|
|
62
|
+
const open = useCallback(() => {
|
|
63
|
+
if (!railFits) {
|
|
64
|
+
openDrawer();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
openRail();
|
|
68
|
+
}, [railFits, openDrawer, openRail]);
|
|
53
69
|
|
|
54
70
|
return {
|
|
55
71
|
canToggle: threadId !== null,
|
|
56
72
|
isShowing: threadId !== null && (railFits ? intelligenceOpen : drawerOpen),
|
|
57
73
|
toggle,
|
|
58
|
-
open
|
|
74
|
+
open,
|
|
59
75
|
drawerOpen,
|
|
60
76
|
closeDrawer: drawer.close,
|
|
61
77
|
railFits,
|
|
62
78
|
openRail,
|
|
63
79
|
};
|
|
64
80
|
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Publish the mounted surface for the pane provider's keyboard handlers. Null
|
|
84
|
+
* while nothing is mounted to serve them, so a shortcut fired with no surface
|
|
85
|
+
* reaches nothing rather than writing `#intelligence` at a width that cannot
|
|
86
|
+
* render it (`docs/architecture/url-state.md`, R6).
|
|
87
|
+
*/
|
|
88
|
+
export const usePublishIntelligenceCommands = (
|
|
89
|
+
ref: RefObject<IntelligenceCommands | null>,
|
|
90
|
+
{ toggle, open }: IntelligenceCommands,
|
|
91
|
+
): void => {
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
ref.current = { toggle, open };
|
|
94
|
+
return () => {
|
|
95
|
+
ref.current = null;
|
|
96
|
+
};
|
|
97
|
+
}, [ref, toggle, open]);
|
|
98
|
+
};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What every intelligence-surface spec needs to poke one: the message the
|
|
3
|
+
* reading pane reads its headers from, the settle the router and the query
|
|
4
|
+
* client both need, and the drawer as the DOM names it.
|
|
5
|
+
*
|
|
6
|
+
* The thread row itself is each spec's own, because the DKIM mismatch that
|
|
7
|
+
* raises the authenticity banner also raises the rail wherever the rail fits.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { DomHarness } from "@/test-support/dom";
|
|
11
|
+
|
|
12
|
+
export const THREAD_ID = "thread-1";
|
|
13
|
+
export const MESSAGE_ID = "msg-1";
|
|
14
|
+
|
|
15
|
+
/** What the reading pane reads each message's own headers and body from. */
|
|
16
|
+
export const describedMessage = {
|
|
17
|
+
messageId: MESSAGE_ID,
|
|
18
|
+
envelope: {
|
|
19
|
+
from: [
|
|
20
|
+
{
|
|
21
|
+
addressId: "addr-1",
|
|
22
|
+
name: "Mondial Relay",
|
|
23
|
+
email: "delivery.notice@gmail.example",
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
to: [],
|
|
27
|
+
cc: [],
|
|
28
|
+
bcc: [],
|
|
29
|
+
},
|
|
30
|
+
bodyParts: [],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/** Let the router commit, the queries land, and the render that follows run. */
|
|
34
|
+
export const settle = async (mounted: DomHarness): Promise<void> => {
|
|
35
|
+
await mounted.flush();
|
|
36
|
+
await mounted.wait(20);
|
|
37
|
+
await mounted.flush();
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** The intelligence drawer, by the role and label it publishes. */
|
|
41
|
+
export const intelligenceDrawer = (mounted: DomHarness): HTMLElement | null =>
|
|
42
|
+
mounted.query('[role="dialog"][aria-label="Message details"]');
|