@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
|
@@ -0,0 +1,41 @@
|
|
|
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 { createContext, useCallback, useContext, useMemo, useState } from "react";
|
|
7
|
+
import { parseVCards } from "../../../../lib/contacts/vcard.js";
|
|
8
|
+
import { listMailboxes } from "../../../../lib/mail/mailApi.js";
|
|
9
|
+
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
10
|
+
import { MAILBOX_LIST_LIMIT, MailConnectionContext } from "../../mail/useMailConnection.js";
|
|
11
|
+
import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
|
|
12
|
+
import ContactCard from "./ContactCard.js";
|
|
13
|
+
import { addCardsToAddressBook, contactScope, loadFolders, scopeMailboxes } from "./contactCardData.js";
|
|
14
|
+
const CardContext = createContext({ show: () => undefined, addVCards: () => Promise.resolve({ added: 0, skipped: 0 }), available: false });
|
|
15
|
+
/** Shows a person's contact card from anywhere inside `AppShell` - see `ContactCard`. */
|
|
16
|
+
export function useContactCard() {
|
|
17
|
+
return useContext(CardContext);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Owns the one contact card that can be open. Mounted once in `AppShell` (inside the compose provider, whose windows the card's "Email" opens), so
|
|
21
|
+
* a participant is clickable in every app. The card is a modal: focus goes into it, and back to the participant that was clicked when it closes.
|
|
22
|
+
*/
|
|
23
|
+
export default function ContactCardProvider({ children, userUid }) {
|
|
24
|
+
const client = useApiClient();
|
|
25
|
+
const connection = useContext(MailConnectionContext);
|
|
26
|
+
const [shown, setShown] = useState(null);
|
|
27
|
+
const show = useCallback((participant, context) => {
|
|
28
|
+
setShown((previous) => ({ participant, context, id: (previous?.id ?? 0) + 1 }));
|
|
29
|
+
}, []);
|
|
30
|
+
const addVCards = useCallback(async (text, message) => {
|
|
31
|
+
const cards = parseVCards(text);
|
|
32
|
+
// The frame's mailboxes when it has them; a page rendered outside it asks.
|
|
33
|
+
const mailboxes = connection?.status === "ready" ? connection.mailboxes : await listMailboxes({ limit: MAILBOX_LIST_LIMIT }, client);
|
|
34
|
+
const folders = await loadFolders(scopeMailboxes(mailboxes, userUid, message), client);
|
|
35
|
+
const result = await addCardsToAddressBook(cards, contactScope(mailboxes, folders, userUid, message), client);
|
|
36
|
+
clearPinnedSignerCache();
|
|
37
|
+
return result;
|
|
38
|
+
}, [connection, client, userUid]);
|
|
39
|
+
const value = useMemo(() => ({ show, addVCards, available: true }), [show, addVCards]);
|
|
40
|
+
return (_jsxs(CardContext.Provider, { value: value, children: [children, shown && _jsx(ContactCard, { participant: shown.participant, context: shown.context, userUid: userUid, onClose: () => setShown(null) }, shown.id)] }));
|
|
41
|
+
}
|
|
@@ -11,6 +11,8 @@ export interface ContactDetailPaneProps {
|
|
|
11
11
|
/** Called after one of the contact's key changes was accepted or kept, or turned out to be stale - the caller
|
|
12
12
|
* re-reads the contact. */
|
|
13
13
|
onKeysChanged?: () => void;
|
|
14
|
+
/** Called with the contact after the star was toggled, so the caller shows the changed contact. */
|
|
15
|
+
onChanged?: (contact: Contact) => void;
|
|
14
16
|
/** `false` when the reader is known not to be able to change this contact's keys (the actions are hidden); `undefined`
|
|
15
17
|
* when unknown (they're hidden after a 403). */
|
|
16
18
|
canResolveKeys?: boolean;
|
|
@@ -20,4 +22,4 @@ export interface ContactDetailPaneProps {
|
|
|
20
22
|
* always visible alongside the contact list) and the mobile detail route
|
|
21
23
|
* (`apps/www/contacts/[uid].tsx`, a full page on its own reached by tapping a contact row).
|
|
22
24
|
*/
|
|
23
|
-
export default function ContactDetailPane({ contact, onEdit, onDelete, backHref, onKeysChanged, canResolveKeys }: ContactDetailPaneProps): React.JSX.Element;
|
|
25
|
+
export default function ContactDetailPane({ contact, onEdit, onDelete, backHref, onKeysChanged, onChanged, canResolveKeys }: ContactDetailPaneProps): React.JSX.Element;
|
|
@@ -4,6 +4,11 @@ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { useState } from "react";
|
|
7
|
+
import { contactPhotoUrl, setContactFavorite } from "../../../../lib/contacts/contactsApi.js";
|
|
8
|
+
import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
|
|
9
|
+
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
10
|
+
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
11
|
+
import FavoriteStarButton from "./FavoriteStarButton.js";
|
|
7
12
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
8
13
|
import ContactAvatar from "../../../../lib/components/avatar/ContactAvatar.js";
|
|
9
14
|
import KeyChangeReview from "./KeyChangeReview.js";
|
|
@@ -27,19 +32,29 @@ function RevocationBadge({ label }) {
|
|
|
27
32
|
* always visible alongside the contact list) and the mobile detail route
|
|
28
33
|
* (`apps/www/contacts/[uid].tsx`, a full page on its own reached by tapping a contact row).
|
|
29
34
|
*/
|
|
30
|
-
export default function ContactDetailPane({ contact, onEdit, onDelete, backHref, onKeysChanged, canResolveKeys }) {
|
|
35
|
+
export default function ContactDetailPane({ contact, onEdit, onDelete, backHref, onKeysChanged, onChanged, canResolveKeys }) {
|
|
36
|
+
const client = useApiClient();
|
|
31
37
|
// The outcome of the last key decision, kept per contact so selecting another contact doesn't carry it over.
|
|
32
38
|
const [keyNotice, setKeyNotice] = useState(null);
|
|
33
39
|
const notice = keyNotice?.contactUid === contact.uid ? keyNotice.text : null;
|
|
34
40
|
const keyConflicts = contact.keyConflicts ?? [];
|
|
35
41
|
const previousKeys = contact.previousKeys ?? [];
|
|
42
|
+
async function handleToggleFavorite() {
|
|
43
|
+
try {
|
|
44
|
+
const changed = await setContactFavorite(contact, !contact.favorite, client);
|
|
45
|
+
onChanged?.(changed);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
notifyApiError(err, "Couldn't update this contact");
|
|
49
|
+
}
|
|
50
|
+
}
|
|
36
51
|
// Pinned signing keys vouch for signatures, so the pinned-signer cache is dropped along with re-reading the contact.
|
|
37
52
|
function refresh(text) {
|
|
38
53
|
setKeyNotice({ contactUid: contact.uid, text });
|
|
39
54
|
clearPinnedSignerCache();
|
|
40
55
|
onKeysChanged?.();
|
|
41
56
|
}
|
|
42
|
-
return (_jsxs("div", { role: "region", "aria-label": "Contact details", className: "max-w-xl flex flex-col gap-5", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline", children: "\u2190 Back to contacts" })), _jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 48 }), _jsxs("div", { children: [
|
|
57
|
+
return (_jsxs("div", { role: "region", "aria-label": "Contact details", className: "max-w-xl flex flex-col gap-5", children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline", children: "\u2190 Back to contacts" })), _jsxs("div", { className: "flex items-start justify-between gap-4", children: [_jsxs("div", { className: "flex items-center gap-3", children: [_jsx(ContactAvatar, { displayName: contact.displayName, size: 48, photoUrl: contactPhotoUrl(contact), email: contact.emails[0]?.address }), _jsxs("div", { children: [_jsxs("h1", { className: "text-xl font-bold tracking-tight", children: [contact.displayName, _jsx(FavoriteStarButton, { favorite: !!contact.favorite, onToggle: () => void handleToggleFavorite(), className: "ml-1 align-middle" })] }), contact.jobTitle && contact.company && (_jsxs("p", { className: "text-sm text-text-muted mt-0.5", children: [contact.jobTitle, " at ", contact.company] }))] })] }), _jsxs("div", { className: "flex gap-2 shrink-0", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: onEdit, children: "Edit" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto text-danger", onClick: onDelete, children: "Delete" })] })] }), _jsxs("div", { className: "bg-surface border border-border rounded-md p-6 flex flex-col gap-4 text-sm", children: [contact.emails.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Email" }), contact.emails.map((e, i) => (_jsxs("div", { children: [e.address, _jsx(CopyIconButton, { value: e.address, label: "Copy email address", className: "mx-0.5" }), " ", _jsxs("span", { className: "text-text-muted", children: ["(", e.type, ")"] })] }, i)))] })), contact.phones.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Phone" }), contact.phones.map((p, i) => (_jsxs("div", { children: [p.phoneNumber, " ", _jsxs("span", { className: "text-text-muted", children: ["(", p.type, ")"] })] }, i)))] })), contact.addresses.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Address" }), contact.addresses.map((a, i) => (_jsx("div", { children: [a.street, a.city, a.state, a.postalCode, a.country].filter(Boolean).join(", ") }, i)))] })), contact.categories && contact.categories.length > 0 && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Categories" }), _jsx("div", { children: contact.categories.join(", ") })] })), contact.notes && (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Notes" }), _jsx("div", { children: contact.notes })] })), (contact.keys && contact.keys.length > 0) ||
|
|
43
58
|
contact.encryptPreference ||
|
|
44
59
|
keyConflicts.length > 0 ||
|
|
45
60
|
previousKeys.length > 0 ? (_jsxs("div", { children: [_jsx("div", { className: "text-text-muted text-xs font-bold uppercase tracking-wide mb-1", children: "Encryption" }), notice && (_jsx("p", { role: "status", className: "mb-2 py-2 px-3 rounded-sm bg-surface-alt text-text", children: notice })), keyConflicts.map((conflict) => {
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React, { ReactNode } from "react";
|
|
2
|
+
import type { ContactCardContext } from "./contactCardData.js";
|
|
3
|
+
import type { Participant } from "./participantDetails.js";
|
|
4
|
+
export interface ParticipantLinkProps {
|
|
5
|
+
participant: Participant;
|
|
6
|
+
/** The message the participant was met in - see `ContactCardValue.show`. */
|
|
7
|
+
context?: ContactCardContext;
|
|
8
|
+
/** The button's accessible name, when what it shows has none of its own (an avatar). */
|
|
9
|
+
label?: string;
|
|
10
|
+
className?: string;
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A participant of a message or an event (a sender, a recipient, an organizer, a guest) that opens their contact card when clicked: a real
|
|
15
|
+
* button, underlined on hover and with a visible focus ring, named by what it shows. Outside a `ContactCardProvider` it is plain text. Never
|
|
16
|
+
* put one inside another button or link.
|
|
17
|
+
*/
|
|
18
|
+
export default function ParticipantLink({ participant, context, label, className, children }: ParticipantLinkProps): React.JSX.Element;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { useContactCard } from "./ContactCardProvider.js";
|
|
3
|
+
/**
|
|
4
|
+
* A participant of a message or an event (a sender, a recipient, an organizer, a guest) that opens their contact card when clicked: a real
|
|
5
|
+
* button, underlined on hover and with a visible focus ring, named by what it shows. Outside a `ContactCardProvider` it is plain text. Never
|
|
6
|
+
* put one inside another button or link.
|
|
7
|
+
*/
|
|
8
|
+
export default function ParticipantLink({ participant, context, label, className, children }) {
|
|
9
|
+
const { show, available } = useContactCard();
|
|
10
|
+
if (!available) {
|
|
11
|
+
return _jsx("span", { className: className, children: children });
|
|
12
|
+
}
|
|
13
|
+
return (_jsx("button", { type: "button", "aria-label": label, onClick: () => show(participant, context), className: ["text-left rounded-sm hover:underline focus-visible:underline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary", className]
|
|
14
|
+
.filter(Boolean)
|
|
15
|
+
.join(" "), children: children }));
|
|
16
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { type CalendarOccurrence } from "../../../../lib/calendar/recurrence.js";
|
|
2
|
+
import { type Contact } from "../../../../lib/contacts/contactsApi.js";
|
|
3
|
+
import { type ParsedVCardContact } from "../../../../lib/contacts/vcard.js";
|
|
4
|
+
import { type Attachment, type Folder, type Mailbox, type Message } from "../../../../lib/mail/mailApi.js";
|
|
5
|
+
import { type ApiClient } from "../../../../lib/util/api.js";
|
|
6
|
+
import { type Participant } from "./participantDetails.js";
|
|
7
|
+
/** What the card is opened for besides who: the message the participant was met in, and that message's attachments when the caller already has them. */
|
|
8
|
+
export interface ContactCardContext {
|
|
9
|
+
message?: Message;
|
|
10
|
+
attachments?: Attachment[];
|
|
11
|
+
}
|
|
12
|
+
/** How many recent messages and upcoming events the card lists. */
|
|
13
|
+
export declare const CARD_LIST_SIZE = 5;
|
|
14
|
+
/** How far ahead "upcoming" looks, in days. */
|
|
15
|
+
export declare const UPCOMING_DAYS = 60;
|
|
16
|
+
/** The most vCard attachments and the most bytes of one that are read to fill a contact in. */
|
|
17
|
+
export declare const MAX_VCARD_FILES = 3;
|
|
18
|
+
export declare const MAX_VCARD_BYTES: number;
|
|
19
|
+
/** The mailboxes whose data the card reads: the user's own, else the primary/first one, plus the message's mailbox when it is one they can reach. */
|
|
20
|
+
export declare function scopeMailboxes(mailboxes: readonly Mailbox[], userUid: string | undefined, message?: Message): Mailbox[];
|
|
21
|
+
/** Every folder of `mailboxes`. A mailbox whose folders cannot be listed is left out rather than failing the rest. */
|
|
22
|
+
export declare function loadFolders(mailboxes: readonly Mailbox[], client?: ApiClient): Promise<Folder[]>;
|
|
23
|
+
/** The contact the address belongs to in `folders`' contacts folders, if any. */
|
|
24
|
+
export declare function findContact(folders: readonly Folder[], address: string, client?: ApiClient): Promise<Contact | undefined>;
|
|
25
|
+
/** The last `CARD_LIST_SIZE` messages to or from `address` in `mailboxes`, newest first. */
|
|
26
|
+
export declare function loadRecentMessages(mailboxes: readonly Mailbox[], address: string, client?: ApiClient): Promise<Message[]>;
|
|
27
|
+
/** The next `CARD_LIST_SIZE` events within `UPCOMING_DAYS` from `now` that `address` organizes or is invited to, soonest first. */
|
|
28
|
+
export declare function loadUpcomingEvents(folders: readonly Folder[], address: string, now: Date, client?: ApiClient): Promise<CalendarOccurrence[]>;
|
|
29
|
+
/** A vCard attachment's text. */
|
|
30
|
+
export declare function fetchVCardText(attachmentUid: string): Promise<string>;
|
|
31
|
+
/**
|
|
32
|
+
* The vCard of the context message that describes `participant`: the first attachment holding a card with their address, else - only for the
|
|
33
|
+
* message's sender, whose own vCard it presumably is - the first card there is. `undefined` when there is none, and for any failure to read one:
|
|
34
|
+
* a vCard only adds detail, it is never needed.
|
|
35
|
+
*/
|
|
36
|
+
export declare function loadParticipantCard(participant: Participant, context: ContactCardContext, client?: ApiClient): Promise<ParsedVCardContact | undefined>;
|
|
37
|
+
/** Where a new contact goes: the message's mailbox when it is one of the user's own, else their primary mailbox - in that mailbox's contacts folder. */
|
|
38
|
+
export declare function contactScope(mailboxes: readonly Mailbox[], folders: readonly Folder[], userUid: string | undefined, message?: Message): {
|
|
39
|
+
mailboxUid: string;
|
|
40
|
+
folderUid: string;
|
|
41
|
+
};
|
|
42
|
+
/** Stores a contact for `participant` with everything known - the name, the address and whatever `card` says. */
|
|
43
|
+
export declare function createContactFor(participant: Participant, card: ParsedVCardContact | undefined, scope: {
|
|
44
|
+
mailboxUid: string;
|
|
45
|
+
folderUid: string;
|
|
46
|
+
}, client?: ApiClient): Promise<Contact>;
|
|
47
|
+
/**
|
|
48
|
+
* The sender's own contact card as a file to attach, `<display name>.vcf`: the mailbox being sent from (its name and primary address) with the phones, company and
|
|
49
|
+
* title of the user's own contact entry for that address, when they have one in their own mailboxes' address books. A lookup that fails just leaves those out.
|
|
50
|
+
*/
|
|
51
|
+
export declare function buildOwnCardFile(mailbox: Mailbox, mailboxes: readonly Mailbox[], userUid: string | undefined, client?: ApiClient): Promise<File>;
|
|
52
|
+
/** The outcome of adding a vCard file's cards to the address book. */
|
|
53
|
+
export interface AddCardsResult {
|
|
54
|
+
added: number;
|
|
55
|
+
skipped: number;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Adds every card of a vCard file to the contacts folder `scope` names, skipping the ones whose email is already a stored contact's (or an earlier
|
|
59
|
+
* card's in the same file). A card that cannot be stored fails the whole call after the earlier ones were added - `AddCardsError` says how many.
|
|
60
|
+
*/
|
|
61
|
+
export declare function addCardsToAddressBook(cards: readonly ParsedVCardContact[], scope: {
|
|
62
|
+
mailboxUid: string;
|
|
63
|
+
folderUid: string;
|
|
64
|
+
}, client?: ApiClient): Promise<AddCardsResult>;
|
|
65
|
+
/** A card failed to store: the message is the failure's and `added` how many cards were stored before it. */
|
|
66
|
+
export declare class AddCardsError extends Error {
|
|
67
|
+
readonly added: number;
|
|
68
|
+
constructor(failure: unknown, added: number);
|
|
69
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* What the contact card (`ContactCard`) and the vCard attachment actions read and write: the user's contact and calendar folders, the contact
|
|
7
|
+
* behind an address, the last messages to or from it, its upcoming events, and the calls that make a contact from a participant or a vCard.
|
|
8
|
+
* Every function takes the `ApiClient` to use (`useApiClient()`), like the API modules under `lib/`.
|
|
9
|
+
*/
|
|
10
|
+
import { addDays } from "date-fns";
|
|
11
|
+
import { listCalendarEvents } from "../../../../lib/calendar/calendarApi.js";
|
|
12
|
+
import { expandAllOccurrences } from "../../../../lib/calendar/recurrence.js";
|
|
13
|
+
import { createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
|
|
14
|
+
import { parseVCards } from "../../../../lib/contacts/vcard.js";
|
|
15
|
+
import { attachmentContentUrl, getMessage, listAttachments, listFolders, } from "../../../../lib/mail/mailApi.js";
|
|
16
|
+
import { search } from "../../../../lib/search/searchApi.js";
|
|
17
|
+
import { ApiRequestError } from "../../../../lib/util/api.js";
|
|
18
|
+
import { ownMailboxes, primaryMailbox } from "../../mail/primaryMailbox.js";
|
|
19
|
+
import { cardForAddress, contactInputFor, contactInputFromCard, findContactByAddress, isVCardAttachment, normalizeAddress, ownContactCard, withoutDuplicates, } from "./participantDetails.js";
|
|
20
|
+
/** How many recent messages and upcoming events the card lists. */
|
|
21
|
+
export const CARD_LIST_SIZE = 5;
|
|
22
|
+
/** How far ahead "upcoming" looks, in days. */
|
|
23
|
+
export const UPCOMING_DAYS = 60;
|
|
24
|
+
/** The most vCard attachments and the most bytes of one that are read to fill a contact in. */
|
|
25
|
+
export const MAX_VCARD_FILES = 3;
|
|
26
|
+
export const MAX_VCARD_BYTES = 256 * 1024;
|
|
27
|
+
/** The mailboxes whose data the card reads: the user's own, else the primary/first one, plus the message's mailbox when it is one they can reach. */
|
|
28
|
+
export function scopeMailboxes(mailboxes, userUid, message) {
|
|
29
|
+
const own = ownMailboxes(mailboxes, userUid);
|
|
30
|
+
const scope = own.length > 0 ? own : [primaryMailbox(mailboxes, userUid)].filter((mailbox) => !!mailbox);
|
|
31
|
+
const messageMailbox = mailboxes.find((mailbox) => mailbox.uid === message?.mailboxUid);
|
|
32
|
+
return messageMailbox && !scope.includes(messageMailbox) ? [...scope, messageMailbox] : scope;
|
|
33
|
+
}
|
|
34
|
+
/** Every folder of `mailboxes`. A mailbox whose folders cannot be listed is left out rather than failing the rest. */
|
|
35
|
+
export async function loadFolders(mailboxes, client) {
|
|
36
|
+
const lists = await Promise.all(mailboxes.map((mailbox) => listFolders(mailbox.uid, client).catch(() => [])));
|
|
37
|
+
return lists.flat();
|
|
38
|
+
}
|
|
39
|
+
/** The contact the address belongs to in `folders`' contacts folders, if any. */
|
|
40
|
+
export async function findContact(folders, address, client) {
|
|
41
|
+
const uids = folders.filter((folder) => folder.type === "contacts").map((folder) => folder.uid);
|
|
42
|
+
return findContactByAddress(await listContactsInFolders(uids, client), address);
|
|
43
|
+
}
|
|
44
|
+
/** The last `CARD_LIST_SIZE` messages to or from `address` in `mailboxes`, newest first. */
|
|
45
|
+
export async function loadRecentMessages(mailboxes, address, client) {
|
|
46
|
+
const pages = await Promise.all(mailboxes.flatMap((mailbox) => ["from", "to"].map((role) => search("", { types: ["message"], [role]: address, limit: CARD_LIST_SIZE, mailboxUid: mailbox.uid }, client))));
|
|
47
|
+
const uids = [...new Set(pages.flatMap((page) => page.results.map((result) => result.entityUid)))];
|
|
48
|
+
// A hit can briefly outlive its message: one that no longer resolves is dropped.
|
|
49
|
+
const messages = (await Promise.all(uids.map((uid) => getMessage(uid, client).catch(() => null)))).filter((message) => !!message);
|
|
50
|
+
return messages.sort((a, b) => Date.parse(b.receivedDate) - Date.parse(a.receivedDate)).slice(0, CARD_LIST_SIZE);
|
|
51
|
+
}
|
|
52
|
+
/** The next `CARD_LIST_SIZE` events within `UPCOMING_DAYS` from `now` that `address` organizes or is invited to, soonest first. */
|
|
53
|
+
export async function loadUpcomingEvents(folders, address, now, client) {
|
|
54
|
+
const wanted = normalizeAddress(address);
|
|
55
|
+
const calendars = folders.filter((folder) => folder.type === "calendar");
|
|
56
|
+
const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client)));
|
|
57
|
+
const involved = lists
|
|
58
|
+
.flat()
|
|
59
|
+
.filter((event) => event.status !== "cancelled" &&
|
|
60
|
+
(normalizeAddress(event.organizer.address) === wanted ||
|
|
61
|
+
(event.attendees ?? []).some((attendee) => normalizeAddress(attendee.address) === wanted)));
|
|
62
|
+
return expandAllOccurrences(involved, now, addDays(now, UPCOMING_DAYS))
|
|
63
|
+
.sort((a, b) => Date.parse(a.startDate) - Date.parse(b.startDate))
|
|
64
|
+
.slice(0, CARD_LIST_SIZE);
|
|
65
|
+
}
|
|
66
|
+
/** A vCard attachment's text. */
|
|
67
|
+
export async function fetchVCardText(attachmentUid) {
|
|
68
|
+
const res = await fetch(attachmentContentUrl(attachmentUid), { credentials: "include" });
|
|
69
|
+
if (!res.ok) {
|
|
70
|
+
throw new ApiRequestError(res.statusText || "Could not load this contact card.", res.status);
|
|
71
|
+
}
|
|
72
|
+
return res.text();
|
|
73
|
+
}
|
|
74
|
+
/** The message's vCard attachments: `context.attachments` when given, else the message's own list. */
|
|
75
|
+
async function vCardAttachments(context, client) {
|
|
76
|
+
const { message } = context;
|
|
77
|
+
const attachments = context.attachments ?? (message?.hasAttachments ? await listAttachments(message.folderUid, message.uid, client) : []);
|
|
78
|
+
return attachments.filter((attachment) => isVCardAttachment(attachment) && attachment.sizeBytes <= MAX_VCARD_BYTES).slice(0, MAX_VCARD_FILES);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* The vCard of the context message that describes `participant`: the first attachment holding a card with their address, else - only for the
|
|
82
|
+
* message's sender, whose own vCard it presumably is - the first card there is. `undefined` when there is none, and for any failure to read one:
|
|
83
|
+
* a vCard only adds detail, it is never needed.
|
|
84
|
+
*/
|
|
85
|
+
export async function loadParticipantCard(participant, context, client) {
|
|
86
|
+
try {
|
|
87
|
+
const isSender = !!context.message && normalizeAddress(context.message.from.address) === normalizeAddress(participant.address);
|
|
88
|
+
let fallback;
|
|
89
|
+
for (const attachment of await vCardAttachments(context, client)) {
|
|
90
|
+
const cards = parseVCards(await fetchVCardText(attachment.uid));
|
|
91
|
+
const match = cardForAddress(cards, participant.address, false);
|
|
92
|
+
if (match) {
|
|
93
|
+
return match;
|
|
94
|
+
}
|
|
95
|
+
fallback ?? (fallback = cards[0]);
|
|
96
|
+
}
|
|
97
|
+
return isSender ? fallback : undefined;
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
/** Where a new contact goes: the message's mailbox when it is one of the user's own, else their primary mailbox - in that mailbox's contacts folder. */
|
|
104
|
+
export function contactScope(mailboxes, folders, userUid, message) {
|
|
105
|
+
const own = ownMailboxes(mailboxes, userUid);
|
|
106
|
+
const mailbox = own.find((candidate) => candidate.uid === message?.mailboxUid) ?? primaryMailbox(mailboxes, userUid);
|
|
107
|
+
const folder = folders.find((candidate) => candidate.mailboxUid === mailbox?.uid && candidate.type === "contacts");
|
|
108
|
+
if (!mailbox || !folder) {
|
|
109
|
+
throw new Error("There is no contacts folder to add this contact to.");
|
|
110
|
+
}
|
|
111
|
+
return { mailboxUid: mailbox.uid, folderUid: folder.uid };
|
|
112
|
+
}
|
|
113
|
+
/** Stores a contact for `participant` with everything known - the name, the address and whatever `card` says. */
|
|
114
|
+
export function createContactFor(participant, card, scope, client) {
|
|
115
|
+
return createContact(contactInputFor(participant, card, scope), client);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The sender's own contact card as a file to attach, `<display name>.vcf`: the mailbox being sent from (its name and primary address) with the phones, company and
|
|
119
|
+
* title of the user's own contact entry for that address, when they have one in their own mailboxes' address books. A lookup that fails just leaves those out.
|
|
120
|
+
*/
|
|
121
|
+
export async function buildOwnCardFile(mailbox, mailboxes, userUid, client) {
|
|
122
|
+
let own;
|
|
123
|
+
try {
|
|
124
|
+
const mine = ownMailboxes(mailboxes, userUid);
|
|
125
|
+
own = await findContact(await loadFolders(mine.length > 0 ? mine : [mailbox], client), mailbox.primarySmtpAddress, client);
|
|
126
|
+
}
|
|
127
|
+
catch {
|
|
128
|
+
// The card is still worth sending without them.
|
|
129
|
+
}
|
|
130
|
+
const { displayName, vcard } = ownContactCard(mailbox, own);
|
|
131
|
+
return new File([vcard], `${displayName.replace(/[\\/:*?"<>|]/g, "").trim() || "Contact"}.vcf`, { type: "text/vcard" });
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Adds every card of a vCard file to the contacts folder `scope` names, skipping the ones whose email is already a stored contact's (or an earlier
|
|
135
|
+
* card's in the same file). A card that cannot be stored fails the whole call after the earlier ones were added - `AddCardsError` says how many.
|
|
136
|
+
*/
|
|
137
|
+
export async function addCardsToAddressBook(cards, scope, client) {
|
|
138
|
+
const { fresh, skipped } = withoutDuplicates(cards, await listContactsInFolders([scope.folderUid], client));
|
|
139
|
+
let added = 0;
|
|
140
|
+
for (const card of fresh) {
|
|
141
|
+
try {
|
|
142
|
+
await createContact(contactInputFromCard(card, scope), client);
|
|
143
|
+
added += 1;
|
|
144
|
+
}
|
|
145
|
+
catch (err) {
|
|
146
|
+
throw new AddCardsError(err, added);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return { added, skipped };
|
|
150
|
+
}
|
|
151
|
+
/** A card failed to store: the message is the failure's and `added` how many cards were stored before it. */
|
|
152
|
+
export class AddCardsError extends Error {
|
|
153
|
+
constructor(failure, added) {
|
|
154
|
+
super(failure instanceof Error ? failure.message : "Could not add the contact.");
|
|
155
|
+
this.name = "AddCardsError";
|
|
156
|
+
this.added = added;
|
|
157
|
+
}
|
|
158
|
+
}
|