@remit/ui 0.0.139 → 0.0.140
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/folder-role.tsx +5 -1
- package/src/components/role-appointment-list.render.test.ts +74 -2
- package/src/components/role-appointment-list.stories.tsx +29 -4
- package/src/components/role-appointment-list.tsx +10 -4
- package/src/index.ts +1 -0
- package/src/lib/folder-tree.test.ts +19 -0
- package/src/lib/folder-tree.ts +8 -0
package/package.json
CHANGED
|
@@ -48,7 +48,11 @@ export function canonicalRoleLabel(role: FolderRole): string {
|
|
|
48
48
|
return ROLE_LABEL[role];
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
/**
|
|
51
|
+
/**
|
|
52
|
+
* Leaf segment of a provider path (`INBOX/Spam` → `Spam`). Only for the two
|
|
53
|
+
* surfaces whose wire payload carries no `hierarchyDelimiter` — quarantine
|
|
54
|
+
* entries and search-result provenance. Everything else: `folderLeaf`.
|
|
55
|
+
*/
|
|
52
56
|
export function providerLeaf(providerPath: string): string {
|
|
53
57
|
const parts = providerPath.split("/");
|
|
54
58
|
return parts[parts.length - 1] || providerPath;
|
|
@@ -11,16 +11,28 @@ import {
|
|
|
11
11
|
const noop = () => {};
|
|
12
12
|
|
|
13
13
|
const folders: CandidateFolder[] = [
|
|
14
|
-
{
|
|
14
|
+
{
|
|
15
|
+
mailboxId: "mb-inbox",
|
|
16
|
+
providerPath: "INBOX",
|
|
17
|
+
hierarchyDelimiter: "/",
|
|
18
|
+
messageCount: 4821,
|
|
19
|
+
},
|
|
15
20
|
{
|
|
16
21
|
mailboxId: "mb-concepten",
|
|
17
22
|
providerPath: "INBOX/Concepten",
|
|
23
|
+
hierarchyDelimiter: "/",
|
|
18
24
|
messageCount: 340,
|
|
19
25
|
},
|
|
20
|
-
{
|
|
26
|
+
{
|
|
27
|
+
mailboxId: "mb-drafts",
|
|
28
|
+
providerPath: "INBOX/Drafts",
|
|
29
|
+
hierarchyDelimiter: "/",
|
|
30
|
+
messageCount: 0,
|
|
31
|
+
},
|
|
21
32
|
{
|
|
22
33
|
mailboxId: "mb-news",
|
|
23
34
|
providerPath: "INBOX/Nieuwsbrieven",
|
|
35
|
+
hierarchyDelimiter: "/",
|
|
24
36
|
messageCount: 2870,
|
|
25
37
|
},
|
|
26
38
|
];
|
|
@@ -215,3 +227,63 @@ describe("RoleAppointmentList", () => {
|
|
|
215
227
|
);
|
|
216
228
|
});
|
|
217
229
|
});
|
|
230
|
+
|
|
231
|
+
describe("RoleAppointmentList — the server’s own delimiter (#877)", () => {
|
|
232
|
+
const dotted: CandidateFolder[] = [
|
|
233
|
+
{
|
|
234
|
+
mailboxId: "mb-inbox",
|
|
235
|
+
providerPath: "INBOX",
|
|
236
|
+
hierarchyDelimiter: ".",
|
|
237
|
+
messageCount: 4821,
|
|
238
|
+
},
|
|
239
|
+
{
|
|
240
|
+
mailboxId: "mb-concepten",
|
|
241
|
+
providerPath: "INBOX.Projects.Concepten",
|
|
242
|
+
hierarchyDelimiter: ".",
|
|
243
|
+
messageCount: 340,
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
mailboxId: "mb-news",
|
|
247
|
+
providerPath: "INBOX.Projects.Nieuwsbrieven",
|
|
248
|
+
hierarchyDelimiter: ".",
|
|
249
|
+
messageCount: 2870,
|
|
250
|
+
},
|
|
251
|
+
];
|
|
252
|
+
|
|
253
|
+
const renderDotted = (
|
|
254
|
+
appointments: Record<string, RoleAppointment>,
|
|
255
|
+
): string =>
|
|
256
|
+
renderToString(
|
|
257
|
+
createElement(RoleAppointmentList, {
|
|
258
|
+
accountEmail: "you@example.com",
|
|
259
|
+
folders: dotted,
|
|
260
|
+
appointments,
|
|
261
|
+
displayNames: {},
|
|
262
|
+
onAppoint: noop,
|
|
263
|
+
onRename: noop,
|
|
264
|
+
}),
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
it("offers the picker option by its leaf", () => {
|
|
268
|
+
assert.match(renderDotted({}), /Concepten · 340 msgs/);
|
|
269
|
+
assert.doesNotMatch(renderDotted({}), /INBOX.Projects.Concepten · 340/);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("names the stale-appointment fallback by its leaf", () => {
|
|
273
|
+
const html = renderDotted({
|
|
274
|
+
drafts: {
|
|
275
|
+
mailboxId: "mb-concepten",
|
|
276
|
+
source: "Stale",
|
|
277
|
+
staleFolderPath: "INBOX.Projects.Oud",
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
assert.match(html, /reader is using Concepten instead\./);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it("lists an unappointed folder by its leaf under Other folders", () => {
|
|
284
|
+
const html = renderDotted({ drafts: appointed("mb-concepten") });
|
|
285
|
+
const other = html.slice(html.indexOf("Other folders"));
|
|
286
|
+
assert.match(other, />Nieuwsbrieven</);
|
|
287
|
+
assert.doesNotMatch(other, />INBOX.Projects.Nieuwsbrieven</);
|
|
288
|
+
});
|
|
289
|
+
});
|
|
@@ -18,35 +18,60 @@ import {
|
|
|
18
18
|
* numbers are wired in.
|
|
19
19
|
*/
|
|
20
20
|
const HOSTNET_FOLDERS: readonly CandidateFolder[] = [
|
|
21
|
-
{
|
|
21
|
+
{
|
|
22
|
+
mailboxId: "mb-inbox",
|
|
23
|
+
providerPath: "INBOX",
|
|
24
|
+
hierarchyDelimiter: "/",
|
|
25
|
+
messageCount: 4821,
|
|
26
|
+
},
|
|
22
27
|
{
|
|
23
28
|
mailboxId: "mb-archive",
|
|
24
29
|
providerPath: "INBOX/Archive",
|
|
30
|
+
hierarchyDelimiter: "/",
|
|
25
31
|
messageCount: 19243,
|
|
26
32
|
},
|
|
27
33
|
{
|
|
28
34
|
mailboxId: "mb-concepten",
|
|
29
35
|
providerPath: "INBOX/Concepten",
|
|
36
|
+
hierarchyDelimiter: "/",
|
|
30
37
|
messageCount: 340,
|
|
31
38
|
},
|
|
32
39
|
{
|
|
33
40
|
mailboxId: "mb-deleted",
|
|
34
41
|
providerPath: "INBOX/Deleted Messages",
|
|
42
|
+
hierarchyDelimiter: "/",
|
|
35
43
|
messageCount: 512,
|
|
36
44
|
},
|
|
37
|
-
{
|
|
45
|
+
{
|
|
46
|
+
mailboxId: "mb-drafts",
|
|
47
|
+
providerPath: "INBOX/Drafts",
|
|
48
|
+
hierarchyDelimiter: "/",
|
|
49
|
+
messageCount: 0,
|
|
50
|
+
},
|
|
38
51
|
{
|
|
39
52
|
mailboxId: "mb-news",
|
|
40
53
|
providerPath: "INBOX/Nieuwsbrieven",
|
|
54
|
+
hierarchyDelimiter: "/",
|
|
41
55
|
messageCount: 2870,
|
|
42
56
|
},
|
|
43
|
-
{
|
|
57
|
+
{
|
|
58
|
+
mailboxId: "mb-sent",
|
|
59
|
+
providerPath: "INBOX/Sent",
|
|
60
|
+
hierarchyDelimiter: "/",
|
|
61
|
+
messageCount: 0,
|
|
62
|
+
},
|
|
44
63
|
{
|
|
45
64
|
mailboxId: "mb-sent-messages",
|
|
46
65
|
providerPath: "INBOX/Sent Messages",
|
|
66
|
+
hierarchyDelimiter: "/",
|
|
47
67
|
messageCount: 6105,
|
|
48
68
|
},
|
|
49
|
-
{
|
|
69
|
+
{
|
|
70
|
+
mailboxId: "mb-spam",
|
|
71
|
+
providerPath: "INBOX/Spam",
|
|
72
|
+
hierarchyDelimiter: "/",
|
|
73
|
+
messageCount: 88,
|
|
74
|
+
},
|
|
50
75
|
];
|
|
51
76
|
|
|
52
77
|
const appointed = (mailboxId: string): RoleAppointment => ({
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { Check, Folder } from "lucide-react";
|
|
2
2
|
import { useId, useRef, useState } from "react";
|
|
3
|
+
import { folderLeaf } from "../lib/folder-tree.js";
|
|
3
4
|
import { Banner } from "./banner.js";
|
|
4
5
|
import { Button } from "./button.js";
|
|
5
6
|
import {
|
|
6
7
|
canonicalRoleLabel,
|
|
7
8
|
type FolderRole,
|
|
8
|
-
providerLeaf,
|
|
9
9
|
roleIcon,
|
|
10
10
|
} from "./folder-role.js";
|
|
11
11
|
import { Input } from "./input.js";
|
|
@@ -35,6 +35,8 @@ export interface CandidateFolder {
|
|
|
35
35
|
mailboxId: string;
|
|
36
36
|
/** Server truth — the IMAP path. Read-only. */
|
|
37
37
|
providerPath: string;
|
|
38
|
+
/** How the folder's own server separates path segments; `""` means flat. */
|
|
39
|
+
hierarchyDelimiter: string;
|
|
38
40
|
/** Live message count, so the user can pick the folder that holds mail. */
|
|
39
41
|
messageCount: number;
|
|
40
42
|
}
|
|
@@ -64,7 +66,8 @@ const UNRESOLVED: RoleAppointment = { mailboxId: null, source: "None" };
|
|
|
64
66
|
/** Picker option text: `Concepten · 340 msgs`. */
|
|
65
67
|
function folderOptionLabel(folder: CandidateFolder): string {
|
|
66
68
|
const noun = folder.messageCount === 1 ? "msg" : "msgs";
|
|
67
|
-
|
|
69
|
+
const leaf = folderLeaf(folder.providerPath, folder.hierarchyDelimiter);
|
|
70
|
+
return `${leaf} · ${folder.messageCount} ${noun}`;
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
const messages = (count: number): string =>
|
|
@@ -151,7 +154,10 @@ function RoleAppointmentRow({
|
|
|
151
154
|
role === "trash"
|
|
152
155
|
? " Deleting mail is stopped until you pick another one."
|
|
153
156
|
: appointed
|
|
154
|
-
? ` reader is using ${
|
|
157
|
+
? ` reader is using ${folderLeaf(
|
|
158
|
+
appointed.providerPath,
|
|
159
|
+
appointed.hierarchyDelimiter,
|
|
160
|
+
)} instead.`
|
|
155
161
|
: "";
|
|
156
162
|
const staleNotice = `The folder you chose for ${label}${
|
|
157
163
|
staleFolderPath ? ` — ${staleFolderPath} —` : ""
|
|
@@ -312,7 +318,7 @@ export function RoleAppointmentList({
|
|
|
312
318
|
>
|
|
313
319
|
<Folder className="size-4 shrink-0 text-fg-subtle" />
|
|
314
320
|
<span className="truncate">
|
|
315
|
-
{
|
|
321
|
+
{folderLeaf(folder.providerPath, folder.hierarchyDelimiter)}
|
|
316
322
|
</span>
|
|
317
323
|
<span className="ml-auto shrink-0 text-2xs text-fg-subtle">
|
|
318
324
|
{messages(folder.messageCount)}
|
package/src/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
filterFolderTree,
|
|
7
7
|
folderAncestors,
|
|
8
8
|
folderDepth,
|
|
9
|
+
folderLeaf,
|
|
9
10
|
folderParent,
|
|
10
11
|
matchesQuery,
|
|
11
12
|
orderFolderNodes,
|
|
@@ -279,3 +280,21 @@ describe("create rows", () => {
|
|
|
279
280
|
]);
|
|
280
281
|
});
|
|
281
282
|
});
|
|
283
|
+
|
|
284
|
+
describe("folderLeaf", () => {
|
|
285
|
+
it("takes the leaf under a dot-delimited namespace", () => {
|
|
286
|
+
assert.equal(folderLeaf("INBOX.Projects.Q3", "."), "Q3");
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("takes the leaf under a slash-delimited namespace", () => {
|
|
290
|
+
assert.equal(folderLeaf("INBOX/Sent Messages", "/"), "Sent Messages");
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("keeps the whole name when the server reports no delimiter", () => {
|
|
294
|
+
assert.equal(folderLeaf("Projects/Q3", ""), "Projects/Q3");
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("keeps a top-level name that carries no delimiter", () => {
|
|
298
|
+
assert.equal(folderLeaf("INBOX", "."), "INBOX");
|
|
299
|
+
});
|
|
300
|
+
});
|
package/src/lib/folder-tree.ts
CHANGED
|
@@ -38,6 +38,14 @@ export type FolderTreeDisplayRow =
|
|
|
38
38
|
| { kind: "folder"; row: FolderTreeRow; index: number }
|
|
39
39
|
| { kind: "create"; parent: FolderTreeNode; depth: number };
|
|
40
40
|
|
|
41
|
+
// A server that reports no delimiter has a flat namespace, so the path is its
|
|
42
|
+
// own leaf; splitting on "" would return single characters.
|
|
43
|
+
export const folderLeaf = (path: string, delimiter: string): string => {
|
|
44
|
+
if (delimiter.length === 0) return path;
|
|
45
|
+
const parts = path.split(delimiter);
|
|
46
|
+
return parts[parts.length - 1] || path;
|
|
47
|
+
};
|
|
48
|
+
|
|
41
49
|
export const folderParent = (path: string, delimiter: string): string => {
|
|
42
50
|
const cut = path.lastIndexOf(delimiter);
|
|
43
51
|
return cut === -1 ? "" : path.slice(0, cut);
|