@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,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure rules behind the contact card (`ContactCard`), the "Add to address book" action of a vCard attachment and the compose window's
|
|
3
|
+
* "Attach my contact card": who a participant is, whether they are external, which contact (or vCard) describes them and what a new contact
|
|
4
|
+
* made from them holds. No network and no React - the calls that use these are in `contactCardData.ts`.
|
|
5
|
+
*/
|
|
6
|
+
import type { Contact, ContactEmail, ContactInput, ContactPhone, ContactPostalAddress } from "../../../../lib/contacts/contactsApi.js";
|
|
7
|
+
import { type ParsedVCardContact } from "../../../../lib/contacts/vcard.js";
|
|
8
|
+
import type { Mailbox } from "../../../../lib/mail/mailApi.js";
|
|
9
|
+
/** Someone who took part in a message or an event: a sender, a recipient, an organizer, a guest. */
|
|
10
|
+
export interface Participant {
|
|
11
|
+
address: string;
|
|
12
|
+
displayName?: string;
|
|
13
|
+
}
|
|
14
|
+
/** Whether an attachment is a vCard: by its MIME type (parameters such as `; charset=utf-8` ignored), else by a `.vcf`/`.vcard` name. */
|
|
15
|
+
export declare function isVCardAttachment(attachment: {
|
|
16
|
+
filename?: string;
|
|
17
|
+
mimeType?: string;
|
|
18
|
+
}): boolean;
|
|
19
|
+
/** An address in the form two of them are compared by. */
|
|
20
|
+
export declare function normalizeAddress(address: string): string;
|
|
21
|
+
/** The name to show for a participant: their own when distinct from the address, else the address's local part. */
|
|
22
|
+
export declare function participantName(participant: Participant): string;
|
|
23
|
+
/** 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. */
|
|
24
|
+
export declare function splitPersonName(displayName: string): {
|
|
25
|
+
givenName?: string;
|
|
26
|
+
surname?: string;
|
|
27
|
+
};
|
|
28
|
+
/** The first of `contacts` with an email equal (case-insensitively) to `address`. */
|
|
29
|
+
export declare function findContactByAddress(contacts: readonly Contact[], address: string): Contact | undefined;
|
|
30
|
+
/** The domains (lower case) of every address of `mailboxes` - primary and aliases. */
|
|
31
|
+
export declare function ownDomains(mailboxes: readonly Pick<Mailbox, "primarySmtpAddress" | "aliasAddresses">[]): Set<string>;
|
|
32
|
+
/** Whether `address` is outside the user's own domains. An address with no domain at all is not called external. */
|
|
33
|
+
export declare function isExternalAddress(address: string, domains: ReadonlySet<string>): boolean;
|
|
34
|
+
/** 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. */
|
|
35
|
+
export declare function cardForAddress(cards: readonly ParsedVCardContact[], address: string, orFirst: boolean): ParsedVCardContact | undefined;
|
|
36
|
+
/** A `ContactInput` whose lists are always there. */
|
|
37
|
+
export type KnownPerson = ContactInput & {
|
|
38
|
+
emails: ContactEmail[];
|
|
39
|
+
phones: ContactPhone[];
|
|
40
|
+
addresses: ContactPostalAddress[];
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* What a new contact for `participant` holds: everything the vCard `card` says (when there is one) and always the participant's address. The
|
|
44
|
+
* 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.
|
|
45
|
+
*/
|
|
46
|
+
export declare function contactInputFor(participant: Participant, card: ParsedVCardContact | undefined, scope: {
|
|
47
|
+
mailboxUid: string;
|
|
48
|
+
folderUid: string;
|
|
49
|
+
}): KnownPerson;
|
|
50
|
+
/** 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. */
|
|
51
|
+
export declare function contactInputFromCard(card: ParsedVCardContact, scope: {
|
|
52
|
+
mailboxUid: string;
|
|
53
|
+
folderUid: string;
|
|
54
|
+
}): KnownPerson;
|
|
55
|
+
/** 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. */
|
|
56
|
+
export declare function withoutDuplicates(cards: readonly ParsedVCardContact[], existing: readonly Contact[]): {
|
|
57
|
+
fresh: ParsedVCardContact[];
|
|
58
|
+
skipped: number;
|
|
59
|
+
};
|
|
60
|
+
/** 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). */
|
|
61
|
+
export declare function ownContactCard(mailbox: Pick<Mailbox, "displayName" | "primarySmtpAddress">, own: Contact | undefined): {
|
|
62
|
+
displayName: string;
|
|
63
|
+
vcard: string;
|
|
64
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { contactToVCard } from "../../../../lib/contacts/vcard.js";
|
|
2
|
+
import { splitMailAddress } from "../../../../lib/mail/mailAddress.js";
|
|
3
|
+
/** The MIME types a vCard file travels as. */
|
|
4
|
+
const VCARD_MIME_TYPES = new Set(["text/vcard", "text/x-vcard", "text/directory", "application/vcard"]);
|
|
5
|
+
/** Whether an attachment is a vCard: by its MIME type (parameters such as `; charset=utf-8` ignored), else by a `.vcf`/`.vcard` name. */
|
|
6
|
+
export function isVCardAttachment(attachment) {
|
|
7
|
+
const mimeType = (attachment.mimeType ?? "").split(";")[0].trim().toLowerCase();
|
|
8
|
+
return VCARD_MIME_TYPES.has(mimeType) || /\.(vcf|vcard)$/i.test(attachment.filename ?? "");
|
|
9
|
+
}
|
|
10
|
+
/** An address in the form two of them are compared by. */
|
|
11
|
+
export function normalizeAddress(address) {
|
|
12
|
+
return address.trim().toLowerCase();
|
|
13
|
+
}
|
|
14
|
+
/** The name to show for a participant: their own when distinct from the address, else the address's local part. */
|
|
15
|
+
export function participantName(participant) {
|
|
16
|
+
const { name, address } = splitMailAddress(participant);
|
|
17
|
+
if (name) {
|
|
18
|
+
return name;
|
|
19
|
+
}
|
|
20
|
+
const at = address.lastIndexOf("@");
|
|
21
|
+
return at > 0 ? address.slice(0, at) : address;
|
|
22
|
+
}
|
|
23
|
+
/** 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. */
|
|
24
|
+
export function splitPersonName(displayName) {
|
|
25
|
+
const name = displayName.trim();
|
|
26
|
+
const comma = name.indexOf(",");
|
|
27
|
+
if (comma > 0) {
|
|
28
|
+
return { givenName: name.slice(comma + 1).trim() || undefined, surname: name.slice(0, comma).trim() };
|
|
29
|
+
}
|
|
30
|
+
const parts = name.split(/\s+/).filter(Boolean);
|
|
31
|
+
if (parts.length < 2) {
|
|
32
|
+
return { givenName: parts[0] };
|
|
33
|
+
}
|
|
34
|
+
return { givenName: parts.slice(0, -1).join(" "), surname: parts[parts.length - 1] };
|
|
35
|
+
}
|
|
36
|
+
/** The first of `contacts` with an email equal (case-insensitively) to `address`. */
|
|
37
|
+
export function findContactByAddress(contacts, address) {
|
|
38
|
+
const wanted = normalizeAddress(address);
|
|
39
|
+
return contacts.find((contact) => contact.emails.some((email) => normalizeAddress(email.address) === wanted));
|
|
40
|
+
}
|
|
41
|
+
/** The domains (lower case) of every address of `mailboxes` - primary and aliases. */
|
|
42
|
+
export function ownDomains(mailboxes) {
|
|
43
|
+
const domains = new Set();
|
|
44
|
+
for (const mailbox of mailboxes) {
|
|
45
|
+
for (const address of [mailbox.primarySmtpAddress, ...(mailbox.aliasAddresses ?? [])]) {
|
|
46
|
+
const at = address.lastIndexOf("@");
|
|
47
|
+
if (at > 0) {
|
|
48
|
+
domains.add(address.slice(at + 1).trim().toLowerCase());
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return domains;
|
|
53
|
+
}
|
|
54
|
+
/** Whether `address` is outside the user's own domains. An address with no domain at all is not called external. */
|
|
55
|
+
export function isExternalAddress(address, domains) {
|
|
56
|
+
const at = address.lastIndexOf("@");
|
|
57
|
+
return at > 0 && !domains.has(address.slice(at + 1).trim().toLowerCase());
|
|
58
|
+
}
|
|
59
|
+
/** 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. */
|
|
60
|
+
export function cardForAddress(cards, address, orFirst) {
|
|
61
|
+
const wanted = normalizeAddress(address);
|
|
62
|
+
return cards.find((card) => (card.emails ?? []).some((email) => normalizeAddress(email.address) === wanted)) ?? (orFirst ? cards[0] : undefined);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* What a new contact for `participant` holds: everything the vCard `card` says (when there is one) and always the participant's address. The
|
|
66
|
+
* 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.
|
|
67
|
+
*/
|
|
68
|
+
export function contactInputFor(participant, card, scope) {
|
|
69
|
+
// A vCard with no name at all is parsed as "Unnamed contact": not a name worth keeping over the participant's own.
|
|
70
|
+
const cardName = card && (card.givenName || card.surname || card.displayName !== "Unnamed contact") ? card.displayName : "";
|
|
71
|
+
const displayName = cardName || participantName(participant);
|
|
72
|
+
const emails = [...(card?.emails ?? [])];
|
|
73
|
+
if (!emails.some((email) => normalizeAddress(email.address) === normalizeAddress(participant.address))) {
|
|
74
|
+
emails.push({ address: participant.address.trim(), type: "other" });
|
|
75
|
+
}
|
|
76
|
+
const names = card?.givenName || card?.surname ? { givenName: card.givenName, surname: card.surname } : splitPersonName(displayName);
|
|
77
|
+
return {
|
|
78
|
+
...scope,
|
|
79
|
+
displayName,
|
|
80
|
+
givenName: names.givenName,
|
|
81
|
+
surname: names.surname,
|
|
82
|
+
emails,
|
|
83
|
+
phones: card?.phones ?? [],
|
|
84
|
+
addresses: card?.addresses ?? [],
|
|
85
|
+
company: card?.company,
|
|
86
|
+
jobTitle: card?.jobTitle,
|
|
87
|
+
notes: card?.notes,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** 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. */
|
|
91
|
+
export function contactInputFromCard(card, scope) {
|
|
92
|
+
const names = card.givenName || card.surname ? { givenName: card.givenName, surname: card.surname } : splitPersonName(card.displayName);
|
|
93
|
+
return { ...card, ...scope, ...names, emails: card.emails ?? [], phones: card.phones ?? [], addresses: card.addresses ?? [] };
|
|
94
|
+
}
|
|
95
|
+
/** 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. */
|
|
96
|
+
export function withoutDuplicates(cards, existing) {
|
|
97
|
+
const known = new Set(existing.flatMap((contact) => contact.emails.map((email) => normalizeAddress(email.address))));
|
|
98
|
+
const fresh = [];
|
|
99
|
+
for (const card of cards) {
|
|
100
|
+
const addresses = (card.emails ?? []).map((email) => normalizeAddress(email.address));
|
|
101
|
+
if (addresses.some((address) => known.has(address))) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
addresses.forEach((address) => known.add(address));
|
|
105
|
+
fresh.push(card);
|
|
106
|
+
}
|
|
107
|
+
return { fresh, skipped: cards.length - fresh.length };
|
|
108
|
+
}
|
|
109
|
+
/** 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). */
|
|
110
|
+
export function ownContactCard(mailbox, own) {
|
|
111
|
+
const displayName = own?.displayName || mailbox.displayName || participantName({ address: mailbox.primarySmtpAddress });
|
|
112
|
+
const names = own?.givenName || own?.surname ? { givenName: own.givenName, surname: own.surname } : splitPersonName(displayName);
|
|
113
|
+
const vcard = contactToVCard({
|
|
114
|
+
uid: "",
|
|
115
|
+
version: 0,
|
|
116
|
+
dateCreated: "",
|
|
117
|
+
dateModified: "",
|
|
118
|
+
mailboxUid: "",
|
|
119
|
+
folderUid: "",
|
|
120
|
+
displayName,
|
|
121
|
+
...names,
|
|
122
|
+
emails: [{ address: mailbox.primarySmtpAddress, type: "work" }],
|
|
123
|
+
phones: own?.phones ?? [],
|
|
124
|
+
addresses: [],
|
|
125
|
+
company: own?.company,
|
|
126
|
+
jobTitle: own?.jobTitle,
|
|
127
|
+
});
|
|
128
|
+
return { displayName, vcard };
|
|
129
|
+
}
|
|
@@ -4,6 +4,7 @@ import type { IconType } from "react-icons";
|
|
|
4
4
|
import { NavItem } from "../../../../lib/components/navigation/BottomTabBar.js";
|
|
5
5
|
import type { Branding } from "../../../../lib/branding/brandingApi.js";
|
|
6
6
|
import { PluginNav, PluginNavProps } from "../../plugins/pluginNav.js";
|
|
7
|
+
import { type ResolvedRailLinks } from "../../plugins/useResolvedRailItems.js";
|
|
7
8
|
/** How long sign-out waits for auth-server's logout before navigating anyway. */
|
|
8
9
|
export declare const LOGOUT_TIMEOUT_MS = 3000;
|
|
9
10
|
export type AppShellApp = "mail" | "calendar" | "contacts" | "tasks";
|
|
@@ -47,8 +48,8 @@ export interface AppDef {
|
|
|
47
48
|
icon: IconType;
|
|
48
49
|
}
|
|
49
50
|
export declare const APPS: AppDef[];
|
|
50
|
-
/** `APPS` followed by the plugins' `appRail` items (generic
|
|
51
|
-
export declare function appRailItems(pluginNav?: PluginNav): NavItem[];
|
|
51
|
+
/** `APPS` followed by the plugins' `appRail` items (their manifest `icon`, or a generic one), core ids winning - see `mergePluginNavItems`. */
|
|
52
|
+
export declare function appRailItems(pluginNav?: PluginNav, resolved?: ResolvedRailLinks): NavItem[];
|
|
52
53
|
/** What only the persistent frame (`apps/www/_shell.tsx`) passes to the chrome it keeps mounted. */
|
|
53
54
|
export interface AppChromeProps extends AppShellProps {
|
|
54
55
|
/** The router is loading the next page - the content is `aria-busy`. */
|
|
@@ -5,7 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import "../../styles/app.css";
|
|
7
7
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
8
|
-
import { HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineEnvelope,
|
|
8
|
+
import { HiOutlineCalendarDays, HiOutlineClipboardDocumentList, HiOutlineEnvelope, HiOutlineUsers, } from "react-icons/hi2";
|
|
9
9
|
import { useRouter } from "@rapidrest/react/client";
|
|
10
10
|
import { useSessionRefresh } from "../../../../lib/auth/session.js";
|
|
11
11
|
import { getSetupStatus } from "../../../../lib/admin/setupApi.js";
|
|
@@ -14,6 +14,7 @@ import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
|
14
14
|
import useBranding from "../../../../lib/branding/useBranding.js";
|
|
15
15
|
import { useIdleKeyTimeout } from "../../../../lib/crypto/useIdleKeyTimeout.js";
|
|
16
16
|
import ComposeProvider from "../mail/compose/ComposeContext.js";
|
|
17
|
+
import ContactCardProvider from "../contacts/ContactCardProvider.js";
|
|
17
18
|
import { flushComposeDrafts, markSigningOut } from "../mail/compose/composeFlushRegistry.js";
|
|
18
19
|
import BottomTabBar from "../../../../lib/components/navigation/BottomTabBar.js";
|
|
19
20
|
import { FrameBrandingFooter, FrameBrandingHeader, useBrandingHtml } from "./BrandingChrome.js";
|
|
@@ -26,7 +27,9 @@ import { SIGN_OUT_CHANNEL, destroyAllLocalIndexes } from "../../search/localInde
|
|
|
26
27
|
import { authApiFetch, setApiUnauthorizedObserver } from "../../../../lib/util/api.js";
|
|
27
28
|
import { destroyUnlockedKeys } from "../../../../lib/crypto/keySession.js";
|
|
28
29
|
import { clearPinnedSignerCache } from "../mail/pinnedSigners.js";
|
|
30
|
+
import { pluginIcon } from "../../plugins/pluginIcons.js";
|
|
29
31
|
import { mergePluginNavItems } from "../../plugins/pluginNav.js";
|
|
32
|
+
import { useResolvedRailItems } from "../../plugins/useResolvedRailItems.js";
|
|
30
33
|
import { useInAppFrame } from "../../navigation/frameContext.js";
|
|
31
34
|
import { APP_HREFS } from "../../navigation/appHrefs.js";
|
|
32
35
|
import { useNavigate } from "../../navigation/index.js";
|
|
@@ -76,9 +79,13 @@ export const APPS = [
|
|
|
76
79
|
/** Ids plugin `appRail` items can't take besides `APPS`' own - `"settings"` has no rail icon but is still a
|
|
77
80
|
* core `active` value. */
|
|
78
81
|
const RESERVED_APP_IDS = ["settings"];
|
|
79
|
-
/** `APPS` followed by the plugins' `appRail` items (generic
|
|
80
|
-
export function appRailItems(pluginNav) {
|
|
81
|
-
|
|
82
|
+
/** `APPS` followed by the plugins' `appRail` items (their manifest `icon`, or a generic one), core ids winning - see `mergePluginNavItems`. */
|
|
83
|
+
export function appRailItems(pluginNav, resolved = {}) {
|
|
84
|
+
// An entry worked out per user (`resolveFrom`) is there only for a user it was answered for, and links where the answer said; the others are as declared.
|
|
85
|
+
const shown = (pluginNav?.appRail ?? [])
|
|
86
|
+
.filter((item) => !item.resolveFrom || resolved[item.id] !== undefined)
|
|
87
|
+
.map((item) => (item.resolveFrom ? { ...item, href: resolved[item.id] } : item));
|
|
88
|
+
return mergePluginNavItems(APPS, shown, ({ id, href, label, icon }) => ({ id, href, label, icon: pluginIcon(icon) }), RESERVED_APP_IDS);
|
|
82
89
|
}
|
|
83
90
|
/**
|
|
84
91
|
* The persistent chrome shared by every webmail app (Mail, Calendar, Contacts, Tasks): a left icon rail for
|
|
@@ -231,10 +238,12 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
|
|
|
231
238
|
// The router sets the document's title to the page's own whenever the page changes, which drops the unread count this puts in front
|
|
232
239
|
// of it, so a new pathname is what puts the count back. (A folder change is shallow and keeps the title.)
|
|
233
240
|
useUnreadTitle(inboxUnreadTotal(mail.mailboxFolders, mail.folderCounts.counts), { enabled: inFrame, resetKey: pathname });
|
|
241
|
+
// Plugin rail entries that depend on the user (the meet plugin's personal room) are asked for once they are signed in.
|
|
242
|
+
const resolvedRail = useResolvedRailItems(pluginNav?.appRail, userUid);
|
|
234
243
|
if (!userUid) {
|
|
235
244
|
return _jsx("div", { className: "min-h-screen" });
|
|
236
245
|
}
|
|
237
|
-
const apps = appRailItems(pluginNav);
|
|
246
|
+
const apps = appRailItems(pluginNav, resolvedRail);
|
|
238
247
|
// The header title: "settings" has no rail item, everything else is labelled by its own rail item.
|
|
239
248
|
const activeLabel = active === "settings" ? "Settings" : apps.find((app) => app.id === active)?.label;
|
|
240
249
|
// A custom header (`Branding.headerHtml`) replaces the app's own title bar and the icon at the top of the rail: it is the top of the app, and the
|
|
@@ -246,16 +255,16 @@ export function AppChrome({ active, userUid, authServerUrl, impersonating, imper
|
|
|
246
255
|
const renderUserMenu = (placement) => (_jsx(UserMenu, { userUid: userUid, authServerUrl: authServerUrl, onSignOut: handleSignOut,
|
|
247
256
|
// An impersonating administrator acts as the impersonated user, so the console link is hidden then.
|
|
248
257
|
showAdminLink: !!trusted && !impersonating, detectAdmin: !trusted && !impersonating, trustedRoles: trustedRoles, showSettingsLink: true, showNotificationSettings: true, onShowShortcuts: () => setShortcutsOpen(true), onShowNotifications: () => setHistoryOpen(true), unseenErrors: unseenErrors, placement: placement }));
|
|
249
|
-
return (_jsx(AppearanceProvider, { userUid: userUid, initial: appearance, lookUp: inFrame, children: _jsx(ShortcutProvider, { children: _jsxs(MailConnectionContext.Provider, { value: inFrame ? mail : null, children: [_jsx(GlobalShortcuts, { authServerUrl: authServerUrl, onToggleHelp: () => setShortcutsOpen((open) => !open) }), _jsx(ShortcutsDialog, { open: shortcutsOpen, onClose: () => setShortcutsOpen(false) }), _jsxs(UnlockPromptProvider, { children: [_jsx(UnlockBridge, {}), _jsx(ComposeProvider, { userUid: userUid, trusted: !!trusted && !impersonating, children: _jsxs("div", { className: "rr-frame-bg min-h-screen flex flex-col", children: [!hideChrome && customHeader && (_jsx(FrameBrandingHeader, { parsed: header, userMenu: menuInHeader ? renderUserMenu("down") : undefined, fallbackMenu: header && !menuInHeader && !menuInFooter ? renderUserMenu("down") : undefined, appTitle: activeLabel })), impersonating && !hideChrome && (_jsxs("div", { className: "h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4", children: [_jsxs("span", { children: ["You are viewing as ", _jsx("strong", { children: userUid }), "."] }), _jsx("button", { type: "button", onClick: handleStopImpersonating, disabled: stoppingImpersonation, className: "underline hover:no-underline disabled:opacity-60", children: stoppingImpersonation ? "Returning to admin…" : "Return to admin" })] })), _jsxs("div", { className: "flex-1 flex min-h-0", children: [!hideChrome && (_jsxs("nav", { "aria-label": "Apps", className: [
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
258
|
+
return (_jsx(AppearanceProvider, { userUid: userUid, initial: appearance, lookUp: inFrame, children: _jsx(ShortcutProvider, { children: _jsxs(MailConnectionContext.Provider, { value: inFrame ? mail : null, children: [_jsx(GlobalShortcuts, { authServerUrl: authServerUrl, onToggleHelp: () => setShortcutsOpen((open) => !open) }), _jsx(ShortcutsDialog, { open: shortcutsOpen, onClose: () => setShortcutsOpen(false) }), _jsxs(UnlockPromptProvider, { children: [_jsx(UnlockBridge, {}), _jsx(ComposeProvider, { userUid: userUid, trusted: !!trusted && !impersonating, children: _jsx(ContactCardProvider, { userUid: userUid, children: _jsxs("div", { className: "rr-frame-bg min-h-screen flex flex-col", children: [!hideChrome && customHeader && (_jsx(FrameBrandingHeader, { parsed: header, userMenu: menuInHeader ? renderUserMenu("down") : undefined, fallbackMenu: header && !menuInHeader && !menuInFooter ? renderUserMenu("down") : undefined, appTitle: activeLabel })), impersonating && !hideChrome && (_jsxs("div", { className: "h-10 shrink-0 bg-warning text-warning-contrast flex items-center justify-center gap-3 text-sm font-medium px-4", children: [_jsxs("span", { children: ["You are viewing as ", _jsx("strong", { children: userUid }), "."] }), _jsx("button", { type: "button", onClick: handleStopImpersonating, disabled: stoppingImpersonation, className: "underline hover:no-underline disabled:opacity-60", children: stoppingImpersonation ? "Returning to admin…" : "Return to admin" })] })), _jsxs("div", { className: "flex-1 flex min-h-0", children: [!hideChrome && (_jsxs("nav", { "aria-label": "Apps", className: [
|
|
259
|
+
"hidden md:flex w-16 shrink-0 bg-surface border-r border-border flex-col items-center gap-1",
|
|
260
|
+
// Under a custom header the rail starts with the app icons; without one its own icon is flush with the top of the window.
|
|
261
|
+
customHeader ? "py-3" : "pb-3",
|
|
262
|
+
].join(" "), children: [!customHeader && (_jsx(RailIcon, { src: iconSrc })), apps.map(({ id, href, label, icon: Icon }) => (_jsx("a", { href: href, "aria-label": label, "aria-current": id === active ? "page" : undefined, title: label, className: [
|
|
263
|
+
"w-10 h-10 flex items-center justify-center rounded-sm",
|
|
264
|
+
id === active
|
|
265
|
+
? "bg-primary/10 text-primary-dark"
|
|
266
|
+
: "text-text-muted hover:bg-surface-alt hover:text-text",
|
|
267
|
+
].join(" "), children: _jsx(Icon, { size: 20, "aria-hidden": "true" }) }, id)))] })), !hideChrome && _jsx(BottomTabBar, { apps: apps, active: active }), _jsxs("div", { className: "flex-1 flex flex-col min-w-0", children: [!hideChrome && !customHeader && (_jsxs("header", { ref: headerRef, className: "rr-solid sticky top-0 z-30 h-16 shrink-0 bg-surface border-b border-border flex items-center justify-between gap-4 px-6", children: [_jsx("span", { className: "font-display font-bold text-lg uppercase tracking-wide", children: activeLabel }), renderUserMenu("down")] })), _jsx(NotificationCenter, {}), _jsx("div", { id: "app-content", tabIndex: -1, "aria-busy": busy || undefined, className: ["flex-1 flex min-h-0 outline-none", hideChrome ? "" : "pb-14 md:pb-0"].join(" "), children: children })] })] }), !hideChrome && _jsx(FrameBrandingFooter, { parsed: footer, userMenu: menuInFooter ? renderUserMenu("up") : undefined, appTitle: activeLabel })] }) }) })] }), _jsx(NotificationHistoryDialog, { open: historyOpen, onClose: () => setHistoryOpen(false) })] }) }) }));
|
|
259
268
|
}
|
|
260
269
|
/**
|
|
261
270
|
* What a page's own shell (`MailShell`, `CalendarShell`, ...) renders around its content. Outside the client-side router it
|
|
@@ -3,13 +3,14 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import { useId, useState } from "react";
|
|
6
|
+
import React, { useId, useState } from "react";
|
|
7
7
|
import { HiOutlineCalendarDays, HiOutlineCheck } from "react-icons/hi2";
|
|
8
8
|
import { acceptProposal, proposeNewTime, removeMessageInvite, respondToMessageInvite, } from "../../../../lib/calendar/inviteApi.js";
|
|
9
9
|
import { guestPermissionsOf } from "../../../../lib/calendar/calendarApi.js";
|
|
10
10
|
import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
|
|
11
11
|
import { useApiClient } from "../../../../lib/util/apiClientContext.js";
|
|
12
12
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
13
|
+
import ParticipantLink from "../contacts/ParticipantLink.js";
|
|
13
14
|
import EventDescriptionView from "../calendar/EventDescriptionView.js";
|
|
14
15
|
import RequestChangeForm from "../calendar/RequestChangeForm.js";
|
|
15
16
|
import { VISIBILITY_LABEL, describeGuestPermissions } from "../calendar/eventFormat.js";
|
|
@@ -55,7 +56,7 @@ function attendeeSummary(attendees) {
|
|
|
55
56
|
return undefined;
|
|
56
57
|
}
|
|
57
58
|
const count = `${attendees.length} ${attendees.length === 1 ? "attendee" : "attendees"}`;
|
|
58
|
-
return attendees.length <= MAX_NAMED_ATTENDEES ?
|
|
59
|
+
return attendees.length <= MAX_NAMED_ATTENDEES ? (_jsxs(_Fragment, { children: [count, ":", " ", attendees.map((attendee, index) => (_jsxs(React.Fragment, { children: [index > 0 && ", ", _jsx(ParticipantLink, { participant: attendee, children: attendee.displayName || attendee.address })] }, index)))] })) : (count);
|
|
59
60
|
}
|
|
60
61
|
/**
|
|
61
62
|
* The calendar invitation a message carries, as Outlook and Gmail draw it: what the meeting is, when and where, who organizes it, and the buttons
|
|
@@ -120,6 +121,7 @@ export default function InviteCard({ messageUid, headingLevel = 2 }) {
|
|
|
120
121
|
const visibility = invite.visibility && invite.visibility !== "default" ? VISIBILITY_LABEL[invite.visibility] : undefined;
|
|
121
122
|
const sender = invite.reply ?? invite.attendees[0];
|
|
122
123
|
const senderName = sender ? sender.displayName || sender.address : "Someone";
|
|
124
|
+
const senderNode = sender ? (_jsx(ParticipantLink, { participant: sender, className: "font-semibold", children: senderName })) : (_jsx("span", { className: "font-semibold", children: senderName }));
|
|
123
125
|
const showResponses = invite.canRespond && !invite.isOrganizer;
|
|
124
126
|
const conflicts = !isReply && !isCounter && method !== "CANCEL" && invite.conflicts.length > 0 ? conflictSummary(invite.conflicts) : undefined;
|
|
125
127
|
const hasActions = showResponses ||
|
|
@@ -129,7 +131,7 @@ export default function InviteCard({ messageUid, headingLevel = 2 }) {
|
|
|
129
131
|
invite.canRemove ||
|
|
130
132
|
canRequest ||
|
|
131
133
|
(invite.onCalendar && !!invite.calendarEventUid);
|
|
132
|
-
return (_jsxs("section", { "aria-labelledby": headingId, className: "rounded-sm border border-border bg-surface-alt px-3 py-3 flex flex-col gap-2 text-sm text-text", children: [_jsxs(Heading, { id: headingId, className: "flex items-center gap-2 text-sm font-semibold", children: [_jsx(HiOutlineCalendarDays, { size: 16, "aria-hidden": "true", className: "shrink-0" }), changeRequest ? CHANGE_REQUEST_HEADING : (HEADINGS[method] ?? "Calendar event")] }), isReply && (_jsxs("p", { className: "break-words", children: [
|
|
134
|
+
return (_jsxs("section", { "aria-labelledby": headingId, className: "rounded-sm border border-border bg-surface-alt px-3 py-3 flex flex-col gap-2 text-sm text-text", children: [_jsxs(Heading, { id: headingId, className: "flex items-center gap-2 text-sm font-semibold", children: [_jsx(HiOutlineCalendarDays, { size: 16, "aria-hidden": "true", className: "shrink-0" }), changeRequest ? CHANGE_REQUEST_HEADING : (HEADINGS[method] ?? "Calendar event")] }), isReply && (_jsxs("p", { className: "break-words", children: [senderNode, " ", (sender?.responseStatus && REPLY_VERBS[sender.responseStatus]) ?? "responded", "."] })), isCounter && !changeRequest && (_jsxs("p", { className: "break-words", children: [senderNode, " proposed a new time", when ? _jsxs(_Fragment, { children: [": ", when] }) : "."] })), changeRequest && (_jsx("p", { className: "break-words", children: changeRequest.applied ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-semibold", children: senderName }), "\u2019s change was applied."] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-semibold", children: senderName }), " requested a change."] })) })), _jsx("p", { className: "font-medium break-words", children: title }), !isReply && (_jsxs("dl", { className: "grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1", children: [when && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "When" }), _jsxs("dd", { className: "break-words", children: [when, invite.recurring && _jsx("span", { className: "text-text-muted", children: " (Repeats)" })] })] })), invite.location && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Where" }), _jsx("dd", { className: "break-words", children: invite.location })] })), hasDescription && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Description" }), _jsx("dd", { className: "break-words", children: _jsx(EventDescriptionView, { html: invite.descriptionHtml, text: invite.description }) })] })), invite.organizer && !isCounter && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Organizer" }), _jsx("dd", { className: "break-words", children: _jsx(ParticipantLink, { participant: invite.organizer, children: formatMailAddress(invite.organizer) }) })] })), attendees && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Attendees" }), _jsx("dd", { className: "break-words", children: attendees })] })), visibility && !isCounter && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Visibility" }), _jsx("dd", { className: "break-words", children: visibility })] }))] })), isReply && when && _jsx("p", { className: "text-text-muted break-words", children: when }), conflicts && (_jsxs("p", { className: "text-danger break-words", children: [_jsx("span", { className: "font-medium", children: "Conflicts with:" }), " ", conflicts] })), invite.guestPermissions && !isReply && !isCounter && method !== "CANCEL" && (_jsx("p", { className: "text-xs text-text-muted break-words", children: describeGuestPermissions(permissions) })), invite.isOrganizer && !isReply && !isCounter && _jsx("p", { className: "text-text-muted", children: "You are the organizer of this meeting." }), method === "CANCEL" && _jsx("p", { className: "font-medium text-danger", children: "This meeting was canceled." }), invite.response && !invite.isOrganizer && !isReply && !isCounter && (_jsx("p", { role: "status", className: `font-medium ${ANSWER_CLASS[invite.response]}`, children: ANSWER_TEXT[invite.response] })), isCounter && invite.response === "accepted" && !changeRequest?.applied && (_jsx("p", { role: "status", className: "font-medium text-success", children: "You accepted the proposed time." })), proposed && (_jsx("p", { role: "status", className: "font-medium text-text", children: "Your proposed time was sent to the organizer." })), requested && (_jsx("p", { role: "status", className: "font-medium text-text", children: "Your change was sent to the organizer." })), invite.outdated && _jsx("p", { className: "text-xs text-text-muted", children: "A newer version of this meeting is already on your calendar." }), hasActions && (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [showResponses && (_jsx("div", { role: "group", "aria-label": "Respond to this meeting", "aria-busy": busy || undefined, className: "flex flex-wrap gap-2", children: RESPONSES.map(({ response, label, failure }) => {
|
|
133
135
|
const current = invite.response === response;
|
|
134
136
|
return (_jsxs(Button, { type: "button", variant: "secondary", className: ["!w-auto", current ? "!bg-primary/10 !border-primary disabled:!opacity-100" : ""].join(" "), "aria-pressed": current, "aria-busy": pending === response || undefined, loading: pending === response, disabled: busy || current, onClick: () => void run(response, failure, () => respondToMessageInvite(messageUid, response, client)), children: [current && _jsx(HiOutlineCheck, { size: 14, "aria-hidden": "true" }), label] }, response));
|
|
135
137
|
}) })), invite.canPropose && !proposing && (_jsx(Button, { type: "button", variant: "text", disabled: busy, onClick: () => setProposing(true), children: "Propose new time" })), canRequest && !requesting && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy, onClick: () => setRequesting(true), children: invite.canRequestChange ? "Request a change" : "Add guests" })), invite.canAcceptProposal && invite.response !== "accepted" && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "acceptProposal" || undefined, loading: pending === "acceptProposal", disabled: busy, onClick: () => void run("acceptProposal", "Couldn't accept the proposed time", () => acceptProposal(messageUid, client)), children: "Accept proposal" })), invite.canAdd && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "add" || undefined, loading: pending === "add", disabled: busy,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { MailAddressLike } from "../../../../lib/mail/mailAddress.js";
|
|
3
|
+
import type { ContactCardContext } from "../contacts/contactCardData.js";
|
|
3
4
|
export interface MailAddressProps {
|
|
4
5
|
/** A message's `from`, a recipient, a conversation participant. */
|
|
5
6
|
recipient: MailAddressLike;
|
|
@@ -19,9 +20,11 @@ export declare const RECIPIENT_LINE_LIMIT = 3;
|
|
|
19
20
|
/**
|
|
20
21
|
* A `To`/`Cc` line of a message header: every recipient as `Name <address>` in the text itself (never hover-only), wrapping
|
|
21
22
|
* onto more lines as needed. A long list shows its first `RECIPIENT_LINE_LIMIT` and folds the rest behind a button -
|
|
22
|
-
* "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty.
|
|
23
|
+
* "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty. Each recipient opens their
|
|
24
|
+
* contact card when clicked (see `ParticipantLink`); `context` is the message they are listed on.
|
|
23
25
|
*/
|
|
24
|
-
export declare function RecipientLine({ label, recipients }: {
|
|
26
|
+
export declare function RecipientLine({ label, recipients, context }: {
|
|
25
27
|
label: string;
|
|
26
28
|
recipients: MailAddressLike[];
|
|
29
|
+
context?: ContactCardContext;
|
|
27
30
|
}): React.JSX.Element | null;
|
|
@@ -5,6 +5,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import React, { useState } from "react";
|
|
7
7
|
import { formatMailAddress, splitMailAddress } from "../../../../lib/mail/mailAddress.js";
|
|
8
|
+
import ParticipantLink from "../contacts/ParticipantLink.js";
|
|
8
9
|
/**
|
|
9
10
|
* A sender or recipient as one compact line for a dense row: the name, when there is one, with the real address in muted
|
|
10
11
|
* type after it - `Jean-Philippe <jp@example.com>` - or the bare address without a name. The address is never left out,
|
|
@@ -26,14 +27,15 @@ export const RECIPIENT_LINE_LIMIT = 3;
|
|
|
26
27
|
/**
|
|
27
28
|
* A `To`/`Cc` line of a message header: every recipient as `Name <address>` in the text itself (never hover-only), wrapping
|
|
28
29
|
* onto more lines as needed. A long list shows its first `RECIPIENT_LINE_LIMIT` and folds the rest behind a button -
|
|
29
|
-
* "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty.
|
|
30
|
+
* "and 12 more" / "Show fewer" - so a message to a hundred people doesn't take the whole header. Nothing when empty. Each recipient opens their
|
|
31
|
+
* contact card when clicked (see `ParticipantLink`); `context` is the message they are listed on.
|
|
30
32
|
*/
|
|
31
|
-
export function RecipientLine({ label, recipients }) {
|
|
33
|
+
export function RecipientLine({ label, recipients, context }) {
|
|
32
34
|
const [expanded, setExpanded] = useState(false);
|
|
33
35
|
if (recipients.length === 0) {
|
|
34
36
|
return null;
|
|
35
37
|
}
|
|
36
38
|
const folded = recipients.length > RECIPIENT_LINE_LIMIT;
|
|
37
39
|
const shown = folded && !expanded ? recipients.slice(0, RECIPIENT_LINE_LIMIT) : recipients;
|
|
38
|
-
return (_jsxs("p", { className: "text-sm text-text-muted break-words", children: [_jsx("span", { children: label }), " ", shown.map((recipient, index) => (_jsxs(React.Fragment, { children: [index > 0 && ", ", _jsx(
|
|
40
|
+
return (_jsxs("p", { className: "text-sm text-text-muted break-words", children: [_jsx("span", { children: label }), " ", shown.map((recipient, index) => (_jsxs(React.Fragment, { children: [index > 0 && ", ", _jsx(ParticipantLink, { participant: recipient, context: context, children: formatMailAddress(recipient) })] }, index))), folded && (_jsxs(_Fragment, { children: [" ", _jsx("button", { type: "button", "aria-expanded": expanded, onClick: () => setExpanded((open) => !open), className: "text-xs font-semibold text-primary-dark underline hover:no-underline", children: expanded ? "Show fewer" : `and ${recipients.length - RECIPIENT_LINE_LIMIT} more` })] }))] }));
|
|
39
41
|
}
|
|
@@ -27,6 +27,10 @@ import { loadOriginalMessage, prefetchOriginalMessage } from "./compose/quotedBo
|
|
|
27
27
|
import { formatRecipient } from "./compose/recipients.js";
|
|
28
28
|
import { formatMailAddress } from "../../../../lib/mail/mailAddress.js";
|
|
29
29
|
import MailAddress, { RecipientLine } from "./MailAddress.js";
|
|
30
|
+
import ParticipantLink from "../contacts/ParticipantLink.js";
|
|
31
|
+
import { fetchVCardText } from "../contacts/contactCardData.js";
|
|
32
|
+
import { isVCardAttachment } from "../contacts/participantDetails.js";
|
|
33
|
+
import VCardAttachmentChip from "./VCardAttachmentChip.js";
|
|
30
34
|
import { useMailShell } from "./layout/MailShell.js";
|
|
31
35
|
import { ariaKeyShortcuts, withHint } from "../../keyboard/format.js";
|
|
32
36
|
import { SHORTCUTS } from "../../keyboard/keymap.js";
|
|
@@ -55,7 +59,7 @@ import { buildConversationPrintDocument, printDocument } from "./reading/printMe
|
|
|
55
59
|
import { olderConversationMessages, printHeaders, printableOlderMessage } from "./reading/conversationPrint.js";
|
|
56
60
|
import { fetchBodyContent } from "./reading/bodyContent.js";
|
|
57
61
|
import { useViewOriginal } from "./reading/viewOriginal.js";
|
|
58
|
-
import { ROW_FOCUS_CLASS,
|
|
62
|
+
import { ROW_FOCUS_CLASS, dateClass, senderClass } from "./unreadStyle.js";
|
|
59
63
|
/** Labels/styling for `specs/end-to-end_encryption.md`'s "Message Security Indicators" table - kept as
|
|
60
64
|
* plain data (not JSX) so `SecurityIndicator` below stays a trivial lookup. "Signature failed" MUST NOT
|
|
61
65
|
* read as a muted variant of "verified" (a failed signature is a stronger negative than no signature at
|
|
@@ -130,6 +134,15 @@ export function formatFingerprint(fingerprint) {
|
|
|
130
134
|
const compact = fingerprint.replace(/[\s:]/g, "").toUpperCase();
|
|
131
135
|
return compact.match(/.{1,4}/g)?.join(" ") ?? compact;
|
|
132
136
|
}
|
|
137
|
+
/** Saves the file at `url` under `filename`: the browser is handed the link as a download. */
|
|
138
|
+
function downloadFromUrl(url, filename) {
|
|
139
|
+
const a = document.createElement("a");
|
|
140
|
+
a.href = url;
|
|
141
|
+
a.download = filename;
|
|
142
|
+
document.body.appendChild(a);
|
|
143
|
+
a.click();
|
|
144
|
+
a.remove();
|
|
145
|
+
}
|
|
133
146
|
/** Saves one attachment recovered from inside a signed/encrypted entity. Always handed to the browser as an
|
|
134
147
|
* opaque download (`application/octet-stream`), never rendered in this origin. */
|
|
135
148
|
function downloadMimeAttachment(attachment) {
|
|
@@ -772,6 +785,8 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
772
785
|
const senderNameCheck = checkSenderName(senderName, senderAddress);
|
|
773
786
|
// Always `Name <address>` (or the bare address): a name alone hides who a message is really from.
|
|
774
787
|
const senderLabel = formatMailAddress({ displayName: senderName, address: senderAddress });
|
|
788
|
+
// What the contact card of anyone named in this message is given: the message, for its mailbox and any vCard it carries.
|
|
789
|
+
const cardContext = { message, attachments };
|
|
775
790
|
// Offered only for a valid signature from a certificate nobody pinned for this sender - never to replace a pin.
|
|
776
791
|
// A recorded signing-key conflict for this sender is resolved from the contact, never by trusting another key.
|
|
777
792
|
const pendingConflict = senderUnpinned && senderKeyState?.conflict !== undefined;
|
|
@@ -913,10 +928,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
913
928
|
: "This message can't be read, so it can't be printed";
|
|
914
929
|
// In Junk Email the card's button is the way back (Not junk), which only needs the right to change the message.
|
|
915
930
|
const reportReason = !writable ? "View-only mailbox" : undefined;
|
|
916
|
-
const card = (_jsxs(CardShell, { unread: cardUnread, children: [_jsxs("div", { className: ["flex flex-wrap items-start gap-x-3 gap-y-1 px-4 pt-3 pb-2", cardUnread ? "bg-primary/[0.07]" : ""].join(" "), children: [_jsx(SenderAvatar, { from: senderRecipient }), _jsxs("div", { className: "flex-1 min-w-[12rem]", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1", children: [threadHeader ? (
|
|
917
|
-
"w-full text-left rounded-sm text-sm",
|
|
918
|
-
ROW_FOCUS_CLASS,
|
|
919
|
-
].join(" "), children: [_jsx(UnreadLabel, { unread: cardUnread }), _jsx(MailAddress, { recipient: senderRecipient, className: senderClass(cardUnread) })] }) })) : (_jsxs("p", { className: "text-sm break-words min-w-0", children: ["From ", _jsx("span", { className: "font-semibold text-text", children: senderLabel })] })), security && _jsx(SecurityIndicator, { security: security })] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsx(RecipientLine, { label: "To", recipients: message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc") }), _jsx(RecipientLine, { label: "Cc", recipients: message.recipients.filter((r) => r.type === "cc") }), _jsx(RecipientLine, { label: "Bcc", recipients: message.recipients.filter((r) => r.type === "bcc") })] }), _jsxs("div", { className: "ml-auto flex flex-wrap items-center justify-end gap-1", children: [_jsx("time", { dateTime: message.receivedDate, className: ["text-xs mr-1", dateClass(cardUnread)].join(" "), children: new Date(message.receivedDate).toLocaleString() }), adaptable && (_jsx("button", { type: "button", "aria-label": "View original", "aria-pressed": viewOriginal, title: viewOriginal ? "Follow the theme" : "View original", onClick: () => setViewOriginal(!viewOriginal), className: [
|
|
931
|
+
const card = (_jsxs(CardShell, { unread: cardUnread, children: [_jsxs("div", { className: ["flex flex-wrap items-start gap-x-3 gap-y-1 px-4 pt-3 pb-2", cardUnread ? "bg-primary/[0.07]" : ""].join(" "), children: [_jsx(SenderAvatar, { from: senderRecipient }), _jsxs("div", { className: "flex-1 min-w-[12rem]", children: [_jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1", children: [threadHeader ? (_jsxs("h2", { className: "relative min-w-0 flex-1", style: BODY_FONT_STYLE, children: [_jsx("button", { type: "button", ref: threadHeader.buttonRef, onClick: threadHeader.onToggle, "aria-expanded": true, "aria-controls": threadHeader.bodyId, "aria-label": `${cardUnread ? "Unread. " : ""}${senderLabel}`, className: ["absolute inset-0 w-full rounded-sm", ROW_FOCUS_CLASS].join(" ") }), _jsx("span", { className: "pointer-events-none relative flex text-sm", children: _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, label: `Contact card for ${senderLabel}`, className: "pointer-events-auto min-w-0 max-w-full", children: _jsx(MailAddress, { recipient: senderRecipient, className: senderClass(cardUnread) }) }) })] })) : (_jsxs("p", { className: "text-sm break-words min-w-0", children: ["From", " ", _jsx(ParticipantLink, { participant: { address: senderAddress, displayName: senderName }, context: cardContext, className: "font-semibold text-text", children: senderLabel })] })), security && _jsx(SecurityIndicator, { security: security })] }), senderNameCheck.misleading && (_jsxs("p", { role: "status", className: "mt-2 py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["The sender\u2019s name \u201C", senderName, "\u201D looks like an email address, but this message was sent from ", _jsx("span", { className: "font-medium", children: senderAddress }), ". Don\u2019t trust it based on the name."] })), _jsx(RecipientLine, { label: "To", recipients: message.recipients.filter((r) => r.type !== "cc" && r.type !== "bcc"), context: cardContext }), _jsx(RecipientLine, { label: "Cc", recipients: message.recipients.filter((r) => r.type === "cc"), context: cardContext }), _jsx(RecipientLine, { label: "Bcc", recipients: message.recipients.filter((r) => r.type === "bcc"), context: cardContext })] }), _jsxs("div", { className: "ml-auto flex flex-wrap items-center justify-end gap-1", children: [_jsx("time", { dateTime: message.receivedDate, className: ["text-xs mr-1", dateClass(cardUnread)].join(" "), children: new Date(message.receivedDate).toLocaleString() }), adaptable && (_jsx("button", { type: "button", "aria-label": "View original", "aria-pressed": viewOriginal, title: viewOriginal ? "Follow the theme" : "View original", onClick: () => setViewOriginal(!viewOriginal), className: [
|
|
920
932
|
"inline-flex items-center justify-center p-1.5 rounded-md border border-border text-sm text-text hover:bg-surface-alt",
|
|
921
933
|
viewOriginal ? "bg-primary/10" : "",
|
|
922
934
|
].join(" "), children: viewOriginal ? _jsx(HiOutlineMoon, { size: 16, "aria-hidden": "true" }) : _jsx(HiOutlineSun, { size: 16, "aria-hidden": "true" }) })), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true" }), label: "Reply", shortcut: keyboard ? SHORTCUTS.mail.reply : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("reply") }), _jsx(IconAction, { icon: _jsxs("span", { className: "inline-flex items-center", "aria-hidden": "true", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16 }), _jsx(HiOutlineArrowUturnLeft, { size: 16, className: "-ml-2.5" })] }), label: "Reply All", shortcut: keyboard ? SHORTCUTS.mail.replyAll : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("replyAll") }), _jsx(IconAction, { icon: _jsx(HiOutlineArrowUturnRight, { size: 16, "aria-hidden": "true" }), label: "Forward", shortcut: keyboard ? SHORTCUTS.mail.forward : undefined, disabled: preparingCompose, onPrefetch: prefetchReply, onClick: () => void handleReplyOrForward("forward") }), archivable && (_jsx(IconAction, { icon: _jsx(HiOutlineArchiveBox, { size: 16, "aria-hidden": "true" }), label: "Archive", shortcut: keyboard ? SHORTCUTS.mail.archive : undefined, busy: archiving, disabled: archiving, onClick: handleArchive })), movable && (_jsx(IconAction, { icon: _jsx(HiOutlineFolderArrowDown, { size: 16, "aria-hidden": "true" }), label: "Move to", shortcut: keyboard ? SHORTCUTS.mail.move : undefined, onClick: () => setMovePrompt(true) })), reportable && (_jsx(IconAction, { icon: inJunk ? _jsx(HiOutlineInboxArrowDown, { size: 16, "aria-hidden": "true" }) : _jsx(HiOutlineNoSymbol, { size: 16, "aria-hidden": "true" }), label: inJunk ? "Not junk" : "Report junk", busy: messageActions.busy, disabled: messageActions.busy || reportReason !== undefined, reason: reportReason, onClick: () => void (inJunk ? messageActions.notJunk() : messageActions.reportJunk()) })), labels && labels.length > 0 && (_jsx(LabelMenuButton, { "aria-label": "Labels", label: "Labels", className: "border border-border py-1.5", labels: labels, mailboxUid: message.mailboxUid, onLabelCreated: onLabelCreated, applied: currentLabelsMessage.labelUids ?? [], onCommit: (labelUids) => void handleApplyLabels(labelUids), busy: savingLabels, note: "Ticked labels are applied to this message and unticked ones removed.", emptyNote: "This mailbox has no labels yet.", commit: { label: "Apply" }, clear: { label: "Remove all labels" } })), archivable && (_jsx(MessageMoreMenu, { actions: menuActions, senderAddress: message.from.address, read: currentLabelsMessage.flags.read === true, flagged: currentLabelsMessage.flags.flagged === true, writable: writable, busy: messageActions.busy, inJunk: inJunk, inDeletedItems: inDeletedItems, sent: !!isSentItems, ownSender: readerOwnAddresses.includes(message.from.address.toLowerCase()), canTrustSender: writable, printReason: printReason, composing: preparingCompose, triggerClassName: ICON_BUTTON_CLASS }))] })] }), (answered || forwarded) && (_jsx("p", { className: "px-4 py-1.5 text-xs text-text-muted bg-surface-alt border-y border-border", children: answered && forwarded ? "You replied to and forwarded this message." : answered ? "You replied to this message." : "You forwarded this message." })), _jsxs("div", { id: threadHeader?.bodyId, className: "px-4 pb-3 flex flex-col gap-2", children: [inThread && (subjectDiffers || !sameSubject(shownSubject, threadSubject)) && (_jsx("h3", { className: "text-sm font-semibold break-words", children: shownSubject })), _jsx("div", { className: "flex flex-wrap items-center gap-2 empty:hidden", children: sendingStatus }), inOutbox && message.scheduledSendError && _jsxs(Alert, { children: ["This message wasn\u2019t sent: ", message.scheduledSendError] }), cancelError && _jsx(Alert, { children: cancelError }), keyChangeNotice && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: keyChangeNotice })), security && UNVERIFIED_SIGNER_STATES.has(security.state) && (_jsxs("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-surface-alt text-text", children: ["Signed, but the signer isn\u2019t a trusted contact key, so the sender isn\u2019t verified.", security.signerEmails && security.signerEmails.length > 0 && _jsxs(_Fragment, { children: [" Certificate for ", security.signerEmails.join(", "), "."] }), security.signerFingerprint && (_jsxs(_Fragment, { children: [" ", "Fingerprint ", _jsx("span", { className: "font-mono text-xs break-all", children: security.signerFingerprint }), "."] }))] })), pendingConflict && (_jsxs("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: ["This sender has a signing key change waiting for your review, so this signer can\u2019t be trusted from here.", " ", _jsx("a", { href: `/contacts/${encodeURIComponent(senderKeyState.contactUid)}`, className: "font-medium text-primary-dark hover:underline", children: "Review it in Contacts" })] })), trustableCertificate && (_jsx("div", { children: _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => {
|
|
@@ -935,7 +947,7 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
935
947
|
.map((l) => (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt", children: [_jsx("span", { className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: l.color ?? "#6366f1" } }), l.name] }, l.uid))) })), ["delivery", "read"]
|
|
936
948
|
.filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
|
|
937
949
|
.map((type) => (_jsxs("div", { className: "flex flex-wrap items-center gap-2 py-2 px-3 rounded-sm bg-surface-alt text-sm", children: [_jsxs("span", { children: [senderLabel, " requested a ", type, " receipt for this message."] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: receiptBusy === type, disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "approve"), children: "Send receipt" }), _jsx(Button, { type: "button", variant: "text", disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "decline"), children: "Decline" })] }, type))), receiptError && _jsx(Alert, { children: receiptError }), mayHoldInvite && _jsx(InviteCard, { messageUid: message.uid, headingLevel: inThread ? 3 : 2 }), security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: "These attachments come from a message whose signature couldn\u2019t be verified. Open them only if you trust the sender." })), innerAttachments
|
|
938
|
-
? innerAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: innerAttachments.map((attachment, index) => (_jsx("li", { children: _jsx("button", { type: "button", onClick: () => downloadMimeAttachment(attachment), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: attachment.filename ?? `Unnamed ${attachment.contentType} attachment` }) }, `${index}:${attachment.filename ?? ""}`))) }))
|
|
939
|
-
: listedAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: listedAttachments.map((attachment) => (_jsx("li", { children: _jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] }) }, attachment.uid))) })), _jsx("div", { ref: bodyRegionRef, tabIndex: -1, className: "outline-none", children: message.encrypted && security === null ? (_jsx(BodySkeleton, {})) : security?.decryptError !== undefined ? (_jsx(EncryptedBody, { locked: lockedRef.current, reason: security.decryptError, unlocking: unlocking, onUnlock: () => void handleUnlockToView() })) : (_jsx(MessageBody, { messageUid: message.uid, messageVersion: message.version, title: bodyTitle, content: bodyContent, attachments: attachments, inlineParts: innerAttachments, original: viewOriginal, onAdaptable: setAdaptable })) })] }), showFooter && (_jsxs("div", { className: "flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply"), children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply all to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll"), children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Forward this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward"), children: "Forward" })] }))] }));
|
|
950
|
+
? innerAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: innerAttachments.map((attachment, index) => (_jsx("li", { children: isVCardAttachment({ filename: attachment.filename, mimeType: attachment.contentType }) ? (_jsx(VCardAttachmentChip, { label: attachment.filename ?? `Unnamed ${attachment.contentType} attachment`, message: message, loadText: async () => new TextDecoder().decode(attachment.decode() ?? new Uint8Array(0)), onDownload: () => downloadMimeAttachment(attachment) })) : (_jsx("button", { type: "button", onClick: () => downloadMimeAttachment(attachment), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: attachment.filename ?? `Unnamed ${attachment.contentType} attachment` })) }, `${index}:${attachment.filename ?? ""}`))) }))
|
|
951
|
+
: listedAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: listedAttachments.map((attachment) => (_jsx("li", { children: isVCardAttachment(attachment) ? (_jsx(VCardAttachmentChip, { label: `${attachment.filename} (${formatBytes(attachment.sizeBytes)})`, message: message, loadText: () => fetchVCardText(attachment.uid), onDownload: () => downloadFromUrl(attachmentContentUrl(attachment.uid), attachment.filename) })) : (_jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] })) }, attachment.uid))) })), _jsx("div", { ref: bodyRegionRef, tabIndex: -1, className: "outline-none", children: message.encrypted && security === null ? (_jsx(BodySkeleton, {})) : security?.decryptError !== undefined ? (_jsx(EncryptedBody, { locked: lockedRef.current, reason: security.decryptError, unlocking: unlocking, onUnlock: () => void handleUnlockToView() })) : (_jsx(MessageBody, { messageUid: message.uid, messageVersion: message.version, title: bodyTitle, content: bodyContent, attachments: attachments, inlineParts: innerAttachments, original: viewOriginal, onAdaptable: setAdaptable })) })] }), showFooter && (_jsxs("div", { className: "flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply"), children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply all to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll"), children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Forward this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward"), children: "Forward" })] }))] }));
|
|
940
952
|
return (_jsxs("div", { className: inThread ? "min-w-0" : "flex-1 min-w-0 min-h-0 flex flex-col h-full", children: [inThread ? (card) : (_jsxs(_Fragment, { children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block px-4 pt-3", children: "\u2190 Back to messages" })), _jsx(SubjectCard, { subject: shownSubject }), _jsx("div", { className: "flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3", children: card })] })), _jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: message.mailboxUid, folders: folders ?? [], currentFolderUid: message.folderUid, onMove: handleMove, onFolderCreated: onFolderCreated }), messageActions.dialog, _jsx(MessageSourceDialog, { open: sourceView !== null, onClose: closeSource, mode: sourceView?.mode ?? "source", source: sourceView?.source ?? { status: "loading" }, encrypted: !!message.encrypted }), _jsxs(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Recall this message?", children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: "This asks every original recipient's mail system to delete their copy, but only if it's still unread there \u2014 there's no way to guarantee it, and no confirmation once it either succeeds or fails. Recipients who already read the message will keep it." }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", loading: recalling, disabled: recalling, onClick: handleRecall, className: "!w-auto", children: "Recall message" }), _jsx(Button, { type: "button", variant: "secondary", disabled: recalling, onClick: () => setConfirming(false), className: "!w-auto", children: "Cancel" })] })] }), _jsx(Modal, { open: trustConfirmOpen && !!trustableCertificate, onClose: () => !trusting && setTrustConfirmOpen(false), title: "Trust this signer?", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsxs("p", { children: ["Mail from ", _jsx("span", { className: "font-medium", children: senderAddress }), " signed with this certificate will show as verified."] }), _jsxs("dl", { className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate issued to" }), _jsx("dd", { children: security?.signerEmails?.length ? security.signerEmails.join(", ") : "No email address" }), _jsx("dt", { className: "text-xs text-text-muted", children: "Fingerprint" }), _jsx("dd", { className: "font-mono text-xs break-all", children: security?.signerFingerprint ? formatFingerprint(security.signerFingerprint) : "Unknown" })] }), _jsx("p", { className: "text-text-muted", children: "Anyone can create a certificate naming any address. Before trusting it, confirm this fingerprint with the sender through another channel, such as a phone call." }), trustError && _jsx(Alert, { children: trustError }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: trusting, disabled: trusting, onClick: () => handleTrustSigner(senderAddress, trustableCertificate), children: "Trust" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => setTrustConfirmOpen(false), children: "Cancel" })] })] }) })] }));
|
|
941
953
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { Message } from "../../../../lib/mail/mailApi.js";
|
|
3
|
+
export interface VCardAttachmentChipProps {
|
|
4
|
+
/** The file's name, as the chip shows it (with the size, when known). */
|
|
5
|
+
label: string;
|
|
6
|
+
/** The message the file is attached to: a new contact goes to its mailbox's address book when that is the user's own. */
|
|
7
|
+
message: Message;
|
|
8
|
+
/** The vCard file's text. */
|
|
9
|
+
loadText: () => Promise<string>;
|
|
10
|
+
/** Saves the file to the device. */
|
|
11
|
+
onDownload: () => void;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A vCard attachment (`.vcf`, `text/vcard`) in a message's attachment list. Clicking it opens a menu - "Add to address book" (every card in the file
|
|
15
|
+
* becomes a contact, except the ones already there) and "Download" - instead of downloading at once. Outside a `ContactCardProvider` there is no
|
|
16
|
+
* address book to add to, and the chip is the plain download it always was.
|
|
17
|
+
*/
|
|
18
|
+
export default function VCardAttachmentChip({ label, message, loadText, onDownload }: VCardAttachmentChipProps): React.JSX.Element;
|