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

import BonMot
import SignalServiceKit
import SignalUI

/* From BonMot 6.0.0: If you're targeting iOS 15 or higher, you may want to check out [AttributedString](https://developer.apple.com/documentation/foundation/attributedstring) instead.
 If you're an existing user of BonMot using Xcode 13, you may want to add the following `typealias` somewhere in your project to avoid a conflict with `Foundation.StringStyle`: */
typealias StringStyle = BonMot.StringStyle

protocol ConversationSearchViewDelegate: AnyObject {
    func conversationSearchViewWillBeginDragging()
    func conversationSearchDidSelectRow()
}

class ConversationSearchViewController: OWSViewController {

    weak var delegate: ConversationSearchViewDelegate?

    private var hasEverAppeared = false
    private var lastReloadDate: Date?
    private let cellContentCache = LRUCache<String, CLVCellContentToken>(maxSize: 256)

    private lazy var spoilerAnimationManager = SpoilerAnimationManager()

    enum SearchSection: Int {
        case noResults
        case contactThreads
        case groupThreads
        case contacts
        case messages
    }

    private var hasThemeChanged = false

    // MARK: View Controller

    override init() {
        super.init()
    }

    override func viewDidLoad() {
        super.viewDidLoad()

        view.backgroundColor = .Signal.background

        tableView.dataSource = self
        tableView.delegate = self
        tableView.backgroundColor = .Signal.background
        tableView.rowHeight = UITableView.automaticDimension
        tableView.estimatedRowHeight = 60
        tableView.separatorColor = .clear
        tableView.separatorInset = .zero
        tableView.separatorStyle = .none

        tableView.register(EmptySearchResultCell.self, forCellReuseIdentifier: EmptySearchResultCell.reuseIdentifier)
        tableView.register(ChatListCell.self, forCellReuseIdentifier: ChatListCell.reuseIdentifier)
        tableView.register(ContactTableViewCell.self, forCellReuseIdentifier: ContactTableViewCell.reuseIdentifier)

        tableView.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(tableView)
        tableView.autoPinHeight(toHeightOf: view)
        tableViewHorizontalEdgeConstraints = [
            tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            view.trailingAnchor.constraint(equalTo: tableView.trailingAnchor),
        ]
        NSLayoutConstraint.activate(tableViewHorizontalEdgeConstraints)
        updateTableViewPaddingIfNeeded()

        DependenciesBridge.shared.databaseChangeObserver.appendDatabaseChangeDelegate(self)

        NotificationCenter.default.addObserver(
            self,
            selector: #selector(blockListDidChange),
            name: BlockingManager.blockListDidChange,
            object: nil,
        )

        updateSeparators()
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        guard hasThemeChanged else {
            return
        }
        hasThemeChanged = false

        reloadTableData()
        self.hasEverAppeared = true
    }

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

    override func themeDidChange() {
        super.themeDidChange()

        reloadTableData()

        hasThemeChanged = true
    }

    @objc
    private func blockListDidChange(_ notification: NSNotification) {
        refreshSearchResults()
    }

    // MARK: Table View

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

    /// Set to `true` when 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 useSidebarTableViewCellAppearance = false {
        didSet {
            guard oldValue != useSidebarTableViewCellAppearance else { return }
            tableViewHorizontalEdgeConstraints.forEach {
                $0.constant = useSidebarTableViewCellAppearance ? 16 : 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 = presentingViewController?.splitViewController, !splitViewController.isCollapsed {
            useSidebarTableViewCellAppearance = true
        } else {
            useSidebarTableViewCellAppearance = false
        }
    }

    private func reloadTableData() {
        lastReloadDate = Date()
        cellContentCache.clear()
        tableView.reloadData()
    }

    private func updateSeparators() {
        tableView.separatorStyle = searchResultSet.isEmpty ? .none : .singleLine
    }

    // MARK: Search

    // Search is triggered with this is set externally by ChatListViewController.
    var searchText = "" {
        didSet {
            guard searchText != oldValue else { return }

            // Use a slight delay to debounce updates.
            refreshSearchResults()
        }
    }

    private var searchResultSet: HomeScreenSearchResultSet = HomeScreenSearchResultSet.empty {
        didSet {
            updateSeparators()
        }
    }

    private var lastSearchText: String?

    private var refreshTimer: Timer?

    private func refreshSearchResults() {
        AssertIsOnMainThread()

        guard !searchResultSet.isEmpty else {
            // To avoid incorrectly showing the "no results" state,
            // always search immediately if the current result set is empty.
            refreshTimer?.invalidate()
            refreshTimer = nil

            updateSearchResults(searchText: searchText)
            return
        }

        if refreshTimer != nil {
            // Don't start a new refresh timer if there's already one active.
            return
        }

        refreshTimer = WeakTimer.scheduledTimer(timeInterval: 0.1, target: self, userInfo: nil, repeats: false) { [weak self] _ in
            guard let strongSelf = self else {
                return
            }

            strongSelf.updateSearchResults(searchText: strongSelf.searchText)
            strongSelf.refreshTimer = nil
        }
    }

    private var currentSearchTask: Task<Void, Never>?

    private func updateSearchResults(searchText: String) {
        let searchText = searchText.stripped
        let lastSearchText = self.lastSearchText
        self.lastSearchText = searchText

        if searchText != lastSearchText {
            // The query has changed; perform a search.
        } else if tableView.visibleCells.contains(where: { $0 is ChatListCell }) {
            // The database may have been updated, and that'll lead to a duplicate
            // query for the same search text. In that case, perform a search if
            // there's a cell that needs to be updated.
        } else {
            // Nothing has changed, so don't perform a search.
            return
        }

        currentSearchTask?.cancel()
        currentSearchTask = Task {
            let searchResultSet: HomeScreenSearchResultSet
            do throws(CancellationError) {
                searchResultSet = try await fetchSearchResults(searchText: searchText)
                if Task.isCancelled {
                    throw CancellationError()
                }
            } catch {
                return
            }
            self.searchResultSet = searchResultSet
            self.reloadTableData()
        }
    }

    private nonisolated func fetchSearchResults(searchText: String) async throws(CancellationError) -> HomeScreenSearchResultSet {
        if searchText.isEmpty {
            return .empty
        }

        let databaseStorage = SSKEnvironment.shared.databaseStorageRef
        return try databaseStorage.read { tx throws(CancellationError) in
            return try FullTextSearcher.shared.searchForHomeScreen(searchText: searchText, tx: tx)
        }
    }
}

// MARK: -

extension ConversationSearchViewController: UITableViewDelegate {

    func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
        guard let searchSection = SearchSection(rawValue: indexPath.section) else {
            owsFailDebug("unknown section selected.")
            return nil
        }

        switch searchSection {
        case .noResults:
            return nil

        case .contactThreads, .groupThreads, .contacts, .messages:
            return indexPath
        }
    }

    func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
        tableView.deselectRow(at: indexPath, animated: false)

        guard let searchSection = SearchSection(rawValue: indexPath.section) else {
            owsFailDebug("unknown section selected.")
            return
        }

        delegate?.conversationSearchDidSelectRow()

        switch searchSection {
        case .noResults:
            owsFailDebug("shouldn't be able to tap 'no results' section")
        case .contactThreads:
            let sectionResults = searchResultSet.contactThreadResults
            guard let searchResult = sectionResults[safe: indexPath.row] else {
                owsFailDebug("unknown row selected.")
                return
            }

            let threadViewModel = searchResult.threadViewModel
            SignalApp.shared.presentConversationForThread(threadUniqueId: threadViewModel.threadUniqueId, action: .compose, animated: true)
        case .groupThreads:
            let sectionResults = searchResultSet.groupThreadResults
            guard let searchResult = sectionResults[safe: indexPath.row] else {
                owsFailDebug("unknown row selected.")
                return
            }

            let threadViewModel = searchResult.threadViewModel
            SignalApp.shared.presentConversationForThread(threadUniqueId: threadViewModel.threadUniqueId, action: .compose, animated: true)
        case .contacts:
            let sectionResults = searchResultSet.contactResults
            guard let searchResult = sectionResults[safe: indexPath.row] else {
                owsFailDebug("unknown row selected.")
                return
            }

            SignalApp.shared.presentConversationForAddress(searchResult.recipientAddress, action: .compose, animated: true)
        case .messages:
            let sectionResults = searchResultSet.messageResults
            guard let searchResult = sectionResults[safe: indexPath.row] else {
                owsFailDebug("unknown row selected.")
                return
            }

            let threadViewModel = searchResult.threadViewModel
            SignalApp.shared.presentConversationForThread(
                threadUniqueId: threadViewModel.threadUniqueId,
                focusMessageId: searchResult.messageId,
                animated: true,
            )
        }
    }

    func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        guard let cell = cell as? ChatListCell else { return }

        cell.isCellVisible = true
    }

    func tableView(_ tableView: UITableView, didEndDisplaying cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        guard let cell = cell as? ChatListCell else { return }

        cell.isCellVisible = false
    }
}

// MARK: -

extension ConversationSearchViewController: UITableViewDataSource {

    func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
        guard let searchSection = SearchSection(rawValue: section) else {
            owsFailDebug("unknown section: \(section)")
            return 0
        }

        switch searchSection {
        case .noResults:
            return searchResultSet.isEmpty ? 1 : 0
        case .contactThreads:
            return searchResultSet.contactThreadResults.count
        case .groupThreads:
            return searchResultSet.groupThreadResults.count
        case .contacts:
            return searchResultSet.contactResults.count
        case .messages:
            return searchResultSet.messageResults.count
        }
    }

    func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat {
        guard let searchSection = SearchSection(rawValue: indexPath.section) else {
            owsFailDebug("Invalid section: \(indexPath.section).")
            return UITableView.automaticDimension
        }

        switch searchSection {
        case .noResults, .contacts:
            return UITableView.automaticDimension
        case .contactThreads, .groupThreads, .messages:
            guard let configuration = cellConfiguration(searchSection: searchSection, row: indexPath.row) else {
                owsFailDebug("Missing configuration.")
                return UITableView.automaticDimension
            }
            let cellContentToken = cellContentToken(forConfiguration: configuration)
            return ChatListCell.measureCellHeight(cellContentToken: cellContentToken)
        }
    }

    private func cellContentToken(forConfiguration configuration: ChatListCell.Configuration, useCache: Bool = true) -> CLVCellContentToken {

        // If we have an existing CLVCellContentToken, use it.
        // Cell measurement/arrangement is expensive.
        let cacheKey = "\(configuration.threadViewModel.threadRecord.uniqueId).\(configuration.overrideSnippet?.text.hashValue ?? 0)"
        if useCache {
            if let cellContentToken = cellContentCache.get(key: cacheKey) {
                return cellContentToken
            }
        }

        let cellContentToken = ChatListCell.buildCellContentToken(for: configuration)
        cellContentCache.set(key: cacheKey, value: cellContentToken)
        return cellContentToken
    }

    func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        guard let searchSection = SearchSection(rawValue: indexPath.section) else {
            return UITableViewCell()
        }

        switch searchSection {
        case .noResults:
            guard let cell = tableView.dequeueReusableCell(withIdentifier: EmptySearchResultCell.reuseIdentifier) as? EmptySearchResultCell else {
                owsFailDebug("cell was unexpectedly nil")
                return UITableViewCell()
            }

            guard indexPath.row == 0 else {
                owsFailDebug("searchResult was unexpected index")
                return UITableViewCell()
            }

            cell.configure(searchText: searchResultSet.searchText)
            return cell

        case .contacts:
            guard let cell = tableView.dequeueReusableCell(withIdentifier: ContactTableViewCell.reuseIdentifier) as? ContactTableViewCell else {
                owsFailDebug("cell was unexpectedly nil")
                return UITableViewCell()
            }

            guard let searchResult = searchResultSet.contactResults[safe: indexPath.row] else {
                owsFailDebug("searchResult was unexpectedly nil")
                return UITableViewCell()
            }

            cell.configureWithSneakyTransaction(address: searchResult.recipientAddress, localUserDisplayMode: .noteToSelf)
            return cell

        case .contactThreads, .groupThreads, .messages:
            guard let cell = tableView.dequeueReusableCell(withIdentifier: ChatListCell.reuseIdentifier) as? ChatListCell else {
                owsFailDebug("cell was unexpectedly nil")
                return UITableViewCell()
            }
            guard let configuration = cellConfiguration(searchSection: searchSection, row: indexPath.row) else {
                owsFailDebug("Missing configuration.")
                return UITableViewCell()
            }

            let cellContentToken = cellContentToken(forConfiguration: configuration)
            cell.configure(cellContentToken: cellContentToken, spoilerAnimationManager: spoilerAnimationManager)
            cell.useSidebarAppearance = useSidebarTableViewCellAppearance
            return cell
        }
    }

    private class var matchSnippetStyle: StringStyle {
        StringStyle(
            .color(.Signal.secondaryLabel),
            .xmlRules([
                .style(FullTextSearchIndexer.matchTag, StringStyle(.font(UIFont.dynamicTypeSubheadline.semibold()))),
            ]),
        )
    }

    private func cellConfiguration(searchSection: SearchSection, row: Int) -> ChatListCell.Configuration? {
        let lastReloadDate: Date? = {
            guard self.hasEverAppeared else {
                return nil
            }
            return self.lastReloadDate
        }()

        switch searchSection {
        case .noResults:
            owsFailDebug("Invalid section.")
            return nil

        case .contactThreads:
            guard let searchResult = searchResultSet.contactThreadResults[safe: row] else {
                owsFailDebug("searchResult was unexpectedly nil")
                return nil
            }
            return ChatListCell.Configuration(
                threadViewModel: searchResult.threadViewModel,
                lastReloadDate: lastReloadDate,
            )

        case .groupThreads:
            guard let searchResult = searchResultSet.groupThreadResults[safe: row] else {
                owsFailDebug("searchResult was unexpectedly nil")
                return nil
            }
            let overrideSnippet: ChatListCell.Configuration.OverrideSnippet?
            if let snippet = searchResult.matchedMembersSnippet?.styled(with: Self.matchSnippetStyle) {
                overrideSnippet = .init(text: .attributedText(snippet), config: .conversationListSearchResultSnippet())
            } else {
                overrideSnippet = nil
            }
            return ChatListCell.Configuration(
                threadViewModel: searchResult.threadViewModel,
                lastReloadDate: lastReloadDate,
                overrideSnippet: overrideSnippet,
                overrideDate: nil,
            )

        case .contacts:
            owsFailDebug("Invalid section.")
            return nil

        case .messages:
            guard let searchResult = searchResultSet.messageResults[safe: row] else {
                owsFailDebug("searchResult was unexpectedly nil")
                return nil
            }
            var overrideDate: Date?
            if searchResult.messageId != nil {
                if let messageDate = searchResult.messageDate {
                    overrideDate = messageDate
                } else {
                    owsFailDebug("message search result is missing message timestamp")
                }
            }
            let overrideSnippet: ChatListCell.Configuration.OverrideSnippet?
            if let snippet = searchResult.snippet {
                overrideSnippet = .init(text: snippet, config: .conversationListSearchResultSnippet())
            } else {
                overrideSnippet = nil
            }
            return ChatListCell.Configuration(
                threadViewModel: searchResult.threadViewModel,
                lastReloadDate: lastReloadDate,
                overrideSnippet: overrideSnippet,
                overrideDate: overrideDate,
            )
        }
    }

    private func titleForHeaderInSection(_ section: Int) -> String? {
        guard let searchSection = SearchSection(rawValue: section) else {
            owsFailDebug("unknown section: \(section)")
            return nil
        }

        switch searchSection {
        case .noResults:
            return nil

        case .contactThreads:
            guard searchResultSet.contactThreadResults.count > 0 else { return nil }

            return OWSLocalizedString(
                "SEARCH_SECTION_CONVERSATIONS",
                comment: "section header for search results that match existing 1:1 chats",
            )

        case .groupThreads:
            guard searchResultSet.groupThreadResults.count > 0 else { return nil }

            return OWSLocalizedString(
                "SEARCH_SECTION_GROUPS",
                comment: "section header for search results that match existing groups",
            )

        case .contacts:
            guard searchResultSet.contactResults.count > 0 else { return nil }

            return OWSLocalizedString(
                "SEARCH_SECTION_CONTACTS",
                comment: "section header for search results that match a contact who doesn't have an existing conversation",
            )

        case .messages:
            guard searchResultSet.messageResults.count > 0 else { return nil }

            return OWSLocalizedString(
                "SEARCH_SECTION_MESSAGES",
                comment: "section header for search results that match a message in a conversation",
            )
        }
    }

    func numberOfSections(in tableView: UITableView) -> Int {
        return 5
    }

    func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
        guard nil != titleForHeaderInSection(section) else {
            return .leastNonzeroMagnitude
        }
        return UITableView.automaticDimension
    }

    func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
        guard let title = titleForHeaderInSection(section) else {
            return UIView()
        }

        let label = UILabel()
        label.textColor = .Signal.label
        label.font = .dynamicTypeHeadline
        label.text = title
        label.translatesAutoresizingMaskIntoConstraints = false

        let headerView = UITableViewHeaderFooterView(reuseIdentifier: nil)
        headerView.directionalLayoutMargins.top = 14
        headerView.directionalLayoutMargins.bottom = 8
        headerView.contentView.addSubview(label)

        NSLayoutConstraint.activate([
            label.topAnchor.constraint(equalTo: headerView.contentView.layoutMarginsGuide.topAnchor),
            label.leadingAnchor.constraint(equalTo: headerView.contentView.layoutMarginsGuide.leadingAnchor),
            label.trailingAnchor.constraint(equalTo: headerView.contentView.layoutMarginsGuide.trailingAnchor),
            label.bottomAnchor.constraint(equalTo: headerView.contentView.layoutMarginsGuide.bottomAnchor),
        ])

        return headerView
    }

    func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
        // Without returning a footer with a non-zero height, Grouped
        // table view will use a default spacing between sections. We
        // do not want that spacing so we use the smallest possible height.
        return .leastNormalMagnitude
    }

    func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
        UIView()
    }

    func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        guard let threadViewModel = getThreadViewModelFor(indexPath: indexPath) else {
            return nil
        }

        return trailingSwipeActionsConfiguration(threadViewModel: threadViewModel)
    }

    func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
        guard let threadViewModel = getThreadViewModelFor(indexPath: indexPath) else {
            return nil
        }

        return leadingSwipeActionsConfiguration(threadViewModel: threadViewModel)
    }

    private func getThreadViewModelFor(indexPath: IndexPath) -> ThreadViewModel? {
        guard let searchSection = SearchSection(rawValue: indexPath.section) else { return nil }

        if searchSection == .contactThreads {
            return searchResultSet.contactThreadResults[indexPath.row].threadViewModel
        } else if searchSection == .groupThreads {
            return searchResultSet.groupThreadResults[indexPath.row].threadViewModel
        }
        return nil
    }
}

// MARK: - UIScrollViewDelegate

extension ConversationSearchViewController {

    func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
        delegate?.conversationSearchViewWillBeginDragging()
    }
}

// MARK: -

class EmptySearchResultCell: UITableViewCell {

    static let reuseIdentifier = "EmptySearchResultCell"

    private let messageLabel = UILabel()

    private let activityIndicator = UIActivityIndicatorView(style: .large)

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

        selectionStyle = .none
        automaticallyUpdatesBackgroundConfiguration = false

        messageLabel.textAlignment = .center
        messageLabel.numberOfLines = 3

        contentView.addSubview(messageLabel)
        messageLabel.translatesAutoresizingMaskIntoConstraints = false

        contentView.addSubview(activityIndicator)
        activityIndicator.translatesAutoresizingMaskIntoConstraints = false

        NSLayoutConstraint.activate([
            messageLabel.heightAnchor.constraint(equalToConstant: 150),
            messageLabel.topAnchor.constraint(greaterThanOrEqualTo: contentView.layoutMarginsGuide.topAnchor),
            messageLabel.centerYAnchor.constraint(equalTo: contentView.layoutMarginsGuide.centerYAnchor),
            messageLabel.leadingAnchor.constraint(greaterThanOrEqualTo: contentView.layoutMarginsGuide.leadingAnchor),
            messageLabel.centerXAnchor.constraint(equalTo: contentView.layoutMarginsGuide.centerXAnchor),

            activityIndicator.centerXAnchor.constraint(equalTo: contentView.layoutMarginsGuide.centerXAnchor),
            activityIndicator.centerYAnchor.constraint(equalTo: contentView.layoutMarginsGuide.centerYAnchor),
        ])
    }

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

    override func updateConfiguration(using state: UICellConfigurationState) {
        var configuration = UIBackgroundConfiguration.clear()
        configuration.backgroundColor = .Signal.background
        backgroundConfiguration = configuration
    }

    func configure(searchText: String) {
        if searchText.isEmpty {
            activityIndicator.color = Theme.primaryIconColor
            activityIndicator.isHidden = false
            activityIndicator.startAnimating()
            messageLabel.isHidden = true
            messageLabel.text = nil
        } else {
            activityIndicator.stopAnimating()
            activityIndicator.isHidden = true
            messageLabel.isHidden = false

            let format = OWSLocalizedString(
                "HOME_VIEW_SEARCH_NO_RESULTS_FORMAT",
                comment: "Format string when search returns no results. Embeds {{search term}}",
            )
            messageLabel.text = String.nonPluralLocalizedStringWithFormat(format, searchText)

            messageLabel.textColor = .Signal.label
            messageLabel.font = UIFont.dynamicTypeBody
        }
    }
}

// MARK: -

extension ConversationSearchViewController: DatabaseChangeDelegate {

    func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) {
        guard databaseChanges.didUpdateThreads || databaseChanges.didUpdateInteractions else {
            return
        }

        refreshSearchResults()
    }

    func databaseChangesDidUpdateExternally() {
        refreshSearchResults()
    }

    func databaseChangesDidReset() {
        refreshSearchResults()
    }
}

// MARK: - ThreadContextualActionProvider

extension ConversationSearchViewController: ThreadContextualActionProvider {
    func threadContextualActionShouldCloseThreadIfActive(threadViewModel: ThreadViewModel) {
        // Ignore
    }

    func threadContextualActionDidComplete() {
        // Ignore
    }
}