Merge branch 'feat/inda-383-daily-stat' into 'main'
[ProtonMail-WebClient.git] / applications / calendar / src / app / containers / alarms / AlarmWatcher.tsx
blob51444a6fed441e7675121a722207dabac87d9a39
1 import type { MutableRefObject } from 'react';
2 import { useEffect, useRef } from 'react';
4 import { differenceInMilliseconds, fromUnixTime } from 'date-fns';
5 import { c } from 'ttag';
7 import { useApi, useContactEmailsCache, useGetCalendarEventRaw, useHasSuspendedCounter } from '@proton/components';
8 import { getEvent as getEventRoute } from '@proton/shared/lib/api/calendars';
9 import { getAlarmMessage, getNextEventTime } from '@proton/shared/lib/calendar/alarms';
10 import { MINUTE } from '@proton/shared/lib/constants';
11 import { isElectronMail } from '@proton/shared/lib/helpers/desktop';
12 import { create, createElectronNotification } from '@proton/shared/lib/helpers/desktopNotification';
13 import { dateLocale } from '@proton/shared/lib/i18n';
14 import type { CalendarAlarm, CalendarEvent } from '@proton/shared/lib/interfaces/calendar';
15 import noop from '@proton/utils/noop';
17 import notificationIcon from '../../../assets/notification.png';
18 import type { CalendarsEventsCache } from '../calendar/eventStore/interface';
20 const MIN_CUTOFF = -MINUTE;
22 export const displayNotification = ({ title = c('Title').t`Calendar alarm`, text = '', ...rest }) => {
23     if (isElectronMail) {
24         return createElectronNotification({ title, body: text, app: 'calendar' });
25     }
26     return create(title, {
27         body: text,
28         icon: notificationIcon,
29         onClick() {
30             window.focus();
31         },
32         ...rest,
33     });
36 const getFirstUnseenAlarm = (alarms: CalendarAlarm[] = [], set: Set<string>) => {
37     return alarms.find(({ ID }) => {
38         return !set.has(ID);
39     });
42 interface Props {
43     alarms: CalendarAlarm[];
44     tzid: string;
45     calendarsEventsCacheRef: MutableRefObject<CalendarsEventsCache>;
47 const AlarmWatcher = ({ alarms = [], tzid, calendarsEventsCacheRef }: Props) => {
48     const api = useApi();
49     const hasSuspendedCounter = useHasSuspendedCounter({ refreshInterval: MINUTE, tolerance: MINUTE / 2 });
50     const { contactEmailsMap } = useContactEmailsCache();
51     const getCalendarEventRaw = useGetCalendarEventRaw(contactEmailsMap);
52     const cacheRef = useRef<Set<string>>();
54     useEffect(() => {
55         let timeoutHandle = 0;
56         let unmounted = false;
58         const run = () => {
59             if (unmounted) {
60                 return;
61             }
62             if (!cacheRef.current) {
63                 cacheRef.current = new Set();
64             }
66             const firstUnseenAlarm = getFirstUnseenAlarm(alarms, cacheRef.current);
67             if (!firstUnseenAlarm) {
68                 return;
69             }
71             const { ID, Occurrence, Trigger, CalendarID, EventID } = firstUnseenAlarm;
73             const nextAlarmTime = fromUnixTime(Occurrence);
74             const now = Date.now();
75             const diff = differenceInMilliseconds(nextAlarmTime, now);
76             const delay = Math.max(diff, 0);
78             const getEvent = () => {
79                 const cachedEvent = calendarsEventsCacheRef.current.getCachedEvent(CalendarID, EventID);
80                 if (cachedEvent) {
81                     return Promise.resolve(cachedEvent);
82                 }
83                 return api<{ Event: CalendarEvent }>({ ...getEventRoute(CalendarID, EventID), silence: true }).then(
84                     ({ Event }) => Event
85                 );
86             };
88             timeoutHandle = window.setTimeout(() => {
89                 if (unmounted || !cacheRef.current) {
90                     return;
91                 }
92                 // Eagerly add the event to seen, ignore if it would fail
93                 cacheRef.current.add(ID);
95                 // Ignore the event if it's in the past after the cutoff
96                 if (diff < MIN_CUTOFF) {
97                     window.setTimeout(run, 0);
98                     return;
99                 }
101                 getEvent()
102                     .then((Event) => getCalendarEventRaw(Event))
103                     .then((eventRaw) => {
104                         if (unmounted) {
105                             return;
106                         }
107                         const { veventComponent: component } = eventRaw;
108                         // compute event start time based on trigger, as we cannot rely on dtstart for recurring events
109                         const start = new Date(getNextEventTime({ Occurrence, Trigger, tzid }));
110                         const now = new Date();
111                         const formatOptions = { locale: dateLocale };
112                         const text = getAlarmMessage({ component, start, now, tzid, formatOptions });
113                         void displayNotification({ text, tag: ID });
114                     })
115                     .catch(noop);
117                 window.setTimeout(run, 0);
118             }, delay);
119         };
121         run();
123         return () => {
124             unmounted = true;
125             if (timeoutHandle) {
126                 window.clearTimeout(timeoutHandle);
127             }
128         };
129     }, [alarms, tzid, getCalendarEventRaw, hasSuspendedCounter]);
131     return null;
134 export default AlarmWatcher;