diff --git a/app.config.js b/app.config.js
index 4b54157c28..4ade9de31a 100644
--- a/app.config.js
+++ b/app.config.js
@@ -110,7 +110,7 @@ module.exports = function (config) {
{
NSPrivacyAccessedAPIType:
'NSPrivacyAccessedAPICategoryUserDefaults',
- NSPrivacyAccessedAPITypeReasons: ['CA92.1'],
+ NSPrivacyAccessedAPITypeReasons: ['CA92.1', '1C8F.1'],
},
],
},
@@ -200,7 +200,7 @@ module.exports = function (config) {
{
icon: './assets/icon-android-notification.png',
color: '#1185fe',
- sounds: ['assets/blueskydm.wav'],
+ sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
},
],
'./plugins/withAndroidManifestPlugin.js',
@@ -209,6 +209,7 @@ module.exports = function (config) {
'./plugins/withAndroidStylesAccentColorPlugin.js',
'./plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js',
'./plugins/shareExtension/withShareExtensions.js',
+ './plugins/notificationsExtension/withNotificationsExtension.js',
].filter(Boolean),
extra: {
eas: {
@@ -225,6 +226,15 @@ module.exports = function (config) {
],
},
},
+ {
+ targetName: 'BlueskyNSE',
+ bundleIdentifier: 'xyz.blueskyweb.app.BlueskyNSE',
+ entitlements: {
+ 'com.apple.security.application-groups': [
+ 'group.app.bsky',
+ ],
+ },
+ },
],
},
},
diff --git a/assets/blueskydm.wav b/assets/blueskydm.wav
deleted file mode 100644
index 8d35258dd7..0000000000
Binary files a/assets/blueskydm.wav and /dev/null differ
diff --git a/assets/dm.aiff b/assets/dm.aiff
new file mode 100644
index 0000000000..364b814b7f
Binary files /dev/null and b/assets/dm.aiff differ
diff --git a/assets/dm.mp3 b/assets/dm.mp3
new file mode 100644
index 0000000000..acb5728ee7
Binary files /dev/null and b/assets/dm.mp3 differ
diff --git a/modules/BlueskyNSE/BlueskyNSE.entitlements b/modules/BlueskyNSE/BlueskyNSE.entitlements
new file mode 100644
index 0000000000..4954bdb33a
--- /dev/null
+++ b/modules/BlueskyNSE/BlueskyNSE.entitlements
@@ -0,0 +1,10 @@
+
+
+
+
+ com.apple.security.application-groups
+
+ group.app.bsky
+
+
+
\ No newline at end of file
diff --git a/modules/BlueskyNSE/Info.plist b/modules/BlueskyNSE/Info.plist
new file mode 100644
index 0000000000..c2dd7eda69
--- /dev/null
+++ b/modules/BlueskyNSE/Info.plist
@@ -0,0 +1,29 @@
+
+
+
+
+ NSExtension
+
+ NSExtensionPointIdentifier
+ com.apple.usernotifications.service
+ NSExtensionPrincipalClass
+ $(PRODUCT_MODULE_NAME).NotificationService
+
+ MainAppScheme
+ bluesky
+ CFBundleName
+ $(PRODUCT_NAME)
+ CFBundleDisplayName
+ Bluesky Notifications
+ CFBundleIdentifier
+ $(PRODUCT_BUNDLE_IDENTIFIER)
+ CFBundleVersion
+ $(CURRENT_PROJECT_VERSION)
+ CFBundleExecutable
+ $(EXECUTABLE_NAME)
+ CFBundlePackageType
+ $(PRODUCT_BUNDLE_PACKAGE_TYPE)
+ CFBundleShortVersionString
+ $(MARKETING_VERSION)
+
+
\ No newline at end of file
diff --git a/modules/BlueskyNSE/NotificationService.swift b/modules/BlueskyNSE/NotificationService.swift
new file mode 100644
index 0000000000..c6f391e007
--- /dev/null
+++ b/modules/BlueskyNSE/NotificationService.swift
@@ -0,0 +1,51 @@
+import UserNotifications
+
+let APP_GROUP = "group.app.bsky"
+
+class NotificationService: UNNotificationServiceExtension {
+ var prefs = UserDefaults(suiteName: APP_GROUP)
+
+ override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
+ guard var bestAttempt = createCopy(request.content),
+ let reason = request.content.userInfo["reason"] as? String
+ else {
+ contentHandler(request.content)
+ return
+ }
+
+ if reason == "chat-message" {
+ mutateWithChatMessage(bestAttempt)
+ }
+
+ // The badge should always be incremented when in the background
+ mutateWithBadge(bestAttempt)
+
+ contentHandler(bestAttempt)
+ }
+
+ override func serviceExtensionTimeWillExpire() {
+ // If for some reason the alloted time expires, we don't actually want to display a notification
+ }
+
+ func createCopy(_ content: UNNotificationContent) -> UNMutableNotificationContent? {
+ return content.mutableCopy() as? UNMutableNotificationContent
+ }
+
+ func mutateWithBadge(_ content: UNMutableNotificationContent) {
+ content.badge = 1
+ }
+
+ func mutateWithChatMessage(_ content: UNMutableNotificationContent) {
+ if self.prefs?.bool(forKey: "playSoundChat") == true {
+ mutateWithDmSound(content)
+ }
+ }
+
+ func mutateWithDefaultSound(_ content: UNMutableNotificationContent) {
+ content.sound = UNNotificationSound.default
+ }
+
+ func mutateWithDmSound(_ content: UNMutableNotificationContent) {
+ content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "dm.aiff"))
+ }
+}
diff --git a/modules/Share-with-Bluesky/Info.plist b/modules/Share-with-Bluesky/Info.plist
index 90fe923455..421abb3c41 100644
--- a/modules/Share-with-Bluesky/Info.plist
+++ b/modules/Share-with-Bluesky/Info.plist
@@ -38,4 +38,4 @@
CFBundleShortVersionString
$(MARKETING_VERSION)
-
+
\ No newline at end of file
diff --git a/modules/Share-with-Bluesky/Share-with-Bluesky.entitlements b/modules/Share-with-Bluesky/Share-with-Bluesky.entitlements
index d2253d31f8..4954bdb33a 100644
--- a/modules/Share-with-Bluesky/Share-with-Bluesky.entitlements
+++ b/modules/Share-with-Bluesky/Share-with-Bluesky.entitlements
@@ -7,4 +7,4 @@
group.app.bsky
-
+
\ No newline at end of file
diff --git a/modules/expo-background-notification-handler/android/build.gradle b/modules/expo-background-notification-handler/android/build.gradle
new file mode 100644
index 0000000000..e18eee9343
--- /dev/null
+++ b/modules/expo-background-notification-handler/android/build.gradle
@@ -0,0 +1,93 @@
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'maven-publish'
+
+group = 'expo.modules.backgroundnotificationhandler'
+version = '0.5.0'
+
+buildscript {
+ def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
+ if (expoModulesCorePlugin.exists()) {
+ apply from: expoModulesCorePlugin
+ applyKotlinExpoModulesCorePlugin()
+ }
+
+ // Simple helper that allows the root project to override versions declared by this library.
+ ext.safeExtGet = { prop, fallback ->
+ rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
+ }
+
+ // Ensures backward compatibility
+ ext.getKotlinVersion = {
+ if (ext.has("kotlinVersion")) {
+ ext.kotlinVersion()
+ } else {
+ ext.safeExtGet("kotlinVersion", "1.8.10")
+ }
+ }
+
+ repositories {
+ mavenCentral()
+ }
+
+ dependencies {
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}")
+ }
+}
+
+afterEvaluate {
+ publishing {
+ publications {
+ release(MavenPublication) {
+ from components.release
+ }
+ }
+ repositories {
+ maven {
+ url = mavenLocal().url
+ }
+ }
+ }
+}
+
+android {
+ compileSdkVersion safeExtGet("compileSdkVersion", 33)
+
+ def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
+ if (agpVersion.tokenize('.')[0].toInteger() < 8) {
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_11
+ targetCompatibility JavaVersion.VERSION_11
+ }
+
+ kotlinOptions {
+ jvmTarget = JavaVersion.VERSION_11.majorVersion
+ }
+ }
+
+ namespace "expo.modules.backgroundnotificationhandler"
+ defaultConfig {
+ minSdkVersion safeExtGet("minSdkVersion", 21)
+ targetSdkVersion safeExtGet("targetSdkVersion", 34)
+ versionCode 1
+ versionName "0.5.0"
+ }
+ lintOptions {
+ abortOnError false
+ }
+ publishing {
+ singleVariant("release") {
+ withSourcesJar()
+ }
+ }
+}
+
+repositories {
+ mavenCentral()
+}
+
+dependencies {
+ implementation project(':expo-modules-core')
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
+ implementation 'com.google.firebase:firebase-messaging-ktx:24.0.0'
+}
diff --git a/modules/expo-background-notification-handler/android/src/main/AndroidManifest.xml b/modules/expo-background-notification-handler/android/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..bdae66c8f5
--- /dev/null
+++ b/modules/expo-background-notification-handler/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+
+
diff --git a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
new file mode 100644
index 0000000000..344508523d
--- /dev/null
+++ b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
@@ -0,0 +1,39 @@
+package expo.modules.backgroundnotificationhandler
+
+import android.content.Context
+import com.google.firebase.messaging.RemoteMessage
+
+class BackgroundNotificationHandler(
+ private val context: Context,
+ private val notifInterface: BackgroundNotificationHandlerInterface
+) {
+ fun handleMessage(remoteMessage: RemoteMessage) {
+ if (ExpoBackgroundNotificationHandlerModule.isForegrounded) {
+ // We'll let expo-notifications handle the notification if the app is foregrounded
+ return
+ }
+
+ if (remoteMessage.data["reason"] == "chat-message") {
+ mutateWithChatMessage(remoteMessage)
+ }
+
+ notifInterface.showMessage(remoteMessage)
+ }
+
+ private fun mutateWithChatMessage(remoteMessage: RemoteMessage) {
+ if (NotificationPrefs(context).getBoolean("playSoundChat")) {
+ // If oreo or higher
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+ remoteMessage.data["channelId"] = "chat-messages"
+ } else {
+ remoteMessage.data["sound"] = "dm.mp3"
+ }
+ } else {
+ if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+ remoteMessage.data["channelId"] = "chat-messages-muted"
+ } else {
+ remoteMessage.data["sound"] = null
+ }
+ }
+ }
+}
diff --git a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandlerInterface.kt b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandlerInterface.kt
new file mode 100644
index 0000000000..41fb65eb68
--- /dev/null
+++ b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandlerInterface.kt
@@ -0,0 +1,7 @@
+package expo.modules.backgroundnotificationhandler
+
+import com.google.firebase.messaging.RemoteMessage
+
+interface BackgroundNotificationHandlerInterface {
+ fun showMessage(remoteMessage: RemoteMessage)
+}
diff --git a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/ExpoBackgroundNotificationHandlerModule.kt b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/ExpoBackgroundNotificationHandlerModule.kt
new file mode 100644
index 0000000000..083ff1223c
--- /dev/null
+++ b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/ExpoBackgroundNotificationHandlerModule.kt
@@ -0,0 +1,70 @@
+package expo.modules.backgroundnotificationhandler
+
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class ExpoBackgroundNotificationHandlerModule : Module() {
+ companion object {
+ var isForegrounded = false
+ }
+
+ override fun definition() = ModuleDefinition {
+ Name("ExpoBackgroundNotificationHandler")
+
+ OnCreate {
+ NotificationPrefs(appContext.reactContext).initialize()
+ }
+
+ OnActivityEntersForeground {
+ isForegrounded = true
+ }
+
+ OnActivityEntersBackground {
+ isForegrounded = false
+ }
+
+ AsyncFunction("getAllPrefsAsync") {
+ return@AsyncFunction NotificationPrefs(appContext.reactContext).getAllPrefs()
+ }
+
+ AsyncFunction("getBoolAsync") { forKey: String ->
+ return@AsyncFunction NotificationPrefs(appContext.reactContext).getBoolean(forKey)
+ }
+
+ AsyncFunction("getStringAsync") { forKey: String ->
+ return@AsyncFunction NotificationPrefs(appContext.reactContext).getString(forKey)
+ }
+
+ AsyncFunction("getStringArrayAsync") { forKey: String ->
+ return@AsyncFunction NotificationPrefs(appContext.reactContext).getStringArray(forKey)
+ }
+
+ AsyncFunction("setBoolAsync") { forKey: String, value: Boolean ->
+ NotificationPrefs(appContext.reactContext).setBoolean(forKey, value)
+ }
+
+ AsyncFunction("setStringAsync") { forKey: String, value: String ->
+ NotificationPrefs(appContext.reactContext).setString(forKey, value)
+ }
+
+ AsyncFunction("setStringArrayAsync") { forKey: String, value: Array ->
+ NotificationPrefs(appContext.reactContext).setStringArray(forKey, value)
+ }
+
+ AsyncFunction("addToStringArrayAsync") { forKey: String, string: String ->
+ NotificationPrefs(appContext.reactContext).addToStringArray(forKey, string)
+ }
+
+ AsyncFunction("removeFromStringArrayAsync") { forKey: String, string: String ->
+ NotificationPrefs(appContext.reactContext).removeFromStringArray(forKey, string)
+ }
+
+ AsyncFunction("addManyToStringArrayAsync") { forKey: String, strings: Array ->
+ NotificationPrefs(appContext.reactContext).addManyToStringArray(forKey, strings)
+ }
+
+ AsyncFunction("removeManyFromStringArrayAsync") { forKey: String, strings: Array ->
+ NotificationPrefs(appContext.reactContext).removeManyFromStringArray(forKey, strings)
+ }
+ }
+}
diff --git a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/NotificationPrefs.kt b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/NotificationPrefs.kt
new file mode 100644
index 0000000000..17ef9205ef
--- /dev/null
+++ b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/NotificationPrefs.kt
@@ -0,0 +1,134 @@
+package expo.modules.backgroundnotificationhandler
+
+import android.content.Context
+
+val DEFAULTS = mapOf(
+ "playSoundChat" to true,
+ "playSoundFollow" to false,
+ "playSoundLike" to false,
+ "playSoundMention" to false,
+ "playSoundQuote" to false,
+ "playSoundReply" to false,
+ "playSoundRepost" to false,
+ "mutedThreads" to mapOf>()
+)
+
+class NotificationPrefs (private val context: Context?) {
+ private val prefs = context?.getSharedPreferences("xyz.blueskyweb.app", Context.MODE_PRIVATE)
+ ?: throw Error("Context is null")
+
+ fun initialize() {
+ prefs
+ .edit()
+ .apply {
+ DEFAULTS.forEach { (key, value) ->
+ if (prefs.contains(key)) {
+ return@forEach
+ }
+
+ when (value) {
+ is Boolean -> {
+ putBoolean(key, value)
+ }
+ is String -> {
+ putString(key, value)
+ }
+ is Array<*> -> {
+ putStringSet(key, value.map { it.toString() }.toSet())
+ }
+ is Map<*, *> -> {
+ putStringSet(key, value.map { it.toString() }.toSet())
+ }
+ }
+ }
+ }
+ .apply()
+ }
+
+ fun getAllPrefs(): MutableMap {
+ return prefs.all
+ }
+
+ fun getBoolean(key: String): Boolean {
+ return prefs.getBoolean(key, false)
+ }
+
+ fun getString(key: String): String? {
+ return prefs.getString(key, null)
+ }
+
+ fun getStringArray(key: String): Array? {
+ return prefs.getStringSet(key, null)?.toTypedArray()
+ }
+
+ fun setBoolean(key: String, value: Boolean) {
+ prefs
+ .edit()
+ .apply {
+ putBoolean(key, value)
+ }
+ .apply()
+ }
+
+ fun setString(key: String, value: String) {
+ prefs
+ .edit()
+ .apply {
+ putString(key, value)
+ }
+ .apply()
+ }
+
+ fun setStringArray(key: String, value: Array) {
+ prefs
+ .edit()
+ .apply {
+ putStringSet(key, value.toSet())
+ }
+ .apply()
+ }
+
+ fun addToStringArray(key: String, string: String) {
+ prefs
+ .edit()
+ .apply {
+ val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf()
+ set.add(string)
+ putStringSet(key, set)
+ }
+ .apply()
+ }
+
+ fun removeFromStringArray(key: String, string: String) {
+ prefs
+ .edit()
+ .apply {
+ val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf()
+ set.remove(string)
+ putStringSet(key, set)
+ }
+ .apply()
+ }
+
+ fun addManyToStringArray(key: String, strings: Array) {
+ prefs
+ .edit()
+ .apply {
+ val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf()
+ set.addAll(strings.toSet())
+ putStringSet(key, set)
+ }
+ .apply()
+ }
+
+ fun removeManyFromStringArray(key: String, strings: Array) {
+ prefs
+ .edit()
+ .apply {
+ val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf()
+ set.removeAll(strings.toSet())
+ putStringSet(key, set)
+ }
+ .apply()
+ }
+}
\ No newline at end of file
diff --git a/modules/expo-background-notification-handler/expo-module.config.json b/modules/expo-background-notification-handler/expo-module.config.json
new file mode 100644
index 0000000000..9e5c9d5509
--- /dev/null
+++ b/modules/expo-background-notification-handler/expo-module.config.json
@@ -0,0 +1,9 @@
+{
+ "platforms": ["ios", "android"],
+ "ios": {
+ "modules": ["ExpoBackgroundNotificationHandlerModule"]
+ },
+ "android": {
+ "modules": ["expo.modules.backgroundnotificationhandler.ExpoBackgroundNotificationHandlerModule"]
+ }
+}
diff --git a/modules/expo-background-notification-handler/index.ts b/modules/expo-background-notification-handler/index.ts
new file mode 100644
index 0000000000..680c6c13f5
--- /dev/null
+++ b/modules/expo-background-notification-handler/index.ts
@@ -0,0 +1,2 @@
+import {BackgroundNotificationHandler} from './src/ExpoBackgroundNotificationHandlerModule'
+export default BackgroundNotificationHandler
diff --git a/modules/expo-background-notification-handler/ios/ExpoBackgroundNotificationHandler.podspec b/modules/expo-background-notification-handler/ios/ExpoBackgroundNotificationHandler.podspec
new file mode 100644
index 0000000000..363c7b5e62
--- /dev/null
+++ b/modules/expo-background-notification-handler/ios/ExpoBackgroundNotificationHandler.podspec
@@ -0,0 +1,21 @@
+Pod::Spec.new do |s|
+ s.name = 'ExpoBackgroundNotificationHandler'
+ s.version = '1.0.0'
+ s.summary = 'Interface for BlueskyNSE preferences'
+ s.description = 'Interface for BlueskyNSE preferenes'
+ s.author = ''
+ s.homepage = 'https://github.com/bluesky-social/social-app'
+ s.platforms = { :ios => '13.4', :tvos => '13.4' }
+ s.source = { git: '' }
+ s.static_framework = true
+
+ s.dependency 'ExpoModulesCore'
+
+ # Swift/Objective-C compatibility
+ s.pod_target_xcconfig = {
+ 'DEFINES_MODULE' => 'YES',
+ 'SWIFT_COMPILATION_MODE' => 'wholemodule'
+ }
+
+ s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
+end
diff --git a/modules/expo-background-notification-handler/ios/ExpoBackgroundNotificationHandlerModule.swift b/modules/expo-background-notification-handler/ios/ExpoBackgroundNotificationHandlerModule.swift
new file mode 100644
index 0000000000..08972a04c5
--- /dev/null
+++ b/modules/expo-background-notification-handler/ios/ExpoBackgroundNotificationHandlerModule.swift
@@ -0,0 +1,116 @@
+import ExpoModulesCore
+
+let APP_GROUP = "group.app.bsky"
+
+let DEFAULTS: [String:Any] = [
+ "playSoundChat" : true,
+ "playSoundFollow": false,
+ "playSoundLike": false,
+ "playSoundMention": false,
+ "playSoundQuote": false,
+ "playSoundReply": false,
+ "playSoundRepost": false,
+ "mutedThreads": [:] as! [String:[String]]
+]
+
+/*
+ * The purpose of this module is to store values that are needed by the notification service
+ * extension. Since we would rather get and store values such as age or user mute state
+ * while the app is foregrounded, we should use this module liberally. We should aim to keep
+ * background fetches to a minimum (two or three times per hour) while the app is backgrounded
+ * or killed
+ */
+public class ExpoBackgroundNotificationHandlerModule: Module {
+ let userDefaults = UserDefaults(suiteName: APP_GROUP)
+
+ public func definition() -> ModuleDefinition {
+ Name("ExpoBackgroundNotificationHandler")
+
+ OnCreate {
+ DEFAULTS.forEach { p in
+ if userDefaults?.value(forKey: p.key) == nil {
+ userDefaults?.setValue(p.value, forKey: p.key)
+ }
+ }
+ }
+
+ AsyncFunction("getAllPrefsAsync") { () -> [String:Any]? in
+ var keys: [String] = []
+ DEFAULTS.forEach { p in
+ keys.append(p.key)
+ }
+ return userDefaults?.dictionaryWithValues(forKeys: keys)
+ }
+
+ AsyncFunction("getBoolAsync") { (forKey: String) -> Bool in
+ if let pref = userDefaults?.bool(forKey: forKey) {
+ return pref
+ }
+ return false
+ }
+
+ AsyncFunction("getStringAsync") { (forKey: String) -> String? in
+ if let pref = userDefaults?.string(forKey: forKey) {
+ return pref
+ }
+ return nil
+ }
+
+ AsyncFunction("getStringArrayAsync") { (forKey: String) -> [String]? in
+ if let pref = userDefaults?.stringArray(forKey: forKey) {
+ return pref
+ }
+ return nil
+ }
+
+ AsyncFunction("setBoolAsync") { (forKey: String, value: Bool) -> Void in
+ userDefaults?.setValue(value, forKey: forKey)
+ }
+
+ AsyncFunction("setStringAsync") { (forKey: String, value: String) -> Void in
+ userDefaults?.setValue(value, forKey: forKey)
+ }
+
+ AsyncFunction("setStringArrayAsync") { (forKey: String, value: [String]) -> Void in
+ userDefaults?.setValue(value, forKey: forKey)
+ }
+
+ AsyncFunction("addToStringArrayAsync") { (forKey: String, string: String) in
+ if var curr = userDefaults?.stringArray(forKey: forKey),
+ !curr.contains(string)
+ {
+ curr.append(string)
+ userDefaults?.setValue(curr, forKey: forKey)
+ }
+ }
+
+ AsyncFunction("removeFromStringArrayAsync") { (forKey: String, string: String) in
+ if var curr = userDefaults?.stringArray(forKey: forKey) {
+ curr.removeAll { s in
+ return s == string
+ }
+ userDefaults?.setValue(curr, forKey: forKey)
+ }
+ }
+
+ AsyncFunction("addManyToStringArrayAsync") { (forKey: String, strings: [String]) in
+ if var curr = userDefaults?.stringArray(forKey: forKey) {
+ strings.forEach { s in
+ if !curr.contains(s) {
+ curr.append(s)
+ }
+ }
+ userDefaults?.setValue(curr, forKey: forKey)
+ }
+ }
+
+ AsyncFunction("removeManyFromStringArrayAsync") { (forKey: String, strings: [String]) in
+ if var curr = userDefaults?.stringArray(forKey: forKey) {
+ strings.forEach { s in
+ curr.removeAll(where: { $0 == s })
+ }
+ userDefaults?.setValue(curr, forKey: forKey)
+ }
+ }
+ }
+}
diff --git a/modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider.tsx b/modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider.tsx
new file mode 100644
index 0000000000..6ecdd1d476
--- /dev/null
+++ b/modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider.tsx
@@ -0,0 +1,70 @@
+import React from 'react'
+
+import {BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
+import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule'
+
+interface BackgroundNotificationPreferencesContext {
+ preferences: BackgroundNotificationHandlerPreferences
+ setPref: (
+ key: Key,
+ value: BackgroundNotificationHandlerPreferences[Key],
+ ) => void
+}
+
+const Context = React.createContext(
+ {} as BackgroundNotificationPreferencesContext,
+)
+export const useBackgroundNotificationPreferences = () =>
+ React.useContext(Context)
+
+export function BackgroundNotificationPreferencesProvider({
+ children,
+}: {
+ children: React.ReactNode
+}) {
+ const [preferences, setPreferences] =
+ React.useState({
+ playSoundChat: true,
+ })
+
+ React.useEffect(() => {
+ ;(async () => {
+ const prefs = await BackgroundNotificationHandler.getAllPrefsAsync()
+ setPreferences(prefs)
+ })()
+ }, [])
+
+ const value = React.useMemo(
+ () => ({
+ preferences,
+ setPref: async <
+ Key extends keyof BackgroundNotificationHandlerPreferences,
+ >(
+ k: Key,
+ v: BackgroundNotificationHandlerPreferences[Key],
+ ) => {
+ switch (typeof v) {
+ case 'boolean': {
+ await BackgroundNotificationHandler.setBoolAsync(k, v)
+ break
+ }
+ case 'string': {
+ await BackgroundNotificationHandler.setStringAsync(k, v)
+ break
+ }
+ default: {
+ throw new Error(`Invalid type for value: ${typeof v}`)
+ }
+ }
+
+ setPreferences(prev => ({
+ ...prev,
+ [k]: v,
+ }))
+ },
+ }),
+ [preferences],
+ )
+
+ return {children}
+}
diff --git a/modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandler.types.ts b/modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandler.types.ts
new file mode 100644
index 0000000000..5fbd302da9
--- /dev/null
+++ b/modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandler.types.ts
@@ -0,0 +1,40 @@
+export type ExpoBackgroundNotificationHandlerModule = {
+ getAllPrefsAsync: () => Promise
+ getBoolAsync: (forKey: string) => Promise
+ getStringAsync: (forKey: string) => Promise
+ getStringArrayAsync: (forKey: string) => Promise
+ setBoolAsync: (
+ forKey: keyof BackgroundNotificationHandlerPreferences,
+ value: boolean,
+ ) => Promise
+ setStringAsync: (
+ forKey: keyof BackgroundNotificationHandlerPreferences,
+ value: string,
+ ) => Promise
+ setStringArrayAsync: (
+ forKey: keyof BackgroundNotificationHandlerPreferences,
+ value: string[],
+ ) => Promise
+ addToStringArrayAsync: (
+ forKey: keyof BackgroundNotificationHandlerPreferences,
+ value: string,
+ ) => Promise
+ removeFromStringArrayAsync: (
+ forKey: keyof BackgroundNotificationHandlerPreferences,
+ value: string,
+ ) => Promise
+ addManyToStringArrayAsync: (
+ forKey: keyof BackgroundNotificationHandlerPreferences,
+ value: string[],
+ ) => Promise
+ removeManyFromStringArrayAsync: (
+ forKey: keyof BackgroundNotificationHandlerPreferences,
+ value: string[],
+ ) => Promise
+}
+
+// TODO there are more preferences in the native code, however they have not been added here yet.
+// Don't add them until the native logic also handles the notifications for those preference types.
+export type BackgroundNotificationHandlerPreferences = {
+ playSoundChat: boolean
+}
diff --git a/modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.ts b/modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.ts
new file mode 100644
index 0000000000..d6517893ad
--- /dev/null
+++ b/modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.ts
@@ -0,0 +1,8 @@
+import {requireNativeModule} from 'expo-modules-core'
+
+import {ExpoBackgroundNotificationHandlerModule} from './ExpoBackgroundNotificationHandler.types'
+
+export const BackgroundNotificationHandler =
+ requireNativeModule(
+ 'ExpoBackgroundNotificationHandler',
+ )
diff --git a/modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.web.ts b/modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.web.ts
new file mode 100644
index 0000000000..29e27fd0fa
--- /dev/null
+++ b/modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.web.ts
@@ -0,0 +1,27 @@
+import {
+ BackgroundNotificationHandlerPreferences,
+ ExpoBackgroundNotificationHandlerModule,
+} from './ExpoBackgroundNotificationHandler.types'
+
+// Stub for web
+export const BackgroundNotificationHandler = {
+ getAllPrefsAsync: async () => {
+ return {} as BackgroundNotificationHandlerPreferences
+ },
+ getBoolAsync: async (_: string) => {
+ return false
+ },
+ getStringAsync: async (_: string) => {
+ return ''
+ },
+ getStringArrayAsync: async (_: string) => {
+ return []
+ },
+ setBoolAsync: async (_: string, __: boolean) => {},
+ setStringAsync: async (_: string, __: string) => {},
+ setStringArrayAsync: async (_: string, __: string[]) => {},
+ addToStringArrayAsync: async (_: string, __: string) => {},
+ removeFromStringArrayAsync: async (_: string, __: string) => {},
+ addManyToStringArrayAsync: async (_: string, __: string[]) => {},
+ removeManyFromStringArrayAsync: async (_: string, __: string[]) => {},
+} as ExpoBackgroundNotificationHandlerModule
diff --git a/patches/expo-notifications+0.27.6.patch b/patches/expo-notifications+0.27.6.patch
new file mode 100644
index 0000000000..ba196eca05
--- /dev/null
+++ b/patches/expo-notifications+0.27.6.patch
@@ -0,0 +1,197 @@
+diff --git a/node_modules/expo-notifications/android/build.gradle b/node_modules/expo-notifications/android/build.gradle
+index 97bf4f4..6e9d427 100644
+--- a/node_modules/expo-notifications/android/build.gradle
++++ b/node_modules/expo-notifications/android/build.gradle
+@@ -118,6 +118,7 @@ dependencies {
+ api 'com.google.firebase:firebase-messaging:22.0.0'
+
+ api 'me.leolin:ShortcutBadger:1.1.22@aar'
++ implementation project(':expo-background-notification-handler')
+
+ if (project.findProject(':expo-modules-test-core')) {
+ testImplementation project(':expo-modules-test-core')
+diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/JSONNotificationContentBuilder.java b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/JSONNotificationContentBuilder.java
+index 0af7fe0..8f2c8d8 100644
+--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/JSONNotificationContentBuilder.java
++++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/JSONNotificationContentBuilder.java
+@@ -14,6 +14,7 @@ import expo.modules.notifications.notifications.enums.NotificationPriority;
+ import expo.modules.notifications.notifications.model.NotificationContent;
+
+ public class JSONNotificationContentBuilder extends NotificationContent.Builder {
++ private static final String CHANNEL_ID_KEY = "channelId";
+ private static final String TITLE_KEY = "title";
+ private static final String TEXT_KEY = "message";
+ private static final String SUBTITLE_KEY = "subtitle";
+@@ -36,6 +37,7 @@ public class JSONNotificationContentBuilder extends NotificationContent.Builder
+
+ public NotificationContent.Builder setPayload(JSONObject payload) {
+ this.setTitle(getTitle(payload))
++ .setChannelId(getChannelId(payload))
+ .setSubtitle(getSubtitle(payload))
+ .setText(getText(payload))
+ .setBody(getBody(payload))
+@@ -60,6 +62,14 @@ public class JSONNotificationContentBuilder extends NotificationContent.Builder
+ return this;
+ }
+
++ protected String getChannelId(JSONObject payload) {
++ try {
++ return payload.getString(CHANNEL_ID_KEY);
++ } catch (JSONException e) {
++ return null;
++ }
++ }
++
+ protected String getTitle(JSONObject payload) {
+ try {
+ return payload.getString(TITLE_KEY);
+diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java
+index f1fed19..1619f59 100644
+--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java
++++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java
+@@ -20,6 +20,7 @@ import expo.modules.notifications.notifications.enums.NotificationPriority;
+ * should be created using {@link NotificationContent.Builder}.
+ */
+ public class NotificationContent implements Parcelable, Serializable {
++ private String mChannelId;
+ private String mTitle;
+ private String mText;
+ private String mSubtitle;
+@@ -50,6 +51,9 @@ public class NotificationContent implements Parcelable, Serializable {
+ }
+ };
+
++ @Nullable
++ public String getChannelId() { return mChannelId; }
++
+ @Nullable
+ public String getTitle() {
+ return mTitle;
+@@ -121,6 +125,7 @@ public class NotificationContent implements Parcelable, Serializable {
+ }
+
+ protected NotificationContent(Parcel in) {
++ mChannelId = in.readString();
+ mTitle = in.readString();
+ mText = in.readString();
+ mSubtitle = in.readString();
+@@ -146,6 +151,7 @@ public class NotificationContent implements Parcelable, Serializable {
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
++ dest.writeString(mChannelId);
+ dest.writeString(mTitle);
+ dest.writeString(mText);
+ dest.writeString(mSubtitle);
+@@ -166,6 +172,7 @@ public class NotificationContent implements Parcelable, Serializable {
+ private static final long serialVersionUID = 397666843266836802L;
+
+ private void writeObject(java.io.ObjectOutputStream out) throws IOException {
++ out.writeObject(mChannelId);
+ out.writeObject(mTitle);
+ out.writeObject(mText);
+ out.writeObject(mSubtitle);
+@@ -190,6 +197,7 @@ public class NotificationContent implements Parcelable, Serializable {
+ }
+
+ private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
++ mChannelId = (String) in.readObject();
+ mTitle = (String) in.readObject();
+ mText = (String) in.readObject();
+ mSubtitle = (String) in.readObject();
+@@ -240,6 +248,7 @@ public class NotificationContent implements Parcelable, Serializable {
+ }
+
+ public static class Builder {
++ private String mChannelId;
+ private String mTitle;
+ private String mText;
+ private String mSubtitle;
+@@ -260,6 +269,11 @@ public class NotificationContent implements Parcelable, Serializable {
+ useDefaultVibrationPattern();
+ }
+
++ public Builder setChannelId(String channelId) {
++ mChannelId = channelId;
++ return this;
++ }
++
+ public Builder setTitle(String title) {
+ mTitle = title;
+ return this;
+@@ -336,6 +350,7 @@ public class NotificationContent implements Parcelable, Serializable {
+
+ public NotificationContent build() {
+ NotificationContent content = new NotificationContent();
++ content.mChannelId = mChannelId;
+ content.mTitle = mTitle;
+ content.mSubtitle = mSubtitle;
+ content.mText = mText;
+diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.java b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.java
+index 6bd9928..aab71ea 100644
+--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.java
++++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.java
+@@ -7,7 +7,6 @@ import android.content.pm.PackageManager;
+ import android.content.res.Resources;
+ import android.graphics.Bitmap;
+ import android.graphics.BitmapFactory;
+-import android.os.Build;
+ import android.os.Bundle;
+ import android.os.Parcel;
+ import android.provider.Settings;
+@@ -48,6 +47,10 @@ public class ExpoNotificationBuilder extends ChannelAwareNotificationBuilder {
+
+ NotificationContent content = getNotificationContent();
+
++ if (content.getChannelId() != null) {
++ builder.setChannelId(content.getChannelId());
++ }
++
+ builder.setAutoCancel(content.isAutoDismiss());
+ builder.setOngoing(content.isSticky());
+
+diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt
+index 55b3a8d..1b99d5b 100644
+--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt
++++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt
+@@ -12,11 +12,14 @@ import expo.modules.notifications.notifications.model.triggers.FirebaseNotificat
+ import expo.modules.notifications.service.NotificationsService
+ import expo.modules.notifications.service.interfaces.FirebaseMessagingDelegate
+ import expo.modules.notifications.tokens.interfaces.FirebaseTokenListener
++import expo.modules.backgroundnotificationhandler.BackgroundNotificationHandler
++import expo.modules.backgroundnotificationhandler.BackgroundNotificationHandlerInterface
++import expo.modules.backgroundnotificationhandler.ExpoBackgroundNotificationHandlerModule
+ import org.json.JSONObject
+ import java.lang.ref.WeakReference
+ import java.util.*
+
+-open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseMessagingDelegate {
++open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseMessagingDelegate, BackgroundNotificationHandlerInterface {
+ companion object {
+ // Unfortunately we cannot save state between instances of a service other way
+ // than by static properties. Fortunately, using weak references we can
+@@ -89,12 +92,21 @@ open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseM
+ fun getBackgroundTasks() = sBackgroundTaskConsumerReferences.values.mapNotNull { it.get() }
+
+ override fun onMessageReceived(remoteMessage: RemoteMessage) {
+- NotificationsService.receive(context, createNotification(remoteMessage))
+- getBackgroundTasks().forEach {
+- it.scheduleJob(RemoteMessageSerializer.toBundle(remoteMessage))
++ if (!ExpoBackgroundNotificationHandlerModule.isForegrounded) {
++ BackgroundNotificationHandler(context, this).handleMessage(remoteMessage)
++ return
++ } else {
++ showMessage(remoteMessage)
++ getBackgroundTasks().forEach {
++ it.scheduleJob(RemoteMessageSerializer.toBundle(remoteMessage))
++ }
+ }
+ }
+
++ override fun showMessage(remoteMessage: RemoteMessage) {
++ NotificationsService.receive(context, createNotification(remoteMessage))
++ }
++
+ protected fun createNotification(remoteMessage: RemoteMessage): Notification {
+ val identifier = getNotificationIdentifier(remoteMessage)
+ val payload = JSONObject(remoteMessage.data as Map<*, *>)
diff --git a/patches/expo-notifications-0.27.6.patch.md b/patches/expo-notifications-0.27.6.patch.md
new file mode 100644
index 0000000000..59b2598f3b
--- /dev/null
+++ b/patches/expo-notifications-0.27.6.patch.md
@@ -0,0 +1,9 @@
+## LOAD BEARING PATCH, DO NOT REMOVE
+
+## Expo-Notifications Patch
+
+This patch supports the Android background notification handling module. Incoming messages
+in `onMessageReceived` are sent to the module for handling.
+
+It also allows us to set the Android notification channel ID from the notification `data`, rather
+than the `notification` object in the payload.
diff --git a/plugins/notificationsExtension/README.md b/plugins/notificationsExtension/README.md
new file mode 100644
index 0000000000..31b8bfe7d6
--- /dev/null
+++ b/plugins/notificationsExtension/README.md
@@ -0,0 +1,17 @@
+# Notifications extension plugin for Expo
+
+This plugin handles moving the necessary files into their respective iOS directories
+
+## Steps
+
+### ios
+
+1. Update entitlements
+2. Set the app group to group.
+3. Add the extension plist
+4. Add the view controller
+5. Update the xcode project's build phases
+
+## Credits
+
+Adapted from https://github.com/andrew-levy/react-native-safari-extension and https://github.com/timedtext/expo-config-plugin-ios-share-extension/blob/master/src/withShareExtensionXcodeTarget.ts
diff --git a/plugins/notificationsExtension/withAppEntitlements.js b/plugins/notificationsExtension/withAppEntitlements.js
new file mode 100644
index 0000000000..4ce81ea611
--- /dev/null
+++ b/plugins/notificationsExtension/withAppEntitlements.js
@@ -0,0 +1,13 @@
+const {withEntitlementsPlist} = require('@expo/config-plugins')
+
+const withAppEntitlements = config => {
+ // eslint-disable-next-line no-shadow
+ return withEntitlementsPlist(config, async config => {
+ config.modResults['com.apple.security.application-groups'] = [
+ `group.app.bsky`,
+ ]
+ return config
+ })
+}
+
+module.exports = {withAppEntitlements}
diff --git a/plugins/notificationsExtension/withExtensionEntitlements.js b/plugins/notificationsExtension/withExtensionEntitlements.js
new file mode 100644
index 0000000000..0cc1c4ca8c
--- /dev/null
+++ b/plugins/notificationsExtension/withExtensionEntitlements.js
@@ -0,0 +1,31 @@
+const {withInfoPlist} = require('@expo/config-plugins')
+const plist = require('@expo/plist')
+const path = require('path')
+const fs = require('fs')
+
+const withExtensionEntitlements = (config, {extensionName}) => {
+ // eslint-disable-next-line no-shadow
+ return withInfoPlist(config, config => {
+ const extensionEntitlementsPath = path.join(
+ config.modRequest.platformProjectRoot,
+ extensionName,
+ `${extensionName}.entitlements`,
+ )
+
+ const notificationsExtensionEntitlements = {
+ 'com.apple.security.application-groups': [`group.app.bsky`],
+ }
+
+ fs.mkdirSync(path.dirname(extensionEntitlementsPath), {
+ recursive: true,
+ })
+ fs.writeFileSync(
+ extensionEntitlementsPath,
+ plist.default.build(notificationsExtensionEntitlements),
+ )
+
+ return config
+ })
+}
+
+module.exports = {withExtensionEntitlements}
diff --git a/plugins/notificationsExtension/withExtensionInfoPlist.js b/plugins/notificationsExtension/withExtensionInfoPlist.js
new file mode 100644
index 0000000000..b0c6cfa89a
--- /dev/null
+++ b/plugins/notificationsExtension/withExtensionInfoPlist.js
@@ -0,0 +1,39 @@
+const {withInfoPlist} = require('@expo/config-plugins')
+const plist = require('@expo/plist')
+const path = require('path')
+const fs = require('fs')
+
+const withExtensionInfoPlist = (config, {extensionName}) => {
+ // eslint-disable-next-line no-shadow
+ return withInfoPlist(config, config => {
+ const plistPath = path.join(
+ config.modRequest.projectRoot,
+ 'modules',
+ extensionName,
+ 'Info.plist',
+ )
+ const targetPath = path.join(
+ config.modRequest.platformProjectRoot,
+ extensionName,
+ 'Info.plist',
+ )
+
+ const extPlist = plist.default.parse(fs.readFileSync(plistPath).toString())
+
+ extPlist.MainAppScheme = config.scheme
+ extPlist.CFBundleName = '$(PRODUCT_NAME)'
+ extPlist.CFBundleDisplayName = 'Bluesky Notifications'
+ extPlist.CFBundleIdentifier = '$(PRODUCT_BUNDLE_IDENTIFIER)'
+ extPlist.CFBundleVersion = '$(CURRENT_PROJECT_VERSION)'
+ extPlist.CFBundleExecutable = '$(EXECUTABLE_NAME)'
+ extPlist.CFBundlePackageType = '$(PRODUCT_BUNDLE_PACKAGE_TYPE)'
+ extPlist.CFBundleShortVersionString = '$(MARKETING_VERSION)'
+
+ fs.mkdirSync(path.dirname(targetPath), {recursive: true})
+ fs.writeFileSync(targetPath, plist.default.build(extPlist))
+
+ return config
+ })
+}
+
+module.exports = {withExtensionInfoPlist}
diff --git a/plugins/notificationsExtension/withExtensionViewController.js b/plugins/notificationsExtension/withExtensionViewController.js
new file mode 100644
index 0000000000..cd29bea7da
--- /dev/null
+++ b/plugins/notificationsExtension/withExtensionViewController.js
@@ -0,0 +1,31 @@
+const {withXcodeProject} = require('@expo/config-plugins')
+const path = require('path')
+const fs = require('fs')
+
+const withExtensionViewController = (
+ config,
+ {controllerName, extensionName},
+) => {
+ // eslint-disable-next-line no-shadow
+ return withXcodeProject(config, config => {
+ const controllerPath = path.join(
+ config.modRequest.projectRoot,
+ 'modules',
+ extensionName,
+ `${controllerName}.swift`,
+ )
+
+ const targetPath = path.join(
+ config.modRequest.platformProjectRoot,
+ extensionName,
+ `${controllerName}.swift`,
+ )
+
+ fs.mkdirSync(path.dirname(targetPath), {recursive: true})
+ fs.copyFileSync(controllerPath, targetPath)
+
+ return config
+ })
+}
+
+module.exports = {withExtensionViewController}
diff --git a/plugins/notificationsExtension/withNotificationsExtension.js b/plugins/notificationsExtension/withNotificationsExtension.js
new file mode 100644
index 0000000000..6a00cfd231
--- /dev/null
+++ b/plugins/notificationsExtension/withNotificationsExtension.js
@@ -0,0 +1,55 @@
+const {withPlugins} = require('@expo/config-plugins')
+const {withAppEntitlements} = require('./withAppEntitlements')
+const {withXcodeTarget} = require('./withXcodeTarget')
+const {withExtensionEntitlements} = require('./withExtensionEntitlements')
+const {withExtensionInfoPlist} = require('./withExtensionInfoPlist')
+const {withExtensionViewController} = require('./withExtensionViewController')
+const {withSounds} = require('./withSounds')
+
+const EXTENSION_NAME = 'BlueskyNSE'
+const EXTENSION_CONTROLLER_NAME = 'NotificationService'
+
+const withNotificationsExtension = config => {
+ const soundFiles = ['dm.aiff']
+
+ return withPlugins(config, [
+ // IOS
+ withAppEntitlements,
+ [
+ withExtensionEntitlements,
+ {
+ extensionName: EXTENSION_NAME,
+ },
+ ],
+ [
+ withExtensionInfoPlist,
+ {
+ extensionName: EXTENSION_NAME,
+ },
+ ],
+ [
+ withExtensionViewController,
+ {
+ extensionName: EXTENSION_NAME,
+ controllerName: EXTENSION_CONTROLLER_NAME,
+ },
+ ],
+ [
+ withSounds,
+ {
+ extensionName: EXTENSION_NAME,
+ soundFiles,
+ },
+ ],
+ [
+ withXcodeTarget,
+ {
+ extensionName: EXTENSION_NAME,
+ controllerName: EXTENSION_CONTROLLER_NAME,
+ soundFiles,
+ },
+ ],
+ ])
+}
+
+module.exports = withNotificationsExtension
diff --git a/plugins/notificationsExtension/withSounds.js b/plugins/notificationsExtension/withSounds.js
new file mode 100644
index 0000000000..652afd5458
--- /dev/null
+++ b/plugins/notificationsExtension/withSounds.js
@@ -0,0 +1,27 @@
+const {withXcodeProject} = require('@expo/config-plugins')
+const path = require('path')
+const fs = require('fs')
+
+const withSounds = (config, {extensionName, soundFiles}) => {
+ // eslint-disable-next-line no-shadow
+ return withXcodeProject(config, config => {
+ for (const file of soundFiles) {
+ const soundPath = path.join(config.modRequest.projectRoot, 'assets', file)
+
+ const targetPath = path.join(
+ config.modRequest.platformProjectRoot,
+ extensionName,
+ file,
+ )
+
+ if (!fs.existsSync(path.dirname(targetPath))) {
+ fs.mkdirSync(path.dirname(targetPath), {recursive: true})
+ }
+ fs.copyFileSync(soundPath, targetPath)
+ }
+
+ return config
+ })
+}
+
+module.exports = {withSounds}
diff --git a/plugins/notificationsExtension/withXcodeTarget.js b/plugins/notificationsExtension/withXcodeTarget.js
new file mode 100644
index 0000000000..e9c7dae39a
--- /dev/null
+++ b/plugins/notificationsExtension/withXcodeTarget.js
@@ -0,0 +1,76 @@
+const {withXcodeProject, IOSConfig} = require('@expo/config-plugins')
+const path = require('path')
+const PBXFile = require('xcode/lib/pbxFile')
+
+const withXcodeTarget = (
+ config,
+ {extensionName, controllerName, soundFiles},
+) => {
+ // eslint-disable-next-line no-shadow
+ return withXcodeProject(config, config => {
+ let pbxProject = config.modResults
+
+ const target = pbxProject.addTarget(
+ extensionName,
+ 'app_extension',
+ extensionName,
+ )
+ pbxProject.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', target.uuid)
+ pbxProject.addBuildPhase(
+ [],
+ 'PBXResourcesBuildPhase',
+ 'Resources',
+ target.uuid,
+ )
+ const pbxGroupKey = pbxProject.pbxCreateGroup(extensionName, extensionName)
+ pbxProject.addFile(`${extensionName}/Info.plist`, pbxGroupKey)
+ pbxProject.addSourceFile(
+ `${extensionName}/${controllerName}.swift`,
+ {target: target.uuid},
+ pbxGroupKey,
+ )
+
+ for (const file of soundFiles) {
+ pbxProject.addSourceFile(
+ `${extensionName}/${file}`,
+ {target: target.uuid},
+ pbxGroupKey,
+ )
+ }
+
+ var configurations = pbxProject.pbxXCBuildConfigurationSection()
+ for (var key in configurations) {
+ if (typeof configurations[key].buildSettings !== 'undefined') {
+ var buildSettingsObj = configurations[key].buildSettings
+ if (
+ typeof buildSettingsObj.PRODUCT_NAME !== 'undefined' &&
+ buildSettingsObj.PRODUCT_NAME === `"${extensionName}"`
+ ) {
+ buildSettingsObj.CLANG_ENABLE_MODULES = 'YES'
+ buildSettingsObj.INFOPLIST_FILE = `"${extensionName}/Info.plist"`
+ buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${extensionName}/${extensionName}.entitlements"`
+ buildSettingsObj.CODE_SIGN_STYLE = 'Automatic'
+ buildSettingsObj.CURRENT_PROJECT_VERSION = `"${config.ios?.buildNumber}"`
+ buildSettingsObj.GENERATE_INFOPLIST_FILE = 'YES'
+ buildSettingsObj.MARKETING_VERSION = `"${config.version}"`
+ buildSettingsObj.PRODUCT_BUNDLE_IDENTIFIER = `"${config.ios?.bundleIdentifier}.${extensionName}"`
+ buildSettingsObj.SWIFT_EMIT_LOC_STRINGS = 'YES'
+ buildSettingsObj.SWIFT_VERSION = '5.0'
+ buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1,2"`
+ buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
+ }
+ }
+ }
+
+ pbxProject.addTargetAttribute(
+ 'DevelopmentTeam',
+ 'B3LX46C5HS',
+ extensionName,
+ )
+ pbxProject.addTargetAttribute('DevelopmentTeam', 'B3LX46C5HS')
+
+ return config
+ })
+}
+
+module.exports = {withXcodeTarget}
diff --git a/scripts/updateExtensions.sh b/scripts/updateExtensions.sh
index f4e462b744..f3e972aa7b 100755
--- a/scripts/updateExtensions.sh
+++ b/scripts/updateExtensions.sh
@@ -1,5 +1,6 @@
#!/bin/bash
IOS_SHARE_EXTENSION_DIRECTORY="./ios/Share-with-Bluesky"
+IOS_NOTIFICATION_EXTENSION_DIRECTORY="./ios/BlueskyNSE"
MODULES_DIRECTORY="./modules"
if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then
@@ -8,3 +9,10 @@ if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then
else
cp -R $IOS_SHARE_EXTENSION_DIRECTORY $MODULES_DIRECTORY
fi
+
+if [ ! -d $IOS_NOTIFICATION_EXTENSION_DIRECTORY ]; then
+ echo "$IOS_NOTIFICATION_EXTENSION_DIRECTORY not found inside of your iOS project."
+ exit 1
+else
+ cp -R $IOS_NOTIFICATION_EXTENSION_DIRECTORY $MODULES_DIRECTORY
+fi
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 9356be7a74..425d6ac6ea 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -47,6 +47,7 @@ import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as PortalProvider} from '#/components/Portal'
import {Splash} from '#/Splash'
+import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import I18nProvider from './locale/i18nProvider'
import {listenSessionDropped} from './state/events'
@@ -102,10 +103,12 @@ function InnerApp() {
-
-
-
-
+
+
+
+
+
+
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 40ceb69420..900ceefd7c 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -39,6 +39,7 @@ import {Shell} from 'view/shell/index'
import {ThemeProvider as Alf} from '#/alf'
import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
import {Provider as PortalProvider} from '#/components/Portal'
+import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import I18nProvider from './locale/i18nProvider'
import {listenSessionDropped} from './state/events'
@@ -92,9 +93,11 @@ function InnerApp() {
-
-
-
+
+
+
+
+
diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts
index 3240a4854a..6f5fbd66bb 100644
--- a/src/lib/hooks/useNotificationHandler.ts
+++ b/src/lib/hooks/useNotificationHandler.ts
@@ -8,6 +8,7 @@ import {track} from 'lib/analytics/analytics'
import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
import {NavigationProp} from 'lib/routes/types'
import {logEvent} from 'lib/statsig/statsig'
+import {isAndroid} from 'platform/detection'
import {useCurrentConvoId} from 'state/messages/current-convo-id'
import {RQKEY as RQKEY_NOTIFS} from 'state/queries/notifications/feed'
import {invalidateCachedUnreadPage} from 'state/queries/notifications/unread'
@@ -40,7 +41,7 @@ type NotificationPayload =
}
const DEFAULT_HANDLER_OPTIONS = {
- shouldShowAlert: false,
+ shouldShowAlert: true,
shouldPlaySound: false,
shouldSetBadge: true,
}
@@ -60,6 +61,28 @@ export function useNotificationsHandler() {
// Safety to prevent double handling of the same notification
const prevDate = React.useRef(0)
+ React.useEffect(() => {
+ if (!isAndroid) return
+
+ Notifications.setNotificationChannelAsync('chat-messages', {
+ name: 'Chat',
+ importance: Notifications.AndroidImportance.MAX,
+ sound: 'dm.mp3',
+ showBadge: true,
+ vibrationPattern: [250],
+ lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
+ })
+
+ Notifications.setNotificationChannelAsync('chat-messages-muted', {
+ name: 'Chat - Muted',
+ importance: Notifications.AndroidImportance.MAX,
+ sound: null,
+ showBadge: true,
+ vibrationPattern: [250],
+ lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
+ })
+ }, [])
+
React.useEffect(() => {
const handleNotification = (payload?: NotificationPayload) => {
if (!payload) return
diff --git a/src/screens/Messages/Settings.tsx b/src/screens/Messages/Settings.tsx
index e4fff12515..7ad21b400f 100644
--- a/src/screens/Messages/Settings.tsx
+++ b/src/screens/Messages/Settings.tsx
@@ -15,8 +15,10 @@ import * as Toast from '#/view/com/util/Toast'
import {ViewHeader} from '#/view/com/util/ViewHeader'
import {CenteredView} from '#/view/com/util/Views'
import {atoms as a} from '#/alf'
+import * as Toggle from '#/components/forms/Toggle'
import {RadioGroup} from '#/components/RadioGroup'
import {Text} from '#/components/Typography'
+import {useBackgroundNotificationPreferences} from '../../../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
import {ClipClopGate} from './gate'
type AllowIncoming = 'all' | 'none' | 'following'
@@ -28,6 +30,7 @@ export function MessagesSettingsScreen({}: Props) {
const {data: profile} = useProfileQuery({
did: currentAccount!.did,
}) as UseQueryResult
+ const {preferences, setPref} = useBackgroundNotificationPreferences()
const {mutate: updateDeclaration} = useUpdateActorDeclaration({
onError: () => {
@@ -65,6 +68,18 @@ export function MessagesSettingsScreen({}: Props) {
onSelect={onSelectItem}
/>
+
+ {
+ setPref('playSoundChat', !preferences.playSoundChat)
+ }}>
+
+ Notification Sounds
+
+
)
}