@rapidmx/web-client 0.26.0 → 0.28.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/ConversationList.tsx +3 -3
- package/apps/shared/components/mail/MessageDetailPane.tsx +18 -18
- package/apps/shared/components/settings/layout/SettingsShell.tsx +3 -3
- package/apps/shared/mail/usePermanentDelete.tsx +14 -7
- 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/apps/www/index.tsx +43 -35
- 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/ConversationList.d.ts +2 -2
- package/dist/apps/shared/components/mail/ConversationList.js +1 -1
- 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/mail/usePermanentDelete.d.ts +2 -0
- package/dist/apps/shared/mail/usePermanentDelete.js +13 -8
- 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/apps/www/index.js +38 -29
- 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
|
@@ -5,10 +5,12 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { useState } from "react";
|
|
7
7
|
import { ApiRequestError } from "../../../../lib/util/api.js";
|
|
8
|
-
import { createContact, updateContact, } from "../../../../lib/contacts/contactsApi.js";
|
|
8
|
+
import { contactPhotoUrl, createContact, deleteContactPhoto, updateContact, uploadContactPhoto, } from "../../../../lib/contacts/contactsApi.js";
|
|
9
9
|
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
10
10
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
11
11
|
import FormField from "../../../../lib/components/forms/FormField.js";
|
|
12
|
+
import ContactPhotoField from "./ContactPhotoField.js";
|
|
13
|
+
import FavoriteStarButton from "./FavoriteStarButton.js";
|
|
12
14
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
13
15
|
import { findWellKnownFolderUid } from "../../mail/findWellKnownFolderUid.js";
|
|
14
16
|
import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
|
|
@@ -36,6 +38,9 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
36
38
|
// Every stored address is kept and editable - editing a contact must never silently drop addresses
|
|
37
39
|
// beyond the first.
|
|
38
40
|
const [addresses, setAddresses] = useState(contact?.addresses ?? []);
|
|
41
|
+
// A picture chosen (or the current one dropped) is applied once the contact itself is saved.
|
|
42
|
+
const [pickedPhoto, setPickedPhoto] = useState(null);
|
|
43
|
+
const [removePhoto, setRemovePhoto] = useState(false);
|
|
39
44
|
const [error, setError] = useState(null);
|
|
40
45
|
const [saving, setSaving] = useState(false);
|
|
41
46
|
function updateEmail(index, patch) {
|
|
@@ -107,6 +112,12 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
107
112
|
...input,
|
|
108
113
|
}, client);
|
|
109
114
|
}
|
|
115
|
+
if (pickedPhoto) {
|
|
116
|
+
saved = await uploadContactPhoto(saved.uid, saved.version, pickedPhoto);
|
|
117
|
+
}
|
|
118
|
+
else if (removePhoto && saved.photoBlobKey) {
|
|
119
|
+
saved = await deleteContactPhoto(saved.uid, saved.version, client);
|
|
120
|
+
}
|
|
110
121
|
// A message opened later in this page load must see the changed contact's signing keys.
|
|
111
122
|
clearPinnedSignerCache();
|
|
112
123
|
onSaved(saved);
|
|
@@ -118,5 +129,12 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
118
129
|
setSaving(false);
|
|
119
130
|
}
|
|
120
131
|
}
|
|
121
|
-
return (_jsxs("form", { onSubmit: handleSubmit, className: "@container flex min-h-0 flex-1 flex-col", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsxs("div", { className: "flex max-w-xl flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), !contact && mailboxes && mailboxes.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "contact-mailbox", children: _jsx("select", { id: "contact-mailbox", className: `${SELECT_CLASS} w-full`, value: targetMailboxUid, onChange: (e) => setTargetMailboxUid(e.target.value), children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) }) })), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "
|
|
132
|
+
return (_jsxs("form", { onSubmit: handleSubmit, className: "@container flex min-h-0 flex-1 flex-col", children: [_jsx("div", { className: "min-h-0 flex-1 overflow-y-auto p-6", children: _jsxs("div", { className: "flex max-w-xl flex-col gap-1", children: [_jsx("h1", { className: "text-xl font-bold uppercase tracking-wide mb-3", children: contact ? "Edit contact" : "New contact" }), error && _jsx(Alert, { children: error }), !contact && mailboxes && mailboxes.length > 1 && (_jsx(FormField, { label: "Mailbox", htmlFor: "contact-mailbox", children: _jsx("select", { id: "contact-mailbox", className: `${SELECT_CLASS} w-full`, value: targetMailboxUid, onChange: (e) => setTargetMailboxUid(e.target.value), children: mailboxes.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) }) })), _jsx(FormField, { label: "Display name", htmlFor: "contact-displayName", children: _jsx("input", { id: "contact-displayName", type: "text", className: INPUT_CLASS, value: displayName, onChange: (e) => setDisplayName(e.target.value) }) }), _jsxs("div", { className: "flex items-start gap-2", children: [_jsx("div", { className: "min-w-0 flex-1", children: _jsx(ContactPhotoField, { displayName: displayName, email: emails[0]?.address, currentUrl: contact && contactPhotoUrl(contact), picked: pickedPhoto, removed: removePhoto, onPick: (file) => {
|
|
133
|
+
setPickedPhoto(file);
|
|
134
|
+
setRemovePhoto(false);
|
|
135
|
+
setError(null);
|
|
136
|
+
}, onRemove: () => {
|
|
137
|
+
setPickedPhoto(null);
|
|
138
|
+
setRemovePhoto(true);
|
|
139
|
+
}, onReject: setError }) }), _jsx(FavoriteStarButton, { favorite: favorite, onToggle: () => setFavorite((value) => !value) })] }), _jsxs("div", { className: "grid grid-cols-1 @md:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "First name", htmlFor: "contact-givenName", children: _jsx("input", { id: "contact-givenName", type: "text", className: INPUT_CLASS, value: givenName, onChange: (e) => setGivenName(e.target.value) }) }), _jsx(FormField, { label: "Last name", htmlFor: "contact-surname", children: _jsx("input", { id: "contact-surname", type: "text", className: INPUT_CLASS, value: surname, onChange: (e) => setSurname(e.target.value) }) })] }), _jsxs("div", { className: "grid grid-cols-1 @md:grid-cols-2 gap-3", children: [_jsx(FormField, { label: "Company", htmlFor: "contact-company", children: _jsx("input", { id: "contact-company", type: "text", className: INPUT_CLASS, value: company, onChange: (e) => setCompany(e.target.value) }) }), _jsx(FormField, { label: "Job title", htmlFor: "contact-jobTitle", children: _jsx("input", { id: "contact-jobTitle", type: "text", className: INPUT_CLASS, value: jobTitle, onChange: (e) => setJobTitle(e.target.value) }) })] }), _jsx(FormField, { label: "Categories (comma-separated)", htmlFor: "contact-categories", children: _jsx("input", { id: "contact-categories", type: "text", className: INPUT_CLASS, value: categoriesText, onChange: (e) => setCategoriesText(e.target.value), placeholder: "VIP, Work" }) }), _jsx(FormField, { label: "Email", htmlFor: "contact-emails", children: _jsxs("div", { id: "contact-emails", className: "flex flex-col gap-2", children: [emails.map((email, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "email", className: `${INPUT_CLASS} flex-1`, value: email.address, onChange: (e) => updateEmail(i, { address: e.target.value }), "aria-label": `Email address ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: email.type, onChange: (e) => updateEmail(i, { type: e.target.value }), "aria-label": `Email type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removeEmail(i), className: "text-sm text-danger px-2", "aria-label": `Remove email ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setEmails((prev) => [...prev, { address: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add email" })] }) }), _jsx(FormField, { label: "Phone", htmlFor: "contact-phones", children: _jsxs("div", { id: "contact-phones", className: "flex flex-col gap-2", children: [phones.map((phone, i) => (_jsxs("div", { className: "flex gap-2", children: [_jsx("input", { type: "tel", className: `${INPUT_CLASS} flex-1`, value: phone.phoneNumber, onChange: (e) => updatePhone(i, { phoneNumber: e.target.value }), "aria-label": `Phone number ${i + 1}` }), _jsx("select", { className: SELECT_CLASS, value: phone.type, onChange: (e) => updatePhone(i, { type: e.target.value }), "aria-label": `Phone type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) }), _jsx("button", { type: "button", onClick: () => removePhone(i), className: "text-sm text-danger px-2", "aria-label": `Remove phone ${i + 1}`, children: "\u00D7" })] }, i))), _jsx("button", { type: "button", onClick: () => setPhones((prev) => [...prev, { phoneNumber: "", type: "work" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add phone" })] }) }), _jsx(FormField, { label: "Address", htmlFor: "contact-address", children: _jsxs("div", { className: "flex flex-col gap-3", children: [addresses.map((address, i) => (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("div", { className: "grid grid-cols-1 @md:grid-cols-2 gap-2", children: [_jsx("input", { type: "text", placeholder: "Street", className: INPUT_CLASS, value: address.street ?? "", onChange: (e) => updateAddress(i, { street: e.target.value }) }), _jsx("input", { type: "text", placeholder: "City", className: INPUT_CLASS, value: address.city ?? "", onChange: (e) => updateAddress(i, { city: e.target.value }) }), _jsx("input", { type: "text", placeholder: "State/Province", className: INPUT_CLASS, value: address.state ?? "", onChange: (e) => updateAddress(i, { state: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Postal code", className: INPUT_CLASS, value: address.postalCode ?? "", onChange: (e) => updateAddress(i, { postalCode: e.target.value }) }), _jsx("input", { type: "text", placeholder: "Country", className: INPUT_CLASS, value: address.country ?? "", onChange: (e) => updateAddress(i, { country: e.target.value }) }), _jsx("select", { className: SELECT_CLASS, value: address.type, onChange: (e) => updateAddress(i, { type: e.target.value }), "aria-label": i === 0 ? "Address type" : `Address type ${i + 1}`, children: ADDRESS_KINDS.map((kind) => (_jsx("option", { value: kind, children: kind }, kind))) })] }), _jsx("button", { type: "button", onClick: () => removeAddress(i), className: "self-start text-xs font-medium text-danger hover:underline", "aria-label": i === 0 ? "Remove address" : `Remove address ${i + 1}`, children: "Remove address" })] }, i))), _jsx("button", { type: "button", onClick: () => setAddresses((prev) => [...prev, { type: "home" }]), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add address" })] }) }), _jsx(FormField, { label: "Notes", htmlFor: "contact-notes", children: _jsx("textarea", { id: "contact-notes", rows: 3, className: INPUT_CLASS, value: notes, onChange: (e) => setNotes(e.target.value) }) })] }) }), _jsxs("div", { className: "rr-solid sticky bottom-14 z-10 flex shrink-0 gap-3 border-t border-border bg-surface px-6 py-3 md:bottom-0", children: [_jsx(Button, { type: "submit", loading: saving, disabled: saving, className: "!w-auto", children: "Save" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onCancel, children: "Cancel" })] })] }));
|
|
122
140
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface ContactPhotoFieldProps {
|
|
3
|
+
displayName: string;
|
|
4
|
+
/** The contact's first address, for the Gravatar shown while they have no picture of their own. */
|
|
5
|
+
email?: string;
|
|
6
|
+
/** The picture the contact has now (`contactPhotoUrl()`), if any. */
|
|
7
|
+
currentUrl?: string;
|
|
8
|
+
/** The picture chosen to replace it, not uploaded until the contact is saved; `null` for none. */
|
|
9
|
+
picked: File | null;
|
|
10
|
+
/** Whether the current picture is to be removed when the contact is saved. */
|
|
11
|
+
removed: boolean;
|
|
12
|
+
onPick: (file: File) => void;
|
|
13
|
+
onRemove: () => void;
|
|
14
|
+
/** Called with the reason a chosen file was turned down. */
|
|
15
|
+
onReject: (message: string) => void;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* The picture of a contact in the edit form: the avatar as it is (their picture, else Gravatar, else initials) with "Change photo" and, when
|
|
19
|
+
* there is a picture of their own, "Remove photo". A file is checked here (type and size) and only uploaded when the form is saved.
|
|
20
|
+
*/
|
|
21
|
+
export default function ContactPhotoField({ displayName, email, currentUrl, picked, removed, onPick, onRemove, onReject }: ContactPhotoFieldProps): React.JSX.Element;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
|
+
import ContactAvatar from "../../../../lib/components/avatar/ContactAvatar.js";
|
|
8
|
+
import Button from "../../../../lib/components/buttons/Button.js";
|
|
9
|
+
import { CONTACT_PHOTO_MAX_BYTES, CONTACT_PHOTO_TYPES } from "../../../../lib/contacts/contactsApi.js";
|
|
10
|
+
/**
|
|
11
|
+
* The picture of a contact in the edit form: the avatar as it is (their picture, else Gravatar, else initials) with "Change photo" and, when
|
|
12
|
+
* there is a picture of their own, "Remove photo". A file is checked here (type and size) and only uploaded when the form is saved.
|
|
13
|
+
*/
|
|
14
|
+
export default function ContactPhotoField({ displayName, email, currentUrl, picked, removed, onPick, onRemove, onReject }) {
|
|
15
|
+
const input = useRef(null);
|
|
16
|
+
const [preview, setPreview] = useState();
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!picked) {
|
|
19
|
+
setPreview(undefined);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
const url = URL.createObjectURL(picked);
|
|
23
|
+
setPreview(url);
|
|
24
|
+
return () => URL.revokeObjectURL(url);
|
|
25
|
+
}, [picked]);
|
|
26
|
+
function handleChange(file) {
|
|
27
|
+
if (!file) {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!CONTACT_PHOTO_TYPES.includes(file.type)) {
|
|
31
|
+
onReject("Choose a JPEG, PNG, GIF or WebP image.");
|
|
32
|
+
}
|
|
33
|
+
else if (file.size > CONTACT_PHOTO_MAX_BYTES) {
|
|
34
|
+
onReject("Choose an image no larger than 1 MB.");
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
onPick(file);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const shown = picked ? preview : removed ? undefined : currentUrl;
|
|
41
|
+
const hasOwn = !!picked || (!!currentUrl && !removed);
|
|
42
|
+
return (_jsxs("div", { className: "flex items-center gap-4 mb-3", children: [_jsx(ContactAvatar, { displayName: displayName || "?", size: 72, photoUrl: shown, email: picked || (currentUrl && !removed) ? undefined : email }), _jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx("input", { ref: input, type: "file", accept: CONTACT_PHOTO_TYPES.join(","), "aria-label": "Contact photo file", className: "sr-only", tabIndex: -1, onChange: (e) => {
|
|
43
|
+
handleChange(e.target.files?.[0]);
|
|
44
|
+
e.target.value = "";
|
|
45
|
+
} }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto !py-1 !px-3 text-xs", onClick: () => input.current?.click(), children: hasOwn ? "Change photo" : "Add photo" }), hasOwn && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto !py-1 !px-3 text-xs", onClick: onRemove, children: "Remove photo" }))] })] }));
|
|
46
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export interface FavoriteStarButtonProps {
|
|
3
|
+
favorite: boolean;
|
|
4
|
+
onToggle: () => void;
|
|
5
|
+
disabled?: boolean;
|
|
6
|
+
className?: string;
|
|
7
|
+
}
|
|
8
|
+
/** The star that marks a contact as a favorite: filled when it is one. A toggle button (`aria-pressed`) named "Favorite". */
|
|
9
|
+
export default function FavoriteStarButton({ favorite, onToggle, disabled, className }: FavoriteStarButtonProps): React.JSX.Element;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { HiOutlineStar, HiStar } from "react-icons/hi2";
|
|
3
|
+
/** The star that marks a contact as a favorite: filled when it is one. A toggle button (`aria-pressed`) named "Favorite". */
|
|
4
|
+
export default function FavoriteStarButton({ favorite, onToggle, disabled, className }) {
|
|
5
|
+
return (_jsx("button", { type: "button", "aria-label": "Favorite", title: favorite ? "Remove from favorites" : "Add to favorites", "aria-pressed": favorite, disabled: disabled, onClick: onToggle, className: [
|
|
6
|
+
"inline-flex items-center justify-center rounded-sm p-1 hover:bg-surface-alt disabled:opacity-50 focus-visible:outline-2 focus-visible:outline-primary",
|
|
7
|
+
favorite ? "text-warning" : "text-text-muted hover:text-text",
|
|
8
|
+
className,
|
|
9
|
+
]
|
|
10
|
+
.filter(Boolean)
|
|
11
|
+
.join(" "), children: favorite ? _jsx(HiStar, { "aria-hidden": "true", className: "size-6" }) : _jsx(HiOutlineStar, { "aria-hidden": "true", className: "size-6" }) }));
|
|
12
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { IconType } from "react-icons";
|
|
|
4
4
|
import { NavItem } from "../../../../lib/components/navigation/BottomTabBar.js";
|
|
5
5
|
import type { Branding } from "../../../../lib/branding/brandingApi.js";
|
|
6
6
|
import { PluginNav, PluginNavProps } from "../../plugins/pluginNav.js";
|
|
7
|
+
import { type ResolvedRailLinks } from "../../plugins/useResolvedRailItems.js";
|
|
7
8
|
/** How long sign-out waits for auth-server's logout before navigating anyway. */
|
|
8
9
|
export declare const LOGOUT_TIMEOUT_MS = 3000;
|
|
9
10
|
export type AppShellApp = "mail" | "calendar" | "contacts" | "tasks";
|
|
@@ -47,8 +48,8 @@ export interface AppDef {
|
|
|
47
48
|
icon: IconType;
|
|
48
49
|
}
|
|
49
50
|
export declare const APPS: AppDef[];
|
|
50
|
-
/** `APPS` followed by the plugins' `appRail` items (generic
|
|
51
|
-
export declare function appRailItems(pluginNav?: PluginNav): NavItem[];
|
|
51
|
+
/** `APPS` followed by the plugins' `appRail` items (their manifest `icon`, or a generic one), core ids winning - see `mergePluginNavItems`. */
|
|
52
|
+
export declare function appRailItems(pluginNav?: PluginNav, resolved?: ResolvedRailLinks): NavItem[];
|
|
52
53
|
/** What only the persistent frame (`apps/www/_shell.tsx`) passes to the chrome it keeps mounted. */
|
|
53
54
|
export interface AppChromeProps extends AppShellProps {
|
|
54
55
|
/** The router is loading the next page - the content is `aria-busy`. */
|
|
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import "../../styles/app.css";
|
|
7
7
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
8
|
-
import { HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineEnvelope,
|
|
8
|
+
import { HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineEnvelope, HiOutlineUsers, } from "react-icons/hi2";
|
|
9
9
|
import { useRouter } from "@rapidrest/react/client";
|
|
10
10
|
import { useSessionRefresh } from "../../../../lib/auth/session.js";
|
|
11
11
|
import { getSetupStatus } from "../../../../lib/admin/setupApi.js";
|
|
@@ -27,7 +27,9 @@ import { SIGN_OUT_CHANNEL, destroyAllLocalIndexes } from "../../search/localInde
|
|
|
27
27
|
import { authApiFetch, setApiUnauthorizedObserver } from "../../../../lib/util/api.js";
|
|
28
28
|
import { destroyUnlockedKeys } from "../../../../lib/crypto/keySession.js";
|
|
29
29
|
import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
|
|
30
|
+
import { pluginIcon } from "../../plugins/pluginIcons.js";
|
|
30
31
|
import { mergePluginNavItems } from "../../plugins/pluginNav.js";
|
|
32
|
+
import { useResolvedRailItems } from "../../plugins/useResolvedRailItems.js";
|
|
31
33
|
import { useInAppFrame } from "../../navigation/frameContext.js";
|
|
32
34
|
import { APP_HREFS } from "../../navigation/appHrefs.js";
|
|
33
35
|
import { useNavigate } from "../../navigation/index.js";
|
|
@@ -77,9 +79,13 @@ export const APPS = [
|
|
|
77
79
|
/** Ids plugin `appRail` items can't take besides `APPS`' own - `"settings"` has no rail icon but is still a
|
|
78
80
|
* core `active` value. */
|
|
79
81
|
const RESERVED_APP_IDS = ["settings"];
|
|
80
|
-
/** `APPS` followed by the plugins' `appRail` items (generic
|
|
81
|
-
export function appRailItems(pluginNav) {
|
|
82
|
-
|
|
82
|
+
/** `APPS` followed by the plugins' `appRail` items (their manifest `icon`, or a generic one), core ids winning - see `mergePluginNavItems`. */
|
|
83
|
+
export function appRailItems(pluginNav, resolved = {}) {
|
|
84
|
+
// An entry worked out per user (`resolveFrom`) is there only for a user it was answered for, and links where the answer said; the others are as declared.
|
|
85
|
+
const shown = (pluginNav?.appRail ?? [])
|
|
86
|
+
.filter((item) => !item.resolveFrom || resolved[item.id] !== undefined)
|
|
87
|
+
.map((item) => (item.resolveFrom ? { ...item, href: resolved[item.id] } : item));
|
|
88
|
+
return mergePluginNavItems(APPS, shown, ({ id, href, label, icon }) => ({ id, href, label, icon: pluginIcon(icon) }), RESERVED_APP_IDS);
|
|
83
89
|
}
|
|
84
90
|
/**
|
|
85
91
|
* The persistent chrome shared by every webmail app (Mail, Calendar, Contacts, Tasks): a left icon rail for
|
|
@@ -232,10 +238,12 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
|
|
|
232
238
|
// The router sets the document's title to the page's own whenever the page changes, which drops the unread count this puts in front
|
|
233
239
|
// of it, so a new pathname is what puts the count back. (A folder change is shallow and keeps the title.)
|
|
234
240
|
useUnreadTitle(inboxUnreadTotal(mail.mailboxFolders, mail.folderCounts.counts), { enabled: inFrame, resetKey: pathname });
|
|
241
|
+
// Plugin rail entries that depend on the user (the meet plugin's personal room) are asked for once they are signed in.
|
|
242
|
+
const resolvedRail = useResolvedRailItems(pluginNav?.appRail, userUid);
|
|
235
243
|
if (!userUid) {
|
|
236
244
|
return _jsx("div", { className: "min-h-screen" });
|
|
237
245
|
}
|
|
238
|
-
const apps = appRailItems(pluginNav);
|
|
246
|
+
const apps = appRailItems(pluginNav, resolvedRail);
|
|
239
247
|
// The header title: "settings" has no rail item, everything else is labelled by its own rail item.
|
|
240
248
|
const activeLabel = active === "settings" ? "Settings" : apps.find((app) => app.id === active)?.label;
|
|
241
249
|
// A custom header (`Branding.headerHtml`) replaces the app's own title bar and the icon at the top of the rail: it is the top of the app, and the
|
|
@@ -30,9 +30,9 @@ export interface ConversationListProps {
|
|
|
30
30
|
* conversation is what this list's rows are. A child row stays a plain "open this message" button - a
|
|
31
31
|
* mixed conversation/message selection has no sensible bulk semantics (see `MailListToolbar`). */
|
|
32
32
|
selectMode?: boolean;
|
|
33
|
-
/** The `
|
|
33
|
+
/** The `conversationRowKey()`s currently ticked. */
|
|
34
34
|
selectedConversationIds?: Set<string>;
|
|
35
|
-
onToggleSelected?: (conversation:
|
|
35
|
+
onToggleSelected?: (conversation: ListedConversation) => void;
|
|
36
36
|
/**
|
|
37
37
|
* What swiping a parent row does on a phone: right to left archives the conversation (resolving whether it went through), left
|
|
38
38
|
* to right asks where to move it. Absent, or `enabled: false` (a desktop), rows don't follow a finger.
|
|
@@ -104,7 +104,7 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
|
|
|
104
104
|
// list itself is arranged in. Copied before reversing - the fetched array is cached.
|
|
105
105
|
const children = loaded && newestFirst ? [...loaded].reverse() : loaded;
|
|
106
106
|
const panelId = `conversation-messages-${conversation.mailboxUid ? `${conversation.mailboxUid}-` : ""}${conversation.conversationId}`;
|
|
107
|
-
const ticked = selectedConversationIds?.has(
|
|
107
|
+
const ticked = selectedConversationIds?.has(id) ?? false;
|
|
108
108
|
return (_jsxs("li", { children: [_jsxs(SwipeRow, { as: "div", enabled: !!swipe?.enabled && !selectMode, onArchive: () => swipe.onArchive(conversation), onMove: () => swipe.onMove(conversation), "data-message-uid": conversation.latestMessageUid, "data-unread": unread ? "true" : undefined, className: rowClass({ unread, selected: conversation.latestMessageUid === selectedUid || ticked }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: unread }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: () => onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: ["shrink-0 px-2 text-text-muted hover:text-text", ROW_FOCUS_CLASS].join(" "), children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, conversation.latestMessageUid), className: ["w-full text-left pr-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: unread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: conversation.participants[0] ?? conversation.latestFrom, className: ["flex-1", senderClass(unread)].join(" ") }), conversation.participants.length > 1 && (_jsxs("span", { className: "text-xs text-text-muted shrink-0 font-normal", title: participantList(conversation), children: ["+", conversation.participants.length - 1, _jsxs("span", { className: "sr-only", children: [" more: ", participantList(conversation)] })] })), _jsx("span", { className: ["text-xs shrink-0", dateClass(unread)].join(" "), children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: ["text-sm truncate", subjectClass(unread)].join(" "), children: decrypted[conversation.latestMessageUid]?.subject || displaySubject(conversation.subject) || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: conversationLooksEncrypted(conversation) ? decrypted[conversation.latestMessageUid]?.preview || _jsx(EncryptedPreview, {}) : conversation.latestPreview }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && conversation.messageCount > 1 && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/15 text-primary-dark font-bold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 13, "aria-label": "Has attachments" })), conversation.flagged && (_jsx(HiOutlineFlag, { size: 13, "aria-label": "Flagged", className: "text-danger" }))] })] }), _jsx(InviteRowChip, { message: {
|
|
109
109
|
uid: conversation.latestMessageUid,
|
|
110
110
|
meetingMethod: conversation.latestMeetingMethod ?? undefined,
|
|
@@ -59,7 +59,7 @@ import { buildConversationPrintDocument, printDocument } from "./reading/printMe
|
|
|
59
59
|
import { olderConversationMessages, printHeaders, printableOlderMessage } from "./reading/conversationPrint.js";
|
|
60
60
|
import { fetchBodyContent } from "./reading/bodyContent.js";
|
|
61
61
|
import { useViewOriginal } from "./reading/viewOriginal.js";
|
|
62
|
-
import { ROW_FOCUS_CLASS,
|
|
62
|
+
import { ROW_FOCUS_CLASS, dateClass, senderClass } from "./unreadStyle.js";
|
|
63
63
|
/** Labels/styling for `specs/end-to-end_encryption.md`'s "Message Security Indicators" table - kept as
|
|
64
64
|
* plain data (not JSX) so `SecurityIndicator` below stays a trivial lookup. "Signature failed" MUST NOT
|
|
65
65
|
* read as a muted variant of "verified" (a failed signature is a stronger negative than no signature at
|
|
@@ -928,10 +928,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
928
928
|
: "This message can't be read, so it can't be printed";
|
|
929
929
|
// In Junk Email the card's button is the way back (Not junk), which only needs the right to change the message.
|
|
930
930
|
const reportReason = !writable ? "View-only mailbox" : undefined;
|
|
931
|
-
const card = (_jsxs(CardShell, { unread: cardUnread, children: [_jsxs("div", { className: ["flex flex-wrap items-start gap-x-3 gap-y-1 px-4 pt-3 pb-2", cardUnread ? "bg-primary/[0.07]" : ""].join(" "), children: [threadHeader ? (_jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, label: `Contact card for ${senderLabel}`, children: _jsx(
|
|
932
|
-
"w-full text-left rounded-sm text-sm",
|
|
933
|
-
ROW_FOCUS_CLASS,
|
|
934
|
-
].join(" "), children: [_jsx(UnreadLabel, { unread: cardUnread }), _jsx(MailAddress, { recipient: senderRecipient, className: senderClass(cardUnread) })] }) })) : (_jsxs("p", { className: "text-sm break-words min-w-0", children: ["From", " ", _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, className: "font-semibold text-text", children: senderLabel })] })), security && _jsx(SecurityIndicator, { security: security })] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsx(RecipientLine, { label: "To", recipients: message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc"), context: cardContext }), _jsx(RecipientLine, { label: "Cc", recipients: message.recipients.filter((r) => r.type === "cc"), context: cardContext }), _jsx(RecipientLine, { label: "Bcc", recipients: message.recipients.filter((r) => r.type === "bcc"), context: cardContext })] }), _jsxs("div", { className: "ml-auto flex flex-wrap items-center justify-end gap-1", children: [_jsx("time", { dateTime: message.receivedDate, className: ["text-xs mr-1", dateClass(cardUnread)].join(" "), children: new Date(message.receivedDate).toLocaleString() }), adaptable && (_jsx("button", { type: "button", "aria-label": "View original", "aria-pressed": viewOriginal, title: viewOriginal ? "Follow the theme" : "View original", onClick: () => setViewOriginal(!viewOriginal), className: [
|
|
931
|
+
const card = (_jsxs(CardShell, { unread: cardUnread, children: [_jsxs("div", { className: ["flex flex-wrap items-start gap-x-3 gap-y-1 px-4 pt-3 pb-2", cardUnread ? "bg-primary/[0.07]" : ""].join(" "), children: [_jsx(SenderAvatar, { from: senderRecipient }), _jsxs("div", { className: "flex-1 min-w-[12rem]", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1", children: [threadHeader ? (_jsxs("h2", { className: "relative min-w-0 flex-1", style: BODY_FONT_STYLE, children: [_jsx("button", { type: "button", ref: threadHeader.buttonRef, onClick: threadHeader.onToggle, "aria-expanded": true, "aria-controls": threadHeader.bodyId, "aria-label": `${cardUnread ? "Unread. " : ""}${senderLabel}`, className: ["absolute inset-0 w-full rounded-sm", ROW_FOCUS_CLASS].join(" ") }), _jsx("span", { className: "pointer-events-none relative flex text-sm", children: _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, label: `Contact card for ${senderLabel}`, className: "pointer-events-auto min-w-0 max-w-full", children: _jsx(MailAddress, { recipient: senderRecipient, className: senderClass(cardUnread) }) }) })] })) : (_jsxs("p", { className: "text-sm break-words min-w-0", children: ["From", " ", _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, className: "font-semibold text-text", children: senderLabel })] })), security && _jsx(SecurityIndicator, { security: security })] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsx(RecipientLine, { label: "To", recipients: message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc"), context: cardContext }), _jsx(RecipientLine, { label: "Cc", recipients: message.recipients.filter((r) => r.type === "cc"), context: cardContext }), _jsx(RecipientLine, { label: "Bcc", recipients: message.recipients.filter((r) => r.type === "bcc"), context: cardContext })] }), _jsxs("div", { className: "ml-auto flex flex-wrap items-center justify-end gap-1", children: [_jsx("time", { dateTime: message.receivedDate, className: ["text-xs mr-1", dateClass(cardUnread)].join(" "), children: new Date(message.receivedDate).toLocaleString() }), adaptable && (_jsx("button", { type: "button", "aria-label": "View original", "aria-pressed": viewOriginal, title: viewOriginal ? "Follow the theme" : "View original", onClick: () => setViewOriginal(!viewOriginal), className: [
|
|
935
932
|
"inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt",
|
|
936
933
|
viewOriginal ? "bg-primary/10" : "",
|
|
937
934
|
].join(" "), children: viewOriginal ? _jsx(HiOutlineMoon, { size: 16, "aria-hidden": "true" }) : _jsx(HiOutlineSun, { size: 16, "aria-hidden": "true" }) })), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true" }), label: "Reply", shortcut: keyboard ? SHORTCUTS.mail.reply : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("reply") }), _jsx(IconAction, { icon: _jsxs("span", { className: "inline-flex items-center", "aria-hidden": "true", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16 }), _jsx(HiOutlineArrowUturnLeft, { size: 16, className: "-ml-2.5" })] }), label: "Reply All", shortcut: keyboard ? SHORTCUTS.mail.replyAll : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("replyAll") }), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnRight, { size: 16, "aria-hidden": "true" }), label: "Forward", shortcut: keyboard ? SHORTCUTS.mail.forward : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("forward") }), archivable && (_jsx(IconAction, { icon: _jsx(HiOutlineArchiveBox, { size: 16, "aria-hidden": "true" }), label: "Archive", shortcut: keyboard ? SHORTCUTS.mail.archive : undefined, busy: archiving, disabled: archiving, onClick: handleArchive })), movable && (_jsx(IconAction, { icon: _jsx(HiOutlineFolderArrowDown, { size: 16, "aria-hidden": "true" }), label: "Move to", shortcut: keyboard ? SHORTCUTS.mail.move : undefined, onClick: () => setMovePrompt(true) })), reportable && (_jsx(IconAction, { icon: inJunk ? _jsx(HiOutlineInboxArrowDown, { size: 16, "aria-hidden": "true" }) : _jsx(HiOutlineNoSymbol, { size: 16, "aria-hidden": "true" }), label: inJunk ? "Not junk" : "Report junk", busy: messageActions.busy, disabled: messageActions.busy || reportReason !== undefined, reason: reportReason, onClick: () => void (inJunk ? messageActions.notJunk() : messageActions.reportJunk()) })), labels && labels.length > 0 && (_jsx(LabelMenuButton, { "aria-label": "Labels", label: "Labels", className: "border border-border py-1.5", labels: labels, mailboxUid: message.mailboxUid, onLabelCreated: onLabelCreated, applied: currentLabelsMessage.labelUids ?? [], onCommit: (labelUids) => void handleApplyLabels(labelUids), busy: savingLabels, note: "Ticked labels are applied to this message and unticked ones removed.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } })), archivable && (_jsx(MessageMoreMenu, { actions: menuActions, senderAddress: message.from.address, read: currentLabelsMessage.flags.read === true, flagged: currentLabelsMessage.flags.flagged === true, writable: writable, busy: messageActions.busy, inJunk: inJunk, inDeletedItems: inDeletedItems, sent: !!isSentItems, ownSender: readerOwnAddresses.includes(message.from.address.toLowerCase()), canTrustSender: writable, printReason: printReason, composing: preparingCompose, triggerClassName: ICON_BUTTON_CLASS }))] })] }), (answered || forwarded) && (_jsx("p", { className: "px-4 py-1.5 text-xs text-text-muted bg-surface-alt border-y border-border", children: answered && forwarded ? "You replied to and forwarded this message." : answered ? "You replied to this message." : "You forwarded this message." })), _jsxs("div", { id: threadHeader?.bodyId, className: "px-4 pb-3 flex flex-col gap-2", children: [inThread && (subjectDiffers || !sameSubject(shownSubject, threadSubject)) && (_jsx("h3", { className: "text-sm font-semibold break-words", children: shownSubject })), _jsx("div", { className: "flex flex-wrap items-center gap-2 empty:hidden", children: sendingStatus }), inOutbox && message.scheduledSendError && _jsxs(Alert, { children: ["This message wasn\u2019t sent: ", message.scheduledSendError] }), cancelError && _jsx(Alert, { children: cancelError }), keyChangeNotice && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: keyChangeNotice })), security && UNVERIFIED_SIGNER_STATES.has(security.state) && (_jsxs("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: ["Signed, but the signer isn\u2019t a trusted contact key, so the sender isn\u2019t verified.", security.signerEmails && security.signerEmails.length > 0 && _jsxs(_Fragment, { children: [" Certificate for ", security.signerEmails.join(", "), "."] }), security.signerFingerprint && (_jsxs(_Fragment, { children: [" ", "Fingerprint ", _jsx("span", { className: "font-mono text-xs break-all", children: security.signerFingerprint }), "."] }))] })), pendingConflict && (_jsxs("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["This sender has a signing key change waiting for your review, so this signer can\u2019t be trusted from here.", " ", _jsx("a", { href: `/contacts/${encodeURIComponent(senderKeyState.contactUid)}`, className: "font-medium text-primary-dark hover:underline", children: "Review it in Contacts" })] })), trustableCertificate && (_jsx("div", { children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => {
|
|
@@ -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 { HiOutlineArrowUturnLeft, HiOutlineBars3, HiOutlineEnvelopeOpen, HiOutlineFunnel, HiOutlineLockClosed, HiOutlineNoSymbol, HiOutlinePaintBrush, HiOutlinePencilSquare,
|
|
7
|
+
import { HiOutlineArrowUturnLeft, HiOutlineBars3, HiOutlineEnvelopeOpen, HiOutlineFunnel, HiOutlineLockClosed, HiOutlineNoSymbol, HiOutlinePaintBrush, HiOutlinePencilSquare, 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";
|
|
@@ -15,6 +15,7 @@ import AppShell from "../../layout/AppShell.js";
|
|
|
15
15
|
import { useLocation, useNavigate } from "../../../navigation/index.js";
|
|
16
16
|
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
17
17
|
import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.js";
|
|
18
|
+
import { pluginIcon } from "../../../plugins/pluginIcons.js";
|
|
18
19
|
import { mergePluginNavItems } from "../../../plugins/pluginNav.js";
|
|
19
20
|
/** Every settings section with its own sidebar entry. */
|
|
20
21
|
export const SETTINGS_SECTIONS = [
|
|
@@ -33,11 +34,11 @@ export const SETTINGS_SECTIONS = [
|
|
|
33
34
|
/** `SETTINGS_SECTIONS` followed by the plugins' `settingsSections` items, core ids winning - see
|
|
34
35
|
* `mergePluginNavItems`. */
|
|
35
36
|
export function settingsSections(pluginNav) {
|
|
36
|
-
return mergePluginNavItems(SETTINGS_SECTIONS, pluginNav?.settingsSections, ({ id, href, label }) => ({
|
|
37
|
+
return mergePluginNavItems(SETTINGS_SECTIONS, pluginNav?.settingsSections, ({ id, href, label, icon }) => ({
|
|
37
38
|
id,
|
|
38
39
|
href,
|
|
39
40
|
label,
|
|
40
|
-
icon:
|
|
41
|
+
icon: pluginIcon(icon),
|
|
41
42
|
}));
|
|
42
43
|
}
|
|
43
44
|
const SettingsShellContext = createContext({ mailboxes: [] });
|
|
@@ -5,6 +5,8 @@ import { EmptyFolderOutcome, PurgeOutcome } from "./permanentDelete.js";
|
|
|
5
5
|
export interface EmptiableFolder {
|
|
6
6
|
uid: string;
|
|
7
7
|
name: string;
|
|
8
|
+
/** Every folder to empty, when "the folder" is the "All mailboxes" view of one type (each mailbox's own Deleted Items): `uid` is then the first of them. */
|
|
9
|
+
uids?: string[];
|
|
8
10
|
}
|
|
9
11
|
export interface PermanentDelete {
|
|
10
12
|
/**
|
|
@@ -65,14 +65,19 @@ export function usePermanentDelete() {
|
|
|
65
65
|
finish(outcome);
|
|
66
66
|
return;
|
|
67
67
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
68
|
+
const outcome = { emptied: true, deleted: [], failed: [] };
|
|
69
|
+
for (const folderUid of request.folder.uids ?? [request.folder.uid]) {
|
|
70
|
+
try {
|
|
71
|
+
const one = await purgeFolder(folderUid, undefined, client);
|
|
72
|
+
outcome.emptied && (outcome.emptied = one.emptied);
|
|
73
|
+
outcome.deleted.push(...one.deleted);
|
|
74
|
+
outcome.failed.push(...one.failed);
|
|
75
|
+
}
|
|
76
|
+
catch (err) {
|
|
77
|
+
notifyApiError(err, `Couldn't empty ${request.folder.name}`);
|
|
78
|
+
// Part of it may have gone before the failure: the caller reloads rather than assume nothing did.
|
|
79
|
+
outcome.emptied = false;
|
|
80
|
+
}
|
|
76
81
|
}
|
|
77
82
|
afterDeleted(outcome.deleted);
|
|
78
83
|
// The whole-folder request does not say which messages went, so the counts are read back rather than adjusted.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { IconType } from "react-icons";
|
|
2
|
+
/** The icon a plugin nav item gets when its manifest names none, or one that isn't in `PLUGIN_ICONS`. */
|
|
3
|
+
export declare const DEFAULT_PLUGIN_ICON: IconType;
|
|
4
|
+
/**
|
|
5
|
+
* The `react-icons/hi2` icons a plugin manifest's nav `icon` can name. A fixed list rather than the whole icon set: importing every
|
|
6
|
+
* icon by name would put all of them in the bundle. A plugin that needs another one gets it added here.
|
|
7
|
+
*/
|
|
8
|
+
export declare const PLUGIN_ICONS: Readonly<Record<string, IconType>>;
|
|
9
|
+
/** The icon for a plugin nav item's `icon` name - see `PLUGIN_ICONS` - or `DEFAULT_PLUGIN_ICON`. */
|
|
10
|
+
export declare function pluginIcon(name: string | undefined): IconType;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { HiOutlineAcademicCap, HiOutlineBanknotes, HiOutlineBeaker, HiOutlineBellAlert, HiOutlineBolt, HiOutlineBookOpen, HiOutlineBriefcase, HiOutlineBuildingOffice2, HiOutlineCalendarDays, HiOutlineChartBar, HiOutlineChartPie, HiOutlineChatBubbleLeftRight, HiOutlineClipboardDocumentCheck, HiOutlineCloud, HiOutlineCog6Tooth, HiOutlineCreditCard, HiOutlineCube, HiOutlineCurrencyDollar, HiOutlineDocumentText, HiOutlineEnvelopeOpen, HiOutlineFolderOpen, HiOutlineFunnel, HiOutlineGlobeAlt, HiOutlineHeart, HiOutlineIdentification, HiOutlineInboxStack, HiOutlineKey, HiOutlineLifebuoy, HiOutlineLightBulb, HiOutlineMap, HiOutlineMegaphone, HiOutlineNewspaper, HiOutlinePaperAirplane, HiOutlinePhone, HiOutlinePhoto, HiOutlinePresentationChartLine, HiOutlinePuzzlePiece, HiOutlineQueueList, HiOutlineRectangleGroup, HiOutlineRocketLaunch, HiOutlineShieldCheck, HiOutlineShoppingCart, HiOutlineSparkles, HiOutlineSquares2X2, HiOutlineTag, HiOutlineTicket, HiOutlineTrophy, HiOutlineUserGroup, HiOutlineUsers, HiOutlineVideoCamera, HiOutlineWrenchScrewdriver, } from "react-icons/hi2";
|
|
2
|
+
/** The icon a plugin nav item gets when its manifest names none, or one that isn't in `PLUGIN_ICONS`. */
|
|
3
|
+
export const DEFAULT_PLUGIN_ICON = HiOutlinePuzzlePiece;
|
|
4
|
+
/**
|
|
5
|
+
* The `react-icons/hi2` icons a plugin manifest's nav `icon` can name. A fixed list rather than the whole icon set: importing every
|
|
6
|
+
* icon by name would put all of them in the bundle. A plugin that needs another one gets it added here.
|
|
7
|
+
*/
|
|
8
|
+
export const PLUGIN_ICONS = {
|
|
9
|
+
HiOutlineAcademicCap,
|
|
10
|
+
HiOutlineBanknotes,
|
|
11
|
+
HiOutlineBeaker,
|
|
12
|
+
HiOutlineBellAlert,
|
|
13
|
+
HiOutlineBolt,
|
|
14
|
+
HiOutlineBookOpen,
|
|
15
|
+
HiOutlineBriefcase,
|
|
16
|
+
HiOutlineBuildingOffice2,
|
|
17
|
+
HiOutlineCalendarDays,
|
|
18
|
+
HiOutlineChartBar,
|
|
19
|
+
HiOutlineChartPie,
|
|
20
|
+
HiOutlineChatBubbleLeftRight,
|
|
21
|
+
HiOutlineClipboardDocumentCheck,
|
|
22
|
+
HiOutlineCloud,
|
|
23
|
+
HiOutlineCog6Tooth,
|
|
24
|
+
HiOutlineCreditCard,
|
|
25
|
+
HiOutlineCube,
|
|
26
|
+
HiOutlineCurrencyDollar,
|
|
27
|
+
HiOutlineDocumentText,
|
|
28
|
+
HiOutlineEnvelopeOpen,
|
|
29
|
+
HiOutlineFolderOpen,
|
|
30
|
+
HiOutlineFunnel,
|
|
31
|
+
HiOutlineGlobeAlt,
|
|
32
|
+
HiOutlineHeart,
|
|
33
|
+
HiOutlineIdentification,
|
|
34
|
+
HiOutlineInboxStack,
|
|
35
|
+
HiOutlineKey,
|
|
36
|
+
HiOutlineLifebuoy,
|
|
37
|
+
HiOutlineLightBulb,
|
|
38
|
+
HiOutlineMap,
|
|
39
|
+
HiOutlineMegaphone,
|
|
40
|
+
HiOutlineNewspaper,
|
|
41
|
+
HiOutlinePaperAirplane,
|
|
42
|
+
HiOutlinePhone,
|
|
43
|
+
HiOutlinePhoto,
|
|
44
|
+
HiOutlinePresentationChartLine,
|
|
45
|
+
HiOutlinePuzzlePiece,
|
|
46
|
+
HiOutlineQueueList,
|
|
47
|
+
HiOutlineRectangleGroup,
|
|
48
|
+
HiOutlineRocketLaunch,
|
|
49
|
+
HiOutlineShieldCheck,
|
|
50
|
+
HiOutlineShoppingCart,
|
|
51
|
+
HiOutlineSparkles,
|
|
52
|
+
HiOutlineSquares2X2,
|
|
53
|
+
HiOutlineTag,
|
|
54
|
+
HiOutlineTicket,
|
|
55
|
+
HiOutlineTrophy,
|
|
56
|
+
HiOutlineUserGroup,
|
|
57
|
+
HiOutlineUsers,
|
|
58
|
+
HiOutlineVideoCamera,
|
|
59
|
+
HiOutlineWrenchScrewdriver,
|
|
60
|
+
};
|
|
61
|
+
/** The icon for a plugin nav item's `icon` name - see `PLUGIN_ICONS` - or `DEFAULT_PLUGIN_ICON`. */
|
|
62
|
+
export function pluginIcon(name) {
|
|
63
|
+
return (name && Object.prototype.hasOwnProperty.call(PLUGIN_ICONS, name) ? PLUGIN_ICONS[name] : undefined) ?? DEFAULT_PLUGIN_ICON;
|
|
64
|
+
}
|
|
@@ -8,6 +8,14 @@ export interface PluginUiNavItem {
|
|
|
8
8
|
label: string;
|
|
9
9
|
/** A same-origin path under the host's prefix (`/settings/...`, `/admin/...`, `/...`). */
|
|
10
10
|
href: string;
|
|
11
|
+
/** A `react-icons/hi2` icon name from `PLUGIN_ICONS` (`pluginIcons.ts`); anything else shows the generic puzzle piece. */
|
|
12
|
+
icon?: string;
|
|
13
|
+
/**
|
|
14
|
+
* For an app-rail entry that depends on the signed-in user: an API path (without the `/api` prefix, e.g. `/mail/video-meetings/personal-room`)
|
|
15
|
+
* the shell requests for them. The entry is shown only when that answers `200 { href }` with a same-origin path, and links there instead of to
|
|
16
|
+
* `href`; anything else hides it. See `useResolvedRailItems()`.
|
|
17
|
+
*/
|
|
18
|
+
resolveFrom?: string;
|
|
11
19
|
}
|
|
12
20
|
/**
|
|
13
21
|
* The navigation every enabled plugin with a successfully built UI contributes, supplied by the server's
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type ApiClient } from "../../../lib/util/api.js";
|
|
2
|
+
import { type PluginUiNavItem } from "./pluginNav.js";
|
|
3
|
+
/** Where each app-rail entry that is worked out per user points, by entry id: only the entries the server answered for are here. */
|
|
4
|
+
export type ResolvedRailLinks = Record<string, string>;
|
|
5
|
+
/**
|
|
6
|
+
* Asks, once per signed-in user, for the entries of a plugin's app rail that are worked out per user (they name an API path in `resolveFrom` - the
|
|
7
|
+
* meet plugin's "Meet" button is one: it shows only for a user who has a personal room, and links to it). Each path is requested through
|
|
8
|
+
* `apiFetch()`; a `200` answering `{ href }` with a same-origin path puts that link in the result, and anything else (no such thing for this user, a
|
|
9
|
+
* server error, an answer that is not a path on this site) leaves the entry out, so a failing plugin never breaks the rail. Entries with no
|
|
10
|
+
* `resolveFrom` are not asked about. Nothing is requested without a signed-in user.
|
|
11
|
+
*/
|
|
12
|
+
export declare function useResolvedRailItems(items: PluginUiNavItem[] | undefined, userUid: string | undefined, client?: ApiClient): ResolvedRailLinks;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
import { withClient } from "../../../lib/util/api.js";
|
|
7
|
+
import { useApiClient } from "../../../lib/util/apiClientContext.js";
|
|
8
|
+
import { isSafePluginHref } from "./pluginNav.js";
|
|
9
|
+
/**
|
|
10
|
+
* Asks, once per signed-in user, for the entries of a plugin's app rail that are worked out per user (they name an API path in `resolveFrom` - the
|
|
11
|
+
* meet plugin's "Meet" button is one: it shows only for a user who has a personal room, and links to it). Each path is requested through
|
|
12
|
+
* `apiFetch()`; a `200` answering `{ href }` with a same-origin path puts that link in the result, and anything else (no such thing for this user, a
|
|
13
|
+
* server error, an answer that is not a path on this site) leaves the entry out, so a failing plugin never breaks the rail. Entries with no
|
|
14
|
+
* `resolveFrom` are not asked about. Nothing is requested without a signed-in user.
|
|
15
|
+
*/
|
|
16
|
+
export function useResolvedRailItems(items, userUid, client) {
|
|
17
|
+
const explicitClient = useApiClient();
|
|
18
|
+
const [links, setLinks] = useState({});
|
|
19
|
+
const asked = (items ?? []).filter((item) => typeof item?.resolveFrom === "string" && item.resolveFrom !== "");
|
|
20
|
+
// A list that is the same entries again (a new array on each render) does not start over.
|
|
21
|
+
const key = asked.map((item) => `${item.id}=${item.resolveFrom}`).join("|");
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
if (!userUid || asked.length === 0) {
|
|
24
|
+
setLinks({});
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
let cancelled = false;
|
|
28
|
+
void Promise.all(asked.map(async (item) => {
|
|
29
|
+
try {
|
|
30
|
+
const answer = await withClient(client ?? explicitClient, item.resolveFrom);
|
|
31
|
+
return typeof answer?.href === "string" && isSafePluginHref(answer.href) ? [item.id, answer.href] : null;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
})).then((results) => {
|
|
37
|
+
if (!cancelled) {
|
|
38
|
+
setLinks(Object.fromEntries(results.filter((entry) => entry !== null)));
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
return () => {
|
|
42
|
+
cancelled = true;
|
|
43
|
+
};
|
|
44
|
+
// `asked` is derived from `key`.
|
|
45
|
+
}, [key, userUid, client, explicitClient]);
|
|
46
|
+
return links;
|
|
47
|
+
}
|
|
@@ -72,7 +72,7 @@ function ContactDetailContent({ uid }) {
|
|
|
72
72
|
setMode("view");
|
|
73
73
|
}, onCancel: () => setMode("view") })] }));
|
|
74
74
|
}
|
|
75
|
-
return (_jsx("div", { className: "p-6", children: _jsx(ContactDetailPane, { contact: contact, onEdit: () => setMode("edit"), onDelete: () => handleDelete(contact), backHref: backHref, onKeysChanged: handleKeysChanged }) }));
|
|
75
|
+
return (_jsx("div", { className: "p-6", children: _jsx(ContactDetailPane, { contact: contact, onEdit: () => setMode("edit"), onDelete: () => handleDelete(contact), backHref: backHref, onKeysChanged: handleKeysChanged, onChanged: setContact }) }));
|
|
76
76
|
}
|
|
77
77
|
export default ContactDetailPage;
|
|
78
78
|
/** The tab's title: `Brand: Contacts` (see `pageTitle()`). */
|