expo-notifications
Version:
Provides an API to fetch push notification tokens and to present, schedule, receive, and respond to notifications.
387 lines (368 loc) • 12 kB
text/typescript
import { Platform, UnavailabilityError, uuid } from 'expo-modules-core';
import NotificationScheduler from './NotificationScheduler';
import {
NativeCalendarTriggerInput,
NativeDailyTriggerInput,
NativeDateTriggerInput,
NativeNotificationTriggerInput,
NativeTimeIntervalTriggerInput,
NativeWeeklyTriggerInput,
NativeMonthlyTriggerInput,
NativeYearlyTriggerInput,
} from './NotificationScheduler.types';
import {
NotificationRequestInput,
NotificationTriggerInput,
SchedulableTriggerInputTypes,
} 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`](#setnotificationhandlerhandler)
* 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: {
* type: SchedulableTriggerInputTypes.TIME_INTERVAL,
* 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: {
* type: SchedulableTriggerInputTypes.TIME_INTERVAL,
* 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 ?? uuid.v4(),
request.content,
parseTrigger(request.trigger)
);
}
type ValidTriggerDateComponents = 'month' | 'day' | 'weekday' | '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.'
);
}
const dateTrigger = parseDateTrigger(userFacingTrigger);
if (dateTrigger) {
return dateTrigger;
}
const calendarTrigger = parseCalendarTrigger(userFacingTrigger);
if (calendarTrigger) {
return calendarTrigger;
}
const dailyTrigger = parseDailyTrigger(userFacingTrigger);
if (dailyTrigger) {
return dailyTrigger;
}
const weeklyTrigger = parseWeeklyTrigger(userFacingTrigger);
if (weeklyTrigger) {
return weeklyTrigger;
}
const monthlyTrigger = parseMonthlyTrigger(userFacingTrigger);
if (monthlyTrigger) {
return monthlyTrigger;
}
const yearlyTrigger = parseYearlyTrigger(userFacingTrigger);
if (yearlyTrigger) {
return yearlyTrigger;
}
const timeIntervalTrigger = parseTimeIntervalTrigger(userFacingTrigger);
if (timeIntervalTrigger) {
return timeIntervalTrigger;
}
return Platform.select({
default: null, // There's no notion of channels on platforms other than Android.
android: {
type: 'channel',
channelId:
typeof userFacingTrigger === 'object' &&
userFacingTrigger !== null &&
!(userFacingTrigger instanceof Date)
? userFacingTrigger?.channelId
: undefined,
},
});
}
function parseCalendarTrigger(
trigger: NotificationTriggerInput
): NativeCalendarTriggerInput | undefined {
if (
trigger !== null &&
typeof trigger === 'object' &&
'type' in trigger &&
trigger.type === SchedulableTriggerInputTypes.CALENDAR
) {
const { repeats, ...calendarTrigger } = trigger;
return { type: 'calendar', value: calendarTrigger, repeats };
}
return undefined;
}
function parseDateTrigger(trigger: NotificationTriggerInput): NativeDateTriggerInput | undefined {
if (trigger instanceof Date || typeof trigger === 'number') {
// TODO @vonovak this branch is not be used by people using TS
// but was part of the public api previously so we keep it for a bit for JS users
console.warn(
`You are using a deprecated parameter type (${trigger}) for the notification trigger. Use "{ type: 'date', timestamp: someValue }" instead.`
);
return { type: 'date', timestamp: toTimestamp(trigger) };
} else if (
typeof trigger === 'object' &&
trigger !== null &&
'type' in trigger &&
trigger.type === SchedulableTriggerInputTypes.DATE &&
'date' in trigger
) {
const result: NativeDateTriggerInput = {
type: 'date',
timestamp: toTimestamp(trigger.date),
};
if (trigger.channelId) {
result.channelId = trigger.channelId;
}
return result;
} else {
return undefined;
}
}
function toTimestamp(date: number | Date) {
if (date instanceof Date) {
return date.getTime();
}
return date;
}
function parseDailyTrigger(trigger: NotificationTriggerInput): NativeDailyTriggerInput | undefined {
if (
trigger !== null &&
typeof trigger === 'object' &&
'type' in trigger &&
trigger.type === SchedulableTriggerInputTypes.DAILY
) {
validateDateComponentsInTrigger(trigger, ['hour', 'minute']);
const result: NativeDailyTriggerInput = {
type: 'daily',
hour: trigger.hour ?? placeholderDateComponentValue,
minute: trigger.minute ?? placeholderDateComponentValue,
};
if (trigger.channelId) {
result.channelId = trigger.channelId;
}
return result;
}
return undefined;
}
function parseWeeklyTrigger(
trigger: NotificationTriggerInput
): NativeWeeklyTriggerInput | undefined {
if (
trigger !== null &&
typeof trigger === 'object' &&
'type' in trigger &&
trigger.type === SchedulableTriggerInputTypes.WEEKLY
) {
validateDateComponentsInTrigger(trigger, ['weekday', 'hour', 'minute']);
const result: NativeWeeklyTriggerInput = {
type: 'weekly',
weekday: trigger.weekday ?? placeholderDateComponentValue,
hour: trigger.hour ?? placeholderDateComponentValue,
minute: trigger.minute ?? placeholderDateComponentValue,
};
if (trigger.channelId) {
result.channelId = trigger.channelId;
}
return result;
}
return undefined;
}
function parseMonthlyTrigger(
trigger: NotificationTriggerInput
): NativeMonthlyTriggerInput | undefined {
if (
trigger !== null &&
typeof trigger === 'object' &&
'type' in trigger &&
trigger.type === SchedulableTriggerInputTypes.MONTHLY
) {
validateDateComponentsInTrigger(trigger, ['day', 'hour', 'minute']);
const result: NativeMonthlyTriggerInput = {
type: 'monthly',
day: trigger.day ?? placeholderDateComponentValue,
hour: trigger.hour ?? placeholderDateComponentValue,
minute: trigger.minute ?? placeholderDateComponentValue,
};
if (trigger.channelId) {
result.channelId = trigger.channelId;
}
return result;
}
return undefined;
}
function parseYearlyTrigger(
trigger: NotificationTriggerInput
): NativeYearlyTriggerInput | undefined {
if (
trigger !== null &&
typeof trigger === 'object' &&
'type' in trigger &&
trigger.type === SchedulableTriggerInputTypes.YEARLY
) {
validateDateComponentsInTrigger(trigger, ['month', 'day', 'hour', 'minute']);
const result: NativeYearlyTriggerInput = {
type: 'yearly',
month: trigger.month ?? placeholderDateComponentValue,
day: trigger.day ?? placeholderDateComponentValue,
hour: trigger.hour ?? placeholderDateComponentValue,
minute: trigger.minute ?? placeholderDateComponentValue,
};
if (trigger.channelId) {
result.channelId = trigger.channelId;
}
return result;
}
return undefined;
}
function parseTimeIntervalTrigger(
trigger: NotificationTriggerInput
): NativeTimeIntervalTriggerInput | undefined {
if (
trigger !== null &&
typeof trigger === 'object' &&
'type' in trigger &&
trigger.type === SchedulableTriggerInputTypes.TIME_INTERVAL &&
'seconds' in trigger &&
typeof trigger.seconds === 'number'
) {
const result: NativeTimeIntervalTriggerInput = {
type: 'timeInterval',
seconds: trigger.seconds,
repeats: trigger.repeats ?? false,
};
if (trigger.channelId) {
result.channelId = trigger.channelId;
}
return result;
}
return undefined;
}
// Needed only to satisfy Typescript types for validated date components
const placeholderDateComponentValue = -9999;
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 = anyTriggerType.day;
const month =
anyTriggerType.month !== undefined ? anyTriggerType.month : new Date().getMonth();
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();
}