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