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

import AVKit
import Foundation
import LibSignalClient
public import SignalServiceKit
import UIKit

public protocol ConversationPickerDelegate: AnyObject {
    func conversationPickerSelectionDidChange(_ conversationPickerViewController: ConversationPickerViewController)

    func conversationPickerDidCompleteSelection(_ conversationPickerViewController: ConversationPickerViewController)

    func conversationPickerCanCancel(_ conversationPickerViewController: ConversationPickerViewController) -> Bool

    func conversationPickerDidCancel(_ conversationPickerViewController: ConversationPickerViewController)

    func approvalMode(_ conversationPickerViewController: ConversationPickerViewController) -> ApprovalMode

    func conversationPickerDidBeginEditingText()

    func conversationPickerSearchBarActiveDidChange(_ conversationPickerViewController: ConversationPickerViewController)
}

// MARK: -

open class ConversationPickerViewController: OWSTableViewController2 {

    public weak var pickerDelegate: ConversationPickerDelegate?

    private let kMaxPickerSelection = 5
    private let attachments: [PreviewableAttachment]
    private let textAttachment: UnsentTextAttachment?
    private let maxVideoAttachmentDuration: TimeInterval?

    private let creationDate = Date()

    public let selection: ConversationPickerSelection

    private let footerView = ApprovalFooterView()

    private var searchTask: Task<Void, Never>?

    private lazy var searchController: UISearchController = {
        let controller = UISearchController(searchResultsController: nil)
        controller.searchResultsUpdater = self
        controller.delegate = self
        return controller
    }()

    public var textInput: String? {
        footerView.textInput
    }

    private var conversationCollection: ConversationCollection = .empty {
        didSet {
            if
                let firstSelectedStoryIndex = conversationCollection.storyConversations.firstIndex(where: { self.selection.isSelected(conversation: $0) }),
                firstSelectedStoryIndex >= self.maxStoryConversationsToRender - 1
            {
                // If we've come in already having selected a story in the expanded section,
                // expand right away.
                self.isStorySectionExpanded = true
            }
            updateTableContents()
        }
    }

    public var approvalTextMode: ApprovalFooterView.ApprovalTextMode {
        get { footerView.approvalTextMode }
        set { footerView.approvalTextMode = newValue }
    }

    public init(
        selection: ConversationPickerSelection,
        attachments: [PreviewableAttachment] = [],
        textAttachment: UnsentTextAttachment? = nil,
        overrideTitle: String? = nil,
    ) {
        self.selection = selection
        self.attachments = attachments
        self.textAttachment = textAttachment

        let maxVideoAttachmentDuration: TimeInterval? = attachments
            .lazy
            .compactMap { attachment in
                guard attachment.rawValue.isVideo else {
                    return nil
                }
                return AVURLAsset(url: attachment.rawValue.dataSource.fileUrl).duration.seconds
            }
            .max()

        self.maxVideoAttachmentDuration = maxVideoAttachmentDuration

        super.init()

        self.selectionBehavior = .toggleSelectionWithAction
        self.shouldAvoidKeyboard = true

        if let overrideTitle {
            self.title = overrideTitle
        } else {
            self.title = Strings.defaultTitle
        }

        self.navigationItem.searchController = searchController
        if #available(iOS 16.0, *) {
            self.navigationItem.preferredSearchBarPlacement = .stacked
        }
        self.navigationItem.hidesSearchBarWhenScrolling = false

        self.bottomFooter = footerView
        selection.delegate = self
        SUIEnvironment.shared.contactsViewHelperRef.addObserver(self)
    }

    private var approvalMode: ApprovalMode {
        pickerDelegate?.approvalMode(self) ?? .send
    }

    public func updateApprovalMode() { footerView.updateContents() }

    override public var preferredNavigationBarStyle: OWSNavigationBarStyle {
        return .solid
    }

    public struct SectionOptions: OptionSet {
        public let rawValue: Int

        public init(rawValue: Int) {
            self.rawValue = rawValue
        }

        public static let mediaPreview = SectionOptions(rawValue: 1 << 0)
        public static let stories = SectionOptions(rawValue: 1 << 1)
        public static let recents = SectionOptions(rawValue: 1 << 2)
        public static let contacts = SectionOptions(rawValue: 1 << 3)
        public static let groups = SectionOptions(rawValue: 1 << 4)

        public static let storiesOnly: SectionOptions = [.mediaPreview, .stories]
        public static let allDestinations: SectionOptions = [.stories, .recents, .contacts, .groups]
    }

    public var sectionOptions: SectionOptions = [.recents, .contacts, .groups] {
        didSet {
            if isViewLoaded { updateTableContents() }
        }
    }

    open nonisolated func threadFilter(_ isIncluded: TSThread) -> Bool { true }

    public var maxStoryConversationsToRender = 3
    public var isStorySectionExpanded = false

    /// When `true`, each time the user selects an item for sending to we will fetch the identity keys for those recipients
    /// and determine if there have been any safety number changes. When you continue from this screen, we will notify of
    /// any safety number changes that have been identified during the batch updates. We don't do this all the time, as we
    /// don't necessarily want to inject safety number changes into every flow where you select conversations (such as
    /// picking members for a group).
    public var shouldBatchUpdateIdentityKeys = false

    public var shouldHideRecentConversationsTitle: Bool = false {
        didSet {
            if isViewLoaded {
                updateTableContents()
            }
        }
    }

    public func selectSearchBar() {
        AssertIsOnMainThread()

        searchController.isActive = true
        searchController.searchBar.becomeFirstResponder()
    }

    override open func viewDidLoad() {
        super.viewDidLoad()

        if pickerDelegate?.conversationPickerCanCancel(self) ?? false {
            self.navigationItem.rightBarButtonItem = .cancelButton { [weak self] in
                self?.onTouchCancelButton()
            }
        }

        if #available(iOS 17.0, *) {
            view.keyboardLayoutGuide.usesBottomSafeArea = false
        }

        tableView.allowsMultipleSelection = true
        tableView.register(ConversationPickerCell.self, forCellReuseIdentifier: ConversationPickerCell.reuseIdentifier)

        footerView.delegate = self

        conversationCollection = buildConversationCollection(sectionOptions: sectionOptions)

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

        // Works around a mysterious issue in which UITraitCollection.current's
        // userInterfaceStyle doesn't match that of this view controller, which
        // results in wonky colors.
        DispatchQueue.main.async { [self] in
            configureThemeForShareExtension()
        }
    }

    var presentationTime: Date?
    override open func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)

        presentationTime = presentationTime ?? Date()
    }

    // MARK: -

    override open func themeDidChange() {
        super.themeDidChange()
        updateTableContents(shouldReload: false)
    }

    /// Manually observe `UITraitCollection` changes to manage `Theme`. This is
    /// typically done by `OWSWindow`, but in the Share Extension we don't have
    /// an `OWSWindow` to do this for us.
    override open func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)
        configureThemeForShareExtension()
    }

    private func configureThemeForShareExtension() {
        guard CurrentAppContext().isShareExtension else { return }
        Theme.shareExtensionInterfaceStyleOverride = traitCollection.userInterfaceStyle
    }

    // MARK: - ConversationCollection

    private func restoreSelection() {
        AssertIsOnMainThread()

        tableView.indexPathsForSelectedRows?.forEach { tableView.deselectRow(at: $0, animated: false) }

        for selectedConversation in selection.conversations {
            guard let index = conversationCollection.indexPath(conversation: selectedConversation) else {
                // This can happen when restoring selection while the currently displayed results
                // are filtered.
                continue
            }
            tableView.selectRow(at: index, animated: false, scrollPosition: .none)
        }

        updateUIForCurrentSelection(animated: false)
    }

    private nonisolated func buildSearchResults(searchText: String) async throws(CancellationError) -> RecipientSearchResultSet? {
        guard searchText.count > 1 else {
            return nil
        }

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

    private nonisolated func buildGroupItem(
        _ groupThread: TSGroupThread,
        isBlocked: Bool,
        transaction tx: DBReadTransaction,
    ) -> GroupConversationItem {
        let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
        let dmConfig = dmConfigurationStore.fetchOrBuildDefault(for: .thread(groupThread), tx: tx)
        return GroupConversationItem(
            groupThreadId: groupThread.uniqueId,
            isBlocked: isBlocked,
            disappearingMessagesConfig: dmConfig,
        )
    }

    private nonisolated func buildContactItem(
        _ address: SignalServiceAddress,
        isBlocked: Bool,
        transaction tx: DBReadTransaction,
    ) -> ContactConversationItem {
        let thread = TSContactThread.getWithContactAddress(address, transaction: tx)
        let dmConfigurationStore = DependenciesBridge.shared.disappearingMessagesConfigurationStore
        let dmConfig = thread.map { dmConfigurationStore.fetchOrBuildDefault(for: .thread($0), tx: tx) }
        let displayName = SSKEnvironment.shared.contactManagerRef.displayName(for: address, tx: tx)
        return ContactConversationItem(
            address: address,
            isBlocked: isBlocked,
            disappearingMessagesConfig: dmConfig,
            comparableName: ComparableDisplayName(address: address, displayName: displayName, config: .current()),
        )
    }

    private nonisolated func buildConversationCollection(sectionOptions: SectionOptions) -> ConversationCollection {
        SSKEnvironment.shared.databaseStorageRef.read { transaction in
            var pinnedItemsByThreadId: [String: RecentConversationItem] = [:]
            var recentItems: [RecentConversationItem] = []
            var contactItems: [ContactConversationItem] = []
            var groupItems: [GroupConversationItem] = []
            var seenAddresses: Set<SignalServiceAddress> = Set()

            let pinnedThreadIds = DependenciesBridge.shared.pinnedThreadStore.pinnedThreadIds(tx: transaction)

            // We append any pinned threads at the start of the "recent"
            // section, so we decrease our maximum recent items based
            // on how many threads are currently pinned.
            let maxRecentCount = 25 - pinnedThreadIds.count

            let addThread = { (thread: TSThread) -> Void in
                guard self.threadFilter(thread) else { return }

                guard thread.canSendChatMessagesToThread(ignoreAnnouncementOnly: true) else {
                    return
                }

                let isThreadBlocked = SSKEnvironment.shared.blockingManagerRef.isThreadBlocked(
                    thread,
                    transaction: transaction,
                )
                if isThreadBlocked {
                    return
                }

                switch thread {
                case let contactThread as TSContactThread:
                    let isThreadHidden = DependenciesBridge.shared.recipientHidingManager.isHiddenAddress(
                        contactThread.contactAddress,
                        tx: transaction,
                    )
                    if isThreadHidden {
                        return
                    }
                    let item = self.buildContactItem(
                        contactThread.contactAddress,
                        isBlocked: isThreadBlocked,
                        transaction: transaction,
                    )

                    seenAddresses.insert(contactThread.contactAddress)
                    if sectionOptions.contains(.recents), pinnedThreadIds.contains(thread.uniqueId) {
                        let recentItem = RecentConversationItem(backingItem: .contact(item))
                        pinnedItemsByThreadId[thread.uniqueId] = recentItem
                    } else if sectionOptions.contains(.recents), recentItems.count < maxRecentCount {
                        let recentItem = RecentConversationItem(backingItem: .contact(item))
                        recentItems.append(recentItem)
                    } else {
                        contactItems.append(item)
                    }
                case let groupThread as TSGroupThread:
                    guard groupThread.groupModel.groupMembership.isLocalUserFullMember else {
                        return
                    }

                    let item = self.buildGroupItem(
                        groupThread,
                        isBlocked: isThreadBlocked,
                        transaction: transaction,
                    )

                    if sectionOptions.contains(.recents), pinnedThreadIds.contains(thread.uniqueId) {
                        let recentItem = RecentConversationItem(backingItem: .group(item))
                        pinnedItemsByThreadId[thread.uniqueId] = recentItem
                    } else if sectionOptions.contains(.recents), recentItems.count < maxRecentCount {
                        let recentItem = RecentConversationItem(backingItem: .group(item))
                        recentItems.append(recentItem)
                    } else {
                        groupItems.append(item)
                    }
                default:
                    owsFailDebug("unexpected thread: \(thread.logString)")
                }
            }

            ThreadFinder().enumerateVisibleThreads(isArchived: false, transaction: transaction) { thread in
                addThread(thread)
            }

            ThreadFinder().enumerateVisibleThreads(isArchived: true, transaction: transaction) { thread in
                addThread(thread)
            }

            SignalAccount.anyEnumerate(transaction: transaction) { signalAccount, _ in
                let address = signalAccount.recipientAddress
                guard !seenAddresses.contains(address) else {
                    return
                }
                seenAddresses.insert(address)

                let isContactBlocked = SSKEnvironment.shared.blockingManagerRef.isAddressBlocked(
                    address,
                    transaction: transaction,
                )

                if isContactBlocked {
                    return
                }

                let isRecipientHidden = DependenciesBridge.shared.recipientHidingManager.isHiddenAddress(
                    address,
                    tx: transaction,
                )
                if isRecipientHidden {
                    return
                }

                let contactItem = self.buildContactItem(
                    address,
                    isBlocked: isContactBlocked,
                    transaction: transaction,
                )

                contactItems.append(contactItem)
            }
            contactItems.sort(by: <)

            let pinnedItems = pinnedItemsByThreadId.sorted { lhs, rhs in
                guard
                    let lhsIndex = pinnedThreadIds.firstIndex(of: lhs.key),
                    let rhsIndex = pinnedThreadIds.firstIndex(of: rhs.key)
                else {
                    owsFailDebug("Unexpectedly have pinned item without pinned thread id")
                    return false
                }

                return lhsIndex < rhsIndex
            }.map { $0.value }

            let storyItems = StoryConversationItem.allItems(
                includeImplicitGroupThreads: true,
                excludeHiddenContexts: true,
                prioritizeThreadsCreatedAfter: creationDate,
                blockingManager: SSKEnvironment.shared.blockingManagerRef,
                transaction: transaction,
            )

            return ConversationCollection(
                contactConversations: contactItems,
                recentConversations: pinnedItems + recentItems,
                groupConversations: groupItems,
                storyConversations: storyItems,
                isSearchResults: false,
            )
        }
    }

    private nonisolated func buildConversationCollection(sectionOptions: SectionOptions, searchResults: RecipientSearchResultSet?) async -> ConversationCollection {
        guard let searchResults else {
            return buildConversationCollection(sectionOptions: sectionOptions)
        }

        return SSKEnvironment.shared.databaseStorageRef.read { transaction in
            let groupItems = searchResults.groupThreads.compactMap { groupThread -> GroupConversationItem? in
                guard
                    self.threadFilter(groupThread),
                    groupThread.canSendChatMessagesToThread(ignoreAnnouncementOnly: true)
                else {
                    return nil
                }

                let isThreadBlocked = SSKEnvironment.shared.blockingManagerRef.isThreadBlocked(
                    groupThread,
                    transaction: transaction,
                )

                if isThreadBlocked {
                    return nil
                }

                return self.buildGroupItem(
                    groupThread,
                    isBlocked: isThreadBlocked,
                    transaction: transaction,
                )
            }

            let contactItems = searchResults.contactResults.map { contactResult -> ContactConversationItem in
                return self.buildContactItem(
                    contactResult.recipientAddress,
                    isBlocked: false,
                    transaction: transaction,
                )
            }

            let storyItems = StoryConversationItem.buildItems(
                from: searchResults.storyThreads,
                excludeHiddenContexts: false,
                blockingManager: SSKEnvironment.shared.blockingManagerRef,
                transaction: transaction,
            )

            return ConversationCollection(
                contactConversations: contactItems,
                recentConversations: [],
                groupConversations: groupItems,
                storyConversations: storyItems,
                isSearchResults: true,
            )
        }
    }

    public func conversation(for indexPath: IndexPath) -> ConversationItem? {
        conversationCollection.conversation(for: indexPath)
    }

    public func conversation(for thread: TSThread) -> ConversationItem? {
        conversationCollection.conversation(for: thread)
    }

    // MARK: - Button Actions

    private func onTouchCancelButton() {
        pickerDelegate?.conversationPickerDidCancel(self)
    }

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

        self.conversationCollection = buildConversationCollection(sectionOptions: sectionOptions)
    }

    private func updateTableContents(shouldReload: Bool = true) {
        AssertIsOnMainThread()

        self.defaultSeparatorInsetLeading = (
            OWSTableViewController2.cellHInnerMargin +
                CGFloat(ContactCellView.avatarSizeClass.diameter) +
                ContactCellView.avatarTextHSpacing,
        )

        let conversationCollection = self.conversationCollection

        let contents = OWSTableContents()

        var hasContents = false

        // Media Preview Section
        do {
            let section = OWSTableSection()
            if
                !conversationCollection.isSearchResults,
                sectionOptions.contains(.mediaPreview),
                !attachments.isEmpty
            {
                addMediaPreview(to: section, attachments: attachments)
            } else if
                !conversationCollection.isSearchResults,
                sectionOptions.contains(.mediaPreview),
                let textAttachment
            {
                addMediaPreview(to: section, textAttachment: textAttachment)
            }
            contents.add(section)
        }

        // Stories Section
        do {
            let section = OWSTableSection()
            if StoryManager.areStoriesEnabled, sectionOptions.contains(.stories), !conversationCollection.storyConversations.isEmpty {
                section.customHeaderView = NewStoryHeaderView(
                    title: Strings.storiesSection,
                    showsNewStoryButton: !conversationCollection.isSearchResults,
                    delegate: self,
                )

                if conversationCollection.isSearchResults {
                    addConversations(to: section, conversations: conversationCollection.storyConversations)
                } else {
                    addExpandableConversations(
                        to: section,
                        sectionIndex: .stories,
                        conversations: conversationCollection.storyConversations,
                        maxConversationsToRender: maxStoryConversationsToRender,
                        isExpanded: isStorySectionExpanded,
                        markAsExpanded: { [weak self] in self?.isStorySectionExpanded = true },
                    )
                }
                hasContents = true
            }
            contents.add(section)
        }

        // Recents Section
        do {
            let section = OWSTableSection()
            if sectionOptions.contains(.recents), !conversationCollection.recentConversations.isEmpty {
                if !shouldHideRecentConversationsTitle || sectionOptions == .recents {
                    section.headerTitle = Strings.recentsSection
                }
                addConversations(to: section, conversations: conversationCollection.recentConversations)
                hasContents = true
            }
            contents.add(section)
        }

        // Contacts Section
        do {
            let section = OWSTableSection()
            if sectionOptions.contains(.contacts), !conversationCollection.contactConversations.isEmpty {
                if sectionOptions != .contacts {
                    section.headerTitle = Strings.signalContactsSection
                }
                addConversations(to: section, conversations: conversationCollection.contactConversations)
                hasContents = true
            }
            contents.add(section)
        }

        // Groups Section
        do {
            let section = OWSTableSection()
            if sectionOptions.contains(.groups), !conversationCollection.groupConversations.isEmpty {
                if sectionOptions != .groups {
                    section.headerTitle = Strings.groupsSection
                }
                addConversations(to: section, conversations: conversationCollection.groupConversations)
                hasContents = true
            }
            contents.add(section)
        }

        // "No matches" Section
        if
            conversationCollection.isSearchResults,
            !hasContents
        {
            let section = OWSTableSection()
            section.add(.label(withText: OWSLocalizedString(
                "CONVERSATION_SEARCH_NO_RESULTS",
                comment: "keyboard toolbar label when no messages match the search string",
            )))
            contents.add(section)
        }

        setContents(contents, shouldReload: shouldReload)
        restoreSelection()
    }

    private func addConversations(to section: OWSTableSection, conversations: [ConversationItem]) {
        for conversation in conversations {
            addConversationPickerCell(to: section, for: conversation)
        }
    }

    /// This must be retained for as long as we want to be able
    /// to display recipient context menus in this view controller.
    private lazy var recipientContextMenuHelper = {
        return RecipientContextMenuHelper(
            databaseStorage: SSKEnvironment.shared.databaseStorageRef,
            blockingManager: SSKEnvironment.shared.blockingManagerRef,
            recipientHidingManager: DependenciesBridge.shared.recipientHidingManager,
            accountManager: DependenciesBridge.shared.tsAccountManager,
            contactsManager: SSKEnvironment.shared.contactManagerRef,
            fromViewController: self,
        )
    }()

    private func addConversationPickerCell(to section: OWSTableSection, for item: ConversationItem) {
        var contextMenuActionProvider: UIContextMenuActionProvider?
        if case let .contact(address) = item.messageRecipient {
            contextMenuActionProvider = recipientContextMenuHelper.actionProvider(address: address)
        }
        section.add(OWSTableItem(
            dequeueCellBlock: { tableView in
                guard let cell = tableView.dequeueReusableCell(withIdentifier: ConversationPickerCell.reuseIdentifier) as? ConversationPickerCell else {
                    owsFailDebug("Missing cell.")
                    return UITableViewCell()
                }
                SSKEnvironment.shared.databaseStorageRef.read { transaction in
                    cell.configure(conversationItem: item, transaction: transaction)
                }
                return cell
            },
            actionBlock: { [weak self] in
                self?.didToggleSelection(conversation: item)
            },
            contextMenuActionProvider: contextMenuActionProvider,
        ))
    }

    private func addMediaPreview(
        to section: OWSTableSection,
        attachments: [PreviewableAttachment],
    ) {
        guard let firstAttachment = attachments.first else {
            owsFailDebug("Cannot add media preview section without attachments")
            return
        }

        guard let mediaPreview = makeMediaPreview(firstAttachment) else {
            return
        }
        let container = addPrimaryMediaPreviewView(mediaPreview, to: section)

        if
            let secondAttachment = attachments.dropFirst().first,
            let secondMediaPreview = makeMediaPreview(secondAttachment)
        {
            let mediaPreviewBorder = UIView()
            mediaPreviewBorder.backgroundColor = self.tableBackgroundColor
            mediaPreviewBorder.layer.masksToBounds = true
            mediaPreviewBorder.layer.cornerRadius = mediaPreview.layer.cornerRadius
            container.insertSubview(mediaPreviewBorder, belowSubview: mediaPreview)

            mediaPreviewBorder.autoPinEdges(toEdgesOf: mediaPreview, with: .init(margin: -3))

            secondMediaPreview.layer.masksToBounds = true
            secondMediaPreview.layer.cornerRadius = 18

            container.insertSubview(secondMediaPreview, belowSubview: mediaPreviewBorder)
            secondMediaPreview.autoVCenterInSuperview()
            secondMediaPreview.autoConstrainAttribute(.vertical, to: .vertical, of: mediaPreview, withOffset: -26)
            secondMediaPreview.autoSetDimensions(to: mediaPreviewSize.applying(.scale(0.85)))

            secondMediaPreview.transform = .identity.rotated(by: (CurrentAppContext().isRTL ? 15 : -15) * CGFloat.pi / 180)
        }
    }

    private func makeMediaPreview(_ attachment: PreviewableAttachment) -> UIView? {
        if attachment.isVideo || attachment.isImage {
            let mediaPreview = MediaMessageView(attachment: attachment, contentMode: .scaleAspectFill)
            mediaPreview.layer.masksToBounds = true
            mediaPreview.layer.cornerRadius = 18
            return mediaPreview
        }
        return nil
    }

    private func addMediaPreview(
        to section: OWSTableSection,
        textAttachment: UnsentTextAttachment,
    ) {
        let previewView = TextAttachmentView(attachment: textAttachment).asThumbnailView()
        previewView.layer.masksToBounds = true
        previewView.layer.cornerRadius = 18
        addPrimaryMediaPreviewView(previewView, to: section)
    }

    @discardableResult
    private func addPrimaryMediaPreviewView(
        _ previewView: UIView,
        to section: OWSTableSection,
    ) -> UIView {
        let container = UIView()
        container.preservesSuperviewLayoutMargins = true

        container.addSubview(previewView)
        previewView.autoPinEdge(toSuperviewEdge: .top, withInset: 3)
        previewView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 3)
        previewView.autoHCenterInSuperview()
        previewView.autoSetDimensions(to: mediaPreviewSize)

        section.customHeaderView = container
        return container
    }

    private var mediaPreviewSize: CGSize {
        if UIDevice.current.isShorterThaniPhoneX {
            return .init(width: 90, height: 160)
        } else {
            return .init(width: 140, height: 248)
        }
    }

    private func addExpandableConversations(
        to section: OWSTableSection,
        sectionIndex: ConversationPickerSection,
        conversations: [ConversationItem],
        maxConversationsToRender: Int,
        isExpanded: Bool,
        markAsExpanded: @escaping () -> Void,
    ) {
        var conversationsToRender = conversations
        let hasMoreConversations = !isExpanded && conversationsToRender.count > maxConversationsToRender
        if hasMoreConversations {
            conversationsToRender = Array(conversationsToRender.prefix(maxConversationsToRender - 1))
        }

        for conversation in conversationsToRender {
            addConversationPickerCell(to: section, for: conversation)
        }

        if hasMoreConversations {
            let expandedConversationIndices = (conversationsToRender.count..<conversations.count).map {
                IndexPath(row: $0, section: sectionIndex.rawValue)
            }

            section.add(OWSTableItem(
                customCellBlock: {
                    let cell = OWSTableItem.newCell()
                    cell.preservesSuperviewLayoutMargins = true
                    cell.contentView.preservesSuperviewLayoutMargins = true

                    let iconView = OWSTableItem.buildIconInCircleView(
                        icon: .groupInfoShowAllMembers,
                        iconSize: AvatarBuilder.smallAvatarSizePoints,
                        innerIconSize: 20,
                        iconTintColor: Theme.primaryTextColor,
                    )

                    let rowLabel = UILabel()
                    rowLabel.text = CommonStrings.seeAllButton
                    rowLabel.textColor = Theme.primaryTextColor
                    rowLabel.font = OWSTableItem.primaryLabelFont
                    rowLabel.lineBreakMode = .byTruncatingTail

                    let contentRow = UIStackView(arrangedSubviews: [iconView, rowLabel])
                    contentRow.spacing = ContactCellView.avatarTextHSpacing

                    cell.contentView.addSubview(contentRow)
                    contentRow.autoPinWidthToSuperviewMargins()
                    contentRow.autoPinHeightToSuperview(withMargin: 7)

                    return cell
                },
                actionBlock: { [weak self] in
                    guard let self else { return }

                    markAsExpanded()

                    if !expandedConversationIndices.isEmpty, let firstIndex = expandedConversationIndices.first {
                        self.tableView.beginUpdates()

                        // Delete the "See All" row.
                        self.tableView.deleteRows(at: [IndexPath(row: firstIndex.row, section: firstIndex.section)], with: .top)

                        // Insert the new rows.
                        self.tableView.insertRows(at: expandedConversationIndices, with: .top)

                        self.updateTableContents(shouldReload: false)
                        self.tableView.endUpdates()
                    } else {
                        self.updateTableContents()
                    }
                },
            ))
        }
    }

    override public func tableView(_ tableView: UITableView, willDisplay cell: UITableViewCell, forRowAt indexPath: IndexPath) {
        super.tableView(tableView, willDisplay: cell, forRowAt: indexPath)

        guard let conversation = conversation(for: indexPath) else {
            return
        }
        if selection.isSelected(conversation: conversation) {
            cell.setSelected(true, animated: false)
            tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
        } else {
            cell.setSelected(false, animated: false)
            tableView.deselectRow(at: indexPath, animated: false)
        }
    }

    override public func tableView(_ tableView: UITableView, willSelectRowAt indexPath: IndexPath) -> IndexPath? {
        guard let indexPath = super.tableView(tableView, willSelectRowAt: indexPath) else {
            return nil
        }

        guard selection.conversations.count < kMaxPickerSelection else {
            showTooManySelectedToast()
            return nil
        }

        guard let conversation = conversation(for: indexPath) else {
            owsFailDebug("item was unexpectedly nil")
            return nil
        }

        guard !conversation.isBlocked else {
            showUnblockUI(conversation: conversation)
            return nil
        }

        if
            let maxVideoAttachmentDuration,
            let durationLimit = conversation.videoAttachmentDurationLimit,
            durationLimit < maxVideoAttachmentDuration
        {
            // Show a tooltip the first time this happens, but still let the
            // user select.
            showVideoSegmentingTooltip(on: indexPath)
        } else {
            // dismiss the tooltip when selecting.
            currentTooltip = nil
        }

        return indexPath
    }

    override public func tableView(_ tableView: UITableView, didDeselectRowAt indexPath: IndexPath) {
        super.tableView(tableView, didDeselectRowAt: indexPath)

        // dismiss the tooltip when unselecting
        currentTooltip = nil
    }

    private func showUnblockUI(conversation: ConversationItem) {
        switch conversation.messageRecipient {
        case .contact(let address):
            BlockListUIUtils.showUnblockAddressActionSheet(
                address,
                from: self,
            ) { isStillBlocked in
                AssertIsOnMainThread()

                guard !isStillBlocked else {
                    return
                }

                self.conversationCollection = self.buildConversationCollection(sectionOptions: self.sectionOptions)
            }
        case .group(let groupThreadId):
            guard
                let groupThread = SSKEnvironment.shared.databaseStorageRef.read(block: { transaction in
                    return TSGroupThread.fetchGroupThreadViaCache(uniqueId: groupThreadId, transaction: transaction)
                })
            else {
                owsFailDebug("Missing group thread for blocked thread")
                return
            }
            BlockListUIUtils.showUnblockThreadActionSheet(
                groupThread,
                from: self,
            ) { isStillBlocked in
                AssertIsOnMainThread()

                guard !isStillBlocked else {
                    return
                }

                self.conversationCollection = self.buildConversationCollection(sectionOptions: self.sectionOptions)
            }
        case .privateStory:
            owsFailDebug("Unexpectedly attempted to show unblock UI for story thread")
        }
    }

    fileprivate func didToggleSelection(conversation: ConversationItem) {
        AssertIsOnMainThread()

        if selection.isSelected(conversation: conversation) {
            didDeselect(conversation: conversation)
        } else {
            didSelect(conversation: conversation)
            searchController.searchBar.resignFirstResponder()
        }
    }

    private func didSelect(conversation: ConversationItem) {
        AssertIsOnMainThread()

        let isBlocked: Bool = SSKEnvironment.shared.databaseStorageRef.read { transaction in
            guard let thread = conversation.getExistingThread(transaction: transaction) else {
                return false
            }
            return !thread.canSendChatMessagesToThread(ignoreAnnouncementOnly: false)
        }
        guard !isBlocked else {
            restoreSelection()
            showBlockedByAnnouncementOnlyToast()
            return
        }

        if let storyConversationItem = conversation as? StoryConversationItem {
            if
                !isStorySectionExpanded,
                let index = conversationCollection.storyConversations.firstIndex(where: {
                    ($0 as? StoryConversationItem)?.threadId == storyConversationItem.threadId
                }),
                index >= maxStoryConversationsToRender - 1
            {
                // Expand so we can see the selection.
                isStorySectionExpanded = true
                updateTableContents(shouldReload: false)
            }

            if
                storyConversationItem.isMyStory,
                SSKEnvironment.shared.databaseStorageRef.read(block: { !StoryManager.hasSetMyStoriesPrivacy(transaction: $0) })
            {
                // Show first time story privacy settings if selecting my story and settings have'nt been
                // changed before.

                // Reload the row when we show the sheet, and when it goes away, so we reflect changes.
                let reloadRowBlock = { [weak self] in
                    self?.tableView.reloadData()
                    if SSKEnvironment.shared.databaseStorageRef.read(block: { StoryManager.hasSetMyStoriesPrivacy(transaction: $0) }) {
                        self?.selection.add(conversation)
                        self?.updateUIForCurrentSelection(animated: true)
                        self?.tableView.selectRow(at: IndexPath(row: 0, section: 0), animated: false, scrollPosition: .none)
                    }
                }
                let sheetController = MyStorySettingsSheetViewController(willDisappear: reloadRowBlock)
                self.present(sheetController, animated: true, completion: reloadRowBlock)
            } else {
                selection.add(conversation)
            }
        } else {
            selection.add(conversation)
        }

        updateUIForCurrentSelection(animated: true)
    }

    private func showBlockedByAnnouncementOnlyToast() {
        Logger.info("")

        let toastFormat = OWSLocalizedString(
            "CONVERSATION_PICKER_BLOCKED_BY_ANNOUNCEMENT_ONLY",
            comment: "Message indicating that only administrators can send message to an announcement-only group.",
        )

        let toastText = String.nonPluralLocalizedStringWithFormat(toastFormat, String(kMaxPickerSelection))
        showToast(message: toastText)
    }

    private func didDeselect(conversation: ConversationItem) {
        AssertIsOnMainThread()

        selection.remove(conversation)
        updateUIForCurrentSelection(animated: true)
    }

    public func updateUIForCurrentSelection(animated: Bool) {
        let conversations = selection.conversations
        let labelText = conversations.map { $0.titleWithSneakyTransaction }.joined(separator: ", ")
        footerView.setNamesText(labelText, animated: animated)
        footerView.proceedButton.isEnabled = !conversations.isEmpty
    }

    private func showTooManySelectedToast() {
        Logger.info("Showing toast for too many chats selected")

        let toastFormat = OWSLocalizedString(
            "CONVERSATION_PICKER_CAN_SELECT_NO_MORE_CONVERSATIONS_%d",
            tableName: "PluralAware",
            comment: "Momentarily shown to the user when attempting to select more conversations than is allowed. Embeds {{max number of conversations}} that can be selected.",
        )

        let toastText = String.localizedStringWithFormat(toastFormat, kMaxPickerSelection)
        showToast(message: toastText)
    }

    private func showToast(message: String) {
        Logger.info("")

        let toastController = ToastController(text: message)

        let bottomInset = (view.bounds.height - tableView.frame.maxY)
        let kToastInset: CGFloat = bottomInset + 10
        toastController.presentToastView(from: .bottom, of: view, inset: kToastInset)
    }

    private var shownTooltipTypes = Set<ConversationItemMessageType>()
    private var currentTooltip: VideoSegmentingTooltipView? {
        didSet {
            oldValue?.removeFromSuperview()
        }
    }

    private func showVideoSegmentingTooltip(on indexPath: IndexPath) {
        guard
            let conversation = self.conversation(for: indexPath),
            let cell = tableView.cellForRow(at: indexPath) as? ConversationPickerCell
        else {
            owsFailDebug("Showing a video trimming tooltop for an invalid index path")
            return
        }

        guard let text = conversation.videoAttachmentStoryLengthTooltipString else {
            return
        }

        let typeIdentifier = conversation.outgoingMessageType
        guard !shownTooltipTypes.contains(typeIdentifier) else {
            // We've already shown the tooltip for this type.
            return
        }
        shownTooltipTypes.insert(typeIdentifier)

        self.currentTooltip = VideoSegmentingTooltipView(
            fromView: tableView,
            widthReferenceView: cell,
            tailReferenceView: cell.tooltipTailReferenceView,
            text: text,
        )
    }

    override public func scrollViewDidScroll(_ scrollView: UIScrollView) {
        // dismiss the tooltip when scrolling.
        currentTooltip = nil
    }
}

private class VideoSegmentingTooltipView: TooltipView {

    let text: String

    init(
        fromView: UIView,
        widthReferenceView: UIView,
        tailReferenceView: UIView,
        text: String,
    ) {
        self.text = text
        super.init(
            fromView: fromView,
            widthReferenceView: widthReferenceView,
            tailReferenceView: tailReferenceView,
            wasTappedBlock: nil,
        )
    }

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

    override func bubbleContentView() -> UIView {
        let label = UILabel()
        label.text = text
        label.font = .dynamicTypeFootnoteClamped
        label.textColor = .ows_white
        label.numberOfLines = 0

        let containerView = UIView()

        containerView.addSubview(label)
        label.autoPinEdgesToSuperviewEdges(with: .init(hMargin: 12, vMargin: 8))

        return containerView
    }

    override var bubbleColor: UIColor { .ows_accentBlue }
    override var bubbleHSpacing: CGFloat { 28 }
    override var bubbleInsets: UIEdgeInsets { .zero }
    override var stretchesBubbleHorizontally: Bool { true }

    override var tailDirection: TooltipView.TailDirection { .up }
    override var dismissOnTap: Bool { true }
}

// MARK: -

extension ConversationPickerViewController: NewStoryHeaderDelegate {
    public func newStoryHeaderView(_ newStoryHeaderView: NewStoryHeaderView, didCreateNewStoryItems items: [StoryConversationItem]) {
        isStorySectionExpanded = true
        conversationCollection = buildConversationCollection(sectionOptions: sectionOptions)
        items.forEach { selection.add($0) }
        restoreSelection()
    }
}

// MARK: - UISearchController integration

extension ConversationPickerViewController: UISearchResultsUpdating, UISearchControllerDelegate {

    public func updateSearchResults(for searchController: UISearchController) {
        let searchText = (searchController.searchBar.text ?? "").stripped
        self.searchTask?.cancel()
        self.searchTask = Task { [self] in
            do throws(CancellationError) {
                let searchResults = try await buildSearchResults(searchText: searchText)
                let conversationCollection = await buildConversationCollection(sectionOptions: sectionOptions, searchResults: searchResults)
                if Task.isCancelled {
                    throw CancellationError()
                }
                self.conversationCollection = conversationCollection
            } catch {
                // Ignore results for cancelled searches.
            }
        }
    }

    public func willPresentSearchController(_ searchController: UISearchController) {
        pickerDelegate?.conversationPickerSearchBarActiveDidChange(self)
        restoreSelection()
    }

    public func didDismissSearchController(_ searchController: UISearchController) {
        // Clear results and notify delegate.
        conversationCollection = buildConversationCollection(sectionOptions: sectionOptions)
        pickerDelegate?.conversationPickerSearchBarActiveDidChange(self)
        restoreSelection()
    }
}

// MARK: -

extension ConversationPickerViewController: ApprovalFooterDelegate {
    public func approvalFooterDelegateDidRequestProceed(_ approvalFooterView: ApprovalFooterView) {
        tryToProceed(untrustedThreshold: presentationTime?.addingTimeInterval(-OWSIdentityManagerImpl.Constants.defaultUntrustedInterval))
    }

    private func tryToProceed(untrustedThreshold: Date?) {
        guard let pickerDelegate else {
            owsFailDebug("Missing delegate.")
            return
        }
        let conversations = selection.conversations
        guard conversations.count > 0 else {
            Logger.warn("No conversations selected.")
            return
        }

        if shouldBatchUpdateIdentityKeys {
            guard let untrustedThreshold else {
                owsFailDebug("Unexpectedly missing presentation time")
                return
            }

            let selectedRecipients = SSKEnvironment.shared.databaseStorageRef.read { transaction in
                conversations.flatMap { conversation in
                    conversation.getExistingThread(transaction: transaction)?.recipientAddresses(with: transaction) ?? []
                }
            }

            // Before continuing, prompt for any safety number changes that we have
            // learned about since the view was presented (to handle batch identity key
            // updates) or since we last checked (to handle the recursive passes
            // through this method).
            let newUntrustedThreshold = Date()
            let didHaveSafetyNumberChanges = SafetyNumberConfirmationSheet.presentIfNecessary(
                addresses: selectedRecipients,
                confirmationText: SafetyNumberStrings.confirmSendButton,
                untrustedThreshold: untrustedThreshold,
            ) { didConfirmSafetyNumberChange in
                guard didConfirmSafetyNumberChange else { return }
                self.tryToProceed(untrustedThreshold: newUntrustedThreshold)
            }

            guard !didHaveSafetyNumberChanges else { return }
        }

        pickerDelegate.conversationPickerDidCompleteSelection(self)
    }

    public func approvalMode(_ approvalFooterView: ApprovalFooterView) -> ApprovalMode {
        return approvalMode
    }

    public func approvalFooterDidBeginEditingText() {
        AssertIsOnMainThread()

        pickerDelegate?.conversationPickerDidBeginEditingText()
    }
}

// MARK: -

extension ConversationPickerViewController {
    public enum Strings {
        public static let defaultTitle = OWSLocalizedString(
            "CONVERSATION_PICKER_TITLE",
            comment: "navbar header",
        )
        fileprivate static let recentsSection = OWSLocalizedString("CONVERSATION_PICKER_SECTION_RECENTS", comment: "table section header for section containing recent conversations")
        fileprivate static let signalContactsSection = OWSLocalizedString("CONVERSATION_PICKER_SECTION_SIGNAL_CONTACTS", comment: "table section header for section containing contacts")
        fileprivate static let groupsSection = OWSLocalizedString("CONVERSATION_PICKER_SECTION_GROUPS", comment: "table section header for section containing groups")
        fileprivate static let storiesSection = OWSLocalizedString("CONVERSATION_PICKER_SECTION_STORIES", comment: "table section header for section containing stories")
    }
}

// MARK: - ConversationPickerCell

class ConversationPickerCell: ContactTableViewCell {
    override open class var reuseIdentifier: String { "ConversationPickerCell" }

    // MARK: - UITableViewCell

    override func setSelected(_ selected: Bool, animated: Bool) {
        super.setSelected(selected, animated: animated)
        applySelection()
    }

    private func applySelection() {
        selectedBadgeView.isHidden = !self.isSelected
        unselectedBadgeView.isHidden = self.isSelected
    }

    // MARK: - ContactTableViewCell

    func configure(conversationItem: ConversationItem, transaction: DBReadTransaction) {
        let configuration: ContactCellConfiguration
        switch conversationItem.messageRecipient {
        case .contact(let address):
            configuration = ContactCellConfiguration(address: address, localUserDisplayMode: .noteToSelf)
        case .group(let groupThreadId):
            guard
                let groupThread = TSGroupThread.fetchGroupThreadViaCache(
                    uniqueId: groupThreadId,
                    transaction: transaction,
                )
            else {
                owsFailDebug("Failed to find group thread")
                return
            }
            configuration = ContactCellConfiguration(groupThread: groupThread, localUserDisplayMode: .noteToSelf)
        case .privateStory(_, let isMyStory):
            if isMyStory {
                guard let localAddress = DependenciesBridge.shared.tsAccountManager.localIdentifiersWithMaybeSneakyTransaction?.aciAddress else {
                    owsFailDebug("Unexpectedly missing local address")
                    return
                }
                configuration = ContactCellConfiguration(address: localAddress, localUserDisplayMode: .asUser)
                configuration.customName = conversationItem.title(transaction: transaction)
            } else {
                guard let image = conversationItem.image else {
                    owsFailDebug("Unexpectedly missing image for private story")
                    return
                }
                configuration = ContactCellConfiguration(name: conversationItem.title(transaction: transaction), avatar: image)
            }
        }
        if conversationItem.isBlocked {
            configuration.accessoryMessage = MessageStrings.conversationIsBlocked
        } else {
            configuration.accessoryView = buildAccessoryView(disappearingMessagesConfig: conversationItem.disappearingMessagesConfig)
        }

        if let storyItem = conversationItem as? StoryConversationItem {
            configuration.attributedSubtitle = storyItem.subtitle(transaction: transaction)?.asAttributedString
            configuration.storyState = storyItem.storyState
        } else {
            configuration.storyState = nil
        }

        super.configure(configuration: configuration, transaction: transaction)

        // Apply theme.
        unselectedBadgeView.layer.borderColor = Theme.primaryIconColor.cgColor

        selectionStyle = .none
        applySelection()
    }

    var showsSelectionUI: Bool = true {
        didSet {
            selectionView.isHidden = !showsSelectionUI
        }
    }

    // MARK: - Subviews

    let selectionBadgeSize = CGSize(square: 24)

    lazy var selectionView: UIView = {
        let container = UIView()

        container.addSubview(unselectedBadgeView)
        unselectedBadgeView.autoPinEdgesToSuperviewEdges()

        container.addSubview(selectedBadgeView)
        selectedBadgeView.autoPinEdgesToSuperviewEdges()

        return container
    }()

    func buildAccessoryView(disappearingMessagesConfig: DisappearingMessagesConfigurationRecord?) -> ContactCellAccessoryView {

        selectionView.removeFromSuperview()
        let selectionWrapper = ManualLayoutView.wrapSubviewUsingIOSAutoLayout(selectionView)

        guard
            let disappearingMessagesConfig,
            disappearingMessagesConfig.isEnabled
        else {
            return ContactCellAccessoryView(
                accessoryView: selectionWrapper,
                size: selectionBadgeSize,
            )
        }

        let timerView = DisappearingTimerConfigurationView(durationSeconds: disappearingMessagesConfig.durationSeconds)
        timerView.tintColor = .ows_middleGray
        let timerSize = CGSize(square: 44)

        let stackView = ManualStackView(name: "stackView")
        let stackConfig = OWSStackView.Config(
            axis: .horizontal,
            alignment: .center,
            spacing: 0,
            layoutMargins: .zero,
        )
        let stackMeasurement = stackView.configure(
            config: stackConfig,
            subviews: [timerView, selectionWrapper],
            subviewInfos: [
                timerSize.asManualSubviewInfo,
                selectionBadgeSize.asManualSubviewInfo,
            ],
        )
        let stackSize = stackMeasurement.measuredSize
        return ContactCellAccessoryView(accessoryView: stackView, size: stackSize)
    }

    lazy var unselectedBadgeView: UIView = {
        let imageView = UIImageView(image: Theme.iconImage(.circle))
        imageView.tintColor = .ows_gray25
        return imageView
    }()

    lazy var selectedBadgeView: UIView = {
        let imageView = UIImageView(image: Theme.iconImage(.checkCircleFill))
        imageView.tintColor = Theme.accentBlueColor
        return imageView
    }()
}

// MARK: -

extension ConversationPickerViewController: ConversationPickerSelectionDelegate {
    func conversationPickerSelectionDidAdd() {
        AssertIsOnMainThread()

        pickerDelegate?.conversationPickerSelectionDidChange(self)
    }

    func conversationPickerSelectionDidRemove() {
        AssertIsOnMainThread()

        pickerDelegate?.conversationPickerSelectionDidChange(self)
    }
}

// MARK: -

protocol ConversationPickerSelectionDelegate: AnyObject {
    func conversationPickerSelectionDidAdd()
    func conversationPickerSelectionDidRemove()

    var shouldBatchUpdateIdentityKeys: Bool { get }
}

// MARK: -

public class ConversationPickerSelection {
    fileprivate weak var delegate: ConversationPickerSelectionDelegate?

    public private(set) var conversations: [ConversationItem] = []

    public init() {}

    public func add(_ conversation: ConversationItem) {
        conversations.append(conversation)
        delegate?.conversationPickerSelectionDidAdd()

        guard delegate?.shouldBatchUpdateIdentityKeys == true else { return }

        let recipients: [ServiceId] = SSKEnvironment.shared.databaseStorageRef.read { transaction in
            guard let thread = conversation.getExistingThread(transaction: transaction) else { return [] }
            return thread.recipientAddresses(with: transaction).compactMap { $0.serviceId }
        }

        Task {
            do {
                Logger.info("Batch updating identity keys for \(recipients.count) recipients")
                let identityManager = DependenciesBridge.shared.identityManager
                try await identityManager.batchUpdateIdentityKeys(for: recipients)
                Logger.info("Successfully batch updated identity keys for \(recipients.count) recipients")
            } catch {
                Logger.warn("Failed to batch update identity keys: \(error)")
            }
        }
    }

    public func remove(_ conversation: ConversationItem) {
        conversations.removeAll {
            ($0 is StoryConversationItem) == (conversation is StoryConversationItem) && $0.messageRecipient == conversation.messageRecipient
        }
        delegate?.conversationPickerSelectionDidRemove()
    }

    public func isSelected(conversation: ConversationItem) -> Bool {
        conversations.contains {
            ($0 is StoryConversationItem) == (conversation is StoryConversationItem) && $0.messageRecipient == conversation.messageRecipient
        }
    }
}

// MARK: -

private enum ConversationPickerSection: Int, CaseIterable {
    case mediaPreview
    case stories
    case recents
    case signalContacts
    case groups
    case emptySearchResults
}

// MARK: -

private struct ConversationCollection {
    static let empty: ConversationCollection = ConversationCollection(
        contactConversations: [],
        recentConversations: [],
        groupConversations: [],
        storyConversations: [],
        isSearchResults: false,
    )

    let contactConversations: [ConversationItem]
    let recentConversations: [ConversationItem]
    let groupConversations: [ConversationItem]
    let storyConversations: [ConversationItem]
    let isSearchResults: Bool

    var allConversations: [ConversationItem] {
        recentConversations + contactConversations + groupConversations + storyConversations
    }

    private func conversations(section: ConversationPickerSection) -> [ConversationItem] {
        switch section {
        case .recents:
            return recentConversations
        case .signalContacts:
            return contactConversations
        case .groups:
            return groupConversations
        case .stories:
            return storyConversations
        case .emptySearchResults:
            return []
        case .mediaPreview:
            owsFailDebug("Should not be fetching conversations for media preview section")
            return []
        }
    }

    fileprivate func indexPath(conversation: ConversationItem) -> IndexPath? {
        switch conversation.messageRecipient {
        case .contact:
            if let row = (recentConversations.map { $0.messageRecipient }).firstIndex(of: conversation.messageRecipient) {
                return IndexPath(row: row, section: ConversationPickerSection.recents.rawValue)
            } else if let row = (contactConversations.map { $0.messageRecipient }).firstIndex(of: conversation.messageRecipient) {
                return IndexPath(row: row, section: ConversationPickerSection.signalContacts.rawValue)
            } else {
                return nil
            }
        case .group:
            if conversation is StoryConversationItem {
                if let row = (storyConversations.map { $0.messageRecipient }).firstIndex(of: conversation.messageRecipient) {
                    return IndexPath(row: row, section: ConversationPickerSection.stories.rawValue)
                } else {
                    return nil
                }
            } else if let row = (recentConversations.map { $0.messageRecipient }).firstIndex(of: conversation.messageRecipient) {
                return IndexPath(row: row, section: ConversationPickerSection.recents.rawValue)
            } else if let row = (groupConversations.map { $0.messageRecipient }).firstIndex(of: conversation.messageRecipient) {
                return IndexPath(row: row, section: ConversationPickerSection.groups.rawValue)
            } else {
                return nil
            }
        case .privateStory:
            if let row = (storyConversations.map { $0.messageRecipient }).firstIndex(of: conversation.messageRecipient) {
                return IndexPath(row: row, section: ConversationPickerSection.stories.rawValue)
            } else {
                return nil
            }
        }
    }

    fileprivate func conversation(for indexPath: IndexPath) -> ConversationItem? {
        guard let section = ConversationPickerSection(rawValue: indexPath.section) else {
            owsFailDebug("section was unexpectedly nil")
            return nil
        }
        return conversations(section: section)[safe: indexPath.row]
    }

    fileprivate func conversation(for thread: TSThread) -> ConversationItem? {
        allConversations.first { item in
            if let thread = thread as? TSGroupThread, case .group(let otherThreadId) = item.messageRecipient {
                return thread.uniqueId == otherThreadId
            } else if let thread = thread as? TSContactThread, case .contact(let otherAddress) = item.messageRecipient {
                return thread.contactAddress == otherAddress
            } else {
                return false
            }
        }
    }
}

extension ConversationPickerViewController: ContactsViewHelperObserver {
    public func contactsViewHelperDidUpdateContacts() {
        /// Triggers subsequent call to `updateTableContents`.
        self.conversationCollection = self.buildConversationCollection(sectionOptions: sectionOptions)
    }
}