@rapidmx/web-client 0.24.0 → 0.26.0

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.
Files changed (74) hide show
  1. package/apps/shared/components/admin/layout/AdminShell.tsx +39 -4
  2. package/apps/shared/components/admin/layout/railPreference.ts +25 -0
  3. package/apps/shared/components/calendar/CalendarListSidebar.tsx +6 -2
  4. package/apps/shared/components/calendar/EventDetails.tsx +5 -2
  5. package/apps/shared/components/contacts/ContactCard.tsx +330 -0
  6. package/apps/shared/components/contacts/ContactCardProvider.tsx +72 -0
  7. package/apps/shared/components/contacts/ParticipantLink.tsx +42 -0
  8. package/apps/shared/components/contacts/contactCardData.ts +220 -0
  9. package/apps/shared/components/contacts/participantDetails.ts +169 -0
  10. package/apps/shared/components/layout/AppShell.tsx +4 -0
  11. package/apps/shared/components/layout/ThemeSwitch.tsx +3 -2
  12. package/apps/shared/components/layout/UserMenu.tsx +30 -10
  13. package/apps/shared/components/mail/InviteCard.tsx +27 -5
  14. package/apps/shared/components/mail/MailAddress.tsx +8 -3
  15. package/apps/shared/components/mail/MessageDetailPane.tsx +63 -18
  16. package/apps/shared/components/mail/VCardAttachmentChip.tsx +85 -0
  17. package/apps/shared/components/mail/compose/ComposeWindow.tsx +112 -1
  18. package/apps/shared/components/mail/compose/RecipientInput.tsx +1 -0
  19. package/apps/shared/components/mail/compose/RichTextEditor.tsx +15 -2
  20. package/apps/shared/components/mail/compose/dropFiles.ts +21 -0
  21. package/apps/shared/components/mail/layout/MailShell.tsx +13 -5
  22. package/apps/shared/components/mail/layout/folderIcons.tsx +54 -0
  23. package/apps/shared/components/settings/layout/SettingsShell.tsx +32 -13
  24. package/apps/shared/navigation/appHrefs.ts +3 -3
  25. package/dist/apps/shared/components/admin/layout/AdminShell.js +21 -4
  26. package/dist/apps/shared/components/admin/layout/railPreference.d.ts +6 -0
  27. package/dist/apps/shared/components/admin/layout/railPreference.js +24 -0
  28. package/dist/apps/shared/components/calendar/CalendarListSidebar.js +3 -2
  29. package/dist/apps/shared/components/calendar/EventDetails.js +2 -1
  30. package/dist/apps/shared/components/contacts/ContactCard.d.ts +19 -0
  31. package/dist/apps/shared/components/contacts/ContactCard.js +132 -0
  32. package/dist/apps/shared/components/contacts/ContactCardProvider.d.ts +25 -0
  33. package/dist/apps/shared/components/contacts/ContactCardProvider.js +41 -0
  34. package/dist/apps/shared/components/contacts/ParticipantLink.d.ts +18 -0
  35. package/dist/apps/shared/components/contacts/ParticipantLink.js +16 -0
  36. package/dist/apps/shared/components/contacts/contactCardData.d.ts +69 -0
  37. package/dist/apps/shared/components/contacts/contactCardData.js +158 -0
  38. package/dist/apps/shared/components/contacts/participantDetails.d.ts +64 -0
  39. package/dist/apps/shared/components/contacts/participantDetails.js +129 -0
  40. package/dist/apps/shared/components/layout/AppShell.js +11 -10
  41. package/dist/apps/shared/components/layout/ThemeSwitch.js +2 -2
  42. package/dist/apps/shared/components/layout/UserMenu.d.ts +1 -2
  43. package/dist/apps/shared/components/layout/UserMenu.js +4 -4
  44. package/dist/apps/shared/components/mail/InviteCard.js +5 -3
  45. package/dist/apps/shared/components/mail/MailAddress.d.ts +5 -2
  46. package/dist/apps/shared/components/mail/MailAddress.js +5 -3
  47. package/dist/apps/shared/components/mail/MessageDetailPane.js +19 -4
  48. package/dist/apps/shared/components/mail/VCardAttachmentChip.d.ts +18 -0
  49. package/dist/apps/shared/components/mail/VCardAttachmentChip.js +53 -0
  50. package/dist/apps/shared/components/mail/compose/ComposeWindow.js +81 -4
  51. package/dist/apps/shared/components/mail/compose/RecipientInput.js +1 -0
  52. package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +5 -2
  53. package/dist/apps/shared/components/mail/compose/RichTextEditor.js +10 -1
  54. package/dist/apps/shared/components/mail/compose/dropFiles.d.ts +8 -0
  55. package/dist/apps/shared/components/mail/compose/dropFiles.js +18 -0
  56. package/dist/apps/shared/components/mail/layout/MailShell.js +5 -4
  57. package/dist/apps/shared/components/mail/layout/folderIcons.d.ts +8 -0
  58. package/dist/apps/shared/components/mail/layout/folderIcons.js +25 -0
  59. package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
  60. package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -15
  61. package/dist/apps/shared/navigation/appHrefs.d.ts +3 -3
  62. package/dist/apps/shared/navigation/appHrefs.js +3 -3
  63. package/dist/lib/components/pickers/TimeZonePicker.js +14 -5
  64. package/dist/lib/contacts/contactsApi.d.ts +3 -0
  65. package/dist/lib/contacts/contactsApi.js +1 -1
  66. package/dist/lib/mail/directoryApi.d.ts +16 -10
  67. package/dist/lib/mail/directoryApi.js +20 -12
  68. package/dist/lib/util/timeZone.d.ts +1 -1
  69. package/dist/lib/util/timeZone.js +22 -9
  70. package/lib/components/pickers/TimeZonePicker.tsx +14 -9
  71. package/lib/contacts/contactsApi.ts +1 -1
  72. package/lib/mail/directoryApi.ts +30 -12
  73. package/lib/util/timeZone.ts +24 -9
  74. package/package.json +1 -1
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { createContext, useContext, useEffect, useMemo, useState } from "react";
7
- import { HiOutlineBars3 } from "react-icons/hi2";
7
+ import { HiOutlineArrowUturnLeft, HiOutlineBars3, HiOutlineEnvelopeOpen, HiOutlineFunnel, HiOutlineLockClosed, HiOutlineNoSymbol, HiOutlinePaintBrush, HiOutlinePencilSquare, HiOutlinePuzzlePiece, HiOutlineShieldCheck, HiOutlineTag, HiOutlineUserCircle, HiOutlineUsers, } from "react-icons/hi2";
8
8
  import { ApiRequestError } from "../../../../../lib/util/api.js";
9
9
  import Drawer from "../../../../../lib/components/overlays/Drawer.js";
10
10
  import { listMailboxes } from "../../../../../lib/mail/mailApi.js";
@@ -18,17 +18,17 @@ import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.
18
18
  import { mergePluginNavItems } from "../../../plugins/pluginNav.js";
19
19
  /** Every settings section with its own sidebar entry. */
20
20
  export const SETTINGS_SECTIONS = [
21
- { id: "profile", href: "/settings/profile", label: "Profile" },
22
- { id: "appearance", href: "/settings/appearance", label: "Appearance" },
23
- { id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies" },
24
- { id: "filters", href: "/settings/filters", label: "Mail Filters" },
25
- { id: "blocked-senders", href: "/settings/blocked-senders", label: "Blocked & Safe Senders" },
26
- { id: "signatures", href: "/settings/signatures", label: "Signatures" },
27
- { id: "labels", href: "/settings/labels", label: "Labels" },
28
- { id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts" },
29
- { id: "encryption", href: "/settings/encryption", label: "Encryption" },
30
- { id: "sharing", href: "/settings/sharing", label: "Sharing" },
31
- { id: "privacy", href: "/settings/privacy", label: "Privacy & Data" },
21
+ { id: "profile", href: "/settings/profile", label: "Profile", icon: HiOutlineUserCircle },
22
+ { id: "appearance", href: "/settings/appearance", label: "Appearance", icon: HiOutlinePaintBrush },
23
+ { id: "auto-reply", href: "/settings/auto-reply", label: "Automatic Replies", icon: HiOutlineArrowUturnLeft },
24
+ { id: "filters", href: "/settings/filters", label: "Mail Filters", icon: HiOutlineFunnel },
25
+ { id: "blocked-senders", href: "/settings/blocked-senders", label: "Blocked & Safe Senders", icon: HiOutlineNoSymbol },
26
+ { id: "signatures", href: "/settings/signatures", label: "Signatures", icon: HiOutlinePencilSquare },
27
+ { id: "labels", href: "/settings/labels", label: "Labels", icon: HiOutlineTag },
28
+ { id: "read-receipts", href: "/settings/read-receipts", label: "Read Receipts", icon: HiOutlineEnvelopeOpen },
29
+ { id: "encryption", href: "/settings/encryption", label: "Encryption", icon: HiOutlineLockClosed },
30
+ { id: "sharing", href: "/settings/sharing", label: "Sharing", icon: HiOutlineUsers },
31
+ { id: "privacy", href: "/settings/privacy", label: "Privacy & Data", icon: HiOutlineShieldCheck },
32
32
  ];
33
33
  /** `SETTINGS_SECTIONS` followed by the plugins' `settingsSections` items, core ids winning - see
34
34
  * `mergePluginNavItems`. */
@@ -37,6 +37,7 @@ export function settingsSections(pluginNav) {
37
37
  id,
38
38
  href,
39
39
  label,
40
+ icon: HiOutlinePuzzlePiece,
40
41
  }));
41
42
  }
42
43
  const SettingsShellContext = createContext({ mailboxes: [] });
@@ -111,7 +112,7 @@ export default function SettingsShell({ active, userUid, authServerUrl, imperson
111
112
  // server didn't send its nav item, so that falls back to the current path.
112
113
  const href = sections.find((s) => s.id === active)?.href ?? window.location.pathname;
113
114
  navigate(`${href}?mailboxUid=${encodeURIComponent(e.target.value)}`);
114
- }, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsx("nav", { "aria-label": "Settings sections", className: "flex flex-col gap-0.5", children: sections.map((section) => (_jsx("a", {
115
+ }, children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsx("nav", { "aria-label": "Settings sections", className: "flex flex-col gap-0.5", children: sections.map((section) => (_jsxs("a", {
115
116
  // `inner` (this whole branch) is only ever computed once `status === "ready"`
116
117
  // and `mailboxUid` has already resolved truthy — a falsy `mailboxUid` returns
117
118
  // `<MailboxProvisioning />` above instead (which is what the `!` below records,
@@ -120,9 +121,9 @@ export default function SettingsShell({ active, userUid, authServerUrl, imperson
120
121
  // structurally can't trigger" removal precedent (e.g.
121
122
  // `DomainDetailContent.handleVerify`).
122
123
  href: `${section.href}?mailboxUid=${encodeURIComponent(mailboxUid)}`, "aria-current": section.id === active ? "page" : undefined, className: [
123
- "block px-2.5 py-1.5 rounded-sm text-sm",
124
+ "flex items-center gap-2.5 px-2.5 py-1.5 rounded-sm text-sm",
124
125
  section.id === active ? "bg-primary/10 text-primary-dark font-medium" : "text-text hover:bg-surface-alt",
125
- ].join(" "), children: section.label }, section.id))) })] }));
126
+ ].join(" "), children: [_jsx(section.icon, { size: 18, "aria-hidden": "true", className: "shrink-0" }), section.label] }, section.id))) })] }));
126
127
  inner = (_jsxs(_Fragment, { children: [_jsx("aside", { className: "hidden md:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3", children: sidebarContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Settings", fullScreen: true, children: _jsx("div", { className: "flex flex-col gap-3", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 flex flex-col", children: [_jsx("button", { type: "button", className: "md:hidden m-3 self-start w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open settings menu", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx(SettingsShellContext.Provider, { value: contextValue, children: children })] })] }));
127
128
  }
128
129
  return (_jsx(AppShell, { active: "settings", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, children: inner }));
@@ -6,9 +6,9 @@ export declare const APP_HREFS: {
6
6
  readonly tasks: "/tasks";
7
7
  };
8
8
  /**
9
- * Settings is reached from the account menu, not the rail. It links straight to the first settings section, the only one that always
10
- * exists - repoint this at a real `/settings` landing page once there is one.
9
+ * Settings is reached from the account menu, not the rail. It links straight to the Profile section, the first of them and the one that
10
+ * always exists - repoint this at a real `/settings` landing page once there is one.
11
11
  */
12
- export declare const SETTINGS_HREF = "/settings/auto-reply";
12
+ export declare const SETTINGS_HREF = "/settings/profile";
13
13
  /** Whether `pathname` is a page of Settings (any section), which is what "already there" means for it. */
14
14
  export declare function isSettingsPath(pathname: string): boolean;
@@ -10,10 +10,10 @@ export const APP_HREFS = {
10
10
  tasks: "/tasks",
11
11
  };
12
12
  /**
13
- * Settings is reached from the account menu, not the rail. It links straight to the first settings section, the only one that always
14
- * exists - repoint this at a real `/settings` landing page once there is one.
13
+ * Settings is reached from the account menu, not the rail. It links straight to the Profile section, the first of them and the one that
14
+ * always exists - repoint this at a real `/settings` landing page once there is one.
15
15
  */
16
- export const SETTINGS_HREF = "/settings/auto-reply";
16
+ export const SETTINGS_HREF = "/settings/profile";
17
17
  /** Whether `pathname` is a page of Settings (any section), which is what "already there" means for it. */
18
18
  export function isSettingsPath(pathname) {
19
19
  return pathname === "/settings" || pathname.startsWith("/settings/");
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
4
4
  // SPDX-License-Identifier: MPL-2.0
5
5
  ///////////////////////////////////////////////////////////////////////////////
6
6
  import { useEffect, useId, useMemo, useRef, useState } from "react";
7
- import { compareTimeZones, describeTimeZone } from "../../util/timeZone.js";
7
+ import { describeTimeZone, sortTimeZones } from "../../util/timeZone.js";
8
8
  /**
9
9
  * A time zone chooser that lists zones as "Los Angeles, America (GMT-07:00)", west to east and then by city, and has a search box in the list: typing narrows it to the zones whose
10
10
  * name or offset contains every word typed ("berlin", "gmt+9", "america indiana"). A button showing the chosen zone opens it; the arrow keys move
@@ -19,10 +19,19 @@ export default function TimeZonePicker({ id, "aria-label": label, "aria-describe
19
19
  const [open, setOpen] = useState(false);
20
20
  const [query, setQuery] = useState("");
21
21
  const [active, setActive] = useState(-1);
22
- // Listed by the offsets shown (which move with daylight saving): west to east, then by city.
23
- const entries = useMemo(() => [...zones]
24
- .sort((a, b) => compareTimeZones(a, b, at))
25
- .map((zone) => ({ zone, text: describeTimeZone(zone, at), haystack: `${describeTimeZone(zone, at)} ${zone.replace(/_/g, " ")}`.toLowerCase() })), [zones, at]);
22
+ // Listed by the offsets shown (which move with daylight saving): west to east, then by city. Worked out only while the list is open - naming several
23
+ // hundred zones is the costly part, and a closed picker (most renders) shows one - and keyed on the date's value, since a caller may hand over a new `Date` each render.
24
+ const atTime = at?.getTime();
25
+ const entries = useMemo(() => {
26
+ if (!open) {
27
+ return [];
28
+ }
29
+ const on = new Date(atTime ?? Date.now());
30
+ return sortTimeZones(zones, on).map((zone) => {
31
+ const text = describeTimeZone(zone, on);
32
+ return { zone, text, haystack: `${text} ${zone.replace(/_/g, " ")}`.toLowerCase() };
33
+ });
34
+ }, [open, zones, atTime]);
26
35
  const shown = useMemo(() => {
27
36
  const words = query.toLowerCase().replace(/_/g, " ").split(/\s+/).filter(Boolean);
28
37
  return words.length === 0 ? entries : entries.filter((entry) => words.every((word) => entry.haystack.includes(word)));
@@ -77,6 +77,9 @@ export declare function pinnedSigningFingerprintsFor(contacts: Contact[], addres
77
77
  * `limit` cap). */
78
78
  export declare const PINNED_FINGERPRINT_PAGE_SIZE = 500;
79
79
  export declare const PINNED_FINGERPRINT_MAX_PAGES = 20;
80
+ /** Every contact in `folderUids`, paged (`PINNED_FINGERPRINT_PAGE_SIZE`, at most `PINNED_FINGERPRINT_MAX_PAGES` pages
81
+ * per folder). A plain contacts read - never a Discovery lookup. */
82
+ export declare function listContactsInFolders(folderUids: string[], client?: ApiClient): Promise<Contact[]>;
80
83
  /**
81
84
  * Fetches the trusted signing-key fingerprints (pinned and previous - see `pinnedSigningFingerprintsFor()`) for a
82
85
  * message sender's `address` from the contacts in `folderUids` (the reader's contacts folders), for
@@ -29,7 +29,7 @@ export const PINNED_FINGERPRINT_PAGE_SIZE = 500;
29
29
  export const PINNED_FINGERPRINT_MAX_PAGES = 20;
30
30
  /** Every contact in `folderUids`, paged (`PINNED_FINGERPRINT_PAGE_SIZE`, at most `PINNED_FINGERPRINT_MAX_PAGES` pages
31
31
  * per folder). A plain contacts read - never a Discovery lookup. */
32
- async function listContactsInFolders(folderUids, client) {
32
+ export async function listContactsInFolders(folderUids, client) {
33
33
  const contacts = [];
34
34
  for (const folderUid of folderUids) {
35
35
  for (let page = 0; page < PINNED_FINGERPRINT_MAX_PAGES; page++) {
@@ -1,12 +1,14 @@
1
1
  /**
2
2
  * Typed wrappers over `@rapidmx/restapi`'s `BaseDirectoryRoute` - recipient suggestions for compose. `GET
3
3
  * /mail/directory` searches the server's mailboxes (people, shared mailboxes, rooms and equipment) and distribution
4
- * lists; `GET /mail/directory/contacts` searches the caller's own contacts. Both match every word of the query against
5
- * the start of a name word or of an address, and return only a display name, an address and a kind.
4
+ * lists; `GET /mail/directory/contacts` searches the caller's own contacts; `GET /mail/directory/correspondents` searches
5
+ * everyone the caller's mailboxes have exchanged mail or shared an event with, address book or not. All match every word of
6
+ * the query against the start of a name word or of an address, and return only a display name, an address and a kind.
6
7
  */
7
8
  import { ApiClient } from "../util/api.js";
8
- /** What a suggestion names. `"contact"` comes from the caller's contacts, everything else from the server directory. */
9
- export type RecipientSuggestionKind = "user" | "shared" | "room" | "equipment" | "list" | "contact";
9
+ /** What a suggestion names. `"contact"` comes from the caller's contacts, `"correspondent"` from the people they have written to, heard from or
10
+ * met with, everything else from the server directory. */
11
+ export type RecipientSuggestionKind = "user" | "shared" | "room" | "equipment" | "list" | "contact" | "correspondent";
10
12
  export interface RecipientSuggestion {
11
13
  displayName: string;
12
14
  address: string;
@@ -38,12 +40,16 @@ export interface ContactSuggestionOptions extends RecipientSuggestionOptions {
38
40
  export declare function searchDirectory(query: string, options?: RecipientSuggestionOptions, client?: ApiClient): Promise<RecipientSuggestion[]>;
39
41
  /** Searches the caller's contacts (every address of a contact whose name matches, or the addresses that match). */
40
42
  export declare function searchContactSuggestions(query: string, options?: ContactSuggestionOptions, client?: ApiClient): Promise<RecipientSuggestion[]>;
41
- /** Contacts first, then directory entries, without repeating an address (compared case-insensitively; the first
42
- * occurrence wins), at most `limit`. */
43
- export declare function mergeRecipientSuggestions(contacts: RecipientSuggestion[], directory: RecipientSuggestion[], limit?: number): RecipientSuggestion[];
43
+ /** Searches the people the caller's mailboxes have exchanged mail or shared an event with, whether or not they are in the address book. Rejects like
44
+ * `searchDirectory()`; a server that predates the endpoint answers 404, which `fetchRecipientSuggestions()` treats as "none". */
45
+ export declare function searchCorrespondents(query: string, options?: ContactSuggestionOptions, client?: ApiClient): Promise<RecipientSuggestion[]>;
46
+ /** Contacts first, then directory entries, then the people the caller has corresponded with, without repeating an address (compared
47
+ * case-insensitively; the first occurrence wins), at most `limit`. */
48
+ export declare function mergeRecipientSuggestions(contacts: RecipientSuggestion[], directory: RecipientSuggestion[], limit?: number, correspondents?: RecipientSuggestion[]): RecipientSuggestion[];
44
49
  /**
45
- * Contacts and directory suggestions for `query`, merged by `mergeRecipientSuggestions()`. Either source failing (a 403
46
- * directory for a caller with no mailbox here, a 429, a network error) still returns the other's entries; only when
47
- * both fail does this reject, with the contacts error. An aborted `signal` always rejects with the `AbortError`.
50
+ * Contacts, directory and correspondent suggestions for `query`, merged by `mergeRecipientSuggestions()`. Any source failing (a 403
51
+ * directory for a caller with no mailbox here, a 429, a network error, a server without the correspondents endpoint) still returns
52
+ * the others' entries; only when the contacts and the directory both fail does this reject, with the contacts error - the
53
+ * correspondents are a bonus and never turn a working search into an error. An aborted `signal` always rejects with the `AbortError`.
48
54
  */
49
55
  export declare function fetchRecipientSuggestions(query: string, options?: ContactSuggestionOptions, client?: ApiClient): Promise<RecipientSuggestion[]>;
@@ -5,8 +5,9 @@
5
5
  /**
6
6
  * Typed wrappers over `@rapidmx/restapi`'s `BaseDirectoryRoute` - recipient suggestions for compose. `GET
7
7
  * /mail/directory` searches the server's mailboxes (people, shared mailboxes, rooms and equipment) and distribution
8
- * lists; `GET /mail/directory/contacts` searches the caller's own contacts. Both match every word of the query against
9
- * the start of a name word or of an address, and return only a display name, an address and a kind.
8
+ * lists; `GET /mail/directory/contacts` searches the caller's own contacts; `GET /mail/directory/correspondents` searches
9
+ * everyone the caller's mailboxes have exchanged mail or shared an event with, address book or not. All match every word of
10
+ * the query against the start of a name word or of an address, and return only a display name, an address and a kind.
10
11
  */
11
12
  import { ApiRequestError, withClient } from "../util/api.js";
12
13
  /** The shortest trimmed query the server searches for - shorter ones resolve to `[]` without a request. */
@@ -58,12 +59,17 @@ export function searchDirectory(query, options = {}, client) {
58
59
  export function searchContactSuggestions(query, options = {}, client) {
59
60
  return fetchSuggestions("/mail/directory/contacts", query, { limit: options.limit?.toString(), mailboxUid: options.mailboxUid }, options.signal, client);
60
61
  }
61
- /** Contacts first, then directory entries, without repeating an address (compared case-insensitively; the first
62
- * occurrence wins), at most `limit`. */
63
- export function mergeRecipientSuggestions(contacts, directory, limit = RECIPIENT_SUGGESTION_DEFAULT_LIMIT) {
62
+ /** Searches the people the caller's mailboxes have exchanged mail or shared an event with, whether or not they are in the address book. Rejects like
63
+ * `searchDirectory()`; a server that predates the endpoint answers 404, which `fetchRecipientSuggestions()` treats as "none". */
64
+ export function searchCorrespondents(query, options = {}, client) {
65
+ return fetchSuggestions("/mail/directory/correspondents", query, { limit: options.limit?.toString(), mailboxUid: options.mailboxUid }, options.signal, client);
66
+ }
67
+ /** Contacts first, then directory entries, then the people the caller has corresponded with, without repeating an address (compared
68
+ * case-insensitively; the first occurrence wins), at most `limit`. */
69
+ export function mergeRecipientSuggestions(contacts, directory, limit = RECIPIENT_SUGGESTION_DEFAULT_LIMIT, correspondents = []) {
64
70
  const seen = new Set();
65
71
  const result = [];
66
- for (const entry of [...contacts, ...directory]) {
72
+ for (const entry of [...contacts, ...directory, ...correspondents]) {
67
73
  const key = entry.address.trim().toLowerCase();
68
74
  if (result.length >= limit) {
69
75
  break;
@@ -79,17 +85,19 @@ function isAbort(error) {
79
85
  return error?.name === "AbortError";
80
86
  }
81
87
  /**
82
- * Contacts and directory suggestions for `query`, merged by `mergeRecipientSuggestions()`. Either source failing (a 403
83
- * directory for a caller with no mailbox here, a 429, a network error) still returns the other's entries; only when
84
- * both fail does this reject, with the contacts error. An aborted `signal` always rejects with the `AbortError`.
88
+ * Contacts, directory and correspondent suggestions for `query`, merged by `mergeRecipientSuggestions()`. Any source failing (a 403
89
+ * directory for a caller with no mailbox here, a 429, a network error, a server without the correspondents endpoint) still returns
90
+ * the others' entries; only when the contacts and the directory both fail does this reject, with the contacts error - the
91
+ * correspondents are a bonus and never turn a working search into an error. An aborted `signal` always rejects with the `AbortError`.
85
92
  */
86
93
  export async function fetchRecipientSuggestions(query, options = {}, client) {
87
94
  const limit = options.limit ?? RECIPIENT_SUGGESTION_DEFAULT_LIMIT;
88
- const [contacts, directory] = await Promise.allSettled([
95
+ const [contacts, directory, correspondents] = await Promise.allSettled([
89
96
  searchContactSuggestions(query, { ...options, limit }, client),
90
97
  searchDirectory(query, { limit, signal: options.signal }, client),
98
+ searchCorrespondents(query, { ...options, limit }, client),
91
99
  ]);
92
- for (const outcome of [contacts, directory]) {
100
+ for (const outcome of [contacts, directory, correspondents]) {
93
101
  if (outcome.status === "rejected" && isAbort(outcome.reason)) {
94
102
  throw outcome.reason;
95
103
  }
@@ -97,5 +105,5 @@ export async function fetchRecipientSuggestions(query, options = {}, client) {
97
105
  if (contacts.status === "rejected" && directory.status === "rejected") {
98
106
  throw contacts.reason instanceof Error ? contacts.reason : new ApiRequestError("Suggestions couldn't be loaded.", 0);
99
107
  }
100
- return mergeRecipientSuggestions(contacts.status === "fulfilled" ? contacts.value : [], directory.status === "fulfilled" ? directory.value : [], limit);
108
+ return mergeRecipientSuggestions(contacts.status === "fulfilled" ? contacts.value : [], directory.status === "fulfilled" ? directory.value : [], limit, correspondents.status === "fulfilled" ? correspondents.value : []);
101
109
  }
@@ -22,7 +22,7 @@ export declare function describeTimeZone(zone: string, at?: Date): string;
22
22
  * Orders zones for a list: by how far their clocks are from UTC at `at` (the westernmost first), then by city, then by id. A zone the runtime
23
23
  * doesn't know comes last.
24
24
  */
25
- export declare function compareTimeZones(a: string, b: string, at?: Date): number;
25
+ export declare function sortTimeZones(zones: readonly string[], at?: Date): string[];
26
26
  /**
27
27
  * Every time zone the browser can name, for a picker (sorted by offset from UTC as it is now, then by city): `Intl.supportedValuesOf("timeZone")` where there is one (it leaves out `UTC`,
28
28
  * which is added), else just `extra` - the zones the caller already holds, such as a mailbox's current one.
@@ -22,13 +22,25 @@ export function deviceTimeZone() {
22
22
  */
23
23
  export function zoneClock(zone, at = new Date()) {
24
24
  try {
25
- const part = (style) => new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: style }).formatToParts(at).find((p) => p.type === "timeZoneName").value;
25
+ const part = (style) => formatterFor(zone, style).formatToParts(at).find((p) => p.type === "timeZoneName").value;
26
26
  return { abbreviation: part("short"), offset: part("longOffset").replace("GMT", "") || "+00:00" };
27
27
  }
28
28
  catch {
29
29
  return null;
30
30
  }
31
31
  }
32
+ /** Building an `Intl.DateTimeFormat` is by far the costly part of naming a zone's clock, and a list names hundreds: each is made once. */
33
+ const formatters = new Map();
34
+ function formatterFor(zone, style) {
35
+ const key = `${style}|${zone}`;
36
+ let formatter = formatters.get(key);
37
+ if (!formatter) {
38
+ // Throws for a zone the runtime doesn't know, which is then not remembered.
39
+ formatter = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: style });
40
+ formatters.set(key, formatter);
41
+ }
42
+ return formatter;
43
+ }
32
44
  /** A zone id split into its city and the region it is in: "America/Argentina/Buenos_Aires" is "Buenos Aires" in "America/Argentina"; "UTC" is in none. */
33
45
  function zoneNames(zone) {
34
46
  const parts = zone.split("/");
@@ -51,13 +63,15 @@ export function describeTimeZone(zone, at = new Date()) {
51
63
  * Orders zones for a list: by how far their clocks are from UTC at `at` (the westernmost first), then by city, then by id. A zone the runtime
52
64
  * doesn't know comes last.
53
65
  */
54
- export function compareTimeZones(a, b, at = new Date()) {
55
- const minutes = (zone) => {
66
+ export function sortTimeZones(zones, at = new Date()) {
67
+ // Each zone's offset is read once, not once per comparison.
68
+ const keyed = zones.map((zone) => {
56
69
  const offset = zoneClock(zone, at)?.offset;
57
- return offset ? (offset.startsWith("-") ? -1 : 1) * (Number(offset.slice(1, 3)) * 60 + Number(offset.slice(4, 6))) : Number.POSITIVE_INFINITY;
58
- };
59
- const byOffset = minutes(a) - minutes(b);
60
- return (Number.isNaN(byOffset) ? 0 : byOffset) || zoneNames(a).city.localeCompare(zoneNames(b).city) || a.localeCompare(b);
70
+ const minutes = offset ? (offset.startsWith("-") ? -1 : 1) * (Number(offset.slice(1, 3)) * 60 + Number(offset.slice(4, 6))) : Number.POSITIVE_INFINITY;
71
+ return { zone, minutes, city: zoneNames(zone).city };
72
+ });
73
+ keyed.sort((a, b) => (a.minutes === b.minutes ? 0 : a.minutes < b.minutes ? -1 : 1) || a.city.localeCompare(b.city) || a.zone.localeCompare(b.zone));
74
+ return keyed.map((entry) => entry.zone);
61
75
  }
62
76
  /**
63
77
  * Every time zone the browser can name, for a picker (sorted by offset from UTC as it is now, then by city): `Intl.supportedValuesOf("timeZone")` where there is one (it leaves out `UTC`,
@@ -71,6 +85,5 @@ export function timeZoneOptions(...extra) {
71
85
  catch {
72
86
  zones = [];
73
87
  }
74
- const now = new Date();
75
- return [...new Set([DEFAULT_TIME_ZONE, ...zones, ...extra.filter(Boolean)])].sort((a, b) => compareTimeZones(a, b, now));
88
+ return sortTimeZones([...new Set([DEFAULT_TIME_ZONE, ...zones, ...extra.filter(Boolean)])]);
76
89
  }
@@ -3,7 +3,7 @@
3
3
  // SPDX-License-Identifier: MPL-2.0
4
4
  ///////////////////////////////////////////////////////////////////////////////
5
5
  import React, { KeyboardEvent, useEffect, useId, useMemo, useRef, useState } from "react";
6
- import { compareTimeZones, describeTimeZone } from "../../util/timeZone.js";
6
+ import { describeTimeZone, sortTimeZones } from "../../util/timeZone.js";
7
7
 
8
8
  export interface TimeZonePickerProps {
9
9
  id?: string;
@@ -34,14 +34,19 @@ export default function TimeZonePicker({ id, "aria-label": label, "aria-describe
34
34
  const [query, setQuery] = useState("");
35
35
  const [active, setActive] = useState(-1);
36
36
 
37
- // Listed by the offsets shown (which move with daylight saving): west to east, then by city.
38
- const entries = useMemo(
39
- () =>
40
- [...zones]
41
- .sort((a, b) => compareTimeZones(a, b, at))
42
- .map((zone) => ({ zone, text: describeTimeZone(zone, at), haystack: `${describeTimeZone(zone, at)} ${zone.replace(/_/g, " ")}`.toLowerCase() })),
43
- [zones, at],
44
- );
37
+ // Listed by the offsets shown (which move with daylight saving): west to east, then by city. Worked out only while the list is open - naming several
38
+ // hundred zones is the costly part, and a closed picker (most renders) shows one - and keyed on the date's value, since a caller may hand over a new `Date` each render.
39
+ const atTime = at?.getTime();
40
+ const entries = useMemo(() => {
41
+ if (!open) {
42
+ return [];
43
+ }
44
+ const on = new Date(atTime ?? Date.now());
45
+ return sortTimeZones(zones, on).map((zone) => {
46
+ const text = describeTimeZone(zone, on);
47
+ return { zone, text, haystack: `${text} ${zone.replace(/_/g, " ")}`.toLowerCase() };
48
+ });
49
+ }, [open, zones, atTime]);
45
50
  const shown = useMemo(() => {
46
51
  const words = query.toLowerCase().replace(/_/g, " ").split(/\s+/).filter(Boolean);
47
52
  return words.length === 0 ? entries : entries.filter((entry) => words.every((word) => entry.haystack.includes(word)));
@@ -109,7 +109,7 @@ export const PINNED_FINGERPRINT_MAX_PAGES = 20;
109
109
 
110
110
  /** Every contact in `folderUids`, paged (`PINNED_FINGERPRINT_PAGE_SIZE`, at most `PINNED_FINGERPRINT_MAX_PAGES` pages
111
111
  * per folder). A plain contacts read - never a Discovery lookup. */
112
- async function listContactsInFolders(folderUids: string[], client?: ApiClient): Promise<Contact[]> {
112
+ export async function listContactsInFolders(folderUids: string[], client?: ApiClient): Promise<Contact[]> {
113
113
  const contacts: Contact[] = [];
114
114
  for (const folderUid of folderUids) {
115
115
  for (let page = 0; page < PINNED_FINGERPRINT_MAX_PAGES; page++) {
@@ -5,13 +5,15 @@
5
5
  /**
6
6
  * Typed wrappers over `@rapidmx/restapi`'s `BaseDirectoryRoute` - recipient suggestions for compose. `GET
7
7
  * /mail/directory` searches the server's mailboxes (people, shared mailboxes, rooms and equipment) and distribution
8
- * lists; `GET /mail/directory/contacts` searches the caller's own contacts. Both match every word of the query against
9
- * the start of a name word or of an address, and return only a display name, an address and a kind.
8
+ * lists; `GET /mail/directory/contacts` searches the caller's own contacts; `GET /mail/directory/correspondents` searches
9
+ * everyone the caller's mailboxes have exchanged mail or shared an event with, address book or not. All match every word of
10
+ * the query against the start of a name word or of an address, and return only a display name, an address and a kind.
10
11
  */
11
12
  import { ApiClient, ApiRequestError, withClient } from "../util/api.js";
12
13
 
13
- /** What a suggestion names. `"contact"` comes from the caller's contacts, everything else from the server directory. */
14
- export type RecipientSuggestionKind = "user" | "shared" | "room" | "equipment" | "list" | "contact";
14
+ /** What a suggestion names. `"contact"` comes from the caller's contacts, `"correspondent"` from the people they have written to, heard from or
15
+ * met with, everything else from the server directory. */
16
+ export type RecipientSuggestionKind = "user" | "shared" | "room" | "equipment" | "list" | "contact" | "correspondent";
15
17
 
16
18
  export interface RecipientSuggestion {
17
19
  displayName: string;
@@ -104,16 +106,29 @@ export function searchContactSuggestions(
104
106
  );
105
107
  }
106
108
 
107
- /** Contacts first, then directory entries, without repeating an address (compared case-insensitively; the first
108
- * occurrence wins), at most `limit`. */
109
+ /** Searches the people the caller's mailboxes have exchanged mail or shared an event with, whether or not they are in the address book. Rejects like
110
+ * `searchDirectory()`; a server that predates the endpoint answers 404, which `fetchRecipientSuggestions()` treats as "none". */
111
+ export function searchCorrespondents(query: string, options: ContactSuggestionOptions = {}, client?: ApiClient): Promise<RecipientSuggestion[]> {
112
+ return fetchSuggestions(
113
+ "/mail/directory/correspondents",
114
+ query,
115
+ { limit: options.limit?.toString(), mailboxUid: options.mailboxUid },
116
+ options.signal,
117
+ client,
118
+ );
119
+ }
120
+
121
+ /** Contacts first, then directory entries, then the people the caller has corresponded with, without repeating an address (compared
122
+ * case-insensitively; the first occurrence wins), at most `limit`. */
109
123
  export function mergeRecipientSuggestions(
110
124
  contacts: RecipientSuggestion[],
111
125
  directory: RecipientSuggestion[],
112
126
  limit: number = RECIPIENT_SUGGESTION_DEFAULT_LIMIT,
127
+ correspondents: RecipientSuggestion[] = [],
113
128
  ): RecipientSuggestion[] {
114
129
  const seen = new Set<string>();
115
130
  const result: RecipientSuggestion[] = [];
116
- for (const entry of [...contacts, ...directory]) {
131
+ for (const entry of [...contacts, ...directory, ...correspondents]) {
117
132
  const key = entry.address.trim().toLowerCase();
118
133
  if (result.length >= limit) {
119
134
  break;
@@ -131,9 +146,10 @@ function isAbort(error: unknown): boolean {
131
146
  }
132
147
 
133
148
  /**
134
- * Contacts and directory suggestions for `query`, merged by `mergeRecipientSuggestions()`. Either source failing (a 403
135
- * directory for a caller with no mailbox here, a 429, a network error) still returns the other's entries; only when
136
- * both fail does this reject, with the contacts error. An aborted `signal` always rejects with the `AbortError`.
149
+ * Contacts, directory and correspondent suggestions for `query`, merged by `mergeRecipientSuggestions()`. Any source failing (a 403
150
+ * directory for a caller with no mailbox here, a 429, a network error, a server without the correspondents endpoint) still returns
151
+ * the others' entries; only when the contacts and the directory both fail does this reject, with the contacts error - the
152
+ * correspondents are a bonus and never turn a working search into an error. An aborted `signal` always rejects with the `AbortError`.
137
153
  */
138
154
  export async function fetchRecipientSuggestions(
139
155
  query: string,
@@ -141,11 +157,12 @@ export async function fetchRecipientSuggestions(
141
157
  client?: ApiClient,
142
158
  ): Promise<RecipientSuggestion[]> {
143
159
  const limit = options.limit ?? RECIPIENT_SUGGESTION_DEFAULT_LIMIT;
144
- const [contacts, directory] = await Promise.allSettled([
160
+ const [contacts, directory, correspondents] = await Promise.allSettled([
145
161
  searchContactSuggestions(query, { ...options, limit }, client),
146
162
  searchDirectory(query, { limit, signal: options.signal }, client),
163
+ searchCorrespondents(query, { ...options, limit }, client),
147
164
  ]);
148
- for (const outcome of [contacts, directory]) {
165
+ for (const outcome of [contacts, directory, correspondents]) {
149
166
  if (outcome.status === "rejected" && isAbort(outcome.reason)) {
150
167
  throw outcome.reason;
151
168
  }
@@ -157,5 +174,6 @@ export async function fetchRecipientSuggestions(
157
174
  contacts.status === "fulfilled" ? contacts.value : [],
158
175
  directory.status === "fulfilled" ? directory.value : [],
159
176
  limit,
177
+ correspondents.status === "fulfilled" ? correspondents.value : [],
160
178
  );
161
179
  }
@@ -25,13 +25,27 @@ export function deviceTimeZone(): string {
25
25
  export function zoneClock(zone: string, at: Date = new Date()): { abbreviation: string; offset: string } | null {
26
26
  try {
27
27
  const part = (style: "short" | "longOffset") =>
28
- new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: style }).formatToParts(at).find((p) => p.type === "timeZoneName")!.value;
28
+ formatterFor(zone, style).formatToParts(at).find((p) => p.type === "timeZoneName")!.value;
29
29
  return { abbreviation: part("short"), offset: part("longOffset").replace("GMT", "") || "+00:00" };
30
30
  } catch {
31
31
  return null;
32
32
  }
33
33
  }
34
34
 
35
+ /** Building an `Intl.DateTimeFormat` is by far the costly part of naming a zone's clock, and a list names hundreds: each is made once. */
36
+ const formatters = new Map<string, Intl.DateTimeFormat>();
37
+
38
+ function formatterFor(zone: string, style: "short" | "longOffset"): Intl.DateTimeFormat {
39
+ const key = `${style}|${zone}`;
40
+ let formatter = formatters.get(key);
41
+ if (!formatter) {
42
+ // Throws for a zone the runtime doesn't know, which is then not remembered.
43
+ formatter = new Intl.DateTimeFormat("en-US", { timeZone: zone, timeZoneName: style });
44
+ formatters.set(key, formatter);
45
+ }
46
+ return formatter;
47
+ }
48
+
35
49
  /** A zone id split into its city and the region it is in: "America/Argentina/Buenos_Aires" is "Buenos Aires" in "America/Argentina"; "UTC" is in none. */
36
50
  function zoneNames(zone: string): { city: string; region: string } {
37
51
  const parts = zone.split("/");
@@ -56,13 +70,15 @@ export function describeTimeZone(zone: string, at: Date = new Date()): string {
56
70
  * Orders zones for a list: by how far their clocks are from UTC at `at` (the westernmost first), then by city, then by id. A zone the runtime
57
71
  * doesn't know comes last.
58
72
  */
59
- export function compareTimeZones(a: string, b: string, at: Date = new Date()): number {
60
- const minutes = (zone: string) => {
73
+ export function sortTimeZones(zones: readonly string[], at: Date = new Date()): string[] {
74
+ // Each zone's offset is read once, not once per comparison.
75
+ const keyed = zones.map((zone) => {
61
76
  const offset = zoneClock(zone, at)?.offset;
62
- return offset ? (offset.startsWith("-") ? -1 : 1) * (Number(offset.slice(1, 3)) * 60 + Number(offset.slice(4, 6))) : Number.POSITIVE_INFINITY;
63
- };
64
- const byOffset = minutes(a) - minutes(b);
65
- return (Number.isNaN(byOffset) ? 0 : byOffset) || zoneNames(a).city.localeCompare(zoneNames(b).city) || a.localeCompare(b);
77
+ const minutes = offset ? (offset.startsWith("-") ? -1 : 1) * (Number(offset.slice(1, 3)) * 60 + Number(offset.slice(4, 6))) : Number.POSITIVE_INFINITY;
78
+ return { zone, minutes, city: zoneNames(zone).city };
79
+ });
80
+ keyed.sort((a, b) => (a.minutes === b.minutes ? 0 : a.minutes < b.minutes ? -1 : 1) || a.city.localeCompare(b.city) || a.zone.localeCompare(b.zone));
81
+ return keyed.map((entry) => entry.zone);
66
82
  }
67
83
 
68
84
  /**
@@ -76,6 +92,5 @@ export function timeZoneOptions(...extra: string[]): string[] {
76
92
  } catch {
77
93
  zones = [];
78
94
  }
79
- const now = new Date();
80
- return [...new Set([DEFAULT_TIME_ZONE, ...zones, ...extra.filter(Boolean)])].sort((a, b) => compareTimeZones(a, b, now));
95
+ return sortTimeZones([...new Set([DEFAULT_TIME_ZONE, ...zones, ...extra.filter(Boolean)])]);
81
96
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rapidmx/web-client",
3
- "version": "0.24.0",
3
+ "version": "0.26.0",
4
4
  "description": "RapidMX's webmail and admin console React UI for web-based clients.",
5
5
  "type": "module",
6
6
  "repository": "https://github.com/rapidmx/web-client",