From 1c5b15e7eff2b858ca81121a68d6f69bf2cee50b Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:18:54 +0000 Subject: [PATCH] core, ui: carry updated support member in internal items, merge only support stats, handle failed member loads --- apps/ios/Shared/Model/ChatModel.swift | 7 ++++++- apps/ios/Shared/SimpleXApp.swift | 5 ++++- .../chat/simplex/common/model/ChatModel.kt | 3 ++- .../chat/simplex/common/model/SimpleXAPI.kt | 11 ++++++++--- .../simplex/common/views/chat/ComposeView.kt | 1 - .../views/chatlist/ChatListNavLinkView.kt | 2 +- plans/member-support-list-events.md | 16 +++++++++++----- src/Simplex/Chat/Library/Commands.hs | 2 +- src/Simplex/Chat/Library/Internal.hs | 18 +++++++++--------- src/Simplex/Chat/Store/Messages.hs | 12 ++++++++---- 10 files changed, 50 insertions(+), 27 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index cdbaf03873..31716c8cb7 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -1328,7 +1328,12 @@ final class ChatModel: ObservableObject { func upsertSupportChatMember(_ cInfo: ChatInfo) { if case let .group(groupInfo, .memberSupport(member?)?) = cInfo { - _ = upsertGroupMember(groupInfo, member) + var m = member + if let current = getGroupMember(member.groupMemberId)?.wrapped { + m = current + m.supportChat = member.supportChat + } + _ = upsertGroupMember(groupInfo, m) objectWillChange.send() } } diff --git a/apps/ios/Shared/SimpleXApp.swift b/apps/ios/Shared/SimpleXApp.swift index 1e9a97c31b..835436bb10 100644 --- a/apps/ios/Shared/SimpleXApp.swift +++ b/apps/ios/Shared/SimpleXApp.swift @@ -157,7 +157,10 @@ struct SimpleXApp: App { private func updateChats() async { do { let chats = try await apiGetChatsAsync() - await MainActor.run { chatModel.updateChats(chats) } + await MainActor.run { + chatModel.updateChats(chats) + chatModel.membersLoaded = false + } if let id = chatModel.chatId, let chat = chatModel.getChat(id), !NtfManager.shared.navigatingToChat { 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 c73b5b1c47..1e3c651699 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 @@ -384,7 +384,8 @@ object ChatModel { 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) + val current = groupMembersIndexes.value[member.groupMemberId]?.let { groupMembers.value.getOrNull(it) } + chatsContext.upsertGroupMember(rhId, cInfo.groupInfo, current?.copy(supportChat = member.supportChat) ?: member) } fun populateGroupMembersIndexes() { 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 7cd59fef81..cd459844af 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 @@ -1210,7 +1210,12 @@ object ChatController { private suspend fun processSendMessageCmd(rh: Long?, cmd: CC): List? { val r = sendCmd(rh, cmd) return when { - r is API.Result && r.res is CR.NewChatItems -> r.res.chatItems + r is API.Result && r.res is CR.NewChatItems -> { + withContext(Dispatchers.Main) { + r.res.chatItems.forEach { 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 -> { val mc = cmd.composedMessages.last().msgContent AlertManager.shared.showAlertMsg( @@ -2483,11 +2488,11 @@ object ChatController { return null } - suspend fun apiListMembers(rh: Long?, groupId: Long): List { + suspend fun apiListMembers(rh: Long?, groupId: Long): List? { 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 emptyList() + return null } suspend fun apiUpdateGroup(rh: Long?, groupId: Long, groupProfile: GroupProfile, isChannel: Boolean): GroupInfo? { diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt index ca013b4f1a..c81149fcb1 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ComposeView.kt @@ -599,7 +599,6 @@ 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 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 660561dc81..62369dbf81 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 @@ -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) + val groupMembers = chatModel.controller.apiListMembers(rhId, groupInfo.groupId) ?: return@coroutineScope val currentMembersById = chatModel.groupMembers.value.associateBy { it.id } val newMembers = groupMembers.map { newMember -> val currentMember = currentMembersById[newMember.id] diff --git a/plans/member-support-list-events.md b/plans/member-support-list-events.md index 8fb5d739f1..f138e41df6 100644 --- a/plans/member-support-list-events.md +++ b/plans/member-support-list-events.md @@ -32,28 +32,34 @@ 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. +- 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. +- 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`. **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. +- New `upsertSupportChatMember(cInfo)`. When the chat info carries a support-chat member, it adds that member if absent. If the member is already present, it replaces only its `supportChat` stats. Status, role and profile keep coming from their dedicated events. That way an item event applied late, such as a leave item handled after `LeftMember`, cannot restore an old status. - It is called for: - `NewChatItems` events; - `ChatItemsDeleted` events; - delete responses (items and reports); - - send responses; + - send and forward responses (in `processSendMessageCmd` on both platforms); - 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. +- Kotlin `apiListMembers` returns `null` on error, and `setGroupMembers` then keeps the current state, so a failed load does not mark members as loaded. +- 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. - 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. -## Known limitation +## Known limitations -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 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. +- 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/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 5dcb1af0c3..35dddac18b 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -4876,7 +4876,7 @@ processChatCommand cxt nm = \case 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)) + Just . GCSIMemberSupport . Just . fromRight sentScopeMem <$> withFastStore' (\db -> runExceptT $ getGroupMemberById db cxt user (groupMemberId' sentScopeMem)) _ -> pure chatScopeInfo pure $ CRNewChatItems user (map (AChatItem SCTGroup SMDSnd (GroupChat gInfo chatScopeInfo')) cis) where diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 996135ce9e..d7d7b40013 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -34,7 +34,7 @@ import qualified Data.ByteString.Char8 as B import qualified Data.ByteString.Lazy.Char8 as LB import Data.Char (isDigit) import Data.Containers.ListUtils (nubOrd) -import Data.Either (partitionEithers, rights) +import Data.Either (fromRight, partitionEithers, rights) import Data.Fixed (div') import Data.Foldable (foldr') import Data.Functor (($>)) @@ -3292,13 +3292,13 @@ createChatItems user itemTs_ dirsCIContents = do createdAt <- liftIO getCurrentTime let itemTs = fromMaybe createdAt itemTs_ cxt <- asks storeCxt - void . withStoreBatch' $ \db -> map (updateChat db cxt createdAt) dirsCIContents - withStoreBatch' $ \db -> concatMap (createACIs db itemTs createdAt) dirsCIContents + cInfos <- withStoreBatch' $ \db -> map (updateChat db cxt createdAt) dirsCIContents + withStoreBatch' $ \db -> concat $ zipWith (createACIs db itemTs createdAt) cInfos dirsCIContents where - updateChat :: DB.Connection -> StoreCxt -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)]) -> IO () + updateChat :: DB.Connection -> StoreCxt -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)]) -> IO (ChatInfo c) updateChat db cxt createdAt (cd, _, contents) - | any (\(content, _, _) -> ciRequiresAttention content) contents || contactChatDeleted cd = void $ updateChatTsStats db cxt user cd createdAt memberChatStats - | otherwise = pure () + | any (\(content, _, _) -> ciRequiresAttention content) contents || contactChatDeleted cd = updateChatTsStats db cxt user cd createdAt memberChatStats + | otherwise = pure $ toChatInfo cd where memberChatStats :: Maybe (Int, MemberAttention, Int) memberChatStats = case cd of @@ -3306,15 +3306,15 @@ createChatItems user itemTs_ dirsCIContents = do let unread = length $ filter (\(content, _, _) -> ciRequiresAttention content) contents in Just (unread, memberAttentionChange unread itemTs_ (Just m) scope, 0) _ -> Nothing - createACIs :: DB.Connection -> UTCTime -> UTCTime -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)]) -> [IO AChatItem] - createACIs db itemTs createdAt (cd, showGroupAsSender, contents) = map createACI contents + createACIs :: DB.Connection -> UTCTime -> UTCTime -> Either ChatError (ChatInfo c) -> (ChatDirection c d, ShowGroupAsSender, [(CIContent d, Maybe SharedMsgId, Maybe MsgSigStatus)]) -> [IO AChatItem] + createACIs db itemTs createdAt cInfo_ (cd, showGroupAsSender, contents) = map createACI contents where createACI (content, sharedMsgId, msgSigned) = do let hasLink_ = ciContentHasLink content Nothing msgVerified = toMsgVerified False msgSigned ciId <- createNewChatItemNoMsg db user cd showGroupAsSender content sharedMsgId hasLink_ msgVerified itemTs createdAt let ci = mkChatItem cd showGroupAsSender ciId content Nothing Nothing Nothing Nothing Nothing False False itemTs Nothing msgVerified createdAt - pure $ AChatItem (chatTypeI @c) (msgDirection @d) (toChatInfo cd) ci + pure $ AChatItem (chatTypeI @c) (msgDirection @d) (fromRight (toChatInfo cd) cInfo_) ci -- rcvMem_ Nothing means message from channel - treated same as message from moderator, -- e.g. it can reset unanswered counter if newer than last unanswered message. diff --git a/src/Simplex/Chat/Store/Messages.hs b/src/Simplex/Chat/Store/Messages.hs index 6053d0b381..c3990d6d92 100644 --- a/src/Simplex/Chat/Store/Messages.hs +++ b/src/Simplex/Chat/Store/Messages.hs @@ -1481,10 +1481,14 @@ getCreateGroupChatScopeInfo db cxt user GroupInfo {membership} = \case pure $ GCSIMemberSupport {groupMember_ = Nothing} GCSMemberSupport (Just gmId) -> do m <- getGroupMemberById db cxt user gmId - when (isNothing $ supportChat m) $ do - ts <- liftIO getCurrentTime - liftIO $ setSupportChatTs db gmId ts - pure GCSIMemberSupport {groupMember_ = Just m} + 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'} getGroupChatScopeInfoForItem :: DB.Connection -> StoreCxt -> User -> GroupInfo -> ChatItemId -> ExceptT StoreError IO (Maybe GroupChatScopeInfo) getGroupChatScopeInfoForItem db cxt user g itemId =