@remit/web-client 0.0.225 → 0.0.226

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.225",
3
+ "version": "0.0.226",
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": {
@@ -7,6 +7,7 @@
7
7
  * failure to read it at all. None of them is an empty panel.
8
8
  */
9
9
  import { meOperationsListQuarantineOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
10
+ import type { RemitImapQuarantineResponse } from "@remit/api-http-client/types.gen.ts";
10
11
  import {
11
12
  Button,
12
13
  QuarantineBugDialog,
@@ -17,11 +18,15 @@ import {
17
18
  } from "@remit/ui";
18
19
  import { useQuery } from "@tanstack/react-query";
19
20
  import { Bug } from "lucide-react";
20
- import { useState } from "react";
21
+ import { useMemo, useState } from "react";
21
22
  import { formatErrorMessage } from "@/components/ui/ErrorState";
23
+ import { useAccountDelimiters } from "@/hooks/useAccountDelimiters";
22
24
  import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
23
25
  import { toQuarantineEntry } from "@/lib/quarantine-entries";
24
26
 
27
+ /** Stable identity, so an empty list does not rebuild the memos below. */
28
+ const EMPTY_WIRE_ENTRIES: readonly RemitImapQuarantineResponse[] = [];
29
+
25
30
  function openIssue(url: string): void {
26
31
  window.open(url, "_blank", "noopener,noreferrer");
27
32
  }
@@ -126,9 +131,23 @@ export function QuarantinePanel() {
126
131
  meOperationsListQuarantineOptions(),
127
132
  );
128
133
 
134
+ const wireEntries = data?.entries ?? EMPTY_WIRE_ENTRIES;
135
+ const accountIds = useMemo(
136
+ () => [...new Set(wireEntries.map((entry) => entry.accountId))],
137
+ [wireEntries],
138
+ );
139
+ const delimiters = useAccountDelimiters(accountIds);
140
+ const entries = useMemo(
141
+ () =>
142
+ wireEntries.map((entry) =>
143
+ toQuarantineEntry(entry, delimiters.get(entry.accountId) ?? "/"),
144
+ ),
145
+ [wireEntries, delimiters],
146
+ );
147
+
129
148
  return (
130
149
  <QuarantinePanelView
131
- entries={(data?.entries ?? []).map(toQuarantineEntry)}
150
+ entries={entries}
132
151
  isPending={isPending}
133
152
  error={error}
134
153
  />
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The hierarchy separator each account's own server reports, for surfaces that
3
+ * hold a provider path but not the mailbox row it came from — quarantine, so
4
+ * far. Fans out the same per-account mailbox-list query as
5
+ * `useResultFolderIndex` (cached forever, react-query dedupes the identical key
6
+ * across call sites) and keeps only the delimiter.
7
+ */
8
+ import { mailboxOperationsListMailboxesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
9
+ import type { RemitImapMailboxResponse } from "@remit/api-http-client/types.gen.ts";
10
+ import { useQueries } from "@tanstack/react-query";
11
+ import { useMemo } from "react";
12
+ import { folderDelimiter } from "@/lib/move-options";
13
+
14
+ /**
15
+ * Hoisted so `useQueries` treats this as a memo rather than a per-render
16
+ * reduce — see `useResultFolderIndex`.
17
+ */
18
+ const combineDelimiters = (
19
+ results: { data?: { items: RemitImapMailboxResponse[] } }[],
20
+ ): string[] =>
21
+ results.map((result) => folderDelimiter(result.data?.items ?? []));
22
+
23
+ export function useAccountDelimiters(
24
+ accountIds: readonly string[],
25
+ ): ReadonlyMap<string, string> {
26
+ const delimiters = useQueries({
27
+ queries: accountIds.map((accountId) => ({
28
+ ...mailboxOperationsListMailboxesOptions({ path: { accountId } }),
29
+ staleTime: Infinity,
30
+ })),
31
+ combine: combineDelimiters,
32
+ });
33
+
34
+ return useMemo(
35
+ () =>
36
+ new Map(
37
+ accountIds.map((accountId, i) => [accountId, delimiters[i] ?? "/"]),
38
+ ),
39
+ [accountIds, delimiters],
40
+ );
41
+ }
@@ -34,16 +34,16 @@ const wire: RemitImapQuarantineResponse = {
34
34
 
35
35
  describe("toQuarantineEntry", () => {
36
36
  it("spells the folder role the way the kit does", () => {
37
- assert.equal(toQuarantineEntry(wire).mailboxRole, "junk");
37
+ assert.equal(toQuarantineEntry(wire, "/").mailboxRole, "junk");
38
38
  });
39
39
 
40
40
  it("leaves the role absent when the folder has none appointed", () => {
41
41
  const { mailboxRole: _dropped, ...roleless } = wire;
42
- assert.equal(toQuarantineEntry(roleless).mailboxRole, undefined);
42
+ assert.equal(toQuarantineEntry(roleless, "/").mailboxRole, undefined);
43
43
  });
44
44
 
45
45
  it("carries every field the report and the row are built from", () => {
46
- const entry = toQuarantineEntry(wire);
46
+ const entry = toQuarantineEntry(wire, "/");
47
47
  assert.equal(entry.quarantineId, wire.quarantineId);
48
48
  assert.equal(entry.uid, wire.uid);
49
49
  assert.equal(entry.uidValidity, wire.uidValidity);
@@ -63,8 +63,13 @@ describe("toQuarantineEntry", () => {
63
63
  assert.deepEqual(entry.structure, wire.structure);
64
64
  });
65
65
 
66
+ it("carries the delimiter the caller resolved, which the wire has not", () => {
67
+ assert.equal("mailboxDelimiter" in wire, false);
68
+ assert.equal(toQuarantineEntry(wire, ".").mailboxDelimiter, ".");
69
+ });
70
+
66
71
  it("drops the fields the settings surface has no use for", () => {
67
- const entry = toQuarantineEntry(wire);
72
+ const entry = toQuarantineEntry(wire, "/");
68
73
  assert.equal("accountConfigId" in entry, false);
69
74
  assert.equal("createdAt" in entry, false);
70
75
  assert.equal("updatedAt" in entry, false);
@@ -7,9 +7,14 @@ import { CANONICAL_TO_NAV_ROLE } from "./folder-roles";
7
7
  * the settings surface has no use for, and spells the folder role in the
8
8
  * canonical PascalCase the API uses everywhere. Both differences are resolved
9
9
  * here rather than by widening the kit's entry type.
10
+ *
11
+ * The record carries no hierarchy delimiter either, so the caller resolves the
12
+ * account's own from its mailbox list and hands it in — the row shows a folder
13
+ * name, and a path cannot be cut into one without it.
10
14
  */
11
15
  export function toQuarantineEntry(
12
16
  wire: RemitImapQuarantineResponse,
17
+ mailboxDelimiter: string,
13
18
  ): QuarantineEntry {
14
19
  return {
15
20
  quarantineId: wire.quarantineId,
@@ -21,6 +26,7 @@ export function toQuarantineEntry(
21
26
  ? CANONICAL_TO_NAV_ROLE[wire.mailboxRole]
22
27
  : undefined,
23
28
  mailboxPath: wire.mailboxPath,
29
+ mailboxDelimiter,
24
30
  failureStage: wire.failureStage,
25
31
  failureCode: wire.failureCode,
26
32
  failureMessage: wire.failureMessage,
@@ -1,5 +1,6 @@
1
1
  import assert from "node:assert/strict";
2
2
  import { describe, it } from "node:test";
3
+ import { provenanceFolderLabel } from "@remit/ui";
3
4
  import {
4
5
  type AccountMailboxes,
5
6
  buildResultFolderIndex,
@@ -9,10 +10,11 @@ import {
9
10
  const account = (
10
11
  appointments: { role: string; mailboxId: string }[],
11
12
  mailboxes: { mailboxId: string; fullPath: string }[],
13
+ hierarchyDelimiter = "/",
12
14
  ): AccountMailboxes =>
13
15
  ({
14
16
  folderAppointments: appointments,
15
- mailboxes,
17
+ mailboxes: mailboxes.map((mailbox) => ({ ...mailbox, hierarchyDelimiter })),
16
18
  }) as unknown as AccountMailboxes;
17
19
 
18
20
  describe("buildResultFolderIndex", () => {
@@ -30,9 +32,11 @@ describe("buildResultFolderIndex", () => {
30
32
  assert.deepEqual(index.get("mb-junk"), {
31
33
  role: "junk",
32
34
  providerPath: "Bulk Mail",
35
+ hierarchyDelimiter: "/",
33
36
  });
34
37
  assert.deepEqual(index.get("mb-work"), {
35
38
  providerPath: "Projects/Bookkeeping",
39
+ hierarchyDelimiter: "/",
36
40
  });
37
41
  });
38
42
 
@@ -72,7 +76,7 @@ describe("resolveResultFolder", () => {
72
76
  it("skips a virtual folder for one that names a real place", () => {
73
77
  assert.deepEqual(resolveResultFolder(index, ["mb-all", "mb-inbox"]), {
74
78
  mailboxId: "mb-inbox",
75
- folder: { role: "inbox", providerPath: "INBOX" },
79
+ folder: { role: "inbox", providerPath: "INBOX", hierarchyDelimiter: "/" },
76
80
  });
77
81
  });
78
82
 
@@ -83,7 +87,11 @@ describe("resolveResultFolder", () => {
83
87
  it("falls back to the first id when none names a real place", () => {
84
88
  assert.deepEqual(resolveResultFolder(index, ["mb-all"]), {
85
89
  mailboxId: "mb-all",
86
- folder: { role: "all", providerPath: "[Gmail]/All Mail" },
90
+ folder: {
91
+ role: "all",
92
+ providerPath: "[Gmail]/All Mail",
93
+ hierarchyDelimiter: "/",
94
+ },
87
95
  });
88
96
  });
89
97
 
@@ -113,3 +121,13 @@ describe("buildResultFolderIndex before the mailbox list arrives", () => {
113
121
  assert.deepEqual(index.get("mb-junk"), { role: "junk" });
114
122
  });
115
123
  });
124
+
125
+ describe("buildResultFolderIndex on a dotted server", () => {
126
+ it("carries the delimiter the account reports, so the chip reads the leaf", () => {
127
+ const index = buildResultFolderIndex([
128
+ account([], [{ mailboxId: "mb-q3", fullPath: "INBOX.Projects.Q3" }], "."),
129
+ ]);
130
+
131
+ assert.equal(provenanceFolderLabel(index.get("mb-q3") ?? {}), "Q3");
132
+ });
133
+ });
@@ -7,6 +7,10 @@
7
7
  * sidebar already loads: the id → folder map here is the only thing either
8
8
  * needs, so neither the search API nor the semantic index has to carry a folder.
9
9
  *
10
+ * The mailbox row carries the hierarchy delimiter its own server reports, and it
11
+ * is kept alongside the path: a chip labelled by path shows the folder's own
12
+ * name, which on a `.`-delimited account cannot be cut on a slash.
13
+ *
10
14
  * The role comes from the account's `folderAppointments` map (RFC 032), which
11
15
  * the server resolves from the IMAP SPECIAL-USE attributes — so `junk` means the
12
16
  * folder the account advertises as `\Junk`, whatever it is called. Names are
@@ -27,7 +31,7 @@ export interface AccountMailboxes {
27
31
  folderAppointments: readonly RemitImapFolderAppointment[];
28
32
  mailboxes: readonly Pick<
29
33
  RemitImapMailboxResponse,
30
- "mailboxId" | "fullPath"
34
+ "mailboxId" | "fullPath" | "hierarchyDelimiter"
31
35
  >[];
32
36
  }
33
37
 
@@ -46,10 +50,11 @@ export function buildResultFolderIndex(
46
50
  for (const [mailboxId, role] of roles) index.set(mailboxId, { role });
47
51
  for (const mailbox of account.mailboxes) {
48
52
  const role = roles.get(mailbox.mailboxId);
49
- index.set(mailbox.mailboxId, {
50
- ...(role ? { role } : {}),
53
+ const path = {
51
54
  providerPath: mailbox.fullPath,
52
- });
55
+ hierarchyDelimiter: mailbox.hierarchyDelimiter,
56
+ };
57
+ index.set(mailbox.mailboxId, role ? { role, ...path } : path);
53
58
  }
54
59
  }
55
60
  return index;