core, ui: carry updated support member in internal items, merge only support stats, handle failed member loads

This commit is contained in:
Narasimha-sc
2026-09-30 15:38:13 +00:00
parent 36cf904a8e
commit 0fe04240b2
10 changed files with 50 additions and 27 deletions
+6 -1
View File
@@ -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()
}
}
+4 -1
View File
@@ -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 {
@@ -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() {
@@ -1210,7 +1210,12 @@ object ChatController {
private suspend fun processSendMessageCmd(rh: Long?, cmd: CC): List<AChatItem>? {
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<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 emptyList()
return null
}
suspend fun apiUpdateGroup(rh: Long?, groupId: Long, groupProfile: GroupProfile, isChannel: Boolean): GroupInfo? {
@@ -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
@@ -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]
+11 -5
View File
@@ -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
+1 -1
View File
@@ -4877,7 +4877,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
+9 -9
View File
@@ -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 (($>))
@@ -3299,13 +3299,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
@@ -3313,15 +3313,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.
+8 -4
View File
@@ -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 =