diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 6a5b1de353..cdbaf03873 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -1326,6 +1326,13 @@ final class ChatModel: ObservableObject { } } + func upsertSupportChatMember(_ cInfo: ChatInfo) { + if case let .group(groupInfo, .memberSupport(member?)?) = cInfo { + _ = upsertGroupMember(groupInfo, member) + objectWillChange.send() + } + } + func upsertGroupMember(_ groupInfo: GroupInfo, _ member: GroupMember) -> Bool { // user member was updated if groupInfo.membership.groupMemberId == member.groupMemberId { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 742ef20688..f61a9810df 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -1923,6 +1923,7 @@ func apiMarkChatItemsRead(_ im: ItemsModel, _ cInfo: ChatInfo, _ itemIds: [ChatI let updatedChatInfo = try await apiChatItemsRead(type: cInfo.chatType, id: cInfo.apiId, scope: cInfo.groupChatScope(), itemIds: itemIds) await MainActor.run { ChatModel.shared.updateChatInfo(updatedChatInfo) + ChatModel.shared.upsertSupportChatMember(updatedChatInfo) ChatModel.shared.markChatItemsRead(im, cInfo, itemIds, mentionsRead) } } catch { @@ -2625,6 +2626,7 @@ func processReceivedMsg(_ res: ChatEvent) async { if cItem.isActiveReport { m.increaseGroupReportsCounter(cInfo.id) } + m.upsertSupportChatMember(cInfo) } else if cItem.isRcvNew && cInfo.ntfsEnabled(chatItem: cItem) { m.increaseUnreadCounter(user: user) } @@ -2690,6 +2692,7 @@ func processReceivedMsg(_ res: ChatEvent) async { if item.deletedChatItem.chatItem.isActiveReport { m.decreaseGroupReportsCounter(item.deletedChatItem.chatInfo.id) } + m.upsertSupportChatMember(item.deletedChatItem.chatInfo) } if let updatedChatInfo = items.last?.deletedChatItem.chatInfo { m.updateChatInfo(updatedChatInfo) diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift index 5213b5509b..3f9ab40d5e 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -54,6 +54,7 @@ func apiLoadMessages( if im.secondaryIMFilter == nil { chatModel.updateChatInfo(chat.chatInfo) } + chatModel.upsertSupportChatMember(chat.chatInfo) im.chatState.splits = newSplits if !chat.chatItems.isEmpty { im.chatState.unreadAfterItemId = chat.chatItems.last!.id diff --git a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift index da9d56a699..3c0cbe987b 100644 --- a/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift +++ b/apps/ios/Shared/Views/Chat/Group/MemberSupportView.swift @@ -20,18 +20,9 @@ struct MemberSupportView: View { var body: some View { viewBody() .onAppear { - Task { - await chatModel.loadGroupMembers(groupInfo) - } - } - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - Task { - await chatModel.loadGroupMembers(groupInfo) - } - } label: { - Image(systemName: "arrow.clockwise") + if !chatModel.membersLoaded { + Task { + await chatModel.loadGroupMembers(groupInfo) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index afad095a60..4208816b0e 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -381,6 +381,12 @@ object ChatModel { fun getContactChat(contactId: Long): Chat? = chats.value.firstOrNull { it.chatInfo is ChatInfo.Direct && it.chatInfo.apiId == contactId } fun getGroupChat(groupId: Long): Chat? = chats.value.firstOrNull { it.chatInfo is ChatInfo.Group && it.chatInfo.apiId == groupId } + suspend fun upsertSupportChatMember(rhId: Long?, cInfo: ChatInfo) { + if (cInfo !is ChatInfo.Group) return + val member = (cInfo.groupChatScope as? GroupChatScopeInfo.MemberSupport)?.groupMember_ ?: return + chatsContext.upsertGroupMember(rhId, cInfo.groupInfo, member) + } + fun populateGroupMembersIndexes() { groupMembersIndexes.value = emptyMap() val gmIndexes = groupMembersIndexes.value.toMutableMap() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index 97e1f025f3..329f13e303 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -3006,6 +3006,7 @@ object ChatController { chatModel.chatsContext.increaseGroupReportsCounter(rhId, cInfo.id) } chatModel.secondaryChatsContext.value?.addChatItem(rhId, cInfo, cItem) + chatModel.upsertSupportChatMember(rhId, cInfo) } } else if (cItem.isRcvNew && cInfo.ntfsEnabled(cItem)) { withContext(Dispatchers.Main) { @@ -3087,6 +3088,7 @@ object ChatController { if (cItem.isActiveReport) { chatModel.chatsContext.decreaseGroupReportsCounter(rhId, cInfo.id) } + chatModel.upsertSupportChatMember(rhId, cInfo) } withContext(Dispatchers.Main) { if (toChatItem == null) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index fd912a8c9e..055d9e50b8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -79,6 +79,7 @@ suspend fun processLoadedChat( withContext(Dispatchers.Main) { chatsCtx.chatItems.replaceAll(chat.chatItems) chatModel.chatId.value = chat.id + chatModel.upsertSupportChatMember(chat.remoteHostId, chat.chatInfo) splits.value = newSplits if (chat.chatItems.isNotEmpty()) { unreadAfterItemId.value = chat.chatItems.last().id diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt index 091924e3c7..66ded0b2a8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatView.kt @@ -762,6 +762,7 @@ fun ChatView( ) if (updatedChatInfo != null) { chatModel.chatsContext.updateChatInfo(chatRh, updatedChatInfo) + chatModel.upsertSupportChatMember(chatRh, updatedChatInfo) } } withContext(Dispatchers.Main) { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index c81149fcb1..ca013b4f1a 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -599,6 +599,7 @@ fun ComposeView( chatItems.forEach { aChatItem -> withContext(Dispatchers.Main) { chatsCtx.addChatItem(chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) + chatModel.upsertSupportChatMember(chat.remoteHostId, aChatItem.chatInfo) } } return chatItems.first().chatItem diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt index b685d20fb9..e7f1ef66ef 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/group/MemberSupportView.kt @@ -42,12 +42,11 @@ fun ModalData.MemberSupportView( ModalManager.end.closeModals() } LaunchedEffect(Unit) { - setGroupMembers(rhId, groupInfo, chatModel) + if (!chatModel.membersLoaded.value) { + setGroupMembers(rhId, groupInfo, chatModel) + } } - ModalView( - close = close, - endButtons = { RefreshMembersButton(rhId, groupInfo) } - ) { + ModalView(close = close) { MemberSupportViewLayout( chat, groupInfo, @@ -56,26 +55,6 @@ fun ModalData.MemberSupportView( } } -@Composable -fun RefreshMembersButton( - rhId: Long?, - groupInfo: GroupInfo -) { - IconButton( - onClick = { - withBGApi { - setGroupMembers(rhId, groupInfo, chatModel) - } - } - ) { - Icon( - painterResource(MR.images.ic_refresh), - contentDescription = null, - tint = MaterialTheme.colors.primary - ) - } -} - @Composable private fun ModalData.MemberSupportViewLayout( chat: Chat, diff --git a/plans/member-support-list-events.md b/plans/member-support-list-events.md new file mode 100644 index 0000000000..78436ef7aa --- /dev/null +++ b/plans/member-support-list-events.md @@ -0,0 +1,60 @@ +# Member support chats list: update from events instead of reloading all members + +## Problem + +A group owner with a large group opens the "Chat with members" list, then opens members' support chats one after another. The first chat opens quickly. Every later one takes several seconds to open. + +## Cause + +1. Only the top modal is composed (`ModalManager.showInView`, `ModalView.kt`). While a support chat is open over the list, `MemberSupportView` has left composition. It re-enters composition when the user goes back. +2. `MemberSupportView` has `LaunchedEffect(Unit) { setGroupMembers(...) }`, so every return to the list runs `apiListMembers`. That call loads the full member list of the group, with profiles and connections. iOS does the same in `.onAppear`. +3. The chat database has one connection (`DBStore.dbConnection :: MVar`), and every store operation takes it. + - Going back to the list is instant, because the old members are still in memory. + - The next `/_get chat #g(_support:m)` is queued behind the member query that is already running. + - Cancelling the coroutine does not stop the core query. +4. Measured on the reporter's device with `/sql slow`: `getGroupMembers` took 1.6 s on average and 15 s at most, over 122 calls. The scoped `getGroupChat` queries in the same log took at most 37 ms each. + +The first open is fast because the member load from opening the list has finished by the time the user taps. + +## Why the list reloaded + +The per-member support stats shown in the list (unread, member attention, mentions, last activity) were never applied from events. The full reload was the only way they got refreshed. + +- **Receiving.** The core already sends the updated member when a support chat changes: `updateChatTsStats` re-reads the member, and the new item is sent as `GroupChat g (Just (GCSIMemberSupport (Just member')))` in `NewChatItems`. The app ignored it. +- **Marking read.** `APIChatItemsRead` computed the updated member in `updateGroupScopeUnreadStats` but discarded it, and returned `GroupChat gInfo' Nothing`. +- **Deleting.** Deletions in a support scope updated `GroupInfo` but kept the scope member from before the update. + +## Fix + +Load the list once per open group, then keep it current from what arrives. + +**Core** +- `updateGroupScopeUnreadStats` returns the updated `GroupChatScopeInfo` together with `GroupInfo`. +- `APIChatItemsRead` returns `GroupChat gInfo' chatScopeInfo'`. +- `deleteGroupCIs` puts the updated scope member into each deletion's chat info. +- Existing clients are unaffected: both apps strip the scope in `updateChatInfo`. + +**Android/desktop, and iOS** +- New `upsertSupportChatMember(cInfo)`. When the chat info carries a support-chat member, it upserts that member into the group's members. +- It is called for: + - `NewChatItems` events; + - `ChatItemsDeleted` events; + - send responses (Kotlin only); + - the mark-read response; + - the initial load of a support chat. +- The member list loads only if `membersLoaded` is false. The mention picker already uses this flag the same way, and it is reset when leaving the group. +- The refresh button is removed. The list is kept current by the updates above. +- iOS also sends `objectWillChange`, because updating a `GMember` in place does not re-render or re-sort the list. + +A member's first support message arrives as a `NewChatItems` event with that member, so a new support chat appears in the list without a reload. + +## Not covered + +- On iOS, `apiSendMessages` returns bare `ChatItem`s without chat info. The list's last-activity order after the user's own reply updates only when the group is reopened. +- API-response deletions made by the user themselves are not hooked. Only deletion events are. Items the user deletes are normally already read. + +## Alternatives considered + +- **Refresh only the viewed member on return** (`apiGroupMemberInfo`). Rejected: it still polls, and it misses changes to other members. +- **A core query returning only members with support chats** (`support_chat_ts IS NOT NULL`). This would also speed up the first list load. It is a larger API change and can follow separately. +- **A `(user_id, group_id, member_role)` index** for `getGroupModerators`, which runs on every support send. That is an independent change on a separate branch. diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 4fe288ca8e..2dca1d26db 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1311,12 +1311,12 @@ processChatCommand cxt nm = \case gInfo <- getGroupInfo db cxt user chatId pure (user, gInfo) chatScopeInfo <- mapM (getChatScopeInfo cxt user) scope - (timedItems, gInfo') <- withFastStore $ \db -> do - (timedItems, gInfo') <- updateGroupChatItemsReadList db cxt user gInfo chatScopeInfo itemIds + (timedItems, gInfo', chatScopeInfo') <- withFastStore $ \db -> do + (timedItems, gInfo', chatScopeInfo') <- updateGroupChatItemsReadList db cxt user gInfo chatScopeInfo itemIds timedItems' <- liftIO $ setGroupChatItemsDeleteAt db user chatId timedItems =<< getCurrentTime - pure (timedItems', gInfo') + pure (timedItems', gInfo', chatScopeInfo') forM_ timedItems $ \(itemId, deleteAt) -> startProximateTimedItemThread user (chatRef, itemId) deleteAt - pure $ CRItemsReadForChat user (AChatInfo SCTGroup $ GroupChat gInfo' Nothing) + pure $ CRItemsReadForChat user (AChatInfo SCTGroup $ GroupChat gInfo' chatScopeInfo') CTLocal -> throwCmdError "not supported" CTContactRequest -> throwCmdError "not supported" CTContactConnection -> throwCmdError "not supported" diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 3d05ab4069..6dda97efdf 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -566,8 +566,8 @@ deleteGroupCIs user gInfo chatScopeInfo items byGroupMember_ deletedTs = do Nothing -> pure deletions Just scopeInfo@GCSIMemberSupport {groupMember_} -> do let decStats = countDeletedUnreadItems groupMember_ deletions - gInfo' <- withFastStore' $ \db -> updateGroupScopeUnreadStats db cxt user gInfo scopeInfo decStats - pure $ map (updateDeletionGroupInfo gInfo') deletions + (gInfo', scopeInfo') <- withFastStore' $ \db -> updateGroupScopeUnreadStats db cxt user gInfo scopeInfo decStats + pure $ map (updateDeletionChatInfo gInfo' scopeInfo') deletions pure deletions' where deleteItem :: DB.Connection -> CChatItem 'CTGroup -> IO ChatItemDeletion @@ -590,12 +590,18 @@ deleteGroupCIs user gInfo chatScopeInfo items byGroupMember_ deletedTs = do 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} = + updateDeletionChatInfo :: GroupInfo -> GroupChatScopeInfo -> ChatItemDeletion -> ChatItemDeletion + updateDeletionChatInfo gInfo' scopeInfo' ChatItemDeletion {deletedChatItem, toChatItem} = ChatItemDeletion - { deletedChatItem = updateACIGroupInfo gInfo' deletedChatItem, - toChatItem = updateACIGroupInfo gInfo' <$> toChatItem + { deletedChatItem = updateACIChatInfo deletedChatItem, + toChatItem = updateACIChatInfo <$> toChatItem } + where + updateACIChatInfo :: AChatItem -> AChatItem + updateACIChatInfo = \case + AChatItem SCTGroup dir (GroupChat _gInfo _scopeInfo) ci -> + AChatItem SCTGroup dir (GroupChat gInfo' (Just scopeInfo')) ci + aci -> aci updateACIGroupInfo :: GroupInfo -> AChatItem -> AChatItem updateACIGroupInfo gInfo' = \case diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index b8909da1b3..6053d0b381 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -148,7 +148,7 @@ import Control.Monad import Control.Monad.Except import Control.Monad.IO.Class import Crypto.Random (ChaChaDRG) -import Data.Bifunctor (first) +import Data.Bifunctor (first, second) import Data.ByteString.Char8 (ByteString) import Data.Char (toLower) import Data.Either (fromRight, rights) @@ -2171,18 +2171,18 @@ getGroupUnreadTimedItems db User {userId} groupId scope = |] (userId, groupId, GCSTMemberSupport_, groupMemberId_, CISRcvNew) -updateGroupChatItemsReadList :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> NonEmpty ChatItemId -> ExceptT StoreError IO ([(ChatItemId, Int)], GroupInfo) +updateGroupChatItemsReadList :: DB.Connection -> StoreCxt -> User -> GroupInfo -> Maybe GroupChatScopeInfo -> NonEmpty ChatItemId -> ExceptT StoreError IO ([(ChatItemId, Int)], GroupInfo, Maybe GroupChatScopeInfo) updateGroupChatItemsReadList db cxt 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' <- case scopeInfo_ of - Nothing -> pure g + (g', scopeInfo') <- case scopeInfo_ of + Nothing -> pure (g, Nothing) Just scopeInfo@GCSIMemberSupport {groupMember_} -> do let decStats = countReadItems groupMember_ readItemsData - liftIO $ updateGroupScopeUnreadStats db cxt user g scopeInfo decStats - pure (timedItems readItemsData, g') + second Just <$> liftIO (updateGroupScopeUnreadStats db cxt user g scopeInfo decStats) + pure (timedItems readItemsData, g', scopeInfo') where getUpdateGroupItem :: UTCTime -> ChatItemId -> IO (Maybe (ChatItemId, Maybe Int, Maybe UTCTime, Maybe GroupMemberId, Maybe BoolInt)) getUpdateGroupItem currentTs itemId = @@ -2216,20 +2216,22 @@ updateGroupChatItemsReadList db cxt user@User {userId} g@GroupInfo {groupId} sco addTimedItem acc (itemId, Just ttl, Nothing, _, _) = (itemId, ttl) : acc addTimedItem acc _ = acc -updateGroupScopeUnreadStats :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupChatScopeInfo -> (Int, Int, Int) -> IO GroupInfo +updateGroupScopeUnreadStats :: DB.Connection -> StoreCxt -> User -> GroupInfo -> GroupChatScopeInfo -> (Int, Int, Int) -> IO (GroupInfo, GroupChatScopeInfo) updateGroupScopeUnreadStats db cxt 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'} + pure (g {membership = membership'}, scopeInfo) 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 + g' <- + if (not nowRequires && didRequire) + then decreaseGroupMembersRequireAttention db user g + else pure g + pure (g', GCSIMemberSupport (Just member')) where updateGMStats m@GroupMember {groupMemberId} = do currentTs <- getCurrentTime