From d85ac4af04bb30cbdff8e450d5a37cd8755a4b6d Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Sat, 5 Apr 2025 11:25:45 +0000 Subject: [PATCH] core: member support chat stats (#5803) * core: member support chat stats * schema * update counts * mark read wip * dec counts on read * rename * plans * test, fixes * plans * refactor * rename --------- Co-authored-by: Evgeny Poberezkin --- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Library/Commands.hs | 28 +-- src/Simplex/Chat/Library/Internal.hs | 39 +++- src/Simplex/Chat/Library/Subscriber.hs | 4 +- src/Simplex/Chat/Store/Connections.hs | 7 +- src/Simplex/Chat/Store/Groups.hs | 42 ++--- src/Simplex/Chat/Store/Messages.hs | 170 +++++++++++++----- .../Migrations/M20250310_group_scope.hs | 23 +-- .../SQLite/Migrations/chat_query_plans.txt | 149 ++++++++------- .../Store/SQLite/Migrations/chat_schema.sql | 8 +- src/Simplex/Chat/Store/Shared.hs | 27 +-- src/Simplex/Chat/Types.hs | 17 +- src/Simplex/Chat/View.hs | 15 +- tests/ChatClient.hs | 10 +- tests/ChatTests/Groups.hs | 150 +++++++++++++++- 15 files changed, 475 insertions(+), 216 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 1ea2991c24..f3325d261d 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -662,7 +662,6 @@ data ChatResponse | CRWelcome {user :: User} | CRGroupCreated {user :: User, groupInfo :: GroupInfo} | CRGroupMembers {user :: User, group :: Group} - | CRMemberSupportChats {user :: User, groupInfo :: GroupInfo, members :: [GroupMember]} -- | CRGroupConversationsArchived {user :: User, groupInfo :: GroupInfo, archivedGroupConversations :: [GroupConversation]} -- | CRGroupConversationsDeleted {user :: User, groupInfo :: GroupInfo, deletedGroupConversations :: [GroupConversation]} | CRContactsList {user :: User, contacts :: [Contact]} @@ -851,6 +850,7 @@ data ChatResponse data TerminalEvent = TEGroupLinkRejected {user :: User, groupInfo :: GroupInfo, groupRejectionReason :: GroupRejectionReason} | TERejectingGroupJoinRequestMember {user :: User, groupInfo :: GroupInfo, member :: GroupMember, groupRejectionReason :: GroupRejectionReason} + | TEMemberSupportChats {user :: User, groupInfo :: GroupInfo, members :: [GroupMember]} deriving (Show) data DeletedRcvQueue = DeletedRcvQueue diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 1895542bb0..781a46270b 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -989,7 +989,7 @@ processChatCommand' vr = \case pure $ prefix <> formattedDate <> ext APIUserRead userId -> withUserId userId $ \user -> withFastStore' (`setUserChatsRead` user) >> ok user UserRead -> withUser $ \User {userId} -> processChatCommand $ 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 @@ -1000,11 +1000,14 @@ processChatCommand' vr = \case forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt ok user CTGroup -> do - user <- withFastStore $ \db -> getUserByGroupId db chatId + (user, gInfo) <- withFastStore $ \db -> do + user <- getUserByGroupId db chatId + gInfo <- getGroupInfo db vr user chatId + pure (user, gInfo) ts <- liftIO getCurrentTime timedItems <- withFastStore' $ \db -> do timedItems <- getGroupUnreadTimedItems db user chatId - updateGroupChatItemsRead 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 @@ -1014,8 +1017,7 @@ processChatCommand' vr = \case ok user CTContactRequest -> pure $ chatCmdError Nothing "not supported" CTContactConnection -> pure $ chatCmdError Nothing "not supported" - -- TODO [knocking] read scope? - APIChatItemsRead chatRef@(ChatRef cType chatId _scope) itemIds -> withUser $ \_ -> case cType of + APIChatItemsRead chatRef@(ChatRef cType chatId scope) itemIds -> withUser $ \_ -> case cType of CTDirect -> do user <- withFastStore $ \db -> getUserByContactId db chatId timedItems <- withFastStore' $ \db -> do @@ -1024,9 +1026,12 @@ processChatCommand' vr = \case forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt ok user CTGroup -> do - user <- withFastStore $ \db -> getUserByGroupId db chatId + (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 chatId itemIds + timedItems <- updateGroupChatItemsReadList db user gInfo scope itemIds setGroupChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt ok user @@ -2317,11 +2322,10 @@ processChatCommand' vr = \case groupId <- withFastStore $ \db -> getGroupIdByName db user gName processChatCommand $ APIListMembers groupId ListMemberSupportChats gName -> withUser $ \user -> do - gInfo <- withFastStore $ \db -> getGroupInfoByName db vr user gName - -- TODO [knocking] delete all support chats (chat items) if role is lowered? - assertUserGroupRole gInfo GRModerator - supportMems <- withFastStore' $ \db -> getSupportMembers db vr user gInfo - pure $ CRMemberSupportChats user gInfo supportMems + groupId <- withFastStore $ \db -> getGroupIdByName db user gName + (Group gInfo members) <- withFastStore $ \db -> getGroup db vr user groupId + let memberSupportChats = filter (isJust . supportChat) members + pure $ CRTerminalEvent $ TEMemberSupportChats user gInfo memberSupportChats APIListGroups userId contactId_ search_ -> withUserId userId $ \user -> CRGroupsList user <$> withFastStore' (\db -> getUserGroupsWithSummary db vr user contactId_ search_) ListGroups cName_ search_ -> withUser $ \user@User {userId} -> do diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 5ef97442da..e0a9f7d040 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1354,16 +1354,16 @@ mkGetMessageChatScope vr user gInfo@GroupInfo {membership} m msgScope_ = pure (gInfo', m, Just scopeInfo) | otherwise -> do referredMember <- withStore $ \db -> getGroupMemberByMemberId db vr user gInfo mId - -- TODO [knocking] return patched _referredMember too? + -- TODO [knocking] return patched _referredMember' too? (_referredMember', scopeInfo) <- liftIO $ mkMemberSupportChatInfo referredMember pure (gInfo, m, Just scopeInfo) mkGroupSupportChatInfo :: GroupInfo -> IO (GroupInfo, GroupChatScopeInfo) -mkGroupSupportChatInfo gInfo@GroupInfo {modsSupportChat} = - case modsSupportChat of +mkGroupSupportChatInfo gInfo@GroupInfo {membership} = + case supportChat membership of Nothing -> do chatTs <- getCurrentTime - let gInfo' = gInfo {modsSupportChat = Just $ GroupSupportChat chatTs True} + let gInfo' = gInfo {membership = membership {supportChat = Just $ GroupSupportChat chatTs 1 0 0}} scopeInfo = GCSIMemberSupport {groupMember_ = Nothing} pure (gInfo', scopeInfo) Just _supportChat -> @@ -1375,7 +1375,7 @@ mkMemberSupportChatInfo m@GroupMember {supportChat} = case supportChat of Nothing -> do chatTs <- getCurrentTime - let m' = m {supportChat = Just $ GroupSupportChat chatTs True} + let m' = m {supportChat = Just $ GroupSupportChat chatTs 1 0 0} scopeInfo = GCSIMemberSupport {groupMember_ = Just m'} pure (m', scopeInfo) Just _supportChat -> @@ -2008,7 +2008,7 @@ saveSndChatItems :: saveSndChatItems user cd itemsData itemTimed live = do createdAt <- liftIO getCurrentTime when (contactChatDeleted cd || any (\NewSndChatItemData {content} -> ciRequiresAttention content) (rights itemsData)) $ - withStore' (\db -> updateChatTs db user cd createdAt) + withStore' (\db -> updateChatTsStats db 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)) @@ -2034,7 +2034,6 @@ saveRcvChatItem' :: (ChatTypeI c, ChatTypeQuotable c) => User -> ChatDirection c saveRcvChatItem' user cd msg@RcvMessage {chatMsgEvent, forwardedByMember} sharedMsgId_ brokerTs (content, (t, ft_)) ciFile itemTimed live mentions = do createdAt <- liftIO getCurrentTime withStore' $ \db -> do - when (ciRequiresAttention content || contactChatDeleted cd) $ updateChatTs db user cd createdAt (mentions' :: Map MemberName CIMention, userMention) <- case cd of CDGroupRcv g@GroupInfo {membership} _scope _m -> do mentions' <- getRcvCIMentions db user g ft_ mentions @@ -2044,12 +2043,20 @@ 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) (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 CDGroupRcv g _scope _m | not (null mentions') -> createGroupCIMentions db g ci mentions' _ -> pure ci + where + chatStatsCounts :: Bool -> Maybe (Int, MemberAttention, Int) + chatStatsCounts userMention = case cd of + CDGroupRcv _g (Just scope) m -> do + let unread = fromEnum $ ciCreateStatus content == CISRcvNew + in Just (unread, memberAttentionChange unread m scope, fromEnum userMention) + _ -> Nothing -- TODO [mentions] optimize by avoiding unnecessary parsing mkChatItem :: (ChatTypeI c, MsgDirectionI d) => ChatDirection c d -> ChatItemId -> CIContent d -> Maybe (CIFile d) -> Maybe (CIQuote c) -> Maybe SharedMsgId -> Maybe CIForwardedFrom -> Maybe CITimed -> Bool -> Bool -> ChatItemTs -> Maybe GroupMemberId -> UTCTime -> ChatItem c d @@ -2268,14 +2275,28 @@ createInternalItemsForChats user itemTs_ dirsCIContents = do where updateChat :: DB.Connection -> UTCTime -> ChatDirection c d -> [CIContent d] -> IO () updateChat db createdAt cd contents - | any ciRequiresAttention contents || contactChatDeleted cd = updateChatTs db user cd createdAt + | any ciRequiresAttention contents || contactChatDeleted cd = updateChatTsStats db user cd createdAt chatStatsCounts | otherwise = pure () + where + chatStatsCounts :: Maybe (Int, MemberAttention, Int) + chatStatsCounts = case cd of + CDGroupRcv _g (Just scope) m -> do + let unread = length $ filter ciRequiresAttention contents + in Just (unread, memberAttentionChange unread m scope, 0) + _ -> Nothing createACIs :: DB.Connection -> UTCTime -> UTCTime -> ChatDirection c d -> [CIContent d] -> [IO AChatItem] createACIs db itemTs createdAt cd = map $ \content -> do ciId <- createNewChatItemNoMsg db user cd content itemTs createdAt let ci = mkChatItem cd ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing createdAt pure $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci +memberAttentionChange :: Int -> GroupMember -> GroupChatScopeInfo -> MemberAttention +memberAttentionChange unread m = \case + GCSIMemberSupport (Just m') + | groupMemberId' m' == groupMemberId' m -> MAInc unread + | otherwise -> MAReset + GCSIMemberSupport Nothing -> MAInc 0 + createLocalChatItems :: User -> ChatDirection 'CTLocal 'MDSnd -> @@ -2283,7 +2304,7 @@ createLocalChatItems :: UTCTime -> CM [ChatItem 'CTLocal 'MDSnd] createLocalChatItems user cd itemsData createdAt = do - withStore' $ \db -> updateChatTs db user cd createdAt + withStore' $ \db -> updateChatTsStats db user cd createdAt Nothing (errs, items) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (createItem db) $ L.toList itemsData) unless (null errs) $ toView $ CRChatErrors (Just user) errs pure items diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 9a7d6144c4..e02b046a33 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2076,7 +2076,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = xInfoMember gInfo m p' brokerTs = void $ processMemberProfileUpdate gInfo m p' True (Just brokerTs) xGrpLinkMem :: GroupInfo -> GroupMember -> Connection -> Profile -> CM () - xGrpLinkMem gInfo@GroupInfo {membership, businessChat} m@GroupMember {groupMemberId, memberCategory, memberStatus} Connection {viaGroupLink} p' = do + xGrpLinkMem gInfo@GroupInfo {membership, businessChat} m@GroupMember {groupMemberId, memberCategory} Connection {viaGroupLink} p' = do xGrpLinkMemReceived <- withStore $ \db -> getXGrpLinkMemReceived db groupMemberId if (viaGroupLink || isJust businessChat) && isNothing (memberContactId m) && memberCategory == GCHostMember && not xGrpLinkMemReceived then do @@ -2087,7 +2087,7 @@ processAgentMessageConn vr user@User {userId} corrId agentConnId agentMessage = else messageError "x.grp.link.mem error: invalid group link host profile update" xGrpLinkAcpt :: GroupInfo -> GroupMember -> GroupMemberRole -> MemberId -> CM () - xGrpLinkAcpt gInfo@GroupInfo {groupId, membership} m role memberId + xGrpLinkAcpt gInfo@GroupInfo {membership} m role memberId | sameMemberId memberId membership = processUserAccepted | otherwise = withStore' (\db -> runExceptT $ getGroupMemberByMemberId db vr user gInfo memberId) >>= \case diff --git a/src/Simplex/Chat/Store/Connections.hs b/src/Simplex/Chat/Store/Connections.hs index b63ea91126..617fef1759 100644 --- a/src/Simplex/Chat/Store/Connections.hs +++ b/src/Simplex/Chat/Store/Connections.hs @@ -136,17 +136,18 @@ getConnectionEntity db vr user@User {userId, userContactId} agentConnId = do 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.mods_support_chat_ts, g.mods_support_chat_unanswered, -- 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, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, -- from GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id diff --git a/src/Simplex/Chat/Store/Groups.hs b/src/Simplex/Chat/Store/Groups.hs index 054fc24519..e2045165d7 100644 --- a/src/Simplex/Chat/Store/Groups.hs +++ b/src/Simplex/Chat/Store/Groups.hs @@ -54,7 +54,6 @@ module Simplex.Chat.Store.Groups getGroupMemberByMemberId, getGroupMembers, getGroupModerators, - getSupportMembers, getGroupMembersForExpiration, getGroupCurrentMembersCount, deleteGroupChatItems, @@ -177,11 +176,11 @@ import Database.SQLite.Simple (Only (..), Query, (:.) (..)) import Database.SQLite.Simple.QQ (sql) #endif -type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe BoolInt) +type MaybeGroupMemberRow = (Maybe Int64, Maybe Int64, Maybe MemberId, Maybe VersionChat, Maybe VersionChat, Maybe GroupMemberRole, Maybe GroupMemberCategory, Maybe GroupMemberStatus, Maybe BoolInt, Maybe MemberRestrictionStatus) :. (Maybe Int64, Maybe GroupMemberId, Maybe ContactName, Maybe ContactId, Maybe ProfileId, Maybe ProfileId, Maybe ContactName, Maybe Text, Maybe ImageData, Maybe ConnReqContact, Maybe LocalAlias, Maybe Preferences) :. (Maybe UTCTime, Maybe UTCTime) :. (Maybe UTCTime, Maybe Int64, Maybe Int64, Maybe Int64) toMaybeGroupMember :: Int64 -> MaybeGroupMemberRow -> Maybe GroupMember -toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId, Just profileId, Just displayName, Just fullName, image, contactLink, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, supportChatUnanswered)) = - Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnanswered)) +toMaybeGroupMember userContactId ((Just groupMemberId, Just groupId, Just memberId, Just minVer, Just maxVer, Just memberRole, Just memberCategory, Just memberStatus, Just showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, Just localDisplayName, memberContactId, Just memberContactProfileId, Just profileId, Just displayName, Just fullName, image, contactLink, Just localAlias, contactPreferences) :. (Just createdAt, Just updatedAt) :. (supportChatTs, Just supportChatUnread, Just supportChatUnanswered, Just supportChatMentions)) = + Just $ toGroupMember userContactId ((groupMemberId, groupId, memberId, minVer, maxVer, memberRole, memberCategory, memberStatus, showMessages, memberBlocked) :. (invitedById, invitedByGroupMemberId, localDisplayName, memberContactId, memberContactProfileId, profileId, displayName, fullName, image, contactLink, localAlias, contactPreferences) :. (createdAt, updatedAt) :. (supportChatTs, supportChatUnread, supportChatUnanswered, supportChatMentions)) toMaybeGroupMember _ _ = Nothing createGroupLink :: DB.Connection -> User -> GroupInfo -> ConnId -> ConnReqContact -> GroupLinkId -> GroupMemberRole -> SubscriptionMode -> ExceptT StoreError IO () @@ -280,17 +279,18 @@ getGroupAndMember db User {userId, userContactId} groupMemberId vr = do 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.mods_support_chat_ts, g.mods_support_chat_unanswered, -- 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, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, -- from GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -361,8 +361,7 @@ createNewGroup db vr gVar user@User {userId} groupProfile incognitoProfile = Exc chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, - customData = Nothing, - modsSupportChat = Nothing + customData = Nothing } -- | creates a new group record for the group the current user was invited to, or returns an existing one @@ -432,8 +431,7 @@ createGroupInvitation db vr user@User {userId} contact@Contact {contactId, activ chatTags = [], chatItemTTL = Nothing, uiThemes = Nothing, - customData = Nothing, - modsSupportChat = Nothing + customData = Nothing }, groupMemberId ) @@ -770,10 +768,10 @@ getUserGroupDetails db vr User {userId, userContactId} _contactId_ search_ = do 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.mods_support_chat_ts, g.mods_support_chat_unanswered, 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, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions FROM groups g JOIN group_profiles gp USING (group_profile_id) JOIN group_members mu USING (group_id) @@ -837,7 +835,8 @@ groupMemberQuery = SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -924,14 +923,6 @@ getGroupModerators db vr user@User {userId, userContactId} GroupInfo {groupId} = (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.member_role IN (?,?,?)") (userId, userId, groupId, userContactId, GRModerator, GRAdmin, GROwner) -getSupportMembers :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] -getSupportMembers db vr user@User {userId, userContactId} GroupInfo {groupId} = do - map (toContactMember vr user) - <$> DB.query - db - (groupMemberQuery <> " WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.support_chat_ts IS NOT NULL") - (userId, userId, groupId, userContactId) - getGroupMembersForExpiration :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> IO [GroupMember] getGroupMembersForExpiration db vr user@User {userId, userContactId} GroupInfo {groupId} = do map (toContactMember vr user) @@ -1570,17 +1561,18 @@ getViaGroupMember db vr User {userId, userContactId} Contact {contactId} = do 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.mods_support_chat_ts, g.mods_support_chat_unanswered, -- 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, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, -- via GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 230028e1ce..2ef501e931 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -1,3 +1,4 @@ +{-# LANGUAGE BangPatterns #-} {-# LANGUAGE CPP #-} {-# LANGUAGE DataKinds #-} {-# LANGUAGE DuplicateRecordFields #-} @@ -10,6 +11,7 @@ {-# LANGUAGE PatternSynonyms #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE StandaloneDeriving #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE TypeOperators #-} @@ -33,7 +35,8 @@ module Simplex.Chat.Store.Messages getPendingGroupMessages, deletePendingGroupMessage, deleteOldMessages, - updateChatTs, + MemberAttention (..), + updateChatTsStats, createNewSndChatItem, createNewRcvChatItem, createNewChatItemNoMsg, @@ -141,7 +144,7 @@ import Data.Bifunctor (first) import Data.ByteString.Char8 (ByteString) import Data.Either (fromRight, rights) import Data.Int (Int64) -import Data.List (sortBy) +import Data.List (foldl', sortBy) import Data.List.NonEmpty (NonEmpty) import qualified Data.List.NonEmpty as L import Data.Map.Strict (Map) @@ -362,8 +365,11 @@ deleteOldMessages db createdAtCutoff = do type NewQuoteRow = (Maybe SharedMsgId, Maybe UTCTime, Maybe MsgContent, Maybe Bool, Maybe MemberId) -updateChatTs :: DB.Connection -> User -> ChatDirection c d -> UTCTime -> IO () -updateChatTs db User {userId} chatDirection chatTs = case toChatInfo chatDirection of +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} -> DB.execute db @@ -374,16 +380,38 @@ updateChatTs db User {userId} chatDirection chatTs = case toChatInfo chatDirecti db "UPDATE groups SET chat_ts = ? WHERE user_id = ? AND group_id = ?" (chatTs, userId, groupId) - GroupChat GroupInfo {groupId} (Just (GCSIMemberSupport Nothing)) -> do - DB.execute - db - "UPDATE groups SET mods_support_chat_ts = ? WHERE user_id = ? AND group_id = ?" - (chatTs, userId, groupId) - GroupChat _gInfo (Just (GCSIMemberSupport (Just GroupMember {groupMemberId}))) -> do - DB.execute - db - "UPDATE group_members SET support_chat_ts = ? WHERE group_member_id = ?" - (chatTs, groupMemberId) + GroupChat GroupInfo {membership} (Just 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} -> DB.execute db @@ -544,7 +572,8 @@ getChatItemQuote_ db User {userId, userContactId} chatDirection QuotedMsg {msgRe m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN contacts c ON m.contact_id = c.contact_id @@ -1260,10 +1289,10 @@ getGroupChat db vr user groupId scope_ contentFilter pagination search_ = do getGroupChatInitial_ db user g scopeInfo contentFilter count getGroupChatScopeInfo :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScope -> ExceptT StoreError IO GroupChatScopeInfo -getGroupChatScopeInfo db vr user GroupInfo {modsSupportChat} = \case - GCSMemberSupport Nothing -> case modsSupportChat of +getGroupChatScopeInfo db vr user GroupInfo {membership} = \case + GCSMemberSupport Nothing -> case supportChat membership of Nothing -> throwError $ SEInternalError "no moderators support chat" - Just _modsSupportChat -> pure $ GCSIMemberSupport {groupMember_ = Nothing} + Just _supportChat -> pure $ GCSIMemberSupport {groupMember_ = Nothing} GCSMemberSupport (Just gmId) -> do m <- getGroupMemberById db vr user gmId case supportChat m of @@ -1857,8 +1886,8 @@ setDirectChatItemsDeleteAt db User {userId} contactId itemIds currentTs = forM i (deleteAt, userId, contactId, chatItemId) pure (chatItemId, deleteAt) -updateGroupChatItemsRead :: DB.Connection -> User -> GroupId -> IO () -updateGroupChatItemsRead db User {userId} groupId = do +updateGroupChatItemsRead :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScope -> IO () +updateGroupChatItemsRead db User {userId} GroupInfo {groupId, membership} scope = do currentTs <- getCurrentTime DB.execute db @@ -1867,6 +1896,20 @@ updateGroupChatItemsRead db User {userId} groupId = do 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_ + DB.execute + db + [sql| + UPDATE group_members + SET support_chat_items_unread = 0, + support_chat_items_member_attention = 0, + support_chat_items_mentions = 0 + WHERE group_member_id = ? + |] + (Only gmId) getGroupUnreadTimedItems :: DB.Connection -> User -> GroupId -> IO [(ChatItemId, Int)] getGroupUnreadTimedItems db User {userId} groupId = @@ -1879,33 +1922,63 @@ getGroupUnreadTimedItems db User {userId} groupId = |] (userId, groupId, CISRcvNew) -updateGroupChatItemsReadList :: DB.Connection -> User -> GroupId -> NonEmpty ChatItemId -> IO [(ChatItemId, Int)] -updateGroupChatItemsReadList db User {userId} groupId itemIds = do +updateGroupChatItemsReadList :: DB.Connection -> User -> GroupInfo -> Maybe GroupChatScope -> NonEmpty ChatItemId -> IO [(ChatItemId, Int)] +updateGroupChatItemsReadList db User {userId} GroupInfo {groupId, membership} scope itemIds = do currentTs <- getCurrentTime - catMaybes . L.toList <$> mapM (getUpdateGroupItem currentTs) itemIds + -- 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 where - getUpdateGroupItem currentTs itemId = do - ttl_ <- maybeFirstRow fromOnly getUnreadTimedItem - setItemRead - pure $ (itemId,) <$> ttl_ + getUpdateGroupItem :: UTCTime -> ChatItemId -> IO (Maybe (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)) + getUpdateGroupItem currentTs itemId = + maybeFirstRow id $ + DB.query + db + [sql| + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? + 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 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) + where + 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 - getUnreadTimedItem = - DB.query - db - [sql| - SELECT timed_ttl - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL - |] - (userId, groupId, CISRcvNew, itemId) - setItemRead = - DB.execute - db - [sql| - UPDATE chat_items SET item_status = ?, updated_at = ? - WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? - |] - (CISRcvRead, currentTs, userId, groupId, CISRcvNew, itemId) + addTimedItem acc (itemId, Just ttl, Nothing, _, _) = (itemId, ttl) : acc + addTimedItem acc _ = acc + +deriving instance Show BoolInt setGroupChatItemsDeleteAt :: DB.Connection -> User -> GroupId -> [(ChatItemId, Int)] -> UTCTime -> IO [(ChatItemId, UTCTime)] setGroupChatItemsDeleteAt db User {userId} groupId itemIds currentTs = forM itemIds $ \(chatItemId, ttl) -> do @@ -2698,19 +2771,22 @@ getGroupChatItem db User {userId, userContactId} groupId itemId = ExceptT $ do m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, - rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_unanswered, + rm.created_at, rm.updated_at, + rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences, - dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_unanswered + dbm.created_at, dbm.updated_at, + dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members gsm ON gsm.group_member_id = i.group_scope_group_member_id diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs index fa48bb8660..51aef80563 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20250310_group_scope.hs @@ -5,24 +5,16 @@ module Simplex.Chat.Store.SQLite.Migrations.M20250310_group_scope where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) --- - group_scope_group_member_id points either to member (for chat with member as admin), --- or to membership (for chat with admins as member); it is used to find chat items for scope, --- when we know from context what group member id we are looking for; --- - to learn scope of chat item when context is not known, group member is joined and scope --- is decided based on whether member is of user member category (membership -> chat with admins), or not. --- TODO [knocking] TBC schema --- TODO - group_members.support_chat_unanswered - don't persist, calculate on the fly? --- TODO - review indexes (drop idx_chat_items_groups_item_ts?) +-- TODO [knocking] review indexes (drop idx_chat_items_groups_item_ts?) m20250310_group_scope :: Query m20250310_group_scope = [sql| ALTER TABLE group_profiles ADD COLUMN member_admission TEXT; -ALTER TABLE groups ADD COLUMN mods_support_chat_ts TEXT; -ALTER TABLE groups ADD COLUMN mods_support_chat_unanswered INTEGER; - ALTER TABLE group_members ADD COLUMN support_chat_ts TEXT; -ALTER TABLE group_members ADD COLUMN support_chat_unanswered INTEGER; +ALTER TABLE group_members ADD COLUMN support_chat_items_unread INTEGER NOT NULL DEFAULT 0; +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 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; @@ -49,10 +41,9 @@ ALTER TABLE chat_items DROP COLUMN group_scope_tag; ALTER TABLE chat_items DROP COLUMN group_scope_group_member_id; ALTER TABLE group_members DROP COLUMN support_chat_ts; -ALTER TABLE group_members DROP COLUMN support_chat_unanswered; - -ALTER TABLE groups DROP COLUMN mods_support_chat_ts; -ALTER TABLE groups DROP COLUMN mods_support_chat_unanswered; +ALTER TABLE group_members DROP COLUMN support_chat_items_unread; +ALTER TABLE group_members DROP COLUMN support_chat_items_member_attention; +ALTER TABLE group_members DROP COLUMN support_chat_items_mentions; ALTER TABLE group_profiles DROP COLUMN member_admission; |] 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 3f8f114a4a..ebbf578434 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -37,17 +37,18 @@ Query: 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.mods_support_chat_ts, g.mods_support_chat_unanswered, -- 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, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, -- from GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) JOIN groups g ON g.group_id = m.group_id @@ -210,21 +211,6 @@ Query: Plan: SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) -Query: - SELECT timed_ttl - FROM chat_items - WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? AND timed_ttl IS NOT NULL AND timed_delete_at IS NULL - -Plan: -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 = ? AND chat_item_id = ? - -Plan: -SEARCH chat_items USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE contact_profiles SET display_name = ?, @@ -559,7 +545,8 @@ Query: m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions FROM group_members m JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) LEFT JOIN contacts c ON m.contact_id = c.contact_id @@ -610,6 +597,46 @@ SEARCH m USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH g USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN SEARCH h USING INDEX idx_sent_probe_hashes_sent_probe_id (sent_probe_id=?) +Query: + UPDATE chat_items SET item_status = ?, updated_at = ? + WHERE user_id = ? AND group_id = ? AND item_status = ? AND chat_item_id = ? + RETURNING chat_item_id, timed_ttl, timed_delete_at, group_member_id, user_mention + +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 = ? @@ -708,19 +735,22 @@ Query: m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, -- quoted ChatItem ri.chat_item_id, i.quoted_shared_msg_id, i.quoted_sent_at, i.quoted_content, i.quoted_sent, -- quoted GroupMember rm.group_member_id, rm.group_id, rm.member_id, rm.peer_chat_min_version, rm.peer_chat_max_version, rm.member_role, rm.member_category, rm.member_status, rm.show_messages, rm.member_restriction, rm.invited_by, rm.invited_by_group_member_id, rm.local_display_name, rm.contact_id, rm.contact_profile_id, rp.contact_profile_id, rp.display_name, rp.full_name, rp.image, rp.contact_link, rp.local_alias, rp.preferences, - rm.created_at, rm.updated_at, rm.support_chat_ts, rm.support_chat_unanswered, + rm.created_at, rm.updated_at, + rm.support_chat_ts, rm.support_chat_items_unread, rm.support_chat_items_member_attention, rm.support_chat_items_mentions, -- deleted by GroupMember dbm.group_member_id, dbm.group_id, dbm.member_id, dbm.peer_chat_min_version, dbm.peer_chat_max_version, dbm.member_role, dbm.member_category, dbm.member_status, dbm.show_messages, dbm.member_restriction, dbm.invited_by, dbm.invited_by_group_member_id, dbm.local_display_name, dbm.contact_id, dbm.contact_profile_id, dbp.contact_profile_id, dbp.display_name, dbp.full_name, dbp.image, dbp.contact_link, dbp.local_alias, dbp.preferences, - dbm.created_at, dbm.updated_at, dbm.support_chat_ts, dbm.support_chat_unanswered + dbm.created_at, dbm.updated_at, + dbm.support_chat_ts, dbm.support_chat_items_unread, dbm.support_chat_items_member_attention, dbm.support_chat_items_mentions FROM chat_items i LEFT JOIN files f ON f.chat_item_id = i.chat_item_id LEFT JOIN group_members gsm ON gsm.group_member_id = i.group_scope_group_member_id @@ -795,17 +825,18 @@ Query: 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.mods_support_chat_ts, g.mods_support_chat_unanswered, -- 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, -- GroupInfo {membership = GroupMember {memberProfile}} pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered, + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions, -- via GroupMember m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -841,10 +872,10 @@ Query: 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.mods_support_chat_ts, g.mods_support_chat_unanswered, 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, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions FROM groups g JOIN group_profiles gp USING (group_profile_id) JOIN group_members mu USING (group_id) @@ -1223,6 +1254,16 @@ 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 = ? @@ -4474,12 +4515,12 @@ Query: 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.mods_support_chat_ts, g.mods_support_chat_unanswered, -- 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, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id JOIN group_members mu ON mu.group_id = g.group_id @@ -4497,12 +4538,12 @@ Query: 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.mods_support_chat_ts, g.mods_support_chat_unanswered, -- 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, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id JOIN group_members mu ON mu.group_id = g.group_id @@ -4518,7 +4559,8 @@ Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4550,7 +4592,8 @@ Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4574,7 +4617,8 @@ Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4598,7 +4642,8 @@ Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4622,7 +4667,8 @@ Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4646,7 +4692,8 @@ Query: SELECT m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, + m.created_at, m.updated_at, + m.support_chat_ts, m.support_chat_items_unread, m.support_chat_items_member_attention, m.support_chat_items_mentions, c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, @@ -4666,30 +4713,6 @@ SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN CORRELATED SCALAR SUBQUERY 1 SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) -Query: - SELECT - m.group_member_id, m.group_id, m.member_id, m.peer_chat_min_version, m.peer_chat_max_version, m.member_role, m.member_category, m.member_status, m.show_messages, m.member_restriction, - m.invited_by, m.invited_by_group_member_id, m.local_display_name, m.contact_id, m.contact_profile_id, p.contact_profile_id, p.display_name, p.full_name, p.image, p.contact_link, p.local_alias, p.preferences, - m.created_at, m.updated_at, m.support_chat_ts, m.support_chat_unanswered, - c.connection_id, c.agent_conn_id, c.conn_level, c.via_contact, c.via_user_contact_link, c.via_group_link, c.group_link_id, c.custom_user_profile_id, - c.conn_status, c.conn_type, c.contact_conn_initiated, c.local_alias, c.contact_id, c.group_member_id, c.snd_file_id, c.rcv_file_id, c.user_contact_link_id, - c.created_at, c.security_code, c.security_code_verified_at, c.pq_support, c.pq_encryption, c.pq_snd_enabled, c.pq_rcv_enabled, c.auth_err_counter, c.quota_err_counter, - c.conn_chat_version, c.peer_chat_min_version, c.peer_chat_max_version - FROM group_members m - JOIN contact_profiles p ON p.contact_profile_id = COALESCE(m.member_profile_id, m.contact_profile_id) - LEFT JOIN connections c ON c.connection_id = ( - SELECT max(cc.connection_id) - FROM connections cc - WHERE cc.user_id = ? AND cc.group_member_id = m.group_member_id - ) - WHERE m.user_id = ? AND m.group_id = ? AND (m.contact_id IS NULL OR m.contact_id != ?) AND m.support_chat_ts IS NOT NULL -Plan: -SEARCH m USING INDEX idx_group_members_group_id (user_id=? AND group_id=?) -SEARCH p USING INTEGER PRIMARY KEY (rowid=?) -SEARCH c USING INTEGER PRIMARY KEY (rowid=?) LEFT-JOIN -CORRELATED SCALAR SUBQUERY 1 -SEARCH cc USING COVERING INDEX idx_connections_group_member (user_id=? AND group_member_id=?) - Query: SELECT f.file_id, f.ci_file_status, f.file_path FROM chat_items i @@ -5915,10 +5938,6 @@ Query: UPDATE groups SET local_display_name = ?, updated_at = ? WHERE user_id = Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) -Query: UPDATE groups SET mods_support_chat_ts = ? WHERE user_id = ? AND group_id = ? -Plan: -SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) - Query: UPDATE groups SET send_rcpts = NULL Plan: SCAN groups diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 3fde7ce08e..b87729b6e8 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -134,9 +134,7 @@ CREATE TABLE groups( business_xcontact_id BLOB NULL, customer_member_id BLOB NULL, chat_item_ttl INTEGER, - local_alias TEXT DEFAULT '', - mods_support_chat_ts TEXT, - mods_support_chat_unanswered INTEGER, -- received + local_alias TEXT DEFAULT '', -- received FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE CASCADE @@ -170,7 +168,9 @@ CREATE TABLE group_members( peer_chat_max_version INTEGER NOT NULL DEFAULT 1, member_restriction TEXT, support_chat_ts TEXT, - support_chat_unanswered INTEGER, + support_chat_items_unread INTEGER NOT NULL DEFAULT 0, + support_chat_items_member_attention INTEGER NOT NULL DEFAULT 0, + support_chat_items_mentions INTEGER NOT NULL DEFAULT 0, 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 093382f3ee..847c74715b 100644 --- a/src/Simplex/Chat/Store/Shared.hs +++ b/src/Simplex/Chat/Store/Shared.hs @@ -577,32 +577,35 @@ 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) :. (Maybe UTCTime, Maybe BoolInt) :. 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) :. 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 ConnReqContact, LocalAlias, Maybe Preferences) :. (UTCTime, UTCTime) :. (Maybe UTCTime, Maybe BoolInt) +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 ConnReqContact, 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) :. (modsSupportChatTs_, modsSupportChatUnanswered_) :. 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) :. 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 - modsSupportChat = case (modsSupportChatTs_, modsSupportChatUnanswered_) of - (Just modsChatTs, unanswered_) -> Just GroupSupportChat {chatTs = modsChatTs, unanswered = maybe False unBI unanswered_} - _ -> Nothing - in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData, modsSupportChat} + in GroupInfo {groupId, localDisplayName, groupProfile, localAlias, businessChat, fullGroupPreferences, membership, chatSettings, createdAt, updatedAt, chatTs, userMemberProfileSentAt, chatTags, chatItemTTL, uiThemes, customData} 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_, supportChatUnanswered_)) = +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)) = let memberProfile = LocalProfile {profileId, displayName, fullName, image, contactLink, preferences, localAlias} memberSettings = GroupMemberSettings {showMessages} blockedByAdmin = maybe False mrsBlocked memberRestriction_ invitedBy = toInvitedBy userContactId invitedById activeConn = Nothing memberChatVRange = fromMaybe (versionToRange maxVer) $ safeVersionRange minVer maxVer - supportChat = case (supportChatTs_, supportChatUnanswered_) of - (Just chatTs, unanswered_) -> Just GroupSupportChat {chatTs, unanswered = maybe False unBI unanswered_} + supportChat = case supportChatTs_ of + Just chatTs -> + Just GroupSupportChat { + chatTs, + unread = supportChatUnread, + memberAttention = supportChatMemberAttention, + mentions = supportChatMentions + } _ -> Nothing in GroupMember {..} @@ -618,12 +621,12 @@ groupInfoQuery = 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.mods_support_chat_ts, g.mods_support_chat_unanswered, -- 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, pu.display_name, pu.full_name, pu.image, pu.contact_link, pu.local_alias, pu.preferences, - mu.created_at, mu.updated_at, NULL AS mu_support_chat_ts, NULL AS mu_support_chat_unanswered + mu.created_at, mu.updated_at, + mu.support_chat_ts, mu.support_chat_items_unread, mu.support_chat_items_member_attention, mu.support_chat_items_mentions FROM groups g JOIN group_profiles gp ON gp.group_profile_id = g.group_profile_id JOIN group_members mu ON mu.group_id = g.group_id diff --git a/src/Simplex/Chat/Types.hs b/src/Simplex/Chat/Types.hs index 6ce902a48d..7b21187559 100644 --- a/src/Simplex/Chat/Types.hs +++ b/src/Simplex/Chat/Types.hs @@ -419,14 +419,7 @@ data GroupInfo = GroupInfo chatTags :: [ChatTagId], chatItemTTL :: Maybe Int64, uiThemes :: Maybe UIThemeEntityOverrides, - customData :: Maybe CustomData, - modsSupportChat :: Maybe GroupSupportChat - } - deriving (Eq, Show) - -data GroupSupportChat = GroupSupportChat - { chatTs :: UTCTime, - unanswered :: Bool + customData :: Maybe CustomData } deriving (Eq, Show) @@ -851,6 +844,14 @@ data GroupMember = GroupMember } deriving (Eq, Show) +data GroupSupportChat = GroupSupportChat + { chatTs :: UTCTime, + unread :: Int64, + memberAttention :: Int64, + mentions :: Int64 + } + deriving (Eq, Show) + 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 8f875fa008..32dd81ee8b 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -186,7 +186,6 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRContactRequestRejected u UserContactRequest {localDisplayName = c} -> ttyUser u [ttyContact c <> ": contact request rejected"] CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRGroupMembers u g -> ttyUser u $ viewGroupMembers g - CRMemberSupportChats u _g ms -> ttyUser u $ viewSupportMembers ms -- CRGroupConversationsArchived u _g _conversations -> ttyUser u [] -- CRGroupConversationsDeleted u _g _conversations -> ttyUser u [] CRGroupsList u gs -> ttyUser u $ viewGroupsList gs @@ -454,6 +453,7 @@ responseToView hu@(currentRH, user_) ChatConfig {logLevel, showReactions, showRe CRTerminalEvent te -> case te of TERejectingGroupJoinRequestMember _ g m reason -> [ttyFullMember m <> ": rejecting request to join group " <> ttyGroup' g <> ", reason: " <> sShow reason] TEGroupLinkRejected u g reason -> ttyUser u [ttyGroup' g <> ": join rejected, reason: " <> sShow reason] + TEMemberSupportChats u g ms -> ttyUser u $ viewMemberSupportChats g ms where ttyUser :: User -> [StyledString] -> [StyledString] ttyUser user@User {showNtfs, activeUser, viewPwdHash} ss @@ -1197,10 +1197,17 @@ viewGroupMembers (Group GroupInfo {membership} members) = map groupMember . filt | not (showMessages $ memberSettings m) = ["blocked"] | otherwise = [] -viewSupportMembers :: [GroupMember] -> [StyledString] -viewSupportMembers = map groupMember +viewMemberSupportChats :: GroupInfo -> [GroupMember] -> [StyledString] +viewMemberSupportChats GroupInfo {membership} ms = support <> map groupMember ms where - groupMember m = memIncognito m <> ttyFullMember m <> ", id: " <> sShow (groupMemberId' m) + support = case supportChat membership of + Just sc -> ["support: " <> chatStats sc] + Nothing -> [] + groupMember m@GroupMember {supportChat} = case supportChat of + Just sc -> memIncognito m <> ttyFullMember m <> (" (id " <> sShow (groupMemberId' m) <> "): ") <> chatStats sc + Nothing -> "" + chatStats GroupSupportChat {unread, memberAttention, mentions} = + "unread: " <> sShow unread <> ", require attention: " <> sShow memberAttention <> ", mentions: " <> sShow mentions viewContactConnected :: Contact -> Maybe Profile -> Bool -> [StyledString] viewContactConnected ct userIncognitoProfile testView = diff --git a/tests/ChatClient.hs b/tests/ChatClient.hs index 33b6978ca3..b360061ef0 100644 --- a/tests/ChatClient.hs +++ b/tests/ChatClient.hs @@ -459,10 +459,16 @@ testChatCfgOpts3 cfg opts p1 p2 p3 test = testChatN cfg opts [p1, p2, p3] test_ test_ _ = error "expected 3 chat clients" testChat4 :: HasCallStack => Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () -testChat4 = testChatCfg4 testCfg +testChat4 = testChatCfgOpts4 testCfg testOpts testChatCfg4 :: HasCallStack => ChatConfig -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () -testChatCfg4 cfg p1 p2 p3 p4 test = testChatN cfg testOpts [p1, p2, p3, p4] test_ +testChatCfg4 cfg = testChatCfgOpts4 cfg testOpts + +testChatOpts4 :: HasCallStack => ChatOpts -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +testChatOpts4 = testChatCfgOpts4 testCfg + +testChatCfgOpts4 :: HasCallStack => ChatConfig -> ChatOpts -> Profile -> Profile -> Profile -> Profile -> (HasCallStack => TestCC -> TestCC -> TestCC -> TestCC -> IO ()) -> TestParams -> IO () +testChatCfgOpts4 cfg opts p1 p2 p3 p4 test = testChatN cfg opts [p1, p2, p3, p4] test_ where test_ :: HasCallStack => [TestCC] -> IO () test_ [tc1, tc2, tc3, tc4] = test tc1 tc2 tc3 tc4 diff --git a/tests/ChatTests/Groups.hs b/tests/ChatTests/Groups.hs index 02d39b0952..00d25b533f 100644 --- a/tests/ChatTests/Groups.hs +++ b/tests/ChatTests/Groups.hs @@ -195,6 +195,7 @@ chatGroupTests = do describe "group scoped messages" $ do it "should send scoped messages to support (single moderator)" testScopedSupportSingleModerator it "should send scoped messages to support (many moderators)" testScopedSupportManyModerators + it "should correctly maintain unread stats for support chats" testScopedSupportUnreadStats testGroupCheckMessages :: HasCallStack => TestParams -> IO () testGroupCheckMessages = @@ -6889,8 +6890,8 @@ testUpdatedMentionNames = do test (mm [("alice", Just "alice"), ("cath", Just "alice")]) "hello @alice @cath" `shouldBe` "hello @alice @alice_1" where - test mentions t = - let (mc', _, _) = updatedMentionNames (MCText t) (parseMaybeMarkdownList t) mentions + test mentionsMap t = + let (mc', _, _) = updatedMentionNames (MCText t) (parseMaybeMarkdownList t) mentionsMap in msgContentText mc' mm = M.fromList . map (second mentionedMember) mentionedMember name_ = CIMention {memberId = MemberId "abcd", memberRef = ciMentionMember <$> name_} @@ -6965,10 +6966,147 @@ testScopedSupportManyModerators = cath <## "chat db error: SEInternalError {message = \"no support chat\"}" alice ##> "/member support chats #team" - alice <## "bob (Bob), id: 2" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" dan ##> "/member support chats #team" - dan <## "bob (Bob), id: 3" + dan <## "bob (Bob) (id 3): unread: 0, require attention: 0, mentions: 0" bob ##> "/member support chats #team" - bob <## "#team: you have insufficient permissions for this action, the required role is moderator" + bob <## "support: unread: 0, require attention: 0, mentions: 0" cath ##> "/member support chats #team" - cath <## "#team: you have insufficient permissions for this action, the required role is moderator" + cath TestParams -> IO () +testScopedSupportUnreadStats = + testChatOpts4 opts aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do + createGroup4 "team" alice (bob, GRMember) (cath, GRMember) (dan, GRModerator) + + alice #> "#team 1" + [bob, cath, dan] *<# "#team alice> 1" + + bob #> "#team 2" + [alice, cath, dan] *<# "#team bob> 2" + + alice ##> "/_send #1(_support:2) text 3" + alice <# "#team (support: bob) 3" + bob <# "#team (support) alice> 3" + dan <# "#team (support: bob) alice> 3" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + dan ##> "/member support chats #team" + 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" + + bob ##> "/_send #1(_support) text 4" + bob <# "#team (support) 4" + [alice, dan] *<# "#team (support: bob) bob> 4" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 1, require attention: 1, mentions: 0" + dan ##> "/member support chats #team" + 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" + + dan ##> "/_send #1(_support:3) text 5" + dan <# "#team (support: bob) 5" + alice <# "#team (support: bob) dan> 5" + bob <# "#team (support) dan> 5" + + alice ##> "/member support chats #team" + 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 <## "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" + + threadDelay 1000000 + + dan ##> "/_send #1(_support:3) json [{\"msgContent\": {\"type\": \"text\", \"text\": \"@alice 6\"}, \"mentions\": {\"alice\": 1}}]" + dan <# "#team (support: bob) @alice 6" + alice <# "#team (support: bob) dan!> @alice 6" + bob <# "#team (support) dan> @alice 6" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 3, require attention: 0, mentions: 1" + dan ##> "/member support chats #team" + 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" + + aliceMentionedByDanItemId <- lastItemId alice + + threadDelay 1000000 + + bob ##> "/_send #1(_support) json [{\"msgContent\": {\"type\": \"text\", \"text\": \"@alice 7\"}, \"mentions\": {\"alice\": 1}}]" + bob <# "#team (support) @alice 7" + alice <# "#team (support: bob) bob!> @alice 7" + dan <# "#team (support: bob) bob> @alice 7" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 4, require attention: 1, mentions: 2" + dan ##> "/member support chats #team" + 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" + + aliceMentionedByBobItemId <- lastItemId alice + + bob ##> "/_send #1(_support) json [{\"msgContent\": {\"type\": \"text\", \"text\": \"@dan 8\"}, \"mentions\": {\"dan\": 4}}]" + bob <# "#team (support) @dan 8" + alice <# "#team (support: bob) bob> @dan 8" + dan <# "#team (support: bob) bob!> @dan 8" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 5, require attention: 2, mentions: 2" + dan ##> "/member support chats #team" + 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" + + alice #$> ("/_read chat items #1(_support:2) " <> aliceMentionedByDanItemId, id, "ok") + + 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 ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 3, require attention: 1, mentions: 0" + + dan ##> "/_send #1(_support:3) json [{\"msgContent\": {\"type\": \"text\", \"text\": \"@bob 9\"}, \"mentions\": {\"bob\": 3}}]" + dan <# "#team (support: bob) @bob 9" + alice <# "#team (support: bob) dan> @bob 9" + bob <# "#team (support) dan!> @bob 9" + + alice ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 4, require attention: 0, mentions: 0" + dan ##> "/member support chats #team" + 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 ##> "/member support chats #team" + alice <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0" + + dan #$> ("/_read chat #1(_support:3)", id, "ok") + + dan ##> "/member support chats #team" + dan <## "bob (Bob) (id 3): unread: 0, require attention: 0, mentions: 0" + + bob #$> ("/_read chat #1(_support)", id, "ok") + + bob ##> "/member support chats #team" + bob <## "support: unread: 0, require attention: 0, mentions: 0" + + cath ##> "/member support chats #team" + cath