diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 10a6a037a3..071fbbb95d 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -127,6 +127,7 @@ final class ChatModel: ObservableObject { addChat(Chat(c), at: i) } } + NtfManager.shared.setNtfBadgeCount(totalUnreadCount()) } // func addGroup(_ group: SimpleXChat.Group) { @@ -139,6 +140,7 @@ final class ChatModel: ObservableObject { chats[i].chatItems = [cItem] if case .rcvNew = cItem.meta.itemStatus { chats[i].chatStats.unreadCount = chats[i].chatStats.unreadCount + 1 + NtfManager.shared.incNtfBadgeCount() } if i > 0 { if chatId == nil { @@ -203,6 +205,9 @@ final class ChatModel: ObservableObject { // remove from current chat if chatId == cInfo.id { if let i = chatItems.firstIndex(where: { $0.id == cItem.id }) { + if chatItems[i].isRcvNew() == true { + NtfManager.shared.decNtfBadgeCount() + } _ = withAnimation { self.chatItems.remove(at: i) } @@ -213,6 +218,7 @@ final class ChatModel: ObservableObject { func markChatItemsRead(_ cInfo: ChatInfo) { // update preview if let chat = getChat(cInfo.id) { + NtfManager.shared.decNtfBadgeCount(by: chat.chatStats.unreadCount) chat.chatStats = ChatStats() } // update current chat @@ -230,6 +236,7 @@ final class ChatModel: ObservableObject { func clearChat(_ cInfo: ChatInfo) { // clear preview if let chat = getChat(cInfo.id) { + NtfManager.shared.decNtfBadgeCount(by: chat.chatStats.unreadCount) chat.chatItems = [] chat.chatStats = ChatStats() chat.chatInfo = cInfo @@ -251,6 +258,10 @@ final class ChatModel: ObservableObject { } } + func totalUnreadCount() -> Int { + chats.reduce(0, { count, chat in count + chat.chatStats.unreadCount }) + } + func getPrevChatItem(_ ci: ChatItem) -> ChatItem? { if let i = chatItems.firstIndex(where: { $0.id == ci.id }), i > 0 { return chatItems[i - 1] diff --git a/apps/ios/Shared/Model/NtfManager.swift b/apps/ios/Shared/Model/NtfManager.swift index de78a60780..2cee0a66ec 100644 --- a/apps/ios/Shared/Model/NtfManager.swift +++ b/apps/ios/Shared/Model/NtfManager.swift @@ -188,6 +188,19 @@ class NtfManager: NSObject, UNUserNotificationCenterDelegate, ObservableObject { addNotification(createCallInvitationNtf(invitation)) } + func setNtfBadgeCount(_ count: Int) { + UIApplication.shared.applicationIconBadgeNumber = count + ntfBadgeCountGroupDefault.set(count) + } + + func decNtfBadgeCount(by count: Int = 1) { + setNtfBadgeCount(max(0, UIApplication.shared.applicationIconBadgeNumber - count)) + } + + func incNtfBadgeCount(by count: Int = 1) { + setNtfBadgeCount(UIApplication.shared.applicationIconBadgeNumber + count) + } + private func addNotification(_ content: UNMutableNotificationContent) { if !granted { return } let trigger = UNTimeIntervalNotificationTrigger(timeInterval: ntfTimeInterval, repeats: false) diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index fca5a67e84..8d97ffebc1 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -594,6 +594,7 @@ func startChat() throws { m.userSMPServers = try getUserSMPServers() let chats = try apiGetChats() m.chats = chats.map { Chat.init($0) } + NtfManager.shared.setNtfBadgeCount(m.totalUnreadCount()) try refreshCallInvitations() (m.savedToken, m.tokenStatus, m.notificationMode) = apiGetNtfToken() if let token = m.deviceToken { diff --git a/apps/ios/SimpleX NSE/NotificationService.swift b/apps/ios/SimpleX NSE/NotificationService.swift index 05c24064e0..32cef1a4bc 100644 --- a/apps/ios/SimpleX NSE/NotificationService.swift +++ b/apps/ios/SimpleX NSE/NotificationService.swift @@ -16,11 +16,15 @@ let suspendingDelay: UInt64 = 2_000_000_000 class NotificationService: UNNotificationServiceExtension { var contentHandler: ((UNNotificationContent) -> Void)? - var bestAttemptContent: UNNotificationContent? + var bestAttemptContent: UNMutableNotificationContent? + var badgeCount: Int = 0 override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("NotificationService.didReceive") - bestAttemptContent = request.content + bestAttemptContent = request.content.mutableCopy() as? UNMutableNotificationContent + badgeCount = ntfBadgeCountGroupDefault.get() + 1 + ntfBadgeCountGroupDefault.set(badgeCount) + bestAttemptContent?.badge = badgeCount as NSNumber self.contentHandler = contentHandler let appState = appStateGroupDefault.get() switch appState { @@ -39,20 +43,22 @@ class NotificationService: UNNotificationServiceExtension { logger.debug("NotificationService: app state is \(state.rawValue, privacy: .public)") if state.inactive { receiveNtfMessages(request, contentHandler) - } else { - contentHandler(request.content) + } else if let content = bestAttemptContent { + contentHandler(content) } } default: logger.debug("NotificationService: app state is \(appState.rawValue, privacy: .public)") - contentHandler(request.content) + if let content = bestAttemptContent { + contentHandler(content) + } } } func receiveNtfMessages(_ request: UNNotificationRequest, _ contentHandler: @escaping (UNNotificationContent) -> Void) { logger.debug("NotificationService: receiveNtfMessages") if case .documents = dbContainerGroupDefault.get() { - contentHandler(request.content) + if let content = bestAttemptContent { contentHandler(content) } return } let userInfo = request.content.userInfo @@ -65,9 +71,11 @@ class NotificationService: UNNotificationServiceExtension { logger.debug("NotificationService: receiveNtfMessages: apiGetNtfMessage \(String(describing: ntfMsgInfo), privacy: .public)") if let connEntity = ntfMsgInfo.connEntity { bestAttemptContent = createConnectionEventNtf(connEntity) + bestAttemptContent?.badge = badgeCount as NSNumber } if let content = receiveMessageForNotification() { logger.debug("NotificationService: receiveMessageForNotification: has message") + content.badge = badgeCount as NSNumber contentHandler(content) } else if let content = bestAttemptContent { logger.debug("NotificationService: receiveMessageForNotification: no message") @@ -105,7 +113,7 @@ func startChat() -> User? { return nil } -func receiveMessageForNotification() -> UNNotificationContent? { +func receiveMessageForNotification() -> UNMutableNotificationContent? { logger.debug("NotificationService receiveMessages started") while true { if let res = recvSimpleXMsg() { diff --git a/apps/ios/SimpleXChat/AppGroup.swift b/apps/ios/SimpleXChat/AppGroup.swift index a093082b7a..7e0138acc7 100644 --- a/apps/ios/SimpleXChat/AppGroup.swift +++ b/apps/ios/SimpleXChat/AppGroup.swift @@ -14,6 +14,7 @@ let GROUP_DEFAULT_DB_CONTAINER = "dbContainer" public let GROUP_DEFAULT_CHAT_LAST_START = "chatLastStart" let GROUP_DEFAULT_NTF_PREVIEW_MODE = "ntfPreviewMode" let GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES = "privacyAcceptImages" +let GROUP_DEFAULT_NTF_BADGE_COUNT = "ntgBadgeCount" let APP_GROUP_NAME = "group.chat.simplex.app" @@ -62,6 +63,8 @@ public let ntfPreviewModeGroupDefault = EnumDefault( public let privacyAcceptImagesGroupDefault = BoolDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_PRIVACY_ACCEPT_IMAGES) +public let ntfBadgeCountGroupDefault = IntDefault(defaults: groupDefaults, forKey: GROUP_DEFAULT_NTF_BADGE_COUNT) + public class DateDefault { var defaults: UserDefaults var key: String @@ -107,7 +110,19 @@ public class EnumDefault where T.RawValue == String { } } -public class BoolDefault { +public class BoolDefault: Default { + public func get() -> Bool { + self.defaults.bool(forKey: self.key) + } +} + +public class IntDefault: Default { + public func get() -> Int { + self.defaults.integer(forKey: self.key) + } +} + +public class Default { var defaults: UserDefaults var key: String @@ -116,11 +131,7 @@ public class BoolDefault { self.key = forKey } - public func get() -> Bool { - defaults.bool(forKey: key) - } - - public func set(_ value: Bool) { + public func set(_ value: T) { defaults.set(value, forKey: key) defaults.synchronize() } diff --git a/src/Simplex/Chat.hs b/src/Simplex/Chat.hs index 3276c770a6..7559de2624 100644 --- a/src/Simplex/Chat.hs +++ b/src/Simplex/Chat.hs @@ -702,7 +702,7 @@ processChatCommand = \case APIJoinGroup groupId -> withUser $ \user@User {userId} -> do ReceivedGroupInvitation {fromMember, connRequest, groupInfo = g@GroupInfo {membership}} <- withStore $ \db -> getGroupInvitation db user groupId withChatLock . procCmd $ do - agentConnId <- withAgent $ \a -> joinConnection a connRequest . directMessage . XGrpAcpt $ memberId (membership:: GroupMember) + agentConnId <- withAgent $ \a -> joinConnection a connRequest . directMessage . XGrpAcpt $ memberId (membership :: GroupMember) withStore' $ \db -> do createMemberConnection db userId fromMember agentConnId updateGroupMemberStatus db userId fromMember GSMemAccepted