Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/HomeView/Chat List/ChatListViewController+Loading.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

public import SignalServiceKit
import UIKit

extension ChatListViewController {
    public var isViewVisible: Bool {
        get { viewState.isViewVisible }
        set {
            if newValue != viewState.isViewVisible {
                viewState.isViewVisible = newValue
                updateCellVisibility()
                shouldBeUpdatingView = newValue
            }
        }
    }

    fileprivate var shouldBeUpdatingView: Bool {
        get { viewState.shouldBeUpdatingView }
        set {
            guard viewState.shouldBeUpdatingView != newValue else {
                // Ignore redundant changes.
                return
            }
            viewState.shouldBeUpdatingView = newValue
        }
    }

    // MARK: -

    func updateShouldBeUpdatingView() {
        AssertIsOnMainThread()

        let isAppForegroundAndActive = CurrentAppContext().isAppForegroundAndActive()

        self.shouldBeUpdatingView = self.isViewVisible && isAppForegroundAndActive
    }

    // MARK: -

    fileprivate func loadRenderStateForReset(
        viewInfo: CLVViewInfo,
        transaction: DBReadTransaction,
    ) -> CLVLoadResult {
        AssertIsOnMainThread()

        return CLVLoader.loadRenderStateForReset(viewInfo: viewInfo, transaction: transaction)
    }

    fileprivate func copyRenderStateAndDiff(viewInfo: CLVViewInfo) -> CLVLoadResult {
        AssertIsOnMainThread()
        return CLVLoader.newRenderStateWithViewInfo(viewInfo, lastRenderState: renderState)
    }

    fileprivate func loadNewRenderStateWithDiff(
        viewInfo: CLVViewInfo,
        updatedThreadIds: Set<String>,
        transaction: DBReadTransaction,
    ) -> CLVLoadResult {
        AssertIsOnMainThread()

        return CLVLoader.loadRenderStateAndDiff(
            viewInfo: viewInfo,
            updatedItemIds: updatedThreadIds,
            lastRenderState: renderState,
            transaction: transaction,
        )
    }

    fileprivate func applyLoadResult(_ loadResult: CLVLoadResult, animated: Bool) {
        AssertIsOnMainThread()

        switch loadResult {
        case .renderStateForReset(renderState: let renderState):
            let previousSelection = tableDataSource.selectedThreadUniqueIds(in: tableView)
            tableDataSource.renderState = renderState

            threadViewModelCache.clear()
            cellContentCache.clear()
            conversationCellHeightCache = nil

            reloadTableData(previouslySelectedThreadUniqueIds: previousSelection)

        case .renderStateWithRowChanges(renderState: let renderState, let rowChanges):
            applyRowChanges(rowChanges, renderState: renderState, animated: animated)

        case .reloadTable:
            reloadTableData()

        case .noChanges:
            break
        }

        tableDataSource.calcRefreshTimer()
        // We need to perform this regardless of the load result type.
        updateViewState()
        viewState.updateViewInfo(renderState.viewInfo)
    }

    private func applyRowChanges(_ rowChanges: [CLVRowChange], renderState: CLVRenderState, animated: Bool) {
        AssertIsOnMainThread()

        let previousRenderState = tableDataSource.renderState
        tableDataSource.renderState = renderState

        let sectionChanges = renderState.sections
            .difference(from: previousRenderState.sections)
            .batchedChanges()
        let isChangingFilter = previousRenderState.viewInfo.inboxFilter != renderState.viewInfo.inboxFilter

        let tableView = self.tableView
        let threadViewModelCache = self.threadViewModelCache
        let cellContentCache = self.cellContentCache

        let filterChangeAnimation = animated ? UITableView.RowAnimation.fade : .none
        let defaultRowAnimation = animated ? UITableView.RowAnimation.automatic : .none

        // only perform a beginUpdates/endUpdates block if really necessary, otherwise
        // strange scroll animations may occur
        var tableUpdatesPerformed = false
        let checkAndSetTableUpdates = {
            if !tableUpdatesPerformed {
                tableView.beginUpdates()
                // animate all UI changes within the same transaction
                if tableView.isEditing, !self.viewState.multiSelectState.isActive {
                    tableView.setEditing(false, animated: true)
                }
                tableUpdatesPerformed = true
            }
        }

        // As soon as structural changes are applied to the table we can not use our optimized update implementation
        // anymore. All indexPaths are based on the old model (before any change was applied) and if we
        // animate move, insert and delete changes the indexPaths of the to be updated rows will differ.
        var useFallBackUpdateMechanism = false

        if !sectionChanges.removals.isEmpty {
            checkAndSetTableUpdates()
            tableView.deleteSections(sectionChanges.removals.offsets, with: .middle)
            useFallBackUpdateMechanism = true
        }

        if !sectionChanges.insertions.isEmpty {
            checkAndSetTableUpdates()
            tableView.insertSections(sectionChanges.insertions.offsets, with: .fade)
            useFallBackUpdateMechanism = true
        }

        for rowChange in rowChanges {
            threadViewModelCache.removeObject(forKey: rowChange.threadUniqueId)
            cellContentCache.removeObject(forKey: rowChange.threadUniqueId)

            switch rowChange.type {
            case .delete(let oldIndexPath):
                checkAndSetTableUpdates()
                tableView.deleteRows(at: [oldIndexPath], with: isChangingFilter ? filterChangeAnimation : defaultRowAnimation)
                useFallBackUpdateMechanism = true
            case .insert(let newIndexPath):
                checkAndSetTableUpdates()
                tableView.insertRows(at: [newIndexPath], with: isChangingFilter ? filterChangeAnimation : defaultRowAnimation)
                useFallBackUpdateMechanism = true
            case .move(let oldIndexPath, let newIndexPath):
                // NOTE: if we're moving within the same section, we perform
                //       moves using a "delete" and "insert" rather than a "move".
                //       This ensures that moved items are also reloaded. This is
                //       how UICollectionView performs reloads internally. We can't
                //       do this when changing sections, because it results in a weird
                //       animation. This should generally be safe, because you'll only
                //       move between sections when pinning / unpinning which doesn't
                //       require the moved item to be reloaded.
                checkAndSetTableUpdates()
                if oldIndexPath.section != newIndexPath.section {
                    tableView.moveRow(at: oldIndexPath, to: newIndexPath)
                } else {
                    tableView.deleteRows(at: [oldIndexPath], with: defaultRowAnimation)
                    tableView.insertRows(at: [newIndexPath], with: defaultRowAnimation)
                }
                useFallBackUpdateMechanism = true
            case .update(let oldIndexPath):
                if tableView.isEditing, !viewState.multiSelectState.isActive {
                    checkAndSetTableUpdates()
                }

                if useFallBackUpdateMechanism {
                    checkAndSetTableUpdates()
                    tableView.reloadRows(at: [oldIndexPath], with: .none)
                } else if let tableDataSource = tableView.dataSource as? CLVTableDataSource {
                    // If we can, we'll do an in-place update to the cell rather
                    // than call `reloadRows`, to avoid what can be a disruptive
                    // re-layout of the chat list.
                    //
                    // This optimization is particularly important when there are
                    // many rapid-fire chat-list-cell updates needed, such as when
                    // fetching avatars after a Backup restore. (See:
                    // `BackupArchiveAvatarFetcher`.)
                    tableDataSource.updateCellContent(at: oldIndexPath, for: tableView)
                } else {
                    owsFailDebug("Failed to apply row update: unexpectedly missing table data source!")
                }
            }
        }

        if !sectionChanges.updates.isEmpty {
            checkAndSetTableUpdates()

            for (_, sectionUpdate) in sectionChanges.updates {
                guard
                    let rowChanges = renderState
                        .sectionDifference(for: sectionUpdate.element, from: previousRenderState)?
                        .batchedChanges()
                else { continue }

                let sectionIndex = sectionUpdate.offset

                if !rowChanges.removals.isEmpty {
                    tableView.deleteRows(at: rowChanges.removals.indexPaths(in: sectionIndex), with: defaultRowAnimation)
                }

                if !rowChanges.insertions.isEmpty {
                    tableView.insertRows(at: rowChanges.insertions.indexPaths(in: sectionIndex), with: defaultRowAnimation)
                }

                if !rowChanges.updates.isEmpty {
                    if let previousSectionIndex = sectionUpdate.previousOffset, sectionIndex != previousSectionIndex {
                        // If the section index has changed, there's a good
                        // chance that the type of the cell has also changed
                        // (i.e., the `reuseIdentifier` last associated with that
                        // `indexPath`). This causes `reconfigureRows(at:)` to
                        // raise an assertion.
                        //
                        // Whenever the type of cell may have changed, we have
                        // to be conservative and reload instead of reconfiguring.
                        tableView.reloadRows(at: rowChanges.updates.indexPaths(in: previousSectionIndex), with: defaultRowAnimation)
                    } else {
                        tableView.reconfigureRows(at: rowChanges.updates.indexPaths(in: sectionIndex))
                    }
                }
            }
        }

        if tableUpdatesPerformed {
            tableView.endUpdates()
        }
    }
}

// MARK: -

private enum CLVLoadType {
    case resetAll
    case incrementalDiff(updatedThreadIds: Set<String>)
    case incrementalWithoutThreadUpdates
    case none
}

// MARK: -

public class CLVLoadCoordinator {
    private let filterStore: ChatListFilterStore
    private var loadInfoBuilder: CLVLoadInfoBuilder

    public weak var viewController: ChatListViewController?

    public init() {
        self.filterStore = ChatListFilterStore()
        self.loadInfoBuilder = CLVLoadInfoBuilder()
        self.loadInfoBuilder.shouldResetAll = true
    }

    private struct CLVLoadInfo {
        let viewInfo: CLVViewInfo
        let loadType: CLVLoadType
    }

    private class CLVLoadInfoBuilder {
        var shouldResetAll = false
        var updatedThreadIds = Set<String>()

        func build(
            loadCoordinator: CLVLoadCoordinator,
            chatListMode: ChatListMode,
            inboxFilter: InboxFilter?,
            isMultiselectActive: Bool,
            lastSelectedThreadId: String?,
            hasVisibleReminders: Bool,
            shouldBackupDownloadProgressViewBeVisible: Bool,
            shouldBackupExportProgressViewBeVisible: Bool,
            lastViewInfo: CLVViewInfo,
            transaction: DBReadTransaction,
        ) -> CLVLoadInfo {
            let inboxFilter = inboxFilter ?? loadCoordinator.filterStore.inboxFilter(transaction: transaction) ?? .none

            let viewInfo = CLVViewInfo.build(
                chatListMode: chatListMode,
                inboxFilter: inboxFilter,
                isMultiselectActive: isMultiselectActive,
                lastSelectedThreadId: lastSelectedThreadId,
                hasVisibleReminders: hasVisibleReminders,
                shouldBackupDownloadProgressViewBeVisible: shouldBackupDownloadProgressViewBeVisible,
                shouldBackupExportProgressViewBeVisible: shouldBackupExportProgressViewBeVisible,
                transaction: transaction,
            )

            if shouldResetAll {
                return CLVLoadInfo(viewInfo: viewInfo, loadType: .resetAll)
            } else if !updatedThreadIds.isEmpty || viewInfo.inboxFilter != lastViewInfo.inboxFilter {
                return CLVLoadInfo(viewInfo: viewInfo, loadType: .incrementalDiff(updatedThreadIds: updatedThreadIds))
            } else if viewInfo != lastViewInfo {
                return CLVLoadInfo(viewInfo: viewInfo, loadType: .incrementalWithoutThreadUpdates)
            } else {
                return CLVLoadInfo(viewInfo: viewInfo, loadType: .none)
            }
        }
    }

    public func saveInboxFilter(_ inboxFilter: InboxFilter) {
        SSKEnvironment.shared.databaseStorageRef.asyncWrite { [filterStore] transaction in
            filterStore.setInboxFilter(inboxFilter, transaction: transaction)
        }
    }

    public func scheduleHardReset() {
        AssertIsOnMainThread()

        loadInfoBuilder.shouldResetAll = true

        loadIfNecessary()
    }

    public func scheduleLoad(updatedThreadIds: some Collection<String>, animated: Bool = true) {
        AssertIsOnMainThread()
        owsAssertDebug(!updatedThreadIds.isEmpty)

        loadInfoBuilder.updatedThreadIds.formUnion(updatedThreadIds)

        loadIfNecessary(suppressAnimations: !animated)
    }

    public func ensureFirstLoad() {
        AssertIsOnMainThread()

        guard let viewController else {
            owsFailDebug("Missing viewController.")
            return
        }

        // During main app launch, the chat list becomes visible _before_
        // app is foreground and active.  Therefore we need to make an
        // exception and update the view contents; otherwise, the home
        // view will briefly appear empty after launch.
        let shouldForceLoad = (
            !viewController.hasEverAppeared &&
                viewController.tableDataSource.renderState.visibleThreadCount == 0,
        )

        loadIfNecessary(suppressAnimations: true, shouldForceLoad: shouldForceLoad)
    }

    @objc
    public func applicationWillEnterForeground() {
        AssertIsOnMainThread()

        guard let viewController else {
            owsFailDebug("Missing viewController.")
            return
        }

        if viewController.isViewVisible {
            // When app returns from background, it should perform one load
            // immediately (before entering the foreground) without animations.
            // Otherwise, the user sees the changes that occurred in the
            // background animate in.
            loadIfNecessary(suppressAnimations: true, shouldForceLoad: true)
        } else {
            viewController.updateViewState()
        }
    }

    public func loadIfNecessary(suppressAnimations: Bool = false, shouldForceLoad: Bool = false) {
        AssertIsOnMainThread()

        guard let viewController else {
            owsFailDebug("Missing viewController.")
            return
        }

        guard viewController.shouldBeUpdatingView || shouldForceLoad else { return }

        // Copy the "current" load info, reset "next" load info.

        let loadResult: CLVLoadResult = SSKEnvironment.shared.databaseStorageRef.read { transaction in
            // Decide what kind of load we prefer.
            let loadInfo = loadInfoBuilder.build(
                loadCoordinator: self,
                chatListMode: viewController.viewState.chatListMode,
                inboxFilter: viewController.viewState.inboxFilter,
                isMultiselectActive: viewController.viewState.multiSelectState.isActive,
                lastSelectedThreadId: viewController.viewState.lastSelectedThreadId,
                hasVisibleReminders: viewController.viewState.reminderViews.hasVisibleReminders,
                shouldBackupDownloadProgressViewBeVisible: viewController.viewState.backupDownloadProgressView.shouldBeVisible,
                shouldBackupExportProgressViewBeVisible: viewController.viewState.backupExportProgressView.shouldBeVisible,
                lastViewInfo: viewController.renderState.viewInfo,
                transaction: transaction,
            )

            // Reset the builder.
            loadInfoBuilder = CLVLoadInfoBuilder()

            // Perform the load.
            //
            // NOTE: we might not receive the kind of load that we requested.
            switch loadInfo.loadType {
            case .resetAll:
                return viewController.loadRenderStateForReset(
                    viewInfo: loadInfo.viewInfo,
                    transaction: transaction,
                )

            case .incrementalDiff(let updatedThreadIds):
                return viewController.loadNewRenderStateWithDiff(
                    viewInfo: loadInfo.viewInfo,
                    updatedThreadIds: updatedThreadIds,
                    transaction: transaction,
                )

            case .incrementalWithoutThreadUpdates:
                return viewController.copyRenderStateAndDiff(viewInfo: loadInfo.viewInfo)

            case .none:
                return .noChanges
            }
        }

        // Apply the load to the view.
        let shouldAnimate = !suppressAnimations && viewController.hasEverAppeared
        if shouldAnimate {
            viewController.applyLoadResult(loadResult, animated: shouldAnimate)
        } else {
            // Suppress animations.
            UIView.animate(withDuration: 0) {
                viewController.applyLoadResult(loadResult, animated: shouldAnimate)
            }
        }
    }
}