Compare commits

...

36 Commits

Author SHA1 Message Date
Hailey 14a975ccfa update nse 2024-10-04 17:25:14 -07:00
Hailey 897e6d85be Merge branch 'main' into hailey/decrement-badge 2024-10-04 16:56:31 -07:00
Hailey 1e17849803 update js 2024-07-12 17:10:30 -07:00
Hailey f5d231f2f0 add functions to android 2024-07-12 17:00:45 -07:00
Hailey 8e44de0168 tweak 2024-07-12 16:47:22 -07:00
Hailey 62aa42f073 lint android 2024-07-12 16:26:12 -07:00
Hailey 849b5ab139 Merge branch 'main' into hailey/decrement-badge 2024-07-12 16:04:59 -07:00
Hailey 516c9e2143 convert some things 2024-07-02 15:36:52 -07:00
Hailey cec1c3e5a2 Merge branch 'hailey/shared-preferences' into hailey/decrement-badge 2024-07-01 18:40:25 -07:00
Hailey ffe15a981a shared preferences api 2024-07-01 18:10:53 -07:00
Hailey 82e53d6a64 Merge branch 'hailey/shared-preferences' into hailey/decrement-badge 2024-06-26 11:56:36 -07:00
Hailey 6823d10327 shared preferences api 2024-06-26 11:53:09 -07:00
Hailey 1948495857 lint 2024-06-26 11:50:49 -07:00
Hailey d976ab31c6 finish android impl 2024-06-26 11:48:19 -07:00
Hailey f3523200f5 merge in some changes 2024-06-26 11:45:30 -07:00
Hailey ac0ba3ade7 add a few more helpers 2024-06-26 11:37:02 -07:00
Hailey 035fad8bc6 implement js changes 2024-06-26 11:31:27 -07:00
Hailey f9c5c42290 load preferences 2024-06-26 11:24:14 -07:00
Hailey d6a2ee4e9e JS changes 2024-06-26 11:16:32 -07:00
Hailey 83056bbf2b Merge branch 'hailey/shared-preferences' into hailey/decrement-badge 2024-06-26 11:05:42 -07:00
Hailey 37ff335a12 shared preferences api 2024-06-26 11:05:16 -07:00
Hailey bf92de36eb ios badge count updating 2024-06-26 11:04:24 -07:00
Hailey fb12def9f9 add any value set call 2024-06-26 11:02:54 -07:00
Hailey f24a4fed2a public 2024-06-26 10:49:25 -07:00
Hailey 124e8f1d55 Merge branch 'hailey/shared-preferences' into hailey/decrement-badge 2024-06-26 10:38:35 -07:00
Hailey 1a8a154c12 shared instance of prefs for other modules 2024-06-26 10:37:36 -07:00
Hailey 86b2bca927 start tweaks 2024-06-26 10:37:17 -07:00
Hailey 3ee7b50847 Merge branch 'hailey/shared-preferences' into hailey/decrement-badge 2024-06-26 09:57:50 -07:00
Hailey e97bf27e6f tweak test 2024-06-26 09:56:07 -07:00
Hailey c1ac691953 rm log 2024-06-26 09:47:25 -07:00
Hailey 0e951e6141 shared preferences api 2024-06-26 09:41:51 -07:00
Hailey be738ca9f6 store convo ids for incrementing dms 2024-06-26 00:22:47 -07:00
Hailey a67a17a904 Merge remote-tracking branch 'origin/main' into hailey/decrement-badge 2024-06-25 21:59:57 -07:00
Hailey f195fad22c implement changes 2024-06-10 09:06:56 -07:00
Hailey 458005445f nit 2024-06-10 08:34:26 -07:00
Hailey 7dad1c280b add support for mark-read-generic and mark-read-messages
update kotlin stub

update js types

update the correct badge count

clamp single decrements too

clamp decrement

future proof with `decrementBy`

add support for `mark-read-generic` and `mark-read-messages`

do nothing when receiving `mark-read-*` is received

simplify swift

update android with new types

add types to `NotificationReason`

add logic for decrementing the badge

add decrement mutation
2024-06-10 08:33:38 -07:00
20 changed files with 413 additions and 502 deletions
+129 -40
View File
@@ -4,6 +4,30 @@ import UIKit
let APP_GROUP = "group.app.bsky"
typealias ContentHandler = (UNNotificationContent) -> Void
enum NotificationType: String, CaseIterable {
case like
case repost
case follow
case reply
case quote
case chatMessage = "chat-message"
case markReadGeneric = "mark-read-generic"
case markReadMessages = "mark-read-messages"
case starterPackJoined = "starterpack-joined"
}
enum BadgeType: String, CaseIterable {
case generic
case messages
}
enum BadgeOperation {
case increment
case decrement
}
let INCREMENTED_FOR_KEY = "incremented-for-convos"
// This extension allows us to do some processing of the received notification
// data before displaying the notification to the user. In our use case, there
// are a few particular things that we want to do:
@@ -30,27 +54,32 @@ class NotificationService: UNNotificationServiceExtension {
private var bestAttempt: UNMutableNotificationContent?
override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
self.contentHandler = contentHandler
guard let bestAttempt = NSEUtil.createCopy(request.content),
let reason = request.content.userInfo["reason"] as? String
let reasonString = request.content.userInfo["reason"] as? String,
let reason = NotificationType(rawValue: reasonString)
else {
contentHandler(request.content)
return
}
self.contentHandler = contentHandler
self.bestAttempt = bestAttempt
if reason == "chat-message" {
mutateWithChatMessage(bestAttempt)
} else {
mutateWithBadge(bestAttempt)
NSEUtil.shared.prefsQueue.sync {
switch reason {
case .like, .repost, .follow, .reply, .quote, .starterPackJoined:
NSEUtil.mutateWithBadge(bestAttempt, badgeType: .generic, operation: .increment)
case .chatMessage:
NSEUtil.mutateWithChatMessage(bestAttempt)
NSEUtil.mutateWithBadge(bestAttempt, badgeType: .messages, operation: .increment)
case .markReadGeneric:
NSEUtil.mutateWithBadge(bestAttempt, badgeType: .generic, operation: .decrement)
case .markReadMessages:
NSEUtil.mutateWithBadge(bestAttempt, badgeType: .messages, operation: .decrement)
}
contentHandler(bestAttempt)
}
// Any image downloading (or other network tasks) should be handled at the end
// of this block. Otherwise, if there is a timeout and serviceExtensionTimeWillExpire
// gets called, we might not have all the needed mutations completed in time.
contentHandler(bestAttempt)
}
override func serviceExtensionTimeWillExpire() {
@@ -60,38 +89,14 @@ class NotificationService: UNNotificationServiceExtension {
}
contentHandler(bestAttempt)
}
// MARK: Mutations
func mutateWithBadge(_ content: UNMutableNotificationContent) {
NSEUtil.shared.prefsQueue.sync {
var count = NSEUtil.shared.prefs?.integer(forKey: "badgeCount") ?? 0
count += 1
// Set the new badge number for the notification, then store that value for using later
content.badge = NSNumber(value: count)
NSEUtil.shared.prefs?.setValue(count, forKey: "badgeCount")
}
}
func mutateWithChatMessage(_ content: UNMutableNotificationContent) {
if NSEUtil.shared.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"))
}
}
// NSEUtil's purpose is to create a shared instance of `UserDefaults` across
// `NotificationService` instances. It also includes a queue so that we can process
// updates to `UserDefaults` in parallel.
//
// Any time that you increment or decrement counts for notifications, you should use
// the prefsQueue so that things remain in sync.
private class NSEUtil {
static let shared = NSEUtil()
@@ -99,7 +104,91 @@ private class NSEUtil {
var prefs = UserDefaults(suiteName: APP_GROUP)
var prefsQueue = DispatchQueue(label: "NSEPrefsQueue")
// MARK: - Utils
static func createCopy(_ content: UNNotificationContent) -> UNMutableNotificationContent? {
return content.mutableCopy() as? UNMutableNotificationContent
}
static func getDecrementedBadgeCount(current: Int, decrementBy by: Int) -> Int {
let new = current - by
if new < 0 {
return 0
}
return new
}
// MARK: - Mutations
static func mutateWithBadge(_ content: UNMutableNotificationContent,
badgeType type: BadgeType,
operation: BadgeOperation) {
var genericCount = Self.shared.prefs?.integer(forKey: BadgeType.generic.rawValue) ?? 0
var messagesCount = Self.shared.prefs?.integer(forKey: BadgeType.messages.rawValue) ?? 0
if type == .generic {
if operation == .decrement {
genericCount = 0
} else {
genericCount += 1
}
Self.shared.prefs?.setValue(genericCount, forKey: BadgeType.generic.rawValue)
// TEMPORARY - since we have not implemented message count clearing on the server, we'll clear
// those here as well.
Self.shared.prefs?.setValue(messagesCount, forKey: BadgeType.messages.rawValue)
} else if type == .messages {
// Not yet implemented, but here's the logic
if operation == .decrement,
Self.shouldDecrementForConvo(content) {
messagesCount = Self.getDecrementedBadgeCount(current: messagesCount, decrementBy: 1)
} else if operation == .increment,
shouldIncrementForConvo(content) {
messagesCount += 1
}
}
}
static func mutateWithChatMessage(_ content: UNMutableNotificationContent) {
if Self.shared.prefs?.bool(forKey: "playSoundChat") == true {
Self.mutateWithDmSound(content)
}
}
static func mutateWithDefaultSound(_ content: UNMutableNotificationContent) {
content.sound = UNNotificationSound.default
}
static func mutateWithDmSound(_ content: UNMutableNotificationContent) {
content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "dm.aiff"))
}
static func shouldIncrementForConvo(_ content: UNMutableNotificationContent) -> Bool {
guard let convoId = content.userInfo["convoId"] as? String,
var dict = Self.shared.prefs?.dictionary(forKey: INCREMENTED_FOR_KEY) as? [String: Bool] else {
return false
}
if dict["convoId"] == true {
return false
}
dict[convoId] = true
Self.shared.prefs?.set(dict, forKey: INCREMENTED_FOR_KEY)
return true
}
static func shouldDecrementForConvo(_ content: UNMutableNotificationContent) -> Bool {
guard let convoId = content.userInfo["convoId"] as? String,
var dict = Self.shared.prefs?.dictionary(forKey: INCREMENTED_FOR_KEY) as? [String: Bool] else {
return false
}
if dict["convoId"] != true {
return false
}
dict.removeValue(forKey: convoId)
Self.shared.prefs?.set(dict, forKey: INCREMENTED_FOR_KEY)
return true
}
}
@@ -6,88 +6,89 @@ 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")
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
if (expoModulesCorePlugin.exists()) {
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
}
}
repositories {
mavenCentral()
}
// 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
}
dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}")
}
// 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
}
publishing {
publications {
release(MavenPublication) {
from components.release
}
}
repositories {
maven {
url = mavenLocal().url
}
}
}
repositories {
maven {
url = mavenLocal().url
}
}
}
}
android {
compileSdkVersion safeExtGet("compileSdkVersion", 33)
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
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
}
}
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"
}
}
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()
lintOptions {
abortOnError false
}
publishing {
singleVariant("release") {
withSourcesJar()
}
}
}
}
repositories {
mavenCentral()
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'
implementation project(':expo-bluesky-swiss-army')
implementation project(':expo-modules-core')
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
implementation 'com.google.firebase:firebase-messaging-ktx:24.0.0'
}
@@ -2,6 +2,20 @@ package expo.modules.backgroundnotificationhandler
import android.content.Context
import com.google.firebase.messaging.RemoteMessage
import expo.modules.blueskyswissarmy.sharedprefs.SharedPrefs
enum class NotificationType(
val type: String,
) {
Like("like"),
Repost("repost"),
Follow("follow"),
Reply("reply"),
Quote("quote"),
ChatMessage("chat-message"),
MarkReadGeneric("mark-read-generic"),
MarkReadMessages("mark-read-messages"),
}
class BackgroundNotificationHandler(
private val context: Context,
@@ -13,15 +27,19 @@ class BackgroundNotificationHandler(
return
}
if (remoteMessage.data["reason"] == "chat-message") {
val type = NotificationType.valueOf(remoteMessage.data["reason"] ?: return)
if (type == NotificationType.ChatMessage) {
mutateWithChatMessage(remoteMessage)
} else if (type == NotificationType.MarkReadGeneric || type == NotificationType.MarkReadMessages) {
return
}
notifInterface.showMessage(remoteMessage)
}
private fun mutateWithChatMessage(remoteMessage: RemoteMessage) {
if (NotificationPrefs(context).getBoolean("playSoundChat")) {
if (SharedPrefs(context).getBoolean("playSoundChat") == true) {
// If oreo or higher
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
remoteMessage.data["channelId"] = "chat-messages"
@@ -1,19 +1,50 @@
package expo.modules.backgroundnotificationhandler
import android.content.Context
import expo.modules.blueskyswissarmy.sharedprefs.SharedPrefs
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
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>>(),
)
enum class BadgeType(val rawValue: String) {
Generic("badgeCountGeneric"),
Messages("badgeCountMessages"),
}
const val INCREMENTED_FOR_KEY = "incremented-for-convos"
class ExpoBackgroundNotificationHandlerModule : Module() {
companion object {
var isForegrounded = false
}
fun getContext(): Context {
return appContext.reactContext ?: throw Error("Context is null")
}
override fun definition() =
ModuleDefinition {
Name("ExpoBackgroundNotificationHandler")
OnCreate {
NotificationPrefs(appContext.reactContext).initialize()
val context = appContext.reactContext ?: throw Error("Context is null")
DEFAULTS.forEach { (key, value) ->
if (SharedPrefs(context).hasValue(key)) {
return@forEach
}
SharedPrefs(context)._setAnyValue(key, value)
}
}
OnActivityEntersForeground {
@@ -24,52 +55,31 @@ class ExpoBackgroundNotificationHandlerModule : Module() {
isForegrounded = false
}
AsyncFunction("getAllPrefsAsync") {
return@AsyncFunction NotificationPrefs(appContext.reactContext).getAllPrefs()
AsyncFunction("getPrefsAsync") {
val keys = DEFAULTS.keys
return@AsyncFunction SharedPrefs(getContext()).getValues(keys)
}
AsyncFunction("getBoolAsync") { forKey: String ->
return@AsyncFunction NotificationPrefs(appContext.reactContext).getBoolean(forKey)
AsyncFunction("resetGenericCountAsync") {
SharedPrefs(getContext()).setValue(BadgeType.Generic.rawValue, 0f)
}
AsyncFunction("getStringAsync") { forKey: String ->
return@AsyncFunction NotificationPrefs(appContext.reactContext).getString(forKey)
AsyncFunction("maybeIncrementMessagesCountAsync") { convoId: String ->
val prefs = SharedPrefs(getContext())
if (!prefs.setContains(INCREMENTED_FOR_KEY, convoId)) {
val curr = prefs.getFloat(BadgeType.Messages.rawValue) ?: 0f
prefs.setValue(BadgeType.Messages.rawValue, curr + 1)
}
}
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)
}
AsyncFunction("setBadgeCountAsync") { _: Int ->
// This does nothing on Android
AsyncFunction("maybeDecrementMessagesCountAsync") { convoId: String ->
val prefs = SharedPrefs(getContext())
if (prefs.setContains(INCREMENTED_FOR_KEY, convoId)) {
val curr = prefs.getFloat(BadgeType.Messages.rawValue) ?: 0f
if (curr != 0f) {
prefs.setValue(BadgeType.Messages.rawValue, curr - 1)
}
}
}
}
}
@@ -1,143 +0,0 @@
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, *> = prefs.all
fun getBoolean(key: String): Boolean = prefs.getBoolean(key, false)
fun getString(key: String): String? = prefs.getString(key, null)
fun getStringArray(key: String): Array<String>? = 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()
}
}
@@ -1,2 +1,4 @@
import {BackgroundNotificationHandler} from './src/ExpoBackgroundNotificationHandlerModule'
export default BackgroundNotificationHandler
import {BackgroundNotificationPreferencesProvider} from './src/BackgroundNotificationHandlerProvider'
import * as BackgroundNotifications from './src/index'
export {BackgroundNotificationPreferencesProvider, BackgroundNotifications}
@@ -10,6 +10,7 @@ Pod::Spec.new do |s|
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.dependency 'ExpoBlueskySwissArmy'
# Swift/Objective-C compatibility
s.pod_target_xcconfig = {
@@ -1,4 +1,5 @@
import ExpoModulesCore
import ExpoBlueskySwissArmy
let APP_GROUP = "group.app.bsky"
@@ -10,10 +11,11 @@ let DEFAULTS: [String: Any] = [
"playSoundQuote": false,
"playSoundReply": false,
"playSoundRepost": false,
"mutedThreads": [:] as! [String: [String]],
"badgeCount": 0
]
let INCREMENTED_FOR_KEY = "incremented-for-convos"
/*
* 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
@@ -29,92 +31,54 @@ public class ExpoBackgroundNotificationHandlerModule: Module {
OnCreate {
DEFAULTS.forEach { p in
if userDefaults?.value(forKey: p.key) == nil {
userDefaults?.setValue(p.value, forKey: p.key)
if !SharedPrefs.shared.hasValue(p.key) {
SharedPrefs.shared._setAnyValue(p.key, p.value)
}
}
}
AsyncFunction("getAllPrefsAsync") { () -> [String: Any]? in
var keys: [String] = []
DEFAULTS.forEach { p in
keys.append(p.key)
AsyncFunction("resetGenericCountAsync") {
SharedPrefs.shared.setValue(BadgeType.generic.toKeyName(), 0)
}
AsyncFunction("maybeIncrementMessagesCountAsync") { (convoId: String) in
guard !SharedPrefs.shared.setContains(INCREMENTED_FOR_KEY, convoId) else {
return false
}
return userDefaults?.dictionaryWithValues(forKeys: keys)
var count = SharedPrefs.shared.getNumber(BadgeType.messages.toKeyName()) ?? 0
count += 1
SharedPrefs.shared.addToSet(INCREMENTED_FOR_KEY, convoId)
SharedPrefs.shared.setValue(BadgeType.messages.toKeyName(), count)
return true
}
AsyncFunction("getBoolAsync") { (forKey: String) -> Bool in
if let pref = userDefaults?.bool(forKey: forKey) {
return pref
AsyncFunction("maybeDecrementMessagesCountAsync") { (convoId: String) in
guard SharedPrefs.shared.setContains(INCREMENTED_FOR_KEY, convoId) else {
return false
}
return false
}
AsyncFunction("getStringAsync") { (forKey: String) -> String? in
if let pref = userDefaults?.string(forKey: forKey) {
return pref
}
return nil
}
var count = SharedPrefs.shared.getNumber(BadgeType.messages.toKeyName()) ?? 0
count -= 1
AsyncFunction("getStringArrayAsync") { (forKey: String) -> [String]? in
if let pref = userDefaults?.stringArray(forKey: forKey) {
return pref
}
return nil
}
AsyncFunction("setBoolAsync") { (forKey: String, value: Bool) in
userDefaults?.setValue(value, forKey: forKey)
}
AsyncFunction("setStringAsync") { (forKey: String, value: String) in
userDefaults?.setValue(value, forKey: forKey)
}
AsyncFunction("setStringArrayAsync") { (forKey: String, value: [String]) 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)
}
}
AsyncFunction("setBadgeCountAsync") { (count: Int) in
userDefaults?.setValue(count, forKey: "badgeCount")
SharedPrefs.shared.removeFromSet(INCREMENTED_FOR_KEY, convoId)
SharedPrefs.shared.setValue(BadgeType.messages.toKeyName(), count)
return true
}
}
}
enum BadgeType: String, Enumerable {
case generic
case messages
func toKeyName() -> String {
switch self {
case .generic:
return "badgeCountGeneric"
case .messages:
return "badgeCountMessages"
}
}
}
@@ -1,7 +1,7 @@
import React from 'react'
import {BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule'
import {SharedPrefs} from '../../expo-bluesky-swiss-army'
import {BackgroundNotificationHandlerPreferences} from './types'
interface BackgroundNotificationPreferencesContext {
preferences: BackgroundNotificationHandlerPreferences
@@ -29,42 +29,25 @@ export function BackgroundNotificationPreferencesProvider({
React.useEffect(() => {
;(async () => {
const prefs = await BackgroundNotificationHandler.getAllPrefsAsync()
setPreferences(prefs)
const prefs: BackgroundNotificationHandlerPreferences = {
playSoundChat: SharedPrefs.getBool('playSoundChat') ?? true,
}
if (prefs) {
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],
)
const value = {
preferences,
setPref: <Key extends keyof BackgroundNotificationHandlerPreferences>(
k: Key,
v: BackgroundNotificationHandlerPreferences[Key],
) => {
SharedPrefs.setValue(k, v)
setPreferences(prev => ({...prev, [k]: v}))
},
}
return <Context.Provider value={value}>{children}</Context.Provider>
}
@@ -1,41 +0,0 @@
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>
setBadgeCountAsync: (count: number) => 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
}
@@ -1,8 +0,0 @@
import {requireNativeModule} from 'expo-modules-core'
import {ExpoBackgroundNotificationHandlerModule} from './ExpoBackgroundNotificationHandler.types'
export const BackgroundNotificationHandler =
requireNativeModule<ExpoBackgroundNotificationHandlerModule>(
'ExpoBackgroundNotificationHandler',
)
@@ -1,28 +0,0 @@
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[]) => {},
setBadgeCountAsync: async (_: number) => {},
} as ExpoBackgroundNotificationHandlerModule
@@ -0,0 +1,16 @@
import {Platform} from 'react-native'
export class NotImplementedError extends Error {
constructor(params = {}) {
if (__DEV__) {
const caller = new Error().stack?.split('\n')[2]
super(
`Not implemented on ${Platform.OS}. Given params: ${JSON.stringify(
params,
)} ${caller}`,
)
} else {
super('Not implemented')
}
}
}
@@ -0,0 +1,19 @@
import {requireNativeModule} from 'expo'
const NativeModule = requireNativeModule('ExpoBackgroundNotificationHandler')
export async function resetGenericCountAsync(): Promise<void> {
await NativeModule.resetGenericCountAsync()
}
export async function maybeIncrementMessagesCountAsync(
convoId: string,
): Promise<boolean> {
return await NativeModule.maybeIncrementMessagesCountAsync(convoId)
}
export async function maybeDecrementMessagesCountAsync(
convoId: string,
): Promise<boolean> {
return await NativeModule.maybeDecrementMessagesCountAsync(convoId)
}
@@ -0,0 +1,22 @@
import {NotImplementedError} from './NotImplemented'
import {BackgroundNotificationHandlerPreferences} from './types'
export function resetGenericCountAsync(): Promise<void> {
throw new NotImplementedError()
}
export function maybeIncrementMessagesCountAsync(
convoId: string,
): Promise<boolean> {
throw new NotImplementedError({convoId})
}
export function maybeDecrementMessagesCountAsync(
convoId: string,
): Promise<boolean> {
throw new NotImplementedError({convoId})
}
export function getPrefsAsync(): Promise<BackgroundNotificationHandlerPreferences | null> {
throw new NotImplementedError()
}
@@ -0,0 +1,3 @@
export type BackgroundNotificationHandlerPreferences = {
playSoundChat: boolean
}
+16
View File
@@ -26,6 +26,8 @@ type NotificationReason =
| 'quote'
| 'chat-message'
| 'starterpack-joined'
| 'mark-read-generic'
| 'mark-read-messages'
type NotificationPayload =
| {
@@ -195,6 +197,20 @@ export function useNotificationsHandler() {
shouldPlaySound: false,
shouldSetBadge: false,
}
} else if (
payload.reason === 'mark-read-generic' ||
payload.reason === 'mark-read-messages'
) {
logger.debug(
`Notifications: ${payload.reason}`,
{},
logger.DebugContext.notifications,
)
return {
shouldShowAlert: false,
shouldPlaySound: false,
shouldSetBadge: false,
}
}
// Any notification other than a chat message should invalidate the unread page
+4 -17
View File
@@ -1,13 +1,13 @@
import React from 'react'
import * as Notifications from 'expo-notifications'
import {getBadgeCountAsync, setBadgeCountAsync} from 'expo-notifications'
import {setBadgeCountAsync} from 'expo-notifications'
import {BskyAgent} from '@atproto/api'
import {logger} from '#/logger'
import {SessionAccount, useAgent, useSession} from '#/state/session'
import {logEvent} from 'lib/statsig/statsig'
import {devicePlatform, isAndroid, isNative} from 'platform/detection'
import BackgroundNotificationHandler from '../../../modules/expo-background-notification-handler'
import {BackgroundNotifications} from '../../../modules/expo-background-notification-handler'
const SERVICE_DID = (serviceUrl?: string) =>
serviceUrl?.includes('staging')
@@ -134,20 +134,7 @@ export function useRequestNotificationsPermission() {
}
}
export async function decrementBadgeCount(by: number) {
if (!isNative) return
let count = await getBadgeCountAsync()
count -= by
if (count < 0) {
count = 0
}
await BackgroundNotificationHandler.setBadgeCountAsync(count)
await setBadgeCountAsync(count)
}
export async function resetBadgeCount() {
await BackgroundNotificationHandler.setBadgeCountAsync(0)
export async function resetGenericBadgeCount() {
await BackgroundNotifications.resetGenericCountAsync()
await setBadgeCountAsync(0)
}
@@ -11,7 +11,6 @@ import {msg} from '@lingui/macro'
import {useLingui} from '@lingui/react'
import {useHaptics} from '#/lib/haptics'
import {decrementBadgeCount} from '#/lib/notifications/notifications'
import {logEvent} from '#/lib/statsig/statsig'
import {sanitizeDisplayName} from '#/lib/strings/display-names'
import {
@@ -33,6 +32,7 @@ import {Link} from '#/components/Link'
import {useMenuControl} from '#/components/Menu'
import {PostAlerts} from '#/components/moderation/PostAlerts'
import {Text} from '#/components/Typography'
import {BackgroundNotifications} from '../../../../modules/expo-background-notification-handler'
export let ChatListItem = ({
convo,
@@ -179,7 +179,7 @@ function ChatListItemReady({
const onPress = useCallback(
(e: GestureResponderEvent) => {
decrementBadgeCount(convo.unreadCount)
BackgroundNotifications.maybeDecrementMessagesCountAsync(convo.id)
if (isDeletedAccount) {
e.preventDefault()
menuControl.open()
@@ -188,7 +188,7 @@ function ChatListItemReady({
logEvent('chat:open', {logContext: 'ChatsList'})
}
},
[convo.unreadCount, isDeletedAccount, menuControl],
[convo.id, isDeletedAccount, menuControl],
)
const onLongPress = useCallback(() => {
+2 -2
View File
@@ -10,7 +10,7 @@ import EventEmitter from 'eventemitter3'
import BroadcastChannel from '#/lib/broadcast'
import {logger} from '#/logger'
import {useAgent, useSession} from '#/state/session'
import {resetBadgeCount} from 'lib/notifications/notifications'
import {resetGenericBadgeCount} from 'lib/notifications/notifications'
import {useModerationOpts} from '../../preferences/moderation-opts'
import {truncateAndInvalidate} from '../util'
import {RQKEY as RQKEY_NOTIFS} from './feed'
@@ -117,7 +117,7 @@ export function Provider({children}: React.PropsWithChildren<{}>) {
// update & broadcast
setNumUnread('')
broadcast.postMessage({event: ''})
resetBadgeCount()
resetGenericBadgeCount()
},
async checkUnread({