diff --git a/app.config.js b/app.config.js index 4b54157c..4ade9de3 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 8d35258d..00000000 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 00000000..364b814b 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 00000000..acb5728e 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 00000000..4954bdb3 --- /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 00000000..c2dd7eda --- /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 00000000..c6f391e0 --- /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 90fe9234..421abb3c 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 d2253d31..4954bdb3 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 00000000..e18eee93 --- /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 00000000..bdae66c8 --- /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 00000000..34450852 --- /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 00000000..41fb65eb --- /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 00000000..083ff122 --- /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 00000000..17ef9205 --- /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 00000000..9e5c9d55 --- /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 00000000..680c6c13 --- /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 00000000..363c7b5e --- /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 00000000..08972a04 --- /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 00000000..6ecdd1d4 --- /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 00000000..5fbd302d --- /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 00000000..d6517893 --- /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 00000000..29e27fd0 --- /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 00000000..ba196eca --- /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 00000000..59b2598f --- /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 00000000..31b8bfe7 --- /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 00000000..4ce81ea6 --- /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 00000000..0cc1c4ca --- /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 00000000..b0c6cfa8 --- /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 00000000..cd29bea7 --- /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 00000000..6a00cfd2 --- /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 00000000..652afd54 --- /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 00000000..e9c7dae3 --- /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 f4e462b7..f3e972aa 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 9356be7a..425d6ac6 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 40ceb694..900ceefd 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 3240a485..6f5fbd66 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 e4fff125..7ad21b40 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 + + ) }