one
Version:
One is a React framework for web, iOS, and Android apps.
512 lines (466 loc) • 16.9 kB
text/typescript
import { Platform } from 'react-native'
import { type AnyMap, NitroModules, type ValueType } from 'react-native-nitro-modules'
import { rethrowNativeError } from '../nativeError'
import type {
NativeChannel,
NativeContent,
NativeNotification,
NativeNotificationAction,
NativeNotificationCategory,
NativeNotificationRequest,
NativeNotificationResponse,
NativePermissionResponse,
NativeTrigger,
OneNotifications,
} from '../specs/OneNotifications.nitro'
import { ForegroundHandler } from './handlerState'
import type {
DevicePushToken,
Notification,
NotificationAction,
NotificationCategory,
NotificationCategoryOptions,
NotificationChannel,
NotificationChannelInput,
NotificationContent,
NotificationHandlerInput,
NotificationPermissionRequest,
NotificationPermissionResponse,
NotificationRequest,
NotificationResponse,
NotificationScheduleInput,
NotificationSubscription,
NotificationTrigger,
ScheduledNotification,
} from './types'
import {
DEFAULT_ACTION_IDENTIFIER,
assertCategoryActions,
assertCategoryIdentifier,
assertCategoryOptions,
fromNativeAuthorizationStatus,
fromNativeImportance,
toNativeImportance,
} from './types'
export type * from './types'
// the OneNotifications nitro hybrid object is created on first use and cached.
let hybrid: OneNotifications | undefined
function native(): OneNotifications {
hybrid ??= NitroModules.createHybridObject<OneNotifications>('OneNotifications')
return hybrid
}
function mapPermissionResponse(
response: NativePermissionResponse
): NotificationPermissionResponse {
return {
status: response.status,
granted: response.granted,
canAskAgain: response.canAskAgain,
...(response.ios
? { ios: { status: fromNativeAuthorizationStatus(response.ios.status) } }
: {}),
}
}
// the current notification authorization, without prompting.
async function getPermissions(): Promise<NotificationPermissionResponse> {
return mapPermissionResponse(await native().getPermissions().catch(rethrowNativeError))
}
// prompt for notification authorization. on android below 13 there is no
// runtime prompt, so this resolves the current status instead.
async function requestPermissions(
options: NotificationPermissionRequest = {}
): Promise<NotificationPermissionResponse> {
return mapPermissionResponse(
await native().requestPermissions(options).catch(rethrowNativeError)
)
}
// the app icon badge count. always 0 on android.
async function getBadgeCount(): Promise<number> {
return native().getBadgeCount().catch(rethrowNativeError)
}
// set the app icon badge count. resolves false on android: the launcher
// owns badges there.
async function setBadgeCount(count: number): Promise<boolean> {
return native().setBadgeCount(count).catch(rethrowNativeError)
}
function mapChannel(channel: NativeChannel): NotificationChannel {
return {
id: channel.id,
name: channel.name,
importance: fromNativeImportance(channel.importance),
description: channel.description,
sound: channel.sound,
vibrationPattern: channel.vibrationPattern,
showBadge: channel.showBadge,
}
}
// create an android notification channel. settings apply on first create;
// afterwards the user owns them in system settings. resolves null on ios,
// which has no channels.
async function setChannel(
channelId: string,
channel: NotificationChannelInput
): Promise<NotificationChannel | null> {
const importance = toNativeImportance(channel.importance)
if (Platform.OS !== 'android') return null
const created = await native()
.setNotificationChannel(channelId, { ...channel, importance })
.catch(rethrowNativeError)
return created ? mapChannel(created) : null
}
async function getChannel(channelId: string): Promise<NotificationChannel | null> {
if (Platform.OS !== 'android') return null
const found = await native()
.getNotificationChannel(channelId)
.catch(rethrowNativeError)
return found ? mapChannel(found) : null
}
async function getChannels(): Promise<NotificationChannel[]> {
if (Platform.OS !== 'android') return []
const channels = await native().getNotificationChannels().catch(rethrowNativeError)
return channels.map(mapChannel)
}
async function deleteChannel(channelId: string): Promise<void> {
if (Platform.OS !== 'android') return
await native().deleteNotificationChannel(channelId).catch(rethrowNativeError)
}
// native flattens nulls to absent fields and trigger variants to one
// struct; these restore the public shapes.
function mapTrigger(trigger: NativeTrigger): NotificationTrigger {
switch (trigger.type) {
case 'timeInterval':
return {
type: 'timeInterval',
seconds: trigger.seconds ?? 0,
repeats: trigger.repeats ?? false,
}
case 'date':
return { type: 'date', date: trigger.date ?? 0 }
case 'push':
return { type: 'push' }
default:
return { type: 'unknown' }
}
}
function mapContent(content: NativeContent): NotificationContent {
return {
title: content.title ?? null,
subtitle: content.subtitle ?? null,
body: content.body ?? null,
data: content.data,
sound: content.sound,
badge: content.badge ?? null,
categoryIdentifier: content.categoryIdentifier ?? null,
}
}
function mapRequest(request: NativeNotificationRequest): NotificationRequest {
return {
identifier: request.identifier,
content: mapContent(request.content),
trigger: mapTrigger(request.trigger),
}
}
function mapNotification(notification: NativeNotification): Notification {
return { request: mapRequest(notification.request), date: notification.date }
}
function mapResponse(response: NativeNotificationResponse): NotificationResponse {
const mapped: NotificationResponse = {
notification: mapNotification(response.notification),
actionIdentifier: response.actionIdentifier,
}
if (response.userText !== undefined) {
mapped.userText = response.userText
}
return mapped
}
// expo names are setNotificationCategoryAsync and friends: the fixed rule
// drops Async and the namespace noun, so these are setCategory,
// getCategories and deleteCategory.
function toNativeAction(action: NotificationAction): NativeNotificationAction {
const native: NativeNotificationAction = {
identifier: action.identifier,
buttonTitle: action.buttonTitle,
authenticationRequired: action.options?.isAuthenticationRequired ?? false,
destructive: action.options?.isDestructive ?? false,
foreground: action.options?.opensAppToForeground ?? true,
}
if (action.textInput !== undefined) {
native.textInputPlaceholder = action.textInput.placeholder
if (action.textInput.submitButtonTitle !== undefined) {
native.textInputSubmitTitle = action.textInput.submitButtonTitle
}
}
return native
}
function fromNativeAction(action: NativeNotificationAction): NotificationAction {
const mapped: NotificationAction = {
identifier: action.identifier,
buttonTitle: action.buttonTitle,
options: {
isAuthenticationRequired: action.authenticationRequired,
isDestructive: action.destructive,
opensAppToForeground: action.foreground,
},
}
if (action.textInputPlaceholder !== undefined) {
mapped.textInput = { placeholder: action.textInputPlaceholder }
if (action.textInputSubmitTitle !== undefined) {
mapped.textInput.submitButtonTitle = action.textInputSubmitTitle
}
}
return mapped
}
function mapCategory(category: NativeNotificationCategory): NotificationCategory {
const mapped: NotificationCategory = {
identifier: category.identifier,
actions: category.actions.map(fromNativeAction),
}
if (category.options !== undefined) {
const options: NotificationCategoryOptions = {}
if (category.options.allowInCarPlay !== undefined) {
options.allowInCarPlay = category.options.allowInCarPlay
}
if (category.options.categorySummaryFormat !== undefined) {
options.categorySummaryFormat = category.options.categorySummaryFormat
}
if (category.options.customDismissAction !== undefined) {
options.customDismissAction = category.options.customDismissAction
}
if (category.options.intentIdentifiers !== undefined) {
options.intentIdentifiers = [...category.options.intentIdentifiers]
}
if (category.options.previewPlaceholder !== undefined) {
options.previewPlaceholder = category.options.previewPlaceholder
}
if (category.options.showTitle !== undefined) {
options.showTitle = category.options.showTitle
}
if (category.options.showSubtitle !== undefined) {
options.showSubtitle = category.options.showSubtitle
}
mapped.options = options
}
return mapped
}
// plain, not async, so bad input throws synchronously like the picker
// entries; registering replaces the identifier, like expo.
function setCategory(
identifier: string,
actions: NotificationAction[],
options?: NotificationCategoryOptions
): Promise<NotificationCategory> {
assertCategoryIdentifier(identifier, 'setCategory')
assertCategoryActions(actions, 'setCategory')
assertCategoryOptions(options, 'setCategory')
const category: NativeNotificationCategory = {
identifier,
actions: actions.map(toNativeAction),
}
if (options !== undefined) {
// allowAnnouncement is accepted and ignored by the platform, like expo.
const { allowAnnouncement: _ignored, ...nativeOptions } = options
category.options = { ...nativeOptions }
}
return native()
.setNotificationCategory(category)
.catch(rethrowNativeError)
.then(mapCategory)
}
function getCategories(): Promise<NotificationCategory[]> {
return native()
.getNotificationCategories()
.catch(rethrowNativeError)
.then((categories) => categories.map(mapCategory))
}
function deleteCategory(identifier: string): Promise<boolean> {
assertCategoryIdentifier(identifier, 'deleteCategory')
return native().deleteNotificationCategory(identifier).catch(rethrowNativeError)
}
// content data crosses nitro as an AnyMap: json values only. undefined and
// other non-json entries drop, as the bridge serializer did.
function toValue(value: unknown): ValueType | undefined {
if (
value === null ||
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean'
) {
return value
}
if (Array.isArray(value)) return value.map((item) => toValue(item) ?? null)
if (typeof value === 'object') return toAnyMap(Object.entries(value))
return undefined
}
function toAnyMap(entries: [string, unknown][]): AnyMap {
const map: AnyMap = {}
for (const [key, item] of entries) {
const value = toValue(item)
if (value !== undefined) map[key] = value
}
return map
}
// one native listener set, fanned out to js listeners, plus the foreground
// runner that answers native's presentation round trip.
const receivedListeners = new Set<(notification: Notification) => void>()
const responseListeners = new Set<(response: NotificationResponse) => void>()
const pushTokenListeners = new Set<(token: DevicePushToken) => void>()
let sharedRunner: ForegroundHandler | null = null
function events(module: OneNotifications): ForegroundHandler {
if (!sharedRunner) {
// native drops unknown ids and shows everything after 3s on its own.
const runner = new ForegroundHandler((requestId, behavior) =>
module.presentNotification(requestId, behavior)
)
module.setListeners(
(requestId, nativeNotification) => {
const notification = mapNotification(nativeNotification)
if (!runner.receive(requestId, notification)) return
receivedListeners.forEach((listener) => listener(notification))
},
(nativeResponse) => {
const response = mapResponse(nativeResponse)
responseListeners.forEach((listener) => listener(response))
},
(token) => {
if (token.type !== 'ios' && token.type !== 'android') return
const mapped: DevicePushToken = { type: token.type, data: token.data }
pushTokenListeners.forEach((listener) => listener(mapped))
}
)
sharedRunner = runner
}
return sharedRunner
}
function subscribe<T>(
listeners: Set<(value: T) => void>,
listener: (value: T) => void
): NotificationSubscription {
events(native())
listeners.add(listener)
return {
remove: () => {
listeners.delete(listener)
},
}
}
// fire when a notification arrives while the app runs in the foreground.
function addReceivedListener(
listener: (notification: Notification) => void
): NotificationSubscription {
return subscribe(receivedListeners, listener)
}
// fire when the user taps a notification while the app runs. a tap that
// cold-starts the app surfaces through getLastResponse instead.
function addResponseReceivedListener(
listener: (response: NotificationResponse) => void
): NotificationSubscription {
return subscribe(responseListeners, listener)
}
// the remote push token: the apns hex token on ios, the fcm registration
// token on android. rejects when push is not enabled and when the platform
// returns no token.
async function getDevicePushToken(): Promise<DevicePushToken> {
const token = await native().getDevicePushToken().catch(rethrowNativeError)
const type =
token && (token.type === 'ios' || token.type === 'android') ? token.type : null
if (!token || type === null || typeof token.data !== 'string' || !token.data.length) {
throw new Error('Notifications.getDevicePushToken did not return a push token')
}
return { type, data: token.data }
}
// fire when the push token refreshes. registering does not fire: read the
// current token with getDevicePushToken.
function addPushTokenListener(
listener: (token: DevicePushToken) => void
): NotificationSubscription {
return subscribe(pushTokenListeners, listener)
}
// decide how an arriving foreground notification presents. until the app
// sets a handler the notification shows fully, like expo. setting null
// leaves native undecided, which hides it on both platforms.
function setHandler(handler: NotificationHandlerInput | null): void {
events(native()).setHandler(handler)
}
// the response that last tapped the app awake, if any. synchronous, like expo.
function getLastResponse(): NotificationResponse | null {
const response = native().getLastNotificationResponse()
return response ? mapResponse(response) : null
}
function clearLastResponse(): void {
native().clearLastNotificationResponse()
}
// schedule a local notification. trigger null delivers immediately,
// a timeInterval waits seconds, a date fires at the timestamp. a past date
// delivers immediately on both platforms.
async function schedule(request: NotificationScheduleInput): Promise<string> {
const { content, trigger } = request
return native()
.scheduleNotification({
identifier: request.identifier,
content: {
...content,
data: content.data ? toAnyMap(Object.entries(content.data)) : undefined,
},
trigger:
trigger == null
? undefined
: trigger.type === 'date'
? {
...trigger,
date:
trigger.date instanceof Date ? trigger.date.getTime() : trigger.date,
}
: trigger,
})
.catch(rethrowNativeError)
}
async function cancelScheduled(identifier: string): Promise<void> {
await native().cancelScheduledNotification(identifier).catch(rethrowNativeError)
}
async function cancelAllScheduled(): Promise<void> {
await native().cancelAllScheduledNotifications().catch(rethrowNativeError)
}
async function getAllScheduled(): Promise<ScheduledNotification[]> {
const scheduled = await native()
.getAllScheduledNotifications()
.catch(rethrowNativeError)
return scheduled.map(mapRequest)
}
async function getPresented(): Promise<Notification[]> {
const presented = await native().getPresentedNotifications().catch(rethrowNativeError)
return presented.map(mapNotification)
}
async function dismiss(identifier: string): Promise<void> {
await native().dismissNotification(identifier).catch(rethrowNativeError)
}
async function dismissAll(): Promise<void> {
await native().dismissAllNotifications().catch(rethrowNativeError)
}
export const Notifications = Object.freeze({
getPermissions,
requestPermissions,
getBadgeCount,
setBadgeCount,
setCategory,
getCategories,
deleteCategory,
setChannel,
getChannel,
getChannels,
deleteChannel,
addReceivedListener,
addResponseReceivedListener,
getDevicePushToken,
addPushTokenListener,
setHandler,
getLastResponse,
clearLastResponse,
schedule,
cancelScheduled,
cancelAllScheduled,
getAllScheduled,
getPresented,
dismiss,
dismissAll,
DEFAULT_ACTION_IDENTIFIER,
})