Route iOS notification settings intent into the app (#11003)

This commit is contained in:
Samuel Newman
2026-06-30 22:23:52 +03:00
committed by GitHub
parent de51ceb577
commit 52aaba4051
6 changed files with 97 additions and 6 deletions
+21 -3
View File
@@ -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
@@ -5,7 +5,8 @@
"ExpoBlueskySharedPrefsModule",
"ExpoBlueskyReferrerModule",
"ExpoBlueskyVisibilityViewModule",
"ExpoPlatformInfoModule"
"ExpoPlatformInfoModule",
"ExpoBlueskyNotificationSettingsModule"
]
},
"android": {
@@ -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 = {
@@ -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)
}
}
}
+20 -1
View File
@@ -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,
@@ -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) {