@remit/web-client 0.0.143 → 0.0.145
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/compose/ComposeProvider.tsx +79 -3
- package/src/components/compose/compose-clears-open-thread.render.test.ts +302 -0
- package/src/components/compose/compose-send-stops-autosave.render.test.ts +34 -5
- package/src/components/layout/ComposeFab.tsx +11 -36
- package/src/components/layout/MailShell.tsx +98 -0
- package/src/components/layout/MailTopBar.tsx +6 -3
- package/src/components/mail/BriefPane.tsx +5 -5
- package/src/components/mail/DraftsView.tsx +0 -11
- package/src/components/mail/FlaggedPane.tsx +1 -1
- package/src/components/mail/MailSidebarAdapter.tsx +5 -8
- package/src/components/mail/MailboxPane.tsx +15 -5
- package/src/components/ui/FatalErrorOverlay.tsx +4 -1
- package/src/hooks/useComposeTargetMailbox.ts +75 -0
- package/src/hooks/useSearchMirror.ts +93 -0
- package/src/hooks/useSearchScope.ts +1 -1
- package/src/lib/compose-routes.test.ts +3 -1
- package/src/lib/compose-routes.ts +6 -6
- package/src/lib/mail-route.test.ts +157 -57
- package/src/lib/mail-route.ts +71 -38
- package/src/lib/mail-search.ts +51 -0
- package/src/lib/route-search-query.test.ts +50 -28
- package/src/lib/search-scope.ts +11 -19
- package/src/lib/search-view.test.ts +142 -38
- package/src/lib/search-view.ts +35 -11
- package/src/routeTree.gen.ts +155 -18
- package/src/router.tsx +17 -0
- package/src/routes/index.tsx +1 -1
- package/src/routes/mail/$mailboxId/index.tsx +10 -0
- package/src/routes/mail/$mailboxId.tsx +27 -19
- package/src/routes/mail/brief/index.tsx +14 -0
- package/src/routes/mail/brief.tsx +50 -0
- package/src/routes/mail/flagged/index.tsx +10 -0
- package/src/routes/mail/flagged.tsx +25 -13
- package/src/routes/mail/index.tsx +9 -36
- package/src/routes/mail/outbox/index.tsx +10 -0
- package/src/routes/mail/outbox.tsx +23 -13
- package/src/routes/mail.tsx +51 -249
- package/src/test-support/list-search-binding.ts +34 -0
- package/src/hooks/useComposeTarget.ts +0 -92
|
@@ -1,18 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
/**
|
|
2
|
+
* /mail/outbox — one of the four list layouts. Two panes, list and reading;
|
|
3
|
+
* there is no intelligence rail for mail that has not been sent yet.
|
|
4
|
+
*/
|
|
5
|
+
import { createFileRoute, Outlet } from "@tanstack/react-router";
|
|
6
|
+
import { MailShell } from "@/components/layout/MailShell";
|
|
7
|
+
import { OutboxPane } from "@/components/mail/OutboxPane";
|
|
8
|
+
import { useSearchMirror } from "@/hooks/useSearchMirror";
|
|
9
|
+
import { outboxSearchSchema } from "@/lib/mail-search";
|
|
3
10
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
function OutboxLayout() {
|
|
12
|
+
useSearchMirror({ to: "/mail/outbox" });
|
|
13
|
+
|
|
14
|
+
return (
|
|
15
|
+
<OutboxPane>
|
|
16
|
+
<MailShell
|
|
17
|
+
phone={<OutboxPane.Phone />}
|
|
18
|
+
list={<OutboxPane.List />}
|
|
19
|
+
reading={<Outlet />}
|
|
20
|
+
/>
|
|
21
|
+
</OutboxPane>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
14
24
|
|
|
15
25
|
export const Route = createFileRoute("/mail/outbox")({
|
|
16
|
-
component:
|
|
26
|
+
component: OutboxLayout,
|
|
17
27
|
validateSearch: outboxSearchSchema,
|
|
18
28
|
});
|
package/src/routes/mail.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import {
|
|
|
2
2
|
configOperationsGetConfigOptions,
|
|
3
3
|
unifiedThreadOperationsListAllThreadsOptions,
|
|
4
4
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { useTriageKeyboard } from "@remit/ui";
|
|
6
6
|
import { useQuery } from "@tanstack/react-query";
|
|
7
7
|
import {
|
|
8
8
|
createFileRoute,
|
|
@@ -11,16 +11,13 @@ import {
|
|
|
11
11
|
useRouterState,
|
|
12
12
|
useSearch,
|
|
13
13
|
} from "@tanstack/react-router";
|
|
14
|
-
import { useCallback, useEffect, useMemo,
|
|
14
|
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
15
15
|
import { z } from "zod";
|
|
16
|
-
import {
|
|
16
|
+
import { useCompose } from "@/components/compose/ComposeProvider";
|
|
17
17
|
import { ComposeFab } from "@/components/layout/ComposeFab";
|
|
18
|
+
import { MailShellProvider } from "@/components/layout/MailShell";
|
|
18
19
|
import { MailTopBar } from "@/components/layout/MailTopBar";
|
|
19
|
-
import { BriefPane } from "@/components/mail/BriefPane";
|
|
20
|
-
import { FlaggedPane } from "@/components/mail/FlaggedPane";
|
|
21
|
-
import { MailboxPane } from "@/components/mail/MailboxPane";
|
|
22
20
|
import { MailNav } from "@/components/mail/MailNav";
|
|
23
|
-
import { OutboxPane } from "@/components/mail/OutboxPane";
|
|
24
21
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
25
22
|
import { KeyboardShortcutsModal } from "@/components/ui/KeyboardShortcutsModal";
|
|
26
23
|
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
|
@@ -29,21 +26,13 @@ import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
|
|
|
29
26
|
import { useMailboxNameIndex } from "@/hooks/useMailboxNameIndex";
|
|
30
27
|
import { useResultFolderIndex } from "@/hooks/useResultFolderIndex";
|
|
31
28
|
import { useStaleAccountSync } from "@/hooks/useStaleAccountSync";
|
|
29
|
+
import { hostsComposeSurface } from "@/lib/compose-routes";
|
|
32
30
|
import { writeIntelligencePref } from "@/lib/intelligence-pref";
|
|
33
31
|
import { MailContext } from "@/lib/mail-context";
|
|
34
32
|
import { MailFreshnessProvider } from "@/lib/mail-freshness";
|
|
35
|
-
import {
|
|
36
|
-
isBriefRoute,
|
|
37
|
-
isFlaggedRoute,
|
|
38
|
-
isOutboxRoute,
|
|
39
|
-
mailViewKey,
|
|
40
|
-
} from "@/lib/mail-route";
|
|
33
|
+
import { mailViewKey } from "@/lib/mail-route";
|
|
41
34
|
import { buildAccountNameIndex } from "@/lib/search-token-index";
|
|
42
|
-
import {
|
|
43
|
-
committedSearchQuery,
|
|
44
|
-
searchInputForView,
|
|
45
|
-
shouldMirrorQuery,
|
|
46
|
-
} from "@/lib/search-view";
|
|
35
|
+
import { committedSearchQuery, searchInputForView } from "@/lib/search-view";
|
|
47
36
|
import { wizardEntryValue, wizardStepValue } from "@/lib/wizard-history";
|
|
48
37
|
import "@/lib/client";
|
|
49
38
|
|
|
@@ -83,12 +72,12 @@ function MailLayout() {
|
|
|
83
72
|
const { q: searchQuery = "" } = useSearch({ from: "/mail" });
|
|
84
73
|
const navigate = useNavigate();
|
|
85
74
|
const tier = useLayoutTier();
|
|
86
|
-
// Below the reading boundary (phone AND tablet)
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
75
|
+
// Below the reading boundary (phone AND tablet) the shell shows a SINGLE
|
|
76
|
+
// pane — there is no reading pane to host the thread or the compose surface.
|
|
77
|
+
// So both tiers use the single-pane view, which swaps the pane in place
|
|
78
|
+
// between list, open thread, and compose. Keying this off "phone" alone left
|
|
79
|
+
// tablet with no compose surface (compose lives in the reading pane, which
|
|
80
|
+
// tablet doesn't mount) — the "c" shortcut / FAB opened nothing.
|
|
92
81
|
const isSinglePane = isSinglePaneTier(tier);
|
|
93
82
|
const [showShortcuts, setShowShortcuts] = useState(false);
|
|
94
83
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
@@ -106,8 +95,8 @@ function MailLayout() {
|
|
|
106
95
|
|
|
107
96
|
// Within one view, URL `q` seeds the input and is a one-directional write
|
|
108
97
|
// target: the debounced local value drives the search API and is mirrored
|
|
109
|
-
// back
|
|
110
|
-
// below and `lib/search-view.ts` (#47).
|
|
98
|
+
// back by the list route's own `useSearchMirror`. Across views the URL wins
|
|
99
|
+
// again — see the view-change adjustment below and `lib/search-view.ts` (#47).
|
|
111
100
|
const [searchInput, setSearchInput] = useState(searchQuery);
|
|
112
101
|
const debouncedSearchInput = useDebouncedValue(searchInput, 200);
|
|
113
102
|
const committedQuery = committedSearchQuery(
|
|
@@ -115,23 +104,21 @@ function MailLayout() {
|
|
|
115
104
|
debouncedSearchInput,
|
|
116
105
|
);
|
|
117
106
|
|
|
118
|
-
const searchQueryRef = useRef(searchQuery);
|
|
119
|
-
searchQueryRef.current = searchQuery;
|
|
120
|
-
|
|
121
107
|
// Search is a mode of the view it was typed in, so leaving that view re-seeds
|
|
122
108
|
// the field from wherever we land (#47): empty when the sidebar dropped `q`
|
|
123
109
|
// (a folder switch starts that folder's search fresh), and the carried query
|
|
124
110
|
// when the top bar's scope chip was removed and sent the user to the brief to
|
|
125
|
-
// search everything. Views that differ only in
|
|
126
|
-
//
|
|
127
|
-
// them (`searchInputForView`).
|
|
111
|
+
// search everything. Views that differ only in what is open below the list —
|
|
112
|
+
// a thread, a mirrored `q` — are the same view, so in-flight typing survives
|
|
113
|
+
// them (`searchInputForView`, `mailViewKey`).
|
|
128
114
|
//
|
|
129
115
|
// Adjusted during render, not in an effect. This is React's documented
|
|
130
116
|
// "adjusting state when a prop changes" pattern: both updates are to this
|
|
131
117
|
// component's own state and are guarded by a changed value, so React re-runs
|
|
132
118
|
// the render before committing and nothing is painted with the stale query.
|
|
133
119
|
// An effect would commit one frame carrying the previous view's text, which
|
|
134
|
-
// the mirror
|
|
120
|
+
// the mirror then has to be defended against.
|
|
121
|
+
const pathname = useRouterState({ select: (s) => s.location.pathname });
|
|
135
122
|
const viewKey = useRouterState({ select: (s) => mailViewKey(s.matches) });
|
|
136
123
|
const [searchViewKey, setSearchViewKey] = useState(viewKey);
|
|
137
124
|
if (searchViewKey !== viewKey) {
|
|
@@ -140,44 +127,6 @@ function MailLayout() {
|
|
|
140
127
|
if (seeded !== undefined) setSearchInput(seeded);
|
|
141
128
|
}
|
|
142
129
|
|
|
143
|
-
// Mirror the settled search into the URL so links are shareable and a
|
|
144
|
-
// refresh restores the query. Within a view this is one-directional — the
|
|
145
|
-
// URL is not read back into state, so there is no sync loop — and it writes
|
|
146
|
-
// only once the debounce agrees with the field, so a query arriving by URL
|
|
147
|
-
// is never overwritten mid-debounce, and the query the user just left behind
|
|
148
|
-
// is never written onto the view they landed on (`shouldMirrorQuery`).
|
|
149
|
-
// When a query *goes* active, also strip selectedMessageId so the reading
|
|
150
|
-
// pane closes (#539): an open message from the pre-search list is not
|
|
151
|
-
// meaningful in the search result set. Only strip on that transition though —
|
|
152
|
-
// tapping a search result commits the same `q` with the selection, so when
|
|
153
|
-
// `prev.q` already equals the query the result was opened under it (not a
|
|
154
|
-
// pre-search leftover) and must survive. The strip otherwise raced the tap:
|
|
155
|
-
// the row shows before the debounce settles, so this mirror can land just
|
|
156
|
-
// after the open and close it again.
|
|
157
|
-
useEffect(() => {
|
|
158
|
-
if (!shouldMirrorQuery(searchInput, committedQuery, searchQueryRef.current))
|
|
159
|
-
return;
|
|
160
|
-
navigate({
|
|
161
|
-
to: ".",
|
|
162
|
-
search: (prev) => {
|
|
163
|
-
const queryAlreadyActive =
|
|
164
|
-
(prev as { q?: string }).q === committedQuery;
|
|
165
|
-
return {
|
|
166
|
-
...prev,
|
|
167
|
-
q: committedQuery || undefined,
|
|
168
|
-
...(committedQuery && !queryAlreadyActive
|
|
169
|
-
? {
|
|
170
|
-
selectedMessageId: undefined,
|
|
171
|
-
selectedThreadId: undefined,
|
|
172
|
-
selectedMailboxId: undefined,
|
|
173
|
-
}
|
|
174
|
-
: {}),
|
|
175
|
-
};
|
|
176
|
-
},
|
|
177
|
-
replace: true,
|
|
178
|
-
});
|
|
179
|
-
}, [searchInput, committedQuery, navigate]);
|
|
180
|
-
|
|
181
130
|
const {
|
|
182
131
|
data: config,
|
|
183
132
|
isLoading,
|
|
@@ -215,12 +164,25 @@ function MailLayout() {
|
|
|
215
164
|
],
|
|
216
165
|
});
|
|
217
166
|
|
|
167
|
+
// `c` / ⌘N off the mailbox routes — the brief, Flagged, the outbox. On a
|
|
168
|
+
// mailbox the pane's own triage layer owns the key, so this is disabled there
|
|
169
|
+
// rather than firing a second compose alongside it.
|
|
170
|
+
const { openCompose } = useCompose();
|
|
171
|
+
const composeHandlers = useMemo(
|
|
172
|
+
() => ({ compose: () => openCompose({ mode: "new" }) }),
|
|
173
|
+
[openCompose],
|
|
174
|
+
);
|
|
175
|
+
useTriageKeyboard({
|
|
176
|
+
enabled: !hostsComposeSurface(pathname),
|
|
177
|
+
handlers: composeHandlers,
|
|
178
|
+
});
|
|
179
|
+
|
|
218
180
|
const handleSearchChange = useCallback((query: string) => {
|
|
219
181
|
setSearchInput(query);
|
|
220
182
|
}, []);
|
|
221
183
|
|
|
222
|
-
// Clears the search field; the
|
|
223
|
-
// the debounce settles.
|
|
184
|
+
// Clears the search field; the list route's mirror drops `q` from the URL
|
|
185
|
+
// after the debounce settles.
|
|
224
186
|
const handleSearchClear = useCallback(() => {
|
|
225
187
|
setSearchInput("");
|
|
226
188
|
}, []);
|
|
@@ -250,43 +212,6 @@ function MailLayout() {
|
|
|
250
212
|
[accounts],
|
|
251
213
|
);
|
|
252
214
|
|
|
253
|
-
// Read the current mailbox params and selected message (if any) from the
|
|
254
|
-
// child route so the parent shell can mount the right pane (brief / mailbox /
|
|
255
|
-
// outbox) and forward the open thread into it.
|
|
256
|
-
//
|
|
257
|
-
// `useParams({ strict: false })` resolves against the *nearest* route match
|
|
258
|
-
// in the React component tree — which for this parent layout is /mail (no
|
|
259
|
-
// path params). The child's $mailboxId param never appears there, so
|
|
260
|
-
// mobileMailboxId would always be undefined and the navigate guard would
|
|
261
|
-
// short-circuit every tap. Use `useRouterState` instead: it exposes all
|
|
262
|
-
// currently matched routes, letting us find the one that carries mailboxId.
|
|
263
|
-
const mobileMailboxId = useRouterState({
|
|
264
|
-
select: (s) => {
|
|
265
|
-
const match = s.matches.find(
|
|
266
|
-
(m): m is typeof m & { params: { mailboxId: string } } =>
|
|
267
|
-
"mailboxId" in m.params,
|
|
268
|
-
);
|
|
269
|
-
return match?.params.mailboxId;
|
|
270
|
-
},
|
|
271
|
-
});
|
|
272
|
-
const { selectedMessageId: mobileSelectedMessageId } = useSearch({
|
|
273
|
-
strict: false,
|
|
274
|
-
}) as { selectedMessageId?: string };
|
|
275
|
-
|
|
276
|
-
// Detect which leaf route is active by its own routeId — never the parent
|
|
277
|
-
// /mail layout's pathname (which is "/mail" on EVERY child route and would
|
|
278
|
-
// wrongly route every mailbox through the brief pane, dropping message rows).
|
|
279
|
-
// See lib/mail-route.ts for the contract + regression test.
|
|
280
|
-
const onBriefRoute = useRouterState({
|
|
281
|
-
select: (s) => isBriefRoute(s.matches),
|
|
282
|
-
});
|
|
283
|
-
const onFlaggedRoute = useRouterState({
|
|
284
|
-
select: (s) => isFlaggedRoute(s.matches),
|
|
285
|
-
});
|
|
286
|
-
const onOutboxRoute = useRouterState({
|
|
287
|
-
select: (s) => isOutboxRoute(s.matches),
|
|
288
|
-
});
|
|
289
|
-
|
|
290
215
|
// Auto-trigger a mailbox-list sync for any account whose lastSyncAt is
|
|
291
216
|
// older than 15 minutes (or unset). Fires once per accountId per session
|
|
292
217
|
// — re-mounts of MailLayout will not retrigger. See #205.
|
|
@@ -321,20 +246,18 @@ function MailLayout() {
|
|
|
321
246
|
// own slide-over Dialog (narrow), never both — so there is exactly one
|
|
322
247
|
// "Mailboxes" nav landmark at any width. `MailNav` adds the mobile
|
|
323
248
|
// Settings + bug-report footer when it detects the slide-over context.
|
|
324
|
-
const
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
const topBar = isSinglePane ? undefined : <MailTopBar accounts={accounts} />;
|
|
337
|
-
const navSlideOver = {
|
|
249
|
+
const shellChrome = {
|
|
250
|
+
isSinglePane,
|
|
251
|
+
isLoading: isLoading || hasNoAccounts,
|
|
252
|
+
intelligenceOpen,
|
|
253
|
+
nav: <MailNav accounts={accounts} onMailboxSelect={handleMailboxSelect} />,
|
|
254
|
+
// Desktop only. Below 1024px the single pane keeps its own header search
|
|
255
|
+
// and the phone takeover; there is no room for a bar spanning panes that do
|
|
256
|
+
// not exist side by side.
|
|
257
|
+
topBar: isSinglePane ? undefined : <MailTopBar accounts={accounts} />,
|
|
258
|
+
// Single-pane only, where the FAB is the compose entry point. Above it the
|
|
259
|
+
// top bar owns compose.
|
|
260
|
+
overlay: isSinglePane ? <ComposeFab /> : undefined,
|
|
338
261
|
navOpen: drawerOpen,
|
|
339
262
|
onOpenNav: () => setDrawerOpen(true),
|
|
340
263
|
onCloseNav: () => setDrawerOpen(false),
|
|
@@ -353,137 +276,16 @@ function MailLayout() {
|
|
|
353
276
|
}}
|
|
354
277
|
/>
|
|
355
278
|
</div>
|
|
356
|
-
) : onBriefRoute ? (
|
|
357
|
-
// Daily brief (/mail/) — no mailboxId param; same 3-pane layout as a
|
|
358
|
-
// mailbox: an open message has an intelligence rail here too (#52).
|
|
359
|
-
<BriefPane selectedMessageId={mobileSelectedMessageId}>
|
|
360
|
-
{isSinglePane ? (
|
|
361
|
-
<AppShellSlotted
|
|
362
|
-
nav={navContent}
|
|
363
|
-
list={<BriefPane.Phone />}
|
|
364
|
-
intelligenceOpen={intelligenceOpen}
|
|
365
|
-
overlay={overlayContent}
|
|
366
|
-
skeleton={<AppShellSkeleton />}
|
|
367
|
-
isLoading={isLoading || hasNoAccounts}
|
|
368
|
-
{...navSlideOver}
|
|
369
|
-
/>
|
|
370
|
-
) : (
|
|
371
|
-
<AppShellSlotted
|
|
372
|
-
nav={navContent}
|
|
373
|
-
topBar={topBar}
|
|
374
|
-
list={<BriefPane.List />}
|
|
375
|
-
reading={<BriefPane.Reading />}
|
|
376
|
-
intelligence={<BriefPane.Intelligence />}
|
|
377
|
-
intelligenceOpen={intelligenceOpen}
|
|
378
|
-
hasThread={Boolean(mobileSelectedMessageId)}
|
|
379
|
-
overlay={overlayContent}
|
|
380
|
-
skeleton={<AppShellSkeleton />}
|
|
381
|
-
isLoading={isLoading || hasNoAccounts}
|
|
382
|
-
{...navSlideOver}
|
|
383
|
-
/>
|
|
384
|
-
)}
|
|
385
|
-
</BriefPane>
|
|
386
|
-
) : onFlaggedRoute ? (
|
|
387
|
-
// Flagged virtual mailbox (/mail/flagged) — flat starred list across
|
|
388
|
-
// accounts; same slots as the brief, intelligence rail included.
|
|
389
|
-
<FlaggedPane selectedMessageId={mobileSelectedMessageId}>
|
|
390
|
-
{isSinglePane ? (
|
|
391
|
-
<AppShellSlotted
|
|
392
|
-
nav={navContent}
|
|
393
|
-
list={<FlaggedPane.Phone />}
|
|
394
|
-
intelligenceOpen={intelligenceOpen}
|
|
395
|
-
overlay={overlayContent}
|
|
396
|
-
skeleton={<AppShellSkeleton />}
|
|
397
|
-
isLoading={isLoading || hasNoAccounts}
|
|
398
|
-
{...navSlideOver}
|
|
399
|
-
/>
|
|
400
|
-
) : (
|
|
401
|
-
<AppShellSlotted
|
|
402
|
-
nav={navContent}
|
|
403
|
-
topBar={topBar}
|
|
404
|
-
list={<FlaggedPane.List />}
|
|
405
|
-
reading={<FlaggedPane.Reading />}
|
|
406
|
-
intelligence={<FlaggedPane.Intelligence />}
|
|
407
|
-
intelligenceOpen={intelligenceOpen}
|
|
408
|
-
hasThread={Boolean(mobileSelectedMessageId)}
|
|
409
|
-
overlay={overlayContent}
|
|
410
|
-
skeleton={<AppShellSkeleton />}
|
|
411
|
-
isLoading={isLoading || hasNoAccounts}
|
|
412
|
-
{...navSlideOver}
|
|
413
|
-
/>
|
|
414
|
-
)}
|
|
415
|
-
</FlaggedPane>
|
|
416
|
-
) : mobileMailboxId ? (
|
|
417
|
-
// Mailbox view (/mail/$mailboxId) — full 4-pane layout.
|
|
418
|
-
<MailboxPane
|
|
419
|
-
mailboxId={mobileMailboxId}
|
|
420
|
-
selectedMessageId={mobileSelectedMessageId}
|
|
421
|
-
>
|
|
422
|
-
{isSinglePane ? (
|
|
423
|
-
<AppShellSlotted
|
|
424
|
-
nav={navContent}
|
|
425
|
-
list={<MailboxPane.Phone />}
|
|
426
|
-
intelligenceOpen={intelligenceOpen}
|
|
427
|
-
overlay={overlayContent}
|
|
428
|
-
skeleton={<AppShellSkeleton />}
|
|
429
|
-
isLoading={isLoading || hasNoAccounts}
|
|
430
|
-
{...navSlideOver}
|
|
431
|
-
/>
|
|
432
|
-
) : (
|
|
433
|
-
<AppShellSlotted
|
|
434
|
-
nav={navContent}
|
|
435
|
-
topBar={topBar}
|
|
436
|
-
list={<MailboxPane.List />}
|
|
437
|
-
reading={<MailboxPane.Reading />}
|
|
438
|
-
intelligence={<MailboxPane.Intelligence />}
|
|
439
|
-
intelligenceOpen={intelligenceOpen}
|
|
440
|
-
hasThread={Boolean(mobileSelectedMessageId)}
|
|
441
|
-
overlay={overlayContent}
|
|
442
|
-
skeleton={<AppShellSkeleton />}
|
|
443
|
-
isLoading={isLoading || hasNoAccounts}
|
|
444
|
-
{...navSlideOver}
|
|
445
|
-
/>
|
|
446
|
-
)}
|
|
447
|
-
</MailboxPane>
|
|
448
|
-
) : onOutboxRoute ? (
|
|
449
|
-
// Outbox — 2-pane layout (list + reading, no intelligence).
|
|
450
|
-
<OutboxPane>
|
|
451
|
-
{isSinglePane ? (
|
|
452
|
-
<AppShellSlotted
|
|
453
|
-
nav={navContent}
|
|
454
|
-
list={<OutboxPane.Phone />}
|
|
455
|
-
intelligenceOpen={false}
|
|
456
|
-
overlay={overlayContent}
|
|
457
|
-
skeleton={<AppShellSkeleton />}
|
|
458
|
-
isLoading={isLoading || hasNoAccounts}
|
|
459
|
-
{...navSlideOver}
|
|
460
|
-
/>
|
|
461
|
-
) : (
|
|
462
|
-
<AppShellSlotted
|
|
463
|
-
nav={navContent}
|
|
464
|
-
topBar={topBar}
|
|
465
|
-
list={<OutboxPane.List />}
|
|
466
|
-
reading={<OutboxPane.Reading />}
|
|
467
|
-
intelligenceOpen={false}
|
|
468
|
-
overlay={overlayContent}
|
|
469
|
-
skeleton={<AppShellSkeleton />}
|
|
470
|
-
isLoading={isLoading || hasNoAccounts}
|
|
471
|
-
{...navSlideOver}
|
|
472
|
-
/>
|
|
473
|
-
)}
|
|
474
|
-
</OutboxPane>
|
|
475
279
|
) : (
|
|
476
|
-
//
|
|
477
|
-
<
|
|
280
|
+
// Each list route mounts its own shell around its own panes.
|
|
281
|
+
<MailShellProvider chrome={shellChrome}>
|
|
282
|
+
<Outlet />
|
|
283
|
+
</MailShellProvider>
|
|
478
284
|
)}
|
|
479
285
|
<KeyboardShortcutsModal
|
|
480
286
|
isOpen={showShortcuts}
|
|
481
287
|
onClose={() => setShowShortcuts(false)}
|
|
482
288
|
/>
|
|
483
|
-
{/* Outlet is required for TanStack Router to activate child routes.
|
|
484
|
-
Routes that manage their own rendering (brief, mailbox, outbox) return
|
|
485
|
-
null from their component — the parent shell owns the layout. */}
|
|
486
|
-
<Outlet />
|
|
487
289
|
</MailFreshnessProvider>
|
|
488
290
|
</MailContext.Provider>
|
|
489
291
|
);
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asserts that each mail list route validates its search with the schema
|
|
3
|
+
* `lib/mail-search.ts` declares for that path. Wire a list to another list's
|
|
4
|
+
* schema and it fails here.
|
|
5
|
+
*
|
|
6
|
+
* It runs as its own process, spawned by `lib/route-search-query.test.ts`.
|
|
7
|
+
* Importing a route file pulls in the whole pane it mounts, and every module a
|
|
8
|
+
* test loads counts in the coverage denominator — reading `validateSearch`
|
|
9
|
+
* in-process dropped web-client's line coverage by ten points with nothing
|
|
10
|
+
* rendered to earn it back.
|
|
11
|
+
*/
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { mailListSearchSchemas } from "@/lib/mail-search";
|
|
14
|
+
import { Route as MailboxRoute } from "@/routes/mail/$mailboxId";
|
|
15
|
+
import { Route as BriefRoute } from "@/routes/mail/brief";
|
|
16
|
+
import { Route as FlaggedRoute } from "@/routes/mail/flagged";
|
|
17
|
+
import { Route as OutboxRoute } from "@/routes/mail/outbox";
|
|
18
|
+
|
|
19
|
+
const routes = {
|
|
20
|
+
"/mail/brief": BriefRoute,
|
|
21
|
+
"/mail/flagged": FlaggedRoute,
|
|
22
|
+
"/mail/outbox": OutboxRoute,
|
|
23
|
+
"/mail/$mailboxId": MailboxRoute,
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
for (const [path, route] of Object.entries(routes)) {
|
|
27
|
+
const declared =
|
|
28
|
+
mailListSearchSchemas[path as keyof typeof mailListSearchSchemas];
|
|
29
|
+
assert.equal(
|
|
30
|
+
route.options.validateSearch,
|
|
31
|
+
declared,
|
|
32
|
+
`${path} does not validate its search with the schema declared for it`,
|
|
33
|
+
);
|
|
34
|
+
}
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Where a global compose has to land.
|
|
3
|
-
*
|
|
4
|
-
* The compose surface (`FullCompose`) is mounted by the mailbox route only, so
|
|
5
|
-
* compose started from anywhere else — the daily brief, flagged, outbox — has
|
|
6
|
-
* to carry the user to a mailbox first. The target is the first account's
|
|
7
|
-
* inbox, falling back to its first mailbox.
|
|
8
|
-
*
|
|
9
|
-
* Both compose entry points use this: the top bar's button on desktop and the
|
|
10
|
-
* mobile `ComposeFab`. One resolver, so the two surfaces cannot disagree about
|
|
11
|
-
* which routes host the compose surface.
|
|
12
|
-
*/
|
|
13
|
-
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
14
|
-
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
15
|
-
import { useQueries } from "@tanstack/react-query";
|
|
16
|
-
import { useLocation, useNavigate } from "@tanstack/react-router";
|
|
17
|
-
import { startTransition, useCallback } from "react";
|
|
18
|
-
import { useCompose } from "@/components/compose/ComposeProvider";
|
|
19
|
-
import { hostsComposeSurface } from "@/lib/compose-routes";
|
|
20
|
-
import { buildMailboxRoleMap } from "@/lib/folder-roles";
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
* The mailbox id a global compose should navigate to, or undefined if none is
|
|
24
|
-
* known yet.
|
|
25
|
-
*
|
|
26
|
-
* Accounts resolve in order and an account whose mailbox query is still in
|
|
27
|
-
* flight blocks rather than being skipped: skipping hands back a later
|
|
28
|
-
* account's inbox and then silently swaps the target once the earlier query
|
|
29
|
-
* settles.
|
|
30
|
-
*/
|
|
31
|
-
export function useComposeTargetMailboxId(
|
|
32
|
-
accounts: RemitImapAccountResponse[],
|
|
33
|
-
): string | undefined {
|
|
34
|
-
const mailboxQueries = useQueries({
|
|
35
|
-
queries: accounts.map((account) => ({
|
|
36
|
-
...mailboxOperationsListMailboxesOptions({
|
|
37
|
-
path: { accountId: account.accountId },
|
|
38
|
-
}),
|
|
39
|
-
staleTime: Infinity,
|
|
40
|
-
})),
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
for (const [index, account] of accounts.entries()) {
|
|
44
|
-
const query = mailboxQueries[index];
|
|
45
|
-
if (!query || query.isPending) return undefined;
|
|
46
|
-
const mailboxes = query.data?.items ?? [];
|
|
47
|
-
if (mailboxes.length === 0) continue;
|
|
48
|
-
const roleMap = buildMailboxRoleMap(account.folderAppointments);
|
|
49
|
-
const inbox = mailboxes.find(
|
|
50
|
-
(mailbox) => roleMap.get(mailbox.mailboxId) === "inbox",
|
|
51
|
-
);
|
|
52
|
-
return (inbox ?? mailboxes[0])?.mailboxId;
|
|
53
|
-
}
|
|
54
|
-
return undefined;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* A compose action that works from every view: opens compose in place when the
|
|
59
|
-
* current route already hosts the surface, otherwise navigates to the target
|
|
60
|
-
* mailbox — compose state lives in `ComposeProvider` (mounted at the root), so
|
|
61
|
-
* it survives the transition and the destination mounts straight into it.
|
|
62
|
-
*
|
|
63
|
-
* With no target resolved yet — a cold load whose mailbox queries have not
|
|
64
|
-
* settled, or accounts with no mailboxes — the action does nothing. Opening
|
|
65
|
-
* compose state first would leave it open with nothing rendering it, and it
|
|
66
|
-
* would then pop up unprompted on the next navigation.
|
|
67
|
-
*/
|
|
68
|
-
export function useGlobalCompose(
|
|
69
|
-
accounts: RemitImapAccountResponse[],
|
|
70
|
-
): () => void {
|
|
71
|
-
const { openCompose } = useCompose();
|
|
72
|
-
const navigate = useNavigate();
|
|
73
|
-
const location = useLocation();
|
|
74
|
-
const targetMailboxId = useComposeTargetMailboxId(accounts);
|
|
75
|
-
|
|
76
|
-
return useCallback(() => {
|
|
77
|
-
if (hostsComposeSurface(location.pathname)) {
|
|
78
|
-
startTransition(() => {
|
|
79
|
-
openCompose({ mode: "new" });
|
|
80
|
-
});
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
if (!targetMailboxId) return;
|
|
84
|
-
startTransition(() => {
|
|
85
|
-
openCompose({ mode: "new" });
|
|
86
|
-
});
|
|
87
|
-
navigate({
|
|
88
|
-
to: "/mail/$mailboxId",
|
|
89
|
-
params: { mailboxId: targetMailboxId },
|
|
90
|
-
});
|
|
91
|
-
}, [openCompose, navigate, location.pathname, targetMailboxId]);
|
|
92
|
-
}
|