@rapidmx/web-client 0.14.0 → 0.15.1
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/admin/domains/[uid].tsx +3 -2
- package/apps/shared/appearance/AppearanceForm.tsx +9 -1
- package/apps/shared/calendar/bookingPlugin.ts +100 -0
- package/apps/shared/calendar/calendarLiveUpdates.ts +27 -0
- package/apps/shared/components/admin/layout/AdminShell.tsx +37 -5
- package/apps/shared/components/admin/settings/MailboxCreateForm.tsx +3 -1
- package/apps/shared/components/admin/settings/PluginsManager.tsx +1545 -1491
- package/apps/shared/components/calendar/AppointmentQuickForm.tsx +327 -0
- package/apps/shared/components/calendar/DescriptionEditor.tsx +274 -0
- package/apps/shared/components/calendar/EventDescriptionView.tsx +70 -0
- package/apps/shared/components/calendar/EventDetails.tsx +401 -0
- package/apps/shared/components/calendar/EventEditor.tsx +640 -0
- package/apps/shared/components/calendar/EventExpandedForm.tsx +210 -0
- package/apps/shared/components/calendar/EventFormParts.tsx +622 -0
- package/apps/shared/components/calendar/EventModal.tsx +126 -862
- package/apps/shared/components/calendar/EventQuickForm.tsx +116 -0
- package/apps/shared/components/calendar/EventShell.tsx +202 -0
- package/apps/shared/components/calendar/FindATime.tsx +379 -0
- package/apps/shared/components/calendar/LazyDescriptionEditor.tsx +70 -0
- package/apps/shared/components/calendar/MonthView.tsx +15 -3
- package/apps/shared/components/calendar/QuickCreateFaces.tsx +87 -0
- package/apps/shared/components/calendar/QuickCreateTabs.tsx +81 -0
- package/apps/shared/components/calendar/QuickFaceFrame.tsx +81 -0
- package/apps/shared/components/calendar/RecurrenceEditor.tsx +10 -5
- package/apps/shared/components/calendar/RequestChangeForm.tsx +271 -0
- package/apps/shared/components/calendar/SplitDayView.tsx +6 -5
- package/apps/shared/components/calendar/TaskQuickForm.tsx +289 -0
- package/apps/shared/components/calendar/TimeGridView.tsx +8 -6
- package/apps/shared/components/calendar/eventDialogFields.ts +86 -0
- package/apps/shared/components/calendar/eventForm.ts +108 -0
- package/apps/shared/components/calendar/eventFormat.ts +183 -0
- package/apps/shared/components/calendar/findTimeGrid.ts +90 -0
- package/apps/shared/components/calendar/swipeNavigation.ts +14 -0
- package/apps/shared/components/layout/UserMenu.tsx +80 -28
- package/apps/shared/components/mail/ConversationList.tsx +100 -48
- package/apps/shared/components/mail/ConversationThreadPane.tsx +490 -486
- package/apps/shared/components/mail/InviteCard.tsx +387 -0
- package/apps/shared/components/mail/MessageDetailPane.tsx +24 -2
- package/apps/shared/components/mail/SwipeRow.tsx +120 -0
- package/apps/shared/components/mail/compose/ComposeWindow.tsx +7 -3
- package/apps/shared/components/mail/invite/InviteRowChip.tsx +104 -0
- package/apps/shared/components/mail/invite/InviteRsvpPopover.tsx +271 -0
- package/apps/shared/components/mail/invite/InviteTimeline.tsx +200 -0
- package/apps/shared/components/mail/invite/ProposeTimeForm.tsx +109 -0
- package/apps/shared/components/mail/invite/inviteFormat.ts +110 -0
- package/apps/shared/components/mail/invite/inviteStore.ts +114 -0
- package/apps/shared/components/mail/layout/MailShell.tsx +522 -490
- package/apps/shared/components/settings/layout/SettingsShell.tsx +1 -0
- package/apps/shared/gestures/useEnterSlide.ts +54 -0
- package/apps/shared/gestures/useSwipe.ts +114 -0
- package/apps/shared/gestures/useSwipeSlide.ts +113 -0
- package/apps/shared/gestures/useWheelPaging.ts +107 -0
- package/apps/shared/mail/newMailNotifications.ts +171 -183
- package/apps/shared/mail/useNewMailNotifications.ts +178 -178
- package/apps/shared/notifications/preferences.ts +36 -0
- package/apps/shared/notifications/store.ts +560 -551
- package/apps/www/_routes.ts +1 -0
- package/apps/www/calendar/index.tsx +139 -37
- package/apps/www/index.tsx +273 -126
- package/apps/www/messages/[uid].tsx +31 -4
- package/apps/www/settings/profile/index.tsx +248 -0
- package/dist/apps/admin/domains/[uid].js +4 -2
- package/dist/apps/shared/appearance/AppearanceForm.js +9 -1
- package/dist/apps/shared/calendar/bookingPlugin.d.ts +50 -0
- package/dist/apps/shared/calendar/bookingPlugin.js +57 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.d.ts +11 -0
- package/dist/apps/shared/calendar/calendarLiveUpdates.js +23 -0
- package/dist/apps/shared/components/admin/layout/AdminShell.d.ts +1 -1
- package/dist/apps/shared/components/admin/layout/AdminShell.js +7 -3
- package/dist/apps/shared/components/admin/settings/MailboxCreateForm.js +3 -1
- package/dist/apps/shared/components/admin/settings/PluginsManager.js +40 -6
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.d.ts +47 -0
- package/dist/apps/shared/components/calendar/AppointmentQuickForm.js +135 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.d.ts +26 -0
- package/dist/apps/shared/components/calendar/DescriptionEditor.js +192 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventDescriptionView.js +39 -0
- package/dist/apps/shared/components/calendar/EventDetails.d.ts +33 -0
- package/dist/apps/shared/components/calendar/EventDetails.js +137 -0
- package/dist/apps/shared/components/calendar/EventEditor.d.ts +59 -0
- package/dist/apps/shared/components/calendar/EventEditor.js +549 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.d.ts +14 -0
- package/dist/apps/shared/components/calendar/EventExpandedForm.js +44 -0
- package/dist/apps/shared/components/calendar/EventFormParts.d.ts +69 -0
- package/dist/apps/shared/components/calendar/EventFormParts.js +202 -0
- package/dist/apps/shared/components/calendar/EventModal.d.ts +26 -13
- package/dist/apps/shared/components/calendar/EventModal.js +57 -455
- package/dist/apps/shared/components/calendar/EventQuickForm.d.ts +18 -0
- package/dist/apps/shared/components/calendar/EventQuickForm.js +25 -0
- package/dist/apps/shared/components/calendar/EventShell.d.ts +64 -0
- package/dist/apps/shared/components/calendar/EventShell.js +134 -0
- package/dist/apps/shared/components/calendar/FindATime.d.ts +18 -0
- package/dist/apps/shared/components/calendar/FindATime.js +190 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.d.ts +8 -0
- package/dist/apps/shared/components/calendar/LazyDescriptionEditor.js +44 -0
- package/dist/apps/shared/components/calendar/MonthView.d.ts +5 -1
- package/dist/apps/shared/components/calendar/MonthView.js +11 -5
- package/dist/apps/shared/components/calendar/QuickCreateFaces.d.ts +30 -0
- package/dist/apps/shared/components/calendar/QuickCreateFaces.js +44 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.d.ts +24 -0
- package/dist/apps/shared/components/calendar/QuickCreateTabs.js +46 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.d.ts +25 -0
- package/dist/apps/shared/components/calendar/QuickFaceFrame.js +19 -0
- package/dist/apps/shared/components/calendar/RecurrenceEditor.d.ts +4 -1
- package/dist/apps/shared/components/calendar/RecurrenceEditor.js +2 -2
- package/dist/apps/shared/components/calendar/RequestChangeForm.d.ts +36 -0
- package/dist/apps/shared/components/calendar/RequestChangeForm.js +138 -0
- package/dist/apps/shared/components/calendar/SplitDayView.d.ts +3 -2
- package/dist/apps/shared/components/calendar/SplitDayView.js +3 -2
- package/dist/apps/shared/components/calendar/TaskQuickForm.d.ts +40 -0
- package/dist/apps/shared/components/calendar/TaskQuickForm.js +101 -0
- package/dist/apps/shared/components/calendar/TimeGridView.d.ts +4 -2
- package/dist/apps/shared/components/calendar/TimeGridView.js +3 -2
- package/dist/apps/shared/components/calendar/eventDialogFields.d.ts +28 -0
- package/dist/apps/shared/components/calendar/eventDialogFields.js +59 -0
- package/dist/apps/shared/components/calendar/eventForm.d.ts +98 -0
- package/dist/apps/shared/components/calendar/eventForm.js +1 -0
- package/dist/apps/shared/components/calendar/eventFormat.d.ts +67 -0
- package/dist/apps/shared/components/calendar/eventFormat.js +163 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.d.ts +30 -0
- package/dist/apps/shared/components/calendar/findTimeGrid.js +83 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.d.ts +9 -0
- package/dist/apps/shared/components/calendar/swipeNavigation.js +8 -0
- package/dist/apps/shared/components/layout/UserMenu.d.ts +10 -8
- package/dist/apps/shared/components/layout/UserMenu.js +70 -23
- package/dist/apps/shared/components/mail/ConversationList.d.ts +12 -1
- package/dist/apps/shared/components/mail/ConversationList.js +21 -4
- package/dist/apps/shared/components/mail/ConversationThreadPane.d.ts +4 -1
- package/dist/apps/shared/components/mail/InviteCard.d.ts +20 -0
- package/dist/apps/shared/components/mail/InviteCard.js +150 -0
- package/dist/apps/shared/components/mail/MessageDetailPane.js +13 -2
- package/dist/apps/shared/components/mail/SwipeRow.d.ts +22 -0
- package/dist/apps/shared/components/mail/SwipeRow.js +71 -0
- package/dist/apps/shared/components/mail/compose/ComposeWindow.js +8 -3
- package/dist/apps/shared/components/mail/invite/InviteRowChip.d.ts +23 -0
- package/dist/apps/shared/components/mail/invite/InviteRowChip.js +46 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.d.ts +20 -0
- package/dist/apps/shared/components/mail/invite/InviteRsvpPopover.js +141 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.d.ts +27 -0
- package/dist/apps/shared/components/mail/invite/InviteTimeline.js +120 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.d.ts +17 -0
- package/dist/apps/shared/components/mail/invite/ProposeTimeForm.js +45 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteFormat.js +94 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.d.ts +28 -0
- package/dist/apps/shared/components/mail/invite/inviteStore.js +95 -0
- package/dist/apps/shared/components/mail/layout/MailShell.d.ts +5 -0
- package/dist/apps/shared/components/mail/layout/MailShell.js +14 -3
- package/dist/apps/shared/components/settings/layout/SettingsShell.js +1 -0
- package/dist/apps/shared/gestures/useEnterSlide.d.ts +14 -0
- package/dist/apps/shared/gestures/useEnterSlide.js +47 -0
- package/dist/apps/shared/gestures/useSwipe.d.ts +45 -0
- package/dist/apps/shared/gestures/useSwipe.js +90 -0
- package/dist/apps/shared/gestures/useSwipeSlide.d.ts +40 -0
- package/dist/apps/shared/gestures/useSwipeSlide.js +95 -0
- package/dist/apps/shared/gestures/useWheelPaging.d.ts +32 -0
- package/dist/apps/shared/gestures/useWheelPaging.js +85 -0
- package/dist/apps/shared/mail/newMailNotifications.d.ts +3 -8
- package/dist/apps/shared/mail/newMailNotifications.js +3 -12
- package/dist/apps/shared/mail/useNewMailNotifications.js +3 -2
- package/dist/apps/shared/notifications/preferences.d.ts +13 -0
- package/dist/apps/shared/notifications/preferences.js +36 -0
- package/dist/apps/shared/notifications/store.d.ts +1 -0
- package/dist/apps/shared/notifications/store.js +7 -23
- package/dist/apps/www/_routes.js +1 -0
- package/dist/apps/www/calendar/index.js +76 -6
- package/dist/apps/www/index.js +140 -39
- package/dist/apps/www/messages/[uid].js +13 -5
- package/dist/apps/www/settings/profile/index.d.ts +4 -0
- package/dist/apps/www/settings/profile/index.js +124 -0
- package/package.json +3 -3
|
@@ -3,18 +3,23 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
3
3
|
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
4
|
// SPDX-License-Identifier: MPL-2.0
|
|
5
5
|
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
-
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
6
|
+
import { useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
7
7
|
import { createPortal } from "react-dom";
|
|
8
|
+
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
8
9
|
import { HiOutlineShieldCheck } from "react-icons/hi2";
|
|
9
10
|
import { formatProfileName, getMyProfile, getMyUsername, profileInitials } from "@rapidmx/react-shared/auth/profileApi.js";
|
|
11
|
+
import { listMailboxes } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
10
12
|
import { accountUrlOf } from "../../auth/accountUrl.js";
|
|
11
13
|
import { DEFAULT_TRUSTED_ROLES, lookUpAdminAccess } from "../../auth/adminAccess.js";
|
|
12
14
|
import { ariaKeyShortcuts } from "../../keyboard/format.js";
|
|
13
15
|
import { SHORTCUTS } from "../../keyboard/keymap.js";
|
|
14
16
|
import { useKeyEnvironment } from "../../keyboard/ShortcutProvider.js";
|
|
15
17
|
import { SETTINGS_HREF } from "../../navigation/appHrefs.js";
|
|
18
|
+
import { MailConnectionContext } from "../../mail/useMailConnection.js";
|
|
16
19
|
import ThemeSwitch from "./ThemeSwitch.js";
|
|
17
|
-
import { desktopPermission,
|
|
20
|
+
import { desktopPermission, requestDesktopPermission, setDesktopOfferDismissed, } from "../../mail/newMailNotifications.js";
|
|
21
|
+
import { getNotificationsEnabled, setNotificationsEnabled } from "../../notifications/preferences.js";
|
|
22
|
+
import { dismissAll } from "../../notifications/store.js";
|
|
18
23
|
function Avatar({ profile, initials, large }) {
|
|
19
24
|
const sizeClass = large ? "w-10 h-10 text-base" : "w-8 h-8 text-sm";
|
|
20
25
|
if (profile?.avatar) {
|
|
@@ -27,13 +32,17 @@ function Avatar({ profile, initials, large }) {
|
|
|
27
32
|
* trigger that opens a dropdown showing the caller's avatar/name, an "Account" link to auth-server's account page,
|
|
28
33
|
* an optional "Settings" and "Admin Console" link, and "Sign Out". This service has no local user directory (see
|
|
29
34
|
* `.claude/NOTES.md`), so the name and avatar come from auth-server, and the displayed name (and the initials badge)
|
|
30
|
-
* falls back down a chain: the profile's name (`GET /api/profiles/me`), else the
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
35
|
+
* falls back down a chain: the profile's name (`GET /api/profiles/me`), else the display name of the mailbox the
|
|
36
|
+
* caller owns (`listMailboxes()`, or the list the app frame already holds; a shared or delegated mailbox does not count), else the caller's username - their first
|
|
37
|
+
* verified `name` alias (`GET /api/aliases?type=name`) - else the bare uid. The mailbox and the username are asked for
|
|
38
|
+
* only when the profile gave no name, and the username only when the mailbox gave none. Every lookup can fail
|
|
39
|
+
* (auth-server's CORS list not including this origin, or no profile document at all - a 404 for some accounts) and none
|
|
40
|
+
* ever surfaces as an error. The avatar image comes from the profile alone.
|
|
34
41
|
*/
|
|
35
42
|
export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminLink, detectAdmin, trustedRoles = DEFAULT_TRUSTED_ROLES, showSettingsLink, showNotificationSettings, onShowShortcuts, placement = "down", onShowNotifications, unseenErrors = 0, }) {
|
|
36
43
|
const env = useKeyEnvironment();
|
|
44
|
+
// A phone has no keyboard to speak of, so the shortcuts list (and the dialog it opens) has nothing to offer there.
|
|
45
|
+
const isMobile = useIsMobile();
|
|
37
46
|
const [open, setOpen] = useState(false);
|
|
38
47
|
// Where the menu sits, in window coordinates: it is drawn through a portal into `<body>` with `position: fixed`, so no ancestor's
|
|
39
48
|
// `overflow`, `z-index` or stacking context - a custom branding header's, say - can clip it or put it behind the page.
|
|
@@ -43,7 +52,14 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
|
|
|
43
52
|
const [popups, setPopups] = useState(true);
|
|
44
53
|
const [permission, setPermission] = useState("unsupported");
|
|
45
54
|
const [profile, setProfile] = useState(undefined);
|
|
55
|
+
// Whether the profile lookup has answered, with a profile or without: the fallback names are only worth asking for after that.
|
|
56
|
+
const [profileSettled, setProfileSettled] = useState(false);
|
|
46
57
|
const [username, setUsername] = useState(undefined);
|
|
58
|
+
// The mailboxes this menu listed itself; `null` until it has, or when it has no need to.
|
|
59
|
+
const [ownListing, setOwnListing] = useState(null);
|
|
60
|
+
// Inside the app frame the mailboxes are already listed for the whole session (see `useMailConnection()`), so they are reused, not listed again.
|
|
61
|
+
const connection = useContext(MailConnectionContext);
|
|
62
|
+
const framed = connection !== null;
|
|
47
63
|
const containerRef = useRef(null);
|
|
48
64
|
useEffect(() => {
|
|
49
65
|
if (!authServerUrl) {
|
|
@@ -56,24 +72,50 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
|
|
|
56
72
|
loaded = await getMyProfile(authServerUrl);
|
|
57
73
|
}
|
|
58
74
|
catch {
|
|
59
|
-
// Unreachable, blocked, or no profile document (404) - fall through to the
|
|
75
|
+
// Unreachable, blocked, or no profile document (404) - fall through to the fallback names.
|
|
60
76
|
}
|
|
61
|
-
if (cancelled) {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
setProfile(loaded);
|
|
65
|
-
// The username is only the fallback for a missing name - no second request when the profile has one.
|
|
66
|
-
if (!formatProfileName(loaded)) {
|
|
67
|
-
const alias = await getMyUsername(authServerUrl);
|
|
68
|
-
if (!cancelled) {
|
|
69
|
-
setUsername(alias);
|
|
70
|
-
}
|
|
77
|
+
if (!cancelled) {
|
|
78
|
+
setProfile(loaded);
|
|
79
|
+
setProfileSettled(true);
|
|
71
80
|
}
|
|
72
81
|
})();
|
|
73
82
|
return () => {
|
|
74
83
|
cancelled = true;
|
|
75
84
|
};
|
|
76
85
|
}, [authServerUrl]);
|
|
86
|
+
// The mailbox's display name and then the username are only fallbacks for a missing name - nothing more is asked when the profile has
|
|
87
|
+
// one, and no username is asked for when the mailbox has a name. Without `authServerUrl` the profile never settles, so nothing is.
|
|
88
|
+
const needsFallbackName = profileSettled && !formatProfileName(profile);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!needsFallbackName || framed) {
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
let cancelled = false;
|
|
94
|
+
// A failure (no mail server, no access) is just "no mailbox name" - the username is asked for next.
|
|
95
|
+
listMailboxes({ limit: 50 }).then((list) => !cancelled && setOwnListing(Array.isArray(list) ? list : []), () => !cancelled && setOwnListing([]));
|
|
96
|
+
return () => {
|
|
97
|
+
cancelled = true;
|
|
98
|
+
};
|
|
99
|
+
}, [needsFallbackName, framed]);
|
|
100
|
+
// `null` while the mailboxes are still on their way.
|
|
101
|
+
const listed = framed ? (connection.status === "checking" ? null : connection.mailboxes) : ownListing;
|
|
102
|
+
const mailboxesKnown = listed !== null;
|
|
103
|
+
// Only a mailbox the caller owns counts: a shared or delegated one carries somebody else's name.
|
|
104
|
+
const mailboxName = needsFallbackName ? listed?.find((mb) => mb.ownerUserUid === userUid)?.displayName?.trim() || undefined : undefined;
|
|
105
|
+
useEffect(() => {
|
|
106
|
+
if (!authServerUrl || !needsFallbackName || !mailboxesKnown || mailboxName) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
let cancelled = false;
|
|
110
|
+
void getMyUsername(authServerUrl).then((alias) => {
|
|
111
|
+
if (!cancelled) {
|
|
112
|
+
setUsername(alias);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
return () => {
|
|
116
|
+
cancelled = true;
|
|
117
|
+
};
|
|
118
|
+
}, [authServerUrl, needsFallbackName, mailboxesKnown, mailboxName]);
|
|
77
119
|
// Keyed on the joined names, not the array: a caller's fresh `["admin"]` each render must not re-ask.
|
|
78
120
|
const trustedRolesKey = JSON.stringify(trustedRoles);
|
|
79
121
|
useEffect(() => {
|
|
@@ -94,13 +136,17 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
|
|
|
94
136
|
// Read fresh each time the menu opens - another tab may have changed either since - and only on the client.
|
|
95
137
|
useEffect(() => {
|
|
96
138
|
if (open && showNotificationSettings) {
|
|
97
|
-
setPopups(
|
|
139
|
+
setPopups(getNotificationsEnabled());
|
|
98
140
|
setPermission(desktopPermission());
|
|
99
141
|
}
|
|
100
142
|
}, [open, showNotificationSettings]);
|
|
101
143
|
function togglePopups() {
|
|
102
|
-
|
|
144
|
+
setNotificationsEnabled(!popups);
|
|
103
145
|
setPopups(!popups);
|
|
146
|
+
if (popups) {
|
|
147
|
+
// Turned off: what is on screen goes too (it stays in "Recent notifications").
|
|
148
|
+
dismissAll();
|
|
149
|
+
}
|
|
104
150
|
}
|
|
105
151
|
async function enableDesktopNotifications() {
|
|
106
152
|
// Asked from this click, as browsers require; a "no" (or "yes") answers the first pop-up's offer as well.
|
|
@@ -151,15 +197,16 @@ export default function UserMenu({ userUid, authServerUrl, onSignOut, showAdminL
|
|
|
151
197
|
window.removeEventListener("scroll", place, true);
|
|
152
198
|
};
|
|
153
199
|
}, [open, placement]);
|
|
154
|
-
const name = formatProfileName(profile) ?? username ?? userUid;
|
|
155
|
-
|
|
200
|
+
const name = formatProfileName(profile) ?? mailboxName ?? username ?? userUid;
|
|
201
|
+
// The badge takes its letter from whichever fallback name is shown, so it never disagrees with the name beside it.
|
|
202
|
+
const initials = profileInitials(profile, userUid, mailboxName ?? username);
|
|
156
203
|
const accountUrl = accountUrlOf(authServerUrl);
|
|
157
204
|
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 &&
|
|
158
205
|
anchor &&
|
|
159
|
-
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: "
|
|
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: () => {
|
|
160
207
|
setOpen(false);
|
|
161
208
|
onShowNotifications();
|
|
162
|
-
}, 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 && (_jsx("button", { role: "menuitem", type: "button", "aria-keyshortcuts": ariaKeyShortcuts(SHORTCUTS.global.help, env), onClick: () => {
|
|
209
|
+
}, 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: () => {
|
|
163
210
|
setOpen(false);
|
|
164
211
|
onShowShortcuts();
|
|
165
212
|
}, className: "block w-full px-3.5 py-2 text-left text-sm text-text hover:bg-surface-alt", children: "Keyboard shortcuts" })), (showAdminLink || detectedAdmin) && (_jsxs("a", { role: "menuitem", href: "/admin", className: "flex items-center gap-2 px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: [_jsx(HiOutlineShieldCheck, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), "Admin Console"] })), _jsx("button", { role: "menuitem", type: "button", onClick: onSignOut, className: "block w-full text-left px-3.5 py-2 text-sm text-text hover:bg-surface-alt", children: "Sign Out" })] }), document.body)] }));
|
|
@@ -17,6 +17,8 @@ export interface ConversationListProps {
|
|
|
17
17
|
* read, which this list would otherwise keep showing as unread until the thread is collapsed and
|
|
18
18
|
* expanded again. */
|
|
19
19
|
messageOverrides?: Record<string, Message>;
|
|
20
|
+
/** Called with one of a conversation's own messages carrying the answer the reader just gave to its meeting request (from that row's RSVP chip), so the caller can keep it. An answer given from the conversation's own (parent) row is kept by this list itself. */
|
|
21
|
+
onMeetingResponded?: (updated: Message) => void;
|
|
20
22
|
/** Turns each parent row into a checkbox row: select mode here ticks whole conversations, since a
|
|
21
23
|
* conversation is what this list's rows are. A child row stays a plain "open this message" button - a
|
|
22
24
|
* mixed conversation/message selection has no sensible bulk semantics (see `MailListToolbar`). */
|
|
@@ -24,6 +26,15 @@ export interface ConversationListProps {
|
|
|
24
26
|
/** The `conversationId`s currently ticked. */
|
|
25
27
|
selectedConversationIds?: Set<string>;
|
|
26
28
|
onToggleSelected?: (conversation: ConversationSummary) => void;
|
|
29
|
+
/**
|
|
30
|
+
* What swiping a parent row does on a phone: right to left archives the conversation (resolving whether it went through), left
|
|
31
|
+
* to right asks where to move it. Absent, or `enabled: false` (a desktop), rows don't follow a finger.
|
|
32
|
+
*/
|
|
33
|
+
swipe?: {
|
|
34
|
+
enabled: boolean;
|
|
35
|
+
onArchive: (conversation: ConversationSummary) => Promise<boolean>;
|
|
36
|
+
onMove: (conversation: ConversationSummary) => void;
|
|
37
|
+
};
|
|
27
38
|
/**
|
|
28
39
|
* Whether a conversation's own messages read newest first, matching the order sense the rows themselves
|
|
29
40
|
* are in ("Newest on top"). `listConversationMessages()` always answers oldest first - the order a
|
|
@@ -44,4 +55,4 @@ export interface ConversationListProps {
|
|
|
44
55
|
* nest inside a button), so expanding and opening are separately reachable by keyboard and each carries
|
|
45
56
|
* its own name.
|
|
46
57
|
*/
|
|
47
|
-
export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, selectMode, selectedConversationIds, onToggleSelected, newestFirst, }: ConversationListProps): React.JSX.Element;
|
|
58
|
+
export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, onMeetingResponded, selectMode, selectedConversationIds, onToggleSelected, swipe, newestFirst, }: ConversationListProps): React.JSX.Element;
|
|
@@ -11,6 +11,8 @@ import MailAddress from "./MailAddress.js";
|
|
|
11
11
|
import { EncryptedPreview, conversationLooksEncrypted } from "./reading/EncryptedPreview.js";
|
|
12
12
|
import { listConversationMessages } from "@rapidmx/react-shared/mail/conversationsApi.js";
|
|
13
13
|
import { ROW_FOCUS_CLASS, UnreadBar, UnreadLabel, dateClass, isUnread, rowClass, senderClass, subjectClass } from "./unreadStyle.js";
|
|
14
|
+
import SwipeRow from "./SwipeRow.js";
|
|
15
|
+
import InviteRowChip from "./invite/InviteRowChip.js";
|
|
14
16
|
/** Every participant as `Name <address>`, for the tooltip and the accessible text of the "+N" a crowded row folds them into. */
|
|
15
17
|
function participantList(conversation) {
|
|
16
18
|
return conversation.participants.map((participant) => formatMailAddress(participant)).join(", ");
|
|
@@ -28,11 +30,16 @@ function participantList(conversation) {
|
|
|
28
30
|
* nest inside a button), so expanding and opening are separately reachable by keyboard and each carries
|
|
29
31
|
* its own name.
|
|
30
32
|
*/
|
|
31
|
-
export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, selectMode, selectedConversationIds, onToggleSelected, newestFirst, }) {
|
|
33
|
+
export default function ConversationList({ conversations, mailboxUid, selectedUid, onOpenMessage, messageOverrides, onMeetingResponded, selectMode, selectedConversationIds, onToggleSelected, swipe, newestFirst, }) {
|
|
32
34
|
const [expanded, setExpanded] = useState(new Set());
|
|
33
35
|
const [messagesById, setMessagesById] = useState({});
|
|
34
36
|
const [loadingIds, setLoadingIds] = useState(new Set());
|
|
35
37
|
const [errorsById, setErrorsById] = useState({});
|
|
38
|
+
// The answers given from a row's RSVP chip, by message uid: a row draws them at once, whichever row (the conversation's, or one of its messages) they were given from.
|
|
39
|
+
const [answers, setAnswers] = useState({});
|
|
40
|
+
function answered(updated) {
|
|
41
|
+
setAnswers((prev) => ({ ...prev, [updated.uid]: updated.meetingResponse }));
|
|
42
|
+
}
|
|
36
43
|
function toggle(conversation) {
|
|
37
44
|
const id = conversation.conversationId;
|
|
38
45
|
setExpanded((prev) => {
|
|
@@ -69,7 +76,7 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
|
|
|
69
76
|
if (conversations.length === 0) {
|
|
70
77
|
return _jsx("p", { className: "p-4 text-sm text-text-muted", children: "No conversations in this folder." });
|
|
71
78
|
}
|
|
72
|
-
return (_jsx("ul", { children: conversations.map((conversation) => {
|
|
79
|
+
return (_jsx("ul", { className: swipe?.enabled ? "overflow-x-clip" : undefined, children: conversations.map((conversation) => {
|
|
73
80
|
const id = conversation.conversationId;
|
|
74
81
|
const isExpanded = expanded.has(id);
|
|
75
82
|
const unread = conversation.unreadCount > 0;
|
|
@@ -79,10 +86,20 @@ export default function ConversationList({ conversations, mailboxUid, selectedUi
|
|
|
79
86
|
const children = loaded && newestFirst ? [...loaded].reverse() : loaded;
|
|
80
87
|
const panelId = `conversation-messages-${id}`;
|
|
81
88
|
const ticked = selectedConversationIds?.has(id) ?? false;
|
|
82
|
-
return (_jsxs("li", { children: [_jsxs("div",
|
|
89
|
+
return (_jsxs("li", { children: [_jsxs(SwipeRow, { as: "div", enabled: !!swipe?.enabled && !selectMode, onArchive: () => swipe.onArchive(conversation), onMove: () => swipe.onMove(conversation), "data-message-uid": conversation.latestMessageUid, "data-unread": unread ? "true" : undefined, className: rowClass({ unread, selected: conversation.latestMessageUid === selectedUid || ticked }, "flex items-stretch"), children: [_jsx(UnreadBar, { unread: unread }), selectMode && (_jsx("span", { className: "shrink-0 flex items-center pl-3", children: _jsx("input", { type: "checkbox", checked: ticked, onChange: () => onToggleSelected?.(conversation), "aria-label": `Select conversation: ${conversation.subject || "(no subject)"}`, className: "w-4 h-4 accent-primary" }) })), _jsx("button", { type: "button", onClick: () => toggle(conversation), "aria-expanded": isExpanded, "aria-controls": panelId, "aria-label": `${isExpanded ? "Collapse" : "Expand"} conversation: ${conversation.subject || "(no subject)"}`, className: ["shrink-0 px-2 text-text-muted hover:text-text", ROW_FOCUS_CLASS].join(" "), children: isExpanded ? (_jsx(HiChevronDown, { size: 16, "aria-hidden": "true" })) : (_jsx(HiChevronRight, { size: 16, "aria-hidden": "true" })) }), _jsxs("div", { className: "flex-1 min-w-0 flex flex-col", children: [_jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, conversation.latestMessageUid), className: ["w-full text-left pr-4 py-3", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: unread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: conversation.participants[0] ?? conversation.latestFrom, className: ["flex-1", senderClass(unread)].join(" ") }), conversation.participants.length > 1 && (_jsxs("span", { className: "text-xs text-text-muted shrink-0 font-normal", title: participantList(conversation), children: ["+", conversation.participants.length - 1, _jsxs("span", { className: "sr-only", children: [" more: ", participantList(conversation)] })] })), _jsx("span", { className: ["text-xs shrink-0", dateClass(unread)].join(" "), children: new Date(conversation.latestDate).toLocaleDateString() })] }), _jsx("div", { className: ["text-sm truncate", subjectClass(unread)].join(" "), children: conversation.subject || "(no subject)" }), _jsx("div", { className: "text-xs text-text-muted truncate font-normal", children: conversationLooksEncrypted(conversation) ? _jsx(EncryptedPreview, {}) : conversation.latestPreview }), _jsxs("div", { className: "flex items-center gap-2 mt-1 text-xs text-text-muted font-normal", children: [conversation.messageCount > 1 && _jsxs("span", { children: [conversation.messageCount, " messages"] }), unread && conversation.messageCount > 1 && (_jsxs("span", { className: "py-0.5 px-2 rounded-pill bg-primary/15 text-primary-dark font-bold", children: [conversation.unreadCount, " unread"] })), conversation.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 13, "aria-label": "Has attachments" })), conversation.flagged && (_jsx(HiOutlineFlag, { size: 13, "aria-label": "Flagged", className: "text-danger" }))] })] }), _jsx(InviteRowChip, { message: {
|
|
90
|
+
uid: conversation.latestMessageUid,
|
|
91
|
+
meetingMethod: conversation.latestMeetingMethod ?? undefined,
|
|
92
|
+
meetingResponse: answers[conversation.latestMessageUid] ??
|
|
93
|
+
messageOverrides?.[conversation.latestMessageUid]?.meetingResponse ??
|
|
94
|
+
conversation.latestMeetingResponse ??
|
|
95
|
+
undefined,
|
|
96
|
+
}, onResponded: answered, className: "mr-4" })] })] }), _jsxs("ul", { id: panelId, hidden: !isExpanded, children: [errorsById[id] && (_jsx("li", { role: "alert", className: "pl-8 pr-4 py-2 text-xs text-danger border-b border-border", children: errorsById[id] })), loadingIds.has(id) && (_jsx("li", { className: "pl-8 pr-4 py-2 text-xs text-text-muted border-b border-border", children: "Loading messages\u2026" })), children?.map((fetched) => {
|
|
83
97
|
const message = messageOverrides?.[fetched.uid] ?? fetched;
|
|
84
98
|
const messageUnread = isUnread(message);
|
|
85
|
-
return (_jsxs("li", { "data-message-uid": message.uid, "data-unread": messageUnread ? "true" : undefined, className: rowClass({ unread: messageUnread, selected: message.uid === selectedUid }), children: [_jsx(UnreadBar, { unread: messageUnread }), _jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, message.uid), className: ["w-full text-left pl-8 pr-4 py-2", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: messageUnread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(messageUnread) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(messageUnread)].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: message.bodyPreview || (message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" })), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] })
|
|
99
|
+
return (_jsxs("li", { "data-message-uid": message.uid, "data-unread": messageUnread ? "true" : undefined, className: rowClass({ unread: messageUnread, selected: message.uid === selectedUid }), children: [_jsx(UnreadBar, { unread: messageUnread }), _jsxs("button", { type: "button", "data-row-open": true, onClick: () => onOpenMessage(conversation, message.uid), className: ["w-full text-left pl-8 pr-4 py-2", ROW_FOCUS_CLASS].join(" "), children: [_jsx(UnreadLabel, { unread: messageUnread }), _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx(MailAddress, { recipient: message.from, className: senderClass(messageUnread) }), _jsx("span", { className: ["text-xs shrink-0", dateClass(messageUnread)].join(" "), children: new Date(message.receivedDate).toLocaleDateString() })] }), _jsxs("div", { className: "flex items-center gap-2 text-xs text-text-muted font-normal", children: [_jsx("span", { className: "truncate", children: message.bodyPreview || (message.encrypted ? _jsx(EncryptedPreview, {}) : null) }), message.hasAttachments && (_jsx(HiOutlinePaperClip, { size: 12, "aria-label": "Has attachments" })), message.flags.flagged && (_jsx(HiOutlineFlag, { size: 12, "aria-label": "Flagged", className: "text-danger" }))] })] }), _jsx(InviteRowChip, { message: { ...message, meetingResponse: answers[message.uid] ?? message.meetingResponse }, onResponded: (updated) => {
|
|
100
|
+
answered(updated);
|
|
101
|
+
onMeetingResponded?.(updated);
|
|
102
|
+
}, className: "ml-8 mr-4" })] }, message.uid));
|
|
86
103
|
})] })] }, id));
|
|
87
104
|
}) }));
|
|
88
105
|
}
|
|
@@ -11,8 +11,11 @@ export declare const THREAD_PAGE_SIZE = 100;
|
|
|
11
11
|
* into an unbounded run of requests. Past it the pane says so and the rest stay readable from the list.
|
|
12
12
|
*/
|
|
13
13
|
export declare const THREAD_MESSAGE_LIMIT = 500;
|
|
14
|
+
/** What the pane needs to know of a conversation up front: the thread itself is loaded by `conversationId`. The subject and
|
|
15
|
+
* count fill the header while it loads, so a caller that only has a message (the mobile route) gives its subject and a count of 1. */
|
|
16
|
+
export type ConversationThreadHead = Pick<ConversationSummary, "conversationId" | "subject" | "messageCount">;
|
|
14
17
|
export interface ConversationThreadPaneProps {
|
|
15
|
-
conversation:
|
|
18
|
+
conversation: ConversationThreadHead | null;
|
|
16
19
|
/** The mailbox the conversation was listed from - `listConversationMessages()` is mailbox-scoped. */
|
|
17
20
|
mailboxUid: string;
|
|
18
21
|
/**
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
export { formatInviteWhen, isCalendarAttachment } from "./invite/inviteFormat.js";
|
|
3
|
+
export interface InviteCardProps {
|
|
4
|
+
/** The message whose calendar invitation to show, if it carries one. Nothing is drawn until the server says there is one. */
|
|
5
|
+
messageUid: string;
|
|
6
|
+
/** The level of the card's heading, so it sits below whatever heading its surroundings have (`2` in the reading pane, `3` inside a thread's card). */
|
|
7
|
+
headingLevel?: 2 | 3;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* The calendar invitation a message carries, as Outlook and Gmail draw it: what the meeting is, when and where, who organizes it, and the buttons
|
|
11
|
+
* that answer it - Accept, Tentative, Decline (and Propose new time) for a request; "Add to calendar" for a published event; "Remove from
|
|
12
|
+
* calendar" for a cancellation; "Accept proposal" for an attendee's proposed new time. An attendee's answer to the reader's own invitation is
|
|
13
|
+
* one line ("Bob tentatively accepted."). Everything that follows an action (the meeting going onto the calendar or off it, the reply to the
|
|
14
|
+
* organizer) is the server's; this only asks for it and draws the invitation the server sends back.
|
|
15
|
+
*
|
|
16
|
+
* Asks the server once per message (through the shared invitation cache, so the list row's RSVP chip and this card agree). A message with no readable
|
|
17
|
+
* invitation, and a lookup that fails for any reason, draw nothing - a lookup that fails is never retried and never disturbs the message around it.
|
|
18
|
+
* An answer that arrives after the message changed or this was unmounted is dropped.
|
|
19
|
+
*/
|
|
20
|
+
export default function InviteCard({ messageUid, headingLevel }: InviteCardProps): React.JSX.Element | null;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useId, useState } from "react";
|
|
7
|
+
import { HiOutlineCalendarDays, HiOutlineCheck } from "react-icons/hi2";
|
|
8
|
+
import { acceptProposal, proposeNewTime, removeMessageInvite, respondToMessageInvite, } from "@rapidmx/react-shared/calendar/inviteApi.js";
|
|
9
|
+
import { guestPermissionsOf } from "@rapidmx/react-shared/calendar/calendarApi.js";
|
|
10
|
+
import { formatMailAddress } from "@rapidmx/react-shared/mail/mailAddress.js";
|
|
11
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
12
|
+
import EventDescriptionView from "../calendar/EventDescriptionView.js";
|
|
13
|
+
import RequestChangeForm from "../calendar/RequestChangeForm.js";
|
|
14
|
+
import { VISIBILITY_LABEL, describeGuestPermissions } from "../calendar/eventFormat.js";
|
|
15
|
+
import { notifyApiError } from "../../notifications/apiErrors.js";
|
|
16
|
+
import { calendarHref, conflictSummary, formatInviteWhen } from "./invite/inviteFormat.js";
|
|
17
|
+
import { useMessageInvite } from "./invite/inviteStore.js";
|
|
18
|
+
import ProposeTimeForm from "./invite/ProposeTimeForm.js";
|
|
19
|
+
export { formatInviteWhen, isCalendarAttachment } from "./invite/inviteFormat.js";
|
|
20
|
+
const HEADINGS = {
|
|
21
|
+
REQUEST: "Meeting invitation",
|
|
22
|
+
CANCEL: "Meeting canceled",
|
|
23
|
+
REPLY: "Meeting response",
|
|
24
|
+
COUNTER: "New time proposed",
|
|
25
|
+
};
|
|
26
|
+
/** The heading of a `COUNTER` that is a guest's request for a change (rather than a different time). */
|
|
27
|
+
const CHANGE_REQUEST_HEADING = "Change requested";
|
|
28
|
+
/** How an answer reads in "Bob accepted." (a REPLY) - a participant who has not answered has not answered. */
|
|
29
|
+
const REPLY_VERBS = {
|
|
30
|
+
accepted: "accepted",
|
|
31
|
+
tentative: "tentatively accepted",
|
|
32
|
+
declined: "declined",
|
|
33
|
+
};
|
|
34
|
+
/** The reader's own answer, as the line under the invitation says it. */
|
|
35
|
+
const ANSWER_TEXT = {
|
|
36
|
+
accepted: "You accepted this meeting.",
|
|
37
|
+
tentative: "You tentatively accepted this meeting.",
|
|
38
|
+
declined: "You declined this meeting. It is not on your calendar.",
|
|
39
|
+
};
|
|
40
|
+
const ANSWER_CLASS = {
|
|
41
|
+
accepted: "text-success",
|
|
42
|
+
tentative: "text-text",
|
|
43
|
+
declined: "text-danger",
|
|
44
|
+
};
|
|
45
|
+
const RESPONSES = [
|
|
46
|
+
{ response: "accepted", label: "Accept", failure: "Couldn't accept this meeting" },
|
|
47
|
+
{ response: "tentative", label: "Tentative", failure: "Couldn't respond tentatively to this meeting" },
|
|
48
|
+
{ response: "declined", label: "Decline", failure: "Couldn't decline this meeting" },
|
|
49
|
+
];
|
|
50
|
+
/** The most attendees that are listed by name; a longer list is only counted. */
|
|
51
|
+
const MAX_NAMED_ATTENDEES = 5;
|
|
52
|
+
function attendeeSummary(attendees) {
|
|
53
|
+
if (attendees.length === 0) {
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
const count = `${attendees.length} ${attendees.length === 1 ? "attendee" : "attendees"}`;
|
|
57
|
+
return attendees.length <= MAX_NAMED_ATTENDEES ? `${count}: ${attendees.map((a) => a.displayName || a.address).join(", ")}` : count;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The calendar invitation a message carries, as Outlook and Gmail draw it: what the meeting is, when and where, who organizes it, and the buttons
|
|
61
|
+
* that answer it - Accept, Tentative, Decline (and Propose new time) for a request; "Add to calendar" for a published event; "Remove from
|
|
62
|
+
* calendar" for a cancellation; "Accept proposal" for an attendee's proposed new time. An attendee's answer to the reader's own invitation is
|
|
63
|
+
* one line ("Bob tentatively accepted."). Everything that follows an action (the meeting going onto the calendar or off it, the reply to the
|
|
64
|
+
* organizer) is the server's; this only asks for it and draws the invitation the server sends back.
|
|
65
|
+
*
|
|
66
|
+
* Asks the server once per message (through the shared invitation cache, so the list row's RSVP chip and this card agree). A message with no readable
|
|
67
|
+
* invitation, and a lookup that fails for any reason, draw nothing - a lookup that fails is never retried and never disturbs the message around it.
|
|
68
|
+
* An answer that arrives after the message changed or this was unmounted is dropped.
|
|
69
|
+
*/
|
|
70
|
+
export default function InviteCard({ messageUid, headingLevel = 2 }) {
|
|
71
|
+
const { invite, setInvite } = useMessageInvite(messageUid);
|
|
72
|
+
const [pending, setPending] = useState(null);
|
|
73
|
+
const [proposing, setProposing] = useState(false);
|
|
74
|
+
// Said once after a proposal went out: the invitation itself doesn't change.
|
|
75
|
+
const [proposed, setProposed] = useState(false);
|
|
76
|
+
// A guest's request for a change (or for guests to be added): the form is open, and once it went out.
|
|
77
|
+
const [requesting, setRequesting] = useState(false);
|
|
78
|
+
const [requested, setRequested] = useState(false);
|
|
79
|
+
const headingId = useId();
|
|
80
|
+
if (!invite) {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
/** Runs one of the card's actions: the buttons are off while it does, its result replaces what the card shows, and a failure leaves the card as it was. */
|
|
84
|
+
async function run(action, failure, call) {
|
|
85
|
+
setPending(action);
|
|
86
|
+
try {
|
|
87
|
+
setInvite(await call());
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
catch (err) {
|
|
91
|
+
notifyApiError(err, failure);
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
finally {
|
|
95
|
+
setPending(null);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function sendProposal(proposal) {
|
|
99
|
+
if (await run("propose", "Couldn't send the proposed time", () => proposeNewTime(messageUid, proposal))) {
|
|
100
|
+
setProposing(false);
|
|
101
|
+
setProposed(true);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const busy = pending !== null;
|
|
105
|
+
const Heading = `h${headingLevel}`;
|
|
106
|
+
const method = invite.method.toUpperCase();
|
|
107
|
+
const isReply = method === "REPLY";
|
|
108
|
+
const isCounter = method === "COUNTER";
|
|
109
|
+
// A `COUNTER` a guest sent to change the event (not just its time): whether the organizer's server applied it already.
|
|
110
|
+
const changeRequest = isCounter ? invite.changeRequest : undefined;
|
|
111
|
+
const permissions = guestPermissionsOf(invite.guestPermissions ?? {});
|
|
112
|
+
const hiddenList = !invite.isOrganizer && !permissions.guestsCanSeeGuestList;
|
|
113
|
+
const canRequest = !!invite.calendarEventUid && (!!invite.canRequestChange || !!invite.canRequestInvite);
|
|
114
|
+
const title = invite.summary?.trim() || "(no title)";
|
|
115
|
+
const when = formatInviteWhen(invite);
|
|
116
|
+
const attendees = hiddenList ? "The organizer has hidden the guest list" : attendeeSummary(invite.attendees);
|
|
117
|
+
const hasDescription = !!(invite.descriptionHtml?.trim() || invite.description?.trim());
|
|
118
|
+
const visibility = invite.visibility && invite.visibility !== "default" ? VISIBILITY_LABEL[invite.visibility] : undefined;
|
|
119
|
+
const sender = invite.reply ?? invite.attendees[0];
|
|
120
|
+
const senderName = sender ? sender.displayName || sender.address : "Someone";
|
|
121
|
+
const showResponses = invite.canRespond && !invite.isOrganizer;
|
|
122
|
+
const conflicts = !isReply && !isCounter && method !== "CANCEL" && invite.conflicts.length > 0 ? conflictSummary(invite.conflicts) : undefined;
|
|
123
|
+
const hasActions = showResponses ||
|
|
124
|
+
invite.canPropose ||
|
|
125
|
+
invite.canAcceptProposal ||
|
|
126
|
+
invite.canAdd ||
|
|
127
|
+
invite.canRemove ||
|
|
128
|
+
canRequest ||
|
|
129
|
+
(invite.onCalendar && !!invite.calendarEventUid);
|
|
130
|
+
return (_jsxs("section", { "aria-labelledby": headingId, className: "rounded-sm border border-border bg-surface-alt px-3 py-3 flex flex-col gap-2 text-sm text-text", children: [_jsxs(Heading, { id: headingId, className: "flex items-center gap-2 text-sm font-semibold", children: [_jsx(HiOutlineCalendarDays, { size: 16, "aria-hidden": "true", className: "shrink-0" }), changeRequest ? CHANGE_REQUEST_HEADING : (HEADINGS[method] ?? "Calendar event")] }), isReply && (_jsxs("p", { className: "break-words", children: [_jsx("span", { className: "font-semibold", children: senderName }), " ", (sender?.responseStatus && REPLY_VERBS[sender.responseStatus]) ?? "responded", "."] })), isCounter && !changeRequest && (_jsxs("p", { className: "break-words", children: [_jsx("span", { className: "font-semibold", children: senderName }), " proposed a new time", when ? _jsxs(_Fragment, { children: [": ", when] }) : "."] })), changeRequest && (_jsx("p", { className: "break-words", children: changeRequest.applied ? (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-semibold", children: senderName }), "\u2019s change was applied."] })) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "font-semibold", children: senderName }), " requested a change."] })) })), _jsx("p", { className: "font-medium break-words", children: title }), !isReply && (_jsxs("dl", { className: "grid grid-cols-[auto_minmax(0,1fr)] gap-x-3 gap-y-1", children: [when && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "When" }), _jsxs("dd", { className: "break-words", children: [when, invite.recurring && _jsx("span", { className: "text-text-muted", children: " (Repeats)" })] })] })), invite.location && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Where" }), _jsx("dd", { className: "break-words", children: invite.location })] })), hasDescription && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Description" }), _jsx("dd", { className: "break-words", children: _jsx(EventDescriptionView, { html: invite.descriptionHtml, text: invite.description }) })] })), invite.organizer && !isCounter && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Organizer" }), _jsx("dd", { className: "break-words", children: formatMailAddress(invite.organizer) })] })), attendees && (!isCounter || !!changeRequest) && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Attendees" }), _jsx("dd", { className: "break-words", children: attendees })] })), visibility && !isCounter && (_jsxs(_Fragment, { children: [_jsx("dt", { className: "text-text-muted", children: "Visibility" }), _jsx("dd", { className: "break-words", children: visibility })] }))] })), isReply && when && _jsx("p", { className: "text-text-muted break-words", children: when }), conflicts && (_jsxs("p", { className: "text-danger break-words", children: [_jsx("span", { className: "font-medium", children: "Conflicts with:" }), " ", conflicts] })), invite.guestPermissions && !isReply && !isCounter && method !== "CANCEL" && (_jsx("p", { className: "text-xs text-text-muted break-words", children: describeGuestPermissions(permissions) })), invite.isOrganizer && !isReply && !isCounter && _jsx("p", { className: "text-text-muted", children: "You are the organizer of this meeting." }), method === "CANCEL" && _jsx("p", { className: "font-medium text-danger", children: "This meeting was canceled." }), invite.response && !invite.isOrganizer && !isReply && !isCounter && (_jsx("p", { role: "status", className: `font-medium ${ANSWER_CLASS[invite.response]}`, children: ANSWER_TEXT[invite.response] })), isCounter && invite.response === "accepted" && !changeRequest?.applied && (_jsx("p", { role: "status", className: "font-medium text-success", children: "You accepted the proposed time." })), proposed && (_jsx("p", { role: "status", className: "font-medium text-text", children: "Your proposed time was sent to the organizer." })), requested && (_jsx("p", { role: "status", className: "font-medium text-text", children: "Your change was sent to the organizer." })), invite.outdated && _jsx("p", { className: "text-xs text-text-muted", children: "A newer version of this meeting is already on your calendar." }), hasActions && (_jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [showResponses && (_jsx("div", { role: "group", "aria-label": "Respond to this meeting", "aria-busy": busy || undefined, className: "flex flex-wrap gap-2", children: RESPONSES.map(({ response, label, failure }) => {
|
|
131
|
+
const current = invite.response === response;
|
|
132
|
+
return (_jsxs(Button, { type: "button", variant: "secondary", className: ["!w-auto", current ? "!bg-primary/10 !border-primary disabled:!opacity-100" : ""].join(" "), "aria-pressed": current, "aria-busy": pending === response || undefined, loading: pending === response, disabled: busy || current, onClick: () => void run(response, failure, () => respondToMessageInvite(messageUid, response)), children: [current && _jsx(HiOutlineCheck, { size: 14, "aria-hidden": "true" }), label] }, response));
|
|
133
|
+
}) })), invite.canPropose && !proposing && (_jsx(Button, { type: "button", variant: "text", disabled: busy, onClick: () => setProposing(true), children: "Propose new time" })), canRequest && !requesting && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy, onClick: () => setRequesting(true), children: invite.canRequestChange ? "Request a change" : "Add guests" })), invite.canAcceptProposal && invite.response !== "accepted" && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "acceptProposal" || undefined, loading: pending === "acceptProposal", disabled: busy, onClick: () => void run("acceptProposal", "Couldn't accept the proposed time", () => acceptProposal(messageUid)), children: "Accept proposal" })), invite.canAdd && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "add" || undefined, loading: pending === "add", disabled: busy,
|
|
134
|
+
// A published event has no organizer to answer, so adding it is accepting it: the server sends no reply.
|
|
135
|
+
onClick: () => void run("add", "Couldn't add this event to your calendar", () => respondToMessageInvite(messageUid, "accepted")), children: "Add to calendar" })), invite.canRemove && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-busy": pending === "remove" || undefined, loading: pending === "remove", disabled: busy, onClick: () => void run("remove", "Couldn't remove this meeting from your calendar", () => removeMessageInvite(messageUid)), children: "Remove from calendar" })), invite.onCalendar && invite.calendarEventUid && (_jsx("a", { href: calendarHref(invite), className: "text-sm font-medium text-primary-dark hover:underline", children: "Open in Calendar" }))] })), requesting && (_jsx("div", { className: "rounded-md bg-surface p-3", children: _jsx(RequestChangeForm, { subject: {
|
|
136
|
+
uid: invite.calendarEventUid,
|
|
137
|
+
title: invite.summary ?? "",
|
|
138
|
+
location: invite.location,
|
|
139
|
+
startDate: invite.startDate,
|
|
140
|
+
endDate: invite.endDate,
|
|
141
|
+
allDay: invite.allDay,
|
|
142
|
+
recurring: invite.recurring,
|
|
143
|
+
description: invite.description,
|
|
144
|
+
descriptionHtml: invite.descriptionHtml,
|
|
145
|
+
guestAddresses: invite.attendees.map((attendee) => attendee.address),
|
|
146
|
+
}, canChange: !!invite.canRequestChange, canInvite: !!invite.canRequestInvite, onSent: () => {
|
|
147
|
+
setRequesting(false);
|
|
148
|
+
setRequested(true);
|
|
149
|
+
}, onCancel: () => setRequesting(false) }) })), proposing && (_jsx(ProposeTimeForm, { invite: invite, busy: pending === "propose", onSubmit: (proposal) => void sendProposal(proposal), onCancel: () => setProposing(false) }))] }));
|
|
150
|
+
}
|
|
@@ -9,6 +9,9 @@ import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
|
9
9
|
import { approveReceipt, archiveMessage, attachmentContentUrl, cancelScheduledSend, declineReceipt, getMailbox, getMessage, getMessageRawContent, moveMessage, recallMessage, setMessageLabels, } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
10
10
|
import { buildForwardQuote, buildReplyQuote, buildReplyRecipients, buildReplyThreading, forwardSubject, replySubject, } from "@rapidmx/react-shared/mail/compose/composeQuoting.js";
|
|
11
11
|
import MoveToFolderDialog from "./MoveToFolderDialog.js";
|
|
12
|
+
import InviteCard from "./InviteCard.js";
|
|
13
|
+
import { isCalendarAttachment } from "./invite/inviteFormat.js";
|
|
14
|
+
import { useMessageInvite } from "./invite/inviteStore.js";
|
|
12
15
|
import { getUnlockedKeys, subscribeKeySession } from "@rapidmx/react-shared/crypto/keySession.js";
|
|
13
16
|
import { extractAddresses } from "@rapidmx/react-shared/crypto/mime.js";
|
|
14
17
|
import { SignerKeyConflictError, signingKeyFingerprints, trustSigner } from "@rapidmx/react-shared/crypto/keyvaultApi.js";
|
|
@@ -744,6 +747,14 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
744
747
|
const forwarded = message.flags.forwarded === true;
|
|
745
748
|
const cardUnread = threadHeader?.unread ?? false;
|
|
746
749
|
const showFooter = footer ?? !inThread;
|
|
750
|
+
// Only a message the server can read and that carries a calendar file is worth asking about an invitation. An encrypted message is out: the
|
|
751
|
+
// server holds only its ciphertext (the calendar file is inside it), so there is nothing for it to find. Drafts and Outbox hold messages
|
|
752
|
+
// that are being written or sent, not received invitations.
|
|
753
|
+
const mayHoldInvite = !message.encrypted && !inOutbox && message.folderUid !== draftsFolderUid && attachments.some(isCalendarAttachment);
|
|
754
|
+
// The calendar file of a message whose invitation card is drawn is the card, not a chip in the attachment list (a nameless "attachment" to click):
|
|
755
|
+
// it is listed again if the card could not be drawn (no invitation the server can read, or the lookup failed), so the file is never out of reach.
|
|
756
|
+
const { invite: shownInvite } = useMessageInvite(message.uid, mayHoldInvite);
|
|
757
|
+
const listedAttachments = shownInvite ? attachments.filter((attachment) => !isCalendarAttachment(attachment)) : attachments;
|
|
747
758
|
const senderRecipient = { displayName: senderName, address: senderAddress };
|
|
748
759
|
const sendingStatus = (_jsxs(_Fragment, { children: [sendInProgress && (_jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt", children: "Sending\u2026" })), isSentItems &&
|
|
749
760
|
(message.recallRequestedAt ? (_jsx("span", { className: "text-xs font-medium text-text-muted shrink-0 py-1 px-2.5 rounded-pill bg-surface-alt", children: "Recall requested" })) : (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", onClick: () => setConfirming(true), children: "Recall this message" }))), inOutbox && !sendInProgress && message.scheduledSendTime && (_jsxs("div", { className: "flex items-center gap-2 shrink-0", children: [_jsxs("span", { className: "text-xs font-medium text-text-muted py-1 px-2.5 rounded-pill bg-surface-alt", children: ["Scheduled for ", new Date(message.scheduledSendTime).toLocaleString()] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: canceling, disabled: canceling, onClick: handleCancelScheduledSend, children: "Cancel" })] })), inOutbox && !sendInProgress && !message.scheduledSendTime && draftsFolderUid && (_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto shrink-0", loading: canceling, disabled: canceling, onClick: handleCancelScheduledSend, children: "Move to Drafts" }))] }));
|
|
@@ -768,8 +779,8 @@ function MessageDetailContent({ message: messageProp, attachments, backHref, isS
|
|
|
768
779
|
.filter((l) => !!l)
|
|
769
780
|
.map((l) => (_jsxs("span", { className: "inline-flex items-center gap-1.5 text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt", children: [_jsx("span", { className: "w-2 h-2 rounded-full shrink-0", style: { backgroundColor: l.color ?? "#6366f1" } }), l.name] }, l.uid))) })), ["delivery", "read"]
|
|
770
781
|
.filter((type) => (type === "delivery" ? message.deliveryReceiptPending : message.readReceiptPending))
|
|
771
|
-
.map((type) => (_jsxs("div", { className: "flex flex-wrap items-center gap-2 py-2 px-3 rounded-sm bg-surface-alt text-sm", children: [_jsxs("span", { children: [senderLabel, " requested a ", type, " receipt for this message."] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: receiptBusy === type, disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "approve"), children: "Send receipt" }), _jsx(Button, { type: "button", variant: "text", disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "decline"), children: "Decline" })] }, type))), receiptError && _jsx(Alert, { children: receiptError }), security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: "These attachments come from a message whose signature couldn\u2019t be verified. Open them only if you trust the sender." })), innerAttachments
|
|
782
|
+
.map((type) => (_jsxs("div", { className: "flex flex-wrap items-center gap-2 py-2 px-3 rounded-sm bg-surface-alt text-sm", children: [_jsxs("span", { children: [senderLabel, " requested a ", type, " receipt for this message."] }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", loading: receiptBusy === type, disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "approve"), children: "Send receipt" }), _jsx(Button, { type: "button", variant: "text", disabled: receiptBusy !== null, onClick: () => handleReceipt(type, "decline"), children: "Decline" })] }, type))), receiptError && _jsx(Alert, { children: receiptError }), mayHoldInvite && _jsx(InviteCard, { messageUid: message.uid, headingLevel: inThread ? 3 : 2 }), security?.state === "signature_failed" && innerAttachments && innerAttachments.length > 0 && (_jsx("p", { role: "status", className: "py-2 px-3 rounded-sm text-sm bg-warning/15 text-text", children: "These attachments come from a message whose signature couldn\u2019t be verified. Open them only if you trust the sender." })), innerAttachments
|
|
772
783
|
? innerAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: innerAttachments.map((attachment, index) => (_jsx("li", { children: _jsx("button", { type: "button", onClick: () => downloadMimeAttachment(attachment), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: attachment.filename ?? `Unnamed ${attachment.contentType} attachment` }) }, `${index}:${attachment.filename ?? ""}`))) }))
|
|
773
|
-
:
|
|
784
|
+
: listedAttachments.length > 0 && (_jsx("ul", { className: "flex flex-wrap gap-2", children: listedAttachments.map((attachment) => (_jsx("li", { children: _jsxs("a", { href: attachmentContentUrl(attachment.uid), className: "text-xs font-medium py-1 px-2.5 rounded-pill bg-surface-alt text-text-muted hover:text-primary-dark", children: [attachment.filename, " (", formatBytes(attachment.sizeBytes), ")"] }) }, attachment.uid))) })), _jsx("div", { ref: bodyRegionRef, tabIndex: -1, className: "outline-none", children: message.encrypted && security === null ? (_jsx(BodySkeleton, {})) : security?.decryptError !== undefined ? (_jsx(EncryptedBody, { locked: lockedRef.current, reason: security.decryptError, unlocking: unlocking, onUnlock: () => void handleUnlockToView() })) : (_jsx(MessageBody, { messageUid: message.uid, messageVersion: message.version, title: bodyTitle, content: bodyContent, attachments: attachments, inlineParts: innerAttachments, original: viewOriginal, onAdaptable: setAdaptable })) })] }), showFooter && (_jsxs("div", { className: "flex flex-wrap gap-2 px-4 py-3 border-t border-border print:hidden", children: [_jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("reply"), children: "Reply" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Reply all to this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("replyAll"), children: "Reply All" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", "aria-label": "Forward this message", disabled: preparingCompose, onClick: () => void handleReplyOrForward("forward"), children: "Forward" })] }))] }));
|
|
774
785
|
return (_jsxs("div", { className: inThread ? "min-w-0" : "flex-1 min-w-0 min-h-0 flex flex-col h-full", children: [inThread ? (card) : (_jsxs(_Fragment, { children: [backHref && (_jsx("a", { href: backHref, className: "text-sm text-primary-dark hover:underline block px-4 pt-3", children: "\u2190 Back to messages" })), _jsx(SubjectCard, { subject: shownSubject }), _jsx("div", { className: "flex-1 min-h-0 overflow-y-auto p-2 sm:p-3 flex flex-col gap-3", children: card })] })), _jsx(MoveToFolderDialog, { open: movePrompt, onClose: () => setMovePrompt(false), mailboxUid: message.mailboxUid, folders: folders ?? [], currentFolderUid: message.folderUid, onMove: handleMove, onFolderCreated: onFolderCreated }), _jsxs(Modal, { open: confirming, onClose: () => setConfirming(false), title: "Recall this message?", children: [_jsx("p", { className: "text-sm text-text-muted mb-4", children: "This asks every original recipient's mail system to delete their copy, but only if it's still unread there \u2014 there's no way to guarantee it, and no confirmation once it either succeeds or fails. Recipients who already read the message will keep it." }), error && _jsx(Alert, { children: error }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", loading: recalling, disabled: recalling, onClick: handleRecall, className: "!w-auto", children: "Recall message" }), _jsx(Button, { type: "button", variant: "secondary", disabled: recalling, onClick: () => setConfirming(false), className: "!w-auto", children: "Cancel" })] })] }), _jsx(Modal, { open: trustConfirmOpen && !!trustableCertificate, onClose: () => !trusting && setTrustConfirmOpen(false), title: "Trust this signer?", children: _jsxs("div", { className: "flex flex-col gap-3 text-sm", children: [_jsxs("p", { children: ["Mail from ", _jsx("span", { className: "font-medium", children: senderAddress }), " signed with this certificate will show as verified."] }), _jsxs("dl", { className: "flex flex-col gap-1", children: [_jsx("dt", { className: "text-xs text-text-muted", children: "Certificate issued to" }), _jsx("dd", { children: security?.signerEmails?.length ? security.signerEmails.join(", ") : "No email address" }), _jsx("dt", { className: "text-xs text-text-muted", children: "Fingerprint" }), _jsx("dd", { className: "font-mono text-xs break-all", children: security?.signerFingerprint ? formatFingerprint(security.signerFingerprint) : "Unknown" })] }), _jsx("p", { className: "text-text-muted", children: "Anyone can create a certificate naming any address. Before trusting it, confirm this fingerprint with the sender through another channel, such as a phone call." }), trustError && _jsx(Alert, { children: trustError }), _jsxs("div", { className: "flex gap-3", children: [_jsx(Button, { type: "button", className: "!w-auto", loading: trusting, disabled: trusting, onClick: () => handleTrustSigner(senderAddress, trustableCertificate), children: "Trust" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: trusting, onClick: () => setTrustConfirmOpen(false), children: "Cancel" })] })] }) })] }));
|
|
775
786
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import React, { HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
export interface SwipeRowProps extends HTMLAttributes<HTMLElement> {
|
|
3
|
+
/** The element the row is: a list item, or the row's own `div` inside one. */
|
|
4
|
+
as?: "li" | "div";
|
|
5
|
+
/** Swipes are only followed while this is `true`: the phone layout, and not while rows are being selected. */
|
|
6
|
+
enabled: boolean;
|
|
7
|
+
/** Right to left. Resolves whether it went through; `false` brings the row back, anything else leaves it gone (its list removes it). */
|
|
8
|
+
onArchive: () => Promise<boolean>;
|
|
9
|
+
/** Left to right. The row comes back at once, and this asks where to (a prompt for the folder). */
|
|
10
|
+
onMove: () => void;
|
|
11
|
+
children: ReactNode;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* A row of a list of mail that slides with a finger: right to left it takes the row away to Archive (a green panel with the
|
|
15
|
+
* archive icon follows the row in), left to right it comes back and asks for a folder to move it to (a blue panel with a
|
|
16
|
+
* folder). The panels are children of the row placed just outside its edges, so they travel with it and fill the gap it leaves
|
|
17
|
+
* without the row's own markup, or the list's, changing; the list has to clip them (`overflow-x-clip`).
|
|
18
|
+
*
|
|
19
|
+
* Only a swipe that starts out horizontal counts (see `useSwipe()`), so scrolling the list is untouched, and it is an addition
|
|
20
|
+
* to, never the only way of, archiving and moving: the selection bar and the reading pane do the same.
|
|
21
|
+
*/
|
|
22
|
+
export default function SwipeRow({ as: Tag, enabled, onArchive, onMove, children, className, style, ...rest }: SwipeRowProps): React.JSX.Element;
|