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

import CoreServices
import LibSignalClient
public import Photos
public import SignalServiceKit
public import SignalUI
import UniformTypeIdentifiers

extension ConversationViewController: ConversationInputToolbarDelegate {

    public func isBlockedConversation() -> Bool {
        threadViewModel.isBlocked
    }

    public func isGroup() -> Bool {
        isGroupConversation
    }

    public func viewForKeyboardLayoutGuide() -> UIView {
        return view
    }

    public func viewForSuggestedStickersPanel() -> UIView { view }

    public func sendButtonPressed() {
        AssertIsOnMainThread()

        guard hasViewWillAppearEverBegun else {
            owsFailDebug("InputToolbar not yet ready.")
            return
        }
        guard let inputToolbar else {
            return
        }

        inputToolbar.acceptAutocorrectSuggestion()

        guard let messageBody = inputToolbar.messageBodyForSending else {
            return
        }

        tryToSendTextMessage(messageBody, updateKeyboardState: true)
    }

    public func messageWasSent() {
        AssertIsOnMainThread()

        self.lastMessageSentDate = Date()

        loadCoordinator.clearUnreadMessagesIndicator()
        inputToolbar?.quotedReplyDraft = nil

        if
            SSKEnvironment.shared.preferencesRef.soundInForeground,
            let soundId = Sounds.systemSoundIDForSound(.standard(.messageSent), quiet: true)
        {
            AudioServicesPlaySystemSound(soundId)
        }
        SSKEnvironment.shared.typingIndicatorsRef.didSendOutgoingMessage(inThread: thread)
    }

    private func tryToSendTextMessage(_ messageBody: MessageBody, updateKeyboardState: Bool) {
        tryToSendTextMessage(
            messageBody,
            updateKeyboardState: updateKeyboardState,
            untrustedThreshold: Date().addingTimeInterval(-OWSIdentityManagerImpl.Constants.defaultUntrustedInterval),
        )
    }

    private func tryToSendTextMessage(_ messageBody: MessageBody, updateKeyboardState: Bool, untrustedThreshold: Date) {
        AssertIsOnMainThread()

        guard hasViewWillAppearEverBegun else {
            owsFailDebug("View not yet ready.")
            return
        }
        guard let inputToolbar else {
            return
        }

        guard !isBlockedConversation() else {
            showUnblockConversationUI { [weak self] isBlocked in
                if !isBlocked {
                    self?.tryToSendTextMessage(messageBody, updateKeyboardState: false, untrustedThreshold: untrustedThreshold)
                }
            }
            return
        }

        let newUntrustedThreshold = Date()
        let didShowSNAlert = showSafetyNumberConfirmationIfNecessary(
            confirmationText: SafetyNumberStrings.confirmSendButton,
            untrustedThreshold: untrustedThreshold,
        ) { [weak self] didConfirmIdentity in
            guard let self else { return }
            if didConfirmIdentity {
                self.tryToSendTextMessage(messageBody, updateKeyboardState: false, untrustedThreshold: newUntrustedThreshold)
            }
        }
        if didShowSNAlert {
            return
        }

        guard !messageBody.text.isEmpty else {
            return
        }

        let didAddToProfileWhitelist = ThreadUtil.addThreadToProfileWhitelistIfEmptyOrPendingRequestAndSetDefaultTimerWithSneakyTransaction(thread)

        let editValidationError: EditSendValidationError? = SSKEnvironment.shared.databaseStorageRef.read { transaction in
            if let editTarget = inputToolbar.editTarget {
                return context.editManager.validateCanSendEdit(
                    targetMessageTimestamp: editTarget.timestamp,
                    thread: self.thread,
                    tx: transaction,
                )
            }
            return nil
        }

        if let error = editValidationError {
            OWSActionSheets.showActionSheet(message: error.localizedDescription)
            return
        }

        if let editTarget = inputToolbar.editTarget {
            ThreadUtil.enqueueEditMessage(
                body: messageBody,
                thread: self.thread,
                // If we have _any_ quoted reply populated, keep the existing quoted reply.
                // If its cleared, "change" it to nothing (clear it).
                quotedReplyEdit: inputToolbar.quotedReplyDraft == nil ? .change(()) : .keep,
                linkPreviewDraft: inputToolbar.linkPreviewDraft,
                editTarget: editTarget,
                persistenceCompletionHandler: {
                    AssertIsOnMainThread()
                    self.loadCoordinator.enqueueReload()
                },
            )
        } else {
            ThreadUtil.enqueueMessage(
                body: messageBody,
                thread: self.thread,
                quotedReplyDraft: inputToolbar.quotedReplyDraft,
                linkPreviewDraft: inputToolbar.linkPreviewDraft,
                persistenceCompletionHandler: {
                    AssertIsOnMainThread()
                    self.loadCoordinator.enqueueReload()
                },
            )
        }

        messageWasSent()

        // Clearing the text message is a key part of the send animation.
        // It takes 10-15ms, but we do it inline rather than dispatch async
        // since the send can't feel "complete" without it.
        inputToolbar.clearTextMessage(animated: true)

        let thread = self.thread
        SSKEnvironment.shared.databaseStorageRef.asyncWrite { transaction in
            // Reload a fresh instance of the thread model; our models are not
            // thread-safe, so it wouldn't be safe to update the model in an
            // async write.
            guard let thread = TSThread.fetchViaCache(uniqueId: thread.uniqueId, transaction: transaction) else {
                owsFailDebug("Missing thread.")
                return
            }
            thread.updateWithDraft(
                draftMessageBody: nil,
                replyInfo: nil,
                editTargetTimestamp: nil,
                transaction: transaction,
            )
        }

        if didAddToProfileWhitelist {
            ensureBannerState()
        }

        NotificationCenter.default.post(name: ChatListViewController.clearSearch, object: nil)
    }

    public func sendSticker(_ stickerInfo: StickerInfo) {
        AssertIsOnMainThread()

        ImpactHapticFeedback.impactOccurred(style: .light)

        ThreadUtil.enqueueMessage(withInstalledSticker: stickerInfo, thread: thread)
        messageWasSent()
    }

    public func presentManageStickersView() {
        AssertIsOnMainThread()

        let manageStickersView = ManageStickersViewController()
        let navigationController = OWSNavigationController(rootViewController: manageStickersView)
        presentFormSheet(navigationController, animated: true)
    }

    public func updateToolbarHeight() {
        guard hasViewWillAppearEverBegun else {
            owsFailDebug("InputToolbar not yet ready.")
            return
        }
        guard inputToolbar != nil else {
            return
        }
        updateContentInsets()
    }

    public func voiceMemoGestureDidStart() {
        AssertIsOnMainThread()
        Logger.info("")

        let kIgnoreMessageSendDoubleTapDurationSeconds: TimeInterval = 2.0
        if
            let lastMessageSentDate = self.lastMessageSentDate,
            abs(lastMessageSentDate.timeIntervalSinceNow) < kIgnoreMessageSendDoubleTapDurationSeconds
        {
            // If users double-taps the message send button, the second tap can look like a
            // very short voice message gesture.  We want to ignore such gestures.
            cancelRecordingVoiceMessage()
            return
        }

        checkPermissionsAndStartRecordingVoiceMessage()
    }

    public func voiceMemoGestureDidComplete() {
        AssertIsOnMainThread()
        Logger.info("")

        finishRecordingVoiceMessage(sendImmediately: true)
    }

    public func voiceMemoGestureDidLock() {
        AssertIsOnMainThread()
        Logger.info("")

        inputToolbar?.lockVoiceMemoUI()
    }

    public func voiceMemoGestureDidCancel() {
        AssertIsOnMainThread()
        Logger.info("")

        cancelRecordingVoiceMessage()
    }

    public func voiceMemoGestureWasInterrupted() {
        AssertIsOnMainThread()
        Logger.info("")

        finishRecordingVoiceMessage(sendImmediately: false)
    }

    func sendVoiceMemoDraft(_ voiceMemoDraft: VoiceMessageInterruptedDraft) {
        AssertIsOnMainThread()

        sendVoiceMessageDraft(voiceMemoDraft)
    }

    public func saveDraft() {
        AssertIsOnMainThread()

        guard hasViewWillAppearEverBegun else {
            owsFailDebug("InputToolbar not yet ready.")
            return
        }
        guard let inputToolbar else {
            return
        }

        if !inputToolbar.isHidden {
            let thread = self.thread
            let currentDraft = inputToolbar.messageBodyForSending
            let quotedReply = inputToolbar.quotedReplyDraft
            let editTarget = inputToolbar.editTarget
            SSKEnvironment.shared.databaseStorageRef.asyncWrite { transaction in
                // Reload a fresh instance of the thread model; our models are not
                // thread-safe, so it wouldn't be safe to update the model in an
                // async write.
                guard let thread = TSThread.fetchViaCache(uniqueId: thread.uniqueId, transaction: transaction) else {
                    owsFailDebug("Missing thread.")
                    return
                }

                let didChange = Self.draftHasChanged(
                    currentDraft: currentDraft,
                    quotedReply: quotedReply,
                    editTarget: editTarget,
                    thread: thread,
                    transaction: transaction,
                )

                // Persist the draft only if its changed. This avoids unnecessary model changes.
                guard didChange else {
                    return
                }

                let replyInfo: ThreadReplyInfo?
                if
                    let quotedReply,
                    let originalMessageTimestamp = quotedReply.originalMessageTimestamp,
                    let aci = quotedReply.originalMessageAuthorAddress.aci
                {
                    replyInfo = ThreadReplyInfo(
                        timestamp: originalMessageTimestamp,
                        author: aci,
                    )
                } else {
                    replyInfo = nil
                }

                let editTargetTimestamp: UInt64? = inputToolbar.editTarget?.timestamp

                thread.updateWithDraft(
                    draftMessageBody: currentDraft,
                    replyInfo: replyInfo,
                    editTargetTimestamp: editTargetTimestamp,
                    transaction: transaction,
                )
            }
        }
    }

    private static func draftHasChanged(
        currentDraft: MessageBody?,
        quotedReply: DraftQuotedReplyModel?,
        editTarget: TSOutgoingMessage?,
        thread: TSThread,
        transaction: DBReadTransaction,
    ) -> Bool {
        let currentText = currentDraft?.text ?? ""
        let persistedText = thread.messageDraft ?? ""
        if currentText != persistedText {
            return true
        }

        let currentRanges = currentDraft?.ranges.mentions ?? [:]
        let persistedRanges = thread.messageDraftBodyRanges?.mentions ?? [:]
        if currentRanges != persistedRanges {
            return true
        }

        if
            let threadTimestamp = thread.editTargetTimestamp,
            threadTimestamp != editTarget?.timestamp ?? 0
        {
            return true
        }

        let threadReplyInfoStore = DependenciesBridge.shared.threadReplyInfoStore
        let persistedQuotedReply = threadReplyInfoStore.fetch(for: thread.uniqueId, tx: transaction)
        if quotedReply?.originalMessageTimestamp != persistedQuotedReply?.timestamp {
            return true
        }
        if quotedReply?.originalMessageAuthorAddress.aci != persistedQuotedReply?.author {
            return true
        }
        return false
    }

    @MainActor
    fileprivate func tryToSendAttachments(
        _ approvedAttachments: ApprovedAttachments,
        from viewController: UIViewController,
        messageBody: MessageBody?,
        attachmentLimits: OutgoingAttachmentLimits,
    ) async throws -> Bool {
        return try await tryToSendAttachments(
            approvedAttachments,
            messageBody: messageBody,
            from: viewController,
            attachmentLimits: attachmentLimits,
            untrustedThreshold: Date().addingTimeInterval(-OWSIdentityManagerImpl.Constants.defaultUntrustedInterval),
        )
    }

    enum SendAttachmentError: Error {
        case inputToolbarNotReady
        case inputToolbarMissing
        case conversationBlocked
        case untrustedContacts
    }

    @MainActor
    private func tryToSendAttachments(
        _ approvedAttachments: ApprovedAttachments,
        messageBody: MessageBody?,
        from viewController: UIViewController,
        attachmentLimits: OutgoingAttachmentLimits,
        untrustedThreshold: Date,
    ) async throws -> Bool {
        AssertIsOnMainThread()

        guard hasViewWillAppearEverBegun, let inputToolbar else {
            return false
        }

        let imageQuality = approvedAttachments.imageQuality
        let imageQualityLevel = ImageQualityLevel.resolvedValue(
            imageQuality: imageQuality,
            standardQualityLevel: attachmentLimits.standardQualityLevel,
        )
        let sendableAttachments = try await approvedAttachments.attachments.mapAsync {
            return try await SendableAttachment.forPreviewableAttachment($0, imageQualityLevel: imageQualityLevel)
        }

        if self.isBlockedConversation() {
            let isBlocked = await self.showUnblockConversationUI()
            if isBlocked {
                // They're still blocked, so stop trying to send.
                return false
            }
        }

        let newUntrustedThreshold = Date()
        let identityIsConfirmed = await self.showSafetyNumberConfirmationIfNecessary(
            from: viewController,
            confirmationText: SafetyNumberStrings.confirmSendButton,
            untrustedThreshold: newUntrustedThreshold,
        )

        guard identityIsConfirmed else {
            // They're still untrusted, so stop trying to send.
            return false
        }

        let didAddToProfileWhitelist = ThreadUtil.addThreadToProfileWhitelistIfEmptyOrPendingRequestAndSetDefaultTimerWithSneakyTransaction(self.thread)

        let hasViewOnceAttachment = approvedAttachments.isViewOnce
        owsPrecondition(!hasViewOnceAttachment || messageBody == nil)
        owsPrecondition(!hasViewOnceAttachment || inputToolbar.quotedReplyDraft == nil)

        ThreadUtil.enqueueMessage(
            body: messageBody,
            attachments: (sendableAttachments, isViewOnce: approvedAttachments.isViewOnce),
            thread: self.thread,
            quotedReplyDraft: inputToolbar.quotedReplyDraft,
            persistenceCompletionHandler: {
                AssertIsOnMainThread()
                self.loadCoordinator.enqueueReload()
            },
        )

        self.messageWasSent()

        if didAddToProfileWhitelist {
            self.ensureBannerState()
        }

        NotificationCenter.default.post(name: ChatListViewController.clearSearch, object: nil)

        return true
    }

    // MARK: - Accessory View

    public func cameraButtonPressed() {
        AssertIsOnMainThread()

        takePictureOrVideo()
    }

    public func photosButtonPressed() {
        AssertIsOnMainThread()

        chooseFromLibrary()
    }

    public func gifButtonPressed() {
        AssertIsOnMainThread()

        showGifPicker()
    }

    public func fileButtonPressed() {
        AssertIsOnMainThread()

        showDocumentPicker()
    }

    public func contactButtonPressed() {
        AssertIsOnMainThread()

        chooseContactForSending()
    }

    public func locationButtonPressed() {
        AssertIsOnMainThread()

        let locationPicker = LocationPicker()
        locationPicker.delegate = self
        let navigationController = OWSNavigationController(rootViewController: locationPicker)
        navigationController.presentationController?.delegate = self
        dismissKeyBoard()
        presentFormSheet(navigationController, animated: true)
    }

    public func paymentButtonPressed() {
        AssertIsOnMainThread()

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

        dismissKeyBoard()

        if SUIEnvironment.shared.paymentsRef.isKillSwitchActive {
            OWSActionSheets.showErrorAlert(message: OWSLocalizedString(
                "SETTINGS_PAYMENTS_CANNOT_SEND_PAYMENTS_KILL_SWITCH",
                comment: "Error message indicating that payments cannot be sent because the feature is not currently available.",
            ))
            return
        }

        if SSKEnvironment.shared.paymentsHelperRef.isPaymentsVersionOutdated {
            OWSActionSheets.showPaymentsOutdatedClientSheet(title: .cantSendPayment)
            return
        }

        SendPaymentViewController.presentFromConversationView(
            self,
            delegate: self,
            recipientAddress: contactThread.contactAddress,
            initialPaymentAmount: nil,
            isOutgoingTransfer: false,
        )
    }

    public func pollButtonPressed() {
        AssertIsOnMainThread()

        dismissKeyBoard()

        let newPollViewController = NewPollViewController2()
        newPollViewController.sendDelegate = self
        present(OWSNavigationController(rootViewController: newPollViewController), animated: true)
    }

    public func didSelectRecentPhoto(asset: PHAsset, attachment: PreviewableAttachment, attachmentLimits: OutgoingAttachmentLimits) {
        AssertIsOnMainThread()

        dismissKeyBoard()

        let pickerModal = SendMediaNavigationController.showingApprovalWithPickedLibraryMedia(
            asset: asset,
            attachment: attachment,
            hasQuotedReplyDraft: inputToolbar?.quotedReplyDraft != nil,
            attachmentLimits: attachmentLimits,
            delegate: self,
            dataSource: self,
        )
        let presenter = self.splitViewController ?? self
        presenter.present(pickerModal, animated: true)
    }
}

// MARK: -

public extension ConversationViewController {

    func showErrorAlert(attachmentError: SignalAttachmentError?) {
        AssertIsOnMainThread()

        Logger.warn("\(attachmentError as Optional)")
        let errorMessage = (attachmentError ?? .missingData).localizedDescription

        OWSActionSheets.showActionSheet(
            title: OWSLocalizedString("ATTACHMENT_ERROR_ALERT_TITLE", comment: "The title of the 'attachment error' alert."),
            message: errorMessage,
        )
    }

    func showApprovalDialog(forAttachments attachments: [PreviewableAttachment], attachmentLimits: OutgoingAttachmentLimits) {
        AssertIsOnMainThread()

        guard hasViewWillAppearEverBegun else {
            owsFailDebug("InputToolbar not yet ready.")
            return
        }
        guard let inputToolbar else {
            return
        }

        let modal = AttachmentApprovalViewController.wrappedInNavController(
            attachments: attachments,
            initialMessageBody: inputToolbar.messageBodyForSending,
            hasQuotedReplyDraft: inputToolbar.quotedReplyDraft != nil,
            attachmentLimits: attachmentLimits,
            approvalDelegate: self,
            approvalDataSource: self,
            stickerSheetDelegate: self,
        )
        modal.modalPresentationStyle = .overCurrentContext
        let presenter = self.splitViewController ?? self
        presenter.present(modal, animated: true)
    }
}

// MARK: -

private extension ConversationViewController {

    // MARK: - Attachment Picking: Contacts

    func chooseContactForSending() {
        AssertIsOnMainThread()

        dismissKeyBoard()
        SUIEnvironment.shared.contactsViewHelperRef.checkReadAuthorization(
            purpose: .share,
            performWhenAllowed: {
                let contactsPicker = ContactPickerViewController(allowsMultipleSelection: false, subtitleCellType: .none)
                contactsPicker.delegate = self
                contactsPicker.title = OWSLocalizedString(
                    "CONTACT_PICKER_TITLE",
                    comment: "navbar title for contact picker when sharing a contact",
                )
                let sheet = OWSNavigationController(rootViewController: contactsPicker)
                sheet.presentationController?.delegate = self
                self.presentFormSheet(sheet, animated: true)
            },
            presentErrorFrom: self,
        )
    }

    // MARK: - Attachment Picking: Documents

    func showDocumentPicker() {
        AssertIsOnMainThread()

        // UIDocumentPickerViewController with asCopy true copies to a temp file within our container.
        // It uses more memory than "open" but lets us avoid working with security scoped URLs.
        let pickerController = UIDocumentPickerViewController(
            forOpeningContentTypes: [.item],
            asCopy: true,
        )
        pickerController.delegate = self
        pickerController.presentationController?.delegate = self

        dismissKeyBoard()
        presentFormSheet(pickerController, animated: true)
    }

    // MARK: - Media Library

    func takePictureOrVideo() {
        AssertIsOnMainThread()

        let attachmentLimits = OutgoingAttachmentLimits.currentLimits()
        ows_askForCameraPermissions { [weak self] cameraGranted in
            guard let self else { return }
            guard cameraGranted else {
                Logger.warn("camera permission denied.")
                return
            }
            self.ows_askForMicrophonePermissions { [weak self] micGranted in
                guard let self else { return }
                if !micGranted {
                    Logger.warn("proceeding, though mic permission denied.")
                    // We can still continue without mic permissions, but any captured video will
                    // be silent.
                }

                let pickerModal = SendMediaNavigationController.showingCameraFirst(
                    hasQuotedReplyDraft: self.inputToolbar?.quotedReplyDraft != nil,
                    attachmentLimits: attachmentLimits,
                )
                pickerModal.sendMediaNavDelegate = self
                pickerModal.sendMediaNavDataSource = self
                pickerModal.modalPresentationStyle = .overFullScreen
                // Defer hiding status bar until modal is fully onscreen
                // to prevent unwanted shifting upwards of the entire presenter VC's view.
                let pickerHidesStatusBar = (pickerModal.topViewController?.prefersStatusBarHidden ?? false)
                if !pickerHidesStatusBar {
                    pickerModal.modalPresentationCapturesStatusBarAppearance = true
                }
                self.dismissKeyBoard()
                self.present(pickerModal, animated: true) {
                    if pickerHidesStatusBar {
                        pickerModal.modalPresentationCapturesStatusBarAppearance = true
                        pickerModal.setNeedsStatusBarAppearanceUpdate()
                    }
                }
            }
        }
    }

    func chooseFromLibrary() {
        AssertIsOnMainThread()

        let pickerModal = SendMediaNavigationController.showingNativePicker(
            hasQuotedReplyDraft: inputToolbar?.quotedReplyDraft != nil,
            attachmentLimits: .currentLimits(),
        )
        pickerModal.sendMediaNavDelegate = self
        pickerModal.sendMediaNavDataSource = self

        self.dismissKeyBoard()
        let presenter = self.splitViewController ?? self
        presenter.present(pickerModal, animated: false)
    }
}

// MARK: - Attachment Picking: GIFs

public extension ConversationViewController {
    func showGifPicker() {
        let gifModal = GifPickerNavigationViewController(
            initialMessageBody: inputToolbar?.messageBodyForSending,
            hasQuotedReplyDraft: inputToolbar?.quotedReplyDraft != nil,
        )
        gifModal.approvalDelegate = self
        gifModal.approvalDataSource = self
        gifModal.presentationController?.delegate = self
        dismissKeyBoard()
        present(gifModal, animated: true)
    }
}

// MARK: -

extension ConversationViewController: LocationPickerDelegate {

    public func didPickLocation(_ locationPicker: LocationPicker, location: Location) {
        AssertIsOnMainThread()

        Task { @MainActor in
            let attachment: SendableAttachment
            do {
                attachment = try await location.prepareAttachment()
            } catch {
                owsFailDebug("Error: \(error).")
                return
            }

            // TODO: Can we move this off the main thread?

            let didAddToProfileWhitelist = ThreadUtil.addThreadToProfileWhitelistIfEmptyOrPendingRequestAndSetDefaultTimerWithSneakyTransaction(self.thread)

            ThreadUtil.enqueueMessage(
                body: MessageBody(text: location.messageText, ranges: .empty),
                attachments: ([attachment], isViewOnce: false),
                thread: self.thread,
                persistenceCompletionHandler: {
                    AssertIsOnMainThread()
                    self.loadCoordinator.enqueueReload()
                },
            )

            self.messageWasSent()

            if didAddToProfileWhitelist {
                self.ensureBannerState()
            }

            NotificationCenter.default.post(name: ChatListViewController.clearSearch, object: nil)
        }
    }

    public func locationPickerDidCancel() {
        self.dismiss(animated: true)
        self.openAttachmentKeyboard()
    }
}

// MARK: -

extension ConversationViewController: UIDocumentPickerDelegate {
    public func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
        self.openAttachmentKeyboard()
    }

    public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentAt url: URL) {
        let resourceValues: URLResourceValues?
        do {
            resourceValues = try url.resourceValues(forKeys: [.contentTypeKey, .isDirectoryKey])
        } catch {
            owsFailDebug("couldn't get resourceValues: \(error)")
            resourceValues = nil
        }

        if resourceValues?.isDirectory == true {
            DispatchQueue.main.async {
                OWSActionSheets.showActionSheet(
                    title: OWSLocalizedString(
                        "ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_TITLE",
                        comment: "Alert title when picking a document fails because user picked a directory/bundle",
                    ),
                    message: OWSLocalizedString(
                        "ATTACHMENT_PICKER_DOCUMENTS_PICKED_DIRECTORY_FAILED_ALERT_BODY",
                        comment: "Alert body when picking a document fails because user picked a directory/bundle",
                    ),
                )
            }
            return
        }

        let filename: String = {
            if let filename = url.lastPathComponent.strippedOrNil {
                return filename
            }
            owsFailDebug("Unable to determine filename")
            return OWSLocalizedString("ATTACHMENT_DEFAULT_FILENAME", comment: "Generic filename for an attachment with no known name")
        }()

        guard url.isFileURL else {
            owsFailDebug("couldn't build data source")
            DispatchQueue.main.async {
                OWSActionSheets.showActionSheet(
                    title: OWSLocalizedString(
                        "ATTACHMENT_PICKER_DOCUMENTS_FAILED_ALERT_TITLE",
                        comment: "Alert title when picking a document fails for an unknown reason",
                    ),
                )
            }
            return
        }

        let dataSource = DataSourcePath(fileUrl: url, ownership: .owned)
        dataSource.sourceFilename = filename

        let contentTypeIdentifier = (resourceValues?.contentType ?? .data).identifier

        let attachmentLimits = OutgoingAttachmentLimits.currentLimits()

        // Although we want to be able to send higher quality attachments through
        // the document picker, it's more important that we ensure the sent format
        // is one all clients can accept (e.g., *not* QuickTime .mov).
        if SignalAttachment.videoUTISet.contains(contentTypeIdentifier) {
            self.showApprovalDialogAfterProcessingVideo(dataSource: dataSource, attachmentLimits: attachmentLimits)
            return
        }

        let attachment: PreviewableAttachment
        do {
            attachment = try PreviewableAttachment.buildAttachment(dataSource: dataSource, dataUTI: contentTypeIdentifier, attachmentLimits: attachmentLimits)
        } catch {
            DispatchQueue.main.async {
                self.showErrorAlert(attachmentError: error as? SignalAttachmentError)
            }
            return
        }

        showApprovalDialog(forAttachments: [attachment], attachmentLimits: attachmentLimits)
    }

    private func showApprovalDialogAfterProcessingVideo(dataSource: DataSourcePath, attachmentLimits: OutgoingAttachmentLimits) {
        AssertIsOnMainThread()

        ModalActivityIndicatorViewController.present(
            fromViewController: self,
            title: CommonStrings.preparingModal,
            canCancel: true,
            asyncBlock: { modalActivityIndicator in
                do {
                    let attachment = try await PreviewableAttachment.compressVideoAsMp4(dataSource: dataSource, attachmentLimits: attachmentLimits)
                    modalActivityIndicator.dismissIfNotCanceled(completionIfNotCanceled: {
                        self.showApprovalDialog(forAttachments: [attachment], attachmentLimits: attachmentLimits)
                    })
                } catch {
                    owsFailDebug("Error: \(error).")
                    modalActivityIndicator.dismissIfNotCanceled(completionIfNotCanceled: {
                        self.showErrorAlert(attachmentError: error as? SignalAttachmentError)
                    })
                }
            },
        )
    }
}

// MARK: -

extension ConversationViewController: SendMediaNavDelegate {
    func sendMediaNavDidCancel(_ sendMediaNavigationController: SendMediaNavigationController) {
        self.dismiss(animated: true, completion: nil)
        self.openAttachmentKeyboard()
    }

    func sendMediaNav(
        _ sendMediaNavigationController: SendMediaNavigationController,
        didApproveAttachments approvedAttachments: ApprovedAttachments,
        messageBody: MessageBody?,
    ) {
        ModalActivityIndicatorViewController.present(
            fromViewController: sendMediaNavigationController,
            title: CommonStrings.preparingModal,
            asyncBlock: { modal in
                await self.sendAttachments(
                    approvedAttachments,
                    messageBody: messageBody,
                    from: sendMediaNavigationController,
                    attachmentLimits: sendMediaNavigationController.attachmentLimits,
                )
                modal.dismiss(completion: {
                    self.dismiss(animated: true)
                })
            },
        )
    }

    /// Attempts to send attachments. Handles prompting to unblock or un-verify safety numbers, as well as showing failure states.
    @MainActor
    func sendAttachments(
        _ approvedAttachments: ApprovedAttachments,
        messageBody: MessageBody?,
        from viewController: UIViewController,
        attachmentLimits: OutgoingAttachmentLimits,
    ) async {
        let didSend: Bool
        do {
            didSend = try await tryToSendAttachments(
                approvedAttachments,
                from: viewController,
                messageBody: messageBody,
                attachmentLimits: attachmentLimits,
            )
        } catch {
            self.showErrorAlert(attachmentError: error as? SignalAttachmentError)
            return
        }
        guard didSend else {
            return
        }
        if
            approvedAttachments.attachments.count == 1,
            let attachment = approvedAttachments.attachments.first,
            attachment.rawValue.isBorderless
        {
            // This looks like a sticker, we shouldn't clear the input toolbar.
        } else {
            inputToolbar?.clearTextMessage(animated: false)
        }

        // we want to already be at the bottom when the user returns, rather than have to watch
        // the new message scroll into view.
        scrollToBottomOfConversation(animated: true)
    }

    func sendMediaNav(
        _ sendMediaNavifationController: SendMediaNavigationController,
        didFinishWithTextAttachment textAttachment: UnsentTextAttachment,
    ) {
        owsFailDebug("Can not post text stories to chat.")
    }

    func sendMediaNav(
        _ sendMediaNavigationController: SendMediaNavigationController,
        didChangeMessageBody newMessageBody: MessageBody?,
    ) {
        guard hasViewWillAppearEverBegun else {
            owsFailDebug("InputToolbar not yet ready.")
            return
        }
        guard let inputToolbar else {
            return
        }

        inputToolbar.setMessageBody(newMessageBody, animated: false)
    }

    func sendMediaNav(_ sendMediaNavigationController: SendMediaNavigationController, didChangeViewOnceState isViewOnce: Bool) {
        // We can ignore this event.
    }
}

// MARK: -

extension ConversationViewController: SendMediaNavDataSource {

    func sendMediaNavInitialMessageBody(_ sendMediaNavigationController: SendMediaNavigationController) -> MessageBody? {
        inputToolbar?.messageBodyForSending
    }

    var sendMediaNavTextInputContextIdentifier: String? { textInputContextIdentifier }

    var sendMediaNavRecipientNames: [String] {
        let displayName = SSKEnvironment.shared.databaseStorageRef.read { tx in SSKEnvironment.shared.contactManagerRef.displayName(for: thread, transaction: tx) }
        return [displayName]
    }

    func sendMediaNavMentionableAcis(tx: DBReadTransaction) -> [Aci] {
        supportsMentions ? thread.recipientAddresses(with: tx).compactMap(\.aci) : []
    }

    func sendMediaNavMentionCacheInvalidationKey() -> String {
        return thread.uniqueId
    }
}

// MARK: - StickerPickerSheetDelegate

extension ConversationViewController: StickerPickerSheetDelegate {
    public func makeManageStickersViewController(for stickerPickerSheet: StickerPickerSheet) -> UIViewController {
        let manageStickersView = ManageStickersViewController()
        let navigationController = OWSNavigationController(rootViewController: manageStickersView)
        return navigationController
    }
}

// MARK: - PollSendDelegate

extension ConversationViewController: PollSendDelegate {
    public func sendPoll(question: String, options: [String], allowMultipleVotes: Bool) {
        ThreadUtil.enqueueMessage(
            withPoll:
            CreatePollMessage(
                question: question,
                options: options,
                allowMultiple: allowMultipleVotes,
            ),
            thread: self.thread,
        )
    }
}