@rapidmx/web-client 0.25.1 → 0.27.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/README.md +15 -3
- package/apps/shared/components/admin/layout/AdminShell.tsx +3 -2
- package/apps/shared/components/calendar/EventDetails.tsx +5 -2
- package/apps/shared/components/contacts/ContactCard.tsx +356 -0
- package/apps/shared/components/contacts/ContactCardProvider.tsx +72 -0
- package/apps/shared/components/contacts/ContactDetailPane.tsx +25 -5
- package/apps/shared/components/contacts/ContactForm.tsx +36 -5
- package/apps/shared/components/contacts/ContactPhotoField.tsx +86 -0
- package/apps/shared/components/contacts/FavoriteStarButton.tsx +36 -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 +18 -6
- 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 +72 -27
- 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/settings/layout/SettingsShell.tsx +3 -3
- package/apps/shared/plugins/pluginIcons.ts +124 -0
- package/apps/shared/plugins/pluginNav.ts +8 -0
- package/apps/shared/plugins/useResolvedRailItems.ts +54 -0
- package/apps/www/contacts/[uid].tsx +1 -0
- package/apps/www/contacts/index.tsx +3 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.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 +148 -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/ContactDetailPane.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactDetailPane.js +17 -2
- package/dist/apps/shared/components/contacts/ContactForm.js +20 -2
- package/dist/apps/shared/components/contacts/ContactPhotoField.d.ts +21 -0
- package/dist/apps/shared/components/contacts/ContactPhotoField.js +46 -0
- package/dist/apps/shared/components/contacts/FavoriteStarButton.d.ts +9 -0
- package/dist/apps/shared/components/contacts/FavoriteStarButton.js +12 -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.d.ts +3 -2
- package/dist/apps/shared/components/layout/AppShell.js +24 -15
- 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 -7
- 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/settings/layout/SettingsShell.js +4 -3
- package/dist/apps/shared/plugins/pluginIcons.d.ts +10 -0
- package/dist/apps/shared/plugins/pluginIcons.js +64 -0
- package/dist/apps/shared/plugins/pluginNav.d.ts +8 -0
- package/dist/apps/shared/plugins/useResolvedRailItems.d.ts +12 -0
- package/dist/apps/shared/plugins/useResolvedRailItems.js +47 -0
- package/dist/apps/www/contacts/[uid].js +1 -1
- package/dist/apps/www/contacts/index.js +2 -2
- package/dist/lib/components/avatar/ContactAvatar.d.ts +9 -3
- package/dist/lib/components/avatar/ContactAvatar.js +29 -6
- package/dist/lib/components/buttons/CopyIconButton.d.ts +13 -0
- package/dist/lib/components/buttons/CopyIconButton.js +16 -0
- package/dist/lib/contacts/contactsApi.d.ts +15 -0
- package/dist/lib/contacts/contactsApi.js +31 -2
- package/dist/lib/contacts/gravatar.d.ts +12 -0
- package/dist/lib/contacts/gravatar.js +43 -0
- package/dist/lib/mail/directoryApi.d.ts +16 -10
- package/dist/lib/mail/directoryApi.js +20 -12
- package/lib/components/avatar/ContactAvatar.tsx +43 -7
- package/lib/components/buttons/CopyIconButton.tsx +49 -0
- package/lib/contacts/contactsApi.ts +35 -2
- package/lib/contacts/gravatar.ts +47 -0
- package/lib/mail/directoryApi.ts +30 -12
- package/package.json +1 -1
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useState } from "react";
|
|
7
|
+
import { gravatarEnabled, gravatarUrl } from "../../contacts/gravatar.js";
|
|
8
|
+
/** A fixed palette of background colors, cycled by a hash of the contact's name, for the initials shown when there is no picture. */
|
|
5
9
|
const PALETTE = ["#7c3aed", "#2563eb", "#0891b2", "#059669", "#d97706", "#dc2626", "#db2777", "#4f46e5"];
|
|
6
10
|
function initialsOf(displayName) {
|
|
7
11
|
const parts = displayName.trim().split(/\s+/).filter(Boolean);
|
|
@@ -19,8 +23,27 @@ function colorOf(displayName) {
|
|
|
19
23
|
}
|
|
20
24
|
return PALETTE[Math.abs(hash) % PALETTE.length];
|
|
21
25
|
}
|
|
22
|
-
/**
|
|
23
|
-
*
|
|
24
|
-
|
|
26
|
+
/**
|
|
27
|
+
* A small circular avatar for a contact, matching Outlook People's: the contact's own picture when they have one, else their Gravatar (when `email`
|
|
28
|
+
* is given), else initials on a color that is deterministic per name. A picture that fails to load falls through to the next.
|
|
29
|
+
*/
|
|
30
|
+
export default function ContactAvatar({ displayName, size = 32, photoUrl, email }) {
|
|
31
|
+
const [gravatar, setGravatar] = useState();
|
|
32
|
+
const [failed, setFailed] = useState([]);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
setGravatar(undefined);
|
|
35
|
+
if (!email || !gravatarEnabled()) {
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
let cancelled = false;
|
|
39
|
+
void gravatarUrl(email, size * 2).then((url) => !cancelled && setGravatar(url));
|
|
40
|
+
return () => {
|
|
41
|
+
cancelled = true;
|
|
42
|
+
};
|
|
43
|
+
}, [email, size]);
|
|
44
|
+
const src = [photoUrl, gravatar].find((url) => url && !failed.includes(url));
|
|
45
|
+
if (src) {
|
|
46
|
+
return (_jsx("img", { src: src, alt: "", "aria-hidden": "true", width: size, height: size, referrerPolicy: "no-referrer", onError: () => setFailed((list) => [...list, src]), className: "inline-block rounded-full object-cover shrink-0", style: { width: size, height: size } }));
|
|
47
|
+
}
|
|
25
48
|
return (_jsx("span", { "aria-hidden": "true", style: { width: size, height: size, backgroundColor: colorOf(displayName), fontSize: size * 0.4 }, className: "inline-flex items-center justify-center rounded-full text-white font-semibold shrink-0", children: initialsOf(displayName) }));
|
|
26
49
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface CopyIconButtonProps {
|
|
3
|
+
/** The text put on the clipboard. */
|
|
4
|
+
value: string;
|
|
5
|
+
/** The button's accessible name and tooltip - say what is copied, e.g. "Copy address". */
|
|
6
|
+
label: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A small icon-only "copy" button to sit right after a value (an email address). The icon turns into a check mark for a moment once the value is on
|
|
11
|
+
* the clipboard, and a polite live region says "Copied" (or "Couldn't copy") for screen readers.
|
|
12
|
+
*/
|
|
13
|
+
export default function CopyIconButton({ value, label, className }: CopyIconButtonProps): React.JSX.Element;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { HiCheck, HiOutlineClipboardDocument } from "react-icons/hi2";
|
|
3
|
+
import useCopyToClipboard from "../../util/useCopyToClipboard.js";
|
|
4
|
+
/**
|
|
5
|
+
* A small icon-only "copy" button to sit right after a value (an email address). The icon turns into a check mark for a moment once the value is on
|
|
6
|
+
* the clipboard, and a polite live region says "Copied" (or "Couldn't copy") for screen readers.
|
|
7
|
+
*/
|
|
8
|
+
export default function CopyIconButton({ value, label, className }) {
|
|
9
|
+
const { status, copy } = useCopyToClipboard();
|
|
10
|
+
return (_jsxs("span", { className: "inline-flex items-center align-middle", children: [_jsx("button", { type: "button", "aria-label": label, title: label, onClick: () => void copy(value), className: [
|
|
11
|
+
"inline-flex items-center justify-center rounded-sm p-1 text-text-muted hover:text-text hover:bg-surface-alt focus-visible:outline-2 focus-visible:outline-primary",
|
|
12
|
+
className,
|
|
13
|
+
]
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.join(" "), children: status === "copied" ? (_jsx(HiCheck, { "aria-hidden": "true", className: "size-4 text-success" })) : (_jsx(HiOutlineClipboardDocument, { "aria-hidden": "true", className: "size-4" })) }), _jsx("span", { role: "status", "aria-live": "polite", className: "sr-only", children: status === "copied" ? "Copied" : status === "failed" ? "Couldn’t copy" : "" })] }));
|
|
16
|
+
}
|
|
@@ -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
|
|
@@ -151,6 +154,18 @@ export declare function updateContact(patch: ContactPatch, client?: ApiClient):
|
|
|
151
154
|
* `setMessageRead()`/`tasksApi.ts`'s `setTaskCompleted()`. Sends only `uid`/`version`/`favorite`, never
|
|
152
155
|
* the rest of `contact` (the server rejects its managed fields). */
|
|
153
156
|
export declare function setContactFavorite(contact: Contact, favorite: boolean, client?: ApiClient): Promise<Contact>;
|
|
157
|
+
/**
|
|
158
|
+
* The URL of a contact's own picture, or `undefined` when they have none. The version is part of it so a replaced picture is fetched again rather than
|
|
159
|
+
* served from the browser's cache.
|
|
160
|
+
*/
|
|
161
|
+
export declare function contactPhotoUrl(contact: Pick<Contact, "uid" | "version" | "photoBlobKey">): string | undefined;
|
|
162
|
+
/** The picture types the server accepts for a contact, and the most bytes it takes. */
|
|
163
|
+
export declare const CONTACT_PHOTO_TYPES: string[];
|
|
164
|
+
export declare const CONTACT_PHOTO_MAX_BYTES: number;
|
|
165
|
+
/** Sets a contact's own picture from an image file (the file's own bytes go up, not JSON) and returns the updated contact. */
|
|
166
|
+
export declare function uploadContactPhoto(uid: string, version: number, file: Blob): Promise<Contact>;
|
|
167
|
+
/** Removes a contact's own picture and returns the updated contact. */
|
|
168
|
+
export declare function deleteContactPhoto(uid: string, version: number, client?: ApiClient): Promise<Contact>;
|
|
154
169
|
export declare function deleteContact(uid: string, version: number, client?: ApiClient): Promise<void>;
|
|
155
170
|
export interface ContactList {
|
|
156
171
|
uid: string;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
5
|
/** Typed wrappers over `@rapidmx/restapi`'s `/mail/contacts` REST surface — see `mailApi.ts`'s own header
|
|
6
6
|
* comment for the shared ACL/authorization model every wrapper file here follows. */
|
|
7
|
-
import { withClient } from "../util/api.js";
|
|
7
|
+
import { ApiRequestError, apiUrl, withClient, withCsrfHeader } from "../util/api.js";
|
|
8
8
|
import { buildQuery } from "../util/apiQuery.js";
|
|
9
9
|
import { signingKeyFingerprints, } from "../crypto/keyvaultApi.js";
|
|
10
10
|
/** Lists a folder's contacts, alphabetically by display name. Never includes soft-deleted contacts — see
|
|
@@ -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++) {
|
|
@@ -118,6 +118,35 @@ export function updateContact(patch, client) {
|
|
|
118
118
|
export function setContactFavorite(contact, favorite, client) {
|
|
119
119
|
return updateContact({ uid: contact.uid, version: contact.version, favorite }, client);
|
|
120
120
|
}
|
|
121
|
+
/**
|
|
122
|
+
* The URL of a contact's own picture, or `undefined` when they have none. The version is part of it so a replaced picture is fetched again rather than
|
|
123
|
+
* served from the browser's cache.
|
|
124
|
+
*/
|
|
125
|
+
export function contactPhotoUrl(contact) {
|
|
126
|
+
return contact.photoBlobKey ? apiUrl(`/mail/contacts/${encodeURIComponent(contact.uid)}/photo?v=${contact.version}`) : undefined;
|
|
127
|
+
}
|
|
128
|
+
/** The picture types the server accepts for a contact, and the most bytes it takes. */
|
|
129
|
+
export const CONTACT_PHOTO_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
|
130
|
+
export const CONTACT_PHOTO_MAX_BYTES = 1024 * 1024;
|
|
131
|
+
/** Sets a contact's own picture from an image file (the file's own bytes go up, not JSON) and returns the updated contact. */
|
|
132
|
+
export async function uploadContactPhoto(uid, version, file) {
|
|
133
|
+
const res = await fetch(apiUrl(`/mail/contacts/${encodeURIComponent(uid)}/photo?version=${version}`), {
|
|
134
|
+
method: "PUT",
|
|
135
|
+
credentials: "include",
|
|
136
|
+
headers: withCsrfHeader({ "Content-Type": file.type }, "PUT"),
|
|
137
|
+
body: file,
|
|
138
|
+
});
|
|
139
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
140
|
+
const body = contentType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Upload failed.", res.status, body?.code);
|
|
143
|
+
}
|
|
144
|
+
return body;
|
|
145
|
+
}
|
|
146
|
+
/** Removes a contact's own picture and returns the updated contact. */
|
|
147
|
+
export function deleteContactPhoto(uid, version, client) {
|
|
148
|
+
return withClient(client, `/mail/contacts/${encodeURIComponent(uid)}/photo?version=${version}`, { method: "DELETE" });
|
|
149
|
+
}
|
|
121
150
|
export function deleteContact(uid, version, client) {
|
|
122
151
|
return withClient(client, `/mail/contacts/${encodeURIComponent(uid)}?version=${version}`, { method: "DELETE" });
|
|
123
152
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The `localStorage` key of the privacy preference: `"off"` stops contact avatars from asking Gravatar for a picture. */
|
|
2
|
+
export declare const GRAVATAR_PREFERENCE_KEY = "rapidmx:gravatar";
|
|
3
|
+
/**
|
|
4
|
+
* Whether avatars may look a person up at Gravatar by the SHA-256 hash of their address (never the address itself). On unless the reader turned
|
|
5
|
+
* it off (`localStorage["rapidmx:gravatar"] = "off"`); a browser without storage keeps the default.
|
|
6
|
+
*/
|
|
7
|
+
export declare function gravatarEnabled(): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* The Gravatar picture URL for `email`, `size` pixels square. It answers `404` (`d=404`) for a person with no Gravatar, so an `<img>` fails to load
|
|
10
|
+
* and the caller falls back to initials rather than showing Gravatar's placeholder. `undefined` for an empty address or without Web Crypto.
|
|
11
|
+
*/
|
|
12
|
+
export declare function gravatarUrl(email: string, size: number): Promise<string | undefined>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/** The `localStorage` key of the privacy preference: `"off"` stops contact avatars from asking Gravatar for a picture. */
|
|
6
|
+
export const GRAVATAR_PREFERENCE_KEY = "rapidmx:gravatar";
|
|
7
|
+
/**
|
|
8
|
+
* Whether avatars may look a person up at Gravatar by the SHA-256 hash of their address (never the address itself). On unless the reader turned
|
|
9
|
+
* it off (`localStorage["rapidmx:gravatar"] = "off"`); a browser without storage keeps the default.
|
|
10
|
+
*/
|
|
11
|
+
export function gravatarEnabled() {
|
|
12
|
+
try {
|
|
13
|
+
return globalThis.localStorage?.getItem(GRAVATAR_PREFERENCE_KEY) !== "off";
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const hashes = new Map();
|
|
20
|
+
/** The lowercase-hex SHA-256 of a trimmed, lowercased `email`, or `undefined` where the browser has no Web Crypto (an insecure page). */
|
|
21
|
+
function hashOf(email) {
|
|
22
|
+
const normalized = email.trim().toLowerCase();
|
|
23
|
+
let hash = hashes.get(normalized);
|
|
24
|
+
if (!hash) {
|
|
25
|
+
hash = (async () => {
|
|
26
|
+
const digest = await globalThis.crypto?.subtle?.digest("SHA-256", new TextEncoder().encode(normalized));
|
|
27
|
+
return digest && Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
28
|
+
})().catch(() => undefined);
|
|
29
|
+
hashes.set(normalized, hash);
|
|
30
|
+
}
|
|
31
|
+
return hash;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* The Gravatar picture URL for `email`, `size` pixels square. It answers `404` (`d=404`) for a person with no Gravatar, so an `<img>` fails to load
|
|
35
|
+
* and the caller falls back to initials rather than showing Gravatar's placeholder. `undefined` for an empty address or without Web Crypto.
|
|
36
|
+
*/
|
|
37
|
+
export async function gravatarUrl(email, size) {
|
|
38
|
+
if (!email.trim()) {
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
const hash = await hashOf(email);
|
|
42
|
+
return hash && `https://gravatar.com/avatar/${hash}?s=${Math.max(1, Math.round(size))}&d=404`;
|
|
43
|
+
}
|
|
@@ -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
|
}
|
|
@@ -2,16 +2,19 @@
|
|
|
2
2
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
3
|
// SPDX-License-Identifier: MPL-2.0
|
|
4
4
|
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
-
import React from "react";
|
|
5
|
+
import React, { useEffect, useState } from "react";
|
|
6
|
+
import { gravatarEnabled, gravatarUrl } from "../../contacts/gravatar.js";
|
|
6
7
|
|
|
7
8
|
export interface ContactAvatarProps {
|
|
8
9
|
displayName: string;
|
|
9
10
|
size?: number;
|
|
11
|
+
/** The contact's own picture (see `contactPhotoUrl()`); shown in preference to anything else. */
|
|
12
|
+
photoUrl?: string;
|
|
13
|
+
/** The contact's address: without a picture of their own, its Gravatar is shown when there is one (and the reader hasn't turned that off). */
|
|
14
|
+
email?: string;
|
|
10
15
|
}
|
|
11
16
|
|
|
12
|
-
/** A fixed palette of background colors, cycled by a hash of the contact's name
|
|
13
|
-
* exists yet (`Contact.photoBlobKey` is modeled server-side but nothing in this app sets/reads it), so every
|
|
14
|
-
* contact gets one of these deterministically rather than a plain generic gray circle for everyone. */
|
|
17
|
+
/** A fixed palette of background colors, cycled by a hash of the contact's name, for the initials shown when there is no picture. */
|
|
15
18
|
const PALETTE = ["#7c3aed", "#2563eb", "#0891b2", "#059669", "#d97706", "#dc2626", "#db2777", "#4f46e5"];
|
|
16
19
|
|
|
17
20
|
function initialsOf(displayName: string): string {
|
|
@@ -32,9 +35,42 @@ function colorOf(displayName: string): string {
|
|
|
32
35
|
return PALETTE[Math.abs(hash) % PALETTE.length];
|
|
33
36
|
}
|
|
34
37
|
|
|
35
|
-
/**
|
|
36
|
-
*
|
|
37
|
-
|
|
38
|
+
/**
|
|
39
|
+
* A small circular avatar for a contact, matching Outlook People's: the contact's own picture when they have one, else their Gravatar (when `email`
|
|
40
|
+
* is given), else initials on a color that is deterministic per name. A picture that fails to load falls through to the next.
|
|
41
|
+
*/
|
|
42
|
+
export default function ContactAvatar({ displayName, size = 32, photoUrl, email }: ContactAvatarProps) {
|
|
43
|
+
const [gravatar, setGravatar] = useState<string | undefined>();
|
|
44
|
+
const [failed, setFailed] = useState<string[]>([]);
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
setGravatar(undefined);
|
|
48
|
+
if (!email || !gravatarEnabled()) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
let cancelled = false;
|
|
52
|
+
void gravatarUrl(email, size * 2).then((url) => !cancelled && setGravatar(url));
|
|
53
|
+
return () => {
|
|
54
|
+
cancelled = true;
|
|
55
|
+
};
|
|
56
|
+
}, [email, size]);
|
|
57
|
+
|
|
58
|
+
const src = [photoUrl, gravatar].find((url) => url && !failed.includes(url));
|
|
59
|
+
if (src) {
|
|
60
|
+
return (
|
|
61
|
+
<img
|
|
62
|
+
src={src}
|
|
63
|
+
alt=""
|
|
64
|
+
aria-hidden="true"
|
|
65
|
+
width={size}
|
|
66
|
+
height={size}
|
|
67
|
+
referrerPolicy="no-referrer"
|
|
68
|
+
onError={() => setFailed((list) => [...list, src])}
|
|
69
|
+
className="inline-block rounded-full object-cover shrink-0"
|
|
70
|
+
style={{ width: size, height: size }}
|
|
71
|
+
/>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
38
74
|
return (
|
|
39
75
|
<span
|
|
40
76
|
aria-hidden="true"
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { HiCheck, HiOutlineClipboardDocument } from "react-icons/hi2";
|
|
7
|
+
import useCopyToClipboard from "../../util/useCopyToClipboard.js";
|
|
8
|
+
|
|
9
|
+
export interface CopyIconButtonProps {
|
|
10
|
+
/** The text put on the clipboard. */
|
|
11
|
+
value: string;
|
|
12
|
+
/** The button's accessible name and tooltip - say what is copied, e.g. "Copy address". */
|
|
13
|
+
label: string;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A small icon-only "copy" button to sit right after a value (an email address). The icon turns into a check mark for a moment once the value is on
|
|
19
|
+
* the clipboard, and a polite live region says "Copied" (or "Couldn't copy") for screen readers.
|
|
20
|
+
*/
|
|
21
|
+
export default function CopyIconButton({ value, label, className }: CopyIconButtonProps) {
|
|
22
|
+
const { status, copy } = useCopyToClipboard();
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
<span className="inline-flex items-center align-middle">
|
|
26
|
+
<button
|
|
27
|
+
type="button"
|
|
28
|
+
aria-label={label}
|
|
29
|
+
title={label}
|
|
30
|
+
onClick={() => void copy(value)}
|
|
31
|
+
className={[
|
|
32
|
+
"inline-flex items-center justify-center rounded-sm p-1 text-text-muted hover:text-text hover:bg-surface-alt focus-visible:outline-2 focus-visible:outline-primary",
|
|
33
|
+
className,
|
|
34
|
+
]
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.join(" ")}
|
|
37
|
+
>
|
|
38
|
+
{status === "copied" ? (
|
|
39
|
+
<HiCheck aria-hidden="true" className="size-4 text-success" />
|
|
40
|
+
) : (
|
|
41
|
+
<HiOutlineClipboardDocument aria-hidden="true" className="size-4" />
|
|
42
|
+
)}
|
|
43
|
+
</button>
|
|
44
|
+
<span role="status" aria-live="polite" className="sr-only">
|
|
45
|
+
{status === "copied" ? "Copied" : status === "failed" ? "Couldn’t copy" : ""}
|
|
46
|
+
</span>
|
|
47
|
+
</span>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
/** Typed wrappers over `@rapidmx/restapi`'s `/mail/contacts` REST surface — see `mailApi.ts`'s own header
|
|
6
6
|
* comment for the shared ACL/authorization model every wrapper file here follows. */
|
|
7
7
|
|
|
8
|
-
import { ApiClient, withClient } from "../util/api.js";
|
|
8
|
+
import { ApiClient, ApiRequestError, apiUrl, withClient, withCsrfHeader } from "../util/api.js";
|
|
9
9
|
import { ListParams, buildQuery } from "../util/apiQuery.js";
|
|
10
10
|
import {
|
|
11
11
|
type EncryptionPreference,
|
|
@@ -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++) {
|
|
@@ -252,6 +252,39 @@ export function setContactFavorite(contact: Contact, favorite: boolean, client?:
|
|
|
252
252
|
return updateContact({ uid: contact.uid, version: contact.version, favorite }, client);
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
+
/**
|
|
256
|
+
* The URL of a contact's own picture, or `undefined` when they have none. The version is part of it so a replaced picture is fetched again rather than
|
|
257
|
+
* served from the browser's cache.
|
|
258
|
+
*/
|
|
259
|
+
export function contactPhotoUrl(contact: Pick<Contact, "uid" | "version" | "photoBlobKey">): string | undefined {
|
|
260
|
+
return contact.photoBlobKey ? apiUrl(`/mail/contacts/${encodeURIComponent(contact.uid)}/photo?v=${contact.version}`) : undefined;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** The picture types the server accepts for a contact, and the most bytes it takes. */
|
|
264
|
+
export const CONTACT_PHOTO_TYPES = ["image/jpeg", "image/png", "image/gif", "image/webp"];
|
|
265
|
+
export const CONTACT_PHOTO_MAX_BYTES = 1024 * 1024;
|
|
266
|
+
|
|
267
|
+
/** Sets a contact's own picture from an image file (the file's own bytes go up, not JSON) and returns the updated contact. */
|
|
268
|
+
export async function uploadContactPhoto(uid: string, version: number, file: Blob): Promise<Contact> {
|
|
269
|
+
const res = await fetch(apiUrl(`/mail/contacts/${encodeURIComponent(uid)}/photo?version=${version}`), {
|
|
270
|
+
method: "PUT",
|
|
271
|
+
credentials: "include",
|
|
272
|
+
headers: withCsrfHeader({ "Content-Type": file.type }, "PUT"),
|
|
273
|
+
body: file,
|
|
274
|
+
});
|
|
275
|
+
const contentType = res.headers.get("content-type") ?? "";
|
|
276
|
+
const body = contentType.includes("application/json") ? await res.json().catch(() => undefined) : undefined;
|
|
277
|
+
if (!res.ok) {
|
|
278
|
+
throw new ApiRequestError((body && (body.message || body.error)) || res.statusText || "Upload failed.", res.status, body?.code);
|
|
279
|
+
}
|
|
280
|
+
return body as Contact;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Removes a contact's own picture and returns the updated contact. */
|
|
284
|
+
export function deleteContactPhoto(uid: string, version: number, client?: ApiClient): Promise<Contact> {
|
|
285
|
+
return withClient(client, `/mail/contacts/${encodeURIComponent(uid)}/photo?version=${version}`, { method: "DELETE" });
|
|
286
|
+
}
|
|
287
|
+
|
|
255
288
|
export function deleteContact(uid: string, version: number, client?: ApiClient): Promise<void> {
|
|
256
289
|
return withClient(client, `/mail/contacts/${encodeURIComponent(uid)}?version=${version}`, { method: "DELETE" });
|
|
257
290
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
|
|
6
|
+
/** The `localStorage` key of the privacy preference: `"off"` stops contact avatars from asking Gravatar for a picture. */
|
|
7
|
+
export const GRAVATAR_PREFERENCE_KEY = "rapidmx:gravatar";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether avatars may look a person up at Gravatar by the SHA-256 hash of their address (never the address itself). On unless the reader turned
|
|
11
|
+
* it off (`localStorage["rapidmx:gravatar"] = "off"`); a browser without storage keeps the default.
|
|
12
|
+
*/
|
|
13
|
+
export function gravatarEnabled(): boolean {
|
|
14
|
+
try {
|
|
15
|
+
return globalThis.localStorage?.getItem(GRAVATAR_PREFERENCE_KEY) !== "off";
|
|
16
|
+
} catch {
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const hashes = new Map<string, Promise<string | undefined>>();
|
|
22
|
+
|
|
23
|
+
/** The lowercase-hex SHA-256 of a trimmed, lowercased `email`, or `undefined` where the browser has no Web Crypto (an insecure page). */
|
|
24
|
+
function hashOf(email: string): Promise<string | undefined> {
|
|
25
|
+
const normalized = email.trim().toLowerCase();
|
|
26
|
+
let hash = hashes.get(normalized);
|
|
27
|
+
if (!hash) {
|
|
28
|
+
hash = (async () => {
|
|
29
|
+
const digest = await globalThis.crypto?.subtle?.digest("SHA-256", new TextEncoder().encode(normalized));
|
|
30
|
+
return digest && Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
31
|
+
})().catch(() => undefined);
|
|
32
|
+
hashes.set(normalized, hash);
|
|
33
|
+
}
|
|
34
|
+
return hash;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The Gravatar picture URL for `email`, `size` pixels square. It answers `404` (`d=404`) for a person with no Gravatar, so an `<img>` fails to load
|
|
39
|
+
* and the caller falls back to initials rather than showing Gravatar's placeholder. `undefined` for an empty address or without Web Crypto.
|
|
40
|
+
*/
|
|
41
|
+
export async function gravatarUrl(email: string, size: number): Promise<string | undefined> {
|
|
42
|
+
if (!email.trim()) {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
const hash = await hashOf(email);
|
|
46
|
+
return hash && `https://gravatar.com/avatar/${hash}?s=${Math.max(1, Math.round(size))}&d=404`;
|
|
47
|
+
}
|