@remit/web-client 0.0.185 → 0.0.187

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.
@@ -6,13 +6,22 @@
6
6
  * their headers, so what the consumer passed and what is rendered are different
7
7
  * lists. These cases mount rows directly and change them, which is the same
8
8
  * thing from the provider's point of view.
9
+ *
10
+ * The confirmation is worded from the folder the row is actually filed in
11
+ * (#855): this list spans mailboxes and accounts, and deleting mail that is
12
+ * already in Trash expunges it on the server rather than moving it.
9
13
  */
10
14
  import assert from "node:assert/strict";
11
15
  import { after, afterEach, before, beforeEach, describe, it } from "node:test";
12
- import type { Verb } from "@remit/ui";
16
+ import { configOperationsGetConfigQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
17
+ import type { ThreadRowData, Verb } from "@remit/ui";
18
+ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
13
19
  import type { JSDOM } from "jsdom";
14
20
  import { act, createElement, createRef, useState } from "react";
15
21
  import { createRoot, type Root } from "react-dom/client";
22
+ import { AuthProviderProvider, noneAuthProvider } from "@/auth/provider";
23
+ import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
24
+ import { makeAccount, makeConfig } from "@/test-support/fixtures";
16
25
  import type { MessageListCommands } from "./MessageList";
17
26
  import {
18
27
  ThreadListInteraction,
@@ -57,6 +66,39 @@ afterEach(() => {
57
66
  act(() => root.unmount());
58
67
  });
59
68
 
69
+ const TRASH_MAILBOX_ID = "mbx-trash";
70
+
71
+ const row = (id: string, mailboxId = "mbx-inbox"): ThreadRowData => ({
72
+ id,
73
+ accountId: "acc-1",
74
+ mailboxId,
75
+ threadId: `t-${id}`,
76
+ fromName: "Alice",
77
+ fromEmail: "alice@example.com",
78
+ subject: "Quarterly report",
79
+ snippet: "",
80
+ timeLabel: "9:42",
81
+ });
82
+
83
+ /**
84
+ * A client that already holds the account's folder appointments, so the
85
+ * confirmation's outcome is settled on the first render rather than arriving a
86
+ * frame later.
87
+ */
88
+ const seededClient = (trashMailboxId: string): QueryClient => {
89
+ const client = new QueryClient();
90
+ client.setQueryData(
91
+ configOperationsGetConfigQueryKey(),
92
+ makeConfig([
93
+ makeAccount({
94
+ accountId: "acc-1",
95
+ folderAppointments: [{ role: "Trash", mailboxId: trashMailboxId }],
96
+ }),
97
+ ]),
98
+ );
99
+ return client;
100
+ };
101
+
60
102
  const rowElements = (ids: string[]) =>
61
103
  ids.map((id) =>
62
104
  createElement("button", { key: id, type: "button", "data-message-id": id }),
@@ -68,26 +110,44 @@ const rowElements = (ids: string[]) =>
68
110
  */
69
111
  function mountList(options: {
70
112
  initialIds: string[];
113
+ rows?: readonly ThreadRowData[];
114
+ client?: QueryClient;
71
115
  onDeleteMessages?: (ids: string[]) => void;
72
116
  onSelectionVerb?: (verb: Verb) => void;
73
117
  }) {
74
118
  const onDeleteMessages = options.onDeleteMessages ?? (() => undefined);
75
119
  const onSelectionVerb = options.onSelectionVerb ?? (() => undefined);
120
+ const rows = options.rows ?? options.initialIds.map((id) => row(id));
121
+ const client = options.client ?? seededClient(TRASH_MAILBOX_ID);
122
+ const authProvider = noneAuthProvider;
76
123
  const commandsRef = createRef<MessageListCommands | null>();
77
124
  let setIds: ((ids: string[]) => void) | undefined;
78
125
  const Harness = () => {
79
126
  const [ids, set] = useState(options.initialIds);
80
127
  setIds = set;
81
128
  return createElement(
82
- ThreadListInteraction,
83
- {
84
- selectedMessageId: undefined,
85
- onOpen: () => undefined,
86
- onDeleteMessages,
87
- onSelectionVerb,
88
- commandsRef,
89
- },
90
- ...rowElements(ids),
129
+ AuthProviderProvider,
130
+ { value: authProvider },
131
+ createElement(
132
+ QueryClientProvider,
133
+ { client },
134
+ createElement(
135
+ ErrorBannerProvider,
136
+ null,
137
+ createElement(
138
+ ThreadListInteraction,
139
+ {
140
+ selectedMessageId: undefined,
141
+ rows,
142
+ onOpen: () => undefined,
143
+ onDeleteMessages,
144
+ onSelectionVerb,
145
+ commandsRef,
146
+ },
147
+ ...rowElements(ids),
148
+ ),
149
+ ),
150
+ ),
91
151
  );
92
152
  };
93
153
  act(() => root.render(createElement(Harness)));
@@ -271,6 +331,111 @@ describe("ThreadListInteraction — delete confirms first", () => {
271
331
  });
272
332
  });
273
333
 
334
+ /**
335
+ * Issue #855. Flagged and the brief span mailboxes, so a row here can already
336
+ * be in its account's Trash — where the same keypress is an IMAP expunge, not a
337
+ * move. The confirmation used to promise "Move to Trash" over every one of
338
+ * them, collecting an answer to a question the user was never asked.
339
+ */
340
+ describe("ThreadListInteraction — the confirmation states the outcome it will produce", () => {
341
+ const dialogText = (): string => dom.window.document.body.textContent ?? "";
342
+
343
+ const askDelete = (list: ReturnType<typeof mountList>) => {
344
+ act(() => list.commands().focusFirst());
345
+ act(() => {
346
+ list.commands().requestVerb("delete");
347
+ });
348
+ };
349
+
350
+ it("asks a row already in Trash as a permanent delete", () => {
351
+ const list = mountList({
352
+ initialIds: ["m1", "m2"],
353
+ rows: [row("m1", TRASH_MAILBOX_ID), row("m2")],
354
+ });
355
+
356
+ askDelete(list);
357
+
358
+ const text = dialogText();
359
+ assert.match(text, /Permanently delete 1 message\?/);
360
+ assert.match(text, /cannot be restored/);
361
+ assert.doesNotMatch(
362
+ text,
363
+ /Move 1 message to Trash\?/,
364
+ "a move is not what this delete does",
365
+ );
366
+ });
367
+
368
+ it("still offers the reversible move for a row filed outside Trash", () => {
369
+ const list = mountList({
370
+ initialIds: ["m1"],
371
+ rows: [row("m1")],
372
+ });
373
+
374
+ askDelete(list);
375
+
376
+ assert.match(dialogText(), /Move 1 message to Trash\?/);
377
+ assert.match(dialogText(), /restore them from Trash later/);
378
+ });
379
+
380
+ it("refuses a row with no account the same way", () => {
381
+ const deleted: string[][] = [];
382
+ const list = mountList({
383
+ initialIds: ["m1"],
384
+ rows: [{ ...row("m1"), accountId: undefined }],
385
+ onDeleteMessages: (ids) => deleted.push(ids),
386
+ });
387
+
388
+ act(() => list.commands().focusFirst());
389
+ act(() => {
390
+ assert.equal(list.commands().requestVerb("delete"), true);
391
+ });
392
+
393
+ assert.match(dialogText(), /Couldn.t delete this message/);
394
+ assert.deepEqual(deleted, []);
395
+ });
396
+
397
+ it("refuses a row it cannot place instead of opening a dialog nobody can answer", () => {
398
+ const deleted: string[][] = [];
399
+ const list = mountList({
400
+ initialIds: ["m1"],
401
+ rows: [],
402
+ onDeleteMessages: (ids) => deleted.push(ids),
403
+ });
404
+
405
+ act(() => list.commands().focusFirst());
406
+ act(() => {
407
+ assert.equal(
408
+ list.commands().requestVerb("delete"),
409
+ true,
410
+ "the press is still claimed — handing it back runs the pane’s own unconfirmed delete",
411
+ );
412
+ });
413
+
414
+ const text = dialogText();
415
+ assert.match(text, /Couldn.t delete this message/);
416
+ assert.match(text, /Nothing was deleted/);
417
+ assert.ok(
418
+ Array.from(
419
+ dom.window.document.querySelectorAll<HTMLAnchorElement>("a"),
420
+ ).some((link) => link.textContent === "Reload the list"),
421
+ "the refusal offers the control its own sentence names",
422
+ );
423
+ assert.doesNotMatch(
424
+ text,
425
+ /Checking where this account files deleted mail/,
426
+ "no load is happening, so nothing may claim one is",
427
+ );
428
+ assert.equal(
429
+ Array.from(
430
+ dom.window.document.querySelectorAll<HTMLButtonElement>("button"),
431
+ ).some((button) => button.textContent === "Move to Trash"),
432
+ false,
433
+ "and no confirmation stands behind the refusal",
434
+ );
435
+ assert.deepEqual(deleted, [], "nothing is deleted by a refusal");
436
+ });
437
+ });
438
+
274
439
  /**
275
440
  * A background refresh drops the ids that left and keeps every survivor — the
276
441
  * same intersect-on-refresh guarantee `useSelection.test.ts` locks for the pure
@@ -280,6 +445,8 @@ describe("ThreadListInteraction — delete confirms first", () => {
280
445
  */
281
446
  function mountSelectableList(initialIds: string[]) {
282
447
  const commandsRef = createRef<MessageListCommands | null>();
448
+ const client = seededClient(TRASH_MAILBOX_ID);
449
+ const authProvider = noneAuthProvider;
283
450
  let setIds: ((ids: string[]) => void) | undefined;
284
451
  let selected: string[] = [];
285
452
  const Probe = () => {
@@ -291,16 +458,29 @@ function mountSelectableList(initialIds: string[]) {
291
458
  const [ids, set] = useState(initialIds);
292
459
  setIds = set;
293
460
  return createElement(
294
- ThreadListInteraction,
295
- {
296
- selectedMessageId: undefined,
297
- onOpen: () => undefined,
298
- onDeleteMessages: () => undefined,
299
- onSelectionVerb: () => undefined,
300
- commandsRef,
301
- },
302
- ...rowElements(ids),
303
- createElement(Probe, { key: "probe" }),
461
+ AuthProviderProvider,
462
+ { value: authProvider },
463
+ createElement(
464
+ QueryClientProvider,
465
+ { client },
466
+ createElement(
467
+ ErrorBannerProvider,
468
+ null,
469
+ createElement(
470
+ ThreadListInteraction,
471
+ {
472
+ selectedMessageId: undefined,
473
+ rows: initialIds.map((id) => row(id)),
474
+ onOpen: () => undefined,
475
+ onDeleteMessages: () => undefined,
476
+ onSelectionVerb: () => undefined,
477
+ commandsRef,
478
+ },
479
+ ...rowElements(ids),
480
+ createElement(Probe, { key: "probe" }),
481
+ ),
482
+ ),
483
+ ),
304
484
  );
305
485
  };
306
486
  act(() => root.render(createElement(Harness)));
@@ -17,8 +17,8 @@
17
17
  * verb acts on a message the user cannot see.
18
18
  */
19
19
  import {
20
- ConfirmDialog,
21
20
  SelectionTopBar,
21
+ type ThreadRowData,
22
22
  useListCursor,
23
23
  type Verb,
24
24
  } from "@remit/ui";
@@ -33,15 +33,27 @@ import {
33
33
  useRef,
34
34
  useState,
35
35
  } from "react";
36
+ import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
37
+ import { useDeleteOutcome } from "@/hooks/useDeleteOutcome";
36
38
  import { useFollowFocusOpen } from "@/hooks/useFollowFocusOpen";
37
39
  import { useIsDesktop } from "@/hooks/useMediaQuery";
38
40
  import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
39
- import { formatDeleteToTrashTitle } from "@/lib/format";
41
+ import type { DeleteTarget } from "@/lib/format";
40
42
  import { tabStopId } from "@/lib/list-focus";
41
43
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
44
+ import { DeleteConfirmDialog } from "./DeleteConfirmDialog";
42
45
  import type { MessageListCommands } from "./MessageList";
43
46
  import type { MessageRowSelection } from "./MessageRow";
44
47
 
48
+ /** Nothing pending, as a stable identity so the outcome memo does not churn. */
49
+ const NO_TARGETS: readonly DeleteTarget[] = [];
50
+
51
+ interface PendingDelete {
52
+ ids: string[];
53
+ /** Where those rows were filed when the delete was asked for. */
54
+ targets: DeleteTarget[];
55
+ }
56
+
45
57
  interface ThreadRowInteraction {
46
58
  focused: boolean;
47
59
  isTabStop: boolean;
@@ -147,6 +159,13 @@ export interface OpenMessageOptions {
147
159
 
148
160
  interface ThreadListInteractionProps {
149
161
  selectedMessageId: string | undefined;
162
+ /**
163
+ * The rows this list is showing. Read only to name the folder a row is
164
+ * filed in, which is what decides whether deleting it moves it to Trash or
165
+ * expunges it — this list spans mailboxes and accounts, so the answer is per
166
+ * row and there is no route mailbox to take it from (#855).
167
+ */
168
+ rows: readonly ThreadRowData[];
150
169
  /** Opens a row — the same navigation a click performs. */
151
170
  onOpen: (messageId: string, options?: OpenMessageOptions) => void;
152
171
  /** Deletes a set of messages. Absent disables the delete key for this list. */
@@ -168,6 +187,7 @@ interface ThreadListInteractionProps {
168
187
 
169
188
  export function ThreadListInteraction({
170
189
  selectedMessageId,
190
+ rows,
171
191
  onOpen,
172
192
  onDeleteMessages,
173
193
  onSelectionVerb,
@@ -178,6 +198,7 @@ export function ThreadListInteraction({
178
198
  children,
179
199
  }: ThreadListInteractionProps) {
180
200
  const isDesktop = useIsDesktop();
201
+ const { pushError } = useErrorBanners();
181
202
  const containerRef = useRef<HTMLDivElement>(null);
182
203
  const orderedIds = useRenderedRowIds(containerRef);
183
204
  const cursor = useListCursor({
@@ -255,11 +276,16 @@ export function ThreadListInteraction({
255
276
  open: followOpen,
256
277
  });
257
278
 
258
- // Pending move-to-Trash for the row under the cursor, awaiting confirmation.
259
- // The id is snapshotted at request time so a cursor move behind the dialog
260
- // cannot retarget it. A delete over a selection is a bulk action and walks the
261
- // wizard instead the same contract the mailbox list's delete has.
262
- const [pendingDelete, setPendingDelete] = useState<string[] | null>(null);
279
+ // Pending delete for the row under the cursor, awaiting confirmation. Both
280
+ // the id and the folder it is filed in are snapshotted at request time, so
281
+ // neither a cursor move nor a background refresh behind the dialog can
282
+ // retarget it or change the question it is asking. A delete over a selection
283
+ // is a bulk action and walks the wizard instead — the same contract the
284
+ // mailbox list's delete has.
285
+ const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(
286
+ null,
287
+ );
288
+ const deleteOutcome = useDeleteOutcome(pendingDelete?.targets ?? NO_TARGETS);
263
289
 
264
290
  // A verb, routed the same way the bar routes its own (#477 1.4, #508). Over a
265
291
  // selection every verb opens the wizard, so the keyboard cannot reach a bulk
@@ -276,15 +302,42 @@ export function ThreadListInteraction({
276
302
  return true;
277
303
  }
278
304
  if (verb !== "delete" || !focusedMessageId) return false;
279
- setPendingDelete([focusedMessageId]);
305
+ // A row this list cannot place is a row whose delete cannot be worded,
306
+ // and a confirmation nobody can answer is worse than no confirmation.
307
+ // The press is still claimed — handing it back runs the pane's own
308
+ // unconfirmed delete — and the refusal is said out loud, with the one
309
+ // control that can actually change the answer.
310
+ const pending = rows.find((row) => row.id === focusedMessageId);
311
+ if (!pending?.mailboxId || !pending.accountId) {
312
+ pushError({
313
+ title: "Couldn't delete this message",
314
+ detail:
315
+ "This list has lost track of which account and folder it is in, so reader can't tell whether deleting it would move it to Trash or erase it. Nothing was deleted.",
316
+ action: { label: "Reload the list", href: window.location.href },
317
+ });
318
+ return true;
319
+ }
320
+ setPendingDelete({
321
+ ids: [focusedMessageId],
322
+ targets: [
323
+ { accountId: pending.accountId, mailboxId: pending.mailboxId },
324
+ ],
325
+ });
280
326
  return true;
281
327
  },
282
- [pendingDelete, selectedCount, onSelectionVerb, focusedMessageId],
328
+ [
329
+ pendingDelete,
330
+ selectedCount,
331
+ onSelectionVerb,
332
+ focusedMessageId,
333
+ rows,
334
+ pushError,
335
+ ],
283
336
  );
284
337
 
285
338
  const confirmDelete = useCallback(() => {
286
339
  if (pendingDelete === null) return;
287
- onDeleteMessages(pendingDelete);
340
+ onDeleteMessages(pendingDelete.ids);
288
341
  setPendingDelete(null);
289
342
  exitSelection();
290
343
  }, [pendingDelete, onDeleteMessages, exitSelection]);
@@ -416,13 +469,11 @@ export function ThreadListInteraction({
416
469
  <div ref={containerRef} className="contents">
417
470
  {children}
418
471
  </div>
419
- <ConfirmDialog
472
+ <DeleteConfirmDialog
420
473
  isOpen={confirmOpen}
421
- title={formatDeleteToTrashTitle(pendingDelete?.length ?? 0)}
422
- description="You can restore them from Trash later."
423
- confirmLabel="Move to Trash"
424
- destructive
425
- isBusy={isDeleting}
474
+ count={pendingDelete?.ids.length ?? 0}
475
+ outcome={deleteOutcome}
476
+ isDeleting={isDeleting}
426
477
  onConfirm={confirmDelete}
427
478
  onCancel={cancelDelete}
428
479
  />
@@ -81,15 +81,45 @@ export const useJunkMailbox = (
81
81
  };
82
82
 
83
83
  /**
84
- * Returns the account's appointed Trash mailbox id. The delete confirmation
85
- * needs it to tell a move-to-Trash apart from a delete inside Trash, which is
86
- * an unrecoverable expunge and has to be asked as one (#845).
84
+ * Each account's appointed Trash mailbox, keyed by account. The delete
85
+ * confirmation needs it to tell a move-to-Trash apart from a delete inside
86
+ * Trash, which is an unrecoverable expunge and has to be asked as one (#845),
87
+ * and apart from an account that appoints no Trash at all, where the server
88
+ * refuses the delete outright (#846).
89
+ *
90
+ * A map rather than a set of ids, because the brief and Flagged answer for
91
+ * selections that span accounts (#855): "is this row in Trash" is a question
92
+ * about the row's own account, and "does any Trash exist" has to be answerable
93
+ * as "no" rather than as silence.
94
+ *
95
+ * `hasAppointments` is data presence, never `!isLoading`. React Query v5
96
+ * computes `isLoading` as `isPending && isFetching`, so an offline query is
97
+ * paused and reports neither loading nor error while `data` is still
98
+ * undefined — which read as "no Trash anywhere", and promised a reversible
99
+ * move over an expunge that would replay on reconnect.
87
100
  */
88
- export const useTrashMailbox = (
89
- accountId: string | undefined,
90
- ): { trashMailboxId: string | undefined; isLoading: boolean } => {
91
- const { mailboxId, isLoading } = useFolderRoleMailbox(accountId, "Trash");
92
- return { trashMailboxId: mailboxId, isLoading };
101
+ export const useTrashByAccount = (): {
102
+ trashByAccount: ReadonlyMap<string, string | undefined>;
103
+ hasAppointments: boolean;
104
+ isError: boolean;
105
+ } => {
106
+ const { data: config, isError } = useQuery({
107
+ ...configOperationsGetConfigOptions(),
108
+ staleTime: Infinity,
109
+ });
110
+
111
+ const trashByAccount = useMemo(() => {
112
+ const byAccount = new Map<string, string | undefined>();
113
+ for (const account of config?.accounts ?? []) {
114
+ byAccount.set(
115
+ account.accountId,
116
+ account.folderAppointments.find((fa) => fa.role === "Trash")?.mailboxId,
117
+ );
118
+ }
119
+ return byAccount;
120
+ }, [config]);
121
+
122
+ return { trashByAccount, hasAppointments: config !== undefined, isError };
93
123
  };
94
124
 
95
125
  /**
@@ -16,7 +16,8 @@ import {
16
16
  patchThreadListQueries,
17
17
  restoreThreadListQueries,
18
18
  snapshotThreadListQueries,
19
- type ThreadListSnapshotEntry,
19
+ type ThreadMessagesData,
20
+ type ThreadMutationContext,
20
21
  threadListCacheKeys,
21
22
  } from "@/lib/thread-list-cache";
22
23
 
@@ -39,23 +40,6 @@ interface UseDeleteMessagesOptions {
39
40
  onAfterOptimisticRemove?: (messageIds: string[]) => void;
40
41
  }
41
42
 
42
- interface ThreadMessagesData {
43
- items: RemitImapThreadMessageResponse[];
44
- [key: string]: unknown;
45
- }
46
-
47
- interface SnapshotEntry<T> {
48
- queryKey: readonly unknown[];
49
- data: T;
50
- }
51
-
52
- interface DeleteContext {
53
- threadMessagesPrefix: readonly unknown[];
54
- listPrefixes: ReadonlyArray<readonly unknown[]>;
55
- previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
56
- previousThreadsList: ThreadListSnapshotEntry[];
57
- }
58
-
59
43
  /**
60
44
  * Pure helper: drop the messages in `messageIds` from a single page's items.
61
45
  *
@@ -94,7 +78,7 @@ export const useDeleteMessages = ({
94
78
 
95
79
  const { mutateAsync, isPending } = useMutation({
96
80
  ...messageBulkOperationsDeleteMessagesMutation(),
97
- onMutate: async (variables): Promise<DeleteContext> => {
81
+ onMutate: async (variables): Promise<ThreadMutationContext> => {
98
82
  const messageIds = new Set(variables.body.messageIds ?? []);
99
83
 
100
84
  const threadMessagesPrefix = threadId
@@ -0,0 +1,35 @@
1
+ /**
2
+ * What a delete is about to do to a specific set of rows — the one derivation
3
+ * every delete confirmation words itself from (#845, #855).
4
+ *
5
+ * `MessageMoveService.deleteMessages` moves a message to its account's Trash
6
+ * unless it is already there, in which case the same keypress is an IMAP
7
+ * expunge and nothing survives it; and it refuses the call outright when the
8
+ * account appoints no Trash. That branch is per message and per account, not
9
+ * per view, so the mailbox list, the brief and Flagged all have to ask it of
10
+ * the rows they are actually about to delete — the brief and Flagged hold rows
11
+ * from several mailboxes and several accounts at once.
12
+ *
13
+ * The decision itself is `deleteOutcomeFor`, kept pure in `lib/format`; this is
14
+ * only the read that feeds it.
15
+ */
16
+ import { useMemo } from "react";
17
+ import {
18
+ type DeleteOutcome,
19
+ type DeleteTarget,
20
+ deleteOutcomeFor,
21
+ } from "@/lib/format";
22
+ import { useTrashByAccount } from "./useArchiveMailbox";
23
+
24
+ /** The outcome of deleting `targets`. */
25
+ export const useDeleteOutcome = (
26
+ targets: readonly DeleteTarget[],
27
+ ): DeleteOutcome => {
28
+ const { trashByAccount, hasAppointments, isError } = useTrashByAccount();
29
+
30
+ return useMemo(
31
+ () =>
32
+ deleteOutcomeFor({ targets, trashByAccount, hasAppointments, isError }),
33
+ [targets, trashByAccount, hasAppointments, isError],
34
+ );
35
+ };
@@ -16,7 +16,8 @@ import {
16
16
  patchThreadListQueries,
17
17
  restoreThreadListQueries,
18
18
  snapshotThreadListQueries,
19
- type ThreadListSnapshotEntry,
19
+ type ThreadMessagesData,
20
+ type ThreadMutationContext,
20
21
  threadListCacheKeys,
21
22
  } from "@/lib/thread-list-cache";
22
23
 
@@ -28,23 +29,6 @@ interface UseMarkAsReadOptions {
28
29
  accountId?: string;
29
30
  }
30
31
 
31
- interface ThreadMessagesData {
32
- items: RemitImapThreadMessageResponse[];
33
- [key: string]: unknown;
34
- }
35
-
36
- interface SnapshotEntry<T> {
37
- queryKey: readonly unknown[];
38
- data: T;
39
- }
40
-
41
- interface MarkAsReadContext {
42
- threadMessagesPrefix: readonly unknown[];
43
- listPrefixes: ReadonlyArray<readonly unknown[]>;
44
- previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
45
- previousThreadsList: ThreadListSnapshotEntry[];
46
- }
47
-
48
32
  /**
49
33
  * Dwell before a message the user is viewing is marked read. A glance closed
50
34
  * within this window leaves it unread (#140). Applied by the single shared
@@ -145,7 +129,7 @@ export const useMarkAsRead = ({
145
129
  const { mutate: markAsRead } = useMutation({
146
130
  ...messageBulkOperationsUpdateFlagsMutation(),
147
131
  meta: softErrorMeta,
148
- onMutate: async (variables): Promise<MarkAsReadContext> => {
132
+ onMutate: async (variables): Promise<ThreadMutationContext> => {
149
133
  const messageIds = new Set(variables.body.messageIds ?? []);
150
134
  const isRead = variables.body.isRead ?? true;
151
135
 
@@ -15,7 +15,8 @@ import {
15
15
  patchThreadListQueries,
16
16
  restoreThreadListQueries,
17
17
  snapshotThreadListQueries,
18
- type ThreadListSnapshotEntry,
18
+ type ThreadMessagesData,
19
+ type ThreadMutationContext,
19
20
  threadListCacheKeys,
20
21
  } from "@/lib/thread-list-cache";
21
22
 
@@ -31,23 +32,6 @@ interface UseMoveMessagesOptions {
31
32
  onAfterOptimisticRemove?: (messageIds: string[]) => void;
32
33
  }
33
34
 
34
- interface ThreadMessagesData {
35
- items: RemitImapThreadMessageResponse[];
36
- [key: string]: unknown;
37
- }
38
-
39
- interface SnapshotEntry<T> {
40
- queryKey: readonly unknown[];
41
- data: T;
42
- }
43
-
44
- interface MoveContext {
45
- threadMessagesPrefix: readonly unknown[];
46
- listPrefixes: ReadonlyArray<readonly unknown[]>;
47
- previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
48
- previousThreadsList: ThreadListSnapshotEntry[];
49
- }
50
-
51
35
  /**
52
36
  * Pure helper: drop the messages in `messageIds` from a single page's items.
53
37
  *
@@ -73,7 +57,7 @@ export const useMoveMessages = ({
73
57
 
74
58
  const { mutateAsync, isPending, isError } = useMutation({
75
59
  ...messageBulkOperationsMoveMessagesMutation(),
76
- onMutate: async (variables): Promise<MoveContext> => {
60
+ onMutate: async (variables): Promise<ThreadMutationContext> => {
77
61
  const messageIds = new Set(variables.body.messageIds ?? []);
78
62
 
79
63
  const threadMessagesPrefix = threadId
@@ -13,7 +13,8 @@ import {
13
13
  patchThreadListQueries,
14
14
  restoreThreadListQueries,
15
15
  snapshotThreadListQueries,
16
- type ThreadListSnapshotEntry,
16
+ type ThreadMessagesData,
17
+ type ThreadMutationContext,
17
18
  threadListCacheKeys,
18
19
  } from "@/lib/thread-list-cache";
19
20
 
@@ -48,23 +49,6 @@ export const resolveMailboxForMessage = (
48
49
  messages?.find((message) => message.messageId === messageId)?.mailboxId ??
49
50
  fallbackMailboxId;
50
51
 
51
- interface ThreadMessagesData {
52
- items: RemitImapThreadMessageResponse[];
53
- [key: string]: unknown;
54
- }
55
-
56
- interface SnapshotEntry<T> {
57
- queryKey: readonly unknown[];
58
- data: T;
59
- }
60
-
61
- interface ToggleStarContext {
62
- threadMessagesPrefix: readonly unknown[];
63
- listPrefixes: ReadonlyArray<readonly unknown[]>;
64
- previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
65
- previousThreadsList: ThreadListSnapshotEntry[];
66
- }
67
-
68
52
  export const toggleStarsInItems = (
69
53
  items: RemitImapThreadMessageResponse[],
70
54
  messageId: string,
@@ -84,7 +68,7 @@ export const useToggleStar = ({
84
68
 
85
69
  const { mutate, isPending, variables } = useMutation({
86
70
  ...messageOperationsUpdateMessageFlagsMutation(),
87
- onMutate: async (vars): Promise<ToggleStarContext> => {
71
+ onMutate: async (vars): Promise<ThreadMutationContext> => {
88
72
  const messageId = vars.path.messageId;
89
73
  const nextStarred = vars.body.isStarred ?? false;
90
74