@rapidmx/web-client 0.15.1 → 0.16.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 +373 -373
- package/apps/admin/branding/index.tsx +39 -39
- package/apps/admin/data-requests/index.tsx +482 -480
- package/apps/admin/domains/[uid].tsx +164 -164
- package/apps/admin/escrow-scopes/[uid].tsx +348 -348
- package/apps/admin/index.tsx +127 -124
- package/apps/admin/mailboxes/[uid].tsx +269 -224
- package/apps/admin/mailboxes/new/index.tsx +28 -28
- package/apps/admin/plugins/index.tsx +15 -15
- package/apps/admin/retention-policy/index.tsx +39 -39
- package/apps/admin/signing-certificates/index.tsx +343 -343
- package/apps/escrow/audit-log/index.tsx +196 -196
- package/apps/escrow/matters/[uid].tsx +621 -621
- package/apps/shared/auth/adminAccess.ts +99 -99
- package/apps/shared/components/admin/layout/AdminShell.tsx +378 -378
- package/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.tsx +238 -0
- package/apps/shared/components/admin/mailboxes/EscrowScopeCard.tsx +152 -152
- package/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.tsx +194 -0
- package/apps/shared/components/admin/settings/BrandingForm.tsx +423 -423
- package/apps/shared/components/admin/settings/EncryptionPolicyForm.tsx +119 -119
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +50 -3
- package/apps/shared/components/admin/settings/MailboxPolicyForm.tsx +198 -198
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1545
- package/apps/shared/components/admin/settings/RetentionPolicyForm.tsx +182 -182
- package/apps/shared/components/admin/setup/EscrowSetupStep.tsx +288 -288
- package/apps/shared/components/admin/setup/SetupWizard.tsx +444 -444
- package/apps/shared/components/admin/usePagedList.tsx +129 -129
- package/apps/shared/components/calendar/EventEditor.tsx +5 -10
- package/apps/shared/components/calendar/EventFormParts.tsx +16 -47
- package/apps/shared/components/calendar/EventModal.tsx +206 -206
- package/apps/shared/components/calendar/GuestInput.tsx +100 -0
- package/apps/shared/components/calendar/MonthView.tsx +185 -185
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +227 -227
- package/apps/shared/components/calendar/RequestChangeForm.tsx +23 -54
- package/apps/shared/components/calendar/SplitDayView.tsx +144 -144
- package/apps/shared/components/calendar/TimeGridView.tsx +246 -246
- package/apps/shared/components/calendar/allDay.ts +124 -124
- package/apps/shared/components/calendar/eventForm.ts +2 -2
- package/apps/shared/components/calendar/eventFormat.ts +49 -22
- package/apps/shared/components/calendar/layout/CalendarShell.tsx +201 -200
- package/apps/shared/components/contacts/ContactForm.tsx +383 -383
- package/apps/shared/components/contacts/ContactsSidebar.tsx +1 -1
- package/apps/shared/components/contacts/ContactsToolbar.tsx +103 -98
- package/apps/shared/components/contacts/layout/ContactsShell.tsx +217 -216
- package/apps/shared/components/escrow/layout/EscrowShell.tsx +155 -155
- package/apps/shared/components/layout/AppShell.tsx +480 -480
- package/apps/shared/components/layout/FloatingActionButton.tsx +31 -0
- package/apps/shared/components/layout/KeyEnrollmentGate.tsx +398 -398
- package/apps/shared/components/layout/MailboxProvisioning.tsx +168 -168
- package/apps/shared/components/layout/ResponsiveToolbar.tsx +315 -315
- package/apps/shared/components/layout/ThemeSwitch.tsx +84 -84
- package/apps/shared/components/layout/UserMenu.tsx +439 -424
- package/apps/shared/components/mail/ConversationList.tsx +323 -323
- package/apps/shared/components/mail/ConversationThreadPane.tsx +612 -491
- package/apps/shared/components/mail/MailSelectionBar.tsx +236 -227
- package/apps/shared/components/mail/MessageDetailPane.tsx +1559 -1559
- package/apps/shared/components/mail/compose/ComposeContext.tsx +312 -312
- package/apps/shared/components/mail/compose/ComposeToolbar.tsx +422 -422
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +1681 -1677
- package/apps/shared/components/mail/compose/RecipientInput.tsx +54 -11
- package/apps/shared/components/mail/compose/RichTextEditor.tsx +147 -147
- package/apps/shared/components/mail/compose/composeFlushRegistry.ts +60 -60
- package/apps/shared/components/mail/compose/quotedBody.ts +161 -161
- package/apps/shared/components/mail/layout/FolderBadgeChip.tsx +21 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +137 -113
- package/apps/shared/components/mail/layout/SidebarSection.tsx +66 -0
- package/apps/shared/components/mail/reading/PendingMessageCard.tsx +68 -0
- package/apps/shared/components/mail/verificationSeals.ts +125 -125
- package/apps/shared/components/rules/RuleBuilder.tsx +311 -311
- package/apps/shared/components/settings/SigningCertificateCard.tsx +344 -344
- package/apps/shared/components/settings/layout/SettingsShell.tsx +255 -254
- package/apps/shared/components/tasks/TasksSidebar.tsx +1 -1
- package/apps/shared/components/tasks/layout/TasksShell.tsx +218 -217
- package/apps/shared/keyboard/GlobalShortcuts.tsx +51 -51
- package/apps/shared/keyboard/ShortcutProvider.tsx +62 -62
- package/apps/shared/keyboard/ShortcutsDialog.tsx +84 -84
- package/apps/shared/keyboard/dispatch.ts +124 -124
- package/apps/shared/keyboard/format.ts +89 -89
- package/apps/shared/keyboard/keymap.ts +114 -114
- package/apps/shared/keyboard/registry.ts +65 -65
- package/apps/shared/keyboard/targets.ts +79 -79
- package/apps/shared/mail/folderCounts.ts +17 -0
- package/apps/shared/mail/folderTree.ts +143 -143
- package/apps/shared/mail/listAllPages.ts +39 -39
- package/apps/shared/mail/newMailNotifications.ts +171 -171
- package/apps/shared/mail/outbox/outgoingReplies.ts +192 -0
- package/apps/shared/mail/outbox/sendJob.ts +19 -1
- package/apps/shared/mail/outbox/sendOutcomes.ts +13 -4
- package/apps/shared/mail/primaryMailbox.ts +71 -0
- package/apps/shared/mail/useCollapsedSections.ts +146 -0
- package/apps/shared/mail/useMailConnection.ts +205 -203
- package/apps/shared/mail/useMailLiveUpdates.ts +277 -277
- package/apps/shared/mail/useMarkMessageRead.ts +47 -47
- package/apps/shared/mail/useNewMailNotifications.ts +178 -178
- package/apps/shared/navigation/AppRouter.tsx +300 -300
- package/apps/shared/navigation/routerContext.tsx +83 -83
- package/apps/shared/notifications/store.ts +560 -560
- package/apps/shared/search/LocalIndexLifecycle.tsx +114 -114
- package/apps/shared/search/crossMailboxSearch.ts +169 -0
- package/apps/shared/search/localIndexBuilder.ts +481 -481
- package/apps/shared/signing/enrollmentStorage.ts +33 -33
- package/apps/shared/signing/enrollmentTracker.ts +385 -385
- package/apps/shared/signing/enrollmentView.ts +251 -251
- package/apps/shared/signing/useNow.ts +19 -19
- package/apps/shared/signing/useSigningEnrollmentWatcher.ts +90 -90
- package/apps/shared/styles/app.css +396 -386
- package/apps/www/calendar/index.tsx +578 -571
- package/apps/www/contacts/[uid].tsx +109 -109
- package/apps/www/contacts/index.tsx +6 -0
- package/apps/www/index.tsx +2847 -2479
- package/apps/www/messages/[uid].tsx +135 -135
- package/apps/www/settings/auto-reply/index.tsx +133 -133
- package/apps/www/settings/encryption/index.tsx +1287 -1287
- package/apps/www/settings/filters/[uid].tsx +176 -176
- package/apps/www/settings/filters/index.tsx +102 -102
- package/apps/www/settings/filters/new/index.tsx +140 -140
- package/apps/www/settings/labels/index.tsx +204 -204
- package/apps/www/settings/privacy/index.tsx +492 -492
- package/apps/www/settings/profile/index.tsx +248 -248
- package/apps/www/settings/read-receipts/index.tsx +147 -147
- package/apps/www/settings/sharing/index.tsx +256 -256
- package/apps/www/settings/signatures/[uid].tsx +172 -172
- package/apps/www/settings/signatures/index.tsx +88 -88
- package/apps/www/settings/signatures/new/index.tsx +135 -135
- package/apps/www/tasks/index.tsx +649 -649
- package/dist/apps/admin/data-requests/index.js +1 -1
- package/dist/apps/admin/index.js +2 -1
- package/dist/apps/admin/mailboxes/[uid].js +12 -2
- package/dist/apps/shared/components/admin/layout/AdminShell.js +2 -2
- package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.d.ts +35 -0
- package/dist/apps/shared/components/admin/mailboxes/EraseLeftoverDataDialog.js +115 -0
- package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.d.ts +16 -0
- package/dist/apps/shared/components/admin/mailboxes/LeftoverMailboxesSection.js +92 -0
- package/dist/apps/shared/components/admin/settings/BrandingForm.js +3 -3
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +25 -3
- package/dist/apps/shared/components/calendar/EventEditor.js +5 -10
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +3 -2
- package/dist/apps/shared/components/calendar/EventFormParts.js +5 -16
- package/dist/apps/shared/components/calendar/GuestInput.d.ts +45 -0
- package/dist/apps/shared/components/calendar/GuestInput.js +30 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +11 -22
- package/dist/apps/shared/components/calendar/eventForm.d.ts +2 -2
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +21 -8
- package/dist/apps/shared/components/calendar/eventFormat.js +50 -23
- package/dist/apps/shared/components/calendar/layout/CalendarShell.js +3 -2
- package/dist/apps/shared/components/contacts/ContactsSidebar.js +1 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.d.ts +3 -1
- package/dist/apps/shared/components/contacts/ContactsToolbar.js +4 -2
- package/dist/apps/shared/components/contacts/layout/ContactsShell.js +4 -3
- package/dist/apps/shared/components/layout/FloatingActionButton.d.ts +13 -0
- package/dist/apps/shared/components/layout/FloatingActionButton.js +9 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +4 -1
- package/dist/apps/shared/components/layout/UserMenu.js +6 -5
- package/dist/apps/shared/components/mail/ConversationThreadPane.js +127 -22
- package/dist/apps/shared/components/mail/MailSelectionBar.d.ts +6 -1
- package/dist/apps/shared/components/mail/MailSelectionBar.js +3 -3
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -4
- package/dist/apps/shared/components/mail/compose/RecipientInput.d.ts +21 -1
- package/dist/apps/shared/components/mail/compose/RecipientInput.js +18 -10
- package/dist/apps/shared/components/mail/layout/FolderBadgeChip.d.ts +6 -0
- package/dist/apps/shared/components/mail/layout/FolderBadgeChip.js +9 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +64 -45
- package/dist/apps/shared/components/mail/layout/SidebarSection.d.ts +23 -0
- package/dist/apps/shared/components/mail/layout/SidebarSection.js +16 -0
- package/dist/apps/shared/components/mail/reading/PendingMessageCard.d.ts +14 -0
- package/dist/apps/shared/components/mail/reading/PendingMessageCard.js +16 -0
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +6 -5
- package/dist/apps/shared/components/tasks/TasksSidebar.js +1 -1
- package/dist/apps/shared/components/tasks/layout/TasksShell.js +4 -3
- package/dist/apps/shared/mail/folderCounts.d.ts +10 -0
- package/dist/apps/shared/mail/folderCounts.js +12 -0
- package/dist/apps/shared/mail/outbox/outgoingReplies.d.ts +80 -0
- package/dist/apps/shared/mail/outbox/outgoingReplies.js +131 -0
- package/dist/apps/shared/mail/outbox/sendJob.d.ts +5 -1
- package/dist/apps/shared/mail/outbox/sendJob.js +14 -1
- package/dist/apps/shared/mail/outbox/sendOutcomes.js +13 -4
- package/dist/apps/shared/mail/primaryMailbox.d.ts +29 -0
- package/dist/apps/shared/mail/primaryMailbox.js +61 -0
- package/dist/apps/shared/mail/useCollapsedSections.d.ts +32 -0
- package/dist/apps/shared/mail/useCollapsedSections.js +110 -0
- package/dist/apps/shared/mail/useMailConnection.js +3 -1
- package/dist/apps/shared/search/crossMailboxSearch.d.ts +72 -0
- package/dist/apps/shared/search/crossMailboxSearch.js +136 -0
- package/dist/apps/shared/styles/app.css +396 -386
- package/dist/apps/www/calendar/index.js +3 -2
- package/dist/apps/www/contacts/index.js +3 -1
- package/dist/apps/www/index.js +426 -189
- package/package.json +2 -2
|
@@ -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 "@rapidmx/react-shared/components/buttons/Button.js";
|
|
9
|
+
import GuestInput from "./GuestInput.js";
|
|
9
10
|
import LazyDescriptionEditor from "./LazyDescriptionEditor.js";
|
|
10
11
|
import RecurrenceEditor from "./RecurrenceEditor.js";
|
|
11
12
|
import ResourcePicker from "./ResourcePicker.js";
|
|
@@ -31,27 +32,15 @@ export function VideoConferencingRow({ c }) {
|
|
|
31
32
|
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." }))] }))] }));
|
|
32
33
|
}
|
|
33
34
|
/**
|
|
34
|
-
* The guests field:
|
|
35
|
-
*
|
|
35
|
+
* The guests field: compose's recipient field (`GuestInput`), which looks a name up among the contacts and the server's mailboxes and lists and
|
|
36
|
+
* turns what is picked, typed or pasted into chips - drawn in the field itself in the quick popover. In the full form the guests are rows with a
|
|
37
|
+
* role, the answer so far and a remove button instead (the field then draws only what is not an address), and rooms and equipment can be added.
|
|
36
38
|
*/
|
|
37
39
|
export function GuestsField({ c, compact }) {
|
|
38
40
|
const { values } = c;
|
|
39
|
-
const [hint, setHint] = useState(null);
|
|
40
41
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
41
42
|
const pickerAnchor = useRef(null);
|
|
42
|
-
|
|
43
|
-
const invalid = c.addGuests(values.guestDraft);
|
|
44
|
-
setHint(invalid.length > 0 ? `“${invalid[0]}” isn’t a valid email address.` : null);
|
|
45
|
-
}
|
|
46
|
-
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx("input", { type: "text", "aria-label": "Add guests", placeholder: "Add guests", className: compact ? QUIET_INPUT_CLASS : INPUT_CLASS, value: values.guestDraft, onChange: (e) => {
|
|
47
|
-
setHint(null);
|
|
48
|
-
c.update({ guestDraft: e.target.value });
|
|
49
|
-
}, onKeyDown: (e) => {
|
|
50
|
-
if (e.key === "Enter" || e.key === "," || e.key === ";") {
|
|
51
|
-
e.preventDefault();
|
|
52
|
-
commit();
|
|
53
|
-
}
|
|
54
|
-
} }), hint && (_jsx("p", { role: "alert", className: "text-xs text-danger", children: hint })), compact ? (values.attendees.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-1.5", children: values.attendees.map((attendee, i) => (_jsxs("li", { className: "flex items-center gap-1 text-xs rounded-full bg-surface-alt pl-2.5 pr-1 py-0.5", children: [_jsx("span", { className: "truncate max-w-[16rem]", children: attendee.address }), _jsx("button", { type: "button", onClick: () => c.removeAttendee(i), className: "w-4 h-4 flex items-center justify-center rounded-full text-text-muted hover:text-text", "aria-label": `Remove attendee ${i + 1}`, children: _jsx(HiOutlineXMark, { size: 12, "aria-hidden": "true" }) })] }, i))) }))) : (_jsxs(_Fragment, { children: [values.attendees.length > 0 && (_jsx("ul", { className: "flex flex-col gap-2", children: values.attendees.map((attendee, i) => (_jsxs("li", { className: "flex items-center gap-2", children: [_jsx("span", { "aria-hidden": "true", className: "w-7 h-7 shrink-0 rounded-full bg-primary/15 text-primary-dark text-xs font-semibold flex items-center justify-center uppercase", children: (attendee.displayName || attendee.address).charAt(0) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-sm truncate", children: attendee.displayName || attendee.address }), _jsxs("div", { className: "text-xs text-text-muted truncate", children: [attendee.displayName ? `${attendee.address} · ` : "", RESPONSE_STATUS_LABEL[attendee.responseStatus]] })] }), _jsx("select", { className: SELECT_CLASS, value: attendee.role, onChange: (e) => c.updateAttendee(i, { role: e.target.value }), "aria-label": `Attendee role ${i + 1}`, children: ATTENDEE_ROLES.map((role) => (_jsx("option", { value: role, children: role }, role))) }), _jsx("button", { type: "button", onClick: () => c.removeAttendee(i), className: "w-7 h-7 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": `Remove attendee ${i + 1}`, children: _jsx(HiOutlineXMark, { size: 16, "aria-hidden": "true" }) })] }, i))) })), _jsx("button", { ref: pickerAnchor, type: "button", onClick: () => setPickerOpen(true), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add room/equipment" }), pickerOpen && (_jsx(ResourcePicker, { anchorRef: pickerAnchor, onClose: () => setPickerOpen(false), onSelect: (mailbox) => {
|
|
43
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(GuestInput, { id: "event-guests", label: "Add guests", placeholder: "Add guests", variant: compact ? "quiet" : "box", listedElsewhere: !compact, guests: values.attendees, invalid: values.guestInvalid, draft: values.guestDraft, skipAddresses: [c.organizerAddress], mailboxUid: values.targetMailboxUid, onChange: (next) => c.update({ attendees: next.guests, guestInvalid: next.invalid, guestDraft: next.draft }) }), compact ? null : (_jsxs(_Fragment, { children: [values.attendees.length > 0 && (_jsx("ul", { className: "flex flex-col gap-2", children: values.attendees.map((attendee, i) => (_jsxs("li", { className: "flex items-center gap-2", children: [_jsx("span", { "aria-hidden": "true", className: "w-7 h-7 shrink-0 rounded-full bg-primary/15 text-primary-dark text-xs font-semibold flex items-center justify-center uppercase", children: (attendee.displayName || attendee.address).charAt(0) }), _jsxs("div", { className: "flex-1 min-w-0", children: [_jsx("div", { className: "text-sm truncate", children: attendee.displayName || attendee.address }), _jsxs("div", { className: "text-xs text-text-muted truncate", children: [attendee.displayName ? `${attendee.address} · ` : "", RESPONSE_STATUS_LABEL[attendee.responseStatus]] })] }), _jsx("select", { className: SELECT_CLASS, value: attendee.role, onChange: (e) => c.updateAttendee(i, { role: e.target.value }), "aria-label": `Attendee role ${i + 1}`, children: ATTENDEE_ROLES.map((role) => (_jsx("option", { value: role, children: role }, role))) }), _jsx("button", { type: "button", onClick: () => c.removeAttendee(i), className: "w-7 h-7 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": `Remove attendee ${i + 1}`, children: _jsx(HiOutlineXMark, { size: 16, "aria-hidden": "true" }) })] }, i))) })), _jsx("button", { ref: pickerAnchor, type: "button", onClick: () => setPickerOpen(true), className: "self-start text-xs font-medium text-primary-dark hover:underline", children: "+ Add room/equipment" }), pickerOpen && (_jsx(ResourcePicker, { anchorRef: pickerAnchor, onClose: () => setPickerOpen(false), onSelect: (mailbox) => {
|
|
55
44
|
c.addResource(mailbox);
|
|
56
45
|
setPickerOpen(false);
|
|
57
46
|
}, excludeAddresses: c.guestAddresses }))] }))] }));
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { Attendee } from "@rapidmx/react-shared/calendar/calendarApi.js";
|
|
3
|
+
import type { ContactSuggestionOptions, RecipientSuggestion } from "@rapidmx/react-shared/mail/directoryApi.js";
|
|
4
|
+
declare const VARIANT_CLASS: {
|
|
5
|
+
/** Reads as plain text until it is hovered or focused, as the rows of the quick-create popover do. */
|
|
6
|
+
quiet: string;
|
|
7
|
+
/** A bordered box, as the full form's fields are. */
|
|
8
|
+
box: string;
|
|
9
|
+
};
|
|
10
|
+
/** What a guests field holds: the guests added, what was committed but is not an address (kept in the field, flagged, until it is fixed or removed),
|
|
11
|
+
* and what is still being typed. */
|
|
12
|
+
export interface GuestEntries {
|
|
13
|
+
guests: Attendee[];
|
|
14
|
+
invalid: string[];
|
|
15
|
+
draft: string;
|
|
16
|
+
}
|
|
17
|
+
export interface GuestInputProps extends GuestEntries {
|
|
18
|
+
/** The input's id, and the prefix of its suggestions' ids. */
|
|
19
|
+
id: string;
|
|
20
|
+
/** The input's accessible name. */
|
|
21
|
+
label: string;
|
|
22
|
+
placeholder?: string;
|
|
23
|
+
variant: keyof typeof VARIANT_CLASS;
|
|
24
|
+
/** The guests are listed elsewhere (rows with a role and an answer): the field draws chips only for what is not an address. */
|
|
25
|
+
listedElsewhere?: boolean;
|
|
26
|
+
/** Addresses that are never added - the organizer's, or the people already invited. */
|
|
27
|
+
skipAddresses?: string[];
|
|
28
|
+
/** The mailbox the event is on, whose contacts are suggested too. */
|
|
29
|
+
mailboxUid?: string;
|
|
30
|
+
onChange: (next: GuestEntries) => void;
|
|
31
|
+
/** Loads suggestions; defaults to the lookup compose's recipient fields use. */
|
|
32
|
+
fetchSuggestions?: (query: string, options: ContactSuggestionOptions) => Promise<RecipientSuggestion[]>;
|
|
33
|
+
debounceMs?: number;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* The guests field of an event: compose's recipient field (`RecipientInput`) reading its chips as guests. Typing looks the name up among the
|
|
37
|
+
* caller's contacts and the server's mailboxes and distribution lists; a suggestion (or an address, `Name <address>`, a comma, a semicolon,
|
|
38
|
+
* Enter or leaving the field) becomes a chip, a paste of several addresses becomes several, and something that is not an address stays as
|
|
39
|
+
* a flagged chip once it is committed - nothing is said while it is being typed. A distribution list is added as its own address, one guest
|
|
40
|
+
* (as compose sends to one), which the mail server delivers to its members.
|
|
41
|
+
*
|
|
42
|
+
* The field is controlled: `guests` are the attendees, and every edit hands back the whole of the entries (see `applyGuestChips()`).
|
|
43
|
+
*/
|
|
44
|
+
export default function GuestInput({ id, label, placeholder, variant, listedElsewhere, skipAddresses, mailboxUid, guests, invalid, draft, onChange, fetchSuggestions, debounceMs, }: GuestInputProps): React.JSX.Element;
|
|
45
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import RecipientInput from "../mail/compose/RecipientInput.js";
|
|
3
|
+
import { applyGuestChips, guestChip } from "./eventFormat.js";
|
|
4
|
+
/** Above the event dialog's own backdrop (`z-[1000]`), which the suggestions would otherwise sit under. */
|
|
5
|
+
const DIALOG_SUGGESTIONS_Z_INDEX = 1100;
|
|
6
|
+
const VARIANT_CLASS = {
|
|
7
|
+
/** Reads as plain text until it is hovered or focused, as the rows of the quick-create popover do. */
|
|
8
|
+
quiet: "w-full text-sm py-1 border-b border-transparent hover:border-border focus-within:border-primary",
|
|
9
|
+
/** A bordered box, as the full form's fields are. */
|
|
10
|
+
box: "w-full text-sm py-1.5 px-3 border border-border rounded-md bg-surface text-text focus-within:border-primary",
|
|
11
|
+
};
|
|
12
|
+
/** What `RecipientInput` calls `onChange` with; the entries come through `onEdit`. */
|
|
13
|
+
const IGNORED = () => undefined;
|
|
14
|
+
/**
|
|
15
|
+
* The guests field of an event: compose's recipient field (`RecipientInput`) reading its chips as guests. Typing looks the name up among the
|
|
16
|
+
* caller's contacts and the server's mailboxes and distribution lists; a suggestion (or an address, `Name <address>`, a comma, a semicolon,
|
|
17
|
+
* Enter or leaving the field) becomes a chip, a paste of several addresses becomes several, and something that is not an address stays as
|
|
18
|
+
* a flagged chip once it is committed - nothing is said while it is being typed. A distribution list is added as its own address, one guest
|
|
19
|
+
* (as compose sends to one), which the mail server delivers to its members.
|
|
20
|
+
*
|
|
21
|
+
* The field is controlled: `guests` are the attendees, and every edit hands back the whole of the entries (see `applyGuestChips()`).
|
|
22
|
+
*/
|
|
23
|
+
export default function GuestInput({ id, label, placeholder, variant, listedElsewhere, skipAddresses, mailboxUid, guests, invalid, draft, onChange, fetchSuggestions, debounceMs, }) {
|
|
24
|
+
const value = [...guests.map(guestChip), ...invalid, draft].filter((part) => part.length > 0).join(", ");
|
|
25
|
+
const listed = new Set(listedElsewhere ? guests.map(guestChip) : []);
|
|
26
|
+
return (_jsx(RecipientInput, { id: id, label: label, ariaLabel: label, listLabel: "Guests", placeholder: placeholder, className: VARIANT_CLASS[variant], dropdownZIndex: DIALOG_SUGGESTIONS_Z_INDEX, value: value, initialPending: draft, mailboxUid: mailboxUid, hideChip: (chip) => listed.has(chip), fetchSuggestions: fetchSuggestions, debounceMs: debounceMs, onChange: IGNORED, onEdit: (chips, pending) => {
|
|
27
|
+
const merged = applyGuestChips(guests, chips, skipAddresses);
|
|
28
|
+
onChange({ guests: merged.attendees, invalid: merged.invalid, draft: pending });
|
|
29
|
+
} }));
|
|
30
|
+
}
|
|
@@ -13,6 +13,7 @@ import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
|
|
|
13
13
|
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
14
14
|
import { notify } from "../../notifications/store.js";
|
|
15
15
|
import { INPUT_CLASS } from "./EventFormParts.js";
|
|
16
|
+
import GuestInput from "./GuestInput.js";
|
|
16
17
|
import LazyDescriptionEditor from "./LazyDescriptionEditor.js";
|
|
17
18
|
import { descriptionProblem, initialDescriptionHtml } from "./eventDialogFields.js";
|
|
18
19
|
import { mergeGuests } from "./eventFormat.js";
|
|
@@ -35,41 +36,29 @@ export default function RequestChangeForm({ subject, canChange, canInvite, onSen
|
|
|
35
36
|
const [start, setStart] = useState(subject.startDate ? toDatetimeLocal(subject.startDate) : "");
|
|
36
37
|
const [end, setEnd] = useState(subject.endDate ? toDatetimeLocal(subject.endDate) : "");
|
|
37
38
|
const [descriptionHtml, setDescriptionHtml] = useState(() => initialDescriptionHtml(subject));
|
|
38
|
-
|
|
39
|
-
const [
|
|
39
|
+
// The guests to add (not the ones already invited: `subject.guestAddresses` are skipped), what is flagged in the field, and what is being typed.
|
|
40
|
+
const [entries, setEntries] = useState({ guests: [], invalid: [], draft: "" });
|
|
40
41
|
const [error, setError] = useState(null);
|
|
41
42
|
const [sending, setSending] = useState(false);
|
|
42
43
|
const heading = canChange ? "Request a change" : "Add guests";
|
|
43
|
-
/** Adds what was typed into the guests box, returning the guests as they now stand;
|
|
44
|
+
/** Adds what was typed into the guests box, returning the guests to add as they now stand; something that is not an address is said. */
|
|
44
45
|
function addTypedGuests() {
|
|
45
|
-
const
|
|
46
|
-
const merged = mergeGuests(invited.map((guest) => ({ ...guest, role: "required", responseStatus: "needsAction", isOrganizer: false })), guestDraft);
|
|
46
|
+
const merged = mergeGuests(entries.guests, [...entries.invalid, entries.draft].join(", "), subject.guestAddresses);
|
|
47
47
|
if (merged.invalid.length > 0) {
|
|
48
48
|
setError(`“${merged.invalid[0]}” isn’t a valid email address.`);
|
|
49
|
-
setGuestDraft(merged.invalid.join(" "));
|
|
50
49
|
return undefined;
|
|
51
50
|
}
|
|
52
|
-
|
|
53
|
-
return merged.attendees
|
|
54
|
-
}
|
|
55
|
-
function handleGuestKey(event) {
|
|
56
|
-
if (event.key === "Enter" || event.key === "," || event.key === ";") {
|
|
57
|
-
event.preventDefault();
|
|
58
|
-
setError(null);
|
|
59
|
-
const more = addTypedGuests();
|
|
60
|
-
if (more) {
|
|
61
|
-
setAdded((current) => [...current, ...more]);
|
|
62
|
-
}
|
|
63
|
-
}
|
|
51
|
+
setEntries({ guests: merged.attendees, invalid: [], draft: "" });
|
|
52
|
+
return merged.attendees;
|
|
64
53
|
}
|
|
65
54
|
async function handleSubmit(event) {
|
|
66
55
|
event.preventDefault();
|
|
67
56
|
setError(null);
|
|
68
|
-
const
|
|
69
|
-
if (!
|
|
57
|
+
const added = canInvite ? addTypedGuests() : [];
|
|
58
|
+
if (!added) {
|
|
70
59
|
return;
|
|
71
60
|
}
|
|
72
|
-
const guests =
|
|
61
|
+
const guests = added.map((guest) => (guest.displayName ? { address: guest.address, displayName: guest.displayName } : { address: guest.address }));
|
|
73
62
|
const request = {};
|
|
74
63
|
if (canChange) {
|
|
75
64
|
if (title.trim() !== subject.title.trim()) {
|
|
@@ -134,5 +123,5 @@ export default function RequestChangeForm({ subject, canChange, canInvite, onSen
|
|
|
134
123
|
setSending(false);
|
|
135
124
|
}
|
|
136
125
|
}
|
|
137
|
-
return (_jsxs("form", { onSubmit: (event) => void handleSubmit(event), "aria-label": heading, className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("h3", { className: "text-sm font-semibold", children: heading }), _jsx("button", { type: "button", "aria-label": "Cancel request", onClick: onCancel, className: "w-7 h-7 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 16, "aria-hidden": "true" }) })] }), _jsx("p", { className: "text-xs text-text-muted", children: "Your request goes to the organizer. Nothing on your calendar changes until their update arrives; when the organizer allows it, it is applied for you." }), error && _jsx(Alert, { children: error }), canChange && (_jsxs(_Fragment, { children: [_jsxs("label", { className: "flex flex-col gap-1 text-xs font-medium", children: ["Title", _jsx("input", { type: "text", className: INPUT_CLASS, value: title, onChange: (e) => setTitle(e.target.value) })] }), _jsxs("label", { className: "flex flex-col gap-1 text-xs font-medium", children: ["Location", _jsx("input", { type: "text", className: INPUT_CLASS, value: location, onChange: (e) => setLocation(e.target.value) })] }), canChangeTime && (_jsxs("div", { className: "flex flex-wrap gap-3", children: [_jsxs("label", { className: "flex flex-col gap-1 text-xs font-medium", children: ["Starts", _jsx("input", { type: "datetime-local", className: INPUT_CLASS, value: start, onChange: (e) => setStart(e.target.value) })] }), _jsxs("label", { className: "flex flex-col gap-1 text-xs font-medium", children: ["Ends", _jsx("input", { type: "datetime-local", className: INPUT_CLASS, value: end, onChange: (e) => setEnd(e.target.value) })] })] })), !canChangeTime && (_jsx("p", { className: "text-xs text-text-muted", children: subject.recurring ? "A change here applies to the whole series; its times can only be changed by the organizer." : "The time of this event can only be changed by the organizer." })), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-xs font-medium", children: "Description" }), _jsx(LazyDescriptionEditor, { value: descriptionHtml, onChange: setDescriptionHtml })] })] })), canInvite && (_jsxs("div", { className: "flex flex-col gap-
|
|
126
|
+
return (_jsxs("form", { onSubmit: (event) => void handleSubmit(event), "aria-label": heading, className: "flex flex-col gap-3", children: [_jsxs("div", { className: "flex items-center justify-between gap-3", children: [_jsx("h3", { className: "text-sm font-semibold", children: heading }), _jsx("button", { type: "button", "aria-label": "Cancel request", onClick: onCancel, className: "w-7 h-7 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text", children: _jsx(HiOutlineXMark, { size: 16, "aria-hidden": "true" }) })] }), _jsx("p", { className: "text-xs text-text-muted", children: "Your request goes to the organizer. Nothing on your calendar changes until their update arrives; when the organizer allows it, it is applied for you." }), error && _jsx(Alert, { children: error }), canChange && (_jsxs(_Fragment, { children: [_jsxs("label", { className: "flex flex-col gap-1 text-xs font-medium", children: ["Title", _jsx("input", { type: "text", className: INPUT_CLASS, value: title, onChange: (e) => setTitle(e.target.value) })] }), _jsxs("label", { className: "flex flex-col gap-1 text-xs font-medium", children: ["Location", _jsx("input", { type: "text", className: INPUT_CLASS, value: location, onChange: (e) => setLocation(e.target.value) })] }), canChangeTime && (_jsxs("div", { className: "flex flex-wrap gap-3", children: [_jsxs("label", { className: "flex flex-col gap-1 text-xs font-medium", children: ["Starts", _jsx("input", { type: "datetime-local", className: INPUT_CLASS, value: start, onChange: (e) => setStart(e.target.value) })] }), _jsxs("label", { className: "flex flex-col gap-1 text-xs font-medium", children: ["Ends", _jsx("input", { type: "datetime-local", className: INPUT_CLASS, value: end, onChange: (e) => setEnd(e.target.value) })] })] })), !canChangeTime && (_jsx("p", { className: "text-xs text-text-muted", children: subject.recurring ? "A change here applies to the whole series; its times can only be changed by the organizer." : "The time of this event can only be changed by the organizer." })), _jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("span", { className: "text-xs font-medium", children: "Description" }), _jsx(LazyDescriptionEditor, { value: descriptionHtml, onChange: setDescriptionHtml })] })] })), canInvite && (_jsxs("div", { className: "flex flex-col gap-1", children: [_jsx("label", { htmlFor: "request-guests", className: "text-xs font-medium", children: "Guests to add" }), _jsx(GuestInput, { id: "request-guests", label: "Guests to add", placeholder: "Name or name@example.com", variant: "box", skipAddresses: subject.guestAddresses, ...entries, onChange: setEntries })] })), _jsxs("div", { className: "flex items-center gap-2", children: [_jsx(Button, { type: "submit", loading: sending, disabled: sending, className: "!w-auto", children: "Send request" }), _jsx(Button, { type: "button", variant: "text", className: "!w-auto", disabled: sending, onClick: onCancel, children: "Cancel" })] })] }));
|
|
138
127
|
}
|
|
@@ -22,6 +22,8 @@ export interface EventFormValues {
|
|
|
22
22
|
attendees: Attendee[];
|
|
23
23
|
/** What has been typed into the guests field but not added yet. */
|
|
24
24
|
guestDraft: string;
|
|
25
|
+
/** What was committed to the guests field (Enter, a comma, leaving it) but is not an address: kept there, flagged, until it is fixed or removed. */
|
|
26
|
+
guestInvalid: string[];
|
|
25
27
|
recurrenceRule: RecurrenceRule | null;
|
|
26
28
|
/** Minutes before the start, as typed; blank means no reminder. */
|
|
27
29
|
reminderMinutes: string;
|
|
@@ -57,8 +59,6 @@ export interface EventFormController {
|
|
|
57
59
|
/** The address the event is organized from (the chosen mailbox's, for a new event). */
|
|
58
60
|
organizerAddress: string;
|
|
59
61
|
deviceZone: string;
|
|
60
|
-
/** Adds the addresses in `text`; returns the ones that are not valid addresses. */
|
|
61
|
-
addGuests: (text: string) => string[];
|
|
62
62
|
updateAttendee: (index: number, patch: Partial<Attendee>) => void;
|
|
63
63
|
removeAttendee: (index: number) => void;
|
|
64
64
|
addResource: (mailbox: Mailbox) => void;
|
|
@@ -33,12 +33,24 @@ export type ReminderUnit = (typeof REMINDER_UNITS)[number]["unit"];
|
|
|
33
33
|
export declare function bestReminderUnit(minutes: number): (typeof REMINDER_UNITS)[number];
|
|
34
34
|
/** "Notify 30 minutes before" / "Notify 1 hour before" / "Notify at the start" for a stored reminder, or "No notification". */
|
|
35
35
|
export declare function describeReminder(minutes: number | undefined): string;
|
|
36
|
-
/** Splits what was typed into the guests field into its addresses: commas, semicolons and white space separate them. */
|
|
37
|
-
export declare function splitGuestInput(text: string): string[];
|
|
38
|
-
/** The loosest address check that is still worth making before an invitation is sent: something, an @, something. */
|
|
39
|
-
export declare function isValidGuestAddress(address: string): boolean;
|
|
40
36
|
/** An attendee as the guests field adds one. */
|
|
41
|
-
export declare function newGuest(address: string): Attendee;
|
|
37
|
+
export declare function newGuest(address: string, displayName?: string): Attendee;
|
|
38
|
+
/** A guest as the guests field shows it: `Name <address>`, or the bare address for one without a name (the form compose's recipient fields use). */
|
|
39
|
+
export declare function guestChip(guest: {
|
|
40
|
+
address: string;
|
|
41
|
+
displayName?: string;
|
|
42
|
+
}): string;
|
|
43
|
+
/**
|
|
44
|
+
* The guests the guests field's chips name, given the guests `current`ly on the list: a chip for someone already there keeps that guest as
|
|
45
|
+
* they are (their role and answer, whatever the case of the address), a chip for a new address adds a required guest with the chip's name, and
|
|
46
|
+
* an address twice (or one of `skipAddresses` - the organizer's, or the people already invited) is added once. The chips are the field's whole
|
|
47
|
+
* list, so a guest without a chip is removed. What is not an address comes back as `invalid`, to stay in the field flagged; several addresses
|
|
48
|
+
* separated by white space in one chip ("a@x.com b@x.com", as a paste can be) are taken as several.
|
|
49
|
+
*/
|
|
50
|
+
export declare function applyGuestChips(current: Attendee[], chips: string[], skipAddresses?: string[]): {
|
|
51
|
+
attendees: Attendee[];
|
|
52
|
+
invalid: string[];
|
|
53
|
+
};
|
|
42
54
|
/** The wall-clock string a form input holds ("YYYY-MM-DDTHH:mm") read as `zone`'s clock, as an instant. `deviceZone` is the zone the browser
|
|
43
55
|
* itself parses such a string in, which needs no conversion. */
|
|
44
56
|
export declare function wallStringToMs(value: string, zone: string, deviceZone: string): number;
|
|
@@ -55,9 +67,10 @@ export declare function localDateFromKey(key: string): Date;
|
|
|
55
67
|
export declare function formatWhen(start: Date, end: Date, allDay: boolean, now?: Date): string;
|
|
56
68
|
/** `formatWhen()` for a stored event: instants for a timed one, the date-only keys (with the exclusive end) for an all-day one. */
|
|
57
69
|
export declare function formatStoredWhen(startDate: string, endDate: string, allDay: boolean, now?: Date): string;
|
|
58
|
-
/** Adds the
|
|
59
|
-
* address
|
|
60
|
-
|
|
70
|
+
/** Adds the guests typed or pasted into the guests field to `current` - recipients separated by commas or semicolons, each an address or
|
|
71
|
+
* `Name <address>` (see `applyGuestChips()`, which keeps who is already there): an address already on the list (in any case) or in
|
|
72
|
+
* `skipAddresses` is skipped, and what is not an address comes back as `invalid` for the field to keep. */
|
|
73
|
+
export declare function mergeGuests(current: Attendee[], text: string, skipAddresses?: string[]): {
|
|
61
74
|
attendees: Attendee[];
|
|
62
75
|
invalid: string[];
|
|
63
76
|
};
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { format } from "date-fns";
|
|
6
6
|
import { fromEventWallClock, toEventWallClock } from "@rapidmx/react-shared/calendar/recurrence.js";
|
|
7
7
|
import { toDatetimeLocal } from "@rapidmx/react-shared/util/dateInput.js";
|
|
8
|
+
import { formatRecipient, isValidRecipientAddress, parseRecipient, splitRecipientList } from "../mail/compose/recipients.js";
|
|
8
9
|
import { addDaysToKey, allDayDateKey } from "./allDay.js";
|
|
9
10
|
/** What the event form and the read-only event view both need to say about a busy status / an answer. */
|
|
10
11
|
export const BUSY_STATUSES = ["busy", "free", "tentative", "oof"];
|
|
@@ -70,17 +71,52 @@ export function describeReminder(minutes) {
|
|
|
70
71
|
const amount = minutes / unit.factor;
|
|
71
72
|
return `Notify ${amount} ${amount === 1 ? unit.singular : unit.unit} before`;
|
|
72
73
|
}
|
|
73
|
-
/**
|
|
74
|
-
export function
|
|
75
|
-
return
|
|
74
|
+
/** An attendee as the guests field adds one. */
|
|
75
|
+
export function newGuest(address, displayName) {
|
|
76
|
+
return { address, ...(displayName ? { displayName } : {}), role: "required", responseStatus: "needsAction", isOrganizer: false };
|
|
76
77
|
}
|
|
77
|
-
/**
|
|
78
|
-
export function
|
|
79
|
-
return
|
|
78
|
+
/** A guest as the guests field shows it: `Name <address>`, or the bare address for one without a name (the form compose's recipient fields use). */
|
|
79
|
+
export function guestChip(guest) {
|
|
80
|
+
return formatRecipient({ address: guest.address, displayName: guest.displayName });
|
|
80
81
|
}
|
|
81
|
-
/**
|
|
82
|
-
|
|
83
|
-
|
|
82
|
+
/**
|
|
83
|
+
* The guests the guests field's chips name, given the guests `current`ly on the list: a chip for someone already there keeps that guest as
|
|
84
|
+
* they are (their role and answer, whatever the case of the address), a chip for a new address adds a required guest with the chip's name, and
|
|
85
|
+
* an address twice (or one of `skipAddresses` - the organizer's, or the people already invited) is added once. The chips are the field's whole
|
|
86
|
+
* list, so a guest without a chip is removed. What is not an address comes back as `invalid`, to stay in the field flagged; several addresses
|
|
87
|
+
* separated by white space in one chip ("a@x.com b@x.com", as a paste can be) are taken as several.
|
|
88
|
+
*/
|
|
89
|
+
export function applyGuestChips(current, chips, skipAddresses = []) {
|
|
90
|
+
const skip = new Set(skipAddresses.map((address) => address.toLowerCase()));
|
|
91
|
+
const attendees = [];
|
|
92
|
+
const invalid = [];
|
|
93
|
+
const known = (address) => current.find((guest) => guest.address.toLowerCase() === address.toLowerCase());
|
|
94
|
+
const listed = (address) => attendees.some((guest) => guest.address.toLowerCase() === address.toLowerCase());
|
|
95
|
+
function add(address, displayName) {
|
|
96
|
+
const existing = known(address);
|
|
97
|
+
if (existing) {
|
|
98
|
+
if (!listed(address)) {
|
|
99
|
+
attendees.push(existing);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
else if (!skip.has(address.toLowerCase()) && !listed(address)) {
|
|
103
|
+
attendees.push(newGuest(address, displayName));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
for (const chip of chips) {
|
|
107
|
+
const { address, displayName } = parseRecipient(chip);
|
|
108
|
+
const parts = chip.trim().split(/\s+/);
|
|
109
|
+
if (isValidRecipientAddress(address) || known(address)) {
|
|
110
|
+
add(address, displayName);
|
|
111
|
+
}
|
|
112
|
+
else if (parts.length > 1 && parts.every(isValidRecipientAddress)) {
|
|
113
|
+
parts.forEach((part) => add(part));
|
|
114
|
+
}
|
|
115
|
+
else if (!invalid.includes(chip.trim())) {
|
|
116
|
+
invalid.push(chip.trim());
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return { attendees, invalid };
|
|
84
120
|
}
|
|
85
121
|
/** The wall-clock string a form input holds ("YYYY-MM-DDTHH:mm") read as `zone`'s clock, as an instant. `deviceZone` is the zone the browser
|
|
86
122
|
* itself parses such a string in, which needs no conversion. */
|
|
@@ -133,20 +169,11 @@ export function formatStoredWhen(startDate, endDate, allDay, now = new Date()) {
|
|
|
133
169
|
}
|
|
134
170
|
return formatWhen(new Date(startDate), new Date(endDate), false, now);
|
|
135
171
|
}
|
|
136
|
-
/** Adds the
|
|
137
|
-
* address
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
for (const token of splitGuestInput(text)) {
|
|
142
|
-
if (!isValidGuestAddress(token)) {
|
|
143
|
-
invalid.push(token);
|
|
144
|
-
}
|
|
145
|
-
else if (!attendees.some((a) => a.address.toLowerCase() === token.toLowerCase())) {
|
|
146
|
-
attendees.push(newGuest(token));
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
return { attendees, invalid };
|
|
172
|
+
/** Adds the guests typed or pasted into the guests field to `current` - recipients separated by commas or semicolons, each an address or
|
|
173
|
+
* `Name <address>` (see `applyGuestChips()`, which keeps who is already there): an address already on the list (in any case) or in
|
|
174
|
+
* `skipAddresses` is skipped, and what is not an address comes back as `invalid` for the field to keep. */
|
|
175
|
+
export function mergeGuests(current, text, skipAddresses = []) {
|
|
176
|
+
return applyGuestChips(current, [...current.map(guestChip), ...splitRecipientList(text)], skipAddresses);
|
|
150
177
|
}
|
|
151
178
|
/** `formatWhen()` for the form's own wall-clock strings (an all-day event's `end` is its inclusive last day); a message while either is empty. */
|
|
152
179
|
export function formatFormWhen(start, end, allDay, now = new Date()) {
|
|
@@ -12,6 +12,7 @@ import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedbac
|
|
|
12
12
|
import AppShell from "../../layout/AppShell.js";
|
|
13
13
|
import { useLocationSearch } from "../../../navigation/AppRouter.js";
|
|
14
14
|
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
15
|
+
import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.js";
|
|
15
16
|
const CalendarShellContext = createContext({
|
|
16
17
|
calendarFolders: [],
|
|
17
18
|
mailboxCalendars: [],
|
|
@@ -49,7 +50,7 @@ export default function CalendarShell({ userUid, authServerUrl, impersonating, i
|
|
|
49
50
|
}
|
|
50
51
|
listMailboxes({ limit: 100 })
|
|
51
52
|
.then((result) => {
|
|
52
|
-
setMailboxes(result);
|
|
53
|
+
setMailboxes(orderMailboxes(result, userUid));
|
|
53
54
|
setStatus("ready");
|
|
54
55
|
})
|
|
55
56
|
.catch((err) => {
|
|
@@ -57,7 +58,7 @@ export default function CalendarShell({ userUid, authServerUrl, impersonating, i
|
|
|
57
58
|
setStatus("error");
|
|
58
59
|
});
|
|
59
60
|
}, [userUid]);
|
|
60
|
-
const ownMailboxUid = mailboxes
|
|
61
|
+
const ownMailboxUid = primaryMailboxUid(mailboxes, userUid);
|
|
61
62
|
const mailboxUid = (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
|
|
62
63
|
ownMailboxUid;
|
|
63
64
|
useEffect(() => {
|
|
@@ -103,5 +103,5 @@ export default function ContactsSidebar({ mailboxUid, contacts, active, onSelect
|
|
|
103
103
|
// `Drawer` (see MailShell/ContactsShell for the identical pattern and the duplicate-id reasoning
|
|
104
104
|
// behind the `idPrefix`).
|
|
105
105
|
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))) })] }))] }));
|
|
106
|
-
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", children: _jsx("div", { className: "flex flex-col gap-4", children: navContent("mobile") }) })] }));
|
|
106
|
+
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") }) })] }));
|
|
107
107
|
}
|
|
@@ -16,6 +16,8 @@ export interface ContactsToolbarProps {
|
|
|
16
16
|
onImportFile: (file: File) => void;
|
|
17
17
|
/** The keyboard's "New contact" is registered by the page: the button names its shortcut in its tooltip and `aria-keyshortcuts`. */
|
|
18
18
|
shortcuts?: boolean;
|
|
19
|
+
/** Leaves the New contact button out - the phone layout offers it as a floating button instead. */
|
|
20
|
+
hideNew?: boolean;
|
|
19
21
|
}
|
|
20
22
|
/**
|
|
21
23
|
* Contacts' Outlook-style ribbon toolbar, fitted to its column (`ResponsiveToolbar`): captions under the icons while there is room, then icons
|
|
@@ -26,4 +28,4 @@ export interface ContactsToolbarProps {
|
|
|
26
28
|
* needs. "New contact list" isn't duplicated here — `ContactsSidebar` already has its own inline
|
|
27
29
|
* "+ New list" affordance next to the "Your contact lists" heading.
|
|
28
30
|
*/
|
|
29
|
-
export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, onImportFile, shortcuts, }: ContactsToolbarProps): React.JSX.Element;
|
|
31
|
+
export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, 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, }) {
|
|
20
|
+
export default function ContactsToolbar({ selectedCount, allSelectedFavorited, onNewContact, onEdit, onDelete, onEmail, onToggleFavorite, onAddCategory, onExportVCard, 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;
|
|
@@ -29,7 +29,9 @@ export default function ContactsToolbar({ selectedCount, allSelectedFavorited, o
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
const actions = [
|
|
32
|
-
|
|
32
|
+
...(hideNew
|
|
33
|
+
? []
|
|
34
|
+
: [{ id: "new", label: "New contact", icon: HiOutlineUserPlus, onClick: onNewContact, hint: shortcuts ? newContactHint : undefined, group: 0, rank: 99, essential: true }]),
|
|
33
35
|
{ id: "edit", label: "Edit", icon: HiOutlinePencil, onClick: onEdit, disabled: selectedCount !== 1, group: 1, rank: 6 },
|
|
34
36
|
{ id: "delete", label: "Delete", icon: HiOutlineTrash, onClick: onDelete, disabled: !hasSelection, group: 1, rank: 5 },
|
|
35
37
|
{ id: "email", label: "Email", icon: HiOutlineEnvelope, onClick: onEmail, disabled: !hasSelection, group: 2, rank: 4 },
|
|
@@ -13,6 +13,7 @@ import Skeleton, { SkeletonList } from "@rapidmx/react-shared/components/feedbac
|
|
|
13
13
|
import AppShell from "../../layout/AppShell.js";
|
|
14
14
|
import { useLocationSearch, useNavigate } from "../../../navigation/AppRouter.js";
|
|
15
15
|
import MailboxProvisioning from "../../layout/MailboxProvisioning.js";
|
|
16
|
+
import { orderMailboxes, primaryMailboxUid } from "../../../mail/primaryMailbox.js";
|
|
16
17
|
const ContactsShellContext = createContext({ mailboxes: [] });
|
|
17
18
|
/** Reads the mailbox/folder Contacts is currently showing, as resolved by the enclosing `ContactsShell`. */
|
|
18
19
|
export function useContactsShell() {
|
|
@@ -51,7 +52,7 @@ export default function ContactsShell({ userUid, authServerUrl, impersonating, i
|
|
|
51
52
|
}
|
|
52
53
|
listMailboxes({ limit: 100 })
|
|
53
54
|
.then((result) => {
|
|
54
|
-
setMailboxes(result);
|
|
55
|
+
setMailboxes(orderMailboxes(result, userUid));
|
|
55
56
|
setStatus("ready");
|
|
56
57
|
})
|
|
57
58
|
.catch((err) => {
|
|
@@ -60,7 +61,7 @@ export default function ContactsShell({ userUid, authServerUrl, impersonating, i
|
|
|
60
61
|
});
|
|
61
62
|
}, [userUid]);
|
|
62
63
|
const mailboxUid = (requestedMailboxUid && mailboxes.some((mb) => mb.uid === requestedMailboxUid) ? requestedMailboxUid : undefined) ??
|
|
63
|
-
mailboxes
|
|
64
|
+
primaryMailboxUid(mailboxes, userUid);
|
|
64
65
|
useEffect(() => {
|
|
65
66
|
if (!mailboxUid) {
|
|
66
67
|
setFolders([]);
|
|
@@ -100,7 +101,7 @@ export default function ContactsShell({ userUid, authServerUrl, impersonating, i
|
|
|
100
101
|
// The mailbox switcher (or an error) is all this column holds: with a single mailbox and no error there is nothing to put in it, and an empty
|
|
101
102
|
// 224 px column beside the contacts' own menu is wasted room.
|
|
102
103
|
const hasSidebar = mailboxes.length > 1 || !!folderError;
|
|
103
|
-
inner = (_jsxs(_Fragment, { children: [hasSidebar && (_jsx("aside", { className: "hidden lg:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3", children: sidebarContent("desktop") })), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Mailbox", children: _jsx("div", { className: "flex flex-col gap-3", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 flex flex-col", children: [hasSidebar && (_jsx("button", { type: "button", className: "lg:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open mailbox switcher", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) })), _jsx(ContactsShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
104
|
+
inner = (_jsxs(_Fragment, { children: [hasSidebar && (_jsx("aside", { className: "hidden lg:flex w-56 shrink-0 bg-surface border-r border-border flex-col p-3 gap-3", children: sidebarContent("desktop") })), _jsx(Drawer, { open: drawerOpen, onClose: () => setDrawerOpen(false), title: "Mailbox", fullScreen: true, children: _jsx("div", { className: "flex flex-col gap-3", children: sidebarContent("mobile") }) }), _jsxs("main", { className: "flex-1 min-w-0 flex flex-col", children: [hasSidebar && (_jsx("button", { type: "button", className: "lg:hidden m-3 w-9 h-9 flex items-center justify-center rounded-sm text-text-muted hover:bg-surface-alt hover:text-text", "aria-label": "Open mailbox switcher", onClick: () => setDrawerOpen(true), children: _jsx(HiOutlineBars3, { size: 20, "aria-hidden": "true" }) })), _jsx(ContactsShellContext.Provider, { value: contextValue, children: children })] })] }));
|
|
104
105
|
}
|
|
105
106
|
return (_jsx(AppShell, { active: "contacts", userUid: userUid, authServerUrl: authServerUrl, impersonating: impersonating, impersonationBaseUrl: impersonationBaseUrl, trusted: trusted, trustedRoles: trustedRoles, pluginNav: pluginNav, children: inner }));
|
|
106
107
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import React, { ButtonHTMLAttributes } from "react";
|
|
2
|
+
import type { IconType } from "react-icons";
|
|
3
|
+
export interface FloatingActionButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "aria-label" | "children" | "className" | "type"> {
|
|
4
|
+
/** The button's accessible name (it shows only the icon). */
|
|
5
|
+
label: string;
|
|
6
|
+
icon: IconType;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The phone layout's primary action - "New message" in Mail, "New event" in Calendar, "New contact" in Contacts - as a small round accent button
|
|
10
|
+
* floating at the bottom right, just above the bottom tab bar (which is `h-14`, fixed). A real `<button>` named by `label`; hidden from `md` up,
|
|
11
|
+
* where each page keeps its own toolbar button. Any other button attribute (`onPointerEnter`, `onFocus`, ...) is passed through.
|
|
12
|
+
*/
|
|
13
|
+
export default function FloatingActionButton({ label, icon: Icon, ...rest }: FloatingActionButtonProps): React.JSX.Element;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* The phone layout's primary action - "New message" in Mail, "New event" in Calendar, "New contact" in Contacts - as a small round accent button
|
|
4
|
+
* floating at the bottom right, just above the bottom tab bar (which is `h-14`, fixed). A real `<button>` named by `label`; hidden from `md` up,
|
|
5
|
+
* where each page keeps its own toolbar button. Any other button attribute (`onPointerEnter`, `onFocus`, ...) is passed through.
|
|
6
|
+
*/
|
|
7
|
+
export default function FloatingActionButton({ label, icon: Icon, ...rest }) {
|
|
8
|
+
return (_jsx("button", { ...rest, type: "button", "aria-label": label, className: "md:hidden fixed right-4 bottom-[4.5rem] z-30 w-12 h-12 flex items-center justify-center rounded-full bg-primary text-white shadow-lg hover:bg-primary-dark", children: _jsx(Icon, { size: 22, "aria-hidden": "true" }) }));
|
|
9
|
+
}
|
|
@@ -32,6 +32,9 @@ export interface UserMenuProps {
|
|
|
32
32
|
placement?: "down" | "up";
|
|
33
33
|
/** Shows a "Recent notifications" item that calls this - opening the history of the last pop-ups (`AppChrome` only). */
|
|
34
34
|
onShowNotifications?: () => void;
|
|
35
|
+
/** Shows a "Back to mail" item, first in the menu, linking to the main application (`/`, on this same host). For the consoles
|
|
36
|
+
* (`AdminShell`), which are a step away from the app; the app itself has no need of it. */
|
|
37
|
+
showMailLink?: boolean;
|
|
35
38
|
/** Errors in that history nobody has looked at yet: a count beside the item, and a dot on the menu button. */
|
|
36
39
|
unseenErrors?: number;
|
|
37
40
|
}
|
|
@@ -47,4 +50,4 @@ export interface UserMenuProps {
|
|
|
47
50
|
* (auth-server's CORS list not including this origin, or no profile document at all - a 404 for some accounts) and none
|
|
48
51
|
* ever surfaces as an error. The avatar image comes from the profile alone.
|
|
49
52
|
*/
|
|
50
|
-
export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, detectAdmin, trustedRoles, showSettingsLink, showNotificationSettings, onShowShortcuts, placement, onShowNotifications, unseenErrors, }: UserMenuProps): React.JSX.Element;
|
|
53
|
+
export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, detectAdmin, trustedRoles, showSettingsLink, showNotificationSettings, onShowShortcuts, placement, onShowNotifications, showMailLink, unseenErrors, }: UserMenuProps): React.JSX.Element;
|
|
@@ -6,7 +6,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
6
6
|
import { useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
7
7
|
import { createPortal } from "react-dom";
|
|
8
8
|
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
9
|
-
import { HiOutlineShieldCheck } from "react-icons/hi2";
|
|
9
|
+
import { HiOutlineArrowUturnLeft, HiOutlineShieldCheck } from "react-icons/hi2";
|
|
10
10
|
import { formatProfileName, getMyProfile, getMyUsername, profileInitials } from "@rapidmx/react-shared/auth/profileApi.js";
|
|
11
11
|
import { listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
12
12
|
import { accountUrlOf } from "../../auth/accountUrl.js";
|
|
@@ -14,8 +14,9 @@ import { DEFAULT_TRUSTED_ROLES, lookUpAdminAccess } from "../../auth/adminAccess
|
|
|
14
14
|
import { ariaKeyShortcuts } from "../../keyboard/format.js";
|
|
15
15
|
import { SHORTCUTS } from "../../keyboard/keymap.js";
|
|
16
16
|
import { useKeyEnvironment } from "../../keyboard/ShortcutProvider.js";
|
|
17
|
-
import { SETTINGS_HREF } from "../../navigation/appHrefs.js";
|
|
17
|
+
import { APP_HREFS, SETTINGS_HREF } from "../../navigation/appHrefs.js";
|
|
18
18
|
import { MailConnectionContext } from "../../mail/useMailConnection.js";
|
|
19
|
+
import { ownMailboxes } from "../../mail/primaryMailbox.js";
|
|
19
20
|
import ThemeSwitch from "./ThemeSwitch.js";
|
|
20
21
|
import { desktopPermission, requestDesktopPermission, setDesktopOfferDismissed, } from "../../mail/newMailNotifications.js";
|
|
21
22
|
import { getNotificationsEnabled, setNotificationsEnabled } from "../../notifications/preferences.js";
|
|
@@ -39,7 +40,7 @@ function Avatar({ profile, initials, large }) {
|
|
|
39
40
|
* (auth-server's CORS list not including this origin, or no profile document at all - a 404 for some accounts) and none
|
|
40
41
|
* ever surfaces as an error. The avatar image comes from the profile alone.
|
|
41
42
|
*/
|
|
42
|
-
export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, detectAdmin, trustedRoles = DEFAULT_TRUSTED_ROLES, showSettingsLink, showNotificationSettings, onShowShortcuts, placement = "down", onShowNotifications, unseenErrors = 0, }) {
|
|
43
|
+
export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, detectAdmin, trustedRoles = DEFAULT_TRUSTED_ROLES, showSettingsLink, showNotificationSettings, onShowShortcuts, placement = "down", onShowNotifications, showMailLink, unseenErrors = 0, }) {
|
|
43
44
|
const env = useKeyEnvironment();
|
|
44
45
|
// A phone has no keyboard to speak of, so the shortcuts list (and the dialog it opens) has nothing to offer there.
|
|
45
46
|
const isMobile = useIsMobile();
|
|
@@ -101,7 +102,7 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
|
|
|
101
102
|
const listed = framed ? (connection.status === "checking" ? null : connection.mailboxes) : ownListing;
|
|
102
103
|
const mailboxesKnown = listed !== null;
|
|
103
104
|
// Only a mailbox the caller owns counts: a shared or delegated one carries somebody else's name.
|
|
104
|
-
const mailboxName = needsFallbackName ? listed
|
|
105
|
+
const mailboxName = needsFallbackName ? ownMailboxes(listed ?? [], userUid)[0]?.displayName?.trim() || undefined : undefined;
|
|
105
106
|
useEffect(() => {
|
|
106
107
|
if (!authServerUrl || !needsFallbackName || !mailboxesKnown || mailboxName) {
|
|
107
108
|
return;
|
|
@@ -203,7 +204,7 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
|
|
|
203
204
|
const accountUrl = accountUrlOf(authServerUrl);
|
|
204
205
|
return (_jsxs("div", { className: "relative", ref: containerRef, children: [_jsxs("button", { type: "button", onClick: () => setOpen((o) => !o), "aria-haspopup": "true", "aria-expanded": open, "aria-label": "Account menu", className: "relative flex items-center gap-1.5 rounded-pill py-1 pl-1 pr-2 hover:bg-surface-alt", children: [_jsx(Avatar, { profile: profile, initials: initials }), unseenErrors > 0 && (_jsx("span", { "data-testid": "unseen-errors-dot", "aria-hidden": "true", className: "absolute right-0.5 top-0.5 h-2.5 w-2.5 rounded-full border-2 border-surface bg-danger" })), _jsx("svg", { width: "14", height: "14", viewBox: "0 0 20 20", fill: "none", className: `text-text-muted transition-transform ${open ? "rotate-180" : ""}`, "aria-hidden": "true", children: _jsx("path", { d: "M5 7.5L10 12.5L15 7.5", stroke: "currentColor", strokeWidth: "1.5", strokeLinecap: "round", strokeLinejoin: "round" }) })] }), open &&
|
|
205
206
|
anchor &&
|
|
206
|
-
createPortal(_jsxs("div", { ref: menuRef, role: "menu", style: { position: "fixed", right: anchor.right, top: anchor.top, bottom: anchor.bottom }, className: "w-60 max-w-[calc(100vw-1rem)] max-h-[calc(100vh-5rem)] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1.5 z-[70]", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), _jsx(ThemeSwitch, {}), accountUrl && (_jsx("a", { role: "menuitem", href: accountUrl, className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Account" })), showSettingsLink && (_jsx("a", { role: "menuitem", href: SETTINGS_HREF, className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Settings" })), showNotificationSettings && (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": popups, type: "button", onClick: togglePopups, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx("span", { children: "Notifications" }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: popups ? "On" : "Off" })] })), showNotificationSettings && permission === "default" && (_jsx("button", { role: "menuitem", type: "button", onClick: () => void enableDesktopNotifications(), className: "block w-full px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: "Turn on desktop notifications" })), onShowNotifications && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => {
|
|
207
|
+
createPortal(_jsxs("div", { ref: menuRef, role: "menu", style: { position: "fixed", right: anchor.right, top: anchor.top, bottom: anchor.bottom }, className: "w-60 max-w-[calc(100vw-1rem)] max-h-[calc(100vh-5rem)] overflow-y-auto bg-surface border border-border rounded-md shadow-modal py-1.5 z-[70]", children: [_jsxs("div", { className: "flex items-center gap-3 px-3.5 py-2.5 border-b border-border", children: [_jsx(Avatar, { profile: profile, initials: initials, large: true }), _jsx("span", { className: "text-sm font-semibold text-text truncate", children: name })] }), showMailLink && (_jsxs("a", { role: "menuitem", href: APP_HREFS.mail, className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineArrowUturnLeft, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Back to mail"] })), _jsx(ThemeSwitch, {}), accountUrl && (_jsx("a", { role: "menuitem", href: accountUrl, className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Account" })), showSettingsLink && (_jsx("a", { role: "menuitem", href: SETTINGS_HREF, className: "block px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Settings" })), showNotificationSettings && (_jsxs("button", { role: "menuitemcheckbox", "aria-checked": popups, type: "button", onClick: togglePopups, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx("span", { children: "Notifications" }), _jsx("span", { className: "text-xs font-semibold text-text-muted", children: popups ? "On" : "Off" })] })), showNotificationSettings && permission === "default" && (_jsx("button", { role: "menuitem", type: "button", onClick: () => void enableDesktopNotifications(), className: "block w-full px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: "Turn on desktop notifications" })), onShowNotifications && (_jsxs("button", { role: "menuitem", type: "button", onClick: () => {
|
|
207
208
|
setOpen(false);
|
|
208
209
|
onShowNotifications();
|
|
209
210
|
}, className: "flex w-full items-center justify-between gap-2 px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: [_jsx("span", { children: "Recent notifications" }), unseenErrors > 0 && (_jsxs("span", { className: "rounded-pill bg-danger px-1.5 text-xs font-bold text-white", children: [_jsx("span", { "aria-hidden": "true", children: unseenErrors }), _jsxs("span", { className: "sr-only", children: [unseenErrors, " unseen ", unseenErrors === 1 ? "error" : "errors"] })] }))] })), onShowShortcuts && !isMobile && (_jsx("button", { role: "menuitem", type: "button", "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.global.help, env), onClick: () => {
|