From 5dda3cc2cd47aced16e1dcbefe1bfa9fa06725e2 Mon Sep 17 00:00:00 2001 From: Narasimha-sc <166327228+Narasimha-sc@users.noreply.github.com> Date: Mon, 19 Oct 2026 19:24:23 +0000 Subject: [PATCH] ui: drop the page-merge application of kept updates --- apps/ios/Shared/Model/ChatModel.swift | 7 ------- .../Shared/Views/Chat/ChatItemsLoader.swift | 10 +++++----- .../chat/simplex/common/model/ChatModel.kt | 7 ------- .../common/views/chat/ChatItemsLoader.kt | 10 +++++----- plans/apps-upsert-old-item-updates.md | 19 +++++++++++++------ 5 files changed, 23 insertions(+), 30 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 91287a2194..b64c2a904f 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -102,13 +102,6 @@ class ItemsModel: ObservableObject { chatState.splits.isEmpty || chatState.splits.first != reversedChatItems.first?.id } - 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() diff --git a/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift b/apps/ios/Shared/Views/Chat/ChatItemsLoader.swift index caa3572b8f..5213b5509b 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.id, chat.chatItems.reversed()) + im.reversedChatItems = 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(chat.id, newReversed) + im.reversedChatItems = 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(chat.id, newReversed) + im.reversedChatItems = 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(chat.id, newReversed) + im.reversedChatItems = 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(chat.id, items.reversed()) + im.reversedChatItems = items.reversed() im.chatState.splits = newSplits if im.secondaryIMFilter == nil { chatModel.updateChatInfo(chat.chatInfo) 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 8d78bc4cf5..f6b4e922d2 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 @@ -737,13 +737,6 @@ object ChatModel { return itemAdded } - 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() 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 b503ddb9f8..fd912a8c9e 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.remoteHostId, chat.id, chat.chatItems)) + chatsCtx.chatItems.replaceAll(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(chat.remoteHostId, chat.id, newItems)) + chatsCtx.chatItems.replaceAll(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(chat.remoteHostId, chat.id, newItems)) + chatsCtx.chatItems.replaceAll(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(chat.remoteHostId, chat.id, newItems)) + chatsCtx.chatItems.replaceAll(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(chat.remoteHostId, chat.id, newItems)) + chatsCtx.chatItems.replaceAll(newItems) chatState.splits.value = newSplits unreadAfterNewestLoaded.value = 0 } diff --git a/plans/apps-upsert-old-item-updates.md b/plans/apps-upsert-old-item-updates.md index 976af5ee3a..49c83fa004 100644 --- a/plans/apps-upsert-old-item-updates.md +++ b/plans/apps-upsert-old-item-updates.md @@ -20,7 +20,7 @@ Chat item IDs only grow, so an item created by an update is newer than every oth - 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, 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. +Otherwise the item is not added, and the update is kept until the item is added (below). On iOS the open chat's preview is replaced by an item that is not loaded only by the same rule. While the newest items are not loaded (the chat was opened at the first unread item, or after a jump to a quoted or found item), neither the loaded items nor the preview bound the IDs of the items that are not loaded: in groups the preview is the item with the latest timestamp, not the highest ID, and after the last item is deleted the preview is a placeholder with ID -1. Once a new item is added at the bottom, the newest items count as loaded again; that is safe, because every earlier item has a lower ID than the new item. The same check applies to member support chats and reports, which use their own loaded state. @@ -28,15 +28,14 @@ Updates of items that are not added or loaded yet. An update can reach `upsertCh - Send response, both platforms: the core can report a status change or a file status change of a message the user has just sent before the app adds that message from the send response. If a message received in between (`NewChatItems`) was added first, it has a higher ID; while the newest items are not loaded, no such message is needed. - Android and desktop: `NewChatItems` is handled in a background coroutine (`withBGApi`), while `ChatItemUpdated`, `ChatItemsDeleted` with a changed item, statuses and file events are applied in order on the main thread. So an edit, a moderation, a deletion mark, a status or a file status of a message that has just been received can come before the message is added. iOS handles events in order. -- Page loads, both platforms: `ChatItemsLoader` reads a page from the database in the background and merges it on the main thread, so an update of an item of that page that arrives in between is applied to neither. So when `upsertChatItem` does not add an item that is not loaded, it keeps the update by item ID (Kotlin `ChatsContext.droppedUpdates`, iOS `ItemsModel.droppedUpdates`), whatever the direction of the item. A later kept update of the same item replaces it, and keeps the earlier status when `shouldKeepOldSndCIStatus` prefers it, as for a loaded item. -- `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 `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. +- `addChatItem` adds the kept item instead of the item it got when the kept item's `updatedAt` is not earlier. The core reports an updated item with the `updatedAt` it had before the change: status changes and deletions write a new `updated_at` to the database only, and edits and file status changes do not change it. `addChatItem` gets the item as it was created (send and forward responses, `NewChatItems`), and every update of it comes later, so the kept item's `updatedAt` is that of the created item or, after an earlier status change or deletion, later, and 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. +- Kept updates are not applied to pages: an update that arrives while `ChatItemsLoader` reads a page that contains its item is applied to neither the loaded items nor the page, so the page shows the earlier state until the item's next update or until the chat is reopened, as before (see Open questions). +- A kept update is removed when `addChatItem` gets its item, whether or not it adds it, when an update of another chat is kept, by the limit below, or when kept updates are cleared. It stays when `upsertChatItem` adds its item, when a page contains its item, or when a chat is shown from items that are already loaded (Kotlin `openLoadedChat`). That is harmless: only `addChatItem` applies a kept update, only to the item with the same ID, and only to an item as it was created, which no kept update is older than. Android and desktop do not change an item that is already loaded when it is added. iOS updates it in place, as before, now with the kept item instead of the created one: the status is kept by `shouldKeepOldSndCIStatus`, and the rest is at least as new as what was applied before. -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. +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` applies 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; `addChatItem` never gets those items, so they are never applied. `addChatItem` otherwise is unchanged: on iOS it calls `_upsertChatItem` with `add: true`. @@ -53,3 +52,11 @@ Not changed: - Android and desktop: when an update of a message that has just been received comes before `NewChatItems` and adds the message (as above), `ChatItemUpdated` shows a notification and `NewChatItems` shows another one, as before. - Until a chat is the open chat (`chatId`), updates of its items are neither applied nor kept, so an update that arrives while the chat is being opened can be missing from its first page until it is reopened, as before. - Only updates are kept, not deletions (`ChatItemsDeleted` without a changed item): on Android and desktop a message that is deleted before `NewChatItems` adds it is still added, and an item deleted while a page is read stays in the merged page on both platforms, until the chat is reopened, as before. + +## Open questions + +- Page loads, both platforms: an update of an item of a page that arrives while the page is read is applied to neither the loaded items nor the page (above). Applying kept updates to a merged page cannot tell whether the page already has the update, because the reported `updatedAt` does not change with it. A fix needs either an event sequence counter in the apps, to apply only updates kept after the page read started, or the core reporting a new `updatedAt` for edits, status changes and deletions. +- Items the core creates from updates (`ChatItemUpdated` of a message the user never received) are not added and do not notify while the newest items of the chat are not loaded, or after the user sent a message before the update arrived; they are shown, without a notification, when the newest items are loaded. +- Groups: items are ordered by timestamp, so an update of a message received late, placed above more than a page of messages but with a higher ID than every loaded item and the preview, is still added at the bottom. +- Kotlin: when the bottommost loaded item is removed (`ChatItemsMerger` `itemsRemoved`), its split is dropped, because the split is moved to the item below the removed one and there is none, so the newest items count as loaded although they are not. In the main chat the preview's item ID still bounds the IDs, except where it does not (groups, a deleted last item, above); member support chats and reports are bounded only by the loaded items. +- Core alternative: the core reporting items it creates from updates as new items (`NewChatItems`) would let the apps never add an item from an update, and would remove the bottom-of-chat check, the ID rule and the group timestamp case above.