@remit/web-client 0.0.167 → 0.0.169

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.
@@ -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,47 +0,0 @@
1
- import type {
2
- RemitImapAccountResponse,
3
- RemitImapDescribeMessageResponse,
4
- } from "@remit/api-http-client/types.gen.ts";
5
- import { useState } from "react";
6
- import { ComposeForm } from "./ComposeForm";
7
- import type { ComposeMode } from "./ComposeProvider";
8
-
9
- interface InlineComposeProps {
10
- mode: ComposeMode;
11
- account?: RemitImapAccountResponse;
12
- sourceMessage?: RemitImapDescribeMessageResponse;
13
- onClose: () => void;
14
- }
15
-
16
- /**
17
- * The reply, as the top block of the conversation it answers. It takes no
18
- * height of its own: it is as tall as what has been written in it and pushes
19
- * the thread down as that grows, so the pane keeps the one scrollbar it had
20
- * before the reply opened. A height here would give the pane a second one, with
21
- * the caret in the inner track.
22
- */
23
- export const InlineCompose = ({
24
- mode,
25
- account,
26
- sourceMessage,
27
- onClose,
28
- }: InlineComposeProps) => {
29
- // The reply's draft lives and dies with the reply. It is not addressable yet
30
- // — that is the `$mode` child route (#720) — so it is held here, where the
31
- // composer is, rather than anywhere a later composer could read it back.
32
- const [outboxMessageId, setOutboxMessageId] = useState<string>();
33
-
34
- return (
35
- <div className="border-b border-line bg-canvas">
36
- <ComposeForm
37
- layout="flow"
38
- mode={mode}
39
- account={account}
40
- sourceMessage={sourceMessage}
41
- outboxMessageId={outboxMessageId}
42
- onDraftCreated={setOutboxMessageId}
43
- onClose={onClose}
44
- />
45
- </div>
46
- );
47
- };