@remit/web-client 0.0.178 → 0.0.180
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/mail/MailboxPane.tsx +7 -2
- package/src/components/mail/MessageToolbar.interaction.test.ts +250 -0
- package/src/components/mail/MessageToolbar.tsx +105 -20
- package/src/components/mail/MoveToTrigger.tsx +8 -1
- package/src/hooks/search-mirror-convergence.render.test.ts +197 -0
- package/src/hooks/search-mirror-detail.render.test.ts +4 -7
- package/src/hooks/useSearchField.render.test.ts +216 -0
- package/src/hooks/useSearchField.ts +85 -0
- package/src/hooks/useSearchMirror.ts +23 -10
- package/src/lib/mail-route.test.ts +69 -48
- package/src/lib/mail-route.ts +35 -13
- package/src/lib/search-view.test.ts +38 -27
- package/src/lib/search-view.ts +21 -2
- package/src/routes/mail.tsx +10 -44
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.180",
|
|
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": {
|
|
@@ -144,6 +144,7 @@ interface MailboxPaneContextValue {
|
|
|
144
144
|
isError: boolean;
|
|
145
145
|
error: unknown;
|
|
146
146
|
mailboxAccountId: string | undefined;
|
|
147
|
+
mailboxAccountLoading: boolean;
|
|
147
148
|
mailboxName: string | null;
|
|
148
149
|
unreadCount: number;
|
|
149
150
|
isDraftsMailbox: boolean;
|
|
@@ -375,7 +376,8 @@ function MailboxPaneProvider({
|
|
|
375
376
|
: undefined,
|
|
376
377
|
});
|
|
377
378
|
|
|
378
|
-
const { accountId: mailboxAccountId } =
|
|
379
|
+
const { accountId: mailboxAccountId, isLoading: mailboxAccountLoading } =
|
|
380
|
+
useMailboxAccount(mailboxId);
|
|
379
381
|
const mailboxName = useCurrentMailboxName({ accounts });
|
|
380
382
|
|
|
381
383
|
// The server answered the active predicate, so these rows are the list: the
|
|
@@ -769,6 +771,7 @@ function MailboxPaneProvider({
|
|
|
769
771
|
isError,
|
|
770
772
|
error,
|
|
771
773
|
mailboxAccountId,
|
|
774
|
+
mailboxAccountLoading,
|
|
772
775
|
mailboxName,
|
|
773
776
|
unreadCount,
|
|
774
777
|
isDraftsMailbox,
|
|
@@ -1053,6 +1056,7 @@ function MailboxReading() {
|
|
|
1053
1056
|
const {
|
|
1054
1057
|
mailboxId,
|
|
1055
1058
|
mailboxAccountId,
|
|
1059
|
+
mailboxAccountLoading,
|
|
1056
1060
|
selectedThread,
|
|
1057
1061
|
conversation,
|
|
1058
1062
|
intelligenceOpen,
|
|
@@ -1127,13 +1131,13 @@ function MailboxReading() {
|
|
|
1127
1131
|
<section className="flex h-full w-full min-w-0 flex-col bg-canvas">
|
|
1128
1132
|
<MessageToolbar
|
|
1129
1133
|
hasThread={hasThread}
|
|
1134
|
+
messageId={conversation?.messageId}
|
|
1130
1135
|
intelligenceOpen={intelligenceShowing}
|
|
1131
1136
|
canToggleIntelligence={hasThread}
|
|
1132
1137
|
onToggleIntelligence={toggleIntelligence}
|
|
1133
1138
|
onReply={onReply ? () => onReply("reply") : undefined}
|
|
1134
1139
|
onReplyAll={onReply ? () => onReply("reply-all") : undefined}
|
|
1135
1140
|
onForward={onReply ? () => onReply("forward") : undefined}
|
|
1136
|
-
canDelete={hasThread}
|
|
1137
1141
|
onDelete={hasThread ? onToolbarDelete : undefined}
|
|
1138
1142
|
onToggleStar={hasThread ? onToolbarStar : undefined}
|
|
1139
1143
|
isStarred={selectedThread?.hasStars}
|
|
@@ -1146,6 +1150,7 @@ function MailboxReading() {
|
|
|
1146
1150
|
}
|
|
1147
1151
|
: undefined
|
|
1148
1152
|
}
|
|
1153
|
+
moveContextLoading={mailboxAccountLoading}
|
|
1149
1154
|
/>
|
|
1150
1155
|
<div className="min-h-0 flex-1 overflow-hidden">{detailPane}</div>
|
|
1151
1156
|
</section>
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The inline notice under the toolbar belongs to the press that produced it and
|
|
3
|
+
* to nothing after it (#818). It came out of #803 as a stored sentence with no
|
|
4
|
+
* reset, and #807 took away the render gate that had been hiding it, so one
|
|
5
|
+
* refused press labelled every later message on the pane.
|
|
6
|
+
*
|
|
7
|
+
* Two invariants hold underneath: no press ends in silence, and no sentence
|
|
8
|
+
* outlives the reason it was written for.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { afterEach, describe, it } from "node:test";
|
|
13
|
+
import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
14
|
+
import { createElement } from "react";
|
|
15
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
16
|
+
import { makeMailbox } from "../../test-support/fixtures";
|
|
17
|
+
import { MessageToolbar, type MessageToolbarProps } from "./MessageToolbar";
|
|
18
|
+
|
|
19
|
+
let harness: DomHarness | undefined;
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
harness?.close();
|
|
23
|
+
harness = undefined;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const VERBS = [
|
|
27
|
+
"Reply",
|
|
28
|
+
"Reply all",
|
|
29
|
+
"Forward",
|
|
30
|
+
"Move to Trash",
|
|
31
|
+
"Move to mailbox",
|
|
32
|
+
"Star",
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
const show = (
|
|
36
|
+
mounted: DomHarness,
|
|
37
|
+
props: Partial<MessageToolbarProps>,
|
|
38
|
+
): void => {
|
|
39
|
+
mounted.renderApp(
|
|
40
|
+
createElement(MessageToolbar, {
|
|
41
|
+
hasThread: true,
|
|
42
|
+
intelligenceOpen: false,
|
|
43
|
+
canToggleIntelligence: false,
|
|
44
|
+
onToggleIntelligence: () => undefined,
|
|
45
|
+
...props,
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** A fresh pane. */
|
|
51
|
+
const mount = (props: Partial<MessageToolbarProps>): DomHarness => {
|
|
52
|
+
harness?.close();
|
|
53
|
+
harness = createDomHarness();
|
|
54
|
+
show(harness, props);
|
|
55
|
+
return harness;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/** The same pane, re-rendered — state survives, as it does under a navigation
|
|
59
|
+
* the router answers without remounting. */
|
|
60
|
+
const update = (props: Partial<MessageToolbarProps>): void => {
|
|
61
|
+
if (!harness) throw new Error("nothing mounted");
|
|
62
|
+
show(harness, props);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const notice = (mounted: DomHarness): string | null =>
|
|
66
|
+
mounted.query('[role="status"]')?.textContent ?? null;
|
|
67
|
+
|
|
68
|
+
const press = async (mounted: DomHarness, label: string): Promise<void> => {
|
|
69
|
+
mounted.click(mounted.byLabel(label));
|
|
70
|
+
await mounted.flush();
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
describe("no toolbar press ends in silence (#818)", () => {
|
|
74
|
+
for (const label of VERBS) {
|
|
75
|
+
it(`${label} explains itself with nothing open, handler or no handler`, async () => {
|
|
76
|
+
let acted = 0;
|
|
77
|
+
const act = () => {
|
|
78
|
+
acted += 1;
|
|
79
|
+
};
|
|
80
|
+
const mounted = mount({
|
|
81
|
+
hasThread: false,
|
|
82
|
+
messageId: undefined,
|
|
83
|
+
onReply: act,
|
|
84
|
+
onReplyAll: act,
|
|
85
|
+
onForward: act,
|
|
86
|
+
onDelete: act,
|
|
87
|
+
onToggleStar: act,
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await press(mounted, label);
|
|
91
|
+
|
|
92
|
+
assert.ok(
|
|
93
|
+
notice(mounted),
|
|
94
|
+
`${label} did nothing and said nothing — the worst outcome`,
|
|
95
|
+
);
|
|
96
|
+
assert.equal(acted, 0, `${label} acted with no message open`);
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
describe("the toolbar notice clears when its reason goes (#818)", () => {
|
|
102
|
+
it("takes the notice down when another message opens", async () => {
|
|
103
|
+
const mounted = mount({ messageId: "message-1" });
|
|
104
|
+
|
|
105
|
+
await press(mounted, "Reply");
|
|
106
|
+
assert.match(
|
|
107
|
+
notice(mounted) ?? "",
|
|
108
|
+
/hasn't loaded yet/,
|
|
109
|
+
"a press with no handler behind it must explain itself",
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
update({ messageId: "message-2" });
|
|
113
|
+
await mounted.flush();
|
|
114
|
+
|
|
115
|
+
assert.equal(
|
|
116
|
+
notice(mounted),
|
|
117
|
+
null,
|
|
118
|
+
"the sentence belongs to the message it was pressed on, not the next one",
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("invites the press again once the verb it named can act", async () => {
|
|
123
|
+
const mounted = mount({ messageId: "message-1" });
|
|
124
|
+
|
|
125
|
+
await press(mounted, "Reply");
|
|
126
|
+
assert.match(notice(mounted) ?? "", /hasn't loaded yet/);
|
|
127
|
+
|
|
128
|
+
update({ messageId: "message-1", onReply: () => undefined });
|
|
129
|
+
await mounted.flush();
|
|
130
|
+
|
|
131
|
+
assert.match(
|
|
132
|
+
notice(mounted) ?? "",
|
|
133
|
+
/Reply is ready — press it again/,
|
|
134
|
+
"the press was never acted on; dropping the notice drops it silently",
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("lets a verb that acts take down an earlier verb's complaint", async () => {
|
|
139
|
+
let starred = 0;
|
|
140
|
+
const mounted = mount({
|
|
141
|
+
messageId: "message-1",
|
|
142
|
+
onToggleStar: () => {
|
|
143
|
+
starred += 1;
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
await press(mounted, "Reply");
|
|
148
|
+
assert.match(notice(mounted) ?? "", /hasn't loaded yet/);
|
|
149
|
+
|
|
150
|
+
await press(mounted, "Star");
|
|
151
|
+
|
|
152
|
+
assert.equal(starred, 1);
|
|
153
|
+
assert.equal(
|
|
154
|
+
notice(mounted),
|
|
155
|
+
null,
|
|
156
|
+
"a star that worked must not sit under a sentence saying nothing loaded",
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
describe("a loading action is not an unavailable one (#818)", () => {
|
|
162
|
+
const openMessage: Partial<MessageToolbarProps> = {
|
|
163
|
+
messageId: "message-1",
|
|
164
|
+
onReply: () => undefined,
|
|
165
|
+
onDelete: () => undefined,
|
|
166
|
+
onToggleStar: () => undefined,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
it("does not tell the reader to open a message that is already open", async () => {
|
|
170
|
+
const mounted = mount({
|
|
171
|
+
...openMessage,
|
|
172
|
+
moveContext: undefined,
|
|
173
|
+
moveContextLoading: true,
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
await press(mounted, "Move to mailbox");
|
|
177
|
+
|
|
178
|
+
const said = notice(mounted);
|
|
179
|
+
assert.ok(said, "a Move press must never be swallowed in silence");
|
|
180
|
+
assert.doesNotMatch(
|
|
181
|
+
said,
|
|
182
|
+
/Open a message first/,
|
|
183
|
+
"the message is on screen — the mailbox list is what has not arrived",
|
|
184
|
+
);
|
|
185
|
+
assert.match(said, /mailbox list hasn't loaded yet/);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("does not promise a lookup that already settled with nothing", async () => {
|
|
189
|
+
const mounted = mount({
|
|
190
|
+
...openMessage,
|
|
191
|
+
moveContext: undefined,
|
|
192
|
+
moveContextLoading: false,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await press(mounted, "Move to mailbox");
|
|
196
|
+
|
|
197
|
+
const said = notice(mounted);
|
|
198
|
+
assert.ok(said);
|
|
199
|
+
assert.doesNotMatch(
|
|
200
|
+
said,
|
|
201
|
+
/hasn't loaded yet/,
|
|
202
|
+
"nothing is in flight — waiting will never make this one arrive",
|
|
203
|
+
);
|
|
204
|
+
assert.match(said, /reload to try again/, "a dead end needs a next step");
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("still says why with nothing open at all", async () => {
|
|
208
|
+
const mounted = mount({ hasThread: false });
|
|
209
|
+
|
|
210
|
+
await press(mounted, "Move to mailbox");
|
|
211
|
+
|
|
212
|
+
assert.match(notice(mounted) ?? "", /Open a message first/);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it("carries a Move pressed during the lookup through to the picker", async () => {
|
|
216
|
+
const mounted = mount({
|
|
217
|
+
...openMessage,
|
|
218
|
+
moveContext: undefined,
|
|
219
|
+
moveContextLoading: true,
|
|
220
|
+
});
|
|
221
|
+
mounted.queryClient.setQueryData(
|
|
222
|
+
mailboxOperationsListMailboxesQueryKey({ path: { accountId: "acc-1" } }),
|
|
223
|
+
{ items: [makeMailbox({ mailboxId: "mbx-work", fullPath: "Work" })] },
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
await press(mounted, "Move to mailbox");
|
|
227
|
+
|
|
228
|
+
update({
|
|
229
|
+
...openMessage,
|
|
230
|
+
moveContextLoading: false,
|
|
231
|
+
moveContext: {
|
|
232
|
+
accountId: "acc-1",
|
|
233
|
+
currentMailboxId: "mbx-inbox",
|
|
234
|
+
onMove: () => undefined,
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
await mounted.flush();
|
|
238
|
+
|
|
239
|
+
assert.equal(
|
|
240
|
+
mounted.byLabel("Move to mailbox").getAttribute("aria-expanded"),
|
|
241
|
+
"true",
|
|
242
|
+
"the press asked for the picker; the context arriving must not lose it",
|
|
243
|
+
);
|
|
244
|
+
assert.equal(
|
|
245
|
+
notice(mounted),
|
|
246
|
+
null,
|
|
247
|
+
"the press was honoured, so there is nothing left to repeat",
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
});
|
|
@@ -24,6 +24,12 @@ import { MoveToTrigger } from "./MoveToTrigger";
|
|
|
24
24
|
*/
|
|
25
25
|
export interface MessageToolbarProps {
|
|
26
26
|
hasThread: boolean;
|
|
27
|
+
/**
|
|
28
|
+
* The message the verbs act on, as the address states it. The inline notice
|
|
29
|
+
* a press leaves behind belongs to that message and to nothing after it, so
|
|
30
|
+
* opening another one takes the sentence down (#818).
|
|
31
|
+
*/
|
|
32
|
+
messageId?: string;
|
|
27
33
|
intelligenceOpen: boolean;
|
|
28
34
|
/**
|
|
29
35
|
* Whether pressing the intelligence toggle would open anything: the view has
|
|
@@ -40,12 +46,6 @@ export interface MessageToolbarProps {
|
|
|
40
46
|
onForward?: () => void;
|
|
41
47
|
/** Delete all messages in the open thread. */
|
|
42
48
|
onDelete?: () => void;
|
|
43
|
-
/**
|
|
44
|
-
* Override the enabled state of the delete button. Defaults to `hasThread`.
|
|
45
|
-
* Pass `true` when a non-thread item (e.g. a Remit draft) is active so the
|
|
46
|
-
* trash icon acts without acting on the other thread-scoped verbs.
|
|
47
|
-
*/
|
|
48
|
-
canDelete?: boolean;
|
|
49
49
|
/** Toggle the star on the most-recent message in the thread. */
|
|
50
50
|
onToggleStar?: () => void;
|
|
51
51
|
/** Whether the most-recent message is starred. */
|
|
@@ -59,6 +59,12 @@ export interface MessageToolbarProps {
|
|
|
59
59
|
currentMailboxId: string;
|
|
60
60
|
onMove: (destinationMailboxId: string) => void;
|
|
61
61
|
};
|
|
62
|
+
/**
|
|
63
|
+
* Whether the lookup behind `moveContext` is still in flight. Absent context
|
|
64
|
+
* is either a read that has not landed or one that settled holding nothing,
|
|
65
|
+
* and the two owe the reader different sentences (#818).
|
|
66
|
+
*/
|
|
67
|
+
moveContextLoading?: boolean;
|
|
62
68
|
}
|
|
63
69
|
|
|
64
70
|
const OPEN_FIRST = "Open a message first";
|
|
@@ -71,8 +77,41 @@ const OPEN_FIRST = "Open a message first";
|
|
|
71
77
|
*/
|
|
72
78
|
const NOT_LOADED_YET = "This conversation hasn't loaded yet";
|
|
73
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Move alone waits on a second read — the account owning this mailbox, found by
|
|
82
|
+
* fanning out over every account's mailbox list. With the message on screen and
|
|
83
|
+
* that fan-out still in flight, "open a message first" is simply false (#818).
|
|
84
|
+
*/
|
|
85
|
+
const MAILBOXES_LOADING = "The mailbox list hasn't loaded yet";
|
|
86
|
+
|
|
87
|
+
/** The same read, settled and holding no account for this mailbox: it errored,
|
|
88
|
+
* or no account lists the folder. Waiting will not fix either one. */
|
|
89
|
+
const MAILBOXES_MISSING =
|
|
90
|
+
"Couldn't find this mailbox's account — reload to try again";
|
|
91
|
+
|
|
92
|
+
/** The accessible name of each verb's button, for the sentence that invites a
|
|
93
|
+
* press to be repeated once what it waited for arrives. */
|
|
94
|
+
const LABELS: Record<MailAction, string> = {
|
|
95
|
+
reply: "Reply",
|
|
96
|
+
replyAll: "Reply all",
|
|
97
|
+
forward: "Forward",
|
|
98
|
+
delete: "Move to Trash",
|
|
99
|
+
move: "Move to mailbox",
|
|
100
|
+
flag: "Star",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
interface Press {
|
|
104
|
+
action: MailAction;
|
|
105
|
+
messageId: string | null;
|
|
106
|
+
/** Whether a thread was open when the press was made. A Move made over an
|
|
107
|
+
* open message is carried into the picker; one made over an empty pane
|
|
108
|
+
* asked for nothing that could still arrive. */
|
|
109
|
+
overThread: boolean;
|
|
110
|
+
}
|
|
111
|
+
|
|
74
112
|
export const MessageToolbar = ({
|
|
75
113
|
hasThread,
|
|
114
|
+
messageId,
|
|
76
115
|
intelligenceOpen,
|
|
77
116
|
canToggleIntelligence,
|
|
78
117
|
onToggleIntelligence,
|
|
@@ -80,35 +119,80 @@ export const MessageToolbar = ({
|
|
|
80
119
|
onReplyAll,
|
|
81
120
|
onForward,
|
|
82
121
|
onDelete,
|
|
83
|
-
canDelete,
|
|
84
122
|
onToggleStar,
|
|
85
123
|
isStarred,
|
|
86
124
|
moveContext,
|
|
125
|
+
moveContextLoading,
|
|
87
126
|
}: MessageToolbarProps) => {
|
|
88
|
-
const [
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
|
|
93
|
-
|
|
127
|
+
const [press, setPress] = useState<Press | null>(null);
|
|
128
|
+
const actingOn = messageId ?? null;
|
|
129
|
+
|
|
130
|
+
// The one place availability is decided. It agrees with `MailActionToolbar`
|
|
131
|
+
// by construction — a null reason implies both a thread and a handler, which
|
|
132
|
+
// is exactly what the shared bar requires before it calls one — so no press
|
|
133
|
+
// can fall between the two and end in silence.
|
|
134
|
+
const whyUnavailable = (action: MailAction): string | null => {
|
|
135
|
+
if (!hasThread) return OPEN_FIRST;
|
|
136
|
+
if (action === "move") {
|
|
137
|
+
if (moveContext) return null;
|
|
138
|
+
return moveContextLoading ? MAILBOXES_LOADING : MAILBOXES_MISSING;
|
|
139
|
+
}
|
|
140
|
+
const handler = {
|
|
141
|
+
reply: onReply,
|
|
142
|
+
replyAll: onReplyAll,
|
|
143
|
+
forward: onForward,
|
|
144
|
+
delete: onDelete,
|
|
145
|
+
flag: onToggleStar,
|
|
146
|
+
}[action];
|
|
147
|
+
return handler ? null : NOT_LOADED_YET;
|
|
148
|
+
};
|
|
149
|
+
|
|
150
|
+
// Derived rather than stored: the sentence is re-read from the press on every
|
|
151
|
+
// render, so it leaves the moment its reason does. What it leaves behind is
|
|
152
|
+
// an invitation, not nothing — the press was made and never acted on.
|
|
153
|
+
const pressed = press && press.messageId === actingOn ? press : null;
|
|
154
|
+
const reason = pressed ? whyUnavailable(pressed.action) : null;
|
|
155
|
+
// Move is the exception: its press is carried into the picker below, which
|
|
156
|
+
// mounts already open, so there is nothing left to ask for.
|
|
157
|
+
const carriedMove =
|
|
158
|
+
pressed?.action === "move" && pressed.overThread && Boolean(moveContext);
|
|
159
|
+
const hint =
|
|
160
|
+
reason ??
|
|
161
|
+
(pressed && !carriedMove
|
|
162
|
+
? `${LABELS[pressed.action]} is ready — press it again`
|
|
163
|
+
: null);
|
|
164
|
+
|
|
165
|
+
const record = (action: MailAction) =>
|
|
166
|
+
setPress({ action, messageId: actingOn, overThread: hasThread });
|
|
167
|
+
const explain = (action: MailAction) => () => record(action);
|
|
168
|
+
// Every verb the bar renders answers a press, wired or not — and a press
|
|
169
|
+
// that acts takes down whatever an earlier one left behind, so a stale
|
|
170
|
+
// complaint never reads as this verb's failure.
|
|
171
|
+
const wired = (action: MailAction, handler: (() => void) | undefined) =>
|
|
172
|
+
handler
|
|
173
|
+
? () => {
|
|
174
|
+
setPress(null);
|
|
175
|
+
handler();
|
|
176
|
+
}
|
|
177
|
+
: explain(action);
|
|
94
178
|
|
|
95
179
|
return (
|
|
96
180
|
<MailActionToolbar
|
|
97
181
|
hasThread={hasThread}
|
|
98
182
|
isStarred={isStarred}
|
|
99
|
-
onUnavailable={
|
|
183
|
+
onUnavailable={record}
|
|
100
184
|
unavailableHint={hint}
|
|
101
185
|
replyTitle={`Reply ${tooltipForAction("reply")}`}
|
|
102
186
|
replyAllTitle={`Reply all ${tooltipForAction("replyAll")}`}
|
|
103
187
|
forwardTitle={`Forward ${tooltipForAction("forward")}`}
|
|
104
188
|
deleteTitle={`Move to Trash ${tooltipForAction("delete")}`}
|
|
105
189
|
flagTitle={`Star ${tooltipForAction("toggleStar")}`}
|
|
106
|
-
onReply={wired(onReply)}
|
|
107
|
-
onReplyAll={wired(onReplyAll)}
|
|
108
|
-
onForward={wired(onForward)}
|
|
109
|
-
onDelete={
|
|
110
|
-
onToggleStar={wired(onToggleStar)}
|
|
111
|
-
onMove={explain(
|
|
190
|
+
onReply={wired("reply", onReply)}
|
|
191
|
+
onReplyAll={wired("replyAll", onReplyAll)}
|
|
192
|
+
onForward={wired("forward", onForward)}
|
|
193
|
+
onDelete={wired("delete", onDelete)}
|
|
194
|
+
onToggleStar={wired("flag", onToggleStar)}
|
|
195
|
+
onMove={explain("move")}
|
|
112
196
|
moveSlot={
|
|
113
197
|
moveContext ? (
|
|
114
198
|
<MoveToTrigger
|
|
@@ -116,6 +200,7 @@ export const MessageToolbar = ({
|
|
|
116
200
|
currentMailboxId={moveContext.currentMailboxId}
|
|
117
201
|
onMove={moveContext.onMove}
|
|
118
202
|
label="Move to mailbox"
|
|
203
|
+
defaultOpen={carriedMove}
|
|
119
204
|
/>
|
|
120
205
|
) : undefined
|
|
121
206
|
}
|
|
@@ -40,6 +40,12 @@ interface MoveToTriggerProps {
|
|
|
40
40
|
* Optional accessible label override for the trigger button.
|
|
41
41
|
*/
|
|
42
42
|
label?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Mount with the picker already open. Carries a Move pressed before the
|
|
45
|
+
* account behind the mailbox had resolved through to the picker it asked
|
|
46
|
+
* for, rather than dropping the press once the button appears (#818).
|
|
47
|
+
*/
|
|
48
|
+
defaultOpen?: boolean;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
const TRIGGER_BASE =
|
|
@@ -63,8 +69,9 @@ export const MoveToTrigger = ({
|
|
|
63
69
|
disabledHint,
|
|
64
70
|
variant = "icon-only",
|
|
65
71
|
label,
|
|
72
|
+
defaultOpen = false,
|
|
66
73
|
}: MoveToTriggerProps) => {
|
|
67
|
-
const [isOpen, setIsOpen] = useState(
|
|
74
|
+
const [isOpen, setIsOpen] = useState(defaultOpen);
|
|
68
75
|
const [pickedId, setPickedId] = useState<string>();
|
|
69
76
|
const isDesktop = useIsDesktop();
|
|
70
77
|
const containerRef = useRef<HTMLDivElement>(null);
|