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