From 21ea35030be04a2d24bb79a82e172dfc3056f7a8 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:06:57 +0000 Subject: [PATCH] ios, android, desktop: apply kept updates only to their chat and database, add the newest item when it is the preview --- apps/ios/Shared/Model/ChatModel.swift | 18 +++++++++--- .../Shared/Views/Chat/ChatItemsLoader.swift | 10 +++---- .../Shared/Views/Database/DatabaseView.swift | 2 ++ .../Views/LocalAuth/LocalAuthView.swift | 1 + .../Views/Migration/MigrateToDevice.swift | 1 + .../chat/simplex/common/model/ChatModel.kt | 29 +++++++++++++------ .../chat/simplex/common/model/SimpleXAPI.kt | 1 + .../common/views/chat/ChatItemsLoader.kt | 10 +++---- .../common/views/database/DatabaseView.kt | 2 ++ .../common/views/migration/MigrateToDevice.kt | 1 + plans/apps-upsert-old-item-updates.md | 8 ++--- 11 files changed, 56 insertions(+), 27 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index c4a48f6039..91287a2194 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -102,13 +102,18 @@ class ItemsModel: ObservableObject { chatState.splits.isEmpty || chatState.splits.first != reversedChatItems.first?.id } - func withDroppedUpdates(_ items: [ChatItem]) -> [ChatItem] { - if droppedUpdates.isEmpty { return items } + func withDroppedUpdates(_ chatId: ChatId, _ items: [ChatItem]) -> [ChatItem] { + if droppedUpdates.isEmpty || droppedUpdatesChatId != chatId { return items } return items.map { item in if let kept = droppedUpdates.removeValue(forKey: item.id), kept.meta.updatedAt > item.meta.updatedAt { kept } else { item } } } + func clearDroppedUpdates() { + droppedUpdatesChatId = nil + droppedUpdates.removeAll() + } + init(secondaryIMFilter: SecondaryItemsModelFilter? = nil) { self.secondaryIMFilter = secondaryIMFilter publisher @@ -756,7 +761,7 @@ final class ChatModel: ObservableObject { // add to current scope if let ciIM = getCIItemsModel(cInfo, cItem) { var ci = cItem - if let kept = ciIM.droppedUpdates.removeValue(forKey: cItem.id) { + if ciIM.droppedUpdatesChatId == cInfo.id, let kept = ciIM.droppedUpdates.removeValue(forKey: cItem.id) { if kept.meta.updatedAt >= cItem.meta.updatedAt { ci = kept } else if shouldKeepOldSndCIStatus(oldStatus: kept.meta.itemStatus, newStatus: cItem.meta.itemStatus) { @@ -814,7 +819,7 @@ final class ChatModel: ObservableObject { } // update current scope if let ciIM = getCIItemsModel(cInfo, cItem) { - let add = (ciIM.chatState.splits.isEmpty || ciIM.chatState.splits.last != ciIM.reversedChatItems.first?.id) && cItem.id > max(lastItemId, ciIM.reversedChatItems.lazy.map(\.id).max() ?? 0) + let add = (ciIM.chatState.splits.isEmpty || ciIM.chatState.splits.last != ciIM.reversedChatItems.first?.id) && cItem.id >= lastItemId && cItem.id > (ciIM.reversedChatItems.lazy.map(\.id).max() ?? 0) itemAdded = _upsertChatItem(ciIM, cInfo, cItem, add: add) } return itemAdded @@ -1059,6 +1064,11 @@ final class ChatModel: ObservableObject { } } + func clearDroppedUpdates() { + im.clearDroppedUpdates() + secondaryIM?.clearDroppedUpdates() + } + func clearChat(_ cInfo: ChatInfo) { // clear preview if let chat = getChat(cInfo.id) { diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift index 5651e91ee5..caa3572b8f 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift @@ -50,7 +50,7 @@ func apiLoadMessages( chatModel.addChat(chat) } await MainActor.run { - im.reversedChatItems = im.withDroppedUpdates(chat.chatItems.reversed()) + im.reversedChatItems = im.withDroppedUpdates(chat.id, chat.chatItems.reversed()) if im.secondaryIMFilter == nil { chatModel.updateChatInfo(chat.chatInfo) } @@ -80,7 +80,7 @@ func apiLoadMessages( let newReversed: [ChatItem] = newItems.reversed() await MainActor.run { if chatWasSwitched(chat, pagination, openAroundItemId) { return } - im.reversedChatItems = im.withDroppedUpdates(newReversed) + im.reversedChatItems = im.withDroppedUpdates(chat.id, newReversed) im.chatState.splits = modifiedSplits.newSplits im.chatState.moveUnreadAfterItem(modifiedSplits.oldUnreadSplitIndex, modifiedSplits.newUnreadSplitIndex, oldItems) } @@ -101,7 +101,7 @@ func apiLoadMessages( let newReversed: [ChatItem] = newItems.reversed() await MainActor.run { if chatWasSwitched(chat, pagination, openAroundItemId) { return } - im.reversedChatItems = im.withDroppedUpdates(newReversed) + im.reversedChatItems = im.withDroppedUpdates(chat.id, newReversed) im.chatState.splits = newSplits im.chatState.moveUnreadAfterItem(im.chatState.splits.first ?? new.last!.id, new) // loading clear bottom area, updating number of unread items after the newest loaded item @@ -125,7 +125,7 @@ func apiLoadMessages( let orderedSplits = newSplits await MainActor.run { if chatWasSwitched(chat, pagination, openAroundItemId) { return } - im.reversedChatItems = im.withDroppedUpdates(newReversed) + im.reversedChatItems = im.withDroppedUpdates(chat.id, newReversed) im.chatState.splits = orderedSplits im.chatState.unreadAfterItemId = chat.chatItems.last!.id im.chatState.totalAfter = navInfo.afterTotal @@ -151,7 +151,7 @@ func apiLoadMessages( let items = newItems await MainActor.run { if chatWasSwitched(chat, pagination, openAroundItemId) { return } - im.reversedChatItems = im.withDroppedUpdates(items.reversed()) + im.reversedChatItems = im.withDroppedUpdates(chat.id, items.reversed()) im.chatState.splits = newSplits if im.secondaryIMFilter == nil { chatModel.updateChatInfo(chat.chatInfo) diff --git a/apps/ios/Shared/Views/Database/DatabaseView.swift b/apps/ios/Shared/Views/Database/DatabaseView.swift index 047a87cf71..17807cd1a5 100644 --- a/apps/ios/Shared/Views/Database/DatabaseView.swift +++ b/apps/ios/Shared/Views/Database/DatabaseView.swift @@ -504,6 +504,7 @@ struct DatabaseView: View { } do { try await apiDeleteStorage() + await MainActor.run { ChatModel.shared.clearDroppedUpdates() } try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true) do { let config = ArchiveConfig(archivePath: archivePath.path) @@ -658,6 +659,7 @@ func stopChatAsync() async throws { func deleteChatAsync() async throws { try await apiDeleteStorage() + await MainActor.run { ChatModel.shared.clearDroppedUpdates() } _ = kcDatabasePassword.remove() storeDBPassphraseGroupDefault.set(true) deleteAppDatabaseAndFiles() diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift index 36608c58d6..c98fe061d7 100644 --- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift +++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift @@ -69,6 +69,7 @@ struct LocalAuthView: View { ItemsModel.shared.chatState.clear() ChatModel.shared.secondaryIM?.reversedChatItems = [] ChatModel.shared.secondaryIM?.chatState.clear() + m.clearDroppedUpdates() m.updateChats([]) m.users = [] _ = kcAppPassword.set(password) diff --git a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift index 4181f6e73f..c03b399394 100644 --- a/apps/ios/Shared/Views/Migration/MigrateToDevice.swift +++ b/apps/ios/Shared/Views/Migration/MigrateToDevice.swift @@ -545,6 +545,7 @@ struct MigrateToDevice: View { try await stopChatAsync() } try await apiDeleteStorage() + await MainActor.run { ChatModel.shared.clearDroppedUpdates() } try? FileManager.default.createDirectory(at: getWallpaperDirectory(), withIntermediateDirectories: true) do { let config = ArchiveConfig(archivePath: archivePath) 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 babe5cb012..8d78bc4cf5 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 @@ -134,11 +134,11 @@ enum class ChatListBanner { BadgeExpired, BadgeIssueFailed, BadgePitch, GetStake data class FileToOpen(val fileId: Long, val inChatList: Boolean) +private const val MAX_DROPPED_UPDATES = 100 + /* * Without this annotation an animation from ChatList to ChatView has 1 frame per the whole animation. Don't delete it * */ -private const val MAX_DROPPED_UPDATES = 100 - // Spec: spec/state.md#ChatModel @Stable object ChatModel { @@ -426,7 +426,7 @@ object ChatModel { val chatItems = mutableStateOf(SnapshotStateList()) // set listener here that will be notified on every add/delete of a chat item val chatState = ActiveChatState() - private var droppedUpdatesChatId: String? = null + private var droppedUpdatesChat: Pair? = null private val droppedUpdates = mutableMapOf() fun hasChat(rhId: Long?, id: String): Boolean = chats.value.firstOrNull { it.id == id && it.remoteHostId == rhId } != null @@ -635,7 +635,7 @@ object ChatModel { // add to current scope withContext(Dispatchers.Main) { if (chatItemBelongsToScope(cInfo, cItem)) { - val kept = droppedUpdates.remove(cItem.id) + val kept = if (droppedUpdatesChat == (rhId to cInfo.id)) droppedUpdates.remove(cItem.id) else null val ci = when { kept == null -> cItem kept.meta.updatedAt >= cItem.meta.updatedAt -> kept @@ -712,13 +712,14 @@ object ChatModel { items[itemIndex] = ci } else if ( (chatState.splits.value.isEmpty() || chatState.splits.value.last() != items.lastOrNull()?.id) - && cItem.id > maxOf(lastItemId ?: 0, items.maxOfOrNull { it.id } ?: 0) + && cItem.id >= (lastItemId ?: 0) + && cItem.id > (items.maxOfOrNull { it.id } ?: 0) ) { addToChatItems(cItem) itemAdded = true } else { - if (droppedUpdatesChatId != cInfo.id) { - droppedUpdatesChatId = cInfo.id + if (droppedUpdatesChat != (rhId to cInfo.id)) { + droppedUpdatesChat = rhId to cInfo.id droppedUpdates.clear() } val kept = droppedUpdates[cItem.id] @@ -736,13 +737,18 @@ object ChatModel { return itemAdded } - fun withDroppedUpdates(items: List): List = - if (droppedUpdates.isEmpty()) { + fun withDroppedUpdates(rhId: Long?, chatId: String, items: List): List = + if (droppedUpdates.isEmpty() || droppedUpdatesChat != (rhId to chatId)) { items } else { items.map { item -> droppedUpdates.remove(item.id)?.takeIf { it.meta.updatedAt > item.meta.updatedAt } ?: item } } + fun clearDroppedUpdates() { + droppedUpdatesChat = null + droppedUpdates.clear() + } + suspend fun updateChatItem(cInfo: ChatInfo, cItem: ChatItem, status: CIStatus? = null, atIndex: Int? = null) { withContext(Dispatchers.Main) { if (chatItemBelongsToScope(cInfo, cItem)) { @@ -1155,6 +1161,11 @@ object ChatModel { } } + fun clearDroppedUpdates() { + chatsContext.clearDroppedUpdates() + secondaryChatsContext.value?.clearDroppedUpdates() + } + fun clearActiveChatFilterIfNeeded() { val clear = when(val f = activeChatTagFilter.value) { is ActiveFilter.PresetTag -> (presetTags[f.tag] ?: 0) == 0 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 3508221858..22826e948b 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 @@ -3821,6 +3821,7 @@ object ChatController { chatModel.chatId.value = null ModalManager.center.closeModals() ModalManager.end.closeModals() + withContext(Dispatchers.Main) { chatModel.clearDroppedUpdates() } AlertManager.shared.hideAllAlerts() AlertManager.privacySensitive.hideAllAlerts() chatModel.currentRemoteHost.value = switchRemoteHost(rhId) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt index 29a2a62aa2..b503ddb9f8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chat/ChatItemsLoader.kt @@ -77,7 +77,7 @@ suspend fun processLoadedChat( } } withContext(Dispatchers.Main) { - chatsCtx.chatItems.replaceAll(chatsCtx.withDroppedUpdates(chat.chatItems)) + chatsCtx.chatItems.replaceAll(chatsCtx.withDroppedUpdates(chat.remoteHostId, chat.id, chat.chatItems)) chatModel.chatId.value = chat.id splits.value = newSplits if (chat.chatItems.isNotEmpty()) { @@ -103,7 +103,7 @@ suspend fun processLoadedChat( newItems.addAll(insertAt, chat.chatItems) withContext(Dispatchers.Main) { if (chatWasSwitched(chat, pagination, openAroundItemId)) return@withContext - chatsCtx.chatItems.replaceAll(chatsCtx.withDroppedUpdates(newItems)) + chatsCtx.chatItems.replaceAll(chatsCtx.withDroppedUpdates(chat.remoteHostId, chat.id, newItems)) splits.value = newSplits chatState.moveUnreadAfterItem(oldUnreadSplitIndex, newUnreadSplitIndex, oldItems) } @@ -123,7 +123,7 @@ suspend fun processLoadedChat( newItems.addAll(indexToAdd, chat.chatItems) withContext(Dispatchers.Main) { if (chatWasSwitched(chat, pagination, openAroundItemId)) return@withContext - chatsCtx.chatItems.replaceAll(chatsCtx.withDroppedUpdates(newItems)) + chatsCtx.chatItems.replaceAll(chatsCtx.withDroppedUpdates(chat.remoteHostId, chat.id, newItems)) splits.value = newSplits chatState.moveUnreadAfterItem(splits.value.firstOrNull() ?: newItems.last().id, newItems) // loading clear bottom area, updating number of unread items after the newest loaded item @@ -146,7 +146,7 @@ suspend fun processLoadedChat( withContext(Dispatchers.Main) { if (chatWasSwitched(chat, pagination, openAroundItemId)) return@withContext - chatsCtx.chatItems.replaceAll(chatsCtx.withDroppedUpdates(newItems)) + chatsCtx.chatItems.replaceAll(chatsCtx.withDroppedUpdates(chat.remoteHostId, chat.id, newItems)) splits.value = newSplits unreadAfterItemId.value = chat.chatItems.last().id totalAfter.value = navInfo.afterTotal @@ -170,7 +170,7 @@ suspend fun processLoadedChat( newItems.addAll(chat.chatItems) withContext(Dispatchers.Main) { if (chatWasSwitched(chat, pagination, openAroundItemId)) return@withContext - chatsCtx.chatItems.replaceAll(chatsCtx.withDroppedUpdates(newItems)) + chatsCtx.chatItems.replaceAll(chatsCtx.withDroppedUpdates(chat.remoteHostId, chat.id, newItems)) chatState.splits.value = newSplits unreadAfterNewestLoaded.value = 0 } diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt index 6abbf83226..7fbee95f6c 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/database/DatabaseView.kt @@ -587,6 +587,7 @@ fun deleteChatDatabaseFilesAndState() { chatModel.chatsContext.chatItems.clearAndNotify() chatModel.chatsContext.chats.clear() chatModel.chatsContext.popChatCollector.clear() + chatModel.clearDroppedUpdates() } withContext(Dispatchers.Main) { chatModel.secondaryChatsContext.value?.chatItems?.clearAndNotify() @@ -716,6 +717,7 @@ suspend fun importArchive( if (archivePath != null) { try { m.controller.apiDeleteStorage() + withContext(Dispatchers.Main) { m.clearDroppedUpdates() } wallpapersDir.mkdirs() try { val config = ArchiveConfig(archivePath, parentTempDirectory = databaseExportDir.toString()) diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt index 150be7ea6b..508e8aab80 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/migration/MigrateToDevice.kt @@ -642,6 +642,7 @@ private fun MutableState.importArchive(archivePath: String, n chatInitControllerRemovingDatabases() } controller.apiDeleteStorage() + withContext(Dispatchers.Main) { chatModel.clearDroppedUpdates() } wallpapersDir.mkdirs() try { val config = ArchiveConfig(archivePath, parentTempDirectory = databaseExportDir.toString()) diff --git a/plans/apps-upsert-old-item-updates.md b/plans/apps-upsert-old-item-updates.md index 7636aa321f..976af5ee3a 100644 --- a/plans/apps-upsert-old-item-updates.md +++ b/plans/apps-upsert-old-item-updates.md @@ -7,7 +7,7 @@ When the core reports an update of a chat item (`ChatItemUpdated`, file status e - Kotlin `ChatModel.ChatsContext.upsertChatItem`: `addToChatItems(cItem)`, `itemAdded = true`; for `ChatItemUpdated`, `chatItemUpdateNotify` then shows a notification (`ntfManager.notifyMessageReceived`), subject to `allowedToShowNotification()` or the chat not being open. - iOS `ChatModel.upsertChatItem` / `_upsertChatItem`: inserts the item at the bottom, and for the open chat also replaces the chat preview with it; for `ChatItemUpdated` and file events, `chatItemSimpleUpdate` then adds a notification. -So an update of an old message, for example an edit, a moderation, a file status change, a delivery status change, or the "file was sent before you connected to the sender" marker from #7638, makes that old message appear at the bottom of the open chat (until it is reopened) and become the chat preview on iOS. Edits and the #7638 marker (`ChatItemUpdated`) also show a notification with the item's text on both platforms; file status changes do so only on iOS (Kotlin `chatItemSimpleUpdate` never notifies); moderation, deletions and delivery statuses never notify. +So an update of an old message, for example an edit, a moderation, a file status change, a delivery status change, or the "file was sent before you connected to the sender" marker from #7638, makes that old message appear at the bottom of the open chat (until it is reopened) and become the chat preview on iOS. Edits and the #7638 marker (`ChatItemUpdated`) also show a notification with the item's text on both platforms. File status changes reported by file events do so only on iOS (Kotlin `chatItemSimpleUpdate` never notifies); file errors and resets reported as `ChatItemUpdated` do so on both platforms. Moderation, deletions and delivery statuses never notify. ## Why the item is added at all @@ -18,7 +18,7 @@ So an update of an old message, for example an edit, a moderation, a file status Chat item IDs only grow, so an item created by an update is newer than every other item in the chat, and an update of an earlier message is not. `upsertChatItem` on both platforms adds an item that is not loaded only if: - the newest items of the chat are loaded: there are no `chatState.splits`, or the last split is not the ID of the last loaded item. Splits are ordered from the top of the chat to the bottom (`ChatItemsLoader` on both platforms), so the last one is the bottommost. The check is written inline on both platforms: iOS `ItemsModel.lastItemsLoaded` and Kotlin `lastItemsLoaded` in `ChatView.kt` (private) compare the first split, which is the bottommost only while there is at most one split, and -- its ID is greater than every loaded item's ID and, for an item of the main chat, the chat preview's item ID. Items of member support chats have no preview in the chat list, so only their loaded items bound the ID. +- its ID is greater than every loaded item's ID and, for an item of the main chat, not less than the chat preview's item ID. The preview's item itself can be missing from the loaded items when it was received while the first page was being read: it then is the newest item and is added. Only the loaded items bound the ID of an item of a member support chat. Such items update the preview only for a pending member (`addChatItem`), and `upsertChatItem` does not compare with it; that is harmless, because the support chat's loaded items bound the ID as for any other member support chat. Otherwise the item is not added, and the update is kept until the item is added or loaded (below). On iOS the open chat's preview is replaced by an item that is not loaded only by the same rule. @@ -34,9 +34,9 @@ So when `upsertChatItem` does not add an item that is not loaded, it keeps the u - `addChatItem` adds the kept item instead of the item it got when the kept item's `updatedAt` is not earlier. Every event about an item is read from the database after the item was created, status and content changes set `updatedAt`, and file status changes do not change it, so the kept item is the newer state. Otherwise (only when the clock went back) it adds the item it got with the kept status when `shouldKeepOldSndCIStatus` prefers it. So the send response does not replace a kept status (`SndSent`, `SndRcvd`) or file status with `SndNew` and the initial file status, and on Android and desktop `NewChatItems` does not replace an edit, moderation or deletion mark that came first. - When `ChatItemsLoader` merges a page (all pagination kinds), it replaces a loaded item by its kept update only when the kept item's `updatedAt` is later: an update kept before the page was read is already in the page. A file status change kept while the page was read does not change `updatedAt`, so the page shows the older file status until the next file event of that item. -- A kept update is removed when its item is added or loaded. +- A kept update is removed when `addChatItem` adds its item or a merged page contains its item. It stays when `upsertChatItem` adds its item or a chat is shown from items that are already loaded (Kotlin `openLoadedChat`); that is harmless, because a merged page applies only a kept update that is newer than the page's item. -The kept updates are of one chat only: they are cleared when an update of another chat is kept. Member support chats and reports have their own (`secondaryChatsContext`, `secondaryIM`). Kept updates of a chat that is no longer open stay until an update of another chat is kept, but they are not applied to another chat: item IDs are unique across chats. At most 100 updates are kept; above that, the one with the lowest ID is removed, because the items that are still to be added are the newest. Most kept updates are of items above the loaded part of the chat, for example receipts or edits of old messages; they are removed when that part is loaded, and are not applied because the page already has them. +The kept updates are of one chat only: they are cleared when an update of another chat is kept (on Android and desktop, also of the same chat of another remote host). Member support chats and reports have their own (`secondaryChatsContext`, `secondaryIM`). Kept updates of a chat that is no longer open stay until an update of another chat is kept, and they are applied only to the chat and database they were kept for: `addChatItem` and page merges apply them only to items of the same chat (and remote host), and they are cleared when the database is deleted (also by the self-destruct passcode and migration) or imported, and on Android and desktop when the remote host is switched (`ChatModel.clearDroppedUpdates`). At most 100 updates are kept; above that, the one with the lowest ID is removed, because the items that are still to be added are the newest. Most kept updates are of items above the loaded part of the chat, for example receipts or edits of old messages; they are removed when that part is loaded, and are not applied because the page already has them. `addChatItem` otherwise is unchanged: on iOS it calls `_upsertChatItem` with `add: true`.