1 import { useMemo, useRef } from 'react';
3 import { c } from 'ttag';
5 import { useUser } from '@proton/account/user/hooks';
6 import { useCalendarBootstrap } from '@proton/calendar/calendarBootstrap/hooks';
7 import { Badge, CalendarEventDateHeader, CalendarInviteButtons, Loader } from '@proton/components';
8 import { useLoading } from '@proton/hooks';
10 getIsCalendarDisabled,
11 getIsCalendarWritable,
13 getIsSubscribedCalendar,
15 } from '@proton/shared/lib/calendar/calendar';
16 import { ICAL_ATTENDEE_STATUS, VIEWS } from '@proton/shared/lib/calendar/constants';
17 import { naiveGetIsDecryptionError } from '@proton/shared/lib/calendar/helper';
18 import { getTimezonedFrequencyString } from '@proton/shared/lib/calendar/recurrence/getFrequencyString';
19 import type { WeekStartsOn } from '@proton/shared/lib/date-fns-utc/interface';
20 import { wait } from '@proton/shared/lib/helpers/promise';
21 import { dateLocale } from '@proton/shared/lib/i18n';
22 import type { CalendarEventSharedData, VcalVeventComponent } from '@proton/shared/lib/interfaces/calendar';
23 import type { SimpleMap } from '@proton/shared/lib/interfaces/utils';
24 import clsx from '@proton/utils/clsx';
28 CalendarViewEventTemporaryEvent,
30 } from '../../containers/calendar/interface';
31 import { getCanDeleteEvent, getCanDuplicateEvent, getCanEditEvent, getCanReplyToEvent } from '../../helpers/event';
32 import { getIsCalendarAppInDrawer } from '../../helpers/views';
33 import type { InviteActions } from '../../interfaces/Invite';
34 import { INVITE_ACTION_TYPES } from '../../interfaces/Invite';
35 import PopoverContainer from './PopoverContainer';
36 import PopoverEventContent from './PopoverEventContent';
37 import PopoverFooter from './PopoverFooter';
38 import PopoverHeader from './PopoverHeader';
39 import { getEventErrorMessage } from './error';
41 EventReloadErrorAction,
43 PopoverDuplicateButton,
46 } from './eventPopoverButtons/EventPopoverButtons';
47 import getEventInformation from './getEventInformation';
48 import useReadEvent from './useReadEvent';
50 import './EventPopover.scss';
52 const { ACCEPTED, TENTATIVE } = ICAL_ATTENDEE_STATUS;
55 formatTime: (date: Date) => string;
57 * On edit event callback
58 * @param userCanDuplicateEvent - used for busy slots to know if we should fetch them or not
61 onEdit: (userCanDuplicateEvent: boolean) => void;
62 onRefresh: () => Promise<void>;
63 onDuplicate?: () => void;
64 onChangePartstat: (inviteActions: InviteActions) => Promise<void>;
65 onDelete: (inviteActions: InviteActions) => Promise<void>;
67 onNavigateToEventFromSearch?: (
68 eventData: CalendarEventSharedData,
69 eventComponent: VcalVeventComponent,
70 occurrence?: { localStart: Date; occurrenceNumber: number }
74 event: CalendarViewEvent | CalendarViewEventTemporaryEvent;
77 weekStartsOn: WeekStartsOn;
78 isSmallViewport: boolean;
79 displayNameEmailMap: SimpleMap<DisplayNameEmail>;
82 const EventPopover = ({
90 onNavigateToEventFromSearch,
94 event: { start, end, isAllDay, isAllPartDay },
101 const isDrawerApp = getIsCalendarAppInDrawer(view);
102 const popoverEventContentRef = useRef<HTMLDivElement>(null);
103 const [{ hasPaidMail }] = useUser();
105 const [loadingDelete, withLoadingDelete] = useLoading();
106 const [loadingRefresh, withLoadingRefresh] = useLoading();
108 const targetEventData = targetEvent?.data || {};
109 const { eventReadResult, eventData, calendarData, eventRecurrence } = targetEventData;
110 const [calendarBootstrap] = useCalendarBootstrap(calendarData.ID);
111 const [{ veventComponent }] = eventReadResult?.result || [{}];
112 const isCalendarDisabled = getIsCalendarDisabled(calendarData);
113 const isSubscribedCalendar = getIsSubscribedCalendar(calendarData);
114 const isOwnedCalendar = getIsOwnedCalendar(calendarData);
115 const isUnknownCalendar = getIsUnknownCalendar(calendarData);
116 const isCalendarWritable = getIsCalendarWritable(calendarData);
118 const isSearchView = view === VIEWS.SEARCH;
119 const model = useReadEvent(targetEventData, tzid, calendarBootstrap?.CalendarSettings);
130 } = getEventInformation(targetEvent, model, hasPaidMail);
131 const canDuplicateEvent =
134 getCanDuplicateEvent({
136 isSubscribedCalendar,
138 isOrganizer: model.isOrganizer,
142 const handleDelete = () => {
143 const sendCancellationNotice =
144 !eventReadError && !isCalendarDisabled && !isCancelled && [ACCEPTED, TENTATIVE].includes(userPartstat);
146 if (model.isAttendee) {
147 return withLoadingDelete(
149 type: isSelfAddressActive
150 ? INVITE_ACTION_TYPES.DECLINE_INVITATION
151 : INVITE_ACTION_TYPES.DECLINE_DISABLED,
152 isProtonProtonInvite: model.isProtonProtonInvite,
153 sendCancellationNotice,
154 selfAddress: model.selfAddress,
155 selfAttendeeIndex: model.selfAttendeeIndex,
156 partstat: ICAL_ATTENDEE_STATUS.DECLINED,
161 return withLoadingDelete(
163 type: isSelfAddressActive ? INVITE_ACTION_TYPES.CANCEL_INVITATION : INVITE_ACTION_TYPES.CANCEL_DISABLED,
164 isProtonProtonInvite: model.isProtonProtonInvite,
165 selfAddress: model.selfAddress,
166 selfAttendeeIndex: model.selfAttendeeIndex,
171 const handleChangePartstat = (partstat: ICAL_ATTENDEE_STATUS) => {
172 return onChangePartstat({
173 isProtonProtonInvite: model.isProtonProtonInvite,
174 type: INVITE_ACTION_TYPES.CHANGE_PARTSTAT,
176 selfAddress: model.selfAddress,
177 selfAttendeeIndex: model.selfAttendeeIndex,
181 const dateHeader = useMemo(
183 <CalendarEventDateHeader
186 isAllDay={isAllDay && !isAllPartDay}
187 formatTime={formatTime}
189 hasModifiedAllDayEndDate
190 className="text-lg m-0"
193 [start, end, isAllDay, isAllPartDay, formatTime]
196 const showEditButton = !isSearchView && getCanEditEvent({ isUnknownCalendar, isCalendarDisabled });
197 const showDeleteButton = !isSearchView && getCanDeleteEvent({ isOwnedCalendar, isCalendarWritable, isInvitation });
198 const showDuplicateButton = !!canDuplicateEvent;
199 const showViewEventButton = isSearchView || isDrawerApp;
201 const mergedStyle = isSmallViewport ? undefined : style;
202 const frequencyString = useMemo(() => {
203 if (!veventComponent) {
206 return getTimezonedFrequencyString(veventComponent.rrule, veventComponent.dtstart, {
211 }, [veventComponent, tzid]);
213 const commonContainerProps = {
218 const commonHeaderProps = {
220 className: 'shrink-0',
223 if (eventReadError) {
224 const showReload = !isSearchView && !naiveGetIsDecryptionError(eventReadError);
226 <PopoverContainer {...commonContainerProps} className="eventpopover flex flex-column flex-nowrap">
228 {...commonHeaderProps}
230 <EventReloadErrorAction
231 showDeleteButton={showDeleteButton}
232 showReloadButton={showReload}
233 loadingDelete={loadingDelete}
234 loadingRefresh={loadingRefresh}
235 onDelete={handleDelete}
236 onRefresh={() => withLoadingRefresh(onRefresh())}
240 <h1 className="h3">{c('Error').t`Error`}</h1>
242 <span>{getEventErrorMessage(eventReadError)}</span>
247 if (isEventReadLoading) {
249 <PopoverContainer {...commonContainerProps} className="eventpopover p-4">
250 <PopoverHeader {...commonHeaderProps}>
258 <PopoverContainer {...commonContainerProps} className="eventpopover flex flex-column flex-nowrap">
260 {...commonHeaderProps}
264 showButton={showEditButton}
265 loading={loadingDelete}
266 onEdit={() => onEdit(!!canDuplicateEvent)}
268 <PopoverDuplicateButton
269 showButton={showDuplicateButton}
270 loading={loadingDelete}
271 onDuplicate={onDuplicate}
274 showButton={showDeleteButton}
275 loading={loadingDelete}
276 onDelete={handleDelete}
279 showButton={showViewEventButton}
281 isSearchView={isSearchView}
282 eventData={eventData}
284 if (!eventData || !veventComponent) {
287 onNavigateToEventFromSearch?.(eventData, veventComponent, eventRecurrence);
296 tooltip={c('Calendar invite info').t`This event has been canceled`}
299 <span className="text-uppercase">{c('Event canceled status badge').t`canceled`}</span>
302 <div className="flex mb-4 flex-nowrap">
305 'event-popover-calendar-border relative shrink-0 my-1',
306 isUnanswered && !isCancelled && 'isUnanswered'
308 style={{ '--calendar-color': color }}
310 <div className="pt-2">
311 <h1 className="eventpopover-title lh-rg text-hyphens overflow-auto mb-0" title={eventTitleSafe}>
314 <div className={clsx([!!frequencyString ? 'mb-2' : 'mb-3'])}>
316 {!!frequencyString && <div className="color-weak">{frequencyString}</div>}
321 <div className="overflow-auto mb-4" ref={popoverEventContentRef}>
323 key={targetEvent.uniqueId}
324 calendar={calendarData}
326 isDrawerApp={isDrawerApp}
327 formatTime={formatTime}
328 displayNameEmailMap={displayNameEmailMap}
329 popoverEventContentRef={popoverEventContentRef}
332 {getCanReplyToEvent({ isOwnedCalendar, isCalendarWritable, isAttendee: model.isAttendee, isCancelled }) && (
334 className="shrink-0 items-start md:items-center justify-space-between gap-4 flex-column md:flex-row"
335 key={targetEvent.uniqueId}
338 <strong>{c('Calendar invite buttons label').t`Attending?`}</strong>
340 <div className="ml-0 md:ml-auto">
341 <CalendarInviteButtons
343 accept: () => handleChangePartstat(ICAL_ATTENDEE_STATUS.ACCEPTED),
344 acceptTentatively: () => handleChangePartstat(ICAL_ATTENDEE_STATUS.TENTATIVE),
345 decline: () => handleChangePartstat(ICAL_ATTENDEE_STATUS.DECLINED),
346 retryCreateEvent: () => wait(0),
347 retryUpdateEvent: () => wait(0),
349 partstat={userPartstat}
350 disabled={isCalendarDisabled || !isSelfAddressActive || isSearchView}
359 export default EventPopover;