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.
This commit is contained in:
Narasimha-sc
2026-08-08 11:07:48 +00:00
parent f921bd47bb
commit 2145bfaacd
4 changed files with 74 additions and 21 deletions
+19 -10
View File
@@ -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)" } }
+3 -1
View File
@@ -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 {
@@ -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) {
+25 -1
View File
@@ -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