@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
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
import { useNavigate, useParams } from "@tanstack/react-router";
|
|
2
|
+
import { useCallback, useMemo } from "react";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import type { MailListRoute } from "@/lib/mail-route";
|
|
5
|
+
import { useBrowsedList } from "./browsed-list";
|
|
6
|
+
import { useRetainOpenPanels } from "./fragment";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The three ways to answer a message. They are one path param rather than three
|
|
10
|
+
* literal routes, so the reply is one address shape and the mode is a value the
|
|
11
|
+
* composer reads.
|
|
12
|
+
*/
|
|
13
|
+
export const replyModes = ["reply", "reply-all", "forward"] as const;
|
|
14
|
+
|
|
15
|
+
export type ReplyMode = (typeof replyModes)[number];
|
|
16
|
+
|
|
17
|
+
const replyModeSchema = z.enum(replyModes);
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The reply route under each list that can have a conversation open. The outbox
|
|
21
|
+
* has no thread segment, so it has no reply either.
|
|
22
|
+
*
|
|
23
|
+
* The draft is an optional segment of the same route rather than a child of it,
|
|
24
|
+
* for the reason `compose/{-$outboxMessageId}` is: the first autosave adopts
|
|
25
|
+
* the id it created while the reader is mid-sentence, and a child route would
|
|
26
|
+
* unmount the composer to record it.
|
|
27
|
+
*/
|
|
28
|
+
const BRIEF_REPLY =
|
|
29
|
+
"/mail/brief/$threadId/$messageId/$mode/{-$outboxMessageId}" as const;
|
|
30
|
+
const FLAGGED_REPLY =
|
|
31
|
+
"/mail/flagged/$threadId/$messageId/$mode/{-$outboxMessageId}" as const;
|
|
32
|
+
const MAILBOX_REPLY =
|
|
33
|
+
"/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}" as const;
|
|
34
|
+
|
|
35
|
+
/** The conversation and the turn inside it that a reply answers. */
|
|
36
|
+
export interface ReplyTarget {
|
|
37
|
+
threadId: string;
|
|
38
|
+
messageId: string;
|
|
39
|
+
mode: ReplyMode;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The reply the address names.
|
|
44
|
+
*
|
|
45
|
+
* `unknown` is a hand-typed segment naming no mode. It is a case rather than a
|
|
46
|
+
* throw because the conversation behind it is still open and readable, and the
|
|
47
|
+
* pane says so where the composer would have been.
|
|
48
|
+
*/
|
|
49
|
+
export interface ReplyAddress {
|
|
50
|
+
mode: ReplyMode;
|
|
51
|
+
threadId: string;
|
|
52
|
+
/** The message being answered, which is the segment above this one. */
|
|
53
|
+
sourceMessageId: string;
|
|
54
|
+
/** The draft it writes to, once the first autosave has made one. */
|
|
55
|
+
outboxMessageId: string | undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export type ReplySurface =
|
|
59
|
+
| ({ kind: "reply" } & ReplyAddress)
|
|
60
|
+
| { kind: "unknown"; segment: string };
|
|
61
|
+
|
|
62
|
+
interface ReplyParams {
|
|
63
|
+
threadId: string;
|
|
64
|
+
messageId: string;
|
|
65
|
+
mode: string;
|
|
66
|
+
outboxMessageId?: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The reply match, if the address has one. Each `from` names a real route, so a
|
|
71
|
+
* segment that does not exist fails to compile.
|
|
72
|
+
*
|
|
73
|
+
* A path matches one list at a time, so at most one of these answers.
|
|
74
|
+
*/
|
|
75
|
+
function useReplyParams(): ReplyParams | undefined {
|
|
76
|
+
const brief = useParams({ from: BRIEF_REPLY, shouldThrow: false });
|
|
77
|
+
const flagged = useParams({ from: FLAGGED_REPLY, shouldThrow: false });
|
|
78
|
+
const mailbox = useParams({ from: MAILBOX_REPLY, shouldThrow: false });
|
|
79
|
+
return brief ?? flagged ?? mailbox;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The reply surface the address names, or none.
|
|
84
|
+
*
|
|
85
|
+
* The mode and the message it answers are both segments, so a reply cannot
|
|
86
|
+
* exist without a source and neither fact has a second owner. The draft is the
|
|
87
|
+
* segment under them, so a reload comes back to what was being written instead
|
|
88
|
+
* of starting a second draft beside it.
|
|
89
|
+
*/
|
|
90
|
+
export function useReplySurface(): ReplySurface | undefined {
|
|
91
|
+
const params = useReplyParams();
|
|
92
|
+
return useMemo(() => {
|
|
93
|
+
if (!params) return undefined;
|
|
94
|
+
const mode = replyModeSchema.safeParse(params.mode);
|
|
95
|
+
if (!mode.success) return { kind: "unknown", segment: params.mode };
|
|
96
|
+
return {
|
|
97
|
+
kind: "reply",
|
|
98
|
+
mode: mode.data,
|
|
99
|
+
threadId: params.threadId,
|
|
100
|
+
sourceMessageId: params.messageId,
|
|
101
|
+
outboxMessageId: params.outboxMessageId,
|
|
102
|
+
};
|
|
103
|
+
}, [params]);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Whether a composer is open inside the conversation.
|
|
108
|
+
*
|
|
109
|
+
* The list's keyboard layer suspends on this: with a reply up, r, R and f are
|
|
110
|
+
* the composer's business, and a list still answering them would restart the
|
|
111
|
+
* reply on screen — or aim one at whichever row the cursor happens to be on.
|
|
112
|
+
* A segment naming no mode does not count: there is no composer to protect, and
|
|
113
|
+
* r is then the way out of a hand-typed address.
|
|
114
|
+
*/
|
|
115
|
+
export function useIsReplying(): boolean {
|
|
116
|
+
return useReplySurface()?.kind === "reply";
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface ReplyNavigation {
|
|
120
|
+
to: typeof BRIEF_REPLY | typeof FLAGGED_REPLY;
|
|
121
|
+
params: ReplyTarget & { outboxMessageId: string | undefined };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
interface MailboxReplyNavigation {
|
|
125
|
+
to: typeof MAILBOX_REPLY;
|
|
126
|
+
params: ReplyTarget & {
|
|
127
|
+
mailboxId: string;
|
|
128
|
+
outboxMessageId: string | undefined;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Where a reply opens: under the message, in the list the reader is browsing.
|
|
134
|
+
*
|
|
135
|
+
* The brief is the fallback for the reason compose falls back to it — it always
|
|
136
|
+
* exists and always mounts the conversation — so the frame before a folder id
|
|
137
|
+
* resolves opens a composer rather than reporting that it cannot.
|
|
138
|
+
*/
|
|
139
|
+
function replyTarget(
|
|
140
|
+
list: MailListRoute["list"] | undefined,
|
|
141
|
+
mailboxId: string | undefined,
|
|
142
|
+
target: ReplyTarget,
|
|
143
|
+
outboxMessageId: string | undefined,
|
|
144
|
+
): ReplyNavigation | MailboxReplyNavigation {
|
|
145
|
+
if (list === "flagged")
|
|
146
|
+
return { to: FLAGGED_REPLY, params: { ...target, outboxMessageId } };
|
|
147
|
+
if (list === "mailbox" && mailboxId)
|
|
148
|
+
return {
|
|
149
|
+
to: MAILBOX_REPLY,
|
|
150
|
+
params: { ...target, mailboxId, outboxMessageId },
|
|
151
|
+
};
|
|
152
|
+
return { to: BRIEF_REPLY, params: { ...target, outboxMessageId } };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The draft a new reply address inherits: the one already being written, when
|
|
157
|
+
* the reply on screen answers the same message.
|
|
158
|
+
*
|
|
159
|
+
* Reply All over a reply is the same message being written — the recipients
|
|
160
|
+
* change and the text does not — so the draft segment travels with the mode.
|
|
161
|
+
* Dropping it would say "a different document" to the composer, which blanks
|
|
162
|
+
* the fields and leaves the draft reachable only from the Outbox. A different
|
|
163
|
+
* message is a different reply and inherits nothing.
|
|
164
|
+
*/
|
|
165
|
+
function inheritedDraft(
|
|
166
|
+
open: ReplySurface | undefined,
|
|
167
|
+
target: ReplyTarget,
|
|
168
|
+
): string | undefined {
|
|
169
|
+
if (open?.kind !== "reply") return undefined;
|
|
170
|
+
if (open.threadId !== target.threadId) return undefined;
|
|
171
|
+
if (open.sourceMessageId !== target.messageId) return undefined;
|
|
172
|
+
return open.outboxMessageId;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Answer a message: the mode and the turn it answers, as one navigation.
|
|
177
|
+
*
|
|
178
|
+
* The message travels with the mode, so replying from a row the address had not
|
|
179
|
+
* named yet opens the conversation on it and the composer over it in the same
|
|
180
|
+
* transition — there is no "open it first, then reply" for the second half to
|
|
181
|
+
* be dropped from.
|
|
182
|
+
*
|
|
183
|
+
* A push, so Back leaves the reply and returns the message it was written
|
|
184
|
+
* under, and the query travels with it.
|
|
185
|
+
*/
|
|
186
|
+
export function useOpenReply(): (target: ReplyTarget) => void {
|
|
187
|
+
const navigate = useNavigate();
|
|
188
|
+
const retainPanels = useRetainOpenPanels();
|
|
189
|
+
const { list, mailboxId } = useBrowsedList();
|
|
190
|
+
const open = useReplySurface();
|
|
191
|
+
|
|
192
|
+
return useCallback(
|
|
193
|
+
(target: ReplyTarget) => {
|
|
194
|
+
navigate({
|
|
195
|
+
...replyTarget(list, mailboxId, target, inheritedDraft(open, target)),
|
|
196
|
+
search: (prev: Record<string, unknown>) => prev,
|
|
197
|
+
hash: retainPanels,
|
|
198
|
+
});
|
|
199
|
+
},
|
|
200
|
+
[navigate, retainPanels, list, mailboxId, open],
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Record the draft the reply just created, in the address.
|
|
206
|
+
*
|
|
207
|
+
* A replace, and every panel left alone: the reader started one message, so the
|
|
208
|
+
* draft arriving under it is neither another step of history nor a move away
|
|
209
|
+
* from what they have open over the conversation.
|
|
210
|
+
*/
|
|
211
|
+
export function useAdoptReplyDraft(): (outboxMessageId: string) => void {
|
|
212
|
+
const navigate = useNavigate();
|
|
213
|
+
const { list, mailboxId } = useBrowsedList();
|
|
214
|
+
const surface = useReplySurface();
|
|
215
|
+
|
|
216
|
+
return useCallback(
|
|
217
|
+
(outboxMessageId: string) => {
|
|
218
|
+
if (surface?.kind !== "reply") return;
|
|
219
|
+
navigate({
|
|
220
|
+
...replyTarget(
|
|
221
|
+
list,
|
|
222
|
+
mailboxId,
|
|
223
|
+
{
|
|
224
|
+
threadId: surface.threadId,
|
|
225
|
+
messageId: surface.sourceMessageId,
|
|
226
|
+
mode: surface.mode,
|
|
227
|
+
},
|
|
228
|
+
outboxMessageId,
|
|
229
|
+
),
|
|
230
|
+
search: (prev: Record<string, unknown>) => prev,
|
|
231
|
+
hash: true,
|
|
232
|
+
replace: true,
|
|
233
|
+
});
|
|
234
|
+
},
|
|
235
|
+
[navigate, list, mailboxId, surface],
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Close the reply, landing back on the message it was answering.
|
|
241
|
+
*
|
|
242
|
+
* A push, like opening it: dismissing a surface moves the reader on, and Back
|
|
243
|
+
* is how they undo that. The conversation stayed matched behind the reply, so
|
|
244
|
+
* this drops the segments below it rather than rebuilding an address.
|
|
245
|
+
*/
|
|
246
|
+
export function useCloseReply(): () => void {
|
|
247
|
+
const navigate = useNavigate();
|
|
248
|
+
const retainPanels = useRetainOpenPanels();
|
|
249
|
+
const { list, mailboxId } = useBrowsedList();
|
|
250
|
+
const params = useReplyParams();
|
|
251
|
+
|
|
252
|
+
return useCallback(() => {
|
|
253
|
+
if (!params) return;
|
|
254
|
+
const search = (prev: Record<string, unknown>) => prev;
|
|
255
|
+
const hash = retainPanels;
|
|
256
|
+
const message = { threadId: params.threadId, messageId: params.messageId };
|
|
257
|
+
if (list === "flagged") {
|
|
258
|
+
navigate({
|
|
259
|
+
to: "/mail/flagged/$threadId/$messageId",
|
|
260
|
+
params: message,
|
|
261
|
+
search,
|
|
262
|
+
hash,
|
|
263
|
+
});
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
if (list === "mailbox" && mailboxId) {
|
|
267
|
+
navigate({
|
|
268
|
+
to: "/mail/$mailboxId/$threadId/$messageId",
|
|
269
|
+
params: { ...message, mailboxId },
|
|
270
|
+
search,
|
|
271
|
+
hash,
|
|
272
|
+
});
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
navigate({
|
|
276
|
+
to: "/mail/brief/$threadId/$messageId",
|
|
277
|
+
params: message,
|
|
278
|
+
search,
|
|
279
|
+
hash,
|
|
280
|
+
});
|
|
281
|
+
}, [navigate, retainPanels, list, mailboxId, params]);
|
|
282
|
+
}
|
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
import type {
|
|
2
|
-
RemitImapAccountResponse,
|
|
3
|
-
RemitImapDescribeMessageResponse,
|
|
4
|
-
} from "@remit/api-http-client/types.gen.ts";
|
|
5
|
-
import { ComposeForm } from "./ComposeForm";
|
|
6
|
-
import type { ComposeMode } from "./ComposeProvider";
|
|
7
|
-
|
|
8
|
-
interface InlineComposeProps {
|
|
9
|
-
mode: ComposeMode;
|
|
10
|
-
account?: RemitImapAccountResponse;
|
|
11
|
-
sourceMessage?: RemitImapDescribeMessageResponse;
|
|
12
|
-
onClose: () => void;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* The reply, as the top block of the conversation it answers. It takes no
|
|
17
|
-
* height of its own: it is as tall as what has been written in it and pushes
|
|
18
|
-
* the thread down as that grows, so the pane keeps the one scrollbar it had
|
|
19
|
-
* before the reply opened. A height here would give the pane a second one, with
|
|
20
|
-
* the caret in the inner track.
|
|
21
|
-
*/
|
|
22
|
-
export const InlineCompose = ({
|
|
23
|
-
mode,
|
|
24
|
-
account,
|
|
25
|
-
sourceMessage,
|
|
26
|
-
onClose,
|
|
27
|
-
}: InlineComposeProps) => (
|
|
28
|
-
<div className="border-b border-line bg-canvas">
|
|
29
|
-
<ComposeForm
|
|
30
|
-
layout="flow"
|
|
31
|
-
mode={mode}
|
|
32
|
-
account={account}
|
|
33
|
-
sourceMessage={sourceMessage}
|
|
34
|
-
onClose={onClose}
|
|
35
|
-
/>
|
|
36
|
-
</div>
|
|
37
|
-
);
|
|
@@ -1,312 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Issue #703: compose state opened with nothing mounting the surface, so the
|
|
3
|
-
* button looked dead and the window turned up on the next navigation.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import assert from "node:assert/strict";
|
|
7
|
-
import { afterEach, describe, it } from "node:test";
|
|
8
|
-
import {
|
|
9
|
-
type AnyRouter,
|
|
10
|
-
createMemoryHistory,
|
|
11
|
-
createRootRoute,
|
|
12
|
-
createRoute,
|
|
13
|
-
createRouter,
|
|
14
|
-
Outlet,
|
|
15
|
-
RouterProvider,
|
|
16
|
-
} from "@tanstack/react-router";
|
|
17
|
-
import { createElement, useEffect, useState } from "react";
|
|
18
|
-
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
19
|
-
import { type HttpMock, mockFetch } from "../../test-support/http";
|
|
20
|
-
import { ComposeProvider, useCompose } from "./ComposeProvider";
|
|
21
|
-
|
|
22
|
-
let harness: DomHarness | undefined;
|
|
23
|
-
let http: HttpMock | undefined;
|
|
24
|
-
let releaseMailboxes: (() => void) | undefined;
|
|
25
|
-
|
|
26
|
-
afterEach(() => {
|
|
27
|
-
releaseMailboxes?.();
|
|
28
|
-
releaseMailboxes = undefined;
|
|
29
|
-
harness?.close();
|
|
30
|
-
harness = undefined;
|
|
31
|
-
http?.restore();
|
|
32
|
-
http = undefined;
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
36
|
-
// `window`.
|
|
37
|
-
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
38
|
-
|
|
39
|
-
const ACCOUNT_ID = "acc-1";
|
|
40
|
-
const INBOX_ID = "mbx-inbox";
|
|
41
|
-
|
|
42
|
-
const ComposeProbe = () => {
|
|
43
|
-
const { state, openCompose } = useCompose();
|
|
44
|
-
return createElement(
|
|
45
|
-
"button",
|
|
46
|
-
{
|
|
47
|
-
type: "button",
|
|
48
|
-
"data-open": String(state.isOpen),
|
|
49
|
-
onClick: () => openCompose({ mode: "new" }),
|
|
50
|
-
},
|
|
51
|
-
"Compose",
|
|
52
|
-
);
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
// The provider sits at the root the way `__root.tsx` mounts it, so navigation
|
|
56
|
-
// reaches it the way it does in the app.
|
|
57
|
-
const RootLayout = () =>
|
|
58
|
-
createElement(
|
|
59
|
-
ComposeProvider,
|
|
60
|
-
null,
|
|
61
|
-
createElement(ComposeProbe),
|
|
62
|
-
createElement(Outlet),
|
|
63
|
-
);
|
|
64
|
-
|
|
65
|
-
/**
|
|
66
|
-
* The folder's real shape: the thread and the message are segments under the
|
|
67
|
-
* list, so an open conversation is something the address holds and compose has
|
|
68
|
-
* to navigate out of.
|
|
69
|
-
*/
|
|
70
|
-
const routerAt = (href: string, layout = RootLayout): AnyRouter => {
|
|
71
|
-
const rootRoute = createRootRoute({ component: layout });
|
|
72
|
-
const mailboxRoute = createRoute({
|
|
73
|
-
getParentRoute: () => rootRoute,
|
|
74
|
-
path: "/mail/$mailboxId",
|
|
75
|
-
validateSearch: (search: Record<string, unknown>) => search,
|
|
76
|
-
component: Outlet,
|
|
77
|
-
});
|
|
78
|
-
const threadRoute = createRoute({
|
|
79
|
-
getParentRoute: () => mailboxRoute,
|
|
80
|
-
path: "/$threadId",
|
|
81
|
-
component: Outlet,
|
|
82
|
-
});
|
|
83
|
-
const messageRoute = createRoute({
|
|
84
|
-
getParentRoute: () => threadRoute,
|
|
85
|
-
path: "/$messageId",
|
|
86
|
-
component: () => null,
|
|
87
|
-
});
|
|
88
|
-
const routeTree = rootRoute.addChildren([
|
|
89
|
-
createRoute({
|
|
90
|
-
getParentRoute: () => rootRoute,
|
|
91
|
-
path: "/mail/outbox",
|
|
92
|
-
validateSearch: (search: Record<string, unknown>) => search,
|
|
93
|
-
component: () => null,
|
|
94
|
-
}),
|
|
95
|
-
mailboxRoute.addChildren([threadRoute.addChildren([messageRoute])]),
|
|
96
|
-
createRoute({
|
|
97
|
-
getParentRoute: () => rootRoute,
|
|
98
|
-
path: "/settings",
|
|
99
|
-
component: () => null,
|
|
100
|
-
}),
|
|
101
|
-
]);
|
|
102
|
-
return createRouter({
|
|
103
|
-
routeTree,
|
|
104
|
-
history: createMemoryHistory({ initialEntries: [href] }),
|
|
105
|
-
}) as unknown as AnyRouter;
|
|
106
|
-
};
|
|
107
|
-
|
|
108
|
-
interface MountOptions {
|
|
109
|
-
/** Hold the folder list open, so no target has resolved at press time. */
|
|
110
|
-
holdMailboxes?: boolean;
|
|
111
|
-
/** Answer with an account that has no folders at all. */
|
|
112
|
-
noMailboxes?: boolean;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
const mount = async (
|
|
116
|
-
router: AnyRouter,
|
|
117
|
-
options: MountOptions = {},
|
|
118
|
-
): Promise<DomHarness> => {
|
|
119
|
-
const held = options.holdMailboxes
|
|
120
|
-
? new Promise<void>((resolve) => {
|
|
121
|
-
releaseMailboxes = resolve;
|
|
122
|
-
})
|
|
123
|
-
: undefined;
|
|
124
|
-
|
|
125
|
-
http = mockFetch(async (call) => {
|
|
126
|
-
if (call.path.endsWith("/config")) {
|
|
127
|
-
return {
|
|
128
|
-
accounts: [
|
|
129
|
-
{
|
|
130
|
-
accountId: ACCOUNT_ID,
|
|
131
|
-
email: "me@example.com",
|
|
132
|
-
folderAppointments: [{ role: "Inbox", mailboxId: INBOX_ID }],
|
|
133
|
-
},
|
|
134
|
-
],
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
if (call.path.endsWith("/mailboxes")) {
|
|
138
|
-
if (held) await held;
|
|
139
|
-
if (options.noMailboxes) return { items: [] };
|
|
140
|
-
return { items: [{ mailboxId: INBOX_ID, fullPath: "INBOX" }] };
|
|
141
|
-
}
|
|
142
|
-
return {};
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
const created = createDomHarness();
|
|
146
|
-
harness = created;
|
|
147
|
-
// Resolve the first match before mounting: `RouterProvider` renders its
|
|
148
|
-
// pending state until the router has loaded, and nothing here waits for it.
|
|
149
|
-
await router.load();
|
|
150
|
-
created.renderApp(createElement(RouterProvider, { router }));
|
|
151
|
-
await created.flush();
|
|
152
|
-
await created.wait(20);
|
|
153
|
-
return created;
|
|
154
|
-
};
|
|
155
|
-
|
|
156
|
-
const press = async (mounted: DomHarness): Promise<HTMLElement> => {
|
|
157
|
-
const button = mounted.byText("button", "Compose");
|
|
158
|
-
mounted.click(button);
|
|
159
|
-
await mounted.flush();
|
|
160
|
-
await mounted.wait(20);
|
|
161
|
-
return button;
|
|
162
|
-
};
|
|
163
|
-
|
|
164
|
-
const THREAD_HREF = `/mail/${INBOX_ID}/th-1/msg-1`;
|
|
165
|
-
|
|
166
|
-
describe("opening compose over an open message (#703)", () => {
|
|
167
|
-
it("walks up to the list so the pane can render the surface", async () => {
|
|
168
|
-
const router = routerAt(THREAD_HREF);
|
|
169
|
-
const mounted = await mount(router);
|
|
170
|
-
|
|
171
|
-
const button = mounted.byText("button", "Compose");
|
|
172
|
-
assert.equal(button.getAttribute("data-open"), "false");
|
|
173
|
-
|
|
174
|
-
await press(mounted);
|
|
175
|
-
|
|
176
|
-
assert.equal(button.getAttribute("data-open"), "true");
|
|
177
|
-
assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
it("keeps the query, so the search the user typed survives", async () => {
|
|
181
|
-
const router = routerAt(`${THREAD_HREF}?q=invoice`);
|
|
182
|
-
const mounted = await mount(router);
|
|
183
|
-
|
|
184
|
-
await press(mounted);
|
|
185
|
-
|
|
186
|
-
assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
|
|
187
|
-
assert.match(router.history.location.search, /q=invoice/);
|
|
188
|
-
});
|
|
189
|
-
|
|
190
|
-
it("leaves the message one Back away rather than erasing it", async () => {
|
|
191
|
-
const router = routerAt(THREAD_HREF);
|
|
192
|
-
const mounted = await mount(router);
|
|
193
|
-
|
|
194
|
-
await press(mounted);
|
|
195
|
-
router.history.back();
|
|
196
|
-
await mounted.flush();
|
|
197
|
-
|
|
198
|
-
assert.equal(router.history.location.pathname, THREAD_HREF);
|
|
199
|
-
});
|
|
200
|
-
|
|
201
|
-
it("adds no history entry when the pane had nothing open", async () => {
|
|
202
|
-
const router = routerAt(`/mail/${INBOX_ID}`);
|
|
203
|
-
const mounted = await mount(router);
|
|
204
|
-
const entries = router.history.length;
|
|
205
|
-
|
|
206
|
-
await press(mounted);
|
|
207
|
-
|
|
208
|
-
assert.equal(router.history.length, entries);
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
it("carries a compose started off the outbox to a route that mounts it", async () => {
|
|
212
|
-
const router = routerAt("/mail/outbox");
|
|
213
|
-
const mounted = await mount(router);
|
|
214
|
-
|
|
215
|
-
const button = await press(mounted);
|
|
216
|
-
|
|
217
|
-
assert.equal(button.getAttribute("data-open"), "true");
|
|
218
|
-
assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
// Walking off the routes that mount the surface closes it. That rule reads the
|
|
223
|
-
// live location, which this harness does not advance — `RouterProvider` here
|
|
224
|
-
// serves its first match and stays there — so it is pinned in the e2e suite
|
|
225
|
-
// (`compose-over-open-message.spec.ts`) instead. What is checked here is the
|
|
226
|
-
// half that would break it: opening compose navigates, and the surface has to
|
|
227
|
-
// survive its own navigation.
|
|
228
|
-
describe("compose survives the navigation that opens it (#703)", () => {
|
|
229
|
-
it("stays open when the press carried the user to another route", async () => {
|
|
230
|
-
const router = routerAt("/mail/outbox");
|
|
231
|
-
const mounted = await mount(router);
|
|
232
|
-
|
|
233
|
-
const button = await press(mounted);
|
|
234
|
-
|
|
235
|
-
assert.equal(router.history.location.pathname, `/mail/${INBOX_ID}`);
|
|
236
|
-
assert.equal(button.getAttribute("data-open"), "true");
|
|
237
|
-
});
|
|
238
|
-
});
|
|
239
|
-
|
|
240
|
-
describe("the open call is stable", () => {
|
|
241
|
-
// A fresh `openCompose` on every render is a loop, not a nuisance: callers
|
|
242
|
-
// hold it in dependency arrays, and one of them opens compose from an effect.
|
|
243
|
-
it("hands back the same function across a re-render", async () => {
|
|
244
|
-
const identities = new Set<unknown>();
|
|
245
|
-
const CountingProbe = () => {
|
|
246
|
-
const { openCompose } = useCompose();
|
|
247
|
-
const [bumped, setBumped] = useState(0);
|
|
248
|
-
useEffect(() => {
|
|
249
|
-
identities.add(openCompose);
|
|
250
|
-
}, [openCompose]);
|
|
251
|
-
return createElement(
|
|
252
|
-
"button",
|
|
253
|
-
{ type: "button", onClick: () => setBumped(bumped + 1) },
|
|
254
|
-
`Bump ${bumped}`,
|
|
255
|
-
);
|
|
256
|
-
};
|
|
257
|
-
const CountingLayout = () =>
|
|
258
|
-
createElement(
|
|
259
|
-
ComposeProvider,
|
|
260
|
-
null,
|
|
261
|
-
createElement(CountingProbe),
|
|
262
|
-
createElement(Outlet),
|
|
263
|
-
);
|
|
264
|
-
|
|
265
|
-
const mounted = await mount(routerAt(`/mail/${INBOX_ID}`, CountingLayout));
|
|
266
|
-
// The target legitimately settles as config and the folder list land. What
|
|
267
|
-
// must not happen is another identity after that, on a render that has
|
|
268
|
-
// nothing to do with compose.
|
|
269
|
-
const settled = identities.size;
|
|
270
|
-
|
|
271
|
-
mounted.click(mounted.byText("button", "Bump"));
|
|
272
|
-
await mounted.flush();
|
|
273
|
-
await mounted.wait(20);
|
|
274
|
-
|
|
275
|
-
assert.equal(mounted.byText("button", "Bump").textContent, "Bump 1");
|
|
276
|
-
assert.equal(identities.size, settled);
|
|
277
|
-
});
|
|
278
|
-
});
|
|
279
|
-
|
|
280
|
-
describe("a compose with nowhere to land says so", () => {
|
|
281
|
-
it("opens nothing and reports it while the folder list is in flight", async () => {
|
|
282
|
-
const router = routerAt("/mail/outbox");
|
|
283
|
-
const mounted = await mount(router, { holdMailboxes: true });
|
|
284
|
-
|
|
285
|
-
const button = await press(mounted);
|
|
286
|
-
|
|
287
|
-
assert.equal(button.getAttribute("data-open"), "false");
|
|
288
|
-
assert.equal(router.history.location.pathname, "/mail/outbox");
|
|
289
|
-
assert.match(mounted.text(), /Not ready to write yet/);
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it("names the fix when no account has a folder", async () => {
|
|
293
|
-
const router = routerAt("/mail/outbox");
|
|
294
|
-
const mounted = await mount(router, { noMailboxes: true });
|
|
295
|
-
|
|
296
|
-
const button = await press(mounted);
|
|
297
|
-
|
|
298
|
-
assert.equal(button.getAttribute("data-open"), "false");
|
|
299
|
-
assert.match(mounted.text(), /Nowhere to write from/);
|
|
300
|
-
assert.match(mounted.text(), /Settings/);
|
|
301
|
-
});
|
|
302
|
-
|
|
303
|
-
it("asks the API for nothing off the mail routes", async () => {
|
|
304
|
-
const router = routerAt("/settings");
|
|
305
|
-
await mount(router);
|
|
306
|
-
|
|
307
|
-
assert.deepEqual(
|
|
308
|
-
(http?.calls ?? []).map((call) => call.path),
|
|
309
|
-
[],
|
|
310
|
-
);
|
|
311
|
-
});
|
|
312
|
-
});
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Where a compose started off a mailbox route has to land.
|
|
3
|
-
*
|
|
4
|
-
* `FullCompose` is mounted by the mailbox route only, so compose started from
|
|
5
|
-
* the daily brief, flagged or the outbox has to carry the user to a mailbox
|
|
6
|
-
* first. The target is the first account's inbox, falling back to its first
|
|
7
|
-
* mailbox.
|
|
8
|
-
*/
|
|
9
|
-
import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
10
|
-
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
11
|
-
import { useQueries } from "@tanstack/react-query";
|
|
12
|
-
import { useMemo } from "react";
|
|
13
|
-
import { buildMailboxRoleMap } from "@/lib/folder-roles";
|
|
14
|
-
|
|
15
|
-
/**
|
|
16
|
-
* The mailbox to land on, or why there is none: a folder list still in flight
|
|
17
|
-
* is a different answer to the user than an account with no folders at all.
|
|
18
|
-
*/
|
|
19
|
-
export type ComposeTarget =
|
|
20
|
-
| { status: "ready"; mailboxId: string }
|
|
21
|
-
| { status: "loading" }
|
|
22
|
-
| { status: "none" };
|
|
23
|
-
|
|
24
|
-
/**
|
|
25
|
-
* Accounts resolve in order and an account whose mailbox query is still in
|
|
26
|
-
* flight blocks rather than being skipped: skipping hands back a later
|
|
27
|
-
* account's inbox and then silently swaps the target once the earlier query
|
|
28
|
-
* settles.
|
|
29
|
-
*
|
|
30
|
-
* The answer is memoised on the two values it is made of. A fresh object every
|
|
31
|
-
* render would rebuild `openCompose` on every render of the provider, and a
|
|
32
|
-
* caller with it in a dependency array then never settles.
|
|
33
|
-
*/
|
|
34
|
-
export function useComposeTarget(
|
|
35
|
-
accounts: RemitImapAccountResponse[],
|
|
36
|
-
): ComposeTarget {
|
|
37
|
-
const mailboxQueries = useQueries({
|
|
38
|
-
queries: accounts.map((account) => ({
|
|
39
|
-
...mailboxOperationsListMailboxesOptions({
|
|
40
|
-
path: { accountId: account.accountId },
|
|
41
|
-
}),
|
|
42
|
-
staleTime: Infinity,
|
|
43
|
-
})),
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
let status: ComposeTarget["status"] = "none";
|
|
47
|
-
let readyMailboxId: string | undefined;
|
|
48
|
-
for (const [index, account] of accounts.entries()) {
|
|
49
|
-
const query = mailboxQueries[index];
|
|
50
|
-
if (!query || query.isPending) {
|
|
51
|
-
status = "loading";
|
|
52
|
-
break;
|
|
53
|
-
}
|
|
54
|
-
const mailboxes = query.data?.items ?? [];
|
|
55
|
-
if (mailboxes.length === 0) continue;
|
|
56
|
-
const roleMap = buildMailboxRoleMap(account.folderAppointments);
|
|
57
|
-
const inbox = mailboxes.find(
|
|
58
|
-
(mailbox) => roleMap.get(mailbox.mailboxId) === "inbox",
|
|
59
|
-
);
|
|
60
|
-
const mailboxId = (inbox ?? mailboxes[0])?.mailboxId;
|
|
61
|
-
if (mailboxId) {
|
|
62
|
-
status = "ready";
|
|
63
|
-
readyMailboxId = mailboxId;
|
|
64
|
-
break;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
return useMemo(
|
|
69
|
-
() =>
|
|
70
|
-
status === "ready" && readyMailboxId
|
|
71
|
-
? { status: "ready", mailboxId: readyMailboxId }
|
|
72
|
-
: { status: status === "ready" ? "none" : status },
|
|
73
|
-
[status, readyMailboxId],
|
|
74
|
-
);
|
|
75
|
-
}
|