@remit/web-client 0.0.11 → 0.0.13
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/layout/ComposeFab.tsx +19 -34
- package/src/components/layout/MailTopBar.tsx +64 -0
- package/src/components/mail/BriefPane.tsx +1 -12
- package/src/components/mail/FlaggedPane.tsx +1 -12
- package/src/components/mail/MailListHeader.tsx +9 -5
- package/src/components/mail/MailboxPane.tsx +0 -10
- package/src/components/mail/MessageToolbar.render.test.ts +39 -0
- package/src/components/mail/MessageToolbar.tsx +8 -48
- package/src/hooks/useComposeTarget.ts +92 -0
- package/src/hooks/useLayoutTier.test.ts +26 -0
- package/src/lib/brief.test.ts +17 -2
- package/src/lib/brief.ts +11 -3
- package/src/lib/compose-routes.test.ts +44 -0
- package/src/lib/compose-routes.ts +25 -0
- package/src/lib/display-category.test.ts +29 -0
- package/src/lib/display-category.ts +9 -7
- package/src/lib/route-search-query.test.ts +48 -0
- package/src/routes/mail/outbox.tsx +5 -0
- package/src/routes/mail.tsx +15 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.13",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -1,16 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import { useLocation } from "@tanstack/react-router";
|
|
2
3
|
import { Pencil } from "lucide-react";
|
|
3
|
-
import { startTransition } from "react";
|
|
4
4
|
import { useCompose } from "@/components/compose/ComposeProvider";
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Returns true when `pathname` is `/mail/<id>` for any id other than
|
|
8
|
-
* the reserved `outbox` segment. That route owns a `FullCompose` mount
|
|
9
|
-
* point, so the FAB can open compose in-place. From every other route
|
|
10
|
-
* the FAB has to send the user there first.
|
|
11
|
-
*/
|
|
12
|
-
const isOnMailboxRoute = (pathname: string): boolean =>
|
|
13
|
-
/^\/mail\/(?!outbox)[^/?]+/.test(pathname);
|
|
5
|
+
import { useGlobalCompose } from "@/hooks/useComposeTarget";
|
|
14
6
|
|
|
15
7
|
/**
|
|
16
8
|
* Primary mobile surfaces where the FAB belongs: anywhere under
|
|
@@ -21,30 +13,34 @@ const isOnMailboxRoute = (pathname: string): boolean =>
|
|
|
21
13
|
const isOnPrimaryMobileRoute = (pathname: string): boolean =>
|
|
22
14
|
pathname.startsWith("/mail") || pathname.startsWith("/settings");
|
|
23
15
|
|
|
16
|
+
interface ComposeFabProps {
|
|
17
|
+
accounts: RemitImapAccountResponse[];
|
|
18
|
+
}
|
|
19
|
+
|
|
24
20
|
/**
|
|
25
21
|
* Floating Action Button for composing a new message. Mobile-only.
|
|
26
22
|
*
|
|
27
23
|
* Layout follows Material 3: 56×56 surface, 16px from the right and
|
|
28
24
|
* bottom edges (plus the iOS safe-area inset). Hidden when any of:
|
|
29
|
-
* - Viewport is `≥
|
|
25
|
+
* - Viewport is `≥ lg` (1024px), where the top bar owns compose. The
|
|
26
|
+
* `/mail` shell also stops mounting the FAB above that width; the
|
|
27
|
+
* `lg:hidden` class covers the pre-hydration frame.
|
|
30
28
|
* - The compose surface is already open.
|
|
31
29
|
* - The user is reading a thread (`?selectedMessageId=…`) — the
|
|
32
30
|
* conversation's Reply/Forward action bar covers that workflow.
|
|
33
31
|
* - The user is not on a primary mobile route (`/mail` or
|
|
34
32
|
* `/settings`).
|
|
35
33
|
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* lives in `__root.tsx`, so the destination route mounts straight into
|
|
42
|
-
* compose.
|
|
34
|
+
* The tap itself is `useGlobalCompose`, shared with the desktop top bar:
|
|
35
|
+
* it opens compose in place on routes that mount `FullCompose` and
|
|
36
|
+
* otherwise carries the user to a real mailbox that does. Compose state
|
|
37
|
+
* survives that transition because `ComposeProvider` lives in
|
|
38
|
+
* `__root.tsx`.
|
|
43
39
|
*/
|
|
44
|
-
export const ComposeFab = () => {
|
|
45
|
-
const { state
|
|
40
|
+
export const ComposeFab = ({ accounts }: ComposeFabProps) => {
|
|
41
|
+
const { state } = useCompose();
|
|
46
42
|
const location = useLocation();
|
|
47
|
-
const
|
|
43
|
+
const compose = useGlobalCompose(accounts);
|
|
48
44
|
|
|
49
45
|
const search = location.search as Record<string, unknown> | undefined;
|
|
50
46
|
const isReadingThread =
|
|
@@ -58,21 +54,10 @@ export const ComposeFab = () => {
|
|
|
58
54
|
)
|
|
59
55
|
return null;
|
|
60
56
|
|
|
61
|
-
const handleClick = () => {
|
|
62
|
-
// Wrap in startTransition so the suspension that ComposeForm
|
|
63
|
-
// triggers on first mount (lazy chunks, queries) doesn't
|
|
64
|
-
// snap the surrounding chrome into a route-level fallback.
|
|
65
|
-
startTransition(() => {
|
|
66
|
-
openCompose({ mode: "new" });
|
|
67
|
-
});
|
|
68
|
-
if (isOnMailboxRoute(location.pathname)) return;
|
|
69
|
-
navigate({ to: "/mail" });
|
|
70
|
-
};
|
|
71
|
-
|
|
72
57
|
return (
|
|
73
58
|
<button
|
|
74
59
|
type="button"
|
|
75
|
-
onClick={
|
|
60
|
+
onClick={compose}
|
|
76
61
|
aria-label="Compose new message"
|
|
77
62
|
className="lg:hidden fixed right-4 z-30 h-14 w-14 rounded-full bg-accent text-accent-fg shadow-lg flex items-center justify-center hover:opacity-90 active:scale-95 transition-all"
|
|
78
63
|
style={{
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MailTopBar — the app's one search surface and its global actions.
|
|
3
|
+
*
|
|
4
|
+
* Mounted above every pane by the `/mail` shell, so the field spans the nav,
|
|
5
|
+
* the list and the reading pane. It is the app's search, not the list's: the
|
|
6
|
+
* list header drops its own field wherever this bar is mounted, so exactly one
|
|
7
|
+
* search input exists on the page and the "/" shortcut has one target.
|
|
8
|
+
*
|
|
9
|
+
* The actions here are the ones that belong to the app rather than to whatever
|
|
10
|
+
* is currently listed or open — compose, bug report, account. Reply, delete,
|
|
11
|
+
* move and the rest stay on the reading pane's own toolbar, under this bar.
|
|
12
|
+
*/
|
|
13
|
+
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
14
|
+
import { AppTopBar, Button, SearchBar } from "@remit/ui";
|
|
15
|
+
import { SquarePen } from "lucide-react";
|
|
16
|
+
import { AccountMenu } from "@/auth/AccountMenu";
|
|
17
|
+
import { BugReportButton } from "@/components/ui/BugReportButton";
|
|
18
|
+
import { useGlobalCompose } from "@/hooks/useComposeTarget";
|
|
19
|
+
import { tooltipForAction } from "@/lib/keymap";
|
|
20
|
+
import { useMailContext } from "@/lib/mail-context";
|
|
21
|
+
|
|
22
|
+
interface MailTopBarProps {
|
|
23
|
+
accounts: RemitImapAccountResponse[];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function MailTopBar({ accounts }: MailTopBarProps) {
|
|
27
|
+
const { searchInput, onSearchChange, onSearchClear, onSearchClearQuery } =
|
|
28
|
+
useMailContext();
|
|
29
|
+
const compose = useGlobalCompose(accounts);
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<AppTopBar
|
|
33
|
+
leading={
|
|
34
|
+
<span className="px-1 text-sm font-semibold tracking-tight text-fg">
|
|
35
|
+
remit
|
|
36
|
+
</span>
|
|
37
|
+
}
|
|
38
|
+
search={
|
|
39
|
+
<SearchBar
|
|
40
|
+
value={searchInput}
|
|
41
|
+
onChange={onSearchChange}
|
|
42
|
+
onClear={onSearchClear}
|
|
43
|
+
onClearQuery={onSearchClearQuery}
|
|
44
|
+
placeholder="Search all mail"
|
|
45
|
+
size="lg"
|
|
46
|
+
/>
|
|
47
|
+
}
|
|
48
|
+
actions={
|
|
49
|
+
<>
|
|
50
|
+
<Button
|
|
51
|
+
variant="ghost"
|
|
52
|
+
size="sm"
|
|
53
|
+
icon={<SquarePen className="size-4" />}
|
|
54
|
+
title={`Compose ${tooltipForAction("compose")}`}
|
|
55
|
+
aria-label="Compose"
|
|
56
|
+
onClick={compose}
|
|
57
|
+
/>
|
|
58
|
+
<BugReportButton />
|
|
59
|
+
<AccountMenu />
|
|
60
|
+
</>
|
|
61
|
+
}
|
|
62
|
+
/>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
@@ -184,26 +184,15 @@ function BriefList() {
|
|
|
184
184
|
*/
|
|
185
185
|
function BriefReading() {
|
|
186
186
|
const { conversation } = useBriefPane();
|
|
187
|
-
const {
|
|
188
|
-
onToggleIntelligence,
|
|
189
|
-
searchInput,
|
|
190
|
-
onSearchChange,
|
|
191
|
-
onSearchClear,
|
|
192
|
-
onSearchClearQuery,
|
|
193
|
-
} = useMailContext();
|
|
187
|
+
const { onToggleIntelligence } = useMailContext();
|
|
194
188
|
|
|
195
189
|
return (
|
|
196
190
|
<section className="flex h-full w-full min-w-0 flex-col bg-canvas">
|
|
197
191
|
<MessageToolbar
|
|
198
192
|
hasThread={Boolean(conversation)}
|
|
199
|
-
onCompose={() => undefined}
|
|
200
193
|
intelligenceOpen={false}
|
|
201
194
|
showIntelligenceToggle={false}
|
|
202
195
|
onToggleIntelligence={onToggleIntelligence}
|
|
203
|
-
searchValue={searchInput}
|
|
204
|
-
onSearchChange={onSearchChange}
|
|
205
|
-
onSearchClear={onSearchClear}
|
|
206
|
-
onSearchClearQuery={onSearchClearQuery}
|
|
207
196
|
/>
|
|
208
197
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
209
198
|
{conversation ? (
|
|
@@ -128,26 +128,15 @@ function FlaggedListSlot() {
|
|
|
128
128
|
*/
|
|
129
129
|
function FlaggedReading() {
|
|
130
130
|
const { selectedThread } = useFlaggedPane();
|
|
131
|
-
const {
|
|
132
|
-
onToggleIntelligence,
|
|
133
|
-
searchInput,
|
|
134
|
-
onSearchChange,
|
|
135
|
-
onSearchClear,
|
|
136
|
-
onSearchClearQuery,
|
|
137
|
-
} = useMailContext();
|
|
131
|
+
const { onToggleIntelligence } = useMailContext();
|
|
138
132
|
|
|
139
133
|
return (
|
|
140
134
|
<section className="flex h-full w-full min-w-0 flex-col bg-canvas">
|
|
141
135
|
<MessageToolbar
|
|
142
136
|
hasThread={Boolean(selectedThread)}
|
|
143
|
-
onCompose={() => undefined}
|
|
144
137
|
intelligenceOpen={false}
|
|
145
138
|
showIntelligenceToggle={false}
|
|
146
139
|
onToggleIntelligence={onToggleIntelligence}
|
|
147
|
-
searchValue={searchInput}
|
|
148
|
-
onSearchChange={onSearchChange}
|
|
149
|
-
onSearchClear={onSearchClear}
|
|
150
|
-
onSearchClearQuery={onSearchClearQuery}
|
|
151
140
|
/>
|
|
152
141
|
<div className="min-h-0 flex-1 overflow-hidden">
|
|
153
142
|
{selectedThread ? (
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
useAppShellLayout,
|
|
32
32
|
} from "@remit/ui";
|
|
33
33
|
import { type ReactNode, useState } from "react";
|
|
34
|
-
import { useLayoutTier } from "@/hooks/useLayoutTier";
|
|
34
|
+
import { isSinglePaneTier, useLayoutTier } from "@/hooks/useLayoutTier";
|
|
35
35
|
import { useMailContext } from "@/lib/mail-context";
|
|
36
36
|
import { loadRecentSearches, saveRecentSearch } from "@/lib/recent-searches";
|
|
37
37
|
import {
|
|
@@ -168,11 +168,15 @@ export function MailListHeader({
|
|
|
168
168
|
<MailHeader
|
|
169
169
|
title={title}
|
|
170
170
|
unreadCount={unreadCount}
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
171
|
+
// Desktop mounts the app top bar, which owns search for the whole
|
|
172
|
+
// shell — the list header shows no field there, so the page never
|
|
173
|
+
// has two search inputs competing for "/" and for focus. Below
|
|
174
|
+
// desktop the header keeps a compact magnifier: on phone it opens
|
|
175
|
+
// the full-screen takeover above, on tablet it expands over the
|
|
176
|
+
// title. `isSinglePaneTier` is the same predicate the shell gates
|
|
177
|
+
// the top bar on, so the two cannot drift into zero or two fields.
|
|
175
178
|
isDesktop={false}
|
|
179
|
+
showSearch={isSinglePaneTier(tier)}
|
|
176
180
|
onMenuClick={() => layout?.openNav()}
|
|
177
181
|
searchValue={searchInput}
|
|
178
182
|
onSearchChange={onSearchChange}
|
|
@@ -202,7 +202,6 @@ interface MailboxPaneContextValue {
|
|
|
202
202
|
onToolbarDiscardDraft: () => void;
|
|
203
203
|
onToolbarMove: (destMailboxId: string) => void;
|
|
204
204
|
composeState: ReturnType<typeof useCompose>["state"];
|
|
205
|
-
openCompose: () => void;
|
|
206
205
|
closeCompose: () => void;
|
|
207
206
|
hasRemitDraftOpen: boolean;
|
|
208
207
|
// Phone actions
|
|
@@ -843,7 +842,6 @@ function MailboxPaneProvider({
|
|
|
843
842
|
onToolbarDiscardDraft: handleToolbarDiscardDraft,
|
|
844
843
|
onToolbarMove: handleToolbarMove,
|
|
845
844
|
composeState,
|
|
846
|
-
openCompose: handleNewCompose,
|
|
847
845
|
closeCompose,
|
|
848
846
|
hasRemitDraftOpen,
|
|
849
847
|
onBack: goBack,
|
|
@@ -1044,10 +1042,7 @@ function MailboxReading() {
|
|
|
1044
1042
|
onToolbarDiscardDraft,
|
|
1045
1043
|
onToolbarMove,
|
|
1046
1044
|
composeState,
|
|
1047
|
-
openCompose,
|
|
1048
1045
|
} = useMailboxPane();
|
|
1049
|
-
const { searchInput, onSearchChange, onSearchClear, onSearchClearQuery } =
|
|
1050
|
-
useMailContext();
|
|
1051
1046
|
const tier = useLayoutTier();
|
|
1052
1047
|
const isDesktop = tier === "desktop";
|
|
1053
1048
|
const hasThread = Boolean(conversation);
|
|
@@ -1078,14 +1073,9 @@ function MailboxReading() {
|
|
|
1078
1073
|
<section className="flex h-full w-full min-w-0 flex-col bg-canvas">
|
|
1079
1074
|
<MessageToolbar
|
|
1080
1075
|
hasThread={hasThread}
|
|
1081
|
-
onCompose={openCompose}
|
|
1082
1076
|
intelligenceOpen={showIntelligence}
|
|
1083
1077
|
showIntelligenceToggle={isDesktop && hasThread}
|
|
1084
1078
|
onToggleIntelligence={onToggleIntelligence}
|
|
1085
|
-
searchValue={searchInput}
|
|
1086
|
-
onSearchChange={onSearchChange}
|
|
1087
|
-
onSearchClear={onSearchClear}
|
|
1088
|
-
onSearchClearQuery={onSearchClearQuery}
|
|
1089
1079
|
onReply={hasThread ? onToolbarReply : undefined}
|
|
1090
1080
|
onReplyAll={hasThread ? onToolbarReplyAll : undefined}
|
|
1091
1081
|
onForward={hasThread ? onToolbarForward : undefined}
|
|
@@ -12,6 +12,7 @@ import { describe, it } from "node:test";
|
|
|
12
12
|
import { MailActionToolbar } from "@remit/ui";
|
|
13
13
|
import React, { createElement } from "react";
|
|
14
14
|
import { renderToString } from "react-dom/server";
|
|
15
|
+
import { MessageToolbar } from "./MessageToolbar";
|
|
15
16
|
|
|
16
17
|
// The node test loader transpiles remit-ui's `.tsx` (resolved through
|
|
17
18
|
// node_modules) with the classic JSX runtime, which references a global
|
|
@@ -55,3 +56,41 @@ describe("MailActionToolbar never disables its action buttons (#799)", () => {
|
|
|
55
56
|
assert.doesNotMatch(html, /aria-label="Archive"/);
|
|
56
57
|
});
|
|
57
58
|
});
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The reading-pane toolbar carries message context only (#49). Search, compose,
|
|
62
|
+
* bug report and the account menu moved up into the app top bar, which spans
|
|
63
|
+
* every pane. Two search fields on one screen fight over "/" and over focus, so
|
|
64
|
+
* the absence of one here is the thing worth pinning.
|
|
65
|
+
*/
|
|
66
|
+
describe("MessageToolbar carries message context only (#49)", () => {
|
|
67
|
+
const render = (hasThread: boolean): string =>
|
|
68
|
+
renderToString(
|
|
69
|
+
createElement(MessageToolbar, {
|
|
70
|
+
hasThread,
|
|
71
|
+
intelligenceOpen: false,
|
|
72
|
+
showIntelligenceToggle: true,
|
|
73
|
+
onToggleIntelligence: () => undefined,
|
|
74
|
+
}) as never,
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
it("mounts no search field", () => {
|
|
78
|
+
for (const hasThread of [false, true]) {
|
|
79
|
+
assert.doesNotMatch(render(hasThread), /aria-label="Search mail"/);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("carries no global actions — compose, bug report, account", () => {
|
|
84
|
+
const html = render(true);
|
|
85
|
+
assert.doesNotMatch(html, /aria-label="Compose"/);
|
|
86
|
+
assert.doesNotMatch(html, /aria-label="Report a bug"/);
|
|
87
|
+
assert.doesNotMatch(html, /aria-label="Account menu"/);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("keeps the message verbs and the intelligence toggle", () => {
|
|
91
|
+
const html = render(true);
|
|
92
|
+
assert.match(html, /aria-label="Reply"/);
|
|
93
|
+
assert.match(html, /aria-label="Star"/);
|
|
94
|
+
assert.match(html, /intelligence sidebar/);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
@@ -1,22 +1,17 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
type MailAction,
|
|
4
|
-
MailActionToolbar,
|
|
5
|
-
SearchBar,
|
|
6
|
-
} from "@remit/ui";
|
|
7
|
-
import { Info, SquarePen } from "lucide-react";
|
|
1
|
+
import { Button, type MailAction, MailActionToolbar } from "@remit/ui";
|
|
2
|
+
import { Info } from "lucide-react";
|
|
8
3
|
import { useState } from "react";
|
|
9
|
-
import { AccountMenu } from "@/auth/AccountMenu";
|
|
10
|
-
import { BugReportButton } from "@/components/ui/BugReportButton";
|
|
11
4
|
import { tooltipForAction } from "@/lib/keymap";
|
|
12
5
|
import { MoveToTrigger } from "./MoveToTrigger";
|
|
13
6
|
|
|
14
7
|
/**
|
|
15
8
|
* Message action toolbar on the pane-header datum (40px, the shared
|
|
16
|
-
* `--spacing-pane-header`).
|
|
17
|
-
* forward, then delete / move / flag — Apple-Mail-style ghost icon
|
|
18
|
-
*
|
|
19
|
-
*
|
|
9
|
+
* `--spacing-pane-header`). Everything here acts on the open message: reply /
|
|
10
|
+
* reply-all / forward, then delete / move / flag — Apple-Mail-style ghost icon
|
|
11
|
+
* buttons — and the intelligence toggle (#422).
|
|
12
|
+
*
|
|
13
|
+
* Search, compose, bug report and the account menu are not message context;
|
|
14
|
+
* they live in the app top bar above every pane (`MailTopBar`, #49).
|
|
20
15
|
*
|
|
21
16
|
* Buttons are always pressable (never `disabled`): with no thread open a press
|
|
22
17
|
* is a no-op that surfaces a one-line inline explanation rather than greying
|
|
@@ -24,7 +19,6 @@ import { MoveToTrigger } from "./MoveToTrigger";
|
|
|
24
19
|
*/
|
|
25
20
|
export interface MessageToolbarProps {
|
|
26
21
|
hasThread: boolean;
|
|
27
|
-
onCompose: () => void;
|
|
28
22
|
intelligenceOpen: boolean;
|
|
29
23
|
/**
|
|
30
24
|
* Whether the intelligence toggle is shown at all. The rail is contextual
|
|
@@ -33,12 +27,6 @@ export interface MessageToolbarProps {
|
|
|
33
27
|
*/
|
|
34
28
|
showIntelligenceToggle: boolean;
|
|
35
29
|
onToggleIntelligence: () => void;
|
|
36
|
-
searchValue: string;
|
|
37
|
-
onSearchChange: (value: string) => void;
|
|
38
|
-
/** Full clear (X button): drops the query and any selected thread (#538). */
|
|
39
|
-
onSearchClear: () => void;
|
|
40
|
-
/** Query-only clear (Esc): drops the query, keeps the thread open (#489). */
|
|
41
|
-
onSearchClearQuery?: () => void;
|
|
42
30
|
|
|
43
31
|
/* ---- wired action callbacks (omit to keep the no-op-explain behaviour) ---- */
|
|
44
32
|
onReply?: () => void;
|
|
@@ -71,14 +59,9 @@ const OPEN_FIRST = "Open a message first";
|
|
|
71
59
|
|
|
72
60
|
export const MessageToolbar = ({
|
|
73
61
|
hasThread,
|
|
74
|
-
onCompose,
|
|
75
62
|
intelligenceOpen,
|
|
76
63
|
showIntelligenceToggle,
|
|
77
64
|
onToggleIntelligence,
|
|
78
|
-
searchValue,
|
|
79
|
-
onSearchChange,
|
|
80
|
-
onSearchClear,
|
|
81
|
-
onSearchClearQuery,
|
|
82
65
|
onReply,
|
|
83
66
|
onReplyAll,
|
|
84
67
|
onForward,
|
|
@@ -120,27 +103,6 @@ export const MessageToolbar = ({
|
|
|
120
103
|
) : undefined
|
|
121
104
|
}
|
|
122
105
|
>
|
|
123
|
-
{/* Apple Mail geometry: search sits top-right over the message area
|
|
124
|
-
but still filters the current list. Reuses the wired SearchBar
|
|
125
|
-
(the global "/" focus shortcut + Escape handling live in it). */}
|
|
126
|
-
<div className="w-64 min-w-40 shrink">
|
|
127
|
-
<SearchBar
|
|
128
|
-
value={searchValue}
|
|
129
|
-
onChange={onSearchChange}
|
|
130
|
-
onClear={onSearchClear}
|
|
131
|
-
onClearQuery={onSearchClearQuery}
|
|
132
|
-
placeholder="Search mail"
|
|
133
|
-
/>
|
|
134
|
-
</div>
|
|
135
|
-
<span className="mx-1 h-4 w-px bg-line" aria-hidden />
|
|
136
|
-
<Button
|
|
137
|
-
variant="ghost"
|
|
138
|
-
size="sm"
|
|
139
|
-
icon={<SquarePen className="size-4" />}
|
|
140
|
-
title={`Compose ${tooltipForAction("compose")}`}
|
|
141
|
-
aria-label="Compose"
|
|
142
|
-
onClick={onCompose}
|
|
143
|
-
/>
|
|
144
106
|
{showIntelligenceToggle && (
|
|
145
107
|
<Button
|
|
146
108
|
variant="ghost"
|
|
@@ -159,8 +121,6 @@ export const MessageToolbar = ({
|
|
|
159
121
|
}
|
|
160
122
|
/>
|
|
161
123
|
)}
|
|
162
|
-
<BugReportButton />
|
|
163
|
-
<AccountMenu />
|
|
164
124
|
</MailActionToolbar>
|
|
165
125
|
);
|
|
166
126
|
};
|
|
@@ -0,0 +1,92 @@
|
|
|
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
|
+
}
|
|
@@ -45,3 +45,29 @@ describe("isSinglePaneTier — compose surface must mount below desktop", () =>
|
|
|
45
45
|
assert.equal(isSinglePaneTier(resolveLayoutTier(1024)), false);
|
|
46
46
|
});
|
|
47
47
|
});
|
|
48
|
+
|
|
49
|
+
describe("exactly one search field is mounted at every width", () => {
|
|
50
|
+
// Two call sites decide this from the same tier: `mail.tsx` mounts the top
|
|
51
|
+
// bar's field when the layout is not single-pane, and `MailListHeader`
|
|
52
|
+
// passes `showSearch` to keep the header's field otherwise. Both read
|
|
53
|
+
// `isSinglePaneTier`, so they are each other's complement by construction —
|
|
54
|
+
// if either is rewritten to its own expression this fails. Two mounted
|
|
55
|
+
// fields compete for "/" and for focus (#59); zero leaves no way to search.
|
|
56
|
+
const mountsTopBarField = (width: number): boolean =>
|
|
57
|
+
!isSinglePaneTier(resolveLayoutTier(width));
|
|
58
|
+
const mountsHeaderField = (width: number): boolean =>
|
|
59
|
+
isSinglePaneTier(resolveLayoutTier(width));
|
|
60
|
+
|
|
61
|
+
for (const width of [0, 390, 767, 768, 1023, 1024, 1440]) {
|
|
62
|
+
it(`mounts one field at ${width}px`, () => {
|
|
63
|
+
const fields =
|
|
64
|
+
Number(mountsTopBarField(width)) + Number(mountsHeaderField(width));
|
|
65
|
+
assert.equal(fields, 1, `${width}px mounts ${fields} search fields`);
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
it("puts the field in the top bar only from desktop up", () => {
|
|
70
|
+
assert.equal(mountsTopBarField(1023), false);
|
|
71
|
+
assert.equal(mountsTopBarField(1024), true);
|
|
72
|
+
});
|
|
73
|
+
});
|
package/src/lib/brief.test.ts
CHANGED
|
@@ -145,11 +145,26 @@ describe("groupBriefSections", () => {
|
|
|
145
145
|
|
|
146
146
|
// --- Fallback to personal ---
|
|
147
147
|
|
|
148
|
-
test("missing category
|
|
148
|
+
test("missing category lands in its own Unclassified section", () => {
|
|
149
|
+
// Never folded into Personal: unclassified mail is work the classifier
|
|
150
|
+
// has not done, and hiding it inside Personal is what made issue #45
|
|
151
|
+
// look like a working classifier with a huge personal inbox.
|
|
149
152
|
const r = row({ id: "1" });
|
|
150
153
|
const sections = groupBriefSections([r]);
|
|
151
154
|
assert.strictEqual(sections.length, 1);
|
|
152
|
-
assert.strictEqual(sections[0].id, "
|
|
155
|
+
assert.strictEqual(sections[0].id, "uncategorized");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("uncategorized rows do not inflate the personal section", () => {
|
|
159
|
+
const sections = groupBriefSections([
|
|
160
|
+
row({ id: "1", category: "personal" }),
|
|
161
|
+
row({ id: "2", category: "uncategorized" }),
|
|
162
|
+
row({ id: "3", category: "uncategorized" }),
|
|
163
|
+
]);
|
|
164
|
+
const personal = sections.find((s) => s.id === "personal");
|
|
165
|
+
const unclassified = sections.find((s) => s.id === "uncategorized");
|
|
166
|
+
assert.strictEqual(personal?.threads.length, 1);
|
|
167
|
+
assert.strictEqual(unclassified?.threads.length, 2);
|
|
153
168
|
});
|
|
154
169
|
|
|
155
170
|
// --- Starred is a row marker, not a section (Flagged lives in the nav) ---
|
package/src/lib/brief.ts
CHANGED
|
@@ -10,9 +10,12 @@
|
|
|
10
10
|
* 4. Marketing
|
|
11
11
|
* 5. Social
|
|
12
12
|
* 6. Automated
|
|
13
|
+
* 7. Unclassified
|
|
13
14
|
*
|
|
14
15
|
* Each row lands in the section for its category; a row with no category counts
|
|
15
|
-
* as `
|
|
16
|
+
* as `uncategorized`, which is its own section rather than being folded into
|
|
17
|
+
* Personal — unclassified mail is missing work, not a decision (issue #45).
|
|
18
|
+
* Starred mail is not a section —
|
|
16
19
|
* the star is a per-row marker, so a starred message stays in its category.
|
|
17
20
|
*
|
|
18
21
|
* Sender trust (vip/wellknown) no longer sections the brief — the signal is
|
|
@@ -87,6 +90,11 @@ const CATEGORY_SECTIONS: ReadonlyArray<{
|
|
|
87
90
|
{ id: "marketing", label: "Marketing", category: MessageCategory.marketing },
|
|
88
91
|
{ id: "social", label: "Social", category: MessageCategory.social },
|
|
89
92
|
{ id: "automated", label: "Automated", category: MessageCategory.automated },
|
|
93
|
+
{
|
|
94
|
+
id: "uncategorized",
|
|
95
|
+
label: "Unclassified",
|
|
96
|
+
category: MessageCategory.uncategorized,
|
|
97
|
+
},
|
|
90
98
|
];
|
|
91
99
|
|
|
92
100
|
/**
|
|
@@ -104,9 +112,9 @@ export function groupBriefSections(rows: ThreadRowData[]): ThreadSection[] {
|
|
|
104
112
|
);
|
|
105
113
|
|
|
106
114
|
for (const row of rows) {
|
|
107
|
-
const category = row.category ?? MessageCategory.
|
|
115
|
+
const category = row.category ?? MessageCategory.uncategorized;
|
|
108
116
|
const bucket =
|
|
109
|
-
byCategory.get(category) ?? byCategory.get(MessageCategory.
|
|
117
|
+
byCategory.get(category) ?? byCategory.get(MessageCategory.uncategorized);
|
|
110
118
|
bucket?.push(row);
|
|
111
119
|
}
|
|
112
120
|
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one route resolver behind both compose entry points — the desktop top
|
|
3
|
+
* bar's button and the mobile `ComposeFab`. When they each carried their own
|
|
4
|
+
* copy the two disagreed, and the FAB opened compose state on routes that
|
|
5
|
+
* mount no surface: a dead button.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { describe, it } from "node:test";
|
|
9
|
+
import { hostsComposeSurface } from "./compose-routes";
|
|
10
|
+
|
|
11
|
+
describe("hostsComposeSurface", () => {
|
|
12
|
+
it("is true for a mailbox route, which mounts FullCompose", () => {
|
|
13
|
+
assert.equal(hostsComposeSurface("/mail/INBOX"), true);
|
|
14
|
+
assert.equal(hostsComposeSurface("/mail/abc-123"), true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("is false for the virtual views, which mount no surface", () => {
|
|
18
|
+
assert.equal(hostsComposeSurface("/mail/outbox"), false);
|
|
19
|
+
assert.equal(hostsComposeSurface("/mail/flagged"), false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("is false for the daily brief, which is /mail itself", () => {
|
|
23
|
+
assert.equal(hostsComposeSurface("/mail"), false);
|
|
24
|
+
assert.equal(hostsComposeSurface("/mail/"), false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it("is false outside the mail shell", () => {
|
|
28
|
+
assert.equal(hostsComposeSurface("/settings/accounts"), false);
|
|
29
|
+
assert.equal(hostsComposeSurface("/"), false);
|
|
30
|
+
assert.equal(hostsComposeSurface("/mailroom/x"), false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("matches whole segments, so a mailbox may be named after a view", () => {
|
|
34
|
+
assert.equal(hostsComposeSurface("/mail/outbox-2024"), true);
|
|
35
|
+
assert.equal(hostsComposeSurface("/mail/flagged-archive"), true);
|
|
36
|
+
assert.equal(hostsComposeSurface("/mail/outboxes"), true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("ignores a query string or hash on the path", () => {
|
|
40
|
+
assert.equal(hostsComposeSurface("/mail/INBOX?q=invoice"), true);
|
|
41
|
+
assert.equal(hostsComposeSurface("/mail/outbox?q=invoice"), false);
|
|
42
|
+
assert.equal(hostsComposeSurface("/mail/INBOX#top"), true);
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which routes mount the compose surface.
|
|
3
|
+
*
|
|
4
|
+
* `FullCompose` is mounted by the mailbox route only, so compose started from
|
|
5
|
+
* anywhere else has to carry the user to a mailbox first. Kept apart from
|
|
6
|
+
* `useComposeTarget` so it stays a plain function with no React or API
|
|
7
|
+
* dependencies — both compose entry points resolve routes through this one
|
|
8
|
+
* definition, and a divergent second copy is what left the mobile FAB dead on
|
|
9
|
+
* `/mail/flagged` and on the brief.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** `/mail/<segment>` values that name a view rather than a mailbox. */
|
|
13
|
+
const VIRTUAL_MAIL_VIEWS = new Set(["outbox", "flagged"]);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* True for `/mail/<id>` where `<id>` is a real mailbox.
|
|
17
|
+
*
|
|
18
|
+
* Compares whole path segments: a mailbox genuinely named `outbox-2024` hosts
|
|
19
|
+
* the surface and must not be read as the virtual outbox.
|
|
20
|
+
*/
|
|
21
|
+
export const hostsComposeSurface = (pathname: string): boolean => {
|
|
22
|
+
const [, root, view] = pathname.split(/[?#]/)[0].split("/");
|
|
23
|
+
if (root !== "mail" || !view) return false;
|
|
24
|
+
return !VIRTUAL_MAIL_VIEWS.has(view);
|
|
25
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import { toDisplayCategory } from "./display-category.js";
|
|
4
|
+
|
|
5
|
+
describe("toDisplayCategory", () => {
|
|
6
|
+
test("shows an unclassified message as unclassified, not personal", () => {
|
|
7
|
+
// Collapsing `uncategorized` into `personal` made "the classifier never
|
|
8
|
+
// ran" indistinguishable from "the classifier decided this is personal"
|
|
9
|
+
// (issue #45).
|
|
10
|
+
assert.strictEqual(toDisplayCategory("uncategorized"), "uncategorized");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
test("treats a row with no category as unclassified", () => {
|
|
14
|
+
assert.strictEqual(toDisplayCategory(undefined), "uncategorized");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("passes every classified category through unchanged", () => {
|
|
18
|
+
for (const category of [
|
|
19
|
+
"personal",
|
|
20
|
+
"newsletter",
|
|
21
|
+
"marketing",
|
|
22
|
+
"automated",
|
|
23
|
+
"transactional",
|
|
24
|
+
"social",
|
|
25
|
+
] as const) {
|
|
26
|
+
assert.strictEqual(toDisplayCategory(category), category);
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
});
|
|
@@ -5,16 +5,18 @@ import type { ThreadCategory } from "@remit/ui";
|
|
|
5
5
|
* Map an API message category to a kit display category.
|
|
6
6
|
*
|
|
7
7
|
* The API category is total (RFC 032 Tier 2) and carries `uncategorized` for
|
|
8
|
-
* messages that are metadata-synced but not yet body-classified.
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
8
|
+
* messages that are metadata-synced but not yet body-classified. That state is
|
|
9
|
+
* shown as itself, not collapsed into `personal`: collapsing made an
|
|
10
|
+
* unclassified message identical on screen to one the classifier positively
|
|
11
|
+
* decided was personal, so a classification gap read as a large personal inbox
|
|
12
|
+
* rather than as missing work (issue #45).
|
|
13
|
+
*
|
|
14
|
+
* Pre-migration rows that read `undefined` carry no category either, so they
|
|
15
|
+
* map to `uncategorized` the same way.
|
|
14
16
|
*/
|
|
15
17
|
export function toDisplayCategory(
|
|
16
18
|
category: RemitImapMessageCategory | undefined,
|
|
17
19
|
): ThreadCategory {
|
|
18
|
-
if (category === undefined
|
|
20
|
+
if (category === undefined) return "uncategorized";
|
|
19
21
|
return category;
|
|
20
22
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `q` lives on the parent `/mail` route, but every child re-declares it: a
|
|
3
|
+
* child's `validateSearch` is authoritative for its own URL, so a child that
|
|
4
|
+
* omits `q` strips it. The top bar mounts a search field on all four of these
|
|
5
|
+
* routes, so a stripped `q` means typing a query does nothing and the query is
|
|
6
|
+
* lost on the next navigation.
|
|
7
|
+
*/
|
|
8
|
+
import assert from "node:assert/strict";
|
|
9
|
+
import { describe, it } from "node:test";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
import { Route as MailboxRoute } from "../routes/mail/$mailboxId";
|
|
12
|
+
import { Route as FlaggedRoute } from "../routes/mail/flagged";
|
|
13
|
+
import { Route as BriefRoute } from "../routes/mail/index";
|
|
14
|
+
import { Route as OutboxRoute } from "../routes/mail/outbox";
|
|
15
|
+
|
|
16
|
+
const routes = {
|
|
17
|
+
"/mail/ (daily brief)": BriefRoute,
|
|
18
|
+
"/mail/flagged": FlaggedRoute,
|
|
19
|
+
"/mail/outbox": OutboxRoute,
|
|
20
|
+
"/mail/$mailboxId": MailboxRoute,
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const parse = (route: { options: { validateSearch?: unknown } }) => {
|
|
24
|
+
const schema = route.options.validateSearch;
|
|
25
|
+
assert.ok(
|
|
26
|
+
schema instanceof z.ZodType,
|
|
27
|
+
"route must validate its search with a zod schema",
|
|
28
|
+
);
|
|
29
|
+
return (search: Record<string, unknown>) => schema.parse(search);
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
describe("every /mail child route carries `q` through its own validation", () => {
|
|
33
|
+
for (const [name, route] of Object.entries(routes)) {
|
|
34
|
+
it(`${name} preserves a query`, () => {
|
|
35
|
+
const parsed = parse(route)({ q: "invoice" }) as { q?: string };
|
|
36
|
+
assert.equal(
|
|
37
|
+
parsed.q,
|
|
38
|
+
"invoice",
|
|
39
|
+
`${name} drops q, so the top bar's search field is inert there`,
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it(`${name} leaves q absent when there is none`, () => {
|
|
44
|
+
const parsed = parse(route)({}) as { q?: string };
|
|
45
|
+
assert.equal(parsed.q, undefined);
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
});
|
|
@@ -3,8 +3,13 @@ import { z } from "zod";
|
|
|
3
3
|
|
|
4
4
|
// Search schema: `selectedOutboxMessageId` is read by `OutboxPane` in the
|
|
5
5
|
// parent `/mail` shell via `useSearch({ strict: false })`.
|
|
6
|
+
//
|
|
7
|
+
// `q` is inherited from the parent /mail route; re-declared here so it survives
|
|
8
|
+
// this route's own search validation and isn't dropped when navigating with a
|
|
9
|
+
// functional search updater.
|
|
6
10
|
const outboxSearchSchema = z.object({
|
|
7
11
|
selectedOutboxMessageId: z.string().optional(),
|
|
12
|
+
q: z.string().optional(),
|
|
8
13
|
});
|
|
9
14
|
|
|
10
15
|
export const Route = createFileRoute("/mail/outbox")({
|
package/src/routes/mail.tsx
CHANGED
|
@@ -15,6 +15,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
15
15
|
import { z } from "zod";
|
|
16
16
|
import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
|
|
17
17
|
import { ComposeFab } from "@/components/layout/ComposeFab";
|
|
18
|
+
import { MailTopBar } from "@/components/layout/MailTopBar";
|
|
18
19
|
import { BriefPane } from "@/components/mail/BriefPane";
|
|
19
20
|
import { FlaggedPane } from "@/components/mail/FlaggedPane";
|
|
20
21
|
import { MailboxPane } from "@/components/mail/MailboxPane";
|
|
@@ -268,7 +269,16 @@ function MailLayout() {
|
|
|
268
269
|
const navContent = (
|
|
269
270
|
<MailNav accounts={accounts} onMailboxSelect={handleMailboxSelect} />
|
|
270
271
|
);
|
|
271
|
-
|
|
272
|
+
// Single-pane only, where the FAB is the compose entry point. Above it the
|
|
273
|
+
// top bar owns compose, and mounting the FAB there would resolve the same
|
|
274
|
+
// compose target twice for a button CSS keeps hidden.
|
|
275
|
+
const overlayContent = isSinglePane ? (
|
|
276
|
+
<ComposeFab accounts={accounts} />
|
|
277
|
+
) : undefined;
|
|
278
|
+
// Desktop only. Below 1024px the single pane keeps its own header search and
|
|
279
|
+
// the phone takeover; there is no room for a bar spanning panes that do not
|
|
280
|
+
// exist side by side.
|
|
281
|
+
const topBar = isSinglePane ? undefined : <MailTopBar accounts={accounts} />;
|
|
272
282
|
const navSlideOver = {
|
|
273
283
|
navOpen: drawerOpen,
|
|
274
284
|
onOpenNav: () => setDrawerOpen(true),
|
|
@@ -303,6 +313,7 @@ function MailLayout() {
|
|
|
303
313
|
) : (
|
|
304
314
|
<AppShellSlotted
|
|
305
315
|
nav={navContent}
|
|
316
|
+
topBar={topBar}
|
|
306
317
|
list={<BriefPane.List />}
|
|
307
318
|
reading={<BriefPane.Reading />}
|
|
308
319
|
intelligenceOpen={intelligenceOpen}
|
|
@@ -330,6 +341,7 @@ function MailLayout() {
|
|
|
330
341
|
) : (
|
|
331
342
|
<AppShellSlotted
|
|
332
343
|
nav={navContent}
|
|
344
|
+
topBar={topBar}
|
|
333
345
|
list={<FlaggedPane.List />}
|
|
334
346
|
reading={<FlaggedPane.Reading />}
|
|
335
347
|
intelligenceOpen={intelligenceOpen}
|
|
@@ -359,6 +371,7 @@ function MailLayout() {
|
|
|
359
371
|
) : (
|
|
360
372
|
<AppShellSlotted
|
|
361
373
|
nav={navContent}
|
|
374
|
+
topBar={topBar}
|
|
362
375
|
list={<MailboxPane.List />}
|
|
363
376
|
reading={<MailboxPane.Reading />}
|
|
364
377
|
intelligence={<MailboxPane.Intelligence />}
|
|
@@ -386,6 +399,7 @@ function MailLayout() {
|
|
|
386
399
|
) : (
|
|
387
400
|
<AppShellSlotted
|
|
388
401
|
nav={navContent}
|
|
402
|
+
topBar={topBar}
|
|
389
403
|
list={<OutboxPane.List />}
|
|
390
404
|
reading={<OutboxPane.Reading />}
|
|
391
405
|
intelligenceOpen={false}
|