@remit/web-client 0.0.8 → 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 +1 -1
- package/src/components/mail/ConversationView.tsx +2 -1
- package/src/components/mail/FlaggedList.tsx +46 -12
- package/src/components/mail/SwipeableMessageRow.tsx +1 -2
- package/src/hooks/useMarkAsRead.test.ts +53 -1
- package/src/hooks/useMarkAsRead.ts +65 -28
- package/src/hooks/useToggleStar.test.ts +39 -1
- package/src/hooks/useToggleStar.ts +34 -2
- package/src/lib/drafts.ts +1 -2
- package/src/lib/starred-rows.test.ts +71 -0
- package/src/lib/starred-rows.ts +25 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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"
|
|
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,21 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FlaggedList — a FLAT, cross-account inbox of starred mail.
|
|
3
3
|
*
|
|
4
|
-
* Reads
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Reads GET /threads with `starred=true`, which is served by the `byStarred`
|
|
5
|
+
* index and returns every starred thread in the config across all non-muted
|
|
6
|
+
* mailboxes, paged. Starredness is decided server-side from `hasStars`; the
|
|
7
|
+
* client neither re-filters nor caps the set, so a starred thread outside the
|
|
8
|
+
* newest inbox page still appears. Rendered as one continuous list (no category
|
|
9
|
+
* sections). The shared `MailViewChrome` owns the `MailHeader` + filter
|
|
10
|
+
* expando; the kit `MessageListPane` (flat, no `briefFilters`) owns the loading
|
|
11
|
+
* / empty / error chrome and keyboard hints, with a consumer-supplied
|
|
12
|
+
* `listBody` so the real rows render at every width.
|
|
10
13
|
*/
|
|
11
|
-
import {
|
|
14
|
+
import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
15
|
+
import { unifiedThreadOperationsListAllThreads } from "@remit/api-http-client/sdk.gen.ts";
|
|
12
16
|
import {
|
|
13
17
|
ComfortableRow,
|
|
14
18
|
flaggedFilterConfig,
|
|
15
19
|
MessageListPane,
|
|
16
20
|
type ThreadRowData,
|
|
17
21
|
} from "@remit/ui";
|
|
18
|
-
import {
|
|
22
|
+
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
19
23
|
import { useCallback, useMemo, useState } from "react";
|
|
20
24
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
21
25
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
@@ -28,6 +32,7 @@ import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
|
28
32
|
import { useMailContext } from "@/lib/mail-context";
|
|
29
33
|
import { rowToSearchResult } from "@/lib/search-result";
|
|
30
34
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
35
|
+
import { dedupeByThread } from "@/lib/starred-rows";
|
|
31
36
|
import { MailViewChrome } from "./MailViewChrome";
|
|
32
37
|
|
|
33
38
|
const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
|
|
@@ -72,8 +77,26 @@ export function FlaggedList({
|
|
|
72
77
|
isError,
|
|
73
78
|
error,
|
|
74
79
|
refetch,
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
fetchNextPage,
|
|
81
|
+
hasNextPage,
|
|
82
|
+
isFetchingNextPage,
|
|
83
|
+
} = useInfiniteQuery({
|
|
84
|
+
queryKey: unifiedThreadOperationsListAllThreadsQueryKey({
|
|
85
|
+
query: { starred: true, order: "desc" },
|
|
86
|
+
}),
|
|
87
|
+
queryFn: async ({ pageParam }) => {
|
|
88
|
+
const { data } = await unifiedThreadOperationsListAllThreads({
|
|
89
|
+
query: {
|
|
90
|
+
starred: true,
|
|
91
|
+
order: "desc",
|
|
92
|
+
continuationToken: pageParam,
|
|
93
|
+
},
|
|
94
|
+
throwOnError: true,
|
|
95
|
+
});
|
|
96
|
+
return data;
|
|
97
|
+
},
|
|
98
|
+
initialPageParam: undefined as string | undefined,
|
|
99
|
+
getNextPageParam: (lastPage) => lastPage.continuationToken,
|
|
77
100
|
staleTime: 60_000,
|
|
78
101
|
});
|
|
79
102
|
|
|
@@ -86,9 +109,10 @@ export function FlaggedList({
|
|
|
86
109
|
const predicates = Array.from(activeFilters)
|
|
87
110
|
.map((id) => FILTER_PREDICATES[id])
|
|
88
111
|
.filter((p): p is (t: ThreadRowData) => boolean => p != null);
|
|
89
|
-
return (
|
|
112
|
+
return dedupeByThread(
|
|
113
|
+
(threadsData?.pages ?? []).flatMap((page) => page.items ?? []),
|
|
114
|
+
)
|
|
90
115
|
.map(toThreadRowData)
|
|
91
|
-
.filter((t) => t.starred === true)
|
|
92
116
|
.filter(
|
|
93
117
|
(t) =>
|
|
94
118
|
(selectedCategory === "all" || t.category === selectedCategory) &&
|
|
@@ -132,6 +156,16 @@ export function FlaggedList({
|
|
|
132
156
|
/>
|
|
133
157
|
))}
|
|
134
158
|
</div>
|
|
159
|
+
{hasNextPage ? (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
className="w-full py-3 text-sm text-muted hover:text-fg disabled:opacity-50"
|
|
163
|
+
onClick={() => fetchNextPage()}
|
|
164
|
+
disabled={isFetchingNextPage}
|
|
165
|
+
>
|
|
166
|
+
{isFetchingNextPage ? "Loading…" : "Load more"}
|
|
167
|
+
</button>
|
|
168
|
+
) : null}
|
|
135
169
|
</div>
|
|
136
170
|
);
|
|
137
171
|
|
|
@@ -50,8 +50,7 @@ const toThreadRowData = (
|
|
|
50
50
|
timeLabel: formatEmailDate(thread.sentDate),
|
|
51
51
|
isRead: thread.isRead,
|
|
52
52
|
hasAttachment: thread.hasAttachment,
|
|
53
|
-
starred:
|
|
54
|
-
thread.star != null && thread.star !== "none" && thread.hasStars === true,
|
|
53
|
+
starred: thread.hasStars === true,
|
|
55
54
|
trust: thread.senderTrust,
|
|
56
55
|
category: toDisplayCategory(thread.category),
|
|
57
56
|
suspicious,
|
|
@@ -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 {
|
|
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
|
-
|
|
45
|
-
|
|
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
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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 =
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
)
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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 {
|
|
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
|
package/src/lib/drafts.ts
CHANGED
|
@@ -64,8 +64,7 @@ export function toImapDraftRowData(
|
|
|
64
64
|
timeLabel: formatEmailDate(thread.sentDate),
|
|
65
65
|
isRead: thread.isRead,
|
|
66
66
|
hasAttachment: thread.hasAttachment,
|
|
67
|
-
starred:
|
|
68
|
-
thread.star != null && thread.star !== "none" && thread.hasStars === true,
|
|
67
|
+
starred: thread.hasStars === true,
|
|
69
68
|
};
|
|
70
69
|
}
|
|
71
70
|
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import { dedupeByThread } from "./starred-rows.js";
|
|
5
|
+
|
|
6
|
+
// The starred listing returns one row per mailbox. The same mail filed in two
|
|
7
|
+
// folders is two rows sharing a threadId, both starred server-side, so a
|
|
8
|
+
// conversation would otherwise render twice.
|
|
9
|
+
|
|
10
|
+
const row = (
|
|
11
|
+
threadId: string,
|
|
12
|
+
messageId: string,
|
|
13
|
+
mailboxId: string,
|
|
14
|
+
): RemitImapThreadMessageResponse =>
|
|
15
|
+
({
|
|
16
|
+
threadId,
|
|
17
|
+
messageId,
|
|
18
|
+
mailboxId,
|
|
19
|
+
}) as unknown as RemitImapThreadMessageResponse;
|
|
20
|
+
|
|
21
|
+
describe("dedupeByThread", () => {
|
|
22
|
+
test("collapses two copies of one conversation to a single row", () => {
|
|
23
|
+
const result = dedupeByThread([
|
|
24
|
+
row("t1", "m-inbox", "inbox"),
|
|
25
|
+
row("t1", "m-archive", "archive"),
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
assert.deepEqual(
|
|
29
|
+
result.map((r) => r.messageId),
|
|
30
|
+
["m-inbox"],
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("keeps the first row, which is the newest under descending order", () => {
|
|
35
|
+
const result = dedupeByThread([
|
|
36
|
+
row("t1", "m-newest", "inbox"),
|
|
37
|
+
row("t1", "m-older", "archive"),
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
assert.equal(result[0]?.messageId, "m-newest");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("leaves distinct conversations alone and preserves order", () => {
|
|
44
|
+
const result = dedupeByThread([
|
|
45
|
+
row("t1", "m1", "inbox"),
|
|
46
|
+
row("t2", "m2", "inbox"),
|
|
47
|
+
row("t3", "m3", "inbox"),
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
assert.deepEqual(
|
|
51
|
+
result.map((r) => r.threadId),
|
|
52
|
+
["t1", "t2", "t3"],
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("collapses copies that arrived on different pages", () => {
|
|
57
|
+
const pageOne = [row("t1", "m1", "inbox"), row("t2", "m2", "inbox")];
|
|
58
|
+
const pageTwo = [row("t2", "m2-copy", "archive"), row("t3", "m3", "inbox")];
|
|
59
|
+
|
|
60
|
+
const result = dedupeByThread([...pageOne, ...pageTwo]);
|
|
61
|
+
|
|
62
|
+
assert.deepEqual(
|
|
63
|
+
result.map((r) => r.threadId),
|
|
64
|
+
["t1", "t2", "t3"],
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("an empty list stays empty", () => {
|
|
69
|
+
assert.deepEqual(dedupeByThread([]), []);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Collapse rows that are the same conversation.
|
|
5
|
+
*
|
|
6
|
+
* A message's identity includes its mailbox, so the same mail filed in two
|
|
7
|
+
* folders is two rows carrying the same server-side star. The starred scope
|
|
8
|
+
* already excludes the folder that causes this wholesale (Gmail's All Mail),
|
|
9
|
+
* but an ordinary copy in a user folder still produces a pair.
|
|
10
|
+
*
|
|
11
|
+
* Deduping over the accumulated pages rather than inside one keeps a
|
|
12
|
+
* conversation single even when its copies straddle a page boundary — a single
|
|
13
|
+
* page cannot know what earlier pages already showed. The first row wins, which
|
|
14
|
+
* is the newest under the server's descending order.
|
|
15
|
+
*/
|
|
16
|
+
export const dedupeByThread = (
|
|
17
|
+
items: RemitImapThreadMessageResponse[],
|
|
18
|
+
): RemitImapThreadMessageResponse[] => {
|
|
19
|
+
const seen = new Set<string>();
|
|
20
|
+
return items.filter((item) => {
|
|
21
|
+
if (seen.has(item.threadId)) return false;
|
|
22
|
+
seen.add(item.threadId);
|
|
23
|
+
return true;
|
|
24
|
+
});
|
|
25
|
+
};
|