@remit/web-client 0.0.165 → 0.0.167

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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/components/compose/ComposeForm.tsx +77 -25
  3. package/src/components/compose/ComposeProvider.tsx +11 -122
  4. package/src/components/compose/FullCompose.tsx +29 -18
  5. package/src/components/compose/InlineCompose.tsx +21 -11
  6. package/src/components/compose/MobileComposeSheet.tsx +14 -18
  7. package/src/components/compose/compose-send-stops-autosave.render.test.ts +7 -9
  8. package/src/components/compose/compose-starts-a-second-message.render.test.ts +170 -0
  9. package/src/components/compose/compose-title.ts +10 -0
  10. package/src/components/compose/mobile-header-stays-expanded.render.test.ts +5 -13
  11. package/src/components/layout/ComposeFab.tsx +6 -15
  12. package/src/components/layout/MailShell.tsx +9 -1
  13. package/src/components/layout/MailTopBar.tsx +3 -6
  14. package/src/components/mail/BriefPane.tsx +6 -5
  15. package/src/components/mail/DraftsView.tsx +14 -15
  16. package/src/components/mail/FlaggedPane.tsx +6 -5
  17. package/src/components/mail/MailboxPane.tsx +36 -124
  18. package/src/components/mail/MessageActionMenu.tsx +4 -3
  19. package/src/components/mail/MessageList.tsx +4 -3
  20. package/src/components/mail/OutboxPane.tsx +8 -13
  21. package/src/components/mail/SwipeableMessageRow.tsx +4 -3
  22. package/src/components/mail/intelligence-drawer.stories.tsx +208 -0
  23. package/src/hooks/useSaveDraft.ts +11 -0
  24. package/src/hooks/useSearchMirror.ts +12 -1
  25. package/src/lib/mail-route.test.ts +35 -1
  26. package/src/lib/mail-route.ts +2 -1
  27. package/src/routeTree.gen.ts +92 -0
  28. package/src/routes/mail/$mailboxId/compose.{-$outboxMessageId}.tsx +14 -0
  29. package/src/routes/mail/brief/compose.{-$outboxMessageId}.tsx +22 -0
  30. package/src/routes/mail/flagged/compose.{-$outboxMessageId}.tsx +13 -0
  31. package/src/routes/mail/outbox/compose.{-$outboxMessageId}.tsx +17 -0
  32. package/src/routes/mail.tsx +12 -8
  33. package/src/routing/compose-press-opens.render.test.ts +179 -0
  34. package/src/routing/compose.ts +220 -0
  35. package/src/routing/fragment.render.test.ts +98 -0
  36. package/src/routing/fragment.test.ts +54 -9
  37. package/src/routing/fragment.ts +37 -9
  38. package/src/routing/index.ts +10 -1
  39. package/src/routing/nav-link.tsx +5 -3
  40. package/src/components/compose/compose-clears-open-thread.render.test.ts +0 -312
  41. package/src/hooks/useComposeTargetMailbox.ts +0 -75
  42. package/src/lib/compose-routes.test.ts +0 -46
  43. package/src/lib/compose-routes.ts +0 -25
@@ -0,0 +1,208 @@
1
+ import { type IntelligenceData, IntelligencePanel } from "@remit/ui";
2
+ import type { Meta, StoryObj } from "@storybook/react-vite";
3
+ import { ChevronLeft, Info } from "lucide-react";
4
+ import { useState } from "react";
5
+ import { expect, userEvent, within } from "storybook/test";
6
+ import { Drawer } from "@/components/layout/Drawer";
7
+ import { resolveRailOpen } from "@/lib/intelligence-pref";
8
+ import { parseOpenPanels, retainOpenPanelsAtTier } from "@/routing";
9
+
10
+ /**
11
+ * The intelligence drawer below the desktop tier (#777).
12
+ *
13
+ * There is no fourth pane here: the rail is a full-screen drawer over the open
14
+ * message, so it belongs to the thread it was opened for. The address is the
15
+ * only place its open state lives, and a navigation drops it — otherwise a DKIM
16
+ * mismatch on one message leaves the drawer covering every message opened
17
+ * after it.
18
+ *
19
+ * Both rules are the app's own: the fragment goes through
20
+ * `retainOpenPanelsAtTier`, and what is up is `resolveRailOpen`.
21
+ */
22
+ const meta: Meta = {
23
+ title: "Flows/Reading/Intelligence Drawer",
24
+ parameters: { layout: "centered" },
25
+ };
26
+ export default meta;
27
+
28
+ type Story = StoryObj;
29
+
30
+ const PHONE_WIDTH = 390;
31
+ const PHONE_HEIGHT = 720;
32
+
33
+ const retainOnPhone = retainOpenPanelsAtTier(false);
34
+
35
+ const intelligence: IntelligenceData = {
36
+ sender: {
37
+ name: "Mondial Relay",
38
+ email: "delivery.notice.4421@gmail.example",
39
+ trust: "unknown",
40
+ firstSeenLabel: "today",
41
+ inboundCount: 1,
42
+ replyCount: 0,
43
+ },
44
+ authenticity: {
45
+ verdict: "mismatch",
46
+ fromDomain: "mondialrelay.fr",
47
+ dkimDomain: "gmail.example",
48
+ claimedBrand: "Mondial Relay",
49
+ summary:
50
+ "The display name claims “Mondial Relay”, but the message was sent and signed by a personal gmail.example mailbox — not mondialrelay.fr.",
51
+ similarCount: 15,
52
+ },
53
+ category: { value: "automated" },
54
+ similar: [],
55
+ };
56
+
57
+ interface Message {
58
+ id: string;
59
+ from: string;
60
+ subject: string;
61
+ body: string;
62
+ }
63
+
64
+ const messages: readonly Message[] = [
65
+ {
66
+ id: "msg-parcel",
67
+ from: "Mondial Relay",
68
+ subject: "Your parcel could not be delivered",
69
+ body: "We attempted to deliver your parcel today and nobody was home. Pay the outstanding €2.40 redelivery fee within 24 hours.",
70
+ },
71
+ {
72
+ id: "msg-standup",
73
+ from: "Alex Rivera",
74
+ subject: "Standup moved to 10:15",
75
+ body: "Pushing today's standup back fifteen minutes — the room is taken until then.",
76
+ },
77
+ ];
78
+
79
+ /**
80
+ * The phone reader, driven by the address alone: `hash` is the whole state,
81
+ * and every navigation writes it through the same helper the panes use.
82
+ */
83
+ const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
84
+ const [hash, setHash] = useState(initialHash);
85
+ const [openId, setOpenId] = useState<string | undefined>(messages[0]?.id);
86
+ const open = messages.find((message) => message.id === openId);
87
+ const drawerOpen = resolveRailOpen({
88
+ panels: parseOpenPanels(hash),
89
+ prefersOpen: true,
90
+ isDesktop: false,
91
+ hasThread: open !== undefined,
92
+ });
93
+ const navigate = (nextId: string | undefined) => {
94
+ setHash(retainOnPhone(hash));
95
+ setOpenId(nextId);
96
+ };
97
+
98
+ return (
99
+ <div className="flex flex-col gap-2">
100
+ <div
101
+ className="flex flex-col overflow-hidden rounded-lg border border-line bg-canvas"
102
+ // The transform makes the frame a containing block, so the drawer's
103
+ // `position: fixed` resolves against the phone instead of the canvas.
104
+ style={{
105
+ width: PHONE_WIDTH,
106
+ height: PHONE_HEIGHT,
107
+ transform: "translateZ(0)",
108
+ }}
109
+ >
110
+ {open ? (
111
+ <>
112
+ <header className="flex items-center gap-1 border-b border-line px-2 py-2">
113
+ <button
114
+ type="button"
115
+ aria-label="Back"
116
+ onClick={() => navigate(undefined)}
117
+ className="inline-flex size-11 items-center justify-center rounded-md text-fg hover:bg-surface-raised"
118
+ >
119
+ <ChevronLeft className="size-5" />
120
+ </button>
121
+ <h1 className="min-w-0 flex-1 truncate text-sm font-semibold text-fg">
122
+ {open.subject}
123
+ </h1>
124
+ <button
125
+ type="button"
126
+ aria-label="Message details"
127
+ onClick={() => setHash("intelligence")}
128
+ className="inline-flex size-11 items-center justify-center rounded-md text-fg hover:bg-surface-raised"
129
+ >
130
+ <Info className="size-5" />
131
+ </button>
132
+ </header>
133
+ <div className="min-h-0 flex-1 overflow-y-auto px-4 py-3 text-sm text-fg">
134
+ <p className="mb-2 text-2xs text-fg-subtle">{open.from}</p>
135
+ <p>{open.body}</p>
136
+ </div>
137
+ </>
138
+ ) : (
139
+ <ul className="flex flex-col">
140
+ {messages.map((message) => (
141
+ <li key={message.id}>
142
+ <button
143
+ type="button"
144
+ onClick={() => navigate(message.id)}
145
+ className="flex w-full flex-col items-start gap-0.5 border-b border-line px-4 py-3 text-left hover:bg-surface-raised"
146
+ >
147
+ <span className="text-sm font-semibold text-fg">
148
+ {message.from}
149
+ </span>
150
+ <span className="text-sm text-fg-muted">
151
+ {message.subject}
152
+ </span>
153
+ </button>
154
+ </li>
155
+ ))}
156
+ </ul>
157
+ )}
158
+ <Drawer
159
+ isOpen={drawerOpen}
160
+ onClose={() => setHash("")}
161
+ ariaLabel="Message details"
162
+ side="right"
163
+ >
164
+ <IntelligencePanel data={intelligence} hideCloseButton />
165
+ </Drawer>
166
+ </div>
167
+ <p className="font-mono text-2xs text-fg-subtle">
168
+ {`/mail/inbox${open ? `/${open.id}` : ""}${hash ? `#${hash}` : ""}`}
169
+ </p>
170
+ </div>
171
+ );
172
+ };
173
+
174
+ /** A message with no drawer over it: the address names no panel. */
175
+ export const Closed: Story = {
176
+ render: () => <PhoneReader />,
177
+ };
178
+
179
+ /**
180
+ * The drawer over the message it was opened for — here by a DKIM mismatch,
181
+ * which opens it without being asked.
182
+ */
183
+ export const OpenOverThread: Story = {
184
+ render: () => <PhoneReader initialHash="intelligence" />,
185
+ };
186
+
187
+ /**
188
+ * The same drawer, then Back and another message. The fragment is dropped on
189
+ * the way out, so the second message is not covered by a panel opened for the
190
+ * first. Press Back with the drawer up to walk it.
191
+ */
192
+ export const DroppedByNavigation: Story = {
193
+ render: () => <PhoneReader initialHash="intelligence" />,
194
+ play: async ({ canvasElement }) => {
195
+ const canvas = within(canvasElement);
196
+ await userEvent.click(canvas.getByLabelText("Back"));
197
+ await userEvent.click(canvas.getByText("Standup moved to 10:15"));
198
+ await expect(canvas.queryByRole("dialog")).toBeNull();
199
+ await expect(canvas.getByText("/mail/inbox/msg-standup")).toBeVisible();
200
+ },
201
+ };
202
+
203
+ /** The drawer over the dark theme. */
204
+ export const OpenOverThreadDark: Story = {
205
+ name: "Open Over Thread (dark)",
206
+ parameters: { theme: "dark" },
207
+ render: () => <PhoneReader initialHash="intelligence" />,
208
+ };
@@ -52,8 +52,19 @@ export const useSaveDraft = ({
52
52
  const propIdRef = useRef(outboxMessageId);
53
53
  const targetIdRef = useRef(outboxMessageId);
54
54
  if (propIdRef.current !== outboxMessageId) {
55
+ // A write waiting out the debounce holds the document that has just been
56
+ // left, aimed at whatever entry is current when it fires. Leaving one is
57
+ // therefore dropping it: otherwise it lands on the next draft, or — with
58
+ // no entry to land on at all — creates one holding the last message's
59
+ // content, which is what a second Compose press used to do.
60
+ //
61
+ // Adoption is the exception, as it is everywhere: the id arriving is this
62
+ // session's own draft coming back, and a save scheduled while it was in
63
+ // flight is still the right content for it.
64
+ const leavingADocument = propIdRef.current !== undefined;
55
65
  propIdRef.current = outboxMessageId;
56
66
  targetIdRef.current = outboxMessageId;
67
+ if (leavingADocument && timerRef.current) clearTimeout(timerRef.current);
57
68
  }
58
69
 
59
70
  const createMutation = useMutation(
@@ -2,6 +2,7 @@ import { useNavigate, useRouterState, useSearch } from "@tanstack/react-router";
2
2
  import { useEffect, useRef } from "react";
3
3
  import { useMailContext } from "@/lib/mail-context";
4
4
  import { shouldMirrorQuery } from "@/lib/search-view";
5
+ import { useIsComposing } from "@/routing";
5
6
 
6
7
  /**
7
8
  * The list the mirror writes to. Each list calls the hook with its own route,
@@ -40,12 +41,17 @@ export type SearchMirrorTarget =
40
41
  * was opened under it (not a pre-search leftover) and must survive. The close
41
42
  * otherwise raced the tap: the row shows before the debounce settles, so this
42
43
  * mirror can land just after the open and undo it.
44
+ *
45
+ * A composer is not the pre-search list's leftover either. It is the reader's
46
+ * own unsent message, so a search started while it is up narrows the list
47
+ * behind it and leaves it standing.
43
48
  */
44
49
  export function useSearchMirror(target: SearchMirrorTarget): void {
45
50
  const navigate = useNavigate();
46
51
  const { searchInput, searchQuery: committedQuery } = useMailContext();
47
52
  const { q: urlQuery = "" } = useSearch({ from: "/mail" });
48
53
  const pathname = useRouterState({ select: (s) => s.location.pathname });
54
+ const isComposing = useIsComposing();
49
55
 
50
56
  // Read at effect time rather than depended on: the URL is what the mirror
51
57
  // compares against, not what re-triggers it.
@@ -67,13 +73,17 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
67
73
  if (!mayWrite) return;
68
74
  const queryGoesActive =
69
75
  Boolean(committedQuery) && urlQueryRef.current !== committedQuery;
76
+ // The composer is the reader's own unsent message, not a leftover of the
77
+ // list they were on, so a query going active narrows the list behind it and
78
+ // leaves it where it is.
79
+ const closesTheOpenSurface = queryGoesActive && !isComposing;
70
80
  const search = (prev: Record<string, unknown>) => ({
71
81
  ...prev,
72
82
  q: committedQuery || undefined,
73
83
  });
74
84
  // A query is a mode of the view the reader is already in, so the panels
75
85
  // they have up are not ones they navigated away from.
76
- if (!queryGoesActive) {
86
+ if (!closesTheOpenSurface) {
77
87
  navigate({ to: ".", search, hash: true, replace: true });
78
88
  return;
79
89
  }
@@ -97,5 +107,6 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
97
107
  mailboxId,
98
108
  listPath,
99
109
  pathname,
110
+ isComposing,
100
111
  ]);
101
112
  }
@@ -131,6 +131,34 @@ describe("mailViewKey", () => {
131
131
  }
132
132
  });
133
133
 
134
+ // Same trap one surface over: compose is a child of the list, and a key that
135
+ // moved when it opened would wipe the query the reader was mid-search on.
136
+ it("gives a list and its compose surface the same key", () => {
137
+ assert.equal(
138
+ mailViewKey(
139
+ matches([
140
+ MAIL_BRIEF_ROUTE_ID,
141
+ `${MAIL_BRIEF_ROUTE_ID}/compose/{-$outboxMessageId}`,
142
+ ]),
143
+ ),
144
+ mailViewKey(brief),
145
+ );
146
+ assert.equal(
147
+ mailViewKey(
148
+ matches(
149
+ [
150
+ MAIL_MAILBOX_ROUTE_ID,
151
+ `${MAIL_MAILBOX_ROUTE_ID}/compose/{-$outboxMessageId}`,
152
+ ],
153
+ {
154
+ mailboxId: "inbox-1",
155
+ },
156
+ ),
157
+ ),
158
+ mailViewKey(mailbox),
159
+ );
160
+ });
161
+
134
162
  it("gives a list and its reading-pane index child the same key", () => {
135
163
  assert.equal(
136
164
  mailViewKey(matches([MAIL_BRIEF_ROUTE_ID, `${MAIL_BRIEF_ROUTE_ID}/`])),
@@ -157,7 +185,7 @@ describe("locationIsOnList", () => {
157
185
  );
158
186
  assert.equal(locationIsOnList("/mail/brief/", "/mail/brief"), true);
159
187
  assert.equal(
160
- locationIsOnList("/mail/inbox-1/compose", "/mail/inbox-1"),
188
+ locationIsOnList("/mail/inbox-1/compose/ob-1", "/mail/inbox-1"),
161
189
  true,
162
190
  );
163
191
  });
@@ -191,6 +219,12 @@ describe("locationOpensDetail", () => {
191
219
  assert.equal(locationOpensDetail("/mail/inbox-1/thread-1"), true);
192
220
  });
193
221
 
222
+ it("is true for the compose surface, which the FAB must not sit over", () => {
223
+ assert.equal(locationOpensDetail("/mail/brief/compose"), true);
224
+ assert.equal(locationOpensDetail("/mail/inbox-1/compose"), true);
225
+ assert.equal(locationOpensDetail("/mail/inbox-1/compose/ob-1"), true);
226
+ });
227
+
194
228
  it("is false on a bare list, trailing slash and query included", () => {
195
229
  assert.equal(locationOpensDetail("/mail/brief"), false);
196
230
  assert.equal(locationOpensDetail("/mail/brief/"), false);
@@ -95,7 +95,8 @@ export function locationIsOnList(pathname: string, listPath: string): boolean {
95
95
 
96
96
  /**
97
97
  * Whether the address names something the list has open below it — a thread, a
98
- * message. A list on its own, and its bare reading pane, do not.
98
+ * message, the compose surface. A list on its own, and its bare reading pane,
99
+ * do not.
99
100
  *
100
101
  * Answers what an open thread used to be asked of the query. Anything sharing
101
102
  * the single pane with the conversation reads it from here rather than keeping a
@@ -36,10 +36,14 @@ import { Route as MailFlaggedThreadIdRouteImport } from './routes/mail/flagged/$
36
36
  import { Route as MailOutboxIndexRouteImport } from './routes/mail/outbox/index'
37
37
  import { Route as MailMailboxIdThreadIdIndexRouteImport } from './routes/mail/$mailboxId/$threadId/index'
38
38
  import { Route as MailMailboxIdThreadIdMessageIdRouteImport } from './routes/mail/$mailboxId/$threadId/$messageId'
39
+ import { Route as MailMailboxIdComposeChar123OutboxMessageIdChar125RouteImport } from './routes/mail/$mailboxId/compose.{-$outboxMessageId}'
39
40
  import { Route as MailBriefThreadIdIndexRouteImport } from './routes/mail/brief/$threadId/index'
40
41
  import { Route as MailBriefThreadIdMessageIdRouteImport } from './routes/mail/brief/$threadId/$messageId'
42
+ import { Route as MailBriefComposeChar123OutboxMessageIdChar125RouteImport } from './routes/mail/brief/compose.{-$outboxMessageId}'
41
43
  import { Route as MailFlaggedThreadIdIndexRouteImport } from './routes/mail/flagged/$threadId/index'
42
44
  import { Route as MailFlaggedThreadIdMessageIdRouteImport } from './routes/mail/flagged/$threadId/$messageId'
45
+ import { Route as MailFlaggedComposeChar123OutboxMessageIdChar125RouteImport } from './routes/mail/flagged/compose.{-$outboxMessageId}'
46
+ import { Route as MailOutboxComposeChar123OutboxMessageIdChar125RouteImport } from './routes/mail/outbox/compose.{-$outboxMessageId}'
43
47
  import { Route as MailOutboxDraftOutboxMessageIdRouteImport } from './routes/mail/outbox/draft/$outboxMessageId'
44
48
 
45
49
  const IndexRoute = IndexRouteImport.update({
@@ -179,6 +183,12 @@ const MailMailboxIdThreadIdMessageIdRoute =
179
183
  path: '/$messageId',
180
184
  getParentRoute: () => MailMailboxIdThreadIdRoute,
181
185
  } as any)
186
+ const MailMailboxIdComposeChar123OutboxMessageIdChar125Route =
187
+ MailMailboxIdComposeChar123OutboxMessageIdChar125RouteImport.update({
188
+ id: '/compose/{-$outboxMessageId}',
189
+ path: '/compose/{-$outboxMessageId}',
190
+ getParentRoute: () => MailMailboxIdRoute,
191
+ } as any)
182
192
  const MailBriefThreadIdIndexRoute = MailBriefThreadIdIndexRouteImport.update({
183
193
  id: '/',
184
194
  path: '/',
@@ -190,6 +200,12 @@ const MailBriefThreadIdMessageIdRoute =
190
200
  path: '/$messageId',
191
201
  getParentRoute: () => MailBriefThreadIdRoute,
192
202
  } as any)
203
+ const MailBriefComposeChar123OutboxMessageIdChar125Route =
204
+ MailBriefComposeChar123OutboxMessageIdChar125RouteImport.update({
205
+ id: '/compose/{-$outboxMessageId}',
206
+ path: '/compose/{-$outboxMessageId}',
207
+ getParentRoute: () => MailBriefRoute,
208
+ } as any)
193
209
  const MailFlaggedThreadIdIndexRoute =
194
210
  MailFlaggedThreadIdIndexRouteImport.update({
195
211
  id: '/',
@@ -202,6 +218,18 @@ const MailFlaggedThreadIdMessageIdRoute =
202
218
  path: '/$messageId',
203
219
  getParentRoute: () => MailFlaggedThreadIdRoute,
204
220
  } as any)
221
+ const MailFlaggedComposeChar123OutboxMessageIdChar125Route =
222
+ MailFlaggedComposeChar123OutboxMessageIdChar125RouteImport.update({
223
+ id: '/compose/{-$outboxMessageId}',
224
+ path: '/compose/{-$outboxMessageId}',
225
+ getParentRoute: () => MailFlaggedRoute,
226
+ } as any)
227
+ const MailOutboxComposeChar123OutboxMessageIdChar125Route =
228
+ MailOutboxComposeChar123OutboxMessageIdChar125RouteImport.update({
229
+ id: '/compose/{-$outboxMessageId}',
230
+ path: '/compose/{-$outboxMessageId}',
231
+ getParentRoute: () => MailOutboxRoute,
232
+ } as any)
205
233
  const MailOutboxDraftOutboxMessageIdRoute =
206
234
  MailOutboxDraftOutboxMessageIdRouteImport.update({
207
235
  id: '/draft/$outboxMessageId',
@@ -236,8 +264,12 @@ export interface FileRoutesByFullPath {
236
264
  '/mail/flagged/': typeof MailFlaggedIndexRoute
237
265
  '/mail/outbox/': typeof MailOutboxIndexRoute
238
266
  '/mail/$mailboxId/$threadId/$messageId': typeof MailMailboxIdThreadIdMessageIdRoute
267
+ '/mail/$mailboxId/compose/{-$outboxMessageId}': typeof MailMailboxIdComposeChar123OutboxMessageIdChar125Route
239
268
  '/mail/brief/$threadId/$messageId': typeof MailBriefThreadIdMessageIdRoute
269
+ '/mail/brief/compose/{-$outboxMessageId}': typeof MailBriefComposeChar123OutboxMessageIdChar125Route
240
270
  '/mail/flagged/$threadId/$messageId': typeof MailFlaggedThreadIdMessageIdRoute
271
+ '/mail/flagged/compose/{-$outboxMessageId}': typeof MailFlaggedComposeChar123OutboxMessageIdChar125Route
272
+ '/mail/outbox/compose/{-$outboxMessageId}': typeof MailOutboxComposeChar123OutboxMessageIdChar125Route
241
273
  '/mail/outbox/draft/$outboxMessageId': typeof MailOutboxDraftOutboxMessageIdRoute
242
274
  '/mail/$mailboxId/$threadId/': typeof MailMailboxIdThreadIdIndexRoute
243
275
  '/mail/brief/$threadId/': typeof MailBriefThreadIdIndexRoute
@@ -261,8 +293,12 @@ export interface FileRoutesByTo {
261
293
  '/mail/flagged': typeof MailFlaggedIndexRoute
262
294
  '/mail/outbox': typeof MailOutboxIndexRoute
263
295
  '/mail/$mailboxId/$threadId/$messageId': typeof MailMailboxIdThreadIdMessageIdRoute
296
+ '/mail/$mailboxId/compose/{-$outboxMessageId}': typeof MailMailboxIdComposeChar123OutboxMessageIdChar125Route
264
297
  '/mail/brief/$threadId/$messageId': typeof MailBriefThreadIdMessageIdRoute
298
+ '/mail/brief/compose/{-$outboxMessageId}': typeof MailBriefComposeChar123OutboxMessageIdChar125Route
265
299
  '/mail/flagged/$threadId/$messageId': typeof MailFlaggedThreadIdMessageIdRoute
300
+ '/mail/flagged/compose/{-$outboxMessageId}': typeof MailFlaggedComposeChar123OutboxMessageIdChar125Route
301
+ '/mail/outbox/compose/{-$outboxMessageId}': typeof MailOutboxComposeChar123OutboxMessageIdChar125Route
266
302
  '/mail/outbox/draft/$outboxMessageId': typeof MailOutboxDraftOutboxMessageIdRoute
267
303
  '/mail/$mailboxId/$threadId': typeof MailMailboxIdThreadIdIndexRoute
268
304
  '/mail/brief/$threadId': typeof MailBriefThreadIdIndexRoute
@@ -296,8 +332,12 @@ export interface FileRoutesById {
296
332
  '/mail/flagged/': typeof MailFlaggedIndexRoute
297
333
  '/mail/outbox/': typeof MailOutboxIndexRoute
298
334
  '/mail/$mailboxId/$threadId/$messageId': typeof MailMailboxIdThreadIdMessageIdRoute
335
+ '/mail/$mailboxId/compose/{-$outboxMessageId}': typeof MailMailboxIdComposeChar123OutboxMessageIdChar125Route
299
336
  '/mail/brief/$threadId/$messageId': typeof MailBriefThreadIdMessageIdRoute
337
+ '/mail/brief/compose/{-$outboxMessageId}': typeof MailBriefComposeChar123OutboxMessageIdChar125Route
300
338
  '/mail/flagged/$threadId/$messageId': typeof MailFlaggedThreadIdMessageIdRoute
339
+ '/mail/flagged/compose/{-$outboxMessageId}': typeof MailFlaggedComposeChar123OutboxMessageIdChar125Route
340
+ '/mail/outbox/compose/{-$outboxMessageId}': typeof MailOutboxComposeChar123OutboxMessageIdChar125Route
301
341
  '/mail/outbox/draft/$outboxMessageId': typeof MailOutboxDraftOutboxMessageIdRoute
302
342
  '/mail/$mailboxId/$threadId/': typeof MailMailboxIdThreadIdIndexRoute
303
343
  '/mail/brief/$threadId/': typeof MailBriefThreadIdIndexRoute
@@ -332,8 +372,12 @@ export interface FileRouteTypes {
332
372
  | '/mail/flagged/'
333
373
  | '/mail/outbox/'
334
374
  | '/mail/$mailboxId/$threadId/$messageId'
375
+ | '/mail/$mailboxId/compose/{-$outboxMessageId}'
335
376
  | '/mail/brief/$threadId/$messageId'
377
+ | '/mail/brief/compose/{-$outboxMessageId}'
336
378
  | '/mail/flagged/$threadId/$messageId'
379
+ | '/mail/flagged/compose/{-$outboxMessageId}'
380
+ | '/mail/outbox/compose/{-$outboxMessageId}'
337
381
  | '/mail/outbox/draft/$outboxMessageId'
338
382
  | '/mail/$mailboxId/$threadId/'
339
383
  | '/mail/brief/$threadId/'
@@ -357,8 +401,12 @@ export interface FileRouteTypes {
357
401
  | '/mail/flagged'
358
402
  | '/mail/outbox'
359
403
  | '/mail/$mailboxId/$threadId/$messageId'
404
+ | '/mail/$mailboxId/compose/{-$outboxMessageId}'
360
405
  | '/mail/brief/$threadId/$messageId'
406
+ | '/mail/brief/compose/{-$outboxMessageId}'
361
407
  | '/mail/flagged/$threadId/$messageId'
408
+ | '/mail/flagged/compose/{-$outboxMessageId}'
409
+ | '/mail/outbox/compose/{-$outboxMessageId}'
362
410
  | '/mail/outbox/draft/$outboxMessageId'
363
411
  | '/mail/$mailboxId/$threadId'
364
412
  | '/mail/brief/$threadId'
@@ -391,8 +439,12 @@ export interface FileRouteTypes {
391
439
  | '/mail/flagged/'
392
440
  | '/mail/outbox/'
393
441
  | '/mail/$mailboxId/$threadId/$messageId'
442
+ | '/mail/$mailboxId/compose/{-$outboxMessageId}'
394
443
  | '/mail/brief/$threadId/$messageId'
444
+ | '/mail/brief/compose/{-$outboxMessageId}'
395
445
  | '/mail/flagged/$threadId/$messageId'
446
+ | '/mail/flagged/compose/{-$outboxMessageId}'
447
+ | '/mail/outbox/compose/{-$outboxMessageId}'
396
448
  | '/mail/outbox/draft/$outboxMessageId'
397
449
  | '/mail/$mailboxId/$threadId/'
398
450
  | '/mail/brief/$threadId/'
@@ -597,6 +649,13 @@ declare module '@tanstack/react-router' {
597
649
  preLoaderRoute: typeof MailMailboxIdThreadIdMessageIdRouteImport
598
650
  parentRoute: typeof MailMailboxIdThreadIdRoute
599
651
  }
652
+ '/mail/$mailboxId/compose/{-$outboxMessageId}': {
653
+ id: '/mail/$mailboxId/compose/{-$outboxMessageId}'
654
+ path: '/compose/{-$outboxMessageId}'
655
+ fullPath: '/mail/$mailboxId/compose/{-$outboxMessageId}'
656
+ preLoaderRoute: typeof MailMailboxIdComposeChar123OutboxMessageIdChar125RouteImport
657
+ parentRoute: typeof MailMailboxIdRoute
658
+ }
600
659
  '/mail/brief/$threadId/': {
601
660
  id: '/mail/brief/$threadId/'
602
661
  path: '/'
@@ -611,6 +670,13 @@ declare module '@tanstack/react-router' {
611
670
  preLoaderRoute: typeof MailBriefThreadIdMessageIdRouteImport
612
671
  parentRoute: typeof MailBriefThreadIdRoute
613
672
  }
673
+ '/mail/brief/compose/{-$outboxMessageId}': {
674
+ id: '/mail/brief/compose/{-$outboxMessageId}'
675
+ path: '/compose/{-$outboxMessageId}'
676
+ fullPath: '/mail/brief/compose/{-$outboxMessageId}'
677
+ preLoaderRoute: typeof MailBriefComposeChar123OutboxMessageIdChar125RouteImport
678
+ parentRoute: typeof MailBriefRoute
679
+ }
614
680
  '/mail/flagged/$threadId/': {
615
681
  id: '/mail/flagged/$threadId/'
616
682
  path: '/'
@@ -625,6 +691,20 @@ declare module '@tanstack/react-router' {
625
691
  preLoaderRoute: typeof MailFlaggedThreadIdMessageIdRouteImport
626
692
  parentRoute: typeof MailFlaggedThreadIdRoute
627
693
  }
694
+ '/mail/flagged/compose/{-$outboxMessageId}': {
695
+ id: '/mail/flagged/compose/{-$outboxMessageId}'
696
+ path: '/compose/{-$outboxMessageId}'
697
+ fullPath: '/mail/flagged/compose/{-$outboxMessageId}'
698
+ preLoaderRoute: typeof MailFlaggedComposeChar123OutboxMessageIdChar125RouteImport
699
+ parentRoute: typeof MailFlaggedRoute
700
+ }
701
+ '/mail/outbox/compose/{-$outboxMessageId}': {
702
+ id: '/mail/outbox/compose/{-$outboxMessageId}'
703
+ path: '/compose/{-$outboxMessageId}'
704
+ fullPath: '/mail/outbox/compose/{-$outboxMessageId}'
705
+ preLoaderRoute: typeof MailOutboxComposeChar123OutboxMessageIdChar125RouteImport
706
+ parentRoute: typeof MailOutboxRoute
707
+ }
628
708
  '/mail/outbox/draft/$outboxMessageId': {
629
709
  id: '/mail/outbox/draft/$outboxMessageId'
630
710
  path: '/draft/$outboxMessageId'
@@ -653,11 +733,14 @@ const MailMailboxIdThreadIdRouteWithChildren =
653
733
  interface MailMailboxIdRouteChildren {
654
734
  MailMailboxIdThreadIdRoute: typeof MailMailboxIdThreadIdRouteWithChildren
655
735
  MailMailboxIdIndexRoute: typeof MailMailboxIdIndexRoute
736
+ MailMailboxIdComposeChar123OutboxMessageIdChar125Route: typeof MailMailboxIdComposeChar123OutboxMessageIdChar125Route
656
737
  }
657
738
 
658
739
  const MailMailboxIdRouteChildren: MailMailboxIdRouteChildren = {
659
740
  MailMailboxIdThreadIdRoute: MailMailboxIdThreadIdRouteWithChildren,
660
741
  MailMailboxIdIndexRoute: MailMailboxIdIndexRoute,
742
+ MailMailboxIdComposeChar123OutboxMessageIdChar125Route:
743
+ MailMailboxIdComposeChar123OutboxMessageIdChar125Route,
661
744
  }
662
745
 
663
746
  const MailMailboxIdRouteWithChildren = MailMailboxIdRoute._addFileChildren(
@@ -680,11 +763,14 @@ const MailBriefThreadIdRouteWithChildren =
680
763
  interface MailBriefRouteChildren {
681
764
  MailBriefThreadIdRoute: typeof MailBriefThreadIdRouteWithChildren
682
765
  MailBriefIndexRoute: typeof MailBriefIndexRoute
766
+ MailBriefComposeChar123OutboxMessageIdChar125Route: typeof MailBriefComposeChar123OutboxMessageIdChar125Route
683
767
  }
684
768
 
685
769
  const MailBriefRouteChildren: MailBriefRouteChildren = {
686
770
  MailBriefThreadIdRoute: MailBriefThreadIdRouteWithChildren,
687
771
  MailBriefIndexRoute: MailBriefIndexRoute,
772
+ MailBriefComposeChar123OutboxMessageIdChar125Route:
773
+ MailBriefComposeChar123OutboxMessageIdChar125Route,
688
774
  }
689
775
 
690
776
  const MailBriefRouteWithChildren = MailBriefRoute._addFileChildren(
@@ -707,11 +793,14 @@ const MailFlaggedThreadIdRouteWithChildren =
707
793
  interface MailFlaggedRouteChildren {
708
794
  MailFlaggedThreadIdRoute: typeof MailFlaggedThreadIdRouteWithChildren
709
795
  MailFlaggedIndexRoute: typeof MailFlaggedIndexRoute
796
+ MailFlaggedComposeChar123OutboxMessageIdChar125Route: typeof MailFlaggedComposeChar123OutboxMessageIdChar125Route
710
797
  }
711
798
 
712
799
  const MailFlaggedRouteChildren: MailFlaggedRouteChildren = {
713
800
  MailFlaggedThreadIdRoute: MailFlaggedThreadIdRouteWithChildren,
714
801
  MailFlaggedIndexRoute: MailFlaggedIndexRoute,
802
+ MailFlaggedComposeChar123OutboxMessageIdChar125Route:
803
+ MailFlaggedComposeChar123OutboxMessageIdChar125Route,
715
804
  }
716
805
 
717
806
  const MailFlaggedRouteWithChildren = MailFlaggedRoute._addFileChildren(
@@ -720,11 +809,14 @@ const MailFlaggedRouteWithChildren = MailFlaggedRoute._addFileChildren(
720
809
 
721
810
  interface MailOutboxRouteChildren {
722
811
  MailOutboxIndexRoute: typeof MailOutboxIndexRoute
812
+ MailOutboxComposeChar123OutboxMessageIdChar125Route: typeof MailOutboxComposeChar123OutboxMessageIdChar125Route
723
813
  MailOutboxDraftOutboxMessageIdRoute: typeof MailOutboxDraftOutboxMessageIdRoute
724
814
  }
725
815
 
726
816
  const MailOutboxRouteChildren: MailOutboxRouteChildren = {
727
817
  MailOutboxIndexRoute: MailOutboxIndexRoute,
818
+ MailOutboxComposeChar123OutboxMessageIdChar125Route:
819
+ MailOutboxComposeChar123OutboxMessageIdChar125Route,
728
820
  MailOutboxDraftOutboxMessageIdRoute: MailOutboxDraftOutboxMessageIdRoute,
729
821
  }
730
822
 
@@ -0,0 +1,14 @@
1
+ // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
+ /**
3
+ * /mail/$mailboxId/compose — the compose surface, open over a folder, and
4
+ * /mail/$mailboxId/compose/<outboxMessageId> for the draft it is editing. The
5
+ * Drafts folder's own rows open here.
6
+ */
7
+ import { createFileRoute } from "@tanstack/react-router";
8
+ import { FullCompose } from "@/components/compose/FullCompose";
9
+
10
+ export const Route = createFileRoute(
11
+ "/mail/$mailboxId/compose/{-$outboxMessageId}",
12
+ )({
13
+ component: FullCompose,
14
+ });
@@ -0,0 +1,22 @@
1
+ // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
+ /**
3
+ * /mail/brief/compose — the compose surface, open over the daily brief, and
4
+ * /mail/brief/compose/<outboxMessageId> once it has a draft to write to.
5
+ *
6
+ * A sibling of the thread route, so navigating here unmatches whatever the list
7
+ * had open in the same transition: nothing has to be closed first, and "opened"
8
+ * and "rendered" are one fact.
9
+ *
10
+ * The draft is an optional segment of this one route rather than a child route,
11
+ * because the first autosave adopts the id it just created while the reader is
12
+ * still typing. Two routes would unmount the composer mid-sentence; one route
13
+ * with a param that arrives is a rewritten address and nothing more.
14
+ */
15
+ import { createFileRoute } from "@tanstack/react-router";
16
+ import { FullCompose } from "@/components/compose/FullCompose";
17
+
18
+ export const Route = createFileRoute("/mail/brief/compose/{-$outboxMessageId}")(
19
+ {
20
+ component: FullCompose,
21
+ },
22
+ );
@@ -0,0 +1,13 @@
1
+ // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
+ /**
3
+ * /mail/flagged/compose — the compose surface, open over the starred list, and
4
+ * /mail/flagged/compose/<outboxMessageId> once it has a draft to write to.
5
+ */
6
+ import { createFileRoute } from "@tanstack/react-router";
7
+ import { FullCompose } from "@/components/compose/FullCompose";
8
+
9
+ export const Route = createFileRoute(
10
+ "/mail/flagged/compose/{-$outboxMessageId}",
11
+ )({
12
+ component: FullCompose,
13
+ });
@@ -0,0 +1,17 @@
1
+ // biome-ignore lint/style/useFilenamingConvention: TanStack Router convention
2
+ /**
3
+ * /mail/outbox/compose — the compose surface, open over the outbox, and
4
+ * /mail/outbox/compose/<outboxMessageId> for the draft it is editing.
5
+ *
6
+ * `draft/<outboxMessageId>` beside it reads an outbox message; this writes one.
7
+ * The two are separate addresses because they are separate surfaces, and "Edit
8
+ * as draft" is the move from one to the other.
9
+ */
10
+ import { createFileRoute } from "@tanstack/react-router";
11
+ import { FullCompose } from "@/components/compose/FullCompose";
12
+
13
+ export const Route = createFileRoute(
14
+ "/mail/outbox/compose/{-$outboxMessageId}",
15
+ )({
16
+ component: FullCompose,
17
+ });