@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
|
@@ -11,13 +11,18 @@ import {
|
|
|
11
11
|
ContactPatch,
|
|
12
12
|
ContactPhone,
|
|
13
13
|
ContactPostalAddress,
|
|
14
|
+
contactPhotoUrl,
|
|
14
15
|
createContact,
|
|
16
|
+
deleteContactPhoto,
|
|
15
17
|
updateContact,
|
|
18
|
+
uploadContactPhoto,
|
|
16
19
|
} from "../../../../lib/contacts/contactsApi.js";
|
|
17
20
|
import { Mailbox } from "../../../../lib/mail/mailApi.js";
|
|
18
21
|
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
19
22
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
20
23
|
import FormField from "../../../../lib/components/forms/FormField.js";
|
|
24
|
+
import ContactPhotoField from "./ContactPhotoField.js";
|
|
25
|
+
import FavoriteStarButton from "./FavoriteStarButton.js";
|
|
21
26
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
22
27
|
import { findWellKnownFolderUid } from "../../mail/findWellKnownFolderUid.js";
|
|
23
28
|
import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
|
|
@@ -62,6 +67,9 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
62
67
|
// Every stored address is kept and editable - editing a contact must never silently drop addresses
|
|
63
68
|
// beyond the first.
|
|
64
69
|
const [addresses, setAddresses] = useState<ContactPostalAddress[]>(contact?.addresses ?? []);
|
|
70
|
+
// A picture chosen (or the current one dropped) is applied once the contact itself is saved.
|
|
71
|
+
const [pickedPhoto, setPickedPhoto] = useState<File | null>(null);
|
|
72
|
+
const [removePhoto, setRemovePhoto] = useState(false);
|
|
65
73
|
const [error, setError] = useState<string | null>(null);
|
|
66
74
|
const [saving, setSaving] = useState(false);
|
|
67
75
|
|
|
@@ -143,6 +151,11 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
143
151
|
client,
|
|
144
152
|
);
|
|
145
153
|
}
|
|
154
|
+
if (pickedPhoto) {
|
|
155
|
+
saved = await uploadContactPhoto(saved.uid, saved.version, pickedPhoto);
|
|
156
|
+
} else if (removePhoto && saved.photoBlobKey) {
|
|
157
|
+
saved = await deleteContactPhoto(saved.uid, saved.version, client);
|
|
158
|
+
}
|
|
146
159
|
// A message opened later in this page load must see the changed contact's signing keys.
|
|
147
160
|
clearPinnedSignerCache();
|
|
148
161
|
onSaved(saved);
|
|
@@ -191,6 +204,29 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
191
204
|
/>
|
|
192
205
|
</FormField>
|
|
193
206
|
|
|
207
|
+
<div className="flex items-start gap-2">
|
|
208
|
+
<div className="min-w-0 flex-1">
|
|
209
|
+
<ContactPhotoField
|
|
210
|
+
displayName={displayName}
|
|
211
|
+
email={emails[0]?.address}
|
|
212
|
+
currentUrl={contact && contactPhotoUrl(contact)}
|
|
213
|
+
picked={pickedPhoto}
|
|
214
|
+
removed={removePhoto}
|
|
215
|
+
onPick={(file) => {
|
|
216
|
+
setPickedPhoto(file);
|
|
217
|
+
setRemovePhoto(false);
|
|
218
|
+
setError(null);
|
|
219
|
+
}}
|
|
220
|
+
onRemove={() => {
|
|
221
|
+
setPickedPhoto(null);
|
|
222
|
+
setRemovePhoto(true);
|
|
223
|
+
}}
|
|
224
|
+
onReject={setError}
|
|
225
|
+
/>
|
|
226
|
+
</div>
|
|
227
|
+
<FavoriteStarButton favorite={favorite} onToggle={() => setFavorite((value) => !value)} />
|
|
228
|
+
</div>
|
|
229
|
+
|
|
194
230
|
<div className="grid grid-cols-1 @md:grid-cols-2 gap-3">
|
|
195
231
|
<FormField label="First name" htmlFor="contact-givenName">
|
|
196
232
|
<input id="contact-givenName" type="text" className={INPUT_CLASS} value={givenName} onChange={(e) => setGivenName(e.target.value)} />
|
|
@@ -209,11 +245,6 @@ export default function ContactForm({ contact, mailboxUid, folderUid, mailboxes,
|
|
|
209
245
|
</FormField>
|
|
210
246
|
</div>
|
|
211
247
|
|
|
212
|
-
<label className="flex items-center gap-2 text-sm mb-4">
|
|
213
|
-
<input type="checkbox" checked={favorite} onChange={(e) => setFavorite(e.target.checked)} />
|
|
214
|
-
Favorite
|
|
215
|
-
</label>
|
|
216
|
-
|
|
217
248
|
<FormField label="Categories (comma-separated)" htmlFor="contact-categories">
|
|
218
249
|
<input
|
|
219
250
|
id="contact-categories"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
6
|
+
import ContactAvatar from "../../../../lib/components/avatar/ContactAvatar.js";
|
|
7
|
+
import Button from "../../../../lib/components/buttons/Button.js";
|
|
8
|
+
import { CONTACT_PHOTO_MAX_BYTES, CONTACT_PHOTO_TYPES } from "../../../../lib/contacts/contactsApi.js";
|
|
9
|
+
|
|
10
|
+
export interface ContactPhotoFieldProps {
|
|
11
|
+
displayName: string;
|
|
12
|
+
/** The contact's first address, for the Gravatar shown while they have no picture of their own. */
|
|
13
|
+
email?: string;
|
|
14
|
+
/** The picture the contact has now (`contactPhotoUrl()`), if any. */
|
|
15
|
+
currentUrl?: string;
|
|
16
|
+
/** The picture chosen to replace it, not uploaded until the contact is saved; `null` for none. */
|
|
17
|
+
picked: File | null;
|
|
18
|
+
/** Whether the current picture is to be removed when the contact is saved. */
|
|
19
|
+
removed: boolean;
|
|
20
|
+
onPick: (file: File) => void;
|
|
21
|
+
onRemove: () => void;
|
|
22
|
+
/** Called with the reason a chosen file was turned down. */
|
|
23
|
+
onReject: (message: string) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 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
|
|
28
|
+
* 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.
|
|
29
|
+
*/
|
|
30
|
+
export default function ContactPhotoField({ displayName, email, currentUrl, picked, removed, onPick, onRemove, onReject }: ContactPhotoFieldProps) {
|
|
31
|
+
const input = useRef<HTMLInputElement>(null);
|
|
32
|
+
const [preview, setPreview] = useState<string | undefined>();
|
|
33
|
+
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (!picked) {
|
|
36
|
+
setPreview(undefined);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const url = URL.createObjectURL(picked);
|
|
40
|
+
setPreview(url);
|
|
41
|
+
return () => URL.revokeObjectURL(url);
|
|
42
|
+
}, [picked]);
|
|
43
|
+
|
|
44
|
+
function handleChange(file: File | undefined) {
|
|
45
|
+
if (!file) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (!CONTACT_PHOTO_TYPES.includes(file.type)) {
|
|
49
|
+
onReject("Choose a JPEG, PNG, GIF or WebP image.");
|
|
50
|
+
} else if (file.size > CONTACT_PHOTO_MAX_BYTES) {
|
|
51
|
+
onReject("Choose an image no larger than 1 MB.");
|
|
52
|
+
} else {
|
|
53
|
+
onPick(file);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const shown = picked ? preview : removed ? undefined : currentUrl;
|
|
58
|
+
const hasOwn = !!picked || (!!currentUrl && !removed);
|
|
59
|
+
return (
|
|
60
|
+
<div className="flex items-center gap-4 mb-3">
|
|
61
|
+
<ContactAvatar displayName={displayName || "?"} size={72} photoUrl={shown} email={picked || (currentUrl && !removed) ? undefined : email} />
|
|
62
|
+
<div className="flex flex-wrap gap-2">
|
|
63
|
+
<input
|
|
64
|
+
ref={input}
|
|
65
|
+
type="file"
|
|
66
|
+
accept={CONTACT_PHOTO_TYPES.join(",")}
|
|
67
|
+
aria-label="Contact photo file"
|
|
68
|
+
className="sr-only"
|
|
69
|
+
tabIndex={-1}
|
|
70
|
+
onChange={(e) => {
|
|
71
|
+
handleChange(e.target.files?.[0]);
|
|
72
|
+
e.target.value = "";
|
|
73
|
+
}}
|
|
74
|
+
/>
|
|
75
|
+
<Button type="button" variant="secondary" className="!w-auto !py-1 !px-3 text-xs" onClick={() => input.current?.click()}>
|
|
76
|
+
{hasOwn ? "Change photo" : "Add photo"}
|
|
77
|
+
</Button>
|
|
78
|
+
{hasOwn && (
|
|
79
|
+
<Button type="button" variant="secondary" className="!w-auto !py-1 !px-3 text-xs" onClick={onRemove}>
|
|
80
|
+
Remove photo
|
|
81
|
+
</Button>
|
|
82
|
+
)}
|
|
83
|
+
</div>
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React from "react";
|
|
6
|
+
import { HiOutlineStar, HiStar } from "react-icons/hi2";
|
|
7
|
+
|
|
8
|
+
export interface FavoriteStarButtonProps {
|
|
9
|
+
favorite: boolean;
|
|
10
|
+
onToggle: () => void;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
className?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** The star that marks a contact as a favorite: filled when it is one. A toggle button (`aria-pressed`) named "Favorite". */
|
|
16
|
+
export default function FavoriteStarButton({ favorite, onToggle, disabled, className }: FavoriteStarButtonProps) {
|
|
17
|
+
return (
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
aria-label="Favorite"
|
|
21
|
+
title={favorite ? "Remove from favorites" : "Add to favorites"}
|
|
22
|
+
aria-pressed={favorite}
|
|
23
|
+
disabled={disabled}
|
|
24
|
+
onClick={onToggle}
|
|
25
|
+
className={[
|
|
26
|
+
"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",
|
|
27
|
+
favorite ? "text-warning" : "text-text-muted hover:text-text",
|
|
28
|
+
className,
|
|
29
|
+
]
|
|
30
|
+
.filter(Boolean)
|
|
31
|
+
.join(" ")}
|
|
32
|
+
>
|
|
33
|
+
{favorite ? <HiStar aria-hidden="true" className="size-6" /> : <HiOutlineStar aria-hidden="true" className="size-6" />}
|
|
34
|
+
</button>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { ReactNode } from "react";
|
|
6
|
+
import { useContactCard } from "./ContactCardProvider.js";
|
|
7
|
+
import type { ContactCardContext } from "./contactCardData.js";
|
|
8
|
+
import type { Participant } from "./participantDetails.js";
|
|
9
|
+
|
|
10
|
+
export interface ParticipantLinkProps {
|
|
11
|
+
participant: Participant;
|
|
12
|
+
/** The message the participant was met in - see `ContactCardValue.show`. */
|
|
13
|
+
context?: ContactCardContext;
|
|
14
|
+
/** The button's accessible name, when what it shows has none of its own (an avatar). */
|
|
15
|
+
label?: string;
|
|
16
|
+
className?: string;
|
|
17
|
+
children: ReactNode;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 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
|
|
22
|
+
* button, underlined on hover and with a visible focus ring, named by what it shows. Outside a `ContactCardProvider` it is plain text. Never
|
|
23
|
+
* put one inside another button or link.
|
|
24
|
+
*/
|
|
25
|
+
export default function ParticipantLink({ participant, context, label, className, children }: ParticipantLinkProps) {
|
|
26
|
+
const { show, available } = useContactCard();
|
|
27
|
+
if (!available) {
|
|
28
|
+
return <span className={className}>{children}</span>;
|
|
29
|
+
}
|
|
30
|
+
return (
|
|
31
|
+
<button
|
|
32
|
+
type="button"
|
|
33
|
+
aria-label={label}
|
|
34
|
+
onClick={() => show(participant, context)}
|
|
35
|
+
className={["text-left rounded-sm hover:underline focus-visible:underline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary", className]
|
|
36
|
+
.filter(Boolean)
|
|
37
|
+
.join(" ")}
|
|
38
|
+
>
|
|
39
|
+
{children}
|
|
40
|
+
</button>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
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 { CalendarEvent, listCalendarEvents } from "../../../../lib/calendar/calendarApi.js";
|
|
12
|
+
import { type CalendarOccurrence, expandAllOccurrences } from "../../../../lib/calendar/recurrence.js";
|
|
13
|
+
import { type Contact, createContact, listContactsInFolders } from "../../../../lib/contacts/contactsApi.js";
|
|
14
|
+
import { type ParsedVCardContact, parseVCards } from "../../../../lib/contacts/vcard.js";
|
|
15
|
+
import {
|
|
16
|
+
type Attachment,
|
|
17
|
+
type Folder,
|
|
18
|
+
type Mailbox,
|
|
19
|
+
type Message,
|
|
20
|
+
attachmentContentUrl,
|
|
21
|
+
getMessage,
|
|
22
|
+
listAttachments,
|
|
23
|
+
listFolders,
|
|
24
|
+
} from "../../../../lib/mail/mailApi.js";
|
|
25
|
+
import { search } from "../../../../lib/search/searchApi.js";
|
|
26
|
+
import { ApiRequestError, type ApiClient } from "../../../../lib/util/api.js";
|
|
27
|
+
import { ownMailboxes, primaryMailbox } from "../../mail/primaryMailbox.js";
|
|
28
|
+
import {
|
|
29
|
+
type Participant,
|
|
30
|
+
cardForAddress,
|
|
31
|
+
contactInputFor,
|
|
32
|
+
contactInputFromCard,
|
|
33
|
+
findContactByAddress,
|
|
34
|
+
isVCardAttachment,
|
|
35
|
+
normalizeAddress,
|
|
36
|
+
ownContactCard,
|
|
37
|
+
withoutDuplicates,
|
|
38
|
+
} from "./participantDetails.js";
|
|
39
|
+
|
|
40
|
+
/** 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. */
|
|
41
|
+
export interface ContactCardContext {
|
|
42
|
+
message?: Message;
|
|
43
|
+
attachments?: Attachment[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** How many recent messages and upcoming events the card lists. */
|
|
47
|
+
export const CARD_LIST_SIZE = 5;
|
|
48
|
+
/** How far ahead "upcoming" looks, in days. */
|
|
49
|
+
export const UPCOMING_DAYS = 60;
|
|
50
|
+
/** The most vCard attachments and the most bytes of one that are read to fill a contact in. */
|
|
51
|
+
export const MAX_VCARD_FILES = 3;
|
|
52
|
+
export const MAX_VCARD_BYTES = 256 * 1024;
|
|
53
|
+
|
|
54
|
+
/** 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. */
|
|
55
|
+
export function scopeMailboxes(mailboxes: readonly Mailbox[], userUid: string | undefined, message?: Message): Mailbox[] {
|
|
56
|
+
const own = ownMailboxes(mailboxes, userUid);
|
|
57
|
+
const scope = own.length > 0 ? own : [primaryMailbox(mailboxes, userUid)].filter((mailbox): mailbox is Mailbox => !!mailbox);
|
|
58
|
+
const messageMailbox = mailboxes.find((mailbox) => mailbox.uid === message?.mailboxUid);
|
|
59
|
+
return messageMailbox && !scope.includes(messageMailbox) ? [...scope, messageMailbox] : scope;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Every folder of `mailboxes`. A mailbox whose folders cannot be listed is left out rather than failing the rest. */
|
|
63
|
+
export async function loadFolders(mailboxes: readonly Mailbox[], client?: ApiClient): Promise<Folder[]> {
|
|
64
|
+
const lists = await Promise.all(mailboxes.map((mailbox) => listFolders(mailbox.uid, client).catch(() => [] as Folder[])));
|
|
65
|
+
return lists.flat();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The contact the address belongs to in `folders`' contacts folders, if any. */
|
|
69
|
+
export async function findContact(folders: readonly Folder[], address: string, client?: ApiClient): Promise<Contact | undefined> {
|
|
70
|
+
const uids = folders.filter((folder) => folder.type === "contacts").map((folder) => folder.uid);
|
|
71
|
+
return findContactByAddress(await listContactsInFolders(uids, client), address);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The last `CARD_LIST_SIZE` messages to or from `address` in `mailboxes`, newest first. */
|
|
75
|
+
export async function loadRecentMessages(mailboxes: readonly Mailbox[], address: string, client?: ApiClient): Promise<Message[]> {
|
|
76
|
+
const pages = await Promise.all(
|
|
77
|
+
mailboxes.flatMap((mailbox) =>
|
|
78
|
+
(["from", "to"] as const).map((role) =>
|
|
79
|
+
search("", { types: ["message"], [role]: address, limit: CARD_LIST_SIZE, mailboxUid: mailbox.uid }, client),
|
|
80
|
+
),
|
|
81
|
+
),
|
|
82
|
+
);
|
|
83
|
+
const uids = [...new Set(pages.flatMap((page) => page.results.map((result) => result.entityUid)))];
|
|
84
|
+
// A hit can briefly outlive its message: one that no longer resolves is dropped.
|
|
85
|
+
const messages = (await Promise.all(uids.map((uid) => getMessage(uid, client).catch(() => null)))).filter((message): message is Message => !!message);
|
|
86
|
+
return messages.sort((a, b) => Date.parse(b.receivedDate) - Date.parse(a.receivedDate)).slice(0, CARD_LIST_SIZE);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The next `CARD_LIST_SIZE` events within `UPCOMING_DAYS` from `now` that `address` organizes or is invited to, soonest first. */
|
|
90
|
+
export async function loadUpcomingEvents(folders: readonly Folder[], address: string, now: Date, client?: ApiClient): Promise<CalendarOccurrence[]> {
|
|
91
|
+
const wanted = normalizeAddress(address);
|
|
92
|
+
const calendars = folders.filter((folder) => folder.type === "calendar");
|
|
93
|
+
const lists = await Promise.all(calendars.map((folder) => listCalendarEvents(folder.uid, client)));
|
|
94
|
+
const involved = lists
|
|
95
|
+
.flat()
|
|
96
|
+
.filter(
|
|
97
|
+
(event: CalendarEvent) =>
|
|
98
|
+
event.status !== "cancelled" &&
|
|
99
|
+
(normalizeAddress(event.organizer.address) === wanted ||
|
|
100
|
+
(event.attendees ?? []).some((attendee) => normalizeAddress(attendee.address) === wanted)),
|
|
101
|
+
);
|
|
102
|
+
return expandAllOccurrences(involved, now, addDays(now, UPCOMING_DAYS))
|
|
103
|
+
.sort((a, b) => Date.parse(a.startDate) - Date.parse(b.startDate))
|
|
104
|
+
.slice(0, CARD_LIST_SIZE);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** A vCard attachment's text. */
|
|
108
|
+
export async function fetchVCardText(attachmentUid: string): Promise<string> {
|
|
109
|
+
const res = await fetch(attachmentContentUrl(attachmentUid), { credentials: "include" });
|
|
110
|
+
if (!res.ok) {
|
|
111
|
+
throw new ApiRequestError(res.statusText || "Could not load this contact card.", res.status);
|
|
112
|
+
}
|
|
113
|
+
return res.text();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The message's vCard attachments: `context.attachments` when given, else the message's own list. */
|
|
117
|
+
async function vCardAttachments(context: ContactCardContext, client?: ApiClient): Promise<Attachment[]> {
|
|
118
|
+
const { message } = context;
|
|
119
|
+
const attachments = context.attachments ?? (message?.hasAttachments ? await listAttachments(message.folderUid, message.uid, client) : []);
|
|
120
|
+
return attachments.filter((attachment) => isVCardAttachment(attachment) && attachment.sizeBytes <= MAX_VCARD_BYTES).slice(0, MAX_VCARD_FILES);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The vCard of the context message that describes `participant`: the first attachment holding a card with their address, else - only for the
|
|
125
|
+
* 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:
|
|
126
|
+
* a vCard only adds detail, it is never needed.
|
|
127
|
+
*/
|
|
128
|
+
export async function loadParticipantCard(participant: Participant, context: ContactCardContext, client?: ApiClient): Promise<ParsedVCardContact | undefined> {
|
|
129
|
+
try {
|
|
130
|
+
const isSender = !!context.message && normalizeAddress(context.message.from.address) === normalizeAddress(participant.address);
|
|
131
|
+
let fallback: ParsedVCardContact | undefined;
|
|
132
|
+
for (const attachment of await vCardAttachments(context, client)) {
|
|
133
|
+
const cards = parseVCards(await fetchVCardText(attachment.uid));
|
|
134
|
+
const match = cardForAddress(cards, participant.address, false);
|
|
135
|
+
if (match) {
|
|
136
|
+
return match;
|
|
137
|
+
}
|
|
138
|
+
fallback ??= cards[0];
|
|
139
|
+
}
|
|
140
|
+
return isSender ? fallback : undefined;
|
|
141
|
+
} catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 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. */
|
|
147
|
+
export function contactScope(mailboxes: readonly Mailbox[], folders: readonly Folder[], userUid: string | undefined, message?: Message): { mailboxUid: string; folderUid: string } {
|
|
148
|
+
const own = ownMailboxes(mailboxes, userUid);
|
|
149
|
+
const mailbox = own.find((candidate) => candidate.uid === message?.mailboxUid) ?? primaryMailbox(mailboxes, userUid);
|
|
150
|
+
const folder = folders.find((candidate) => candidate.mailboxUid === mailbox?.uid && candidate.type === "contacts");
|
|
151
|
+
if (!mailbox || !folder) {
|
|
152
|
+
throw new Error("There is no contacts folder to add this contact to.");
|
|
153
|
+
}
|
|
154
|
+
return { mailboxUid: mailbox.uid, folderUid: folder.uid };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** Stores a contact for `participant` with everything known - the name, the address and whatever `card` says. */
|
|
158
|
+
export function createContactFor(
|
|
159
|
+
participant: Participant,
|
|
160
|
+
card: ParsedVCardContact | undefined,
|
|
161
|
+
scope: { mailboxUid: string; folderUid: string },
|
|
162
|
+
client?: ApiClient,
|
|
163
|
+
): Promise<Contact> {
|
|
164
|
+
return createContact(contactInputFor(participant, card, scope), client);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* 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
|
|
169
|
+
* 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.
|
|
170
|
+
*/
|
|
171
|
+
export async function buildOwnCardFile(mailbox: Mailbox, mailboxes: readonly Mailbox[], userUid: string | undefined, client?: ApiClient): Promise<File> {
|
|
172
|
+
let own: Contact | undefined;
|
|
173
|
+
try {
|
|
174
|
+
const mine = ownMailboxes(mailboxes, userUid);
|
|
175
|
+
own = await findContact(await loadFolders(mine.length > 0 ? mine : [mailbox], client), mailbox.primarySmtpAddress, client);
|
|
176
|
+
} catch {
|
|
177
|
+
// The card is still worth sending without them.
|
|
178
|
+
}
|
|
179
|
+
const { displayName, vcard } = ownContactCard(mailbox, own);
|
|
180
|
+
return new File([vcard], `${displayName.replace(/[\\/:*?"<>|]/g, "").trim() || "Contact"}.vcf`, { type: "text/vcard" });
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** The outcome of adding a vCard file's cards to the address book. */
|
|
184
|
+
export interface AddCardsResult {
|
|
185
|
+
added: number;
|
|
186
|
+
skipped: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* 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
|
|
191
|
+
* 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.
|
|
192
|
+
*/
|
|
193
|
+
export async function addCardsToAddressBook(
|
|
194
|
+
cards: readonly ParsedVCardContact[],
|
|
195
|
+
scope: { mailboxUid: string; folderUid: string },
|
|
196
|
+
client?: ApiClient,
|
|
197
|
+
): Promise<AddCardsResult> {
|
|
198
|
+
const { fresh, skipped } = withoutDuplicates(cards, await listContactsInFolders([scope.folderUid], client));
|
|
199
|
+
let added = 0;
|
|
200
|
+
for (const card of fresh) {
|
|
201
|
+
try {
|
|
202
|
+
await createContact(contactInputFromCard(card, scope), client);
|
|
203
|
+
added += 1;
|
|
204
|
+
} catch (err) {
|
|
205
|
+
throw new AddCardsError(err, added);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return { added, skipped };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A card failed to store: the message is the failure's and `added` how many cards were stored before it. */
|
|
212
|
+
export class AddCardsError extends Error {
|
|
213
|
+
readonly added: number;
|
|
214
|
+
|
|
215
|
+
constructor(failure: unknown, added: number) {
|
|
216
|
+
super(failure instanceof Error ? failure.message : "Could not add the contact.");
|
|
217
|
+
this.name = "AddCardsError";
|
|
218
|
+
this.added = added;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
/**
|
|
6
|
+
* The pure rules behind the contact card (`ContactCard`), the "Add to address book" action of a vCard attachment and the compose window's
|
|
7
|
+
* "Attach my contact card": who a participant is, whether they are external, which contact (or vCard) describes them and what a new contact
|
|
8
|
+
* made from them holds. No network and no React - the calls that use these are in `contactCardData.ts`.
|
|
9
|
+
*/
|
|
10
|
+
import type { Contact, ContactEmail, ContactInput, ContactPhone, ContactPostalAddress } from "../../../../lib/contacts/contactsApi.js";
|
|
11
|
+
import { contactToVCard, type ParsedVCardContact } from "../../../../lib/contacts/vcard.js";
|
|
12
|
+
import { splitMailAddress } from "../../../../lib/mail/mailAddress.js";
|
|
13
|
+
import type { Mailbox } from "../../../../lib/mail/mailApi.js";
|
|
14
|
+
|
|
15
|
+
/** Someone who took part in a message or an event: a sender, a recipient, an organizer, a guest. */
|
|
16
|
+
export interface Participant {
|
|
17
|
+
address: string;
|
|
18
|
+
displayName?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The MIME types a vCard file travels as. */
|
|
22
|
+
const VCARD_MIME_TYPES = new Set(["text/vcard", "text/x-vcard", "text/directory", "application/vcard"]);
|
|
23
|
+
|
|
24
|
+
/** Whether an attachment is a vCard: by its MIME type (parameters such as `; charset=utf-8` ignored), else by a `.vcf`/`.vcard` name. */
|
|
25
|
+
export function isVCardAttachment(attachment: { filename?: string; mimeType?: string }): boolean {
|
|
26
|
+
const mimeType = (attachment.mimeType ?? "").split(";")[0].trim().toLowerCase();
|
|
27
|
+
return VCARD_MIME_TYPES.has(mimeType) || /\.(vcf|vcard)$/i.test(attachment.filename ?? "");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** An address in the form two of them are compared by. */
|
|
31
|
+
export function normalizeAddress(address: string): string {
|
|
32
|
+
return address.trim().toLowerCase();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The name to show for a participant: their own when distinct from the address, else the address's local part. */
|
|
36
|
+
export function participantName(participant: Participant): string {
|
|
37
|
+
const { name, address } = splitMailAddress(participant);
|
|
38
|
+
if (name) {
|
|
39
|
+
return name;
|
|
40
|
+
}
|
|
41
|
+
const at = address.lastIndexOf("@");
|
|
42
|
+
return at > 0 ? address.slice(0, at) : address;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A display name as given name + surname: `Doe, Jane` and `Jane Doe` are both Jane Doe; a single word is only a given name, and a middle name stays with the given. */
|
|
46
|
+
export function splitPersonName(displayName: string): { givenName?: string; surname?: string } {
|
|
47
|
+
const name = displayName.trim();
|
|
48
|
+
const comma = name.indexOf(",");
|
|
49
|
+
if (comma > 0) {
|
|
50
|
+
return { givenName: name.slice(comma + 1).trim() || undefined, surname: name.slice(0, comma).trim() };
|
|
51
|
+
}
|
|
52
|
+
const parts = name.split(/\s+/).filter(Boolean);
|
|
53
|
+
if (parts.length < 2) {
|
|
54
|
+
return { givenName: parts[0] };
|
|
55
|
+
}
|
|
56
|
+
return { givenName: parts.slice(0, -1).join(" "), surname: parts[parts.length - 1] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The first of `contacts` with an email equal (case-insensitively) to `address`. */
|
|
60
|
+
export function findContactByAddress(contacts: readonly Contact[], address: string): Contact | undefined {
|
|
61
|
+
const wanted = normalizeAddress(address);
|
|
62
|
+
return contacts.find((contact) => contact.emails.some((email) => normalizeAddress(email.address) === wanted));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The domains (lower case) of every address of `mailboxes` - primary and aliases. */
|
|
66
|
+
export function ownDomains(mailboxes: readonly Pick<Mailbox, "primarySmtpAddress" | "aliasAddresses">[]): Set<string> {
|
|
67
|
+
const domains = new Set<string>();
|
|
68
|
+
for (const mailbox of mailboxes) {
|
|
69
|
+
for (const address of [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])]) {
|
|
70
|
+
const at = address.lastIndexOf("@");
|
|
71
|
+
if (at > 0) {
|
|
72
|
+
domains.add(address.slice(at + 1).trim().toLowerCase());
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return domains;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Whether `address` is outside the user's own domains. An address with no domain at all is not called external. */
|
|
80
|
+
export function isExternalAddress(address: string, domains: ReadonlySet<string>): boolean {
|
|
81
|
+
const at = address.lastIndexOf("@");
|
|
82
|
+
return at > 0 && !domains.has(address.slice(at + 1).trim().toLowerCase());
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The card of `cards` that carries `address`; when none does and `orFirst` (the message's sender - a vCard a person attaches is their own), the first card. */
|
|
86
|
+
export function cardForAddress(cards: readonly ParsedVCardContact[], address: string, orFirst: boolean): ParsedVCardContact | undefined {
|
|
87
|
+
const wanted = normalizeAddress(address);
|
|
88
|
+
return cards.find((card) => (card.emails ?? []).some((email) => normalizeAddress(email.address) === wanted)) ?? (orFirst ? cards[0] : undefined);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A `ContactInput` whose lists are always there. */
|
|
92
|
+
export type KnownPerson = ContactInput & { emails: ContactEmail[]; phones: ContactPhone[]; addresses: ContactPostalAddress[] };
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* What a new contact for `participant` holds: everything the vCard `card` says (when there is one) and always the participant's address. The
|
|
96
|
+
* name is the vCard's, else the participant's, else the address's local part; given name and surname are the vCard's `N`, else split from the name.
|
|
97
|
+
*/
|
|
98
|
+
export function contactInputFor(
|
|
99
|
+
participant: Participant,
|
|
100
|
+
card: ParsedVCardContact | undefined,
|
|
101
|
+
scope: { mailboxUid: string; folderUid: string },
|
|
102
|
+
): KnownPerson {
|
|
103
|
+
// A vCard with no name at all is parsed as "Unnamed contact": not a name worth keeping over the participant's own.
|
|
104
|
+
const cardName = card && (card.givenName || card.surname || card.displayName !== "Unnamed contact") ? card.displayName : "";
|
|
105
|
+
const displayName = cardName || participantName(participant);
|
|
106
|
+
const emails: ContactEmail[] = [...(card?.emails ?? [])];
|
|
107
|
+
if (!emails.some((email) => normalizeAddress(email.address) === normalizeAddress(participant.address))) {
|
|
108
|
+
emails.push({ address: participant.address.trim(), type: "other" });
|
|
109
|
+
}
|
|
110
|
+
const names = card?.givenName || card?.surname ? { givenName: card.givenName, surname: card.surname } : splitPersonName(displayName);
|
|
111
|
+
return {
|
|
112
|
+
...scope,
|
|
113
|
+
displayName,
|
|
114
|
+
givenName: names.givenName,
|
|
115
|
+
surname: names.surname,
|
|
116
|
+
emails,
|
|
117
|
+
phones: card?.phones ?? [],
|
|
118
|
+
addresses: card?.addresses ?? [],
|
|
119
|
+
company: card?.company,
|
|
120
|
+
jobTitle: card?.jobTitle,
|
|
121
|
+
notes: card?.notes,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** A contact made from a vCard on its own, for "Add to address book": the card's own name and details, with the given/surname split when it lacks them. */
|
|
126
|
+
export function contactInputFromCard(card: ParsedVCardContact, scope: { mailboxUid: string; folderUid: string }): KnownPerson {
|
|
127
|
+
const names = card.givenName || card.surname ? { givenName: card.givenName, surname: card.surname } : splitPersonName(card.displayName);
|
|
128
|
+
return { ...card, ...scope, ...names, emails: card.emails ?? [], phones: card.phones ?? [], addresses: card.addresses ?? [] };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The cards that are not yet in the address book, and how many were: a card is a duplicate when one of its emails is a stored contact's or an earlier card's. */
|
|
132
|
+
export function withoutDuplicates(
|
|
133
|
+
cards: readonly ParsedVCardContact[],
|
|
134
|
+
existing: readonly Contact[],
|
|
135
|
+
): { fresh: ParsedVCardContact[]; skipped: number } {
|
|
136
|
+
const known = new Set(existing.flatMap((contact) => contact.emails.map((email) => normalizeAddress(email.address))));
|
|
137
|
+
const fresh: ParsedVCardContact[] = [];
|
|
138
|
+
for (const card of cards) {
|
|
139
|
+
const addresses = (card.emails ?? []).map((email) => normalizeAddress(email.address));
|
|
140
|
+
if (addresses.some((address) => known.has(address))) {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
addresses.forEach((address) => known.add(address));
|
|
144
|
+
fresh.push(card);
|
|
145
|
+
}
|
|
146
|
+
return { fresh, skipped: cards.length - fresh.length };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The sender's own vCard, and the name it is filed under. It is built from - the mailbox's name and primary address, with the phones, company and title of the user's own contact entry when they have one (never its other emails or postal addresses: a card that is sent out says only what it should). */
|
|
150
|
+
export function ownContactCard(mailbox: Pick<Mailbox, "displayName" | "primarySmtpAddress">, own: Contact | undefined): { displayName: string; vcard: string } {
|
|
151
|
+
const displayName = own?.displayName || mailbox.displayName || participantName({ address: mailbox.primarySmtpAddress });
|
|
152
|
+
const names = own?.givenName || own?.surname ? { givenName: own.givenName, surname: own.surname } : splitPersonName(displayName);
|
|
153
|
+
const vcard = contactToVCard({
|
|
154
|
+
uid: "",
|
|
155
|
+
version: 0,
|
|
156
|
+
dateCreated: "",
|
|
157
|
+
dateModified: "",
|
|
158
|
+
mailboxUid: "",
|
|
159
|
+
folderUid: "",
|
|
160
|
+
displayName,
|
|
161
|
+
...names,
|
|
162
|
+
emails: [{ address: mailbox.primarySmtpAddress, type: "work" }],
|
|
163
|
+
phones: own?.phones ?? [],
|
|
164
|
+
addresses: [],
|
|
165
|
+
company: own?.company,
|
|
166
|
+
jobTitle: own?.jobTitle,
|
|
167
|
+
});
|
|
168
|
+
return { displayName, vcard };
|
|
169
|
+
}
|