From 411562dce0528d884330748ceca40300d23778fd Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Wed, 19 Aug 2026 09:29:39 +0000 Subject: [PATCH] android, desktop, ios: show pending invitee's support messages in chat list preview A member pending review has no main scope to write in, so their "chat with admins" messages are what the group's row in the chat list should show. Completes #5909 for messages the invitee sends, edits and deletes. Chat list: - previewItem picks the row's item: for a pending invitee a message is not replaced by a content-less event, and otherwise the later item wins. - ChatInfo.inMainChatList replaces the repeated scope/memberPending tests in addChatItem, upsertChatItem and removeChatItem, so edits, status changes and deletions of a support item reach the row that shows it. - addChatItem is split into addItemToChatList, previewItem and addItemToScope. - upsertChatItem tests "chat is in the list" and "chat may be added to the list" as separate branches - only a main scope chat can be added. Sent items reach only the active chats context, so ChatModel.addSentChatItem and upsertSentChatItem update the primary context as well when the active context is a support chat shown in the main list. Cross context dispatch stays with the caller, as in processReceivedMsg; no ChatsContext method touches the other context. Chat preview row shows the message instead of the "reviewed by admins" status when there is text, or content that is rendered without text (media without a caption), which needs previews to be enabled. --- apps/ios/Shared/Model/ChatModel.swift | 36 ++--- .../Views/ChatList/ChatPreviewView.swift | 5 +- apps/ios/SimpleXChat/ChatTypes.swift | 4 + .../chat/simplex/common/model/ChatModel.kt | 146 ++++++++++-------- .../simplex/common/views/chat/ComposeView.kt | 6 +- .../common/views/chat/item/FramedItemView.kt | 2 +- .../common/views/chatlist/ChatPreviewView.kt | 5 +- 7 files changed, 111 insertions(+), 93 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index e3a6ae30b9..5de2bfdb31 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -658,21 +658,8 @@ final class ChatModel: ObservableObject { // update chat list if let i = getChatIndex(cInfo.id) { // update preview - if cInfo.groupChatScope() == nil || cInfo.groupInfo?.membership.memberPending ?? false { - chats[i].chatItems = switch cInfo { - case .group: - if let currentPreviewItem = chats[i].chatItems.first { - if cItem.meta.itemTs >= currentPreviewItem.meta.itemTs { - [cItem] - } else { - [currentPreviewItem] - } - } else { - [cItem] - } - default: - [cItem] - } + if cInfo.inMainChatList { + chats[i].chatItems = [previewItem(cInfo, chats[i].chatItems.first, cItem)] if case .rcvNew = cItem.meta.itemStatus { unreadCollector.changeUnreadCounter(cInfo.id, by: 1, unreadMentions: cItem.meta.userMention ? 1 : 0) } @@ -692,6 +679,17 @@ final class ChatModel: ObservableObject { } } + private func previewItem(_ cInfo: ChatInfo, _ currentItem: ChatItem?, _ newItem: ChatItem) -> ChatItem { + guard case let .group(groupInfo, _) = cInfo, let currentItem else { return newItem } + if groupInfo.membership.memberPending { + let currentIsMessage = currentItem.content.msgContent != nil + if currentIsMessage != (newItem.content.msgContent != nil) { + return currentIsMessage ? currentItem : newItem + } + } + return newItem.meta.itemTs < currentItem.meta.itemTs ? currentItem : newItem + } + func getCIItemsModel(_ cInfo: ChatInfo, _ ci: ChatItem) -> ItemsModel? { let cInfoScope = cInfo.groupChatScope() return if let cInfoScope = cInfoScope { @@ -718,16 +716,16 @@ final class ChatModel: ObservableObject { func upsertChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) -> Bool { // update chat list var itemAdded: Bool = false - if cInfo.groupChatScope() == nil { + if cInfo.inMainChatList { if let chat = getChat(cInfo.id) { if let pItem = chat.chatItems.last { - if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) { + if pItem.id == cItem.id || (cInfo.groupChatScope() == nil && chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) { chat.chatItems = [cItem] } } else { chat.chatItems = [cItem] } - } else { + } else if cInfo.groupChatScope() == nil { addChat(Chat(chatInfo: cInfo, chatItems: [cItem])) itemAdded = true } @@ -789,7 +787,7 @@ final class ChatModel: ObservableObject { func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { // update chat list - if cInfo.groupChatScope() == nil { + if cInfo.inMainChatList { if cItem.isRcvNew { unreadCollector.changeUnreadCounter(cInfo.id, by: -1, unreadMentions: cItem.meta.userMention ? -1 : 0) } diff --git a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift index 59b92a265b..2d118e1aa7 100644 --- a/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift +++ b/apps/ios/Shared/Views/ChatList/ChatPreviewView.swift @@ -349,10 +349,13 @@ struct ChatPreviewView: View { } @ViewBuilder private func chatMessagePreview(_ cItem: ChatItem?, _ hasFilePreview: Bool = false) -> some View { + let memberPending = chat.chatInfo.groupInfo?.membership.memberPending ?? false + let itemHasText = cItem?.content.hasMsgContent == true + let itemContentShown = showChatPreviews && memberPending && cItem?.content.msgContent != nil if chatModel.draftChatId == chat.id, let draft = chatModel.draft { let (t, hasSecrets) = messageDraft(draft) chatPreviewLayout(t, draft: true, hasFilePreview: hasFilePreview, hasSecrets: hasSecrets) - } else if cItem?.content.hasMsgContent != true, let previewText = chatPreviewInfoText() { + } else if !itemHasText, !itemContentShown, let previewText = chatPreviewInfoText() { chatPreviewInfoTextLayout(previewText) } else if let cItem = cItem { let (t, hasSecrets) = chatItemPreview(cItem) diff --git a/apps/ios/SimpleXChat/ChatTypes.swift b/apps/ios/SimpleXChat/ChatTypes.swift index 7f9f1a4fcc..82bd5810eb 100644 --- a/apps/ios/SimpleXChat/ChatTypes.swift +++ b/apps/ios/SimpleXChat/ChatTypes.swift @@ -1886,6 +1886,10 @@ public enum ChatInfo: Identifiable, Decodable, NamedChat, Hashable { groupInfo?.useRelays == true } + public var inMainChatList: Bool { + groupChatScope() == nil || groupInfo?.membership.memberPending ?? false + } + // this works for features that are common for contacts and groups public func featureEnabled(_ feature: ChatFeature) -> Bool { switch self { 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 b532246f48..dc7ca8ec30 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 @@ -363,6 +363,20 @@ object ChatModel { } } + suspend fun addSentChatItem(activeCtx: ChatsContext, rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { + activeCtx.addChatItem(rhId, cInfo, cItem) + if (activeCtx.secondaryContextFilter != null && cInfo.inMainChatList) { + chatsContext.addChatItem(rhId, cInfo, cItem) + } + } + + suspend fun upsertSentChatItem(activeCtx: ChatsContext, rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { + activeCtx.upsertChatItem(rhId, cInfo, cItem) + if (activeCtx.secondaryContextFilter != null && cInfo.inMainChatList) { + chatsContext.upsertChatItem(rhId, cInfo, cItem) + } + } + // Spec: spec/state.md#ChatsContext class ChatsContext(val secondaryContextFilter: SecondaryContextFilter?) { val chats = mutableStateOf(SnapshotStateList()) @@ -520,75 +534,69 @@ object ChatModel { } suspend fun addChatItem(rhId: Long?, chatInfo: ChatInfo, cItem: ChatItem) { - // updates membersRequireAttention val cInfo = if (chatInfo is ChatInfo.Direct && chatInfo.chatDeleted) { // mark chat non deleted - val updatedContact = chatInfo.contact.copy(chatDeleted = false) - ChatInfo.Direct(updatedContact) + ChatInfo.Direct(chatInfo.contact.copy(chatDeleted = false)) } else { chatInfo } + // updates membersRequireAttention updateChatInfo(rhId, cInfo) - // update chat list - val i = getChatIndex(rhId, cInfo.id) - val chat: Chat - if (i >= 0) { - chat = chats[i] - // update preview (for chat from main scope to show new items for invitee in pending status) - if (cInfo.groupChatScope() == null || cInfo.groupInfo_?.membership?.memberPending == true) { - val newPreviewItem = when (cInfo) { - is ChatInfo.Group -> { - val currentPreviewItem = chat.chatItems.firstOrNull() - if (currentPreviewItem != null) { - if (cItem.meta.itemTs >= currentPreviewItem.meta.itemTs) { - cItem - } else { - currentPreviewItem - } - } else { - cItem - } - } + addItemToChatList(rhId, cInfo, cItem) + withContext(Dispatchers.Main) { + addItemToScope(cInfo, cItem) + } + } - else -> cItem - } - val wasUnread = chat.unreadTag - chats[i] = chat.copy( - chatItems = arrayListOf(newPreviewItem), - chatStats = - if (cItem.meta.itemStatus is CIStatus.RcvNew) { - increaseUnreadCounter(rhId, currentUser.value!!) - chat.chatStats.copy(unreadCount = chat.chatStats.unreadCount + 1, unreadMentions = if (cItem.meta.userMention) chat.chatStats.unreadMentions + 1 else chat.chatStats.unreadMentions) - } else - chat.chatStats - ) - updateChatTagReadInPrimaryContext(chats[i], wasUnread) - } - // pop chat - if (appPlatform.isDesktop && cItem.chatDir.sent) { - reorderChat(chats[i], 0) - } else { - popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = i) - } + private suspend fun addItemToChatList(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { + val i = getChatIndex(rhId, cInfo.id) + if (i < 0) { + val items: List = if (cInfo.groupChatScope() == null) arrayListOf(cItem) else emptyList() + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = items)) + return + } + val chat = chats[i] + if (cInfo.inMainChatList) { + val wasUnread = chat.unreadTag + val stats = chat.chatStats + chats[i] = chat.copy( + chatItems = arrayListOf(previewItem(cInfo, chat.chatItems.firstOrNull(), cItem)), + chatStats = if (cItem.meta.itemStatus is CIStatus.RcvNew) { + increaseUnreadCounter(rhId, currentUser.value!!) + stats.copy( + unreadCount = stats.unreadCount + 1, + unreadMentions = if (cItem.meta.userMention) stats.unreadMentions + 1 else stats.unreadMentions + ) + } else stats + ) + updateChatTagReadInPrimaryContext(chats[i], wasUnread) + } + if (appPlatform.isDesktop && cItem.chatDir.sent) { + reorderChat(chats[i], 0) } else { - if (cInfo.groupChatScope() == null) { - addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) - } else { - addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = emptyList())) + popChatCollector.throttlePopChat(chat.remoteHostId, chat.id, currentPosition = i) + } + } + + private fun previewItem(cInfo: ChatInfo, currentItem: ChatItem?, newItem: ChatItem): ChatItem { + if (cInfo !is ChatInfo.Group || currentItem == null) return newItem + if (cInfo.groupInfo.membership.memberPending) { + val currentIsMessage = currentItem.content.msgContent != null + if (currentIsMessage != (newItem.content.msgContent != null)) { + return if (currentIsMessage) currentItem else newItem } } - // add to current scope - withContext(Dispatchers.Main) { - if (chatItemBelongsToScope(cInfo, cItem)) { - // Prevent situation when chat item already in the list received from backend - if (chatItems.value.none { it.id == cItem.id }) { - if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { - addToChatItems(kotlin.math.max(0, chatItems.value.lastIndex), cItem) - } else { - addToChatItems(cItem) - } - } - } + return if (newItem.meta.itemTs < currentItem.meta.itemTs) currentItem else newItem + } + + private fun addItemToScope(cInfo: ChatInfo, cItem: ChatItem) { + if (!chatItemBelongsToScope(cInfo, cItem)) return + // Prevent situation when chat item already in the list received from backend + if (chatItems.value.any { it.id == cItem.id }) return + if (chatItems.value.lastOrNull()?.id == ChatItem.TEMP_LIVE_CHAT_ITEM_ID) { + addToChatItems(kotlin.math.max(0, chatItems.value.lastIndex), cItem) + } else { + addToChatItems(cItem) } } @@ -611,11 +619,10 @@ object ChatModel { suspend fun upsertChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem): Boolean { var itemAdded = false // update chat list - if (cInfo.groupChatScope() == null) { - val i = getChatIndex(rhId, cInfo.id) - val chat: Chat - if (i >= 0) { - chat = chats[i] + val i = getChatIndex(rhId, cInfo.id) + if (i >= 0) { + if (cInfo.inMainChatList) { + val chat = chats[i] val pItem = chat.chatItems.lastOrNull() if (pItem?.id == cItem.id) { chats[i] = chat.copy(chatItems = arrayListOf(cItem)) @@ -624,10 +631,10 @@ object ChatModel { decreaseCounterInPrimaryContext(rhId, cInfo.id) } } - } else { - addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) - itemAdded = true } + } else if (cInfo.groupChatScope() == null) { + addChat(Chat(remoteHostId = rhId, chatInfo = cInfo, chatItems = arrayListOf(cItem))) + itemAdded = true } // update current scope withContext(Dispatchers.Main) { @@ -669,7 +676,7 @@ object ChatModel { fun removeChatItem(rhId: Long?, cInfo: ChatInfo, cItem: ChatItem) { // update chat list - if (cInfo.groupChatScope() == null) { + if (cInfo.inMainChatList) { if (cItem.isRcvNew) { decreaseCounterInPrimaryContext(rhId, cInfo.id) } @@ -1821,6 +1828,9 @@ sealed class ChatInfo: SomeChat, NamedChat { val isChannel: Boolean get() = groupInfo_?.useRelays == true + + val inMainChatList: Boolean + get() = groupChatScope() == null || groupInfo_?.membership?.memberPending == true } @Serializable 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 6bfbad52ef..0e43d2dc05 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 @@ -583,7 +583,7 @@ fun ComposeView( if (!chatItems.isNullOrEmpty()) { chatItems.forEach { aChatItem -> withContext(Dispatchers.Main) { - chatsCtx.addChatItem(chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) + chatModel.addSentChatItem(chatsCtx, chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) } } return chatItems.first().chatItem @@ -725,7 +725,7 @@ fun ComposeView( withContext(Dispatchers.Main) { chatItems?.forEach { chatItem -> - chatsCtx.addChatItem(rhId, chat.chatInfo, chatItem) + chatModel.addSentChatItem(chatsCtx, rhId, chat.chatInfo, chatItem) } } @@ -803,7 +803,7 @@ fun ComposeView( ) if (updatedItem != null) { withContext(Dispatchers.Main) { - chatsCtx.upsertChatItem(chat.remoteHostId, cInfo, updatedItem.chatItem) + chatModel.upsertSentChatItem(chatsCtx, chat.remoteHostId, cInfo, updatedItem.chatItem) } } return updatedItem?.chatItem diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt index cbd15aca67..50d9732cdc 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/item/FramedItemView.kt @@ -442,7 +442,7 @@ fun sendCommandMsg(chatsCtx: ChatModel.ChatsContext, chat: Chat, msg: String) { if (!chatItems.isNullOrEmpty()) { chatItems.forEach { aChatItem -> withContext(Dispatchers.Main) { - chatsCtx.addChatItem(chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) + chatModel.addSentChatItem(chatsCtx, chat.remoteHostId, aChatItem.chatInfo, aChatItem.chatItem) } } } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt index fbc4c7e336..8240d129f0 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/ChatPreviewView.kt @@ -226,6 +226,9 @@ fun ChatPreviewView( fun chatPreviewText() { val previewText = chatPreviewInfoText() val ci = chat.chatItems.lastOrNull() + val memberPending = cInfo.groupInfo_?.membership?.memberPending == true + val itemHasText = ci?.content?.hasMsgContent == true + val itemContentShown = showChatPreviews && memberPending && ci?.content?.msgContent != null if (chatModelDraftChatId == chat.id && chatModelDraft != null) { val sp20 = with(LocalDensity.current) { 20.sp.toDp() } val (text: CharSequence, inlineTextContent) = remember(chatModelDraft) { chatModelDraft.message.text to messageDraft(chatModelDraft, sp20) } @@ -246,7 +249,7 @@ fun ChatPreviewView( inlineContent = inlineTextContent, modifier = Modifier.fillMaxWidth() ) - } else if (ci?.content?.hasMsgContent != true && previewText != null) { + } else if (!itemHasText && !itemContentShown && previewText != null) { Text(previewText.first, color = previewText.second) } else if (ci != null && showChatPreviews) { val (text: CharSequence, inlineTextContent) = when {