Signal-iOS/Signal/src/ViewControllers/HomeView/HVTableDataSource.swift
2021-08-12 20:03:34 -03:00

976 lines
38 KiB
Swift

//
// 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..<preloadCount {
let offset = +index
tryToEnqueue(IndexPath(row: lastIndexPath.row + offset,
section: lastIndexPath.section))
}
} else {
guard let firstIndexPath = sortedIndexPaths.first else {
owsFailDebug("Missing indexPath.")
return
}
guard firstIndexPath.row > 0 else {
return
}
// Order matters; we want to preload in order of proximity
// to viewport.
for index in 0..<preloadCount {
let offset = -index
tryToEnqueue(IndexPath(row: firstIndexPath.row + offset,
section: firstIndexPath.section))
}
}
for indexPath in indexPathsToPreload {
preloadCellIfNecessaryAsync(indexPath: indexPath)
}
}
public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
AssertIsOnMainThread()
guard let viewController = viewController else {
owsFailDebug("Missing viewController.")
return
}
viewController.dismissSearchKeyboard()
}
}
// MARK: -
@objc
public enum HomeViewMode: Int, CaseIterable {
case archive
case inbox
}
// MARK: -
@objc
public enum HomeViewSection: Int, CaseIterable {
case reminders
case pinned
case unpinned
case archiveButton
}
// MARK: -
extension HVTableDataSource: UITableViewDelegate {
public func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> 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")
}
}