@rapidmx/web-client 0.32.0 → 0.33.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/apps/shared/components/admin/diagnostics/SettingsTable.tsx +13 -2
- package/apps/shared/components/admin/mailboxes/MailboxTable.tsx +24 -4
- package/apps/shared/components/admin/settings/DomainDnsSetup.tsx +3 -3
- package/apps/shared/components/calendar/EventDetails.tsx +10 -0
- package/apps/shared/components/calendar/EventFormParts.tsx +10 -0
- package/apps/shared/components/calendar/MonthView.tsx +10 -4
- package/apps/shared/components/contacts/ContactCard.tsx +4 -2
- package/apps/shared/components/contacts/ContactsSidebar.tsx +8 -2
- package/apps/shared/components/contacts/ContactsToolbar.tsx +6 -0
- package/apps/shared/components/mail/ConversationThreadPane.tsx +34 -2
- package/apps/shared/components/mail/compose/ComposeContext.tsx +19 -1
- package/apps/shared/components/mail/compose/draftResume.ts +54 -0
- package/apps/www/calendar/index.tsx +4 -4
- package/apps/www/contacts/index.tsx +75 -3
- package/apps/www/tasks/index.tsx +1 -1
- package/dist/apps/shared/components/admin/diagnostics/SettingsTable.js +3 -2
- package/dist/apps/shared/components/admin/mailboxes/MailboxTable.js +6 -1
- package/dist/apps/shared/components/admin/settings/DomainDnsSetup.js +3 -3
- package/dist/apps/shared/components/calendar/EventDetails.js +2 -1
- package/dist/apps/shared/components/calendar/EventFormParts.js +2 -1
- package/dist/apps/shared/components/calendar/MonthView.d.ts +1 -1
- package/dist/apps/shared/components/calendar/MonthView.js +4 -4
- package/dist/apps/shared/components/contacts/ContactCard.js +4 -1
- package/dist/apps/shared/components/contacts/ContactsSidebar.d.ts +6 -2
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +3 -3
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -1
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +35 -1
- package/dist/apps/shared/components/mail/compose/ComposeContext.d.ts +2 -0
- package/dist/apps/shared/components/mail/compose/ComposeContext.js +17 -1
- package/dist/apps/shared/components/mail/compose/draftResume.d.ts +10 -0
- package/dist/apps/shared/components/mail/compose/draftResume.js +48 -0
- package/dist/apps/www/calendar/index.js +4 -4
- package/dist/apps/www/contacts/index.js +63 -5
- package/dist/apps/www/tasks/index.js +1 -1
- package/dist/lib/calendar/recurrence.js +1 -1
- package/dist/lib/components/overlays/overlayStack.d.ts +6 -0
- package/dist/lib/components/overlays/overlayStack.js +10 -0
- package/dist/lib/components/pickers/MiniDatePicker.d.ts +1 -1
- package/dist/lib/components/pickers/MiniDatePicker.js +4 -4
- package/dist/lib/contacts/contactsApi.d.ts +12 -0
- package/dist/lib/contacts/contactsApi.js +8 -0
- package/lib/calendar/recurrence.ts +1 -1
- package/lib/components/overlays/overlayStack.ts +11 -0
- package/lib/components/pickers/MiniDatePicker.tsx +4 -4
- package/lib/contacts/contactsApi.ts +16 -0
- package/package.json +1 -1
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
///////////////////////////////////////////////////////////////////////////////
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
6
|
import { useMemo, useState } from "react";
|
|
7
7
|
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
8
|
+
import CopyIconButton from "../../../../../lib/components/buttons/CopyIconButton.js";
|
|
8
9
|
/** How many settings are listed at first, and how many more each "Show more" adds: an environment can be a hundred long. */
|
|
9
10
|
export const SETTINGS_PAGE_SIZE = 100;
|
|
10
11
|
/** What stands where the server withheld a value. Drawn here from `redacted`: the server never sends the value (or any text for it). */
|
|
@@ -27,5 +28,5 @@ export default function SettingsTable({ title, description, id, settings }) {
|
|
|
27
28
|
setQuery(event.target.value);
|
|
28
29
|
setLimit(SETTINGS_PAGE_SIZE);
|
|
29
30
|
} }) }), _jsx("p", { role: "status", className: "mb-2 text-xs text-text-muted", children: (matching.length === settings.length ? `${settings.length} settings` : `${matching.length} of ${settings.length} settings match`) +
|
|
30
|
-
(hiddenCount > 0 ? `, ${hiddenCount} with a hidden value` : "") }), matching.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: settings.length === 0 ? "Nothing is set." : "No settings match." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Value"].map((heading) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: heading }, heading))) }) }), _jsx("tbody", { children: shown.map((item) => (_jsxs("tr", { children: [
|
|
31
|
+
(hiddenCount > 0 ? `, ${hiddenCount} with a hidden value` : "") }), matching.length === 0 ? (_jsx("p", { className: "text-sm text-text-muted", children: settings.length === 0 ? "Nothing is set." : "No settings match." })) : (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsx("tr", { children: ["Name", "Value"].map((heading) => (_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: heading }, heading))) }) }), _jsx("tbody", { children: shown.map((item) => (_jsxs("tr", { children: [_jsxs("td", { className: "py-2 px-2.5 border-b border-border font-mono break-all", children: [item.name, _jsx(CopyIconButton, { value: item.name, label: `Copy the name of ${item.name}` })] }), _jsx("td", { className: "py-2 px-2.5 border-b border-border font-mono break-all", children: item.redacted ? (_jsx("span", { className: "text-text-muted", children: HIDDEN_VALUE })) : (_jsxs(_Fragment, { children: [item.value, _jsx(CopyIconButton, { value: item.value ?? "", label: `Copy the value of ${item.name}` })] })) })] }, item.name))) })] }) })), shown.length < matching.length && (_jsxs("div", { className: "mt-3 flex items-center gap-3", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", onClick: () => setLimit(limit + SETTINGS_PAGE_SIZE), children: "Show more" }), _jsxs("span", { className: "text-xs text-text-muted", children: ["Showing ", shown.length, " of ", matching.length] })] }))] }));
|
|
31
32
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import CopyIconButton from "../../../../../lib/components/buttons/CopyIconButton.js";
|
|
2
3
|
function formatBytes(bytes) {
|
|
3
4
|
if (bytes >= 1000000000)
|
|
4
5
|
return `${(bytes / 1000000000).toFixed(1)} GB`;
|
|
@@ -8,9 +9,13 @@ function formatBytes(bytes) {
|
|
|
8
9
|
return `${(bytes / 1000).toFixed(1)} KB`;
|
|
9
10
|
return `${bytes} B`;
|
|
10
11
|
}
|
|
12
|
+
/** A cell's value with the icon button that copies it. */
|
|
13
|
+
function Copyable({ value, label }) {
|
|
14
|
+
return (_jsxs("span", { className: "inline-flex items-center gap-1", children: [value, _jsx(CopyIconButton, { value: value, label: label })] }));
|
|
15
|
+
}
|
|
11
16
|
export default function MailboxTable({ mailboxes }) {
|
|
12
17
|
if (mailboxes.length === 0) {
|
|
13
18
|
return _jsx("p", { className: "text-sm text-text-muted", children: "No mailboxes found." });
|
|
14
19
|
}
|
|
15
|
-
return (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Address" }), _jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Display name" }), _jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Owner" }), _jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Quota used" }), _jsx("th", { className: "border-b border-border" })] }) }), _jsx("tbody", { children: mailboxes.map((mailbox) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: mailbox.primarySmtpAddress }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: mailbox.displayName }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: mailbox.ownerUserUid
|
|
20
|
+
return (_jsx("div", { className: "overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm border-collapse", children: [_jsx("thead", { children: _jsxs("tr", { children: [_jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Address" }), _jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Display name" }), _jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Owner" }), _jsx("th", { className: "text-left text-xs uppercase tracking-wide text-text-muted py-2 px-2.5 border-b border-border", children: "Quota used" }), _jsx("th", { className: "border-b border-border" })] }) }), _jsx("tbody", { children: mailboxes.map((mailbox) => (_jsxs("tr", { children: [_jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: _jsx(Copyable, { value: mailbox.primarySmtpAddress, label: `Copy the address of ${mailbox.primarySmtpAddress}` }) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: _jsx(Copyable, { value: mailbox.displayName, label: `Copy the display name of ${mailbox.primarySmtpAddress}` }) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: mailbox.ownerUserUid ? (_jsx(Copyable, { value: mailbox.ownerUserUid, label: `Copy the owner of ${mailbox.primarySmtpAddress}` })) : (_jsx("span", { className: "inline-block text-xs font-bold uppercase tracking-wide py-0.5 px-2 rounded-pill bg-surface-alt text-text-muted", children: "Shared" })) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle", children: _jsx(Copyable, { value: `${formatBytes(mailbox.usedBytes)} / ${formatBytes(mailbox.quotaBytes)}`, label: `Copy the quota used of ${mailbox.primarySmtpAddress}` }) }), _jsx("td", { className: "py-2.5 px-2.5 border-b border-border align-middle text-right", children: _jsx("a", { href: `/admin/mailboxes/${encodeURIComponent(mailbox.uid)}`, className: "text-primary-dark font-medium hover:underline", children: "View" }) })] }, mailbox.uid))) })] }) }));
|
|
16
21
|
}
|
|
@@ -7,7 +7,7 @@ import { useEffect, useState } from "react";
|
|
|
7
7
|
import { getDnsSetup, getDomain, verifyDomain } from "../../../../../lib/admin/domainsApi.js";
|
|
8
8
|
import Alert from "../ActionAlert.js";
|
|
9
9
|
import Button from "../../../../../lib/components/buttons/Button.js";
|
|
10
|
-
import
|
|
10
|
+
import CopyIconButton from "../../../../../lib/components/buttons/CopyIconButton.js";
|
|
11
11
|
import { actionErrorMessage } from "../elevation.js";
|
|
12
12
|
const RECORD_TYPE_LABELS = {
|
|
13
13
|
ownership: "Ownership (TXT)",
|
|
@@ -44,9 +44,9 @@ function splitSrvValue(value) {
|
|
|
44
44
|
const match = /^(\d+)\s+(\d+)\s+(\d+)\s+(\S+)$/.exec(value.trim());
|
|
45
45
|
return match ? { priority: match[1], weight: match[2], port: match[3], target: match[4] } : null;
|
|
46
46
|
}
|
|
47
|
-
/** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a
|
|
47
|
+
/** A value to type into a DNS provider's form, shown in full (long DKIM keys wrap) with a copy icon button beside it. */
|
|
48
48
|
function CopyableValue({ value, copyLabel }) {
|
|
49
|
-
return (_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("code", { className: "flex-1 min-w-0 text-xs break-all", children: value }), _jsx(
|
|
49
|
+
return (_jsxs("div", { className: "flex items-start gap-2", children: [_jsx("code", { className: "flex-1 min-w-0 text-xs break-all", children: value }), _jsx(CopyIconButton, { value: value, label: copyLabel, className: "shrink-0" })] }));
|
|
50
50
|
}
|
|
51
51
|
/**
|
|
52
52
|
* A domain's status, its ownership TXT record with "Verify now", and the DNS setup checklist - shared by the domain
|
|
@@ -15,6 +15,7 @@ import { getVideoMeeting } from "../../../../lib/videoconf/videoMeetingsApi.js";
|
|
|
15
15
|
import { describeTimeZone, deviceTimeZone } from "../../../../lib/util/timeZone.js";
|
|
16
16
|
import Alert from "../../../../lib/components/feedback/Alert.js";
|
|
17
17
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
18
|
+
import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
|
|
18
19
|
import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
|
|
19
20
|
import ParticipantLink from "../contacts/ParticipantLink.js";
|
|
20
21
|
import EventDescriptionView from "./EventDescriptionView.js";
|
|
@@ -125,7 +126,7 @@ export default function EventDetails({ occurrence, isInvited, myResponse, isOwnA
|
|
|
125
126
|
{ response: "tentative", label: "Tentative" },
|
|
126
127
|
{ response: "declined", label: "Decline" },
|
|
127
128
|
];
|
|
128
|
-
return (_jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-start gap-3 px-5 pt-4", children: [_jsx("span", { "aria-hidden": "true", className: "w-3.5 h-3.5 rounded-full shrink-0 mt-2", style: { backgroundColor: calendarColor } }), _jsx("h2", { className: "flex-1 min-w-0 text-xl font-semibold break-words", children: occurrence.title || "(no title)" }), _jsx("button", { type: "button", "aria-label": "Close details", onClick: onClose, className: "w-8 h-8 shrink-0 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), _jsxs("div", { className: "flex flex-col gap-3 px-5 pt-3 pb-4 text-sm", children: [error && _jsx(Alert, { children: error }), _jsxs(DetailRow, { icon: _jsx(HiOutlineClock, { size: 18 }), children: [!occurrence.allDay && eventZone && eventZone !== deviceTimeZone() ? (_jsxs(_Fragment, { children: [_jsxs("div", { children: [formatStoredWhen(occurrence.startDate, occurrence.endDate, false), " \u2022 ", describeTimeZone(deviceTimeZone(), new Date(occurrence.startDate))] }), _jsxs("div", { className: "text-text-muted", children: [formatStoredWhenInZone(occurrence.startDate, occurrence.endDate, eventZone), " \u2022 ", describeTimeZone(eventZone, new Date(occurrence.startDate))] })] })) : (_jsx("div", { children: formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay) })), occurrence.recurrenceRule && _jsxs("div", { className: "text-text-muted", children: ["Repeats ", describeRecurrence(occurrence.recurrenceRule)] })] }), occurrence.location && (_jsx(DetailRow, { icon: _jsx(HiOutlineMapPin, { size: 18 }), children: _jsx("div", { children: occurrence.location }) })), hasDescription && (_jsx(DetailRow, { icon: _jsx(HiOutlineBars3BottomLeft, { size: 18 }), children: _jsx(EventDescriptionView, { html: occurrence.descriptionHtml, text: occurrence.description }) })), videoMeetingUid && (
|
|
129
|
+
return (_jsxs("div", { className: "flex flex-col", children: [_jsxs("div", { className: "flex items-start gap-3 px-5 pt-4", children: [_jsx("span", { "aria-hidden": "true", className: "w-3.5 h-3.5 rounded-full shrink-0 mt-2", style: { backgroundColor: calendarColor } }), _jsx("h2", { className: "flex-1 min-w-0 text-xl font-semibold break-words", children: occurrence.title || "(no title)" }), _jsx("button", { type: "button", "aria-label": "Close details", onClick: onClose, className: "w-8 h-8 shrink-0 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 20, "aria-hidden": "true" }) })] }), _jsxs("div", { className: "flex flex-col gap-3 px-5 pt-3 pb-4 text-sm", children: [error && _jsx(Alert, { children: error }), _jsxs(DetailRow, { icon: _jsx(HiOutlineClock, { size: 18 }), children: [!occurrence.allDay && eventZone && eventZone !== deviceTimeZone() ? (_jsxs(_Fragment, { children: [_jsxs("div", { children: [formatStoredWhen(occurrence.startDate, occurrence.endDate, false), " \u2022 ", describeTimeZone(deviceTimeZone(), new Date(occurrence.startDate))] }), _jsxs("div", { className: "text-text-muted", children: [formatStoredWhenInZone(occurrence.startDate, occurrence.endDate, eventZone), " \u2022 ", describeTimeZone(eventZone, new Date(occurrence.startDate))] })] })) : (_jsx("div", { children: formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay) })), occurrence.recurrenceRule && _jsxs("div", { className: "text-text-muted", children: ["Repeats ", describeRecurrence(occurrence.recurrenceRule)] })] }), occurrence.location && (_jsx(DetailRow, { icon: _jsx(HiOutlineMapPin, { size: 18 }), children: _jsx("div", { children: occurrence.location }) })), hasDescription && (_jsx(DetailRow, { icon: _jsx(HiOutlineBars3BottomLeft, { size: 18 }), children: _jsx(EventDescriptionView, { html: occurrence.descriptionHtml, text: occurrence.description }) })), videoMeetingUid && (_jsxs(DetailRow, { icon: _jsx(HiOutlineVideoCamera, { size: 18 }), children: [_jsxs("div", { className: "flex items-center gap-3 flex-wrap", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !joinUrl, onClick: () => window.open(joinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !joinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] }), joinUrl && (_jsxs("div", { className: "mt-1.5 flex items-center gap-1 min-w-0 text-xs text-text-muted", children: [_jsx("a", { href: joinUrl, target: "_blank", rel: "noopener noreferrer", className: "break-all underline hover:text-text", children: joinUrl }), _jsx(CopyIconButton, { value: joinUrl, label: "Copy the video call link", className: "shrink-0" })] }))] })), _jsxs(DetailRow, { icon: _jsx(HiOutlineUser, { size: 18 }), children: [_jsx("div", { children: _jsx(ParticipantLink, { participant: occurrence.organizer, children: organizerName || occurrence.organizer.address }) }), _jsxs("div", { className: "text-text-muted", children: [organizerName ? `${occurrence.organizer.address} · ` : "", "Organizer"] })] }), (guests.length > 0 || hiddenList) && (_jsxs(DetailRow, { icon: _jsx(HiOutlineUserGroup, { size: 18 }), children: [_jsx("div", { className: "font-medium mb-1", children: hiddenList ? "The organizer has hidden the guest list" : guests.length === 1 ? "1 guest" : `${guests.length} guests` }), _jsx("ul", { className: "flex flex-col gap-1", children: guests.map((guest, i) => (_jsxs("li", { className: "flex items-baseline justify-between gap-3", children: [_jsxs("span", { className: "min-w-0 truncate", children: [_jsx(ParticipantLink, { participant: guest, children: guest.displayName || guest.address }), ROLE_LABEL[guest.role] && _jsxs("span", { className: "text-text-muted", children: [" (", ROLE_LABEL[guest.role], ")"] })] }), _jsx("span", { className: "shrink-0 text-xs text-text-muted", children: RESPONSE_STATUS_LABEL[guest.responseStatus] })] }, i))) }), !isInvited && _jsx("div", { className: "text-xs text-text-muted mt-1", children: describeGuestPermissions(permissions) })] })), _jsxs(DetailRow, { icon: _jsx(HiOutlineBriefcase, { size: 18 }), children: [_jsx("div", { children: BUSY_STATUS_LABEL[occurrence.busyStatus] }), _jsx("div", { className: "text-text-muted", children: VISIBILITY_LABEL[visibilityOf(occurrence)] }), occurrence.autoReplyEnabled && (_jsx("div", { className: "text-text-muted", children: "Sends an automatic reply while this event is happening" }))] }), _jsx(DetailRow, { icon: _jsx(HiOutlineBell, { size: 18 }), children: _jsx("div", { children: describeReminder(occurrence.reminderMinutesBeforeStart) }) }), calendarName && (_jsxs("div", { className: "flex items-center gap-3 text-text-muted", children: [_jsx("span", { "aria-hidden": "true", className: "w-3 h-3 ml-[3px] rounded-full shrink-0", style: { backgroundColor: calendarColor } }), _jsx("span", { className: "truncate", children: calendarName })] })), isInvited && (_jsxs("p", { className: "text-xs text-text-muted", children: ["You were invited to this event. Only the organizer can change its details", canChange || canInvite ? ", but you can ask them to." : "."] }))] }), requesting && (_jsx("div", { className: "mx-5 mb-3 p-3 rounded-md bg-surface-alt", children: _jsx(RequestChangeForm, { subject: {
|
|
129
130
|
uid: occurrence.uid,
|
|
130
131
|
title: occurrence.title,
|
|
131
132
|
location: occurrence.location,
|
|
@@ -6,6 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
6
6
|
import { useRef, useState } from "react";
|
|
7
7
|
import { HiOutlineBars3BottomLeft, HiOutlineBell, HiOutlineMapPin, HiOutlineUserGroup, HiOutlineVideoCamera, HiOutlineXMark } from "react-icons/hi2";
|
|
8
8
|
import Button from "../../../../lib/components/buttons/Button.js";
|
|
9
|
+
import CopyIconButton from "../../../../lib/components/buttons/CopyIconButton.js";
|
|
9
10
|
import GuestInput from "./GuestInput.js";
|
|
10
11
|
import LazyDescriptionEditor from "./LazyDescriptionEditor.js";
|
|
11
12
|
import RecurrenceEditor from "./RecurrenceEditor.js";
|
|
@@ -32,7 +33,7 @@ export function LocationRow({ c }) {
|
|
|
32
33
|
/** "Add video conferencing" and, once the event has a meeting, the button that joins it. */
|
|
33
34
|
export function VideoConferencingRow({ c }) {
|
|
34
35
|
const { values } = c;
|
|
35
|
-
return (_jsxs(IconRow, { icon: _jsx(HiOutlineVideoCamera, { size: 20 }), children: [_jsxs("label", { className: "flex items-center gap-2 text-sm py-1.5", children: [_jsx("input", { type: "checkbox", checked: values.videoEnabled, onChange: (e) => c.update({ videoEnabled: e.target.checked }) }), "Add video conferencing"] }), values.videoEnabled && (_jsx("p", { className: "text-xs text-text-muted mb-1", children: "Changing attendees after enabling video conferencing won\u2019t update meeting invitees \u2014 turn this off and back on to reissue links to the current attendee list." })), c.videoError && (_jsx("p", { role: "alert", className: "text-xs text-danger mb-1", children: c.videoError })), c.videoMeetingUid && (_jsxs("div", { className: "flex items-center gap-3 mb-1", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !c.joinUrl, onClick: () => window.open(c.joinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !c.joinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: c.organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] }))] }));
|
|
36
|
+
return (_jsxs(IconRow, { icon: _jsx(HiOutlineVideoCamera, { size: 20 }), children: [_jsxs("label", { className: "flex items-center gap-2 text-sm py-1.5", children: [_jsx("input", { type: "checkbox", checked: values.videoEnabled, onChange: (e) => c.update({ videoEnabled: e.target.checked }) }), "Add video conferencing"] }), values.videoEnabled && (_jsx("p", { className: "text-xs text-text-muted mb-1", children: "Changing attendees after enabling video conferencing won\u2019t update meeting invitees \u2014 turn this off and back on to reissue links to the current attendee list." })), c.videoError && (_jsx("p", { role: "alert", className: "text-xs text-danger mb-1", children: c.videoError })), c.videoMeetingUid && (_jsxs("div", { className: "flex items-center gap-3 mb-1", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: !c.joinUrl, onClick: () => window.open(c.joinUrl, "_blank", "noopener,noreferrer"), children: "Join video call" }), !c.joinUrl && (_jsx("span", { className: "text-xs text-text-muted", children: c.organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available." }))] })), c.videoMeetingUid && c.joinUrl && (_jsxs("div", { className: "mb-1 flex items-center gap-1 min-w-0 text-xs text-text-muted", children: [_jsx("a", { href: c.joinUrl, target: "_blank", rel: "noopener noreferrer", className: "break-all underline hover:text-text", children: c.joinUrl }), _jsx(CopyIconButton, { value: c.joinUrl, label: "Copy the video call link", className: "shrink-0" })] }))] }));
|
|
36
37
|
}
|
|
37
38
|
/**
|
|
38
39
|
* The guests field: compose's recipient field (`GuestInput`), which looks a name up among the contacts and the server's mailboxes and lists and
|
|
@@ -14,6 +14,6 @@ export interface MonthViewProps {
|
|
|
14
14
|
* popover to open beside. */
|
|
15
15
|
onSelectSlot?: (start: Date, end: Date, anchor: EventAnchor) => void;
|
|
16
16
|
}
|
|
17
|
-
/** A real 6-week month grid (
|
|
17
|
+
/** A real 6-week month grid (Sunday-start), matching Outlook/Gmail's month view. Multi-day and all-day
|
|
18
18
|
* events are shown on every day they cover (see `allDay.ts`'s `occursOnDay`). */
|
|
19
19
|
export default function MonthView({ viewDate, occurrences, folderColors, onSelectDay, onSelectEvent, onSelectSlot }: MonthViewProps): React.JSX.Element;
|
|
@@ -6,13 +6,13 @@ import { occursOnDay, startsOnDay } from "./allDay.js";
|
|
|
6
6
|
import { occurrenceMarker, useActiveOccurrenceKey } from "./activeOccurrence.js";
|
|
7
7
|
import { anchorOf } from "./EventShell.js";
|
|
8
8
|
const MAX_CHIPS_PER_DAY = 3;
|
|
9
|
-
/** A real 6-week month grid (
|
|
9
|
+
/** A real 6-week month grid (Sunday-start), matching Outlook/Gmail's month view. Multi-day and all-day
|
|
10
10
|
* events are shown on every day they cover (see `allDay.ts`'s `occursOnDay`). */
|
|
11
11
|
export default function MonthView({ viewDate, occurrences, folderColors, onSelectDay, onSelectEvent, onSelectSlot }) {
|
|
12
|
-
const gridStart = startOfWeek(startOfMonth(viewDate), { weekStartsOn:
|
|
13
|
-
const gridEnd = endOfWeek(endOfMonth(viewDate), { weekStartsOn:
|
|
12
|
+
const gridStart = startOfWeek(startOfMonth(viewDate), { weekStartsOn: 0 });
|
|
13
|
+
const gridEnd = endOfWeek(endOfMonth(viewDate), { weekStartsOn: 0 });
|
|
14
14
|
const days = eachDayOfInterval({ start: gridStart, end: gridEnd });
|
|
15
|
-
return (
|
|
15
|
+
return (_jsxs("div", { className: "flex-1 grid grid-cols-7 grid-rows-[auto_repeat(6,minmax(0,1fr))] min-h-0", role: "grid", "aria-label": "Month", children: [days.slice(0, 7).map((day) => (_jsxs("div", { role: "columnheader", className: "px-2 py-1 text-xs font-semibold text-text-muted border-b border-border", children: [_jsx("span", { className: "sm:hidden", children: format(day, "EEEEE") }), _jsx("span", { className: "hidden sm:inline", children: format(day, "EEEE") })] }, day.getDay()))), days.map((day) => (_jsx(DayCell, { day: day, inCurrentMonth: isSameMonth(day, viewDate), occurrences: occurrences.filter((occ) => occursOnDay(occ, day)), folderColors: folderColors, onSelectDay: onSelectDay, onSelectEvent: onSelectEvent, onSelectSlot: onSelectSlot }, day.toISOString())))] }));
|
|
16
16
|
}
|
|
17
17
|
function DayCell({ day, inCurrentMonth, occurrences, folderColors, onSelectDay, onSelectEvent, onSelectSlot }) {
|
|
18
18
|
const { setNodeRef, isOver } = useDroppable({ id: dayDropId(day) });
|
|
@@ -22,6 +22,7 @@ import { useMailboxUpdateAccess } from "../../mail/useMailboxUpdateAccess.js";
|
|
|
22
22
|
import { notify } from "../../notifications/store.js";
|
|
23
23
|
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
24
24
|
import { APP_HREFS } from "../../navigation/appHrefs.js";
|
|
25
|
+
import { closeAllOverlays } from "../../../../lib/components/overlays/overlayStack.js";
|
|
25
26
|
import { useCompose } from "../mail/compose/ComposeContext.js";
|
|
26
27
|
import { formatInviteWhen } from "../mail/invite/inviteFormat.js";
|
|
27
28
|
import { contactScope, createContactFor, findContact, loadFolders, loadParticipantCard, loadRecentMessages, loadUpcomingEvents, scopeMailboxes, } from "./contactCardData.js";
|
|
@@ -147,8 +148,10 @@ export default function ContactCard({ participant, context = {}, onClose, userUi
|
|
|
147
148
|
}
|
|
148
149
|
}
|
|
149
150
|
function handleEmail() {
|
|
151
|
+
// Every card open, not just this one: it may have been reached from another (a guest of an event), which would stay on screen behind the new window.
|
|
152
|
+
closeAllOverlays();
|
|
150
153
|
openCompose({ to: formatMailAddress(participant) });
|
|
151
154
|
onClose();
|
|
152
155
|
}
|
|
153
|
-
return (_jsxs(Modal, { open: true, onClose: onClose, title: name, children: [_jsxs("div", { className: "flex items-center gap-4", children: [stored && canEditPhoto ? (_jsx(ContactPhotoEditor, { contact: stored, displayName: name, size: 72, email: participant.address, onChanged: setChanged, onBusyChange: setPhotoBusy })) : (_jsx(ContactPhotoAvatar, { displayName: name, size: 72, contact: stored, email: participant.address })), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("p", { className: "text-sm text-text-muted break-words", children: [participant.address, _jsx(CopyIconButton, { value: participant.address, label: "Copy address", className: "ml-1" })] }), external && (_jsx("span", { className: "inline-block mt-1 text-xs font-semibold py-0.5 px-2 rounded-pill bg-warning/15 text-text", children: "External" })), (known.jobTitle || known.company) && (_jsx("p", { className: "mt-1 text-sm break-words", children: [known.jobTitle, known.company].filter(Boolean).join(", ") }))] }), stored && _jsx(FavoriteStarButton, { favorite: !!stored.favorite, disabled: photoBusy, onToggle: () => void handleToggleFavorite(stored), className: "shrink-0 self-start" })] }), _jsxs("div", { className: "mt-4 flex flex-wrap items-center gap-2", children: [
|
|
156
|
+
return (_jsxs(Modal, { open: true, onClose: onClose, title: name, children: [_jsxs("div", { className: "flex items-center gap-4", children: [stored && canEditPhoto ? (_jsx(ContactPhotoEditor, { contact: stored, displayName: name, size: 72, email: participant.address, onChanged: setChanged, onBusyChange: setPhotoBusy })) : (_jsx(ContactPhotoAvatar, { displayName: name, size: 72, contact: stored, email: participant.address })), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsxs("p", { className: "text-sm text-text-muted break-words", children: [participant.address, _jsx(CopyIconButton, { value: participant.address, label: "Copy address", className: "ml-1" })] }), external && (_jsx("span", { className: "inline-block mt-1 text-xs font-semibold py-0.5 px-2 rounded-pill bg-warning/15 text-text", children: "External" })), (known.jobTitle || known.company) && (_jsx("p", { className: "mt-1 text-sm break-words", children: [known.jobTitle, known.company].filter(Boolean).join(", ") }))] }), stored && _jsx(FavoriteStarButton, { favorite: !!stored.favorite, disabled: photoBusy, onToggle: () => void handleToggleFavorite(stored), className: "shrink-0 self-start" })] }), _jsxs("div", { className: "mt-4 flex flex-wrap items-center gap-2", children: [_jsx(Button, { type: "button", variant: "secondary", "aria-label": "Email", title: "Email", className: `${ACTION_CLASS} inline-flex items-center`, onClick: handleEmail, children: _jsx(HiOutlineEnvelope, { "aria-hidden": "true", className: "size-4" }) }), stored ? (_jsx("a", { href: `${APP_HREFS.contacts}/${encodeURIComponent(stored.uid)}`, onClick: onClose, className: "inline-flex items-center text-xs font-semibold py-1 px-3 rounded-sm border border-border hover:border-accent hover:text-accent-dark focus-visible:outline-2 focus-visible:outline-primary", children: "Open contact" })) : (_jsxs(Button, { type: "button", variant: "secondary", className: `${ACTION_CLASS} inline-flex items-center gap-1.5`, disabled: !createWith || creating, loading: creating, onClick: () => void handleCreate(createWith), children: [_jsx(HiOutlineUserPlus, { "aria-hidden": "true", className: "size-4" }), "Add to contacts"] }))] }), _jsxs("section", { className: "mt-5", children: [_jsx("h3", { className: "text-sm font-bold mb-2", children: "Contact" }), !stored && contact.status === "loading" && _jsx(Skeleton, { width: "w-1/2", className: "mb-2" }), !stored && contact.status === "error" && (_jsx("p", { role: "alert", className: "text-sm text-danger mb-2", children: "Couldn\u2019t check your address book." })), !stored && contact.status === "ready" && _jsx("p", { className: "text-sm text-text-muted mb-2", children: "Not in your address book." }), _jsxs("dl", { className: "flex flex-col gap-1.5", children: [known.emails.map((email) => (_jsxs(Detail, { label: ["Email", kindLabel(email.type)].filter(Boolean).join(" "), children: [email.address, _jsx(CopyIconButton, { value: email.address, label: "Copy email address", className: "ml-1" })] }, `e:${email.address}`))), known.phones.map((phone) => (_jsx(Detail, { label: ["Phone", kindLabel(phone.type)].filter(Boolean).join(" "), children: phone.phoneNumber }, `p:${phone.phoneNumber}`))), known.company && _jsx(Detail, { label: "Company", children: known.company }), known.jobTitle && _jsx(Detail, { label: "Job title", children: known.jobTitle }), known.addresses.map((address, index) => (_jsx(Detail, { label: ["Address", kindLabel(address.type)].filter(Boolean).join(" "), children: [address.street, [address.postalCode, address.city].filter(Boolean).join(" "), address.state, address.country].filter(Boolean).join("\n") }, `a:${index}`))), known.notes && _jsx(Detail, { label: "Notes", children: known.notes })] })] }), _jsx(CardSection, { title: "Recent messages", section: messages, empty: "No recent messages", failure: "Couldn't load recent messages.", isEmpty: (list) => list.length === 0, render: (list) => (_jsx("ul", { className: "flex flex-col", children: list.map((message) => (_jsx("li", { children: _jsxs("a", { href: `/messages/${encodeURIComponent(message.uid)}`, onClick: onClose, className: ROW_LINK_CLASS, children: [_jsxs("span", { className: "flex items-baseline justify-between gap-3 text-sm", children: [_jsx("span", { className: "font-medium truncate", children: message.subject || "(No subject)" }), _jsx("time", { dateTime: message.receivedDate, className: "text-xs text-text-muted shrink-0", children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsx("span", { className: "block text-xs text-text-muted truncate", children: message.bodyPreview })] }) }, message.uid))) })) }), _jsx(CardSection, { title: "Upcoming events", section: events, empty: "No upcoming events", failure: "Couldn't load upcoming events.", isEmpty: (list) => list.length === 0, render: (list) => (_jsx("ul", { className: "flex flex-col", children: list.map((event) => (_jsx("li", { children: _jsxs("a", { href: APP_HREFS.calendar, onClick: onClose, className: ROW_LINK_CLASS, children: [_jsx("span", { className: "block text-sm font-medium truncate", children: event.title }), _jsx("span", { className: "block text-xs text-text-muted", children: formatInviteWhen(event) })] }) }, event.occurrenceKey))) })) })] }));
|
|
154
157
|
}
|
|
@@ -4,6 +4,8 @@ export type ContactsView = {
|
|
|
4
4
|
type: "all";
|
|
5
5
|
} | {
|
|
6
6
|
type: "favorites";
|
|
7
|
+
} | {
|
|
8
|
+
type: "suggested";
|
|
7
9
|
} | {
|
|
8
10
|
type: "list";
|
|
9
11
|
uid: string;
|
|
@@ -22,6 +24,8 @@ export interface ContactsSidebarProps {
|
|
|
22
24
|
contacts: Contact[];
|
|
23
25
|
active: ContactsView;
|
|
24
26
|
onSelect: (view: ContactsView) => void;
|
|
27
|
+
/** How many contacts the mailbox's Suggested Contacts folder holds; `undefined` when it has none to show (the folder isn't known), which hides the view. */
|
|
28
|
+
suggestedCount?: number;
|
|
25
29
|
/** Whether to offer the "Deleted" view - restapi only honors `deleted=true` for a caller with delete and
|
|
26
30
|
* update rights on the mailbox (anyone else silently gets the live contacts), so the parent hides it
|
|
27
31
|
* otherwise. Defaults to `true`. */
|
|
@@ -31,10 +35,10 @@ export interface ContactsSidebarProps {
|
|
|
31
35
|
refreshToken?: number;
|
|
32
36
|
}
|
|
33
37
|
/**
|
|
34
|
-
* Contacts' Outlook-style left sidebar: "Your contacts" (all, with count), "Favorites", "Your contact
|
|
38
|
+
* Contacts' Outlook-style left sidebar: "Your contacts" (all, with count), "Favorites", "Suggested contacts" (the people the mailbox has corresponded with, when there are any), "Your contact
|
|
35
39
|
* lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
|
|
36
40
|
* distinct `categories` values across the loaded contacts — no separate category registry exists
|
|
37
41
|
* server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
|
|
38
42
|
* `ContactsShellContext` itself minimal (mailbox/folder resolution only).
|
|
39
43
|
*/
|
|
40
|
-
export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect, refreshToken, showDeleted }: ContactsSidebarProps): React.JSX.Element;
|
|
44
|
+
export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect, refreshToken, showDeleted, suggestedCount }: ContactsSidebarProps): React.JSX.Element;
|
|
@@ -39,13 +39,13 @@ function NavItem({ label, count, active, onClick, swatch, }) {
|
|
|
39
39
|
].join(" "), children: [_jsxs("span", { className: "flex items-center gap-2 min-w-0", children: [swatch && _jsx("span", { "aria-hidden": "true", className: "w-2.5 h-2.5 rounded-full shrink-0", style: { backgroundColor: swatch } }), _jsx("span", { className: "truncate", children: label })] }), count !== undefined && _jsx("span", { className: "text-xs text-text-muted shrink-0", children: count })] }));
|
|
40
40
|
}
|
|
41
41
|
/**
|
|
42
|
-
* Contacts' Outlook-style left sidebar: "Your contacts" (all, with count), "Favorites", "Your contact
|
|
42
|
+
* Contacts' Outlook-style left sidebar: "Your contacts" (all, with count), "Favorites", "Suggested contacts" (the people the mailbox has corresponded with, when there are any), "Your contact
|
|
43
43
|
* lists" (real `ContactList` records, fetched here), "Deleted", and "Categories" (derived from the
|
|
44
44
|
* distinct `categories` values across the loaded contacts — no separate category registry exists
|
|
45
45
|
* server-side). Owns its own contact-list data-fetching, per this app's convention of keeping
|
|
46
46
|
* `ContactsShellContext` itself minimal (mailbox/folder resolution only).
|
|
47
47
|
*/
|
|
48
|
-
export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect, refreshToken, showDeleted = true }) {
|
|
48
|
+
export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect, refreshToken, showDeleted = true, suggestedCount }) {
|
|
49
49
|
const client = useApiClient();
|
|
50
50
|
const [lists, setLists] = useState([]);
|
|
51
51
|
const [listsError, setListsError] = useState(null);
|
|
@@ -104,6 +104,6 @@ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect
|
|
|
104
104
|
// A function, not a plain JSX constant — rendered both in the desktop `<nav>` and the mobile
|
|
105
105
|
// `Drawer` (see MailShell/ContactsShell for the identical pattern and the duplicate-id reasoning
|
|
106
106
|
// behind the `idPrefix`).
|
|
107
|
-
const navContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx(NavItem, { label: "Your contacts", count: contacts.length, active: active.type === "all", onClick: () => handleSelect({ type: "all" }) }), _jsx(NavItem, { label: "Favorites", count: favoriteCount, active: active.type === "favorites", onClick: () => handleSelect({ type: "favorites" }) }), showDeleted && _jsx(NavItem, { label: "Deleted", active: active.type === "deleted", onClick: () => handleSelect({ type: "deleted" }) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-2.5 mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Your contact lists" }), _jsx("button", { type: "button", onClick: () => setAddingList(true), "aria-label": "New contact list", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), listsError && _jsx("p", { className: "px-2.5 text-xs text-danger", children: listsError }), _jsx("div", { className: "flex flex-col gap-0.5", children: lists.map((list) => (_jsx(NavItem, { label: list.name, count: listCounts.get(list.uid) ?? 0, active: active.type === "list" && active.uid === list.uid, onClick: () => handleSelect({ type: "list", uid: list.uid, name: list.name }) }, list.uid))) }), addingList && (_jsxs("form", { onSubmit: handleAddList, className: "flex items-center gap-1 px-2.5 mt-1", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New list name", id: `${idPrefix}-new-list-name`, value: newListName, onChange: (e) => setNewListName(e.target.value), className: "flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "submit", className: "text-xs font-semibold text-primary-dark", children: "Add" })] }))] }), categories.length > 0 && (_jsxs("div", { children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted px-2.5 mb-1", children: "Categories" }), _jsx("div", { className: "flex flex-col gap-0.5", children: categories.map((name) => (_jsx(NavItem, { label: name, swatch: categoryColor(name), active: active.type === "category" && active.name === name, onClick: () => handleSelect({ type: "category", name }) }, name))) })] }))] }));
|
|
107
|
+
const navContent = (idPrefix) => (_jsxs(_Fragment, { children: [_jsxs("div", { className: "flex flex-col gap-0.5", children: [_jsx(NavItem, { label: "Your contacts", count: contacts.length, active: active.type === "all", onClick: () => handleSelect({ type: "all" }) }), _jsx(NavItem, { label: "Favorites", count: favoriteCount, active: active.type === "favorites", onClick: () => handleSelect({ type: "favorites" }) }), suggestedCount !== undefined && (_jsx(NavItem, { label: "Suggested contacts", count: suggestedCount, active: active.type === "suggested", onClick: () => handleSelect({ type: "suggested" }) })), showDeleted && _jsx(NavItem, { label: "Deleted", active: active.type === "deleted", onClick: () => handleSelect({ type: "deleted" }) })] }), _jsxs("div", { children: [_jsxs("div", { className: "flex items-center justify-between px-2.5 mb-1", children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted", children: "Your contact lists" }), _jsx("button", { type: "button", onClick: () => setAddingList(true), "aria-label": "New contact list", className: "text-xs font-bold text-primary-dark hover:underline", children: "+" })] }), listsError && _jsx("p", { className: "px-2.5 text-xs text-danger", children: listsError }), _jsx("div", { className: "flex flex-col gap-0.5", children: lists.map((list) => (_jsx(NavItem, { label: list.name, count: listCounts.get(list.uid) ?? 0, active: active.type === "list" && active.uid === list.uid, onClick: () => handleSelect({ type: "list", uid: list.uid, name: list.name }) }, list.uid))) }), addingList && (_jsxs("form", { onSubmit: handleAddList, className: "flex items-center gap-1 px-2.5 mt-1", children: [_jsx("input", { type: "text", autoFocus: true, "aria-label": "New list name", id: `${idPrefix}-new-list-name`, value: newListName, onChange: (e) => setNewListName(e.target.value), className: "flex-1 text-xs py-1 px-1.5 border border-border rounded-sm bg-surface" }), _jsx("button", { type: "submit", className: "text-xs font-semibold text-primary-dark", children: "Add" })] }))] }), categories.length > 0 && (_jsxs("div", { children: [_jsx("h2", { className: "text-xs font-bold uppercase tracking-wide text-text-muted px-2.5 mb-1", children: "Categories" }), _jsx("div", { className: "flex flex-col gap-0.5", children: categories.map((name) => (_jsx(NavItem, { label: name, swatch: categoryColor(name), active: active.type === "category" && active.name === name, onClick: () => handleSelect({ type: "category", name }) }, name))) })] }))] }));
|
|
108
108
|
return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", className: "lg:hidden m-3 mb-0 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open contacts menu", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) }), _jsx("nav", { "aria-label": "Contacts", className: "hidden lg:flex w-56 shrink-0 border-r border-border flex-col gap-4 p-3 overflow-y-auto", children: navContent("desktop") }), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Contacts", fullScreen: true, children: _jsx("div", { className: "flex flex-col gap-4", children: navContent("mobile") }) })] }));
|
|
109
109
|
}
|
|
@@ -11,6 +11,8 @@ export interface ContactsToolbarProps {
|
|
|
11
11
|
onToggleFavorite: () => void;
|
|
12
12
|
onAddCategory: () => void;
|
|
13
13
|
onExportVCard: () => void;
|
|
14
|
+
/** Turns the selected suggested contacts into contacts of the mailbox. Given only in the Suggested contacts view, which is also the only place the button is offered. */
|
|
15
|
+
onAddToContacts?: () => void;
|
|
14
16
|
/** A `.vcf` file the user picked via the toolbar's own hidden file input — parsing/import is the
|
|
15
17
|
* page's responsibility (it has the mailbox/folder context and the actual `createContact` calls). */
|
|
16
18
|
onImportFile: (file: File) => void;
|
|
@@ -28,4 +30,4 @@ export interface ContactsToolbarProps {
|
|
|
28
30
|
* needs. "New contact list" isn't duplicated here — `ContactsSidebar` already has its own inline
|
|
29
31
|
* "+ New list" affordance next to the "Your contact lists" heading.
|
|
30
32
|
*/
|
|
31
|
-
export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, onImportFile, shortcuts, hideNew, }: ContactsToolbarProps): React.JSX.Element;
|
|
33
|
+
export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, onAddToContacts, onImportFile, shortcuts, hideNew, }: ContactsToolbarProps): React.JSX.Element;
|
|
@@ -17,7 +17,7 @@ import ResponsiveToolbar from "../layout/ResponsiveToolbar.js";
|
|
|
17
17
|
* needs. "New contact list" isn't duplicated here — `ContactsSidebar` already has its own inline
|
|
18
18
|
* "+ New list" affordance next to the "Your contact lists" heading.
|
|
19
19
|
*/
|
|
20
|
-
export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, onImportFile, shortcuts, hideNew, }) {
|
|
20
|
+
export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, onAddToContacts, onImportFile, shortcuts, hideNew, }) {
|
|
21
21
|
const newContactHint = useShortcutProps("New contact", SHORTCUTS.contacts.create, !!shortcuts);
|
|
22
22
|
const importInputRef = useRef(null);
|
|
23
23
|
const hasSelection = selectedCount > 0;
|
|
@@ -45,6 +45,9 @@ export default function ContactsToolbar({ selectedCount, allSelectedFavorited, o
|
|
|
45
45
|
rank: 3,
|
|
46
46
|
},
|
|
47
47
|
{ id: "category", label: "Add category", icon: HiOutlineTag, onClick: onAddCategory, disabled: !hasSelection, group: 2, rank: 2 },
|
|
48
|
+
...(onAddToContacts
|
|
49
|
+
? [{ id: "addToContacts", label: "Add to contacts", icon: HiOutlineUserPlus, onClick: onAddToContacts, disabled: !hasSelection, group: 1, rank: 7 }]
|
|
50
|
+
: []),
|
|
48
51
|
{ id: "export", label: "Export", icon: HiOutlineArrowUpTray, onClick: onExportVCard, disabled: !hasSelection, group: 3, rank: 1 },
|
|
49
52
|
{ id: "import", label: "Import", icon: HiOutlineArrowDownTray, onClick: () => importInputRef.current?.click(), group: 3, rank: 0 },
|
|
50
53
|
];
|
|
@@ -15,7 +15,8 @@ import { ENCRYPTED_SUBJECT_PLACEHOLDER, EncryptedPreview, displaySubject } from
|
|
|
15
15
|
import { useDecryptedMessages } from "../../mail/decryptedMessages.js";
|
|
16
16
|
import { CollapsedCard, SkeletonCards, SubjectCard } from "./reading/MessageCard.js";
|
|
17
17
|
import PendingMessageCard from "./reading/PendingMessageCard.js";
|
|
18
|
-
import { InlineComposeSlot, useInlineCompose } from "./compose/ComposeContext.js";
|
|
18
|
+
import { InlineComposeSlot, useCompose, useInlineCompose } from "./compose/ComposeContext.js";
|
|
19
|
+
import { resumeFromDraft } from "./compose/draftResume.js";
|
|
19
20
|
import { useMailShell } from "./layout/MailShell.js";
|
|
20
21
|
import { setReadState } from "../../mail/messageReadState.js";
|
|
21
22
|
import { adoptedOutgoing, belongsToThread, settleOutgoing, useOutgoingReplies } from "../../mail/outbox/outgoingReplies.js";
|
|
@@ -141,6 +142,11 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
|
|
|
141
142
|
// The Reply / Reply all / Forward compose windows of this thread's messages, drawn as cards at the top of the list; the thread's messages are
|
|
142
143
|
// what makes the compose provider open a reply here rather than floating at the bottom right.
|
|
143
144
|
const inlineSessions = useInlineCompose(messages.map((message) => message.uid));
|
|
145
|
+
const { openCompose } = useCompose();
|
|
146
|
+
/** The draft this pane has already opened an editor for, so it is opened once per visit - closing the editor leaves the draft as a message to read. */
|
|
147
|
+
const openedDraftRef = useRef(null);
|
|
148
|
+
const selectedUidRef = useRef(selectedUid);
|
|
149
|
+
selectedUidRef.current = selectedUid;
|
|
144
150
|
// Bumped on every conversation switch - an in-flight load/attachments/mark-read response carrying an
|
|
145
151
|
// older generation belongs to a superseded conversation and is dropped rather than applied.
|
|
146
152
|
const generationRef = useRef(0);
|
|
@@ -226,6 +232,27 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
|
|
|
226
232
|
// this newest-first order is the *last* of the run, not the first.
|
|
227
233
|
setPendingFocusUid([...expanded][expanded.size - 1]);
|
|
228
234
|
}, [conversationId, selectedUid, messages]);
|
|
235
|
+
// A draft the reader opens is edited where it is read: its compose window opens as the card at the top of the thread, with the draft as it was
|
|
236
|
+
// saved. It is opened once per visit to the draft (an encrypted draft, which only the server holds the ciphertext of, stays a message to read).
|
|
237
|
+
useEffect(() => {
|
|
238
|
+
if (openedDraftRef.current !== null && openedDraftRef.current !== selectedUid) {
|
|
239
|
+
openedDraftRef.current = null;
|
|
240
|
+
}
|
|
241
|
+
if (loading || loadedIdRef.current !== conversationId || !selectedUid || openedDraftRef.current === selectedUid)
|
|
242
|
+
return;
|
|
243
|
+
const draft = messages.find((message) => message.uid === selectedUid);
|
|
244
|
+
if (!draft || folders.find((folder) => folder.uid === draft.folderUid)?.type !== "drafts")
|
|
245
|
+
return;
|
|
246
|
+
openedDraftRef.current = draft.uid;
|
|
247
|
+
void resumeFromDraft(draft, client)
|
|
248
|
+
.then((resume) => {
|
|
249
|
+
// The reader may have moved on while it loaded.
|
|
250
|
+
if (resume && selectedUidRef.current === draft.uid) {
|
|
251
|
+
openCompose({ mailboxUid: draft.mailboxUid, resume, inlineFor: draft.uid });
|
|
252
|
+
}
|
|
253
|
+
})
|
|
254
|
+
.catch(() => undefined);
|
|
255
|
+
}, [conversationId, selectedUid, messages, loading, folders]);
|
|
229
256
|
useLayoutEffect(() => {
|
|
230
257
|
if (!pendingFocusUid)
|
|
231
258
|
return;
|
|
@@ -238,6 +265,9 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
|
|
|
238
265
|
// ancestor actually scrolls, exactly as the toggle below already does it. When that is the page
|
|
239
266
|
// itself, nothing inside the pane scrolls and the row is already in view, so it is left alone.
|
|
240
267
|
const row = rowRefs.current[pendingFocusUid];
|
|
268
|
+
// None for a draft opened as the editor card (see the effect above): the card takes the view when it is placed.
|
|
269
|
+
if (!row)
|
|
270
|
+
return;
|
|
241
271
|
const scroller = scrollingAncestor(row);
|
|
242
272
|
if (scroller !== document.documentElement) {
|
|
243
273
|
const rect = row.getBoundingClientRect();
|
|
@@ -450,6 +480,10 @@ export default function ConversationThreadPane({ conversation, mailboxUid, selec
|
|
|
450
480
|
headerRefs.current[reply.uid] = node;
|
|
451
481
|
} }) }, reply.uid))), messages.map((message) => {
|
|
452
482
|
const uid = message.uid;
|
|
483
|
+
// A draft being edited here is the editor card above, not a message to read as well.
|
|
484
|
+
if (inlineSessions.some((session) => session.draftUid === uid)) {
|
|
485
|
+
return null;
|
|
486
|
+
}
|
|
453
487
|
const expanded = expandedUids.has(uid);
|
|
454
488
|
const bodyId = `thread-message-${uid}`;
|
|
455
489
|
const messageUnread = isUnread(message);
|
|
@@ -119,6 +119,8 @@ export interface InlineComposeSession {
|
|
|
119
119
|
id: string;
|
|
120
120
|
/** The uid of the message the window answers. */
|
|
121
121
|
inlineFor: string;
|
|
122
|
+
/** The uid of the draft the window continues, when it was opened to edit one: the draft is then the card, not a message beside it. */
|
|
123
|
+
draftUid?: string;
|
|
122
124
|
}
|
|
123
125
|
/**
|
|
124
126
|
* For a reading pane: announces that it is showing the messages `uids`, so a Reply or Forward of one of them opens its compose
|
|
@@ -115,6 +115,9 @@ export function useCompose() {
|
|
|
115
115
|
*/
|
|
116
116
|
export default function ComposeProvider({ children, userUid, trusted }) {
|
|
117
117
|
const [sessions, setSessions] = useState([]);
|
|
118
|
+
// The sessions as of the last render, for `openCompose()` to read at the click.
|
|
119
|
+
const sessionsRef = useRef(sessions);
|
|
120
|
+
sessionsRef.current = sessions;
|
|
118
121
|
const client = useApiClient();
|
|
119
122
|
const isMobile = useIsMobile();
|
|
120
123
|
const { Component: ComposeWindow, failed, retry: retryLoad } = useComposeWindowComponent(sessions.length > 0);
|
|
@@ -138,7 +141,7 @@ export default function ComposeProvider({ children, userUid, trusted }) {
|
|
|
138
141
|
const hosted = new Set(hostedUids);
|
|
139
142
|
const isInline = (session) => !!session.inlineFor && !parked.has(session.id) && hosted.has(session.inlineFor);
|
|
140
143
|
const inlineKey = sessions.filter(isInline).map((session) => `${session.id}:${session.inlineFor}`).join("|");
|
|
141
|
-
const inlineSessions = useMemo(() => sessions.filter(isInline).map((session) => ({ id: session.id, inlineFor: session.inlineFor })), [inlineKey]);
|
|
144
|
+
const inlineSessions = useMemo(() => sessions.filter(isInline).map((session) => ({ id: session.id, inlineFor: session.inlineFor, draftUid: session.resume?.draft.uid })), [inlineKey]);
|
|
142
145
|
const inlineValue = useMemo(() => ({ sessions: inlineSessions, register, containerFor: (id) => containersRef.current.get(id) }), [inlineSessions, register]);
|
|
143
146
|
const floatingSessions = sessions.filter((session) => !inlineSessions.some((inline) => inline.id === session.id));
|
|
144
147
|
// A session whose message is no longer in any pane (the reader went to another conversation) is parked in the stack.
|
|
@@ -184,6 +187,19 @@ export default function ComposeProvider({ children, userUid, trusted }) {
|
|
|
184
187
|
};
|
|
185
188
|
}, [hasSessions]);
|
|
186
189
|
function openCompose({ mailboxUid, to, cc, subject, quotedHtml, signatureContext = "new", suppressSigning, encrypt, threading, pending, resume, inlineFor, }) {
|
|
190
|
+
// A draft already being edited has its window: it is brought back to the pane that was holding it rather than opened a second time.
|
|
191
|
+
const editing = resume && sessionsRef.current.find((session) => session.resume?.draft.uid === resume.draft.uid);
|
|
192
|
+
if (editing) {
|
|
193
|
+
setParked((prev) => {
|
|
194
|
+
if (!prev.has(editing.id)) {
|
|
195
|
+
return prev;
|
|
196
|
+
}
|
|
197
|
+
const next = new Set(prev);
|
|
198
|
+
next.delete(editing.id);
|
|
199
|
+
return next;
|
|
200
|
+
});
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
187
203
|
const id = crypto.randomUUID();
|
|
188
204
|
markComposePhase(id, "click");
|
|
189
205
|
// The window's own element, attached to the page from the start (it is moved to its slot or the stack as soon as that is drawn).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Message } from "../../../../../lib/mail/mailApi.js";
|
|
2
|
+
import type { ApiClient } from "../../../../../lib/util/api.js";
|
|
3
|
+
import type { ResumeCompose } from "../../../mail/outbox/composeBridge.js";
|
|
4
|
+
/**
|
|
5
|
+
* What the compose window needs to continue `draft` - a message in the Drafts folder the reader opened rather than one a window of this tab
|
|
6
|
+
* saved - read back from the server: its recipients and subject from the record, its body from the server's copy (with its embedded pictures)
|
|
7
|
+
* and its attachments. `undefined` for a draft that cannot be edited here: an encrypted one holds only its ciphertext, which is never opened.
|
|
8
|
+
* Never rejects for a body that could not be loaded - the draft then opens with the preview, as a reply's quote does.
|
|
9
|
+
*/
|
|
10
|
+
export declare function resumeFromDraft(draft: Message, client?: ApiClient): Promise<ResumeCompose | undefined>;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import { listAttachments } from "../../../../../lib/mail/mailApi.js";
|
|
6
|
+
import { formatMailAddress } from "../../../../../lib/mail/mailAddress.js";
|
|
7
|
+
import { loadOriginalMessage } from "./quotedBody.js";
|
|
8
|
+
/** The comma-separated address text a recipient field holds, for the recipients of one type. */
|
|
9
|
+
function recipientText(recipients, type) {
|
|
10
|
+
return recipients
|
|
11
|
+
.filter((recipient) => recipient.type === type)
|
|
12
|
+
.map((recipient) => formatMailAddress(recipient))
|
|
13
|
+
.join(", ");
|
|
14
|
+
}
|
|
15
|
+
/** Plain text as the HTML paragraphs the editor holds. */
|
|
16
|
+
function textToHtml(text) {
|
|
17
|
+
const escape = (value) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
18
|
+
return text
|
|
19
|
+
.split(/\n{2,}/)
|
|
20
|
+
.map((paragraph) => `<p>${escape(paragraph).replace(/\n/g, "<br>")}</p>`)
|
|
21
|
+
.join("");
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* What the compose window needs to continue `draft` - a message in the Drafts folder the reader opened rather than one a window of this tab
|
|
25
|
+
* saved - read back from the server: its recipients and subject from the record, its body from the server's copy (with its embedded pictures)
|
|
26
|
+
* and its attachments. `undefined` for a draft that cannot be edited here: an encrypted one holds only its ciphertext, which is never opened.
|
|
27
|
+
* Never rejects for a body that could not be loaded - the draft then opens with the preview, as a reply's quote does.
|
|
28
|
+
*/
|
|
29
|
+
export async function resumeFromDraft(draft, client) {
|
|
30
|
+
if (draft.encrypted) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
const [original, attachments] = await Promise.all([loadOriginalMessage(draft, null), listAttachments(draft.folderUid, draft.uid, client)]);
|
|
34
|
+
const html = original.body.html !== undefined ? original.body.html : original.body.text !== undefined ? textToHtml(original.body.text) : textToHtml(draft.bodyPreview ?? "");
|
|
35
|
+
return {
|
|
36
|
+
draft,
|
|
37
|
+
mailboxUid: draft.mailboxUid,
|
|
38
|
+
to: recipientText(draft.recipients, "to"),
|
|
39
|
+
cc: recipientText(draft.recipients, "cc"),
|
|
40
|
+
bcc: recipientText(draft.recipients, "bcc"),
|
|
41
|
+
subject: draft.subject,
|
|
42
|
+
html,
|
|
43
|
+
attachments,
|
|
44
|
+
requestReceipt: !!draft.requestReceipt,
|
|
45
|
+
signEnabled: false,
|
|
46
|
+
encryptRequested: false,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -161,13 +161,13 @@ function CalendarContent({ userUid, bookingHref }) {
|
|
|
161
161
|
return { rangeStart: new Date(-MAX_DATE_MS), rangeEnd: new Date(MAX_DATE_MS), days: [] };
|
|
162
162
|
}
|
|
163
163
|
if (view === "month") {
|
|
164
|
-
const start = startOfWeek(startOfMonth(viewDate), { weekStartsOn:
|
|
165
|
-
const end = endOfWeek(endOfMonth(viewDate), { weekStartsOn:
|
|
164
|
+
const start = startOfWeek(startOfMonth(viewDate), { weekStartsOn: 0 });
|
|
165
|
+
const end = endOfWeek(endOfMonth(viewDate), { weekStartsOn: 0 });
|
|
166
166
|
return { rangeStart: start, rangeEnd: end, days: [] };
|
|
167
167
|
}
|
|
168
168
|
if (view === "week") {
|
|
169
|
-
const start = startOfWeek(viewDate, { weekStartsOn:
|
|
170
|
-
const end = endOfWeek(viewDate, { weekStartsOn:
|
|
169
|
+
const start = startOfWeek(viewDate, { weekStartsOn: 0 });
|
|
170
|
+
const end = endOfWeek(viewDate, { weekStartsOn: 0 });
|
|
171
171
|
return { rangeStart: start, rangeEnd: end, days: eachDayOfInterval({ start, end }) };
|
|
172
172
|
}
|
|
173
173
|
if (view === "workWeek") {
|