From 52aaba4051e2f7a7184fd16e75dea6cbc8c102c9 Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Tue, 30 Jun 2026 22:23:52 +0300 Subject: [PATCH] Route iOS notification settings intent into the app (#11003) --- modules/expo-bluesky-swiss-army/README.md | 24 +++++++++-- .../expo-module.config.json | 3 +- .../ios/ExpoBlueskySwissArmy.podspec | 1 + ...xpoBlueskyNotificationSettingsModule.swift | 40 +++++++++++++++++++ src/lib/notifications/notifications.ts | 21 +++++++++- .../Settings/NotificationSettings/index.tsx | 14 ++++++- 6 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 modules/expo-bluesky-swiss-army/ios/NotificationSettings/ExpoBlueskyNotificationSettingsModule.swift diff --git a/modules/expo-bluesky-swiss-army/README.md b/modules/expo-bluesky-swiss-army/README.md index ca2861c28f..b1cc9eecc9 100644 --- a/modules/expo-bluesky-swiss-army/README.md +++ b/modules/expo-bluesky-swiss-army/README.md @@ -10,6 +10,7 @@ This module consolidates several native features into a single Expo module: - **Referrer**: Tracking how users arrive at the app (web referrers, app referrers, Google Play install referrer) - **SharedPrefs**: Shared preferences storage using native platform APIs (UserDefaults on iOS, SharedPreferences on Android) - **VisibilityView**: A native view component that tracks which view is currently visible on screen +- **NotificationSettings**: iOS handler that routes the system "notification settings" intent into the app ## Modules @@ -123,9 +124,24 @@ This is useful for features like video autoplay, where you want to know which vi - Android: Full support using View position tracking - Web: Passthrough component (renders children without tracking) -## Architecture +### NotificationSettings -### TypeScript Layer +iOS only. Has no JavaScript surface - it is a pure native side effect registered +at app launch. + +When push permissions are requested with `provideAppNotificationSettings: true`, +iOS adds an in-app notification settings button to the system Settings screen for +Bluesky. Tapping it launches the app and triggers +`userNotificationCenter(_:openSettingsFor:)`. expo-notifications owns the +`UNUserNotificationCenter` delegate and fans this callback out to registered +`NotificationDelegate`s. This module registers one and converts the callback into +a `bluesky://settings/notifications` deep link, which the app's existing linking +config routes to the notification settings screen. + +**Platform Support:** +- iOS: Full support +- Android: Not applicable (Android opens the system notification settings directly) +- Web: Not applicable The module uses platform-specific file extensions to provide appropriate implementations: @@ -170,7 +186,9 @@ The module uses platform-specific file extensions to provide appropriate impleme ### Expo Module Config -The module is registered in `expo-module.config.json` with all four sub-modules for both iOS and Android. +The module is registered in `expo-module.config.json`. The PlatformInfo, +Referrer, SharedPrefs, and VisibilityView sub-modules are registered for both iOS +and Android; NotificationSettings is iOS only. ### iOS diff --git a/modules/expo-bluesky-swiss-army/expo-module.config.json b/modules/expo-bluesky-swiss-army/expo-module.config.json index 4cdc11e993..ef2648193e 100644 --- a/modules/expo-bluesky-swiss-army/expo-module.config.json +++ b/modules/expo-bluesky-swiss-army/expo-module.config.json @@ -5,7 +5,8 @@ "ExpoBlueskySharedPrefsModule", "ExpoBlueskyReferrerModule", "ExpoBlueskyVisibilityViewModule", - "ExpoPlatformInfoModule" + "ExpoPlatformInfoModule", + "ExpoBlueskyNotificationSettingsModule" ] }, "android": { diff --git a/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec b/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec index be4b0eae45..54ce66526c 100644 --- a/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec +++ b/modules/expo-bluesky-swiss-army/ios/ExpoBlueskySwissArmy.podspec @@ -10,6 +10,7 @@ Pod::Spec.new do |s| s.static_framework = true s.dependency 'ExpoModulesCore' + s.dependency 'EXNotifications' # Swift/Objective-C compatibility s.pod_target_xcconfig = { diff --git a/modules/expo-bluesky-swiss-army/ios/NotificationSettings/ExpoBlueskyNotificationSettingsModule.swift b/modules/expo-bluesky-swiss-army/ios/NotificationSettings/ExpoBlueskyNotificationSettingsModule.swift new file mode 100644 index 0000000000..6374d33487 --- /dev/null +++ b/modules/expo-bluesky-swiss-army/ios/NotificationSettings/ExpoBlueskyNotificationSettingsModule.swift @@ -0,0 +1,40 @@ +import EXNotifications +import ExpoModulesCore +import UIKit +import UserNotifications + +/* + * When we request push notification permissions with + * `provideAppNotificationSettings: true`, iOS adds an in-app notification + * settings button to the system Settings screen for Bluesky (and may surface + * it elsewhere, e.g. from a delivered notification). Tapping it launches the + * app and calls `userNotificationCenter(_:openSettingsFor:)`. + * + * expo-notifications owns the `UNUserNotificationCenter` delegate via its + * `NotificationCenterManager`, which fans that callback out to any registered + * `NotificationDelegate` through `openSettings(_:)`. We register here and turn + * the callback into a `bluesky://settings/notifications` deep link so the app's + * existing linking config routes the user to the notification settings screen. + */ +public class ExpoBlueskyNotificationSettingsModule: Module, NotificationDelegate { + public func definition() -> ModuleDefinition { + Name("ExpoBlueskyNotificationSettings") + + OnCreate { + NotificationCenterManager.shared.addDelegate(self) + } + + OnDestroy { + NotificationCenterManager.shared.removeDelegate(self) + } + } + + public func openSettings(_ notification: UNNotification?) { + guard let url = URL(string: "bluesky://settings/notifications") else { + return + } + DispatchQueue.main.async { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + } +} diff --git a/src/lib/notifications/notifications.ts b/src/lib/notifications/notifications.ts index 1c2e6dbe5a..e8de8e9de3 100644 --- a/src/lib/notifications/notifications.ts +++ b/src/lib/notifications/notifications.ts @@ -249,7 +249,26 @@ export function useRequestNotificationsPermission() { return } - const res = await Notifications.requestPermissionsAsync() + const res = await Notifications.requestPermissionsAsync({ + ios: { + /* + * These three default to true when no argument is passed to + * `requestPermissionsAsync`, but passing an options object opts out of + * that default, so we have to set them explicitly to preserve the + * existing behavior. + */ + allowAlert: true, + allowBadge: true, + allowSound: true, + /* + * Adds an in-app notification settings button to the system Settings + * screen for Bluesky. When tapped, iOS calls back into the app, which + * we route to the in-app notification settings (see the + * NotificationSettings module in expo-bluesky-swiss-army). + */ + provideAppNotificationSettings: true, + }, + }) ax.metric(`notifications:request`, { context: context, diff --git a/src/screens/Settings/NotificationSettings/index.tsx b/src/screens/Settings/NotificationSettings/index.tsx index 7df759de32..00318a9180 100644 --- a/src/screens/Settings/NotificationSettings/index.tsx +++ b/src/screens/Settings/NotificationSettings/index.tsx @@ -82,7 +82,19 @@ export function NotificationSettingsScreen({}: Props) { const onRequestPermissions = async () => { if (IS_WEB) return if (permissions?.canAskAgain) { - const response = await Notification.requestPermissionsAsync() + const response = await Notification.requestPermissionsAsync({ + ios: { + /* + * These default to true only when no argument is passed, so set them + * explicitly to preserve behavior alongside + * provideAppNotificationSettings. + */ + allowAlert: true, + allowBadge: true, + allowSound: true, + provideAppNotificationSettings: true, + }, + }) queryClient.setQueryData(RQKEY, response) } else { if (IS_ANDROID) {