deer social fork for personal usage. but you might see a use idk. github mirror

Add push notification extensions (#4005)

* add wav

* add sound to config

* add extension to `updateExtensions.sh`

* add ios source files

* add a build extension

* add a new module

* use correct type on ios

* update the build plugin

* add android handler

* create a patch for expo-notifications

* basic android implementation

* add entitlements for notifications extension

* add some generic logic for ios

* add age check logic

* add extension to app config

* remove dash

* move directory

* rename again

* update privacy manifest

* add prefs storage ios

* better types

* create interface for setting and getting prefs

* add notifications prefs for android

* add functions to module

* add types to js

* add prefs context

* add web stub

* wrap the app

* fix types

* more preferences for ios

* add a test toggle

* swap vars

* update patch

* fix patch error

* fix typo

* sigh

* sigh

* get stored prefs on launch

* anotehr type

* simplify

* about finished

* comment

* adjust plugin

* use supported file types

* update NSE

* futureproof ios

* futureproof android

* update sound file name

* handle initialization

* more cleanup

* update js types

* strict js types

* set the notification channel

* rm

* add silent channel

* add mute logic

* update patch

* podfile

* adjust channels

* fix android channel

* update readme

* oreo or higher

* nit

* don't use getValue

* nit

authored by hailey.at and committed by GitHub bf7b66d5 31868b25

+12 -2
app.config.js
··· 110 110 { 111 111 NSPrivacyAccessedAPIType: 112 112 'NSPrivacyAccessedAPICategoryUserDefaults', 113 - NSPrivacyAccessedAPITypeReasons: ['CA92.1'], 113 + NSPrivacyAccessedAPITypeReasons: ['CA92.1', '1C8F.1'], 114 114 }, 115 115 ], 116 116 }, ··· 200 200 { 201 201 icon: './assets/icon-android-notification.png', 202 202 color: '#1185fe', 203 - sounds: ['assets/blueskydm.wav'], 203 + sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'], 204 204 }, 205 205 ], 206 206 './plugins/withAndroidManifestPlugin.js', ··· 209 209 './plugins/withAndroidStylesAccentColorPlugin.js', 210 210 './plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js', 211 211 './plugins/shareExtension/withShareExtensions.js', 212 + './plugins/notificationsExtension/withNotificationsExtension.js', 212 213 ].filter(Boolean), 213 214 extra: { 214 215 eas: { ··· 219 220 { 220 221 targetName: 'Share-with-Bluesky', 221 222 bundleIdentifier: 'xyz.blueskyweb.app.Share-with-Bluesky', 223 + entitlements: { 224 + 'com.apple.security.application-groups': [ 225 + 'group.app.bsky', 226 + ], 227 + }, 228 + }, 229 + { 230 + targetName: 'BlueskyNSE', 231 + bundleIdentifier: 'xyz.blueskyweb.app.BlueskyNSE', 222 232 entitlements: { 223 233 'com.apple.security.application-groups': [ 224 234 'group.app.bsky',
assets/blueskydm.wav

This is a binary file and will not be displayed.

assets/dm.aiff

This is a binary file and will not be displayed.

assets/dm.mp3

This is a binary file and will not be displayed.

+10
modules/BlueskyNSE/BlueskyNSE.entitlements
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>com.apple.security.application-groups</key> 6 + <array> 7 + <string>group.app.bsky</string> 8 + </array> 9 + </dict> 10 + </plist>
+29
modules/BlueskyNSE/Info.plist
··· 1 + <?xml version="1.0" encoding="UTF-8"?> 2 + <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> 3 + <plist version="1.0"> 4 + <dict> 5 + <key>NSExtension</key> 6 + <dict> 7 + <key>NSExtensionPointIdentifier</key> 8 + <string>com.apple.usernotifications.service</string> 9 + <key>NSExtensionPrincipalClass</key> 10 + <string>$(PRODUCT_MODULE_NAME).NotificationService</string> 11 + </dict> 12 + <key>MainAppScheme</key> 13 + <string>bluesky</string> 14 + <key>CFBundleName</key> 15 + <string>$(PRODUCT_NAME)</string> 16 + <key>CFBundleDisplayName</key> 17 + <string>Bluesky Notifications</string> 18 + <key>CFBundleIdentifier</key> 19 + <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string> 20 + <key>CFBundleVersion</key> 21 + <string>$(CURRENT_PROJECT_VERSION)</string> 22 + <key>CFBundleExecutable</key> 23 + <string>$(EXECUTABLE_NAME)</string> 24 + <key>CFBundlePackageType</key> 25 + <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string> 26 + <key>CFBundleShortVersionString</key> 27 + <string>$(MARKETING_VERSION)</string> 28 + </dict> 29 + </plist>
+51
modules/BlueskyNSE/NotificationService.swift
··· 1 + import UserNotifications 2 + 3 + let APP_GROUP = "group.app.bsky" 4 + 5 + class NotificationService: UNNotificationServiceExtension { 6 + var prefs = UserDefaults(suiteName: APP_GROUP) 7 + 8 + override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { 9 + guard var bestAttempt = createCopy(request.content), 10 + let reason = request.content.userInfo["reason"] as? String 11 + else { 12 + contentHandler(request.content) 13 + return 14 + } 15 + 16 + if reason == "chat-message" { 17 + mutateWithChatMessage(bestAttempt) 18 + } 19 + 20 + // The badge should always be incremented when in the background 21 + mutateWithBadge(bestAttempt) 22 + 23 + contentHandler(bestAttempt) 24 + } 25 + 26 + override func serviceExtensionTimeWillExpire() { 27 + // If for some reason the alloted time expires, we don't actually want to display a notification 28 + } 29 + 30 + func createCopy(_ content: UNNotificationContent) -> UNMutableNotificationContent? { 31 + return content.mutableCopy() as? UNMutableNotificationContent 32 + } 33 + 34 + func mutateWithBadge(_ content: UNMutableNotificationContent) { 35 + content.badge = 1 36 + } 37 + 38 + func mutateWithChatMessage(_ content: UNMutableNotificationContent) { 39 + if self.prefs?.bool(forKey: "playSoundChat") == true { 40 + mutateWithDmSound(content) 41 + } 42 + } 43 + 44 + func mutateWithDefaultSound(_ content: UNMutableNotificationContent) { 45 + content.sound = UNNotificationSound.default 46 + } 47 + 48 + func mutateWithDmSound(_ content: UNMutableNotificationContent) { 49 + content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "dm.aiff")) 50 + } 51 + }
+1 -1
modules/Share-with-Bluesky/Info.plist
··· 38 38 <key>CFBundleShortVersionString</key> 39 39 <string>$(MARKETING_VERSION)</string> 40 40 </dict> 41 - </plist> 41 + </plist>
+1 -1
modules/Share-with-Bluesky/Share-with-Bluesky.entitlements
··· 7 7 <string>group.app.bsky</string> 8 8 </array> 9 9 </dict> 10 - </plist> 10 + </plist>
+93
modules/expo-background-notification-handler/android/build.gradle
··· 1 + apply plugin: 'com.android.library' 2 + apply plugin: 'kotlin-android' 3 + apply plugin: 'maven-publish' 4 + 5 + group = 'expo.modules.backgroundnotificationhandler' 6 + version = '0.5.0' 7 + 8 + buildscript { 9 + def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle") 10 + if (expoModulesCorePlugin.exists()) { 11 + apply from: expoModulesCorePlugin 12 + applyKotlinExpoModulesCorePlugin() 13 + } 14 + 15 + // Simple helper that allows the root project to override versions declared by this library. 16 + ext.safeExtGet = { prop, fallback -> 17 + rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback 18 + } 19 + 20 + // Ensures backward compatibility 21 + ext.getKotlinVersion = { 22 + if (ext.has("kotlinVersion")) { 23 + ext.kotlinVersion() 24 + } else { 25 + ext.safeExtGet("kotlinVersion", "1.8.10") 26 + } 27 + } 28 + 29 + repositories { 30 + mavenCentral() 31 + } 32 + 33 + dependencies { 34 + classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}") 35 + } 36 + } 37 + 38 + afterEvaluate { 39 + publishing { 40 + publications { 41 + release(MavenPublication) { 42 + from components.release 43 + } 44 + } 45 + repositories { 46 + maven { 47 + url = mavenLocal().url 48 + } 49 + } 50 + } 51 + } 52 + 53 + android { 54 + compileSdkVersion safeExtGet("compileSdkVersion", 33) 55 + 56 + def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION 57 + if (agpVersion.tokenize('.')[0].toInteger() < 8) { 58 + compileOptions { 59 + sourceCompatibility JavaVersion.VERSION_11 60 + targetCompatibility JavaVersion.VERSION_11 61 + } 62 + 63 + kotlinOptions { 64 + jvmTarget = JavaVersion.VERSION_11.majorVersion 65 + } 66 + } 67 + 68 + namespace "expo.modules.backgroundnotificationhandler" 69 + defaultConfig { 70 + minSdkVersion safeExtGet("minSdkVersion", 21) 71 + targetSdkVersion safeExtGet("targetSdkVersion", 34) 72 + versionCode 1 73 + versionName "0.5.0" 74 + } 75 + lintOptions { 76 + abortOnError false 77 + } 78 + publishing { 79 + singleVariant("release") { 80 + withSourcesJar() 81 + } 82 + } 83 + } 84 + 85 + repositories { 86 + mavenCentral() 87 + } 88 + 89 + dependencies { 90 + implementation project(':expo-modules-core') 91 + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}" 92 + implementation 'com.google.firebase:firebase-messaging-ktx:24.0.0' 93 + }
+2
modules/expo-background-notification-handler/android/src/main/AndroidManifest.xml
··· 1 + <manifest> 2 + </manifest>
+39
modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
··· 1 + package expo.modules.backgroundnotificationhandler 2 + 3 + import android.content.Context 4 + import com.google.firebase.messaging.RemoteMessage 5 + 6 + class BackgroundNotificationHandler( 7 + private val context: Context, 8 + private val notifInterface: BackgroundNotificationHandlerInterface 9 + ) { 10 + fun handleMessage(remoteMessage: RemoteMessage) { 11 + if (ExpoBackgroundNotificationHandlerModule.isForegrounded) { 12 + // We'll let expo-notifications handle the notification if the app is foregrounded 13 + return 14 + } 15 + 16 + if (remoteMessage.data["reason"] == "chat-message") { 17 + mutateWithChatMessage(remoteMessage) 18 + } 19 + 20 + notifInterface.showMessage(remoteMessage) 21 + } 22 + 23 + private fun mutateWithChatMessage(remoteMessage: RemoteMessage) { 24 + if (NotificationPrefs(context).getBoolean("playSoundChat")) { 25 + // If oreo or higher 26 + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { 27 + remoteMessage.data["channelId"] = "chat-messages" 28 + } else { 29 + remoteMessage.data["sound"] = "dm.mp3" 30 + } 31 + } else { 32 + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { 33 + remoteMessage.data["channelId"] = "chat-messages-muted" 34 + } else { 35 + remoteMessage.data["sound"] = null 36 + } 37 + } 38 + } 39 + }
+7
modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandlerInterface.kt
··· 1 + package expo.modules.backgroundnotificationhandler 2 + 3 + import com.google.firebase.messaging.RemoteMessage 4 + 5 + interface BackgroundNotificationHandlerInterface { 6 + fun showMessage(remoteMessage: RemoteMessage) 7 + }
+70
modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/ExpoBackgroundNotificationHandlerModule.kt
··· 1 + package expo.modules.backgroundnotificationhandler 2 + 3 + import expo.modules.kotlin.modules.Module 4 + import expo.modules.kotlin.modules.ModuleDefinition 5 + 6 + class ExpoBackgroundNotificationHandlerModule : Module() { 7 + companion object { 8 + var isForegrounded = false 9 + } 10 + 11 + override fun definition() = ModuleDefinition { 12 + Name("ExpoBackgroundNotificationHandler") 13 + 14 + OnCreate { 15 + NotificationPrefs(appContext.reactContext).initialize() 16 + } 17 + 18 + OnActivityEntersForeground { 19 + isForegrounded = true 20 + } 21 + 22 + OnActivityEntersBackground { 23 + isForegrounded = false 24 + } 25 + 26 + AsyncFunction("getAllPrefsAsync") { 27 + return@AsyncFunction NotificationPrefs(appContext.reactContext).getAllPrefs() 28 + } 29 + 30 + AsyncFunction("getBoolAsync") { forKey: String -> 31 + return@AsyncFunction NotificationPrefs(appContext.reactContext).getBoolean(forKey) 32 + } 33 + 34 + AsyncFunction("getStringAsync") { forKey: String -> 35 + return@AsyncFunction NotificationPrefs(appContext.reactContext).getString(forKey) 36 + } 37 + 38 + AsyncFunction("getStringArrayAsync") { forKey: String -> 39 + return@AsyncFunction NotificationPrefs(appContext.reactContext).getStringArray(forKey) 40 + } 41 + 42 + AsyncFunction("setBoolAsync") { forKey: String, value: Boolean -> 43 + NotificationPrefs(appContext.reactContext).setBoolean(forKey, value) 44 + } 45 + 46 + AsyncFunction("setStringAsync") { forKey: String, value: String -> 47 + NotificationPrefs(appContext.reactContext).setString(forKey, value) 48 + } 49 + 50 + AsyncFunction("setStringArrayAsync") { forKey: String, value: Array<String> -> 51 + NotificationPrefs(appContext.reactContext).setStringArray(forKey, value) 52 + } 53 + 54 + AsyncFunction("addToStringArrayAsync") { forKey: String, string: String -> 55 + NotificationPrefs(appContext.reactContext).addToStringArray(forKey, string) 56 + } 57 + 58 + AsyncFunction("removeFromStringArrayAsync") { forKey: String, string: String -> 59 + NotificationPrefs(appContext.reactContext).removeFromStringArray(forKey, string) 60 + } 61 + 62 + AsyncFunction("addManyToStringArrayAsync") { forKey: String, strings: Array<String> -> 63 + NotificationPrefs(appContext.reactContext).addManyToStringArray(forKey, strings) 64 + } 65 + 66 + AsyncFunction("removeManyFromStringArrayAsync") { forKey: String, strings: Array<String> -> 67 + NotificationPrefs(appContext.reactContext).removeManyFromStringArray(forKey, strings) 68 + } 69 + } 70 + }
+134
modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/NotificationPrefs.kt
··· 1 + package expo.modules.backgroundnotificationhandler 2 + 3 + import android.content.Context 4 + 5 + val DEFAULTS = mapOf<String, Any>( 6 + "playSoundChat" to true, 7 + "playSoundFollow" to false, 8 + "playSoundLike" to false, 9 + "playSoundMention" to false, 10 + "playSoundQuote" to false, 11 + "playSoundReply" to false, 12 + "playSoundRepost" to false, 13 + "mutedThreads" to mapOf<String, List<String>>() 14 + ) 15 + 16 + class NotificationPrefs (private val context: Context?) { 17 + private val prefs = context?.getSharedPreferences("xyz.blueskyweb.app", Context.MODE_PRIVATE) 18 + ?: throw Error("Context is null") 19 + 20 + fun initialize() { 21 + prefs 22 + .edit() 23 + .apply { 24 + DEFAULTS.forEach { (key, value) -> 25 + if (prefs.contains(key)) { 26 + return@forEach 27 + } 28 + 29 + when (value) { 30 + is Boolean -> { 31 + putBoolean(key, value) 32 + } 33 + is String -> { 34 + putString(key, value) 35 + } 36 + is Array<*> -> { 37 + putStringSet(key, value.map { it.toString() }.toSet()) 38 + } 39 + is Map<*, *> -> { 40 + putStringSet(key, value.map { it.toString() }.toSet()) 41 + } 42 + } 43 + } 44 + } 45 + .apply() 46 + } 47 + 48 + fun getAllPrefs(): MutableMap<String, *> { 49 + return prefs.all 50 + } 51 + 52 + fun getBoolean(key: String): Boolean { 53 + return prefs.getBoolean(key, false) 54 + } 55 + 56 + fun getString(key: String): String? { 57 + return prefs.getString(key, null) 58 + } 59 + 60 + fun getStringArray(key: String): Array<String>? { 61 + return prefs.getStringSet(key, null)?.toTypedArray() 62 + } 63 + 64 + fun setBoolean(key: String, value: Boolean) { 65 + prefs 66 + .edit() 67 + .apply { 68 + putBoolean(key, value) 69 + } 70 + .apply() 71 + } 72 + 73 + fun setString(key: String, value: String) { 74 + prefs 75 + .edit() 76 + .apply { 77 + putString(key, value) 78 + } 79 + .apply() 80 + } 81 + 82 + fun setStringArray(key: String, value: Array<String>) { 83 + prefs 84 + .edit() 85 + .apply { 86 + putStringSet(key, value.toSet()) 87 + } 88 + .apply() 89 + } 90 + 91 + fun addToStringArray(key: String, string: String) { 92 + prefs 93 + .edit() 94 + .apply { 95 + val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf() 96 + set.add(string) 97 + putStringSet(key, set) 98 + } 99 + .apply() 100 + } 101 + 102 + fun removeFromStringArray(key: String, string: String) { 103 + prefs 104 + .edit() 105 + .apply { 106 + val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf() 107 + set.remove(string) 108 + putStringSet(key, set) 109 + } 110 + .apply() 111 + } 112 + 113 + fun addManyToStringArray(key: String, strings: Array<String>) { 114 + prefs 115 + .edit() 116 + .apply { 117 + val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf() 118 + set.addAll(strings.toSet()) 119 + putStringSet(key, set) 120 + } 121 + .apply() 122 + } 123 + 124 + fun removeManyFromStringArray(key: String, strings: Array<String>) { 125 + prefs 126 + .edit() 127 + .apply { 128 + val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf() 129 + set.removeAll(strings.toSet()) 130 + putStringSet(key, set) 131 + } 132 + .apply() 133 + } 134 + }
+9
modules/expo-background-notification-handler/expo-module.config.json
··· 1 + { 2 + "platforms": ["ios", "android"], 3 + "ios": { 4 + "modules": ["ExpoBackgroundNotificationHandlerModule"] 5 + }, 6 + "android": { 7 + "modules": ["expo.modules.backgroundnotificationhandler.ExpoBackgroundNotificationHandlerModule"] 8 + } 9 + }
+2
modules/expo-background-notification-handler/index.ts
··· 1 + import {BackgroundNotificationHandler} from './src/ExpoBackgroundNotificationHandlerModule' 2 + export default BackgroundNotificationHandler
+21
modules/expo-background-notification-handler/ios/ExpoBackgroundNotificationHandler.podspec
··· 1 + Pod::Spec.new do |s| 2 + s.name = 'ExpoBackgroundNotificationHandler' 3 + s.version = '1.0.0' 4 + s.summary = 'Interface for BlueskyNSE preferences' 5 + s.description = 'Interface for BlueskyNSE preferenes' 6 + s.author = '' 7 + s.homepage = 'https://github.com/bluesky-social/social-app' 8 + s.platforms = { :ios => '13.4', :tvos => '13.4' } 9 + s.source = { git: '' } 10 + s.static_framework = true 11 + 12 + s.dependency 'ExpoModulesCore' 13 + 14 + # Swift/Objective-C compatibility 15 + s.pod_target_xcconfig = { 16 + 'DEFINES_MODULE' => 'YES', 17 + 'SWIFT_COMPILATION_MODE' => 'wholemodule' 18 + } 19 + 20 + s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}" 21 + end
+116
modules/expo-background-notification-handler/ios/ExpoBackgroundNotificationHandlerModule.swift
··· 1 + import ExpoModulesCore 2 + 3 + let APP_GROUP = "group.app.bsky" 4 + 5 + let DEFAULTS: [String:Any] = [ 6 + "playSoundChat" : true, 7 + "playSoundFollow": false, 8 + "playSoundLike": false, 9 + "playSoundMention": false, 10 + "playSoundQuote": false, 11 + "playSoundReply": false, 12 + "playSoundRepost": false, 13 + "mutedThreads": [:] as! [String:[String]] 14 + ] 15 + 16 + /* 17 + * The purpose of this module is to store values that are needed by the notification service 18 + * extension. Since we would rather get and store values such as age or user mute state 19 + * while the app is foregrounded, we should use this module liberally. We should aim to keep 20 + * background fetches to a minimum (two or three times per hour) while the app is backgrounded 21 + * or killed 22 + */ 23 + public class ExpoBackgroundNotificationHandlerModule: Module { 24 + let userDefaults = UserDefaults(suiteName: APP_GROUP) 25 + 26 + public func definition() -> ModuleDefinition { 27 + Name("ExpoBackgroundNotificationHandler") 28 + 29 + OnCreate { 30 + DEFAULTS.forEach { p in 31 + if userDefaults?.value(forKey: p.key) == nil { 32 + userDefaults?.setValue(p.value, forKey: p.key) 33 + } 34 + } 35 + } 36 + 37 + AsyncFunction("getAllPrefsAsync") { () -> [String:Any]? in 38 + var keys: [String] = [] 39 + DEFAULTS.forEach { p in 40 + keys.append(p.key) 41 + } 42 + return userDefaults?.dictionaryWithValues(forKeys: keys) 43 + } 44 + 45 + AsyncFunction("getBoolAsync") { (forKey: String) -> Bool in 46 + if let pref = userDefaults?.bool(forKey: forKey) { 47 + return pref 48 + } 49 + return false 50 + } 51 + 52 + AsyncFunction("getStringAsync") { (forKey: String) -> String? in 53 + if let pref = userDefaults?.string(forKey: forKey) { 54 + return pref 55 + } 56 + return nil 57 + } 58 + 59 + AsyncFunction("getStringArrayAsync") { (forKey: String) -> [String]? in 60 + if let pref = userDefaults?.stringArray(forKey: forKey) { 61 + return pref 62 + } 63 + return nil 64 + } 65 + 66 + AsyncFunction("setBoolAsync") { (forKey: String, value: Bool) -> Void in 67 + userDefaults?.setValue(value, forKey: forKey) 68 + } 69 + 70 + AsyncFunction("setStringAsync") { (forKey: String, value: String) -> Void in 71 + userDefaults?.setValue(value, forKey: forKey) 72 + } 73 + 74 + AsyncFunction("setStringArrayAsync") { (forKey: String, value: [String]) -> Void in 75 + userDefaults?.setValue(value, forKey: forKey) 76 + } 77 + 78 + AsyncFunction("addToStringArrayAsync") { (forKey: String, string: String) in 79 + if var curr = userDefaults?.stringArray(forKey: forKey), 80 + !curr.contains(string) 81 + { 82 + curr.append(string) 83 + userDefaults?.setValue(curr, forKey: forKey) 84 + } 85 + } 86 + 87 + AsyncFunction("removeFromStringArrayAsync") { (forKey: String, string: String) in 88 + if var curr = userDefaults?.stringArray(forKey: forKey) { 89 + curr.removeAll { s in 90 + return s == string 91 + } 92 + userDefaults?.setValue(curr, forKey: forKey) 93 + } 94 + } 95 + 96 + AsyncFunction("addManyToStringArrayAsync") { (forKey: String, strings: [String]) in 97 + if var curr = userDefaults?.stringArray(forKey: forKey) { 98 + strings.forEach { s in 99 + if !curr.contains(s) { 100 + curr.append(s) 101 + } 102 + } 103 + userDefaults?.setValue(curr, forKey: forKey) 104 + } 105 + } 106 + 107 + AsyncFunction("removeManyFromStringArrayAsync") { (forKey: String, strings: [String]) in 108 + if var curr = userDefaults?.stringArray(forKey: forKey) { 109 + strings.forEach { s in 110 + curr.removeAll(where: { $0 == s }) 111 + } 112 + userDefaults?.setValue(curr, forKey: forKey) 113 + } 114 + } 115 + } 116 + }
+70
modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider.tsx
··· 1 + import React from 'react' 2 + 3 + import {BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types' 4 + import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule' 5 + 6 + interface BackgroundNotificationPreferencesContext { 7 + preferences: BackgroundNotificationHandlerPreferences 8 + setPref: <Key extends keyof BackgroundNotificationHandlerPreferences>( 9 + key: Key, 10 + value: BackgroundNotificationHandlerPreferences[Key], 11 + ) => void 12 + } 13 + 14 + const Context = React.createContext<BackgroundNotificationPreferencesContext>( 15 + {} as BackgroundNotificationPreferencesContext, 16 + ) 17 + export const useBackgroundNotificationPreferences = () => 18 + React.useContext(Context) 19 + 20 + export function BackgroundNotificationPreferencesProvider({ 21 + children, 22 + }: { 23 + children: React.ReactNode 24 + }) { 25 + const [preferences, setPreferences] = 26 + React.useState<BackgroundNotificationHandlerPreferences>({ 27 + playSoundChat: true, 28 + }) 29 + 30 + React.useEffect(() => { 31 + ;(async () => { 32 + const prefs = await BackgroundNotificationHandler.getAllPrefsAsync() 33 + setPreferences(prefs) 34 + })() 35 + }, []) 36 + 37 + const value = React.useMemo( 38 + () => ({ 39 + preferences, 40 + setPref: async < 41 + Key extends keyof BackgroundNotificationHandlerPreferences, 42 + >( 43 + k: Key, 44 + v: BackgroundNotificationHandlerPreferences[Key], 45 + ) => { 46 + switch (typeof v) { 47 + case 'boolean': { 48 + await BackgroundNotificationHandler.setBoolAsync(k, v) 49 + break 50 + } 51 + case 'string': { 52 + await BackgroundNotificationHandler.setStringAsync(k, v) 53 + break 54 + } 55 + default: { 56 + throw new Error(`Invalid type for value: ${typeof v}`) 57 + } 58 + } 59 + 60 + setPreferences(prev => ({ 61 + ...prev, 62 + [k]: v, 63 + })) 64 + }, 65 + }), 66 + [preferences], 67 + ) 68 + 69 + return <Context.Provider value={value}>{children}</Context.Provider> 70 + }
+40
modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandler.types.ts
··· 1 + export type ExpoBackgroundNotificationHandlerModule = { 2 + getAllPrefsAsync: () => Promise<BackgroundNotificationHandlerPreferences> 3 + getBoolAsync: (forKey: string) => Promise<boolean> 4 + getStringAsync: (forKey: string) => Promise<string> 5 + getStringArrayAsync: (forKey: string) => Promise<string[]> 6 + setBoolAsync: ( 7 + forKey: keyof BackgroundNotificationHandlerPreferences, 8 + value: boolean, 9 + ) => Promise<void> 10 + setStringAsync: ( 11 + forKey: keyof BackgroundNotificationHandlerPreferences, 12 + value: string, 13 + ) => Promise<void> 14 + setStringArrayAsync: ( 15 + forKey: keyof BackgroundNotificationHandlerPreferences, 16 + value: string[], 17 + ) => Promise<void> 18 + addToStringArrayAsync: ( 19 + forKey: keyof BackgroundNotificationHandlerPreferences, 20 + value: string, 21 + ) => Promise<void> 22 + removeFromStringArrayAsync: ( 23 + forKey: keyof BackgroundNotificationHandlerPreferences, 24 + value: string, 25 + ) => Promise<void> 26 + addManyToStringArrayAsync: ( 27 + forKey: keyof BackgroundNotificationHandlerPreferences, 28 + value: string[], 29 + ) => Promise<void> 30 + removeManyFromStringArrayAsync: ( 31 + forKey: keyof BackgroundNotificationHandlerPreferences, 32 + value: string[], 33 + ) => Promise<void> 34 + } 35 + 36 + // TODO there are more preferences in the native code, however they have not been added here yet. 37 + // Don't add them until the native logic also handles the notifications for those preference types. 38 + export type BackgroundNotificationHandlerPreferences = { 39 + playSoundChat: boolean 40 + }
+8
modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.ts
··· 1 + import {requireNativeModule} from 'expo-modules-core' 2 + 3 + import {ExpoBackgroundNotificationHandlerModule} from './ExpoBackgroundNotificationHandler.types' 4 + 5 + export const BackgroundNotificationHandler = 6 + requireNativeModule<ExpoBackgroundNotificationHandlerModule>( 7 + 'ExpoBackgroundNotificationHandler', 8 + )
+27
modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.web.ts
··· 1 + import { 2 + BackgroundNotificationHandlerPreferences, 3 + ExpoBackgroundNotificationHandlerModule, 4 + } from './ExpoBackgroundNotificationHandler.types' 5 + 6 + // Stub for web 7 + export const BackgroundNotificationHandler = { 8 + getAllPrefsAsync: async () => { 9 + return {} as BackgroundNotificationHandlerPreferences 10 + }, 11 + getBoolAsync: async (_: string) => { 12 + return false 13 + }, 14 + getStringAsync: async (_: string) => { 15 + return '' 16 + }, 17 + getStringArrayAsync: async (_: string) => { 18 + return [] 19 + }, 20 + setBoolAsync: async (_: string, __: boolean) => {}, 21 + setStringAsync: async (_: string, __: string) => {}, 22 + setStringArrayAsync: async (_: string, __: string[]) => {}, 23 + addToStringArrayAsync: async (_: string, __: string) => {}, 24 + removeFromStringArrayAsync: async (_: string, __: string) => {}, 25 + addManyToStringArrayAsync: async (_: string, __: string[]) => {}, 26 + removeManyFromStringArrayAsync: async (_: string, __: string[]) => {}, 27 + } as ExpoBackgroundNotificationHandlerModule
+197
patches/expo-notifications+0.27.6.patch
··· 1 + diff --git a/node_modules/expo-notifications/android/build.gradle b/node_modules/expo-notifications/android/build.gradle 2 + index 97bf4f4..6e9d427 100644 3 + --- a/node_modules/expo-notifications/android/build.gradle 4 + +++ b/node_modules/expo-notifications/android/build.gradle 5 + @@ -118,6 +118,7 @@ dependencies { 6 + api 'com.google.firebase:firebase-messaging:22.0.0' 7 + 8 + api 'me.leolin:ShortcutBadger:1.1.22@aar' 9 + + implementation project(':expo-background-notification-handler') 10 + 11 + if (project.findProject(':expo-modules-test-core')) { 12 + testImplementation project(':expo-modules-test-core') 13 + 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 14 + index 0af7fe0..8f2c8d8 100644 15 + --- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/JSONNotificationContentBuilder.java 16 + +++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/JSONNotificationContentBuilder.java 17 + @@ -14,6 +14,7 @@ import expo.modules.notifications.notifications.enums.NotificationPriority; 18 + import expo.modules.notifications.notifications.model.NotificationContent; 19 + 20 + public class JSONNotificationContentBuilder extends NotificationContent.Builder { 21 + + private static final String CHANNEL_ID_KEY = "channelId"; 22 + private static final String TITLE_KEY = "title"; 23 + private static final String TEXT_KEY = "message"; 24 + private static final String SUBTITLE_KEY = "subtitle"; 25 + @@ -36,6 +37,7 @@ public class JSONNotificationContentBuilder extends NotificationContent.Builder 26 + 27 + public NotificationContent.Builder setPayload(JSONObject payload) { 28 + this.setTitle(getTitle(payload)) 29 + + .setChannelId(getChannelId(payload)) 30 + .setSubtitle(getSubtitle(payload)) 31 + .setText(getText(payload)) 32 + .setBody(getBody(payload)) 33 + @@ -60,6 +62,14 @@ public class JSONNotificationContentBuilder extends NotificationContent.Builder 34 + return this; 35 + } 36 + 37 + + protected String getChannelId(JSONObject payload) { 38 + + try { 39 + + return payload.getString(CHANNEL_ID_KEY); 40 + + } catch (JSONException e) { 41 + + return null; 42 + + } 43 + + } 44 + + 45 + protected String getTitle(JSONObject payload) { 46 + try { 47 + return payload.getString(TITLE_KEY); 48 + 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 49 + index f1fed19..1619f59 100644 50 + --- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java 51 + +++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java 52 + @@ -20,6 +20,7 @@ import expo.modules.notifications.notifications.enums.NotificationPriority; 53 + * should be created using {@link NotificationContent.Builder}. 54 + */ 55 + public class NotificationContent implements Parcelable, Serializable { 56 + + private String mChannelId; 57 + private String mTitle; 58 + private String mText; 59 + private String mSubtitle; 60 + @@ -50,6 +51,9 @@ public class NotificationContent implements Parcelable, Serializable { 61 + } 62 + }; 63 + 64 + + @Nullable 65 + + public String getChannelId() { return mChannelId; } 66 + + 67 + @Nullable 68 + public String getTitle() { 69 + return mTitle; 70 + @@ -121,6 +125,7 @@ public class NotificationContent implements Parcelable, Serializable { 71 + } 72 + 73 + protected NotificationContent(Parcel in) { 74 + + mChannelId = in.readString(); 75 + mTitle = in.readString(); 76 + mText = in.readString(); 77 + mSubtitle = in.readString(); 78 + @@ -146,6 +151,7 @@ public class NotificationContent implements Parcelable, Serializable { 79 + 80 + @Override 81 + public void writeToParcel(Parcel dest, int flags) { 82 + + dest.writeString(mChannelId); 83 + dest.writeString(mTitle); 84 + dest.writeString(mText); 85 + dest.writeString(mSubtitle); 86 + @@ -166,6 +172,7 @@ public class NotificationContent implements Parcelable, Serializable { 87 + private static final long serialVersionUID = 397666843266836802L; 88 + 89 + private void writeObject(java.io.ObjectOutputStream out) throws IOException { 90 + + out.writeObject(mChannelId); 91 + out.writeObject(mTitle); 92 + out.writeObject(mText); 93 + out.writeObject(mSubtitle); 94 + @@ -190,6 +197,7 @@ public class NotificationContent implements Parcelable, Serializable { 95 + } 96 + 97 + private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException { 98 + + mChannelId = (String) in.readObject(); 99 + mTitle = (String) in.readObject(); 100 + mText = (String) in.readObject(); 101 + mSubtitle = (String) in.readObject(); 102 + @@ -240,6 +248,7 @@ public class NotificationContent implements Parcelable, Serializable { 103 + } 104 + 105 + public static class Builder { 106 + + private String mChannelId; 107 + private String mTitle; 108 + private String mText; 109 + private String mSubtitle; 110 + @@ -260,6 +269,11 @@ public class NotificationContent implements Parcelable, Serializable { 111 + useDefaultVibrationPattern(); 112 + } 113 + 114 + + public Builder setChannelId(String channelId) { 115 + + mChannelId = channelId; 116 + + return this; 117 + + } 118 + + 119 + public Builder setTitle(String title) { 120 + mTitle = title; 121 + return this; 122 + @@ -336,6 +350,7 @@ public class NotificationContent implements Parcelable, Serializable { 123 + 124 + public NotificationContent build() { 125 + NotificationContent content = new NotificationContent(); 126 + + content.mChannelId = mChannelId; 127 + content.mTitle = mTitle; 128 + content.mSubtitle = mSubtitle; 129 + content.mText = mText; 130 + 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 131 + index 6bd9928..aab71ea 100644 132 + --- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.java 133 + +++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.java 134 + @@ -7,7 +7,6 @@ import android.content.pm.PackageManager; 135 + import android.content.res.Resources; 136 + import android.graphics.Bitmap; 137 + import android.graphics.BitmapFactory; 138 + -import android.os.Build; 139 + import android.os.Bundle; 140 + import android.os.Parcel; 141 + import android.provider.Settings; 142 + @@ -48,6 +47,10 @@ public class ExpoNotificationBuilder extends ChannelAwareNotificationBuilder { 143 + 144 + NotificationContent content = getNotificationContent(); 145 + 146 + + if (content.getChannelId() != null) { 147 + + builder.setChannelId(content.getChannelId()); 148 + + } 149 + + 150 + builder.setAutoCancel(content.isAutoDismiss()); 151 + builder.setOngoing(content.isSticky()); 152 + 153 + 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 154 + index 55b3a8d..1b99d5b 100644 155 + --- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt 156 + +++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt 157 + @@ -12,11 +12,14 @@ import expo.modules.notifications.notifications.model.triggers.FirebaseNotificat 158 + import expo.modules.notifications.service.NotificationsService 159 + import expo.modules.notifications.service.interfaces.FirebaseMessagingDelegate 160 + import expo.modules.notifications.tokens.interfaces.FirebaseTokenListener 161 + +import expo.modules.backgroundnotificationhandler.BackgroundNotificationHandler 162 + +import expo.modules.backgroundnotificationhandler.BackgroundNotificationHandlerInterface 163 + +import expo.modules.backgroundnotificationhandler.ExpoBackgroundNotificationHandlerModule 164 + import org.json.JSONObject 165 + import java.lang.ref.WeakReference 166 + import java.util.* 167 + 168 + -open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseMessagingDelegate { 169 + +open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseMessagingDelegate, BackgroundNotificationHandlerInterface { 170 + companion object { 171 + // Unfortunately we cannot save state between instances of a service other way 172 + // than by static properties. Fortunately, using weak references we can 173 + @@ -89,12 +92,21 @@ open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseM 174 + fun getBackgroundTasks() = sBackgroundTaskConsumerReferences.values.mapNotNull { it.get() } 175 + 176 + override fun onMessageReceived(remoteMessage: RemoteMessage) { 177 + - NotificationsService.receive(context, createNotification(remoteMessage)) 178 + - getBackgroundTasks().forEach { 179 + - it.scheduleJob(RemoteMessageSerializer.toBundle(remoteMessage)) 180 + + if (!ExpoBackgroundNotificationHandlerModule.isForegrounded) { 181 + + BackgroundNotificationHandler(context, this).handleMessage(remoteMessage) 182 + + return 183 + + } else { 184 + + showMessage(remoteMessage) 185 + + getBackgroundTasks().forEach { 186 + + it.scheduleJob(RemoteMessageSerializer.toBundle(remoteMessage)) 187 + + } 188 + } 189 + } 190 + 191 + + override fun showMessage(remoteMessage: RemoteMessage) { 192 + + NotificationsService.receive(context, createNotification(remoteMessage)) 193 + + } 194 + + 195 + protected fun createNotification(remoteMessage: RemoteMessage): Notification { 196 + val identifier = getNotificationIdentifier(remoteMessage) 197 + val payload = JSONObject(remoteMessage.data as Map<*, *>)
+9
patches/expo-notifications-0.27.6.patch.md
··· 1 + ## LOAD BEARING PATCH, DO NOT REMOVE 2 + 3 + ## Expo-Notifications Patch 4 + 5 + This patch supports the Android background notification handling module. Incoming messages 6 + in `onMessageReceived` are sent to the module for handling. 7 + 8 + It also allows us to set the Android notification channel ID from the notification `data`, rather 9 + than the `notification` object in the payload.
+17
plugins/notificationsExtension/README.md
··· 1 + # Notifications extension plugin for Expo 2 + 3 + This plugin handles moving the necessary files into their respective iOS directories 4 + 5 + ## Steps 6 + 7 + ### ios 8 + 9 + 1. Update entitlements 10 + 2. Set the app group to group.<identifier> 11 + 3. Add the extension plist 12 + 4. Add the view controller 13 + 5. Update the xcode project's build phases 14 + 15 + ## Credits 16 + 17 + 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
+13
plugins/notificationsExtension/withAppEntitlements.js
··· 1 + const {withEntitlementsPlist} = require('@expo/config-plugins') 2 + 3 + const withAppEntitlements = config => { 4 + // eslint-disable-next-line no-shadow 5 + return withEntitlementsPlist(config, async config => { 6 + config.modResults['com.apple.security.application-groups'] = [ 7 + `group.app.bsky`, 8 + ] 9 + return config 10 + }) 11 + } 12 + 13 + module.exports = {withAppEntitlements}
+31
plugins/notificationsExtension/withExtensionEntitlements.js
··· 1 + const {withInfoPlist} = require('@expo/config-plugins') 2 + const plist = require('@expo/plist') 3 + const path = require('path') 4 + const fs = require('fs') 5 + 6 + const withExtensionEntitlements = (config, {extensionName}) => { 7 + // eslint-disable-next-line no-shadow 8 + return withInfoPlist(config, config => { 9 + const extensionEntitlementsPath = path.join( 10 + config.modRequest.platformProjectRoot, 11 + extensionName, 12 + `${extensionName}.entitlements`, 13 + ) 14 + 15 + const notificationsExtensionEntitlements = { 16 + 'com.apple.security.application-groups': [`group.app.bsky`], 17 + } 18 + 19 + fs.mkdirSync(path.dirname(extensionEntitlementsPath), { 20 + recursive: true, 21 + }) 22 + fs.writeFileSync( 23 + extensionEntitlementsPath, 24 + plist.default.build(notificationsExtensionEntitlements), 25 + ) 26 + 27 + return config 28 + }) 29 + } 30 + 31 + module.exports = {withExtensionEntitlements}
+39
plugins/notificationsExtension/withExtensionInfoPlist.js
··· 1 + const {withInfoPlist} = require('@expo/config-plugins') 2 + const plist = require('@expo/plist') 3 + const path = require('path') 4 + const fs = require('fs') 5 + 6 + const withExtensionInfoPlist = (config, {extensionName}) => { 7 + // eslint-disable-next-line no-shadow 8 + return withInfoPlist(config, config => { 9 + const plistPath = path.join( 10 + config.modRequest.projectRoot, 11 + 'modules', 12 + extensionName, 13 + 'Info.plist', 14 + ) 15 + const targetPath = path.join( 16 + config.modRequest.platformProjectRoot, 17 + extensionName, 18 + 'Info.plist', 19 + ) 20 + 21 + const extPlist = plist.default.parse(fs.readFileSync(plistPath).toString()) 22 + 23 + extPlist.MainAppScheme = config.scheme 24 + extPlist.CFBundleName = '$(PRODUCT_NAME)' 25 + extPlist.CFBundleDisplayName = 'Bluesky Notifications' 26 + extPlist.CFBundleIdentifier = '$(PRODUCT_BUNDLE_IDENTIFIER)' 27 + extPlist.CFBundleVersion = '$(CURRENT_PROJECT_VERSION)' 28 + extPlist.CFBundleExecutable = '$(EXECUTABLE_NAME)' 29 + extPlist.CFBundlePackageType = '$(PRODUCT_BUNDLE_PACKAGE_TYPE)' 30 + extPlist.CFBundleShortVersionString = '$(MARKETING_VERSION)' 31 + 32 + fs.mkdirSync(path.dirname(targetPath), {recursive: true}) 33 + fs.writeFileSync(targetPath, plist.default.build(extPlist)) 34 + 35 + return config 36 + }) 37 + } 38 + 39 + module.exports = {withExtensionInfoPlist}
+31
plugins/notificationsExtension/withExtensionViewController.js
··· 1 + const {withXcodeProject} = require('@expo/config-plugins') 2 + const path = require('path') 3 + const fs = require('fs') 4 + 5 + const withExtensionViewController = ( 6 + config, 7 + {controllerName, extensionName}, 8 + ) => { 9 + // eslint-disable-next-line no-shadow 10 + return withXcodeProject(config, config => { 11 + const controllerPath = path.join( 12 + config.modRequest.projectRoot, 13 + 'modules', 14 + extensionName, 15 + `${controllerName}.swift`, 16 + ) 17 + 18 + const targetPath = path.join( 19 + config.modRequest.platformProjectRoot, 20 + extensionName, 21 + `${controllerName}.swift`, 22 + ) 23 + 24 + fs.mkdirSync(path.dirname(targetPath), {recursive: true}) 25 + fs.copyFileSync(controllerPath, targetPath) 26 + 27 + return config 28 + }) 29 + } 30 + 31 + module.exports = {withExtensionViewController}
+55
plugins/notificationsExtension/withNotificationsExtension.js
··· 1 + const {withPlugins} = require('@expo/config-plugins') 2 + const {withAppEntitlements} = require('./withAppEntitlements') 3 + const {withXcodeTarget} = require('./withXcodeTarget') 4 + const {withExtensionEntitlements} = require('./withExtensionEntitlements') 5 + const {withExtensionInfoPlist} = require('./withExtensionInfoPlist') 6 + const {withExtensionViewController} = require('./withExtensionViewController') 7 + const {withSounds} = require('./withSounds') 8 + 9 + const EXTENSION_NAME = 'BlueskyNSE' 10 + const EXTENSION_CONTROLLER_NAME = 'NotificationService' 11 + 12 + const withNotificationsExtension = config => { 13 + const soundFiles = ['dm.aiff'] 14 + 15 + return withPlugins(config, [ 16 + // IOS 17 + withAppEntitlements, 18 + [ 19 + withExtensionEntitlements, 20 + { 21 + extensionName: EXTENSION_NAME, 22 + }, 23 + ], 24 + [ 25 + withExtensionInfoPlist, 26 + { 27 + extensionName: EXTENSION_NAME, 28 + }, 29 + ], 30 + [ 31 + withExtensionViewController, 32 + { 33 + extensionName: EXTENSION_NAME, 34 + controllerName: EXTENSION_CONTROLLER_NAME, 35 + }, 36 + ], 37 + [ 38 + withSounds, 39 + { 40 + extensionName: EXTENSION_NAME, 41 + soundFiles, 42 + }, 43 + ], 44 + [ 45 + withXcodeTarget, 46 + { 47 + extensionName: EXTENSION_NAME, 48 + controllerName: EXTENSION_CONTROLLER_NAME, 49 + soundFiles, 50 + }, 51 + ], 52 + ]) 53 + } 54 + 55 + module.exports = withNotificationsExtension
+27
plugins/notificationsExtension/withSounds.js
··· 1 + const {withXcodeProject} = require('@expo/config-plugins') 2 + const path = require('path') 3 + const fs = require('fs') 4 + 5 + const withSounds = (config, {extensionName, soundFiles}) => { 6 + // eslint-disable-next-line no-shadow 7 + return withXcodeProject(config, config => { 8 + for (const file of soundFiles) { 9 + const soundPath = path.join(config.modRequest.projectRoot, 'assets', file) 10 + 11 + const targetPath = path.join( 12 + config.modRequest.platformProjectRoot, 13 + extensionName, 14 + file, 15 + ) 16 + 17 + if (!fs.existsSync(path.dirname(targetPath))) { 18 + fs.mkdirSync(path.dirname(targetPath), {recursive: true}) 19 + } 20 + fs.copyFileSync(soundPath, targetPath) 21 + } 22 + 23 + return config 24 + }) 25 + } 26 + 27 + module.exports = {withSounds}
+76
plugins/notificationsExtension/withXcodeTarget.js
··· 1 + const {withXcodeProject, IOSConfig} = require('@expo/config-plugins') 2 + const path = require('path') 3 + const PBXFile = require('xcode/lib/pbxFile') 4 + 5 + const withXcodeTarget = ( 6 + config, 7 + {extensionName, controllerName, soundFiles}, 8 + ) => { 9 + // eslint-disable-next-line no-shadow 10 + return withXcodeProject(config, config => { 11 + let pbxProject = config.modResults 12 + 13 + const target = pbxProject.addTarget( 14 + extensionName, 15 + 'app_extension', 16 + extensionName, 17 + ) 18 + pbxProject.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', target.uuid) 19 + pbxProject.addBuildPhase( 20 + [], 21 + 'PBXResourcesBuildPhase', 22 + 'Resources', 23 + target.uuid, 24 + ) 25 + const pbxGroupKey = pbxProject.pbxCreateGroup(extensionName, extensionName) 26 + pbxProject.addFile(`${extensionName}/Info.plist`, pbxGroupKey) 27 + pbxProject.addSourceFile( 28 + `${extensionName}/${controllerName}.swift`, 29 + {target: target.uuid}, 30 + pbxGroupKey, 31 + ) 32 + 33 + for (const file of soundFiles) { 34 + pbxProject.addSourceFile( 35 + `${extensionName}/${file}`, 36 + {target: target.uuid}, 37 + pbxGroupKey, 38 + ) 39 + } 40 + 41 + var configurations = pbxProject.pbxXCBuildConfigurationSection() 42 + for (var key in configurations) { 43 + if (typeof configurations[key].buildSettings !== 'undefined') { 44 + var buildSettingsObj = configurations[key].buildSettings 45 + if ( 46 + typeof buildSettingsObj.PRODUCT_NAME !== 'undefined' && 47 + buildSettingsObj.PRODUCT_NAME === `"${extensionName}"` 48 + ) { 49 + buildSettingsObj.CLANG_ENABLE_MODULES = 'YES' 50 + buildSettingsObj.INFOPLIST_FILE = `"${extensionName}/Info.plist"` 51 + buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${extensionName}/${extensionName}.entitlements"` 52 + buildSettingsObj.CODE_SIGN_STYLE = 'Automatic' 53 + buildSettingsObj.CURRENT_PROJECT_VERSION = `"${config.ios?.buildNumber}"` 54 + buildSettingsObj.GENERATE_INFOPLIST_FILE = 'YES' 55 + buildSettingsObj.MARKETING_VERSION = `"${config.version}"` 56 + buildSettingsObj.PRODUCT_BUNDLE_IDENTIFIER = `"${config.ios?.bundleIdentifier}.${extensionName}"` 57 + buildSettingsObj.SWIFT_EMIT_LOC_STRINGS = 'YES' 58 + buildSettingsObj.SWIFT_VERSION = '5.0' 59 + buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1,2"` 60 + buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS' 61 + } 62 + } 63 + } 64 + 65 + pbxProject.addTargetAttribute( 66 + 'DevelopmentTeam', 67 + 'B3LX46C5HS', 68 + extensionName, 69 + ) 70 + pbxProject.addTargetAttribute('DevelopmentTeam', 'B3LX46C5HS') 71 + 72 + return config 73 + }) 74 + } 75 + 76 + module.exports = {withXcodeTarget}
+8
scripts/updateExtensions.sh
··· 1 1 #!/bin/bash 2 2 IOS_SHARE_EXTENSION_DIRECTORY="./ios/Share-with-Bluesky" 3 + IOS_NOTIFICATION_EXTENSION_DIRECTORY="./ios/BlueskyNSE" 3 4 MODULES_DIRECTORY="./modules" 4 5 5 6 if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then ··· 8 9 else 9 10 cp -R $IOS_SHARE_EXTENSION_DIRECTORY $MODULES_DIRECTORY 10 11 fi 12 + 13 + if [ ! -d $IOS_NOTIFICATION_EXTENSION_DIRECTORY ]; then 14 + echo "$IOS_NOTIFICATION_EXTENSION_DIRECTORY not found inside of your iOS project." 15 + exit 1 16 + else 17 + cp -R $IOS_NOTIFICATION_EXTENSION_DIRECTORY $MODULES_DIRECTORY 18 + fi
+7 -4
src/App.native.tsx
··· 47 47 import {useColorModeTheme} from '#/alf/util/useColorModeTheme' 48 48 import {Provider as PortalProvider} from '#/components/Portal' 49 49 import {Splash} from '#/Splash' 50 + import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' 50 51 import I18nProvider from './locale/i18nProvider' 51 52 import {listenSessionDropped} from './state/events' 52 53 ··· 102 103 <LoggedOutViewProvider> 103 104 <SelectedFeedProvider> 104 105 <UnreadNotifsProvider> 105 - <GestureHandlerRootView style={s.h100pct}> 106 - <TestCtrls /> 107 - <Shell /> 108 - </GestureHandlerRootView> 106 + <BackgroundNotificationPreferencesProvider> 107 + <GestureHandlerRootView style={s.h100pct}> 108 + <TestCtrls /> 109 + <Shell /> 110 + </GestureHandlerRootView> 111 + </BackgroundNotificationPreferencesProvider> 109 112 </UnreadNotifsProvider> 110 113 </SelectedFeedProvider> 111 114 </LoggedOutViewProvider>
+6 -3
src/App.web.tsx
··· 39 39 import {ThemeProvider as Alf} from '#/alf' 40 40 import {useColorModeTheme} from '#/alf/util/useColorModeTheme' 41 41 import {Provider as PortalProvider} from '#/components/Portal' 42 + import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' 42 43 import I18nProvider from './locale/i18nProvider' 43 44 import {listenSessionDropped} from './state/events' 44 45 ··· 92 93 <LoggedOutViewProvider> 93 94 <SelectedFeedProvider> 94 95 <UnreadNotifsProvider> 95 - <SafeAreaProvider> 96 - <Shell /> 97 - </SafeAreaProvider> 96 + <BackgroundNotificationPreferencesProvider> 97 + <SafeAreaProvider> 98 + <Shell /> 99 + </SafeAreaProvider> 100 + </BackgroundNotificationPreferencesProvider> 98 101 </UnreadNotifsProvider> 99 102 </SelectedFeedProvider> 100 103 </LoggedOutViewProvider>
+24 -1
src/lib/hooks/useNotificationHandler.ts
··· 8 8 import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher' 9 9 import {NavigationProp} from 'lib/routes/types' 10 10 import {logEvent} from 'lib/statsig/statsig' 11 + import {isAndroid} from 'platform/detection' 11 12 import {useCurrentConvoId} from 'state/messages/current-convo-id' 12 13 import {RQKEY as RQKEY_NOTIFS} from 'state/queries/notifications/feed' 13 14 import {invalidateCachedUnreadPage} from 'state/queries/notifications/unread' ··· 40 41 } 41 42 42 43 const DEFAULT_HANDLER_OPTIONS = { 43 - shouldShowAlert: false, 44 + shouldShowAlert: true, 44 45 shouldPlaySound: false, 45 46 shouldSetBadge: true, 46 47 } ··· 59 60 60 61 // Safety to prevent double handling of the same notification 61 62 const prevDate = React.useRef(0) 63 + 64 + React.useEffect(() => { 65 + if (!isAndroid) return 66 + 67 + Notifications.setNotificationChannelAsync('chat-messages', { 68 + name: 'Chat', 69 + importance: Notifications.AndroidImportance.MAX, 70 + sound: 'dm.mp3', 71 + showBadge: true, 72 + vibrationPattern: [250], 73 + lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE, 74 + }) 75 + 76 + Notifications.setNotificationChannelAsync('chat-messages-muted', { 77 + name: 'Chat - Muted', 78 + importance: Notifications.AndroidImportance.MAX, 79 + sound: null, 80 + showBadge: true, 81 + vibrationPattern: [250], 82 + lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE, 83 + }) 84 + }, []) 62 85 63 86 React.useEffect(() => { 64 87 const handleNotification = (payload?: NotificationPayload) => {
+15
src/screens/Messages/Settings.tsx
··· 15 15 import {ViewHeader} from '#/view/com/util/ViewHeader' 16 16 import {CenteredView} from '#/view/com/util/Views' 17 17 import {atoms as a} from '#/alf' 18 + import * as Toggle from '#/components/forms/Toggle' 18 19 import {RadioGroup} from '#/components/RadioGroup' 19 20 import {Text} from '#/components/Typography' 21 + import {useBackgroundNotificationPreferences} from '../../../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider' 20 22 import {ClipClopGate} from './gate' 21 23 22 24 type AllowIncoming = 'all' | 'none' | 'following' ··· 28 30 const {data: profile} = useProfileQuery({ 29 31 did: currentAccount!.did, 30 32 }) as UseQueryResult<AppBskyActorDefs.ProfileViewDetailed, Error> 33 + const {preferences, setPref} = useBackgroundNotificationPreferences() 31 34 32 35 const {mutate: updateDeclaration} = useUpdateActorDeclaration({ 33 36 onError: () => { ··· 64 67 ]} 65 68 onSelect={onSelectItem} 66 69 /> 70 + </View> 71 + <View style={[a.px_md, a.py_lg, a.gap_md]}> 72 + <Toggle.Item 73 + name="a" 74 + label="Click me" 75 + value={preferences.playSoundChat} 76 + onChange={() => { 77 + setPref('playSoundChat', !preferences.playSoundChat) 78 + }}> 79 + <Toggle.Checkbox /> 80 + <Toggle.LabelText>Notification Sounds</Toggle.LabelText> 81 + </Toggle.Item> 67 82 </View> 68 83 </CenteredView> 69 84 )