mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-05 18:49:03 +00:00
core, ui: update member support chats list from events instead of reloading all members
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
+2
@@ -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) {
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -762,6 +762,7 @@ fun ChatView(
|
||||
)
|
||||
if (updatedChatInfo != null) {
|
||||
chatModel.chatsContext.updateChatInfo(chatRh, updatedChatInfo)
|
||||
chatModel.upsertSupportChatMember(chatRh, updatedChatInfo)
|
||||
}
|
||||
}
|
||||
withContext(Dispatchers.Main) {
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+4
-25
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user