@remit/web-client 0.0.9 → 0.0.10

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.9",
3
+ "version": "0.0.10",
4
4
  "type": "module",
5
5
  "description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
6
6
  "exports": {
@@ -163,7 +163,7 @@ export const ConversationView = ({
163
163
  } = useQuery({
164
164
  ...threadDetailOperationsListThreadMessagesOptions({
165
165
  path: { threadId },
166
- query: { order: "desc", mailboxId },
166
+ query: { order: "desc" },
167
167
  }),
168
168
  });
169
169
 
@@ -266,6 +266,7 @@ export const ConversationView = ({
266
266
  } = useToggleStar({
267
267
  threadId,
268
268
  mailboxId,
269
+ messages,
269
270
  });
270
271
 
271
272
  // Compose state for inline reply/forward.
@@ -1,7 +1,10 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
3
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
4
- import { selectMessagesToMarkRead } from "./useMarkAsRead.js";
4
+ import {
5
+ resolveMailboxesForMessages,
6
+ selectMessagesToMarkRead,
7
+ } from "./useMarkAsRead.js";
5
8
 
6
9
  const make = (
7
10
  overrides: Partial<RemitImapThreadMessageResponse> & {
@@ -163,3 +166,52 @@ describe("selectMessagesToMarkRead", () => {
163
166
  assert.deepStrictEqual(got, ["m-older"]);
164
167
  });
165
168
  });
169
+
170
+ describe("resolveMailboxesForMessages", () => {
171
+ const messages = [
172
+ make({
173
+ messageId: "m-received",
174
+ threadMessageId: "tm1",
175
+ isRead: false,
176
+ mailboxId: "mb-inbox",
177
+ }),
178
+ make({
179
+ messageId: "m-sent",
180
+ threadMessageId: "tm2",
181
+ isRead: false,
182
+ mailboxId: "mb-sent",
183
+ }),
184
+ ];
185
+
186
+ test("names every mailbox the batch touches, not the browsed one", () => {
187
+ const got = resolveMailboxesForMessages(
188
+ ["m-received", "m-sent"],
189
+ messages,
190
+ "mb-inbox",
191
+ );
192
+ assert.deepStrictEqual([...got].sort(), ["mb-inbox", "mb-sent"]);
193
+ });
194
+
195
+ test("marking only the sent reply leaves the browsed mailbox alone", () => {
196
+ const got = resolveMailboxesForMessages(["m-sent"], messages, "mb-inbox");
197
+ assert.deepStrictEqual(got, ["mb-sent"]);
198
+ });
199
+
200
+ test("names each mailbox once however many messages it holds", () => {
201
+ const got = resolveMailboxesForMessages(
202
+ ["m-received", "m-received"],
203
+ messages,
204
+ "mb-inbox",
205
+ );
206
+ assert.deepStrictEqual(got, ["mb-inbox"]);
207
+ });
208
+
209
+ test("falls back to the browsed mailbox for an unknown message", () => {
210
+ const got = resolveMailboxesForMessages(
211
+ ["m-elsewhere"],
212
+ messages,
213
+ "mb-inbox",
214
+ );
215
+ assert.deepStrictEqual(got, ["mb-inbox"]);
216
+ });
217
+ });
@@ -41,12 +41,35 @@ interface SnapshotEntry<T> {
41
41
 
42
42
  interface MarkAsReadContext {
43
43
  threadMessagesPrefix: readonly unknown[];
44
- threadsListPrefix: readonly unknown[];
45
- threadsSearchPrefix: readonly unknown[];
44
+ threadsListPrefixes: ReadonlyArray<readonly unknown[]>;
45
+ threadsSearchPrefixes: ReadonlyArray<readonly unknown[]>;
46
46
  previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
47
47
  previousThreadsList: SnapshotEntry<ThreadsListData>[];
48
48
  }
49
49
 
50
+ /**
51
+ * The mailboxes whose cached listings a batch of message mutations affects.
52
+ *
53
+ * A conversation spans mailboxes (#46), so marking a thread read can touch a
54
+ * received message in INBOX and the user's own reply in Sent at once, and each
55
+ * one's lists live under its own mailbox key. Falls back to the browsed mailbox
56
+ * for ids the thread's messages do not cover.
57
+ */
58
+ export const resolveMailboxesForMessages = (
59
+ messageIds: Iterable<string>,
60
+ messages: RemitImapThreadMessageResponse[],
61
+ fallbackMailboxId: string,
62
+ ): string[] => {
63
+ const byMessageId = new Map(
64
+ messages.map((message) => [message.messageId, message.mailboxId]),
65
+ );
66
+ const mailboxIds = new Set<string>();
67
+ for (const messageId of messageIds) {
68
+ mailboxIds.add(byMessageId.get(messageId) ?? fallbackMailboxId);
69
+ }
70
+ return [...mailboxIds];
71
+ };
72
+
50
73
  const setReadOnItems = (
51
74
  items: RemitImapThreadMessageResponse[],
52
75
  messageIds: Set<string>,
@@ -101,17 +124,26 @@ export const useMarkAsRead = ({
101
124
  threadDetailOperationsListThreadMessagesQueryKey({
102
125
  path: { threadId },
103
126
  });
104
- const threadsListPrefix = threadOperationsListThreadsQueryKey({
105
- path: { mailboxId },
106
- });
107
- const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
108
- path: { mailboxId },
109
- });
127
+ const affectedMailboxIds = resolveMailboxesForMessages(
128
+ messageIds,
129
+ messages,
130
+ mailboxId,
131
+ );
132
+ const threadsListPrefixes = affectedMailboxIds.map((id) =>
133
+ threadOperationsListThreadsQueryKey({ path: { mailboxId: id } }),
134
+ );
135
+ const threadsSearchPrefixes = affectedMailboxIds.map((id) =>
136
+ threadOperationsSearchThreadsQueryKey({ path: { mailboxId: id } }),
137
+ );
110
138
 
111
139
  await Promise.all([
112
140
  queryClient.cancelQueries({ queryKey: threadMessagesPrefix }),
113
- queryClient.cancelQueries({ queryKey: threadsListPrefix }),
114
- queryClient.cancelQueries({ queryKey: threadsSearchPrefix }),
141
+ ...threadsListPrefixes.map((queryKey) =>
142
+ queryClient.cancelQueries({ queryKey }),
143
+ ),
144
+ ...threadsSearchPrefixes.map((queryKey) =>
145
+ queryClient.cancelQueries({ queryKey }),
146
+ ),
115
147
  ]);
116
148
 
117
149
  const previousThreadMessages = queryClient
@@ -122,12 +154,12 @@ export const useMarkAsRead = ({
122
154
  )
123
155
  .map(([queryKey, data]) => ({ queryKey, data }));
124
156
 
125
- const previousThreadsList = queryClient
126
- .getQueriesData<ThreadsListData>({ queryKey: threadsListPrefix })
127
- .concat(
128
- queryClient.getQueriesData<ThreadsListData>({
129
- queryKey: threadsSearchPrefix,
130
- }),
157
+ const previousThreadsList = [
158
+ ...threadsListPrefixes,
159
+ ...threadsSearchPrefixes,
160
+ ]
161
+ .flatMap((queryKey) =>
162
+ queryClient.getQueriesData<ThreadsListData>({ queryKey }),
131
163
  )
132
164
  .filter(
133
165
  (entry): entry is [readonly unknown[], ThreadsListData] =>
@@ -157,19 +189,20 @@ export const useMarkAsRead = ({
157
189
  };
158
190
  };
159
191
 
160
- queryClient.setQueriesData<ThreadsListData>(
161
- { queryKey: threadsListPrefix },
162
- patchListData,
163
- );
164
- queryClient.setQueriesData<ThreadsListData>(
165
- { queryKey: threadsSearchPrefix },
166
- patchListData,
167
- );
192
+ for (const queryKey of [
193
+ ...threadsListPrefixes,
194
+ ...threadsSearchPrefixes,
195
+ ]) {
196
+ queryClient.setQueriesData<ThreadsListData>(
197
+ { queryKey },
198
+ patchListData,
199
+ );
200
+ }
168
201
 
169
202
  return {
170
203
  threadMessagesPrefix,
171
- threadsListPrefix,
172
- threadsSearchPrefix,
204
+ threadsListPrefixes,
205
+ threadsSearchPrefixes,
173
206
  previousThreadMessages,
174
207
  previousThreadsList,
175
208
  };
@@ -203,8 +236,12 @@ export const useMarkAsRead = ({
203
236
  onSettled: (_data, _err, _vars, context) => {
204
237
  if (!context) return;
205
238
  queryClient.invalidateQueries({ queryKey: context.threadMessagesPrefix });
206
- queryClient.invalidateQueries({ queryKey: context.threadsListPrefix });
207
- queryClient.invalidateQueries({ queryKey: context.threadsSearchPrefix });
239
+ for (const queryKey of [
240
+ ...context.threadsListPrefixes,
241
+ ...context.threadsSearchPrefixes,
242
+ ]) {
243
+ queryClient.invalidateQueries({ queryKey });
244
+ }
208
245
  // Refresh the sidebar mailbox list so the unread badge picks up the
209
246
  // next backend `unseenCount` (which is owned by IMAP sync).
210
247
  if (accountId) {
@@ -1,7 +1,10 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
3
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
4
- import { toggleStarsInItems } from "./useToggleStar.js";
4
+ import {
5
+ resolveMailboxForMessage,
6
+ toggleStarsInItems,
7
+ } from "./useToggleStar.js";
5
8
 
6
9
  const make = (
7
10
  messageId: string,
@@ -55,3 +58,38 @@ describe("toggleStarsInItems", () => {
55
58
  );
56
59
  });
57
60
  });
61
+
62
+ describe("resolveMailboxForMessage", () => {
63
+ const messages = [
64
+ { ...make("m-received", false), mailboxId: "mb-inbox" },
65
+ { ...make("m-sent", false), mailboxId: "mb-sent" },
66
+ ];
67
+
68
+ test("starring a sent reply patches the sent mailbox, not the browsed one", () => {
69
+ assert.strictEqual(
70
+ resolveMailboxForMessage("m-sent", messages, "mb-inbox"),
71
+ "mb-sent",
72
+ );
73
+ });
74
+
75
+ test("starring a message in the browsed mailbox is unchanged", () => {
76
+ assert.strictEqual(
77
+ resolveMailboxForMessage("m-received", messages, "mb-inbox"),
78
+ "mb-inbox",
79
+ );
80
+ });
81
+
82
+ test("falls back to the browsed mailbox when the thread is unknown", () => {
83
+ assert.strictEqual(
84
+ resolveMailboxForMessage("m-sent", undefined, "mb-inbox"),
85
+ "mb-inbox",
86
+ );
87
+ });
88
+
89
+ test("falls back to the browsed mailbox for a message not in the thread", () => {
90
+ assert.strictEqual(
91
+ resolveMailboxForMessage("m-elsewhere", messages, "mb-inbox"),
92
+ "mb-inbox",
93
+ );
94
+ });
95
+ });
@@ -12,9 +12,35 @@ import { formatErrorDetail } from "@/components/ui/error-banners";
12
12
 
13
13
  interface UseToggleStarOptions {
14
14
  threadId: string;
15
+ /**
16
+ * The mailbox whose lists to patch when the starred message is not in
17
+ * `messages` — the browsed mailbox, which is where a list row lives.
18
+ */
15
19
  mailboxId: string;
20
+ /**
21
+ * The thread's messages, when the caller has them. A conversation spans
22
+ * mailboxes (#46), so the lists to patch are the ones for the mailbox the
23
+ * starred message is actually in, not the one being browsed.
24
+ */
25
+ messages?: RemitImapThreadMessageResponse[];
16
26
  }
17
27
 
28
+ /**
29
+ * The mailbox whose cached listings a mutation on `messageId` affects.
30
+ *
31
+ * The message's own mailbox, falling back to the browsed one when the thread's
32
+ * messages are unknown or do not contain it. Starring a reply in Sent from a
33
+ * conversation opened in INBOX has to patch Sent's lists; INBOX's do not hold
34
+ * that message and patching them changes nothing.
35
+ */
36
+ export const resolveMailboxForMessage = (
37
+ messageId: string,
38
+ messages: RemitImapThreadMessageResponse[] | undefined,
39
+ fallbackMailboxId: string,
40
+ ): string =>
41
+ messages?.find((message) => message.messageId === messageId)?.mailboxId ??
42
+ fallbackMailboxId;
43
+
18
44
  interface ThreadMessagesData {
19
45
  items: RemitImapThreadMessageResponse[];
20
46
  [key: string]: unknown;
@@ -57,6 +83,7 @@ export const toggleStarsInItems = (
57
83
  export const useToggleStar = ({
58
84
  threadId,
59
85
  mailboxId,
86
+ messages,
60
87
  }: UseToggleStarOptions) => {
61
88
  const queryClient = useQueryClient();
62
89
  const { pushError } = useErrorBanners();
@@ -75,11 +102,16 @@ export const useToggleStar = ({
75
102
  threadDetailOperationsListThreadMessagesQueryKey({
76
103
  path: { threadId },
77
104
  });
105
+ const affectedMailboxId = resolveMailboxForMessage(
106
+ messageId,
107
+ messages,
108
+ mailboxId,
109
+ );
78
110
  const threadsListPrefix = threadOperationsListThreadsQueryKey({
79
- path: { mailboxId },
111
+ path: { mailboxId: affectedMailboxId },
80
112
  });
81
113
  const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
82
- path: { mailboxId },
114
+ path: { mailboxId: affectedMailboxId },
83
115
  });
84
116
  // The unified cross-account listing backs the daily brief and the
85
117
  // Starred mailbox, so a star toggled from an inbox has to land there