1import { Platform, UnavailabilityError, uuid } from 'expo-modules-core';
2
3import NotificationScheduler from './NotificationScheduler';
4import { NotificationTriggerInput as NativeNotificationTriggerInput } from './NotificationScheduler.types';
5import {
6  NotificationRequestInput,
7  NotificationTriggerInput,
8  DailyTriggerInput,
9  WeeklyTriggerInput,
10  YearlyTriggerInput,
11  CalendarTriggerInput,
12  TimeIntervalTriggerInput,
13  DateTriggerInput,
14  ChannelAwareTriggerInput,
15  SchedulableNotificationTriggerInput,
16} from './Notifications.types';
17
18/**
19 * Schedules a notification to be triggered in the future.
20 * > **Note:** Please note that this does not mean that the notification will be presented when it is triggered.
21 * For the notification to be presented you have to set a notification handler with [`setNotificationHandler`](#notificationssetnotificationhandlerhandler)
22 * that will return an appropriate notification behavior. For more information see the example below.
23 * @param request An object describing the notification to be triggered.
24 * @return Returns a Promise resolving to a string which is a notification identifier you can later use to cancel the notification or to identify an incoming notification.
25 * @example
26 * # Schedule the notification that will trigger once, in one minute from now
27 * ```ts
28 * import * as Notifications from 'expo-notifications';
29 *
30 * Notifications.scheduleNotificationAsync({
31 *   content: {
32 *     title: "Time's up!",
33 *     body: 'Change sides!',
34 *   },
35 *   trigger: {
36 *     seconds: 60,
37 *   },
38 * });
39 * ```
40 *
41 * # Schedule the notification that will trigger repeatedly, every 20 minutes
42 * ```ts
43 * import * as Notifications from 'expo-notifications';
44 *
45 * Notifications.scheduleNotificationAsync({
46 *   content: {
47 *     title: 'Remember to drink water!',
48 *   },
49 *   trigger: {
50 *     seconds: 60 * 20,
51 *     repeats: true,
52 *   },
53 * });
54 * ```
55 *
56 * # Schedule the notification that will trigger once, at the beginning of next hour
57 * ```ts
58 * import * as Notifications from 'expo-notifications';
59 *
60 * const trigger = new Date(Date.now() + 60 * 60 * 1000);
61 * trigger.setMinutes(0);
62 * trigger.setSeconds(0);
63 *
64 * Notifications.scheduleNotificationAsync({
65 *   content: {
66 *     title: 'Happy new hour!',
67 *   },
68 *   trigger,
69 * });
70 * ```
71 * @header schedule
72 */
73export default async function scheduleNotificationAsync(
74  request: NotificationRequestInput
75): Promise<string> {
76  if (!NotificationScheduler.scheduleNotificationAsync) {
77    throw new UnavailabilityError('Notifications', 'scheduleNotificationAsync');
78  }
79
80  return await NotificationScheduler.scheduleNotificationAsync(
81    request.identifier ?? uuid.v4(),
82    request.content,
83    parseTrigger(request.trigger)
84  );
85}
86
87type ValidTriggerDateComponents = 'month' | 'day' | 'weekday' | 'hour' | 'minute';
88
89const DAILY_TRIGGER_EXPECTED_DATE_COMPONENTS: readonly ValidTriggerDateComponents[] = [
90  'hour',
91  'minute',
92];
93const WEEKLY_TRIGGER_EXPECTED_DATE_COMPONENTS: readonly ValidTriggerDateComponents[] = [
94  'weekday',
95  'hour',
96  'minute',
97];
98const YEARLY_TRIGGER_EXPECTED_DATE_COMPONENTS: readonly ValidTriggerDateComponents[] = [
99  'day',
100  'month',
101  'hour',
102  'minute',
103];
104
105export function parseTrigger(
106  userFacingTrigger: NotificationTriggerInput
107): NativeNotificationTriggerInput {
108  if (userFacingTrigger === null) {
109    return null;
110  }
111
112  if (userFacingTrigger === undefined) {
113    throw new TypeError(
114      'Encountered an `undefined` notification trigger. If you want to trigger the notification immediately, pass in an explicit `null` value.'
115    );
116  }
117
118  if (isDateTrigger(userFacingTrigger)) {
119    return parseDateTrigger(userFacingTrigger);
120  } else if (isDailyTriggerInput(userFacingTrigger)) {
121    validateDateComponentsInTrigger(userFacingTrigger, DAILY_TRIGGER_EXPECTED_DATE_COMPONENTS);
122    return {
123      type: 'daily',
124      channelId: userFacingTrigger.channelId,
125      hour: userFacingTrigger.hour,
126      minute: userFacingTrigger.minute,
127    };
128  } else if (isWeeklyTriggerInput(userFacingTrigger)) {
129    validateDateComponentsInTrigger(userFacingTrigger, WEEKLY_TRIGGER_EXPECTED_DATE_COMPONENTS);
130    return {
131      type: 'weekly',
132      channelId: userFacingTrigger.channelId,
133      weekday: userFacingTrigger.weekday,
134      hour: userFacingTrigger.hour,
135      minute: userFacingTrigger.minute,
136    };
137  } else if (isYearlyTriggerInput(userFacingTrigger)) {
138    validateDateComponentsInTrigger(userFacingTrigger, YEARLY_TRIGGER_EXPECTED_DATE_COMPONENTS);
139    return {
140      type: 'yearly',
141      channelId: userFacingTrigger.channelId,
142      day: userFacingTrigger.day,
143      month: userFacingTrigger.month,
144      hour: userFacingTrigger.hour,
145      minute: userFacingTrigger.minute,
146    };
147  } else if (isSecondsPropertyMisusedInCalendarTriggerInput(userFacingTrigger)) {
148    throw new TypeError(
149      'Could not have inferred the notification trigger type: if you want to use a time interval trigger, pass in only `seconds` with or without `repeats` property; if you want to use calendar-based trigger, pass in `second`.'
150    );
151  } else if ('seconds' in userFacingTrigger) {
152    return {
153      type: 'timeInterval',
154      channelId: userFacingTrigger.channelId,
155      seconds: userFacingTrigger.seconds,
156      repeats: userFacingTrigger.repeats ?? false,
157    };
158  } else if (isCalendarTrigger(userFacingTrigger)) {
159    const { repeats, ...calendarTrigger } = userFacingTrigger;
160    return { type: 'calendar', value: calendarTrigger, repeats };
161  } else {
162    return Platform.select({
163      default: null, // There's no notion of channels on platforms other than Android.
164      android: { type: 'channel', channelId: userFacingTrigger.channelId },
165    });
166  }
167}
168
169function isCalendarTrigger(
170  trigger: CalendarTriggerInput | ChannelAwareTriggerInput
171): trigger is CalendarTriggerInput {
172  const { channelId, ...triggerWithoutChannelId } = trigger;
173  return Object.keys(triggerWithoutChannelId).length > 0;
174}
175
176function isDateTrigger(
177  trigger:
178    | DateTriggerInput
179    | WeeklyTriggerInput
180    | DailyTriggerInput
181    | CalendarTriggerInput
182    | TimeIntervalTriggerInput
183): trigger is DateTriggerInput {
184  return (
185    trigger instanceof Date ||
186    typeof trigger === 'number' ||
187    (typeof trigger === 'object' && 'date' in trigger)
188  );
189}
190
191function parseDateTrigger(trigger: DateTriggerInput): NativeNotificationTriggerInput {
192  if (trigger instanceof Date || typeof trigger === 'number') {
193    return { type: 'date', timestamp: toTimestamp(trigger) };
194  }
195  return { type: 'date', timestamp: toTimestamp(trigger.date), channelId: trigger.channelId };
196}
197
198function toTimestamp(date: number | Date) {
199  if (date instanceof Date) {
200    return date.getTime();
201  }
202  return date;
203}
204
205function isDailyTriggerInput(
206  trigger: SchedulableNotificationTriggerInput
207): trigger is DailyTriggerInput {
208  if (typeof trigger !== 'object') return false;
209  const { channelId, ...triggerWithoutChannelId } = trigger as DailyTriggerInput;
210  return (
211    Object.keys(triggerWithoutChannelId).length ===
212      DAILY_TRIGGER_EXPECTED_DATE_COMPONENTS.length + 1 &&
213    DAILY_TRIGGER_EXPECTED_DATE_COMPONENTS.every(
214      (component) => component in triggerWithoutChannelId
215    ) &&
216    'repeats' in triggerWithoutChannelId &&
217    triggerWithoutChannelId.repeats === true
218  );
219}
220
221function isWeeklyTriggerInput(
222  trigger: SchedulableNotificationTriggerInput
223): trigger is WeeklyTriggerInput {
224  if (typeof trigger !== 'object') return false;
225  const { channelId, ...triggerWithoutChannelId } = trigger as WeeklyTriggerInput;
226  return (
227    Object.keys(triggerWithoutChannelId).length ===
228      WEEKLY_TRIGGER_EXPECTED_DATE_COMPONENTS.length + 1 &&
229    WEEKLY_TRIGGER_EXPECTED_DATE_COMPONENTS.every(
230      (component) => component in triggerWithoutChannelId
231    ) &&
232    'repeats' in triggerWithoutChannelId &&
233    triggerWithoutChannelId.repeats === true
234  );
235}
236
237function isYearlyTriggerInput(
238  trigger: SchedulableNotificationTriggerInput
239): trigger is YearlyTriggerInput {
240  if (typeof trigger !== 'object') return false;
241  const { channelId, ...triggerWithoutChannelId } = trigger as YearlyTriggerInput;
242  return (
243    Object.keys(triggerWithoutChannelId).length ===
244      YEARLY_TRIGGER_EXPECTED_DATE_COMPONENTS.length + 1 &&
245    YEARLY_TRIGGER_EXPECTED_DATE_COMPONENTS.every(
246      (component) => component in triggerWithoutChannelId
247    ) &&
248    'repeats' in triggerWithoutChannelId &&
249    triggerWithoutChannelId.repeats === true
250  );
251}
252
253function isSecondsPropertyMisusedInCalendarTriggerInput(
254  trigger: TimeIntervalTriggerInput | CalendarTriggerInput
255) {
256  const { channelId, ...triggerWithoutChannelId } = trigger;
257  return (
258    // eg. { seconds: ..., repeats: ..., hour: ... }
259    ('seconds' in triggerWithoutChannelId &&
260      'repeats' in triggerWithoutChannelId &&
261      Object.keys(triggerWithoutChannelId).length > 2) ||
262    // eg. { seconds: ..., hour: ... }
263    ('seconds' in triggerWithoutChannelId &&
264      !('repeats' in triggerWithoutChannelId) &&
265      Object.keys(triggerWithoutChannelId).length > 1)
266  );
267}
268
269function validateDateComponentsInTrigger(
270  trigger: NonNullable<NotificationTriggerInput>,
271  components: readonly ValidTriggerDateComponents[]
272) {
273  const anyTriggerType = trigger as any;
274  components.forEach((component) => {
275    if (!(component in anyTriggerType)) {
276      throw new TypeError(`The ${component} parameter needs to be present`);
277    }
278    if (typeof anyTriggerType[component] !== 'number') {
279      throw new TypeError(`The ${component} parameter should be a number`);
280    }
281    switch (component) {
282      case 'month': {
283        const { month } = anyTriggerType;
284        if (month < 0 || month > 11) {
285          throw new RangeError(`The month parameter needs to be between 0 and 11. Found: ${month}`);
286        }
287        break;
288      }
289      case 'day': {
290        const { day, month } = anyTriggerType;
291        const daysInGivenMonth = daysInMonth(month);
292        if (day < 1 || day > daysInGivenMonth) {
293          throw new RangeError(
294            `The day parameter for month ${month} must be between 1 and ${daysInGivenMonth}. Found: ${day}`
295          );
296        }
297        break;
298      }
299      case 'weekday': {
300        const { weekday } = anyTriggerType;
301        if (weekday < 1 || weekday > 7) {
302          throw new RangeError(
303            `The weekday parameter needs to be between 1 and 7. Found: ${weekday}`
304          );
305        }
306        break;
307      }
308      case 'hour': {
309        const { hour } = anyTriggerType;
310        if (hour < 0 || hour > 23) {
311          throw new RangeError(`The hour parameter needs to be between 0 and 23. Found: ${hour}`);
312        }
313        break;
314      }
315      case 'minute': {
316        const { minute } = anyTriggerType;
317        if (minute < 0 || minute > 59) {
318          throw new RangeError(
319            `The minute parameter needs to be between 0 and 59. Found: ${minute}`
320          );
321        }
322        break;
323      }
324    }
325  });
326}
327
328/**
329 * Determines the number of days in the given month (or January if omitted).
330 * If year is specified, it will include leap year logic, else it will always assume a leap year
331 */
332function daysInMonth(month: number = 0, year?: number) {
333  return new Date(year ?? 2000, month + 1, 0).getDate();
334}
335