// // Copyright (c) 2021 Open Whisper Systems. All rights reserved. // import Foundation @objc public enum ThreadMappingChange: Int { case delete, insert, update, move } @objc public class ThreadMappingSectionChange: NSObject { @objc public let type: ThreadMappingChange @objc public let index: UInt init(type: ThreadMappingChange, index: UInt) { self.type = type self.index = index } } @objc public class ThreadMappingRowChange: NSObject { @objc public let type: ThreadMappingChange @objc public let uniqueRowId: String /// Will be nil for inserts @objc public let oldIndexPath: IndexPath? /// Will be nil for deletes @objc public let newIndexPath: IndexPath? init(type: ThreadMappingChange, uniqueRowId: String, oldIndexPath: IndexPath?, newIndexPath: IndexPath?) { #if DEBUG switch type { case .delete: assert(oldIndexPath != nil) assert(newIndexPath == nil) case .insert: assert(oldIndexPath == nil) assert(newIndexPath != nil) case .update: assert(oldIndexPath != nil) assert(newIndexPath == nil) case .move: assert(oldIndexPath != nil) assert(newIndexPath != nil) } #endif self.type = type self.uniqueRowId = uniqueRowId self.oldIndexPath = oldIndexPath self.newIndexPath = newIndexPath } } @objc public class ThreadMappingDiff: NSObject { @objc let sectionChanges: [ThreadMappingSectionChange] @objc let rowChanges: [ThreadMappingRowChange] init(sectionChanges: [ThreadMappingSectionChange], rowChanges: [ThreadMappingRowChange]) { self.sectionChanges = sectionChanges self.rowChanges = rowChanges } } // MARK: - @objc class ThreadMapping: NSObject { private var pinnedThreads = OrderedDictionary() private var unpinnedThreads: [TSThread] = [] private let pinnedSection: Int = ConversationListViewControllerSection.pinned.rawValue private let unpinnedSection: Int = ConversationListViewControllerSection.unpinned.rawValue @objc let numberOfSections: Int = 1 @objc var archiveCount: UInt = 0 @objc var inboxCount: UInt = 0 @objc var hasPinnedAndUnpinnedThreads: Bool { !pinnedThreads.isEmpty && !unpinnedThreads.isEmpty } @objc var pinnedThreadIds: [String] { pinnedThreads.orderedKeys } @objc(indexPathForUniqueId:) func indexPath(uniqueId: String) -> IndexPath? { if let index = (unpinnedThreads.firstIndex { $0.uniqueId == uniqueId}) { return IndexPath(item: index, section: unpinnedSection) } else if let index = (pinnedThreads.orderedKeys.firstIndex { $0 == uniqueId}) { return IndexPath(item: index, section: pinnedSection) } else { return nil } } @objc func numberOfItems(inSection section: Int) -> Int { if section == pinnedSection { return pinnedThreads.count } else if section == unpinnedSection { return unpinnedThreads.count } else { owsFailDebug("section had unexpected value: \(section)") return 0 } } @objc(threadForIndexPath:) func thread(indexPath: IndexPath) -> TSThread? { switch indexPath.section { case pinnedSection: return pinnedThreads[safe: indexPath.item]?.value case unpinnedSection: return unpinnedThreads[safe: indexPath.item] default: owsFailDebug("Unexpected index path \(indexPath)") return nil } } @objc(indexPathAfterThread:) func indexPath(after thread: TSThread?) -> IndexPath? { let isPinnedThread: Bool if let thread = thread, pinnedThreads.orderedKeys.contains(thread.uniqueId) { isPinnedThread = true } else { isPinnedThread = false } let section = isPinnedThread ? pinnedSection : unpinnedSection let threadsInSection = isPinnedThread ? pinnedThreads.orderedValues : unpinnedThreads guard !threadsInSection.isEmpty else { return nil } let firstIndexPath = IndexPath(item: 0, section: section) guard let thread = thread else { return firstIndexPath } guard let index = threadsInSection.firstIndex(where: { $0.uniqueId == thread.uniqueId}) else { return firstIndexPath } if index < (threadsInSection.count - 1) { return IndexPath(item: index + 1, section: section) } else { return nil } } @objc(indexPathBeforeThread:) func indexPath(before thread: TSThread?) -> IndexPath? { let isPinnedThread: Bool if let thread = thread, pinnedThreads.orderedKeys.contains(thread.uniqueId) { isPinnedThread = true } else { isPinnedThread = false } let section = isPinnedThread ? pinnedSection : unpinnedSection let threadsInSection = isPinnedThread ? pinnedThreads.orderedValues : unpinnedThreads guard !threadsInSection.isEmpty else { return nil } let lastIndexPath = IndexPath(item: threadsInSection.count - 1, section: section) guard let thread = thread else { return lastIndexPath } guard let index = threadsInSection.firstIndex(where: { $0.uniqueId == thread.uniqueId}) else { return lastIndexPath } if index > 0 { return IndexPath(item: index - 1, section: section) } else { return nil } } let threadFinder = AnyThreadFinder() @objc func updateSwallowingErrors(isViewingArchive: Bool, transaction: SDSAnyReadTransaction) { do { try update(isViewingArchive: isViewingArchive, transaction: transaction) } catch { owsFailDebug("error: \(error)") } } func update(isViewingArchive: Bool, transaction: SDSAnyReadTransaction) throws { try Bench(title: "update thread mapping (\(isViewingArchive ? "archive" : "inbox"))") { archiveCount = try threadFinder.visibleThreadCount(isArchived: true, transaction: transaction) inboxCount = try threadFinder.visibleThreadCount(isArchived: false, transaction: transaction) try self.loadThreads(isViewingArchive: isViewingArchive, transaction: transaction) } } private func loadThreads(isViewingArchive: Bool, transaction: SDSAnyReadTransaction) throws { var pinnedThreads = [TSThread]() var threads = [TSThread]() var pinnedThreadIds = PinnedThreadManager.pinnedThreadIds defer { // Pinned threads are always ordered in the order they were pinned. if isViewingArchive { self.pinnedThreads = OrderedDictionary() } else { let existingPinnedThreadIds = pinnedThreads.map { $0.uniqueId } self.pinnedThreads = OrderedDictionary( keyValueMap: Dictionary(uniqueKeysWithValues: pinnedThreads.map { ($0.uniqueId, $0) }), orderedKeys: pinnedThreadIds.filter { existingPinnedThreadIds.contains($0) } ) } self.unpinnedThreads = threads } // This method is a perf hotspot. To improve perf, we try to leverage // the model cache. If any problems arise, we fall back to using // threadFinder.enumerateVisibleThreads() which is robust but expensive. func loadWithoutCache() throws { try self.threadFinder.enumerateVisibleThreads(isArchived: isViewingArchive, transaction: transaction) { thread in if pinnedThreadIds.contains(thread.uniqueId) { pinnedThreads.append(thread) } else { threads.append(thread) } } } // Loading the mapping from the cache has the following steps: // // 1. Fetch the uniqueIds for the visible threads. let threadIds = try threadFinder.visibleThreadIds(isArchived: isViewingArchive, transaction: transaction) guard !threadIds.isEmpty else { return } // 2. Try to pull as many threads as possible from the cache. var threadIdToModelMap: [String: TSThread] = modelReadCaches.threadReadCache.getThreadsIfInCache(forUniqueIds: threadIds, transaction: transaction) var threadsToLoad = Set(threadIds) threadsToLoad.subtract(threadIdToModelMap.keys) // 3. Bulk load any threads that are not in the cache in a // single query. // // NOTE: There's an upper bound on how long SQL queries should be. // We use kMaxIncrementalRowChanges to limit query size. guard threadsToLoad.count <= UIDatabaseObserver.kMaxIncrementalRowChanges else { try loadWithoutCache() return } if !threadsToLoad.isEmpty { let loadedThreads = try threadFinder.threads(withThreadIds: threadsToLoad, transaction: transaction) guard loadedThreads.count == threadsToLoad.count else { owsFailDebug("Loading threads failed.") try loadWithoutCache() return } for thread in loadedThreads { threadIdToModelMap[thread.uniqueId] = thread } } guard threadIds.count == threadIdToModelMap.count else { owsFailDebug("Missing threads.") try loadWithoutCache() return } // 4. Build the ordered list of threads. for threadId in threadIds { guard let thread = threadIdToModelMap[threadId] else { owsFailDebug("Couldn't read thread: \(threadId)") try loadWithoutCache() return } if pinnedThreadIds.contains(thread.uniqueId) { pinnedThreads.append(thread) } else { threads.append(thread) } } } @objc func updateAndCalculateDiffSwallowingErrors(isViewingArchive: Bool, updatedItemIds: Set, transaction: SDSAnyReadTransaction) -> ThreadMappingDiff? { do { return try updateAndCalculateDiff(isViewingArchive: isViewingArchive, updatedItemIds: updatedItemIds, transaction: transaction) } catch { owsFailDebug("error: \(error)") return nil } } @objc func updateAndCalculateDiff(isViewingArchive: Bool, updatedItemIds allUpdatedItemIds: Set, transaction: SDSAnyReadTransaction) throws -> ThreadMappingDiff { // Ignore updates to non-visible threads. var updatedItemIds = Set() for threadId in allUpdatedItemIds { guard let thread = TSThread.anyFetch(uniqueId: threadId, transaction: transaction) else { // Missing thread, it was deleted and should no longer be visible. continue } if thread.shouldThreadBeVisible { updatedItemIds.insert(threadId) } } let oldPinnedThreadIds: [String] = pinnedThreads.orderedKeys let oldUnpinnedThreadIds: [String] = unpinnedThreads.map { $0.uniqueId } try update(isViewingArchive: isViewingArchive, transaction: transaction) let newPinnedThreadIds: [String] = pinnedThreads.orderedKeys let newUnpinnedThreadIds: [String] = unpinnedThreads.map { $0.uniqueId } let allNewThreadIds = Set(newPinnedThreadIds + newUnpinnedThreadIds) // We want to be economical and issue as few changes as possible. // We can skip some "moves". E.g. if we "delete" the first item, // we don't need to explicitly "move" the other items up an index. // In an effort to use as few "moves" as possible, we calculate // the "naive" ordering that will occur after the "deletes" and // "inserts," then only "move up" items that are not in their // final position. We leverage the fact that items in the // conversation view only move upwards when modified. var naivePinnedThreadIdOrdering = oldPinnedThreadIds var naiveUnpinnedThreadIdOrdering = oldUnpinnedThreadIds var rowChanges: [ThreadMappingRowChange] = [] // 1. Deletes - Always perform deletes before inserts and updates. // // * The indexPath for deletes uses pre-update indices. // * We use `reversed` to ensure that items // are deleted in reverse order, to avoid confusion around // each deletion affecting the indices of subsequent deletions. let deletedThreadIds = (oldPinnedThreadIds + oldUnpinnedThreadIds) .filter { !newPinnedThreadIds.contains($0) && !newUnpinnedThreadIds.contains($0) } for deletedThreadId in deletedThreadIds.reversed() { let wasPinned = oldPinnedThreadIds.contains(deletedThreadId) let oldThreadIds = wasPinned ? oldPinnedThreadIds : oldUnpinnedThreadIds let oldSection = wasPinned ? pinnedSection : unpinnedSection guard let oldIndex = oldThreadIds.firstIndexAsInt(of: deletedThreadId) else { throw OWSAssertionError("oldIndex was unexpectedly nil") } owsAssertDebug(newPinnedThreadIds.firstIndexAsInt(of: deletedThreadId) == nil) owsAssertDebug(newUnpinnedThreadIds.firstIndexAsInt(of: deletedThreadId) == nil) rowChanges.append(ThreadMappingRowChange(type: .delete, uniqueRowId: deletedThreadId, oldIndexPath: IndexPath(row: oldIndex, section: oldSection), newIndexPath: nil)) func updateNaiveThreadIdOrdering(_ naiveThreadIdOrdering: inout [String]) throws { if oldIndex >= 0 && oldIndex < naiveThreadIdOrdering.count { owsAssertDebug(naiveThreadIdOrdering[oldIndex] == deletedThreadId) naiveThreadIdOrdering.remove(at: oldIndex) } else { throw OWSAssertionError("Could not delete item.") } } // Update naive ordering to reflect the delete. if wasPinned { try updateNaiveThreadIdOrdering(&naivePinnedThreadIdOrdering) } else { try updateNaiveThreadIdOrdering(&naiveUnpinnedThreadIdOrdering) } } // 2. Inserts - Always perform inserts before updates. // // * The indexPath for inserts uses post-update indices. // * We insert in ascending order. let insertedThreadIds = (newPinnedThreadIds + newUnpinnedThreadIds) .filter { !oldPinnedThreadIds.contains($0) && !oldUnpinnedThreadIds.contains($0) } for insertedThreadId in insertedThreadIds { let isPinned = newPinnedThreadIds.contains(insertedThreadId) let newThreadIds = isPinned ? newPinnedThreadIds : newUnpinnedThreadIds let newSection = isPinned ? pinnedSection : unpinnedSection owsAssertDebug(oldPinnedThreadIds.firstIndexAsInt(of: insertedThreadId) == nil) owsAssertDebug(oldUnpinnedThreadIds.firstIndexAsInt(of: insertedThreadId) == nil) guard let newIndex = newThreadIds.firstIndexAsInt(of: insertedThreadId) else { throw OWSAssertionError("newIndex was unexpectedly nil") } rowChanges.append(ThreadMappingRowChange(type: .insert, uniqueRowId: insertedThreadId, oldIndexPath: nil, newIndexPath: IndexPath(row: newIndex, section: newSection))) func updateNaiveThreadIdOrdering(_ naiveThreadIdOrdering: inout [String]) throws { if newIndex >= 0 && newIndex <= naiveThreadIdOrdering.count { naiveThreadIdOrdering.insert(insertedThreadId, at: newIndex) } else { throw OWSAssertionError("Could not insert item.") } } // Update naive ordering to reflect the insert. if isPinned { try updateNaiveThreadIdOrdering(&naivePinnedThreadIdOrdering) } else { try updateNaiveThreadIdOrdering(&naiveUnpinnedThreadIdOrdering) } } // 3. Moves // // * As noted above, we only need to "move" items whose // naive ordering doesn't reflect the final ordering. // * The old indexPath for moves uses pre-update indices. // * The new indexPath for moves uses post-update indices. // * We move in ascending "new" order. guard allNewThreadIds == Set(naivePinnedThreadIdOrdering + naiveUnpinnedThreadIdOrdering) else { throw OWSAssertionError("Could not map contents.") } // We first check for items that moved to a new section (e.g. // was pinned and is no longer pinned) because we want to perform // one "move" animation for it. We don't need to reload these cells // because the cell contents do not change between being pinned // and unpinned. Using an insert and delete will result in a // strange animation when moving to a different section. let newlyPinnedThreadIds = Array(Set(newPinnedThreadIds).subtracting(oldPinnedThreadIds)) let newlyUnpinnedThreadIds = Array(Set(newUnpinnedThreadIds).subtracting(oldUnpinnedThreadIds)) let movedToNewSectionThreadIds = newlyPinnedThreadIds + newlyUnpinnedThreadIds for threadId in movedToNewSectionThreadIds { let isPinned = newPinnedThreadIds.contains(threadId) let wasPinned = oldPinnedThreadIds.contains(threadId) let oldThreadIds = wasPinned ? oldPinnedThreadIds : oldUnpinnedThreadIds let newThreadIds = isPinned ? newPinnedThreadIds : newUnpinnedThreadIds guard let oldIndex = oldThreadIds.firstIndexAsInt(of: threadId) else { continue } guard let newIndex = newThreadIds.firstIndexAsInt(of: threadId) else { throw OWSAssertionError("newIndex was unexpectedly nil.") } let oldSection = wasPinned ? pinnedSection : unpinnedSection let newSection = isPinned ? pinnedSection : unpinnedSection rowChanges.append(ThreadMappingRowChange(type: .move, uniqueRowId: threadId, oldIndexPath: IndexPath(row: oldIndex, section: oldSection), newIndexPath: IndexPath(row: newIndex, section: newSection))) // Update naive ordering. if wasPinned { guard let naiveIndex = naivePinnedThreadIdOrdering.firstIndexAsInt(of: threadId) else { throw OWSAssertionError("Missing naive index") } naivePinnedThreadIdOrdering.remove(at: naiveIndex) } else { guard let naiveIndex = naiveUnpinnedThreadIdOrdering.firstIndexAsInt(of: threadId) else { throw OWSAssertionError("Missing naive index") } naiveUnpinnedThreadIdOrdering.remove(at: naiveIndex) } if isPinned { if newIndex >= 0 && newIndex <= naivePinnedThreadIdOrdering.count { naivePinnedThreadIdOrdering.insert(threadId, at: newIndex) } else { throw OWSAssertionError("Could not insert item.") } } else { if newIndex >= 0 && newIndex <= naiveUnpinnedThreadIdOrdering.count { naiveUnpinnedThreadIdOrdering.insert(threadId, at: newIndex) } else { throw OWSAssertionError("Could not insert item.") } } } // We then check for items that moved within the same section. // UICollectionView cannot reload and move an item in the same // PerformBatchUpdates, so ConversationListViewController // performs these moves using an insert and a delete to ensure // that the moved item is reloaded. This is how UICollectionView // performs reloads internally. var movedThreadIds = [String]() let possiblyMovedWithinSectionThreadIds = allNewThreadIds .subtracting(insertedThreadIds) .subtracting(deletedThreadIds) .subtracting(movedToNewSectionThreadIds) for threadId in possiblyMovedWithinSectionThreadIds { let isPinned = newPinnedThreadIds.contains(threadId) let wasPinned = oldPinnedThreadIds.contains(threadId) owsAssertDebug(isPinned == wasPinned) let section = isPinned ? pinnedSection : unpinnedSection let oldThreadIds = wasPinned ? oldPinnedThreadIds : oldUnpinnedThreadIds let newThreadIds = isPinned ? newPinnedThreadIds : newUnpinnedThreadIds guard let oldIndex = oldThreadIds.firstIndexAsInt(of: threadId) else { continue } guard let newIndex = newThreadIds.firstIndexAsInt(of: threadId) else { throw OWSAssertionError("newIndex was unexpectedly nil.") } let naiveThreadIdOrdering = wasPinned ? naivePinnedThreadIdOrdering : naiveUnpinnedThreadIdOrdering guard let naiveIndex = naiveThreadIdOrdering.firstIndexAsInt(of: threadId) else { throw OWSAssertionError("threadId not in newThreadIdOrdering.") } guard newIndex != naiveIndex || isPinned != wasPinned else { continue } rowChanges.append(ThreadMappingRowChange(type: .move, uniqueRowId: threadId, oldIndexPath: IndexPath(row: oldIndex, section: section), newIndexPath: IndexPath(row: newIndex, section: section))) movedThreadIds.append(threadId) func updateNaiveThreadIdOrdering(_ naiveThreadIdOrdering: inout [String]) throws { naiveThreadIdOrdering.remove(at: naiveIndex) if newIndex >= 0 && newIndex <= naiveThreadIdOrdering.count { naiveThreadIdOrdering.insert(threadId, at: newIndex) } else { throw OWSAssertionError("Could not insert item.") } } // Update naive ordering. if isPinned { try updateNaiveThreadIdOrdering(&naivePinnedThreadIdOrdering) } else { try updateNaiveThreadIdOrdering(&naiveUnpinnedThreadIdOrdering) } } func logThreadIds(_ threadIds: [String], name: String) { Logger.verbose("\(name)[\(threadIds.count)]: \(threadIds.joined(separator: "\n"))") } // Once the moves are complete, the new ordering should be correct. guard newPinnedThreadIds == naivePinnedThreadIdOrdering else { logThreadIds(newPinnedThreadIds, name: "newPinnedThreadIds") logThreadIds(oldPinnedThreadIds, name: "oldPinnedThreadIds") logThreadIds(newUnpinnedThreadIds, name: "newUnpinnedThreadIds") logThreadIds(oldUnpinnedThreadIds, name: "oldUnpinnedThreadIds") logThreadIds(newlyPinnedThreadIds, name: "newlyPinnedThreadIds") logThreadIds(newlyUnpinnedThreadIds, name: "newlyUnpinnedThreadIds") logThreadIds(naivePinnedThreadIdOrdering, name: "naivePinnedThreadIdOrdering") logThreadIds(naiveUnpinnedThreadIdOrdering, name: "naiveUnpinnedThreadIdOrdering") logThreadIds(insertedThreadIds, name: "insertedThreadIds") logThreadIds(deletedThreadIds, name: "deletedThreadIds") logThreadIds(movedToNewSectionThreadIds, name: "movedToNewSectionThreadIds") logThreadIds(Array(possiblyMovedWithinSectionThreadIds), name: "possiblyMovedWithinSectionThreadIds") logThreadIds(movedThreadIds, name: "movedThreadIds") throw OWSAssertionError("Could not reorder pinned contents.") } guard newUnpinnedThreadIds == naiveUnpinnedThreadIdOrdering else { logThreadIds(newPinnedThreadIds, name: "newPinnedThreadIds") logThreadIds(oldPinnedThreadIds, name: "oldPinnedThreadIds") logThreadIds(newUnpinnedThreadIds, name: "newUnpinnedThreadIds") logThreadIds(oldUnpinnedThreadIds, name: "oldUnpinnedThreadIds") logThreadIds(newlyPinnedThreadIds, name: "newlyPinnedThreadIds") logThreadIds(newlyUnpinnedThreadIds, name: "newlyUnpinnedThreadIds") logThreadIds(naivePinnedThreadIdOrdering, name: "naivePinnedThreadIdOrdering") logThreadIds(naiveUnpinnedThreadIdOrdering, name: "naiveUnpinnedThreadIdOrdering") logThreadIds(insertedThreadIds, name: "insertedThreadIds") logThreadIds(deletedThreadIds, name: "deletedThreadIds") logThreadIds(movedToNewSectionThreadIds, name: "movedToNewSectionThreadIds") logThreadIds(Array(possiblyMovedWithinSectionThreadIds), name: "possiblyMovedWithinSectionThreadIds") logThreadIds(movedThreadIds, name: "movedThreadIds") throw OWSAssertionError("Could not reorder unpinned contents.") } // 4. Updates // // * The indexPath for updates uses pre-update indices. // * We cannot and should not update any item that was inserted, deleted or moved. // * Updated items that also moved use "move" changes (above). let updatedThreadIds = updatedItemIds .subtracting(insertedThreadIds) .subtracting(deletedThreadIds) .subtracting(movedToNewSectionThreadIds) .subtracting(movedThreadIds) for updatedThreadId in updatedThreadIds { let wasPinned = oldPinnedThreadIds.contains(updatedThreadId) let oldThreadIds = wasPinned ? oldPinnedThreadIds : oldUnpinnedThreadIds let oldSection = wasPinned ? pinnedSection : unpinnedSection guard let oldIndex = oldThreadIds.firstIndexAsInt(of: updatedThreadId) else { throw OWSAssertionError("oldIndex was unexpectedly nil") } rowChanges.append(ThreadMappingRowChange(type: .update, uniqueRowId: updatedThreadId, oldIndexPath: IndexPath(row: oldIndex, section: oldSection), newIndexPath: nil)) } return ThreadMappingDiff(sectionChanges: [], rowChanges: rowChanges) } } extension Collection where Element: Equatable { func firstIndexAsInt(of element: Element) -> Int? { guard let index = firstIndex(of: element) else { return nil } return distance(from: startIndex, to: index) } }