@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.
- package/apps/shared/components/admin/layout/AdminShell.tsx +39 -4
- package/apps/shared/components/admin/layout/railPreference.ts +25 -0
- package/apps/shared/components/calendar/CalendarListSidebar.tsx +6 -2
- package/apps/shared/components/calendar/EventDetails.tsx +5 -2
- package/apps/shared/components/contacts/ContactCard.tsx +330 -0
- package/apps/shared/components/contacts/ContactCardProvider.tsx +72 -0
- package/apps/shared/components/contacts/ParticipantLink.tsx +42 -0
- package/apps/shared/components/contacts/contactCardData.ts +220 -0
- package/apps/shared/components/contacts/participantDetails.ts +169 -0
- package/apps/shared/components/layout/AppShell.tsx +4 -0
- package/apps/shared/components/layout/ThemeSwitch.tsx +3 -2
- package/apps/shared/components/layout/UserMenu.tsx +30 -10
- package/apps/shared/components/mail/InviteCard.tsx +27 -5
- package/apps/shared/components/mail/MailAddress.tsx +8 -3
- package/apps/shared/components/mail/MessageDetailPane.tsx +63 -18
- package/apps/shared/components/mail/VCardAttachmentChip.tsx +85 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +112 -1
- package/apps/shared/components/mail/compose/RecipientInput.tsx +1 -0
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +15 -2
- package/apps/shared/components/mail/compose/dropFiles.ts +21 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +13 -5
- package/apps/shared/components/mail/layout/folderIcons.tsx +54 -0
- package/apps/shared/components/settings/layout/SettingsShell.tsx +32 -13
- package/apps/shared/navigation/appHrefs.ts +3 -3
- package/dist/apps/shared/components/admin/layout/AdminShell.js +21 -4
- package/dist/apps/shared/components/admin/layout/railPreference.d.ts +6 -0
- package/dist/apps/shared/components/admin/layout/railPreference.js +24 -0
- package/dist/apps/shared/components/calendar/CalendarListSidebar.js +3 -2
- package/dist/apps/shared/components/calendar/EventDetails.js +2 -1
- package/dist/apps/shared/components/contacts/ContactCard.d.ts +19 -0
- package/dist/apps/shared/components/contacts/ContactCard.js +132 -0
- package/dist/apps/shared/components/contacts/ContactCardProvider.d.ts +25 -0
- package/dist/apps/shared/components/contacts/ContactCardProvider.js +41 -0
- package/dist/apps/shared/components/contacts/ParticipantLink.d.ts +18 -0
- package/dist/apps/shared/components/contacts/ParticipantLink.js +16 -0
- package/dist/apps/shared/components/contacts/contactCardData.d.ts +69 -0
- package/dist/apps/shared/components/contacts/contactCardData.js +158 -0
- package/dist/apps/shared/components/contacts/participantDetails.d.ts +64 -0
- package/dist/apps/shared/components/contacts/participantDetails.js +129 -0
- package/dist/apps/shared/components/layout/AppShell.js +11 -10
- package/dist/apps/shared/components/layout/ThemeSwitch.js +2 -2
- package/dist/apps/shared/components/layout/UserMenu.d.ts +1 -2
- package/dist/apps/shared/components/layout/UserMenu.js +4 -4
- package/dist/apps/shared/components/mail/InviteCard.js +5 -3
- package/dist/apps/shared/components/mail/MailAddress.d.ts +5 -2
- package/dist/apps/shared/components/mail/MailAddress.js +5 -3
- package/dist/apps/shared/components/mail/MessageDetailPane.js +19 -4
- package/dist/apps/shared/components/mail/VCardAttachmentChip.d.ts +18 -0
- package/dist/apps/shared/components/mail/VCardAttachmentChip.js +53 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +81 -4
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +1 -0
- package/dist/apps/shared/components/mail/compose/RichTextEditor.d.ts +5 -2
- package/dist/apps/shared/components/mail/compose/RichTextEditor.js +10 -1
- package/dist/apps/shared/components/mail/compose/dropFiles.d.ts +8 -0
- package/dist/apps/shared/components/mail/compose/dropFiles.js +18 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +5 -4
- package/dist/apps/shared/components/mail/layout/folderIcons.d.ts +8 -0
- package/dist/apps/shared/components/mail/layout/folderIcons.js +25 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.d.ts +3 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +16 -15
- package/dist/apps/shared/navigation/appHrefs.d.ts +3 -3
- package/dist/apps/shared/navigation/appHrefs.js +3 -3
- package/dist/lib/components/pickers/TimeZonePicker.js +14 -5
- package/dist/lib/contacts/contactsApi.d.ts +3 -0
- package/dist/lib/contacts/contactsApi.js +1 -1
- package/dist/lib/mail/directoryApi.d.ts +16 -10
- package/dist/lib/mail/directoryApi.js +20 -12
- package/dist/lib/util/timeZone.d.ts +1 -1
- package/dist/lib/util/timeZone.js +22 -9
- package/lib/components/pickers/TimeZonePicker.tsx +14 -9
- package/lib/contacts/contactsApi.ts +1 -1
- package/lib/mail/directoryApi.ts +30 -12
- package/lib/util/timeZone.ts +24 -9
- 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) => (
|
|
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
|
-
"
|
|
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
|
|
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/
|
|
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
|
|
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/
|
|
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 {
|
|
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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
|
5
|
-
* the
|
|
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,
|
|
9
|
-
|
|
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
|
-
/**
|
|
42
|
-
*
|
|
43
|
-
export declare function
|
|
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
|
|
46
|
-
* directory for a caller with no mailbox here, a 429, a network error
|
|
47
|
-
* both fail does this reject, with the contacts error
|
|
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
|
|
9
|
-
* the
|
|
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
|
-
/**
|
|
62
|
-
*
|
|
63
|
-
export function
|
|
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
|
|
83
|
-
* directory for a caller with no mailbox here, a 429, a network error
|
|
84
|
-
* both fail does this reject, with the contacts error
|
|
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
|
|
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) =>
|
|
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
|
|
55
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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++) {
|
package/lib/mail/directoryApi.ts
CHANGED
|
@@ -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
|
|
9
|
-
* the
|
|
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,
|
|
14
|
-
|
|
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
|
-
/**
|
|
108
|
-
*
|
|
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
|
|
135
|
-
* directory for a caller with no mailbox here, a 429, a network error
|
|
136
|
-
* both fail does this reject, with the contacts error
|
|
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
|
}
|
package/lib/util/timeZone.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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