@remit/web-client 0.0.311 → 0.0.313

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,93 @@
1
+ import type { RemitImapOutboxAttachmentResponse } from "@remit/api-http-client/types.gen.ts";
2
+ import type { InlineImagePlacement, InlineImages } from "@remit/ui/rich-text";
3
+ import { useMemo, useRef } from "react";
4
+ import { useAuthProvider } from "@/auth/provider";
5
+ import {
6
+ attachmentFailureContent,
7
+ extractAttachmentFailureDetail,
8
+ extractAttachmentFailureReason,
9
+ fetchAttachment,
10
+ } from "@/lib/attachment-download";
11
+ import type { PushErrorInput } from "../components/ui/error-banners";
12
+ import type { StoredContent } from "../lib/compose-attachment-controller";
13
+
14
+ interface UseInlineImagesOptions {
15
+ attach: (files: File[], placement?: InlineImagePlacement) => Promise<void>;
16
+ owns: (contentId: string) => boolean;
17
+ isLoaded: () => boolean;
18
+ storedContent: (contentId: string) => StoredContent | null;
19
+ refreshLinks: () => Promise<void>;
20
+ onRemoved: (listener: (contentId: string) => void) => () => void;
21
+ draftAttachments: readonly RemitImapOutboxAttachmentResponse[];
22
+ pushError: (input: PushErrorInput) => string;
23
+ }
24
+
25
+ export const useInlineImages = (
26
+ options: UseInlineImagesOptions,
27
+ ): InlineImages => {
28
+ const { getToken } = useAuthProvider();
29
+ const latest = useRef({ ...options, getToken });
30
+ latest.current = { ...options, getToken };
31
+
32
+ return useMemo<InlineImages>(() => {
33
+ const beforeLoad = (contentId: string): StoredContent | null => {
34
+ const current = latest.current;
35
+ if (current.isLoaded()) return null;
36
+ return (
37
+ current.draftAttachments.find(
38
+ (attachment) => attachment.contentId === contentId,
39
+ ) ?? null
40
+ );
41
+ };
42
+
43
+ const stored = (contentId: string): StoredContent | null =>
44
+ latest.current.storedContent(contentId) ?? beforeLoad(contentId);
45
+
46
+ const fetchStored = (content: StoredContent): Promise<string> =>
47
+ fetchAttachment(content.contentUrl, latest.current.getToken).then(
48
+ (blob) =>
49
+ URL.createObjectURL(new Blob([blob], { type: content.contentType })),
50
+ );
51
+
52
+ const fetchRenewed = (contentId: string): Promise<string> =>
53
+ latest.current.refreshLinks().then(() => {
54
+ const renewed = stored(contentId);
55
+ if (!renewed) {
56
+ throw new Error("the file is no longer on this draft");
57
+ }
58
+ return fetchStored(renewed);
59
+ });
60
+
61
+ return {
62
+ attach: (files, placement) => {
63
+ void latest.current.attach(files, placement);
64
+ },
65
+ owns: (contentId) =>
66
+ latest.current.owns(contentId) || beforeLoad(contentId) !== null,
67
+ onRemoved: (listener) => latest.current.onRemoved(listener),
68
+ resolve: (contentId) => {
69
+ const content = stored(contentId);
70
+ if (!content) return Promise.resolve(null);
71
+ return fetchStored(content)
72
+ .catch((error: unknown) => {
73
+ if (extractAttachmentFailureReason(error) !== "link-expired") {
74
+ throw error;
75
+ }
76
+ return fetchRenewed(contentId);
77
+ })
78
+ .catch((error: unknown) => {
79
+ const reason = extractAttachmentFailureReason(error);
80
+ latest.current.pushError({
81
+ ...attachmentFailureContent(
82
+ reason,
83
+ content.filename,
84
+ extractAttachmentFailureDetail(error),
85
+ ),
86
+ error: reason === "generic" ? error : undefined,
87
+ });
88
+ return null;
89
+ });
90
+ },
91
+ };
92
+ }, []);
93
+ };
@@ -22,6 +22,7 @@ import {
22
22
  calendarUnavailable,
23
23
  calendarWriteGate,
24
24
  } from "@/components/calendar/CalendarUnavailable";
25
+ import type { ReplyWithText } from "@/components/compose/reply-with-times";
25
26
  import {
26
27
  calendarWindowOfDays,
27
28
  deviceTimeZone,
@@ -57,7 +58,6 @@ interface Working {
57
58
  tab: IntelligenceTabId | undefined;
58
59
  offering: boolean;
59
60
  picked: string[];
60
- copy: "idle" | "copied" | "failed";
61
61
  answering: string;
62
62
  /** The card the last refusal was about, and what it said. */
63
63
  failure: { suggestionId: string; text: string };
@@ -68,7 +68,6 @@ const fresh = (messageId: string): Working => ({
68
68
  tab: undefined,
69
69
  offering: false,
70
70
  picked: [],
71
- copy: "idle",
72
71
  answering: "",
73
72
  failure: { suggestionId: "", text: "" },
74
73
  });
@@ -92,6 +91,7 @@ export interface IntelligenceCalendar {
92
91
 
93
92
  export function useIntelligenceCalendar(
94
93
  thread: RemitImapThreadMessageResponse,
94
+ replyWithText?: ReplyWithText,
95
95
  ): IntelligenceCalendar {
96
96
  const messageId = thread.messageId;
97
97
  const { suggestions, isLoading, error } =
@@ -238,7 +238,8 @@ export function useIntelligenceCalendar(
238
238
  : "Couldn't read the invitations in this message. Reopen it to try again.";
239
239
 
240
240
  const cancellation = invitation?.method === "Cancel";
241
- const copyText = date === "" ? "" : slotsAsText(date, slots, working.picked);
241
+ const pickedText =
242
+ date === "" ? "" : slotsAsText(date, slots, working.picked);
242
243
 
243
244
  const actions: IntelligenceCalendarActions = {
244
245
  onAddInvite: () =>
@@ -272,24 +273,14 @@ export function useIntelligenceCalendar(
272
273
  onOfferOtherTimes: () => update({ offering: true }),
273
274
  onToggleSlot: (slot) =>
274
275
  update({
275
- copy: "idle",
276
276
  picked: working.picked.includes(slot.startTime)
277
277
  ? working.picked.filter((start) => start !== slot.startTime)
278
278
  : [...working.picked, slot.startTime],
279
279
  }),
280
- onCopySlots: () => {
281
- // Outside a secure context — plain http on a tailnet host — the
282
- // browser exposes no clipboard at all, so the absence is the failure.
283
- const clipboard: Clipboard | undefined = navigator.clipboard;
284
- if (!clipboard) {
285
- update({ copy: "failed" });
286
- return;
287
- }
288
- void Promise.resolve()
289
- .then(() => clipboard.writeText(copyText))
290
- .then(() => update({ copy: "copied" }))
291
- .catch(() => update({ copy: "failed" }));
292
- },
280
+ onReplyWithSlots: replyWithText
281
+ ? () =>
282
+ replyWithText({ threadId: thread.threadId, messageId }, pickedText)
283
+ : undefined,
293
284
  onAddSuggestion: (suggestionId) =>
294
285
  answer(suggestionId, "add this to your calendar", () =>
295
286
  answers.accept(suggestionId, defaultCalendarId),
@@ -334,8 +325,6 @@ export function useIntelligenceCalendar(
334
325
  proposals: [],
335
326
  slots,
336
327
  picked: working.picked,
337
- copy: working.copy,
338
- copyText,
339
328
  }
340
329
  : undefined,
341
330
  suggestions: deck,
@@ -55,6 +55,7 @@ const createServer = () => {
55
55
  },
56
56
  complete: async (_draft, outboxAttachmentId) => {
57
57
  completed.push(outboxAttachmentId);
58
+ return { contentUrl: `https://content.test/${outboxAttachmentId}` };
58
59
  },
59
60
  keep: async (_draft, attachmentIds) => {
60
61
  keeps.push(attachmentIds);
@@ -66,12 +67,20 @@ const createServer = () => {
66
67
  },
67
68
  };
68
69
 
70
+ const refuseMint = (filename: string, error: unknown) => {
71
+ const answer = mints.get(filename);
72
+ if (!answer) throw new Error(`no reservation asked for ${filename}`);
73
+ answer.reject(error);
74
+ };
75
+
69
76
  const answerMint = (filename: string, outboxAttachmentId: string) => {
70
77
  const answer = mints.get(filename);
71
78
  if (!answer) throw new Error(`no reservation asked for ${filename}`);
72
79
  answer.resolve({
73
80
  outboxAttachmentId,
74
81
  filename,
82
+ contentType: "application/octet-stream",
83
+ contentId: `${outboxAttachmentId}@test`,
75
84
  uploadUrl: `https://upload.test/${outboxAttachmentId}`,
76
85
  });
77
86
  };
@@ -79,6 +88,7 @@ const createServer = () => {
79
88
  return {
80
89
  transport,
81
90
  answerMint,
91
+ refuseMint,
82
92
  onDraft,
83
93
  put,
84
94
  completed,
@@ -128,6 +138,106 @@ describe("ComposeAttachmentController", () => {
128
138
  assert.equal(controller.blockingReason(), undefined);
129
139
  });
130
140
 
141
+ it("hands the content-id to whoever attached the file once it is stored", async () => {
142
+ const { server, controller } = setup();
143
+ const stored: [string, string][] = [];
144
+ const shot = file("shot.png");
145
+
146
+ const attaching = controller.attach([shot], {
147
+ stored: (attached, contentId) => {
148
+ stored.push([attached.name, contentId]);
149
+ },
150
+ failed: () => {
151
+ throw new Error("a stored file was reported failed");
152
+ },
153
+ });
154
+ await settle();
155
+ assert.deepEqual(stored, [], "named before the bytes were stored");
156
+ server.answerMint("shot.png", "att-shot");
157
+ await attaching;
158
+
159
+ assert.deepEqual(stored, [["shot.png", "att-shot@test"]]);
160
+ assert.deepEqual(controller.storedContent("att-shot@test"), {
161
+ filename: "shot.png",
162
+ contentType: "application/octet-stream",
163
+ contentUrl: "https://content.test/att-shot",
164
+ });
165
+ });
166
+
167
+ it("tells whoever attached a refused file that it did not land, and keeps the refusal on its row", async () => {
168
+ const { server, controller } = setup();
169
+ const failed: string[] = [];
170
+
171
+ const attaching = controller.attach([file("huge.png")], {
172
+ stored: () => {
173
+ throw new Error("a refused file was reported stored");
174
+ },
175
+ failed: (attached) => {
176
+ failed.push(attached.name);
177
+ },
178
+ });
179
+ await settle();
180
+ server.refuseMint("huge.png", {
181
+ code: "attachment_rejected",
182
+ reason: "FileTooLarge",
183
+ message: '"huge.png" is 30.0 MB, over the 25.0 MB a message can carry.',
184
+ });
185
+ await attaching;
186
+
187
+ assert.deepEqual(failed, ["huge.png"]);
188
+ assert.deepEqual(rowFor(controller, "huge.png")?.state, {
189
+ status: "failed",
190
+ reason: '"huge.png" is 30.0 MB, over the 25.0 MB a message can carry.',
191
+ retryable: false,
192
+ });
193
+ });
194
+
195
+ it("names the content-id of a file once the server has dropped it", async () => {
196
+ const { server, controller } = setup();
197
+ const removed: string[] = [];
198
+ controller.onRemoved((contentId) => removed.push(contentId));
199
+
200
+ const attaching = controller.attach([file("shot.png")]);
201
+ await settle();
202
+ server.answerMint("shot.png", "att-shot");
203
+ await attaching;
204
+ await controller.remove(controller.getSnapshot()[0].key);
205
+
206
+ assert.deepEqual(removed, ["att-shot@test"]);
207
+ assert.equal(controller.owns("att-shot@test"), false);
208
+ });
209
+
210
+ it("keeps a file attached while the draft it joins was still loading", async () => {
211
+ const { server, controller } = setup();
212
+
213
+ const attaching = controller.attach([file("minutes.pdf")]);
214
+ await settle();
215
+ controller.load(DRAFT, [
216
+ {
217
+ outboxAttachmentId: "att-earlier",
218
+ outboxMessageId: DRAFT,
219
+ filename: "agenda.pdf",
220
+ contentType: "application/pdf",
221
+ sizeBytes: 5,
222
+ state: "Stored",
223
+ contentId: "att-earlier@remit",
224
+ contentUrl: "https://content.test/att-earlier",
225
+ },
226
+ ]);
227
+ server.answerMint("minutes.pdf", "att-minutes");
228
+ await attaching;
229
+
230
+ assert.deepEqual(
231
+ controller
232
+ .getSnapshot()
233
+ .map((item) => [item.filename, item.state.status]),
234
+ [
235
+ ["agenda.pdf", "attached"],
236
+ ["minutes.pdf", "attached"],
237
+ ],
238
+ );
239
+ });
240
+
131
241
  it("takes a file removed while its reservation is out off the draft, and never uploads it", async () => {
132
242
  const { server, controller } = setup();
133
243
 
@@ -12,9 +12,26 @@ export type DraftForAttachment =
12
12
  export interface MintedAttachment {
13
13
  outboxAttachmentId: string;
14
14
  filename: string;
15
+ contentType: string;
16
+ contentId: string;
15
17
  uploadUrl: string;
16
18
  }
17
19
 
20
+ export interface CompletedAttachment {
21
+ contentUrl: string;
22
+ }
23
+
24
+ export interface AttachmentPlacement {
25
+ stored: (file: File, contentId: string) => void;
26
+ failed: (file: File) => void;
27
+ }
28
+
29
+ export interface StoredContent {
30
+ filename: string;
31
+ contentType: string;
32
+ contentUrl: string;
33
+ }
34
+
18
35
  /** The four requests a file on a draft is made of. Each throws on failure. */
19
36
  export interface AttachmentTransport {
20
37
  mint: (outboxMessageId: string, file: File) => Promise<MintedAttachment>;
@@ -22,7 +39,7 @@ export interface AttachmentTransport {
22
39
  complete: (
23
40
  outboxMessageId: string,
24
41
  outboxAttachmentId: string,
25
- ) => Promise<void>;
42
+ ) => Promise<CompletedAttachment>;
26
43
  keep: (outboxMessageId: string, attachmentIds: string[]) => Promise<void>;
27
44
  }
28
45
 
@@ -34,6 +51,10 @@ export interface ComposeAttachmentControllerDeps {
34
51
  interface Entry extends ComposeAttachmentItem {
35
52
  serverId: string | null;
36
53
  file: File | null;
54
+ placement: AttachmentPlacement | null;
55
+ contentId: string | null;
56
+ contentType: string;
57
+ contentUrl: string | null;
37
58
  }
38
59
 
39
60
  interface AttachmentRejection {
@@ -91,6 +112,10 @@ const fromServer = (attachment: RemitImapOutboxAttachmentResponse): Entry => ({
91
112
  key: attachment.outboxAttachmentId,
92
113
  serverId: attachment.outboxAttachmentId,
93
114
  file: null,
115
+ placement: null,
116
+ contentId: attachment.contentId,
117
+ contentType: attachment.contentType,
118
+ contentUrl: attachment.contentUrl,
94
119
  filename: attachment.filename,
95
120
  sizeBytes: attachment.sizeBytes,
96
121
  state:
@@ -124,6 +149,8 @@ export class ComposeAttachmentController {
124
149
  private readonly inFlight = new Map<string, AbortController>();
125
150
  private readonly mintedAfterRemoval = new Map<string, string>();
126
151
  private readonly removing = new Map<string, string>();
152
+ private readonly removedListeners = new Set<(contentId: string) => void>();
153
+ private loaded = false;
127
154
 
128
155
  constructor(private readonly deps: ComposeAttachmentControllerDeps) {}
129
156
 
@@ -134,6 +161,47 @@ export class ComposeAttachmentController {
134
161
 
135
162
  getSnapshot = (): ComposeAttachmentItem[] => this.snapshot;
136
163
 
164
+ owns = (contentId: string): boolean =>
165
+ this.entries.some((entry) => entry.contentId === contentId);
166
+
167
+ storedContent = (contentId: string): StoredContent | null => {
168
+ const entry = this.entries.find(
169
+ (candidate) =>
170
+ candidate.contentId === contentId && candidate.contentUrl !== null,
171
+ );
172
+ if (!entry?.contentUrl) return null;
173
+ return {
174
+ filename: entry.filename,
175
+ contentType: entry.contentType,
176
+ contentUrl: entry.contentUrl,
177
+ };
178
+ };
179
+
180
+ isLoaded = (): boolean => this.loaded;
181
+
182
+ refreshLinks = (
183
+ attachments: readonly RemitImapOutboxAttachmentResponse[],
184
+ ): void => {
185
+ const links = new Map(
186
+ attachments.map((attachment) => [
187
+ attachment.outboxAttachmentId,
188
+ attachment.contentUrl,
189
+ ]),
190
+ );
191
+ this.set(
192
+ this.entries.map((entry) => {
193
+ const contentUrl =
194
+ entry.serverId === null ? undefined : links.get(entry.serverId);
195
+ return contentUrl === undefined ? entry : { ...entry, contentUrl };
196
+ }),
197
+ );
198
+ };
199
+
200
+ onRemoved = (listener: (contentId: string) => void): (() => void) => {
201
+ this.removedListeners.add(listener);
202
+ return () => this.removedListeners.delete(listener);
203
+ };
204
+
137
205
  blockingReason = (): string | undefined => {
138
206
  const [removing] = this.removing.values();
139
207
  if (removing !== undefined) return `Removing "${removing}"…`;
@@ -210,6 +278,8 @@ export class ComposeAttachmentController {
210
278
  this.patch(generation, key, {
211
279
  serverId: minted.outboxAttachmentId,
212
280
  filename: minted.filename,
281
+ contentId: minted.contentId,
282
+ contentType: minted.contentType,
213
283
  });
214
284
  }
215
285
  settle();
@@ -232,19 +302,29 @@ export class ComposeAttachmentController {
232
302
 
233
303
  const completed = await transport
234
304
  .complete(outboxMessageId, minted.outboxAttachmentId)
235
- .then(
236
- () => true,
237
- (error: unknown) => {
238
- this.patch(generation, key, {
239
- state: refusalOf(minted.filename, error),
240
- });
241
- return false;
242
- },
243
- );
244
- if (!completed || signal.aborted) return;
245
- this.patch(generation, key, { state: ATTACHED });
305
+ .catch((error: unknown) => {
306
+ this.patch(generation, key, {
307
+ state: refusalOf(minted.filename, error),
308
+ });
309
+ return null;
310
+ });
311
+ if (completed === null || signal.aborted) return;
312
+ this.patch(generation, key, {
313
+ state: ATTACHED,
314
+ contentUrl: completed.contentUrl,
315
+ });
316
+ if (generation !== this.generation) return;
317
+ this.entries
318
+ .find((entry) => entry.key === key)
319
+ ?.placement?.stored(file, minted.contentId);
246
320
  };
247
321
 
322
+ private settleFailure(generation: number, key: string, file: File): void {
323
+ if (generation !== this.generation) return;
324
+ const entry = this.entries.find((candidate) => candidate.key === key);
325
+ if (entry?.state.status === "failed") entry.placement?.failed(file);
326
+ }
327
+
248
328
  private run = async (
249
329
  generation: number,
250
330
  key: string,
@@ -254,6 +334,7 @@ export class ComposeAttachmentController {
254
334
  ): Promise<void> => {
255
335
  await this.upload(generation, key, file, outboxMessageId, signal);
256
336
  if (this.inFlight.get(key)?.signal === signal) this.inFlight.delete(key);
337
+ this.settleFailure(generation, key, file);
257
338
  };
258
339
 
259
340
  /**
@@ -282,7 +363,10 @@ export class ComposeAttachmentController {
282
363
  );
283
364
  };
284
365
 
285
- attach = async (files: File[]): Promise<void> => {
366
+ attach = async (
367
+ files: File[],
368
+ placement?: AttachmentPlacement,
369
+ ): Promise<void> => {
286
370
  const generation = this.generation;
287
371
  const added = files.map((file) => {
288
372
  this.nextKey += 1;
@@ -290,6 +374,10 @@ export class ComposeAttachmentController {
290
374
  key: `local-${this.nextKey}`,
291
375
  serverId: null,
292
376
  file,
377
+ placement: placement ?? null,
378
+ contentId: null,
379
+ contentType: file.type || "application/octet-stream",
380
+ contentUrl: null,
293
381
  filename: file.name,
294
382
  sizeBytes: file.size,
295
383
  state: UPLOADING,
@@ -302,11 +390,12 @@ export class ComposeAttachmentController {
302
390
 
303
391
  const draft = await this.deps.ensureDraft();
304
392
  if (draft.outcome === "refused") {
305
- for (const { entry } of added) {
393
+ for (const { entry, file } of added) {
306
394
  this.inFlight.delete(entry.key);
307
395
  this.patch(generation, entry.key, {
308
396
  state: failed(draft.reason, true),
309
397
  });
398
+ this.settleFailure(generation, entry.key, file);
310
399
  }
311
400
  return;
312
401
  }
@@ -379,7 +468,12 @@ export class ComposeAttachmentController {
379
468
  this.set(this.entries);
380
469
  const serverId = entry.serverId ?? this.mintedAfterRemoval.get(key) ?? null;
381
470
  this.mintedAfterRemoval.delete(key);
382
- if (failure === null || generation !== this.generation) return;
471
+ if (generation !== this.generation) return;
472
+ if (failure === null) {
473
+ if (entry.contentId === null) return;
474
+ for (const listener of this.removedListeners) listener(entry.contentId);
475
+ return;
476
+ }
383
477
  if (serverId === null) return;
384
478
  this.set([
385
479
  ...this.entries,
@@ -400,7 +494,16 @@ export class ComposeAttachmentController {
400
494
  attachments: readonly RemitImapOutboxAttachmentResponse[],
401
495
  ): void => {
402
496
  this.draftId = outboxMessageId;
403
- this.set(attachments.map(fromServer));
497
+ this.loaded = true;
498
+ const listed = new Set(
499
+ attachments.map((attachment) => attachment.outboxAttachmentId),
500
+ );
501
+ const attachedHere = this.entries.filter(
502
+ (entry) =>
503
+ entry.file !== null &&
504
+ (entry.serverId === null || !listed.has(entry.serverId)),
505
+ );
506
+ this.set([...attachments.map(fromServer), ...attachedHere]);
404
507
  };
405
508
 
406
509
  reset = (outboxMessageId: string | undefined): void => {
@@ -410,6 +513,7 @@ export class ComposeAttachmentController {
410
513
  this.mintedAfterRemoval.clear();
411
514
  this.removing.clear();
412
515
  this.draftId = outboxMessageId;
516
+ this.loaded = false;
413
517
  this.set([]);
414
518
  };
415
519
  }
@@ -50,3 +50,20 @@ export const mailListSearchSchemas = {
50
50
  "/mail/outbox": outboxSearchSchema,
51
51
  "/mail/$mailboxId": mailboxSearchSchema,
52
52
  } as const;
53
+
54
+ export const composeSeedSearch = z.object({ body: z.string().optional() });
55
+
56
+ export type ComposeSeed = Required<z.infer<typeof composeSeedSearch>>;
57
+
58
+ export const withoutComposeSeed = (
59
+ prev: Record<string, unknown>,
60
+ ): Record<string, unknown> => {
61
+ const { body: _seed, ...rest } = prev;
62
+ return rest;
63
+ };
64
+
65
+ export const replySearchSchemas = {
66
+ brief: briefSearchSchema.extend(composeSeedSearch.shape),
67
+ flagged: flaggedSearchSchema.extend(composeSeedSearch.shape),
68
+ mailbox: mailboxSearchSchema.extend(composeSeedSearch.shape),
69
+ } as const;
@@ -8,7 +8,8 @@
8
8
  * The conversation reads it off the address and opens it at its head.
9
9
  */
10
10
  import { createFileRoute } from "@tanstack/react-router";
11
+ import { replySearchSchemas } from "@/lib/mail-search";
11
12
 
12
13
  export const Route = createFileRoute(
13
14
  "/mail/$mailboxId/$threadId/$messageId/$mode/{-$outboxMessageId}",
14
- )({});
15
+ )({ validateSearch: replySearchSchemas.mailbox });
@@ -20,7 +20,8 @@
20
20
  * the pane reads the reply off the address, the way the shell reads compose.
21
21
  */
22
22
  import { createFileRoute } from "@tanstack/react-router";
23
+ import { replySearchSchemas } from "@/lib/mail-search";
23
24
 
24
25
  export const Route = createFileRoute(
25
26
  "/mail/brief/$threadId/$messageId/$mode/{-$outboxMessageId}",
26
- )({});
27
+ )({ validateSearch: replySearchSchemas.brief });
@@ -8,7 +8,8 @@
8
8
  * The conversation reads it off the address and opens it at its head.
9
9
  */
10
10
  import { createFileRoute } from "@tanstack/react-router";
11
+ import { replySearchSchemas } from "@/lib/mail-search";
11
12
 
12
13
  export const Route = createFileRoute(
13
14
  "/mail/flagged/$threadId/$messageId/$mode/{-$outboxMessageId}",
14
- )({});
15
+ )({ validateSearch: replySearchSchemas.flagged });
@@ -1,5 +1,6 @@
1
1
  import { useNavigate, useParams } from "@tanstack/react-router";
2
2
  import { useCallback } from "react";
3
+ import { withoutComposeSeed } from "@/lib/mail-search";
3
4
  import { useBrowsedList, useNavigateToBrowsedList } from "./browsed-list";
4
5
  import { useRetainOpenPanels } from "./fragment";
5
6
 
@@ -108,7 +109,7 @@ function useComposeNavigate(): (
108
109
  ) => {
109
110
  navigate({
110
111
  ...composeTarget(list, mailboxId, outboxMessageId),
111
- search: (prev: Record<string, unknown>) => prev,
112
+ search: withoutComposeSeed,
112
113
  // Opening or closing the surface is going somewhere, so the panes the
113
114
  // reader keeps up travel and the overlays they were reading over do
114
115
  // not. Recording the draft is not going anywhere — the address is
@@ -1,5 +1,6 @@
1
1
  import { useNavigate, useParams } from "@tanstack/react-router";
2
2
  import { useCallback } from "react";
3
+ import { withoutComposeSeed } from "@/lib/mail-search";
3
4
  import { useNavigateToBrowsedList } from "./browsed-list";
4
5
  import { useRetainOpenPanels } from "./fragment";
5
6
 
@@ -29,7 +30,7 @@ export function useOpenOutboxDraft(): (outboxMessageId: string) => void {
29
30
  navigate({
30
31
  to: "/mail/outbox/draft/$outboxMessageId",
31
32
  params: { outboxMessageId },
32
- search: (prev: Record<string, unknown>) => prev,
33
+ search: withoutComposeSeed,
33
34
  hash: retainPanels,
34
35
  });
35
36
  },