@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.39",
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
- threadsData?.pages.flatMap((page) => page.items ?? []) ?? [],
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
+ };