core, tests, ui: keep member order with role index, skip down-migration schema comparison, reload support list on return with older remote hosts

This commit is contained in:
Narasimha-sc
2026-09-27 10:16:52 +00:00
parent 2698b0e12d
commit b7cce5513e
19 changed files with 127 additions and 141 deletions
+8 -16
View File
@@ -424,7 +424,6 @@ final class ChatModel: ObservableObject {
@Published var groupMembers: [GMember] = []
@Published var groupMembersIndexes: Dictionary<Int64, Int> = [:] // groupMemberId to index in groupMembers list
@Published var membersLoaded = false
var membersLoadedGroupId: Int64?
// 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.
@Published var channelRelayHostnames: [Int64: [String]] = [:]
@@ -578,12 +577,9 @@ final class ChatModel: ObservableObject {
let groupMembers = await apiListMembers(groupInfo.groupId)
await MainActor.run {
if chatId == groupInfo.id {
if let groupMembers {
self.groupMembers = groupMembers.map { GMember.init($0) }
self.populateGroupMembersIndexes()
self.membersLoaded = true
self.membersLoadedGroupId = groupInfo.groupId
}
self.groupMembers = groupMembers.map { GMember.init($0) }
self.populateGroupMembersIndexes()
self.membersLoaded = true
updateView()
}
}
@@ -1331,15 +1327,11 @@ final class ChatModel: ObservableObject {
}
func upsertSupportChatMember(_ cInfo: ChatInfo) {
if case let .group(groupInfo, .memberSupport(member?)?) = cInfo, chatId == groupInfo.id {
var m = member
var supportChatAdded = false
if let current = getGroupMember(member.groupMemberId)?.wrapped {
supportChatAdded = current.supportChat == nil && member.supportChat != nil
m = current
m.supportChat = member.supportChat
m.memberProfile = member.memberProfile
}
if case let .group(groupInfo, .memberSupport(member?)?) = cInfo {
var m = getGroupMember(member.groupMemberId)?.wrapped ?? member
let supportChatAdded = m.supportChat == nil && member.supportChat != nil
m.supportChat = member.supportChat
m.memberProfile = member.memberProfile
_ = upsertGroupMember(groupInfo, m)
if supportChatAdded {
objectWillChange.send()
+4 -6
View File
@@ -592,10 +592,8 @@ private func processSendMessageCmd(toChatType: ChatType, cmd: ChatCommand) async
} else {
r = await chatApiSendCmd(cmd, bgDelay: msgDelay)
if case let .result(.newChatItems(_, aChatItems)) = r {
await MainActor.run {
for aChatItem in aChatItems {
chatModel.upsertSupportChatMember(aChatItem.chatInfo)
}
if let last = aChatItems.last {
await MainActor.run { chatModel.upsertSupportChatMember(last.chatInfo) }
}
return aChatItems.map { $0.chatItem }
}
@@ -2062,10 +2060,10 @@ func apiLeaveGroup(_ groupId: Int64) async throws -> GroupInfo {
}
// use ChatModel's loadGroupMembers from views
func apiListMembers(_ groupId: Int64) async -> [GroupMember]? {
func apiListMembers(_ groupId: Int64) async -> [GroupMember] {
let r: APIResult<ChatResponse2> = await chatApiSendCmd(.apiListMembers(groupId: groupId))
if case let .result(.groupMembers(_, group)) = r { return group.members }
return nil
return []
}
func filterMembersToAdd(_ ms: [GMember]) -> [Contact] {
@@ -359,9 +359,6 @@ struct ChatView: View {
revealedItems = Set()
stopAudioPlayer()
if let cId {
chatModel.groupMembers = []
chatModel.groupMembersIndexes.removeAll()
chatModel.membersLoaded = false
if let c = chatModel.getChat(cId) {
chat = c
}
@@ -3083,7 +3080,6 @@ func archiveReports(_ chat: Chat, _ itemIds: [Int64], _ forAll: Bool, _ onSucces
}
if let updatedChatInfo = deleted.last?.deletedChatItem.chatInfo {
ChatModel.shared.updateChatInfo(updatedChatInfo)
ChatModel.shared.upsertSupportChatMember(updatedChatInfo)
}
}
await onSuccess()
@@ -82,7 +82,7 @@ func acceptMember(_ groupInfo: GroupInfo, _ member: GroupMember, _ role: GroupMe
do {
let (gInfo, acceptedMember) = try await apiAcceptMember(groupInfo.groupId, member.groupMemberId, role)
await MainActor.run {
_ = ChatModel.shared.upsertGroupMember(gInfo, acceptedMember)
_ = ChatModel.shared.upsertGroupMember(gInfo, ChatModel.shared.withLoadedSupportChat(acceptedMember))
ChatModel.shared.updateGroup(gInfo)
dismiss?()
}
@@ -298,7 +298,7 @@ struct GroupMemberInfoView: View {
do {
let (_, stats) = try await apiGroupMemberInfo(groupInfo.apiId, member.groupMemberId)
let getCode = (member.memberActive || (groupInfo.useRelays && member.memberCurrent)) && member.memberRole != .relay
let (mem, code) = getCode ? try await apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (member, nil)
let (mem, code) = getCode ? try await apiGetGroupMemberCode(groupInfo.apiId, member.groupMemberId) : (chatModel.withLoadedSupportChat(member), nil)
await MainActor.run {
_ = chatModel.upsertGroupMember(groupInfo, mem)
connectionStats = stats
@@ -578,7 +578,7 @@ struct GroupMemberInfoView: View {
connectionCode: code,
connectionVerified: member.verified,
verify: { code in
var member = groupMember.wrapped
var member = chatModel.withLoadedSupportChat(groupMember.wrapped)
if let r = apiVerifyGroupMember(member.groupId, member.groupMemberId, connectionCode: code) {
let (verified, existingCode) = r
let connCode = verified ? SecurityCode(securityCode: existingCode, verifiedAt: .now) : nil
@@ -773,7 +773,7 @@ struct GroupMemberInfoView: View {
let stats = try apiSwitchGroupMember(groupInfo.apiId, groupMember.groupMemberId)
connectionStats = stats
await MainActor.run {
chatModel.updateGroupMemberConnectionStats(groupInfo, groupMember.wrapped, stats)
chatModel.updateGroupMemberConnectionStats(groupInfo, chatModel.withLoadedSupportChat(groupMember.wrapped), stats)
dismiss()
}
} catch let error {
@@ -791,7 +791,7 @@ struct GroupMemberInfoView: View {
let stats = try apiAbortSwitchGroupMember(groupInfo.apiId, groupMember.groupMemberId)
connectionStats = stats
await MainActor.run {
chatModel.updateGroupMemberConnectionStats(groupInfo, groupMember.wrapped, stats)
chatModel.updateGroupMemberConnectionStats(groupInfo, chatModel.withLoadedSupportChat(groupMember.wrapped), stats)
}
} catch let error {
logger.error("abortSwitchMemberAddress apiAbortSwitchGroupMember error: \(responseError(error))")
@@ -869,7 +869,7 @@ func updateMemberSettings(_ gInfo: GroupInfo, _ member: GroupMember, _ memberSet
do {
try await apiSetMemberSettings(gInfo.groupId, member.groupMemberId, memberSettings)
await MainActor.run {
var mem = member
var mem = ChatModel.shared.withLoadedSupportChat(member)
mem.memberSettings = memberSettings
_ = ChatModel.shared.upsertGroupMember(gInfo, mem)
}
@@ -13,25 +13,17 @@ struct MemberSupportView: View {
@EnvironmentObject var chatModel: ChatModel
@EnvironmentObject var theme: AppTheme
@State private var searchText: String = ""
@State private var membersLoading = false
@FocusState private var searchFocussed
var groupInfo: GroupInfo
@Binding var scrollToItemId: ChatItem.ID?
var body: some View {
viewBody()
.onAppear { loadMembersIfNeeded() }
.onChange(of: chatModel.membersLoaded) { _ in loadMembersIfNeeded() }
}
private func loadMembersIfNeeded() {
if (!chatModel.membersLoaded || chatModel.membersLoadedGroupId != groupInfo.groupId) && chatModel.chatId == groupInfo.id && !membersLoading {
membersLoading = true
Task {
await chatModel.loadGroupMembers(groupInfo)
await MainActor.run { membersLoading = false }
.onAppear {
if !chatModel.membersLoaded {
Task { await chatModel.loadGroupMembers(groupInfo) }
}
}
}
}
@ViewBuilder private func viewBody() -> some View {
@@ -383,14 +383,14 @@ object ChatModel {
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
if (cInfo !is ChatInfo.Group || remoteHostId() != rhId) return
val member = (cInfo.groupChatScope as? GroupChatScopeInfo.MemberSupport)?.groupMember_ ?: return
val current = groupMembersIndexes.value[member.groupMemberId]?.let { groupMembers.value.getOrNull(it) }
val current = getGroupMember(member.groupMemberId)
chatsContext.upsertGroupMember(rhId, cInfo.groupInfo, current?.copy(supportChat = member.supportChat, memberProfile = member.memberProfile) ?: member)
}
fun withLoadedSupportChat(member: GroupMember): GroupMember {
val supportChat = groupMembersIndexes.value[member.groupMemberId]?.let { groupMembers.value.getOrNull(it) }?.supportChat
val supportChat = getGroupMember(member.groupMemberId)?.supportChat
return if (supportChat != null) member.copy(supportChat = supportChat) else member
}
@@ -986,8 +986,10 @@ object ChatModel {
// stale data, should be cleared at that point, otherwise, duplicated items will be here which will produce crashes in LazyColumn
groupMembers.value = emptyList()
groupMembersIndexes.value = emptyMap()
membersLoaded.value = false
supportMembersLoaded.value = false
if (chatId.value == groupInfo.id) {
membersLoaded.value = false
supportMembersLoaded.value = false
}
}
val memberIndex = groupMembersIndexes.value[member.groupMemberId]
val updated = chatItems.value.map {
@@ -1211,9 +1211,7 @@ object ChatController {
val r = sendCmd(rh, cmd)
return when {
r is API.Result && r.res is CR.NewChatItems -> {
withContext(Dispatchers.Main) {
r.res.chatItems.forEach { chatModel.upsertSupportChatMember(rh, it.chatInfo) }
}
r.res.chatItems.lastOrNull()?.let { withContext(Dispatchers.Main) { chatModel.upsertSupportChatMember(rh, it.chatInfo) } }
r.res.chatItems
}
r is API.Error && r.err is ChatError.ChatErrorStore && r.err.storeError is StoreError.LargeMsg && cmd is CC.ApiSendMessages -> {
@@ -2488,11 +2486,11 @@ object ChatController {
return null
}
suspend fun apiListMembers(rh: Long?, groupId: Long): List<GroupMember>? {
suspend fun apiListMembers(rh: Long?, groupId: Long): List<GroupMember> {
val r = sendCmd(rh, CC.ApiListMembers(groupId))
if (r is API.Result && r.res is CR.GroupMembers) return r.res.group.members
Log.e(TAG, "apiListMembers bad response: ${r.responseType} ${r.details}")
return null
return emptyList()
}
suspend fun apiListSupportMembers(rh: Long?, groupId: Long): List<GroupMember>? {
@@ -3445,7 +3445,6 @@ private fun archiveReports(chatRh: Long?, chatInfo: ChatInfo, itemIds: List<Long
}
deleted.lastOrNull()?.deletedChatItem?.chatInfo?.let { updatedChatInfo ->
chatModel.chatsContext.updateChatInfo(chatRh, updatedChatInfo)
chatModel.upsertSupportChatMember(chatRh, updatedChatInfo)
}
}
withContext(Dispatchers.Main) {
@@ -117,7 +117,7 @@ private fun acceptMember(rhId: Long?, groupInfo: GroupInfo, member: GroupMember,
val r = chatModel.controller.apiAcceptMember(rhId, groupInfo.groupId, member.groupMemberId, role)
if (r != null) {
withContext(Dispatchers.Main) {
chatModel.chatsContext.upsertGroupMember(rhId, r.first, r.second)
chatModel.chatsContext.upsertGroupMember(rhId, r.first, chatModel.withLoadedSupportChat(r.second))
chatModel.chatsContext.updateGroup(rhId, r.first)
}
}
@@ -41,20 +41,19 @@ fun ModalData.MemberSupportView(
KeyChangeEffect(chatModel.chatId.value) {
ModalManager.end.closeModals()
}
val membersLoading = remember { stateGetOrPut("membersLoading") { false } }
LaunchedEffect(Unit) {
if (rhId != null) setSupportMembers(rhId, groupInfo, chatModel)
}
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 {
setSupportMembers(rhId, groupInfo, chatModel)
} finally {
membersLoading.value = false
}
}
if (rhId != null || chatModel.chatId.value != groupInfo.id) return@LaunchedEffect
val otherGroupMembers = chatModel.groupMembers.value.firstOrNull()?.let { it.groupId != groupInfo.groupId } == true
if ((!chatModel.membersLoaded.value && !chatModel.supportMembersLoaded.value) || otherGroupMembers) {
setSupportMembers(rhId, groupInfo, chatModel)
}
}
ModalView(close = close) {
ModalView(
close = close
) {
MemberSupportViewLayout(
chat,
groupInfo,
@@ -253,7 +253,7 @@ suspend fun apiFindMessages(chatsCtx: ChatModel.ChatsContext, ch: Chat, contentT
suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope {
// groupMembers loading can take a long time and if the user already closed the screen, coroutine may be canceled
val groupMembers = chatModel.controller.apiListMembers(rhId, groupInfo.groupId) ?: return@coroutineScope
val groupMembers = chatModel.controller.apiListMembers(rhId, groupInfo.groupId)
val currentMembersById = chatModel.groupMembers.value.associateBy { it.id }
val newMembers = groupMembers.map { newMember ->
val currentMember = currentMembersById[newMember.id]
@@ -265,8 +265,7 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo
newMember
}
}
withContext(Dispatchers.Main) {
if (chatModel.chatId.value != groupInfo.id && chatModel.creatingChannelId.value != groupInfo.id) return@withContext
withContext(NonCancellable + Dispatchers.Main) {
chatModel.groupMembersIndexes.value = emptyMap()
chatModel.groupMembers.value = newMembers
chatModel.membersLoaded.value = true
@@ -274,11 +273,15 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo
}
}
suspend fun setSupportMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) = coroutineScope {
suspend fun setSupportMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatModel) {
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
if (supportMembers == null) {
setGroupMembers(rhId, groupInfo, chatModel)
return
}
withContext(NonCancellable + Dispatchers.Main) {
if (chatModel.chatId.value != groupInfo.id || chatModel.remoteHostId() != rhId) return@withContext
if (chatModel.groupMembers.value.any { it.groupId != groupInfo.groupId }) chatModel.membersLoaded.value = false
val membersById = LinkedHashMap<Long, GroupMember>()
chatModel.groupMembers.value.forEach { if (it.groupId == groupInfo.groupId) membersById[it.groupMemberId] = it }
supportMembers.forEach { member ->
+35 -33
View File
@@ -9,14 +9,14 @@ A group owner with a large group opens the "Chat with members" list, then opens
## 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`.
2. `MemberSupportView` had `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 did 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.
When the list is opened from the chat toolbar, 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
@@ -34,9 +34,9 @@ Load the list once per open group, then keep it current from what arrives.
- `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.
- The group send response re-reads the support scope member after `saveSndChatItems` has updated `support_chat_ts`, instead of returning the member from before the send. If that read fails, it falls back to the pre-send member rather than failing a send that has already happened.
- Internal items go through `createChatItems`, for example "new member pending review" (unread and attention +1). It now builds its items from the `ChatInfo` returned by `updateChatTsStats`, as `saveRcvChatItem'` already does, instead of the pre-update `toChatInfo cd`. Otherwise a new pending member would appear without a badge. This applies to every chat type: direct and main group chats now carry the updated `chatTs`, as received items already did.
- A moderation that arrives before its message creates the item and marks it deleted. The `ChatItemsDeleted` event now carries the group info and scope returned by creating the item, not those from before it.
- The group send response carries the chat info returned by `updateChatTsStats`, via a new `saveSndChatItems'` that `saveSndChatItems` wraps, instead of the member from before the send; for main-chat sends it carries the updated `chatTs` too.
- Internal items go through `createChatItems`, for example "new member pending review" (unread +1, and attention +1 when the pending member is the sender). It now builds its items from the `ChatInfo` returned by `updateChatTsStats`, as `saveRcvChatItem'` already does, instead of the pre-update `toChatInfo cd`. Otherwise a new pending member would appear without a badge. This applies to every chat type: direct and main group chats now carry the updated `chatTs`, as received items already did.
- A moderation that arrives before its message creates the item and marks it deleted. The `ChatItemsDeleted` event now carries the scope returned by creating the item, not the one from before it.
- Opening a member's support chat for the first time sets `support_chat_ts` and now returns the re-read member, so the new chat appears in the list.
- Existing clients are unaffected: both apps strip the scope in `updateChatInfo`.
@@ -45,49 +45,49 @@ Load the list once per open group, then keep it current from what arrives.
- It is called for:
- `NewChatItems` events;
- `ChatItemsDeleted` events;
- delete responses (items and reports);
- delete responses for items (the reports response carries no scope, as `APIDeleteReceivedReports` passes none, and the stats are not changed either);
- send and forward responses (in `processSendMessageCmd` on both platforms);
- the mark-read response;
- the initial load of a support chat.
- `JoinedGroupMember` and `JoinedGroupMemberConnecting` are emitted right after the "new member pending review" item, and they carry the member with zero support stats. Their handlers keep the support stats already in the list, so they do not erase the badge the item event just set. On Kotlin that item event can also be applied after them; the merge covers either order.
- The member list loads only if `membersLoaded` is false. The load runs outside the list screen's lifetime (`withBGApi` on Kotlin, a `Task` on iOS) and is tracked by an in-progress flag in the list's state. So opening a member's chat before the first load finishes neither cancels it nor causes another full load on return. On both platforms the list also reloads when `membersLoaded` is reset while it is open and its group is still the open chat. Examples are `ChatView` recomposing after an Android configuration change, and the iOS reset on resume. The mention picker already uses this flag the same way, and it is reset when leaving the group.
- `apiListMembers` returns `null`/`nil` on error on both platforms, and the member load then keeps the current state, so a failed load does not mark members as loaded. iOS still runs the load's completion, so group info still opens.
- iOS clears the loaded members and resets `membersLoaded` when the open chat changes without going through the chat list (notification tap, "forwarded from", member info), as Kotlin already does. Because group info can also be opened from a message avatar without reloading members, the iOS list additionally reloads unless members were loaded for this group (`membersLoadedGroupId`).
- iOS resets `membersLoaded` when chats are refreshed on resume, because the notification extension may have changed support chats while the app was suspended.
- Kotlin `upsertGroupMember` also resets `membersLoaded` when it clears another group's stale members.
- Kotlin `setGroupMembers` now writes on the main thread, where all upserts run, so an upsert can no longer land between clearing the index and rebuilding it and add a duplicate. It writes its result only if the group is still the open chat (or the channel being created), as iOS `loadGroupMembers` already does. Without this check, a slow load from a previously opened channel could finish after a chat switch and mark another group's members as loaded. The old reload on every return hid that.
- `JoinedGroupMember` and `JoinedGroupMemberConnecting` are emitted right after the "new member pending review" item, and they carry the member as read before that item. Their handlers keep the support stats already in the list, so they do not erase the badge the item event just set. The same applies to the accept-member response, which carries the member from before the accept item updated `support_chat_ts`. On Kotlin that item event can also be applied after them; the merge covers either order. Other member events that follow a group event item (accepted by another moderator, connected, profile updated) need no merge: those items do not change support stats (`ciRequiresAttention` is false for them).
- The list loads members only when they are not loaded for its group. On Kotlin with the local host, the load runs in the list's `LaunchedEffect`, keyed on the loaded flags, so it restarts when they are reset (for example by `ChatView` after an Android configuration change); leaving the list cancels a load whose response has not arrived. With a remote host it loads on every open (see Known limitations). On iOS the list loads when it appears if `membersLoaded` is not set, so after the reset on resume it reloads the next time it appears.
- iOS resets `membersLoaded` when chats are refreshed on resume after the app was suspended, because the notification extension may have changed support chats while the app was suspended.
- Kotlin `upsertGroupMember` also resets the loaded flags when it clears another group's stale members for the open chat.
- On Kotlin, chat ids (`#<groupId>`) are not unique across remote hosts, so `setSupportMembers` and `upsertSupportChatMember` also check that the result is for the current remote host, and `setSupportMembers` that it is still for the open chat; the fallback full load, as before, checks neither.
- Kotlin `setGroupMembers` now writes on the main thread, where all upserts run, so an upsert can no longer land between clearing the index and rebuilding it and add a duplicate.
- On iOS, member info opened from a support chat holds a copy of the member taken when that chat was opened. Member info, block/unblock for me, security code verification and changing or aborting the receiving address now keep the list's support stats (`withLoadedSupportChat`) instead of writing back those of the member they started with; other fields are written as before. The old reload on return hid this. On Kotlin member info already reads the member from the model.
- The refresh button is removed. The list is kept current by the updates above.
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.
## Known limitations
- 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 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.
- On iOS, a reset on resume while a member's support chat is open over the list runs one full member load in the background (the list stays in the navigation stack). An open started during that load waits for it once.
## Not addressed
- 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.
- On Kotlin, the group's member list is shared with a channel being created (desktop can show both at once), and an update for the channel clears the other group's members, as before this change. The loaded flags are reset only when the cleared list is written for the open chat, so the open list of another group does not reload after each channel update; it stays empty until it is reopened or returned to, when it loads because the member list holds another group's members, or until the next update for a member of that group, which clears the channel's members and resets the loaded flags, so the list loads. That load drops the channel's relay members from the list until the next relay update.
- A failed member load stores an empty list marked as loaded, as before, but reopening the list no longer retries it (except with a remote host), so the list shows no members until group info or the group is reopened.
- On a remote host the list still queries on every return, because an older host neither supports `/_members support` nor returns the updated member on read, send and delete, and a failed command cannot be told apart from an older host. It runs the support query, and falls back to the full load, the previous behaviour, when the command fails.
- A member load in flight when a support-chat update arrives overwrites that update with its snapshot: the full load for all members, and the Kotlin support-members load for the members it returns. 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. This includes the responses to role change, block for all, fix connection and, on Kotlin, changing or aborting the member's receiving address, which carry the member as read at the start of the command. On Kotlin, `NewChatItems` is applied asynchronously while other events and responses are applied directly, so a later update can be applied before an earlier item's stats and profile.
- 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. The support-stats upsert forces a re-render only when a member is added or an existing member gets their first support chat, so that the member appears in the list. The next `ChatModel` change usually follows soon after 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 when the member is re-read and written to the list, for example on the next full member event (role, profile, connected), after a role, block or fix-connection action, or when group info or the group is reopened.
- On Kotlin, security code verification and block/unblock for me upsert the member as read when the action started, and member info opened from the chat view upserts the member as read by its API calls, or the member it was opened with when they fail, so a support update that lands in between is overwritten until that member's next update.
- A member who is also a contact and changes their profile over the direct connection: the list shows the old name and image until that member's next support-chat update, when group info is opened, or when the group is reopened.
- On iOS, a list that is on screen when the app resumes is not reloaded until the user leaves it and comes back.
- On iOS, the reset on resume also makes the mention picker reload all members when an existing mention is edited after a resume, and returning to the list before its load finishes after a resume starts another full load.
- On Kotlin, opening the list from the chat toolbar loads only the support members and does not set `membersLoaded`, so editing an existing mention keeps loading all members on each keystroke until one load finishes, as it already does after opening any 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.
**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 returned members replace their entries in the group's member list (keeping cached connection stats) and `supportMembersLoaded` is set, without setting `membersLoaded`, so mentions and group info still load all members when they need them; `setSupportMembers` clears `membersLoaded` when it drops another group's members, so mentions do not treat the smaller list as complete. The list also loads when the member list holds another group's members; opened from group info, it already has all members. If the command fails, for example when desktop controls a remote host running an older app, the list falls back to the full member load (see Known limitations for remote hosts).
**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.
**Measured** on the 20,031-member test group with 30 support chats, same query text, warm cache, a separate run from the index measurements below: 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.
**iOS** is unchanged here: its list is reached only through group info. Opened from the toolbar, group info loads all members first (`ChatView.swift:520`); opened from a channel avatar, it relies on the load when the channel was opened. So the list normally finds members loaded, and loads itself only when `membersLoaded` is not set, for example after the reset on resume.
## 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.
**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 (?,?,?)`. No index includes `member_role`; the query uses `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.
**Fix.** Migration `20260926_member_role_index` (SQLite and Postgres) replaces `idx_group_members_group_id (user_id, group_id)` with `idx_group_members_group_id_member_role (user_id, group_id, member_role)`. The new index has the old one as a prefix, so every query that used the old index can use the new one, and the number of indexes on `group_members` stays the same.
@@ -103,18 +103,20 @@ A member's first support message arrives as a `NewChatItems` event with that mem
Only the role-filtered queries change; the other rows are within noise, because those queries read every member row either way.
**Plans.** I compared `EXPLAIN QUERY PLAN` before and after for all 144 queries touching `group_members` in `chat_query_plans.txt`, with foreign keys on, on SQLite 3.39.2 (the version the apps bundle) and 3.40.1:
**Plans.** I compared `EXPLAIN QUERY PLAN` before and after for all 144 queries touching `group_members` in `chat_query_plans.txt` (the new support-members query is not among them, as no test runs it), with foreign keys on, on SQLite 3.39.2 (the version the apps bundle) and 3.40.1:
- 135 plans are unchanged.
- 4 now use `member_role` in the index: `getGroupModerators`, its two-role variant, and the two `member_role = ?` relay queries.
- 4 now use `member_role` in the index: the three-role query (`getGroupModerators`, `getGroupRosterMembers`), the two-role query (`getGroupAdminsMods`), the `member_role = ?` query (`getGroupOnlyMembers`, `getGroupOwners`) and the relay query (`getGroupRelayMembers`). Single-role queries keep row-id order, so they need no sort.
- 5 read the same `(user_id, group_id)` range from the new index. The covering `DELETE` stays covering.
- None gains a scan or a temp B-tree sort.
- The only query that orders by `group_member_id` filters on `group_id` alone and never used this index.
Keeping the old index alongside was also checked: SQLite then chooses the new index for all 9 queries anyway, so the old one would only add write cost.
**Row order.** `getGroupMembers` and the three functions using the `member_role IN (...)` queries have no `ORDER BY`. Through the old index they returned members in insertion order; through the new one SQLite returns them grouped by role, which would change `/ms` output, break ordered test assertions and, in `getGroupRosterMembers`, change which members `buildGroupRoster` keeps under `maxGroupRosterSize`. These four functions now sort the result by `group_member_id` in Haskell, which restores the previous order exactly. `getGroupMemberIdByName` takes the first row of a name lookup that can match several rows (a removed member re-added under the same name), so it sorts by `group_member_id` too and keeps returning the oldest row, as before. `getGroupMembersForExpiration` is also unsorted now, but only deletes each member in turn. `getHostMemberId_` also takes the first row, of the host members; a group has several only in channels, where they are all relays with the same role, so the order among them is unchanged. For the four `groupMemberQuery` functions, an SQL `ORDER BY m.group_member_id` was rejected: without table statistics SQLite then chooses `idx_group_members_user_id (user_id)` to avoid the sort, which scans every membership of the user in all groups.
Keeping the old index alongside was also checked: SQLite then chooses the new index for 8 of the 9 queries; the covering `DELETE` keeps the old one, so keeping it would only add write cost.
**Cost.** The one-off migration took 3.2 s to create the index and 0.9 s to drop the old one on a 520,155-member table (9 MB index). Role changes now also update the index, and role changes are rare.
## Alternatives considered
- **Refresh only the viewed member on return** (`apiGroupMemberInfo`). Rejected: it still polls, and it misses changes to other members.
- **Reload only members with support chats on every return** (`/_members support`, ~7 ms at 20k members). Much smaller than this change, but it still puts a query in front of every next open, and the list would not update while it is shown. Applying what arrives needs no query; this is used only for remote hosts, which may run an older core.
- **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.
+2 -7
View File
@@ -4868,7 +4868,7 @@ processChatCommand cxt nm = \case
(chatMsgEvents, quotedItems_) <- L.unzip <$> prepareMsgs (L.zip cmrs fInvs_) timed_
(msgs_, gsr) <- sendGroupMessages user g Nothing showGroupAsSender recipients signMsgs chatMsgEvents
let itemsData = prepareSndItemsData (L.toList cmrs) (L.toList ciFiles_) (L.toList quotedItems_) (L.toList msgs_)
cis_ <- saveSndChatItems user (CDGroupSnd gInfo chatScopeInfo) showGroupAsSender itemsData timed_ live
(sentChatInfo, cis_) <- saveSndChatItems' user (CDGroupSnd gInfo chatScopeInfo) showGroupAsSender itemsData timed_ live
when (length cis_ /= length cmrs) $ logError "sendGroupContentMessages: cmrs and cis_ length mismatch"
createMemberSndStatuses cis_ msgs_ gsr
let r@(_, cis) = partitionEithers cis_
@@ -4876,12 +4876,7 @@ processChatCommand cxt nm = \case
forM_ (timed_ >>= timedDeleteAt') $ \deleteAt ->
forM_ cis $ \ci ->
startProximateTimedItemThread user (ChatRef CTGroup groupId scope, chatItemId' ci) deleteAt
chatScopeInfo' <- case chatScopeInfo of
Just GCSIMemberSupport {groupMember_ = Just sentScopeMem} ->
Just . GCSIMemberSupport . Just
<$> (withFastStore (\db -> getGroupMemberById db cxt user (groupMemberId' sentScopeMem)) `catchAllErrors` \_ -> pure sentScopeMem)
_ -> pure chatScopeInfo
pure $ CRNewChatItems user (map (AChatItem SCTGroup SMDSnd (GroupChat gInfo chatScopeInfo')) cis)
pure $ CRNewChatItems user (map (AChatItem SCTGroup SMDSnd sentChatInfo) cis)
where
setupSndFileTransfers :: Int -> CM (NonEmpty (Maybe FileInvitation, Maybe (CIFile 'MDSnd)))
setupSndFileTransfers n =
+18 -4
View File
@@ -2933,12 +2933,26 @@ saveSndChatItems ::
Maybe CITimed ->
Bool ->
CM [Either ChatError (ChatItem c 'MDSnd)]
saveSndChatItems user cd showGroupAsSender itemsData itemTimed live = do
saveSndChatItems user cd showGroupAsSender itemsData itemTimed live = snd <$> saveSndChatItems' user cd showGroupAsSender itemsData itemTimed live
saveSndChatItems' ::
forall c.
ChatTypeI c =>
User ->
ChatDirection c 'MDSnd ->
ShowGroupAsSender ->
[Either ChatError (NewSndChatItemData c)] ->
Maybe CITimed ->
Bool ->
CM (ChatInfo c, [Either ChatError (ChatItem c 'MDSnd)])
saveSndChatItems' user cd showGroupAsSender itemsData itemTimed live = do
createdAt <- liftIO getCurrentTime
cxt <- chatStoreCxt
when (contactChatDeleted cd || any (\NewSndChatItemData {content} -> ciRequiresAttention content) (rights itemsData)) $
void (withStore' $ \db -> updateChatTsStats db cxt user cd createdAt Nothing)
lift $ withStoreBatch (\db -> map (bindRight $ createItem db createdAt) itemsData)
cInfo <-
if contactChatDeleted cd || any (\NewSndChatItemData {content} -> ciRequiresAttention content) (rights itemsData)
then withStore' $ \db -> updateChatTsStats db cxt user cd createdAt Nothing
else pure $ toChatInfo cd
(cInfo,) <$> lift (withStoreBatch (\db -> map (bindRight $ createItem db createdAt) itemsData))
where
createItem :: DB.Connection -> UTCTime -> NewSndChatItemData c -> IO (Either ChatError (ChatItem c 'MDSnd))
createItem db createdAt NewSndChatItemData {msg = msg@SndMessage {sharedMsgId, signedMsg_}, content, itemTexts, itemMentions, ciFile, quotedItem, itemForwarded} = do
+4 -4
View File
@@ -2248,10 +2248,10 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag
| otherwise = do
file_ <- processFileInv gInfo' (Just m')
(ci, cInfo) <- createNonLive gInfo' (Just m') scopeInfo file_
let (moderatedGInfo, moderatedScopeInfo) = case cInfo of
GroupChat itemGInfo itemScopeInfo -> (itemGInfo, itemScopeInfo)
_ -> (gInfo', scopeInfo)
deletions <- markGroupCIsDeleted user moderatedGInfo moderatedScopeInfo [CChatItem SMDRcv ci] (Just moderator) moderatedAt
let moderatedScopeInfo = case cInfo of
GroupChat _ itemScopeInfo -> itemScopeInfo
_ -> scopeInfo
deletions <- markGroupCIsDeleted user gInfo' moderatedScopeInfo [CChatItem SMDRcv ci] (Just moderator) moderatedAt
toView $ CEvtChatItemsDeleted user deletions False False
-- m' is Maybe GroupMember
createNonLive gInfo' m' scopeInfo file_ = do
+5 -5
View File
@@ -1252,7 +1252,7 @@ getGroupSupportMembers db cxt user@User {userId, userContactId} groupId = do
getGroupMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember]
getGroupMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do
currentTs <- getCurrentTime
map (toContactMember currentTs cxt user)
sortOn groupMemberId' . 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 != ?)")
@@ -1289,7 +1289,7 @@ getSupportScopeMembersByIndexes db cxt user gInfo scopeGMId indexesInGroup = do
getGroupModerators :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember]
getGroupModerators db cxt user@User {userId, userContactId} GroupInfo {groupId} = do
currentTs <- getCurrentTime
map (toContactMember currentTs cxt user)
sortOn groupMemberId' . 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.member_role IN (?,?,?)")
@@ -1301,7 +1301,7 @@ getGroupModerators db cxt user@User {userId, userContactId} GroupInfo {groupId}
getGroupRosterMembers :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember]
getGroupRosterMembers db cxt user@User {userId, userContactId} GroupInfo {groupId} = do
currentTs <- getCurrentTime
filter memberCurrent . map (toContactMember currentTs cxt user)
sortOn groupMemberId' . filter memberCurrent . 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.member_role IN (?,?,?)")
@@ -1312,7 +1312,7 @@ getGroupRosterMembers db cxt user@User {userId, userContactId} GroupInfo {groupI
getGroupAdminsMods :: DB.Connection -> StoreCxt -> User -> GroupInfo -> IO [GroupMember]
getGroupAdminsMods db cxt user@User {userId, userContactId} GroupInfo {groupId} = do
currentTs <- getCurrentTime
filter memberCurrent . map (toContactMember currentTs cxt user)
sortOn groupMemberId' . filter memberCurrent . 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.member_role IN (?,?)")
@@ -2910,7 +2910,7 @@ getGroupIdByName db User {userId} gName =
getGroupMemberIdByName :: DB.Connection -> User -> GroupId -> ContactName -> ExceptT StoreError IO GroupMemberId
getGroupMemberIdByName db User {userId} groupId groupMemberName =
ExceptT . firstRow fromOnly (SEGroupMemberNameNotFound groupId groupMemberName) $
DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND local_display_name = ?" (userId, groupId, groupMemberName)
sortOn fromOnly <$> DB.query db "SELECT group_member_id FROM group_members WHERE user_id = ? AND group_id = ? AND local_display_name = ?" (userId, groupId, groupMemberName)
getActiveMembersByName :: DB.Connection -> StoreCxt -> User -> ContactName -> ExceptT StoreError IO [(GroupInfo, GroupMember)]
getActiveMembersByName db cxt user@User {userId} groupMemberName = do
+7 -13
View File
@@ -1481,14 +1481,10 @@ getCreateGroupChatScopeInfo db cxt user GroupInfo {membership} = \case
pure $ GCSIMemberSupport {groupMember_ = Nothing}
GCSMemberSupport (Just gmId) -> do
m <- getGroupMemberById db cxt user gmId
m' <-
if isNothing (supportChat m)
then do
ts <- liftIO getCurrentTime
liftIO $ setSupportChatTs db gmId ts
getGroupMemberById db cxt user gmId
else pure m
pure GCSIMemberSupport {groupMember_ = Just m'}
when (isNothing $ supportChat m) $ do
ts <- liftIO getCurrentTime
liftIO $ setSupportChatTs db gmId ts
GCSIMemberSupport . Just <$> if isNothing (supportChat m) then getGroupMemberById db cxt user gmId else pure m
getGroupChatScopeInfoForItem :: DB.Connection -> StoreCxt -> User -> GroupInfo -> ChatItemId -> ExceptT StoreError IO (Maybe GroupChatScopeInfo)
getGroupChatScopeInfoForItem db cxt user g itemId =
@@ -2231,11 +2227,9 @@ updateGroupScopeUnreadStats db cxt user g@GroupInfo {membership} scopeInfo (unre
member' <- updateGMStats member
let didRequire = gmRequiresAttention member
nowRequires = gmRequiresAttention member'
g' <-
if (not nowRequires && didRequire)
then decreaseGroupMembersRequireAttention db user g
else pure g
pure (g', GCSIMemberSupport (Just member'))
(,GCSIMemberSupport (Just member')) <$> if (not nowRequires && didRequire)
then decreaseGroupMembersRequireAttention db user g
else pure g
where
updateGMStats m@GroupMember {groupMemberId} = do
currentTs <- getCurrentTime
+3 -1
View File
@@ -147,7 +147,9 @@ skipComparisonForDownMigrations =
-- appends; CREATE INDEX appends).
"20260529_delivery_job_senders",
-- group_domain is removed
"20260603_simplex_name"
"20260603_simplex_name",
-- on down migration idx_group_members_group_id index moves down to the end of the file
"20260926_member_role_index"
]
getSchema :: FilePath -> FilePath -> IO String