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