Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/Calls/UserInterface/CallsListViewController.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import LibSignalClient
import SignalRingRTC
import SignalServiceKit
import SignalUI

// MARK: - CallCellDelegate

private protocol CallCellDelegate: AnyObject {
    func joinCall(from viewModel: CallsListViewController.CallViewModel)
    func returnToCall(from viewModel: CallsListViewController.CallViewModel)
    func presentToast(toastText: String)
}

// MARK: - CallsListViewController

class CallsListViewController: OWSViewController, HomeTabViewController, CallServiceStateObserver, GroupCallObserver {
    private typealias Snapshot = NSDiffableDataSourceSnapshot<Section, RowIdentifier>

    private enum Constants {
        /// The maximum number of search results to match.
        static let maxSearchResults: Int = 100

        /// An interval to wait after the search term changes before actually
        /// issuing a search.
        static let searchDebounceInterval: TimeInterval = 0.1
    }

    // MARK: - Dependencies

    private struct Dependencies {
        let adHocCallRecordManager: any AdHocCallRecordManager
        let badgeManager: BadgeManager
        let blockingManager: BlockingManager
        let callLinkStore: any CallLinkRecordStore
        let callRecordDeleteAllJobQueue: CallRecordDeleteAllJobQueue
        let callRecordDeleteManager: any CallRecordDeleteManager
        let callRecordMissedCallManager: CallRecordMissedCallManager
        let callRecordQuerier: CallRecordQuerier
        let callRecordStore: CallRecordStore
        let callService: CallService
        let contactsManager: any ContactManager
        let databaseChangeObserver: DatabaseChangeObserver
        let databaseStorage: SDSDatabaseStorage
        let db: any DB
        let groupCallManager: GroupCallManager
        let interactionDeleteManager: InteractionDeleteManager
        let interactionStore: InteractionStore
        let searchableNameFinder: SearchableNameFinder
        let threadStore: ThreadStore
        let tsAccountManager: any TSAccountManager
    }

    private nonisolated let deps: Dependencies = Dependencies(
        adHocCallRecordManager: DependenciesBridge.shared.adHocCallRecordManager,
        badgeManager: AppEnvironment.shared.badgeManager,
        blockingManager: SSKEnvironment.shared.blockingManagerRef,
        callLinkStore: DependenciesBridge.shared.callLinkStore,
        callRecordDeleteAllJobQueue: SSKEnvironment.shared.callRecordDeleteAllJobQueueRef,
        callRecordDeleteManager: DependenciesBridge.shared.callRecordDeleteManager,
        callRecordMissedCallManager: DependenciesBridge.shared.callRecordMissedCallManager,
        callRecordQuerier: DependenciesBridge.shared.callRecordQuerier,
        callRecordStore: DependenciesBridge.shared.callRecordStore,
        callService: AppEnvironment.shared.callService,
        contactsManager: SSKEnvironment.shared.contactManagerRef,
        databaseChangeObserver: DependenciesBridge.shared.databaseChangeObserver,
        databaseStorage: SSKEnvironment.shared.databaseStorageRef,
        db: DependenciesBridge.shared.db,
        groupCallManager: SSKEnvironment.shared.groupCallManagerRef,
        interactionDeleteManager: DependenciesBridge.shared.interactionDeleteManager,
        interactionStore: DependenciesBridge.shared.interactionStore,
        searchableNameFinder: SearchableNameFinder(
            contactManager: SSKEnvironment.shared.contactManagerRef,
            searchableNameIndexer: DependenciesBridge.shared.searchableNameIndexer,
            phoneNumberVisibilityFetcher: DependenciesBridge.shared.phoneNumberVisibilityFetcher,
            recipientDatabaseTable: DependenciesBridge.shared.recipientDatabaseTable,
        ),
        threadStore: DependenciesBridge.shared.threadStore,
        tsAccountManager: DependenciesBridge.shared.tsAccountManager,
    )

    private let appReadiness: AppReadinessSetter

    init(appReadiness: AppReadinessSetter) {
        self.appReadiness = appReadiness
        super.init()
    }

    // MARK: - Lifecycle

    private var logger: PrefixedLogger = PrefixedLogger(prefix: "[CallsListVC]")

    private lazy var emptyStateMessageView: UILabel = {
        let label = UILabel()
        label.numberOfLines = 0
        label.textAlignment = .center
        return label
    }()

    private lazy var noSearchResultsView: UILabel = {
        let label = UILabel()
        label.numberOfLines = 0
        label.textAlignment = .center
        label.font = .dynamicTypeBody
        return label
    }()

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .Signal.background

        updateBarButtonItems()
        OWSTableViewController2.removeBackButtonText(viewController: self)

        if #available(iOS 26, *) {
            toolbarDeleteButton.image = UIImage(resource: .trash)
            self.toolbarItems = [.flexibleSpace(), toolbarDeleteButton]
        }

        tableView.delegate = self
        tableView.allowsSelectionDuringEditing = true
        tableView.allowsMultipleSelectionDuringEditing = true
        tableView.separatorStyle = .none
        tableView.contentInset = .zero
        tableView.backgroundColor = .Signal.background
        tableView.register(CreateCallLinkCell.self, forCellReuseIdentifier: Self.createCallLinkReuseIdentifier)
        tableView.register(CallCell.self, forCellReuseIdentifier: Self.callCellReuseIdentifier)
        tableView.dataSource = dataSource
        view.addSubview(tableView)
        tableView.autoPinHeight(toHeightOf: view)
        tableViewHorizontalEdgeConstraints = [
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            view.trailingAnchor.constraint(equalTo: tableView.trailingAnchor),
        ]
        NSLayoutConstraint.activate(tableViewHorizontalEdgeConstraints)
        updateTableViewPaddingIfNeeded()

        view.addSubview(emptyStateMessageView)
        emptyStateMessageView.autoCenterInSuperview()

        view.addSubview(noSearchResultsView)
        noSearchResultsView.autoPinWidthToSuperviewMargins()
        noSearchResultsView.autoPinEdge(toSuperviewMargin: .top, withInset: 80)

        initializeLoadedViewModels()
        attachSelfAsObservers()
    }

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        updateDisplayedDateForAllCallCells()
        clearMissedCallsIfNecessary()
        isPeekingEnabled = true
        schedulePeekTimerIfNeeded()
        peekOnAppear()
        peekIfPossible()
    }

    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        isPeekingEnabled = false
    }

    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)
        updateTableViewPaddingIfNeeded()
    }

    func updateBarButtonItems() {
        if tableView.isEditing {
            navigationItem.leftBarButtonItem = cancelMultiselectButton()
            navigationItem.rightBarButtonItem = deleteAllCallsButton()
        } else {
            navigationItem.leftBarButtonItem = profileBarButtonItem()
            navigationItem.rightBarButtonItem = newCallButton()
        }

        if splitViewController?.isCollapsed == false {
            navigationItem.titleView = sidebarFilterPickerContainer
        } else {
            navigationItem.titleView = filterPicker
        }
    }

    // MARK: Profile button

    private func profileBarButtonItem() -> UIBarButtonItem {
        createSettingsBarButtonItem(
            databaseStorage: SSKEnvironment.shared.databaseStorageRef,
            buildActions: { settingsAction -> [UIMenuElement] in
                return [
                    UIAction(
                        title: Strings.selectCallsButtonTitle,
                        image: Theme.iconImage(.contextMenuSelect),
                        handler: { [weak self] _ in
                            self?.startMultiselect()
                        },
                    ),
                    settingsAction,
                ]
            },
            showAppSettings: { [weak self] in
                self?.showAppSettings()
            },
        )
    }

    private func showAppSettings() {
        AssertIsOnMainThread()

        conversationSplitViewController?.selectedConversationViewController?.dismissMessageContextMenu(animated: true)
        presentFormSheet(AppSettingsViewController.inModalNavigationController(appReadiness: appReadiness), animated: true)
    }

    private func startMultiselect() {
        Logger.debug("Select calls")
        // Swipe actions count as edit mode, so cancel those
        // before entering multiselection editing mode.
        tableView.setEditing(false, animated: true)
        tableView.setEditing(true, animated: true)
        updateBarButtonItems()
        showToolbar()
    }

    private var multiselectToolbarContainer: BlurredToolbarContainer?
    private var multiselectToolbar: UIToolbar? {
        multiselectToolbarContainer?.toolbar
    }

    private lazy var toolbarDeleteButton = UIBarButtonItem(
        title: CommonStrings.deleteButton,
        style: .plain,
        target: self,
        action: #selector(deleteSelectedCalls),
    )

    private func showToolbar() {
        if #available(iOS 26, *) {
            navigationController?.setToolbarHidden(false, animated: true)
            (tabBarController as? HomeTabBarController)?.setTabBarHidden(true)
            return
        }

        guard
            // Don't create a new toolbar if we already have one
            multiselectToolbarContainer == nil,
            let tabController = tabBarController as? HomeTabBarController
        else { return }

        let toolbarContainer = BlurredToolbarContainer()
        toolbarContainer.alpha = 0
        view.addSubview(toolbarContainer)
        toolbarContainer.autoPinWidthToSuperview()
        toolbarContainer.autoPinEdge(toSuperviewEdge: .bottom)
        self.multiselectToolbarContainer = toolbarContainer

        let bottomInset = tabController.tabBar.height - tabController.tabBar.safeAreaInsets.bottom
        self.tableView.contentInset.bottom = bottomInset
        self.tableView.verticalScrollIndicatorInsets.bottom = bottomInset

        tabController.setTabBarHidden(true, animated: true, duration: 0.1) { [weak self] _ in
            guard let self else { return }
            // See ChatListViewController.showToolbar for why this is async
            DispatchQueue.main.async {
                self.multiselectToolbar?.setItems(
                    [.flexibleSpace(), self.toolbarDeleteButton],
                    animated: false,
                )
                self.updateMultiselectToolbarButtons()
            }
            UIView.animate(withDuration: 0.25) {
                toolbarContainer.alpha = 1
            }
        }
    }

    private func updateMultiselectToolbarButtons() {
        let selectedRows = tableView.indexPathsForSelectedRows ?? []
        let hasSelectedEntries = !selectedRows.isEmpty
        toolbarDeleteButton.isEnabled = hasSelectedEntries
    }

    @objc
    private func deleteSelectedCalls() {
        guard let selectedRows = tableView.indexPathsForSelectedRows else {
            return
        }

        let selectedModelReferenceses: [ViewModelLoader.ModelReferences] = selectedRows.map { idxPath in
            return viewModelLoader.modelReferences(at: idxPath.row)
        }

        promptToDeleteMultiple(count: selectedModelReferenceses.count) { [weak self] in
            do {
                try await self?.deleteCalls(modelReferenceses: selectedModelReferenceses)
                self?.presentToast(text: String.localizedStringWithFormat(
                    Strings.deleteMultipleSuccessFormat,
                    selectedModelReferenceses.count,
                ))
            } catch {
                Logger.warn("\(error)")
                self?.presentSomeCallLinkDeletionError()
            }
        }
    }

    // MARK: Call Link Button

    private func createCallLink() {
        CreateCallLinkViewController.createCallLinkOnServerAndPresent(from: self)
    }

    // MARK: New call button

    private func newCallButton() -> UIBarButtonItem {
        let barButtonItem = UIBarButtonItem(
            image: Theme.iconImage(.buttonNewCall),
            style: .plain,
            target: self,
            action: #selector(newCall),
        )
        barButtonItem.accessibilityLabel = OWSLocalizedString(
            "NEW_CALL_LABEL",
            comment: "Accessibility label for the new call button on the Calls Tab",
        )
        barButtonItem.accessibilityHint = OWSLocalizedString(
            "NEW_CALL_HINT",
            comment: "Accessibility hint describing the action of the new call button on the Calls Tab",
        )
        return barButtonItem
    }

    @objc
    private func newCall() {
        let viewController = NewCallViewController()
        viewController.delegate = self
        let modal = OWSNavigationController(rootViewController: viewController)
        self.navigationController?.presentFormSheet(modal, animated: true)
    }

    // MARK: Cancel multiselect button

    private func cancelMultiselectButton() -> UIBarButtonItem {
        .cancelButton { [weak self] in
            self?.cancelMultiselect()
        }
    }

    private func cancelMultiselect() {
        tableView.setEditing(false, animated: true)
        updateBarButtonItems()
        hideToolbar()
    }

    private func hideToolbar() {
        if #available(iOS 26, *) {
            self.navigationController?.setToolbarHidden(true, animated: true)
            (self.tabBarController as? HomeTabBarController)?.setTabBarHidden(false)
            return
        }

        guard let multiselectToolbarContainer else { return }
        UIView.animate(withDuration: 0.25) {
            multiselectToolbarContainer.alpha = 0
        } completion: { _ in
            multiselectToolbarContainer.removeFromSuperview()
            self.multiselectToolbarContainer = nil
            guard let tabController = self.tabBarController as? HomeTabBarController else { return }
            tabController.setTabBarHidden(false, animated: true, duration: 0.1) { _ in
                self.tableView.contentInset.bottom = 0
                self.tableView.verticalScrollIndicatorInsets.bottom = 0
            }
        }
    }

    // MARK: Delete All button

    private func deleteAllCallsButton() -> UIBarButtonItem {
        return UIBarButtonItem(
            title: Strings.deleteAllCallsButtonTitle,
            style: .plain,
            target: self,
            action: #selector(promptAboutDeletingAllCalls),
        )
    }

    @objc
    private func promptAboutDeletingAllCalls() {
        OWSActionSheets.showConfirmationAlert(
            title: Strings.deleteAllCallsPromptTitle,
            message: Strings.deleteAllCallsPromptMessage,
            proceedTitle: Strings.deleteAllCallsButtonTitle,
            proceedStyle: .destructive,
        ) { [weak self] _ in
            Task {
                do {
                    try await self?.deleteAllCalls()
                } catch {
                    Logger.warn("\(error)")
                    self?.presentSomeCallLinkDeletionError()
                }
            }
        }
    }

    private func deleteAllCalls() async throws {
        let callLinksToDelete: [(rootKey: CallLinkRootKey, adminPasskey: Data)]
        callLinksToDelete = await self.deps.databaseStorage.awaitableWrite { tx in
            // We might have call links that have never been used. Plus, any call link
            // for which we're the admin isn't deleted by a "clear all" operation
            // because they must first be deleted on the server. (We delete them
            // individually at the end of this method.)
            let callLinksToDelete: [(rootKey: CallLinkRootKey, adminPasskey: Data)]
            callLinksToDelete = (try? self.deps.callLinkStore.fetchAll(tx: tx).compactMap {
                guard let adminPasskey = $0.adminPasskey else {
                    return nil
                }
                return ($0.rootKey, adminPasskey)
            }) ?? []
            /// Delete-all should use the timestamp of the most-recent call, at
            /// the time the action was initiated, as the timestamp we delete
            /// before (and include in the outgoing sync message).
            ///
            /// If we don't have a most-recent call there's no point in
            /// doing a delete anyway.
            ///
            /// We also want to be sure we get the absolute most-recent call,
            /// rather than the most recent call matching our UI state – if the
            /// user does delete-all while filtering to Missed, we still want to
            /// actually delete all.
            let mostRecentCursor = self.deps.callRecordQuerier.fetchCursor(ordering: .descending, tx: tx)
            if let mostRecentCallRecord = try? mostRecentCursor?.next() {
                /// This will ultimately post "call records deleted"
                /// notifications that this view is listening to, so we don't
                /// need to do any manual UI updates.
                self.deps.callRecordDeleteAllJobQueue.addJob(
                    sendDeleteAllSyncMessage: true,
                    deleteAllBefore: .callRecord(mostRecentCallRecord),
                    tx: tx,
                )
            }
            return callLinksToDelete
        }
        try await deleteCallLinks(callLinksToDelete: callLinksToDelete)
    }

    // MARK: Tab picker

    private enum FilterMode: Int {
        case all = 0
        case missed = 1
    }

    private lazy var filterPicker: UISegmentedControl = {
        let segmentedControl = UISegmentedControl(items: [
            Strings.filterPickerOptionAll,
            Strings.filterPickerOptionMissed,
        ])
        segmentedControl.selectedSegmentIndex = 0
        segmentedControl.addTarget(self, action: #selector(filterChangedFromPrimary), for: .valueChanged)
        return segmentedControl
    }()

    // Having a UISegmentedControl as a titleView a split view sidebar on iOS 26
    // looks too large and is cut off at the top. But putting it in a container
    // doesn't look as good when not in a sidebar.
    private lazy var sidebarFilterPicker: UISegmentedControl = {
        let segmentedControl = UISegmentedControl(items: [
            Strings.filterPickerOptionAll,
            Strings.filterPickerOptionMissed,
        ])
        segmentedControl.selectedSegmentIndex = 0
        segmentedControl.addTarget(self, action: #selector(filterChangedFromSidebar), for: .valueChanged)
        return segmentedControl
    }()

    private lazy var sidebarFilterPickerContainer: UIView = {
        let container = UIView()
        container.addSubview(sidebarFilterPicker)
        sidebarFilterPicker.autoPinWidthToSuperview()
        // idk why but it's gotta baaarely shift to align with the profile pic
        sidebarFilterPicker.autoAlignAxis(.horizontal, toSameAxisOf: container, withOffset: -2 / 3)
        sidebarFilterPicker.autoPinHeightToSuperview(relation: .lessThanOrEqual)
        return container
    }()

    // MARK: Search bar

    /// Sets the navigation item's search controller if it hasn't already been
    /// set. Call this after loading the table the first time so that the search
    /// bar is collapsed by default.
    func setSearchControllerIfNeeded() {
        guard navigationItem.searchController == nil else { return }
        let searchController = UISearchController(searchResultsController: nil)
        navigationItem.searchController = searchController
        searchController.searchResultsUpdater = self
    }

    @objc
    private func filterChangedFromPrimary() {
        sidebarFilterPicker.selectedSegmentIndex = filterPicker.selectedSegmentIndex
        filterChanged()
    }

    @objc
    private func filterChangedFromSidebar() {
        filterPicker.selectedSegmentIndex = sidebarFilterPicker.selectedSegmentIndex
        filterChanged()
    }

    private func filterChanged() {
        reinitializeLoadedViewModels(debounceInterval: 0, animated: true)
        updateMultiselectToolbarButtons()
    }

    private var currentFilterMode: FilterMode {
        FilterMode(rawValue: filterPicker.selectedSegmentIndex) ?? .all
    }

    // MARK: - Observers and Notifications

    private func attachSelfAsObservers() {
        deps.databaseChangeObserver.appendDatabaseChangeDelegate(self)

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(significantTimeChangeOccurred),
            name: UIApplication.significantTimeChangeNotification,
            object: nil,
        )

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(groupCallInteractionWasUpdated),
            name: GroupCallInteractionUpdatedNotification.name,
            object: nil,
        )

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(receivedCallRecordStoreNotification),
            name: CallRecordStoreNotification.name,
            object: nil,
        )

        // There might be an ongoing call when the calls tab appears, and if that's
        // the case, we want to grab its eraId before it ends.
        deps.callService.callServiceState.addObserver(self, syncStateImmediately: true)
    }

    /// A significant time change has occurred, according to the system. We
    /// should update the displayed date for all visible calls.
    @objc
    private func significantTimeChangeOccurred() {
        updateDisplayedDateForAllCallCells()
    }

    private func updateDisplayedDateForAllCallCells() {
        for callCell in tableView.visibleCells.compactMap({ $0 as? CallCell }) {
            callCell.updateDisplayedDateAndScheduleRefresh()
        }
    }

    /// When a group call interaction changes, we'll reload the row for the call
    /// it represents (if that row is loaded) so as to reflect the latest state
    /// for that group call.
    ///
    /// Recall that we track "is a group call ongoing" as a property on the
    /// interaction representing that group call, so we need this so we reload
    /// when the call ends.
    ///
    /// Note also that the ``didUpdateCall(from:to:)`` hook below is hit during
    /// the group-call-join process but before we have actually joined the call,
    /// due to the asynchronous nature of group calls. Consequently, we also
    /// need this hook to reload when we ourselves have joined the call, as us
    /// joining updates the "joined members" property also tracked on the group
    /// call interaction.
    @objc
    private func groupCallInteractionWasUpdated(_ notification: NSNotification) {
        guard let notification = GroupCallInteractionUpdatedNotification(notification) else {
            owsFail("Unexpectedly failed to instantiate group call interaction updated notification!")
        }

        if DebugFlags.internalLogging {
            logger.info("Group call interaction was updated, reloading.")
        }

        let callRecordIdForGroupCall = CallRecord.ID(
            conversationId: .thread(threadRowId: notification.groupThreadRowId),
            callId: notification.callId,
        )

        reloadRows(callRecordIds: [callRecordIdForGroupCall])
    }

    @objc
    private func receivedCallRecordStoreNotification(_ notification: NSNotification) {
        guard let callRecordStoreNotification = CallRecordStoreNotification(notification) else {
            owsFail("Unexpected notification! \(type(of: notification))")
        }

        switch callRecordStoreNotification.updateType {
        case .inserted:
            newCallRecordWasInserted()
        case .deleted(let callRecordIds):
            existingCallRecordsWereDeleted(callRecordIds: callRecordIds)
        case .statusUpdated(let callRecordId):
            callRecordStatusWasUpdated(callRecordId: callRecordId)
        }
    }

    /// When a call record is inserted, we'll try loading newer records.
    ///
    /// The 99% case for a call record being inserted is that a new call was
    /// started – which is to say, the inserted call record is the most recent
    /// call. For this case, by loading newer calls we'll load that new call and
    /// present it at the top.
    ///
    /// It is possible that we'll have a call inserted into the middle of our
    /// existing calls, for example if we receive a delayed sync message about a
    /// call from a while ago that we somehow never learned about on this
    /// device. If that happens, we won't load and live-update with that call –
    /// instead, we'll see it the next time this view is reloaded.
    private func newCallRecordWasInserted() {
        loadMoreCalls(direction: .newer, animated: true)
    }

    private func existingCallRecordsWereDeleted(callRecordIds: [CallRecord.ID]) {
        deps.db.read { tx in
            viewModelLoader.dropCalls(matching: callRecordIds, tx: tx)
        }

        updateSnapshot(updatedReferences: [], animated: true)
    }

    /// When the status of a call record changes, we'll reload the row it
    /// represents (if that row is loaded) so as to reflect the latest state for
    /// that record.
    ///
    /// For example, imagine a ringing call that is declined on this device and
    /// accepted on another device. The other device will tell us it accepted
    /// via a sync message, and we should update this view to reflect the
    /// accepted call.
    private func callRecordStatusWasUpdated(callRecordId: CallRecord.ID) {
        reloadRows(callRecordIds: [callRecordId])
    }

    // MARK: CallServiceStateObserver

    private var currentCallId: UInt64?

    /// When we learn that this device has joined or left a call, we'll reload
    /// any rows related to that call so that we show the latest state in this
    /// view.
    ///
    /// Recall that any 1:1 call we are not actively joined to has ended, and
    /// that that is not the case for group calls.
    func didUpdateCall(from oldValue: SignalCall?, to newValue: SignalCall?) {
        // When a SignalCall ends, reload it if we know its callId.
        if let oldValue, let callId = self.currentCallId {
            switch oldValue.mode {
            case .individual:
                break
            case .groupThread(let call as GroupCall), .callLink(let call as GroupCall):
                call.removeObserver(self)
            }
            reloadRow(forCall: oldValue.mode, callId: callId)
            self.currentCallId = nil
        }

        // When a SignalCall starts, reload it as soon as we learn its callId.
        if let newValue {
            switch newValue.mode {
            case .individual(let call):
                if let callId = call.callId {
                    reloadRow(forCall: newValue.mode, callId: callId)
                    self.currentCallId = callId
                } else {
                    owsFailDebug("Can't start individual calls without callIds.")
                }
            case .groupThread(let call as GroupCall), .callLink(let call as GroupCall):
                // We need to fetch the eraId asynchronously, so there's nothing to refresh.
                call.addObserver(self, syncStateImmediately: true)
            }
        }
    }

    // MARK: GroupCallObserver

    func groupCallPeekChanged(_ call: GroupCall) {
        guard self.currentCallId == nil, let eraId = call.ringRtcCall.peekInfo?.eraId else {
            // We've already set it or still don't know it.
            return
        }
        let callId = callIdFromEra(eraId)
        self.currentCallId = callId
        reloadRow(forCall: CallMode(groupCall: call), callId: callId)
    }

    // MARK: Reloading the Current Call

    private func reloadRow(forCall callMode: CallMode, callId: UInt64) {
        let conversationId: CallRecord.ConversationID
        switch callMode {
        case .individual(let call):
            conversationId = .thread(threadRowId: call.thread.sqliteRowId!)
        case .groupThread(let call):
            let rowId = deps.db.read { tx in deps.threadStore.fetchGroupThread(groupId: call.groupId, tx: tx)?.sqliteRowId }
            guard let rowId else {
                owsFailDebug("Can't reload call with non-existent group thread.")
                return
            }
            conversationId = .thread(threadRowId: rowId)
        case .callLink(let call):
            // Query the database separately when starting & ending calls because the
            // row will usually be inserted during the call (ie `rowId` may be nil when
            // starting the call but nonnil when ending the very same call).
            let rowId = deps.db.read { tx in try? deps.callLinkStore.fetch(roomId: call.callLink.rootKey.deriveRoomId(), tx: tx)?.id }
            guard let rowId else {
                // If you open the lobby for an ongoing call that you've never joined,
                // we'll call this method after the peek succeeds. However, you haven't
                // joined the call yet, so there's no calls tab item that needs to be
                // reloaded to show the "Return" button. If you do join the call, a new row
                // will be added, and it will be (implicitly re)loaded because it's new.
                return
            }
            conversationId = .callLink(callLinkRowId: rowId)
        }
        reloadRows(callRecordIds: [CallRecord.ID(conversationId: conversationId, callId: callId)])
    }

    // MARK: - Clear missed calls

    /// A serial queue for clearing the missed-call badge.
    private let clearMissedCallQueue = DispatchQueue(label: "org.signal.calls-list-clear-missed")

    /// Asynchronously clears any missed-call badges, avoiding write
    /// transactions if possible.
    ///
    /// - Important
    /// The asynchronous work enqueued by this method is executed serially, such
    /// that multiple calls to this method will not race.
    private func clearMissedCallsIfNecessary() {
        clearMissedCallQueue.async {
            let unreadMissedCallCount = self.deps.db.read { tx in
                self.deps.callRecordMissedCallManager.countUnreadMissedCalls(tx: tx)
            }

            /// We expect that the only unread calls to mark as read will be
            /// missed calls, so if there's no unread missed calls no need to
            /// open a write transaction.
            guard unreadMissedCallCount > 0 else { return }

            self.deps.db.write { tx in
                self.deps.callRecordMissedCallManager.markUnreadCallsAsRead(
                    beforeTimestamp: nil,
                    sendSyncMessage: true,
                    tx: tx,
                )
            }
        }
    }

    // MARK: - Call loading

    private var _viewModelLoader: ViewModelLoader!
    private var viewModelLoader: ViewModelLoader! {
        get {
            AssertIsOnMainThread()
            return _viewModelLoader
        }
        set(newValue) {
            AssertIsOnMainThread()
            _viewModelLoader = newValue
        }
    }

    private func initializeLoadedViewModels() {
        /// On initialization, we are not filtering to only missed calls.
        let onlyLoadMissedCalls = false

        /// On initialization, we are not filtering for any search term.
        let onlyMatchThreadRowIds: [Int64]? = nil

        setAndPrimeViewModelLoader(
            onlyLoadMissedCalls: onlyLoadMissedCalls,
            onlyMatchThreadRowIds: onlyMatchThreadRowIds,
            animated: false,
        )
    }

    /// Used to avoid concurrent calls to `reinitializeLoadedViewModels` from
    /// clobbering each other.
    private var reinitializeLoadedViewModelsTask: Task<Void, any Error>?

    /// Asynchronously resets our `viewModelLoader` for the current UI state,
    /// then kicks off an initial page load.
    ///
    /// - Note
    /// This method will perform an FTS search for our current search term, if
    /// we have one. That operation can be painfully slow for users with a large
    /// FTS index, so we need to do it asynchronously.
    private func _reinitializeLoadedViewModels(animated: Bool) async throws(CancellationError) {
        let searchTerm = self.searchTerm
        let onlyLoadMissedCalls: Bool = {
            switch self.currentFilterMode {
            case .all: return false
            case .missed: return true
            }
        }()

        let threadRowIdsMatchingSearchTerm: [Int64]?
        if let searchTerm {
            threadRowIdsMatchingSearchTerm = try await findThreadRowIdsMatchingSearchTerm(searchTerm)
        } else {
            threadRowIdsMatchingSearchTerm = nil
        }

        if Task.isCancelled {
            /// While we were performing a search above, another caller entered
            /// this method. Bail out in preference of the later caller!
            throw CancellationError()
        }

        setAndPrimeViewModelLoader(
            onlyLoadMissedCalls: onlyLoadMissedCalls,
            onlyMatchThreadRowIds: threadRowIdsMatchingSearchTerm,
            animated: animated,
        )
    }

    /// Finds the row IDs of threads matching the given search term.
    ///
    /// - Note
    /// This operation can be slow, as it involves a potentially-heavy FTS
    /// query. Importantly, this method is `nonisolated` such that it doesn't
    /// inherit the `@MainActor` isolation of `UIViewController`.
    private nonisolated func findThreadRowIdsMatchingSearchTerm(
        _ searchTerm: String,
    ) async throws(CancellationError) -> [Int64] {
        return try self.deps.databaseStorage.read { tx throws(CancellationError) -> [Int64] in
            guard let localIdentifiers = self.deps.tsAccountManager.localIdentifiers(tx: tx) else {
                owsFail("Can't search if you've never been registered.")
            }

            var threadRowIdsMatchingSearchTerm = Set<Int64>()
            let addresses = try self.deps.searchableNameFinder.searchNames(
                for: searchTerm,
                maxResults: Constants.maxSearchResults,
                localIdentifiers: localIdentifiers,
                tx: tx,
                addGroupThread: { groupThread in
                    guard let sqliteRowId = groupThread.sqliteRowId else {
                        owsFail("How did we match a thread in the FTS index that hasn't been inserted?")
                    }
                    threadRowIdsMatchingSearchTerm.insert(sqliteRowId)
                },
                addStoryThread: { _ in },
            )

            for address in addresses {
                guard
                    let contactThread = TSContactThread.getWithContactAddress(address, transaction: tx),
                    contactThread.shouldThreadBeVisible
                else {
                    continue
                }
                guard let sqliteRowId = contactThread.sqliteRowId else {
                    owsFail("How did we match a thread in the FTS index that hasn't been inserted?")
                }
                threadRowIdsMatchingSearchTerm.insert(sqliteRowId)
            }

            return Array(threadRowIdsMatchingSearchTerm)
        }
    }

    private func setAndPrimeViewModelLoader(
        onlyLoadMissedCalls: Bool,
        onlyMatchThreadRowIds: [Int64]?,
        animated: Bool,
    ) {
        let callRecordLoader = CallRecordLoaderImpl(
            callRecordQuerier: self.deps.callRecordQuerier,
            configuration: CallRecordLoaderImpl.Configuration(
                onlyLoadMissedCalls: onlyLoadMissedCalls,
                onlyMatchThreadRowIds: onlyMatchThreadRowIds,
            ),
        )

        /// We don't want to capture self in the blocks we pass when creating
        /// the view model loader (and thereby create a retain cycle), so we'll
        /// early-capture the dependencies those blocks need.
        let capturedDeps = self.deps

        self.viewModelLoader = ViewModelLoader(
            callLinkStore: self.deps.callLinkStore,
            callRecordLoader: callRecordLoader,
            callViewModelForCallRecords: { callRecords, tx in
                return Self.callViewModel(
                    forCallRecords: callRecords,
                    upcomingCallLinkRowId: nil,
                    deps: capturedDeps,
                    tx: tx,
                )
            },
            callViewModelForUpcomingCallLink: { callLinkRowId, tx in
                return Self.callViewModel(
                    forCallRecords: [],
                    upcomingCallLinkRowId: callLinkRowId,
                    deps: capturedDeps,
                    tx: tx,
                )
            },
            fetchCallRecordBlock: { callRecordId, tx -> CallRecord? in
                return capturedDeps.callRecordStore.fetch(
                    callRecordId: callRecordId,
                    tx: tx,
                ).unwrapped
            },
            shouldFetchUpcomingCallLinks: !onlyLoadMissedCalls && onlyMatchThreadRowIds == nil,
        )

        self.reloadUpcomingCallLinks()

        // Load the initial page of records. We've thrown away all our
        // existing calls, so we want to always update the snapshot.
        self.loadMoreCalls(
            direction: .older,
            animated: animated,
            forceUpdateSnapshot: true,
        )
    }

    /// Load more calls as necessary given that a row for the given index path
    /// is soon going to be presented.
    private func loadMoreCallsIfNecessary(indexToBeDisplayed callIndex: Int) {
        if callIndex + 1 == viewModelLoader.totalCount {
            /// If this index path represents the oldest loaded call, try and
            /// load another page of even-older calls.
            loadMoreCalls(direction: .older, animated: false)
        }
    }

    private func reloadUpcomingCallLinks() {
        deps.db.read { tx in viewModelLoader.reloadUpcomingCallLinkReferences(tx: tx) }
    }

    /// Synchronously loads more calls, then asynchronously update the snapshot
    /// if any new calls were actually loaded.
    ///
    /// - Parameter forceUpdateSnapshot
    /// Whether we should always update the snapshot, regardless of if any new
    /// calls were loaded.
    private func loadMoreCalls(
        direction loadDirection: ViewModelLoader.LoadDirection,
        animated: Bool,
        forceUpdateSnapshot: Bool = false,
    ) {
        let (shouldUpdateSnapshot, updatedReferences) = deps.db.read { tx in
            return viewModelLoader.loadCallHistoryItemReferences(direction: loadDirection, tx: tx)
        }

        guard forceUpdateSnapshot || shouldUpdateSnapshot || !updatedReferences.isEmpty else {
            return
        }

        DispatchQueue.main.async {
            self.updateSnapshot(updatedReferences: updatedReferences, animated: animated)
            // Add the search bar after loading table content the first time so
            // that it is collapsed by default.
            self.setSearchControllerIfNeeded()
        }
    }

    /// Converts ``CallRecord``s into a ``CallViewModel``.
    ///
    /// - Important
    /// The primary and and coalesced call records *must* all have the same
    /// thread, direction, missed status, and call type.
    private static func callViewModel(
        forCallRecords callRecords: [CallRecord],
        upcomingCallLinkRowId: Int64?,
        deps: Dependencies,
        tx: DBReadTransaction,
    ) -> CallViewModel {
        owsPrecondition(
            Set(callRecords.map(\.conversationId)).count <= 1,
            "Coalesced call records were for a different conversation than the primary!",
        )
        owsPrecondition(
            Set(callRecords.map(\.callDirection)).count <= 1,
            "Coalesced call records were of a different direction than the primary!",
        )
        owsPrecondition(
            Set(callRecords.map(\.callStatus.isMissedCall)).count <= 1,
            "Coalesced call records were of a different missed status than the primary!",
        )
        owsPrecondition(
            callRecords.isSortedByTimestamp(.descending),
            "Primary and coalesced call records were not ordered descending by timestamp!",
        )

        let callLinkRecord = { () -> CallLinkRecord? in
            let callLinkRowId: Int64
            if let upcomingCallLinkRowId {
                callLinkRowId = upcomingCallLinkRowId
            } else if case .callLink(let callLinkRowId2) = callRecords.first!.conversationId {
                callLinkRowId = callLinkRowId2
            } else {
                return nil
            }
            do {
                return try deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx) ?? {
                    owsFail("Couldn't load CallLinkRecord that must exist!")
                }()
            } catch {
                owsFail("Couldn't load CallLinkRecord that must exist: \(error)")
            }
        }()

        if let callLinkRecord {
            return CallViewModel(
                reference: .callLink(rowId: callLinkRecord.id),
                callRecords: callRecords,
                title: callLinkRecord.state.localizedName,
                recipientType: .callLink(callLinkRecord.rootKey),
                direction: .callLink,
                medium: .link,
                state: { () -> CallViewModel.State in
                    if let activeCallId = callLinkRecord.activeCallId {
                        if deps.callService.callServiceState.currentCall?.callId == activeCallId {
                            return .participating
                        }
                        return .active
                    }
                    return .inactive
                }(),
            )
        }

        // If it's not a CallLink, we MUST have at least one CallRecord.
        let callRecord = callRecords.first!

        let callDirection: CallViewModel.Direction = {
            if callRecord.callStatus.isMissedCall {
                return .missed
            }

            switch callRecord.callDirection {
            case .incoming: return .incoming
            case .outgoing: return .outgoing
            }
        }()

        /// The call state may be different between the primary and the
        /// coalesced calls. For the view model's state, we use the primary.
        let callState: CallViewModel.State = {
            let currentCallId: UInt64? = deps.callService.callServiceState.currentCall?.callId

            switch callRecord.callStatus {
            case .individual:
                if callRecord.callId == currentCallId {
                    // We can have at most one 1:1 call active at a time, and if
                    // we have an active 1:1 call we must be in it. All other
                    // 1:1 calls must have ended.
                    return .participating
                }
            case .group:
                guard
                    let groupCallInteraction: OWSGroupCallMessage = deps.interactionStore
                        .fetchAssociatedInteraction(callRecord: callRecord, tx: tx)
                else {
                    owsFail("Missing interaction for group call. This should be impossible per the DB schema!")
                }

                // We learn that a group call ended by peeking the group. During
                // that peek, we update the group call interaction. It's a
                // leetle wonky that we use the interaction to store that info,
                // but such is life.
                if !groupCallInteraction.hasEnded {
                    if callRecord.callId == currentCallId {
                        return .participating
                    }

                    return .active
                }
            case .callLink:
                owsFail("Can't reach this point because we've already handled Call Links.")
            }

            return .inactive
        }()

        let title: String
        let medium: CallViewModel.Medium
        let recipientType: CallViewModel.RecipientType

        switch callRecord.conversationId {
        case .thread(let threadRowId):
            guard
                let callThread = deps.threadStore.fetchThread(
                    rowId: threadRowId,
                    tx: tx,
                )
            else {
                owsFail("Missing thread for call record! This should be impossible, per the DB schema.")
            }
            switch callThread {
            case let contactThread as TSContactThread:
                title = deps.contactsManager.displayName(for: contactThread.contactAddress, tx: tx).resolvedValue()
                let callType: CallViewModel.RecipientType.IndividualCallType
                switch callRecords.first!.callType {
                case .audioCall:
                    medium = .audio
                    callType = .audio
                case .adHocCall, .groupCall:
                    owsFailDebug("Had group call type for 1:1 call!")
                    fallthrough
                case .videoCall:
                    medium = .video
                    callType = .video
                }
                recipientType = .individual(type: callType, contactThread: contactThread)
            case let groupThread as TSGroupThread:
                title = groupThread.groupModel.groupNameOrDefault
                medium = .video
                recipientType = .groupThread(groupId: groupThread.groupId)
            default:
                owsFail("Call thread was neither contact nor group! This should be impossible.")
            }
        case .callLink:
            owsFail("Can't reach this point because we've already handled Call Links.")
        }

        return CallViewModel(
            reference: .callRecords(oldestId: callRecords.last!.id),
            callRecords: callRecords,
            title: title,
            recipientType: recipientType,
            direction: callDirection,
            medium: medium,
            state: callState,
        )
    }

    // MARK: - Peeking

    /// Fires when active calls should be checked again. Also replenishes
    /// `peekAllowance`. May be nonnil even when `isPeekingEnabled` is false to
    /// handle rapid tab switching.
    private var peekTimer: Timer?

    /// Remaining peeks that can be performed. Replenishes every 30 seconds.
    private var peekAllowance = 10

    /// If true, call links & active calls should be peeked periodically.
    private var isPeekingEnabled = false

    /// An ordered list of groups & call links that would be useful to peek.
    private var peekQueue = [Peekable]()

    /// Identifiers that have been scheduled in this 30 second interval. We
    /// remove items when `peekTimer` fires to avoid peeking the same values
    /// repeatedly when scrolling.
    private var peekQueueIdentifiers = Set<Data>()

    /// Timestamps when call links were recently peeked.
    private var callLinkPeekDates = [Data: MonotonicDate]()

    private enum Peekable {
        case groupThread(groupId: GroupIdentifier)
        case callLink(rootKey: CallLinkRootKey)

        var identifier: Data {
            switch self {
            case .groupThread(let groupId): groupId.serialize()
            case .callLink(let rootKey): rootKey.deriveRoomId()
            }
        }
    }

    /// Schedules a peeks if there's no peek scheduled.
    private func addToPeekQueue(_ peekable: Peekable) {
        guard peekQueueIdentifiers.insert(peekable.identifier).inserted else {
            return
        }
        peekQueue.append(peekable)
        peekIfPossible()
    }

    /// Peeks `peekAllowance` items from `peekQueue`.
    ///
    /// It will often be the case that items added via `addToPeekQueue` are
    /// peeked immediately. However, if more than 10 items are added, they'll
    /// queue up until the next 30 second interval.
    private func peekIfPossible() {
        guard self.isPeekingEnabled else {
            return
        }
        let peekBatch = self.peekQueue.prefix(peekAllowance)
        self.peekQueue.removeFirst(peekBatch.count)
        for peekable in peekBatch {
            switch peekable {
            case .groupThread(let groupId):
                Task { [deps] in
                    await deps.groupCallManager.peekGroupCallAndUpdateThread(forGroupId: groupId, peekTrigger: .localEvent())
                }
            case .callLink(let rootKey):
                self.callLinkPeekDates[rootKey.deriveRoomId()] = MonotonicDate()
                Task { [deps] in
                    do {
                        try await Self.peekCallLink(rootKey: rootKey, deps: deps)
                    } catch {
                        Logger.warn("\(error)")
                    }
                }
            }
        }
        self.peekAllowance -= peekBatch.count
    }

    private nonisolated static func peekCallLink(rootKey: CallLinkRootKey, deps: Dependencies) async throws {
        let registeredState = try deps.tsAccountManager.registeredStateWithMaybeSneakyTransaction()
        let authCredential = try await deps.callService.authCredentialManager.fetchCallLinkAuthCredential(
            localIdentifiers: registeredState.localIdentifiers,
        )
        let eraId: String?
        do {
            eraId = try await deps.callService.callLinkManager.peekCallLink(rootKey: rootKey, authCredential: authCredential)
        } catch CallLinkManagerImpl.PeekError.expired, CallLinkManagerImpl.PeekError.invalid {
            eraId = nil
        }
        try await deps.db.awaitableWrite { tx in
            try deps.adHocCallRecordManager.handlePeekResult(eraId: eraId, rootKey: rootKey, tx: tx)
        }
    }

    /// Performs the relevant peek steps when the view appears.
    private func peekOnAppear() {
        if viewModelLoader == nil {
            return
        }
        if !viewModelLoader.isEmpty, viewModelLoader.viewModels().compacted().isEmpty {
            _ = viewModelLoader.viewModel(at: 0, sneakyTransactionDb: self.deps.db)
        }
        peekActiveCalls()
        peekInactiveCallLinks()
    }

    /// Schedules periodic operations: replinishing & active call re-peeking.
    private func schedulePeekTimerIfNeeded() {
        if self.peekTimer != nil {
            return
        }
        self.peekTimer = Timer.scheduledTimer(
            withTimeInterval: 30,
            repeats: true,
            block: { [weak self] timer in
                guard let self else {
                    timer.invalidate()
                    return
                }
                self.peekAllowance = 10
                self.peekQueueIdentifiers = Set(self.peekQueue.map(\.identifier))
                guard self.isPeekingEnabled else {
                    timer.invalidate()
                    self.peekTimer = nil
                    return
                }
                self.peekActiveCalls()
                self.peekIfPossible()
            },
        )
    }

    private func peekActiveCalls() {
        for viewModel in viewModelLoader.viewModels() {
            guard let viewModel else {
                continue
            }
            peekIfActive(viewModel)
        }
    }

    private func peekIfActive(_ viewModel: CallViewModel) {
        guard viewModel.state == .active else {
            return
        }
        switch viewModel.recipientType {
        case .individual:
            break
        case .groupThread(groupId: let groupId):
            guard let groupId = try? GroupIdentifier(contents: groupId) else {
                owsFailDebug("Can't peek group call with invalid group id.")
                break
            }
            addToPeekQueue(.groupThread(groupId: groupId))
        case .callLink(let rootKey):
            addToPeekQueue(.callLink(rootKey: rootKey))
        }
    }

    private func peekInactiveCallLinks() {
        for viewModel in viewModelLoader.viewModels() {
            guard let viewModel, viewModel.state == .inactive else {
                continue
            }
            switch viewModel.recipientType {
            case .individual, .groupThread:
                break
            case .callLink(let rootKey):
                // Skip any where the link is more than 10 days old.
                if
                    let timestamp = viewModel.callRecords.first?.callBeganTimestamp,
                    -Date(millisecondsSince1970: timestamp).timeIntervalSinceNow > 10 * .day
                {
                    continue
                }
                // Skip any that have been updated in the past 5 minutes.
                if
                    let peekDate = callLinkPeekDates[rootKey.deriveRoomId()],
                    MonotonicDate() - peekDate < MonotonicDuration(clampingSeconds: 300)
                {
                    continue
                }
                addToPeekQueue(.callLink(rootKey: rootKey))
            }
        }
    }

    // MARK: - Search term

    /// - Important
    /// Don't use this directly – use ``searchTerm``.
    private var _searchTerm: String? {
        didSet {
            guard oldValue != searchTerm else {
                // If the term hasn't changed, don't do anything.
                return
            }

            searchTermDidChange()
        }
    }

    /// The user's current search term. Coalesces empty strings into `nil`.
    private var searchTerm: String? {
        get { _searchTerm }
        set { _searchTerm = newValue?.nilIfEmpty }
    }

    private func searchTermDidChange() {
        reinitializeLoadedViewModels(debounceInterval: Constants.searchDebounceInterval, animated: true)
    }

    private func reinitializeLoadedViewModels(debounceInterval: TimeInterval, animated: Bool) {
        self.reinitializeLoadedViewModelsTask?.cancel()
        self.reinitializeLoadedViewModelsTask = Task {
            do {
                try await Task.sleep(nanoseconds: debounceInterval.clampedNanoseconds)
                try await self._reinitializeLoadedViewModels(animated: true)
            } catch {
                // A new reinitialize call was started, so bail out.
            }
        }
    }

    // MARK: - Table view

    fileprivate enum Section: Int, Hashable {
        case createCallLink
        case existingCalls
    }

    fileprivate enum RowIdentifier: Hashable {
        case createCallLink
        case callViewModelReference(CallViewModel.Reference)
    }

    struct CallViewModel {
        enum Reference: Hashable {
            case callRecords(oldestId: CallRecord.ID)
            case callLink(rowId: Int64)
        }

        enum Direction {
            case outgoing
            case incoming
            case missed
            case callLink

            var label: String {
                switch self {
                case .outgoing:
                    return Strings.callDirectionLabelOutgoing
                case .incoming:
                    return Strings.callDirectionLabelIncoming
                case .missed:
                    return Strings.callDirectionLabelMissed
                case .callLink:
                    return CallStrings.callLink
                }
            }

            var symbol: SignalSymbol {
                switch self {
                case .outgoing:
                    .arrowUpRight
                case .incoming, .missed:
                    .arrowDownLeft
                case .callLink:
                    .link
                }
            }
        }

        enum Medium {
            case audio
            case video
            case link
        }

        enum State {
            /// This call is active, but the user is not in it.
            case active
            /// The user is currently in this call.
            case participating
            /// The call is no longer active or was never active (eg, an upcoming Call Link).
            case inactive
        }

        enum RecipientType {
            case individual(type: IndividualCallType, contactThread: TSContactThread)
            case groupThread(groupId: Data)
            case callLink(CallLinkRootKey)

            enum IndividualCallType {
                case audio
                case video
            }
        }

        let reference: Reference
        let callRecords: [CallRecord]

        let title: String
        let recipientType: RecipientType
        let direction: Direction
        let medium: Medium
        let state: State

        init(
            reference: Reference,
            callRecords: [CallRecord],
            title: String,
            recipientType: RecipientType,
            direction: Direction,
            medium: Medium,
            state: State,
        ) {
            self.reference = reference
            self.callRecords = callRecords
            self.title = title
            self.recipientType = recipientType
            self.direction = direction
            self.medium = medium
            self.state = state
        }

        var isMissed: Bool {
            switch direction {
            case .outgoing, .incoming, .callLink:
                return false
            case .missed:
                return true
            }
        }
    }

    let tableView = UITableView(frame: .zero, style: .plain)

    /// Set to `true` when call list is displayed in split view controller's "sidebar" on iOS 26 and later.
    /// Setting this to `true` would add an extra padding on both sides of the table view.
    /// This value is also passed down to table view cells that make their own layout choices based on the value.
    private var useSidebarCallListCellAppearance = false {
        didSet {
            guard oldValue != useSidebarCallListCellAppearance else { return }
            tableViewHorizontalEdgeConstraints.forEach {
                $0.constant = useSidebarCallListCellAppearance ? 18 : 0
            }
            tableView.reloadData()
        }
    }

    private var tableViewHorizontalEdgeConstraints: [NSLayoutConstraint] = []

    /// iOS 26+: checks if this VC is displayed in the collapsed split view controller and updates `useSidebarCallListCellAppearance` accordingly.
    /// Does nothing on prior iOS versions.
    private func updateTableViewPaddingIfNeeded() {
        guard #available(iOS 26, *) else { return }

        if let splitViewController, !splitViewController.isCollapsed {
            useSidebarCallListCellAppearance = true
        } else {
            useSidebarCallListCellAppearance = false
        }
    }

    private static let createCallLinkReuseIdentifier = "createCallLink"
    private static let callCellReuseIdentifier = "callCell"

    private lazy var dataSource = DiffableDataSource(
        tableView: tableView,
    ) { [weak self] tableView, indexPath, _ -> UITableViewCell? in
        return self?.buildTableViewCell(tableView: tableView, indexPath: indexPath) ?? UITableViewCell()
    }

    private func buildTableViewCell(tableView: UITableView, indexPath: IndexPath) -> UITableViewCell? {
        switch Section(rawValue: indexPath.section) {
        case .createCallLink:
            guard
                let createCallLinkCell = tableView.dequeueReusableCell(
                    withIdentifier: Self.createCallLinkReuseIdentifier,
                    for: indexPath,
                ) as? CreateCallLinkCell else { return nil }

            createCallLinkCell.useSidebarAppearance = useSidebarCallListCellAppearance
            return createCallLinkCell

        case .existingCalls:
            guard
                let callCell = tableView.dequeueReusableCell(
                    withIdentifier: Self.callCellReuseIdentifier,
                    for: indexPath,
                ) as? CallCell else { return nil }

            callCell.useSidebarAppearance = useSidebarCallListCellAppearance
            // These loads should be sufficiently fast that doing them here,
            // synchronously, is fine.
            loadMoreCallsIfNecessary(indexToBeDisplayed: indexPath.row)
            if let viewModel = viewModelLoader.viewModel(at: indexPath.row, sneakyTransactionDb: deps.db) {
                callCell.delegate = self
                callCell.viewModel = viewModel

                peekIfActive(viewModel)

                return callCell
            }
            owsFailDebug("Missing cached view model – how did this happen?")
            /// Return an empty table cell, rather than a ``CallCell`` that's
            /// gonna be incorrectly configured.

        case .none:
            break
        }
        return nil
    }

    private func getSnapshot() -> Snapshot {
        var snapshot = Snapshot()
        snapshot.appendSections([.createCallLink])
        snapshot.appendItems([.createCallLink])
        snapshot.appendSections([.existingCalls])
        snapshot.appendItems(viewModelLoader.viewModelReferences().map { .callViewModelReference($0) })
        return snapshot
    }

    private func updateSnapshot(updatedReferences: Set<CallViewModel.Reference>, animated: Bool) {
        var snapshot = getSnapshot()
        if #available(iOS 18.0, *) {
            snapshot.reloadItems(updatedReferences.map { .callViewModelReference($0) })
            dataSource.apply(snapshot, animatingDifferences: animated)
        } else {
            // On iOS 17 and lower, moving & reloading a row at the same time may
            // result in the wrong row being reloaded. Mitigate this by scheduling
            // these as two separate operations.
            dataSource.apply(snapshot, animatingDifferences: animated)
            if !updatedReferences.isEmpty {
                snapshot.reloadItems(updatedReferences.map { .callViewModelReference($0) })
                dataSource.apply(snapshot, animatingDifferences: animated)
            }
        }
        updateEmptyStateMessage()
        cancelMultiselectIfEmpty()
    }

    /// Reload any rows containing one of the given call record IDs.
    private func reloadRows(callRecordIds callRecordIdsToReload: [CallRecord.ID]) {
        if callRecordIdsToReload.isEmpty {
            return
        }

        /// Invalidate the view models, so when the data source reloads the rows,
        /// it'll reflect the new underlying state for that row.
        let referencesToReload = viewModelLoader.invalidate(
            callLinkRowIds: [],
            callRecordIds: Set(callRecordIdsToReload),
        )

        if referencesToReload.isEmpty {
            return
        }

        if DebugFlags.internalLogging {
            logger.info("Reloading \(referencesToReload.count) rows.")
        }

        var snapshot = getSnapshot()
        snapshot.reloadItems(referencesToReload.map { .callViewModelReference($0) })
        dataSource.apply(snapshot)
    }

    private func reloadAllRows() {
        var snapshot = getSnapshot()
        snapshot.reloadSections([.createCallLink, .existingCalls])
        dataSource.apply(snapshot)
    }

    private func cancelMultiselectIfEmpty() {
        if tableView.isEditing, viewModelLoader.isEmpty {
            cancelMultiselect()
        }
    }

    private func updateEmptyStateMessage() {
        switch (viewModelLoader.isEmpty, searchTerm) {
        case (true, .some(let searchTerm)) where !searchTerm.isEmpty:
            noSearchResultsView.text = String.nonPluralLocalizedStringWithFormat(
                Strings.searchNoResultsFoundLabelFormat,
                searchTerm,
            )
            noSearchResultsView.alpha = 1
            emptyStateMessageView.alpha = 0
        case (true, _):
            emptyStateMessageView.attributedText = NSAttributedString.composed(of: {
                switch currentFilterMode {
                case .all:
                    return [
                        Strings.noRecentCallsLabel,
                        "\n",
                        Strings.noRecentCallsSuggestionLabel
                            .styled(with: .font(.dynamicTypeSubheadline)),
                    ]
                case .missed:
                    return [
                        Strings.noMissedCallsLabel,
                    ]
                }
            }())
                .styled(
                    with: .font(.dynamicTypeSubheadline.semibold()),
                )
            noSearchResultsView.alpha = 0
            emptyStateMessageView.alpha = 1
        case (_, _):
            // Hide empty state message
            noSearchResultsView.alpha = 0
            emptyStateMessageView.alpha = 0
        }
    }
}

private extension IndexPath {
    static func indexPathForPrimarySection(row: Int) -> IndexPath {
        return IndexPath(
            row: row,
            section: CallsListViewController.Section.existingCalls.rawValue,
        )
    }
}

private extension SignalCall {
    var callId: UInt64? {
        switch mode {
        case .individual(let individualCall):
            return individualCall.callId
        case .groupThread(let call as GroupCall), .callLink(let call as GroupCall):
            return call.ringRtcCall.peekInfo?.eraId.map { callIdFromEra($0) }
        }
    }
}

private extension CallRecordStore {
    func fetch(
        callRecordId: CallRecord.ID,
        tx: DBReadTransaction,
    ) -> CallRecordStore.MaybeDeletedFetchResult {
        return fetch(
            callId: callRecordId.callId,
            conversationId: callRecordId.conversationId,
            tx: tx,
        )
    }
}

// MARK: - Data Source

extension CallsListViewController {
    fileprivate class DiffableDataSource: UITableViewDiffableDataSource<Section, RowIdentifier> {
        override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
            switch Section(rawValue: indexPath.section) {
            case .createCallLink:
                return false
            case .existingCalls, .none:
                return true
            }
        }
    }
}

// MARK: - UITableViewDelegate

extension CallsListViewController: UITableViewDelegate {

    private func viewModelWithSneakyTransaction(at indexPath: IndexPath) -> CallViewModel? {
        owsPrecondition(
            indexPath.section == Section.existingCalls.rawValue,
            "Unexpected section for index path: \(indexPath.section)",
        )

        guard let viewModel = viewModelLoader.viewModel(at: indexPath.row, sneakyTransactionDb: deps.db) else {
            owsFailBeta("Missing view model for index path. How did this happen?")
            return nil
        }

        return viewModel
    }

    func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
        switch Section(rawValue: indexPath.section) {
        case .createCallLink:
            if tableView.isEditing {
                return nil
            }
        case .existingCalls, .none:
            break
        }
        return indexPath
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        if tableView.isEditing {
            updateMultiselectToolbarButtons()
            return
        }

        tableView.deselectRow(at: indexPath, animated: true)

        switch Section(rawValue: indexPath.section) {
        case .createCallLink:
            createCallLink()
        case .existingCalls, .none:
            guard let viewModel = viewModelWithSneakyTransaction(at: indexPath) else {
                return
            }
            showCallInfo(from: viewModel)
        }
    }

    func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
        if tableView.isEditing {
            updateMultiselectToolbarButtons()
        }
    }

    func tableView(_ tableView: UITableView, shouldBeginMultipleSelectionInteractionAt indexPath: IndexPath) -> Bool {
        switch Section(rawValue: indexPath.section) {
        case .createCallLink:
            return false
        case .existingCalls, .none:
            return true
        }
    }

    func tableView(_ tableView: UITableView, didBeginMultipleSelectionInteractionAt indexPath: IndexPath) {
        updateBarButtonItems()
        showToolbar()
    }

    func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
        switch Section(rawValue: indexPath.section) {
        case .createCallLink:
            return nil
        case .existingCalls, .none:
            break
        }

        return self.longPressActions(forRowAt: indexPath)
            .map { actions in UIMenu(children: actions) }
            .map { menu in
                UIContextMenuConfiguration(identifier: indexPath as NSCopying, previewProvider: nil) { _ in menu }
            }
    }

    func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        switch Section(rawValue: indexPath.section) {
        case .createCallLink:
            return nil
        case .existingCalls, .none:
            break
        }

        guard let viewModel = viewModelWithSneakyTransaction(at: indexPath) else {
            return nil
        }

        guard let chatThread = goToChatThread(from: viewModel) else {
            return nil
        }

        let goToChatAction = ContextualActionBuilder.makeContextualAction(
            style: .normal,
            color: .ows_accentBlue,
            image: .arrowSquareUprightFill,
            title: Strings.goToChatActionTitle,
        ) { [weak self] completion in
            self?.goToChat(for: chatThread()!)
            completion(true)
        }

        return .init(actions: [goToChatAction])
    }

    func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        switch Section(rawValue: indexPath.section) {
        case .createCallLink:
            return nil
        case .existingCalls, .none:
            break
        }

        let modelReferences = viewModelLoader.modelReferences(at: indexPath.row)

        let deleteAction = ContextualActionBuilder.makeContextualAction(
            style: .destructive,
            color: .ows_accentRed,
            image: .trashFill,
            title: CommonStrings.deleteButton,
        ) { [weak self] completion in
            self?.promptToDeleteCallIfNeeded(modelReferences: modelReferences)
            completion(true)
        }

        return .init(actions: [deleteAction])
    }

    private func longPressActions(forRowAt indexPath: IndexPath) -> [UIAction]? {
        guard let viewModel = viewModelWithSneakyTransaction(at: indexPath) else {
            return nil
        }
        let modelReferences = viewModelLoader.modelReferences(at: indexPath.row)

        var actions = [UIAction]()

        switch viewModel.state {
        case .active:
            let joinCallTitle: String
            let joinCallIconName: String
            switch viewModel.medium {
            case .audio:
                joinCallTitle = Strings.joinVoiceCallActionTitle
                joinCallIconName = Theme.iconName(.contextMenuVoiceCall)
            case .video, .link:
                // [CallLink] TODO: Use "Start Call" instead of "Start Video Call".
                joinCallTitle = Strings.joinVideoCallActionTitle
                joinCallIconName = Theme.iconName(.contextMenuVideoCall)
            }
            let joinCallAction = UIAction(
                title: joinCallTitle,
                image: UIImage(named: joinCallIconName),
                attributes: [],
            ) { [weak self] _ in
                self?.joinCall(from: viewModel)
            }
            actions.append(joinCallAction)
        case .participating:
            let returnToCallIconName: String
            switch viewModel.medium {
            case .audio:
                returnToCallIconName = Theme.iconName(.contextMenuVoiceCall)
            case .video, .link:
                returnToCallIconName = Theme.iconName(.contextMenuVideoCall)
            }
            let returnToCallAction = UIAction(
                title: Strings.returnToCallActionTitle,
                image: UIImage(named: returnToCallIconName),
                attributes: [],
            ) { [weak self] _ in
                self?.returnToCall(from: viewModel)
            }
            actions.append(returnToCallAction)
        case .inactive:
            switch viewModel.recipientType {
            case .individual:
                let audioCallAction = UIAction(
                    title: Strings.startVoiceCallActionTitle,
                    image: Theme.iconImage(.contextMenuVoiceCall),
                    attributes: [],
                ) { [weak self] _ in
                    self?.startCall(from: viewModel, withVideo: false)
                }
                actions.append(audioCallAction)
            case .groupThread, .callLink:
                break
            }

            let videoCallAction = UIAction(
                title: Strings.startVideoCallActionTitle,
                image: Theme.iconImage(.contextMenuVideoCall),
                attributes: [],
            ) { [weak self] _ in
                self?.startCall(from: viewModel, withVideo: true)
            }
            actions.append(videoCallAction)
        }

        if let chatThread = goToChatThread(from: viewModel) {
            let goToChatAction = UIAction(
                title: Strings.goToChatActionTitle,
                image: Theme.iconImage(.contextMenuOpenInChat),
                attributes: [],
            ) { [weak self] _ in
                self?.goToChat(for: chatThread()!)
            }
            actions.append(goToChatAction)
        }

        let infoAction = UIAction(
            title: Strings.viewCallInfoActionTitle,
            image: Theme.iconImage(.contextMenuInfo),
            attributes: [],
        ) { [weak self] _ in
            self?.showCallInfo(from: viewModel)
        }
        actions.append(infoAction)

        let selectAction = UIAction(
            title: Strings.selectCallActionTitle,
            image: Theme.iconImage(.contextMenuSelect),
            attributes: [],
        ) { [weak self] _ in
            self?.selectCall(forRowAt: indexPath)
        }
        actions.append(selectAction)

        switch viewModel.state {
        case .active, .inactive:
            let deleteAction = UIAction(
                title: Strings.deleteCallActionTitle,
                image: Theme.iconImage(.contextMenuDelete),
                attributes: .destructive,
            ) { [weak self] _ in
                self?.promptToDeleteCallIfNeeded(modelReferences: modelReferences)
            }
            actions.append(deleteAction)
        case .participating:
            break
        }

        return actions
    }
}

// MARK: - Actions

extension CallsListViewController: CallCellDelegate, NewCallViewControllerDelegate {

    private var callStarterContext: CallStarter.Context {
        .init(
            blockingManager: deps.blockingManager,
            databaseStorage: deps.databaseStorage,
            callService: deps.callService,
        )
    }

    private func startCall(from viewModel: CallViewModel, withVideo: Bool? = nil) {
        switch viewModel.recipientType {
        case let .individual(type, contactThread):
            CallStarter(
                contactThread: contactThread,
                withVideo: withVideo ?? (type == .video),
                context: self.callStarterContext,
            ).startCall(from: self)
        case let .groupThread(groupId):
            owsPrecondition(withVideo != false, "Can't start voice call.")
            let groupId = try! GroupIdentifier(contents: groupId)
            CallStarter(
                groupId: groupId,
                context: self.callStarterContext,
            ).startCall(from: self)
        case .callLink(let rootKey):
            owsPrecondition(withVideo != false, "Can't start voice call.")
            CallStarter(
                callLink: rootKey,
                context: self.callStarterContext,
            ).startCall(from: self)
        }
    }

    private func promptToDeleteMultiple(count: Int, proceedAction: @escaping @MainActor () async -> Void) {
        OWSActionSheets.showConfirmationAlert(
            title: String.localizedStringWithFormat(Strings.deleteMultipleTitleFormat, count),
            message: Strings.deleteMultipleMessage,
            proceedTitle: Strings.deleteCallActionTitle,
            proceedStyle: .destructive,
            proceedAction: { _ in Task { await proceedAction() } },
            fromViewController: self,
        )
    }

    private func presentSomeCallLinkDeletionError() {
        let actionSheet = ActionSheetController(message: Strings.deleteMultipleError)
        actionSheet.addAction(OWSActionSheets.okayAction)
        self.presentActionSheet(actionSheet)
    }

    private func promptToDeleteCallIfNeeded(modelReferences: ViewModelLoader.ModelReferences) {
        // If we're the admin for this link, we need to show a warning that other
        // people won't be able to use it.
        if isAdmin(forCallLinkRowId: modelReferences.callLinkRowId) {
            CallLinkDeleter.promptToDelete(fromViewController: self) { [weak self] in
                do {
                    try await self?.deleteCalls(modelReferenceses: [modelReferences])
                    self?.presentToast(text: CallLinkDeleter.successText)
                } catch {
                    Logger.warn("\(error)")
                    self?.presentToast(text: CallLinkDeleter.failureText)
                }
            }
        } else {
            // Otherwise, we can just delete it.
            Task {
                do {
                    try await self.deleteCalls(modelReferenceses: [modelReferences])
                } catch {
                    owsFailDebug("\(error)")
                }
            }
        }
    }

    private func isAdmin(forCallLinkRowId callLinkRowId: Int64?) -> Bool {
        return deps.databaseStorage.read { tx in
            guard let callLinkRowId else {
                return false
            }
            do {
                let callLinkRecord = try self.deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx) ?? {
                    throw OWSAssertionError("Couldn't fetch CallLink that must exist.")
                }()
                return callLinkRecord.adminPasskey != nil
            } catch {
                owsFailDebug("\(error)")
                return false
            }
        }
    }

    private func deleteCalls(modelReferenceses: [ViewModelLoader.ModelReferences]) async throws {
        let callLinksToDelete: [(rootKey: CallLinkRootKey, adminPasskey: Data)]

        // First, delete everything that's local only. This includes thread-based
        // calls & any call link calls for which we're not the admin. These
        // deletions never fail (except for db corruption-level failures).
        callLinksToDelete = try await deps.databaseStorage.awaitableWrite { tx in
            var callLinksToDelete = [(rootKey: CallLinkRootKey, adminPasskey: Data)]()
            var callRecordIdsWithInteractions = [CallRecord.ID]()
            for modelReferences in modelReferenceses {
                if let callLinkRowId = modelReferences.callLinkRowId {
                    let callLinkRecord = try self.deps.callLinkStore.fetch(rowId: callLinkRowId, tx: tx) ?? {
                        throw OWSAssertionError("Couldn't fetch CallLink that must exist.")
                    }()
                    if let adminPasskey = callLinkRecord.adminPasskey {
                        callLinksToDelete.append((callLinkRecord.rootKey, adminPasskey))
                    } else {
                        try self.deleteCallRecords(forCallLinkRowId: callLinkRecord.id, tx: tx)
                    }
                } else {
                    callRecordIdsWithInteractions.append(contentsOf: modelReferences.callRecordRowIds)
                }
            }
            let callRecordsWithInteractions = callRecordIdsWithInteractions.compactMap { callRecordId -> CallRecord? in
                return self.deps.callRecordStore.fetch(callRecordId: callRecordId, tx: tx).unwrapped
            }

            /// Deleting these call records will trigger a ``CallRecordStoreNotification``,
            /// which we're listening for in this view and will in turn lead us
            /// to update the UI as appropriate.
            self.deps.interactionDeleteManager.delete(
                alongsideAssociatedCallRecords: callRecordsWithInteractions,
                sideEffects: .default(),
                tx: tx,
            )

            return callLinksToDelete
        }

        // Then, delete any call links we found for which we're the admin. Each of
        // these may independently fail.
        try await deleteCallLinks(callLinksToDelete: callLinksToDelete)
    }

    private nonisolated func deleteCallRecords(forCallLinkRowId callLinkRowId: Int64, tx: DBWriteTransaction) throws {
        let callRecords = try deps.callRecordStore.fetchExisting(conversationId: .callLink(callLinkRowId: callLinkRowId), limit: nil, tx: tx)
        deps.callRecordDeleteManager.deleteCallRecords(callRecords, sendSyncMessageOnDelete: true, tx: tx)
    }

    private func deleteCallLinks(callLinksToDelete: [(rootKey: CallLinkRootKey, adminPasskey: Data)]) async throws {
        let callLinkStateUpdater = deps.callService.callLinkStateUpdater
        try await withThrowingTaskGroup(of: Void.self) { taskGroup in
            for callLinkToDelete in callLinksToDelete {
                taskGroup.addTask {
                    try await CallLinkDeleter.deleteCallLink(
                        stateUpdater: callLinkStateUpdater,
                        storageServiceManager: SSKEnvironment.shared.storageServiceManagerRef,
                        rootKey: callLinkToDelete.rootKey,
                        adminPasskey: callLinkToDelete.adminPasskey,
                    )
                }
            }
            var anyError: (any Error)?
            while let result = await taskGroup.nextResult() {
                switch result {
                case .success:
                    break
                case .failure(let error):
                    Logger.warn("Couldn't delete call link: \(error)")
                    anyError = error
                }
            }
            if let anyError {
                throw anyError
            }
        }
    }

    private func selectCall(forRowAt indexPath: IndexPath) {
        startMultiselect()
        tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
    }

    // MARK: CallCellDelegate

    fileprivate func joinCall(from viewModel: CallViewModel) {
        startCall(from: viewModel)
    }

    fileprivate func returnToCall(from viewModel: CallViewModel) {
        AppEnvironment.shared.windowManagerRef.returnToCallView()
    }

    fileprivate func presentToast(toastText: String) {
        presentToast(text: toastText)
    }

    fileprivate func showCallInfo(from viewModel: CallViewModel) {
        AssertIsOnMainThread()

        switch viewModel.recipientType {
        case .individual(type: _, let thread):
            showCallInfo(forThread: thread, callRecords: viewModel.callRecords)
        case .groupThread(let groupId):
            let thread = deps.db.read { tx in deps.threadStore.fetchGroupThread(groupId: groupId, tx: tx)! }
            showCallInfo(forThread: thread, callRecords: viewModel.callRecords)
        case .callLink(let rootKey):
            showCallInfo(forRootKey: rootKey, callRecords: viewModel.callRecords)
        }
    }

    private func showCallInfo(forThread thread: TSThread, callRecords: [CallRecord]) {
        let (threadViewModel, isSystemContact) = deps.databaseStorage.read { tx in
            let threadViewModel = ThreadViewModel(
                thread: thread,
                forChatList: false,
                transaction: tx,
            )
            let isSystemContact = thread.isSystemContact(
                contactsManager: deps.contactsManager,
                tx: tx,
            )
            return (threadViewModel, isSystemContact)
        }

        let callDetailsView = ConversationSettingsViewController(
            threadViewModel: threadViewModel,
            isSystemContact: isSystemContact,
            // Nothing would have been revealed, so this can be a fresh instance
            spoilerState: SpoilerRenderState(),
            callRecords: callRecords,
            memberLabelCoordinator: nil,
        )

        showCallInfo(viewController: callDetailsView)
    }

    private func showCallInfo(forRootKey rootKey: CallLinkRootKey, callRecords: [CallRecord]) {
        let callLinkRecord = deps.db.read { tx -> CallLinkRecord in
            do {
                return try deps.callLinkStore.fetch(roomId: rootKey.deriveRoomId(), tx: tx) ?? {
                    owsFail("Can't fetch CallLinkRecord that must exist.")
                }()
            } catch {
                owsFail("Can't fetch CallLinkRecord: \(error)")
            }
        }
        showCallInfo(viewController: CallLinkViewController.forExisting(callLinkRecord: callLinkRecord, callRecords: callRecords))
    }

    private func showCallInfo(viewController: UIViewController) {
        viewController.hidesBottomBarWhenPushed = true
        navigationController?.pushViewController(viewController, animated: true)
    }

    // MARK: NewCallViewControllerDelegate

    private func goToChatThread(from viewModel: CallViewModel) -> (() -> TSThread?)? {
        switch viewModel.recipientType {
        case .individual(type: _, let thread):
            return { thread }
        case .groupThread(let groupId):
            return { [deps] in
                return deps.db.read { tx in deps.threadStore.fetchGroupThread(groupId: groupId, tx: tx) }
            }
        case .callLink:
            return nil
        }
    }

    func goToChat(for thread: TSThread) {
        SignalApp.shared.presentConversationForThread(
            threadUniqueId: thread.uniqueId,
            action: .compose,
            animated: false,
        )
    }
}

// MARK: UISearchResultsUpdating

extension CallsListViewController: UISearchResultsUpdating {
    func updateSearchResults(for searchController: UISearchController) {
        self.searchTerm = searchController.searchBar.text
    }
}

// MARK: - DatabaseChangeDelegate

extension CallsListViewController: DatabaseChangeDelegate {
    /// If the database changed externally – which is to say, in the NSE – state
    /// that this view relies on may have changed. We can't know if it'll have
    /// affected us, so we'll simply load calls fresh and make the table view
    /// reload all the cells.
    func databaseChangesDidUpdateExternally() {
        logger.info("Database changed externally, loading calls anew and reloading all rows.")

        reinitializeLoadedViewModels(debounceInterval: 0, animated: false)
        reloadAllRows()
    }

    func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) {
        guard let rowIds = databaseChanges.tableRowIds[CallLinkRecord.databaseTableName] else {
            return
        }
        reloadUpcomingCallLinks()
        let updatedReferences = viewModelLoader.invalidate(callLinkRowIds: rowIds, callRecordIds: [])
        updateSnapshot(updatedReferences: updatedReferences, animated: true)
    }

    func databaseChangesDidReset() {}
}

// MARK: - Call cell

private extension CallsListViewController {
    class CallCell: UITableViewCell {
        weak var delegate: CallCellDelegate?

        var viewModel: CallViewModel? {
            didSet {
                updateContents()
            }
        }

        /// If set to `true` background in `selected` state would have rounded corners.
        var useSidebarAppearance = false

        // MARK: Subviews

        private lazy var avatarView = ConversationAvatarView(
            sizeClass: .fortyFour,
            localUserDisplayMode: .asUser,
        )

        private lazy var titleLabel: UILabel = {
            let label = UILabel()
            label.font = .dynamicTypeHeadline
            label.textColor = .Signal.label
            return label
        }()

        private lazy var subtitleLabel: UILabel = {
            let label = UILabel()
            label.textColor = .Signal.secondaryLabel
            return label
        }()

        private lazy var timestampLabel: UILabel = {
            let label = UILabel()
            label.font = .dynamicTypeSubheadline
            label.textColor = .Signal.secondaryLabel
            return label
        }()

        private func makeStartCallButton(viewModel: CallViewModel) -> UIButton {
            var config = UIButton.Configuration.gray()
            config.cornerStyle = .capsule
            config.background.backgroundInsets = .init(margin: 2)
            config.baseBackgroundColor = UIColor.Signal.tertiaryFill
            config.baseForegroundColor = UIColor.Signal.label

            let icon: ThemeIcon = switch viewModel.medium {
            case .audio:
                .buttonVoiceCall
            case .video, .link:
                .buttonVideoCall
            }

            config.image = Theme.iconImage(icon)

            let button = UIButton(
                configuration: config,
                primaryAction: UIAction { [weak self] _ in
                    self?.detailsTapped(viewModel: viewModel)
                },
            )
            button.setCompressionResistanceHorizontalHigh()
            return button
        }

        private func makeJoinButton(viewModel: CallViewModel) -> UIButton {
            var config = UIButton.Configuration.borderedProminent()
            if #available(iOS 26, *) {
                config = UIButton.Configuration.prominentGlass()
            } else {
                config.cornerStyle = .capsule
            }

            let icon: UIImage
            switch viewModel.medium {
            case .audio:
                icon = Theme.iconImage(.phoneFill16)
            case .video, .link:
                icon = Theme.iconImage(.videoFill16)
            }

            let text: String
            switch viewModel.state {
            case .active:
                text = Strings.joinCallButtonTitle
            case .participating:
                text = Strings.returnToCallButtonTitle
            case .inactive:
                text = ""
            }

            config.title = text
            config.titleTextAttributesTransformer = .defaultFont(.dynamicTypeSubheadline.bold())
            config.image = icon
            config.imagePadding = 4

            let button = UIButton(
                configuration: config,
                primaryAction: UIAction { [weak self] _ in
                    self?.detailsTapped(viewModel: viewModel)
                },
            )
            button.tintColor = UIColor.Signal.green
            return button
        }

        // MARK: Init

        private let trailingHStack = UIStackView()
        private var trailingButton: UIButton?

        override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
            super.init(style: style, reuseIdentifier: reuseIdentifier)

            tintColor = .Signal.accent
            automaticallyUpdatesBackgroundConfiguration = false

            let bodyVStack = UIStackView(arrangedSubviews: [
                titleLabel,
                subtitleLabel,
            ])
            bodyVStack.axis = .vertical

            let leadingHStack = UIStackView(arrangedSubviews: [
                avatarView,
                bodyVStack,
            ])
            leadingHStack.alignment = .center
            leadingHStack.axis = .horizontal
            leadingHStack.spacing = 12

            trailingHStack.addArrangedSubview(timestampLabel)
            trailingHStack.axis = .horizontal
            trailingHStack.spacing = 12
            trailingHStack.alignment = .center

            let outerHStack = UIStackView(arrangedSubviews: [
                leadingHStack,
                UIView(),
                trailingHStack,
            ])
            outerHStack.axis = .horizontal
            outerHStack.spacing = 4

            contentView.addSubview(outerHStack)
            outerHStack.autoPinWidthToSuperviewMargins()
            outerHStack.autoPinHeightToSuperview(withMargin: 14)
        }

        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }

        deinit {
            timestampDisplayRefreshTimer?.invalidate()
        }

        // MARK: Dynamically-refreshing timestamp

        /// A timer tracking the next time this cell should refresh its
        /// displayed timestamp.
        private var timestampDisplayRefreshTimer: Timer?

        /// Immediately update the display timestamp for this cell, and schedule
        /// an automatic refresh of the display timestamp as appropriate.
        func updateDisplayedDateAndScheduleRefresh() {
            AssertIsOnMainThread()

            timestampDisplayRefreshTimer?.invalidate()
            timestampDisplayRefreshTimer = nil

            guard let viewModel else { return }

            let date: Date? = {
                switch viewModel.state {
                case .active, .participating:
                    /// Don't show a date for active calls.
                    return nil
                case .inactive:
                    return viewModel.callRecords.first?.callBeganDate
                }
            }()

            guard let date else {
                timestampLabel.text = nil
                return
            }

            let (formattedDate, nextRefreshDate) = DateUtil.formatDynamicDateShort(date)

            timestampLabel.text = formattedDate

            if let nextRefreshDate {
                timestampDisplayRefreshTimer = .scheduledTimer(
                    withTimeInterval: max(1, nextRefreshDate.timeIntervalSinceNow),
                    repeats: false,
                ) { [weak self] _ in
                    guard let self else { return }
                    self.updateDisplayedDateAndScheduleRefresh()
                }
            }
        }

        // MARK: Updates

        private func updateContents() {
            guard let viewModel else {
                return owsFailDebug("Missing view model")
            }

            avatarView.updateWithSneakyTransactionIfNecessary { configuration in
                switch viewModel.recipientType {
                case .individual(type: _, let thread):
                    configuration.dataSource = .thread(thread)
                case .groupThread(let groupId):
                    configuration.setGroupIdWithSneakyTransaction(groupId: groupId)
                case .callLink(let rootKey):
                    configuration.dataSource = .asset(avatar: CommonCallLinksUI.callLinkIcon(rootKey: rootKey), badge: nil)
                }
            }

            let titleText: String = {
                if viewModel.callRecords.count <= 1 {
                    return viewModel.title
                } else {
                    return String.nonPluralLocalizedStringWithFormat(Strings.coalescedCallsTitleFormat, viewModel.title, "\(viewModel.callRecords.count)")
                }
            }()
            titleLabel.text = titleText

            switch viewModel.direction {
            case .incoming, .outgoing, .callLink:
                titleLabel.textColor = .Signal.label
            case .missed:
                titleLabel.textColor = .Signal.red
            }

            self.subtitleLabel.attributedText = .composed(of: [
                viewModel.direction.symbol.attributedString(for: .subheadline),
                " ",
                viewModel.direction.label,
            ]).styled(with: .font(.dynamicTypeSubheadline))

            let button = switch viewModel.state {
            case .active, .participating:
                makeJoinButton(viewModel: viewModel)
            case .inactive:
                makeStartCallButton(viewModel: viewModel)
            }
            trailingButton?.removeFromSuperview()
            trailingButton = button
            trailingHStack.addArrangedSubview(button)

            updateDisplayedDateAndScheduleRefresh()
        }

        override func updateConfiguration(using state: UICellConfigurationState) {
            var configuration = UIBackgroundConfiguration.clear()
            if state.isSelected || state.isHighlighted {
                configuration.backgroundColor = Theme.tableCell2SelectedBackgroundColor
                if useSidebarAppearance {
                    configuration.cornerRadius = 36
                }
            } else {
                configuration.backgroundColor = .Signal.background
            }
            backgroundConfiguration = configuration
        }

        // MARK: Actions

        private enum canJoinCallResult {
            case failedGroupTerminated
            case failedNotMemberOfGroup
            case success
        }

        private func canJoinCall(viewModel: CallViewModel) -> canJoinCallResult {
            let db = DependenciesBridge.shared.db

            switch viewModel.recipientType {
            case .groupThread(groupId: let groupId):
                guard
                    let groupThread: TSGroupThread = db.read(block: { tx in
                        return try? TSGroupThread.fetch(forGroupId: GroupIdentifier(contents: groupId), tx: tx)
                    })
                else {
                    owsFailDebug("unable to fetch groupThread")
                    return .success
                }
                if groupThread.isTerminatedGroup {
                    return .failedGroupTerminated
                }
                if !groupThread.isLocalUserFullMemberOfThread {
                    return .failedNotMemberOfGroup
                }
                return .success
            case .callLink, .individual:
                return .success
            }
        }

        private func detailsTapped(viewModel: CallViewModel) {
            guard let delegate else {
                return owsFailDebug("Missing delegate")
            }

            let canJoinCall = canJoinCall(viewModel: viewModel)
            switch canJoinCall {
            case .failedGroupTerminated:
                delegate.presentToast(
                    toastText: OWSLocalizedString(
                        "END_GROUP_ACTION_ERROR",
                        comment: "Description for error sheet that says the user can no longer take this action because the group has ended.",
                    ),
                )
                return
            case .failedNotMemberOfGroup:
                delegate.presentToast(
                    toastText: OWSLocalizedString(
                        "GROUP_CALL_NOT_A_MEMBER",
                        comment: "Text indicating you can't take this action because you're not a member of the group",
                    ),
                )
                return
            case .success:
                break
            }

            switch viewModel.state {
            case .active, .inactive:
                delegate.joinCall(from: viewModel)
            case .participating:
                delegate.returnToCall(from: viewModel)
            }
        }
    }
}

private extension CallsListViewController {
    class CreateCallLinkCell: UITableViewCell {
        /// If set to `true` background in `selected` state would have rounded corners.
        var useSidebarAppearance = false

        private enum Constants {
            static let iconDimension: CGFloat = 24
            static let spacing: CGFloat = 18
            static let hMargin: CGFloat = 26
            static let vMargin: CGFloat = 15
        }

        private lazy var iconView: UIImageView = {
            let imageView = UIImageView(image: UIImage(named: "link"))
            imageView.tintColor = .Signal.label
            imageView.autoSetDimensions(to: CGSize(square: Constants.iconDimension))
            return imageView
        }()

        private lazy var label: UILabel = {
            let label = UILabel()
            label.font = .dynamicTypeHeadline
            label.textColor = .Signal.label
            label.numberOfLines = 3
            label.lineBreakMode = .byTruncatingTail
            label.text = OWSLocalizedString(
                "CREATE_CALL_LINK_LABEL",
                comment: "Label for button that enables you to make a new call link.",
            )
            return label
        }()

        override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
            super.init(style: style, reuseIdentifier: reuseIdentifier)

            automaticallyUpdatesBackgroundConfiguration = false

            let stackView = UIStackView(arrangedSubviews: [iconView, label])
            stackView.axis = .horizontal
            stackView.spacing = Constants.spacing
            stackView.alignment = .center

            self.contentView.addSubview(stackView)
            stackView.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(hMargin: Constants.hMargin, vMargin: Constants.vMargin))
        }

        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }

        override func updateConfiguration(using state: UICellConfigurationState) {
            var configuration = UIBackgroundConfiguration.clear()
            if state.isSelected || state.isHighlighted {
                configuration.backgroundColor = Theme.tableCell2SelectedBackgroundColor
                if useSidebarAppearance {
                    configuration.cornerRadius = 36
                }
            } else {
                configuration.backgroundColor = .Signal.background
            }
            backgroundConfiguration = configuration
        }
    }
}