diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index 686472003f..843b7e6133 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -727,6 +727,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { case contactDeleted(user: UserRef, contact: Contact) case contactConnectionDeleted(user: UserRef, connection: PendingContactConnection) case groupDeletedUser(user: UserRef, groupInfo: GroupInfo) + case itemsReadForChat(user: UserRef, chatInfo: ChatInfo) case chatCleared(user: UserRef, chatInfo: ChatInfo) case userProfileNoChange(user: User) case userProfileUpdated(user: User, fromProfile: Profile, toProfile: Profile, updateSummary: UserProfileUpdateSummary) @@ -765,6 +766,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { case .contactDeleted: "contactDeleted" case .contactConnectionDeleted: "contactConnectionDeleted" case .groupDeletedUser: "groupDeletedUser" + case .itemsReadForChat: "itemsReadForChat" case .chatCleared: "chatCleared" case .userProfileNoChange: "userProfileNoChange" case .userProfileUpdated: "userProfileUpdated" @@ -797,6 +799,7 @@ enum ChatResponse1: Decodable, ChatAPIResult { case let .contactDeleted(u, contact): return withUser(u, String(describing: contact)) case let .contactConnectionDeleted(u, connection): return withUser(u, String(describing: connection)) case let .groupDeletedUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) + case let .itemsReadForChat(u, chatInfo): return withUser(u, String(describing: chatInfo)) case let .chatCleared(u, chatInfo): return withUser(u, String(describing: chatInfo)) case .userProfileNoChange: return noDetails case let .userProfileUpdated(u, _, toProfile, _): return withUser(u, String(describing: toProfile)) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 4c8fe12c60..3588ec8db4 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -564,6 +564,8 @@ final class ChatModel: ObservableObject { // } func addChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { + // updates membersRequireAttention + updateChatInfo(cInfo) // mark chat non deleted if case let .direct(contact) = cInfo, contact.chatDeleted { var updatedContact = contact @@ -1034,23 +1036,6 @@ final class ChatModel: ObservableObject { return unread } - func increaseGroupSupportChatsUnreadCounter(_ chatId: ChatId) { - changeGroupSupportChatsUnreadCounter(chatId, 1) - } - - func decreaseGroupSupportChatsUnreadCounter(_ chatId: ChatId, by: Int = 1) { - changeGroupSupportChatsUnreadCounter(chatId, -by) - } - - private func changeGroupSupportChatsUnreadCounter(_ chatId: ChatId, _ by: Int = 0) { - if by == 0 { return } - - if let i = getChatIndex(chatId) { - let chat = chats[i] - chat.chatStats.supportChatsUnreadCount = max(0, chat.chatStats.supportChatsUnreadCount + by) - } - } - func increaseGroupReportsCounter(_ chatId: ChatId) { changeGroupReportsCounter(chatId, 1) } @@ -1296,6 +1281,18 @@ final class Chat: ObservableObject, Identifiable, ChatLike { var viewId: String { get { "\(chatInfo.id) \(created.timeIntervalSince1970)" } } + var supportUnreadCount: Int { + switch chatInfo { + case let .group(groupInfo, _): + if groupInfo.canModerate { + return groupInfo.membersRequireAttention + } else { + return groupInfo.membership.supportChat?.unread ?? 0 + } + default: return 0 + } + } + public static var sampleData: Chat = Chat(chatInfo: ChatInfo.sampleData.direct, chatItems: []) } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 6e704daaa5..032ed46c8b 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1252,8 +1252,10 @@ func apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?) async throws try await sendCommandOkResp(.apiChatRead(type: type, id: id, scope: scope)) } -func apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws { - try await sendCommandOkResp(.apiChatItemsRead(type: type, id: id, scope: scope, itemIds: itemIds)) +func apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ChatInfo { + let r: ChatResponse1 = try await chatSendCmd(.apiChatItemsRead(type: type, id: id, scope: scope, itemIds: itemIds)) + if case let .itemsReadForChat(_, updatedChatInfo) = r { return updatedChatInfo } + throw r.unexpected } func apiChatUnread(type: ChatType, id: Int64, unreadChat: Bool) async throws { @@ -1587,8 +1589,9 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async { func apiMarkChatItemsRead(_ im: ItemsModel, _ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async { do { - try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemIds: itemIds) + let updatedChatInfo = try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemIds: itemIds) await MainActor.run { + ChatModel.shared.updateChatInfo(updatedChatInfo) ChatModel.shared.markChatItemsRead(im, cInfo, itemIds, mentionsRead) } } catch { @@ -1637,9 +1640,9 @@ func apiJoinGroup(_ groupId: Int64) async throws -> JoinGroupResult { } } -func apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: GroupMemberRole) async throws -> GroupMember { +func apiAcceptMember(_ groupId: Int64, _ groupMemberId: Int64, _ memberRole: GroupMemberRole) async throws -> (GroupInfo, GroupMember) { let r: ChatResponse2 = try await chatSendCmd(.apiAcceptMember(groupId: groupId, groupMemberId: groupMemberId, memberRole: memberRole)) - if case let .memberAccepted(_, _, member) = r { return member } + if case let .memberAccepted(_, groupInfo, member) = r { return (groupInfo, member) } throw r.unexpected } @@ -2131,9 +2134,6 @@ func processReceivedMsg(_ res: ChatEvent) async { if cItem.isActiveReport { m.increaseGroupReportsCounter(cInfo.id) } - if cInfo.groupChatScope() != nil && cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) { - m.increaseGroupSupportChatsUnreadCounter(cInfo.id) - } } else if cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) { m.increaseUnreadCounter(user: user) } @@ -2252,6 +2252,7 @@ func processReceivedMsg(_ res: ChatEvent) async { if active(user) { await MainActor.run { _ = m.upsertGroupMember(groupInfo, member) + m.updateGroup(groupInfo) } } case let .deletedMemberUser(user, groupInfo, member, withMessages): // TODO update user member diff --git a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift index 9a2032ea4c..b60842a4a0 100644 --- a/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift +++ b/apps/ios/Shared/Views/Chat/ChatInfoToolbar.swift @@ -35,7 +35,7 @@ struct ChatInfoToolbar: View { .frame(width: 14, height: 14) .symbolRenderingMode(.palette) .foregroundStyle(.white, .red) - } else if chat.chatStats.supportChatsUnreadCount > 0 { + } else if chat.supportUnreadCount > 0 { Image(systemName: "flag.circle.fill") .resizable() .scaledToFit() diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 3736e269a8..0eafb89070 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -1339,17 +1339,11 @@ struct ChatView: View { if !itemIds.isEmpty { waitToMarkRead { await apiMarkChatItemsRead(im, chat.chatInfo, itemIds, mentionsRead: unreadMentions) - if (im.secondaryIMFilter != nil) { - m.decreaseGroupSupportChatsUnreadCounter(chat.chatInfo.id, by: itemIds.count ) - } } } } else if chatItem.isRcvNew { waitToMarkRead { await apiMarkChatItemsRead(im, chat.chatInfo, [chatItem.id], mentionsRead: chatItem.meta.userMention ? 1 : 0) - if (im.secondaryIMFilter != nil) { - m.decreaseGroupSupportChatsUnreadCounter(chat.chatInfo.id) - } } } } diff --git a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift index 5692bbd01b..5f78581360 100644 --- a/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift +++ b/apps/ios/Shared/Views/Chat/ComposeMessage/ContextPendingMemberActionsView.swift @@ -73,9 +73,10 @@ func showAcceptMemberAlert(_ groupInfo: GroupInfo, _ member: GroupMember, dismis func acceptMember(_ groupInfo: GroupInfo, _ member: GroupMember, _ role: GroupMemberRole, dismiss: DismissAction? = nil) { Task { do { - let acceptedMember = try await apiAcceptMember(groupInfo.groupId, member.groupMemberId, role) + let (gInfo, acceptedMember) = try await apiAcceptMember(groupInfo.groupId, member.groupMemberId, role) await MainActor.run { _ = ChatModel.shared.upsertGroupMember(groupInfo, acceptedMember) + ChatModel.shared.updateGroup(gInfo) dismiss?() } } catch let error { diff --git a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift index c84124b593..56bbed7357 100644 --- a/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift +++ b/apps/ios/Shared/Views/Chat/Group/GroupChatInfoView.swift @@ -579,7 +579,7 @@ struct GroupChatInfoView: View { } label: { Label( "Chats with members", - systemImage: chat.chatStats.supportChatsUnreadCount > 0 ? "flag.fill" : "flag" + systemImage: chat.supportUnreadCount > 0 ? "flag.fill" : "flag" ) } } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 40736e3887..64a444e739 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -445,7 +445,7 @@ struct ChatPreviewView: View { ProgressView() } else if chat.chatStats.reportsCount > 0 { groupReportsIcon(size: size * 0.8) - } else if chat.chatStats.supportChatsUnreadCount > 0 { + } else if chat.supportUnreadCount > 0 { GroupSupportUnreadIcon(size: size * 0.8) } else { incognitoIcon(chat.chatInfo.incognito, theme.colors.secondary, size: size) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 8501d55bab..3c854b03ca 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1610,14 +1610,12 @@ public struct ChatStats: Decodable, Hashable { unreadCount: Int = 0, unreadMentions: Int = 0, reportsCount: Int = 0, - supportChatsUnreadCount: Int = 0, minUnreadItemId: Int64 = 0, unreadChat: Bool = false ) { self.unreadCount = unreadCount self.unreadMentions = unreadMentions self.reportsCount = reportsCount - self.supportChatsUnreadCount = supportChatsUnreadCount self.minUnreadItemId = minUnreadItemId self.unreadChat = unreadChat } @@ -1626,8 +1624,6 @@ public struct ChatStats: Decodable, Hashable { public var unreadMentions: Int = 0 // actual only via getChats() and getChat(.initial), otherwise, zero public var reportsCount: Int = 0 - // actual only via getChats() and getChat(.initial), otherwise, zero - public var supportChatsUnreadCount: Int = 0 public var minUnreadItemId: Int64 = 0 // actual only via getChats(), otherwise, false public var unreadChat: Bool = false @@ -2029,6 +2025,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { var updatedAt: Date var chatTs: Date? public var uiThemes: ThemeModeOverrides? + public var membersRequireAttention: Int public var id: ChatId { get { "#\(groupId)" } } public var apiId: Int64 { get { groupId } } @@ -2066,6 +2063,7 @@ public struct GroupInfo: Identifiable, Decodable, NamedChat, Hashable { chatSettings: ChatSettings.defaults, createdAt: .now, updatedAt: .now, + membersRequireAttention: 0, chatTags: [], localAlias: "" ) 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 3dbae3dd01..fc8eb66eb1 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 @@ -429,6 +429,8 @@ object ChatModel { } suspend fun addChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { + // updates membersRequireAttention + updateChatInfo(rhId, cInfo) // mark chat non deleted if (cInfo is ChatInfo.Direct && cInfo.chatDeleted) { val updatedContact = cInfo.contact.copy(chatDeleted = false) @@ -903,28 +905,6 @@ object ChatModel { } } - fun increaseGroupSupportChatsUnreadCounter(rhId: Long?, chatId: ChatId) { - changeGroupSupportChatsUnreadCounter(rhId, chatId, 1) - } - - fun decreaseGroupSupportChatsUnreadCounter(rhId: Long?, chatId: ChatId, by: Int = 1) { - changeGroupSupportChatsUnreadCounter(rhId, chatId, -by) - } - - private fun changeGroupSupportChatsUnreadCounter(rhId: Long?, chatId: ChatId, by: Int = 0) { - if (by == 0) return - - val i = getChatIndex(rhId, chatId) - if (i >= 0) { - val chat = chats.value[i] - chats[i] = chat.copy( - chatStats = chat.chatStats.copy( - supportChatsUnreadCount = (chat.chatStats.supportChatsUnreadCount + by).coerceAtLeast(0), - ) - ) - } - } - fun increaseGroupReportsCounter(rhId: Long?, chatId: ChatId) { changeGroupReportsCounter(rhId, chatId, 1) } @@ -1329,6 +1309,16 @@ data class Chat( val id: String get() = chatInfo.id + val supportUnreadCount: Int get() = when (chatInfo) { + is ChatInfo.Group -> + if (chatInfo.groupInfo.canModerate) { + chatInfo.groupInfo.membersRequireAttention + } else { + chatInfo.groupInfo.membership.supportChat?.unread ?: 0 + } + else -> 0 + } + fun groupFeatureEnabled(feature: GroupFeature): Boolean = if (chatInfo is ChatInfo.Group) { chatInfo.groupInfo.groupFeatureEnabled(feature) @@ -1342,8 +1332,6 @@ data class Chat( val unreadMentions: Int = 0, // actual only via getChats() and getChat(.initial), otherwise, zero val reportsCount: Int = 0, - // actual only via getChats() and getChat(.initial), otherwise, zero - val supportChatsUnreadCount: Int = 0, val minUnreadItemId: Long = 0, // actual only via getChats(), otherwise, false val unreadChat: Boolean = false @@ -1853,6 +1841,7 @@ data class GroupInfo ( override val updatedAt: Instant, val chatTs: Instant?, val uiThemes: ThemeModeOverrides? = null, + val membersRequireAttention: Int, val chatTags: List, val chatItemTTL: Long?, override val localAlias: String, @@ -1915,6 +1904,7 @@ data class GroupInfo ( updatedAt = Clock.System.now(), chatTs = Clock.System.now(), uiThemes = null, + membersRequireAttention = 0, chatTags = emptyList(), localAlias = "", chatItemTTL = null diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 9880bb63b0..c1c87924a5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -1669,11 +1669,11 @@ object ChatController { return false } - suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List): Boolean { + suspend fun apiChatItemsRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?, itemIds: List): ChatInfo? { val r = sendCmd(rh, CC.ApiChatItemsRead(type, id, scope, itemIds)) - if (r.result is CR.CmdOk) return true + if (r is API.Result && r.res is CR.ItemsReadForChat) return r.res.chatInfo Log.e(TAG, "apiChatItemsRead bad response: ${r.responseType} ${r.details}") - return false + return null } suspend fun apiChatUnread(rh: Long?, type: ChatType, id: Long, unreadChat: Boolean): Boolean { @@ -1902,9 +1902,9 @@ object ChatController { } } - suspend fun apiAcceptMember(rh: Long?, groupId: Long, groupMemberId: Long, memberRole: GroupMemberRole): GroupMember? { + suspend fun apiAcceptMember(rh: Long?, groupId: Long, groupMemberId: Long, memberRole: GroupMemberRole): Pair? { val r = sendCmd(rh, CC.ApiAcceptMember(groupId, groupMemberId, memberRole)) - if (r is API.Result && r.res is CR.MemberAccepted) return r.res.member + if (r is API.Result && r.res is CR.MemberAccepted) return r.res.groupInfo to r.res.member if (!(networkErrorAlert(r))) { apiErrorAlert("apiAcceptMember", generalGetString(MR.strings.error_accepting_member), r) } @@ -2430,9 +2430,6 @@ object ChatController { if (cItem.isActiveReport) { chatModel.chatsContext.increaseGroupReportsCounter(rhId, cInfo.id) } - if (cInfo.groupChatScope() != null && cItem.isRcvNew && cInfo.ntfsEnabled(cItem)) { - chatModel.chatsContext.increaseGroupSupportChatsUnreadCounter(rhId, cInfo.id) - } } withContext(Dispatchers.Main) { chatModel.secondaryChatsContext.value?.addChatItem(rhId, cInfo, cItem) @@ -2586,6 +2583,7 @@ object ChatController { if (active(r.user)) { withContext(Dispatchers.Main) { chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) + chatModel.chatsContext.updateGroup(rhId, r.groupInfo) } } is CR.DeletedMemberUser -> // TODO update user member @@ -5796,6 +5794,7 @@ sealed class CR { @Serializable @SerialName("contactAlreadyExists") class ContactAlreadyExists(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactDeleted") class ContactDeleted(val user: UserRef, val contact: Contact): CR() @Serializable @SerialName("contactDeletedByContact") class ContactDeletedByContact(val user: UserRef, val contact: Contact): CR() + @Serializable @SerialName("itemsReadForChat") class ItemsReadForChat(val user: UserRef, val chatInfo: ChatInfo): CR() @Serializable @SerialName("chatCleared") class ChatCleared(val user: UserRef, val chatInfo: ChatInfo): CR() @Serializable @SerialName("userProfileNoChange") class UserProfileNoChange(val user: User): CR() @Serializable @SerialName("userProfileUpdated") class UserProfileUpdated(val user: User, val fromProfile: Profile, val toProfile: Profile, val updateSummary: UserProfileUpdateSummary): CR() @@ -5976,6 +5975,7 @@ sealed class CR { is ContactAlreadyExists -> "contactAlreadyExists" is ContactDeleted -> "contactDeleted" is ContactDeletedByContact -> "contactDeletedByContact" + is ItemsReadForChat -> "itemsReadForChat" is ChatCleared -> "chatCleared" is UserProfileNoChange -> "userProfileNoChange" is UserProfileUpdated -> "userProfileUpdated" @@ -6146,6 +6146,7 @@ sealed class CR { is ContactAlreadyExists -> withUser(user, json.encodeToString(contact)) is ContactDeleted -> withUser(user, json.encodeToString(contact)) is ContactDeletedByContact -> withUser(user, json.encodeToString(contact)) + is ItemsReadForChat -> withUser(user, json.encodeToString(chatInfo)) is ChatCleared -> withUser(user, json.encodeToString(chatInfo)) is UserProfileNoChange -> withUser(user, noDetails()) is UserProfileUpdated -> withUser(user, json.encodeToString(toProfile)) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 84d811d1a9..01a3622357 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -633,17 +633,17 @@ fun ChatView( withBGApi { withContext(Dispatchers.Main) { chatModel.chatsContext.markChatItemsRead(chatRh, chatInfo.id, itemsIds) - if (chatsCtx.secondaryContextFilter != null) { - chatModel.chatsContext.decreaseGroupSupportChatsUnreadCounter(chatRh, chatInfo.id) - } ntfManager.cancelNotificationsForChat(chatInfo.id) - chatModel.controller.apiChatItemsRead( + val updatedChatInfo = chatModel.controller.apiChatItemsRead( chatRh, chatInfo.chatType, chatInfo.apiId, chatInfo.groupChatScope(), itemsIds ) + if (updatedChatInfo != null) { + chatModel.chatsContext.updateChatInfo(chatRh, updatedChatInfo) + } } withContext(Dispatchers.Main) { chatModel.secondaryChatsContext.value?.markChatItemsRead(chatRh, chatInfo.id, itemsIds) @@ -894,10 +894,10 @@ fun ChatLayout( } } val reportsCount = reportsCount(chatInfo?.id) - val supportChatsUnreadCount = supportChatsUnreadCount(chatInfo?.id) + val supportUnreadCount = supportUnreadCount(chatInfo?.id) if (oneHandUI.value && chatBottomBar.value) { - if (chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportChatsUnreadCount > 0)) { - SupportChatsCountToolbar(reportsCount, supportChatsUnreadCount, withStatusBar = true, showReports, showSupportChats) + if (chatInfo != null && chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportUnreadCount > 0)) { + SupportChatsCountToolbar(chatInfo, reportsCount, supportUnreadCount, withStatusBar = true, showReports, showSupportChats) } else { StatusBarBackground() } @@ -953,8 +953,8 @@ fun ChatLayout( SelectedItemsCounterToolbar(selectedChatItems, !oneHandUI.value || !chatBottomBar.value) } } - if ((reportsCount > 0 || supportChatsUnreadCount > 0) && (!oneHandUI.value || !chatBottomBar.value)) { - SupportChatsCountToolbar(reportsCount, supportChatsUnreadCount, withStatusBar = false, showReports, showSupportChats) + if (chatInfo != null && (reportsCount > 0 || supportUnreadCount > 0) && (!oneHandUI.value || !chatBottomBar.value)) { + SupportChatsCountToolbar(chatInfo, reportsCount, supportUnreadCount, withStatusBar = false, showReports, showSupportChats) } } } @@ -1187,8 +1187,9 @@ fun ChatInfoToolbarTitle(cInfo: ChatInfo, imageSize: Dp = 40.dp, iconColor: Colo @Composable private fun SupportChatsCountToolbar( + chatInfo: ChatInfo, reportsCount: Int, - supportChatsUnreadCount: Int, + supportUnreadCount: Int, withStatusBar: Boolean, showReports: () -> Unit, showSupportChats: () -> Unit @@ -1225,7 +1226,7 @@ private fun SupportChatsCountToolbar( } } - if (supportChatsUnreadCount > 0) { + if (supportUnreadCount > 0) { Row( Modifier .fillMaxWidth() @@ -1240,10 +1241,13 @@ private fun SupportChatsCountToolbar( Icon(painterResource(MR.images.ic_flag), null, Modifier.size(22.dp), tint = MaterialTheme.colors.primary) Spacer(Modifier.width(4.dp)) Text( - if (appPlatform.isAndroid) - stringResource(MR.strings.group_new_support_messages_short).format(supportChatsUnreadCount) + if (chatInfo is ChatInfo.Group && chatInfo.groupInfo.canModerate) + if (appPlatform.isAndroid) + stringResource(MR.strings.group_new_support_chats_short).format(supportUnreadCount) + else + stringResource(MR.strings.group_new_support_chats).format(supportUnreadCount) else - stringResource(MR.strings.group_new_support_messages).format(supportChatsUnreadCount), + stringResource(MR.strings.group_new_support_messages).format(supportUnreadCount), style = MaterialTheme.typography.button ) } @@ -1329,10 +1333,10 @@ fun BoxScope.ChatItemsList( } val reversedChatItems = remember { derivedStateOf { chatsCtx.chatItems.value.asReversed() } } val reportsCount = reportsCount(chatInfo.id) - val supportChatsUnreadCount = supportChatsUnreadCount(chatInfo.id) + val supportUnreadCount = supportUnreadCount(chatInfo.id) val topPaddingToContent = topPaddingToContent( chatView = chatsCtx.secondaryContextFilter == null, - additionalTopBar = chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportChatsUnreadCount > 0) + additionalTopBar = chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportUnreadCount > 0) ) val topPaddingToContentPx = rememberUpdatedState(with(LocalDensity.current) { topPaddingToContent.roundToPx() }) val numberOfBottomAppBars = numberOfBottomAppBars() @@ -1672,7 +1676,7 @@ fun BoxScope.ChatItemsList( ), reverseLayout = true, additionalBarOffset = composeViewHeight, - additionalTopBar = rememberUpdatedState(chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportChatsUnreadCount > 0)), + additionalTopBar = rememberUpdatedState(chatsCtx.secondaryContextFilter == null && (reportsCount > 0 || supportUnreadCount > 0)), chatBottomBar = remember { appPrefs.chatBottomBar.state } ) { val mergedItemsValue = mergedItems.value @@ -2358,11 +2362,11 @@ fun reportsCount(staleChatId: String?): Int { } @Composable -fun supportChatsUnreadCount(staleChatId: String?): Int { +fun supportUnreadCount(staleChatId: String?): Int { return if (staleChatId?.startsWith("#") != true) { 0 } else { - remember(staleChatId) { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId }?.chatStats } }.value?.supportChatsUnreadCount ?: 0 + remember(staleChatId) { derivedStateOf { chatModel.chats.value.firstOrNull { chat -> chat.chatInfo.id == staleChatId } } }.value?.supportUnreadCount ?: 0 } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt index 8f1d40dd58..401509a171 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeContextPendingMemberActionsView.kt @@ -102,13 +102,14 @@ fun acceptMemberDialog(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, c private fun acceptMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember, role: GroupMemberRole, close: (() -> Unit)?) { withBGApi { - val acceptedMember = chatModel.controller.apiAcceptMember(rhId, groupInfo.groupId, member.groupMemberId, role) - if (acceptedMember != null) { + val r = chatModel.controller.apiAcceptMember(rhId, groupInfo.groupId, member.groupMemberId, role) + if (r != null) { withContext(Dispatchers.Main) { - chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, acceptedMember) + chatModel.chatsContext.upsertGroupMember(rhId, groupInfo, r.second) + chatModel.chatsContext.updateGroup(rhId, r.first) } withContext(Dispatchers.Main) { - chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, acceptedMember) + chatModel.secondaryChatsContext.value?.upsertGroupMember(rhId, groupInfo, r.second) } } close?.invoke() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt index 9a27dd150b..006456004b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/GroupChatInfoView.kt @@ -741,10 +741,10 @@ private fun GroupChatInfoHeader(cInfo: ChatInfo, groupInfo: GroupInfo) { @Composable private fun MemberSupportButton(chat: Chat, onClick: () -> Unit) { SettingsActionItem( - painterResource(if (chat.chatStats.supportChatsUnreadCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag), + painterResource(if (chat.supportUnreadCount > 0) MR.images.ic_flag_filled else MR.images.ic_flag), stringResource(MR.strings.member_support), click = onClick, - iconColor = (if (chat.chatStats.supportChatsUnreadCount > 0) MaterialTheme.colors.primary else MaterialTheme.colors.secondary) + iconColor = (if (chat.supportUnreadCount > 0) MaterialTheme.colors.primary else MaterialTheme.colors.secondary) ) } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index 584a358c5e..eb7eb74103 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -364,7 +364,7 @@ fun ChatPreviewView( progressView() } else if (chat.chatStats.reportsCount > 0) { GroupReportsIcon() - } else if (chat.chatStats.supportChatsUnreadCount > 0) { + } else if (chat.supportUnreadCount > 0) { GroupSupportUnreadIcon() } else { IncognitoIcon(chat.chatInfo.incognito) diff --git a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml index 4cc727ba4f..acd57f7ffc 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -465,8 +465,9 @@ 1 report %d reports Member reports - %d new support messages - %d messages + %d messages + %d chats with members + %d chats Share message… diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c8151dbac5..7653322c8d 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -676,6 +676,7 @@ data ChatResponse | CRSentConfirmation {user :: User, connection :: PendingContactConnection} | CRSentInvitation {user :: User, connection :: PendingContactConnection, customUserProfile :: Maybe Profile} | CRSentInvitationToContact {user :: User, contact :: Contact, customUserProfile :: Maybe Profile} + | CRItemsReadForChat {user :: User, chatInfo :: AChatInfo} | CRContactDeleted {user :: User, contact :: Contact} | CRChatCleared {user :: User, chatInfo :: AChatInfo} | CRUserContactLinkCreated {user :: User, connLinkContact :: CreatedLinkContact} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index e58ee17c7b..959fab7021 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1017,22 +1017,26 @@ processChatCommand' vr = \case CTContactConnection -> throwCmdError "not supported" APIChatItemsRead chatRef@(ChatRef cType chatId scope) itemIds -> withUser $ \_ -> case cType of CTDirect -> do - user <- withFastStore $ \db -> getUserByContactId db chatId + (user, ct) <- withFastStore $ \db -> do + user <- getUserByContactId db chatId + ct <- getContact db vr user chatId + pure (user, ct) timedItems <- withFastStore' $ \db -> do timedItems <- updateDirectChatItemsReadList db user chatId itemIds setDirectChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt - ok user + pure $ CRItemsReadForChat user (AChatInfo SCTDirect $ DirectChat ct) CTGroup -> do (user, gInfo) <- withFastStore $ \db -> do user <- getUserByGroupId db chatId gInfo <- getGroupInfo db vr user chatId pure (user, gInfo) - timedItems <- withFastStore' $ \db -> do - timedItems <- updateGroupChatItemsReadList db user gInfo scope itemIds - setGroupChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime + (timedItems, gInfo') <- withFastStore $ \db -> do + (timedItems, gInfo') <- updateGroupChatItemsReadList db vr user gInfo scope itemIds + timedItems' <- liftIO $ setGroupChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime + pure (timedItems', gInfo') forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt - ok user + pure $ CRItemsReadForChat user (AChatInfo SCTGroup $ GroupChat gInfo' Nothing) CTLocal -> throwCmdError "not supported" CTContactRequest -> throwCmdError "not supported" CTContactConnection -> throwCmdError "not supported" @@ -2054,8 +2058,11 @@ processChatCommand' vr = \case void $ sendDirectMemberMessage mConn msg groupId introduceToRemaining vr user gInfo m {memberRole = role} when (groupFeatureAllowed SGFHistory gInfo) $ sendHistory user gInfo m - m' <- withFastStore' $ \db -> updateGroupMemberAccepted db user m GSMemConnected role - pure $ CRMemberAccepted user gInfo m' + (m', gInfo') <- withFastStore' $ \db -> do + m' <- updateGroupMemberAccepted db user m GSMemConnected role + gInfo' <- updateGroupMembersRequireAttention db user gInfo m m' + pure (m', gInfo') + pure $ CRMemberAccepted user gInfo' m' Nothing -> throwChatError CEGroupMemberNotActive GSMemPendingReview -> do let scope = Just $ GCSMemberSupport $ Just (groupMemberId' m) @@ -2063,8 +2070,11 @@ processChatCommand' vr = \case let rcpModMs' = filter memberCurrent modMs msg = XGrpLinkAcpt GAAccepted role (memberId' m) void $ sendGroupMessage user gInfo scope ([m] <> rcpModMs') msg - m' <- withFastStore' $ \db -> updateGroupMemberAccepted db user m newMemberStatus role - pure $ CRMemberAccepted user gInfo m' + (m', gInfo') <- withFastStore' $ \db -> do + m' <- updateGroupMemberAccepted db user m newMemberStatus role + gInfo' <- updateGroupMembersRequireAttention db user gInfo m m' + pure (m', gInfo') + pure $ CRMemberAccepted user gInfo' m' where newMemberStatus = case memberConn m of Just c | connReady c -> GSMemConnected diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 3a26ece6f7..7809d8cc68 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -2032,8 +2032,9 @@ saveSndChatItems :: CM [Either ChatError (ChatItem c 'MDSnd)] saveSndChatItems user cd itemsData itemTimed live = do createdAt <- liftIO getCurrentTime + vr <- chatVersionRange when (contactChatDeleted cd || any (\NewSndChatItemData {content} -> ciRequiresAttention content) (rights itemsData)) $ - withStore' (\db -> updateChatTsStats db user cd createdAt Nothing) + void $ withStore' (\db -> updateChatTsStats db vr user cd createdAt Nothing) lift $ withStoreBatch (\db -> map (bindRight $ createItem db createdAt) itemsData) where createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd)) @@ -2045,19 +2046,20 @@ saveSndChatItems user cd itemsData itemTimed live = do CDGroupSnd g _scope | not (null itemMentions) -> createGroupCIMentions db g ci itemMentions _ -> pure ci -saveRcvChatItemNoParse :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv) +saveRcvChatItemNoParse :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> CIContent 'MDRcv -> CM (ChatItem c 'MDRcv, ChatInfo c) saveRcvChatItemNoParse user cd msg brokerTs = saveRcvChatItem user cd msg brokerTs . ciContentNoParse -saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> CM (ChatItem c 'MDRcv) +saveRcvChatItem :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> CM (ChatItem c 'MDRcv, ChatInfo c) saveRcvChatItem user cd msg@RcvMessage {sharedMsgId_} brokerTs content = saveRcvChatItem' user cd msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty ciContentNoParse :: CIContent 'MDRcv -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) ciContentNoParse content = (content, (ciContentToText content, Nothing)) -saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv) +saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c 'MDRcv -> RcvMessage -> Maybe SharedMsgId -> UTCTime -> (CIContent 'MDRcv, (Text, Maybe MarkdownList)) -> Maybe (CIFile 'MDRcv) -> Maybe CITimed -> Bool -> Map MemberName MsgMention -> CM (ChatItem c 'MDRcv, ChatInfo c) saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do createdAt <- liftIO getCurrentTime + vr <- chatVersionRange withStore' $ \db -> do (mentions' :: Map MemberName CIMention, userMention) <- case cd of CDGroupRcv g@GroupInfo {membership} _scope _m -> do @@ -2068,13 +2070,16 @@ saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} shared userMention' = userReply || any (\CIMention {memberId} -> sameMemberId memberId membership) mentions' in pure (mentions', userMention') CDDirectRcv _ -> pure (M.empty, False) - when (ciRequiresAttention content || contactChatDeleted cd) $ updateChatTsStats db user cd createdAt (chatStatsCounts userMention) + cInfo' <- if (ciRequiresAttention content || contactChatDeleted cd) + then updateChatTsStats db vr user cd createdAt (chatStatsCounts userMention) + else pure $ toChatInfo cd (ciId, quotedItem, itemForwarded) <- createNewRcvChatItem db user cd msg sharedMsgId_ content itemTimed live userMention brokerTs createdAt forM_ ciFile $ \CIFile {fileId} -> updateFileTransferChatItemId db fileId ciId createdAt let ci = mkChatItem_ cd ciId content (t, ft_) ciFile quotedItem sharedMsgId_ itemForwarded itemTimed live userMention brokerTs forwardedByMember createdAt - case cd of + ci' <- case cd of CDGroupRcv g _scope _m | not (null mentions') -> createGroupCIMentions db g ci mentions' _ -> pure ci + pure (ci', cInfo') where chatStatsCounts :: Bool -> Maybe (Int, MemberAttention, Int) chatStatsCounts userMention = case cd of @@ -2297,12 +2302,13 @@ createInternalItemsForChats :: createInternalItemsForChats user itemTs_ dirsCIContents = do createdAt <- liftIO getCurrentTime let itemTs = fromMaybe createdAt itemTs_ - void . withStoreBatch' $ \db -> map (uncurry $ updateChat db createdAt) dirsCIContents + vr <- chatVersionRange' + void . withStoreBatch' $ \db -> map (uncurry $ updateChat db vr createdAt) dirsCIContents withStoreBatch' $ \db -> concatMap (uncurry $ createACIs db itemTs createdAt) dirsCIContents where - updateChat :: DB.Connection -> UTCTime -> ChatDirection c d -> [CIContent d] -> IO () - updateChat db createdAt cd contents - | any ciRequiresAttention contents || contactChatDeleted cd = updateChatTsStats db user cd createdAt chatStatsCounts + updateChat :: DB.Connection -> VersionRangeChat -> UTCTime -> ChatDirection c d -> [CIContent d] -> IO () + updateChat db vr createdAt cd contents + | any ciRequiresAttention contents || contactChatDeleted cd = void $ updateChatTsStats db vr user cd createdAt chatStatsCounts | otherwise = pure () where chatStatsCounts :: Maybe (Int, MemberAttention, Int) @@ -2331,7 +2337,8 @@ createLocalChatItems :: UTCTime -> CM [ChatItem 'CTLocal 'MDSnd] createLocalChatItems user cd itemsData createdAt = do - withStore' $ \db -> updateChatTsStats db user cd createdAt Nothing + vr <- chatVersionRange + void $ withStore' $ \db -> updateChatTsStats db vr user cd createdAt Nothing (errs, items) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (createItem db) $ L.toList itemsData) unless (null errs) $ toView $ CEvtChatErrors errs pure items diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 6fb09d56e9..d978ae7bc1 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -784,29 +784,33 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = memberConnectedChatItem gInfo' scopeInfo m' unless (memberPending membership) $ maybeCreateGroupDescrLocal gInfo' m' GCInviteeMember -> do - mStatus <- + (gInfo', mStatus) <- if not (memberPending m) - then withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected $> GSMemConnected - else pure $ memberStatus m - (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - memberConnectedChatItem gInfo' scopeInfo m' + then do + mStatus <- withStore' $ \db -> updateGroupMemberStatus db userId m GSMemConnected $> GSMemConnected + pure (gInfo, mStatus) + else do + gInfo' <- withStore' $ \db -> increaseGroupMembersRequireAttention db user gInfo + pure (gInfo', memberStatus m) + (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m + memberConnectedChatItem gInfo'' scopeInfo m' case scopeInfo of Just (GCSIMemberSupport _) -> do - createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m') (CIRcvGroupEvent RGENewMemberPendingReview) Nothing + createInternalChatItem user (CDGroupRcv gInfo'' scopeInfo m') (CIRcvGroupEvent RGENewMemberPendingReview) Nothing _ -> pure () - toView $ CEvtJoinedGroupMember user gInfo' m' {memberStatus = mStatus} + toView $ CEvtJoinedGroupMember user gInfo'' m' {memberStatus = mStatus} let Connection {viaUserContactLink} = conn - when (isJust viaUserContactLink && isNothing (memberContactId m')) $ sendXGrpLinkMem gInfo' + when (isJust viaUserContactLink && isNothing (memberContactId m')) $ sendXGrpLinkMem gInfo'' when (connChatVersion < batchSend2Version) sendGroupAutoReply case mStatus of GSMemPendingApproval -> pure () - GSMemPendingReview -> introduceToModerators vr user gInfo' m' + GSMemPendingReview -> introduceToModerators vr user gInfo'' m' _ -> do - introduceToAll vr user gInfo' m' - when (groupFeatureAllowed SGFHistory gInfo') $ sendHistory user gInfo' m' + introduceToAll vr user gInfo'' m' + when (groupFeatureAllowed SGFHistory gInfo'') $ sendHistory user gInfo'' m' where - sendXGrpLinkMem gInfo' = do - let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo' + sendXGrpLinkMem gInfo'' = do + let profileMode = ExistingIncognito <$> incognitoMembershipProfile gInfo'' profileToSend = profileToSendOnAccept user profileMode True void $ sendDirectMemberMessage conn (XGrpLinkMem profileToSend) groupId _ -> do @@ -1466,9 +1470,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = where brokerTs = metaBrokerTs msgMeta newChatItem content ciFile_ timed_ live = do - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content ciFile_ timed_ live M.empty + (ci, cInfo) <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content ciFile_ timed_ live M.empty reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getDirectCIReactions db ct sharedMsgId) sharedMsgId_ - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci {reactions}] + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci {reactions}] autoAcceptFile :: Maybe (RcvFileTransfer, CIFile 'MDRcv) -> CM () autoAcceptFile = mapM_ $ \(ft, CIFile {fileSize}) -> do @@ -1534,11 +1538,11 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- Chat item and update message which created it will have different sharedMsgId in this case... let timed_ = rcvContactCITimed ct ttl ts = ciContentTexts content - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live M.empty + (ci, cInfo) <- saveRcvChatItem' user (CDDirectRcv ct) msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live M.empty ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc updateDirectChatItem' db user contactId ci content True live Nothing Nothing - toView $ CEvtChatItemUpdated user (AChatItem SCTDirect SMDRcv (DirectChat ct) ci') + toView $ CEvtChatItemUpdated user (AChatItem SCTDirect SMDRcv cInfo ci') where brokerTs = metaBrokerTs msgMeta content = CIRcvMsgContent mc @@ -1658,27 +1662,26 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | groupFeatureAllowed SGFFullDelete gInfo = do (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ -- ignores member role when blocked by admin - ci <- saveRcvCI gInfo' scopeInfo m' (ciContentNoParse CIRcvBlocked) Nothing timed' False M.empty + (ci, cInfo) <- saveRcvCI gInfo' scopeInfo m' (ciContentNoParse CIRcvBlocked) Nothing timed' False M.empty ci' <- withStore' $ \db -> updateGroupCIBlockedByAdmin db user gInfo' ci brokerTs - groupMsgToView gInfo' scopeInfo ci' + groupMsgToView cInfo ci' | otherwise = do file_ <- processFileInv - ci <- createNonLive file_ + (ci, cInfo) <- createNonLive file_ ci' <- withStore' $ \db -> markGroupCIBlockedByAdmin db user gInfo ci - (gInfo', _m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ - groupMsgToView gInfo' scopeInfo ci' + groupMsgToView cInfo ci' applyModeration CIModeration {moderatorMember = moderator@GroupMember {memberRole = moderatorRole}, moderatedAt} | moderatorRole < GRModerator || moderatorRole < memberRole = createContentItem | groupFeatureMemberAllowed SGFFullDelete moderator gInfo = do (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ - ci <- saveRcvCI gInfo' scopeInfo m' (ciContentNoParse CIRcvModerated) Nothing timed' False M.empty + (ci, cInfo) <- saveRcvCI gInfo' scopeInfo m' (ciContentNoParse CIRcvModerated) Nothing timed' False M.empty ci' <- withStore' $ \db -> updateGroupChatItemModerated db user gInfo' ci moderator moderatedAt - groupMsgToView gInfo' scopeInfo ci' + groupMsgToView cInfo ci' | otherwise = do (gInfo', _m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ file_ <- processFileInv - ci <- createNonLive file_ + (ci, _cInfo) <- createNonLive file_ deletions <- markGroupCIsDeleted user gInfo' scopeInfo [CChatItem SMDRcv ci] (Just moderator) moderatedAt toView $ CEvtChatItemsDeleted user deletions False False createNonLive file_ = do @@ -1693,10 +1696,10 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = newChatItem ciContent ciFile_ timed_ live = do let mentions' = if showMessages (memberSettings m) then mentions else [] (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ - ci <- saveRcvCI gInfo' scopeInfo m' ciContent ciFile_ timed_ live mentions' + (ci, cInfo) <- saveRcvCI gInfo' scopeInfo m' ciContent ciFile_ timed_ live mentions' ci' <- blockedMember m' ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo' ci reactions <- maybe (pure []) (\sharedMsgId -> withStore' $ \db -> getGroupCIReactions db gInfo' memberId sharedMsgId) sharedMsgId_ - groupMsgToView gInfo' scopeInfo ci' {reactions} + groupMsgToView cInfo ci' {reactions} groupMessageUpdate :: GroupInfo -> GroupMember -> SharedMsgId -> MsgContent -> Map MemberName MsgMention -> Maybe MsgScope -> RcvMessage -> UTCTime -> Maybe Int -> Maybe Bool -> CM () groupMessageUpdate gInfo@GroupInfo {groupId} m@GroupMember {groupMemberId, memberId} sharedMsgId mc mentions msgScope_ msg@RcvMessage {msgId} brokerTs ttl_ live_ @@ -1710,12 +1713,12 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let timed_ = rcvGroupCITimed gInfo ttl_ mentions' = if showMessages (memberSettings m) then mentions else [] (gInfo', m', scopeInfo) <- mkGetMessageChatScope vr user gInfo m msgScope_ - ci <- saveRcvChatItem' user (CDGroupRcv gInfo' scopeInfo m') msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live mentions' + (ci, cInfo) <- saveRcvChatItem' user (CDGroupRcv gInfo' scopeInfo m') msg (Just sharedMsgId) brokerTs (content, ts) Nothing timed_ live mentions' ci' <- withStore' $ \db -> do createChatItemVersion db (chatItemId' ci) brokerTs mc ci' <- updateGroupChatItem db user groupId ci content True live Nothing blockedMember m' ci' $ markGroupChatItemBlocked db user gInfo' ci' - toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv (GroupChat gInfo' scopeInfo) ci') + toView $ CEvtChatItemUpdated user (AChatItem SCTGroup SMDRcv cInfo ci') where content = CIRcvMsgContent mc ts@(_, ft_) = msgContentTexts mc @@ -1802,8 +1805,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] + (ci, cInfo) <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] where brokerTs = metaBrokerTs msgMeta @@ -1816,9 +1819,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = let fileProtocol = if isJust xftpRcvFile then FPXFTP else FPSMP ciFile = Just $ CIFile {fileId, fileName, fileSize, fileSource = Nothing, fileStatus = CIFSRcvInvitation, fileProtocol} content = ciContentNoParse $ CIRcvMsgContent $ MCFile "" - ci <- saveRcvChatItem' user (CDGroupRcv gInfo Nothing m) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty + (ci, cInfo) <- saveRcvChatItem' user (CDGroupRcv gInfo Nothing m) msg sharedMsgId_ brokerTs content ciFile Nothing False M.empty ci' <- blockedMember m ci $ withStore' $ \db -> markGroupChatItemBlocked db user gInfo ci - groupMsgToView gInfo Nothing ci' + groupMsgToView cInfo ci' blockedMember :: Monad m' => GroupMember -> ChatItem c d -> m' (ChatItem c d) -> m' (ChatItem c d) blockedMember m ci blockedCI @@ -1969,9 +1972,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = _ -> messageError "x.file.acpt.inv: member connection is not active" else messageError "x.file.acpt.inv: fileName is different from expected" - groupMsgToView :: forall d. MsgDirectionI d => GroupInfo -> Maybe GroupChatScopeInfo -> ChatItem 'CTGroup d -> CM () - groupMsgToView gInfo chatScopeInfo ci = do - toView $ CEvtNewChatItems user [AChatItem SCTGroup (msgDirection @d) (GroupChat gInfo chatScopeInfo) ci] + groupMsgToView :: forall d. MsgDirectionI d => ChatInfo 'CTGroup -> ChatItem 'CTGroup d -> CM () + groupMsgToView cInfo ci = do + toView $ CEvtNewChatItems user [AChatItem SCTGroup (msgDirection @d) cInfo ci] processGroupInvitation :: Contact -> GroupInvitation -> RcvMessage -> MsgMeta -> CM () processGroupInvitation ct inv msg msgMeta = do @@ -1996,9 +1999,9 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CEvtUserAcceptedGroupSent user gInfo {membership = membership {memberStatus = GSMemAccepted}} (Just ct) else do let content = CIRcvGroupInvitation (CIGroupInvitation {groupId, groupMemberId, localDisplayName, groupProfile, status = CIGISPending}) memRole - ci <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content + (ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs content withStore' $ \db -> setGroupInvitationChatItemId db user groupId (chatItemId' ci) - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] toView $ CEvtReceivedGroupInvitation {user, groupInfo = gInfo, contact = ct, fromMemberRole = fromRole, memberRole = memRole} where brokerTs = metaBrokerTs msgMeta @@ -2024,8 +2027,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = forM_ contactConns $ \conn -> withStore' $ \db -> updateConnectionStatus db conn ConnDeleted activeConn' <- forM (contactConn ct') $ \conn -> pure conn {connStatus = ConnDeleted} let ct'' = ct' {activeConn = activeConn'} :: Contact - ci <- saveRcvChatItemNoParse user (CDDirectRcv ct'') msg brokerTs (CIRcvDirectEvent RDEContactDeleted) - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct'') ci] + (ci, cInfo) <- saveRcvChatItemNoParse user (CDDirectRcv ct'') msg brokerTs (CIRcvDirectEvent RDEContactDeleted) + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] toView $ CEvtContactDeletedByContact user ct'' else do contactConns <- withStore' $ \db -> getContactConnections db vr userId c @@ -2096,13 +2099,16 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case Left _ -> messageError "x.grp.link.acpt error: referenced member does not exist" Right referencedMember -> do - referencedMember' <- withFastStore' $ \db -> updateGroupMemberAccepted db user referencedMember (newMemberStatus referencedMember) role + (referencedMember', gInfo') <- withFastStore' $ \db -> do + referencedMember' <- updateGroupMemberAccepted db user referencedMember (newMemberStatus referencedMember) role + gInfo' <- updateGroupMembersRequireAttention db user gInfo referencedMember referencedMember' + pure (referencedMember', gInfo') when (memberCategory referencedMember == GCInviteeMember) $ introduceToRemainingMembers referencedMember' let scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Just referencedMember'} gEvent = RGEMemberAccepted (groupMemberId' referencedMember') (fromLocalProfile $ memberProfile referencedMember') - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo scopeInfo m) msg brokerTs (CIRcvGroupEvent gEvent) - groupMsgToView gInfo scopeInfo ci - toView $ CEvtMemberAcceptedByOther user gInfo m referencedMember' + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m) msg brokerTs (CIRcvGroupEvent gEvent) + groupMsgToView cInfo ci + toView $ CEvtMemberAcceptedByOther user gInfo' m referencedMember' where newMemberStatus refMem = case memberConn refMem of Just c | connReady c -> GSMemConnected @@ -2112,8 +2118,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = GAAccepted -> do membership' <- withStore' $ \db -> updateGroupMemberAccepted db user membership GSMemConnected role let scopeInfo = Just $ GCSIMemberSupport {groupMember_ = Nothing} - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo scopeInfo m) msg brokerTs (CIRcvGroupEvent RGEUserAccepted) - groupMsgToView gInfo scopeInfo ci + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo scopeInfo m) msg brokerTs (CIRcvGroupEvent RGEUserAccepted) + groupMsgToView cInfo ci toView $ CEvtUserJoinedGroup user gInfo {membership = membership'} m let cd = CDGroupRcv gInfo Nothing m createInternalChatItem user cd (CIRcvGroupE2EEInfo E2EInfo {pqEnabled = PQEncOff}) Nothing @@ -2265,7 +2271,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = then do g <- asks random dhKeyPair <- atomically $ if encryptedCall callType then Just <$> C.generateKeyPair g else pure Nothing - ci <- saveCallItem CISCallPending + (ci, cInfo) <- saveCallItem CISCallPending callUUID <- UUID.toText <$> liftIO V4.nextRandom let sharedKey = C.Key . C.dhBytes' <$> (C.dh' <$> callDhPubKey <*> (snd <$> dhKeyPair)) callState = CallInvitationReceived {peerCallType = callType, localDhPubKey = fst <$> dhKeyPair, sharedKey} @@ -2278,15 +2284,15 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = call_ <- atomically (TM.lookupInsert contactId call' calls) forM_ call_ $ \call -> updateCallItemStatus user ct call WCSDisconnected Nothing toView $ CEvtCallInvitation RcvCallInvitation {user, contact = ct, callType, sharedKey, callUUID, callTs = chatItemTs' ci} - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] else featureRejected CFCalls where brokerTs = metaBrokerTs msgMeta saveCallItem status = saveRcvChatItemNoParse user (CDDirectRcv ct) msg brokerTs (CIRcvCall status 0) featureRejected f = do let content = ciContentNoParse $ CIRcvChatFeatureRejected f - ci <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat ct) ci] + (ci, cInfo) <- saveRcvChatItem' user (CDDirectRcv ct) msg sharedMsgId_ brokerTs content Nothing Nothing False M.empty + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] -- to party initiating call xCallOffer :: Contact -> CallId -> CallOffer -> RcvMessage -> CM () @@ -2457,27 +2463,37 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = unless (sameMemberId memId $ membership gInfo) $ withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memId) >>= \case Right unknownMember@GroupMember {memberStatus = GSMemUnknown} -> do - updatedMember <- withStore $ \db -> updateUnknownMemberAnnounced db vr user m unknownMember memInfo initialStatus - toView $ CEvtUnknownMemberAnnounced user gInfo m unknownMember updatedMember - memberAnnouncedToView updatedMember + (updatedMember, gInfo') <- withStore $ \db -> do + updatedMember <- updateUnknownMemberAnnounced db vr user m unknownMember memInfo initialStatus + gInfo' <- if memberPending updatedMember + then liftIO $ increaseGroupMembersRequireAttention db user gInfo + else pure gInfo + pure (updatedMember, gInfo') + toView $ CEvtUnknownMemberAnnounced user gInfo' m unknownMember updatedMember + memberAnnouncedToView updatedMember gInfo' Right _ -> messageError "x.grp.mem.new error: member already exists" Left _ -> do - newMember <- withStore $ \db -> createNewGroupMember db user gInfo m memInfo GCPostMember initialStatus - memberAnnouncedToView newMember + (newMember, gInfo') <- withStore $ \db -> do + newMember <- createNewGroupMember db user gInfo m memInfo GCPostMember initialStatus + gInfo' <- if memberPending newMember + then liftIO $ increaseGroupMembersRequireAttention db user gInfo + else pure gInfo + pure (newMember, gInfo') + memberAnnouncedToView newMember gInfo' where initialStatus = case msgScope_ of Just (MSMember _) -> GSMemPendingReview _ -> GSMemAnnounced - memberAnnouncedToView announcedMember@GroupMember {groupMemberId, memberProfile} = do + memberAnnouncedToView announcedMember@GroupMember {groupMemberId, memberProfile} gInfo' = do (announcedMember', scopeInfo) <- getMemNewChatScope announcedMember let event = RGEMemberAdded groupMemberId (fromLocalProfile memberProfile) - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo scopeInfo m) msg brokerTs (CIRcvGroupEvent event) - groupMsgToView gInfo scopeInfo ci + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m) msg brokerTs (CIRcvGroupEvent event) + groupMsgToView cInfo ci case scopeInfo of Just (GCSIMemberSupport _) -> do - createInternalChatItem user (CDGroupRcv gInfo scopeInfo m) (CIRcvGroupEvent RGENewMemberPendingReview) (Just brokerTs) + createInternalChatItem user (CDGroupRcv gInfo' scopeInfo m) (CIRcvGroupEvent RGENewMemberPendingReview) (Just brokerTs) _ -> pure () - toView $ CEvtJoinedGroupMemberConnecting user gInfo m announcedMember' + toView $ CEvtJoinedGroupMemberConnecting user gInfo' m announcedMember' getMemNewChatScope announcedMember = case msgScope_ of Nothing -> pure (announcedMember, Nothing) Just (MSMember _) -> do @@ -2571,8 +2587,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = do withStore' $ \db -> updateGroupMemberRole db user member memRole (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo' m - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) - groupMsgToView gInfo'' scopeInfo ci + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) + groupMsgToView cInfo ci toView CEvtMemberRole {user, groupInfo = gInfo'', byMember = m', member = member {memberRole = memRole}, fromRole, toRole = memRole} checkHostRole :: GroupMember -> GroupMemberRole -> CM () @@ -2600,8 +2616,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toggleNtf bm' (not blocked) let ciContent = CIRcvGroupEvent $ RGEMemberBlocked bmId (fromLocalProfile bmp) blocked (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs ciContent - groupMsgToView gInfo' scopeInfo ci + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs ciContent + groupMsgToView cInfo ci toView CEvtMemberBlockedForAll {user, groupInfo = gInfo', byMember = m', member = bm, blocked} Left (SEGroupMemberNotFoundByMemberId _) -> do bm <- createUnknownMember gInfo memId @@ -2685,8 +2701,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = | otherwise = a deleteMemberItem gEvent = do (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) - groupMsgToView gInfo' scopeInfo ci + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs (CIRcvGroupEvent gEvent) + groupMsgToView cInfo ci deleteMessages :: MsgDirectionI d => GroupMember -> SMsgDirection d -> CM () deleteMessages delMem msgDir | groupFeatureMemberAllowed SGFFullDelete m gInfo = deleteGroupMemberCIs user gInfo delMem m msgDir @@ -2698,8 +2714,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- member record is not deleted to allow creation of "member left" chat item withStore' $ \db -> updateGroupMemberStatus db userId m GSMemLeft (gInfo', m', scopeInfo) <- mkGroupChatScope gInfo m - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) - groupMsgToView gInfo' scopeInfo ci + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEMemberLeft) + groupMsgToView cInfo ci toView $ CEvtLeftMember user gInfo' m' {memberStatus = GSMemLeft} xGrpDel :: GroupInfo -> GroupMember -> RcvMessage -> UTCTime -> CM () @@ -2712,8 +2728,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = -- member records are not deleted to keep history deleteMembersConnections user ms (gInfo'', m', scopeInfo) <- mkGroupChatScope gInfo m - ci <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) - groupMsgToView gInfo'' scopeInfo ci + (ci, cInfo) <- saveRcvChatItemNoParse user (CDGroupRcv gInfo'' scopeInfo m') msg brokerTs (CIRcvGroupEvent RGEGroupDeleted) + groupMsgToView cInfo ci toView $ CEvtGroupDeleted user gInfo'' {membership = membership {memberStatus = GSMemGroupDeleted}} m' xGrpInfo :: GroupInfo -> GroupMember -> GroupProfile -> RcvMessage -> UTCTime -> CM () @@ -2726,8 +2742,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = toView $ CEvtGroupUpdated user g g'' (Just m') let cd = CDGroupRcv g'' scopeInfo m' unless (sameGroupProfileInfo p p') $ do - ci <- saveRcvChatItemNoParse user cd msg brokerTs (CIRcvGroupEvent $ RGEGroupUpdated p') - groupMsgToView g'' scopeInfo ci + (ci, cInfo) <- saveRcvChatItemNoParse user cd msg brokerTs (CIRcvGroupEvent $ RGEGroupUpdated p') + groupMsgToView cInfo ci createGroupFeatureChangedItems user cd CIRcvGroupFeature g g'' Just _ -> updateGroupPrefs_ g m $ fromMaybe defaultBusinessGroupPrefs $ groupPreferences p' @@ -2787,8 +2803,8 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = createInternalChatItem user (CDGroupRcv g' scopeInfo m'') (CIRcvGroupEvent RGEMemberCreatedContact) Nothing toView $ CEvtNewMemberContactReceivedInv user mCt' g' m'' forM_ mContent_ $ \mc -> do - ci <- saveRcvChatItem user (CDDirectRcv mCt') msg brokerTs (CIRcvMsgContent mc, msgContentTexts mc) - toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv (DirectChat mCt') ci] + (ci, cInfo) <- saveRcvChatItem user (CDDirectRcv mCt') msg brokerTs (CIRcvMsgContent mc, msgContentTexts mc) + toView $ CEvtNewChatItems user [AChatItem SCTDirect SMDRcv cInfo ci] securityCodeChanged :: Contact -> CM () securityCodeChanged ct = do diff --git a/src/Simplex/Chat/Messages.hs b/src/Simplex/Chat/Messages.hs index 8aa595bed8..00dd999139 100644 --- a/src/Simplex/Chat/Messages.hs +++ b/src/Simplex/Chat/Messages.hs @@ -350,14 +350,13 @@ data ChatStats = ChatStats { unreadCount :: Int, -- returned both in /_get chat initial API and in /_get chats API unreadMentions :: Int, -- returned both in /_get chat initial API and in /_get chats API reportsCount :: Int, -- returned both in /_get chat initial API and in /_get chats API - supportChatsUnreadCount :: Int, -- returned both in /_get chat initial API and in /_get chats API minUnreadItemId :: ChatItemId, unreadChat :: Bool } deriving (Show) emptyChatStats :: ChatStats -emptyChatStats = ChatStats 0 0 0 0 0 False +emptyChatStats = ChatStats 0 0 0 0 False data NavigationInfo = NavigationInfo { afterUnread :: Int, diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index 8407cb11a7..121796f3fd 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -137,7 +137,8 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 24f598a7e6..710ee50e09 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -80,6 +80,8 @@ module Simplex.Chat.Store.Groups updateGroupMemberStatus, updateGroupMemberStatusById, updateGroupMemberAccepted, + updateGroupMembersRequireAttention, + increaseGroupMembersRequireAttention, createNewGroupMember, checkGroupMemberHasItems, deleteGroupMember, @@ -279,7 +281,8 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = do -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -362,7 +365,8 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, - customData = Nothing + customData = Nothing, + membersRequireAttention = 0 } -- | creates a new group record for the group the current user was invited to, or returns an existing one @@ -432,7 +436,8 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, - customData = Nothing + customData = Nothing, + membersRequireAttention = 0 }, groupMemberId ) @@ -768,7 +773,8 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, @@ -1224,6 +1230,37 @@ updateGroupMemberAccepted db User {userId} m@GroupMember {groupMemberId} status (status, role, currentTs, userId, groupMemberId) pure m {memberStatus = status, memberRole = role, updatedAt = currentTs} +updateGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> GroupMember -> GroupMember -> IO GroupInfo +updateGroupMembersRequireAttention db user@User {userId} g@GroupInfo {groupId, membersRequireAttention} member member' + | nowRequires && not didRequire = + increaseGroupMembersRequireAttention db user g + | not nowRequires && didRequire = do + DB.execute + db + [sql| + UPDATE groups + SET members_require_attention = members_require_attention - 1 + WHERE user_id = ? AND group_id = ? + |] + (userId, groupId) + pure g {membersRequireAttention = membersRequireAttention - 1} + | otherwise = pure g + where + didRequire = gmRequiresAttention member + nowRequires = gmRequiresAttention member' + +increaseGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> IO GroupInfo +increaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, membersRequireAttention} = do + DB.execute + db + [sql| + UPDATE groups + SET members_require_attention = members_require_attention + 1 + WHERE user_id = ? AND group_id = ? + |] + (userId, groupId) + pure g {membersRequireAttention = membersRequireAttention + 1} + -- | add new member with profile createNewGroupMember :: DB.Connection -> User -> GroupInfo -> GroupMember -> MemberInfo -> GroupMemberCategory -> GroupMemberStatus -> ExceptT StoreError IO GroupMember createNewGroupMember db user gInfo invitingMember memInfo@MemberInfo {profile} memCategory memStatus = do @@ -1561,7 +1598,8 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 39ccb698fa..83d34e5d24 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -369,56 +369,103 @@ type NewQuoteRow = (Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bo data MemberAttention = MAInc Int | MAReset deriving (Show) -updateChatTsStats :: DB.Connection -> User -> ChatDirection c d -> UTCTime -> Maybe (Int, MemberAttention, Int) -> IO () -updateChatTsStats db User {userId} chatDirection chatTs chatStats_ = case toChatInfo chatDirection of - DirectChat Contact {contactId} -> +updateChatTsStats :: DB.Connection -> VersionRangeChat -> User -> ChatDirection c d -> UTCTime -> Maybe (Int, MemberAttention, Int) -> IO (ChatInfo c) +updateChatTsStats db vr user@User {userId} chatDirection chatTs chatStats_ = case toChatInfo chatDirection of + DirectChat ct@Contact {contactId} -> do DB.execute db "UPDATE contacts SET chat_ts = ?, chat_deleted = 0 WHERE user_id = ? AND contact_id = ?" (chatTs, userId, contactId) - GroupChat GroupInfo {groupId, membership} scopeInfo -> do + pure $ DirectChat ct {chatTs = Just chatTs} + GroupChat g@GroupInfo {groupId} Nothing -> do DB.execute db "UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ?" (chatTs, userId, groupId) - forM_ scopeInfo $ \GCSIMemberSupport {groupMember_} -> do - let gmId = groupMemberId' $ fromMaybe membership groupMember_ - case chatStats_ of - Nothing -> - DB.execute - db - "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" - (chatTs, gmId) - Just (unread, MAInc unanswered, mentions) -> - DB.execute - db - [sql| - UPDATE group_members - SET support_chat_ts = ?, - support_chat_items_unread = support_chat_items_unread + ?, - support_chat_items_member_attention = support_chat_items_member_attention + ?, - support_chat_items_mentions = support_chat_items_mentions + ? - WHERE group_member_id = ? - |] - (chatTs, unread, unanswered, mentions, gmId) - Just (unread, MAReset, mentions) -> - DB.execute - db - [sql| - UPDATE group_members - SET support_chat_ts = ?, - support_chat_items_unread = support_chat_items_unread + ?, - support_chat_items_member_attention = 0, - support_chat_items_mentions = support_chat_items_mentions + ? - WHERE group_member_id = ? - |] - (chatTs, unread, mentions, gmId) - LocalChat NoteFolder {noteFolderId} -> + pure $ GroupChat g {chatTs = Just chatTs} Nothing + GroupChat g@GroupInfo {groupId, membership, membersRequireAttention} (Just GCSIMemberSupport {groupMember_}) -> + case groupMember_ of + Nothing -> do + membership' <- updateGMStats membership + DB.execute + db + "UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ?" + (chatTs, userId, groupId) + pure $ GroupChat g {membership = membership', chatTs = Just chatTs} (Just $ GCSIMemberSupport Nothing) + Just member -> do + member' <- updateGMStats member + let didRequire = gmRequiresAttention member + nowRequires = gmRequiresAttention member' + if + | nowRequires && not didRequire -> do + DB.execute + db + [sql| + UPDATE groups + SET chat_ts = ?, + members_require_attention = members_require_attention + 1 + WHERE user_id = ? AND group_id = ? + |] + (chatTs, userId, groupId) + pure $ GroupChat g {membersRequireAttention = membersRequireAttention + 1, chatTs = Just chatTs} (Just $ GCSIMemberSupport (Just member')) + | not nowRequires && didRequire -> do + DB.execute + db + [sql| + UPDATE groups + SET chat_ts = ?, + members_require_attention = members_require_attention - 1 + WHERE user_id = ? AND group_id = ? + |] + (chatTs, userId, groupId) + pure $ GroupChat g {membersRequireAttention = membersRequireAttention - 1, chatTs = Just chatTs} (Just $ GCSIMemberSupport (Just member')) + | otherwise -> do + DB.execute + db + "UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ?" + (chatTs, userId, groupId) + pure $ GroupChat g {chatTs = Just chatTs} (Just $ GCSIMemberSupport (Just member')) + where + updateGMStats m@GroupMember {groupMemberId} = do + case chatStats_ of + Nothing -> + DB.execute + db + "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" + (chatTs, groupMemberId) + Just (unread, MAInc unanswered, mentions) -> + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = support_chat_items_member_attention + ?, + support_chat_items_mentions = support_chat_items_mentions + ? + WHERE group_member_id = ? + |] + (chatTs, unread, unanswered, mentions, groupMemberId) + Just (unread, MAReset, mentions) -> + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = 0, + support_chat_items_mentions = support_chat_items_mentions + ? + WHERE group_member_id = ? + |] + (chatTs, unread, mentions, groupMemberId) + m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId + pure $ either (const m) id m_ -- Left shouldn't happen, but types require it + LocalChat nf@NoteFolder {noteFolderId} -> do DB.execute db "UPDATE note_folders SET chat_ts = ? WHERE user_id = ? AND note_folder_id = ?" (chatTs, userId, noteFolderId) - _ -> pure () + pure $ LocalChat nf {chatTs = chatTs} + cInfo -> pure cInfo setSupportChatTs :: DB.Connection -> GroupMemberId -> UTCTime -> IO () setSupportChatTs db groupMemberId chatTs = @@ -636,13 +683,13 @@ type ChatStatsRow = (Int, ChatItemId, BoolInt) toChatStats :: ChatStatsRow -> ChatStats toChatStats (unreadCount, minUnreadItemId, BI unreadChat) = - ChatStats {unreadCount, unreadMentions = 0, reportsCount = 0, supportChatsUnreadCount = 0, minUnreadItemId, unreadChat} + ChatStats {unreadCount, unreadMentions = 0, reportsCount = 0, minUnreadItemId, unreadChat} -type GroupStatsRow = (Int, Int, Int, Int, ChatItemId, BoolInt) +type GroupStatsRow = (Int, Int, Int, ChatItemId, BoolInt) toGroupStats :: GroupStatsRow -> ChatStats -toGroupStats (unreadCount, unreadMentions, reportsCount, supportChatsUnreadCount, minUnreadItemId, BI unreadChat) = - ChatStats {unreadCount, unreadMentions, reportsCount, supportChatsUnreadCount, minUnreadItemId, unreadChat} +toGroupStats (unreadCount, unreadMentions, reportsCount, minUnreadItemId, BI unreadChat) = + ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat} findDirectChatPreviews_ :: DB.Connection -> User -> PaginationByTime -> ChatListQuery -> IO [AChatPreviewData] findDirectChatPreviews_ db User {userId} pagination clq = @@ -763,7 +810,6 @@ findGroupChatPreviews_ db User {userId} pagination clq = COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), - COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g @@ -780,16 +826,8 @@ findGroupChatPreviews_ db User {userId} pagination clq = AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS Count - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - AND group_scope_tag IS NOT NULL - AND item_status = ? - GROUP BY group_id - ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id |] - baseParams = (userId, userId, CISRcvNew, userId, MCReport_, BI False, userId, CISRcvNew) + baseParams = (userId, userId, CISRcvNew, userId, MCReport_, BI False) getPreviews = case clq of CLQFilters {favorite = False, unread = False} -> do let q = baseQuery <> " WHERE g.user_id = ?" @@ -1494,16 +1532,14 @@ getGroupChatInitial_ db user g scopeInfo_ contentFilter count = do where getStats minUnreadItemId (unreadCount, unreadMentions) = do reportsCount <- getGroupReportsCount_ db user g False - supportChatsUnreadCount <- getGroupSupportChatsUnreadCount_ db user g - pure ChatStats {unreadCount, unreadMentions, reportsCount, supportChatsUnreadCount, minUnreadItemId, unreadChat = False} + pure ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat = False} getGroupStats_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> ExceptT StoreError IO ChatStats getGroupStats_ db user g scopeInfo_ = do minUnreadItemId <- fromMaybe 0 <$> getGroupMinUnreadId_ db user g scopeInfo_ Nothing (unreadCount, unreadMentions) <- getGroupUnreadCount_ db user g scopeInfo_ Nothing reportsCount <- liftIO $ getGroupReportsCount_ db user g False - supportChatsUnreadCount <- liftIO $ getGroupSupportChatsUnreadCount_ db user g - pure ChatStats {unreadCount, unreadMentions, reportsCount, supportChatsUnreadCount, minUnreadItemId, unreadChat = False} + pure ChatStats {unreadCount, unreadMentions, reportsCount, minUnreadItemId, unreadChat = False} getGroupMinUnreadId_ :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> ExceptT StoreError IO (Maybe ChatItemId) getGroupMinUnreadId_ db user g scopeInfo_ contentFilter = @@ -1527,14 +1563,6 @@ getGroupReportsCount_ db User {userId} GroupInfo {groupId} archived = "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0" (userId, groupId, MCReport_, BI archived) -getGroupSupportChatsUnreadCount_ :: DB.Connection -> User -> GroupInfo -> IO Int -getGroupSupportChatsUnreadCount_ db User {userId} GroupInfo {groupId} = - fromOnly . head - <$> DB.query - db - "SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NOT NULL AND item_status = ?" - (userId, groupId, CISRcvNew) - queryUnreadGroupItems :: FromRow r => DB.Connection -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> Maybe MsgContentTag -> Query -> Query -> ExceptT StoreError IO [r] queryUnreadGroupItems db User {userId} GroupInfo {groupId} scopeInfo_ contentFilter baseQuery orderLimit = case (scopeInfo_, contentFilter) of @@ -1954,14 +1982,14 @@ getGroupUnreadTimedItems db User {userId} groupId = |] (userId, groupId, CISRcvNew) -updateGroupChatItemsReadList :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScope -> NonEmpty ChatItemId -> IO [(ChatItemId, Int)] -updateGroupChatItemsReadList db User {userId} GroupInfo {groupId, membership} scope itemIds = do - currentTs <- getCurrentTime +updateGroupChatItemsReadList :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScope -> NonEmpty ChatItemId -> ExceptT StoreError IO ([(ChatItemId, Int)], GroupInfo) +updateGroupChatItemsReadList db vr user@User {userId} g@GroupInfo {groupId, membership, membersRequireAttention} scope itemIds = do + currentTs <- liftIO getCurrentTime -- Possible improvement is to differentiate retrieval queries for each scope, -- but we rely on UI to not pass item IDs from incorrect scope. - readItemsData <- catMaybes . L.toList <$> mapM (getUpdateGroupItem currentTs) itemIds - updateChatStats readItemsData - pure $ timedItems readItemsData + readItemsData <- liftIO $ catMaybes . L.toList <$> mapM (getUpdateGroupItem currentTs) itemIds + g' <- updateChatStats readItemsData + pure (timedItems readItemsData, g') where getUpdateGroupItem :: UTCTime -> ChatItemId -> IO (Maybe (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)) getUpdateGroupItem currentTs itemId = @@ -1974,36 +2002,61 @@ updateGroupChatItemsReadList db User {userId} GroupInfo {groupId, membership} sc RETURNING chat_item_id, timed_ttl, timed_delete_at, group_member_id, user_mention |] (CISRcvRead, currentTs, userId, groupId, CISRcvNew, itemId) - updateChatStats :: [(ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)] -> IO () + updateChatStats :: [(ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)] -> ExceptT StoreError IO GroupInfo updateChatStats readItemsData = case scope of - Nothing -> pure () - Just GCSMemberSupport {groupMemberId_} -> do - let unread = length readItemsData - (unanswered, mentions) = decStats - gmId = fromMaybe (groupMemberId' membership) groupMemberId_ - DB.execute - db - [sql| - UPDATE group_members - SET support_chat_items_unread = support_chat_items_unread - ?, - support_chat_items_member_attention = support_chat_items_member_attention - ?, - support_chat_items_mentions = support_chat_items_mentions - ? - WHERE group_member_id = ? - |] - (unread, unanswered, mentions, gmId) + Nothing -> pure g + Just GCSMemberSupport {groupMemberId_} -> case groupMemberId_ of + Nothing -> do + membership' <- updateGMStats membership + pure g {membership = membership'} + Just groupMemberId -> do + member <- getGroupMemberById db vr user groupMemberId + member' <- updateGMStats member + let didRequire = gmRequiresAttention member + nowRequires = gmRequiresAttention member' + if (not nowRequires && didRequire) + then do + liftIO $ + DB.execute + db + [sql| + UPDATE groups + SET members_require_attention = members_require_attention - 1 + WHERE user_id = ? AND group_id = ? + |] + (userId, groupId) + pure g {membersRequireAttention = membersRequireAttention - 1} + else + pure g where - decStats :: (Int, Int) - decStats = foldl' countItem (0, 0) readItemsData + updateGMStats GroupMember {groupMemberId} = do + let unread = length readItemsData + (unanswered, mentions) = decStats + liftIO $ + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_items_unread = support_chat_items_unread - ?, + support_chat_items_member_attention = support_chat_items_member_attention - ?, + support_chat_items_mentions = support_chat_items_mentions - ? + WHERE group_member_id = ? + |] + (unread, unanswered, mentions, groupMemberId) + getGroupMemberById db vr user groupMemberId where - countItem :: (Int, Int) -> (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt) -> (Int, Int) - countItem (!unanswered, !mentions) (_, _, _, itemGMId_, userMention_) = - let unanswered' = case (groupMemberId_, itemGMId_) of - (Just scopeGMId, Just itemGMId) | itemGMId == scopeGMId -> unanswered + 1 - _ -> unanswered - mentions' = case userMention_ of - Just (BI True) -> mentions + 1 - _ -> mentions - in (unanswered', mentions') + decStats :: (Int, Int) + decStats = foldl' countItem (0, 0) readItemsData + where + countItem :: (Int, Int) -> (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt) -> (Int, Int) + countItem (!unanswered, !mentions) (_, _, _, itemGMId_, userMention_) = + let unanswered' = case (groupMemberId_, itemGMId_) of + (Just scopeGMId, Just itemGMId) | itemGMId == scopeGMId -> unanswered + 1 + _ -> unanswered + mentions' = case userMention_ of + Just (BI True) -> mentions + 1 + _ -> mentions + in (unanswered', mentions') timedItems :: [(ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)] -> [(ChatItemId, Int)] timedItems = foldl' addTimedItem [] where diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20250403_group_scope.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20250403_group_scope.hs index 0dcd47527e..4c5c20c99f 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20250403_group_scope.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20250403_group_scope.hs @@ -17,6 +17,8 @@ ALTER TABLE group_members ADD COLUMN support_chat_items_unread BIGINT NOT NULL D ALTER TABLE group_members ADD COLUMN support_chat_items_member_attention BIGINT NOT NULL DEFAULT 0; ALTER TABLE group_members ADD COLUMN support_chat_items_mentions BIGINT NOT NULL DEFAULT 0; +ALTER TABLE groups ADD COLUMN members_require_attention BIGINT NOT NULL DEFAULT 0; + ALTER TABLE chat_items ADD COLUMN group_scope_tag TEXT; ALTER TABLE chat_items ADD COLUMN group_scope_group_member_id BIGINT REFERENCES group_members(group_member_id) ON DELETE CASCADE; @@ -53,6 +55,8 @@ DROP INDEX idx_chat_items_group_scope_group_member_id; ALTER TABLE chat_items DROP COLUMN group_scope_tag; ALTER TABLE chat_items DROP COLUMN group_scope_group_member_id; +ALTER TABLE groups DROP COLUMN members_require_attention; + ALTER TABLE group_members DROP COLUMN support_chat_ts; ALTER TABLE group_members DROP COLUMN support_chat_items_unread; ALTER TABLE group_members DROP COLUMN support_chat_items_member_attention; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs index d17e969866..883464fa83 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250403_group_scope.hs @@ -15,6 +15,8 @@ ALTER TABLE group_members ADD COLUMN support_chat_items_unread INTEGER NOT NULL ALTER TABLE group_members ADD COLUMN support_chat_items_member_attention INTEGER NOT NULL DEFAULT 0; ALTER TABLE group_members ADD COLUMN support_chat_items_mentions INTEGER NOT NULL DEFAULT 0; +ALTER TABLE groups ADD COLUMN members_require_attention INTEGER NOT NULL DEFAULT 0; + ALTER TABLE chat_items ADD COLUMN group_scope_tag TEXT; ALTER TABLE chat_items ADD COLUMN group_scope_group_member_id INTEGER REFERENCES group_members(group_member_id) ON DELETE CASCADE; @@ -50,6 +52,8 @@ DROP INDEX idx_chat_items_group_scope_group_member_id; ALTER TABLE chat_items DROP COLUMN group_scope_tag; ALTER TABLE chat_items DROP COLUMN group_scope_group_member_id; +ALTER TABLE groups DROP COLUMN members_require_attention; + ALTER TABLE group_members DROP COLUMN support_chat_ts; ALTER TABLE group_members DROP COLUMN support_chat_items_unread; ALTER TABLE group_members DROP COLUMN support_chat_items_member_attention; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index f4ba1c1e4e..4fbd9858e6 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -1,3 +1,39 @@ +Query: + UPDATE groups + SET members_require_attention = members_require_attention - 1 + WHERE user_id = ? AND group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET support_chat_items_unread = support_chat_items_unread - ?, + support_chat_items_member_attention = support_chat_items_member_attention - ?, + support_chat_items_mentions = support_chat_items_mentions - ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE groups + SET chat_ts = ?, + members_require_attention = members_require_attention + 1 + WHERE user_id = ? AND group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE groups + SET chat_ts = ?, + members_require_attention = members_require_attention - 1 + WHERE user_id = ? AND group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: INSERT INTO group_members ( group_id, member_id, member_role, member_category, member_status, invited_by, invited_by_group_member_id, @@ -36,7 +72,8 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -81,6 +118,28 @@ Query: Plan: SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = 0, + support_chat_items_mentions = support_chat_items_mentions + ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: + UPDATE group_members + SET support_chat_ts = ?, + support_chat_items_unread = support_chat_items_unread + ?, + support_chat_items_member_attention = support_chat_items_member_attention + ?, + support_chat_items_mentions = support_chat_items_mentions + ? + WHERE group_member_id = ? + +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: INSERT INTO contact_requests (user_contact_link_id, agent_invitation_id, peer_chat_min_version, peer_chat_max_version, contact_profile_id, local_display_name, user_id, @@ -621,38 +680,6 @@ Query: Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) -Query: - UPDATE group_members - SET support_chat_items_unread = support_chat_items_unread - ?, - support_chat_items_member_attention = support_chat_items_member_attention - ?, - support_chat_items_mentions = support_chat_items_mentions - ? - WHERE group_member_id = ? - -Plan: -SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) - -Query: - UPDATE group_members - SET support_chat_ts = ?, - support_chat_items_unread = support_chat_items_unread + ?, - support_chat_items_member_attention = 0, - support_chat_items_mentions = support_chat_items_mentions + ? - WHERE group_member_id = ? - -Plan: -SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) - -Query: - UPDATE group_members - SET support_chat_ts = ?, - support_chat_items_unread = support_chat_items_unread + ?, - support_chat_items_member_attention = support_chat_items_member_attention + ?, - support_chat_items_mentions = support_chat_items_mentions + ? - WHERE group_member_id = ? - -Plan: -SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) - Query: DELETE FROM chat_item_reactions WHERE contact_id = ? AND shared_msg_id = ? AND reaction_sent = ? AND reaction = ? @@ -840,7 +867,8 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupInfo {membership} mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -887,7 +915,8 @@ Query: SELECT g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, mu.group_member_id, g.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, mu.created_at, mu.updated_at, @@ -1270,16 +1299,6 @@ Query: Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) -Query: - UPDATE group_members - SET support_chat_items_unread = 0, - support_chat_items_member_attention = 0, - support_chat_items_mentions = 0 - WHERE group_member_id = ? - -Plan: -SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE group_profiles SET display_name = ?, full_name = ?, description = ?, image = ?, preferences = ?, member_admission = ?, updated_at = ? @@ -1294,6 +1313,14 @@ SEARCH group_profiles USING INTEGER PRIMARY KEY (rowid=?) LIST SUBQUERY 1 SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE groups + SET members_require_attention = members_require_attention - 1 + WHERE user_id = ? AND group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE user_contact_links SET auto_accept = ?, business_address = ?, auto_accept_incognito = ?, auto_reply_msg_content = ? @@ -1948,7 +1975,6 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), - COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g @@ -1965,14 +1991,6 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS Count - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - AND group_scope_tag IS NOT NULL - AND item_status = ? - GROUP BY group_id - ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id WHERE g.user_id = ? @@ -1988,13 +2006,10 @@ MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH gp USING INTEGER PRIMARY KEY (rowid=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN -SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2012,7 +2027,6 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), - COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g @@ -2029,14 +2043,6 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS Count - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - AND group_scope_tag IS NOT NULL - AND item_status = ? - GROUP BY group_id - ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? AND (g.favorite = 1 @@ -2047,12 +2053,9 @@ MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN -SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2070,7 +2073,6 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), - COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g @@ -2087,14 +2089,6 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS Count - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - AND group_scope_tag IS NOT NULL - AND item_status = ? - GROUP BY group_id - ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) @@ -2104,12 +2098,9 @@ MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts 0) @@ -2161,12 +2143,9 @@ MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN -SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2184,7 +2163,6 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), - COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g @@ -2201,14 +2179,6 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS Count - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - AND group_scope_tag IS NOT NULL - AND item_status = ? - GROUP BY group_id - ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? AND (g.unread_chat = 1 OR ChatStats.UnreadCount > 0) @@ -2218,12 +2188,9 @@ MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN -SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2241,7 +2208,6 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), - COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g @@ -2258,14 +2224,6 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS Count - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - AND group_scope_tag IS NOT NULL - AND item_status = ? - GROUP BY group_id - ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? AND g.favorite = 1 @@ -2275,12 +2233,9 @@ MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN -SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2355,7 +2298,6 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), - COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g @@ -2372,14 +2314,6 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS Count - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - AND group_scope_tag IS NOT NULL - AND item_status = ? - GROUP BY group_id - ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? AND g.favorite = 1 @@ -2389,12 +2323,9 @@ MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN -SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2412,7 +2343,6 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), - COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g @@ -2429,26 +2359,15 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS Count - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - AND group_scope_tag IS NOT NULL - AND item_status = ? - GROUP BY group_id - ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? AND g.chat_ts < ? ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts ? ORDER BY g.chat_ts ASC LIMIT ? Plan: MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=? AND chat_ts>?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN -SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -2520,7 +2427,6 @@ Query: COALESCE(ChatStats.UnreadCount, 0), COALESCE(ChatStats.UnreadMentions, 0), COALESCE(ReportCount.Count, 0), - COALESCE(SupportChatsUnreadCount.Count, 0), COALESCE(ChatStats.MinUnread, 0), g.unread_chat FROM groups g @@ -2537,26 +2443,15 @@ Query: AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 GROUP BY group_id ) ReportCount ON ReportCount.group_id = g.group_id - LEFT JOIN ( - SELECT group_id, COUNT(1) AS Count - FROM chat_items - WHERE user_id = ? AND group_id IS NOT NULL - AND group_scope_tag IS NOT NULL - AND item_status = ? - GROUP BY group_id - ) SupportChatsUnreadCount ON SupportChatsUnreadCount.group_id = g.group_id WHERE g.user_id = ? ORDER BY g.chat_ts DESC LIMIT ? Plan: MATERIALIZE ChatStats SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id>?) MATERIALIZE ReportCount SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id>?) -MATERIALIZE SupportChatsUnreadCount -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id>?) SEARCH g USING INDEX idx_groups_chat_ts (user_id=?) SEARCH ChatStats USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN SEARCH ReportCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN -SEARCH SupportChatsUnreadCount USING AUTOMATIC COVERING INDEX (group_id=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH ci USING INDEX idx_chat_items_groups_item_ts (user_id=? AND group_id=?) @@ -4526,6 +4421,14 @@ Query: Plan: SEARCH group_snd_item_statuses USING INDEX idx_group_snd_item_statuses_chat_item_id_group_member_id (chat_item_id=? AND group_member_id=?) +Query: + UPDATE groups + SET members_require_attention = members_require_attention + 1 + WHERE user_id = ? AND group_id = ? + +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET via_group_link_uri_hash = (SELECT via_contact_uri_hash FROM connections WHERE connection_id = ?) @@ -4625,7 +4528,8 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -4648,7 +4552,8 @@ Query: -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, @@ -5638,10 +5543,6 @@ Query: SELECT COUNT(1) FROM chat_item_versions WHERE chat_item_id = ? Plan: SEARCH chat_item_versions USING COVERING INDEX idx_chat_item_versions_chat_item_id (chat_item_id=?) -Query: SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND group_scope_tag IS NOT NULL AND item_status = ? -Plan: -SEARCH chat_items USING COVERING INDEX idx_chat_items_group_scope_item_status (user_id=? AND group_id=? AND group_scope_tag>?) - Query: SELECT COUNT(1) FROM chat_items WHERE user_id = ? AND group_id = ? AND msg_content_tag = ? AND item_deleted = ? AND item_sent = 0 Plan: SEARCH chat_items USING COVERING INDEX idx_chat_items_groups_msg_content_tag_deleted (user_id=? AND group_id=? AND msg_content_tag=? AND item_deleted=? AND item_sent=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index e1cefaa1d1..552d753593 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -134,7 +134,8 @@ CREATE TABLE groups( business_xcontact_id BLOB NULL, customer_member_id BLOB NULL, chat_item_ttl INTEGER, - local_alias TEXT DEFAULT '', -- received + local_alias TEXT DEFAULT '', + members_require_attention INTEGER NOT NULL DEFAULT 0, -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE diff --git a/src/Simplex/Chat/Store/Shared.hs b/src/Simplex/Chat/Store/Shared.hs index d876c9bd92..f0c0c86716 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -579,18 +579,18 @@ safeDeleteLDN db User {userId} localDisplayName = do type BusinessChatInfoRow = (Maybe BusinessChatType, Maybe MemberId, Maybe MemberId) -type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64) :. GroupMemberRow +type GroupInfoRow = (Int64, GroupName, GroupName, Text, Text, Maybe Text, Maybe ImageData, Maybe MsgFilter, Maybe BoolInt, BoolInt, Maybe GroupPreferences, Maybe GroupMemberAdmission) :. (UTCTime, UTCTime, Maybe UTCTime, Maybe UTCTime) :. BusinessChatInfoRow :. (Maybe UIThemeEntityOverrides, Maybe CustomData, Maybe Int64, Int) :. GroupMemberRow type GroupMemberRow = (Int64, Int64, MemberId, VersionChat, VersionChat, GroupMemberRole, GroupMemberCategory, GroupMemberStatus, BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, ContactName, Maybe ContactId, ProfileId, ProfileId, ContactName, Text, Maybe ImageData, Maybe ConnLinkContact, LocalAlias, Maybe Preferences) :. (UTCTime, UTCTime) :. (Maybe UTCTime, Int64, Int64, Int64) toGroupInfo :: VersionRangeChat -> Int64 -> [ChatTagId] -> GroupInfoRow -> GroupInfo -toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL) :. userMemberRow) = +toGroupInfo vr userContactId chatTags ((groupId, localDisplayName, displayName, fullName, localAlias, description, image, enableNtfs_, sendRcpts, BI favorite, groupPreferences, memberAdmission) :. (createdAt, updatedAt, chatTs, userMemberProfileSentAt) :. businessRow :. (uiThemes, customData, chatItemTTL, membersRequireAttention) :. userMemberRow) = let membership = (toGroupMember userContactId userMemberRow) {memberChatVRange = vr} chatSettings = ChatSettings {enableNtfs = fromMaybe MFAll enableNtfs_, sendRcpts = unBI <$> sendRcpts, favorite} fullGroupPreferences = mergeGroupPreferences groupPreferences groupProfile = GroupProfile {displayName, fullName, description, image, groupPreferences, memberAdmission} businessChat = toBusinessChatInfo businessRow - in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData} + in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData, membersRequireAttention} toGroupMember :: Int64 -> GroupMemberRow -> GroupMember toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, BI showMessages, memberRestriction_) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, preferences) :. (createdAt, updatedAt) :. (supportChatTs_, supportChatUnread, supportChatMemberAttention, supportChatMentions)) = @@ -622,7 +622,8 @@ groupInfoQuery = -- GroupInfo g.group_id, g.local_display_name, gp.display_name, gp.full_name, g.local_alias, gp.description, gp.image, g.enable_ntfs, g.send_rcpts, g.favorite, gp.preferences, gp.member_admission, - g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, g.ui_themes, g.custom_data, g.chat_item_ttl, + g.created_at, g.updated_at, g.chat_ts, g.user_member_profile_sent_at, g.business_chat, g.business_member_id, g.customer_member_id, + g.ui_themes, g.custom_data, g.chat_item_ttl, g.members_require_attention, -- GroupMember - membership mu.group_member_id, mu.group_id, mu.member_id, mu.peer_chat_min_version, mu.peer_chat_max_version, mu.member_role, mu.member_category, mu.member_status, mu.show_messages, mu.member_restriction, mu.invited_by, mu.invited_by_group_member_id, mu.local_display_name, mu.contact_id, mu.contact_profile_id, pu.contact_profile_id, diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index da999b96bf..1cd0486aad 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -421,7 +421,8 @@ data GroupInfo = GroupInfo chatTags :: [ChatTagId], chatItemTTL :: Maybe Int64, uiThemes :: Maybe UIThemeEntityOverrides, - customData :: Maybe CustomData + customData :: Maybe CustomData, + membersRequireAttention :: Int } deriving (Eq, Show) @@ -854,6 +855,13 @@ data GroupSupportChat = GroupSupportChat } deriving (Eq, Show) +gmRequiresAttention :: GroupMember -> Bool +gmRequiresAttention m@GroupMember {supportChat} = + memberPending m || maybe False supportChatAttention supportChat + where + supportChatAttention GroupSupportChat {memberAttention, mentions} = + memberAttention > 0 || mentions > 0 + data GroupMemberRef = GroupMemberRef {groupMemberId :: Int64, profile :: Profile} deriving (Eq, Show) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index e6e91c154e..2e08afeaef 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -192,6 +192,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRSentConfirmation u _ -> ttyUser u ["confirmation sent!"] CRSentInvitation u _ customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView CRSentInvitationToContact u _c customUserProfile -> ttyUser u $ viewSentInvitation customUserProfile testView + CRItemsReadForChat u chatId -> ttyUser u ["items read for chat"] CRContactDeleted u c -> ttyUser u [ttyContact' c <> ": contact is deleted"] CRChatCleared u chatInfo -> ttyUser u $ viewChatCleared chatInfo CRAcceptingContactRequest u c -> ttyUser u $ viewAcceptingContactRequest c diff --git a/tests/ChatTests/Direct.hs b/tests/ChatTests/Direct.hs index 817e16113f..21ee1e9218 100644 --- a/tests/ChatTests/Direct.hs +++ b/tests/ChatTests/Direct.hs @@ -372,7 +372,7 @@ testMarkReadDirect = testChat2 aliceProfile bobProfile $ \alice bob -> do bob ##> "/last_item_id" i :: ChatItemId <- read <$> getTermLine bob let itemIds = intercalate "," $ map show [i - 3 .. i] - bob #$> ("/_read chat items @2 " <> itemIds, id, "ok") + bob #$> ("/_read chat items @2 " <> itemIds, id, "items read for chat") testChatPaginationInitial :: HasCallStack => TestParams -> IO () testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do @@ -391,7 +391,7 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice -- Read next 2 items let itemIds = intercalate "," $ map itemId [1 .. 2] - bob #$> ("/_read chat items @2 " <> itemIds, id, "ok") + bob #$> ("/_read chat items @2 " <> itemIds, id, "items read for chat") bob #$> ("/_get chat @2 initial=2", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")]) -- Read all items diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 49acf71ecb..687a7fde81 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -371,7 +371,7 @@ testMarkReadGroup = testChat2 aliceProfile bobProfile $ \alice bob -> do bob ##> "/last_item_id" i :: ChatItemId <- read <$> getTermLine bob let itemIds = intercalate "," $ map show [i - 3 .. i] - bob #$> ("/_read chat items #1 " <> itemIds, id, "ok") + bob #$> ("/_read chat items #1 " <> itemIds, id, "items read for chat") testChatPaginationInitial :: HasCallStack => TestParams -> IO () testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do @@ -392,7 +392,7 @@ testChatPaginationInitial = testChatOpts2 opts aliceProfile bobProfile $ \alice -- Read next 2 items let itemIds = intercalate "," $ map groupItemId [1 .. 2] - bob #$> ("/_read chat items #1 " <> itemIds, id, "ok") + bob #$> ("/_read chat items #1 " <> itemIds, id, "items read for chat") bob #$> ("/_get chat #1 initial=2", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")]) -- Read all items @@ -7072,12 +7072,12 @@ testScopedSupportUnreadStats = bob ##> "/member support chats #team" bob <## "support: unread: 3, require attention: 0, mentions: 0" - alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByDanItemId, id, "ok") + alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByDanItemId, id, "items read for chat") alice ##> "/member support chats #team" alice <## "bob (Bob) (id 2): unread: 4, require attention: 2, mentions: 1" - alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByBobItemId, id, "ok") + alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByBobItemId, id, "items read for chat") alice ##> "/member support chats #team" alice <## "bob (Bob) (id 2): unread: 3, require attention: 1, mentions: 0"