From de0b293da7a4bc15a727ba81027a48065af69aca Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:27:37 +0000 Subject: [PATCH] core, ui: keep loaded support stats on member join, handle failed loads on iOS, write members on main thread --- apps/ios/Shared/Model/ChatModel.swift | 16 +++++++++++++--- apps/ios/Shared/Model/SimpleXAPI.swift | 8 ++++---- .../chat/simplex/common/model/ChatModel.kt | 6 ++++++ .../chat/simplex/common/model/SimpleXAPI.kt | 4 ++-- .../common/views/chatlist/ChatListNavLinkView.kt | 12 +++++++----- plans/member-support-list-events.md | 10 +++++++--- src/Simplex/Chat/Library/Subscriber.hs | 5 +++-- 7 files changed, 42 insertions(+), 19 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 31716c8cb7..8420b6c14c 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -577,9 +577,11 @@ final class ChatModel: ObservableObject { let groupMembers = await apiListMembers(groupInfo.groupId) await MainActor.run { if chatId == groupInfo.id { - self.groupMembers = groupMembers.map { GMember.init($0) } - self.populateGroupMembersIndexes() - self.membersLoaded = true + if let groupMembers { + self.groupMembers = groupMembers.map { GMember.init($0) } + self.populateGroupMembersIndexes() + self.membersLoaded = true + } updateView() } } @@ -1338,6 +1340,14 @@ final class ChatModel: ObservableObject { } } + func withLoadedSupportChat(_ member: GroupMember) -> GroupMember { + var m = member + if let supportChat = getGroupMember(member.groupMemberId)?.wrapped.supportChat { + m.supportChat = supportChat + } + return m + } + func upsertGroupMember(_ groupInfo: GroupInfo, _ member: GroupMember) -> Bool { // user member was updated if groupInfo.membership.groupMemberId == member.groupMemberId { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index fb0c0fd9c7..bdc975374d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -2062,10 +2062,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 = await chatApiSendCmd(.apiListMembers(groupId: groupId)) if case let .result(.groupMembers(_, group)) = r { return group.members } - return [] + return nil } func filterMembersToAdd(_ ms: [GMember]) -> [Contact] { @@ -2747,7 +2747,7 @@ func processReceivedMsg(_ res: ChatEvent) async { case let .joinedGroupMemberConnecting(user, groupInfo, _, member): if active(user) { await MainActor.run { - _ = m.upsertGroupMember(groupInfo, member) + _ = m.upsertGroupMember(groupInfo, m.withLoadedSupportChat(member)) } } case let .memberAcceptedByOther(user, groupInfo, _, member): @@ -2810,7 +2810,7 @@ func processReceivedMsg(_ res: ChatEvent) async { case let .joinedGroupMember(user, groupInfo, member): if active(user) { await MainActor.run { - _ = m.upsertGroupMember(groupInfo, member) + _ = m.upsertGroupMember(groupInfo, m.withLoadedSupportChat(member)) } } case let .connectedToGroupMember(user, groupInfo, member, memberContact): diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt index 1e3c651699..99488e8c53 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/ChatModel.kt @@ -388,6 +388,11 @@ object ChatModel { chatsContext.upsertGroupMember(rhId, cInfo.groupInfo, current?.copy(supportChat = member.supportChat) ?: member) } + fun withLoadedSupportChat(member: GroupMember): GroupMember { + val supportChat = groupMembersIndexes.value[member.groupMemberId]?.let { groupMembers.value.getOrNull(it) }?.supportChat + return if (supportChat != null) member.copy(supportChat = supportChat) else member + } + fun populateGroupMembersIndexes() { groupMembersIndexes.value = emptyMap() val gmIndexes = groupMembersIndexes.value.toMutableMap() @@ -979,6 +984,7 @@ 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 } val memberIndex = groupMembersIndexes.value[member.groupMemberId] val updated = chatItems.value.map { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index cd459844af..430323655b 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -3162,7 +3162,7 @@ object ChatController { is CR.JoinedGroupMemberConnecting -> if (active(r.user)) { withContext(Dispatchers.Main) { - chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, chatModel.withLoadedSupportChat(r.member)) } } is CR.MemberAcceptedByOther -> @@ -3273,7 +3273,7 @@ object ChatController { is CR.JoinedGroupMember -> if (active(r.user)) { withContext(Dispatchers.Main) { - chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, r.member) + chatModel.chatsContext.upsertGroupMember(rhId, r.groupInfo, chatModel.withLoadedSupportChat(r.member)) } } is CR.ConnectedToGroupMember -> { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt index 62369dbf81..639e9c95b6 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatListNavLinkView.kt @@ -265,11 +265,13 @@ suspend fun setGroupMembers(rhId: Long?, groupInfo: GroupInfo, chatModel: ChatMo newMember } } - if (chatModel.chatId.value != groupInfo.id && chatModel.creatingChannelId.value != groupInfo.id) return@coroutineScope - chatModel.groupMembersIndexes.value = emptyMap() - chatModel.groupMembers.value = newMembers - chatModel.membersLoaded.value = true - chatModel.populateGroupMembersIndexes() + withContext(Dispatchers.Main) { + if (chatModel.chatId.value != groupInfo.id && chatModel.creatingChannelId.value != groupInfo.id) return@withContext + chatModel.groupMembersIndexes.value = emptyMap() + chatModel.groupMembers.value = newMembers + chatModel.membersLoaded.value = true + chatModel.populateGroupMembersIndexes() + } } @Composable diff --git a/plans/member-support-list-events.md b/plans/member-support-list-events.md index f138e41df6..6bec8b544b 100644 --- a/plans/member-support-list-events.md +++ b/plans/member-support-list-events.md @@ -32,8 +32,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. +- 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 returns a store error, 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. +- A moderation that arrives before its message creates the item and marks it deleted. The `ChatItemsDeleted` event now carries the chat info returned by creating the item, not the scope 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`. @@ -46,10 +47,12 @@ Load the list once per open group, then keep it current from what arrives. - 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 mention picker already uses this flag the same way, and it is reset when leaving the group. -- Kotlin `apiListMembers` returns `null` on error, and `setGroupMembers` then keeps the current state, so a failed load does not mark members as loaded. +- `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 resets `membersLoaded` when chats are refreshed on resume, because the notification extension may have changed support chats while the app was suspended. -- Kotlin `setGroupMembers` 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. +- 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. - 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. @@ -59,6 +62,7 @@ A member's first support message arrives as a `NewChatItems` event with that mem - 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. - 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, if the list is on screen while the app resumes, changes the notification extension made while suspended appear only after the list is left and reopened. - 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 group is reopened. ## Alternatives considered diff --git a/src/Simplex/Chat/Library/Subscriber.hs b/src/Simplex/Chat/Library/Subscriber.hs index 1dbf1adcb9..54f2173424 100644 --- a/src/Simplex/Chat/Library/Subscriber.hs +++ b/src/Simplex/Chat/Library/Subscriber.hs @@ -2247,8 +2247,9 @@ processAgentMessageConn cxt user@User {userId} entity gks_ corrId agentConnId ag groupMsgToView cInfo ci' | otherwise = do file_ <- processFileInv gInfo' (Just m') - (ci, _cInfo) <- createNonLive gInfo' (Just m') scopeInfo file_ - deletions <- markGroupCIsDeleted user gInfo' scopeInfo [CChatItem SMDRcv ci] (Just moderator) moderatedAt + (ci, cInfo) <- createNonLive gInfo' (Just m') scopeInfo file_ + let moderatedScopeInfo = case cInfo of GroupChat _ itemScopeInfo -> itemScopeInfo + 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