Files
workavia-calendar-front/src/features/Events/EventDisplayPreview.tsx
T
Camille Moussu 34363562cc Color picker rework (#232)
Co-authored-by: Camille Moussu <cmoussu@linagora.com>
2025-10-24 01:20:56 +02:00

707 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
import CircleIcon from "@mui/icons-material/Circle";
import CloseIcon from "@mui/icons-material/Close";
import EditIcon from "@mui/icons-material/Edit";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import FileDownloadOutlinedIcon from "@mui/icons-material/FileDownloadOutlined";
import LocationOnOutlinedIcon from "@mui/icons-material/LocationOnOutlined";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import NotificationsNoneIcon from "@mui/icons-material/NotificationsNone";
import PeopleAltOutlinedIcon from "@mui/icons-material/PeopleAltOutlined";
import SubjectIcon from "@mui/icons-material/Subject";
import VideocamIcon from "@mui/icons-material/Videocam";
import RepeatIcon from "@mui/icons-material/Repeat";
import LockOutlineIcon from "@mui/icons-material/LockOutline";
import {
Box,
Button,
ButtonGroup,
Chip,
DialogActions,
Divider,
IconButton,
Menu,
MenuItem,
Tooltip,
Typography,
} from "@mui/material";
import AvatarGroup from "@mui/material/AvatarGroup";
import { useEffect, useState } from "react";
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { deleteEventAsync } from "../Calendars/CalendarSlice";
import { dlEvent } from "./EventApi";
import EventDisplayModal from "./EventDisplay";
import EventUpdateModal from "./EventUpdateModal";
import ResponsiveDialog from "../../components/Dialog/ResponsiveDialog";
import { EditModeDialog } from "../../components/Event/EditModeDialog";
import EventDuplication from "../../components/Event/EventDuplicate";
import {
handleDelete,
handleRSVP,
} from "../../components/Event/eventHandlers/eventHandlers";
import { InfoRow } from "../../components/Event/InfoRow";
import { renderAttendeeBadge } from "../../components/Event/utils/eventUtils";
import { getTimezoneOffset } from "../../components/Calendar/TimezoneSelector";
import { getCalendarRange } from "../../utils/dateUtils";
import { updateTempCalendar } from "../../components/Calendar/utils/calendarUtils";
import { CalendarEvent } from "./EventsTypes";
export default function EventPreviewModal({
eventId,
calId,
tempEvent,
open,
onClose,
}: {
eventId: string;
calId: string;
tempEvent?: boolean;
open: boolean;
onClose: (event: {}, reason: "backdropClick" | "escapeKeyDown") => void;
}) {
const dispatch = useAppDispatch();
const calendars = useAppSelector((state) => state.calendars);
const timezone =
useAppSelector((state) => state.calendars.timeZone) ??
Intl.DateTimeFormat().resolvedOptions().timeZone;
const calendarList = Object.values(
useAppSelector((state) => state.calendars.list)
);
const calendar = tempEvent
? calendars.templist[calId]
: calendars.list[calId];
const event = calendar.events[eventId];
const user = useAppSelector((state) => state.user);
const isRecurring = event?.uid?.includes("/");
const isOwn = calendar.ownerEmails?.includes(user.userData.email);
const [showAllAttendees, setShowAllAttendees] = useState(false);
const [openFullDisplay, setOpenFullDisplay] = useState(false);
const [openUpdateModal, setOpenUpdateModal] = useState(false);
const [hidePreview, setHidePreview] = useState(false);
const [openEditModePopup, setOpenEditModePopup] = useState<string | null>(
null
);
const [typeOfAction, setTypeOfAction] = useState<"solo" | "all" | undefined>(
undefined
);
const [afterChoiceFunc, setAfterChoiceFunc] = useState<Function>();
const [toggleActionMenu, setToggleActionMenu] = useState<Element | null>(
null
);
const mailSpaUrl = (window as any).MAIL_SPA_URL ?? null;
useEffect(() => {
if (!event || !calendar) {
onClose({}, "backdropClick");
}
}, [event, calendar, onClose]);
if (!event || !calendar) return null;
const attendeeDisplayLimit = 3;
const attendees =
event.attendee?.filter(
(a) => a.cal_address !== event.organizer?.cal_address
) || [];
const visibleAttendees = showAllAttendees
? attendees
: attendees.slice(0, attendeeDisplayLimit);
const currentUserAttendee = event.attendee?.find(
(person) => person.cal_address === user.userData.email
);
const organizer = event.attendee?.find(
(a) => a.cal_address === event.organizer?.cal_address
);
const updateTempList = async () => {
if (calendars.templist) {
const calendarRange = getCalendarRange(new Date(event.start));
await updateTempCalendar(
calendars.templist,
event,
dispatch,
calendarRange
);
}
};
return (
<>
<ResponsiveDialog
open={open && !hidePreview}
onClose={() => onClose({}, "backdropClick")}
showHeaderActions={false}
style={{ overflow: "auto" }}
title={
event.title && (
<>
<DialogActions>
<Box>
{(window as any).DEBUG && (
<IconButton
onClick={async () => {
const icsContent = await dlEvent(event);
const blob = new Blob([icsContent], {
type: "text/calendar",
});
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `${eventId}.ics`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}}
>
<FileDownloadOutlinedIcon />
</IconButton>
)}
{user.userData.email === event.organizer?.cal_address &&
calendar.ownerEmails?.includes(user.userData.email) && (
<IconButton
size="small"
onClick={() => {
if (isRecurring) {
setAfterChoiceFunc(() => () => {
setHidePreview(true);
setOpenUpdateModal(true);
});
setOpenEditModePopup("edit");
} else {
setHidePreview(true);
setOpenUpdateModal(true);
}
}}
>
<EditIcon />
</IconButton>
)}
{((event.class !== "PRIVATE" && !isOwn) || isOwn) && (
<IconButton
size="small"
onClick={(e) => setToggleActionMenu(e.currentTarget)}
>
<MoreVertIcon />
</IconButton>
)}
<Menu
open={Boolean(toggleActionMenu)}
onClose={() => setToggleActionMenu(null)}
anchorEl={toggleActionMenu}
>
{mailSpaUrl && attendees.length > 0 && (
<MenuItem
onClick={() =>
window.open(
`${mailSpaUrl}/mailto/?uri=mailto:${event.attendee
.map((a) => a.cal_address)
.filter((mail) => mail !== user.userData.email)
.join(",")}?subject=${event.title}`
)
}
>
Email attendees
</MenuItem>
)}
<EventDuplication event={event} onClose={onClose} />
{user.userData.email === event.organizer?.cal_address && (
<MenuItem
onClick={async () => {
if (isRecurring) {
setAfterChoiceFunc(
() =>
(typeOfAction?: "solo" | "all" | undefined) =>
handleDelete(
isRecurring,
typeOfAction,
onClose,
dispatch,
calendar,
event,
calId,
eventId
)
);
setOpenEditModePopup("edit");
} else {
onClose({}, "backdropClick");
await dispatch(
deleteEventAsync({
calId,
eventId,
eventURL: event.URL,
})
);
}
updateTempList();
}}
>
Delete event
</MenuItem>
)}
</Menu>
<IconButton
size="small"
onClick={() => onClose({}, "backdropClick")}
>
<CloseIcon />
</IconButton>
</Box>
</DialogActions>
<Box display="flex" flexDirection="row">
{event.class === "PRIVATE" &&
(isOwn ? (
<Tooltip
title={
"Only you and attendees can see the details of this event."
}
placement="top"
>
<LockOutlineIcon />
</Tooltip>
) : (
<LockOutlineIcon />
))}
<Typography
variant="inherit"
fontWeight="bold"
style={{
wordBreak: "break-word",
}}
gutterBottom
>
{event.title}
</Typography>
{event.transp === "TRANSPARENT" && (
<Tooltip
title={
"Others see you as available during the time range of this event."
}
placement="top"
>
<Chip
icon={<CircleIcon color="success" fontSize="small" />}
label="Free"
/>
</Tooltip>
)}
</Box>
<Typography variant="body2" color="textSecondary" gutterBottom>
{formatDate(event.start, event.allday)}
{event.end &&
formatEnd(event.start, event.end, event.allday) &&
` ${formatEnd(event.start, event.end, event.allday)} ${!event.allday && getTimezoneOffset(timezone)}`}
</Typography>
</>
)
}
actions={
<>
{currentUserAttendee && (
<Box style={{ display: "flex", flexDirection: "row" }}>
<Typography variant="body2">Attending?</Typography>
<ButtonGroup size="small" fullWidth>
<Button
color={
currentUserAttendee?.partstat === "ACCEPTED"
? "success"
: "primary"
}
onClick={() => {
if (isRecurring) {
setAfterChoiceFunc(
() => (type: string) =>
handleRSVP(
dispatch,
calendar,
user,
event,
"ACCEPTED",
onClose,
type,
calendarList
)
);
setOpenEditModePopup("attendance");
} else {
handleRSVP(
dispatch,
calendar,
user,
event,
"ACCEPTED",
onClose
);
}
}}
>
Accept
</Button>
<Button
color={
currentUserAttendee?.partstat === "TENTATIVE"
? "warning"
: "primary"
}
onClick={() => {
if (isRecurring) {
setAfterChoiceFunc(
() => (type: string) =>
handleRSVP(
dispatch,
calendar,
user,
event,
"TENTATIVE",
onClose,
type,
calendarList
)
);
setOpenEditModePopup("attendance");
} else {
handleRSVP(
dispatch,
calendar,
user,
event,
"TENTATIVE",
onClose
);
}
}}
>
Maybe
</Button>
<Button
color={
currentUserAttendee?.partstat === "DECLINED"
? "error"
: "primary"
}
onClick={() => {
if (isRecurring) {
setAfterChoiceFunc(
() => (type: string) =>
handleRSVP(
dispatch,
calendar,
user,
event,
"DECLINED",
onClose,
type,
calendarList
)
);
setOpenEditModePopup("attendance");
} else {
handleRSVP(
dispatch,
calendar,
user,
event,
"DECLINED",
onClose
);
}
}}
>
Decline
</Button>
</ButtonGroup>
</Box>
)}
</>
}
>
{((event.class !== "PRIVATE" && !isOwn) || isOwn) && (
<>
{/* Video */}
{event.x_openpass_videoconference && (
<InfoRow
icon={<VideocamIcon style={{ fontSize: 18 }} />}
content={
<Button
variant="contained"
onClick={() =>
window.open(event.x_openpass_videoconference)
}
>
Join the video conference
</Button>
}
/>
)}
{/* Attendees */}
{attendees?.length > 0 && (
<>
<InfoRow
icon={<PeopleAltOutlinedIcon />}
content={
<Box
style={{
marginBottom: 1,
display: "flex",
flexDirection: "row",
}}
>
<Box>
<Typography>{attendees.length} guests</Typography>
<Typography>
{
attendees.filter((a) => a.partstat === "ACCEPTED")
.length
}{" "}
yes,{" "}
{
attendees.filter((a) => a.partstat === "DECLINED")
.length
}{" "}
no
</Typography>
</Box>
{!showAllAttendees && (
<AvatarGroup max={attendeeDisplayLimit}>
{organizer &&
renderAttendeeBadge(
organizer,
"org",
showAllAttendees,
true
)}
{visibleAttendees.map((a, idx) =>
renderAttendeeBadge(
a,
idx.toString(),
showAllAttendees
)
)}
</AvatarGroup>
)}
<Typography
variant="body2"
color="primary"
style={{ cursor: "pointer", marginTop: 0.5 }}
onClick={() => setShowAllAttendees(!showAllAttendees)}
>
{showAllAttendees ? "Show less" : `Show more `}
</Typography>
</Box>
}
/>
</>
)}
{showAllAttendees &&
organizer &&
renderAttendeeBadge(organizer, "org", showAllAttendees, true)}
{showAllAttendees &&
visibleAttendees.map((a, idx) =>
renderAttendeeBadge(a, idx.toString(), showAllAttendees)
)}
{/* Location */}
{event.location && (
<InfoRow
icon={<LocationOnOutlinedIcon />}
text={event.location}
/>
)}
{/* Description */}
{event.description && (
<InfoRow icon={<SubjectIcon />} text={event.description} />
)}
{/* ALARM */}
{event.alarm && (
<InfoRow
icon={<NotificationsNoneIcon />}
text={`${event.alarm.trigger} before by ${event.alarm.action}`}
/>
)}
{/* Repetition */}
{event.repetition && (
<InfoRow
icon={<RepeatIcon />}
text={makeRecurrenceString(event)}
/>
)}
</>
)}
{event.class === "PRIVATE" && !isOwn && (
<Box
sx={{
backgroundColor: "#F3F4F6",
height: 48,
borderRadius: "8px",
gap: "16px",
paddingTop: "16px",
paddingBottom: " 16px",
}}
>
<Typography
fontFamily={"Inter"}
fontWeight={500}
fontSize={"12px"}
lineHeight={"16px"}
letterSpacing={"0.5px"}
textAlign={"center"}
>
This is a private event. Details are hidden.
</Typography>
</Box>
)}
{/* Error */}
{event.error && (
<InfoRow
icon={<ErrorOutlineIcon color="error" style={{ fontSize: 18 }} />}
text={event.error}
error
/>
)}
{/* Calendar color dot */}
<Box
style={{
display: "flex",
alignItems: "center",
gap: 1,
marginBottom: 2,
}}
>
<CalendarTodayIcon style={{ fontSize: 16 }} />
<CircleIcon
style={{
color: calendar.color?.light ?? "#3788D8",
width: 12,
height: 12,
}}
/>
<Typography variant="body2">{calendar.name}</Typography>
</Box>
{currentUserAttendee && !event.repetition && (
<>
<Divider variant="fullWidth" />
</>
)}
</ResponsiveDialog>
<EditModeDialog
type={openEditModePopup}
setOpen={setOpenEditModePopup}
event={event}
eventAction={(type: "solo" | "all" | undefined) => {
setTypeOfAction(type);
afterChoiceFunc && afterChoiceFunc(type);
}}
/>
<EventDisplayModal
open={openFullDisplay}
onClose={() => setOpenFullDisplay(false)}
eventId={eventId}
calId={calId}
typeOfAction={typeOfAction}
/>
<EventUpdateModal
open={openUpdateModal}
onClose={() => {
setOpenUpdateModal(false);
setHidePreview(false);
}}
onCloseAll={() => {
setOpenUpdateModal(false);
onClose({}, "backdropClick");
}}
eventId={eventId}
calId={calId}
typeOfAction={typeOfAction}
/>
</>
);
}
function makeRecurrenceString(event: CalendarEvent): string | undefined {
if (!event.repetition) {
return;
}
const recur = [`Reccurent Event · ${event.repetition.freq}`];
const recurType: Record<string, string> = {
daily: "days",
monthly: "months",
yearly: "years",
};
if (event.repetition.byday) {
recur.push(`on ${event.repetition.byday.join(", ")}`);
}
if (event.repetition.interval && event.repetition.interval > 1) {
recur.push(
`every ${event.repetition.interval} ${recurType[event.repetition.freq]}`
);
}
if (event.repetition.occurrences) {
recur.push(`for ${event.repetition.occurrences} occurences`);
}
if (event.repetition.endDate) {
recur.push(`until ${event.repetition.endDate}`);
}
return recur.join(", ");
}
function formatDate(date: Date | string, allday?: boolean) {
if (allday) {
return new Date(date).toLocaleDateString(undefined, {
year: "numeric",
month: "long",
weekday: "long",
day: "numeric",
});
} else {
return new Date(date).toLocaleString(undefined, {
year: "numeric",
month: "long",
weekday: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
}
function formatEnd(start: Date | string, end: Date | string, allday?: boolean) {
const startDate = new Date(start);
const endDate = new Date(end);
const sameDay =
startDate.getFullYear() === endDate.getFullYear() &&
startDate.getMonth() === endDate.getMonth() &&
startDate.getDate() === endDate.getDate();
if (allday) {
return sameDay
? null
: endDate.toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
} else {
if (sameDay) {
return endDate.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
}
return endDate.toLocaleString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
}