1import { Platform, UnavailabilityError } from '@unimodules/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
19export default async function scheduleNotificationAsync(
20  request: NotificationRequestInput
21): Promise<string> {
22  if (!NotificationScheduler.scheduleNotificationAsync) {
23    throw new UnavailabilityError('Notifications', 'scheduleNotificationAsync');
24  }
25
26  return await NotificationScheduler.scheduleNotificationAsync(
27    request.identifier ?? uuidv4(),
28    request.content,
29    parseTrigger(request.trigger)
30  );
31}
32
33type ValidTriggerDateComponents = 'month' | 'day' | 'weekday' | 'hour' | 'minute';
34
35const DAILY_TRIGGER_EXPECTED_DATE_COMPONENTS: readonly ValidTriggerDateComponents[] = [
36  'hour',
37  'minute',
38];
39const WEEKLY_TRIGGER_EXPECTED_DATE_COMPONENTS: readonly ValidTriggerDateComponents[] = [
40  'weekday',
41  'hour',
42  'minute',
43];
44const YEARLY_TRIGGER_EXPECTED_DATE_COMPONENTS: readonly ValidTriggerDateComponents[] = [
45  'day',
46  'month',
47  'hour',
48  'minute',
49];
50
51export function parseTrigger(
52  userFacingTrigger: NotificationTriggerInput
53): NativeNotificationTriggerInput {
54  if (userFacingTrigger === null) {
55    return null;
56  }
57
58  if (userFacingTrigger === undefined) {
59    throw new TypeError(
60      'Encountered an `undefined` notification trigger. If you want to trigger the notification immediately, pass in an explicit `null` value.'
61    );
62  }
63
64  if (isDateTrigger(userFacingTrigger)) {
65    return parseDateTrigger(userFacingTrigger);
66  } else if (isDailyTriggerInput(userFacingTrigger)) {
67    validateDateComponentsInTrigger(userFacingTrigger, DAILY_TRIGGER_EXPECTED_DATE_COMPONENTS);
68    return {
69      type: 'daily',
70      channelId: userFacingTrigger.channelId,
71      hour: userFacingTrigger.hour,
72      minute: userFacingTrigger.minute,
73    };
74  } else if (isWeeklyTriggerInput(userFacingTrigger)) {
75    validateDateComponentsInTrigger(userFacingTrigger, WEEKLY_TRIGGER_EXPECTED_DATE_COMPONENTS);
76    return {
77      type: 'weekly',
78      channelId: userFacingTrigger.channelId,
79      weekday: userFacingTrigger.weekday,
80      hour: userFacingTrigger.hour,
81      minute: userFacingTrigger.minute,
82    };
83  } else if (isYearlyTriggerInput(userFacingTrigger)) {
84    validateDateComponentsInTrigger(userFacingTrigger, YEARLY_TRIGGER_EXPECTED_DATE_COMPONENTS);
85    return {
86      type: 'yearly',
87      channelId: userFacingTrigger.channelId,
88      day: userFacingTrigger.day,
89      month: userFacingTrigger.month,
90      hour: userFacingTrigger.hour,
91      minute: userFacingTrigger.minute,
92    };
93  } else if (isSecondsPropertyMisusedInCalendarTriggerInput(userFacingTrigger)) {
94    throw new TypeError(
95      '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`.'
96    );
97  } else if ('seconds' in userFacingTrigger) {
98    return {
99      type: 'timeInterval',
100      channelId: userFacingTrigger.channelId,
101      seconds: userFacingTrigger.seconds,
102      repeats: userFacingTrigger.repeats ?? false,
103    };
104  } else if (isCalendarTrigger(userFacingTrigger)) {
105    const { repeats, ...calendarTrigger } = userFacingTrigger;
106    return { type: 'calendar', value: calendarTrigger, repeats };
107  } else {
108    return Platform.select({
109      default: null, // There's no notion of channels on platforms other than Android.
110      android: { type: 'channel', channelId: userFacingTrigger.channelId },
111    });
112  }
113}
114
115function isCalendarTrigger(
116  trigger: CalendarTriggerInput | ChannelAwareTriggerInput
117): trigger is CalendarTriggerInput {
118  const { channelId, ...triggerWithoutChannelId } = trigger;
119  return Object.keys(triggerWithoutChannelId).length > 0;
120}
121
122function isDateTrigger(
123  trigger:
124    | DateTriggerInput
125    | WeeklyTriggerInput
126    | DailyTriggerInput
127    | CalendarTriggerInput
128    | TimeIntervalTriggerInput
129): trigger is DateTriggerInput {
130  return (
131    trigger instanceof Date ||
132    typeof trigger === 'number' ||
133    (typeof trigger === 'object' && 'date' in trigger)
134  );
135}
136
137function parseDateTrigger(trigger: DateTriggerInput): NativeNotificationTriggerInput {
138  if (trigger instanceof Date || typeof trigger === 'number') {
139    return { type: 'date', timestamp: toTimestamp(trigger) };
140  }
141  return { type: 'date', timestamp: toTimestamp(trigger.date), channelId: trigger.channelId };
142}
143
144function toTimestamp(date: number | Date) {
145  if (date instanceof Date) {
146    return date.getTime();
147  }
148  return date;
149}
150
151function isDailyTriggerInput(
152  trigger: SchedulableNotificationTriggerInput
153): trigger is DailyTriggerInput {
154  if (typeof trigger !== 'object') return false;
155  const { channelId, ...triggerWithoutChannelId } = trigger as DailyTriggerInput;
156  return (
157    Object.keys(triggerWithoutChannelId).length ===
158      DAILY_TRIGGER_EXPECTED_DATE_COMPONENTS.length + 1 &&
159    DAILY_TRIGGER_EXPECTED_DATE_COMPONENTS.every(
160      component => component in triggerWithoutChannelId
161    ) &&
162    'repeats' in triggerWithoutChannelId &&
163    triggerWithoutChannelId.repeats === true
164  );
165}
166
167function isWeeklyTriggerInput(
168  trigger: SchedulableNotificationTriggerInput
169): trigger is WeeklyTriggerInput {
170  if (typeof trigger !== 'object') return false;
171  const { channelId, ...triggerWithoutChannelId } = trigger as WeeklyTriggerInput;
172  return (
173    Object.keys(triggerWithoutChannelId).length ===
174      WEEKLY_TRIGGER_EXPECTED_DATE_COMPONENTS.length + 1 &&
175    WEEKLY_TRIGGER_EXPECTED_DATE_COMPONENTS.every(
176      component => component in triggerWithoutChannelId
177    ) &&
178    'repeats' in triggerWithoutChannelId &&
179    triggerWithoutChannelId.repeats === true
180  );
181}
182
183function isYearlyTriggerInput(
184  trigger: SchedulableNotificationTriggerInput
185): trigger is YearlyTriggerInput {
186  if (typeof trigger !== 'object') return false;
187  const { channelId, ...triggerWithoutChannelId } = trigger as YearlyTriggerInput;
188  return (
189    Object.keys(triggerWithoutChannelId).length ===
190      YEARLY_TRIGGER_EXPECTED_DATE_COMPONENTS.length + 1 &&
191    YEARLY_TRIGGER_EXPECTED_DATE_COMPONENTS.every(
192      component => component in triggerWithoutChannelId
193    ) &&
194    'repeats' in triggerWithoutChannelId &&
195    triggerWithoutChannelId.repeats === true
196  );
197}
198
199function isSecondsPropertyMisusedInCalendarTriggerInput(
200  trigger: TimeIntervalTriggerInput | CalendarTriggerInput
201) {
202  const { channelId, ...triggerWithoutChannelId } = trigger;
203  return (
204    // eg. { seconds: ..., repeats: ..., hour: ... }
205    ('seconds' in triggerWithoutChannelId &&
206      'repeats' in triggerWithoutChannelId &&
207      Object.keys(triggerWithoutChannelId).length > 2) ||
208    // eg. { seconds: ..., hour: ... }
209    ('seconds' in triggerWithoutChannelId &&
210      !('repeats' in triggerWithoutChannelId) &&
211      Object.keys(triggerWithoutChannelId).length > 1)
212  );
213}
214
215function validateDateComponentsInTrigger(
216  trigger: NonNullable<NotificationTriggerInput>,
217  components: readonly ValidTriggerDateComponents[]
218) {
219  const anyTriggerType = trigger as any;
220  components.forEach(component => {
221    if (!(component in anyTriggerType)) {
222      throw new TypeError(`The ${component} parameter needs to be present`);
223    }
224    if (typeof anyTriggerType[component] !== 'number') {
225      throw new TypeError(`The ${component} parameter should be a number`);
226    }
227    switch (component) {
228      case 'month': {
229        const { month } = anyTriggerType;
230        if (month < 0 || month > 11) {
231          throw new RangeError(`The month parameter needs to be between 0 and 11. Found: ${month}`);
232        }
233        break;
234      }
235      case 'day': {
236        const { day, month } = anyTriggerType;
237        const daysInGivenMonth = daysInMonth(month);
238        if (day < 1 || day > daysInGivenMonth) {
239          throw new RangeError(
240            `The day parameter for month ${month} must be between 1 and ${daysInGivenMonth}. Found: ${day}`
241          );
242        }
243        break;
244      }
245      case 'weekday': {
246        const { weekday } = anyTriggerType;
247        if (weekday < 1 || weekday > 7) {
248          throw new RangeError(
249            `The weekday parameter needs to be between 1 and 7. Found: ${weekday}`
250          );
251        }
252        break;
253      }
254      case 'hour': {
255        const { hour } = anyTriggerType;
256        if (hour < 0 || hour > 23) {
257          throw new RangeError(`The hour parameter needs to be between 0 and 23. Found: ${hour}`);
258        }
259        break;
260      }
261      case 'minute': {
262        const { minute } = anyTriggerType;
263        if (minute < 0 || minute > 59) {
264          throw new RangeError(
265            `The minute parameter needs to be between 0 and 59. Found: ${minute}`
266          );
267        }
268        break;
269      }
270    }
271  });
272}
273
274/**
275 * Determines the number of days in the given month (or January if omitted).
276 * If year is specified, it will include leap year logic, else it will always assume a leap year
277 */
278function daysInMonth(month: number = 0, year?: number) {
279  return new Date(year ?? 2000, month + 1, 0).getDate();
280}
281