core, ui: update member support chats list from events instead of reloading all members

This commit is contained in:
Narasimha-sc
2026-09-30 15:38:13 +00:00
parent 93910b80e9
commit 84ed5a9a2b
14 changed files with 118 additions and 58 deletions
+7
View File
@@ -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 {
+3
View File
@@ -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()
@@ -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) {
@@ -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
@@ -762,6 +762,7 @@ fun ChatView(
)
if (updatedChatInfo != null) {
chatModel.chatsContext.updateChatInfo(chatRh, updatedChatInfo)
chatModel.upsertSupportChatMember(chatRh, updatedChatInfo)
}
}
withContext(Dispatchers.Main) {
@@ -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
@@ -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,
+60
View File
@@ -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.
+4 -4
View File
@@ -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"
+12 -6
View File
@@ -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
+13 -11
View File
@@ -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