1 import type { ReactElement, RefObject } from 'react';
2 import { useMemo } from 'react';
4 import { c, msgid } from 'ttag';
6 import { VideoConferencingWidgetConfig } from '@proton/calendar';
11 CollapsibleHeaderIconButton,
15 useContactEmailsCache,
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';
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 = {
47 icon: ReactElement | null;
48 partstat: ICAL_ATTENDEE_STATUS;
53 isCurrentUser?: boolean;
54 /** If registered in contacts */
57 type GroupedAttendees = {
58 [key: string]: AttendeeViewModel[];
60 const { ACCEPTED, DECLINED, TENTATIVE, NEEDS_ACTION } = ICAL_ATTENDEE_STATUS;
63 calendar: VisualCalendar;
64 model: EventModelReadView;
65 formatTime: (date: Date) => string;
66 displayNameEmailMap: SimpleMap<DisplayNameEmail>;
67 popoverEventContentRef: RefObject<HTMLDivElement>;
70 const PopoverEventContent = ({
75 popoverEventContentRef,
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) => () => {
86 fn: [{ field: 'fn', value: name, uid: createContactPropertyUid() }],
87 email: [{ field: 'email', value: email, uid: createContactPropertyUid() }],
92 postMessageFromIframe(
94 type: DRAWER_EVENTS.OPEN_CONTACT_MODAL,
103 const handleContactDetails = (contactID: string) => () => {
105 postMessageFromIframe(
107 type: DRAWER_EVENTS.OPEN_CONTACT_MODAL,
108 payload: { contactID },
113 onDetails(contactID);
117 const isCalendarDisabled = getIsCalendarDisabled(calendar);
118 const isSubscribedCalendar = getIsSubscribedCalendar(calendar);
119 const { organizer, attendees } = model;
120 const hasOrganizer = !!organizer;
121 const numberOfParticipants = attendees.length;
124 title: organizerTitle,
125 contactID: organizerContactID,
126 initials: organizerInitials,
127 } = getOrganizerDisplayData(
129 model.isOrganizer && !isSubscribedCalendar,
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.`;
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} />
160 <span className="text-break" title={calendarName}>
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
173 const groupedAttendees = attendeesWithoutOrganizer
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)
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)];
192 icon: <AttendeeStatusIcon partstat={attendee.partstat} />,
193 partstat: attendee.partstat,
197 email: attendeeEmail,
199 contactID: contactEmail?.ContactID,
202 .reduce<GroupedAttendees>(
204 if (Object.prototype.hasOwnProperty.call(acc, item.partstat)) {
205 acc[item.partstat as keyof typeof acc].push(item);
207 acc.other.push(item);
220 const getAttendees = () => {
222 <ul className="unstyled m-0">
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}>
237 extraText={extraText}
239 isContact={!!contactID}
240 isCurrentUser={isCurrentUser}
241 onCreateOrEditContact={
242 contactID ? handleContactDetails(contactID) : handleContactAdd(email, name)
251 const organizerPartstat =
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` },
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`,
271 // We don't really use the delegated status right now
272 if (organizerPartstat && organizerPartstat !== ICAL_ATTENDEE_STATUS.DELEGATED) {
273 groupedReplies[organizerPartstat].count += 1;
276 const labelClassName = 'inline-flex pt-1';
280 {sanitizedLocation ? (
281 <IconRow labelClassName={labelClassName} title={c('Label').t`Location`} icon="map-pin">
282 <span className="text-break" dangerouslySetInnerHTML={{ __html: sanitizedLocation }} />
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">
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`}
297 <Icon name="chevron-down" />
298 </CollapsibleHeaderIconButton>
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}`)
312 <CollapsibleContent>{getAttendees()}</CollapsibleContent>
314 <div className="pr-1">
316 className="is-organizer"
317 title={organizerTitle}
318 initials={organizerInitials}
319 icon={organizerPartstatIcon}
321 tooltip={organizerTitle}
322 extraText={c('Label').t`Organizer`}
323 email={organizer.email}
324 isContact={!!organizerContactID}
325 isCurrentUser={model.isOrganizer && !isSubscribedCalendar}
326 onCreateOrEditContact={
328 ? handleContactDetails(organizerContactID)
329 : handleContactAdd(organizer.email, organizerName)
338 labelClassName="inline-flex pt-1"
339 title={c('Label').t`Calendar`}
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) => (
349 key={notification.id}
350 notification={notification}
351 formatTime={formatTime}
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 }} />
368 export default PopoverEventContent;