@rapidmx/web-client 0.26.0 → 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/contacts/ContactCard.tsx +38 -12
- 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/layout/AppShell.tsx +14 -6
- package/apps/shared/components/mail/MessageDetailPane.tsx +18 -18
- 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/contacts/ContactCard.js +19 -3
- 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/layout/AppShell.d.ts +3 -2
- package/dist/apps/shared/components/layout/AppShell.js +13 -5
- package/dist/apps/shared/components/mail/MessageDetailPane.js +2 -5
- 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 +12 -0
- package/dist/lib/contacts/contactsApi.js +30 -1
- package/dist/lib/contacts/gravatar.d.ts +12 -0
- package/dist/lib/contacts/gravatar.js +43 -0
- package/lib/components/avatar/ContactAvatar.tsx +43 -7
- package/lib/components/buttons/CopyIconButton.tsx +49 -0
- package/lib/contacts/contactsApi.ts +34 -1
- package/lib/contacts/gravatar.ts +47 -0
- package/package.json +1 -1
|
@@ -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,
|
|
@@ -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
|
+
}
|
package/package.json
CHANGED