@remit/web-client 0.0.39 → 0.0.41
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.
|
|
3
|
+
"version": "0.0.41",
|
|
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": {
|
|
@@ -128,6 +128,7 @@ function useSelectedNavId(): string {
|
|
|
128
128
|
const location = useLocation();
|
|
129
129
|
const params = useParams({ strict: false }) as { mailboxId?: string };
|
|
130
130
|
|
|
131
|
+
if (location.pathname.startsWith("/settings")) return "settings";
|
|
131
132
|
if (location.pathname.startsWith("/mail/outbox")) return "outbox";
|
|
132
133
|
if (location.pathname.startsWith("/mail/flagged")) return "flagged";
|
|
133
134
|
if (params.mailboxId) return params.mailboxId;
|
|
@@ -271,6 +272,19 @@ export function MailSidebarAdapter({
|
|
|
271
272
|
</Link>
|
|
272
273
|
);
|
|
273
274
|
}
|
|
275
|
+
if (navId === "settings") {
|
|
276
|
+
return (
|
|
277
|
+
<Link
|
|
278
|
+
to="/settings/accounts"
|
|
279
|
+
onClick={() => onClick?.()}
|
|
280
|
+
className={className}
|
|
281
|
+
aria-label={ariaLabel}
|
|
282
|
+
title={title}
|
|
283
|
+
>
|
|
284
|
+
{children}
|
|
285
|
+
</Link>
|
|
286
|
+
);
|
|
287
|
+
}
|
|
274
288
|
return (
|
|
275
289
|
<Link
|
|
276
290
|
to="/mail/$mailboxId"
|
|
@@ -99,6 +99,7 @@ import {
|
|
|
99
99
|
buildConversationTarget,
|
|
100
100
|
type ConversationTarget,
|
|
101
101
|
} from "@/lib/conversation-target";
|
|
102
|
+
import { dedupeThreadMessages } from "@/lib/dedupe-thread-messages";
|
|
102
103
|
import { readIntelligencePref } from "@/lib/intelligence-pref";
|
|
103
104
|
import { useMailContext } from "@/lib/mail-context";
|
|
104
105
|
import { isRescueCandidate } from "@/lib/rescue-candidates";
|
|
@@ -370,7 +371,9 @@ function MailboxPaneProvider({
|
|
|
370
371
|
});
|
|
371
372
|
|
|
372
373
|
const rawThreads = dropDeletedThreads(
|
|
373
|
-
|
|
374
|
+
dedupeThreadMessages(
|
|
375
|
+
threadsData?.pages.flatMap((page) => page.items ?? []) ?? [],
|
|
376
|
+
),
|
|
374
377
|
);
|
|
375
378
|
|
|
376
379
|
const [filterCategory, setFilterCategory] = useState("all");
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import assert from "node:assert";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import { dedupeThreadMessages } from "./dedupe-thread-messages.js";
|
|
5
|
+
|
|
6
|
+
function threadMessage(
|
|
7
|
+
overrides: Partial<RemitImapThreadMessageResponse> = {},
|
|
8
|
+
): RemitImapThreadMessageResponse {
|
|
9
|
+
return {
|
|
10
|
+
threadId: "t1",
|
|
11
|
+
threadMessageId: "tm1",
|
|
12
|
+
messageId: "m1",
|
|
13
|
+
accountConfigId: "cfg_1",
|
|
14
|
+
mailboxId: "mb1",
|
|
15
|
+
fromName: "Sender",
|
|
16
|
+
fromEmail: "sender@example.com",
|
|
17
|
+
subject: "Subject",
|
|
18
|
+
snippet: "Snippet",
|
|
19
|
+
sentDate: 1767225600,
|
|
20
|
+
isRead: false,
|
|
21
|
+
isDeleted: false,
|
|
22
|
+
hasAttachment: false,
|
|
23
|
+
hasStars: false,
|
|
24
|
+
star: "none",
|
|
25
|
+
senderTrust: "unknown",
|
|
26
|
+
createdAt: 0,
|
|
27
|
+
updatedAt: 0,
|
|
28
|
+
...overrides,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("dedupeThreadMessages (#166)", () => {
|
|
33
|
+
test("collapses an overlapping page to one row per threadMessageId", () => {
|
|
34
|
+
const pageOne = [
|
|
35
|
+
threadMessage({ threadMessageId: "tm1", messageId: "m1" }),
|
|
36
|
+
threadMessage({ threadMessageId: "tm2", messageId: "m2" }),
|
|
37
|
+
];
|
|
38
|
+
const pageTwo = [
|
|
39
|
+
threadMessage({ threadMessageId: "tm2", messageId: "m2" }),
|
|
40
|
+
threadMessage({ threadMessageId: "tm3", messageId: "m3" }),
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const got = dedupeThreadMessages([...pageOne, ...pageTwo]);
|
|
44
|
+
|
|
45
|
+
assert.deepStrictEqual(
|
|
46
|
+
got.map((item) => item.threadMessageId),
|
|
47
|
+
["tm1", "tm2", "tm3"],
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("keeps the first occurrence's data when duplicates disagree", () => {
|
|
52
|
+
const items = [
|
|
53
|
+
threadMessage({ threadMessageId: "tm1", isRead: false }),
|
|
54
|
+
threadMessage({ threadMessageId: "tm1", isRead: true }),
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const got = dedupeThreadMessages(items);
|
|
58
|
+
|
|
59
|
+
assert.equal(got.length, 1);
|
|
60
|
+
assert.equal(got[0].isRead, false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("leaves non-overlapping pages unaffected", () => {
|
|
64
|
+
const items = [
|
|
65
|
+
threadMessage({ threadMessageId: "tm1", messageId: "m1" }),
|
|
66
|
+
threadMessage({ threadMessageId: "tm2", messageId: "m2" }),
|
|
67
|
+
threadMessage({ threadMessageId: "tm3", messageId: "m3" }),
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const got = dedupeThreadMessages(items);
|
|
71
|
+
|
|
72
|
+
assert.deepStrictEqual(got, items);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("returns an empty array for empty input", () => {
|
|
76
|
+
assert.deepStrictEqual(dedupeThreadMessages([]), []);
|
|
77
|
+
});
|
|
78
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure helper: collapse a flattened list of thread messages to one row per
|
|
5
|
+
* `threadMessageId`, keeping the first occurrence.
|
|
6
|
+
*
|
|
7
|
+
* Belt-and-braces guard for issue #166. The web client appends every fetched
|
|
8
|
+
* page as-is and relies on the keyset cursor never handing back an overlapping
|
|
9
|
+
* page. `threadMessageId` is unique per message, so two rows sharing one are
|
|
10
|
+
* the same message — dropping the later row removes a duplicate, never a
|
|
11
|
+
* distinct result. Applied where pages are flattened into the list, so every
|
|
12
|
+
* consumer of the list sees a clean array and no render site has to remember
|
|
13
|
+
* to dedupe.
|
|
14
|
+
*/
|
|
15
|
+
export const dedupeThreadMessages = (
|
|
16
|
+
items: RemitImapThreadMessageResponse[],
|
|
17
|
+
): RemitImapThreadMessageResponse[] => {
|
|
18
|
+
const seen = new Set<string>();
|
|
19
|
+
const deduped: RemitImapThreadMessageResponse[] = [];
|
|
20
|
+
for (const item of items) {
|
|
21
|
+
if (seen.has(item.threadMessageId)) continue;
|
|
22
|
+
seen.add(item.threadMessageId);
|
|
23
|
+
deduped.push(item);
|
|
24
|
+
}
|
|
25
|
+
return deduped;
|
|
26
|
+
};
|