diff --git a/apps/ios/Shared/Model/ChatModel.swift b/apps/ios/Shared/Model/ChatModel.swift index 3d5f238122..c9772090e3 100644 --- a/apps/ios/Shared/Model/ChatModel.swift +++ b/apps/ios/Shared/Model/ChatModel.swift @@ -43,6 +43,21 @@ private func addTermItem(_ items: inout [TerminalItem], _ item: TerminalItem) { items.append(item) } +class ItemsModel: ObservableObject { + static let shared = ItemsModel() + private let publisher = ObservableObjectPublisher() + private var bag = Set() + var reversedChatItems: [ChatItem] = [] { + willSet { publisher.send() } + } + init() { + publisher + .throttle(for: 0.25, scheduler: DispatchQueue.main, latest: true) + .sink { self.objectWillChange.send() } + .store(in: &bag) + } +} + final class ChatModel: ObservableObject { @Published var onboardingStage: OnboardingStage? @Published var setDeliveryReceipts = false @@ -69,7 +84,6 @@ final class ChatModel: ObservableObject { @Published var networkStatuses: Dictionary = [:] // current chat @Published var chatId: String? - @Published var reversedChatItems: [ChatItem] = [] var chatItemStatuses: Dictionary = [:] @Published var chatToTop: String? @Published var groupMembers: [GMember] = [] @@ -117,6 +131,8 @@ final class ChatModel: ObservableObject { static let shared = ChatModel() + let im = ItemsModel.shared + static var ok: Bool { ChatModel.shared.chatDbStatus == .ok } let ntfEnableLocal = true @@ -343,7 +359,7 @@ final class ChatModel: ObservableObject { var res: Bool if let chat = getChat(cInfo.id) { if let pItem = chat.chatItems.last { - if pItem.id == cItem.id || (chatId == cInfo.id && reversedChatItems.first(where: { $0.id == cItem.id }) == nil) { + if pItem.id == cItem.id || (chatId == cInfo.id && im.reversedChatItems.first(where: { $0.id == cItem.id }) == nil) { chat.chatItems = [cItem] } } else { @@ -373,7 +389,7 @@ final class ChatModel: ObservableObject { if let status = chatItemStatuses.removeValue(forKey: ci.id), case .sndNew = ci.meta.itemStatus { ci.meta.itemStatus = status } - reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0) + im.reversedChatItems.insert(ci, at: hasLiveDummy ? 1 : 0) } return true } @@ -397,12 +413,12 @@ final class ChatModel: ObservableObject { } private func _updateChatItem(at i: Int, with cItem: ChatItem) { - reversedChatItems[i] = cItem - reversedChatItems[i].viewTimestamp = .now + im.reversedChatItems[i] = cItem + im.reversedChatItems[i].viewTimestamp = .now } func getChatItemIndex(_ cItem: ChatItem) -> Int? { - reversedChatItems.firstIndex(where: { $0.id == cItem.id }) + im.reversedChatItems.firstIndex(where: { $0.id == cItem.id }) } func removeChatItem(_ cInfo: ChatInfo, _ cItem: ChatItem) { @@ -419,7 +435,7 @@ final class ChatModel: ObservableObject { if chatId == cInfo.id { if let i = getChatItemIndex(cItem) { _ = withAnimation { - self.reversedChatItems.remove(at: i) + im.reversedChatItems.remove(at: i) } } } @@ -427,16 +443,16 @@ final class ChatModel: ObservableObject { } func nextChatItemData(_ chatItemId: Int64, previous: Bool, map: @escaping (ChatItem) -> T?) -> T? { - guard var i = reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil } + guard var i = im.reversedChatItems.firstIndex(where: { $0.id == chatItemId }) else { return nil } if previous { - while i < reversedChatItems.count - 1 { + while i < im.reversedChatItems.count - 1 { i += 1 - if let res = map(reversedChatItems[i]) { return res } + if let res = map(im.reversedChatItems[i]) { return res } } } else { while i > 0 { i -= 1 - if let res = map(reversedChatItems[i]) { return res } + if let res = map(im.reversedChatItems[i]) { return res } } } return nil @@ -467,7 +483,7 @@ final class ChatModel: ObservableObject { func addLiveDummy(_ chatInfo: ChatInfo) -> ChatItem { let cItem = ChatItem.liveDummy(chatInfo.chatType) withAnimation { - reversedChatItems.insert(cItem, at: 0) + im.reversedChatItems.insert(cItem, at: 0) } return cItem } @@ -475,15 +491,15 @@ final class ChatModel: ObservableObject { func removeLiveDummy(animated: Bool = true) { if hasLiveDummy { if animated { - withAnimation { _ = reversedChatItems.removeFirst() } + withAnimation { _ = im.reversedChatItems.removeFirst() } } else { - _ = reversedChatItems.removeFirst() + _ = im.reversedChatItems.removeFirst() } } } private var hasLiveDummy: Bool { - reversedChatItems.first?.isLiveDummy == true + im.reversedChatItems.first?.isLiveDummy == true } func markChatItemsRead(_ cInfo: ChatInfo) { @@ -500,7 +516,7 @@ final class ChatModel: ObservableObject { private func markCurrentChatRead(fromIndex i: Int = 0) { var j = i - while j < reversedChatItems.count { + while j < im.reversedChatItems.count { markChatItemRead_(j) j += 1 } @@ -514,7 +530,7 @@ final class ChatModel: ObservableObject { var unreadBelow = 0 var j = i - 1 while j >= 0 { - if case .rcvNew = self.reversedChatItems[j].meta.itemStatus { + if case .rcvNew = self.im.reversedChatItems[j].meta.itemStatus { unreadBelow += 1 } j -= 1 @@ -549,7 +565,7 @@ final class ChatModel: ObservableObject { // clear current chat if chatId == cInfo.id { chatItemStatuses = [:] - reversedChatItems = [] + im.reversedChatItems = [] } } @@ -557,32 +573,58 @@ final class ChatModel: ObservableObject { if chatId == cInfo.id, let itemIndex = getChatItemIndex(cItem), let chatIndex = getChatIndex(cInfo.id), - reversedChatItems[itemIndex].isRcvNew { + im.reversedChatItems[itemIndex].isRcvNew { await MainActor.run { withTransaction(Transaction()) { // update current chat markChatItemRead_(itemIndex) // update preview - decreaseUnreadCounter(chatIndex) + unreadCollector.decreaseUnreadCounter(chatIndex) } } } } + private let unreadCollector = UnreadCollector() + + class UnreadCollector { + private let subject = PassthroughSubject() + private var bag = Set() + private var dictionary = Dictionary() + + init() { + subject + .debounce(for: 1, scheduler: DispatchQueue.main) + .sink { _ in + self.dictionary.forEach { key, value in + ChatModel.shared.decreaseUnreadCounter(key, by: value) + } + self.dictionary = Dictionary() + } + .store(in: &bag) + } + + // Only call from main thread + func decreaseUnreadCounter(_ chatIndex: Int) { + dictionary[chatIndex] = (dictionary[chatIndex] ?? 0) + 1 + subject.send(chatIndex) + } + } + private func markChatItemRead_(_ i: Int) { - let meta = reversedChatItems[i].meta + let meta = im.reversedChatItems[i].meta if case .rcvNew = meta.itemStatus { - reversedChatItems[i].meta.itemStatus = .rcvRead - reversedChatItems[i].viewTimestamp = .now + im.reversedChatItems[i].meta.itemStatus = .rcvRead + im.reversedChatItems[i].viewTimestamp = .now if meta.itemLive != true, let ttl = meta.itemTimed?.ttl { - reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl) + im.reversedChatItems[i].meta.itemTimed?.deleteAt = .now + TimeInterval(ttl) } } } - func decreaseUnreadCounter(_ chatIndex: Int) { - chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount - 1 - decreaseUnreadCounter(user: currentUser!) + func decreaseUnreadCounter(_ chatIndex: Int, by count: Int = 1) { + chats[chatIndex].chatStats.unreadCount = chats[chatIndex].chatStats.unreadCount - count + decreaseUnreadCounter(user: currentUser!, by: count) } func increaseUnreadCounter(user: any UserLike) { @@ -612,8 +654,8 @@ final class ChatModel: ObservableObject { var ns: [String] = [] if let ciCategory = chatItem.mergeCategory, var i = getChatItemIndex(chatItem) { - while i < reversedChatItems.count { - let ci = reversedChatItems[i] + while i < im.reversedChatItems.count { + let ci = im.reversedChatItems[i] if ci.mergeCategory != ciCategory { break } if let m = ci.memberConnected { ns.append(m.displayName) @@ -628,7 +670,7 @@ final class ChatModel: ObservableObject { // returns the index of the passed item and the next item (it has smaller index) func getNextChatItem(_ ci: ChatItem) -> (Int?, ChatItem?) { if let i = getChatItemIndex(ci) { - (i, i > 0 ? reversedChatItems[i - 1] : nil) + (i, i > 0 ? im.reversedChatItems[i - 1] : nil) } else { (nil, nil) } @@ -638,10 +680,10 @@ final class ChatModel: ObservableObject { // and the previous visible item with another merge category func getPrevShownChatItem(_ ciIndex: Int?, _ ciCategory: CIMergeCategory?) -> (Int?, ChatItem?) { guard var i = ciIndex else { return (nil, nil) } - let fst = reversedChatItems.count - 1 + let fst = im.reversedChatItems.count - 1 while i < fst { i = i + 1 - let ci = reversedChatItems[i] + let ci = im.reversedChatItems[i] if ciCategory == nil || ciCategory != ci.mergeCategory { return (i - 1, ci) } @@ -654,7 +696,7 @@ final class ChatModel: ObservableObject { var prevMember: GroupMember? = nil var memberIds: Set = [] for i in range { - if case let .groupRcv(m) = reversedChatItems[i].chatDir { + if case let .groupRcv(m) = im.reversedChatItems[i].chatDir { if prevMember == nil && m.groupMemberId != member.groupMemberId { prevMember = m } memberIds.insert(m.groupMemberId) } @@ -729,9 +771,9 @@ final class ChatModel: ObservableObject { var i = 0 var totalBelow = 0 var unreadBelow = 0 - while i < reversedChatItems.count - 1 && !itemsInView.contains(reversedChatItems[i].viewId) { + while i < im.reversedChatItems.count - 1 && !itemsInView.contains(im.reversedChatItems[i].viewId) { totalBelow += 1 - if reversedChatItems[i].isRcvNew { + if im.reversedChatItems[i].isRcvNew { unreadBelow += 1 } i += 1 @@ -740,12 +782,12 @@ final class ChatModel: ObservableObject { } func topItemInView(itemsInView: Set) -> ChatItem? { - let maxIx = reversedChatItems.count - 1 + let maxIx = im.reversedChatItems.count - 1 var i = 0 - let inView = { itemsInView.contains(self.reversedChatItems[$0].viewId) } + let inView = { itemsInView.contains(self.im.reversedChatItems[$0].viewId) } while i < maxIx && !inView(i) { i += 1 } while i < maxIx && inView(i) { i += 1 } - return reversedChatItems[min(i - 1, maxIx)] + return im.reversedChatItems[min(i - 1, maxIx)] } func setContactNetworkStatus(_ contact: Contact, _ status: NetworkStatus) { diff --git a/apps/ios/Shared/Model/SimpleXAPI.swift b/apps/ios/Shared/Model/SimpleXAPI.swift index fb727b494e..b08694c49d 100644 --- a/apps/ios/Shared/Model/SimpleXAPI.swift +++ b/apps/ios/Shared/Model/SimpleXAPI.swift @@ -325,11 +325,12 @@ func loadChat(chat: Chat, search: String = "") { do { let cInfo = chat.chatInfo let m = ChatModel.shared + let im = ItemsModel.shared m.chatItemStatuses = [:] - m.reversedChatItems = [] + im.reversedChatItems = [] let chat = try apiGetChat(type: cInfo.chatType, id: cInfo.apiId, search: search) m.updateChatInfo(chat.chatInfo) - m.reversedChatItems = chat.chatItems.reversed() + im.reversedChatItems = chat.chatItems.reversed() } catch let error { logger.error("loadChat error: \(responseError(error))") } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift index c41039a4ef..27d8d9c2de 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/CIChatFeatureView.swift @@ -11,6 +11,7 @@ import SimpleXChat struct CIChatFeatureView: View { @EnvironmentObject var m: ChatModel + @ObservedObject var im = ItemsModel.shared @ObservedObject var chat: Chat @EnvironmentObject var theme: AppTheme var chatItem: ChatItem @@ -53,8 +54,8 @@ struct CIChatFeatureView: View { var fs: [FeatureInfo] = [] var icons: Set = [] if var i = m.getChatItemIndex(chatItem) { - while i < m.reversedChatItems.count, - let f = featureInfo(m.reversedChatItems[i]) { + while i < im.reversedChatItems.count, + let f = featureInfo(im.reversedChatItems[i]) { if !icons.contains(f.icon) { fs.insert(f, at: 0) icons.insert(f.icon) diff --git a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift index 2fdd708fdb..258e2e34dc 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/FramedItemView.swift @@ -49,7 +49,7 @@ struct FramedItemView: View { if let qi = chatItem.quotedItem { ciQuoteView(qi) .onTapGesture { - if let ci = m.reversedChatItems.first(where: { $0.id == qi.itemId }) { + if let ci = ItemsModel.shared.reversedChatItems.first(where: { $0.id == qi.itemId }) { withAnimation { scrollModel.scrollToItem(id: ci.id) } diff --git a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift index f8bd9156da..25e06b9ea4 100644 --- a/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift +++ b/apps/ios/Shared/Views/Chat/ChatItem/MarkedDeletedItemView.swift @@ -35,8 +35,8 @@ struct MarkedDeletedItemView: View { var blockedByAdmin = 0 var deleted = 0 var moderatedBy: Set = [] - while i < m.reversedChatItems.count, - let ci = .some(m.reversedChatItems[i]), + while i < ItemsModel.shared.reversedChatItems.count, + let ci = .some(ItemsModel.shared.reversedChatItems[i]), ci.mergeCategory == ciCategory, let itemDeleted = ci.meta.itemDeleted { switch itemDeleted { diff --git a/apps/ios/Shared/Views/Chat/ChatView.swift b/apps/ios/Shared/Views/Chat/ChatView.swift index 97decdf143..9887865233 100644 --- a/apps/ios/Shared/Views/Chat/ChatView.swift +++ b/apps/ios/Shared/Views/Chat/ChatView.swift @@ -15,6 +15,7 @@ private let memberImageSize: CGFloat = 34 struct ChatView: View { @EnvironmentObject var chatModel: ChatModel + @ObservedObject var im = ItemsModel.shared @State var theme: AppTheme = buildTheme() @Environment(\.dismiss) var dismiss @Environment(\.colorScheme) var colorScheme @@ -110,7 +111,7 @@ struct ChatView: View { .onChange(of: revealedChatItem) { _ in NotificationCenter.postReverseListNeedsLayout() } - .onChange(of: chatModel.reversedChatItems) { reversedChatItems in + .onChange(of: im.reversedChatItems) { reversedChatItems in if reversedChatItems.count <= loadItemsPerPage && filtered(reversedChatItems).count < 10 { loadChatItems(chat.chatInfo) } @@ -124,7 +125,7 @@ struct ChatView: View { DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { if chatModel.chatId == nil { chatModel.chatItemStatuses = [:] - chatModel.reversedChatItems = [] + ItemsModel.shared.reversedChatItems = [] chatModel.groupMembers = [] chatModel.groupMembersIndexes.removeAll() chatModel.membersLoaded = false @@ -339,7 +340,7 @@ struct ChatView: View { private func chatItemsList() -> some View { let cInfo = chat.chatInfo - let mergedItems = filtered(chatModel.reversedChatItems) + let mergedItems = filtered(im.reversedChatItems) return GeometryReader { g in ReverseList(items: mergedItems, scrollState: $scrollModel.state) { ci in let voiceNoFrame = voiceWithoutFrame(ci) @@ -372,7 +373,7 @@ struct ChatView: View { loadChat(chat: c) } } - .onChange(of: chatModel.reversedChatItems) { _ in + .onChange(of: im.reversedChatItems) { _ in floatingButtonModel.chatItemsChanged() } } @@ -562,7 +563,7 @@ struct ChatView: View { // Load additional items until the page is +50 large after merging while chatItemsAvailable && filtered(reversedPage).count < loadItemsPerPage { let pagination: ChatPagination = - if let lastItem = reversedPage.last ?? chatModel.reversedChatItems.last { + if let lastItem = reversedPage.last ?? im.reversedChatItems.last { .before(chatItemId: lastItem.id, count: loadItemsPerPage) } else { .last(count: loadItemsPerPage) @@ -580,7 +581,7 @@ struct ChatView: View { if reversedPage.count == 0 { firstPage = true } else { - chatModel.reversedChatItems.append(contentsOf: reversedPage) + im.reversedChatItems.append(contentsOf: reversedPage) } loadingItems = false } @@ -634,11 +635,12 @@ struct ChatView: View { let ciCategory = chatItem.mergeCategory let (prevHidden, prevItem) = m.getPrevShownChatItem(currIndex, ciCategory) let range = itemsRange(currIndex, prevHidden) + let im = ItemsModel.shared Group { if revealed, let range = range { - let items = Array(zip(Array(range), m.reversedChatItems[range])) + let items = Array(zip(Array(range), im.reversedChatItems[range])) ForEach(items, id: \.1.viewId) { (i, ci) in - let prev = i == prevHidden ? prevItem : m.reversedChatItems[i + 1] + let prev = i == prevHidden ? prevItem : im.reversedChatItems[i + 1] chatItemView(ci, nil, prev) } } else { @@ -663,9 +665,10 @@ struct ChatView: View { } private func unreadItems(_ range: ClosedRange) -> [ChatItem]? { + let im = ItemsModel.shared let items = range.compactMap { i in - if i >= 0 && i < m.reversedChatItems.count { - let ci = m.reversedChatItems[i] + if i >= 0 && i < im.reversedChatItems.count { + let ci = im.reversedChatItems[i] return if ci.isRcvNew { ci } else { nil } } else { return nil @@ -1156,7 +1159,7 @@ struct ChatView: View { if let range = itemsRange(currIndex, prevHidden) { var itemIds: [Int64] = [] for i in range { - itemIds.append(m.reversedChatItems[i].id) + itemIds.append(ItemsModel.shared.reversedChatItems[i].id) } showDeleteMessages = true deletingItems = itemIds @@ -1399,7 +1402,7 @@ struct ChatView_Previews: PreviewProvider { static var previews: some View { let chatModel = ChatModel() chatModel.chatId = "@1" - chatModel.reversedChatItems = [ + ItemsModel.shared.reversedChatItems = [ ChatItem.getSample(1, .directSnd, .now, "hello"), ChatItem.getSample(2, .directRcv, .now, "hi"), ChatItem.getSample(3, .directRcv, .now, "hi there"), diff --git a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift index 9691a9efd3..b75cbf85b4 100644 --- a/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift +++ b/apps/ios/Shared/Views/LocalAuth/LocalAuthView.swift @@ -64,7 +64,7 @@ struct LocalAuthView: View { deleteAppDatabaseAndFiles() // Clear sensitive data on screen just in case app fails to hide its views while new database is created m.chatId = nil - m.reversedChatItems = [] + ItemsModel.shared.reversedChatItems = [] m.chats = [] m.users = [] _ = kcAppPassword.set(password)