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