@remit/web-client 0.0.166 → 0.0.168
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/ComposeForm.tsx +91 -29
- package/src/components/compose/ComposeProvider.tsx +19 -124
- package/src/components/compose/ConversationCompose.tsx +57 -0
- package/src/components/compose/FullCompose.tsx +29 -18
- package/src/components/compose/MobileComposeSheet.tsx +14 -18
- package/src/components/compose/compose-send-stops-autosave.render.test.ts +7 -9
- package/src/components/compose/compose-starts-a-second-message.render.test.ts +170 -0
- package/src/components/compose/compose-title.ts +10 -0
- package/src/components/compose/mobile-header-stays-expanded.render.test.ts +5 -13
- package/src/components/layout/ComposeFab.tsx +6 -15
- package/src/components/layout/MailShell.tsx +9 -1
- package/src/components/layout/MailTopBar.tsx +3 -6
- package/src/components/mail/BriefPane.tsx +49 -13
- package/src/components/mail/ConversationView.tsx +82 -77
- package/src/components/mail/DraftsView.tsx +12 -14
- package/src/components/mail/FlaggedPane.tsx +49 -13
- package/src/components/mail/MailboxPane.tsx +77 -167
- package/src/components/mail/OutboxPane.tsx +3 -9
- package/src/components/mail/conversation-reply-reach.render.test.ts +81 -28
- package/src/hooks/useSaveDraft.ts +11 -0
- package/src/hooks/useSearchMirror.ts +12 -1
- package/src/hooks/useThreadActions.ts +8 -17
- package/src/lib/mail-route.test.ts +35 -1
- package/src/lib/mail-route.ts +2 -1
- package/src/routeTree.gen.ts +220 -15
- package/src/routes/mail/$mailboxId/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +14 -0
- package/src/routes/mail/$mailboxId/$threadId/$messageId.tsx +4 -0
- package/src/routes/mail/$mailboxId/compose.{-$outboxMessageId}.tsx +14 -0
- package/src/routes/mail/brief/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +26 -0
- package/src/routes/mail/brief/$threadId/$messageId.tsx +4 -0
- package/src/routes/mail/brief/compose.{-$outboxMessageId}.tsx +22 -0
- package/src/routes/mail/flagged/$threadId/$messageId/$mode.{-$outboxMessageId}.tsx +14 -0
- package/src/routes/mail/flagged/$threadId/$messageId.tsx +4 -0
- package/src/routes/mail/flagged/compose.{-$outboxMessageId}.tsx +13 -0
- package/src/routes/mail/outbox/compose.{-$outboxMessageId}.tsx +17 -0
- package/src/routes/mail.tsx +12 -8
- package/src/routing/browsed-list.ts +22 -0
- package/src/routing/compose-press-opens.render.test.ts +179 -0
- package/src/routing/compose.ts +205 -0
- package/src/routing/index.ts +20 -0
- package/src/routing/reply.ts +282 -0
- package/src/components/compose/InlineCompose.tsx +0 -37
- package/src/components/compose/compose-clears-open-thread.render.test.ts +0 -312
- package/src/hooks/useComposeTargetMailbox.ts +0 -75
- package/src/lib/compose-routes.test.ts +0 -46
- package/src/lib/compose-routes.ts +0 -25
|
@@ -37,11 +37,11 @@ import {
|
|
|
37
37
|
createRouter,
|
|
38
38
|
RouterContextProvider,
|
|
39
39
|
} from "@tanstack/react-router";
|
|
40
|
-
import { createElement,
|
|
40
|
+
import { createElement, useState } from "react";
|
|
41
41
|
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
42
42
|
import { type HttpMock, httpError, mockFetch } from "../../test-support/http";
|
|
43
43
|
import { ComposeForm } from "./ComposeForm";
|
|
44
|
-
import { ComposeProvider
|
|
44
|
+
import { ComposeProvider } from "./ComposeProvider";
|
|
45
45
|
|
|
46
46
|
const ACCOUNT_ID = "acc-1";
|
|
47
47
|
const OUTBOX_MESSAGE_ID = "ob-604";
|
|
@@ -108,19 +108,17 @@ const patchesAfterTheSend = (): number => {
|
|
|
108
108
|
.length;
|
|
109
109
|
};
|
|
110
110
|
|
|
111
|
+
// The draft the composer is on is its owner's to hand it — the address for the
|
|
112
|
+
// compose route, local state for an inline reply. Here it is the test's.
|
|
111
113
|
const Opened = ({ outboxMessageId }: { outboxMessageId?: string }) => {
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
useEffect(() => {
|
|
115
|
-
openCompose({ mode: "reply", account, sourceMessage, outboxMessageId });
|
|
116
|
-
}, [openCompose, outboxMessageId]);
|
|
117
|
-
|
|
118
|
-
if (!state.isOpen) return null;
|
|
114
|
+
const [draftId, setDraftId] = useState(outboxMessageId);
|
|
119
115
|
|
|
120
116
|
return createElement(ComposeForm, {
|
|
121
117
|
mode: "reply",
|
|
122
118
|
account,
|
|
123
119
|
sourceMessage,
|
|
120
|
+
outboxMessageId: draftId,
|
|
121
|
+
onDraftCreated: setDraftId,
|
|
124
122
|
onClose: () => {
|
|
125
123
|
closed += 1;
|
|
126
124
|
},
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Compose pressed while already composing starts a new message (#719).
|
|
3
|
+
*
|
|
4
|
+
* The compose route carries the draft as an optional segment, so pressing
|
|
5
|
+
* Compose from inside the composer drops that segment and leaves the same route
|
|
6
|
+
* matched — nothing remounts the form. The reset effect used to bail whenever
|
|
7
|
+
* the incoming id was absent, so the previous draft's recipients, subject and
|
|
8
|
+
* body stayed on screen under an address that said new message, and the next
|
|
9
|
+
* autosave took the create branch and wrote a SECOND draft holding the first
|
|
10
|
+
* one's content.
|
|
11
|
+
*
|
|
12
|
+
* Two properties, one cause: what is on screen, and what is written. The write
|
|
13
|
+
* is the damaging half — the duplicate outlives the session, in the reader's
|
|
14
|
+
* drafts.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { afterEach, describe, it } from "node:test";
|
|
19
|
+
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
20
|
+
import {
|
|
21
|
+
type AnyRouter,
|
|
22
|
+
createMemoryHistory,
|
|
23
|
+
createRootRoute,
|
|
24
|
+
createRoute,
|
|
25
|
+
createRouter,
|
|
26
|
+
RouterContextProvider,
|
|
27
|
+
} from "@tanstack/react-router";
|
|
28
|
+
import { createElement, useState } from "react";
|
|
29
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
30
|
+
import { type HttpMock, mockFetch } from "../../test-support/http";
|
|
31
|
+
import { ComposeForm } from "./ComposeForm";
|
|
32
|
+
import { ComposeProvider } from "./ComposeProvider";
|
|
33
|
+
|
|
34
|
+
const ACCOUNT_ID = "acc-1";
|
|
35
|
+
const DRAFT_ID = "ob-first";
|
|
36
|
+
const AUTOSAVE_DEBOUNCE_MS = 2000;
|
|
37
|
+
|
|
38
|
+
const account = {
|
|
39
|
+
accountId: ACCOUNT_ID,
|
|
40
|
+
email: "me@example.com",
|
|
41
|
+
smtpEnabled: true,
|
|
42
|
+
} as unknown as RemitImapAccountResponse;
|
|
43
|
+
|
|
44
|
+
const draft = {
|
|
45
|
+
outboxMessageId: DRAFT_ID,
|
|
46
|
+
accountId: ACCOUNT_ID,
|
|
47
|
+
fromAddress: account.email,
|
|
48
|
+
toAddresses: ["them@example.com"],
|
|
49
|
+
ccAddresses: [],
|
|
50
|
+
bccAddresses: [],
|
|
51
|
+
references: [],
|
|
52
|
+
subject: "Lunch on Thursday",
|
|
53
|
+
textBody: "Does one o'clock work?",
|
|
54
|
+
status: "draft",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
let harness: DomHarness | undefined;
|
|
58
|
+
let http: HttpMock | undefined;
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
harness?.close();
|
|
62
|
+
harness = undefined;
|
|
63
|
+
http?.restore();
|
|
64
|
+
http = undefined;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
68
|
+
|
|
69
|
+
const rootRoute = createRootRoute();
|
|
70
|
+
const mailboxRoute = createRoute({
|
|
71
|
+
getParentRoute: () => rootRoute,
|
|
72
|
+
path: "/mail/$mailboxId",
|
|
73
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const testRouter = (): AnyRouter =>
|
|
77
|
+
createRouter({
|
|
78
|
+
routeTree: rootRoute.addChildren([mailboxRoute]),
|
|
79
|
+
history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
|
|
80
|
+
}) as unknown as AnyRouter;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The composer with its draft handed in, and a button that takes it away again
|
|
84
|
+
* — which is what pressing Compose does to the address it is mounted under.
|
|
85
|
+
*/
|
|
86
|
+
const Composer = () => {
|
|
87
|
+
const [draftId, setDraftId] = useState<string | undefined>(DRAFT_ID);
|
|
88
|
+
|
|
89
|
+
return createElement(
|
|
90
|
+
"div",
|
|
91
|
+
null,
|
|
92
|
+
createElement(
|
|
93
|
+
"button",
|
|
94
|
+
{ type: "button", onClick: () => setDraftId(undefined) },
|
|
95
|
+
"Compose",
|
|
96
|
+
),
|
|
97
|
+
createElement(ComposeForm, {
|
|
98
|
+
mode: "new",
|
|
99
|
+
account,
|
|
100
|
+
outboxMessageId: draftId,
|
|
101
|
+
onDraftCreated: setDraftId,
|
|
102
|
+
onClose: () => undefined,
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const creates = () =>
|
|
108
|
+
(http?.calls ?? []).filter(
|
|
109
|
+
(call) => call.method === "POST" && call.path.endsWith("/outbox"),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const mount = async (): Promise<DomHarness> => {
|
|
113
|
+
http = mockFetch(async (call) => {
|
|
114
|
+
if (call.path.endsWith("/config")) return { accounts: [account] };
|
|
115
|
+
if (call.method === "POST" && call.path.endsWith("/outbox"))
|
|
116
|
+
return { ...draft, outboxMessageId: "ob-second" };
|
|
117
|
+
return draft;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const created = createDomHarness();
|
|
121
|
+
harness = created;
|
|
122
|
+
created.renderApp(
|
|
123
|
+
createElement(RouterContextProvider, {
|
|
124
|
+
router: testRouter(),
|
|
125
|
+
// biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
|
|
126
|
+
children: createElement(ComposeProvider, null, createElement(Composer)),
|
|
127
|
+
}),
|
|
128
|
+
);
|
|
129
|
+
await created.flush();
|
|
130
|
+
await created.wait(50);
|
|
131
|
+
return created;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const subjectField = (mounted: DomHarness): HTMLInputElement => {
|
|
135
|
+
const field = mounted.query<HTMLInputElement>("[data-subject-field]");
|
|
136
|
+
if (!field) throw new Error("the composer has no subject field");
|
|
137
|
+
return field;
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
describe("Compose pressed while already composing", () => {
|
|
141
|
+
it("takes the previous draft's message off the screen", async () => {
|
|
142
|
+
const mounted = await mount();
|
|
143
|
+
assert.equal(subjectField(mounted).value, draft.subject);
|
|
144
|
+
assert.match(mounted.text(), /them@example.com/);
|
|
145
|
+
|
|
146
|
+
mounted.click(mounted.byText("button", "Compose"));
|
|
147
|
+
await mounted.flush();
|
|
148
|
+
await mounted.wait(20);
|
|
149
|
+
|
|
150
|
+
assert.equal(subjectField(mounted).value, "");
|
|
151
|
+
assert.doesNotMatch(mounted.text(), /them@example.com/);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// The half that outlives the session: an autosave from a form still holding
|
|
155
|
+
// the old content, with no draft to write it to, creates a second one.
|
|
156
|
+
it("writes no second draft carrying the first one's content", async () => {
|
|
157
|
+
const mounted = await mount();
|
|
158
|
+
assert.equal(subjectField(mounted).value, draft.subject);
|
|
159
|
+
|
|
160
|
+
mounted.click(mounted.byText("button", "Compose"));
|
|
161
|
+
await mounted.flush();
|
|
162
|
+
await mounted.wait(AUTOSAVE_DEBOUNCE_MS + 500);
|
|
163
|
+
|
|
164
|
+
assert.deepEqual(
|
|
165
|
+
creates().map((call) => call.body?.subject),
|
|
166
|
+
[],
|
|
167
|
+
"a blank new message has nothing to save, so nothing was written",
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the compose surface calls itself.
|
|
3
|
+
*
|
|
4
|
+
* The address says which message is being written, so the heading says the same
|
|
5
|
+
* thing: a message with nothing saved behind it yet is a new one, and one the
|
|
6
|
+
* autosave has written is the draft it wrote. The heading changing as the first
|
|
7
|
+
* save lands is the same moment the action bar says "Draft saved".
|
|
8
|
+
*/
|
|
9
|
+
export const composeSurfaceTitle = (outboxMessageId: string | undefined) =>
|
|
10
|
+
outboxMessageId ? "Draft" : "New Message";
|
|
@@ -23,11 +23,11 @@ import {
|
|
|
23
23
|
createRouter,
|
|
24
24
|
RouterContextProvider,
|
|
25
25
|
} from "@tanstack/react-router";
|
|
26
|
-
import { act, createElement
|
|
26
|
+
import { act, createElement } from "react";
|
|
27
27
|
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
28
28
|
import { type HttpMock, mockFetch } from "../../test-support/http";
|
|
29
29
|
import { ComposeForm } from "./ComposeForm";
|
|
30
|
-
import { ComposeProvider
|
|
30
|
+
import { ComposeProvider } from "./ComposeProvider";
|
|
31
31
|
|
|
32
32
|
const ACCOUNT_ID = "acc-1";
|
|
33
33
|
const PHONE = {
|
|
@@ -97,21 +97,13 @@ const testRouter = (): AnyRouter =>
|
|
|
97
97
|
history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
|
|
98
98
|
}) as unknown as AnyRouter;
|
|
99
99
|
|
|
100
|
-
const Opened = () =>
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
useEffect(() => {
|
|
104
|
-
openCompose({ mode: "new", account });
|
|
105
|
-
}, [openCompose]);
|
|
106
|
-
|
|
107
|
-
if (!state.isOpen) return null;
|
|
108
|
-
|
|
109
|
-
return createElement(ComposeForm, {
|
|
100
|
+
const Opened = () =>
|
|
101
|
+
createElement(ComposeForm, {
|
|
110
102
|
mode: "new",
|
|
111
103
|
account,
|
|
104
|
+
onDraftCreated: () => {},
|
|
112
105
|
onClose: () => {},
|
|
113
106
|
});
|
|
114
|
-
};
|
|
115
107
|
|
|
116
108
|
const mount = async (): Promise<void> => {
|
|
117
109
|
http = mockFetch(async (call) => {
|
|
@@ -1,33 +1,24 @@
|
|
|
1
1
|
import { useLocation } from "@tanstack/react-router";
|
|
2
2
|
import { Pencil } from "lucide-react";
|
|
3
|
-
import { useCallback } from "react";
|
|
4
|
-
import { useCompose } from "@/components/compose/ComposeProvider";
|
|
5
3
|
import { locationOpensDetail } from "@/lib/mail-route";
|
|
4
|
+
import { useOpenCompose } from "@/routing";
|
|
6
5
|
|
|
7
6
|
/**
|
|
8
7
|
* Floating Action Button for composing a new message. Mobile-only.
|
|
9
8
|
*
|
|
10
9
|
* Layout follows Material 3: 56×56 surface, 16px from the right and
|
|
11
|
-
* bottom edges (plus the iOS safe-area inset). Hidden when
|
|
10
|
+
* bottom edges (plus the iOS safe-area inset). Hidden when either:
|
|
12
11
|
* - Viewport is `≥ lg` (1024px), where the top bar owns compose. The
|
|
13
12
|
* `/mail` shell also stops mounting the FAB above that width; the
|
|
14
13
|
* `lg:hidden` class covers the pre-hydration frame.
|
|
15
|
-
* - The
|
|
16
|
-
*
|
|
17
|
-
* its reply bar is under this corner. Every list says so in its path.
|
|
18
|
-
* - The user is off `/mail`, which is every route with no mail in it.
|
|
14
|
+
* - The single pane has something open — a conversation, or compose itself.
|
|
15
|
+
* Every list says so in its path.
|
|
19
16
|
*/
|
|
20
17
|
export const ComposeFab = () => {
|
|
21
|
-
const
|
|
18
|
+
const compose = useOpenCompose();
|
|
22
19
|
const location = useLocation();
|
|
23
|
-
const compose = useCallback(() => {
|
|
24
|
-
openCompose({ mode: "new" });
|
|
25
|
-
}, [openCompose]);
|
|
26
20
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if (!location.pathname.startsWith("/mail") || state.isOpen || isReadingThread)
|
|
30
|
-
return null;
|
|
21
|
+
if (locationOpensDetail(location.pathname)) return null;
|
|
31
22
|
|
|
32
23
|
return (
|
|
33
24
|
<button
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { AppShellSlotted } from "@remit/ui";
|
|
2
2
|
import { createContext, type ReactNode, useContext } from "react";
|
|
3
|
+
import { FullCompose } from "@/components/compose/FullCompose";
|
|
3
4
|
import { AppShellSkeleton } from "@/components/layout/AppShellSkeleton";
|
|
5
|
+
import { useIsComposing } from "@/routing";
|
|
4
6
|
|
|
5
7
|
/**
|
|
6
8
|
* The chrome every list shares, published by the `/mail` layout and consumed by
|
|
@@ -53,6 +55,11 @@ export interface MailShellProps {
|
|
|
53
55
|
* Below the reading boundary the shell is one pane and takes `phone`, which
|
|
54
56
|
* swaps between the list and whatever is open in place. Above it the panes sit
|
|
55
57
|
* side by side and the reading and intelligence slots are filled.
|
|
58
|
+
*
|
|
59
|
+
* Compose is the one surface the single pane cannot take from the `Outlet`,
|
|
60
|
+
* because at this width there is no reading slot to fill. It is read off the
|
|
61
|
+
* address here rather than in each list's phone view, so the four lists cannot
|
|
62
|
+
* disagree about it.
|
|
56
63
|
*/
|
|
57
64
|
export function MailShell({
|
|
58
65
|
phone,
|
|
@@ -62,6 +69,7 @@ export function MailShell({
|
|
|
62
69
|
hasThread = false,
|
|
63
70
|
}: MailShellProps) {
|
|
64
71
|
const chrome = useContext(MailShellCtx);
|
|
72
|
+
const isComposing = useIsComposing();
|
|
65
73
|
if (!chrome) return <AppShellSkeleton />;
|
|
66
74
|
|
|
67
75
|
const shared = {
|
|
@@ -78,7 +86,7 @@ export function MailShell({
|
|
|
78
86
|
return (
|
|
79
87
|
<AppShellSlotted
|
|
80
88
|
{...shared}
|
|
81
|
-
list={phone}
|
|
89
|
+
list={isComposing ? <FullCompose /> : phone}
|
|
82
90
|
intelligenceOpen={chrome.intelligenceOpen}
|
|
83
91
|
/>
|
|
84
92
|
);
|
|
@@ -9,14 +9,14 @@
|
|
|
9
9
|
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
10
10
|
import { RefreshButton, ShellTopBar, shortcutHintForAction } from "@remit/ui";
|
|
11
11
|
import { useNavigate } from "@tanstack/react-router";
|
|
12
|
-
import {
|
|
12
|
+
import { useMemo } from "react";
|
|
13
13
|
import { AccountMenu } from "@/auth/AccountMenu";
|
|
14
|
-
import { useCompose } from "@/components/compose/ComposeProvider";
|
|
15
14
|
import { useRefreshControl } from "@/hooks/useRefreshControl";
|
|
16
15
|
import { useSearchScope } from "@/hooks/useSearchScope";
|
|
17
16
|
import { openBugReport } from "@/lib/bug-report";
|
|
18
17
|
import { useMailContext } from "@/lib/mail-context";
|
|
19
18
|
import { useMailFreshness } from "@/lib/mail-freshness";
|
|
19
|
+
import { useOpenCompose } from "@/routing";
|
|
20
20
|
|
|
21
21
|
interface MailTopBarProps {
|
|
22
22
|
accounts: RemitImapAccountResponse[];
|
|
@@ -26,10 +26,7 @@ export function MailTopBar({ accounts }: MailTopBarProps) {
|
|
|
26
26
|
const { searchInput, onSearchChange, onSearchClear, onSearchClearQuery } =
|
|
27
27
|
useMailContext();
|
|
28
28
|
const navigate = useNavigate();
|
|
29
|
-
const
|
|
30
|
-
const compose = useCallback(() => {
|
|
31
|
-
openCompose({ mode: "new" });
|
|
32
|
-
}, [openCompose]);
|
|
29
|
+
const compose = useOpenCompose();
|
|
33
30
|
const { scope, clearScope } = useSearchScope(accounts);
|
|
34
31
|
const chips =
|
|
35
32
|
scope.kind === "scoped"
|
|
@@ -44,6 +44,10 @@ import { useMailContext } from "@/lib/mail-context";
|
|
|
44
44
|
import {
|
|
45
45
|
type OpenThreadPath,
|
|
46
46
|
type OpenThreadTarget,
|
|
47
|
+
type ReplyMode,
|
|
48
|
+
useIsComposing,
|
|
49
|
+
useIsReplying,
|
|
50
|
+
useOpenReply,
|
|
47
51
|
useRetainOpenPanels,
|
|
48
52
|
} from "@/routing";
|
|
49
53
|
|
|
@@ -70,6 +74,11 @@ interface BriefPaneContextValue {
|
|
|
70
74
|
* account — the brief spans accounts, so there is no route mailbox to key by.
|
|
71
75
|
*/
|
|
72
76
|
actions: ThreadActions;
|
|
77
|
+
/**
|
|
78
|
+
* Answer the conversation the address has open. Absent when it names none,
|
|
79
|
+
* which is what the toolbar turns into its own explanation.
|
|
80
|
+
*/
|
|
81
|
+
onReply: ((mode: ReplyMode) => void) | undefined;
|
|
73
82
|
/** Keyboard, multi-select and next/previous, shared with the mailbox view. */
|
|
74
83
|
triage: TriageContext;
|
|
75
84
|
onDeleteMessages: (messageIds: string[]) => void;
|
|
@@ -204,15 +213,47 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
|
|
|
204
213
|
const triageTarget = focusedThread ?? selectedThread;
|
|
205
214
|
const triageActions = useThreadActions({ thread: triageTarget });
|
|
206
215
|
|
|
216
|
+
// The toolbar answers the conversation on screen; the keyboard answers the
|
|
217
|
+
// row the cursor is on, which may be one the address has not opened yet.
|
|
218
|
+
// Both are one navigation, because the mode and the message it answers are
|
|
219
|
+
// segments of the same address — there is no open-it-first step for the
|
|
220
|
+
// answering half to be dropped from.
|
|
221
|
+
const isComposing = useIsComposing();
|
|
222
|
+
const isReplying = useIsReplying();
|
|
223
|
+
const openReply = useOpenReply();
|
|
224
|
+
const replyToOpenThread = useMemo(() => {
|
|
225
|
+
if (!selectedThread || !selectedMessageId) return undefined;
|
|
226
|
+
return (mode: ReplyMode) =>
|
|
227
|
+
openReply({
|
|
228
|
+
threadId: selectedThread.threadId,
|
|
229
|
+
messageId: selectedMessageId,
|
|
230
|
+
mode,
|
|
231
|
+
});
|
|
232
|
+
}, [openReply, selectedThread, selectedMessageId]);
|
|
233
|
+
|
|
234
|
+
const replyToFocusedThread = useMemo(() => {
|
|
235
|
+
if (!triageTarget) return undefined;
|
|
236
|
+
return (mode: ReplyMode) =>
|
|
237
|
+
openReply({
|
|
238
|
+
threadId: triageTarget.threadId,
|
|
239
|
+
messageId: triageTarget.messageId,
|
|
240
|
+
mode,
|
|
241
|
+
});
|
|
242
|
+
}, [openReply, triageTarget]);
|
|
243
|
+
|
|
207
244
|
const { nextMessageId, previousMessageId } = useTriageLayer({
|
|
208
245
|
context: triage,
|
|
209
246
|
orderedIds: triage.orderedIds,
|
|
210
247
|
selectedMessageId,
|
|
248
|
+
// The list stays mounted under both writing surfaces, so the triage keys
|
|
249
|
+
// would otherwise fire at the message behind whatever is being typed — or
|
|
250
|
+
// answer a row the cursor moved to while a reply was open.
|
|
251
|
+
enabled: !isComposing && !isReplying,
|
|
211
252
|
onClose: handleCloseThread,
|
|
212
253
|
handlers: {
|
|
213
|
-
reply: () =>
|
|
214
|
-
replyAll: () =>
|
|
215
|
-
forward: () =>
|
|
254
|
+
reply: () => replyToFocusedThread?.("reply"),
|
|
255
|
+
replyAll: () => replyToFocusedThread?.("reply-all"),
|
|
256
|
+
forward: () => replyToFocusedThread?.("forward"),
|
|
216
257
|
// The list takes any verb aimed at its selection and opens the wizard
|
|
217
258
|
// on it, so a shortcut can never reach a bulk action the bar would have
|
|
218
259
|
// reviewed. What comes back here is a verb aimed at the bare cursor.
|
|
@@ -252,6 +293,7 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
|
|
|
252
293
|
nextThread: adjacentThread(nextMessageId),
|
|
253
294
|
previousThread: adjacentThread(previousMessageId),
|
|
254
295
|
actions,
|
|
296
|
+
onReply: replyToOpenThread,
|
|
255
297
|
triage,
|
|
256
298
|
onDeleteMessages: deleteMessages,
|
|
257
299
|
handleDeselectIfRemoved,
|
|
@@ -289,7 +331,7 @@ function BriefList() {
|
|
|
289
331
|
* Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
|
|
290
332
|
*/
|
|
291
333
|
function BriefReading() {
|
|
292
|
-
const { conversation, actions } = useBriefPane();
|
|
334
|
+
const { conversation, actions, onReply } = useBriefPane();
|
|
293
335
|
const { intelligenceOpen, onToggleIntelligence } = useMailContext();
|
|
294
336
|
// The rail's own width gate, not the shell tier: between 1024 and 1280 the
|
|
295
337
|
// reading pane is mounted but the rail is not, so "enabled" would promise an
|
|
@@ -305,13 +347,9 @@ function BriefReading() {
|
|
|
305
347
|
intelligenceOpen={canToggleIntelligence && intelligenceOpen}
|
|
306
348
|
canToggleIntelligence={canToggleIntelligence}
|
|
307
349
|
onToggleIntelligence={onToggleIntelligence}
|
|
308
|
-
onReply={
|
|
309
|
-
onReplyAll={
|
|
310
|
-
|
|
311
|
-
}
|
|
312
|
-
onForward={
|
|
313
|
-
hasThread ? () => actions.requestCompose("forward") : undefined
|
|
314
|
-
}
|
|
350
|
+
onReply={onReply ? () => onReply("reply") : undefined}
|
|
351
|
+
onReplyAll={onReply ? () => onReply("reply-all") : undefined}
|
|
352
|
+
onForward={onReply ? () => onReply("forward") : undefined}
|
|
315
353
|
onDelete={hasThread ? actions.deleteThread : undefined}
|
|
316
354
|
onToggleStar={hasThread ? actions.toggleStar : undefined}
|
|
317
355
|
isStarred={actions.isStarred}
|
|
@@ -333,8 +371,6 @@ function BriefReading() {
|
|
|
333
371
|
subject={conversation.subject}
|
|
334
372
|
selectedMessageId={conversation.messageId}
|
|
335
373
|
authenticity={conversation.authenticity}
|
|
336
|
-
composeRequest={actions.composeRequest}
|
|
337
|
-
onComposeClose={actions.clearComposeRequest}
|
|
338
374
|
/>
|
|
339
375
|
) : (
|
|
340
376
|
<ReadingPaneEmpty />
|