mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-03 23:28:22 +00:00
core, ui: carry updated support member in internal items, merge only support stats, handle failed member loads
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+2
-1
@@ -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() {
|
||||
|
||||
+8
-3
@@ -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? {
|
||||
|
||||
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user