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.
This commit is contained in:
Narasimha-sc
2026-08-19 11:24:56 +00:00
parent 10c905dbdd
commit 411562dce0
7 changed files with 111 additions and 93 deletions
+17 -19
View File
@@ -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)
}
@@ -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)
+4
View File
@@ -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 {
@@ -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<Chat>())
@@ -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<ChatItem> = 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
@@ -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
@@ -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)
}
}
}
@@ -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 {