core, ui: update group scope unread stats on item deletions; create reports as read (reports use a different attention mechanism) (#5930)

This commit is contained in:
spaced4ndy 2025-05-20 16:18:23 +00:00 committed by GitHub
parent f162f96be8
commit e84016e1b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 186 additions and 90 deletions

View file

@ -2206,6 +2206,9 @@ func processReceivedMsg(_ res: ChatEvent) async {
m.decreaseGroupReportsCounter(item.deletedChatItem.chatInfo.id)
}
}
if let updatedChatInfo = items.last?.deletedChatItem.chatInfo {
m.updateChatInfo(updatedChatInfo)
}
}
case let .groupChatItemsDeleted(user, groupInfo, chatItemIDs, _, member_):
await groupChatItemsDeleted(user, groupInfo, chatItemIDs, member_)

View file

@ -2277,6 +2277,7 @@ struct ChatView: View {
if deletedItem.isActiveReport {
m.decreaseGroupReportsCounter(chat.chatInfo.id)
}
m.updateChatInfo(itemDeletion.deletedChatItem.chatInfo)
}
}
}
@ -2466,6 +2467,9 @@ private func deleteMessages(_ chat: Chat, _ deletingItems: [Int64], _ mode: CIDe
ChatModel.shared.decreaseGroupReportsCounter(chat.chatInfo.id)
}
}
if let updatedChatInfo = deletedItems.last?.deletedChatItem.chatInfo {
ChatModel.shared.updateChatInfo(updatedChatInfo)
}
}
await onSuccess()
} catch {
@ -2497,6 +2501,9 @@ func archiveReports(_ chatInfo: ChatInfo, _ itemIds: [Int64], _ forAll: Bool, _
ChatModel.shared.decreaseGroupReportsCounter(chatInfo.id)
}
}
if let updatedChatInfo = deleted.last?.deletedChatItem.chatInfo {
ChatModel.shared.updateChatInfo(updatedChatInfo)
}
}
await onSuccess()
} catch {

View file

@ -2533,6 +2533,11 @@ object ChatController {
}
}
}
r.chatItemDeletions.lastOrNull()?.deletedChatItem?.chatInfo?.let { updatedChatInfo ->
withContext(Dispatchers.Main) {
chatModel.chatsContext.updateChatInfo(rhId, updatedChatInfo)
}
}
}
is CR.GroupChatItemsDeleted -> {
groupChatItemsDeleted(rhId, r)

View file

@ -455,6 +455,7 @@ fun ChatView(
if (deletedItem.isActiveReport) {
chatModel.chatsContext.decreaseGroupReportsCounter(chatRh, chatInfo.id)
}
chatModel.chatsContext.updateChatInfo(chatRh, deleted.deletedChatItem.chatInfo)
}
withContext(Dispatchers.Main) {
if (toChatItem != null) {
@ -2672,6 +2673,9 @@ private fun deleteMessages(chatRh: Long?, chatInfo: ChatInfo, itemIds: List<Long
chatModel.chatsContext.decreaseGroupReportsCounter(chatRh, chatInfo.id)
}
}
deleted.lastOrNull()?.deletedChatItem?.chatInfo?.let { updatedChatInfo ->
chatModel.chatsContext.updateChatInfo(chatRh, updatedChatInfo)
}
}
withContext(Dispatchers.Main) {
for (di in deleted) {
@ -2712,6 +2716,9 @@ private fun archiveReports(chatRh: Long?, chatInfo: ChatInfo, itemIds: List<Long
chatModel.chatsContext.decreaseGroupReportsCounter(chatRh, chatInfo.id)
}
}
deleted.lastOrNull()?.deletedChatItem?.chatInfo?.let { updatedChatInfo ->
chatModel.chatsContext.updateChatInfo(chatRh, updatedChatInfo)
}
}
withContext(Dispatchers.Main) {
for (di in deleted) {

View file

@ -1032,8 +1032,9 @@ processChatCommand' vr = \case
user <- getUserByGroupId db chatId
gInfo <- getGroupInfo db vr user chatId
pure (user, gInfo)
chatScopeInfo <- mapM (getChatScopeInfo vr user) scope
(timedItems, gInfo') <- withFastStore $ \db -> do
(timedItems, gInfo') <- updateGroupChatItemsReadList db vr user gInfo scope itemIds
(timedItems, gInfo') <- updateGroupChatItemsReadList db vr user gInfo chatScopeInfo itemIds
timedItems' <- liftIO $ setGroupChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime
pure (timedItems', gInfo')
forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt
@ -2239,7 +2240,7 @@ processChatCommand' vr = \case
deleted = deleted1 <> deleted2 <> deleted3 <> deleted4
-- Read group info with updated membersRequireAttention
gInfo' <- withFastStore $ \db -> getGroupInfo db vr user groupId
let acis' = map (updateCIGroupInfo gInfo') acis
let acis' = map (updateACIGroupInfo gInfo') acis
unless (null acis') $ toView $ CEvtNewChatItems user acis'
unless (null errs) $ toView $ CEvtChatErrors errs
when withMessages $ deleteMessages user gInfo' deleted
@ -2299,11 +2300,6 @@ processChatCommand' vr = \case
-- instead we re-read it once after deleting all members before response.
void $ deleteOrUpdateMemberRecordIO db user gInfo m
pure m {memberStatus = GSMemRemoved}
updateCIGroupInfo :: GroupInfo -> AChatItem -> AChatItem
updateCIGroupInfo gInfo' = \case
AChatItem SCTGroup SMDSnd (GroupChat _gInfo chatScopeInfo) ci ->
AChatItem SCTGroup SMDSnd (GroupChat gInfo' chatScopeInfo) ci
aci -> aci
deleteMessages user gInfo@GroupInfo {membership} ms
| groupFeatureMemberAllowed SGFFullDelete membership gInfo = deleteGroupMembersCIs user gInfo ms membership
| otherwise = markGroupMembersCIsDeleted user gInfo ms membership

View file

@ -37,7 +37,7 @@ import Data.Foldable (foldr')
import Data.Functor (($>))
import Data.Functor.Identity
import Data.Int (Int64)
import Data.List (find, mapAccumL, partition)
import Data.List (find, foldl', mapAccumL, partition)
import Data.List.NonEmpty (NonEmpty (..), (<|))
import qualified Data.List.NonEmpty as L
import Data.Map.Strict (Map)
@ -459,7 +459,14 @@ deleteGroupCIs user gInfo chatScopeInfo items byGroupMember_ deletedTs = do
deleteCIFiles user ciFilesInfo
(errs, deletions) <- lift $ partitionEithers <$> withStoreBatch' (\db -> map (deleteItem db) items)
unless (null errs) $ toView $ CEvtChatErrors errs
pure deletions
vr <- chatVersionRange
deletions' <- case chatScopeInfo of
Nothing -> pure deletions
Just scopeInfo@GCSIMemberSupport {groupMember_} -> do
let decStats = countDeletedUnreadItems groupMember_ deletions
gInfo' <- withFastStore' $ \db -> updateGroupScopeUnreadStats db vr user gInfo scopeInfo decStats
pure $ map (updateDeletionGroupInfo gInfo') deletions
pure deletions'
where
deleteItem :: DB.Connection -> CChatItem 'CTGroup -> IO ChatItemDeletion
deleteItem db (CChatItem md ci) = do
@ -467,6 +474,32 @@ deleteGroupCIs user gInfo chatScopeInfo items byGroupMember_ deletedTs = do
Just m -> Just <$> updateGroupChatItemModerated db user gInfo ci m deletedTs
Nothing -> Nothing <$ deleteGroupChatItem db user gInfo ci
pure $ groupDeletion md gInfo chatScopeInfo ci ci'
countDeletedUnreadItems :: Maybe GroupMember -> [ChatItemDeletion] -> (Int, Int, Int)
countDeletedUnreadItems scopeMember_ = foldl' countItem (0, 0, 0)
where
countItem :: (Int, Int, Int) -> ChatItemDeletion -> (Int, Int, Int)
countItem (!unread, !unanswered, !mentions) ChatItemDeletion {deletedChatItem}
| aChatItemIsRcvNew deletedChatItem =
let unread' = unread + 1
unanswered' = case (scopeMember_, aChatItemRcvFromMember deletedChatItem) of
(Just scopeMember, Just rcvFromMember)
| groupMemberId' rcvFromMember == groupMemberId' scopeMember -> unanswered + 1
_ -> unanswered
mentions' = if isACIUserMention deletedChatItem then mentions + 1 else mentions
in (unread', unanswered', mentions')
| otherwise = (unread, unanswered, mentions)
updateDeletionGroupInfo :: GroupInfo -> ChatItemDeletion -> ChatItemDeletion
updateDeletionGroupInfo gInfo' ChatItemDeletion {deletedChatItem, toChatItem} =
ChatItemDeletion
{ deletedChatItem = updateACIGroupInfo gInfo' deletedChatItem,
toChatItem = updateACIGroupInfo gInfo' <$> toChatItem
}
updateACIGroupInfo :: GroupInfo -> AChatItem -> AChatItem
updateACIGroupInfo gInfo' = \case
AChatItem SCTGroup dir (GroupChat _gInfo chatScopeInfo) ci ->
AChatItem SCTGroup dir (GroupChat gInfo' chatScopeInfo) ci
aci -> aci
deleteGroupMemberCIs :: MsgDirectionI d => User -> GroupInfo -> GroupMember -> GroupMember -> SMsgDirection d -> CM ()
deleteGroupMemberCIs user gInfo member byGroupMember msgDir = do

View file

@ -215,6 +215,9 @@ data CIMentionMember = CIMentionMember
}
deriving (Eq, Show)
isACIUserMention :: AChatItem -> Bool
isACIUserMention (AChatItem _ _ _ ci) = isUserMention ci
isUserMention :: ChatItem c d -> Bool
isUserMention ChatItem {meta = CIMeta {userMention}} = userMention
@ -295,6 +298,16 @@ chatItemMember GroupInfo {membership} ChatItem {chatDir} = case chatDir of
CIGroupSnd -> membership
CIGroupRcv m -> m
chatItemRcvFromMember :: ChatItem c d -> Maybe GroupMember
chatItemRcvFromMember ChatItem {chatDir} = case chatDir of
CIGroupRcv m -> Just m
_ -> Nothing
chatItemIsRcvNew :: ChatItem c d -> Bool
chatItemIsRcvNew ChatItem {meta = CIMeta {itemStatus}} = case itemStatus of
CISRcvNew -> True
_ -> False
ciReactionAllowed :: ChatItem c d -> Bool
ciReactionAllowed ChatItem {meta = CIMeta {itemDeleted = Just _}} = False
ciReactionAllowed ChatItem {content} = isJust $ ciMsgContent content
@ -395,6 +408,12 @@ aChatItemTs (AChatItem _ _ _ ci) = chatItemTs' ci
aChatItemDir :: AChatItem -> MsgDirection
aChatItemDir (AChatItem _ sMsgDir _ _) = toMsgDirection sMsgDir
aChatItemRcvFromMember :: AChatItem -> Maybe GroupMember
aChatItemRcvFromMember (AChatItem _ _ _ ci) = chatItemRcvFromMember ci
aChatItemIsRcvNew :: AChatItem -> Bool
aChatItemIsRcvNew (AChatItem _ _ _ ci) = chatItemIsRcvNew ci
updateFileStatus :: forall c d. ChatItem c d -> CIFileStatus d -> ChatItem c d
updateFileStatus ci@ChatItem {file} status = case file of
Just f -> ci {file = Just (f :: CIFile d) {fileStatus = status}}
@ -966,7 +985,10 @@ ciStatusNew = case msgDirection @d of
ciCreateStatus :: forall d. MsgDirectionI d => CIContent d -> CIStatus d
ciCreateStatus content = case msgDirection @d of
SMDSnd -> ciStatusNew
SMDRcv -> if ciRequiresAttention content then ciStatusNew else CISRcvRead
SMDRcv
| isCIReport content -> CISRcvRead
| ciRequiresAttention content -> ciStatusNew
| otherwise -> CISRcvRead
membersGroupItemStatus :: [(GroupSndStatus, Int)] -> CIStatus 'MDSnd
membersGroupItemStatus memStatusCounts

View file

@ -182,6 +182,9 @@ ciMsgContent = \case
CIRcvMsgContent mc -> Just mc
_ -> Nothing
isCIReport :: CIContent d -> Bool
isCIReport = maybe False isReport . ciMsgContent
data MsgDecryptError
= MDERatchetHeader
| MDETooManySkipped

View file

@ -1233,7 +1233,7 @@ updateGroupMemberAccepted db User {userId} m@GroupMember {groupMemberId} status
pure m {memberStatus = status, memberRole = role, updatedAt = currentTs}
deleteGroupMemberSupportChat :: DB.Connection -> User -> GroupInfo -> GroupMember -> IO (GroupInfo, GroupMember)
deleteGroupMemberSupportChat db user@User {userId} g@GroupInfo {groupId, membersRequireAttention} m@GroupMember {groupMemberId, supportChat} = do
deleteGroupMemberSupportChat db user g m@GroupMember {groupMemberId} = do
let requiredAttention = gmRequiresAttention m
currentTs <- getCurrentTime
DB.execute

View file

@ -80,6 +80,7 @@ module Simplex.Chat.Store.Messages
updateGroupChatItemsRead,
getGroupUnreadTimedItems,
updateGroupChatItemsReadList,
updateGroupScopeUnreadStats,
setGroupChatItemsDeleteAt,
updateLocalChatItemsRead,
getChatRefViaItemId,
@ -2018,13 +2019,17 @@ getGroupUnreadTimedItems db User {userId} groupId =
|]
(userId, groupId, CISRcvNew)
updateGroupChatItemsReadList :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> Maybe GroupChatScope -> NonEmpty ChatItemId -> ExceptT StoreError IO ([(ChatItemId, Int)], GroupInfo)
updateGroupChatItemsReadList db vr user@User {userId} g@GroupInfo {groupId, membership, membersRequireAttention} scope itemIds = do
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
currentTs <- liftIO getCurrentTime
-- Possible improvement is to differentiate retrieval queries for each scope,
-- but we rely on UI to not pass item IDs from incorrect scope.
readItemsData <- liftIO $ catMaybes . L.toList <$> mapM (getUpdateGroupItem currentTs) itemIds
g' <- updateChatStats readItemsData
g' <- case scopeInfo_ of
Nothing -> pure g
Just scopeInfo@GCSIMemberSupport {groupMember_} -> do
let decStats = countReadItems groupMember_ readItemsData
liftIO $ updateGroupScopeUnreadStats db vr user g scopeInfo decStats
pure (timedItems readItemsData, g')
where
getUpdateGroupItem :: UTCTime -> ChatItemId -> IO (Maybe (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt))
@ -2038,67 +2043,58 @@ updateGroupChatItemsReadList db vr user@User {userId} g@GroupInfo {groupId, memb
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)] -> ExceptT StoreError IO GroupInfo
updateChatStats readItemsData = case scope of
Nothing -> pure g
Just GCSMemberSupport {groupMemberId_} -> case groupMemberId_ of
Nothing -> do
membership' <- updateGMStats membership
pure g {membership = membership'}
Just groupMemberId -> do
member <- getGroupMemberById db vr user groupMemberId
member' <- updateGMStats member
let didRequire = gmRequiresAttention member
nowRequires = gmRequiresAttention member'
if (not nowRequires && didRequire)
then do
liftIO $
DB.execute
db
[sql|
UPDATE groups
SET members_require_attention = members_require_attention - 1
WHERE user_id = ? AND group_id = ?
|]
(userId, groupId)
pure g {membersRequireAttention = membersRequireAttention - 1}
else
pure g
where
updateGMStats GroupMember {groupMemberId} = do
let unread = length readItemsData
(unanswered, mentions) = decStats
liftIO $
DB.execute
db
[sql|
UPDATE group_members
SET support_chat_items_unread = support_chat_items_unread - ?,
support_chat_items_member_attention = support_chat_items_member_attention - ?,
support_chat_items_mentions = support_chat_items_mentions - ?
WHERE group_member_id = ?
|]
(unread, unanswered, mentions, groupMemberId)
getGroupMemberById db vr user groupMemberId
where
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')
countReadItems :: Maybe GroupMember -> [(ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)] -> (Int, Int, Int)
countReadItems scopeMember_ readItemsData =
let unread = length readItemsData
(unanswered, mentions) = foldl' countItem (0, 0) readItemsData
in (unread, unanswered, mentions)
where
countItem :: (Int, Int) -> (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt) -> (Int, Int)
countItem (!unanswered, !mentions) (_, _, _, itemGMId_, userMention_) =
let unanswered' = case (scopeMember_, itemGMId_) of
(Just scopeMember, Just itemGMId) | itemGMId == groupMemberId' scopeMember -> 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
addTimedItem acc (itemId, Just ttl, Nothing, _, _) = (itemId, ttl) : acc
addTimedItem acc _ = acc
updateGroupScopeUnreadStats :: DB.Connection -> VersionRangeChat -> User -> GroupInfo -> GroupChatScopeInfo -> (Int, Int, Int) -> IO GroupInfo
updateGroupScopeUnreadStats db vr user g@GroupInfo {membership} scopeInfo (unread, unanswered, mentions) =
case scopeInfo of
GCSIMemberSupport {groupMember_} -> case groupMember_ of
Nothing -> do
membership' <- updateGMStats membership
pure g {membership = membership'}
Just member -> do
member' <- updateGMStats member
let didRequire = gmRequiresAttention member
nowRequires = gmRequiresAttention member'
if (not nowRequires && didRequire)
then decreaseGroupMembersRequireAttention db user g
else pure g
where
updateGMStats m@GroupMember {groupMemberId} = do
currentTs <- getCurrentTime
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 - ?,
updated_at = ?
WHERE group_member_id = ?
|]
(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
deriving instance Show BoolInt
setGroupChatItemsDeleteAt :: DB.Connection -> User -> GroupId -> [(ChatItemId, Int)] -> UTCTime -> IO [(ChatItemId, UTCTime)]

View file

@ -1,21 +1,3 @@
Query:
UPDATE groups
SET members_require_attention = members_require_attention - 1
WHERE user_id = ? AND group_id = ?
Plan:
SEARCH groups USING INTEGER PRIMARY KEY (rowid=?)
Query:
UPDATE group_members
SET support_chat_items_unread = support_chat_items_unread - ?,
support_chat_items_member_attention = support_chat_items_member_attention - ?,
support_chat_items_mentions = support_chat_items_mentions - ?
WHERE group_member_id = ?
Plan:
SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?)
Query:
UPDATE groups
SET chat_ts = ?,
@ -1321,6 +1303,17 @@ Query:
Plan:
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 - ?,
updated_at = ?
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 = ?

View file

@ -196,7 +196,8 @@ chatGroupTests = do
it "should send scoped messages to support (single moderator)" testScopedSupportSingleModerator
it "should send scoped messages to support (many moderators)" testScopedSupportManyModerators
it "should send messages to admins and members" testSupportCLISendCommand
it "should correctly maintain unread stats for support chats" testScopedSupportUnreadStats
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
testGroupCheckMessages :: HasCallStack => TestParams -> IO ()
testGroupCheckMessages =
@ -7009,8 +7010,8 @@ testSupportCLISendCommand =
bob ##> "#team (support 4"
bob <## "bad chat command: Failed reading: empty"
testScopedSupportUnreadStats :: HasCallStack => TestParams -> IO ()
testScopedSupportUnreadStats =
testScopedSupportUnreadStatsOnRead :: HasCallStack => TestParams -> IO ()
testScopedSupportUnreadStatsOnRead =
testChatOpts4 opts aliceProfile bobProfile cathProfile danProfile $ \alice bob cath dan -> do
createGroup4 "team" alice (bob, GRMember) (cath, GRMember) (dan, GRModerator)
@ -7149,3 +7150,33 @@ testScopedSupportUnreadStats =
{ markRead = False
}
testScopedSupportUnreadStatsOnDelete :: HasCallStack => TestParams -> IO ()
testScopedSupportUnreadStatsOnDelete =
testChatOpts2 opts aliceProfile bobProfile $ \alice bob -> do
createGroup2 "team" alice bob
alice ##> "/set delete #team on"
alice <## "updated group preferences:"
alice <## "Full deletion: on"
bob <## "alice updated group #team:"
bob <## "updated group preferences:"
bob <## "Full deletion: on"
bob #> "#team (support) 1"
alice <# "#team (support: bob) bob> 1"
msgIdBob <- lastItemId bob
alice ##> "/member support chats #team"
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 <## "bob (Bob) (id 2): unread: 0, require attention: 0, mentions: 0"
where
opts =
testOpts
{ markRead = False
}