@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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { ReactNode } from "react";
|
|
6
|
+
import { DescriptionNode, htmlToPlainText, parseEventDescription } from "@rapidmx/react-shared/calendar/eventDescription.js";
|
|
7
|
+
|
|
8
|
+
function renderNodes(nodes: DescriptionNode[]): ReactNode[] {
|
|
9
|
+
return nodes.map((node, index) => {
|
|
10
|
+
if ("text" in node) {
|
|
11
|
+
return node.text;
|
|
12
|
+
}
|
|
13
|
+
switch (node.tag) {
|
|
14
|
+
case "br":
|
|
15
|
+
return <br key={index} />;
|
|
16
|
+
case "a":
|
|
17
|
+
return (
|
|
18
|
+
<a key={index} href={node.href} target="_blank" rel="noopener noreferrer" className="text-primary-dark underline break-words">
|
|
19
|
+
{renderNodes(node.children)}
|
|
20
|
+
</a>
|
|
21
|
+
);
|
|
22
|
+
case "ul":
|
|
23
|
+
return (
|
|
24
|
+
<ul key={index} className="list-disc pl-5 my-0.5">
|
|
25
|
+
{renderNodes(node.children)}
|
|
26
|
+
</ul>
|
|
27
|
+
);
|
|
28
|
+
case "ol":
|
|
29
|
+
return (
|
|
30
|
+
<ol key={index} className="list-decimal pl-5 my-0.5">
|
|
31
|
+
{renderNodes(node.children)}
|
|
32
|
+
</ol>
|
|
33
|
+
);
|
|
34
|
+
case "p":
|
|
35
|
+
return (
|
|
36
|
+
<p key={index} className="mb-1 last:mb-0 min-h-[1lh]">
|
|
37
|
+
{renderNodes(node.children)}
|
|
38
|
+
</p>
|
|
39
|
+
);
|
|
40
|
+
case "li":
|
|
41
|
+
return <li key={index}>{renderNodes(node.children)}</li>;
|
|
42
|
+
case "strong":
|
|
43
|
+
return <strong key={index}>{renderNodes(node.children)}</strong>;
|
|
44
|
+
case "em":
|
|
45
|
+
return <em key={index}>{renderNodes(node.children)}</em>;
|
|
46
|
+
case "u":
|
|
47
|
+
return <u key={index}>{renderNodes(node.children)}</u>;
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface EventDescriptionViewProps {
|
|
53
|
+
/** The description as HTML, from the server or an invitation - untrusted, so it is read into allowed elements only (`parseEventDescription()`) and drawn as
|
|
54
|
+
* React elements, never as markup. */
|
|
55
|
+
html?: string | null;
|
|
56
|
+
/** The plain-text description, shown (line breaks kept) when there is no HTML, or when the HTML holds nothing this can draw. */
|
|
57
|
+
text?: string | null;
|
|
58
|
+
className?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* An event's description, read-only: the rich text with its bold, italics, underline, lists and links (which open in a new tab, `noopener noreferrer`),
|
|
63
|
+
* else the plain text. Nothing is drawn for an event with no description.
|
|
64
|
+
*/
|
|
65
|
+
export default function EventDescriptionView({ html, text, className }: EventDescriptionViewProps) {
|
|
66
|
+
if (htmlToPlainText(html) !== "") {
|
|
67
|
+
return <div className={["break-words", className].filter(Boolean).join(" ")}>{renderNodes(parseEventDescription(html))}</div>;
|
|
68
|
+
}
|
|
69
|
+
return text?.trim() ? <div className={["break-words whitespace-pre-wrap", className].filter(Boolean).join(" ")}>{text}</div> : null;
|
|
70
|
+
}
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
2
|
+
// Copyright (C) 2026 Jean-Philippe Steinmetz
|
|
3
|
+
// SPDX-License-Identifier: MPL-2.0
|
|
4
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
5
|
+
import React, { ReactNode, useEffect, useState } from "react";
|
|
6
|
+
import {
|
|
7
|
+
HiOutlineBars3BottomLeft,
|
|
8
|
+
HiOutlineBell,
|
|
9
|
+
HiOutlineBriefcase,
|
|
10
|
+
HiOutlineCheck,
|
|
11
|
+
HiOutlineClock,
|
|
12
|
+
HiOutlineMapPin,
|
|
13
|
+
HiOutlineUser,
|
|
14
|
+
HiOutlineUserGroup,
|
|
15
|
+
HiOutlineVideoCamera,
|
|
16
|
+
HiOutlineXMark,
|
|
17
|
+
} from "react-icons/hi2";
|
|
18
|
+
import { ApiRequestError } from "@rapidmx/react-shared/util/api.js";
|
|
19
|
+
import { AttendeeResponseInput, guestPermissionsOf, respondToEvent, visibilityOf } from "@rapidmx/react-shared/calendar/calendarApi.js";
|
|
20
|
+
import { htmlToPlainText } from "@rapidmx/react-shared/calendar/eventDescription.js";
|
|
21
|
+
import { deleteEventOccurrence, deleteEventSeries } from "@rapidmx/react-shared/calendar/calendarMutations.js";
|
|
22
|
+
import { CalendarOccurrence, describeRecurrence } from "@rapidmx/react-shared/calendar/recurrence.js";
|
|
23
|
+
import { getVideoMeeting } from "@rapidmx/react-shared/videoconf/videoMeetingsApi.js";
|
|
24
|
+
import { deviceTimeZone } from "@rapidmx/react-shared/util/timeZone.js";
|
|
25
|
+
import Alert from "@rapidmx/react-shared/components/feedback/Alert.js";
|
|
26
|
+
import Button from "@rapidmx/react-shared/components/buttons/Button.js";
|
|
27
|
+
import { joinMeetingUrl } from "../../calendar/calendarReminders.js";
|
|
28
|
+
import EventDescriptionView from "./EventDescriptionView.js";
|
|
29
|
+
import RequestChangeForm from "./RequestChangeForm.js";
|
|
30
|
+
import { BUSY_STATUS_LABEL, RESPONSE_STATUS_LABEL, VISIBILITY_LABEL, describeGuestPermissions, describeReminder, formatStoredWhen } from "./eventFormat.js";
|
|
31
|
+
|
|
32
|
+
const ROLE_LABEL = { required: "", optional: "Optional", resource: "Room/equipment" } as const;
|
|
33
|
+
|
|
34
|
+
function DetailRow({ icon, children }: { icon: ReactNode; children: ReactNode }) {
|
|
35
|
+
return (
|
|
36
|
+
<div className="flex items-start gap-3">
|
|
37
|
+
<span aria-hidden="true" className="shrink-0 text-text-muted mt-0.5">
|
|
38
|
+
{icon}
|
|
39
|
+
</span>
|
|
40
|
+
<div className="flex-1 min-w-0 break-words">{children}</div>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface EventDetailsProps {
|
|
46
|
+
occurrence: CalendarOccurrence;
|
|
47
|
+
/** Whether this mailbox is invited to the event rather than organizing it: only the organizer can change an event, so there is no
|
|
48
|
+
* Modify, and the mailbox may answer instead. */
|
|
49
|
+
isInvited: boolean;
|
|
50
|
+
/** Set only when this mailbox is an invited attendee (one of the attendees, and not the organizer): the answer it has given so far, which
|
|
51
|
+
* also switches on the Accept / Tentative / Decline block. */
|
|
52
|
+
myResponse?: AttendeeResponseInput | "needsAction";
|
|
53
|
+
/** Whether an address is one of this mailbox's own (its primary address or an alias): tells the reader's own entry from the other guests when the organizer
|
|
54
|
+
* has hidden the guest list. */
|
|
55
|
+
isOwnAddress?: (address: string) => boolean;
|
|
56
|
+
calendarName?: string;
|
|
57
|
+
calendarColor?: string;
|
|
58
|
+
onClose: () => void;
|
|
59
|
+
/** Modify: switch to the edit form. */
|
|
60
|
+
onModify: () => void;
|
|
61
|
+
onSaved: () => void;
|
|
62
|
+
onDeleted: () => void;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* What clicking an existing event shows first: everything the event says, read-only. The reader who organized it (or whose event has no
|
|
67
|
+
* other organizer) can Modify or Delete it; one who was invited can answer (Accept, Tentative, Decline) or remove it from their own
|
|
68
|
+
* calendar, but not change it - the organizer's next update would overwrite that anyway. When the organizer allows guests to ask for changes (or for
|
|
69
|
+
* guests to be added) the invited reader can "Request a change" / "Add guests": the request goes to the organizer (`RequestChangeForm`) and nothing here
|
|
70
|
+
* changes until their update arrives. When the organizer hides the guest list the reader sees only themselves and is told so.
|
|
71
|
+
*
|
|
72
|
+
* An event this reader may only see as a busy block (`occurrence.redacted`: a private or confidential event on a shared calendar) is drawn as just that -
|
|
73
|
+
* its time and that it is busy - with nothing to change, delete or answer.
|
|
74
|
+
*/
|
|
75
|
+
export default function EventDetails({
|
|
76
|
+
occurrence,
|
|
77
|
+
isInvited,
|
|
78
|
+
myResponse,
|
|
79
|
+
isOwnAddress,
|
|
80
|
+
calendarName,
|
|
81
|
+
calendarColor,
|
|
82
|
+
onClose,
|
|
83
|
+
onModify,
|
|
84
|
+
onSaved,
|
|
85
|
+
onDeleted,
|
|
86
|
+
}: EventDetailsProps) {
|
|
87
|
+
const [error, setError] = useState<string | null>(null);
|
|
88
|
+
const [busy, setBusy] = useState(false);
|
|
89
|
+
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
90
|
+
const [requesting, setRequesting] = useState(false);
|
|
91
|
+
// The organizer's own join link for the event's video meeting: `undefined` while it is fetched, `null` once known to be none.
|
|
92
|
+
const [organizerJoinUrl, setOrganizerJoinUrl] = useState<string | null | undefined>(undefined);
|
|
93
|
+
const videoMeetingUid = occurrence.videoMeetingUid;
|
|
94
|
+
|
|
95
|
+
// The link lives on the meeting, not on the event, so it is fetched once here. A failure and a meeting with no link at all are the
|
|
96
|
+
// same thing to this view - there is nothing to open either way - so both settle on `null`.
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (!videoMeetingUid) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
let cancelled = false;
|
|
102
|
+
void getVideoMeeting(videoMeetingUid)
|
|
103
|
+
.then((meeting) => meeting.organizerJoinUrl ?? null)
|
|
104
|
+
.catch(() => null)
|
|
105
|
+
.then((url) => {
|
|
106
|
+
if (!cancelled) {
|
|
107
|
+
setOrganizerJoinUrl(url);
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
return () => {
|
|
111
|
+
cancelled = true;
|
|
112
|
+
};
|
|
113
|
+
}, [videoMeetingUid]);
|
|
114
|
+
// The same scheme allow-list a reminder's own location gets: a hostile or malformed join link never reaches `window.open()`.
|
|
115
|
+
const joinUrl = joinMeetingUrl(organizerJoinUrl);
|
|
116
|
+
|
|
117
|
+
async function handleDelete(scope: "occurrence" | "series") {
|
|
118
|
+
setError(null);
|
|
119
|
+
setBusy(true);
|
|
120
|
+
try {
|
|
121
|
+
if (occurrence.isRecurringOccurrence && scope === "occurrence") {
|
|
122
|
+
await deleteEventOccurrence(occurrence);
|
|
123
|
+
} else {
|
|
124
|
+
await deleteEventSeries(occurrence);
|
|
125
|
+
}
|
|
126
|
+
onDeleted();
|
|
127
|
+
} catch (err) {
|
|
128
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not delete this event.");
|
|
129
|
+
setBusy(false);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// `@rapidmx/restapi`'s `respond()` route has no occurrence-vs-series scope of its own (see `calendarApi.ts`'s `respondToEvent` doc
|
|
134
|
+
// comment) - it always acts on `occurrence.uid` as a single event document.
|
|
135
|
+
async function handleRespond(responseStatus: AttendeeResponseInput) {
|
|
136
|
+
setError(null);
|
|
137
|
+
setBusy(true);
|
|
138
|
+
try {
|
|
139
|
+
await respondToEvent(occurrence.uid, responseStatus);
|
|
140
|
+
// A decline soft-deletes the mailbox's own copy server-side - treat it the same as a
|
|
141
|
+
// delete rather than a save so the calendar view drops it immediately.
|
|
142
|
+
if (responseStatus === "declined") {
|
|
143
|
+
onDeleted();
|
|
144
|
+
} else {
|
|
145
|
+
onSaved();
|
|
146
|
+
}
|
|
147
|
+
} catch (err) {
|
|
148
|
+
setError(err instanceof ApiRequestError ? err.message : "Could not send your response.");
|
|
149
|
+
setBusy(false);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (occurrence.redacted) {
|
|
154
|
+
return (
|
|
155
|
+
<div className="flex flex-col">
|
|
156
|
+
<div className="flex items-start gap-3 px-5 pt-4">
|
|
157
|
+
<span aria-hidden="true" className="w-3.5 h-3.5 rounded-full shrink-0 mt-2" style={{ backgroundColor: calendarColor }} />
|
|
158
|
+
<h2 className="flex-1 min-w-0 text-xl font-semibold break-words">Busy</h2>
|
|
159
|
+
<button
|
|
160
|
+
type="button"
|
|
161
|
+
aria-label="Close details"
|
|
162
|
+
onClick={onClose}
|
|
163
|
+
className="w-8 h-8 shrink-0 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text"
|
|
164
|
+
>
|
|
165
|
+
<HiOutlineXMark size={20} aria-hidden="true" />
|
|
166
|
+
</button>
|
|
167
|
+
</div>
|
|
168
|
+
<div className="flex flex-col gap-3 px-5 pt-3 pb-4 text-sm">
|
|
169
|
+
<DetailRow icon={<HiOutlineClock size={18} />}>
|
|
170
|
+
<div>{formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay)}</div>
|
|
171
|
+
{occurrence.recurrenceRule && <div className="text-text-muted">Repeats {describeRecurrence(occurrence.recurrenceRule)}</div>}
|
|
172
|
+
</DetailRow>
|
|
173
|
+
<p className="text-xs text-text-muted">This time is busy. The event’s details are private, so only its time is shown.</p>
|
|
174
|
+
</div>
|
|
175
|
+
<div className="flex px-5 pb-4">
|
|
176
|
+
<Button type="button" variant="secondary" className="!w-auto ml-auto" onClick={onClose}>
|
|
177
|
+
Close
|
|
178
|
+
</Button>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const permissions = guestPermissionsOf(occurrence);
|
|
185
|
+
// The organizer hid the guest list: this mailbox's own copy lists only itself (the server sends nothing more), and that is all that is shown.
|
|
186
|
+
const hiddenList = isInvited && !permissions.guestsCanSeeGuestList;
|
|
187
|
+
const allGuests = occurrence.attendees.filter((a) => !a.isOrganizer);
|
|
188
|
+
const guests = hiddenList ? allGuests.filter((a) => !!isOwnAddress?.(a.address)) : allGuests;
|
|
189
|
+
const hasDescription = htmlToPlainText(occurrence.descriptionHtml) !== "" || !!occurrence.description?.trim();
|
|
190
|
+
// An invited reader may ask the organizer for what the organizer allows.
|
|
191
|
+
const askable = isInvited && myResponse !== undefined;
|
|
192
|
+
const canChange = askable && permissions.guestsCanModify;
|
|
193
|
+
const canInvite = askable && permissions.guestsCanInviteOthers;
|
|
194
|
+
const organizerName = occurrence.organizer.displayName;
|
|
195
|
+
const eventZone = occurrence.timezone;
|
|
196
|
+
const answers: { response: AttendeeResponseInput; label: string }[] = [
|
|
197
|
+
{ response: "accepted", label: "Accept" },
|
|
198
|
+
{ response: "tentative", label: "Tentative" },
|
|
199
|
+
{ response: "declined", label: "Decline" },
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
return (
|
|
203
|
+
<div className="flex flex-col">
|
|
204
|
+
<div className="flex items-start gap-3 px-5 pt-4">
|
|
205
|
+
<span aria-hidden="true" className="w-3.5 h-3.5 rounded-full shrink-0 mt-2" style={{ backgroundColor: calendarColor }} />
|
|
206
|
+
<h2 className="flex-1 min-w-0 text-xl font-semibold break-words">{occurrence.title || "(no title)"}</h2>
|
|
207
|
+
<button
|
|
208
|
+
type="button"
|
|
209
|
+
aria-label="Close details"
|
|
210
|
+
onClick={onClose}
|
|
211
|
+
className="w-8 h-8 shrink-0 flex items-center justify-center rounded-full text-text-muted hover:bg-surface-alt hover:text-text"
|
|
212
|
+
>
|
|
213
|
+
<HiOutlineXMark size={20} aria-hidden="true" />
|
|
214
|
+
</button>
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
<div className="flex flex-col gap-3 px-5 pt-3 pb-4 text-sm">
|
|
218
|
+
{error && <Alert>{error}</Alert>}
|
|
219
|
+
|
|
220
|
+
<DetailRow icon={<HiOutlineClock size={18} />}>
|
|
221
|
+
<div>{formatStoredWhen(occurrence.startDate, occurrence.endDate, occurrence.allDay)}</div>
|
|
222
|
+
{occurrence.recurrenceRule && <div className="text-text-muted">Repeats {describeRecurrence(occurrence.recurrenceRule)}</div>}
|
|
223
|
+
{!occurrence.allDay && eventZone && eventZone !== deviceTimeZone() && (
|
|
224
|
+
<div className="text-text-muted">Event time zone: {eventZone.replace(/_/g, " ")}</div>
|
|
225
|
+
)}
|
|
226
|
+
</DetailRow>
|
|
227
|
+
|
|
228
|
+
{occurrence.location && (
|
|
229
|
+
<DetailRow icon={<HiOutlineMapPin size={18} />}>
|
|
230
|
+
<div>{occurrence.location}</div>
|
|
231
|
+
</DetailRow>
|
|
232
|
+
)}
|
|
233
|
+
|
|
234
|
+
{hasDescription && (
|
|
235
|
+
<DetailRow icon={<HiOutlineBars3BottomLeft size={18} />}>
|
|
236
|
+
<EventDescriptionView html={occurrence.descriptionHtml} text={occurrence.description} />
|
|
237
|
+
</DetailRow>
|
|
238
|
+
)}
|
|
239
|
+
|
|
240
|
+
{videoMeetingUid && (
|
|
241
|
+
<DetailRow icon={<HiOutlineVideoCamera size={18} />}>
|
|
242
|
+
<div className="flex items-center gap-3 flex-wrap">
|
|
243
|
+
<Button
|
|
244
|
+
type="button"
|
|
245
|
+
variant="secondary"
|
|
246
|
+
className="!w-auto"
|
|
247
|
+
disabled={!joinUrl}
|
|
248
|
+
onClick={() => window.open(joinUrl, "_blank", "noopener,noreferrer")}
|
|
249
|
+
>
|
|
250
|
+
Join video call
|
|
251
|
+
</Button>
|
|
252
|
+
{!joinUrl && (
|
|
253
|
+
<span className="text-xs text-text-muted">
|
|
254
|
+
{organizerJoinUrl === undefined ? "Loading the join link…" : "This meeting’s join link isn’t available."}
|
|
255
|
+
</span>
|
|
256
|
+
)}
|
|
257
|
+
</div>
|
|
258
|
+
</DetailRow>
|
|
259
|
+
)}
|
|
260
|
+
|
|
261
|
+
<DetailRow icon={<HiOutlineUser size={18} />}>
|
|
262
|
+
<div>{organizerName || occurrence.organizer.address}</div>
|
|
263
|
+
<div className="text-text-muted">
|
|
264
|
+
{organizerName ? `${occurrence.organizer.address} · ` : ""}Organizer
|
|
265
|
+
</div>
|
|
266
|
+
</DetailRow>
|
|
267
|
+
|
|
268
|
+
{(guests.length > 0 || hiddenList) && (
|
|
269
|
+
<DetailRow icon={<HiOutlineUserGroup size={18} />}>
|
|
270
|
+
<div className="font-medium mb-1">
|
|
271
|
+
{hiddenList ? "The organizer has hidden the guest list" : guests.length === 1 ? "1 guest" : `${guests.length} guests`}
|
|
272
|
+
</div>
|
|
273
|
+
<ul className="flex flex-col gap-1">
|
|
274
|
+
{guests.map((guest, i) => (
|
|
275
|
+
<li key={i} className="flex items-baseline justify-between gap-3">
|
|
276
|
+
<span className="min-w-0 truncate">
|
|
277
|
+
{guest.displayName || guest.address}
|
|
278
|
+
{ROLE_LABEL[guest.role] && <span className="text-text-muted"> ({ROLE_LABEL[guest.role]})</span>}
|
|
279
|
+
</span>
|
|
280
|
+
<span className="shrink-0 text-xs text-text-muted">{RESPONSE_STATUS_LABEL[guest.responseStatus]}</span>
|
|
281
|
+
</li>
|
|
282
|
+
))}
|
|
283
|
+
</ul>
|
|
284
|
+
{!isInvited && <div className="text-xs text-text-muted mt-1">{describeGuestPermissions(permissions)}</div>}
|
|
285
|
+
</DetailRow>
|
|
286
|
+
)}
|
|
287
|
+
|
|
288
|
+
<DetailRow icon={<HiOutlineBriefcase size={18} />}>
|
|
289
|
+
<div>{BUSY_STATUS_LABEL[occurrence.busyStatus]}</div>
|
|
290
|
+
<div className="text-text-muted">{VISIBILITY_LABEL[visibilityOf(occurrence)]}</div>
|
|
291
|
+
{occurrence.autoReplyEnabled && (
|
|
292
|
+
<div className="text-text-muted">Sends an automatic reply while this event is happening</div>
|
|
293
|
+
)}
|
|
294
|
+
</DetailRow>
|
|
295
|
+
|
|
296
|
+
<DetailRow icon={<HiOutlineBell size={18} />}>
|
|
297
|
+
<div>{describeReminder(occurrence.reminderMinutesBeforeStart)}</div>
|
|
298
|
+
</DetailRow>
|
|
299
|
+
|
|
300
|
+
{calendarName && (
|
|
301
|
+
<div className="flex items-center gap-3 text-text-muted">
|
|
302
|
+
<span aria-hidden="true" className="w-3 h-3 ml-[3px] rounded-full shrink-0" style={{ backgroundColor: calendarColor }} />
|
|
303
|
+
<span className="truncate">{calendarName}</span>
|
|
304
|
+
</div>
|
|
305
|
+
)}
|
|
306
|
+
|
|
307
|
+
{isInvited && (
|
|
308
|
+
<p className="text-xs text-text-muted">
|
|
309
|
+
You were invited to this event. Only the organizer can change its details
|
|
310
|
+
{canChange || canInvite ? ", but you can ask them to." : "."}
|
|
311
|
+
</p>
|
|
312
|
+
)}
|
|
313
|
+
</div>
|
|
314
|
+
|
|
315
|
+
{requesting && (
|
|
316
|
+
<div className="mx-5 mb-3 p-3 rounded-md bg-surface-alt">
|
|
317
|
+
<RequestChangeForm
|
|
318
|
+
subject={{
|
|
319
|
+
uid: occurrence.uid,
|
|
320
|
+
title: occurrence.title,
|
|
321
|
+
location: occurrence.location,
|
|
322
|
+
startDate: occurrence.startDate,
|
|
323
|
+
endDate: occurrence.endDate,
|
|
324
|
+
allDay: occurrence.allDay,
|
|
325
|
+
recurring: occurrence.isRecurringOccurrence,
|
|
326
|
+
description: occurrence.description,
|
|
327
|
+
descriptionHtml: occurrence.descriptionHtml,
|
|
328
|
+
guestAddresses: occurrence.attendees.map((a) => a.address),
|
|
329
|
+
}}
|
|
330
|
+
canChange={canChange}
|
|
331
|
+
canInvite={canInvite}
|
|
332
|
+
onSent={() => setRequesting(false)}
|
|
333
|
+
onCancel={() => setRequesting(false)}
|
|
334
|
+
/>
|
|
335
|
+
</div>
|
|
336
|
+
)}
|
|
337
|
+
|
|
338
|
+
{myResponse !== undefined && (
|
|
339
|
+
<div className="flex flex-col gap-2 mx-5 mb-3 p-3 rounded-md bg-surface-alt">
|
|
340
|
+
<div className="flex items-center justify-between gap-3 flex-wrap">
|
|
341
|
+
<span className="text-sm font-medium">Your response</span>
|
|
342
|
+
<div className="flex gap-2">
|
|
343
|
+
{answers.map(({ response, label }) => {
|
|
344
|
+
const current = myResponse === response;
|
|
345
|
+
return (
|
|
346
|
+
<Button
|
|
347
|
+
key={response}
|
|
348
|
+
type="button"
|
|
349
|
+
variant="secondary"
|
|
350
|
+
className={["!w-auto", response === "declined" ? "text-danger" : "", current ? "!bg-primary/10 !border-primary" : ""].join(" ")}
|
|
351
|
+
aria-pressed={current}
|
|
352
|
+
disabled={busy}
|
|
353
|
+
onClick={() => void handleRespond(response)}
|
|
354
|
+
>
|
|
355
|
+
{current && <HiOutlineCheck size={14} aria-hidden="true" />}
|
|
356
|
+
{label}
|
|
357
|
+
</Button>
|
|
358
|
+
);
|
|
359
|
+
})}
|
|
360
|
+
</div>
|
|
361
|
+
</div>
|
|
362
|
+
<p className="text-xs text-text-muted">Other attendees’ responses may take a few minutes to update.</p>
|
|
363
|
+
</div>
|
|
364
|
+
)}
|
|
365
|
+
|
|
366
|
+
<div className="flex flex-wrap items-center gap-3 px-5 pb-4">
|
|
367
|
+
{!isInvited && (
|
|
368
|
+
<Button type="button" className="!w-auto" disabled={busy} onClick={onModify}>
|
|
369
|
+
Modify
|
|
370
|
+
</Button>
|
|
371
|
+
)}
|
|
372
|
+
{(canChange || canInvite) && !requesting && (
|
|
373
|
+
<Button type="button" variant="secondary" className="!w-auto" disabled={busy} onClick={() => setRequesting(true)}>
|
|
374
|
+
{canChange ? "Request a change" : "Add guests"}
|
|
375
|
+
</Button>
|
|
376
|
+
)}
|
|
377
|
+
{!occurrence.isRecurringOccurrence ? (
|
|
378
|
+
<Button type="button" variant="secondary" className="!w-auto text-danger" disabled={busy} onClick={() => void handleDelete("series")}>
|
|
379
|
+
Delete
|
|
380
|
+
</Button>
|
|
381
|
+
) : !confirmingDelete ? (
|
|
382
|
+
<Button type="button" variant="secondary" className="!w-auto text-danger" disabled={busy} onClick={() => setConfirmingDelete(true)}>
|
|
383
|
+
Delete
|
|
384
|
+
</Button>
|
|
385
|
+
) : (
|
|
386
|
+
<div className="flex gap-2">
|
|
387
|
+
<Button type="button" variant="secondary" className="!w-auto text-danger" disabled={busy} onClick={() => void handleDelete("occurrence")}>
|
|
388
|
+
Delete this event
|
|
389
|
+
</Button>
|
|
390
|
+
<Button type="button" variant="secondary" className="!w-auto text-danger" disabled={busy} onClick={() => void handleDelete("series")}>
|
|
391
|
+
Delete series
|
|
392
|
+
</Button>
|
|
393
|
+
</div>
|
|
394
|
+
)}
|
|
395
|
+
<Button type="button" variant="secondary" className="!w-auto ml-auto" onClick={onClose}>
|
|
396
|
+
Close
|
|
397
|
+
</Button>
|
|
398
|
+
</div>
|
|
399
|
+
</div>
|
|
400
|
+
);
|
|
401
|
+
}
|