Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / applications / calendar / src / app / components / events / PopoverEventContent.tsx
blob7a1d30a377ab638815cc27b8ae47b516c7fdb298
1 import type { ReactElement, RefObject } from 'react';
2 import { useMemo } from 'react';
4 import { c, msgid } from 'ttag';
6 import { VideoConferencingWidgetConfig } from '@proton/calendar';
7 import {
8     Collapsible,
9     CollapsibleContent,
10     CollapsibleHeader,
11     CollapsibleHeaderIconButton,
12     Icon,
13     IconRow,
14     Info,
15     useContactEmailsCache,
16     useContactModals,
17 } from '@proton/components';
18 import { useLinkHandler } from '@proton/components/hooks/useLinkHandler';
19 import { useMailSettings } from '@proton/mail/mailSettings/hooks';
20 import { getIsCalendarDisabled, getIsSubscribedCalendar } from '@proton/shared/lib/calendar/calendar';
21 import { ICAL_ATTENDEE_ROLE, ICAL_ATTENDEE_STATUS } from '@proton/shared/lib/calendar/constants';
22 import { escapeInvalidHtmlTags, restrictedCalendarSanitize } from '@proton/shared/lib/calendar/sanitize';
23 import urlify from '@proton/shared/lib/calendar/urlify';
24 import { APPS } from '@proton/shared/lib/constants';
25 import { createContactPropertyUid } from '@proton/shared/lib/contacts/properties';
26 import { postMessageFromIframe } from '@proton/shared/lib/drawer/helpers';
27 import { DRAWER_EVENTS } from '@proton/shared/lib/drawer/interfaces';
28 import {
29     canonicalizeEmail,
30     canonicalizeEmailByGuess,
31     canonicalizeInternalEmail,
32 } from '@proton/shared/lib/helpers/email';
33 import { getInitials } from '@proton/shared/lib/helpers/string';
34 import type { EventModelReadView, VisualCalendar } from '@proton/shared/lib/interfaces/calendar';
35 import type { SimpleMap } from '@proton/shared/lib/interfaces/utils';
37 import type { DisplayNameEmail } from '../../containers/calendar/interface';
38 import { getOrganizerDisplayData } from '../../helpers/attendees';
39 import AttendeeStatusIcon from './AttendeeStatusIcon';
40 import Participant from './Participant';
41 import PopoverNotification from './PopoverNotification';
42 import getAttendanceTooltip from './getAttendanceTooltip';
44 type AttendeeViewModel = {
45     title: string;
46     name: string;
47     icon: ReactElement | null;
48     partstat: ICAL_ATTENDEE_STATUS;
49     initials: string;
50     tooltip: string;
51     extraText?: string;
52     email: string;
53     isCurrentUser?: boolean;
54     /** If registered in contacts */
55     contactID?: string;
57 type GroupedAttendees = {
58     [key: string]: AttendeeViewModel[];
60 const { ACCEPTED, DECLINED, TENTATIVE, NEEDS_ACTION } = ICAL_ATTENDEE_STATUS;
62 interface Props {
63     calendar: VisualCalendar;
64     model: EventModelReadView;
65     formatTime: (date: Date) => string;
66     displayNameEmailMap: SimpleMap<DisplayNameEmail>;
67     popoverEventContentRef: RefObject<HTMLDivElement>;
68     isDrawerApp: boolean;
70 const PopoverEventContent = ({
71     calendar,
72     model,
73     formatTime,
74     displayNameEmailMap,
75     popoverEventContentRef,
76     isDrawerApp,
77 }: Props) => {
78     const [mailSettings] = useMailSettings();
79     const { Name: calendarName } = calendar;
80     const { contactEmailsMap } = useContactEmailsCache();
81     const { modals: contactModals, onDetails, onEdit } = useContactModals();
83     const handleContactAdd = (email: string, name: string) => () => {
84         const payload = {
85             vCardContact: {
86                 fn: [{ field: 'fn', value: name, uid: createContactPropertyUid() }],
87                 email: [{ field: 'email', value: email, uid: createContactPropertyUid() }],
88             },
89         };
91         if (isDrawerApp) {
92             postMessageFromIframe(
93                 {
94                     type: DRAWER_EVENTS.OPEN_CONTACT_MODAL,
95                     payload,
96                 },
97                 APPS.PROTONMAIL
98             );
99         } else {
100             onEdit(payload);
101         }
102     };
103     const handleContactDetails = (contactID: string) => () => {
104         if (isDrawerApp) {
105             postMessageFromIframe(
106                 {
107                     type: DRAWER_EVENTS.OPEN_CONTACT_MODAL,
108                     payload: { contactID },
109                 },
110                 APPS.PROTONMAIL
111             );
112         } else {
113             onDetails(contactID);
114         }
115     };
117     const isCalendarDisabled = getIsCalendarDisabled(calendar);
118     const isSubscribedCalendar = getIsSubscribedCalendar(calendar);
119     const { organizer, attendees } = model;
120     const hasOrganizer = !!organizer;
121     const numberOfParticipants = attendees.length;
122     const {
123         name: organizerName,
124         title: organizerTitle,
125         contactID: organizerContactID,
126         initials: organizerInitials,
127     } = getOrganizerDisplayData(
128         organizer,
129         model.isOrganizer && !isSubscribedCalendar,
130         contactEmailsMap,
131         displayNameEmailMap
132     );
133     const sanitizedLocation = useMemo(() => {
134         const urlified = urlify(model.location.trim());
135         const escaped = escapeInvalidHtmlTags(urlified);
136         return restrictedCalendarSanitize(escaped);
137     }, [model.location]);
138     const htmlString = useMemo(() => {
139         const urlified = urlify(model.description.trim());
140         const escaped = escapeInvalidHtmlTags(urlified);
141         return restrictedCalendarSanitize(escaped);
142     }, [model.description]);
144     const calendarString = useMemo(() => {
145         if (isCalendarDisabled) {
146             const disabledText = <span className="text-italic">({c('Disabled calendar').t`Disabled`})</span>;
147             const tooltipText = c('Disabled calendar tooltip').t`The event belongs to a disabled calendar.`;
149             return (
150                 <>
151                     <span className="text-break flex-auto grow-0 mr-2">{calendarName}</span>
152                     <span className="text-no-wrap shrink-0">
153                         {disabledText} <Info title={tooltipText} />
154                     </span>
155                 </>
156             );
157         }
159         return (
160             <span className="text-break" title={calendarName}>
161                 {calendarName}
162             </span>
163         );
164     }, [calendarName, isCalendarDisabled]);
166     const { modal: linkModal } = useLinkHandler(popoverEventContentRef, mailSettings);
168     const canonicalizedOrganizerEmail = canonicalizeEmailByGuess(organizer?.email || '');
170     const attendeesWithoutOrganizer = model.attendees.filter(
171         ({ email }) => canonicalizeEmailByGuess(email) !== canonicalizedOrganizerEmail
172     );
173     const groupedAttendees = attendeesWithoutOrganizer
174         .map((attendee) => {
175             const attendeeEmail = attendee.email;
176             const selfEmail = model.selfAddress?.Email;
177             const displayContact = displayNameEmailMap[canonicalizeEmailByGuess(attendeeEmail)];
178             const displayName = displayContact?.displayName || attendee.cn || attendeeEmail;
179             const isCurrentUser = !!(
180                 selfEmail && canonicalizeInternalEmail(selfEmail) === canonicalizeInternalEmail(attendeeEmail)
181             );
182             const name = isCurrentUser ? c('Participant name').t`You` : displayName;
183             const title = name === attendee.email || isCurrentUser ? attendeeEmail : `${name} <${attendeeEmail}>`;
184             const initials = getInitials(displayName);
185             const tooltip = getAttendanceTooltip({ partstat: attendee.partstat, name, isYou: isCurrentUser });
186             const extraText = attendee.role === ICAL_ATTENDEE_ROLE.OPTIONAL ? c('Attendee role').t`Optional` : '';
187             const contactEmail = contactEmailsMap[canonicalizeEmail(attendeeEmail)];
189             return {
190                 title,
191                 name,
192                 icon: <AttendeeStatusIcon partstat={attendee.partstat} />,
193                 partstat: attendee.partstat,
194                 initials,
195                 tooltip,
196                 extraText,
197                 email: attendeeEmail,
198                 isCurrentUser,
199                 contactID: contactEmail?.ContactID,
200             };
201         })
202         .reduce<GroupedAttendees>(
203             (acc, item) => {
204                 if (Object.prototype.hasOwnProperty.call(acc, item.partstat)) {
205                     acc[item.partstat as keyof typeof acc].push(item);
206                 } else {
207                     acc.other.push(item);
208                 }
209                 return acc;
210             },
211             {
212                 [ACCEPTED]: [],
213                 [DECLINED]: [],
214                 [TENTATIVE]: [],
215                 [NEEDS_ACTION]: [],
216                 other: [],
217             }
218         );
220     const getAttendees = () => {
221         return (
222             <ul className="unstyled m-0">
223                 {[
224                     ...groupedAttendees[ACCEPTED],
225                     ...groupedAttendees[TENTATIVE],
226                     ...groupedAttendees[DECLINED],
227                     ...groupedAttendees[NEEDS_ACTION],
228                     ...groupedAttendees.other,
229                 ].map(({ icon, name, title, initials, tooltip, extraText, email, contactID, isCurrentUser }) => (
230                     <li className="pr-1" key={title}>
231                         <Participant
232                             title={title}
233                             initials={initials}
234                             icon={icon}
235                             name={name}
236                             tooltip={tooltip}
237                             extraText={extraText}
238                             email={email}
239                             isContact={!!contactID}
240                             isCurrentUser={isCurrentUser}
241                             onCreateOrEditContact={
242                                 contactID ? handleContactDetails(contactID) : handleContactAdd(email, name)
243                             }
244                         />
245                     </li>
246                 ))}
247             </ul>
248         );
249     };
251     const organizerPartstat =
252         hasOrganizer &&
253         model.attendees.find(({ email }) => canonicalizeEmailByGuess(email) === canonicalizedOrganizerEmail)?.partstat;
254     const organizerPartstatIcon = organizerPartstat ? <AttendeeStatusIcon partstat={organizerPartstat} /> : null;
256     const groupedReplies = {
257         [ACCEPTED]: { count: groupedAttendees[ACCEPTED].length, text: c('Event reply').t`yes` },
258         [TENTATIVE]: { count: groupedAttendees[TENTATIVE].length, text: c('Event reply').t`maybe` },
259         [DECLINED]: { count: groupedAttendees[DECLINED].length, text: c('Event reply').t`no` },
260         [NEEDS_ACTION]: {
261             count:
262                 attendeesWithoutOrganizer.length -
263                 (groupedAttendees[ACCEPTED].length +
264                     groupedAttendees[TENTATIVE].length +
265                     groupedAttendees[DECLINED].length +
266                     groupedAttendees.other.length),
267             text: c('Event reply').t`pending`,
268         },
269     };
271     // We don't really use the delegated status right now
272     if (organizerPartstat && organizerPartstat !== ICAL_ATTENDEE_STATUS.DELEGATED) {
273         groupedReplies[organizerPartstat].count += 1;
274     }
276     const labelClassName = 'inline-flex pt-1';
278     return (
279         <>
280             {sanitizedLocation ? (
281                 <IconRow labelClassName={labelClassName} title={c('Label').t`Location`} icon="map-pin">
282                     <span className="text-break" dangerouslySetInnerHTML={{ __html: sanitizedLocation }} />
283                 </IconRow>
284             ) : null}
285             <VideoConferencingWidgetConfig model={model} widgetLocation="event-details" />
286             {!!numberOfParticipants && organizer && (
287                 <IconRow labelClassName={labelClassName} icon="user" title={c('Label').t`Participants`}>
288                     <div className="w-full">
289                         <Collapsible>
290                             <CollapsibleHeader
291                                 suffix={
292                                     <CollapsibleHeaderIconButton
293                                         expandText={c('Participants expand button label').t`Expand participants list`}
294                                         collapseText={c('Participants collapse button label')
295                                             .t`Collapse participants list`}
296                                     >
297                                         <Icon name="chevron-down" />
298                                     </CollapsibleHeaderIconButton>
299                                 }
300                             >
301                                 <div className="attendee-count">
302                                     {numberOfParticipants}{' '}
303                                     {c('Label').ngettext(msgid`participant`, `participants`, numberOfParticipants)}
304                                     <div className="color-weak text-sm m-0">
305                                         {Object.entries(groupedReplies)
306                                             .filter(([, { count }]) => count)
307                                             .map(([, { text, count }]) => `${count} ${text}`)
308                                             .join(', ')}
309                                     </div>
310                                 </div>
311                             </CollapsibleHeader>
312                             <CollapsibleContent>{getAttendees()}</CollapsibleContent>
313                         </Collapsible>
314                         <div className="pr-1">
315                             <Participant
316                                 className="is-organizer"
317                                 title={organizerTitle}
318                                 initials={organizerInitials}
319                                 icon={organizerPartstatIcon}
320                                 name={organizerName}
321                                 tooltip={organizerTitle}
322                                 extraText={c('Label').t`Organizer`}
323                                 email={organizer.email}
324                                 isContact={!!organizerContactID}
325                                 isCurrentUser={model.isOrganizer && !isSubscribedCalendar}
326                                 onCreateOrEditContact={
327                                     organizerContactID
328                                         ? handleContactDetails(organizerContactID)
329                                         : handleContactAdd(organizer.email, organizerName)
330                                 }
331                             />
332                         </div>
333                     </div>
334                 </IconRow>
335             )}
336             <IconRow
337                 className="flex-1"
338                 labelClassName="inline-flex pt-1"
339                 title={c('Label').t`Calendar`}
340                 icon="calendar-grid"
341             >
342                 {calendarString}
343             </IconRow>
344             {model.notifications?.length ? (
345                 <IconRow labelClassName={labelClassName} title={c('Label').t`Notifications`} icon="bell">
346                     <div className="flex flex-column">
347                         {model.notifications.map((notification) => (
348                             <PopoverNotification
349                                 key={notification.id}
350                                 notification={notification}
351                                 formatTime={formatTime}
352                             />
353                         ))}
354                     </div>
355                 </IconRow>
356             ) : null}
357             {htmlString ? (
358                 <IconRow labelClassName={labelClassName} title={c('Label').t`Description`} icon="text-align-left">
359                     <div className="text-break my-0 text-pre-wrap" dangerouslySetInnerHTML={{ __html: htmlString }} />
360                 </IconRow>
361             ) : null}
362             {linkModal}
363             {contactModals}
364         </>
365     );
368 export default PopoverEventContent;