@rapidmx/web-client 0.14.1 → 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 -866
- 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 -459
- 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 +2 -2
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useRef, useState } from "react";
|
|
7
|
+
import { HiOutlineArchiveBox, HiOutlineFolder } from "react-icons/hi2";
|
|
8
|
+
import { useSwipe } from "../../gestures/useSwipe.js";
|
|
9
|
+
/** How long the row takes to leave, or come back - `motion-reduce` skips it. */
|
|
10
|
+
const SLIDE = "transform 150ms ease-out";
|
|
11
|
+
/**
|
|
12
|
+
* 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
|
|
13
|
+
* 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
|
|
14
|
+
* folder). The panels are children of the row placed just outside its edges, so they travel with it and fill the gap it leaves
|
|
15
|
+
* without the row's own markup, or the list's, changing; the list has to clip them (`overflow-x-clip`).
|
|
16
|
+
*
|
|
17
|
+
* Only a swipe that starts out horizontal counts (see `useSwipe()`), so scrolling the list is untouched, and it is an addition
|
|
18
|
+
* to, never the only way of, archiving and moving: the selection bar and the reading pane do the same.
|
|
19
|
+
*/
|
|
20
|
+
export default function SwipeRow({ as: Tag = "div", enabled, onArchive, onMove, children, className, style, ...rest }) {
|
|
21
|
+
const [offset, setOffset] = useState(0);
|
|
22
|
+
const [width, setWidth] = useState(0);
|
|
23
|
+
/** The row is on its way out (or waiting for the server to confirm it), so a finger can't drag it. */
|
|
24
|
+
const [leaving, setLeaving] = useState(false);
|
|
25
|
+
const [dragging, setDragging] = useState(false);
|
|
26
|
+
const element = useRef(null);
|
|
27
|
+
const mounted = useRef(true);
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
mounted.current = true;
|
|
30
|
+
return () => {
|
|
31
|
+
mounted.current = false;
|
|
32
|
+
};
|
|
33
|
+
}, []);
|
|
34
|
+
const swipe = useSwipe({
|
|
35
|
+
enabled: enabled && !leaving,
|
|
36
|
+
onDrag: (next) => {
|
|
37
|
+
setDragging(next !== 0);
|
|
38
|
+
setOffset(next);
|
|
39
|
+
},
|
|
40
|
+
onSwipe: (direction) => {
|
|
41
|
+
if (direction === "right") {
|
|
42
|
+
onMove();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
// A swipe is only ever heard by the row's own element, which is what `element` holds.
|
|
46
|
+
const rowWidth = element.current.clientWidth;
|
|
47
|
+
setWidth(rowWidth);
|
|
48
|
+
setLeaving(true);
|
|
49
|
+
setOffset(-rowWidth);
|
|
50
|
+
void onArchive().then((archived) => {
|
|
51
|
+
// Unmounted on success, once the list has dropped the row; a refusal brings it back.
|
|
52
|
+
if (!archived && mounted.current) {
|
|
53
|
+
setLeaving(false);
|
|
54
|
+
setOffset(0);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
if (!enabled) {
|
|
60
|
+
return (_jsx(Tag, { className: className, style: style, ...rest, children: children }));
|
|
61
|
+
}
|
|
62
|
+
const active = offset !== 0;
|
|
63
|
+
return (_jsxs(Tag, { ...rest, ...swipe.handlers, ref: (node) => {
|
|
64
|
+
element.current = node;
|
|
65
|
+
}, className: className, style: {
|
|
66
|
+
...style,
|
|
67
|
+
...swipe.style,
|
|
68
|
+
transform: active ? `translateX(${offset}px)` : undefined,
|
|
69
|
+
transition: dragging ? "none" : SLIDE,
|
|
70
|
+
}, "data-swiping": active ? "true" : undefined, children: [children, (active || leaving) && (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", "data-swipe-panel": "move", className: "absolute inset-y-0 right-full w-full bg-primary text-white flex items-center justify-end pr-6", children: _jsx(HiOutlineFolder, { size: 24 }) }), _jsx("span", { "aria-hidden": "true", "data-swipe-panel": "archive", style: width ? { width } : undefined, className: "absolute inset-y-0 left-full w-full bg-success text-white flex items-center justify-start pl-6", children: _jsx(HiOutlineArchiveBox, { size: 24 }) })] }))] }));
|
|
71
|
+
}
|
|
@@ -1141,11 +1141,16 @@ export default function ComposeWindow({ session, onClose, onToggleMinimize, user
|
|
|
1141
1141
|
return (_jsxs(_Fragment, { children: [_jsx("div", { role: "dialog", "aria-label": title, className: "w-64 shrink-0 bg-surface border border-border border-b-0 rounded-t-md shadow-modal", children: _jsxs("div", { className: "h-10 flex items-center justify-between gap-2 px-3 rounded-t-md bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Restore", onClick: onToggleMinimize, icon: HiOutlineArrowsPointingOut }), _jsx(HeaderButton, { label: "Discard draft", onClick: handleDiscard, icon: HiOutlineXMark, disabled: closing })] })] }) }), discardModal] }));
|
|
1142
1142
|
}
|
|
1143
1143
|
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: windowRef, role: "dialog", "data-shortcut-scope": "compose", "aria-labelledby": titleId, style: !isMobile && manualSize ? { width: manualSize.width, height: manualSize.height } : undefined, className: [
|
|
1144
|
-
"
|
|
1144
|
+
"shrink-0 flex flex-col bg-surface border border-border shadow-modal overflow-hidden",
|
|
1145
|
+
// `fixed` and `relative` may not both be on the element: Tailwind emits `.relative` after `.fixed`, so it wins and the
|
|
1146
|
+
// sheet sat in the flow of the little fixed container the windows are stacked in instead of covering the screen.
|
|
1145
1147
|
isMobile
|
|
1146
1148
|
? "fixed inset-0 w-full h-full rounded-none border-0"
|
|
1147
|
-
: ["border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
|
|
1148
|
-
].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled: closing, shortcut: SHORTCUTS.compose.close })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2 shrink-0 max-h-[45%] overflow-y-auto", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(Alert, { children: sendError }), attachError && _jsx(Alert, { children: attachError }), discardError && _jsx(Alert, { children: discardError })] })), securityBlock && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: securityBlock.message }), _jsx(Button, { type: "button", onClick: () => void submit(true, blockedScheduleIso), children: securityBlock.overrideLabel })] }) })), fromOptions.length > 1 && (_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-from-${id}`, className: "text-xs text-text-muted shrink-0", children: "From" }), _jsx("select", { id: `compose-from-${id}`, className: `${FIELD_INPUT} disabled:opacity-55`,
|
|
1149
|
+
: ["relative border-b-0 rounded-t-md", manualSize ? "" : expanded ? "w-[720px] h-[85vh]" : "w-[480px] h-[520px]"].join(" "),
|
|
1150
|
+
].join(" "), children: [!isMobile && (_jsxs(_Fragment, { children: [_jsx("div", { onPointerDown: handleResizeStart("top"), "aria-hidden": "true", className: "absolute top-0 left-0 right-0 h-1.5 cursor-ns-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("left"), "aria-hidden": "true", className: "absolute top-0 left-0 bottom-0 w-1.5 cursor-ew-resize z-20" }), _jsx("div", { onPointerDown: handleResizeStart("corner"), role: "separator", "aria-label": "Resize", className: "absolute top-0 left-0 w-3 h-3 cursor-nwse-resize z-30" })] })), _jsxs("div", { className: "h-10 shrink-0 flex items-center justify-between gap-2 px-3 bg-primary-darker text-white cursor-pointer", onClick: onToggleMinimize, children: [_jsx("span", { id: titleId, className: "text-sm font-medium truncate", children: title }), _jsxs("div", { className: "flex items-center gap-0.5", onClick: (e) => e.stopPropagation(), children: [_jsx(HeaderButton, { label: "Minimize", onClick: onToggleMinimize, icon: HiOutlineMinus }), !isMobile && (_jsx(HeaderButton, { label: expanded ? "Collapse" : "Expand", onClick: toggleExpanded, icon: expanded ? HiOutlineArrowsPointingIn : HiOutlineArrowsPointingOut })), _jsx(HeaderButton, { label: "Close", onClick: () => void handleClose(), icon: HiOutlineXMark, disabled: closing, shortcut: SHORTCUTS.compose.close })] })] }), _jsxs("div", { className: "flex-1 min-h-0 flex flex-col", children: [(folderError || draftError || sendError || attachError || discardError) && (_jsxs("div", { className: "px-3 pt-2 shrink-0 max-h-[45%] overflow-y-auto", children: [folderError && _jsx(Alert, { children: folderError }), draftError && _jsx(Alert, { children: draftError }), sendError && _jsx(Alert, { children: sendError }), attachError && _jsx(Alert, { children: attachError }), discardError && _jsx(Alert, { children: discardError })] })), securityBlock && (_jsx("div", { className: "px-3 pt-2", children: _jsxs(Alert, { children: [_jsx("p", { className: "mb-2", children: securityBlock.message }), _jsx(Button, { type: "button", onClick: () => void submit(true, blockedScheduleIso), children: securityBlock.overrideLabel })] }) })), fromOptions.length > 1 && (_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-from-${id}`, className: "text-xs text-text-muted shrink-0", children: "From" }), _jsx("select", { id: `compose-from-${id}`, className: `${FIELD_INPUT} disabled:opacity-55`,
|
|
1151
|
+
// Two or more options only exist once the mailboxes have loaded, and the same answer that lets them list
|
|
1152
|
+
// (one that isn't known to be view-only) is what picks the default sender, so a sender is always chosen by now.
|
|
1153
|
+
value: mailboxUid, disabled: hasUploads, title: hasUploads ? "The sender can't be changed after adding attachments or images." : undefined, onChange: (e) => handleFromChange(e.target.value), children: fromOptions.map((mb) => (_jsxs("option", { value: mb.uid, children: [mb.displayName ? `${mb.displayName} <${mb.primarySmtpAddress}>` : mb.primarySmtpAddress, mb.ownerUserUid ? "" : " (shared)"] }, mb.uid))) })] })), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-to-${id}`, className: "text-xs text-text-muted shrink-0", children: "To" }), _jsx(RecipientInput, { id: `compose-to-${id}`, label: "To", value: to, mailboxUid: mailboxUid, onChange: setTo, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)), autoFocus: focusToOnMount }), !showCcBcc && (_jsx("button", { type: "button", onClick: () => setShowCcBcc(true), className: "text-xs text-text-muted hover:text-text shrink-0", children: "Cc Bcc" }))] }), showCcBcc && (_jsxs(_Fragment, { children: [_jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-cc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Cc" }), _jsx(RecipientInput, { id: `compose-cc-${id}`, label: "Cc", value: cc, mailboxUid: mailboxUid, onChange: setCc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] }), _jsxs("div", { className: FIELD_ROW, children: [_jsx("label", { htmlFor: `compose-bcc-${id}`, className: "text-xs text-text-muted shrink-0", children: "Bcc" }), _jsx(RecipientInput, { id: `compose-bcc-${id}`, label: "Bcc", value: bcc, mailboxUid: mailboxUid, onChange: setBcc, onBlur: (value) => checkRecipientDiscovery(parseAddresses(value)), onCommit: (value) => checkRecipientDiscovery(parseAddresses(value)) })] })] })), (() => {
|
|
1149
1154
|
// De-duplicated by the same normalized key `recipientStatuses` itself is keyed by - two
|
|
1150
1155
|
// fields (or one field twice) naming the same mailbox in different casing now resolve to
|
|
1151
1156
|
// one shared status object (see `addressCacheKey()`'s own doc comment), so without this
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { Message } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
3
|
+
export interface InviteRowChipProps {
|
|
4
|
+
message: Message;
|
|
5
|
+
/** Called with the row's message carrying the reader's new answer (`meetingResponse`), after an RSVP, so the list can show it. */
|
|
6
|
+
onResponded?: (updated: Message) => void;
|
|
7
|
+
/** The chip's horizontal margins - the row's own padding. Default `mx-4`. */
|
|
8
|
+
className?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Whether a list row draws the chip: a meeting request the server can read (an encrypted message's calendar file is inside its ciphertext). */
|
|
11
|
+
export declare function showsInviteChip(message: Pick<Message, "meetingMethod" | "encrypted">): boolean;
|
|
12
|
+
/**
|
|
13
|
+
* The line a meeting request gets in a list of mail, under its preview, as Outlook draws it: a calendar icon, when the meeting starts (in the reader's
|
|
14
|
+
* zone), under that whether it conflicts with the reader's calendar - or, once answered, the answer - and an RSVP button that opens the day view with
|
|
15
|
+
* Accept, Decline and Tentative (`InviteRsvpPopover`).
|
|
16
|
+
*
|
|
17
|
+
* The invitation is looked up (once, through the shared cache) only for a row that `showsInviteChip()` - the rest of the list is never asked about. Until
|
|
18
|
+
* it arrives, or when the lookup fails, the chip says only "Meeting request", with no button.
|
|
19
|
+
*
|
|
20
|
+
* It lives beside the row's open button, not inside it (a button can't hold a button), and keeps taps and touches to itself: pressing RSVP neither
|
|
21
|
+
* opens the row nor starts a swipe of it. That holds for the popover too, which React nests under the chip even though it is drawn on the page.
|
|
22
|
+
*/
|
|
23
|
+
export default function InviteRowChip({ message, onResponded, className }: InviteRowChipProps): React.JSX.Element | null;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useRef, useState } from "react";
|
|
7
|
+
import { HiOutlineCalendarDays } from "react-icons/hi2";
|
|
8
|
+
import { ANSWER_LABEL, formatRowStart } from "./inviteFormat.js";
|
|
9
|
+
import { useMessageInvite } from "./inviteStore.js";
|
|
10
|
+
import InviteRsvpPopover from "./InviteRsvpPopover.js";
|
|
11
|
+
/** Whether a list row draws the chip: a meeting request the server can read (an encrypted message's calendar file is inside its ciphertext). */
|
|
12
|
+
export function showsInviteChip(message) {
|
|
13
|
+
return message.meetingMethod === "REQUEST" && !message.encrypted;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* The line a meeting request gets in a list of mail, under its preview, as Outlook draws it: a calendar icon, when the meeting starts (in the reader's
|
|
17
|
+
* zone), under that whether it conflicts with the reader's calendar - or, once answered, the answer - and an RSVP button that opens the day view with
|
|
18
|
+
* Accept, Decline and Tentative (`InviteRsvpPopover`).
|
|
19
|
+
*
|
|
20
|
+
* The invitation is looked up (once, through the shared cache) only for a row that `showsInviteChip()` - the rest of the list is never asked about. Until
|
|
21
|
+
* it arrives, or when the lookup fails, the chip says only "Meeting request", with no button.
|
|
22
|
+
*
|
|
23
|
+
* It lives beside the row's open button, not inside it (a button can't hold a button), and keeps taps and touches to itself: pressing RSVP neither
|
|
24
|
+
* opens the row nor starts a swipe of it. That holds for the popover too, which React nests under the chip even though it is drawn on the page.
|
|
25
|
+
*/
|
|
26
|
+
export default function InviteRowChip({ message, onResponded, className = "mx-4" }) {
|
|
27
|
+
const eligible = showsInviteChip(message);
|
|
28
|
+
const { invite, setInvite } = useMessageInvite(message.uid, eligible);
|
|
29
|
+
const [open, setOpen] = useState(false);
|
|
30
|
+
const rsvpRef = useRef(null);
|
|
31
|
+
if (!eligible) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const answer = invite?.response ?? message.meetingResponse;
|
|
35
|
+
const conflicts = invite?.conflicts.length ?? 0;
|
|
36
|
+
const start = invite ? formatRowStart(invite) : undefined;
|
|
37
|
+
function answered(updated) {
|
|
38
|
+
setInvite(updated);
|
|
39
|
+
onResponded?.({ ...message, meetingResponse: updated.response ?? message.meetingResponse });
|
|
40
|
+
}
|
|
41
|
+
const stop = (event) => event.stopPropagation();
|
|
42
|
+
return (_jsxs("div", { "data-invite-chip": true, onClick: stop, onTouchStart: stop, onTouchMove: stop, onTouchEnd: stop, onTouchCancel: stop, className: [className, "mb-2 -mt-1 flex items-center gap-2 rounded-sm border border-border bg-surface px-2 py-1.5 text-xs font-normal"].join(" "), children: [_jsx(HiOutlineCalendarDays, { size: 16, "aria-hidden": "true", className: "shrink-0 text-text-muted" }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "truncate text-text", children: start ?? "Meeting request" }), invite && (_jsx("div", { className: [
|
|
43
|
+
"truncate",
|
|
44
|
+
answer === "accepted" ? "text-success" : answer === "declined" ? "text-danger" : conflicts > 0 ? "text-danger" : "text-text-muted",
|
|
45
|
+
].join(" "), children: answer ? ANSWER_LABEL[answer] : conflicts > 0 ? `Conflicts with ${conflicts} ${conflicts === 1 ? "event" : "events"}` : "No conflicts" }))] }), invite?.canRespond && !invite.isOrganizer && (_jsx("button", { ref: rsvpRef, type: "button", "aria-haspopup": "dialog", "aria-expanded": open, "aria-label": `RSVP to ${invite.summary?.trim() || "this meeting"}`, onClick: () => setOpen((wasOpen) => !wasOpen), className: "shrink-0 py-1 px-3 rounded-sm border border-border text-xs font-semibold text-text hover:bg-surface-alt", children: "RSVP" })), open && invite && (_jsx(InviteRsvpPopover, { messageUid: message.uid, invite: invite, anchorRef: rsvpRef, onDone: answered, onClose: () => setOpen(false) }))] }));
|
|
46
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import React, { RefObject } from "react";
|
|
2
|
+
import { MessageInvite } from "@rapidmx/react-shared/calendar/inviteApi.js";
|
|
3
|
+
export interface InviteRsvpPopoverProps {
|
|
4
|
+
messageUid: string;
|
|
5
|
+
invite: MessageInvite;
|
|
6
|
+
/** The RSVP button: the popover hangs from it, and focus goes back to it when the popover closes. */
|
|
7
|
+
anchorRef: RefObject<HTMLElement | null>;
|
|
8
|
+
/** Called with the invitation as it stands after an answer or a proposal. The popover has closed itself. */
|
|
9
|
+
onDone: (invite: MessageInvite) => void;
|
|
10
|
+
onClose: () => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* What the RSVP button opens: an anchored popover on a desktop, a dialog on a phone. A day view of the reader's schedule around the meeting, with
|
|
14
|
+
* the invitation highlighted and its conflicts marked, and Accept, Decline, a "?" for Tentative and a "..." menu (Propose new time, Open in calendar).
|
|
15
|
+
* An answer is sent to the server, which puts the meeting on the calendar and replies to the organizer; the popover then closes and hands the
|
|
16
|
+
* updated invitation to its caller. A failure raises a pop-up and leaves the popover open.
|
|
17
|
+
*
|
|
18
|
+
* Focus moves to the first button when it opens, Escape closes it (a menu open inside it first), and focus returns to the RSVP button.
|
|
19
|
+
*/
|
|
20
|
+
export default function InviteRsvpPopover({ messageUid, invite, anchorRef, onDone, onClose }: InviteRsvpPopoverProps): React.JSX.Element;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
3
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
4
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
5
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
6
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
7
|
+
import { HiOutlineCheck, HiOutlineEllipsisHorizontal } from "react-icons/hi2";
|
|
8
|
+
import { proposeNewTime, respondToMessageInvite } from "@rapidmx/react-shared/calendar/inviteApi.js";
|
|
9
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
10
|
+
import Modal from "@rapidmx/react-shared/components/overlays/Modal.js";
|
|
11
|
+
import PopoverPortal from "@rapidmx/react-shared/components/overlays/PopoverPortal.js";
|
|
12
|
+
import useIsMobile from "@rapidmx/react-shared/util/useIsMobile.js";
|
|
13
|
+
import { notifyApiError } from "../../../notifications/apiErrors.js";
|
|
14
|
+
import { calendarHref, conflictSummary, formatInviteWhen } from "./inviteFormat.js";
|
|
15
|
+
import InviteTimeline from "./InviteTimeline.js";
|
|
16
|
+
import ProposeTimeForm from "./ProposeTimeForm.js";
|
|
17
|
+
/** The popover's width, and the room it leaves the window's edges. */
|
|
18
|
+
const WIDTH = 360;
|
|
19
|
+
const WINDOW_MARGIN = 16;
|
|
20
|
+
/** What the popover is tall for its own text and footer, before the day view (px), and how tall the day view is per hour drawn. */
|
|
21
|
+
const CHROME_HEIGHT = 190;
|
|
22
|
+
const CONFLICT_LINE_HEIGHT = 22;
|
|
23
|
+
const HOURS_HEIGHT = 36;
|
|
24
|
+
const PROPOSE_HEIGHT = 340;
|
|
25
|
+
/** The hours the day view draws for an invitation, as `InviteTimeline` will (a fixed estimate is all a popover's box needs). */
|
|
26
|
+
function estimateHeight(invite, proposing) {
|
|
27
|
+
const cap = window.innerHeight - WINDOW_MARGIN;
|
|
28
|
+
if (proposing) {
|
|
29
|
+
return Math.min(PROPOSE_HEIGHT, cap);
|
|
30
|
+
}
|
|
31
|
+
const start = invite.startDate ? new Date(invite.startDate) : undefined;
|
|
32
|
+
const end = invite.endDate ? new Date(invite.endDate) : undefined;
|
|
33
|
+
const spanHours = start && end ? Math.max(0, (end.getTime() - start.getTime()) / 3600000) : 1;
|
|
34
|
+
// The day view spans the invitation with two hours either side, and never less than five.
|
|
35
|
+
const hours = Math.min(24, Math.max(5, Math.ceil(spanHours) + 4));
|
|
36
|
+
const allDay = invite.schedule.filter((entry) => entry.allDay).length;
|
|
37
|
+
return Math.min(CHROME_HEIGHT + hours * HOURS_HEIGHT + allDay * CONFLICT_LINE_HEIGHT + (invite.conflicts.length > 0 ? CONFLICT_LINE_HEIGHT : 0), cap);
|
|
38
|
+
}
|
|
39
|
+
const RESPONSES = [
|
|
40
|
+
{ response: "accepted", label: "Accept", text: "Accept", failure: "Couldn't accept this meeting" },
|
|
41
|
+
{ response: "declined", label: "Decline", text: "Decline", failure: "Couldn't decline this meeting" },
|
|
42
|
+
{ response: "tentative", label: "Tentative", text: "?", failure: "Couldn't respond tentatively to this meeting" },
|
|
43
|
+
];
|
|
44
|
+
/** What is inside the RSVP popover (or dialog): the meeting, a day view around it and the buttons that answer it. */
|
|
45
|
+
function RsvpPanel({ messageUid, invite, onDone, onClose, onProposingChange }) {
|
|
46
|
+
const titleId = useId();
|
|
47
|
+
const [pending, setPending] = useState(null);
|
|
48
|
+
const [proposing, setProposingState] = useState(false);
|
|
49
|
+
const [menuOpen, setMenuOpen] = useState(false);
|
|
50
|
+
const rootRef = useRef(null);
|
|
51
|
+
const moreRef = useRef(null);
|
|
52
|
+
const menuRef = useRef(null);
|
|
53
|
+
const busy = pending !== null;
|
|
54
|
+
const when = formatInviteWhen(invite);
|
|
55
|
+
// Focus goes to the first thing to do when the panel opens.
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
rootRef.current?.querySelector("button:not([disabled])")?.focus();
|
|
58
|
+
}, []);
|
|
59
|
+
function setProposing(next) {
|
|
60
|
+
setProposingState(next);
|
|
61
|
+
onProposingChange?.(next);
|
|
62
|
+
}
|
|
63
|
+
async function answer(response, failure) {
|
|
64
|
+
setPending(response);
|
|
65
|
+
try {
|
|
66
|
+
onDone(await respondToMessageInvite(messageUid, response));
|
|
67
|
+
}
|
|
68
|
+
catch (err) {
|
|
69
|
+
notifyApiError(err, failure);
|
|
70
|
+
setPending(null);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function propose(proposal) {
|
|
74
|
+
setPending("propose");
|
|
75
|
+
try {
|
|
76
|
+
onDone(await proposeNewTime(messageUid, proposal));
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
notifyApiError(err, "Couldn't send the proposed time");
|
|
80
|
+
setPending(null);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function handleKeyDown(event) {
|
|
84
|
+
if (event.key === "Escape") {
|
|
85
|
+
event.stopPropagation();
|
|
86
|
+
if (menuOpen) {
|
|
87
|
+
setMenuOpen(false);
|
|
88
|
+
moreRef.current?.focus();
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
onClose();
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
function handleMenuKeyDown(event) {
|
|
96
|
+
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
event.preventDefault();
|
|
100
|
+
const items = Array.from(menuRef.current.querySelectorAll('[role="menuitem"]'));
|
|
101
|
+
const at = items.indexOf(document.activeElement);
|
|
102
|
+
items[(at + (event.key === "ArrowDown" ? 1 : -1) + items.length) % items.length].focus();
|
|
103
|
+
}
|
|
104
|
+
return (_jsxs("div", { ref: rootRef, role: "group", "aria-labelledby": titleId, tabIndex: -1, onKeyDown: handleKeyDown, className: "relative flex-1 min-h-0 flex flex-col text-sm text-text outline-none", children: [_jsxs("div", { className: "flex-1 min-h-0 overflow-y-auto px-4 pt-3 pb-2 flex flex-col gap-2", children: [_jsx("h3", { id: titleId, className: "font-semibold break-words", children: invite.summary?.trim() || "(no title)" }), when && _jsxs("p", { className: "break-words", children: [when, invite.recurring && _jsx("span", { className: "text-text-muted", children: " (Repeats)" })] }), invite.conflicts.length > 0 && (_jsxs("p", { className: "text-danger break-words", children: [_jsx("span", { className: "font-medium", children: "Conflicts with:" }), " ", conflictSummary(invite.conflicts)] })), proposing ? (_jsx(ProposeTimeForm, { invite: invite, busy: pending === "propose", onSubmit: (proposal) => void propose(proposal), onCancel: () => setProposing(false) })) : (_jsx(InviteTimeline, { invite: invite }))] }), !proposing && (_jsxs("div", { className: "flex items-center gap-2 px-4 py-3 border-t border-border", "aria-busy": busy || undefined, children: [RESPONSES.map(({ response, label, text, failure }) => {
|
|
105
|
+
const current = invite.response === response;
|
|
106
|
+
return (_jsxs(Button, { type: "button", variant: response === "accepted" && !current ? "primary" : "secondary", className: ["!w-auto", response === "tentative" ? "!px-3" : "", current ? "!bg-primary/10 !border-primary disabled:!opacity-100" : ""].join(" "), "aria-label": label, title: label, "aria-pressed": current, "aria-busy": pending === response || undefined, loading: pending === response, disabled: busy || current, onClick: () => void answer(response, failure), children: [current && _jsx(HiOutlineCheck, { size: 14, "aria-hidden": "true" }), text] }, response));
|
|
107
|
+
}), _jsxs("div", { className: "ml-auto relative", children: [_jsx("button", { ref: moreRef, type: "button", "aria-label": "More actions", "aria-haspopup": "menu", "aria-expanded": menuOpen, disabled: busy, onClick: () => setMenuOpen((open) => !open), className: "inline-flex items-center justify-center p-1.5 rounded-md border border-border text-text hover:bg-surface-alt disabled:opacity-50", children: _jsx(HiOutlineEllipsisHorizontal, { size: 18, "aria-hidden": "true" }) }), menuOpen && (_jsxs("div", { ref: (node) => {
|
|
108
|
+
menuRef.current = node;
|
|
109
|
+
// A menu that has just opened takes the focus, as a menu does.
|
|
110
|
+
node?.querySelector('[role="menuitem"]')?.focus();
|
|
111
|
+
}, role: "menu", "aria-label": "More actions", onKeyDown: handleMenuKeyDown, className: "absolute bottom-full right-0 mb-1 w-48 py-1 rounded-md border border-border bg-surface shadow-modal z-10", children: [invite.canPropose && (_jsx("button", { type: "button", role: "menuitem", onClick: () => {
|
|
112
|
+
setMenuOpen(false);
|
|
113
|
+
setProposing(true);
|
|
114
|
+
}, className: "w-full px-3 py-2 text-left hover:bg-surface-alt", children: "Propose new time" })), _jsx("a", { role: "menuitem", href: calendarHref(invite), className: "block px-3 py-2 hover:bg-surface-alt", children: "Open in calendar" })] }))] })] }))] }));
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* What the RSVP button opens: an anchored popover on a desktop, a dialog on a phone. A day view of the reader's schedule around the meeting, with
|
|
118
|
+
* the invitation highlighted and its conflicts marked, and Accept, Decline, a "?" for Tentative and a "..." menu (Propose new time, Open in calendar).
|
|
119
|
+
* An answer is sent to the server, which puts the meeting on the calendar and replies to the organizer; the popover then closes and hands the
|
|
120
|
+
* updated invitation to its caller. A failure raises a pop-up and leaves the popover open.
|
|
121
|
+
*
|
|
122
|
+
* Focus moves to the first button when it opens, Escape closes it (a menu open inside it first), and focus returns to the RSVP button.
|
|
123
|
+
*/
|
|
124
|
+
export default function InviteRsvpPopover({ messageUid, invite, anchorRef, onDone, onClose }) {
|
|
125
|
+
const isMobile = useIsMobile();
|
|
126
|
+
const [proposing, setProposing] = useState(false);
|
|
127
|
+
const title = invite.summary?.trim() || "(no title)";
|
|
128
|
+
function close() {
|
|
129
|
+
onClose();
|
|
130
|
+
anchorRef.current?.focus();
|
|
131
|
+
}
|
|
132
|
+
function done(updated) {
|
|
133
|
+
onDone(updated);
|
|
134
|
+
close();
|
|
135
|
+
}
|
|
136
|
+
const panel = _jsx(RsvpPanel, { messageUid: messageUid, invite: invite, onDone: done, onClose: close, onProposingChange: setProposing });
|
|
137
|
+
if (isMobile) {
|
|
138
|
+
return (_jsx(Modal, { open: true, onClose: close, title: "Meeting invitation", children: panel }));
|
|
139
|
+
}
|
|
140
|
+
return (_jsx(PopoverPortal, { anchorRef: anchorRef, onClose: onClose, width: WIDTH, height: estimateHeight(invite, proposing), "aria-label": `RSVP: ${title}`, children: panel }));
|
|
141
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { MessageInvite } from "@rapidmx/react-shared/calendar/inviteApi.js";
|
|
3
|
+
/** A block of the day and where it sits among the blocks it overlaps. */
|
|
4
|
+
export interface PlacedBlock {
|
|
5
|
+
/** Which of the overlapping columns it is drawn in. */
|
|
6
|
+
column: number;
|
|
7
|
+
/** How many columns its cluster of overlapping blocks needs. */
|
|
8
|
+
columns: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Lays overlapping blocks side by side: blocks that overlap (directly or through a chain of others) form a cluster, and each block takes the first
|
|
12
|
+
* column of the cluster that is free where it starts. `blocks` must be sorted by start; the result is in the same order.
|
|
13
|
+
*/
|
|
14
|
+
export declare function placeBlocks(blocks: {
|
|
15
|
+
start: number;
|
|
16
|
+
end: number;
|
|
17
|
+
}[]): PlacedBlock[];
|
|
18
|
+
export interface InviteTimelineProps {
|
|
19
|
+
invite: Pick<MessageInvite, "startDate" | "endDate" | "summary" | "conflicts" | "schedule">;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A small day agenda around an invitation, as Outlook draws it in its RSVP card: hour rows for the invitation's day (from a couple of hours before it
|
|
23
|
+
* to a couple after), the invitation itself as a highlighted block, and the reader's other events as blocks - the ones that overlap it marked in red
|
|
24
|
+
* (and named as conflicts to a screen reader). Overlapping blocks stand side by side. All-day events are listed above the hours rather than drawn.
|
|
25
|
+
* Draws nothing when the invitation has no usable time.
|
|
26
|
+
*/
|
|
27
|
+
export default function InviteTimeline({ invite }: InviteTimelineProps): React.JSX.Element | null;
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { validDate } from "./inviteFormat.js";
|
|
3
|
+
/** How tall one hour is drawn, in px. */
|
|
4
|
+
const HOUR_HEIGHT = 36;
|
|
5
|
+
/** The room the hour labels take at the left, as a CSS length. */
|
|
6
|
+
const LABEL_WIDTH = "3.25rem";
|
|
7
|
+
/** The hours of context shown before the invitation and after it, and the least the day view is tall (in hours). */
|
|
8
|
+
const CONTEXT_HOURS = 2;
|
|
9
|
+
const MIN_HOURS = 5;
|
|
10
|
+
const MINUTE_MS = 60000;
|
|
11
|
+
/**
|
|
12
|
+
* Lays overlapping blocks side by side: blocks that overlap (directly or through a chain of others) form a cluster, and each block takes the first
|
|
13
|
+
* column of the cluster that is free where it starts. `blocks` must be sorted by start; the result is in the same order.
|
|
14
|
+
*/
|
|
15
|
+
export function placeBlocks(blocks) {
|
|
16
|
+
const placed = [];
|
|
17
|
+
let cluster = [];
|
|
18
|
+
let columnEnds = [];
|
|
19
|
+
let clusterEnd = -Infinity;
|
|
20
|
+
const close = () => {
|
|
21
|
+
for (const member of cluster) {
|
|
22
|
+
placed[member.index] = { column: member.column, columns: columnEnds.length };
|
|
23
|
+
}
|
|
24
|
+
cluster = [];
|
|
25
|
+
columnEnds = [];
|
|
26
|
+
};
|
|
27
|
+
blocks.forEach((block, index) => {
|
|
28
|
+
if (block.start >= clusterEnd) {
|
|
29
|
+
close();
|
|
30
|
+
}
|
|
31
|
+
let column = columnEnds.findIndex((end) => end <= block.start);
|
|
32
|
+
if (column === -1) {
|
|
33
|
+
column = columnEnds.length;
|
|
34
|
+
}
|
|
35
|
+
columnEnds[column] = block.end;
|
|
36
|
+
cluster.push({ index, column });
|
|
37
|
+
clusterEnd = Math.max(clusterEnd, block.end);
|
|
38
|
+
});
|
|
39
|
+
close();
|
|
40
|
+
return placed;
|
|
41
|
+
}
|
|
42
|
+
/** The hours the day view spans: the invitation's own, with `CONTEXT_HOURS` either side (at least `MIN_HOURS`), kept inside the invitation's day. */
|
|
43
|
+
function hourWindow(start, end) {
|
|
44
|
+
const sameDay = start.toDateString() === end.toDateString();
|
|
45
|
+
const endHour = sameDay ? Math.ceil(end.getHours() + end.getMinutes() / 60) : 24;
|
|
46
|
+
let from = Math.max(0, start.getHours() - CONTEXT_HOURS);
|
|
47
|
+
let to = Math.min(24, endHour + CONTEXT_HOURS);
|
|
48
|
+
if (to - from < MIN_HOURS) {
|
|
49
|
+
to = Math.min(24, from + MIN_HOURS);
|
|
50
|
+
from = Math.max(0, to - MIN_HOURS);
|
|
51
|
+
}
|
|
52
|
+
return { from, to };
|
|
53
|
+
}
|
|
54
|
+
const BLOCK_CLASS = {
|
|
55
|
+
invite: "bg-primary/20 border-primary text-text font-semibold",
|
|
56
|
+
conflict: "bg-danger-bg border-danger text-danger",
|
|
57
|
+
busy: "bg-surface border-border text-text",
|
|
58
|
+
free: "bg-transparent border-border text-text-muted",
|
|
59
|
+
};
|
|
60
|
+
const CLOCK = new Intl.DateTimeFormat(undefined, { hour: "numeric", minute: "2-digit" });
|
|
61
|
+
const HOUR = new Intl.DateTimeFormat(undefined, { hour: "numeric" });
|
|
62
|
+
/**
|
|
63
|
+
* A small day agenda around an invitation, as Outlook draws it in its RSVP card: hour rows for the invitation's day (from a couple of hours before it
|
|
64
|
+
* to a couple after), the invitation itself as a highlighted block, and the reader's other events as blocks - the ones that overlap it marked in red
|
|
65
|
+
* (and named as conflicts to a screen reader). Overlapping blocks stand side by side. All-day events are listed above the hours rather than drawn.
|
|
66
|
+
* Draws nothing when the invitation has no usable time.
|
|
67
|
+
*/
|
|
68
|
+
export default function InviteTimeline({ invite }) {
|
|
69
|
+
const start = validDate(invite.startDate);
|
|
70
|
+
const end = validDate(invite.endDate);
|
|
71
|
+
if (!start || !end || end <= start) {
|
|
72
|
+
return null;
|
|
73
|
+
}
|
|
74
|
+
const { from, to } = hourWindow(start, end);
|
|
75
|
+
const windowStart = new Date(start.getFullYear(), start.getMonth(), start.getDate(), from).getTime();
|
|
76
|
+
const windowEnd = new Date(start.getFullYear(), start.getMonth(), start.getDate(), to).getTime();
|
|
77
|
+
const conflictUids = new Set(invite.conflicts.map((entry) => entry.uid));
|
|
78
|
+
// A conflict the schedule doesn't list is still drawn.
|
|
79
|
+
const entries = [...invite.schedule, ...invite.conflicts.filter((c) => !invite.schedule.some((s) => s.uid === c.uid))];
|
|
80
|
+
const allDay = entries.filter((entry) => entry.allDay);
|
|
81
|
+
const blocks = [{ key: "invite", title: invite.summary?.trim() || "(no title)", start: start.getTime(), end: end.getTime(), kind: "invite", tentative: false }];
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
const entryStart = validDate(entry.startDate)?.getTime();
|
|
84
|
+
const entryEnd = validDate(entry.endDate)?.getTime();
|
|
85
|
+
if (entry.allDay || entryStart === undefined || entryEnd === undefined) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
blocks.push({
|
|
89
|
+
key: entry.uid,
|
|
90
|
+
title: entry.title || "(no title)",
|
|
91
|
+
start: entryStart,
|
|
92
|
+
end: entryEnd,
|
|
93
|
+
kind: conflictUids.has(entry.uid) ? "conflict" : entry.busy ? "busy" : "free",
|
|
94
|
+
tentative: entry.tentative,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
// Clipped to the hours shown; a block entirely outside them isn't drawn.
|
|
98
|
+
const visible = blocks
|
|
99
|
+
.map((block) => ({ ...block, start: Math.max(block.start, windowStart), end: Math.min(block.end, windowEnd), fullStart: block.start, fullEnd: block.end }))
|
|
100
|
+
.filter((block) => block.end > block.start)
|
|
101
|
+
.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
102
|
+
const placed = placeBlocks(visible);
|
|
103
|
+
const hours = Array.from({ length: to - from }, (_, i) => from + i);
|
|
104
|
+
return (_jsxs("div", { "aria-label": "Your schedule around this meeting", role: "group", className: "text-xs", children: [allDay.length > 0 && (_jsx("ul", { "aria-label": "All-day events", className: "mb-1 flex flex-col gap-0.5", children: allDay.map((entry) => (_jsxs("li", { className: "px-2 py-0.5 rounded-sm border border-border bg-surface text-text-muted truncate", children: ["All day: ", entry.title || "(no title)"] }, entry.uid))) })), _jsxs("div", { className: "relative border-t border-border", style: { height: hours.length * HOUR_HEIGHT }, children: [hours.map((hour) => (_jsx("div", { "aria-hidden": "true", className: "absolute inset-x-0 border-b border-border text-text-muted", style: { top: (hour - from) * HOUR_HEIGHT, height: HOUR_HEIGHT }, children: _jsx("span", { className: "absolute left-0 top-0 pr-1 leading-none pt-0.5", style: { width: LABEL_WIDTH }, children: HOUR.format(new Date(2000, 0, 1, hour)) }) }, hour))), _jsx("ul", { "aria-label": "Events", children: visible.map((block, index) => {
|
|
105
|
+
const { column, columns } = placed[index];
|
|
106
|
+
const top = ((block.start - windowStart) / MINUTE_MS / 60) * HOUR_HEIGHT;
|
|
107
|
+
const height = Math.max(18, ((block.end - block.start) / MINUTE_MS / 60) * HOUR_HEIGHT - 1);
|
|
108
|
+
const range = `${CLOCK.format(block.fullStart)} to ${CLOCK.format(block.fullEnd)}`;
|
|
109
|
+
return (_jsxs("li", { title: `${block.title}, ${range}`, "data-kind": block.kind, className: [
|
|
110
|
+
"absolute overflow-hidden rounded-sm border px-1.5 py-0.5 leading-tight",
|
|
111
|
+
BLOCK_CLASS[block.kind],
|
|
112
|
+
block.tentative ? "border-dashed" : "",
|
|
113
|
+
].join(" "), style: {
|
|
114
|
+
top,
|
|
115
|
+
height,
|
|
116
|
+
left: `calc(${LABEL_WIDTH} + (100% - ${LABEL_WIDTH}) * ${column} / ${columns})`,
|
|
117
|
+
width: `calc((100% - ${LABEL_WIDTH}) / ${columns} - 2px)`,
|
|
118
|
+
}, children: [_jsx("span", { className: "block truncate", children: block.title }), _jsxs("span", { className: "sr-only", children: [", ", range, block.kind === "invite" ? ", this meeting" : "", block.kind === "conflict" ? ", conflicts with this meeting" : "", block.tentative ? ", tentative" : ""] })] }, block.key));
|
|
119
|
+
}) })] })] }));
|
|
120
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import type { MessageInvite, ProposedTime } from "@rapidmx/react-shared/calendar/inviteApi.js";
|
|
3
|
+
export interface ProposeTimeFormProps {
|
|
4
|
+
/** The invitation being answered: its own time (in the reader's zone) is what the fields start out as. */
|
|
5
|
+
invite: Pick<MessageInvite, "startDate" | "endDate">;
|
|
6
|
+
/** A proposal is on its way: the form is off. */
|
|
7
|
+
busy?: boolean;
|
|
8
|
+
/** Called with the proposed instants once the fields are valid. The caller reports a failure and closes the form on success. */
|
|
9
|
+
onSubmit: (proposal: ProposedTime) => void;
|
|
10
|
+
onCancel: () => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* The small form behind "Propose new time": a date and a start and end time - in the reader's own time zone, prefilled with the invitation's own
|
|
14
|
+
* time - and an optional note to the organizer. It validates what it can (all three fields, and an end after the start) before anything is sent.
|
|
15
|
+
* The end is on the start's day: a proposal spans one day, as Outlook's does from a list.
|
|
16
|
+
*/
|
|
17
|
+
export default function ProposeTimeForm({ invite, busy, onSubmit, onCancel }: ProposeTimeFormProps): React.JSX.Element;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } 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 Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
8
|
+
import { isoDay, validDate } from "./inviteFormat.js";
|
|
9
|
+
const INPUT_CLASS = "w-full text-sm border border-border rounded-sm py-1.5 px-2 bg-surface text-text";
|
|
10
|
+
/** `HH:MM` of an instant in the reader's zone, the value of a time input. */
|
|
11
|
+
function clock(date) {
|
|
12
|
+
return `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The small form behind "Propose new time": a date and a start and end time - in the reader's own time zone, prefilled with the invitation's own
|
|
16
|
+
* time - and an optional note to the organizer. It validates what it can (all three fields, and an end after the start) before anything is sent.
|
|
17
|
+
* The end is on the start's day: a proposal spans one day, as Outlook's does from a list.
|
|
18
|
+
*/
|
|
19
|
+
export default function ProposeTimeForm({ invite, busy = false, onSubmit, onCancel }) {
|
|
20
|
+
const start = validDate(invite.startDate);
|
|
21
|
+
const end = validDate(invite.endDate);
|
|
22
|
+
const [date, setDate] = useState(start ? isoDay(start) : "");
|
|
23
|
+
const [startTime, setStartTime] = useState(start ? clock(start) : "");
|
|
24
|
+
const [endTime, setEndTime] = useState(end ? clock(end) : "");
|
|
25
|
+
const [comment, setComment] = useState("");
|
|
26
|
+
const [error, setError] = useState(null);
|
|
27
|
+
const id = useId();
|
|
28
|
+
function submit(event) {
|
|
29
|
+
event.preventDefault();
|
|
30
|
+
if (!date || !startTime || !endTime) {
|
|
31
|
+
setError("Choose a date and a start and end time.");
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
const proposedStart = new Date(`${date}T${startTime}:00`);
|
|
35
|
+
const proposedEnd = new Date(`${date}T${endTime}:00`);
|
|
36
|
+
if (proposedEnd <= proposedStart) {
|
|
37
|
+
setError("The end time must be after the start time.");
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
setError(null);
|
|
41
|
+
const note = comment.trim();
|
|
42
|
+
onSubmit({ startDate: proposedStart.toISOString(), endDate: proposedEnd.toISOString(), ...(note ? { comment: note } : {}) });
|
|
43
|
+
}
|
|
44
|
+
return (_jsxs("form", { "aria-label": "Propose a new time", onSubmit: submit, noValidate: true, className: "flex flex-col gap-2 text-sm", children: [_jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsxs("div", { className: "flex-1 min-w-[9rem]", children: [_jsx("label", { htmlFor: `${id}-date`, className: "block text-xs text-text-muted mb-0.5", children: "Date" }), _jsx("input", { id: `${id}-date`, type: "date", value: date, disabled: busy, onChange: (e) => setDate(e.target.value), className: INPUT_CLASS })] }), _jsxs("div", { className: "flex-1 min-w-[6rem]", children: [_jsx("label", { htmlFor: `${id}-start`, className: "block text-xs text-text-muted mb-0.5", children: "Start" }), _jsx("input", { id: `${id}-start`, type: "time", value: startTime, disabled: busy, onChange: (e) => setStartTime(e.target.value), className: INPUT_CLASS })] }), _jsxs("div", { className: "flex-1 min-w-[6rem]", children: [_jsx("label", { htmlFor: `${id}-end`, className: "block text-xs text-text-muted mb-0.5", children: "End" }), _jsx("input", { id: `${id}-end`, type: "time", value: endTime, disabled: busy, onChange: (e) => setEndTime(e.target.value), className: INPUT_CLASS })] })] }), _jsxs("div", { children: [_jsx("label", { htmlFor: `${id}-comment`, className: "block text-xs text-text-muted mb-0.5", children: "Comment (optional)" }), _jsx("textarea", { id: `${id}-comment`, rows: 2, value: comment, disabled: busy, onChange: (e) => setComment(e.target.value), className: `${INPUT_CLASS} resize-y` })] }), error && (_jsx("p", { role: "alert", className: "text-danger", children: error })), _jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx(Button, { type: "submit", className: "!w-auto", loading: busy, disabled: busy, "aria-busy": busy || undefined, children: "Send proposal" }), _jsx(Button, { type: "button", variant: "secondary", className: "!w-auto", disabled: busy, onClick: onCancel, children: "Cancel" })] })] }));
|
|
45
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { Attachment } from "@rapidmx/react-shared/mail/mailApi.js";
|
|
2
|
+
import type { InviteResponse, MessageInvite } from "@rapidmx/react-shared/calendar/inviteApi.js";
|
|
3
|
+
/** Whether an attachment is a calendar file - what tells the pane a message may carry an invitation worth asking the server about. By type
|
|
4
|
+
* or by name, since mail clients disagree about which one they set (`text/calendar`, `application/ics`, or a bare `.ics` as octet-stream). */
|
|
5
|
+
export declare function isCalendarAttachment(attachment: Pick<Attachment, "filename" | "mimeType">): boolean;
|
|
6
|
+
/** `zone` if the browser knows it, else UTC - an all-day date is a calendar day, and needs a fixed zone to stay the same day for every reader. */
|
|
7
|
+
export declare function allDayZone(zone: string | undefined): string;
|
|
8
|
+
/** The instant an ISO string names, or `undefined` when it is missing or unparseable. */
|
|
9
|
+
export declare function validDate(iso: string | undefined): Date | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* When the meeting is, in the reader's own locale and time zone: one date and a time range for a same-day event, both ends in full for
|
|
12
|
+
* one that spans days, and dates only for an all-day event (drawn in the organizer's zone, since a calendar day is not an instant). `undefined`
|
|
13
|
+
* when the invitation names no usable start. A missing or unusable end shows the start alone.
|
|
14
|
+
*/
|
|
15
|
+
export declare function formatInviteWhen(invite: Pick<MessageInvite, "startDate" | "endDate" | "allDay" | "timezone">): string | undefined;
|
|
16
|
+
/** The meeting's start the way a list row writes it - `Thu 9/24/2026 1:00 PM` (dates only for an all-day event), in the reader's zone. */
|
|
17
|
+
export declare function formatRowStart(invite: Pick<MessageInvite, "startDate" | "allDay" | "timezone">): string | undefined;
|
|
18
|
+
/** `YYYY-MM-DD` of an instant in the reader's zone, or in `timeZone` when given (`en-CA` writes a date that way). */
|
|
19
|
+
export declare function isoDay(date: Date, timeZone?: string): string;
|
|
20
|
+
/** Where "Open in Calendar" goes: the day view of the day the meeting starts (the `date`/`view` the calendar page reads), or the calendar itself
|
|
21
|
+
* when the invitation names no usable start. An all-day event's day is the organizer's; any other is the reader's. */
|
|
22
|
+
export declare function calendarHref(invite: Pick<MessageInvite, "startDate" | "allDay" | "timezone">): string;
|
|
23
|
+
/** The reader's answer as a list row says it. */
|
|
24
|
+
export declare const ANSWER_LABEL: Record<InviteResponse, string>;
|
|
25
|
+
/** The titles of the events an invitation conflicts with, as "A, B and 2 more". */
|
|
26
|
+
export declare function conflictSummary(conflicts: {
|
|
27
|
+
title: string;
|
|
28
|
+
}[]): string;
|