@remit/web-client 0.0.146 → 0.0.148

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,223 @@
1
+ /**
2
+ * Reading a message and answering it, on a desktop pane.
3
+ *
4
+ * The inline reply was held below the conversation rather than inside it. It
5
+ * took whatever height the message left over, which on a normal-length one is
6
+ * nothing: the recipient rows and the verbs stayed, the writing area went down
7
+ * to a couple of lines, and scrolling the message could not reach it because
8
+ * the thing to reach was already on screen and squeezed.
9
+ *
10
+ * The reply belongs to the conversation and scrolls with it, and the chevron
11
+ * beside the sender is the control that puts a message away — reclaiming the
12
+ * space is what a reader reaches for it to do.
13
+ */
14
+
15
+ import assert from "node:assert/strict";
16
+ import { afterEach, describe, it } from "node:test";
17
+ import type {
18
+ RemitImapDescribeMessageResponse,
19
+ RemitImapThreadMessageResponse,
20
+ } from "@remit/api-http-client/types.gen.ts";
21
+ import {
22
+ type AnyRouter,
23
+ createMemoryHistory,
24
+ createRootRoute,
25
+ createRoute,
26
+ createRouter,
27
+ Outlet,
28
+ RouterProvider,
29
+ } from "@tanstack/react-router";
30
+ import { createElement } from "react";
31
+ import { ComposeProvider } from "@/components/compose/ComposeProvider";
32
+ import { createDomHarness, type DomHarness } from "@/test-support/dom";
33
+ import { makeAccount, makeThreadMessage } from "@/test-support/fixtures";
34
+ import { type HttpMock, mockFetch } from "@/test-support/http";
35
+ import { ConversationView } from "./ConversationView";
36
+
37
+ const ACCOUNT_ID = "acc-1";
38
+ const THREAD_ID = "thread-1";
39
+ const MAILBOX_ID = "mbx-inbox";
40
+ const MESSAGE_ID = "msg-1";
41
+
42
+ const account = makeAccount({ accountId: ACCOUNT_ID });
43
+
44
+ const threadMessage: RemitImapThreadMessageResponse = makeThreadMessage({
45
+ messageId: MESSAGE_ID,
46
+ threadId: THREAD_ID,
47
+ mailboxId: MAILBOX_ID,
48
+ accountId: ACCOUNT_ID,
49
+ subject: "Lunch Thursday?",
50
+ fromName: "Ada Lovelace",
51
+ fromEmail: "ada@example.com",
52
+ // Already read: marking one read on open is a mutation this test has no
53
+ // business driving.
54
+ isRead: true,
55
+ });
56
+
57
+ const describeMessage: RemitImapDescribeMessageResponse = {
58
+ message: {
59
+ messageId: MESSAGE_ID,
60
+ mailboxId: MAILBOX_ID,
61
+ uid: 1,
62
+ rfc822Size: 512,
63
+ internalDate: 1_767_225_600_000,
64
+ },
65
+ envelope: {
66
+ messageId: MESSAGE_ID,
67
+ date: 1_767_225_600_000,
68
+ subject: "Lunch Thursday?",
69
+ messageIdValue: "<ada-1@example.com>",
70
+ from: [
71
+ {
72
+ addressId: "addr-ada",
73
+ displayName: "Ada Lovelace",
74
+ normalizedEmail: "ada@example.com",
75
+ addressRole: "from",
76
+ addressOrder: 0,
77
+ },
78
+ ],
79
+ to: [
80
+ {
81
+ addressId: "addr-me",
82
+ normalizedEmail: "alice@example.com",
83
+ addressRole: "to",
84
+ addressOrder: 0,
85
+ },
86
+ ],
87
+ cc: [],
88
+ bcc: [],
89
+ replyTo: [],
90
+ category: "uncategorized",
91
+ senderTrust: "unknown",
92
+ },
93
+ flags: ["\\Seen"],
94
+ bodyParts: [],
95
+ references: [],
96
+ };
97
+
98
+ let harness: DomHarness | undefined;
99
+ let http: HttpMock | undefined;
100
+
101
+ afterEach(() => {
102
+ harness?.close();
103
+ harness = undefined;
104
+ http?.restore();
105
+ http = undefined;
106
+ });
107
+
108
+ // The router reads `self` at construction; the shared jsdom globals stop at
109
+ // `window`.
110
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
111
+
112
+ // The conversation is what the mailbox route renders, and the per-message
113
+ // action menu reads the route's own search — so it is mounted on a route
114
+ // rather than beside one.
115
+ const testRouter = (): AnyRouter => {
116
+ // The compose provider sits at the root the way `__root.tsx` mounts it.
117
+ const rootRoute = createRootRoute({
118
+ component: () =>
119
+ createElement(ComposeProvider, null, createElement(Outlet)),
120
+ });
121
+ const routeTree = rootRoute.addChildren([
122
+ createRoute({
123
+ getParentRoute: () => rootRoute,
124
+ path: "/mail/$mailboxId",
125
+ validateSearch: (search: Record<string, unknown>) => search,
126
+ component: () =>
127
+ createElement(ConversationView, {
128
+ threadId: THREAD_ID,
129
+ mailboxId: MAILBOX_ID,
130
+ subject: "Lunch Thursday?",
131
+ }),
132
+ }),
133
+ ]);
134
+ return createRouter({
135
+ routeTree,
136
+ history: createMemoryHistory({ initialEntries: [`/mail/${MAILBOX_ID}`] }),
137
+ }) as unknown as AnyRouter;
138
+ };
139
+
140
+ const mount = async (): Promise<DomHarness> => {
141
+ http = mockFetch((call) => {
142
+ if (call.path.endsWith("/config")) return { accounts: [account] };
143
+ if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
144
+ return { items: [threadMessage] };
145
+ }
146
+ if (call.path.endsWith(`/messages/${MESSAGE_ID}`)) return describeMessage;
147
+ return { items: [] };
148
+ });
149
+
150
+ const router = testRouter();
151
+ await router.load();
152
+ harness = createDomHarness();
153
+ harness.renderApp(createElement(RouterProvider, { router }));
154
+ await harness.flush();
155
+ await harness.wait(20);
156
+ await harness.flush();
157
+ return harness;
158
+ };
159
+
160
+ /** The r shortcut the reading pane binds — how a reader opens the reply. */
161
+ const pressReply = async (mounted: DomHarness): Promise<void> => {
162
+ mounted.dispatch(
163
+ mounted.window,
164
+ new mounted.window.KeyboardEvent("keydown", { key: "r", bubbles: true }),
165
+ );
166
+ await mounted.flush();
167
+ await mounted.wait(20);
168
+ await mounted.flush();
169
+ };
170
+
171
+ /**
172
+ * Which region of the pane a node belongs to — the pane's own child that holds
173
+ * it. Two nodes in the same region move together; two in different ones are
174
+ * separate bands of the pane, each with whatever height the other leaves.
175
+ */
176
+ const paneRegionHolding = (pane: Element, node: Node): Element | null =>
177
+ [...pane.children].find((child) => child.contains(node)) ?? null;
178
+
179
+ describe("answering the message that is open", () => {
180
+ it("puts the reply in the same scrolling region as the message", async () => {
181
+ const mounted = await mount();
182
+
183
+ await pressReply(mounted);
184
+
185
+ const pane = mounted.query("article");
186
+ assert.ok(pane, "the conversation pane is mounted");
187
+
188
+ const compose = mounted.query('[data-testid="compose-body-area"]');
189
+ assert.ok(compose, "the reply opened");
190
+
191
+ const message = mounted.query('[data-testid="message-date"]');
192
+ assert.ok(message, "the message is on screen");
193
+
194
+ // Compared as a boolean: an assertion over two DOM nodes serializes the
195
+ // whole jsdom graph into its failure message.
196
+ assert.ok(
197
+ paneRegionHolding(pane, compose) === paneRegionHolding(pane, message),
198
+ "the reply scrolls with the message instead of sitting in a band below it, where a long message leaves it no height",
199
+ );
200
+ });
201
+
202
+ it("collapses the message from the chevron beside the sender", async () => {
203
+ const mounted = await mount();
204
+
205
+ assert.ok(
206
+ mounted.text().includes("ada@example.com"),
207
+ "the message opens expanded",
208
+ );
209
+
210
+ const collapse = mounted.byLabel("Collapse message");
211
+ mounted.click(collapse);
212
+ await mounted.flush();
213
+
214
+ assert.ok(
215
+ mounted.query('[aria-label="Collapse message"]') === null,
216
+ "the chevron collapsed the message it belongs to",
217
+ );
218
+ assert.ok(
219
+ mounted.query('[role="button"][aria-expanded="false"]'),
220
+ "the message is back to a collapsed row",
221
+ );
222
+ });
223
+ });
@@ -0,0 +1,205 @@
1
+ /**
2
+ * Where the search mirror writes, now that the brief's open thread is a path
3
+ * segment (#718).
4
+ *
5
+ * Mirroring `q` used to be one navigation with one destination, because the
6
+ * thread travelled in the query and `...prev` carried it along. It is now two:
7
+ * a query going active closes the reading pane, and that close is the address
8
+ * walking up to the list — while every other write has to keep the address it
9
+ * found, or clearing the search would shut the conversation being read.
10
+ *
11
+ * Driven through a real router over the brief's real route shape, because the
12
+ * whole question is what the router resolves a destination to from a thread
13
+ * route. Reasoning about `to: "."` is not evidence.
14
+ */
15
+
16
+ import assert from "node:assert/strict";
17
+ import { afterEach, describe, it } from "node:test";
18
+ import {
19
+ type AnyRouter,
20
+ createMemoryHistory,
21
+ createRootRoute,
22
+ createRoute,
23
+ createRouter,
24
+ Outlet,
25
+ RouterProvider,
26
+ } from "@tanstack/react-router";
27
+ import { createElement } from "react";
28
+ import { MailContext, type MailContextValue } from "@/lib/mail-context";
29
+ import { mailViewKey } from "@/lib/mail-route";
30
+ import { EMPTY_RESULT_FOLDER_INDEX } from "@/lib/result-folder";
31
+ import { createDomHarness, type DomHarness } from "../test-support/dom";
32
+ import { useSearchMirror } from "./useSearchMirror";
33
+
34
+ let harness: DomHarness | undefined;
35
+
36
+ afterEach(() => {
37
+ harness?.close();
38
+ harness = undefined;
39
+ });
40
+
41
+ // The router reads `self` at construction; the shared jsdom globals stop at
42
+ // `window`.
43
+ (globalThis as { self?: typeof globalThis }).self ??= globalThis;
44
+
45
+ const THREAD_ID = "th-1";
46
+ const MESSAGE_ID = "msg-1";
47
+
48
+ const mailContext = (input: string, committed: string): MailContextValue => ({
49
+ accounts: [],
50
+ mailboxNameIndex: new Map(),
51
+ accountNameIndex: new Map(),
52
+ resultFolderIndex: EMPTY_RESULT_FOLDER_INDEX,
53
+ searchQuery: committed,
54
+ searchInput: input,
55
+ searchViewKey: "/mail/brief",
56
+ onSearchChange: () => {},
57
+ onSearchClear: () => {},
58
+ onSearchClearQuery: () => {},
59
+ intelligenceOpen: false,
60
+ onToggleIntelligence: () => {},
61
+ onSetIntelligenceOpen: () => {},
62
+ });
63
+
64
+ /**
65
+ * The brief's shape: the list is a layout route and the thread and message are
66
+ * segments under it, so a write from the list has a matched child to lose.
67
+ */
68
+ const routerAt = (href: string): AnyRouter => {
69
+ const rootRoute = createRootRoute({ component: Outlet });
70
+ const passthrough = (search: Record<string, unknown>) => search;
71
+ const mailRoute = createRoute({
72
+ getParentRoute: () => rootRoute,
73
+ path: "/mail",
74
+ validateSearch: passthrough,
75
+ component: Outlet,
76
+ });
77
+ const briefRoute = createRoute({
78
+ getParentRoute: () => mailRoute,
79
+ path: "/brief",
80
+ validateSearch: passthrough,
81
+ component: () => {
82
+ useSearchMirror({ to: "/mail/brief" });
83
+ return createElement(Outlet);
84
+ },
85
+ });
86
+ const threadRoute = createRoute({
87
+ getParentRoute: () => briefRoute,
88
+ path: "/$threadId",
89
+ component: Outlet,
90
+ });
91
+ const messageRoute = createRoute({
92
+ getParentRoute: () => threadRoute,
93
+ path: "/$messageId",
94
+ component: () => null,
95
+ });
96
+ const routeTree = rootRoute.addChildren([
97
+ mailRoute.addChildren([
98
+ briefRoute.addChildren([threadRoute.addChildren([messageRoute])]),
99
+ ]),
100
+ ]);
101
+ return createRouter({
102
+ routeTree,
103
+ history: createMemoryHistory({ initialEntries: [href] }),
104
+ }) as unknown as AnyRouter;
105
+ };
106
+
107
+ const mount = async (
108
+ router: AnyRouter,
109
+ input: string,
110
+ committed: string,
111
+ ): Promise<DomHarness> => {
112
+ const created = createDomHarness();
113
+ harness = created;
114
+ await router.load();
115
+ created.renderApp(
116
+ createElement(
117
+ MailContext.Provider,
118
+ { value: mailContext(input, committed) },
119
+ createElement(RouterProvider, { router }),
120
+ ),
121
+ );
122
+ await created.flush();
123
+ await created.wait(20);
124
+ return created;
125
+ };
126
+
127
+ const threadHref = `/mail/brief/${THREAD_ID}/${MESSAGE_ID}`;
128
+
129
+ describe("mirroring a query that goes active", () => {
130
+ it("walks up to the list, so no thread stays matched behind the results", async () => {
131
+ const router = routerAt(threadHref);
132
+ await mount(router, "invoice", "invoice");
133
+
134
+ assert.equal(router.history.location.pathname, "/mail/brief");
135
+ assert.match(router.history.location.search, /q=invoice/);
136
+ });
137
+
138
+ it("closes the pane from a bare thread address too", async () => {
139
+ const router = routerAt(`/mail/brief/${THREAD_ID}`);
140
+ await mount(router, "invoice", "invoice");
141
+
142
+ assert.equal(router.history.location.pathname, "/mail/brief");
143
+ });
144
+
145
+ it("replaces rather than pushes, so Back is not a search step", async () => {
146
+ const router = routerAt(threadHref);
147
+ const before = router.history.length;
148
+ await mount(router, "invoice", "invoice");
149
+
150
+ assert.equal(router.history.length, before);
151
+ });
152
+ });
153
+
154
+ describe("mirroring a cleared query", () => {
155
+ it("keeps the conversation open — dropping the search is not closing it", async () => {
156
+ const router = routerAt(`${threadHref}?q=invoice`);
157
+ await mount(router, "", "");
158
+
159
+ assert.equal(router.history.location.pathname, threadHref);
160
+ assert.equal(router.history.location.search.includes("q="), false);
161
+ });
162
+
163
+ it("leaves the address alone when there is nothing to write", async () => {
164
+ const router = routerAt(`${threadHref}?q=invoice`);
165
+ await mount(router, "invoice", "invoice");
166
+
167
+ assert.equal(router.history.location.pathname, threadHref);
168
+ assert.match(router.history.location.search, /q=invoice/);
169
+ });
170
+
171
+ it("waits for the debounce rather than writing the previous query", async () => {
172
+ const router = routerAt(threadHref);
173
+ await mount(router, "invo", "");
174
+
175
+ assert.equal(router.history.location.pathname, threadHref);
176
+ assert.equal(router.history.location.search.includes("q="), false);
177
+ });
178
+ });
179
+
180
+ /**
181
+ * The other half of why a typed query survives an opened thread: the field
182
+ * re-seeds on a view change, so the view key of the list and of the thread route
183
+ * under it must be equal.
184
+ *
185
+ * Read off the matches the router resolved for a real address, so the ids under
186
+ * test are the ones path segments produce rather than ids written out here.
187
+ */
188
+ describe("the view key of an open brief thread", () => {
189
+ const viewKeyAt = async (href: string): Promise<string> => {
190
+ const router = routerAt(href);
191
+ await router.load();
192
+ return mailViewKey(
193
+ router.state.matches.map((match: { routeId: string }) => ({
194
+ routeId: match.routeId,
195
+ })),
196
+ );
197
+ };
198
+
199
+ it("is the brief's own, thread and message segments alike", async () => {
200
+ const list = await viewKeyAt("/mail/brief");
201
+ assert.notEqual(list, "", "the brief resolves to a view key at all");
202
+ assert.equal(await viewKeyAt(`/mail/brief/${THREAD_ID}`), list);
203
+ assert.equal(await viewKeyAt(threadHref), list);
204
+ });
205
+ });
@@ -28,14 +28,19 @@ export type SearchMirrorTarget =
28
28
  * flight and replacing the entry the reader had just pushed. They would click
29
29
  * Inbox and land back on the brief.
30
30
  *
31
- * When a query *goes* active it also strips the selection so the reading pane
32
- * closes (#539): an open message from the pre-search list is not meaningful in
33
- * the search result set. Only on that transition though tapping a search
34
- * result commits the same `q` with the selection, so when `prev.q` already
35
- * equals the query the result was opened under it (not a pre-search leftover)
36
- * and must survive. The strip otherwise raced the tap: the row shows before the
37
- * debounce settles, so this mirror can land just after the open and close it
38
- * again.
31
+ * When a query *goes* active it also closes the reading pane (#539): an open
32
+ * message from the pre-search list is not meaningful in the search result set.
33
+ * The close is a navigation to the list route, which unmatches the thread that
34
+ * was open under it plus, until the remaining lists move their selection into
35
+ * the path, dropping the params they still read it from. Any other write keeps
36
+ * the address it found: mirroring a query the reader is editing, or clearing
37
+ * one, must not shut the conversation they are reading.
38
+ *
39
+ * Only on that transition, though — tapping a search result commits the same `q`
40
+ * with the open thread, so when the URL already says the query the conversation
41
+ * was opened under it (not a pre-search leftover) and must survive. The close
42
+ * otherwise raced the tap: the row shows before the debounce settles, so this
43
+ * mirror can land just after the open and undo it.
39
44
  */
40
45
  export function useSearchMirror(target: SearchMirrorTarget): void {
41
46
  const navigate = useNavigate();
@@ -61,20 +66,23 @@ export function useSearchMirror(target: SearchMirrorTarget): void {
61
66
  listPath,
62
67
  });
63
68
  if (!mayWrite) return;
64
- const search = (prev: Record<string, unknown>) => {
65
- const queryAlreadyActive = prev.q === committedQuery;
66
- return {
67
- ...prev,
68
- q: committedQuery || undefined,
69
- ...(committedQuery && !queryAlreadyActive
70
- ? {
71
- selectedMessageId: undefined,
72
- selectedThreadId: undefined,
73
- selectedMailboxId: undefined,
74
- }
75
- : {}),
76
- };
77
- };
69
+ const queryGoesActive =
70
+ Boolean(committedQuery) && urlQueryRef.current !== committedQuery;
71
+ const search = (prev: Record<string, unknown>) => ({
72
+ ...prev,
73
+ q: committedQuery || undefined,
74
+ ...(queryGoesActive
75
+ ? {
76
+ selectedMessageId: undefined,
77
+ selectedThreadId: undefined,
78
+ selectedMailboxId: undefined,
79
+ }
80
+ : {}),
81
+ });
82
+ if (!queryGoesActive) {
83
+ navigate({ to: ".", search, replace: true });
84
+ return;
85
+ }
78
86
  if (to === "/mail/$mailboxId") {
79
87
  if (!mailboxId) return;
80
88
  navigate({ to, params: { mailboxId }, search, replace: true });
@@ -39,7 +39,7 @@ export function useSearchScope(accounts: RemitImapAccountResponse[]): {
39
39
  if (chipId !== SEARCH_SCOPE_CHIP_ID) return;
40
40
  navigate({
41
41
  to: "/mail/brief",
42
- search: { q: searchInput || undefined, selectedMessageId: undefined },
42
+ search: { q: searchInput || undefined },
43
43
  });
44
44
  },
45
45
  [navigate, searchInput],
@@ -0,0 +1,35 @@
1
+ import { threadDetailOperationsListThreadMessagesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
3
+ import { useQuery } from "@tanstack/react-query";
4
+
5
+ /**
6
+ * A thread's own row for the message the reader pointed at.
7
+ *
8
+ * This is how a conversation answers for itself when no list holds it — a
9
+ * cross-folder search hit, or an address pasted into a fresh tab.
10
+ * `GET /threads/{threadId}/messages` returns the same row shape a listing does,
11
+ * `mailboxId` included, so the thread id is the whole address even though the
12
+ * mail is filed in some folder. The reading pane already makes that request, so
13
+ * this resolves off the same cache entry rather than a round trip of its own.
14
+ *
15
+ * A thread spans folders — the reader's own reply sits in Sent — so `messageId`
16
+ * picks the row they pointed at. With none, the newest message answers.
17
+ */
18
+ export const useThreadRow = (
19
+ threadId: string | undefined,
20
+ messageId: string | undefined,
21
+ ): RemitImapThreadMessageResponse | undefined => {
22
+ const { data } = useQuery({
23
+ ...threadDetailOperationsListThreadMessagesOptions({
24
+ path: { threadId: threadId ?? "" },
25
+ }),
26
+ enabled: Boolean(threadId),
27
+ });
28
+
29
+ const items = data?.items ?? [];
30
+ const pointedAt = messageId
31
+ ? items.find((item) => item.messageId === messageId)
32
+ : undefined;
33
+ const newest = items.length > 0 ? items[items.length - 1] : undefined;
34
+ return pointedAt ?? newest;
35
+ };
@@ -20,6 +20,7 @@ import assert from "node:assert/strict";
20
20
  import { describe, it } from "node:test";
21
21
  import {
22
22
  locationIsOnList,
23
+ locationOpensDetail,
23
24
  MAIL_BRIEF_ROUTE_ID,
24
25
  MAIL_FLAGGED_ROUTE_ID,
25
26
  MAIL_MAILBOX_ROUTE_ID,
@@ -178,3 +179,28 @@ describe("locationIsOnList", () => {
178
179
  assert.equal(locationIsOnList("/mail/outbox-2024", "/mail/outbox"), false);
179
180
  });
180
181
  });
182
+
183
+ /**
184
+ * What "a thread is open" used to be asked of the query. Everything sharing the
185
+ * single pane with the conversation reads it from the address instead.
186
+ */
187
+ describe("locationOpensDetail", () => {
188
+ it("is true for a thread and for the message inside it", () => {
189
+ assert.equal(locationOpensDetail("/mail/brief/thread-1"), true);
190
+ assert.equal(locationOpensDetail("/mail/brief/thread-1/message-1"), true);
191
+ assert.equal(locationOpensDetail("/mail/inbox-1/thread-1"), true);
192
+ });
193
+
194
+ it("is false on a bare list, trailing slash and query included", () => {
195
+ assert.equal(locationOpensDetail("/mail/brief"), false);
196
+ assert.equal(locationOpensDetail("/mail/brief/"), false);
197
+ assert.equal(locationOpensDetail("/mail/brief?q=invoice"), false);
198
+ assert.equal(locationOpensDetail("/mail/inbox-1"), false);
199
+ assert.equal(locationOpensDetail("/mail"), false);
200
+ });
201
+
202
+ it("is false outside the mail shell", () => {
203
+ assert.equal(locationOpensDetail("/settings/accounts"), false);
204
+ assert.equal(locationOpensDetail("/onboarding"), false);
205
+ });
206
+ });
@@ -92,3 +92,16 @@ export function locationIsOnList(pathname: string, listPath: string): boolean {
92
92
  if (pathname === listPath) return true;
93
93
  return pathname.startsWith(`${listPath}/`);
94
94
  }
95
+
96
+ /**
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.
99
+ *
100
+ * Answers what an open thread used to be asked of the query. Anything sharing
101
+ * the single pane with the conversation reads it from here rather than keeping a
102
+ * second opinion about what is on screen.
103
+ */
104
+ export function locationOpensDetail(pathname: string): boolean {
105
+ const segments = pathname.split(/[?#]/)[0].split("/").filter(Boolean);
106
+ return segments[0] === "mail" && segments.length > 2;
107
+ }
@@ -12,13 +12,13 @@ import { z } from "zod";
12
12
  /** What every list carries, whatever else it carries. */
13
13
  const listSearch = z.object({ q: z.string().optional() });
14
14
 
15
- export const briefSearchSchema = listSearch.extend({
16
- selectedMessageId: z.string().optional(),
17
- // A tapped semantic "Related" hit can point at a message outside the loaded
18
- // brief list; carrying its thread + mailbox lets the brief open it directly.
19
- selectedThreadId: z.string().optional(),
20
- selectedMailboxId: z.string().optional(),
21
- });
15
+ /**
16
+ * The brief's open thread and the message inside it are path segments
17
+ * (`/mail/brief/<thread>/<message>`), so the query carries nothing but the
18
+ * search. An old link's selection params are dropped here, which is what
19
+ * "tolerated and ignored" means until the other three lists follow.
20
+ */
21
+ export const briefSearchSchema = listSearch.extend({});
22
22
 
23
23
  export const flaggedSearchSchema = listSearch.extend({
24
24
  selectedMessageId: z.string().optional(),