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 } = getOrganizerDisplayData(
128 model.isOrganizer && !isSubscribedCalendar,
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.`;
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} />
159 <span className="text-break" title={calendarName}>
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
172 const groupedAttendees = attendeesWithoutOrganizer
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)
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)];
191 icon: <AttendeeStatusIcon partstat={attendee.partstat} />,
192 partstat: attendee.partstat,
196 email: attendeeEmail,
198 contactID: contactEmail?.ContactID,
201 .reduce<GroupedAttendees>(
203 if (Object.prototype.hasOwnProperty.call(acc, item.partstat)) {
204 acc[item.partstat as keyof typeof acc].push(item);
206 acc.other.push(item);
219 const getAttendees = () => {
221 <ul className="unstyled m-0">
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}>
236 extraText={extraText}
238 isContact={!!contactID}
239 isCurrentUser={isCurrentUser}
240 onCreateOrEditContact={
241 contactID ? handleContactDetails(contactID) : handleContactAdd(email, name)
250 const organizerPartstat =
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` },
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`,
270 // We don't really use the delegated status right now
271 if (organizerPartstat && organizerPartstat !== ICAL_ATTENDEE_STATUS.DELEGATED) {
272 groupedReplies[organizerPartstat].count += 1;
275 const labelClassName = 'inline-flex pt-1';
279 {sanitizedLocation ? (
280 <IconRow labelClassName={labelClassName} title={c('Label').t`Location`} icon="map-pin">
281 <span className="text-break" dangerouslySetInnerHTML={{ __html: sanitizedLocation }} />
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">
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`}
296 <Icon name="chevron-down" />
297 </CollapsibleHeaderIconButton>
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}`)
311 <CollapsibleContent>{getAttendees()}</CollapsibleContent>
313 <div className="pr-1">
315 className="is-organizer"
316 title={organizerTitle}
317 initials={getInitials(organizerName)}
318 icon={organizerPartstatIcon}
320 tooltip={organizerTitle}
321 extraText={c('Label').t`Organizer`}
322 email={organizer.email}
323 isContact={!!organizerContactID}
324 isCurrentUser={model.isOrganizer && !isSubscribedCalendar}
325 onCreateOrEditContact={
327 ? handleContactDetails(organizerContactID)
328 : handleContactAdd(organizer.email, organizerName)
337 labelClassName="inline-flex pt-1"
338 title={c('Label').t`Calendar`}
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) => (
348 key={notification.id}
349 notification={notification}
350 formatTime={formatTime}
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 }} />
367 export default PopoverEventContent;