UNPKG

one

Version:

One is a React framework for web, iOS, and Android apps.

512 lines (466 loc) • 16.9 kB
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, })