@remit/ui 0.0.154 → 0.0.156

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.
@@ -10,18 +10,55 @@ describe("provenanceFolderLabel", () => {
10
10
 
11
11
  it("reads a junk appointment as Spam whatever the server calls it", () => {
12
12
  assert.equal(
13
- provenanceFolderLabel({ role: "junk", providerPath: "Bulk Mail" }),
13
+ provenanceFolderLabel({
14
+ role: "junk",
15
+ providerPath: "Bulk Mail",
16
+ hierarchyDelimiter: "/",
17
+ }),
14
18
  "Spam",
15
19
  );
16
20
  });
17
21
 
18
22
  it("falls back to the leaf of a folder nobody appointed", () => {
19
23
  assert.equal(
20
- provenanceFolderLabel({ providerPath: "Projects/Bookkeeping" }),
24
+ provenanceFolderLabel({
25
+ providerPath: "Projects/Bookkeeping",
26
+ hierarchyDelimiter: "/",
27
+ }),
21
28
  "Bookkeeping",
22
29
  );
23
30
  });
24
31
 
32
+ it("cuts the path on the delimiter the server reports, not on a slash", () => {
33
+ assert.equal(
34
+ provenanceFolderLabel({
35
+ providerPath: "INBOX.Projects.Q3",
36
+ hierarchyDelimiter: ".",
37
+ }),
38
+ "Q3",
39
+ );
40
+ });
41
+
42
+ it("leaves a slash alone in a folder name on a dotted server", () => {
43
+ assert.equal(
44
+ provenanceFolderLabel({
45
+ providerPath: "INBOX.Reading/Writing",
46
+ hierarchyDelimiter: ".",
47
+ }),
48
+ "Reading/Writing",
49
+ );
50
+ });
51
+
52
+ it("treats a flat namespace as one folder name", () => {
53
+ assert.equal(
54
+ provenanceFolderLabel({
55
+ providerPath: "Projects/Q3",
56
+ hierarchyDelimiter: "",
57
+ }),
58
+ "Projects/Q3",
59
+ );
60
+ });
61
+
25
62
  it("refuses to label a view rather than a place", () => {
26
63
  assert.equal(provenanceFolderLabel({ role: "all" }), undefined);
27
64
  assert.equal(provenanceFolderLabel({ role: "flagged" }), undefined);
@@ -29,28 +66,43 @@ describe("provenanceFolderLabel", () => {
29
66
 
30
67
  it("refuses to label Gmail's own reserved namespace", () => {
31
68
  assert.equal(
32
- provenanceFolderLabel({ providerPath: "[Gmail]/All Mail" }),
69
+ provenanceFolderLabel({
70
+ providerPath: "[Gmail]/All Mail",
71
+ hierarchyDelimiter: "/",
72
+ }),
33
73
  undefined,
34
74
  );
35
75
  assert.equal(
36
- provenanceFolderLabel({ providerPath: "[Gmail]/Starred" }),
76
+ provenanceFolderLabel({
77
+ providerPath: "[Gmail]/Starred",
78
+ hierarchyDelimiter: "/",
79
+ }),
37
80
  undefined,
38
81
  );
39
82
  });
40
83
 
41
84
  it("refuses the googlemail.com spelling of the same namespace", () => {
42
85
  assert.equal(
43
- provenanceFolderLabel({ providerPath: "[Google Mail]/All Mail" }),
86
+ provenanceFolderLabel({
87
+ providerPath: "[Google Mail]/All Mail",
88
+ hierarchyDelimiter: "/",
89
+ }),
44
90
  undefined,
45
91
  );
46
92
  assert.equal(
47
- provenanceFolderLabel({ providerPath: "[Google Mail]/Starred" }),
93
+ provenanceFolderLabel({
94
+ providerPath: "[Google Mail]/Starred",
95
+ hierarchyDelimiter: "/",
96
+ }),
48
97
  undefined,
49
98
  );
50
99
  });
51
100
 
52
101
  it("labels a user folder that merely mentions Gmail", () => {
53
- assert.equal(provenanceFolderLabel({ providerPath: "Gmail" }), "Gmail");
102
+ assert.equal(
103
+ provenanceFolderLabel({ providerPath: "Gmail", hierarchyDelimiter: "/" }),
104
+ "Gmail",
105
+ );
54
106
  });
55
107
 
56
108
  it("has nothing to say about a folder it knows nothing about", () => {
@@ -9,6 +9,7 @@ import {
9
9
  Trash2,
10
10
  } from "lucide-react";
11
11
  import type { ReactNode } from "react";
12
+ import { folderLeaf, folderPathSegments } from "../lib/folder-tree.js";
12
13
 
13
14
  /* ------------------------------------------------------------------ */
14
15
  /* Folder role vocabulary (RFC 032 exclusive-folder-appointment, */
@@ -48,30 +49,28 @@ export function canonicalRoleLabel(role: FolderRole): string {
48
49
  return ROLE_LABEL[role];
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
- */
56
- export function providerLeaf(providerPath: string): string {
57
- const parts = providerPath.split("/");
58
- return parts[parts.length - 1] || providerPath;
59
- }
60
-
61
52
  /* ------------------------------------------------------------------ */
62
53
  /* Provenance: where a search result actually lives */
63
54
  /* ------------------------------------------------------------------ */
64
55
 
65
56
  /**
66
57
  * The folder a search result was read from. `role` is the account's IMAP
67
- * special-use appointment (`junk` is `\Junk`); accounts that expose a folder
68
- * nobody appointed carry only a `providerPath`.
58
+ * special-use appointment (`junk` is `\Junk`); a folder nobody appointed a role
59
+ * to carries only its path.
60
+ *
61
+ * The path and the separator its own server cuts it on travel together or not
62
+ * at all: `INBOX.Projects.Q3` is three segments on a `.`-delimited server and a
63
+ * single folder name on a `/`-delimited one, so a path alone cannot be cut.
69
64
  */
70
- export interface ResultFolder {
71
- role?: FolderRole;
72
- /** Provider path as the server spells it, e.g. `Projects/Bookkeeping`. */
73
- providerPath?: string;
74
- }
65
+ export type ResultFolder =
66
+ | {
67
+ role?: FolderRole;
68
+ /** Provider path as the server spells it, e.g. `Projects/Bookkeeping`. */
69
+ providerPath: string;
70
+ /** The account's own hierarchy separator; `""` means a flat namespace. */
71
+ hierarchyDelimiter: string;
72
+ }
73
+ | { role?: FolderRole; providerPath?: never; hierarchyDelimiter?: never };
75
74
 
76
75
  /**
77
76
  * Roles that name a view rather than a place a message is filed. A message in
@@ -113,8 +112,12 @@ export function provenanceFolderLabel(
113
112
  return canonicalRoleLabel(folder.role);
114
113
  }
115
114
  if (!folder.providerPath) return undefined;
116
- if (GMAIL_NAMESPACES.has(folder.providerPath.split("/")[0])) return undefined;
117
- return providerLeaf(folder.providerPath);
115
+ const segments = folderPathSegments(
116
+ folder.providerPath,
117
+ folder.hierarchyDelimiter,
118
+ );
119
+ if (GMAIL_NAMESPACES.has(segments[0])) return undefined;
120
+ return folderLeaf(folder.providerPath, folder.hierarchyDelimiter);
118
121
  }
119
122
 
120
123
  export function roleIcon(role: FolderRole): ReactNode {
@@ -123,7 +123,7 @@ const crossFolderMatches: SearchResult[] = [
123
123
  subject: "Invoices for the quarter",
124
124
  snippet: "The quarterly set, filed with the rest of the bookkeeping.",
125
125
  date: "Jan 30",
126
- folder: { providerPath: "Projects/Bookkeeping" },
126
+ folder: { providerPath: "Projects/Bookkeeping", hierarchyDelimiter: "/" },
127
127
  },
128
128
  ];
129
129
 
@@ -1,7 +1,8 @@
1
1
  import { Bug } from "lucide-react";
2
+ import { folderLeaf } from "../lib/folder-tree.js";
2
3
  import { Badge } from "./badge.js";
3
4
  import { Button } from "./button.js";
4
- import { canonicalRoleLabel, providerLeaf } from "./folder-role.js";
5
+ import { canonicalRoleLabel } from "./folder-role.js";
5
6
  import {
6
7
  type QuarantineEntry,
7
8
  quarantineSummary,
@@ -47,7 +48,7 @@ export function QuarantineEntryRow({
47
48
  </Badge>
48
49
  )}
49
50
  <span className="truncate" title={entry.mailboxPath}>
50
- {providerLeaf(entry.mailboxPath)}
51
+ {folderLeaf(entry.mailboxPath, entry.mailboxDelimiter)}
51
52
  </span>
52
53
  <span aria-hidden>·</span>
53
54
  <span>{`uid ${entry.uid}`}</span>
@@ -14,6 +14,7 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
14
14
  uid: 40217,
15
15
  mailboxRole: "inbox",
16
16
  mailboxPath: "INBOX",
17
+ mailboxDelimiter: "/",
17
18
  failureStage: "BodyParse",
18
19
  failureCode: "UnreadableBody",
19
20
  failureMessage: "multipart boundary was never closed",
@@ -42,6 +43,7 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
42
43
  uid: 40219,
43
44
  mailboxRole: "archive",
44
45
  mailboxPath: "Archive/2026",
46
+ mailboxDelimiter: "/",
45
47
  failureStage: "BodyParse",
46
48
  failureCode: "UnknownCharset",
47
49
  failureMessage: "declared charset is not a known encoding",
@@ -65,7 +67,9 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
65
67
  // A folder the user never appointed a role to — the ordinary state of a
66
68
  // plain folder, and the reason mailboxRole is optional.
67
69
  mailboxRole: undefined,
68
- mailboxPath: "Clients/Acme Holdings",
70
+ mailboxPath: "INBOX.Clients.Acme Holdings",
71
+ // A `.`-delimited server: the row still reads the leaf, not the path.
72
+ mailboxDelimiter: ".",
69
73
  failureStage: "BodyParse",
70
74
  failureCode: "UnreadableBody",
71
75
  failureMessage: "stream ended before the declared body length",
@@ -91,6 +95,7 @@ export const quarantineDemoEntries: readonly QuarantineEntry[] = [
91
95
  uid: 40263,
92
96
  mailboxRole: "inbox",
93
97
  mailboxPath: "INBOX",
98
+ mailboxDelimiter: "/",
94
99
  failureStage: "BodyParse",
95
100
  failureCode: "UnreadableBody",
96
101
  failureMessage: "connection closed mid-body",
@@ -78,6 +78,12 @@ export interface QuarantineEntry {
78
78
  mailboxRole?: FolderRole;
79
79
  /** The user's own folder name. Shown on screen, withheld from the report. */
80
80
  mailboxPath: string;
81
+ /**
82
+ * The separator that folder's own server cuts its paths on, so the row can
83
+ * show the leaf. Resolved from the account's mailbox list — the quarantine
84
+ * record itself carries no delimiter. `""` means a flat namespace.
85
+ */
86
+ mailboxDelimiter: string;
81
87
  failureStage: QuarantineFailureStage;
82
88
  failureCode: QuarantineFailureCode;
83
89
  /** Parser error text. Shown on screen, never in the report. */
@@ -44,6 +44,19 @@ describe("QuarantineSection", () => {
44
44
  assert.match(html, /Acme Holdings/);
45
45
  });
46
46
 
47
+ it("shows the folder name of a dotted path, not the whole path", () => {
48
+ const html = render([
49
+ {
50
+ ...base,
51
+ mailboxPath: "INBOX.Projects.Q3",
52
+ mailboxDelimiter: ".",
53
+ mailboxRole: undefined,
54
+ },
55
+ ]);
56
+ assert.match(html, />Q3</);
57
+ assert.doesNotMatch(html, />INBOX\.Projects\.Q3</);
58
+ });
59
+
47
60
  it("offers reporting as the only per-row action", () => {
48
61
  const html = render([base]);
49
62
  assert.match(html, /Cut a bug/);
@@ -235,7 +235,10 @@ describe("SearchResults provenance labels", () => {
235
235
  {
236
236
  ...result,
237
237
  id: "c1",
238
- folder: { providerPath: "Projects/Books" },
238
+ folder: {
239
+ providerPath: "Projects/Books",
240
+ hierarchyDelimiter: "/",
241
+ },
239
242
  },
240
243
  ],
241
244
  },
@@ -274,7 +277,10 @@ describe("SearchResults provenance labels", () => {
274
277
  {
275
278
  ...result,
276
279
  id: "v3",
277
- folder: { providerPath: "[Gmail]/Important" },
280
+ folder: {
281
+ providerPath: "[Gmail]/Important",
282
+ hierarchyDelimiter: "/",
283
+ },
278
284
  },
279
285
  ],
280
286
  },
@@ -112,7 +112,7 @@ const crossFolderMatches: SearchResult[] = [
112
112
  subject: "Invoices for the quarter",
113
113
  snippet: "The quarterly set, filed with the rest of the bookkeeping.",
114
114
  date: "Jan 30",
115
- folder: { providerPath: "Projects/Bookkeeping" },
115
+ folder: { providerPath: "Projects/Bookkeeping", hierarchyDelimiter: "/" },
116
116
  },
117
117
  ];
118
118
 
@@ -355,7 +355,10 @@ export const VirtualFoldersGoUnlabelled: Story = {
355
355
  {
356
356
  ...topMatches[2],
357
357
  id: "v3",
358
- folder: { providerPath: "[Gmail]/Important" },
358
+ folder: {
359
+ providerPath: "[Gmail]/Important",
360
+ hierarchyDelimiter: "/",
361
+ },
359
362
  },
360
363
  ...crossFolderMatches.slice(0, 1),
361
364
  ],
package/src/index.ts CHANGED
@@ -11,6 +11,29 @@ export {
11
11
  type EnvelopeAddress,
12
12
  } from "./components/address-display.js";
13
13
  export { AddressTag, type AddressTagProps } from "./components/address-tag.js";
14
+ export {
15
+ AgendaComposer,
16
+ type AgendaComposerProps,
17
+ AgendaPhraseField,
18
+ type AgendaPhraseFieldProps,
19
+ PhraseReading,
20
+ } from "./components/agenda-composer.js";
21
+ export {
22
+ type AgendaDensity,
23
+ AgendaFlow,
24
+ type AgendaFlowProps,
25
+ type AgendaScrollTarget,
26
+ } from "./components/agenda-flow.js";
27
+ export {
28
+ AgendaDensityControl,
29
+ type AgendaDensityControlProps,
30
+ FreeTimeList,
31
+ type FreeTimeListProps,
32
+ NextUpCard,
33
+ type NextUpCardProps,
34
+ PositionMap,
35
+ type PositionMapProps,
36
+ } from "./components/agenda-panels.js";
14
37
  export {
15
38
  AppPasswordHint,
16
39
  type AppPasswordHintProps,
@@ -137,14 +160,20 @@ export {
137
160
  segmentClassName,
138
161
  } from "./components/calendar-toolbar.js";
139
162
  export {
163
+ type AgendaParse,
140
164
  type CalendarAttendee,
141
165
  type CalendarColorId,
166
+ type CalendarDay,
142
167
  type CalendarDescriptor,
143
168
  type CalendarEventData,
169
+ type CalendarSlotPick,
144
170
  type CalendarViewId,
171
+ type ChoicePicks,
145
172
  calendarColorIds,
146
173
  type EventDraft,
147
174
  type EventSuggestion,
175
+ type PhraseChoice,
176
+ type PhraseChoiceOption,
148
177
  type RecurrenceScope,
149
178
  type RsvpState,
150
179
  type ZoneCertainty,
@@ -354,7 +383,6 @@ export {
354
383
  type FolderRole,
355
384
  isVirtualFolderRole,
356
385
  provenanceFolderLabel,
357
- providerLeaf,
358
386
  type ResultFolder,
359
387
  roleIcon,
360
388
  } from "./components/folder-role.js";
@@ -798,6 +826,41 @@ export {
798
826
  sanitizeAdoptedHtml,
799
827
  sanitizeQuotedHtml,
800
828
  } from "./lib/adopted-html.js";
829
+ export {
830
+ type AgendaRow,
831
+ addDays,
832
+ type BusySpan,
833
+ buildAgendaRows,
834
+ buildCalendarDay,
835
+ busyMinutesOf,
836
+ busySpansOn,
837
+ type ClashOptions,
838
+ clashesWith,
839
+ conflictsOf,
840
+ DAY_END_MINUTE,
841
+ DAY_START_MINUTE,
842
+ datesBetween,
843
+ FREE_MINUTES,
844
+ type FreeStretch,
845
+ formatMinute,
846
+ formatRunLabel,
847
+ formatShortDay,
848
+ formatSpan,
849
+ freeAhead,
850
+ freeStretchesOn,
851
+ groupOverlapping,
852
+ isClearDay,
853
+ isEmptyDay,
854
+ minuteOfDay,
855
+ monthLabel,
856
+ type NextUp,
857
+ readNextUp,
858
+ shortMonthLabel,
859
+ type WallSpan,
860
+ wallSpanOn,
861
+ weekdayLongLabel,
862
+ weekdayShortLabel,
863
+ } from "./lib/agenda-time.js";
801
864
  export {
802
865
  DEFAULT_ATTACHMENT_FILENAME,
803
866
  formatByteSize,