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

import SignalServiceKit

/// A snapshot combining both view database state used to render chat list table view rows.
struct CLVRenderState {
    struct Section: Hashable, Identifiable {
        var type: ChatListSectionType
        var id: ChatListSectionType { type }
        var title: String?
        var threadUniqueIds: KeyPath<CLVRenderState, [String]>?
        var value: AnyHashable?
    }

    /// A type-erased representation of a row in a dynamic section of the chat
    /// list, to support automatic diffing of rows.
    struct RowItem: Hashable, Identifiable {
        var section: ChatListSectionType
        var id: AnyHashable
        var value: AnyHashable

        init(section: ChatListSectionType, value: some Hashable & Identifiable) {
            self.section = section
            self.id = value.id
            self.value = value
        }
    }

    static var empty: CLVRenderState {
        CLVRenderState(
            viewInfo: .empty,
            pinnedThreadUniqueIds: [],
            unpinnedThreadUniqueIds: [],
        )
    }

    let viewInfo: CLVViewInfo
    let pinnedThreadUniqueIds: [String]
    let unpinnedThreadUniqueIds: [String]

    private(set) var sections: [Section] = []
    private(set) var inboxFilterSection: ChatListInboxFilterSection?

    init(
        viewInfo: CLVViewInfo,
        pinnedThreadUniqueIds: [String],
        unpinnedThreadUniqueIds: [String],
    ) {
        self.viewInfo = viewInfo
        self.pinnedThreadUniqueIds = pinnedThreadUniqueIds
        self.unpinnedThreadUniqueIds = unpinnedThreadUniqueIds
        self.inboxFilterSection = ChatListInboxFilterSection(renderState: self)
        self.sections = ChatListSectionType.allCases.compactMap(makeSection(for:))
    }

    private func makeSection(for sectionType: ChatListSectionType) -> Section? {
        switch sectionType {
        case .pinned:
            let isTitleVisible = hasSectionTitles && !pinnedThreadUniqueIds.isEmpty
            return Section(
                type: sectionType,
                title: isTitleVisible ? OWSLocalizedString("PINNED_SECTION_TITLE", comment: "The title for pinned conversation section on the conversation list") : nil,
                threadUniqueIds: \.pinnedThreadUniqueIds,
            )

        case .unpinned:
            let isTitleVisible = hasSectionTitles && !unpinnedThreadUniqueIds.isEmpty
            return Section(
                type: sectionType,
                title: isTitleVisible ? OWSLocalizedString("UNPINNED_SECTION_TITLE", comment: "The title for unpinned conversation section on the conversation list") : nil,
                threadUniqueIds: \.unpinnedThreadUniqueIds,
            )

        case .reminders where hasVisibleReminders,
             .backupDownloadProgressView where shouldBackupDownloadProgressViewBeVisible,
             .backupExportProgressView where shouldBackupExportProgressViewBeVisible,
             .archiveButton where hasArchivedThreadsRow:
            return Section(type: sectionType)

        case .reminders,
             .backupDownloadProgressView,
             .backupExportProgressView,
             .archiveButton:
            return nil

        case .inboxFilterFooter:
            guard let inboxFilterSection else { return nil }
            return Section(type: sectionType, value: inboxFilterSection)
        }
    }

    var hasSectionTitles: Bool {
        !pinnedThreadUniqueIds.isEmpty
    }

    var visibleThreadCount: Int {
        pinnedThreadUniqueIds.count + unpinnedThreadUniqueIds.count
    }

    var hasArchivedThreadsRow: Bool {
        viewInfo.hasArchivedThreadsRow
    }

    var hasVisibleReminders: Bool {
        viewInfo.hasVisibleReminders
    }

    var shouldBackupDownloadProgressViewBeVisible: Bool {
        viewInfo.shouldBackupDownloadProgressViewBeVisible
    }

    var shouldBackupExportProgressViewBeVisible: Bool {
        viewInfo.shouldBackupExportProgressViewBeVisible
    }

    // MARK: UITableViewDataSource

    func numberOfRows(in section: Section) -> Int {
        switch section.type {
        case .reminders,
             .backupDownloadProgressView,
             .backupExportProgressView,
             .archiveButton,
             .inboxFilterFooter:
            return 1
        case .pinned:
            return pinnedThreadUniqueIds.count
        case .unpinned:
            return unpinnedThreadUniqueIds.count
        }
    }

    /// For chat list sections that support dynamic content, compute a
    /// collection difference of the rows in that section.
    func sectionDifference(for section: Section, from renderState: CLVRenderState) -> CollectionDifference<RowItem>? {
        switch section.type {
        case .inboxFilterFooter:
            let items = items(in: section) ?? []
            let oldValue = renderState.items(in: section) ?? []
            return items.difference(from: oldValue)

        case .pinned,
             .unpinned,
             .reminders,
             .backupDownloadProgressView,
             .backupExportProgressView,
             .archiveButton:
            return nil
        }
    }

    private func items(in section: Section) -> [RowItem]? {
        switch section.type {
        case .inboxFilterFooter:
            if let inboxFilterSection {
                return [RowItem(section: section.type, value: inboxFilterSection)]
            } else {
                return nil
            }

        case .pinned,
             .unpinned,
             .reminders,
             .backupDownloadProgressView,
             .backupExportProgressView,
             .archiveButton:
            owsFailDebug("Section diffing not yet supported in section '\(section.type)'")
            return nil
        }
    }

    func sectionIndex(for sectionType: ChatListSectionType) -> Int? {
        sections.firstIndex(where: { $0.type == sectionType })
    }

    func threadUniqueId(forIndexPath indexPath: IndexPath) -> String? {
        let section = sections[indexPath.section]
        guard let key = section.threadUniqueIds else { return nil }
        return self[keyPath: key][indexPath.row]
    }

    func indexPath(forUniqueId uniqueId: String) -> IndexPath? {
        if let index = pinnedThreadUniqueIds.firstIndex(of: uniqueId) {
            let section = sectionIndex(for: .pinned)!
            return IndexPath(item: index, section: section)
        } else if let index = unpinnedThreadUniqueIds.firstIndex(of: uniqueId) {
            let section = sectionIndex(for: .unpinned)!
            return IndexPath(item: index, section: section)
        } else {
            return nil
        }
    }

    func indexPath(afterThread thread: TSThread?) -> IndexPath? {
        let section: (index: Int, threadUniqueIds: KeyPath<CLVRenderState, [String]>)

        let threadIsPinned = thread.map { pinnedThreadUniqueIds.contains($0.uniqueId) } == true
        let noThreadSelectedAndHasPinnedThreads = thread == nil && !pinnedThreadUniqueIds.isEmpty
        if threadIsPinned || noThreadSelectedAndHasPinnedThreads {
            let index = sectionIndex(for: .pinned)!
            section = (index, sections[index].threadUniqueIds!)
        } else {
            let index = sectionIndex(for: .unpinned)!
            section = (index, sections[index].threadUniqueIds!)
        }

        guard !self[keyPath: section.threadUniqueIds].isEmpty else { return nil }

        let firstIndexPath = IndexPath(item: 0, section: section.index)

        guard
            let thread,
            let index = self[keyPath: section.threadUniqueIds].firstIndex(of: thread.uniqueId)
        else { return firstIndexPath }

        if index < (self[keyPath: section.threadUniqueIds].count - 1) {
            return IndexPath(item: index + 1, section: section.index)
        } else if
            let nextSection = sections[safe: section.index + 1],
            let nextSectionThreads = nextSection.threadUniqueIds,
            !self[keyPath: nextSectionThreads].isEmpty
        {
            return IndexPath(item: 0, section: section.index + 1)
        } else {
            return nil
        }
    }

    func indexPath(beforeThread thread: TSThread?) -> IndexPath? {
        let section: (index: Int, threadUniqueIds: KeyPath<CLVRenderState, [String]>)

        let threadIsPinned = thread.map { pinnedThreadUniqueIds.contains($0.uniqueId) } == true
        let allChatsArePinned = unpinnedThreadUniqueIds.isEmpty
        if threadIsPinned || allChatsArePinned {
            let index = sectionIndex(for: .pinned)!
            section = (index, sections[index].threadUniqueIds!)
        } else {
            let index = sectionIndex(for: .unpinned)!
            section = (index, sections[index].threadUniqueIds!)
        }

        guard !self[keyPath: section.threadUniqueIds].isEmpty else { return nil }

        let lastIndexPath = IndexPath(item: self[keyPath: section.threadUniqueIds].count - 1, section: section.index)

        guard
            let thread,
            let index = self[keyPath: section.threadUniqueIds].firstIndex(of: thread.uniqueId)
        else { return lastIndexPath }

        if index > 0 {
            return IndexPath(item: index - 1, section: section.index)
        } else if
            let previousSection = sections[safe: section.index - 1],
            let previousSectionThreads = previousSection.threadUniqueIds,
            !self[keyPath: previousSectionThreads].isEmpty
        {
            return IndexPath(item: self[keyPath: previousSectionThreads].count - 1, section: section.index - 1)
        } else {
            return nil
        }
    }
}