expo-notifications
Version:
Notifications module
335 lines (316 loc) • 11.3 kB
text/typescript
import { Platform, UnavailabilityError, uuidv4 } from 'expo-modules-core';
import NotificationScheduler from './NotificationScheduler';
import { NotificationTriggerInput as NativeNotificationTriggerInput } from './NotificationScheduler.types';
import {
NotificationRequestInput,
NotificationTriggerInput,
DailyTriggerInput,
WeeklyTriggerInput,
YearlyTriggerInput,
CalendarTriggerInput,
TimeIntervalTriggerInput,
DateTriggerInput,
ChannelAwareTriggerInput,
SchedulableNotificationTriggerInput,
} from './Notifications.types';
/**
* Schedules a notification to be triggered in the future.
* > **Note:** Please note that this does not mean that the notification will be presented when it is triggered.
* For the notification to be presented you have to set a notification handler with [`setNotificationHandler`](#notificationssetnotificationhandlerhandler)
* that will return an appropriate notification behavior. For more information see the example below.
* @param request An object describing the notification to be triggered.
* @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.
* @example
* # Schedule the notification that will trigger once, in one minute from now
* ```ts
* import * as Notifications from 'expo-notifications';
*
* Notifications.scheduleNotificationAsync({
* content: {
* title: "Time's up!",
* body: 'Change sides!',
* },
* trigger: {
* seconds: 60,
* },
* });
* ```
*
* # Schedule the notification that will trigger repeatedly, every 20 minutes
* ```ts
* import * as Notifications from 'expo-notifications';
*
* Notifications.scheduleNotificationAsync({
* content: {
* title: 'Remember to drink water!',
* },
* trigger: {
* seconds: 60 * 20,
* repeats: true,
* },
* });
* ```
*
* # Schedule the notification that will trigger once, at the beginning of next hour
* ```ts
* import * as Notifications from 'expo-notifications';
*
* const trigger = new Date(Date.now() + 60 * 60 * 1000);
* trigger.setMinutes(0);
* trigger.setSeconds(0);
*
* Notifications.scheduleNotificationAsync({
* content: {
* title: 'Happy new hour!',
* },
* trigger,
* });
* ```
* @header schedule
*/
export default async function scheduleNotificationAsync(
request: NotificationRequestInput
): Promise<string> {
if (!NotificationScheduler.scheduleNotificationAsync) {
throw new UnavailabilityError('Notifications', 'scheduleNotificationAsync');
}
return await NotificationScheduler.scheduleNotificationAsync(
request.identifier ?? uuidv4(),
request.content,
parseTrigger(request.trigger)
);
}
type ValidTriggerDateComponents = 'month' | 'day' | 'weekday' | 'hour' | 'minute';
const DAILY_TRIGGER_EXPECTED_DATE_COMPONENTS: readonly ValidTriggerDateComponents[] = [
'hour',
'minute',
];
const WEEKLY_TRIGGER_EXPECTED_DATE_COMPONENTS: readonly ValidTriggerDateComponents[] = [
'weekday',
'hour',
'minute',
];
const YEARLY_TRIGGER_EXPECTED_DATE_COMPONENTS: readonly ValidTriggerDateComponents[] = [
'day',
'month',
'hour',
'minute',
];
export function parseTrigger(
userFacingTrigger: NotificationTriggerInput
): NativeNotificationTriggerInput {
if (userFacingTrigger === null) {
return null;
}
if (userFacingTrigger === undefined) {
throw new TypeError(
'Encountered an `undefined` notification trigger. If you want to trigger the notification immediately, pass in an explicit `null` value.'
);
}
if (isDateTrigger(userFacingTrigger)) {
return parseDateTrigger(userFacingTrigger);
} else if (isDailyTriggerInput(userFacingTrigger)) {
validateDateComponentsInTrigger(userFacingTrigger, DAILY_TRIGGER_EXPECTED_DATE_COMPONENTS);
return {
type: 'daily',
channelId: userFacingTrigger.channelId,
hour: userFacingTrigger.hour,
minute: userFacingTrigger.minute,
};
} else if (isWeeklyTriggerInput(userFacingTrigger)) {
validateDateComponentsInTrigger(userFacingTrigger, WEEKLY_TRIGGER_EXPECTED_DATE_COMPONENTS);
return {
type: 'weekly',
channelId: userFacingTrigger.channelId,
weekday: userFacingTrigger.weekday,
hour: userFacingTrigger.hour,
minute: userFacingTrigger.minute,
};
} else if (isYearlyTriggerInput(userFacingTrigger)) {
validateDateComponentsInTrigger(userFacingTrigger, YEARLY_TRIGGER_EXPECTED_DATE_COMPONENTS);
return {
type: 'yearly',
channelId: userFacingTrigger.channelId,
day: userFacingTrigger.day,
month: userFacingTrigger.month,
hour: userFacingTrigger.hour,
minute: userFacingTrigger.minute,
};
} else if (isSecondsPropertyMisusedInCalendarTriggerInput(userFacingTrigger)) {
throw new TypeError(
'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`.'
);
} else if ('seconds' in userFacingTrigger) {
return {
type: 'timeInterval',
channelId: userFacingTrigger.channelId,
seconds: userFacingTrigger.seconds,
repeats: userFacingTrigger.repeats ?? false,
};
} else if (isCalendarTrigger(userFacingTrigger)) {
const { repeats, ...calendarTrigger } = userFacingTrigger;
return { type: 'calendar', value: calendarTrigger, repeats };
} else {
return Platform.select({
default: null, // There's no notion of channels on platforms other than Android.
android: { type: 'channel', channelId: userFacingTrigger.channelId },
});
}
}
function isCalendarTrigger(
trigger: CalendarTriggerInput | ChannelAwareTriggerInput
): trigger is CalendarTriggerInput {
const { channelId, ...triggerWithoutChannelId } = trigger;
return Object.keys(triggerWithoutChannelId).length > 0;
}
function isDateTrigger(
trigger:
| DateTriggerInput
| WeeklyTriggerInput
| DailyTriggerInput
| CalendarTriggerInput
| TimeIntervalTriggerInput
): trigger is DateTriggerInput {
return (
trigger instanceof Date ||
typeof trigger === 'number' ||
(typeof trigger === 'object' && 'date' in trigger)
);
}
function parseDateTrigger(trigger: DateTriggerInput): NativeNotificationTriggerInput {
if (trigger instanceof Date || typeof trigger === 'number') {
return { type: 'date', timestamp: toTimestamp(trigger) };
}
return { type: 'date', timestamp: toTimestamp(trigger.date), channelId: trigger.channelId };
}
function toTimestamp(date: number | Date) {
if (date instanceof Date) {
return date.getTime();
}
return date;
}
function isDailyTriggerInput(
trigger: SchedulableNotificationTriggerInput
): trigger is DailyTriggerInput {
if (typeof trigger !== 'object') return false;
const { channelId, ...triggerWithoutChannelId } = trigger as DailyTriggerInput;
return (
Object.keys(triggerWithoutChannelId).length ===
DAILY_TRIGGER_EXPECTED_DATE_COMPONENTS.length + 1 &&
DAILY_TRIGGER_EXPECTED_DATE_COMPONENTS.every(
(component) => component in triggerWithoutChannelId
) &&
'repeats' in triggerWithoutChannelId &&
triggerWithoutChannelId.repeats === true
);
}
function isWeeklyTriggerInput(
trigger: SchedulableNotificationTriggerInput
): trigger is WeeklyTriggerInput {
if (typeof trigger !== 'object') return false;
const { channelId, ...triggerWithoutChannelId } = trigger as WeeklyTriggerInput;
return (
Object.keys(triggerWithoutChannelId).length ===
WEEKLY_TRIGGER_EXPECTED_DATE_COMPONENTS.length + 1 &&
WEEKLY_TRIGGER_EXPECTED_DATE_COMPONENTS.every(
(component) => component in triggerWithoutChannelId
) &&
'repeats' in triggerWithoutChannelId &&
triggerWithoutChannelId.repeats === true
);
}
function isYearlyTriggerInput(
trigger: SchedulableNotificationTriggerInput
): trigger is YearlyTriggerInput {
if (typeof trigger !== 'object') return false;
const { channelId, ...triggerWithoutChannelId } = trigger as YearlyTriggerInput;
return (
Object.keys(triggerWithoutChannelId).length ===
YEARLY_TRIGGER_EXPECTED_DATE_COMPONENTS.length + 1 &&
YEARLY_TRIGGER_EXPECTED_DATE_COMPONENTS.every(
(component) => component in triggerWithoutChannelId
) &&
'repeats' in triggerWithoutChannelId &&
triggerWithoutChannelId.repeats === true
);
}
function isSecondsPropertyMisusedInCalendarTriggerInput(
trigger: TimeIntervalTriggerInput | CalendarTriggerInput
) {
const { channelId, ...triggerWithoutChannelId } = trigger;
return (
// eg. { seconds: ..., repeats: ..., hour: ... }
('seconds' in triggerWithoutChannelId &&
'repeats' in triggerWithoutChannelId &&
Object.keys(triggerWithoutChannelId).length > 2) ||
// eg. { seconds: ..., hour: ... }
('seconds' in triggerWithoutChannelId &&
!('repeats' in triggerWithoutChannelId) &&
Object.keys(triggerWithoutChannelId).length > 1)
);
}
function validateDateComponentsInTrigger(
trigger: NonNullable<NotificationTriggerInput>,
components: readonly ValidTriggerDateComponents[]
) {
const anyTriggerType = trigger as any;
components.forEach((component) => {
if (!(component in anyTriggerType)) {
throw new TypeError(`The ${component} parameter needs to be present`);
}
if (typeof anyTriggerType[component] !== 'number') {
throw new TypeError(`The ${component} parameter should be a number`);
}
switch (component) {
case 'month': {
const { month } = anyTriggerType;
if (month < 0 || month > 11) {
throw new RangeError(`The month parameter needs to be between 0 and 11. Found: ${month}`);
}
break;
}
case 'day': {
const { day, month } = anyTriggerType;
const daysInGivenMonth = daysInMonth(month);
if (day < 1 || day > daysInGivenMonth) {
throw new RangeError(
`The day parameter for month ${month} must be between 1 and ${daysInGivenMonth}. Found: ${day}`
);
}
break;
}
case 'weekday': {
const { weekday } = anyTriggerType;
if (weekday < 1 || weekday > 7) {
throw new RangeError(
`The weekday parameter needs to be between 1 and 7. Found: ${weekday}`
);
}
break;
}
case 'hour': {
const { hour } = anyTriggerType;
if (hour < 0 || hour > 23) {
throw new RangeError(`The hour parameter needs to be between 0 and 23. Found: ${hour}`);
}
break;
}
case 'minute': {
const { minute } = anyTriggerType;
if (minute < 0 || minute > 59) {
throw new RangeError(
`The minute parameter needs to be between 0 and 59. Found: ${minute}`
);
}
break;
}
}
});
}
/**
* Determines the number of days in the given month (or January if omitted).
* If year is specified, it will include leap year logic, else it will always assume a leap year
*/
function daysInMonth(month: number = 0, year?: number) {
return new Date(year ?? 2000, month + 1, 0).getDate();
}