@remit/ui 0.0.139 → 0.0.141
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/src/lib/use-long-press.test.ts +37 -16
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);
|
|
@@ -12,17 +12,25 @@
|
|
|
12
12
|
* need a real `document`/`window`/`PointerEvent`, which `renderToString`
|
|
13
13
|
* (the pattern used elsewhere in this repo for presentational components)
|
|
14
14
|
* cannot exercise.
|
|
15
|
+
*
|
|
16
|
+
* The clock is mocked. Every timing assertion here is about one boundary —
|
|
17
|
+
* the press crossed the threshold, or it ended first — and racing that
|
|
18
|
+
* boundary against a wall clock on a loaded runner turns a passing test red
|
|
19
|
+
* (#645). Time only moves when `advance` moves it.
|
|
15
20
|
*/
|
|
16
21
|
|
|
17
22
|
import "@remit/test-dom";
|
|
18
23
|
import assert from "node:assert/strict";
|
|
19
|
-
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
24
|
+
import { afterEach, beforeEach, describe, it, mock } from "node:test";
|
|
20
25
|
import { act, createElement } from "react";
|
|
21
26
|
import { createRoot, type Root } from "react-dom/client";
|
|
22
27
|
import { useLongPress } from "./use-long-press.js";
|
|
23
28
|
|
|
24
29
|
const THRESHOLD = 40;
|
|
25
30
|
|
|
31
|
+
/** Past react-aria's own teardown of its transient post-pointerup contextmenu listener. */
|
|
32
|
+
const AFTER_ARIA_CONTEXTMENU_TEARDOWN = 100;
|
|
33
|
+
|
|
26
34
|
let container: HTMLElement;
|
|
27
35
|
let root: Root;
|
|
28
36
|
|
|
@@ -106,17 +114,27 @@ function pointerCancel(row: Element) {
|
|
|
106
114
|
row.dispatchEvent(new PointerEvent("pointercancel", { bubbles: true }));
|
|
107
115
|
}
|
|
108
116
|
|
|
109
|
-
function
|
|
110
|
-
return act(() =>
|
|
117
|
+
function advance(ms: number) {
|
|
118
|
+
return act(() => {
|
|
119
|
+
mock.timers.tick(ms);
|
|
120
|
+
});
|
|
111
121
|
}
|
|
112
122
|
|
|
113
123
|
beforeEach(() => {
|
|
114
124
|
container = document.getElementById("root") as unknown as HTMLElement;
|
|
115
125
|
container.innerHTML = "";
|
|
116
126
|
root = createRoot(container);
|
|
127
|
+
mock.timers.enable({ apis: ["setTimeout"] });
|
|
117
128
|
});
|
|
118
129
|
|
|
119
130
|
afterEach(() => {
|
|
131
|
+
// The hook's contextmenu arming is module state bounded by a timer, and a
|
|
132
|
+
// test that leaves the finger down leaves it armed. Release the pointer so
|
|
133
|
+
// the next test starts disarmed rather than inheriting a dead timer id.
|
|
134
|
+
act(() => {
|
|
135
|
+
pointerUp();
|
|
136
|
+
});
|
|
137
|
+
mock.timers.reset();
|
|
120
138
|
act(() => {
|
|
121
139
|
root.unmount();
|
|
122
140
|
});
|
|
@@ -128,7 +146,10 @@ describe("useLongPress (react-aria wrapper)", () => {
|
|
|
128
146
|
const row = mount({ onLongPress: () => fired++ });
|
|
129
147
|
|
|
130
148
|
pointerDown(row);
|
|
131
|
-
await
|
|
149
|
+
await advance(THRESHOLD - 1);
|
|
150
|
+
assert.equal(fired, 0, "the threshold had not elapsed yet");
|
|
151
|
+
|
|
152
|
+
await advance(1);
|
|
132
153
|
|
|
133
154
|
assert.equal(fired, 1);
|
|
134
155
|
});
|
|
@@ -138,9 +159,9 @@ describe("useLongPress (react-aria wrapper)", () => {
|
|
|
138
159
|
const row = mount({ onLongPress: () => fired++ });
|
|
139
160
|
|
|
140
161
|
pointerDown(row);
|
|
141
|
-
await
|
|
162
|
+
await advance(THRESHOLD - 1);
|
|
142
163
|
pointerUp();
|
|
143
|
-
await
|
|
164
|
+
await advance(THRESHOLD);
|
|
144
165
|
|
|
145
166
|
assert.equal(fired, 0);
|
|
146
167
|
});
|
|
@@ -153,9 +174,9 @@ describe("useLongPress (react-aria wrapper)", () => {
|
|
|
153
174
|
const row = mount({ onLongPress: () => fired++ });
|
|
154
175
|
|
|
155
176
|
pointerDown(row);
|
|
156
|
-
await
|
|
177
|
+
await advance(THRESHOLD - 1);
|
|
157
178
|
pointerCancel(row);
|
|
158
|
-
await
|
|
179
|
+
await advance(THRESHOLD);
|
|
159
180
|
|
|
160
181
|
assert.equal(fired, 0);
|
|
161
182
|
});
|
|
@@ -165,7 +186,7 @@ describe("useLongPress (react-aria wrapper)", () => {
|
|
|
165
186
|
const row = mount({ onLongPress: () => fired++, isDisabled: true });
|
|
166
187
|
|
|
167
188
|
pointerDown(row);
|
|
168
|
-
await
|
|
189
|
+
await advance(THRESHOLD);
|
|
169
190
|
|
|
170
191
|
assert.equal(fired, 0);
|
|
171
192
|
});
|
|
@@ -175,7 +196,7 @@ describe("useLongPress (react-aria wrapper)", () => {
|
|
|
175
196
|
const row = mount({ onLongPress: () => fired++ });
|
|
176
197
|
|
|
177
198
|
pointerDown(row);
|
|
178
|
-
await
|
|
199
|
+
await advance(THRESHOLD);
|
|
179
200
|
assert.equal(
|
|
180
201
|
fired,
|
|
181
202
|
1,
|
|
@@ -228,7 +249,7 @@ describe("useLongPress (react-aria wrapper)", () => {
|
|
|
228
249
|
"the touch long-press menu is still suppressed",
|
|
229
250
|
);
|
|
230
251
|
pointerUpOn(row);
|
|
231
|
-
await
|
|
252
|
+
await advance(AFTER_ARIA_CONTEXTMENU_TEARDOWN);
|
|
232
253
|
|
|
233
254
|
assert.equal(
|
|
234
255
|
dispatchContextMenu(row).defaultPrevented,
|
|
@@ -238,15 +259,15 @@ describe("useLongPress (react-aria wrapper)", () => {
|
|
|
238
259
|
});
|
|
239
260
|
|
|
240
261
|
it("does not suppress the keyboard menu after a touch tap that raised no menu", async () => {
|
|
241
|
-
// A tap that lifts without a menu must still disarm suppression. The
|
|
242
|
-
// clears react-aria's own transient post-touch contextmenu
|
|
243
|
-
// which it removes shortly after pointerup — in a browser a
|
|
244
|
-
// arrives long after that
|
|
262
|
+
// A tap that lifts without a menu must still disarm suppression. The
|
|
263
|
+
// advance clears react-aria's own transient post-touch contextmenu
|
|
264
|
+
// listener, which it removes shortly after pointerup — in a browser a
|
|
265
|
+
// keyboard menu arrives long after that, so only this hook's ref decides.
|
|
245
266
|
const row = mount({ onLongPress: () => undefined });
|
|
246
267
|
|
|
247
268
|
pointerDown(row, "touch");
|
|
248
269
|
pointerUpOn(row);
|
|
249
|
-
await
|
|
270
|
+
await advance(AFTER_ARIA_CONTEXTMENU_TEARDOWN);
|
|
250
271
|
|
|
251
272
|
assert.equal(dispatchContextMenu(row).defaultPrevented, false);
|
|
252
273
|
});
|