// // Copyright (c) 2021 Open Whisper Systems. All rights reserved. // import Foundation @objc public class HVTableDataSource: NSObject { private var viewState: HVViewState! public let tableView = HVTableView() @objc public weak var viewController: HomeViewController? fileprivate var splitViewController: UISplitViewController? { viewController?.splitViewController } @objc public var renderState: HVRenderState = .empty private let kArchivedConversationsReuseIdentifier = "kArchivedConversationsReuseIdentifier" fileprivate var lastReloadDate: Date? { tableView.lastReloadDate } fileprivate var lastPreloadCellDate: Date? public required override init() { super.init() } func configure(viewState: HVViewState) { AssertIsOnMainThread() self.viewState = viewState tableView.delegate = self tableView.dataSource = self tableView.separatorStyle = .none tableView.separatorColor = Theme.cellSeparatorColor tableView.register(HomeViewCell.self, forCellReuseIdentifier: HomeViewCell.reuseIdentifier) tableView.register(UITableViewCell.self, forCellReuseIdentifier: kArchivedConversationsReuseIdentifier) tableView.tableFooterView = UIView() } } // MARK: - extension HVTableDataSource { public func threadViewModel(forThread thread: TSThread) -> ThreadViewModel { let threadViewModelCache = viewState.threadViewModelCache if let value = threadViewModelCache.get(key: thread.uniqueId) { return value } let threadViewModel = databaseStorage.read { transaction in ThreadViewModel(thread: thread, forHomeView: true, transaction: transaction) } threadViewModelCache.set(key: thread.uniqueId, value: threadViewModel) return threadViewModel } @objc func threadViewModel(forIndexPath indexPath: IndexPath) -> ThreadViewModel? { guard let thread = self.thread(forIndexPath: indexPath) else { return nil } return self.threadViewModel(forThread: thread) } func thread(forIndexPath indexPath: IndexPath) -> TSThread? { renderState.thread(forIndexPath: indexPath) } } // MARK: - UIScrollViewDelegate extension HVTableDataSource: UIScrollViewDelegate { private func preloadCellsIfNecessary() { AssertIsOnMainThread() guard let viewController = self.viewController else { owsFailDebug("Missing viewController.") return } guard viewController.hasEverAppeared else { return } let newContentOffset = tableView.contentOffset let oldContentOffset = viewController.lastKnownTableViewContentOffset viewController.lastKnownTableViewContentOffset = newContentOffset guard let oldContentOffset = oldContentOffset else { return } let deltaY = (newContentOffset - oldContentOffset).y guard deltaY != 0 else { return } let isScrollingDownward = deltaY > 0 // Debounce. let maxPreloadFrequency: TimeInterval = kSecondInterval / 100 if let lastPreloadCellDate = self.lastPreloadCellDate, abs(lastPreloadCellDate.timeIntervalSinceNow) < maxPreloadFrequency { return } lastPreloadCellDate = Date() guard let visibleIndexPaths = tableView.indexPathsForVisibleRows else { owsFailDebug("Missing visibleIndexPaths.") return } let conversationIndexPaths = visibleIndexPaths.compactMap { indexPath -> IndexPath? in guard let section = HomeViewSection(rawValue: indexPath.section) else { owsFailDebug("Invalid section: \(indexPath.section).") return nil } switch section { case .reminders: return nil case .pinned, .unpinned: return indexPath case .archiveButton: return nil } } guard !conversationIndexPaths.isEmpty else { return } let sortedIndexPaths = conversationIndexPaths.sorted() var indexPathsToPreload = [IndexPath]() func tryToEnqueue(_ indexPath: IndexPath) { let rowCount = numberOfRows(inSection: indexPath.section) guard indexPath.row >= 0, indexPath.row < rowCount else { return } indexPathsToPreload.append(indexPath) } let preloadCount: Int = 3 if isScrollingDownward { guard let lastIndexPath = sortedIndexPaths.last else { owsFailDebug("Missing indexPath.") return } // Order matters; we want to preload in order of proximity // to viewport. for index in 0.. 0 else { return } // Order matters; we want to preload in order of proximity // to viewport. for index in 0.. CGFloat { AssertIsOnMainThread() guard let section = HomeViewSection(rawValue: section) else { owsFailDebug("Invalid section: \(section).") return 0 } switch section { case .pinned, .unpinned: if !renderState.hasPinnedAndUnpinnedThreads { return CGFloat.epsilon } return UITableView.automaticDimension default: // Without returning a header with a non-zero height, Grouped // table view will use a default spacing between sections. We // do not want that spacing so we use the smallest possible height. return CGFloat.epsilon } } public func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat { AssertIsOnMainThread() // Without returning a footer with a non-zero height, Grouped // table view will use a default spacing between sections. We // do not want that spacing so we use the smallest possible height. return CGFloat.epsilon } public func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { AssertIsOnMainThread() guard let section = HomeViewSection(rawValue: section) else { owsFailDebug("Invalid section: \(section).") return nil } switch section { case .pinned, .unpinned: let container = UIView() container.layoutMargins = UIEdgeInsets(top: 14, leading: 16, bottom: 8, trailing: 16) let label = UILabel() container.addSubview(label) label.autoPinEdgesToSuperviewMargins() label.font = UIFont.ows_dynamicTypeBody.ows_semibold label.textColor = Theme.primaryTextColor label.text = (section == .pinned ? NSLocalizedString("PINNED_SECTION_TITLE", comment: "The title for pinned conversation section on the conversation list") : NSLocalizedString("UNPINNED_SECTION_TITLE", comment: "The title for unpinned conversation section on the conversation list")) return container default: return UIView() } } public func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? { AssertIsOnMainThread() return UIView() } public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { AssertIsOnMainThread() Logger.info("\(indexPath)") guard let viewController = self.viewController else { owsFailDebug("Missing viewController.") return } viewController.dismissSearchKeyboard() guard let section = HomeViewSection(rawValue: indexPath.section) else { owsFailDebug("Invalid section: \(indexPath.section).") return } switch section { case .reminders: break case .pinned, .unpinned: guard let threadViewModel = threadViewModel(forIndexPath: indexPath) else { owsFailDebug("Missing threadViewModel.") return } viewController.present(threadViewModel.threadRecord, action: .none, animated: true) case .archiveButton: viewController.showArchivedConversations() } } @available(iOS 13.0, *) public func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { AssertIsOnMainThread() guard let viewController = self.viewController else { owsFailDebug("Missing viewController.") return nil } guard viewController.canPresentPreview(fromIndexPath: indexPath) else { return nil } guard let threadId = renderState.thread(forIndexPath: indexPath) else { return nil } return UIContextMenuConfiguration(identifier: threadId, previewProvider: { [weak viewController] in viewController?.createPreviewController(atIndexPath: indexPath) }, actionProvider: { _ in // nil for now. But we may want to add options like "Pin" or "Mute" in the future return nil }) } @available(iOS 13.0, *) public func tableView(_ tableView: UITableView, previewForDismissingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { AssertIsOnMainThread() guard let threadId = configuration.identifier as? String else { owsFailDebug("Unexpected context menu configuration identifier") return nil } guard let indexPath = renderState.indexPath(forUniqueId: threadId) else { Logger.warn("No index path for threadId: \(threadId).") return nil } // Below is a partial workaround for database updates causing cells to reload mid-transition: // When the conversation view controller is dismissed, it touches the database which causes // the row to update. // // The way this *should* appear is that during presentation and dismissal, the row animates // into and out of the platter. Currently, it looks like UIKit uses a portal view to accomplish // this. It seems the row stays in its original position and is occluded by context menu internals // while the portal view is translated. // // But in our case, when the table view is updated the old cell will be removed and hidden by // UITableView. So mid-transition, the cell appears to disappear. What's left is the background // provided by UIPreviewParameters. By default this is opaque and the end result is that an empty // row appears while dismissal completes. // // A straightforward way to work around this is to just set the background color to clear. When // the row is updated because of a database change, it will appear to snap into position instead // of properly animating. This isn't *too* much of an issue since the row is usually occluded by // the platter anyway. This avoids the empty row issue. A better solution would probably be to // defer data source updates until the transition completes but, as far as I can tell, we aren't // notified when this happens. guard let cell = tableView.cellForRow(at: indexPath) as? HomeViewCell else { owsFailDebug("Invalid cell.") return nil } let cellFrame = tableView.rectForRow(at: indexPath) let center = cellFrame.center let target = UIPreviewTarget(container: tableView, center: center) let params = UIPreviewParameters() params.backgroundColor = .clear return UITargetedPreview(view: cell, parameters: params, target: target) } @available(iOS 13.0, *) public func tableView(_ tableView: UITableView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { AssertIsOnMainThread() guard let viewController = self.viewController else { owsFailDebug("Missing viewController.") return } guard let vc = animator.previewViewController else { owsFailDebug("Missing previewViewController.") return } animator.addAnimations { [weak viewController] in viewController?.commitPreviewController(vc) } } public func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) { AssertIsOnMainThread() guard let cell = cell as? HomeViewCell else { return } viewController?.updateCellVisibility(cell: cell, isCellVisible: true) preloadCellsIfNecessary() } public func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) { AssertIsOnMainThread() guard let cell = cell as? HomeViewCell else { return } viewController?.updateCellVisibility(cell: cell, isCellVisible: false) } } // MARK: - extension HVTableDataSource: UITableViewDataSource { public func numberOfSections(in tableView: UITableView) -> Int { AssertIsOnMainThread() return HomeViewSection.allCases.count } public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { AssertIsOnMainThread() return numberOfRows(inSection: section) } fileprivate func numberOfRows(inSection section: Int) -> Int { AssertIsOnMainThread() guard let viewController = self.viewController else { owsFailDebug("Missing viewController.") return 0 } guard let section = HomeViewSection(rawValue: section) else { owsFailDebug("Invalid section: \(section).") return 0 } switch section { case .reminders: return viewController.hasVisibleReminders ? 1 : 0 case .pinned: return renderState.pinnedThreads.count case .unpinned: return renderState.unpinnedThreads.count case .archiveButton: return viewController.hasArchivedThreadsRow ? 1 : 0 } } public func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { AssertIsOnMainThread() guard let section = HomeViewSection(rawValue: indexPath.section) else { owsFailDebug("Invalid section: \(indexPath.section).") return UITableView.automaticDimension } switch section { case .reminders: return UITableView.automaticDimension case .pinned, .unpinned: return measureConversationCell(tableView: tableView, indexPath: indexPath) case .archiveButton: return UITableView.automaticDimension } } public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { AssertIsOnMainThread() guard let viewController = self.viewController else { owsFailDebug("Missing viewController.") return UITableViewCell() } guard let section = HomeViewSection(rawValue: indexPath.section) else { owsFailDebug("Invalid section: \(indexPath.section).") return UITableViewCell() } let cell: UITableViewCell = { switch section { case .reminders: return viewController.reminderViewCell case .pinned, .unpinned: return buildConversationCell(tableView: tableView, indexPath: indexPath) case .archiveButton: return buildArchivedConversationsButtonCell(tableView: tableView, indexPath: indexPath) } }() if let splitViewController = self.splitViewController { if !splitViewController.isCollapsed { cell.selectedBackgroundView?.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray65 : .ows_gray15 cell.backgroundColor = Theme.secondaryBackgroundColor } } else { owsFailDebug("Missing splitViewController.") } return cell } private func measureConversationCell(tableView: UITableView, indexPath: IndexPath) -> CGFloat { AssertIsOnMainThread() guard let cellConfigurationAndContentToken = buildCellConfigurationAndContentTokenSync(forIndexPath: indexPath) else { owsFailDebug("Missing cellConfigurationAndContentToken.") return UITableView.automaticDimension } return HomeViewCell.measureCellHeight(cellContentToken: cellConfigurationAndContentToken.contentToken) } private func buildConversationCell(tableView: UITableView, indexPath: IndexPath) -> UITableViewCell { AssertIsOnMainThread() guard let cell = tableView.dequeueReusableCell(withIdentifier: HomeViewCell.reuseIdentifier) as? HomeViewCell else { owsFailDebug("Invalid cell.") return UITableViewCell() } guard let cellConfigurationAndContentToken = buildCellConfigurationAndContentTokenSync(forIndexPath: indexPath) else { owsFailDebug("Missing cellConfigurationAndContentToken.") return UITableViewCell() } let configuration = cellConfigurationAndContentToken.configuration let contentToken = cellConfigurationAndContentToken.contentToken cell.configure(cellContentToken: contentToken) let thread = configuration.thread.threadRecord let cellName: String = { if let groupThread = thread as? TSGroupThread { return "cell-group-\(groupThread.groupModel.groupName ?? "unknown")" } else if let contactThread = thread as? TSContactThread { return "cell-contact-\(contactThread.contactAddress.stringForDisplay)" } else { owsFailDebug("invalid-thread-\(thread.uniqueId) ") return "Unknown" } }() cell.accessibilityIdentifier = cellName if isConversationActive(forThread: thread) { tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none) } else { tableView.deselectRow(at: indexPath, animated: false) } return cell } private func isConversationActive(forThread thread: TSThread) -> Bool { AssertIsOnMainThread() guard let conversationSplitViewController = splitViewController as? ConversationSplitViewController else { owsFailDebug("Missing conversationSplitViewController.") return false } return conversationSplitViewController.selectedThread?.uniqueId == thread.uniqueId } private func buildArchivedConversationsButtonCell(tableView: UITableView, indexPath: IndexPath) -> UITableViewCell { AssertIsOnMainThread() guard let cell = tableView.dequeueReusableCell(withIdentifier: kArchivedConversationsReuseIdentifier) else { owsFailDebug("Invalid cell.") return UITableViewCell() } OWSTableItem.configureCell(cell) cell.selectionStyle = .none for subview in cell.contentView.subviews { subview.removeFromSuperview() } let disclosureImageName = CurrentAppContext().isRTL ? "NavBarBack" : "NavBarBackRTL" let disclosureImageView = UIImageView.withTemplateImageName(disclosureImageName, tintColor: UIColor(rgbHex: 0xd1d1d6)) disclosureImageView.setContentHuggingHigh() disclosureImageView.setCompressionResistanceHigh() let label = UILabel() label.text = NSLocalizedString("HOME_VIEW_ARCHIVED_CONVERSATIONS", comment: "Label for 'archived conversations' button.") label.textAlignment = .center label.font = .ows_dynamicTypeBody label.textColor = Theme.primaryTextColor let stackView = UIStackView() stackView.axis = .horizontal stackView.spacing = 5 // If alignment isn't set, UIStackView uses the height of // disclosureImageView, even if label has a higher desired height. stackView.alignment = .center stackView.addArrangedSubview(label) stackView.addArrangedSubview(disclosureImageView) cell.contentView.addSubview(stackView) stackView.autoCenterInSuperview() // Constrain to cell margins. stackView.autoPinEdge(toSuperviewMargin: .leading, relation: .greaterThanOrEqual) stackView.autoPinEdge(toSuperviewMargin: .trailing, relation: .greaterThanOrEqual) stackView.autoPinEdge(toSuperviewMargin: .top) stackView.autoPinEdge(toSuperviewMargin: .bottom) cell.accessibilityIdentifier = "archived_conversations" return cell } // MARK: - Edit Actions public func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { AssertIsOnMainThread() // TODO: Is this method necessary? } public func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { AssertIsOnMainThread() guard let section = HomeViewSection(rawValue: indexPath.section) else { owsFailDebug("Invalid section: \(indexPath.section).") return nil } switch section { case .reminders, .archiveButton: return nil case .pinned, .unpinned: guard let threadViewModel = threadViewModel(forIndexPath: indexPath) else { owsFailDebug("Missing threadViewModel.") return nil } guard let viewController = self.viewController else { owsFailDebug("Missing viewController.") return nil } let deleteAction = UIContextualAction(style: .destructive, title: nil) { [weak viewController] (_, _, completion) in viewController?.deleteThreadWithConfirmation(threadViewModel: threadViewModel) completion(false) } deleteAction.backgroundColor = .ows_accentRed deleteAction.image = self.actionImage(name: "trash-solid-24", title: CommonStrings.deleteButton) deleteAction.accessibilityLabel = CommonStrings.deleteButton let archiveAction = UIContextualAction(style: .normal, title: nil) { [weak viewController] (_, _, completion) in viewController?.archiveThread(threadViewModel: threadViewModel) completion(false) } let archiveTitle = (viewController.homeViewMode == .inbox ? CommonStrings.archiveAction : CommonStrings.unarchiveAction) archiveAction.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray45 : .ows_gray25 archiveAction.image = self.actionImage(name: "archive-solid-24", title: archiveTitle) archiveAction.accessibilityLabel = archiveTitle // The first action will be auto-performed for "very long swipes". return UISwipeActionsConfiguration(actions: [ archiveAction, deleteAction ]) } } private func actionImage(name imageName: String, title: String) -> UIImage? { AssertIsOnMainThread() // We need to bake the title text into the image because `UIContextualAction` // only displays title + image when the cell's height > 91. We want to always // show both. guard let image = UIImage(named: imageName) else { owsFailDebug("Missing image.") return nil } guard let image = image.withTitle(title, font: UIFont.systemFont(ofSize: 13), color: .ows_white, maxTitleWidth: 68, minimumScaleFactor: CGFloat(8) / CGFloat(13), spacing: 4) else { owsFailDebug("Missing image.") return nil } return image.withRenderingMode(.alwaysTemplate) } public func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { AssertIsOnMainThread() guard let section = HomeViewSection(rawValue: indexPath.section) else { owsFailDebug("Invalid section: \(indexPath.section).") return false } switch section { case .reminders: return false case .pinned, .unpinned: return true case .archiveButton: return false } } public func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? { AssertIsOnMainThread() guard let section = HomeViewSection(rawValue: indexPath.section) else { owsFailDebug("Invalid section: \(indexPath.section).") return nil } switch section { case .reminders: return nil case .archiveButton: return nil case .pinned, .unpinned: guard let threadViewModel = threadViewModel(forIndexPath: indexPath) else { owsFailDebug("Missing threadViewModel.") return nil } let thread = threadViewModel.threadRecord let isThreadPinned = PinnedThreadManager.isThreadPinned(thread) let pinnedStateAction: UIContextualAction if isThreadPinned { pinnedStateAction = UIContextualAction(style: .normal, title: nil) { [weak viewController] (_, _, completion) in viewController?.unpinThread(threadViewModel: threadViewModel) completion(false) } pinnedStateAction.backgroundColor = UIColor(rgbHex: 0xff990a) pinnedStateAction.accessibilityLabel = CommonStrings.unpinAction pinnedStateAction.image = actionImage(name: "unpin-solid-24", title: CommonStrings.unpinAction) } else { pinnedStateAction = UIContextualAction(style: .destructive, title: nil) { [weak viewController] (_, _, completion) in completion(false) viewController?.pinThread(threadViewModel: threadViewModel) } pinnedStateAction.backgroundColor = UIColor(rgbHex: 0xff990a) pinnedStateAction.accessibilityLabel = CommonStrings.pinAction pinnedStateAction.image = actionImage(name: "pin-solid-24", title: CommonStrings.pinAction) } let readStateAction: UIContextualAction if threadViewModel.hasUnreadMessages { readStateAction = UIContextualAction(style: .destructive, title: nil) { [weak viewController] (_, _, completion) in completion(false) // We delay here so the animation can play out before we // reload the cell DispatchQueue.main.asyncAfter(deadline: .now() + 0.65) { [weak viewController] in viewController?.markThreadAsRead(threadViewModel: threadViewModel) } } readStateAction.backgroundColor = .ows_accentBlue readStateAction.accessibilityLabel = CommonStrings.readAction readStateAction.image = actionImage(name: "read-solid-24", title: CommonStrings.readAction) } else { readStateAction = UIContextualAction(style: .normal, title: nil) { [weak viewController] (_, _, completion) in completion(false) // We delay here so the animation can play out before we // reload the cell DispatchQueue.main.asyncAfter(deadline: .now() + 0.65) { [weak viewController] in viewController?.markThreadAsUnread(threadViewModel: threadViewModel) } } readStateAction.backgroundColor = .ows_accentBlue readStateAction.accessibilityLabel = CommonStrings.unreadAction readStateAction.image = actionImage(name: "unread-solid-24", title: CommonStrings.unreadAction) } // The first action will be auto-performed for "very long swipes". return UISwipeActionsConfiguration(actions: [ readStateAction, pinnedStateAction ]) } } } // MARK: - extension HVTableDataSource { fileprivate struct HVCellConfigurationAndContentToken { let configuration: HomeViewCell.Configuration let contentToken: HVCellContentToken } // This method can be called from any thread. private static func buildCellConfiguration(threadViewModel: ThreadViewModel, lastReloadDate: Date?) -> HomeViewCell.Configuration { owsAssertDebug(threadViewModel.homeViewInfo != nil) let isBlocked = threadViewModel.homeViewInfo?.isBlocked == true let configuration = HomeViewCell.Configuration(thread: threadViewModel, lastReloadDate: lastReloadDate, isBlocked: isBlocked) return configuration } fileprivate func buildCellConfigurationAndContentTokenSync(forIndexPath indexPath: IndexPath) -> HVCellConfigurationAndContentToken? { AssertIsOnMainThread() guard let threadViewModel = threadViewModel(forIndexPath: indexPath) else { owsFailDebug("Missing threadViewModel.") return nil } guard let viewController = self.viewController else { owsFailDebug("Missing viewController.") return nil } let lastReloadDate: Date? = { guard viewState.hasEverAppeared else { return nil } return self.lastReloadDate }() let configuration = Self.buildCellConfiguration(threadViewModel: threadViewModel, lastReloadDate: lastReloadDate) let cellContentCache = viewController.cellContentCache let contentToken = { () -> HVCellContentToken in // If we have an existing HVCellContentToken, use it. // Cell measurement/arrangement is expensive. let cacheKey = configuration.thread.threadRecord.uniqueId if let cellContentToken = cellContentCache.get(key: cacheKey) { return cellContentToken } let cellContentToken = HomeViewCell.buildCellContentToken(forConfiguration: configuration) cellContentCache.set(key: cacheKey, value: cellContentToken) return cellContentToken }() return HVCellConfigurationAndContentToken(configuration: configuration, contentToken: contentToken) } // TODO: It would be preferable to figure out some way to use ReverseDispatchQueue. private static let preloadSerialQueue = DispatchQueue(label: "HVTableDataSource.preloadSerialQueue") fileprivate func preloadCellIfNecessaryAsync(indexPath: IndexPath) { AssertIsOnMainThread() guard let viewController = self.viewController else { owsFailDebug("Missing viewController.") return } // These caches should only be accessed on the main thread. // They are thread-safe, but we don't want to race with a reset. let cellContentCache = viewController.cellContentCache let threadViewModelCache = viewController.threadViewModelCache // Take note of the cache reset counts. If either cache is reset // before we complete, discard the outcome of the preload. let cellContentCacheResetCount = cellContentCache.resetCount let threadViewModelCacheResetCount = threadViewModelCache.resetCount guard let thread = self.thread(forIndexPath: indexPath) else { owsFailDebug("Missing thread.") return } let cacheKey = thread.uniqueId guard nil == cellContentCache.get(key: cacheKey) else { // If we already have an existing HVCellContentToken, abort. return } let lastReloadDate: Date? = { guard viewState.hasEverAppeared else { return nil } return self.lastReloadDate }() // We use a serial queue to ensure we don't race and preload the same cell // twice at the same time. firstly(on: Self.preloadSerialQueue) { () -> (ThreadViewModel, HVCellContentToken) in guard nil == cellContentCache.get(key: cacheKey) else { // If we already have an existing HVCellContentToken, abort. throw HVPreloadError.alreadyLoaded } // This is the expensive work we do off the main thread. let threadViewModel = Self.databaseStorage.read { transaction in ThreadViewModel(thread: thread, forHomeView: true, transaction: transaction) } let configuration = Self.buildCellConfiguration(threadViewModel: threadViewModel, lastReloadDate: lastReloadDate) let contentToken = HomeViewCell.buildCellContentToken(forConfiguration: configuration) return (threadViewModel, contentToken) }.done(on: .main) { (threadViewModel: ThreadViewModel, contentToken: HVCellContentToken) in // Commit the preloaded values to their respective caches. guard cellContentCacheResetCount == cellContentCache.resetCount else { Logger.info("cellContentCache was reset.") return } guard threadViewModelCacheResetCount == threadViewModelCache.resetCount else { Logger.info("cellContentCache was reset.") return } if nil == threadViewModelCache.get(key: cacheKey) { threadViewModelCache.set(key: cacheKey, value: threadViewModel) } else { Logger.info("threadViewModel already loaded.") } if nil == cellContentCache.get(key: cacheKey) { cellContentCache.set(key: cacheKey, value: contentToken) } else { Logger.info("contentToken already loaded.") } }.catch(on: .global()) { error in if case HVPreloadError.alreadyLoaded = error { return } owsFailDebugUnlessNetworkFailure(error) } } private enum HVPreloadError: Error { case alreadyLoaded } } // MARK: - public class HVTableView: UITableView { fileprivate var lastReloadDate: Date? @objc public override func reloadData() { AssertIsOnMainThread() lastReloadDate = Date() super.reloadData() } @objc public required init() { super.init(frame: .zero, style: .grouped) } @available(*, unavailable, message: "use other constructor instead.") required init?(coder: NSCoder) { notImplemented() fatalError("init(coder:) has not been implemented") } }