diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e3a6ae30b9..6201753319 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -567,12 +567,15 @@ final class ChatModel: ObservableObject { // Spec: spec/state.md#updateChatInfo func updateChatInfo(_ cInfo: ChatInfo) { if let i = getChatIndex(cInfo.id) { + let prevUserUnread = chats[i].userUnreadCount if case let .group(groupInfo, groupChatScope) = cInfo, groupChatScope != nil { chats[i].chatInfo = .group(groupInfo: groupInfo, groupChatScope: nil) } else { chats[i].chatInfo = cInfo } chats[i].created = Date.now + // muted chat does not count towards user unread counter, so it is updated when notifications change + if let user = currentUser { changeUnreadCounter(user: user, by: chats[i].userUnreadCount - prevUserUnread) } } } @@ -959,8 +962,10 @@ final class ChatModel: ObservableObject { func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) { _updateChat(cInfo.id) { chat in let wasUnread = chat.unreadTag + let prevUserUnread = chat.userUnreadCount chat.chatStats.unreadChat = unreadChat ChatTagsModel.shared.updateChatTagRead(chat, wasUnread: wasUnread) + self.changeUnreadCounter(user: self.currentUser!, by: chat.userUnreadCount - prevUserUnread) } } @@ -1103,11 +1108,12 @@ final class ChatModel: ObservableObject { func changeUnreadCounter(_ chatIndex: Int, by count: Int, unreadMentions: Int) { let wasUnread = chats[chatIndex].unreadTag + let prevUserUnread = chats[chatIndex].userUnreadCount let stats = chats[chatIndex].chatStats chats[chatIndex].chatStats.unreadCount = stats.unreadCount + count chats[chatIndex].chatStats.unreadMentions = stats.unreadMentions + unreadMentions ChatTagsModel.shared.updateChatTagRead(chats[chatIndex], wasUnread: wasUnread) - changeUnreadCounter(user: currentUser!, by: count) + changeUnreadCounter(user: currentUser!, by: chats[chatIndex].userUnreadCount - prevUserUnread) } func increaseUnreadCounter(user: any UserLike) { @@ -1115,10 +1121,7 @@ final class ChatModel: ObservableObject { } func decreaseUnreadCounter(user: any UserLike, chat: Chat) { - let by = chat.chatInfo.chatSettings?.enableNtfs == .mentions - ? chat.chatStats.unreadMentions - : chat.chatStats.unreadCount - decreaseUnreadCounter(user: user, by: by) + decreaseUnreadCounter(user: user, by: chat.userUnreadCount) } func decreaseUnreadCounter(user: any UserLike, by: Int = 1) { @@ -1126,6 +1129,7 @@ final class ChatModel: ObservableObject { } private func changeUnreadCounter(user: any UserLike, by: Int) { + if by == 0 { return } if let i = users.firstIndex(where: { $0.user.userId == user.userId }) { users[i].unreadCount += by } @@ -1136,11 +1140,7 @@ final class ChatModel: ObservableObject { func totalUnreadCountForAllUsers() -> Int { var unread: Int = 0 for chat in chats { - switch chat.chatInfo.chatSettings?.enableNtfs { - case .all: unread += chat.chatStats.unreadCount - case .mentions: unread += chat.chatStats.unreadMentions - default: () - } + unread += chat.userUnreadCount } for u in users { if !u.user.activeUser { @@ -1391,6 +1391,15 @@ final class Chat: ObservableObject, Identifiable, ChatLike { } } + // what this chat adds to unread counter of its user profile - has to match getUsersInfo in Profiles.hs + var userUnreadCount: Int { + switch chatInfo.chatSettings?.enableNtfs { + case .all: chatStats.unreadCount > 0 ? chatStats.unreadCount : (chatStats.unreadChat ? 1 : 0) + case .mentions: chatStats.unreadMentions > 0 ? chatStats.unreadMentions : (chatStats.unreadChat ? 1 : 0) + default: 0 + } + } + var id: ChatId { get { chatInfo.id } } var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 7a934fc746..f3bb069d27 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1869,6 +1869,8 @@ func apiCallStatus(_ contact: Contact, _ status: String) async throws { func markChatRead(_ im: ItemsModel, _ chat: Chat) async { do { + // markAllChatItemsRead resets chat stats of the same chat object, so it has to be read before + let unreadChat = chat.chatStats.unreadChat if chat.chatStats.unreadCount > 0 { let cInfo = chat.chatInfo try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId) @@ -1876,7 +1878,7 @@ func markChatRead(_ im: ItemsModel, _ chat: Chat) async { withAnimation { ChatModel.shared.markAllChatItemsRead(im, cInfo) } } } - if chat.chatStats.unreadChat { + if unreadChat { await markChatUnread(chat, unreadChat: false) } } catch { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index c9c30950ea..dea265a97d 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -433,7 +433,10 @@ object ChatModel { } else if (currentCInfo is ChatInfo.Group && newCInfo is ChatInfo.Group && newCInfo.groupChatScope != null) { newCInfo = newCInfo.copy(groupInfo = newCInfo.groupInfo, groupChatScope = null) } - chats[i] = chats[i].copy(chatInfo = newCInfo) + val updatedChat = chats[i].copy(chatInfo = newCInfo) + // muted chat does not count towards user unread counter, so it is updated when notifications change + currentUser.value?.let { changeUserUnreadCounter(rhId, it, chats[i], updatedChat) } + chats[i] = updatedChat } } @@ -490,6 +493,7 @@ object ChatModel { suspend fun replaceChat(rhId: Long?, id: String, chat: Chat) { val i = getChatIndex(rhId, id) if (i >= 0) { + currentUser.value?.let { changeUserUnreadCounter(rhId, it, chats[i], chat) } chats[i] = chat } else { // invalid state, correcting @@ -557,11 +561,11 @@ object ChatModel { chatItems = arrayListOf(newPreviewItem), chatStats = if (cItem.meta.itemStatus is CIStatus.RcvNew) { - increaseUnreadCounter(rhId, currentUser.value!!) chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, unreadMentions = if (cItem.meta.userMention) chat.chatStats.unreadMentions + 1 else chat.chatStats.unreadMentions) } else chat.chatStats ) + changeUserUnreadCounter(rhId, currentUser.value!!, chat, chatsContext.chats[i]) updateChatTagReadInPrimaryContext(chatsContext.chats[i], wasUnread) } // pop chat @@ -621,7 +625,7 @@ object ChatModel { chats[i] = chat.copy(chatItems = arrayListOf(cItem)) if (pItem.isRcvNew && !cItem.isRcvNew) { // status changed from New to Read, update counter - decreaseCounterInPrimaryContext(rhId, cInfo.id) + decreaseCounterInPrimaryContext(rhId, cInfo.id, cItem.meta.userMention) } } } else { @@ -671,7 +675,7 @@ object ChatModel { // update chat list if (cInfo.groupChatScope() == null) { if (cItem.isRcvNew) { - decreaseCounterInPrimaryContext(rhId, cInfo.id) + decreaseCounterInPrimaryContext(rhId, cInfo.id, cItem.meta.userMention) } // update preview val i = getChatIndex(rhId, cInfo.id) @@ -723,7 +727,7 @@ object ChatModel { for (item in chatItems.value) { if (isRemovedMemberItem(item)) { if (item.isRcvNew) { - decreaseCounterInPrimaryContext(rhId, groupInfo.id) + decreaseCounterInPrimaryContext(rhId, groupInfo.id, item.meta.userMention) } if (item.isActiveReport) { decreaseGroupReportsCounter(rhId, groupInfo.id) @@ -768,9 +772,9 @@ object ChatModel { // clear preview val i = getChatIndex(rhId, cInfo.id) if (i >= 0) { - decreaseUnreadCounter(rhId, currentUser.value!!, chats[i].chatStats.unreadCount) val chatBefore = chats[i] chats[i] = chats[i].copy(chatItems = arrayListOf(), chatStats = Chat.ChatStats(), chatInfo = cInfo) + changeUserUnreadCounter(rhId, currentUser.value!!, chatBefore, chats[i]) markChatTagRead(chatBefore) } // clear current chat @@ -841,10 +845,10 @@ object ChatModel { val wasUnread = chat.unreadTag val unreadCount = if (itemIds != null) chat.chatStats.unreadCount - markedRead else 0 val unreadMentions = if (itemIds != null) chat.chatStats.unreadMentions - mentionsMarkedRead else 0 - decreaseUnreadCounter(remoteHostId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIdx] = chat.copy( chatStats = chat.chatStats.copy(unreadCount = unreadCount, unreadMentions = unreadMentions) ) + changeUserUnreadCounter(remoteHostId, currentUser.value!!, chat, chats[chatIdx]) updateChatTagReadInPrimaryContext(chats[chatIdx], wasUnread) } } @@ -886,7 +890,7 @@ object ChatModel { return markedRead to mentionsMarkedRead } - private fun decreaseCounterInPrimaryContext(rhId: Long?, chatId: ChatId) { + private fun decreaseCounterInPrimaryContext(rhId: Long?, chatId: ChatId, userMention: Boolean) { // updates anything only in main ChatView, not GroupReportsView or anything else from the future if (secondaryContextFilter != null) return @@ -896,12 +900,13 @@ object ChatModel { val chat = chats[chatIndex] val unreadCount = kotlin.math.max(chat.chatStats.unreadCount - 1, 0) val wasUnread = chat.unreadTag - decreaseUnreadCounter(rhId, currentUser.value!!, chat.chatStats.unreadCount - unreadCount) chats[chatIndex] = chat.copy( chatStats = chat.chatStats.copy( unreadCount = unreadCount, + unreadMentions = if (userMention) kotlin.math.max(chat.chatStats.unreadMentions - 1, 0) else chat.chatStats.unreadMentions, ) ) + changeUserUnreadCounter(rhId, currentUser.value!!, chat, chats[chatIndex]) updateChatTagReadInPrimaryContext(chats[chatIndex], wasUnread) } @@ -984,6 +989,12 @@ object ChatModel { changeUnreadCounterInPrimaryContext(rhId, user, 1) } + // updates unread counter of the user profile by the difference of what the chat contributes to it - see Chat.userUnreadCount + private fun changeUserUnreadCounter(rhId: Long?, user: UserLike, prevChat: Chat, updatedChat: Chat) { + val by = updatedChat.userUnreadCount - prevChat.userUnreadCount + if (by != 0) changeUnreadCounterInPrimaryContext(rhId, user, by) + } + fun decreaseUnreadCounter(rhId: Long?, user: UserLike, by: Int = 1) { changeUnreadCounterInPrimaryContext(rhId, user, -by) } @@ -1425,6 +1436,13 @@ data class Chat( else -> chatStats.unreadChat } + // what this chat adds to unread counter of its user profile - has to match getUsersInfo in Profiles.hs + val userUnreadCount: Int get() = when (chatInfo.chatSettings?.enableNtfs) { + All -> if (chatStats.unreadCount > 0) chatStats.unreadCount else if (chatStats.unreadChat) 1 else 0 + Mentions -> if (chatStats.unreadMentions > 0) chatStats.unreadMentions else if (chatStats.unreadChat) 1 else 0 + else -> 0 + } + val id: String get() = chatInfo.id val supportUnreadCount: Int get() = when (chatInfo) { diff --git a/src/Simplex/Chat/Store/Profiles.hs b/src/Simplex/Chat/Store/Profiles.hs index ce6a8c4c9f..9159499213 100644 --- a/src/Simplex/Chat/Store/Profiles.hs +++ b/src/Simplex/Chat/Store/Profiles.hs @@ -194,7 +194,31 @@ getUsersInfo db = getUsers db >>= mapM getUserInfo AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL OR (g.enable_ntfs = 2 AND i.user_mention = 1)) |] (userId, CISRcvNew) - pure UserInfo {user, unreadCount = fromMaybe 0 ctCount + fromMaybe 0 gCount} + -- chat marked unread counts as 1, unless it has unread items counted above + unreadChatCount <- + maybeFirstRow fromOnly $ + DB.query + db + [sql| + SELECT + (SELECT COUNT(1) FROM contacts ct + WHERE ct.user_id = ? AND ct.unread_chat = 1 AND ct.is_user = 0 AND ct.deleted = 0 + AND (ct.enable_ntfs = 1 OR ct.enable_ntfs IS NULL) + AND NOT EXISTS ( + SELECT 1 FROM chat_items i + WHERE i.user_id = ct.user_id AND i.contact_id = ct.contact_id AND i.item_status = ? + )) + + (SELECT COUNT(1) FROM groups g + WHERE g.user_id = ? AND g.unread_chat = 1 + AND (g.enable_ntfs = 1 OR g.enable_ntfs IS NULL OR g.enable_ntfs = 2) + AND NOT EXISTS ( + SELECT 1 FROM chat_items i + WHERE i.user_id = g.user_id AND i.group_id = g.group_id AND i.item_status = ? + AND (COALESCE(g.enable_ntfs, 1) <> 2 OR i.user_mention = 1) + )) + |] + (userId, CISRcvNew, userId, CISRcvNew) + pure UserInfo {user, unreadCount = fromMaybe 0 ctCount + fromMaybe 0 gCount + fromMaybe 0 unreadChatCount} getUsers :: DB.Connection -> IO [User] getUsers db = do