[#397 bis] added participation management on event from thunderbird (#432)

Co-authored-by: Camille Moussu <cmoussu@linagora.com>
This commit is contained in:
Camille Moussu
2026-01-07 11:45:17 +01:00
committed by GitHub
parent e59d878e6c
commit 9ff75056c7
47 changed files with 550 additions and 366 deletions
@@ -0,0 +1,56 @@
import { Box, Typography } from "@mui/material";
import { Dispatch, SetStateAction } from "react";
import { useI18n } from "twake-i18n";
import { Calendar } from "../../Calendars/CalendarTypes";
import { userData } from "../../User/userDataTypes";
import { ContextualizedEvent } from "../EventsTypes";
import { RSVPButton } from "./RSVPButton";
interface AttendanceValidationProps {
contextualizedEvent: ContextualizedEvent;
calendarList: Calendar[];
user: userData | undefined;
setAfterChoiceFunc: Dispatch<SetStateAction<Function | undefined>>;
setOpenEditModePopup: Dispatch<SetStateAction<string | null>>;
}
export function AttendanceValidation({
contextualizedEvent,
calendarList,
user,
setAfterChoiceFunc,
setOpenEditModePopup,
}: AttendanceValidationProps) {
const { currentUserAttendee, isOwn } = contextualizedEvent;
const { t } = useI18n();
// Check if we should show RSVP buttons
const hasNoAttendeesOrOrganizer =
!(contextualizedEvent.event?.attendee?.length > 0) &&
!contextualizedEvent.event?.organizer;
if (!((currentUserAttendee || hasNoAttendeesOrOrganizer) && isOwn)) {
return null;
}
const commonButtonProps = {
contextualizedEvent,
user,
calendarList,
setAfterChoiceFunc,
setOpenEditModePopup,
};
return (
<>
<Typography sx={{ marginRight: 2 }}>
{t("eventPreview.attendingQuestion")}
</Typography>
<Box display="flex" gap="15px" alignItems="center">
<RSVPButton rsvpValue="ACCEPTED" {...commonButtonProps} />
<RSVPButton rsvpValue="TENTATIVE" {...commonButtonProps} />
<RSVPButton rsvpValue="DECLINED" {...commonButtonProps} />
</Box>
</>
);
}
@@ -0,0 +1,67 @@
import { Button } from "@mui/material";
import { Dispatch, SetStateAction } from "react";
import { useI18n } from "twake-i18n";
import { useAppDispatch } from "../../../app/hooks";
import { Calendar } from "../../Calendars/CalendarTypes";
import { PartStat } from "../../User/models/attendee";
import { userData } from "../../User/userDataTypes";
import { ContextualizedEvent } from "../EventsTypes";
import { handleRSVPClick } from "./handleRSVPClick";
const rsvpColor: Record<PartStat, "success" | "error" | "warning" | "primary"> =
{
ACCEPTED: "success",
DECLINED: "error",
TENTATIVE: "warning",
"NEEDS-ACTION": "primary",
} as const;
interface RSVPButtonProps {
rsvpValue: PartStat;
contextualizedEvent: ContextualizedEvent;
user: userData | undefined;
calendarList: Calendar[];
setAfterChoiceFunc: Dispatch<SetStateAction<Function | undefined>>;
setOpenEditModePopup: Dispatch<SetStateAction<string | null>>;
}
export function RSVPButton({
rsvpValue,
contextualizedEvent,
user,
calendarList,
setAfterChoiceFunc,
setOpenEditModePopup,
}: RSVPButtonProps) {
const { t } = useI18n();
const dispatch = useAppDispatch();
const { currentUserAttendee } = contextualizedEvent;
return (
<Button
variant={
currentUserAttendee?.partstat === rsvpValue ? "contained" : "outlined"
}
color={
currentUserAttendee?.partstat === rsvpValue
? rsvpColor[rsvpValue]
: "primary"
}
size="large"
sx={{ borderRadius: "50px" }}
onClick={() =>
handleRSVPClick(
rsvpValue,
contextualizedEvent,
user,
calendarList,
setAfterChoiceFunc,
setOpenEditModePopup,
dispatch
)
}
>
{t(`eventPreview.${rsvpValue}`)}
</Button>
);
}
@@ -0,0 +1,43 @@
import { Dispatch, SetStateAction } from "react";
import { AppDispatch } from "../../../app/store";
import { handleRSVP } from "../../../components/Event/eventHandlers/eventHandlers";
import { Calendar } from "../../Calendars/CalendarTypes";
import { PartStat } from "../../User/models/attendee";
import { userData } from "../../User/userDataTypes";
import { ContextualizedEvent } from "../EventsTypes";
export async function handleRSVPClick(
rsvp: PartStat,
contextualizedEvent: ContextualizedEvent,
user: userData | undefined,
calendarList: Calendar[],
setAfterChoiceFunc: Dispatch<SetStateAction<Function | undefined>>,
setOpenEditModePopup: Dispatch<SetStateAction<string | null>>,
dispatch: AppDispatch
) {
const { isRecurring, calendar, event } = contextualizedEvent;
if (isRecurring) {
setAfterChoiceFunc(() => async (type: string) => {
try {
await handleRSVP(
dispatch,
calendar,
user,
event,
rsvp,
type,
calendarList
);
} catch (error) {
console.error("Error handling RSVP:", error);
}
});
setOpenEditModePopup("attendance");
} else {
try {
await handleRSVP(dispatch, calendar, user, event, rsvp);
} catch (error) {
console.error("Error handling RSVP:", error);
}
}
}
+20 -151
View File
@@ -36,10 +36,7 @@ import {
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 { handleDelete } from "../../components/Event/eventHandlers/eventHandlers";
import { InfoRow } from "../../components/Event/InfoRow";
import { renderAttendeeBadge } from "../../components/Event/utils/eventUtils";
import { getCalendarRange } from "../../utils/dateUtils";
@@ -48,8 +45,12 @@ import { dlEvent } from "./EventApi";
import { CalendarEvent } from "./EventsTypes";
import EventUpdateModal from "./EventUpdateModal";
import { useI18n } from "twake-i18n";
import { userAttendee } from "../User/userDataTypes";
import { userAttendee } from "../User/models/attendee";
import { browserDefaultTimeZone } from "../../utils/timezone";
import { AttendanceValidation } from "./AttendanceValidation/AttendanceValidation";
import { Calendar } from "../Calendars/CalendarTypes";
import { userData } from "../User/userDataTypes";
import { createEventContext } from "./createEventContext";
export default function EventPreviewModal({
eventId,
@@ -77,12 +78,13 @@ export default function EventPreviewModal({
? calendars.templist[calId]
: calendars.list[calId];
const event = calendar.events[eventId];
const user = useAppSelector((state) => state.user);
const user = useAppSelector((state) => state.user.userData);
if (!user) return null;
const isRecurring = event?.uid?.includes("/");
const isOwn = calendar.ownerEmails?.includes(user.userData?.email);
const isOwn = calendar.ownerEmails?.includes(user.email);
const isOrganizer = event.organizer
? user.userData?.email === event.organizer.cal_address
? user.email === event.organizer.cal_address
: isOwn;
const [showAllAttendees, setShowAllAttendees] = useState(false);
const [openUpdateModal, setOpenUpdateModal] = useState(false);
@@ -274,8 +276,9 @@ export default function EventPreviewModal({
: attendees.slice(0, attendeeDisplayLimit);
const currentUserAttendee = event.attendee?.find(
(person) => person.cal_address === user.userData?.email
(person) => person.cal_address === user.email
);
const contextualizedEvent = createEventContext(event, calendar, user);
const organizer = event.attendee?.find(
(a) => a.cal_address === event.organizer?.cal_address
@@ -365,7 +368,7 @@ export default function EventPreviewModal({
window.open(
`${mailSpaUrl}/mailto/?uri=mailto:${event.attendee
.map((a) => a.cal_address)
.filter((mail) => mail !== user.userData?.email)
.filter((mail) => mail !== user.email)
.join(",")}?subject=${event.title}`
)
}
@@ -467,147 +470,13 @@ export default function EventPreviewModal({
</>
}
actions={
currentUserAttendee &&
isOwn && (
<>
<>
<Typography sx={{ marginRight: 2 }}>
{t("eventPreview.attendingQuestion")}
</Typography>
<Box display="flex" gap="15px" alignItems="center">
<Button
variant={
currentUserAttendee?.partstat === "ACCEPTED"
? "contained"
: "outlined"
}
color={
currentUserAttendee?.partstat === "ACCEPTED"
? "success"
: "primary"
}
size="large"
sx={{ borderRadius: "50px" }}
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
);
}
}}
>
{t("eventPreview.accept")}
</Button>
<Button
variant={
currentUserAttendee?.partstat === "TENTATIVE"
? "contained"
: "outlined"
}
color={
currentUserAttendee?.partstat === "TENTATIVE"
? "warning"
: "primary"
}
size="large"
sx={{ borderRadius: "50px" }}
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
);
}
}}
>
{t("eventPreview.maybe")}
</Button>
<Button
variant={
currentUserAttendee?.partstat === "DECLINED"
? "contained"
: "outlined"
}
color={
currentUserAttendee?.partstat === "DECLINED"
? "error"
: "primary"
}
size="large"
sx={{ borderRadius: "50px" }}
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
);
}
}}
>
{t("eventPreview.decline")}
</Button>
</Box>
</>
</>
)
<AttendanceValidation
contextualizedEvent={contextualizedEvent}
calendarList={calendarList}
user={user}
setAfterChoiceFunc={setAfterChoiceFunc}
setOpenEditModePopup={setOpenEditModePopup}
/>
}
>
{((event.class !== "PRIVATE" && !isOwn) || isOwn) && (
+6 -6
View File
@@ -12,8 +12,8 @@ import React, {
import { useAppDispatch, useAppSelector } from "../../app/hooks";
import { ResponsiveDialog } from "../../components/Dialog";
import { putEventAsync } from "../Calendars/CalendarSlice";
import { Calendars } from "../Calendars/CalendarTypes";
import { userAttendee } from "../User/userDataTypes";
import { Calendar } from "../Calendars/CalendarTypes";
import { userAttendee } from "../User/models/attendee";
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
import { createSelector } from "@reduxjs/toolkit";
import { TIMEZONES } from "../../utils/timezone-data";
@@ -75,11 +75,11 @@ function EventPopover({
if (id.split("/")[0] === userId) {
return calendars.list?.[id];
}
return {} as Calendars;
return {} as Calendar;
})
.filter((calendar) => calendar.id)
);
const userPersonalCalendars: Calendars[] = useAppSelector(
const userPersonalCalendars: Calendar[] = useAppSelector(
selectPersonalCalendars
);
@@ -722,10 +722,10 @@ function EventPopover({
const newEventUID = crypto.randomUUID();
// Resolve target calendar safely
const targetCalendar: Calendars | undefined =
const targetCalendar: Calendar | undefined =
calList[calendarid] ||
userPersonalCalendars[0] ||
(Object.values(calList)[0] as Calendars | undefined);
(Object.values(calList)[0] as Calendar | undefined);
if (!targetCalendar || !targetCalendar.id) {
console.error("No target calendar available to save event");
return;
+5 -5
View File
@@ -12,8 +12,8 @@ import {
updateEventLocal,
clearFetchCache,
} from "../Calendars/CalendarSlice";
import { Calendars } from "../Calendars/CalendarTypes";
import { userAttendee } from "../User/userDataTypes";
import { Calendar } from "../Calendars/CalendarTypes";
import { userAttendee } from "../User/models/attendee";
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
import { TIMEZONES } from "../../utils/timezone-data";
import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils";
@@ -95,10 +95,10 @@ function EventUpdateModal({
const calendarsList = useAppSelector((state) => state.calendars.list);
const userPersonalCalendars: Calendars[] = useMemo(() => {
const allCalendars = Object.values(calendarsList) as Calendars[];
const userPersonalCalendars: Calendar[] = useMemo(() => {
const allCalendars = Object.values(calendarsList) as Calendar[];
return allCalendars.filter(
(c: Calendars) => c.id?.split("/")[0] === user.userData?.openpaasId
(c: Calendar) => c.id?.split("/")[0] === user.userData?.openpaasId
);
}, [calendarsList, user.userData?.openpaasId]);
+12 -1
View File
@@ -1,4 +1,6 @@
import { userAttendee, userOrganiser } from "../User/userDataTypes";
import { Calendar } from "../Calendars/CalendarTypes";
import { userAttendee } from "../User/models/attendee";
import { userOrganiser } from "../User/userDataTypes";
export interface CalendarEvent {
URL: string;
@@ -39,3 +41,12 @@ export interface AlarmObject {
trigger: string;
action: string;
}
export interface ContextualizedEvent {
event: CalendarEvent;
calendar: Calendar;
currentUserAttendee: userAttendee | undefined;
isOwn: boolean;
isRecurring: boolean;
isOrganizer: boolean;
}
+27
View File
@@ -0,0 +1,27 @@
import { Calendar } from "../Calendars/CalendarTypes";
import { userData } from "../User/userDataTypes";
import { CalendarEvent, ContextualizedEvent } from "./EventsTypes";
export function createEventContext(
event: CalendarEvent,
calendar: Calendar,
user: userData
): ContextualizedEvent {
const isOwn = calendar.ownerEmails?.includes(user.email) ?? false;
const isRecurring = event?.uid?.includes("/") ?? false;
const isOrganizer = event.organizer
? user?.email === event.organizer.cal_address
: isOwn;
const currentUserAttendee = event.attendee?.find(
(person) => person.cal_address === user.email
);
return {
event,
calendar,
currentUserAttendee,
isOwn,
isRecurring,
isOrganizer,
};
}
+12 -9
View File
@@ -1,4 +1,4 @@
import { userAttendee } from "../User/userDataTypes";
import { userAttendee } from "../User/models/attendee";
import { AlarmObject, CalendarEvent, RepetitionObject } from "./EventsTypes";
import ICAL from "ical.js";
import { TIMEZONES } from "../../utils/timezone-data";
@@ -7,6 +7,7 @@ import {
convertFormDateTimeToISO,
detectDateTimeFormat,
} from "../../components/Event/utils/dateTimeHelpers";
import { createAttendee } from "../User/models/attendee.mapper";
type RawEntry = [string, Record<string, string>, string, any];
function resolveTimezoneId(tzid?: string): string | undefined {
@@ -119,14 +120,16 @@ export function parseCalendarEvent(
};
break;
case "attendee":
(event.attendee as userAttendee[]).push({
cn: params?.cn ?? "",
cal_address: value.replace(/^mailto:/i, ""),
partstat: params?.partstat ?? "",
rsvp: params?.rsvp ?? "",
role: params?.role ?? "",
cutype: params?.cutype ?? "",
});
(event.attendee as userAttendee[]).push(
createAttendee({
cn: params?.cn,
cal_address: value.replace(/^mailto:/i, ""),
partstat: params?.partstat as userAttendee["partstat"],
rsvp: params?.rsvp as userAttendee["rsvp"],
role: params?.role as userAttendee["role"],
cutype: params?.cutype as userAttendee["cutype"],
})
);
break;
case "dtstamp":
event.stamp = value;
+1 -1
View File
@@ -1,7 +1,7 @@
import { TIMEZONES } from "../../utils/timezone-data";
import { resolveTimezone } from "../../components/Calendar/TimezoneSelector";
import { CalendarEvent, RepetitionObject } from "./EventsTypes";
import { userAttendee } from "../User/userDataTypes";
import { userAttendee } from "../User/models/attendee";
import { formatDateTimeInTimezone } from "../../components/Event/utils/dateTimeFormatters";
import { addVideoConferenceToDescription } from "../../utils/videoConferenceUtils";
import { browserDefaultTimeZone } from "../../utils/timezone";