From 2145bfaacd8dc9409fe85aefe604563ed974cccb Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 8 Aug 2026 11:07:48 +0000 Subject: [PATCH] core, ui: don't count muted chats in profile unread counter, count chats marked unread Unread counter above the profiles counted unread mentions in groups with mentions-only notifications, but nothing in muted chats, so two profiles with chats that both show as muted could show different counters. getUsersInfo now also counts chats marked unread - 1 per chat, unless the chat has unread items counted already. Muted chats are not counted at all, including when they are marked unread. Clients use the same rule in Chat.userUnreadCount and update the counter by the difference between what the chat contributed before and after, so that muting a chat or marking it unread updates the counter without reloading. --- apps/ios/Shared/Model/ChatModel.swift | 29 +++++++++------ apps/ios/Shared/Model/SimpleXAPI.swift | 4 ++- .../chat/simplex/common/model/ChatModel.kt | 36 ++++++++++++++----- src/Simplex/Chat/Store/Profiles.hs | 26 +++++++++++++- 4 files changed, 74 insertions(+), 21 deletions(-) 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