From 28ab0ba9cdf888dece5244863a93420df1072949 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 25 May 2026 19:08:19 +0300 Subject: [PATCH] exclude expo notifications from web bundle --- eslint.config.mjs | 20 +++ src/Navigation.tsx | 9 +- src/lib/hooks/useNotificationHandler.web.ts | 122 ++++++++++++++++++ src/lib/notifications/expo-helpers.ts | 23 ++++ src/lib/notifications/expo-helpers.web.ts | 31 +++++ src/lib/notifications/notifications.web.ts | 28 ++++ .../Settings/NotificationSettings/index.tsx | 9 +- 7 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 src/lib/hooks/useNotificationHandler.web.ts create mode 100644 src/lib/notifications/expo-helpers.ts create mode 100644 src/lib/notifications/expo-helpers.web.ts create mode 100644 src/lib/notifications/notifications.web.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index a47d3606fd..c2220134a4 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -277,6 +277,11 @@ export default defineConfig( message: 'React is already in the global type namespace. Use named imports for runtime modules.', }, + { + name: 'expo-notifications', + message: + 'Import the helpers from #/lib/notifications/expo-helpers (or the hooks from #/lib/notifications/notifications) instead. expo-notifications is stubbed on web; importing it directly pulls ~70KB of dead weight into the web bundle.', + }, ], }, ], @@ -312,6 +317,21 @@ export default defineConfig( }, }, + /** + * Native-only files that legitimately wrap expo-notifications. The .web.ts + * counterparts to these files don't import the package. + */ + { + files: [ + 'src/lib/notifications/notifications.ts', + 'src/lib/notifications/expo-helpers.ts', + 'src/lib/hooks/useNotificationHandler.ts', + ], + rules: { + 'no-restricted-imports': 'off', + }, + }, + /** * Test files configuration */ diff --git a/src/Navigation.tsx b/src/Navigation.tsx index 5f8d715f53..d13be5a8d5 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -1,6 +1,5 @@ import {type JSX, useCallback, useRef} from 'react' import * as Linking from 'expo-linking' -import * as Notifications from 'expo-notifications' import {i18n, type MessageDescriptor} from '@lingui/core' import {msg} from '@lingui/core/macro' import { @@ -29,6 +28,10 @@ import { storePayloadForAccountSwitch, } from '#/lib/hooks/useNotificationHandler' import {useWebScrollRestoration} from '#/lib/hooks/useWebScrollRestoration' +import { + clearLastNotificationResponse, + getLastNotificationResponse, +} from '#/lib/notifications/expo-helpers' import {useCallOnce} from '#/lib/once' import {buildStateObject} from '#/lib/routes/helpers' import { @@ -976,7 +979,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { // intent urls are handled by `useIntentHandler` if (linkingUrl) return - const notificationResponse = Notifications.getLastNotificationResponse() + const notificationResponse = getLastNotificationResponse() if (notificationResponse) { notyLogger.debug(`handlePushNotificationEntry: response`, { @@ -985,7 +988,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { // Clear the last notification response to ensure it's not used again try { - Notifications.clearLastNotificationResponse() + clearLastNotificationResponse() } catch (error) { notyLogger.error( `handlePushNotificationEntry: error clearing notification response`, diff --git a/src/lib/hooks/useNotificationHandler.web.ts b/src/lib/hooks/useNotificationHandler.web.ts new file mode 100644 index 0000000000..4459a505b8 --- /dev/null +++ b/src/lib/hooks/useNotificationHandler.web.ts @@ -0,0 +1,122 @@ +// Web shim for #/lib/hooks/useNotificationHandler. expo-notifications doesn't +// run on web, so the hook is a no-op and the helper functions only deal with +// pure-data payloads we receive from elsewhere. +// +// Keeping this file expo-notifications-free saves ~70KB on web. +import {AtUri} from '@atproto/api' + +export type NotificationReason = + | 'like' + | 'repost' + | 'follow' + | 'mention' + | 'reply' + | 'quote' + | 'chat-message' + | 'chat-reaction' + | 'chat-added-to-group' + | 'chat-removed-from-group' + | 'chat-join-request-rejected' + | 'starterpack-joined' + | 'like-via-repost' + | 'repost-via-repost' + | 'verified' + | 'unverified' + | 'subscribed-post' + +type ChatNotificationReason = Extract + +export type NotificationPayload = + | undefined + | { + reason: Exclude + uri: string + subject: string + recipientDid: string + } + | { + reason: 'chat-message' + convoId: string + messageId: string + recipientDid: string + } + | { + reason: 'chat-reaction' + convoId: string + messageId: string + recipientDid: string + } + | { + reason: + | 'chat-added-to-group' + | 'chat-removed-from-group' + | 'chat-join-request-rejected' + convoId: string + recipientDid: string + } + +export type ChatNotificationPayload = Extract< + NonNullable, + {reason: ChatNotificationReason} +> + +export function isChatNotificationPayload( + payload: NonNullable, +): payload is ChatNotificationPayload { + return payload.reason.startsWith('chat-') +} + +export function useNotificationsHandler() {} + +export function storePayloadForAccountSwitch(_payload: NotificationPayload) {} + +// On web there's no expo-notifications object to inspect; callers from web +// code paths shouldn't invoke this, but we keep a stub that returns null. +export function getNotificationPayload( + _e: unknown, +): NotificationPayload | null { + return null +} + +export function notificationToURL(payload: NotificationPayload): string | null { + switch (payload?.reason) { + case 'like': + case 'repost': + case 'like-via-repost': + case 'repost-via-repost': { + const urip = new AtUri(payload.subject) + if (urip.collection === 'app.bsky.feed.post') { + return `/profile/${urip.host}/post/${urip.rkey}` + } else { + return '/notifications' + } + } + case 'reply': + case 'quote': + case 'mention': + case 'subscribed-post': { + const urip = new AtUri(payload.uri) + if (urip.collection === 'app.bsky.feed.post') { + return `/profile/${urip.host}/post/${urip.rkey}` + } else { + return '/notifications' + } + } + case 'follow': + case 'starterpack-joined': { + const urip = new AtUri(payload.uri) + return `/profile/${urip.host}` + } + case 'chat-message': + case 'chat-reaction': + case 'chat-added-to-group': + case 'chat-removed-from-group': + case 'chat-join-request-rejected': + return null + case 'verified': + case 'unverified': + return '/notifications' + default: + return null + } +} diff --git a/src/lib/notifications/expo-helpers.ts b/src/lib/notifications/expo-helpers.ts new file mode 100644 index 0000000000..0c1d33f06d --- /dev/null +++ b/src/lib/notifications/expo-helpers.ts @@ -0,0 +1,23 @@ +// Thin wrapper around expo-notifications APIs called outside the dedicated +// notifications hooks. The .web.ts variant stubs these out so we don't pull +// expo-notifications into the web bundle. +import * as Notifications from 'expo-notifications' + +export function getLastNotificationResponse() { + return Notifications.getLastNotificationResponse() +} + +export function clearLastNotificationResponse() { + return Notifications.clearLastNotificationResponse() +} + +export function getPermissionsAsync() { + return Notifications.getPermissionsAsync() +} + +export function requestPermissionsAsync() { + return Notifications.requestPermissionsAsync() +} + +export type NotificationPermissionsStatus = + Notifications.NotificationPermissionsStatus diff --git a/src/lib/notifications/expo-helpers.web.ts b/src/lib/notifications/expo-helpers.web.ts new file mode 100644 index 0000000000..de9d83c85d --- /dev/null +++ b/src/lib/notifications/expo-helpers.web.ts @@ -0,0 +1,31 @@ +// Web stubs. None of these are called on web (callers gate via IS_NATIVE / +// IS_WEB), but the imports must resolve to keep expo-notifications out of the +// web bundle. + +export function getLastNotificationResponse(): null { + return null +} + +export function clearLastNotificationResponse(): void {} + +export type NotificationPermissionsStatus = { + status: 'granted' | 'denied' | 'undetermined' + granted: boolean + canAskAgain: boolean + expires: 'never' | number +} + +const denied: NotificationPermissionsStatus = { + status: 'denied', + granted: false, + canAskAgain: false, + expires: 'never', +} + +export async function getPermissionsAsync(): Promise { + return denied +} + +export async function requestPermissionsAsync(): Promise { + return denied +} diff --git a/src/lib/notifications/notifications.web.ts b/src/lib/notifications/notifications.web.ts new file mode 100644 index 0000000000..645ecc9587 --- /dev/null +++ b/src/lib/notifications/notifications.web.ts @@ -0,0 +1,28 @@ +// Web shim for #/lib/notifications/notifications. expo-notifications doesn't +// run on web, so all of these are no-ops; the corresponding native callers +// are gated by IS_NATIVE / Platform.OS, so on web they're never invoked. +// +// Keeping this file expo-notifications-free saves ~70KB on web. +import {type AtpAgent} from '@atproto/api' + +export function useRegisterPushToken() { + return () => {} +} + +export function useGetAndRegisterPushToken() { + return async () => undefined +} + +export function useNotificationsRegistration() {} + +export function useRequestNotificationsPermission() { + return async ( + _context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home', + ) => {} +} + +export async function decrementBadgeCount(_by: number) {} + +export async function resetBadgeCount() {} + +export async function unregisterPushToken(_agents: AtpAgent[]) {} diff --git a/src/screens/Settings/NotificationSettings/index.tsx b/src/screens/Settings/NotificationSettings/index.tsx index 96fee77218..5f260f29cb 100644 --- a/src/screens/Settings/NotificationSettings/index.tsx +++ b/src/screens/Settings/NotificationSettings/index.tsx @@ -1,6 +1,5 @@ import {useEffect} from 'react' import {Linking, View} from 'react-native' -import * as Notification from 'expo-notifications' import {type AppBskyNotificationDefs} from '@atproto/api' import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' @@ -8,6 +7,10 @@ import {Trans} from '@lingui/react/macro' import {useQuery, useQueryClient} from '@tanstack/react-query' import {useAppState} from '#/lib/appState' +import { + getPermissionsAsync, + requestPermissionsAsync, +} from '#/lib/notifications/expo-helpers' import { type AllNavigatorParams, type NativeStackScreenProps, @@ -47,7 +50,7 @@ export function NotificationSettingsScreen({}: Props) { queryKey: RQKEY, queryFn: async () => { if (IS_WEB) return null - return await Notification.getPermissionsAsync() + return await getPermissionsAsync() }, }) @@ -61,7 +64,7 @@ export function NotificationSettingsScreen({}: Props) { const onRequestPermissions = async () => { if (IS_WEB) return if (permissions?.canAskAgain) { - const response = await Notification.requestPermissionsAsync() + const response = await requestPermissionsAsync() queryClient.setQueryData(RQKEY, response) } else { if (IS_ANDROID) {