From 38b8e0cee69ec1c3b02fb9887feebe3611237687 Mon Sep 17 00:00:00 2001 From: spaced4ndy <8711996+spaced4ndy@users.noreply.github.com> Date: Tue, 29 Apr 2025 16:27:19 +0000 Subject: [PATCH] ios: refactor chat state (remove chatItemsChangesListener) (#5858) --- apps/ios/Shared/Model/ChatModel.swift | 14 +- apps/ios/Shared/Model/SimpleXAPI.swift | 2 +- .../Shared/Views/Chat/ChatItemsMerger.swift | 193 +++++++++--------- apps/ios/Shared/Views/Chat/ChatView.swift | 2 +- .../Views/LocalAuth/LocalAuthView.swift | 2 +- 5 files changed, 104 insertions(+), 109 deletions(-) diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 00260cc05e..c73cb32c58 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -54,9 +54,7 @@ class ItemsModel: ObservableObject { willSet { publisher.send() } } - // set listener here that will be notified on every add/delete of a chat item let chatState = ActiveChatState() - var chatItemsChangesListener: RecalculatePositions = RecalculatePositions() // Publishes directly to `objectWillChange` publisher, // this will cause reversedChatItems to be rendered without throttling @@ -573,7 +571,7 @@ final class ChatModel: ObservableObject { ci.meta.itemStatus = status } im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0) - im.chatItemsChangesListener.added((ci.id, ci.isRcvNew), hasLiveDummy ? 1 : 0) + im.chatState.itemAdded((ci.id, ci.isRcvNew), hasLiveDummy ? 1 : 0) im.itemAdded = true ChatItemDummyModel.shared.sendUpdate() return true @@ -621,7 +619,7 @@ final class ChatModel: ObservableObject { if let i = getChatItemIndex(cItem) { withAnimation { let item = im.reversedChatItems.remove(at: i) - im.chatItemsChangesListener.removed([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed()) + im.chatState.itemsRemoved([(item.id, i, item.isRcvNew)], im.reversedChatItems.reversed()) } } } @@ -709,7 +707,7 @@ final class ChatModel: ObservableObject { let cItem = ChatItem.liveDummy(chatInfo.chatType) withAnimation { im.reversedChatItems.insert(cItem, at: 0) - im.chatItemsChangesListener.added((cItem.id, cItem.isRcvNew), 0) + im.chatState.itemAdded((cItem.id, cItem.isRcvNew), 0) im.itemAdded = true } return cItem @@ -743,7 +741,7 @@ final class ChatModel: ObservableObject { markChatItemRead_(i) i += 1 } - im.chatItemsChangesListener.read(nil, im.reversedChatItems.reversed()) + im.chatState.itemsRead(nil, im.reversedChatItems.reversed()) } } func markChatUnread(_ cInfo: ChatInfo, unreadChat: Bool = true) { @@ -767,7 +765,7 @@ final class ChatModel: ObservableObject { if chatId == cInfo.id { chatItemStatuses = [:] im.reversedChatItems = [] - im.chatItemsChangesListener.cleared() + im.chatState.clear() } } @@ -785,7 +783,7 @@ final class ChatModel: ObservableObject { } i += 1 } - im.chatItemsChangesListener.read(unreadItemIds, im.reversedChatItems.reversed()) + im.chatState.itemsRead(unreadItemIds, im.reversedChatItems.reversed()) } self.unreadCollector.changeUnreadCounter(cInfo.id, by: -itemIds.count, unreadMentions: -mentionsRead) } diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index 2de818abb2..22d004f4d9 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -345,7 +345,7 @@ func loadChat(chatId: ChatId, search: String = "", openAroundItemId: ChatItem.ID m.chatItemStatuses = [:] if clearItems { im.reversedChatItems = [] - ItemsModel.shared.chatItemsChangesListener.cleared() + ItemsModel.shared.chatState.clear() } } await apiLoadMessages(chatId, openAroundItemId != nil ? .around(chatItemId: openAroundItemId!, count: loadItemsPerPage) : (search == "" ? .initial(count: loadItemsPerPage) : .last(count: loadItemsPerPage)), im.chatState, search, openAroundItemId, { 0...0 }) diff --git a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift index 2e9dac166a..0a55ed48cc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift +++ b/apps/ios/Shared/Views/Chat/ChatItemsMerger.swift @@ -321,6 +321,101 @@ class ActiveChatState { unreadAfter = 0 unreadAfterNewestLoaded = 0 } + + func itemsRead(_ itemIds: Set?, _ newItems: [ChatItem]) { + guard let itemIds else { + // special case when the whole chat became read + unreadTotal = 0 + unreadAfter = 0 + return + } + var unreadAfterItemIndex: Int = -1 + // since it's more often that the newest items become read, it's logical to loop from the end of the list to finish it faster + var i = newItems.count - 1 + var ids = itemIds + // intermediate variables to prevent re-setting state value a lot of times without reason + var newUnreadTotal = unreadTotal + var newUnreadAfter = unreadAfter + while i >= 0 { + let item = newItems[i] + if item.id == unreadAfterItemId { + unreadAfterItemIndex = i + } + if ids.contains(item.id) { + // was unread, now this item is read + if (unreadAfterItemIndex == -1) { + newUnreadAfter -= 1 + } + newUnreadTotal -= 1 + ids.remove(item.id) + if ids.isEmpty { + break + } + } + i -= 1 + } + unreadTotal = newUnreadTotal + unreadAfter = newUnreadAfter + } + + func itemAdded(_ item: (Int64, Bool), _ index: Int) { + if item.1 { + unreadAfter += 1 + unreadTotal += 1 + } + } + + func itemsRemoved(_ itemIds: [(Int64, Int, Bool)], _ newItems: [ChatItem]) { + var newSplits: [Int64] = [] + for split in splits { + let index = itemIds.firstIndex(where: { (delId, _, _) in delId == split }) + // deleted the item that was right before the split between items, find newer item so it will act like the split + if let index { + let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count + let newSplit = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil + // it the whole section is gone and splits overlap, don't add it at all + if let newSplit, !newSplits.contains(newSplit) { + newSplits.append(newSplit) + } + } else { + newSplits.append(split) + } + } + splits = newSplits + + let index = itemIds.firstIndex(where: { (delId, _, _) in delId == unreadAfterItemId }) + // unread after item was removed + if let index { + let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count + var newUnreadAfterItemId = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil + let newUnreadAfterItemWasNull = newUnreadAfterItemId == nil + if newUnreadAfterItemId == nil { + // everything on top (including unread after item) were deleted, take top item as unread after id + newUnreadAfterItemId = newItems.first?.id + } + if let newUnreadAfterItemId { + unreadAfterItemId = newUnreadAfterItemId + totalAfter -= itemIds.filter { (_, delIndex, _) in delIndex > index }.count + unreadTotal -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex <= index && isRcvNew }.count + unreadAfter -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex > index && isRcvNew }.count + if newUnreadAfterItemWasNull { + // since the unread after item was moved one item after initial position, adjust counters accordingly + if newItems.first?.isRcvNew == true { + unreadTotal += 1 + unreadAfter -= 1 + } + } + } else { + // all items were deleted, 0 items in chatItems + unreadAfterItemId = -1 + totalAfter = 0 + unreadTotal = 0 + unreadAfter = 0 + } + } else { + totalAfter -= itemIds.count + } + } } class BoxedValue: Equatable, Hashable { @@ -359,101 +454,3 @@ func visibleItemIndexesNonReversed(_ listState: EndlessScrollView.Li // visible items mapped to their underlying data structure which is ItemsModel.shared.reversedChatItems.reversed() return range } - -class RecalculatePositions { - private var chatState: ActiveChatState { get { ItemsModel.shared.chatState } } - - func read(_ itemIds: Set?, _ newItems: [ChatItem]) { - guard let itemIds else { - // special case when the whole chat became read - chatState.unreadTotal = 0 - chatState.unreadAfter = 0 - return - } - var unreadAfterItemIndex: Int = -1 - // since it's more often that the newest items become read, it's logical to loop from the end of the list to finish it faster - var i = newItems.count - 1 - var ids = itemIds - // intermediate variables to prevent re-setting state value a lot of times without reason - var newUnreadTotal = chatState.unreadTotal - var newUnreadAfter = chatState.unreadAfter - while i >= 0 { - let item = newItems[i] - if item.id == chatState.unreadAfterItemId { - unreadAfterItemIndex = i - } - if ids.contains(item.id) { - // was unread, now this item is read - if (unreadAfterItemIndex == -1) { - newUnreadAfter -= 1 - } - newUnreadTotal -= 1 - ids.remove(item.id) - if ids.isEmpty { - break - } - } - i -= 1 - } - chatState.unreadTotal = newUnreadTotal - chatState.unreadAfter = newUnreadAfter - } - func added(_ item: (Int64, Bool), _ index: Int) { - if item.1 { - chatState.unreadAfter += 1 - chatState.unreadTotal += 1 - } - } - func removed(_ itemIds: [(Int64, Int, Bool)], _ newItems: [ChatItem]) { - var newSplits: [Int64] = [] - for split in chatState.splits { - let index = itemIds.firstIndex(where: { (delId, _, _) in delId == split }) - // deleted the item that was right before the split between items, find newer item so it will act like the split - if let index { - let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count - let newSplit = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil - // it the whole section is gone and splits overlap, don't add it at all - if let newSplit, !newSplits.contains(newSplit) { - newSplits.append(newSplit) - } - } else { - newSplits.append(split) - } - } - chatState.splits = newSplits - - let index = itemIds.firstIndex(where: { (delId, _, _) in delId == chatState.unreadAfterItemId }) - // unread after item was removed - if let index { - let idx = itemIds[index].1 - itemIds.filter { (_, delIndex, _) in delIndex <= index }.count - var newUnreadAfterItemId = newItems.count > idx && idx >= 0 ? newItems[idx].id : nil - let newUnreadAfterItemWasNull = newUnreadAfterItemId == nil - if newUnreadAfterItemId == nil { - // everything on top (including unread after item) were deleted, take top item as unread after id - newUnreadAfterItemId = newItems.first?.id - } - if let newUnreadAfterItemId { - chatState.unreadAfterItemId = newUnreadAfterItemId - chatState.totalAfter -= itemIds.filter { (_, delIndex, _) in delIndex > index }.count - chatState.unreadTotal -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex <= index && isRcvNew }.count - chatState.unreadAfter -= itemIds.filter { (_, delIndex, isRcvNew) in delIndex > index && isRcvNew }.count - if newUnreadAfterItemWasNull { - // since the unread after item was moved one item after initial position, adjust counters accordingly - if newItems.first?.isRcvNew == true { - chatState.unreadTotal += 1 - chatState.unreadAfter -= 1 - } - } - } else { - // all items were deleted, 0 items in chatItems - chatState.unreadAfterItemId = -1 - chatState.totalAfter = 0 - chatState.unreadTotal = 0 - chatState.unreadAfter = 0 - } - } else { - chatState.totalAfter -= itemIds.count - } - } - func cleared() { chatState.clear() } -} diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index eae28b76be..1349996683 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -281,7 +281,7 @@ struct ChatView: View { if chatModel.chatId == nil { chatModel.chatItemStatuses = [:] ItemsModel.shared.reversedChatItems = [] - ItemsModel.shared.chatItemsChangesListener.cleared() + ItemsModel.shared.chatState.clear() chatModel.groupMembers = [] chatModel.groupMembersIndexes.removeAll() chatModel.membersLoaded = false diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift index 7c16e22571..16ab26eff7 100644 --- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift +++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift @@ -65,7 +65,7 @@ struct LocalAuthView: View { // Clear sensitive data on screen just in case app fails to hide its views while new database is created m.chatId = nil ItemsModel.shared.reversedChatItems = [] - ItemsModel.shared.chatItemsChangesListener.cleared() + ItemsModel.shared.chatState.clear() m.updateChats([]) m.users = [] _ = kcAppPassword.set(password)