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
zio/stable
Hailey 2024-05-15 11:49:07 -07:00 committed by GitHub
parent 31868b255f
commit bf7b66d5c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1297 additions and 12 deletions

View File

@ -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',
],
},
},
],
},
},

Binary file not shown.

BIN
assets/dm.aiff 100644

Binary file not shown.

BIN
assets/dm.mp3 100644

Binary file not shown.

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.application-groups</key>
<array>
<string>group.app.bsky</string>
</array>
</dict>
</plist>

View File

@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.usernotifications.service</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).NotificationService</string>
</dict>
<key>MainAppScheme</key>
<string>bluesky</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundleDisplayName</key>
<string>Bluesky Notifications</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleVersion</key>
<string>$(CURRENT_PROJECT_VERSION)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundlePackageType</key>
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
</dict>
</plist>

View File

@ -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"))
}
}

View File

@ -38,4 +38,4 @@
<key>CFBundleShortVersionString</key>
<string>$(MARKETING_VERSION)</string>
</dict>
</plist>
</plist>

View File

@ -7,4 +7,4 @@
<string>group.app.bsky</string>
</array>
</dict>
</plist>
</plist>

View File

@ -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'
}

View File

@ -0,0 +1,2 @@
<manifest>
</manifest>

View File

@ -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
}
}
}
}

View File

@ -0,0 +1,7 @@
package expo.modules.backgroundnotificationhandler
import com.google.firebase.messaging.RemoteMessage
interface BackgroundNotificationHandlerInterface {
fun showMessage(remoteMessage: RemoteMessage)
}

View File

@ -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<String> ->
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<String> ->
NotificationPrefs(appContext.reactContext).addManyToStringArray(forKey, strings)
}
AsyncFunction("removeManyFromStringArrayAsync") { forKey: String, strings: Array<String> ->
NotificationPrefs(appContext.reactContext).removeManyFromStringArray(forKey, strings)
}
}
}

View File

@ -0,0 +1,134 @@
package expo.modules.backgroundnotificationhandler
import android.content.Context
val DEFAULTS = mapOf<String, Any>(
"playSoundChat" to true,
"playSoundFollow" to false,
"playSoundLike" to false,
"playSoundMention" to false,
"playSoundQuote" to false,
"playSoundReply" to false,
"playSoundRepost" to false,
"mutedThreads" to mapOf<String, List<String>>()
)
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<String, *> {
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<String>? {
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<String>) {
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<String>) {
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<String>) {
prefs
.edit()
.apply {
val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf()
set.removeAll(strings.toSet())
putStringSet(key, set)
}
.apply()
}
}

View File

@ -0,0 +1,9 @@
{
"platforms": ["ios", "android"],
"ios": {
"modules": ["ExpoBackgroundNotificationHandlerModule"]
},
"android": {
"modules": ["expo.modules.backgroundnotificationhandler.ExpoBackgroundNotificationHandlerModule"]
}
}

View File

@ -0,0 +1,2 @@
import {BackgroundNotificationHandler} from './src/ExpoBackgroundNotificationHandlerModule'
export default BackgroundNotificationHandler

View File

@ -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

View File

@ -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)
}
}
}
}

View File

@ -0,0 +1,70 @@
import React from 'react'
import {BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule'
interface BackgroundNotificationPreferencesContext {
preferences: BackgroundNotificationHandlerPreferences
setPref: <Key extends keyof BackgroundNotificationHandlerPreferences>(
key: Key,
value: BackgroundNotificationHandlerPreferences[Key],
) => void
}
const Context = React.createContext<BackgroundNotificationPreferencesContext>(
{} as BackgroundNotificationPreferencesContext,
)
export const useBackgroundNotificationPreferences = () =>
React.useContext(Context)
export function BackgroundNotificationPreferencesProvider({
children,
}: {
children: React.ReactNode
}) {
const [preferences, setPreferences] =
React.useState<BackgroundNotificationHandlerPreferences>({
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 <Context.Provider value={value}>{children}</Context.Provider>
}

View File

@ -0,0 +1,40 @@
export type ExpoBackgroundNotificationHandlerModule = {
getAllPrefsAsync: () => Promise<BackgroundNotificationHandlerPreferences>
getBoolAsync: (forKey: string) => Promise<boolean>
getStringAsync: (forKey: string) => Promise<string>
getStringArrayAsync: (forKey: string) => Promise<string[]>
setBoolAsync: (
forKey: keyof BackgroundNotificationHandlerPreferences,
value: boolean,
) => Promise<void>
setStringAsync: (
forKey: keyof BackgroundNotificationHandlerPreferences,
value: string,
) => Promise<void>
setStringArrayAsync: (
forKey: keyof BackgroundNotificationHandlerPreferences,
value: string[],
) => Promise<void>
addToStringArrayAsync: (
forKey: keyof BackgroundNotificationHandlerPreferences,
value: string,
) => Promise<void>
removeFromStringArrayAsync: (
forKey: keyof BackgroundNotificationHandlerPreferences,
value: string,
) => Promise<void>
addManyToStringArrayAsync: (
forKey: keyof BackgroundNotificationHandlerPreferences,
value: string[],
) => Promise<void>
removeManyFromStringArrayAsync: (
forKey: keyof BackgroundNotificationHandlerPreferences,
value: string[],
) => Promise<void>
}
// 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
}

View File

@ -0,0 +1,8 @@
import {requireNativeModule} from 'expo-modules-core'
import {ExpoBackgroundNotificationHandlerModule} from './ExpoBackgroundNotificationHandler.types'
export const BackgroundNotificationHandler =
requireNativeModule<ExpoBackgroundNotificationHandlerModule>(
'ExpoBackgroundNotificationHandler',
)

View File

@ -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

View File

@ -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<*, *>)

View File

@ -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.

View File

@ -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.<identifier>
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

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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}

View File

@ -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

View File

@ -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}

View File

@ -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}

View File

@ -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

View File

@ -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() {
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
<BackgroundNotificationPreferencesProvider>
<GestureHandlerRootView style={s.h100pct}>
<TestCtrls />
<Shell />
</GestureHandlerRootView>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>

View File

@ -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() {
<LoggedOutViewProvider>
<SelectedFeedProvider>
<UnreadNotifsProvider>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
<BackgroundNotificationPreferencesProvider>
<SafeAreaProvider>
<Shell />
</SafeAreaProvider>
</BackgroundNotificationPreferencesProvider>
</UnreadNotifsProvider>
</SelectedFeedProvider>
</LoggedOutViewProvider>

View File

@ -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

View File

@ -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<AppBskyActorDefs.ProfileViewDetailed, Error>
const {preferences, setPref} = useBackgroundNotificationPreferences()
const {mutate: updateDeclaration} = useUpdateActorDeclaration({
onError: () => {
@ -65,6 +68,18 @@ export function MessagesSettingsScreen({}: Props) {
onSelect={onSelectItem}
/>
</View>
<View style={[a.px_md, a.py_lg, a.gap_md]}>
<Toggle.Item
name="a"
label="Click me"
value={preferences.playSoundChat}
onChange={() => {
setPref('playSoundChat', !preferences.playSoundChat)
}}>
<Toggle.Checkbox />
<Toggle.LabelText>Notification Sounds</Toggle.LabelText>
</Toggle.Item>
</View>
</CenteredView>
)
}