Cleanup - unused files / unused exports / duplicate exports
[ProtonMail-WebClient.git] / applications / calendar / src / app / components / events / PopoverEventContent.tsx
blob915248866bd915505aeeb6bd287afab6a66a4593
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     } = getOrganizerDisplayData(
127         organizer,
128         model.isOrganizer && !isSubscribedCalendar,
129         contactEmailsMap,
130         displayNameEmailMap
131     );
132     const sanitizedLocation = useMemo(() => {
133         const urlified = urlify(model.location.trim());
134         const escaped = escapeInvalidHtmlTags(urlified);
135         return restrictedCalendarSanitize(escaped);
136     }, [model.location]);
137     const htmlString = useMemo(() => {
138         const urlified = urlify(model.description.trim());
139         const escaped = escapeInvalidHtmlTags(urlified);
140         return restrictedCalendarSanitize(escaped);
141     }, [model.description]);
143     const calendarString = useMemo(() => {
144         if (isCalendarDisabled) {
145             const disabledText = <span className="text-italic">({c('Disabled calendar').t`Disabled`})</span>;
146             const tooltipText = c('Disabled calendar tooltip').t`The event belongs to a disabled calendar.`;
148             return (
149                 <>
150                     <span className="text-break flex-auto grow-0 mr-2">{calendarName}</span>
151                     <span className="text-no-wrap shrink-0">
152                         {disabledText} <Info title={tooltipText} />
153                     </span>
154                 </>
155             );
156         }
158         return (
159             <span className="text-break" title={calendarName}>
160                 {calendarName}
161             </span>
162         );
163     }, [calendarName, isCalendarDisabled]);
165     const { modal: linkModal } = useLinkHandler(popoverEventContentRef, mailSettings);
167     const canonicalizedOrganizerEmail = canonicalizeEmailByGuess(organizer?.email || '');
169     const attendeesWithoutOrganizer = model.attendees.filter(
170         ({ email }) => canonicalizeEmailByGuess(email) !== canonicalizedOrganizerEmail
171     );
172     const groupedAttendees = attendeesWithoutOrganizer
173         .map((attendee) => {
174             const attendeeEmail = attendee.email;
175             const selfEmail = model.selfAddress?.Email;
176             const displayContact = displayNameEmailMap[canonicalizeEmailByGuess(attendeeEmail)];
177             const displayName = displayContact?.displayName || attendee.cn || attendeeEmail;
178             const isCurrentUser = !!(
179                 selfEmail && canonicalizeInternalEmail(selfEmail) === canonicalizeInternalEmail(attendeeEmail)
180             );
181             const name = isCurrentUser ? c('Participant name').t`You` : displayName;
182             const title = name === attendee.email || isCurrentUser ? attendeeEmail : `${name} <${attendeeEmail}>`;
183             const initials = getInitials(displayName);
184             const tooltip = getAttendanceTooltip({ partstat: attendee.partstat, name, isYou: isCurrentUser });
185             const extraText = attendee.role === ICAL_ATTENDEE_ROLE.OPTIONAL ? c('Attendee role').t`Optional` : '';
186             const contactEmail = contactEmailsMap[canonicalizeEmail(attendeeEmail)];
188             return {
189                 title,
190                 name,
191                 icon: <AttendeeStatusIcon partstat={attendee.partstat} />,
192                 partstat: attendee.partstat,
193                 initials,
194                 tooltip,
195                 extraText,
196                 email: attendeeEmail,
197                 isCurrentUser,
198                 contactID: contactEmail?.ContactID,
199             };
200         })
201         .reduce<GroupedAttendees>(
202             (acc, item) => {
203                 if (Object.prototype.hasOwnProperty.call(acc, item.partstat)) {
204                     acc[item.partstat as keyof typeof acc].push(item);
205                 } else {
206                     acc.other.push(item);
207                 }
208                 return acc;
209             },
210             {
211                 [ACCEPTED]: [],
212                 [DECLINED]: [],
213                 [TENTATIVE]: [],
214                 [NEEDS_ACTION]: [],
215                 other: [],
216             }
217         );
219     const getAttendees = () => {
220         return (
221             <ul className="unstyled m-0">
222                 {[
223                     ...groupedAttendees[ACCEPTED],
224                     ...groupedAttendees[TENTATIVE],
225                     ...groupedAttendees[DECLINED],
226                     ...groupedAttendees[NEEDS_ACTION],
227                     ...groupedAttendees.other,
228                 ].map(({ icon, name, title, initials, tooltip, extraText, email, contactID, isCurrentUser }) => (
229                     <li className="pr-1" key={title}>
230                         <Participant
231                             title={title}
232                             initials={initials}
233                             icon={icon}
234                             name={name}
235                             tooltip={tooltip}
236                             extraText={extraText}
237                             email={email}
238                             isContact={!!contactID}
239                             isCurrentUser={isCurrentUser}
240                             onCreateOrEditContact={
241                                 contactID ? handleContactDetails(contactID) : handleContactAdd(email, name)
242                             }
243                         />
244                     </li>
245                 ))}
246             </ul>
247         );
248     };
250     const organizerPartstat =
251         hasOrganizer &&
252         model.attendees.find(({ email }) => canonicalizeEmailByGuess(email) === canonicalizedOrganizerEmail)?.partstat;
253     const organizerPartstatIcon = organizerPartstat ? <AttendeeStatusIcon partstat={organizerPartstat} /> : null;
255     const groupedReplies = {
256         [ACCEPTED]: { count: groupedAttendees[ACCEPTED].length, text: c('Event reply').t`yes` },
257         [TENTATIVE]: { count: groupedAttendees[TENTATIVE].length, text: c('Event reply').t`maybe` },
258         [DECLINED]: { count: groupedAttendees[DECLINED].length, text: c('Event reply').t`no` },
259         [NEEDS_ACTION]: {
260             count:
261                 attendeesWithoutOrganizer.length -
262                 (groupedAttendees[ACCEPTED].length +
263                     groupedAttendees[TENTATIVE].length +
264                     groupedAttendees[DECLINED].length +
265                     groupedAttendees.other.length),
266             text: c('Event reply').t`pending`,
267         },
268     };
270     // We don't really use the delegated status right now
271     if (organizerPartstat && organizerPartstat !== ICAL_ATTENDEE_STATUS.DELEGATED) {
272         groupedReplies[organizerPartstat].count += 1;
273     }
275     const labelClassName = 'inline-flex pt-1';
277     return (
278         <>
279             {sanitizedLocation ? (
280                 <IconRow labelClassName={labelClassName} title={c('Label').t`Location`} icon="map-pin">
281                     <span className="text-break" dangerouslySetInnerHTML={{ __html: sanitizedLocation }} />
282                 </IconRow>
283             ) : null}
284             <VideoConferencingWidgetConfig model={model} widgetLocation="event-details" />
285             {!!numberOfParticipants && organizer && (
286                 <IconRow labelClassName={labelClassName} icon="user" title={c('Label').t`Participants`}>
287                     <div className="w-full">
288                         <Collapsible>
289                             <CollapsibleHeader
290                                 suffix={
291                                     <CollapsibleHeaderIconButton
292                                         expandText={c('Participants expand button label').t`Expand participants list`}
293                                         collapseText={c('Participants collapse button label')
294                                             .t`Collapse participants list`}
295                                     >
296                                         <Icon name="chevron-down" />
297                                     </CollapsibleHeaderIconButton>
298                                 }
299                             >
300                                 <div className="attendee-count">
301                                     {numberOfParticipants}{' '}
302                                     {c('Label').ngettext(msgid`participant`, `participants`, numberOfParticipants)}
303                                     <div className="color-weak text-sm m-0">
304                                         {Object.entries(groupedReplies)
305                                             .filter(([, { count }]) => count)
306                                             .map(([, { text, count }]) => `${count} ${text}`)
307                                             .join(', ')}
308                                     </div>
309                                 </div>
310                             </CollapsibleHeader>
311                             <CollapsibleContent>{getAttendees()}</CollapsibleContent>
312                         </Collapsible>
313                         <div className="pr-1">
314                             <Participant
315                                 className="is-organizer"
316                                 title={organizerTitle}
317                                 initials={getInitials(organizerName)}
318                                 icon={organizerPartstatIcon}
319                                 name={organizerName}
320                                 tooltip={organizerTitle}
321                                 extraText={c('Label').t`Organizer`}
322                                 email={organizer.email}
323                                 isContact={!!organizerContactID}
324                                 isCurrentUser={model.isOrganizer && !isSubscribedCalendar}
325                                 onCreateOrEditContact={
326                                     organizerContactID
327                                         ? handleContactDetails(organizerContactID)
328                                         : handleContactAdd(organizer.email, organizerName)
329                                 }
330                             />
331                         </div>
332                     </div>
333                 </IconRow>
334             )}
335             <IconRow
336                 className="flex-1"
337                 labelClassName="inline-flex pt-1"
338                 title={c('Label').t`Calendar`}
339                 icon="calendar-grid"
340             >
341                 {calendarString}
342             </IconRow>
343             {model.notifications?.length ? (
344                 <IconRow labelClassName={labelClassName} title={c('Label').t`Notifications`} icon="bell">
345                     <div className="flex flex-column">
346                         {model.notifications.map((notification) => (
347                             <PopoverNotification
348                                 key={notification.id}
349                                 notification={notification}
350                                 formatTime={formatTime}
351                             />
352                         ))}
353                     </div>
354                 </IconRow>
355             ) : null}
356             {htmlString ? (
357                 <IconRow labelClassName={labelClassName} title={c('Label').t`Description`} icon="text-align-left">
358                     <div className="text-break my-0 text-pre-wrap" dangerouslySetInnerHTML={{ __html: htmlString }} />
359                 </IconRow>
360             ) : null}
361             {linkModal}
362             {contactModals}
363         </>
364     );
367 export default PopoverEventContent;