Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

public import LibSignalClient
import PassKit
import QuickLook
public import SignalServiceKit
public import SignalUI

extension ConversationViewController: CVComponentDelegate {

    public var wallpaperBlurProvider: WallpaperBlurProvider? { backgroundContainer }

    public var spoilerState: SpoilerRenderState { return self.viewState.spoilerState }

    public func enqueueReload() {
        self.loadCoordinator.enqueueReload()
    }

    public func enqueueReloadWithoutCaches() {
        self.loadCoordinator.enqueueReloadWithoutCaches()
    }

    // MARK: - Collapse Sets

    public func didTapCollapseSet(collapseSetId: String) {
        AssertIsOnMainThread()
        if viewState.expandedCollapseSets.contains(collapseSetId) {
            viewState.expandedCollapseSets.remove(collapseSetId)
        } else {
            viewState.expandedCollapseSets.insert(collapseSetId)
        }
        loadCoordinator.enqueueReload()
    }

    // MARK: - Double-Tap

    public func didDoubleTapTextViewItem(_ viewModel: CVItemViewModelImpl) {
        AssertIsOnMainThread()

        let controller = DoubleTapToEditOnboardingController(presentationContext: self) {
            self.messageActionsEditItem(viewModel)
        }

        controller.beginEditing(animated: true)
    }

    // MARK: - Long Press

    public func didLongPressTextViewItem(
        _ cell: CVCell,
        itemViewModel: CVItemViewModelImpl,
        shouldAllowReply: Bool,
    ) {
        AssertIsOnMainThread()

        let messageActions = MessageActions.textActions(
            itemViewModel: itemViewModel,
            shouldAllowReply: shouldAllowReply,
            delegate: self,
        )
        self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
    }

    public func didLongPressMediaViewItem(
        _ cell: CVCell,
        itemViewModel: CVItemViewModelImpl,
        shouldAllowReply: Bool,
    ) {
        AssertIsOnMainThread()

        let messageActions = MessageActions.mediaActions(
            itemViewModel: itemViewModel,
            shouldAllowReply: shouldAllowReply,
            delegate: self,
        )
        self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
    }

    public func didLongPressQuote(
        _ cell: CVCell,
        itemViewModel: CVItemViewModelImpl,
        shouldAllowReply: Bool,
    ) {
        AssertIsOnMainThread()

        let messageActions = MessageActions.quotedMessageActions(
            itemViewModel: itemViewModel,
            shouldAllowReply: shouldAllowReply,
            delegate: self,
        )
        self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
    }

    public func didLongPressSystemMessage(
        _ cell: CVCell,
        itemViewModel: CVItemViewModelImpl,
    ) {
        AssertIsOnMainThread()

        let messageActions = MessageActions.infoMessageActions(
            itemViewModel: itemViewModel,
            delegate: self,
        )
        self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
    }

    public func didLongPressSticker(
        _ cell: CVCell,
        itemViewModel: CVItemViewModelImpl,
        shouldAllowReply: Bool,
    ) {
        AssertIsOnMainThread()

        let messageActions = MessageActions.mediaActions(
            itemViewModel: itemViewModel,
            shouldAllowReply: shouldAllowReply,
            delegate: self,
        )
        self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
    }

    public func didLongPressPaymentMessage(
        _ cell: CVCell,
        itemViewModel: CVItemViewModelImpl,
        shouldAllowReply: Bool,
    ) {
        let messageActions = MessageActions.paymentActions(
            itemViewModel: itemViewModel,
            shouldAllowReply: shouldAllowReply,
            delegate: self,
        )
        self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
    }

    public func didLongPressPoll(
        _ cell: CVCell,
        itemViewModel: CVItemViewModelImpl,
        shouldAllowReply: Bool,
    ) {
        let messageActions = MessageActions.pollActions(
            itemViewModel: itemViewModel,
            shouldAllowReply: shouldAllowReply,
            delegate: self,
        )
        self.presentContextMenu(with: messageActions, focusedOn: cell, andModel: itemViewModel)
    }

    public func didChangeLongPress(_ itemViewModel: CVItemViewModelImpl) {
        AssertIsOnMainThread()

        collectionViewActiveContextMenuInteraction?.initiatingGestureRecognizerDidChange()
    }

    public func didEndLongPress(_ itemViewModel: CVItemViewModelImpl) {
        AssertIsOnMainThread()

        collectionViewActiveContextMenuInteraction?.initiatingGestureRecognizerDidEnd()
    }

    public func didCancelLongPress(_ itemViewModel: CVItemViewModelImpl) {
        AssertIsOnMainThread()

        collectionViewActiveContextMenuInteraction?.initiatingGestureRecognizerDidEnd()
    }

    // MARK: -

    public func willBecomeVisibleWithSkippedDownloads(_ message: TSMessage) {
        AssertIsOnMainThread()

        if viewState.manuallyCanceledDownloadsMessageIds.contains(message.uniqueId) {
            // Don't auto-enqueue download if the user has previously manually
            // cancelled downloads for this message.
            return
        }

        // If any of the failed or pending downloads were enqueued by a Backup
        // restore, immediately attempt to download those attachments.
        Task {
            let attachmentDownloadManager = DependenciesBridge.shared.attachmentDownloadManager
            let attachmentStore = DependenciesBridge.shared.attachmentStore
            let backupAttachmentDownloadStore = DependenciesBridge.shared.backupAttachmentDownloadStore
            let db = DependenciesBridge.shared.db

            guard let messageRowId = message.sqliteRowId else {
                owsFailDebug("Cannot increase priority for uninserted message!")
                return
            }

            let messageHasAnyEnqueuedBackupDownloads = db.read { tx in
                let referencedAttachments = attachmentStore.fetchReferencedAttachmentsOwnedByMessage(
                    messageRowId: messageRowId,
                    tx: tx,
                )

                return referencedAttachments.contains { referencedAttachment in
                    // We only auto-download on appear if we've got a cdn number to try.
                    // The user can still manual download if there isn't one (using fallback cdn).
                    guard referencedAttachment.attachment.mediaTierInfo?.cdnNumber != nil else {
                        return false
                    }
                    // Otherwise use presence in the backup download queue to indicate
                    // downloadability; this just functionally bumps the priority so the
                    // download happens immediately and unconditionally.
                    let enqueuedDownload = backupAttachmentDownloadStore.getEnqueuedDownload(
                        attachmentRowId: referencedAttachment.attachment.id,
                        thumbnail: false,
                        tx: tx,
                    )
                    switch enqueuedDownload?.state {
                    case nil, .done:
                        return false
                    case .ineligible, .ready:
                        return true
                    }
                }
            }

            if messageHasAnyEnqueuedBackupDownloads {
                await db.awaitableWrite { tx in
                    attachmentDownloadManager.enqueueDownloadOfAttachmentsForMessage(
                        message,
                        priority: .default,
                        tx: tx,
                    )
                }
            }
        }
    }

    public func didTapSkippedDownloads(_ message: TSMessage) {
        AssertIsOnMainThread()

        let db = DependenciesBridge.shared.db
        let attachmentDownloadManager = DependenciesBridge.shared.attachmentDownloadManager
        db.write { tx in
            attachmentDownloadManager.enqueueDownloadOfAttachmentsForMessage(
                message,
                priority: .userInitiated,
                tx: tx,
            )
        }
    }

    public func didCancelDownload(_ message: TSMessage, attachmentId: Attachment.IDType) {
        AssertIsOnMainThread()

        // Record that the user manually canceled download for this message.
        viewState.manuallyCanceledDownloadsMessageIds.insert(message.uniqueId)

        let db = DependenciesBridge.shared.db
        let attachmentDownloadManager = DependenciesBridge.shared.attachmentDownloadManager
        db.write { tx in
            attachmentDownloadManager.cancelDownload(
                for: attachmentId,
                tx: tx,
            )
        }
    }

    // MARK: -

    public func didTapReplyToItem(_ itemViewModel: CVItemViewModelImpl) {
        AssertIsOnMainThread()

        populateReplyForMessage(itemViewModel)
    }

    public func didTapSenderAvatar(_ interaction: TSInteraction) {
        AssertIsOnMainThread()

        guard let incomingMessage = interaction as? TSIncomingMessage else {
            owsFailDebug("not an incoming message.")
            return
        }

        showMemberActionSheet(forAddress: incomingMessage.authorAddress, withHapticFeedback: false)
    }

    public func shouldAllowReplyForItem(_ itemViewModel: CVItemViewModelImpl) -> Bool {
        AssertIsOnMainThread()

        if thread.isGroupThread, !thread.isLocalUserFullMemberOfThread {
            return false
        }
        if thread.isGroupThread, thread.isTerminatedGroup {
            return false
        }
        if self.threadViewModel.hasPendingMessageRequest {
            return false
        }
        if itemViewModel.wasRemotelyDeleted {
            return false
        }
        if itemViewModel.isSmsMessageRestoredFromBackup {
            return false
        }

        if let outgoingMessage = itemViewModel.interaction as? TSOutgoingMessage {
            if outgoingMessage.messageState == .failed {
                // Don't allow "delete" or "reply" on "failed" outgoing messages.
                return false
            } else if outgoingMessage.messageState == .sending {
                // Don't allow "delete" or "reply" on "sending" outgoing messages.
                return false
            } else if outgoingMessage.messageState == .pending {
                // Don't allow "delete" or "reply" on "sending" outgoing messages.
                return false
            }
        }

        return true
    }

    public func didTapReactions(
        reactionState: InteractionReactionState,
        message: TSMessage,
    ) {
        AssertIsOnMainThread()

        if !reactionState.hasReactions {
            owsFailDebug("missing reaction state")
            return
        }

        let detailSheet = ReactionsDetailSheet(reactionState: reactionState, message: message)
        self.present(detailSheet, animated: true, completion: nil)
        self.reactionsDetailSheet = detailSheet
    }

    public var hasPendingMessageRequest: Bool {
        AssertIsOnMainThread()

        return self.threadViewModel.hasPendingMessageRequest
    }

    public func didTapTruncatedTextMessage(_ itemViewModel: CVItemViewModelImpl) {
        AssertIsOnMainThread()

        expandTruncatedTextOrPresentLongTextView(itemViewModel)
    }

    public func didTapShowEditHistory(_ itemViewModel: CVItemViewModelImpl) {
        AssertIsOnMainThread()

        guard let message = itemViewModel.interaction as? TSMessage else {
            owsFailDebug("Invalid interaction.")
            return
        }

        let sheet = EditHistoryTableSheetViewController(
            message: message,
            threadViewModel: self.threadViewModel,
            spoilerState: viewState.spoilerState,
            editManager: self.context.editManager,
            database: SSKEnvironment.shared.databaseStorageRef,
            databaseChangeObserver: DependenciesBridge.shared.databaseChangeObserver,
        )
        sheet.delegate = self
        self.present(sheet, animated: true)
    }

    // MARK: -

    public func didTapUndownloadableMedia() {
        let toast = ToastController(
            text: OWSLocalizedString(
                "UNAVAILABLE_MEDIA_TAP_TOAST",
                comment: "Toast shown when tapping older media that can no longer be downloaded",
            ),
            image: .photoSlash,
        )
        let inset = (self.inputToolbar?.height ?? 0) + 16
        toast.presentToastView(from: .bottom, of: self.view, inset: inset)
    }

    public func didTapUndownloadableGenericFile() {
        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "FILE_UNAVAILABLE_SHEET_TITLE",
                comment: "Title for sheet shown when tapping a document/file that has expired and is unavailable for download",
            ),
            message: String.localizedStringWithFormat(
                OWSLocalizedString(
                    "UNAVAILABLE_ATTACHMENT_FILE_SHEET_MESSAGE_%d",
                    tableName: "PluralAware",
                    comment: "Message for sheet shown when tapping a document/file that has expired and is unavailable for download. Embeds {{ the number of days that files are available, e.g. '45' }}.",
                ),
                _freeTierMediaDays(),
            ),
        )
        actionSheet.addAction(.okay)
        actionSheet.isCancelable = true
        (conversationSplitViewController ?? self).present(actionSheet, animated: true)
    }

    public func didTapUndownloadableOversizeText() {
        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "OVERSIZE_TEXT_UNAVAILABLE_SHEET_TITLE",
                comment: "Title for sheet shown when tapping oversized text that has expired and is unavailable for download",
            ),
            message: String.localizedStringWithFormat(
                OWSLocalizedString(
                    "UNAVAILABLE_ATTACHMENT_OVERSIZE_TEXT_SHEET_MESSAGE_%d",
                    tableName: "PluralAware",
                    comment: "Message for sheet shown when tapping oversized text that has expired and is unavailable for download. Embeds {{ the number of days that files are available, e.g. '45' }}.",
                ),
                _freeTierMediaDays(),
            ),
        )
        actionSheet.addAction(.okay)
        actionSheet.isCancelable = true
        (conversationSplitViewController ?? self).present(actionSheet, animated: true)
    }

    public func didTapUndownloadableAudio() {
        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "AUDIO_UNAVAILABLE_SHEET_TITLE",
                comment: "Title for sheet shown when tapping a voice message that has expired and is unavailable for download",
            ),
            message: String.localizedStringWithFormat(
                OWSLocalizedString(
                    "UNAVAILABLE_ATTACHMENT_AUDIO_SHEET_MESSAGE_%d",
                    tableName: "PluralAware",
                    comment: "Message for sheet shown when tapping a voice message that has expired and is unavailable for download. Embeds {{ the number of days that files are available, e.g. '45' }}.",
                ),
                _freeTierMediaDays(),
            ),
        )
        actionSheet.addAction(.okay)
        actionSheet.isCancelable = true
        (conversationSplitViewController ?? self).present(actionSheet, animated: true)
    }

    public func didTapUndownloadableSticker() {
        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "STICKER_UNAVAILABLE_SHEET_TITLE",
                comment: "Title for sheet shown when tapping a sticker that has expired and is unavailable for download",
            ),
            message: String.localizedStringWithFormat(
                OWSLocalizedString(
                    "UNAVAILABLE_ATTACHMENT_STICKER_SHEET_MESSAGE_%d",
                    tableName: "PluralAware",
                    comment: "Message for sheet shown when tapping a sticker that has expired and is unavailable for download. Embeds {{ the number of days that files are available, e.g. '45' }}.",
                ),
                _freeTierMediaDays(),
            ),
        )
        actionSheet.addAction(.okay)
        actionSheet.isCancelable = true
        (conversationSplitViewController ?? self).present(actionSheet, animated: true)
    }

    public func didTapBrokenVideo() {
        let toastText = OWSLocalizedString(
            "VIDEO_BROKEN",
            comment: "Toast alert text shown when tapping on a video that cannot be played.",
        )
        presentToastCVC(toastText)
    }

    private func _freeTierMediaDays() -> UInt64 {
        let db = DependenciesBridge.shared.db
        let subscriptionConfigManager = DependenciesBridge.shared.subscriptionConfigManager

        return db.read { tx in
            subscriptionConfigManager.backupConfigurationOrDefault(tx: tx).freeTierMediaDays
        }
    }

    // MARK: - Messages

    public func didTapBodyMedia(
        itemViewModel: CVItemViewModelImpl,
        attachmentStream: ReferencedAttachmentStream,
        imageView: UIView,
    ) {
        AssertIsOnMainThread()

        dismissKeyBoard()

        guard
            let pageVC = MediaPageViewController(
                initialMediaAttachment: attachmentStream,
                thread: self.thread,
                spoilerState: self.viewState.spoilerState,
            )
        else {
            return
        }

        self.present(pageVC, animated: true, completion: nil)
    }

    public func didTapGenericAttachment(_ attachment: CVComponentGenericAttachment) -> CVAttachmentTapAction {
        AssertIsOnMainThread()

        let timestamp = Date().ows_millisecondsSince1970
        let attachmentId = attachment.attachmentId
        Task {
            await DependenciesBridge.shared.db.awaitableWrite { tx in
                DependenciesBridge.shared.attachmentStore.markViewedFullscreen(
                    attachmentId: attachmentId,
                    timestamp: timestamp,
                    tx: tx,
                )
            }
        }

        if
            PKAddPassesViewController.canAddPasses(),
            let pkPass = attachment.representedPKPass(),
            let addPassesVC = PKAddPassesViewController(pass: pkPass)
        {
            self.present(addPassesVC, animated: true, completion: nil)
            return .handledByDelegate
        } else if let previewController = attachment.createQLPreviewController() {
            self.present(previewController, animated: true, completion: nil)
            return .handledByDelegate
        } else {
            return .default
        }
    }

    public func didTapQuotedReply(_ quotedReply: QuotedReplyModel) {
        AssertIsOnMainThread()
        owsAssertDebug(quotedReply.originalMessageAuthorAddress.isValid)

        if quotedReply.originalContent.isStory {
            guard
                let quotedStoryAuthorAci = quotedReply.originalMessageAuthorAddress.aci,
                let timestamp = quotedReply.originalMessageTimestamp
            else {
                return
            }
            guard
                let quotedStory = SSKEnvironment.shared.databaseStorageRef.read(
                    block: { StoryFinder.story(timestamp: timestamp, author: quotedStoryAuthorAci, transaction: $0) },
                ) else { return }

            let context: StoryContext
            if
                let contactServiceId = self.threadViewModel.contactAddress?.serviceId,
                quotedStory.authorAddress.isLocalAddress,
                case let .outgoing(recipientStates) = quotedStory.manifest,
                let recipientState = recipientStates[contactServiceId],
                let validContext = recipientState.firstValidContext()
            {
                // If its an outgoing story from the local user and the contact
                // is in the recipient states, set the context to the first valid
                // context they are a part of.
                context = validContext
            } else {
                // Else fall back to thinking this is an incoming story from this contact.
                context = .authorAci(quotedStory.authorAci)
            }

            let vc = StoryPageViewController(
                context: context,
                spoilerState: spoilerState,
                loadMessage: quotedStory,
            )
            presentFullScreen(vc, animated: true)
        } else {
            scrollToQuotedMessage(quotedReply, isAnimated: true)
        }
    }

    public func didTapLinkPreview(url: URL) {
        AssertIsOnMainThread()
        self.handleUrl(url)
    }

    func handleUrl(_ url: URL) {
        if StickerPackInfo.isStickerPackShare(url) {
            if let stickerPackInfo = StickerPackInfo.parseStickerPackShare(url) {
                didTapStickerPack(stickerPackInfo)
            } else {
                owsFailDebug("Could not parse sticker pack share URL: \(url)")
            }
            return
        }

        if GroupManager.isPossibleGroupInviteLink(url) {
            didTapGroupInviteLink(url: url)
            return
        }

        if SignalProxy.isValidProxyLink(url) {
            didTapProxyLink(url: url)
            return
        }

        if SignalDotMePhoneNumberLink.isPossibleUrl(url) {
            cvc_didTapSignalMeLink(url: url)
            return
        }

        if let usernameLink = Usernames.UsernameLink(usernameLinkUrl: url) {
            didTapUsernameLink(usernameLink: usernameLink)
            return
        }

        if let callLink = CallLink(url: url) {
            didTapCallLink(callLink)
            return
        }

        UIApplication.shared.open(url, options: [:], completionHandler: nil)
    }

    public func didTapContactShare(_ contactShare: ContactShareViewModel) {
        AssertIsOnMainThread()

        let view = ContactViewController(contactShare: contactShare)
        navigationController?.pushViewController(view, animated: true)
    }

    public func didTapSendMessage(to phoneNumbers: [String]) {
        AssertIsOnMainThread()

        contactShareViewHelper.sendMessage(to: phoneNumbers, from: self)
    }

    public func didTapSendInvite(toContactShare contactShare: ContactShareViewModel) {
        AssertIsOnMainThread()

        contactShareViewHelper.showInviteContact(contactShare: contactShare, from: self)
    }

    public func didTapAddToContacts(contactShare: ContactShareViewModel) {
        AssertIsOnMainThread()

        contactShareViewHelper.showAddToContactsPrompt(contactShare: contactShare, from: self)
    }

    public func didTapStickerPack(_ stickerPackInfo: StickerPackInfo) {
        AssertIsOnMainThread()

        let packView = StickerPackViewController(stickerPackInfo: stickerPackInfo)
        packView.present(from: self, animated: true)
    }

    public func didTapPayment(_ payment: PaymentsHistoryItem) {
        AssertIsOnMainThread()

        let paymentsDetailViewController = PaymentsDetailViewController(
            paymentItem: payment,
        )
        navigationController?.pushViewController(paymentsDetailViewController, animated: true)
    }

    public func didTapGroupInviteLink(url: URL) {
        AssertIsOnMainThread()
        owsAssertDebug(GroupManager.isPossibleGroupInviteLink(url))

        GroupInviteLinksUI.openGroupInviteLink(url, fromViewController: self)
    }

    public func didTapProxyLink(url: URL) {
        AssertIsOnMainThread()
        guard let vc = ProxyLinkSheetViewController(url: url) else { return }
        present(vc, animated: true)
    }

    func didTapCallLink(_ callLink: CallLink) {
        AssertIsOnMainThread()
        GroupCallViewController.presentLobby(for: callLink)
    }

    public func cvc_didTapSignalMeLink(url: URL) {
        SignalDotMePhoneNumberLink.openChat(url: url, fromViewController: self)
    }

    public func didTapUsernameLink(usernameLink: Usernames.UsernameLink) {
        Task {
            guard
                let (_, aci) = await UsernameQuerier().queryForUsernameLink(
                    link: usernameLink,
                    fromViewController: self,
                )
            else {
                return
            }

            SignalApp.shared.presentConversationForAddress(
                SignalServiceAddress(aci),
                animated: true,
            )
        }
    }

    public func didTapShowMessageDetail(_ itemViewModel: CVItemViewModelImpl) {
        showDetailView(itemViewModel)
    }

    public func prepareMessageDetailForInteractivePresentation(_ itemViewModel: CVItemViewModelImpl) {
        prepareDetailViewForInteractivePresentation(itemViewModel)
    }

    public func beginCellAnimation(maximumDuration: TimeInterval) -> EndCellAnimation {
        AssertIsOnMainThread()

        if maximumDuration > 0.8 {
            owsFailDebug("Animation is too long, skipping.")
            return {}
        }

        let identifier = UUID()
        viewState.beginCellAnimation(identifier: identifier)

        var timer: Timer?
        let endAnimation = { [weak self] in
            AssertIsOnMainThread()
            guard let self else { return }

            timer?.invalidate()
            self.viewState.endCellAnimation(identifier: identifier)
            self.loadCoordinator.enqueueReload()
        }

        // Automatically unblock loads once the max duration is reached, even
        // if the cell didn't tell us it finished.
        timer = Timer.scheduledTimer(withTimeInterval: maximumDuration, repeats: false) { _ in
            endAnimation()
        }

        return endAnimation
    }

    // MARK: - System Cell

    public func didTapPreviouslyVerifiedIdentityChange(_ address: SignalServiceAddress) {
        AssertIsOnMainThread()

        showFingerprint(address: address)
    }

    public func showFingerprint(address: SignalServiceAddress) {
        AssertIsOnMainThread()

        // Ensure keyboard isn't hiding the "safety numbers changed" interaction when we
        // return from FingerprintViewController.
        dismissKeyBoard()

        let addressAci: Aci? = address.aci ?? {
            guard let phoneNumber = address.phoneNumber else {
                return nil
            }
            // Reload the address from disk if we lack an ACI.
            let recipientDatabaseTable = DependenciesBridge.shared.recipientDatabaseTable
            return SSKEnvironment.shared.databaseStorageRef.read { tx in
                return recipientDatabaseTable.fetchRecipient(phoneNumber: phoneNumber, transaction: tx)?.aci
            }
        }()

        FingerprintViewController.present(for: addressAci, from: self)
    }

    public func didTapUnverifiedIdentityChange(_ address: SignalServiceAddress) {
        AssertIsOnMainThread()
        owsAssertDebug(address.isValid)

        dismissKeyBoard()

        let headerImageView = UIImageView(image: UIImage(named: "safety-number-change"))
        let headerView = UIView()
        headerView.addSubview(headerImageView)
        headerImageView.autoPinEdge(toSuperviewEdge: .top, withInset: 22)
        headerImageView.autoPinEdge(toSuperviewEdge: .bottom)
        headerImageView.autoHCenterInSuperview()
        headerImageView.autoSetDimension(.width, toSize: 200)
        headerImageView.autoSetDimension(.height, toSize: 110)

        let displayName = SSKEnvironment.shared.databaseStorageRef.read { tx in SSKEnvironment.shared.contactManagerRef.displayName(for: address, tx: tx).resolvedValue() }
        let messageFormat = OWSLocalizedString(
            "UNVERIFIED_SAFETY_NUMBER_CHANGE_DESCRIPTION_FORMAT",
            comment: "Description for the unverified safety number change. Embeds {name of contact with identity change}",
        )

        let actionSheet = ActionSheetController(
            title: nil,
            message: String.nonPluralLocalizedStringWithFormat(messageFormat, displayName),
        )
        actionSheet.customHeader = headerView

        actionSheet.addAction(ActionSheetAction(
            title: OWSLocalizedString(
                "UNVERIFIED_SAFETY_NUMBER_VERIFY_ACTION",
                comment: "Action to verify a safety number after it has changed",
            ),
            style: .default,
        ) { [weak self] _ in
            self?.showFingerprint(address: address)
        })

        actionSheet.addAction(ActionSheetAction(
            title: CommonStrings.notNowButton,
            style: .cancel,
            handler: nil,
        ))
        presentActionSheet(actionSheet)
    }

    public func didTapCorruptedMessage(_ message: TSErrorMessage) {
        AssertIsOnMainThread()

        let threadName = SSKEnvironment.shared.databaseStorageRef.read { transaction in
            SSKEnvironment.shared.contactManagerRef.displayName(for: self.thread, transaction: transaction)
        }
        let alertMessage = String.nonPluralLocalizedStringWithFormat(
            OWSLocalizedString(
                "CORRUPTED_SESSION_DESCRIPTION",
                comment: "ActionSheet title",
            ),
            threadName,
        )
        let alert = ActionSheetController(title: nil, message: alertMessage)

        alert.addAction(OWSActionSheets.cancelAction)

        alert.addAction(ActionSheetAction(
            title: OWSLocalizedString(
                "FINGERPRINT_SHRED_KEYMATERIAL_BUTTON",
                comment: "",
            ),
            style: .default,
        ) { [weak self] _ in
            guard let self else { return }
            guard let contactThread = self.thread as? TSContactThread else {
                // Corrupt Message errors only appear in contact threads.
                Logger.error("Unexpected request to reset session in group thread.")
                return
            }

            SSKEnvironment.shared.databaseStorageRef.asyncWrite { transaction in
                SSKEnvironment.shared.smJobQueuesRef.sessionResetJobQueue.add(contactThread: contactThread, transaction: transaction)
            }
        })

        dismissKeyBoard()
        self.presentActionSheet(alert)
    }

    public func didTapSessionRefreshMessage(_ message: TSErrorMessage) {
        dismissKeyBoard()

        OWSActionSheets.showContactSupportActionSheet(
            title: OWSLocalizedString(
                "SESSION_REFRESH_ALERT_TITLE",
                comment: "Title for the session refresh alert",
            ),
            message: OWSLocalizedString(
                "SESSION_REFRESH_ALERT_MESSAGE",
                comment: "Description for the session refresh alert",
            ),
            emailFilter: .custom("Signal iOS Session Refresh"),
            fromViewController: self,
        )
    }

    // See: resendGroupUpdate
    public func didTapResendGroupUpdateForErrorMessage(_ message: TSErrorMessage) {
        AssertIsOnMainThread()

        guard let groupId = try? (self.thread as? TSGroupThread)?.groupIdentifier else {
            owsFailDebug("Invalid thread.")
            return
        }
        Task {
            _ = await GroupManager.sendGroupUpdateMessage(groupId: groupId)
            Logger.info("Group updated, removing group creation error.")

            await SSKEnvironment.shared.databaseStorageRef.awaitableWrite { tx in
                DependenciesBridge.shared.interactionDeleteManager
                    .delete(message, sideEffects: .default(), tx: tx)
            }
        }
    }

    public func didTapShowFingerprint(_ address: SignalServiceAddress) {
        AssertIsOnMainThread()

        showFingerprint(address: address)
    }

    // MARK: -

    public func didTapIndividualCall(_ call: TSCall) {
        AssertIsOnMainThread()
        owsAssertDebug(self.inputToolbar != nil)

        guard let contactThread = thread as? TSContactThread else {
            owsFailDebug("Invalid thread.")
            return
        }

        let displayName = SSKEnvironment.shared.databaseStorageRef.read { tx in
            return SSKEnvironment.shared.contactManagerRef.displayName(for: contactThread.contactAddress, tx: tx).resolvedValue()
        }

        let alert = ActionSheetController(
            title: CallStrings.callBackAlertTitle,
            message: String.nonPluralLocalizedStringWithFormat(
                CallStrings.callBackAlertMessageFormat,
                displayName,
            ),
        )

        alert.addAction(ActionSheetAction(
            title: CallStrings.callBackAlertCallButton,
            style: .default,
        ) { [weak self] _ in
            guard let self else { return }
            switch call.offerType {
            case .audio:
                self.startIndividualAudioCall()
            case .video:
                self.startIndividualVideoCall()
            }
        })
        alert.addAction(OWSActionSheets.cancelAction)

        inputToolbar?.clearDesiredKeyboard()
        dismissKeyBoard()
        self.presentActionSheet(alert)
    }

    public func didTapLearnMoreMissedCallFromBlockedContact(_ call: TSCall) {
        AssertIsOnMainThread()

        guard let contactThread = thread as? TSContactThread else {
            owsFailDebug("Invalid thread.")
            return
        }
        let address = contactThread.contactAddress
        let displayName = SSKEnvironment.shared.databaseStorageRef.read { tx in SSKEnvironment.shared.contactManagerRef.displayName(for: address, tx: tx).resolvedValue() }

        let alert = ActionSheetController(
            title: String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "MISSED_CALL_BLOCKED_SYSTEM_SETTINGS_SHEET_TITLE",
                    comment: "Title for sheet shown when the user taps a missed call from a contact blocked in iOS settings. Embeds {{ Contact's name }}",
                ),
                displayName,
            ),
            message: OWSLocalizedString(
                "MISSED_CALL_BLOCKED_SYSTEM_SETTINGS_SHEET_MESSAGE",
                comment: "Message for sheet shown when the user taps a missed call from a contact blocked in iOS settings.",
            ),
        )

        alert.addAction(
            ActionSheetAction(
                title: OWSLocalizedString(
                    "MISSED_CALL_BLOCKED_SYSTEM_SETTINGS_SHEET_BLOCK_ACTION",
                    comment: "Action to block contact in Signal for sheet shown when the user taps a missed call from a contact blocked in iOS settings.",
                ),
                style: .destructive,
            ) { [weak self] _ in
                guard self != nil else { return }
                SSKEnvironment.shared.databaseStorageRef.write { tx in
                    SSKEnvironment.shared.blockingManagerRef.addBlockedAddress(
                        address,
                        blockMode: .local,
                        transaction: tx,
                    )
                }
            },
        )
        alert.addAction(OWSActionSheets.okayAction)

        inputToolbar?.clearDesiredKeyboard()
        dismissKeyBoard()
        self.presentActionSheet(alert)
    }

    public func didTapGroupCall() {
        AssertIsOnMainThread()

        showGroupLobbyOrActiveCall()
    }

    public func didTapPendingOutgoingMessage(_ message: TSOutgoingMessage) {
        AssertIsOnMainThread()
        if SSKEnvironment.shared.spamChallengeResolverRef.isPausingMessages {
            SpamCaptchaViewController.presentActionSheet(from: self)
            DependenciesBridge.shared.db.write { tx in
                SupportKeyValueStore().setLastChallengeDate(value: Date(), transaction: tx)
            }
        } else {
            SSKEnvironment.shared.spamChallengeResolverRef.retryPausedMessagesIfReady()
        }

    }

    public func didTapFailedMessage(_ message: TSMessage) {
        AssertIsOnMainThread()

        let promptBuilder = ResendMessagePromptBuilder(
            databaseStorage: SSKEnvironment.shared.databaseStorageRef,
            messageSenderJobQueue: SSKEnvironment.shared.messageSenderJobQueueRef,
        )
        dismissKeyBoard()

        self.present(promptBuilder.build(for: message, isTerminatedGroup: thread.isTerminatedGroup), animated: true)
    }

    public func didTapGroupMigrationLearnMore() {
        AssertIsOnMainThread()
        presentFormSheet(
            LegacyGroupLearnMoreViewController(mode: .explainNewGroups),
            animated: true,
        )
    }

    public func didTapGroupInviteLinkPromotion(groupModel: TSGroupModel) {
        AssertIsOnMainThread()

        showGroupLinkPromotionActionSheet()
    }

    public func didTapViewGroupDescription(newGroupDescription: String) {
        AssertIsOnMainThread()
        guard let groupModel = (thread as? TSGroupThread)?.groupModel else {
            owsFailDebug("Unexpectedly missing group model.")
            return
        }

        let vc = GroupDescriptionViewController(
            groupModel: groupModel,
            groupDescriptionCurrent: newGroupDescription,
            options: [],
        )
        let navigationController = OWSNavigationController(rootViewController: vc)
        self.presentFormSheet(navigationController, animated: true)
    }

    public func didTapNameEducation(type: SafetyTipsType) {
        AssertIsOnMainThread()
        present(NameEducationSheet(type: type), animated: true)
    }

    public func didTapShowConversationSettings() {
        AssertIsOnMainThread()

        showConversationSettings()
    }

    public func didTapShowConversationSettingsAndShowMemberRequests() {
        AssertIsOnMainThread()

        if thread.isTerminatedGroup {
            showUnableToTakeActionInEndedGroupSheet()
            return
        }

        showConversationSettingsAndShowMemberRequests()
    }

    public func didTapBlockRequest(
        groupModel: TSGroupModelV2,
        requesterName: String,
        requesterAci: Aci,
    ) {
        AssertIsOnMainThread()

        if thread.isTerminatedGroup {
            showUnableToTakeActionInEndedGroupSheet()
            return
        }

        let actionSheet = ActionSheetController(
            title: OWSLocalizedString(
                "GROUPS_BLOCK_REQUEST_SHEET_TITLE",
                comment: "Title for sheet asking if the user wants to block a request to join the group.",
            ),
            message: String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "GROUPS_BLOCK_REQUEST_SHEET_MESSAGE",
                    comment: "Message for sheet offering to let the user block a request to join the group. Embeds {{ the requester's name }}.",
                ),
                requesterName,
            ),
        )

        actionSheet.addAction(
            .init(
                title: OWSLocalizedString(
                    "GROUPS_BLOCK_REQUEST_SHEET_BLOCK_BUTTON",
                    comment: "Label for button that will block a request to join a group.",
                ),
                style: .default,
                handler: { _ in
                    GroupViewUtils.updateGroupWithActivityIndicator(
                        fromViewController: self,
                        updateBlock: {
                            // If the user in question has canceled their request,
                            // this call will still block them.
                            try await GroupManager.acceptOrDenyMemberRequestsV2(
                                groupModel: groupModel,
                                aci: requesterAci,
                                shouldAccept: false,
                            )
                        },
                        completion: nil,
                    )
                },
            ),
        )

        actionSheet.addAction(OWSActionSheets.cancelAction)

        OWSActionSheets.showActionSheet(actionSheet, fromViewController: self)
    }

    public func didTapShowUpgradeAppUI() {
        AssertIsOnMainThread()

        UIApplication.shared.open(TSConstants.appStoreUrl, options: [:], completionHandler: nil)
    }

    public func didTapUpdateSystemContact(_ address: SignalServiceAddress, newNameComponents: PersonNameComponents) {
        SUIEnvironment.shared.contactsViewHelperRef.presentSystemContactsFlow(
            CreateOrEditContactFlow(address: address, nameComponents: newNameComponents),
            from: self,
        )
    }

    public func didTapPhoneNumberChange(aci: Aci, phoneNumberOld: String, phoneNumberNew: String) {
        SUIEnvironment.shared.contactsViewHelperRef.checkEditAuthorization(
            performWhenAllowed: {
                let existingContact: CNContact? = {
                    guard let cnContactId = SSKEnvironment.shared.contactManagerRef.cnContactId(for: phoneNumberOld) else {
                        return nil
                    }
                    return SSKEnvironment.shared.contactManagerRef.cnContact(withId: cnContactId)
                }()
                guard let existingContact else {
                    owsFailDebug("Missing existing contact for phone number change.")
                    return
                }

                let address = SignalServiceAddress(serviceId: aci, phoneNumber: phoneNumberNew)
                SUIEnvironment.shared.contactsViewHelperRef.presentSystemContactsFlow(
                    CreateOrEditContactFlow(address: address, contact: existingContact),
                    from: self,
                )
            },
            presentErrorFrom: self,
        )
    }

    public func didTapViewOnceAttachment(_ interaction: TSInteraction) {
        AssertIsOnMainThread()

        ViewOnceMessageViewController.tryToPresent(interaction: interaction, from: self)
    }

    public func didTapViewOnceExpired(_ interaction: TSInteraction) {
        AssertIsOnMainThread()

        if interaction is TSOutgoingMessage {
            presentViewOnceOutgoingToast()
        } else {
            presentViewOnceAlreadyViewedToast()
        }
    }

    public func didTapContactName(thread: TSContactThread) {
        AssertIsOnMainThread()
        ContactAboutSheet(thread: thread, spoilerState: self.spoilerState)
            .present(from: self)
    }

    public func didTapUnknownThreadWarningGroup() {
        AssertIsOnMainThread()

        showUnknownThreadWarningAlert()
    }

    public func didTapUnknownThreadWarningContact() {
        AssertIsOnMainThread()

        showUnknownThreadWarningAlert()
    }

    public func didTapDeliveryIssueWarning(_ message: TSErrorMessage) {
        AssertIsOnMainThread()
        guard let senderAddress = message.sender else {
            owsFailDebug("Expected a sender address")
            return
        }

        // If the error message was added to a group thread, we must know that the failed decryption was
        // associated with the current thread. Why?
        //
        // - If we fail to decrypt a message, the sender may have tagged the envelope with a groupId. That
        // groupId is used to look up the source thread and insert this error message.
        // - If there is no groupId on the envelope, we don't know anything about which thread the original
        // message belongs to, so we fall back to inserting this message in the author's 1:1 thread.
        // - There's no other information that would allow us to determine the originating thread other
        // that this groupId field.
        // - Therefore, if this error message was added to a group thread, we know we must have the right thread
        // thread. If it's not in a group thread, we can't infer anything about the thread of the original message.
        //
        // Maybe one day the envelope will be annotated with additional information to always allow us to tie
        // the failed decryption to the originating thread. But until then, this heuristic will always be correct.
        // There's no reason to add an additional bit to the interactions db to track whether or not we know
        // the originating thread.
        showDeliveryIssueWarningAlert(from: senderAddress, isKnownThread: thread.isGroupThread)
    }

    public func didTapActivatePayments() {
        AssertIsOnMainThread()
        SignalApp.shared.showAppSettings(mode: .payments)
    }

    public func didTapSendPayment() {
        AssertIsOnMainThread()
        // Same action as tapping on the attachment toolbar.
        paymentButtonPressed()
    }

    public func didTapThreadMergeLearnMore(phoneNumber: String) {
        guard let contactAddress = (thread as? TSContactThread)?.contactAddress else {
            owsFailDebug("Can't handle a merge event in a group.")
            return
        }
        let formattedMessage: String = {
            let formatString = OWSLocalizedString(
                "THREAD_MERGE_LEARN_MORE",
                comment: "Shown after tapping a 'Learn More' button when multiple conversations for the same person have been merged into one. The first parameter is a phone number (eg +1 650-555-0100) and the second parameter is a name (eg John).",
            )
            let formattedPhoneNumber = PhoneNumber.bestEffortLocalizedPhoneNumber(e164: phoneNumber)
            let shortDisplayName = SSKEnvironment.shared.databaseStorageRef.read { tx in
                return SSKEnvironment.shared.contactManagerRef.displayName(for: contactAddress, tx: tx).resolvedValue(useShortNameIfAvailable: true)
            }
            return String.nonPluralLocalizedStringWithFormat(formatString, formattedPhoneNumber, shortDisplayName)
        }()
        let customHeader: UIView = {
            let imageView = UIImageView(image: UIImage(named: "merged-chat")!)
            imageView.contentMode = .scaleAspectFit
            imageView.autoSetDimensions(to: .square(88))

            let stackView = UIStackView()
            stackView.isLayoutMarginsRelativeArrangement = true
            stackView.layoutMargins = UIEdgeInsets(top: 16, leading: 0, bottom: 0, trailing: 0)
            stackView.axis = .vertical
            stackView.alignment = .center
            stackView.addArrangedSubview(imageView)
            return stackView
        }()
        let actionSheet = ActionSheetController(message: formattedMessage)
        actionSheet.customHeader = customHeader
        actionSheet.addAction(ActionSheetAction(title: CommonStrings.okButton))
        presentActionSheet(actionSheet)
    }

    public func didTapReportSpamLearnMore() {
        AssertIsOnMainThread()

        let alert = ActionSheetController(
            title: OWSLocalizedString(
                "INFO_MESSAGE_REPORTED_SPAM_LEARN_MORE_TITLE",
                comment: "Title of the alert shown when a user taps on 'learn more' via the spam info message.",
            ),
            message: OWSLocalizedString(
                "INFO_MESSAGE_REPORTED_SPAM_LEARN_MORE_MESSAGE",
                comment: "Body message of the alert shown when a user taps on 'learn more' via the spam info message.",
            ),
        )
        alert.addAction(OWSActionSheets.okayAction)

        inputToolbar?.clearDesiredKeyboard()
        dismissKeyBoard()
        self.presentActionSheet(alert)
    }

    public func didTapMessageRequestAcceptedOptions() {
        AssertIsOnMainThread()

        let message: String
        if thread is TSContactThread {
            message = String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "INFO_MESSAGE_ACCEPTED_MESSAGE_REQUEST_OPTIONS_ACTION_SHEET_HEADER_CONTACT",
                    comment: "Header for an action sheet providing options in response to an accepted 1:1 message request. Embeds {{ the name of your chat partner }}.",
                ),
                threadViewModel.shortName ?? threadViewModel.name,
            )
        } else if thread is TSGroupThread {
            message = OWSLocalizedString(
                "INFO_MESSAGE_ACCEPTED_MESSAGE_REQUEST_OPTIONS_ACTION_SHEET_HEADER_GROUP",
                comment: "Header for an action sheet providing options in response to an accepted group message request.",
            )
        } else {
            return
        }

        let alert = ActionSheetController(
            message: message,
        )
        alert.addAction(ActionSheetAction(
            title: String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "MESSAGE_REQUEST_ACCEPTED_INFO_MESSAGE_SHEET_OPTION_BLOCK",
                    comment: "Sheet option for blocking a chat. In this case, the sheet appears when the user taps a button attached to a 'message request accepted' info message in-chat.",
                ),
            ),
            style: .default,
            handler: { [weak self] _ in
                guard let self else { return }

                let blockThreadActionSheet = createBlockThreadActionSheet()
                presentActionSheet(blockThreadActionSheet)
            },
        ))
        alert.addAction(ActionSheetAction(
            title: String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "MESSAGE_REQUEST_ACCEPTED_INFO_MESSAGE_SHEET_OPTION_SPAM",
                    comment: "Sheet option for reporting a chat as spam. In this case, the sheet appears when the user taps a button attached to a 'message request accepted' info message in-chat.",
                ),
            ),
            style: .default,
            handler: { [weak self] _ in
                guard let self else { return }

                let reportThreadActionSheet = createReportThreadActionSheet()
                presentActionSheet(reportThreadActionSheet)
            },
        ))
        alert.addAction(ActionSheetAction(
            title: String.nonPluralLocalizedStringWithFormat(
                OWSLocalizedString(
                    "MESSAGE_REQUEST_ACCEPTED_INFO_MESSAGE_SHEET_OPTION_DELETE",
                    comment: "Sheet option for deleting a chat. In this case, the sheet appears when the user taps a button attached to a 'message request accepted' info message in-chat.",
                ),
            ),
            style: .default,
            handler: { [weak self] _ in
                guard let self else { return }

                let deleteThreadActionSheet = createDeleteThreadActionSheet()
                presentActionSheet(deleteThreadActionSheet)
            },
        ))
        alert.addAction(.cancel)

        inputToolbar?.clearDesiredKeyboard()
        dismissKeyBoard()
        presentActionSheet(alert)
    }

    public func didTapJoinCallLinkCall(callLink: CallLink) {
        GroupCallViewController.presentLobby(for: callLink)
    }

    public func didTapViewVotes(poll: OWSPoll) {
        let message: TSMessage? = DependenciesBridge.shared.db.read { tx in
            InteractionFinder.fetch(rowId: poll.interactionId, transaction: tx) as? TSMessage
        }

        guard let message else {
            return
        }

        let pollDetails = PollDetailsViewController(
            poll: poll,
            message: message,
            pollManager: DependenciesBridge.shared.pollMessageManager,
            db: DependenciesBridge.shared.db,
            databaseChangeObserver: DependenciesBridge.shared.databaseChangeObserver,
            isTerminatedGroup: thread.isTerminatedGroup,
        )
        pollDetails.delegate = self
        self.present(OWSNavigationController(rootViewController: pollDetails), animated: true)
    }

    public func didTapViewPoll(pollInteractionUniqueId: String) {
        ensureInteractionLoadedThenScrollToInteraction(
            pollInteractionUniqueId,
            alignment: .centerIfNotEntirelyOnScreen,
            isAnimated: true,
        )
    }

    public func didTapVoteOnPoll(poll: OWSPoll, optionIndex: UInt32, isUnvote: Bool) {
        guard !threadViewModel.hasPendingMessageRequest else {
            return
        }

        if let groupThread = self.thread as? TSGroupThread, !groupThread.groupModel.groupMembership.isLocalUserFullMember || groupThread.isTerminatedGroup {
            return
        }

        do {
            try DependenciesBridge.shared.db.write { tx in
                let targetPoll = DependenciesBridge.shared.interactionStore.fetchInteraction(
                    rowId: poll.interactionId,
                    tx: tx,
                )

                guard let targetPoll else {
                    return
                }

                guard
                    let pollVoteMessage = try DependenciesBridge.shared.pollMessageManager.applyPendingVoteToLocalState(
                        pollInteraction: targetPoll,
                        optionIndex: optionIndex,
                        isUnvote: isUnvote,
                        thread: thread,
                        tx: tx,
                    )
                else {
                    Logger.error("Unable to update local poll state with votes")
                    return
                }

                // Touch message so it reloads to show updated vote state.
                SSKEnvironment.shared.databaseStorageRef.touch(interaction: targetPoll, shouldReindex: false, tx: tx)

                let preparedMessage = PreparedOutgoingMessage.preprepared(
                    transientMessageWithoutAttachments: pollVoteMessage,
                )

                SSKEnvironment.shared.messageSenderJobQueueRef.add(
                    message: preparedMessage,
                    transaction: tx,
                )
            }
        } catch {
            Logger.error("Unable to update local poll state with votes: \(error)")
        }
    }

    public func didTapViewPinnedMessage(pinnedMessageUniqueId: String) {
        ensureInteractionLoadedThenScrollToInteraction(
            pinnedMessageUniqueId,
            alignment: .centerIfNotEntirelyOnScreen,
            isAnimated: true,
        )
    }

    public func didTapSafetyTips() {
        let viewController = SafetyTipsViewController()
        viewController.delegate = self
        present(viewController, animated: true)
    }
}

// MARK: - SafetyTipsViewControllerDelegate

extension ConversationViewController: SafetyTipsViewControllerDelegate {
    public func didTapViewMoreSafetyTips() {
        let viewController = MoreSafetyTipsViewController()
        present(viewController, animated: true)
    }
}

// MARK: - OWSNavigationChildController

extension ConversationViewController: OWSNavigationChildController {
    public var shouldCancelNavigationBack: Bool {
        // If presentedViewController is not nil, it means we haven't finished dismissing
        // and should not allow the back navigation gesture.
        return presentedViewController != nil
    }
}

// MARK: - PollDetailsViewControllerDelegate

extension ConversationViewController: PollDetailsViewControllerDelegate {
    public func terminatePoll(poll: OWSPoll) {
        do {
            try DependenciesBridge.shared.pollMessageManager.sendPollTerminateMessage(poll: poll, thread: thread)
        } catch {
            Logger.error("Failed to end poll: \(error)")
        }
    }
}