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,
)
}
}