mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 22:18:36 +00:00
core, ui: load only members with support chats for the support chats list
This commit is contained in:
@@ -168,6 +168,7 @@ object ChatModel {
|
||||
val groupMembers = mutableStateOf<List<GroupMember>>(emptyList())
|
||||
val groupMembersIndexes = mutableStateOf<Map<Long, Int>>(emptyMap())
|
||||
val membersLoaded = mutableStateOf(false)
|
||||
val supportMembersLoaded = mutableStateOf(false)
|
||||
// Runtime-only relay hostnames for pre-join channel display, not persisted — lost on app restart.
|
||||
// APIConnectPreparedGroup re-fetches fresh relays at connect time, so stale data doesn't affect join.
|
||||
val channelRelayHostnames = mutableStateMapOf<Long, List<String>>()
|
||||
@@ -969,6 +970,7 @@ object ChatModel {
|
||||
channelRelayHostnames.remove(groupId)
|
||||
}
|
||||
membersLoaded.value = false
|
||||
supportMembersLoaded.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -985,6 +987,7 @@ object ChatModel {
|
||||
groupMembers.value = emptyList()
|
||||
groupMembersIndexes.value = emptyMap()
|
||||
membersLoaded.value = false
|
||||
supportMembersLoaded.value = false
|
||||
}
|
||||
val memberIndex = groupMembersIndexes.value[member.groupMemberId]
|
||||
val updated = chatItems.value.map {
|
||||
|
||||
+10
@@ -2495,6 +2495,13 @@ object ChatController {
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiListSupportMembers(rh: Long?, groupId: Long): List<GroupMember>? {
|
||||
val r = sendCmd(rh, CC.ApiListSupportMembers(groupId))
|
||||
if (r is API.Result && r.res is CR.GroupMembers) return r.res.group.members
|
||||
Log.e(TAG, "apiListSupportMembers bad response: ${r.responseType} ${r.details}")
|
||||
return null
|
||||
}
|
||||
|
||||
suspend fun apiUpdateGroup(rh: Long?, groupId: Long, groupProfile: GroupProfile, isChannel: Boolean): GroupInfo? {
|
||||
val r = sendCmd(rh, CC.ApiUpdateGroupProfile(groupId, groupProfile))
|
||||
val errorTitle = if (isChannel) MR.strings.error_saving_channel_profile else MR.strings.error_saving_group_profile
|
||||
@@ -3959,6 +3966,7 @@ sealed class CC {
|
||||
class ApiRemoveMembers(val groupId: Long, val memberIds: List<Long>, val withMessages: Boolean): CC()
|
||||
class ApiLeaveGroup(val groupId: Long): CC()
|
||||
class ApiListMembers(val groupId: Long): CC()
|
||||
class ApiListSupportMembers(val groupId: Long): CC()
|
||||
class ApiUpdateGroupProfile(val groupId: Long, val groupProfile: GroupProfile): CC()
|
||||
class ApiSetPublicGroupAccess(val groupId: Long, val access: PublicGroupAccess): CC()
|
||||
class APICreateGroupLink(val groupId: Long, val memberRole: GroupMemberRole): CC()
|
||||
@@ -4175,6 +4183,7 @@ sealed class CC {
|
||||
is ApiRemoveMembers -> "/_remove #$groupId ${memberIds.joinToString(",")} messages=${onOff(withMessages)}"
|
||||
is ApiLeaveGroup -> "/_leave #$groupId"
|
||||
is ApiListMembers -> "/_members #$groupId"
|
||||
is ApiListSupportMembers -> "/_members support #$groupId"
|
||||
is ApiUpdateGroupProfile -> "/_group_profile #$groupId ${json.encodeToString(groupProfile)}"
|
||||
is APICreateGroupLink -> "/_create link #$groupId ${memberRole.name.lowercase()}"
|
||||
is APIGroupLinkMemberRole -> "/_set link role #$groupId ${memberRole.name.lowercase()}"
|
||||
@@ -4370,6 +4379,7 @@ sealed class CC {
|
||||
is ApiRemoveMembers -> "apiRemoveMembers"
|
||||
is ApiLeaveGroup -> "apiLeaveGroup"
|
||||
is ApiListMembers -> "apiListMembers"
|
||||
is ApiListSupportMembers -> "apiListSupportMembers"
|
||||
is ApiUpdateGroupProfile -> "apiUpdateGroupProfile"
|
||||
is APICreateGroupLink -> "apiCreateGroupLink"
|
||||
is APIGroupLinkMemberRole -> "apiGroupLinkMemberRole"
|
||||
|
||||
+2
@@ -174,6 +174,7 @@ fun ChatView(
|
||||
chatModel.groupMembers.value = emptyList()
|
||||
chatModel.groupMembersIndexes.value = emptyMap()
|
||||
chatModel.membersLoaded.value = false
|
||||
chatModel.supportMembersLoaded.value = false
|
||||
}
|
||||
showSearch.value = false
|
||||
searchText.value = ""
|
||||
@@ -385,6 +386,7 @@ fun ChatView(
|
||||
chatModel.groupMembers.value = emptyList()
|
||||
chatModel.groupMembersIndexes.value = emptyMap()
|
||||
chatModel.membersLoaded.value = false
|
||||
chatModel.supportMembersLoaded.value = false
|
||||
ChannelRelaysModel.reset()
|
||||
},
|
||||
info = {
|
||||
|
||||
+3
-3
@@ -42,12 +42,12 @@ fun ModalData.MemberSupportView(
|
||||
ModalManager.end.closeModals()
|
||||
}
|
||||
val membersLoading = remember { stateGetOrPut("membersLoading") { false } }
|
||||
LaunchedEffect(chatModel.membersLoaded.value) {
|
||||
if (!chatModel.membersLoaded.value && chatModel.chatId.value == groupInfo.id && !membersLoading.value) {
|
||||
LaunchedEffect(chatModel.membersLoaded.value, chatModel.supportMembersLoaded.value) {
|
||||
if (!chatModel.membersLoaded.value && !chatModel.supportMembersLoaded.value && chatModel.chatId.value == groupInfo.id && !membersLoading.value) {
|
||||
membersLoading.value = true
|
||||
withBGApi {
|
||||
try {
|
||||
setGroupMembers(rhId, groupInfo, chatModel)
|
||||
setSupportMembers(rhId, groupInfo, chatModel)
|
||||
} finally {
|
||||
membersLoading.value = false
|
||||
}
|
||||
|
||||
+22
@@ -274,6 +274,28 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setSupportMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope {
|
||||
val supportMembers = chatModel.controller.apiListSupportMembers(rhId, groupInfo.groupId)
|
||||
?: return@coroutineScope setGroupMembers(rhId, groupInfo, chatModel)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (chatModel.chatId.value != groupInfo.id) return@withContext
|
||||
val membersById = LinkedHashMap<Long, GroupMember>()
|
||||
chatModel.groupMembers.value.forEach { if (it.groupId == groupInfo.groupId) membersById[it.groupMemberId] = it }
|
||||
supportMembers.forEach { member ->
|
||||
val currentStats = membersById[member.groupMemberId]?.activeConn?.connectionStats
|
||||
val memberConn = member.activeConn
|
||||
membersById[member.groupMemberId] = if (currentStats != null && memberConn != null && memberConn.connectionStats == null) {
|
||||
member.copy(activeConn = memberConn.copy(connectionStats = currentStats))
|
||||
} else {
|
||||
member
|
||||
}
|
||||
}
|
||||
chatModel.groupMembers.value = membersById.values.toList()
|
||||
chatModel.populateGroupMembersIndexes()
|
||||
chatModel.supportMembersLoaded.value = true
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContactMenuItems(chat: Chat, contact: Contact, chatModel: ChatModel, showMenu: MutableState<Boolean>, showMarkRead: Boolean) {
|
||||
if (contact.nextAcceptContactRequest) {
|
||||
|
||||
@@ -411,6 +411,7 @@ undocumentedCommands =
|
||||
"APIGroupMemberQueueInfo",
|
||||
"APIHideUser",
|
||||
"APIImportArchive",
|
||||
"APIListSupportMembers",
|
||||
"APIMuteUser",
|
||||
"APIPlanForwardChatItems",
|
||||
"APIPrepareContact",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Member support chats: faster opening and sending
|
||||
|
||||
Two independent changes: the member support list is updated from events instead of reloading all members (opening), and group members get a role index for the moderator lookup (sending).
|
||||
Three changes: the member support list is updated from events instead of reloading all members (opening a member's chat), the list loads only members with support chats (opening the list), and group members get a role index for the moderator lookup (sending).
|
||||
|
||||
## Problem
|
||||
|
||||
@@ -65,7 +65,7 @@ A member's first support message arrives as a `NewChatItems` event with that mem
|
||||
- A reset of `membersLoaded` that lands between the load's result write and clearing the in-progress flag (one dispatch hop) is skipped. For example, an Android configuration change or an iOS resume at that exact moment. The list then stays unloaded until it is next returned to.
|
||||
- A failed member load (`apiListMembers` error) is not retried while the list stays open; it is retried when the list is reopened.
|
||||
- The in-progress flag belongs to one open instance of the list. Closing and reopening the list while its first load is still running starts a second full load (previously every open reloaded).
|
||||
- A full member load that is in flight when a support-chat update arrives overwrites that update with its snapshot. For example, the first list load can race a member's first support message. The member then reappears on their next message, when their chat is opened, or when the group is reopened.
|
||||
- A member load (full, or support members only) that is in flight when a support-chat update arrives overwrites that update with its snapshot. For example, the first list load can race a member's first support message. The member then reappears on their next message, when their chat is opened, or when the group is reopened.
|
||||
- Support stats snapshots from different events and responses are applied in arrival order, so a rare reordering can briefly show an older count until the next update for that member.
|
||||
- On iOS, updating an existing member in place (including the new support-stats upserts) updates that row's badges, because rows observe their `GMember`, but not the list order or filter until the next `ChatModel` change. A re-render is forced only when an existing member gets their first support chat, so that the member appears in the list. The next `ChatModel` change usually follows within about a second of a read (the unread counter), so the list can now re-sort while a member's support chat is pushed from it. Rows keep their identity (`ForEach` by member id), and the row's leading swipe action is now built unconditionally with the condition inside it, so the row that holds the active `NavigationLink(isActive:)` is not rebuilt when its unread state flips. Whether re-sorting alone can pop the pushed chat on older iOS versions needs a device test.
|
||||
- The connection-state labels in rows (failed, disabled, inactive) come from `activeConn`. Neither app handles `ConnectionDisabled` or `ConnectionInactive`, so these labels now refresh only on the next full member event for that member (role, profile, connected) or when the group is reopened.
|
||||
@@ -75,6 +75,16 @@ A member's first support message arrives as a `NewChatItems` event with that mem
|
||||
|
||||
- In channels (`useRelays`), opening a support chat runs the chat view's initialisation, which loads all members for relay groups on both platforms. So each support chat open in a channel still does a full member load. This was already the case before this change; the reported bug is in an ordinary group.
|
||||
|
||||
## Opening the list: load only members with support chats
|
||||
|
||||
**Cause.** The list shows only members with a support chat, usually a handful, but its first load was `apiListMembers`: every member of the group with profile and connection, serialized to JSON by the core and decoded by the app. On the reporter's device that was 1.6 s on average and 15 s at most.
|
||||
|
||||
**Fix.** A new command `/_members support #<groupId>` (`APIListSupportMembers`, listed with the other app-only commands in `undocumentedCommands`) returns `groupMemberQuery ... AND m.support_chat_ts IS NOT NULL` in the existing `CRGroupMembers` response. On Kotlin the list loads with it (`setSupportMembers`): the members are upserted into the group's member list and `supportMembersLoaded` is set, without setting `membersLoaded`, so mentions and group info still load all members when they need them. `supportMembersLoaded` is reset everywhere `membersLoaded` is. The list loads if neither flag is set. If the command fails, for example on a remote host running an older app that does not know it, the list falls back to the full member load.
|
||||
|
||||
**Measured** on the 20,031-member test group with 30 support chats, same query text, warm cache: all members 208.1 ms (20,030 rows); members with support chats 7.3 ms (30 rows). The JSON encoding and decoding of 20k members is avoided as well.
|
||||
|
||||
**iOS** is unchanged here: its list is reached only through group info, which loads all members before it opens (`ChatView.swift:522`), so the list itself never does the first load. Making the list fast on iOS needs group info to stop waiting for the full load, a separate change.
|
||||
|
||||
## Sending: index group members by role
|
||||
|
||||
**Cause.** A support-chat send gets its recipients from `getGroupModerators` (`getGroupRecipients`, `Library/Internal.hs`): `groupMemberQuery ... WHERE m.user_id = ? AND m.group_id = ? AND ... AND m.member_role IN (?,?,?)`. The only index is `idx_group_members_group_id (user_id, group_id)`, so SQLite reads every member row of the group and filters on the role. On the reporter's device this query took 178 ms on average and 4.8 s at most, over 80 calls, and it holds the single database connection while it runs.
|
||||
@@ -107,4 +117,4 @@ Keeping the old index alongside was also checked: SQLite then chooses the new in
|
||||
## 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.
|
||||
- **An index on `(user_id, group_id, support_chat_ts)`** for the support-members query. It cuts the query from 7.3 ms to 0.3 ms at 20k members, but every support message updates `support_chat_ts`, so the index would add a write to each one. Not added.
|
||||
|
||||
@@ -471,6 +471,7 @@ data ChatCommand
|
||||
| APIRemoveMembers {groupId :: GroupId, groupMemberIds :: NonEmpty GroupMemberId, withMessages :: Bool}
|
||||
| APILeaveGroup {groupId :: GroupId}
|
||||
| APIListMembers {groupId :: GroupId}
|
||||
| APIListSupportMembers {groupId :: GroupId}
|
||||
| APIUpdateGroupProfile {groupId :: GroupId, groupProfile :: GroupProfile}
|
||||
| APISetPublicGroupAccess GroupId PublicGroupAccess
|
||||
| APICreateGroupLink {groupId :: GroupId, memberRole :: GroupMemberRole}
|
||||
|
||||
@@ -3230,6 +3230,8 @@ processChatCommand cxt nm = \case
|
||||
pure (ms, filter memberCurrentOrPending ms)
|
||||
APIListMembers groupId -> withUser $ \user ->
|
||||
CRGroupMembers user <$> withFastStore (\db -> getGroup db cxt user groupId)
|
||||
APIListSupportMembers groupId -> withUser $ \user ->
|
||||
CRGroupMembers user <$> withFastStore (\db -> getGroupSupportMembers db cxt user groupId)
|
||||
-- -- validate: prohibit to delete/archive if member is pending (has to communicate approval or rejection)
|
||||
-- APIDeleteGroupConversations groupId _gcId -> withUser $ \user -> do
|
||||
-- _gInfo <- withFastStore $ \db -> getGroupInfo db cxt user groupId
|
||||
@@ -6154,6 +6156,7 @@ chatCommandP =
|
||||
"/_remove #" *> (APIRemoveMembers <$> A.decimal <*> _strP <*> (" messages=" *> onOffP <|> pure False)),
|
||||
"/_leave #" *> (APILeaveGroup <$> A.decimal),
|
||||
"/_members #" *> (APIListMembers <$> A.decimal),
|
||||
"/_members support #" *> (APIListSupportMembers <$> A.decimal),
|
||||
-- "/_archive conversations #" *> (APIArchiveGroupConversations <$> A.decimal <*> _strP),
|
||||
-- "/_delete conversations #" *> (APIDeleteGroupConversations <$> A.decimal <*> _strP),
|
||||
"/_server test " *> (APITestProtoServer <$> A.decimal <* A.space <*> strP),
|
||||
|
||||
@@ -67,6 +67,7 @@ module Simplex.Chat.Store.Groups
|
||||
getGroupMemberViaMemberId_,
|
||||
getScopeMemberIdViaMemberId,
|
||||
getGroupMembers,
|
||||
getGroupSupportMembers,
|
||||
getGroupMembersByIndexes,
|
||||
getSupportScopeMembersByIndexes,
|
||||
getGroupModerators,
|
||||
@@ -1235,6 +1236,19 @@ getGroupMemberViaMemberId_ db User {userId} groupId memberId =
|
||||
"SELECT group_member_id, member_category FROM group_members WHERE user_id = ? AND group_id = ? AND member_id = ?"
|
||||
(userId, groupId, memberId)
|
||||
|
||||
getGroupSupportMembers :: DB.Connection -> StoreCxt -> User -> GroupId -> ExceptT StoreError IO Group
|
||||
getGroupSupportMembers db cxt user@User {userId, userContactId} groupId = do
|
||||
gInfo <- getGroupInfo db cxt user groupId
|
||||
currentTs <- liftIO getCurrentTime
|
||||
members <-
|
||||
liftIO $
|
||||
map (toContactMember currentTs cxt 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, groupId, userContactId)
|
||||
pure $ Group gInfo members
|
||||
|
||||
getGroupMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember]
|
||||
getGroupMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do
|
||||
currentTs <- getCurrentTime
|
||||
|
||||
Reference in New Issue
Block a user