@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.139",
3
+ "version": "0.0.141",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -48,7 +48,11 @@ export function canonicalRoleLabel(role: FolderRole): string {
48
48
  return ROLE_LABEL[role];
49
49
  }
50
50
 
51
- /** Leaf segment of a provider path (`INBOX/Spam` → `Spam`). */
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
- { mailboxId: "mb-inbox", providerPath: "INBOX", messageCount: 4821 },
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
- { mailboxId: "mb-drafts", providerPath: "INBOX/Drafts", messageCount: 0 },
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
- { mailboxId: "mb-inbox", providerPath: "INBOX", messageCount: 4821 },
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
- { mailboxId: "mb-drafts", providerPath: "INBOX/Drafts", messageCount: 0 },
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
- { mailboxId: "mb-sent", providerPath: "INBOX/Sent", messageCount: 0 },
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
- { mailboxId: "mb-spam", providerPath: "INBOX/Spam", messageCount: 88 },
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
- return `${providerLeaf(folder.providerPath)} · ${folder.messageCount} ${noun}`;
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 ${providerLeaf(appointed.providerPath)} instead.`
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
- {providerLeaf(folder.providerPath)}
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
@@ -851,6 +851,7 @@ export {
851
851
  filterFolderTree,
852
852
  folderAncestors,
853
853
  folderDepth,
854
+ folderLeaf,
854
855
  folderParent,
855
856
  matchesQuery,
856
857
  orderFolderNodes,
@@ -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
+ });
@@ -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 wait(ms: number) {
110
- return act(() => new Promise((resolve) => setTimeout(resolve, ms)));
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 wait(THRESHOLD + 40);
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 wait(THRESHOLD / 2);
162
+ await advance(THRESHOLD - 1);
142
163
  pointerUp();
143
- await wait(THRESHOLD + 40);
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 wait(THRESHOLD / 2);
177
+ await advance(THRESHOLD - 1);
157
178
  pointerCancel(row);
158
- await wait(THRESHOLD + 40);
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 wait(THRESHOLD + 40);
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 wait(THRESHOLD + 40);
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 wait(THRESHOLD);
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 wait
242
- // clears react-aria's own transient post-touch contextmenu listener,
243
- // which it removes shortly after pointerup — in a browser a keyboard menu
244
- // arrives long after that window, so only this hook's ref decides.
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 wait(THRESHOLD);
270
+ await advance(AFTER_ARIA_CONTEXTMENU_TEARDOWN);
250
271
 
251
272
  assert.equal(dispatchContextMenu(row).defaultPrevented, false);
252
273
  });