diff --git a/apps/ios/Shared/Model/AppAPITypes.swift b/apps/ios/Shared/Model/AppAPITypes.swift index d37d64646d..d8347f156b 100644 --- a/apps/ios/Shared/Model/AppAPITypes.swift +++ b/apps/ios/Shared/Model/AppAPITypes.swift @@ -912,6 +912,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case leftMemberUser(user: UserRef, groupInfo: GroupInfo) case groupMembers(user: UserRef, group: SimpleXChat.Group) case memberAccepted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) + case memberSupportChatRead(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case memberSupportChatDeleted(user: UserRef, groupInfo: GroupInfo, member: GroupMember) case membersRoleUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], toRole: GroupMemberRole) case membersBlockedForAllUser(user: UserRef, groupInfo: GroupInfo, members: [GroupMember], blocked: Bool) @@ -961,6 +962,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case .leftMemberUser: "leftMemberUser" case .groupMembers: "groupMembers" case .memberAccepted: "memberAccepted" + case .memberSupportChatRead: "memberSupportChatRead" case .memberSupportChatDeleted: "memberSupportChatDeleted" case .membersRoleUser: "membersRoleUser" case .membersBlockedForAllUser: "membersBlockedForAllUser" @@ -1006,6 +1008,7 @@ enum ChatResponse2: Decodable, ChatAPIResult { case let .leftMemberUser(u, groupInfo): return withUser(u, String(describing: groupInfo)) case let .groupMembers(u, group): return withUser(u, String(describing: group)) case let .memberAccepted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") + case let .memberSupportChatRead(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .memberSupportChatDeleted(u, groupInfo, member): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(member)") case let .membersRoleUser(u, groupInfo, members, toRole): return withUser(u, "groupInfo: \(groupInfo)\nmembers: \(members)\ntoRole: \(toRole)") case let .membersBlockedForAllUser(u, groupInfo, members, blocked): return withUser(u, "groupInfo: \(groupInfo)\nmember: \(members)\nblocked: \(blocked)") diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index ae9f21e34b..6aff1e9c39 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1391,8 +1391,14 @@ func apiRejectContactRequest(contactReqId: Int64) async throws -> Contact? { throw r.unexpected } -func apiChatRead(type: ChatType, id: Int64, scope: GroupChatScope?) async throws { - try await sendCommandOkResp(.apiChatRead(type: type, id: id, scope: scope)) +func apiChatRead(type: ChatType, id: Int64) async throws { + try await sendCommandOkResp(.apiChatRead(type: type, id: id, scope: nil)) +} + +func apiSupportChatRead(type: ChatType, id: Int64, scope: GroupChatScope) async throws -> (GroupInfo, GroupMember) { + let r: ChatResponse2 = try await chatSendCmd(.apiChatRead(type: type, id: id, scope: scope)) + if case let .memberSupportChatRead(_, groupInfo, member) = r { return (groupInfo, member) } + throw r.unexpected } func apiChatItemsRead(type: ChatType, id: Int64, scope: GroupChatScope?, itemIds: [Int64]) async throws -> ChatInfo { @@ -1729,7 +1735,7 @@ func markChatRead(_ im: ItemsModel, _ chat: Chat) async { do { if chat.chatStats.unreadCount > 0 { let cInfo = chat.chatInfo - try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope()) + try await apiChatRead(type: cInfo.chatType, id: cInfo.apiId) await MainActor.run { withAnimation { ChatModel.shared.markAllChatItemsRead(im, cInfo) } } @@ -1754,6 +1760,20 @@ func markChatUnread(_ chat: Chat, unreadChat: Bool = true) async { } } +func markSupportChatRead(_ groupInfo: GroupInfo, _ member: GroupMember) async { + do { + if member.supportChatNotRead { + let (updatedGroupInfo, updatedMember) = try await apiSupportChatRead(type: .group, id: groupInfo.apiId, scope: .memberSupport(groupMemberId_: member.groupMemberId)) + await MainActor.run { + _ = ChatModel.shared.upsertGroupMember(updatedGroupInfo, updatedMember) + ChatModel.shared.updateGroup(updatedGroupInfo) + } + } + } catch { + logger.error("markSupportChatRead apiChatRead error: \(responseError(error))") + } +} + func apiMarkChatItemsRead(_ im: ItemsModel, _ cInfo: ChatInfo, _ itemIds: [ChatItem.ID], mentionsRead: Int) async { do { let updatedChatInfo = try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemIds: itemIds) diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index 7f3672ea17..2101202ed8 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -92,6 +92,16 @@ struct MemberSupportView: View { .frame(width: 1, height: 1) .hidden() } + .if(!memberWithChat.wrapped.memberPending && memberWithChat.wrapped.supportChatNotRead) { v in + v.swipeActions(edge: .leading, allowsFullSwipe: true) { + Button { + Task { await markSupportChatRead(groupInfo, memberWithChat.wrapped) } + } label: { + Label("Read", systemImage: "checkmark") + } + .tint(theme.colors.primary) + } + } .swipeActions(edge: .trailing, allowsFullSwipe: true) { if memberWithChat.wrapped.memberPending { Button { diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 2e2f41ec99..9695e8e911 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -2661,6 +2661,15 @@ public struct GroupMember: Identifiable, Decodable, Hashable { memberRole >= .moderator && versionRange.maxVersion >= REPORTS_VERSION } + public var supportChatNotRead: Bool { + if let supportChat = supportChat, + supportChat.memberAttention > 0 || supportChat.mentions > 0 || supportChat.unread > 0 { + true + } else { + false + } + } + public var versionRange: VersionRange { if let activeConn { activeConn.peerChatVRange 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 7bf849f8f7..628e768d57 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 @@ -2355,6 +2355,12 @@ data class GroupMember ( && !memberPending } + val supportChatNotRead: Boolean get() = + if (supportChat != null) + supportChat.memberAttention > 0 || supportChat.mentions > 0 || supportChat.unread > 0 + else + false + val versionRange: VersionRange = activeConn?.peerChatVRange ?: memberChatVRange val memberIncognito = memberProfile.profileId != memberContactProfileId 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 9b60a968b7..17ebaa6c96 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 @@ -1849,13 +1849,20 @@ object ChatController { return null } - suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope?): Boolean { - val r = sendCmd(rh, CC.ApiChatRead(type, id, scope)) + suspend fun apiChatRead(rh: Long?, type: ChatType, id: Long): Boolean { + val r = sendCmd(rh, CC.ApiChatRead(type, id, scope = null)) if (r.result is CR.CmdOk) return true Log.e(TAG, "apiChatRead bad response: ${r.responseType} ${r.details}") return false } + suspend fun apiSupportChatRead(rh: Long?, type: ChatType, id: Long, scope: GroupChatScope): Pair? { + val r = sendCmd(rh, CC.ApiChatRead(type, id, scope)) + if (r is API.Result && r.res is CR.MemberSupportChatRead) return r.res.groupInfo to r.res.member + apiErrorAlert("apiSupportChatRead", generalGetString(MR.strings.error_marking_member_support_chat_read), r) + return null + } + 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 is API.Result && r.res is CR.ItemsReadForChat) return r.res.chatInfo @@ -6211,6 +6218,7 @@ sealed class CR { @Serializable @SerialName("groupDeletedUser") class GroupDeletedUser(val user: UserRef, val groupInfo: GroupInfo): CR() @Serializable @SerialName("joinedGroupMemberConnecting") class JoinedGroupMemberConnecting(val user: UserRef, val groupInfo: GroupInfo, val hostMember: GroupMember, val member: GroupMember): CR() @Serializable @SerialName("memberAccepted") class MemberAccepted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() + @Serializable @SerialName("memberSupportChatRead") class MemberSupportChatRead(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("memberSupportChatDeleted") class MemberSupportChatDeleted(val user: UserRef, val groupInfo: GroupInfo, val member: GroupMember): CR() @Serializable @SerialName("memberAcceptedByOther") class MemberAcceptedByOther(val user: UserRef, val groupInfo: GroupInfo, val acceptingMember: GroupMember, val member: GroupMember): CR() @Serializable @SerialName("memberRole") class MemberRole(val user: UserRef, val groupInfo: GroupInfo, val byMember: GroupMember, val member: GroupMember, val fromRole: GroupMemberRole, val toRole: GroupMemberRole): CR() @@ -6395,6 +6403,7 @@ sealed class CR { is GroupDeletedUser -> "groupDeletedUser" is JoinedGroupMemberConnecting -> "joinedGroupMemberConnecting" is MemberAccepted -> "memberAccepted" + is MemberSupportChatRead -> "memberSupportChatRead" is MemberSupportChatDeleted -> "memberSupportChatDeleted" is MemberAcceptedByOther -> "memberAcceptedByOther" is MemberRole -> "memberRole" @@ -6572,6 +6581,7 @@ sealed class CR { is GroupDeletedUser -> withUser(user, json.encodeToString(groupInfo)) is JoinedGroupMemberConnecting -> withUser(user, "groupInfo: $groupInfo\nhostMember: $hostMember\nmember: $member") is MemberAccepted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") + is MemberSupportChatRead -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is MemberSupportChatDeleted -> withUser(user, "groupInfo: $groupInfo\nmember: $member") is MemberAcceptedByOther -> withUser(user, "groupInfo: $groupInfo\nacceptingMember: $acceptingMember\nmember: $member") is MemberRole -> withUser(user, "groupInfo: $groupInfo\nbyMember: $byMember\nmember: $member\nfromRole: $fromRole\ntoRole: $toRole") 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 44a625fd9d..b69c98887d 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 @@ -707,8 +707,7 @@ fun ChatView( chatModel.controller.apiChatRead( chatRh, chatInfo.chatType, - chatInfo.apiId, - chatInfo.groupChatScope() + chatInfo.apiId ) } withContext(Dispatchers.Main) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt index 298a545c8c..e696128288 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -270,6 +270,12 @@ private fun DropDownMenuForSupportChat(rhId: Long?, member: GroupMember, groupIn showMenu.value = false }) } else { + if (member.supportChatNotRead) { + ItemAction(stringResource(MR.strings.mark_read), painterResource(MR.images.ic_check), color = MaterialTheme.colors.primary, onClick = { + markSupportChatRead(rhId, groupInfo, member) + showMenu.value = false + }) + } ItemAction(stringResource(MR.strings.delete_member_support_chat_button), painterResource(MR.images.ic_delete), color = MaterialTheme.colors.error, onClick = { deleteMemberSupportChatDialog(rhId, groupInfo, member) showMenu.value = false @@ -300,3 +306,22 @@ private fun deleteMemberSupportChat(rhId: Long?, groupInfo: GroupInfo, member: G } } } + +private fun markSupportChatRead(rhId: Long?, groupInfo: GroupInfo, member: GroupMember) { + withBGApi { + if (member.supportChatNotRead) { + val r = chatModel.controller.apiSupportChatRead( + rh = rhId, + type = ChatType.Group, + id = groupInfo.apiId, + scope = GroupChatScope.MemberSupport(member.groupMemberId) + ) + if (r != null) { + withContext(Dispatchers.Main) { + chatModel.chatsContext.upsertGroupMember(rhId, r.first, r.second) + chatModel.chatsContext.updateGroup(rhId, r.first) + } + } + } + } +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 2128d1991b..4a8e9e5193 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -648,8 +648,7 @@ fun markChatRead(c: Chat) { chatModel.controller.apiChatRead( chat.remoteHostId, chat.chatInfo.chatType, - chat.chatInfo.apiId, - chat.chatInfo.groupChatScope() + chat.chatInfo.apiId ) chat = chatModel.getChat(chat.id) ?: return@withApi } 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 151a2019be..401f69f9c0 100644 --- a/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml +++ b/apps/multiplatform/common/src/commonMain/resources/MR/base/strings.xml @@ -167,6 +167,7 @@ Error adding member(s) Error joining group Error accepting member + Error marking chat with member as read Error deleting chat with member Cannot receive file Sender cancelled file transfer. diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 1c2e4baec3..8d5cb9f348 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -162,6 +162,7 @@ undocumentedResponses = "CRGroupUserChanged", "CRItemsReadForChat", "CRJoinedGroupMember", + "CRMemberSupportChatRead", "CRMemberSupportChatDeleted", "CRMemberSupportChats", "CRNetworkConfig", diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 919274f4d2..d8a6f7a30a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -726,6 +726,7 @@ data ChatResponse | CRNetworkStatuses {user_ :: Maybe User, networkStatuses :: [ConnNetworkStatus]} | CRJoinedGroupMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRMemberAccepted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} + | CRMemberSupportChatRead {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRMemberSupportChatDeleted {user :: User, groupInfo :: GroupInfo, member :: GroupMember} | CRMembersRoleUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], toRole :: GroupMemberRole} | CRMembersBlockedForAllUser {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], blocked :: Bool} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index b6b7dbe87c..e1b0779e1f 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -517,13 +517,36 @@ processChatCommand vr nm = \case pure $ CRApiChat user (AChat SCTDirect directChat) navInfo CTGroup -> do (groupChat, navInfo) <- withFastStore (\db -> getGroupChat db vr user cId scope_ contentFilter pagination search) - pure $ CRApiChat user (AChat SCTGroup groupChat) navInfo + groupChat' <- checkSupportChatAttention user groupChat + pure $ CRApiChat user (AChat SCTGroup groupChat') navInfo CTLocal -> do when (isJust contentFilter) $ throwCmdError "content filter not supported" (localChat, navInfo) <- withFastStore (\db -> getLocalChat db user cId pagination search) pure $ CRApiChat user (AChat SCTLocal localChat) navInfo CTContactRequest -> throwCmdError "not implemented" CTContactConnection -> throwCmdError "not supported" + where + checkSupportChatAttention :: User -> Chat 'CTGroup -> CM (Chat 'CTGroup) + checkSupportChatAttention user groupChat@Chat {chatInfo, chatItems} = + case chatInfo of + GroupChat gInfo (Just GCSIMemberSupport {groupMember_ = Just scopeMem@GroupMember {supportChat = Just suppChat}}) -> do + case correctedMemAttention (groupMemberId' scopeMem) suppChat chatItems of + Just newMemAttention -> do + (gInfo', scopeMem') <- + withFastStore' $ \db -> setSupportChatMemberAttention db vr user gInfo scopeMem newMemAttention + pure $ groupChat {chatInfo = GroupChat gInfo' (Just $ GCSIMemberSupport (Just scopeMem'))} + Nothing -> pure groupChat + _ -> pure groupChat + where + correctedMemAttention :: GroupMemberId -> GroupSupportChat -> [CChatItem 'CTGroup] -> Maybe Int64 + correctedMemAttention scopeGMId GroupSupportChat {memberAttention} items = + let numNewFromMember = fromIntegral . length . takeWhile newFromMember $ reverse items + in if numNewFromMember == memberAttention then Nothing else Just numNewFromMember + where + newFromMember :: CChatItem 'CTGroup -> Bool + newFromMember (CChatItem _ ChatItem {chatDir = CIGroupRcv m, meta = CIMeta {itemStatus = CISRcvNew}}) = + groupMemberId' m == scopeGMId + newFromMember _ = False APIGetChatItems pagination search -> withUser $ \user -> do chatItems <- withFastStore $ \db -> getAllChatItems db vr user pagination search pure $ CRChatItems user Nothing chatItems @@ -998,7 +1021,7 @@ processChatCommand vr nm = \case pure $ prefix <> formattedDate <> ext APIUserRead userId -> withUserId userId $ \user -> withFastStore' (`setUserChatsRead` user) >> ok user UserRead -> withUser $ \User {userId} -> processChatCommand vr nm $ APIUserRead userId - APIChatRead chatRef@(ChatRef cType chatId scope) -> withUser $ \_ -> case cType of + APIChatRead chatRef@(ChatRef cType chatId scope_) -> withUser $ \_ -> case cType of CTDirect -> do user <- withFastStore $ \db -> getUserByContactId db chatId ts <- liftIO getCurrentTime @@ -1014,12 +1037,23 @@ processChatCommand vr nm = \case gInfo <- getGroupInfo db vr user chatId pure (user, gInfo) ts <- liftIO getCurrentTime - timedItems <- withFastStore' $ \db -> do - timedItems <- getGroupUnreadTimedItems db user chatId - updateGroupChatItemsRead db user gInfo scope - setGroupChatItemsDeleteAt db user chatId timedItems ts - forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt - ok user + case scope_ of + Nothing -> do + timedItems <- withFastStore' $ \db -> do + timedItems <- getGroupUnreadTimedItems db user chatId Nothing + updateGroupChatItemsRead db user gInfo + setGroupChatItemsDeleteAt db user chatId timedItems ts + forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt + ok user + Just scope -> do + scopeInfo <- getChatScopeInfo vr user scope + (gInfo', m', timedItems) <- withFastStore' $ \db -> do + timedItems <- getGroupUnreadTimedItems db user chatId (Just scope) + (gInfo', m') <- updateSupportChatItemsRead db vr user gInfo scopeInfo + timedItems' <- setGroupChatItemsDeleteAt db user chatId timedItems ts + pure (gInfo', m', timedItems') + forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt + pure $ CRMemberSupportChatRead user gInfo' m' CTLocal -> do user <- withFastStore $ \db -> getUserByNoteFolderId db chatId withFastStore' $ \db -> updateLocalChatItemsRead db user chatId diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index f7b07daad7..1b218df56d 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -1495,13 +1495,21 @@ decreaseGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> IO decreaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, membersRequireAttention} = do DB.execute db +#if defined(dbPostgres) [sql| UPDATE groups - SET members_require_attention = members_require_attention - 1 + SET members_require_attention = GREATEST(0, members_require_attention - 1) WHERE user_id = ? AND group_id = ? |] +#else + [sql| + UPDATE groups + SET members_require_attention = MAX(0, members_require_attention - 1) + WHERE user_id = ? AND group_id = ? + |] +#endif (userId, groupId) - pure g {membersRequireAttention = membersRequireAttention - 1} + pure g {membersRequireAttention = max 0 (membersRequireAttention - 1)} increaseGroupMembersRequireAttention :: DB.Connection -> User -> GroupInfo -> IO GroupInfo increaseGroupMembersRequireAttention db User {userId} g@GroupInfo {groupId, membersRequireAttention} = do diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index d1c147d43c..c152bdc4bf 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -38,6 +38,7 @@ module Simplex.Chat.Store.Messages MemberAttention (..), updateChatTsStats, setSupportChatTs, + setSupportChatMemberAttention, createNewSndChatItem, createNewRcvChatItem, createNewChatItemNoMsg, @@ -79,6 +80,7 @@ module Simplex.Chat.Store.Messages setDirectChatItemRead, setDirectChatItemsDeleteAt, updateGroupChatItemsRead, + updateSupportChatItemsRead, getGroupUnreadTimedItems, updateGroupChatItemsReadList, updateGroupScopeUnreadStats, @@ -423,14 +425,23 @@ updateChatTsStats db vr user@User {userId} chatDirection chatTs chatStats_ = cas | not nowRequires && didRequire -> do DB.execute db +#if defined(dbPostgres) [sql| UPDATE groups SET chat_ts = ?, - members_require_attention = members_require_attention - 1 + members_require_attention = GREATEST(0, members_require_attention - 1) WHERE user_id = ? AND group_id = ? |] +#else + [sql| + UPDATE groups + SET chat_ts = ?, + members_require_attention = MAX(0, members_require_attention - 1) + WHERE user_id = ? AND group_id = ? + |] +#endif (chatTs, userId, groupId) - pure $ GroupChat g {membersRequireAttention = membersRequireAttention - 1, chatTs = Just chatTs} (Just $ GCSIMemberSupport (Just member')) + pure $ GroupChat g {membersRequireAttention = max 0 (membersRequireAttention - 1), chatTs = Just chatTs} (Just $ GCSIMemberSupport (Just member')) | otherwise -> do DB.execute db @@ -496,6 +507,21 @@ setSupportChatTs :: DB.Connection -> GroupMemberId -> UTCTime -> IO () setSupportChatTs db groupMemberId chatTs = DB.execute db "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" (chatTs, groupMemberId) +setSupportChatMemberAttention :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupMember -> Int64 -> IO (GroupInfo, GroupMember) +setSupportChatMemberAttention db vr user g m memberAttention = do + m' <- updateGMAttention m + g' <- updateGroupMembersRequireAttention db user g m m' + pure (g', m') + where + updateGMAttention m@GroupMember {groupMemberId} = do + currentTs <- getCurrentTime + DB.execute + db + "UPDATE group_members SET support_chat_items_member_attention = ?, updated_at = ? WHERE group_member_id = ?" + (memberAttention, currentTs, groupMemberId' m) + m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId + pure $ either (const m) id m_ -- Left shouldn't happen, but types require it + createNewSndChatItem :: DB.Connection -> User -> ChatDirection c 'MDSnd -> SndMessage -> CIContent 'MDSnd -> Maybe (CIQuote c) -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> UTCTime -> IO ChatItemId createNewSndChatItem db user chatDirection SndMessage {msgId, sharedMsgId} ciContent quotedItem itemForwarded timed live createdAt = createNewChatItem_ db user chatDirection False createdByMsgId (Just sharedMsgId) ciContent quoteRow itemForwarded timed live False createdAt Nothing createdAt @@ -2010,20 +2036,46 @@ setDirectChatItemsDeleteAt db User {userId} contactId itemIds currentTs = forM i (deleteAt, userId, contactId, chatItemId) pure (chatItemId, deleteAt) -updateGroupChatItemsRead :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScope -> IO () -updateGroupChatItemsRead db User {userId} GroupInfo {groupId, membership} scope = do +updateGroupChatItemsRead :: DB.Connection -> User -> GroupInfo -> IO () +updateGroupChatItemsRead db User {userId} GroupInfo {groupId, membership} = do currentTs <- getCurrentTime DB.execute db [sql| UPDATE chat_items SET item_status = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? AND item_status = ? + WHERE user_id = ? AND group_id = ? + AND item_status = ? |] (CISRcvRead, currentTs, userId, groupId, CISRcvNew) - case scope of - Nothing -> pure () - Just GCSMemberSupport {groupMemberId_} -> do - let gmId = fromMaybe (groupMemberId' membership) groupMemberId_ + +updateSupportChatItemsRead :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScopeInfo -> IO (GroupInfo, GroupMember) +updateSupportChatItemsRead db vr user@User {userId} g@GroupInfo {groupId, membership} scopeInfo = do + currentTs <- getCurrentTime + case scopeInfo of + GCSIMemberSupport {groupMember_} -> do + DB.execute + db + [sql| + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? + AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ? + AND item_status = ? + |] + (CISRcvRead, currentTs, userId, groupId, GCSTMemberSupport_, groupMemberId' <$> groupMember_, CISRcvNew) + case groupMember_ of + Nothing -> do + membership' <- updateGMStats membership + pure (g {membership = membership'}, membership') + Just member -> do + member' <- updateGMStats member + let didRequire = gmRequiresAttention member + nowRequires = gmRequiresAttention member' + if (not nowRequires && didRequire) + then (,member') <$> decreaseGroupMembersRequireAttention db user g + else pure (g, member') + where + updateGMStats m@GroupMember {groupMemberId} = do + currentTs <- getCurrentTime DB.execute db [sql| @@ -2033,18 +2085,34 @@ updateGroupChatItemsRead db User {userId} GroupInfo {groupId, membership} scope support_chat_items_mentions = 0 WHERE group_member_id = ? |] - (Only gmId) + (Only groupMemberId) + m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId + pure $ either (const m) id m_ -- Left shouldn't happen, but types require it -getGroupUnreadTimedItems :: DB.Connection -> User -> GroupId -> IO [(ChatItemId, Int)] -getGroupUnreadTimedItems db User {userId} groupId = - DB.query - db - [sql| - SELECT chat_item_id, timed_ttl - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL - |] - (userId, groupId, CISRcvNew) +getGroupUnreadTimedItems :: DB.Connection -> User -> GroupId -> Maybe GroupChatScope -> IO [(ChatItemId, Int)] +getGroupUnreadTimedItems db User {userId} groupId scope = + case scope of + Nothing -> + DB.query + db + [sql| + SELECT chat_item_id, timed_ttl + FROM chat_items + WHERE user_id = ? AND group_id = ? + AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + |] + (userId, groupId, CISRcvNew) + Just GCSMemberSupport {groupMemberId_} -> + DB.query + db + [sql| + SELECT chat_item_id, timed_ttl + FROM chat_items + WHERE user_id = ? AND group_id = ? + AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ? + AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + |] + (userId, groupId, GCSTMemberSupport_, groupMemberId_, CISRcvNew) updateGroupChatItemsReadList :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> NonEmpty ChatItemId -> ExceptT StoreError IO ([(ChatItemId, Int)], GroupInfo) updateGroupChatItemsReadList db vr user@User {userId} g@GroupInfo {groupId} scopeInfo_ itemIds = do @@ -2110,14 +2178,25 @@ updateGroupScopeUnreadStats db vr user g@GroupInfo {membership} scopeInfo (unrea currentTs <- getCurrentTime DB.execute db +#if defined(dbPostgres) [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 - ?, + SET support_chat_items_unread = GREATEST(0, support_chat_items_unread - ?), + support_chat_items_member_attention = GREATEST(0, support_chat_items_member_attention - ?), + support_chat_items_mentions = GREATEST(0, support_chat_items_mentions - ?), updated_at = ? WHERE group_member_id = ? |] +#else + [sql| + UPDATE group_members + SET support_chat_items_unread = MAX(0, support_chat_items_unread - ?), + support_chat_items_member_attention = MAX(0, support_chat_items_member_attention - ?), + support_chat_items_mentions = MAX(0, support_chat_items_mentions - ?), + updated_at = ? + WHERE group_member_id = ? + |] +#endif (unread, unanswered, mentions, currentTs, groupMemberId) m_ <- runExceptT $ getGroupMemberById db vr user groupMemberId pure $ either (const m) id m_ -- Left shouldn't happen, but types require it 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 7eacc2337b..5a76567db0 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -25,7 +25,7 @@ SEARCH contact_requests USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE groups SET chat_ts = ?, - members_require_attention = members_require_attention + 1 + members_require_attention = MAX(0, members_require_attention - 1) WHERE user_id = ? AND group_id = ? Plan: @@ -34,7 +34,7 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE groups SET chat_ts = ?, - members_require_attention = members_require_attention - 1 + members_require_attention = members_require_attention + 1 WHERE user_id = ? AND group_id = ? Plan: @@ -1189,6 +1189,25 @@ Plan: SEARCH chat_items USING INDEX idx_chat_items_group_scope_item_ts (user_id=?) USE TEMP B-TREE FOR ORDER BY +Query: + SELECT chat_item_id, timed_ttl + FROM chat_items + WHERE user_id = ? AND group_id = ? + AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ? + AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?) + +Query: + SELECT chat_item_id, timed_ttl + FROM chat_items + WHERE user_id = ? AND group_id = ? + AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) + Query: SELECT chat_item_moderation_id, moderator_member_id, created_by_msg_id, moderated_at FROM chat_item_moderations @@ -1431,6 +1450,15 @@ SEARCH r USING INDEX idx_received_probes_user_id (user_id=?) SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN +Query: + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? + AND group_scope_tag = ? AND group_scope_group_member_id IS NOT DISTINCT FROM ? + AND item_status = ? + +Plan: +SEARCH chat_items USING INDEX idx_chat_items_group_scope_stats_all (user_id=? AND group_id=? AND group_scope_tag=? AND group_scope_group_member_id=? AND item_status=?) + Query: UPDATE connections SET via_contact_uri = NULL, via_contact_uri_hash = NULL, xcontact_id = NULL WHERE user_id = ? AND via_group_link = 1 AND contact_id IN ( @@ -1482,9 +1510,9 @@ SEARCH group_members 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 - ?, + SET support_chat_items_unread = MAX(0, support_chat_items_unread - ?), + support_chat_items_member_attention = MAX(0, support_chat_items_member_attention - ?), + support_chat_items_mentions = MAX(0, support_chat_items_mentions - ?), updated_at = ? WHERE group_member_id = ? @@ -4267,14 +4295,6 @@ Query: Plan: SEARCH chat_items USING INDEX idx_chat_items_contacts (user_id=? AND contact_id=? AND item_status=?) -Query: - SELECT chat_item_id, timed_ttl - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_status = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL - -Plan: -SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) - Query: SELECT group_snd_item_status, COUNT(1) FROM group_snd_item_statuses @@ -4361,7 +4381,8 @@ SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE chat_items SET item_status = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? AND item_status = ? + WHERE user_id = ? AND group_id = ? + AND item_status = ? Plan: SEARCH chat_items USING INDEX idx_chat_items_groups_user_mention (user_id=? AND group_id=? AND item_status=?) @@ -4623,7 +4644,7 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE groups - SET members_require_attention = members_require_attention + 1 + SET members_require_attention = MAX(0, members_require_attention - 1) WHERE user_id = ? AND group_id = ? Plan: @@ -4631,7 +4652,7 @@ SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) Query: UPDATE groups - SET members_require_attention = members_require_attention - 1 + SET members_require_attention = members_require_attention + 1 WHERE user_id = ? AND group_id = ? Plan: @@ -6277,6 +6298,14 @@ Query: UPDATE group_members SET member_role = ? WHERE user_id = ? AND group_memb Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE group_members SET support_chat_items_member_attention = ?, updated_at = ? WHERE group_member_id = ? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + +Query: UPDATE group_members SET support_chat_items_member_attention=100 WHERE group_member_id=? +Plan: +SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) @@ -6317,6 +6346,10 @@ Query: UPDATE groups SET local_display_name = ?, updated_at = ? WHERE user_id = Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: UPDATE groups SET members_require_attention=1 WHERE group_id=? +Plan: +SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE groups SET request_shared_msg_id = ? WHERE group_id = ? Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 6fef6cc204..f31daf233c 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -231,6 +231,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, testView} liveIte CRNetworkStatuses u statuses -> if testView then ttyUser' u $ viewNetworkStatuses statuses else [] CRJoinedGroupMember u g m -> ttyUser u $ viewJoinedGroupMember g m CRMemberAccepted u g m -> ttyUser u $ viewMemberAccepted g m + CRMemberSupportChatRead u g m -> ttyUser u $ viewSupportChatRead g m CRMemberSupportChatDeleted u g m -> ttyUser u [ttyGroup' g <> ": " <> ttyMember m <> " support chat deleted"] CRMembersRoleUser u g members r' -> ttyUser u $ viewMemberRoleUserChanged g members r' CRMembersBlockedForAllUser u g members blocked -> ttyUser u $ viewMembersBlockedForAllUser g members blocked @@ -1229,6 +1230,11 @@ viewMemberAccepted g m@GroupMember {memberStatus} = case memberStatus of GSMemPendingReview -> [ttyGroup' g <> ": " <> ttyMember m <> " accepted and pending review (will introduce moderators)"] _ -> [ttyGroup' g <> ": " <> ttyMember m <> " accepted"] +viewSupportChatRead :: GroupInfo -> GroupMember -> [StyledString] +viewSupportChatRead g@GroupInfo {membership = GroupMember {groupMemberId = membershipId}} m + | groupMemberId' m == membershipId = [ttyGroup' g <> ": support chat read"] + | otherwise = [ttyGroup' g <> ": " <> ttyMember m <> " support chat read"] + viewMemberAcceptedByOther :: GroupInfo -> GroupMember -> GroupMember -> [StyledString] viewMemberAcceptedByOther g acceptingMember m@GroupMember {memberCategory, memberStatus} = case memberCategory of GCUserMember -> case memberStatus of @@ -1324,8 +1330,12 @@ viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filt | otherwise = [] viewMemberSupportChats :: GroupInfo -> [GroupMember] -> [StyledString] -viewMemberSupportChats GroupInfo {membership} ms = support <> map groupMember ms +viewMemberSupportChats GroupInfo {membership = membership@GroupMember {memberRole = membershipRole}, membersRequireAttention} ms = + memsAttention <> support <> map groupMember ms where + memsAttention + | membershipRole >= GRModerator = ["members require attention: " <> sShow membersRequireAttention] + | otherwise = [] support = case supportChat membership of Just sc -> ["support: " <> chatStats sc] Nothing -> [] diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 365bd16f71..7f8761c1e1 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -18,6 +18,7 @@ import Control.Concurrent.Async (concurrently_) import Control.Monad (forM_, void, when) import Data.Bifunctor (second) import qualified Data.ByteString.Char8 as B +import Data.Int (Int64) import Data.List (intercalate, isInfixOf) import qualified Data.Map.Strict as M import qualified Data.Text as T @@ -219,6 +220,7 @@ chatGroupTests = do it "should send messages to admins and members" testSupportCLISendCommand it "should correctly maintain unread stats for support chats on reading chat items" testScopedSupportUnreadStatsOnRead it "should correctly maintain unread stats for support chats on deleting chat items" testScopedSupportUnreadStatsOnDelete + it "should correct member attention stat for support chat on opening it" testScopedSupportUnreadStatsCorrectOnOpen testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = @@ -7432,8 +7434,10 @@ testScopedSupportManyModerators = cath #$> ("/_get chat #1(_support:3) count=100", chat, []) alice ##> "/member support chats #team" + alice <## "members require attention: 0" alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" dan ##> "/member support chats #team" + dan <## "members require attention: 0" dan <## "bob (Bob) (id 3): unread: 0, require attention: 0, mentions: 0" bob ##> "/member support chats #team" bob <## "support: unread: 0, require attention: 0, mentions: 0" @@ -7888,8 +7892,10 @@ testScopedSupportUnreadStatsOnRead = dan <# "#team (support: bob) alice> 3" alice ##> "/member support chats #team" + alice <## "members require attention: 0" alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" dan ##> "/member support chats #team" + dan <## "members require attention: 0" dan <## "bob (Bob) (id 3): unread: 1, require attention: 0, mentions: 0" bob ##> "/member support chats #team" bob <## "support: unread: 1, require attention: 0, mentions: 0" @@ -7899,8 +7905,10 @@ testScopedSupportUnreadStatsOnRead = [alice, dan] *<# "#team (support: bob) bob> 4" alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 1, require attention: 1, mentions: 0" dan ##> "/member support chats #team" + dan <## "members require attention: 1" dan <## "bob (Bob) (id 3): unread: 2, require attention: 1, mentions: 0" bob ##> "/member support chats #team" bob <## "support: unread: 1, require attention: 0, mentions: 0" @@ -7913,9 +7921,11 @@ testScopedSupportUnreadStatsOnRead = bob <# "#team (support) dan> 5" alice ##> "/member support chats #team" + alice <## "members require attention: 0" alice <## "bob (Bob) (id 2): unread: 2, require attention: 0, mentions: 0" -- In test "answering" doesn't reset unanswered, but in UI items would be marked read on opening chat dan ##> "/member support chats #team" + dan <## "members require attention: 1" dan <## "bob (Bob) (id 3): unread: 2, require attention: 1, mentions: 0" bob ##> "/member support chats #team" bob <## "support: unread: 2, require attention: 0, mentions: 0" @@ -7928,8 +7938,10 @@ testScopedSupportUnreadStatsOnRead = bob <# "#team (support) dan> @alice 6" alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 3, require attention: 0, mentions: 1" dan ##> "/member support chats #team" + dan <## "members require attention: 1" dan <## "bob (Bob) (id 3): unread: 2, require attention: 1, mentions: 0" bob ##> "/member support chats #team" bob <## "support: unread: 3, require attention: 0, mentions: 0" @@ -7944,8 +7956,10 @@ testScopedSupportUnreadStatsOnRead = dan <# "#team (support: bob) bob> @alice 7" alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 4, require attention: 1, mentions: 2" dan ##> "/member support chats #team" + dan <## "members require attention: 1" dan <## "bob (Bob) (id 3): unread: 3, require attention: 2, mentions: 0" bob ##> "/member support chats #team" bob <## "support: unread: 3, require attention: 0, mentions: 0" @@ -7958,8 +7972,10 @@ testScopedSupportUnreadStatsOnRead = dan <# "#team (support: bob) bob!> @dan 8" alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 5, require attention: 2, mentions: 2" dan ##> "/member support chats #team" + dan <## "members require attention: 1" dan <## "bob (Bob) (id 3): unread: 4, require attention: 3, mentions: 1" bob ##> "/member support chats #team" bob <## "support: unread: 3, require attention: 0, mentions: 0" @@ -7967,11 +7983,13 @@ testScopedSupportUnreadStatsOnRead = alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByDanItemId, id, "items read for chat") alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 4, require attention: 2, mentions: 1" alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByBobItemId, id, "items read for chat") alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 3, require attention: 1, mentions: 0" threadDelay 1000000 @@ -7982,23 +8000,30 @@ testScopedSupportUnreadStatsOnRead = bob <# "#team (support) dan!> @bob 9" alice ##> "/member support chats #team" + alice <## "members require attention: 0" alice <## "bob (Bob) (id 2): unread: 4, require attention: 0, mentions: 0" dan ##> "/member support chats #team" + dan <## "members require attention: 1" dan <## "bob (Bob) (id 3): unread: 4, require attention: 3, mentions: 1" bob ##> "/member support chats #team" bob <## "support: unread: 4, require attention: 0, mentions: 1" - alice #$> ("/_read chat #1(_support:2)", id, "ok") + alice ##> "/_read chat #1(_support:2)" + alice <## "#team: bob support chat read" alice ##> "/member support chats #team" + alice <## "members require attention: 0" alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" - dan #$> ("/_read chat #1(_support:3)", id, "ok") + dan ##> "/_read chat #1(_support:3)" + dan <## "#team: bob support chat read" dan ##> "/member support chats #team" + dan <## "members require attention: 0" dan <## "bob (Bob) (id 3): unread: 0, require attention: 0, mentions: 0" - bob #$> ("/_read chat #1(_support)", id, "ok") + bob ##> "/_read chat #1(_support)" + bob <## "#team: support chat read" bob ##> "/member support chats #team" bob <## "support: unread: 0, require attention: 0, mentions: 0" @@ -8029,12 +8054,90 @@ testScopedSupportUnreadStatsOnDelete = msgIdBob <- lastItemId bob alice ##> "/member support chats #team" + alice <## "members require attention: 1" alice <## "bob (Bob) (id 2): unread: 1, require attention: 1, mentions: 0" bob #$> ("/_delete item #1(_support) " <> msgIdBob <> " broadcast", id, "message deleted") alice <# "#team (support: bob) bob> [deleted] 1" alice ##> "/member support chats #team" + alice <## "members require attention: 0" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + where + opts = + testOpts + { markRead = False + } + +testScopedSupportUnreadStatsCorrectOnOpen :: HasCallStack => TestParams -> IO () +testScopedSupportUnreadStatsCorrectOnOpen = + testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do + createGroup2 "team" alice bob + + bob #> "#team (support) 1" + alice <# "#team (support: bob) bob> 1" + + bob #> "#team (support) 2" + alice <# "#team (support: bob) bob> 2" + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 2, require attention: 2, mentions: 0" + + alice ##> "/_read chat #1(_support:2)" + alice <## "#team: bob support chat read" + + alice ##> "/member support chats #team" + alice <## "members require attention: 0" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + + bob #> "#team (support) 3" + alice <# "#team (support: bob) bob> 3" + + bob #> "#team (support) 4" + alice <# "#team (support: bob) bob> 4" + + bob #> "#team (support) 5" + alice <# "#team (support: bob) bob> 5" + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 3, require attention: 3, mentions: 0" + + -- opening chat should correct group_members.support_chat_items_member_attention value if it got out of sync + void $ withCCTransaction alice $ \db -> + DB.execute db "UPDATE group_members SET support_chat_items_member_attention=100 WHERE group_member_id=?" (Only (2 :: Int64)) + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 3, require attention: 100, mentions: 0" + + alice #$> ("/_get chat #1(_support:2) count=100", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")]) + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 3, require attention: 3, mentions: 0" + + alice ##> "/_read chat #1(_support:2)" + alice <## "#team: bob support chat read" + + alice ##> "/member support chats #team" + alice <## "members require attention: 0" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + + -- opening chat should also correct groups.members_require_attention value if corrected member no longer requires attention + void $ withCCTransaction alice $ \db -> do + DB.execute db "UPDATE group_members SET support_chat_items_member_attention=100 WHERE group_member_id=?" (Only (2 :: Int64)) + DB.execute db "UPDATE groups SET members_require_attention=1 WHERE group_id=?" (Only (1 :: Int64)) + + alice ##> "/member support chats #team" + alice <## "members require attention: 1" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 100, mentions: 0" + + alice #$> ("/_get chat #1(_support:2) count=100", chat, [(0, "1"), (0, "2"), (0, "3"), (0, "4"), (0, "5")]) + + alice ##> "/member support chats #team" + alice <## "members require attention: 0" alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" where opts =