Signal-iOS/Signal/ConversationView/ConversationViewController+Delegates.swift

480 lines
18 KiB
Swift

//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import AVFoundation
import Foundation
public import LibSignalClient
public import SignalServiceKit
public import SignalUI
extension ConversationViewController: AttachmentApprovalViewControllerDelegate {
public func attachmentApproval(
_ attachmentApproval: AttachmentApprovalViewController,
didApproveAttachments approvedAttachments: ApprovedAttachments,
messageBody: MessageBody?,
) {
ModalActivityIndicatorViewController.present(
fromViewController: attachmentApproval,
title: CommonStrings.preparingModal,
asyncBlock: { modal in
await self.sendAttachments(
approvedAttachments,
messageBody: messageBody,
from: attachmentApproval,
attachmentLimits: attachmentApproval.attachmentLimits,
)
modal.dismiss(completion: {
self.dismiss(animated: true)
})
},
)
}
public func attachmentApprovalDidCancel() {
dismiss(animated: true, completion: nil)
self.popKeyBoard()
}
public func attachmentApproval(
_ attachmentApproval: AttachmentApprovalViewController,
didChangeMessageBody newMessageBody: MessageBody?,
) {
AssertIsOnMainThread()
guard hasViewWillAppearEverBegun else {
owsFailDebug("InputToolbar not yet ready.")
return
}
guard let inputToolbar else {
return
}
inputToolbar.setMessageBody(newMessageBody, animated: false)
}
public func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachmentApprovalItem: AttachmentApprovalItem) { }
public func attachmentApprovalDidTapAddMore(_ attachmentApproval: AttachmentApprovalViewController) { }
public func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didChangeViewOnceState isViewOnce: Bool) { }
}
extension ConversationViewController: AttachmentApprovalViewControllerDataSource {
public var attachmentApprovalTextInputContextIdentifier: String? { textInputContextIdentifier }
public var attachmentApprovalRecipientNames: [String] {
let displayName = SSKEnvironment.shared.databaseStorageRef.read { tx in SSKEnvironment.shared.contactManagerRef.displayName(for: thread, transaction: tx) }
return [displayName]
}
public func attachmentApprovalMentionableAcis(tx: DBReadTransaction) -> [Aci] {
supportsMentions ? thread.recipientAddresses(with: tx).compactMap(\.aci) : []
}
public func attachmentApprovalMentionCacheInvalidationKey() -> String {
return thread.uniqueId
}
}
extension ConversationViewController: UIAdaptivePresentationControllerDelegate {
public func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
switch presentationController.presentedViewController {
case is GifPickerNavigationViewController, is UIDocumentPickerViewController:
self.openAttachmentKeyboard()
case let navigationController as OWSNavigationController:
switch navigationController.viewControllers.first {
case is ContactPickerViewController, is LocationPicker:
self.openAttachmentKeyboard()
default:
break
}
default:
break
}
}
}
// MARK: -
extension ConversationViewController: ContactPickerDelegate {
public func contactPickerDidCancel(_: ContactPickerViewController) {
dismiss(animated: true, completion: nil)
self.openAttachmentKeyboard()
}
public func contactPicker(_ contactPicker: ContactPickerViewController, didSelect systemContact: SystemContact) {
AssertIsOnMainThread()
guard let cnContact = SSKEnvironment.shared.contactManagerRef.cnContact(withId: systemContact.cnContactId) else {
owsFailDebug("Could not load system contact.")
return
}
let contactShareDraft = SSKEnvironment.shared.databaseStorageRef.read { tx in
return ContactShareDraft.load(
cnContact: cnContact,
signalContact: systemContact,
contactManager: SSKEnvironment.shared.contactManagerRef,
phoneNumberUtil: SSKEnvironment.shared.phoneNumberUtilRef,
profileManager: SSKEnvironment.shared.profileManagerRef,
recipientManager: DependenciesBridge.shared.recipientManager,
tsAccountManager: DependenciesBridge.shared.tsAccountManager,
tx: tx,
)
}
let approveContactShare = ContactShareViewController(contactShareDraft: contactShareDraft)
approveContactShare.shareDelegate = self
guard let navigationController = contactPicker.navigationController else {
owsFailDebug("Missing contactsPicker.navigationController.")
return
}
navigationController.pushViewController(approveContactShare, animated: true)
}
public func contactPicker(_: ContactPickerViewController, didSelectMultiple systemContacts: [SystemContact]) {
owsFailDebug("Multiple selection not allowed.")
dismiss(animated: true, completion: nil)
}
public func contactPicker(_: ContactPickerViewController, shouldSelect systemContact: SystemContact) -> Bool {
// Any reason to preclude contacts?
return true
}
}
// MARK: -
extension ConversationViewController: ContactShareViewControllerDelegate {
public func contactShareViewController(
_ viewController: ContactShareViewController,
didApproveContactShare contactShare:
ContactShareDraft,
) {
dismiss(animated: true) {
self.send(contactShareDraft: contactShare)
}
}
public func contactShareViewControllerDidCancel(_ viewController: ContactShareViewController) {
dismiss(animated: true, completion: nil)
}
public func titleForContactShareViewController(_ viewController: ContactShareViewController) -> String? {
return nil
}
public func recipientsDescriptionForContactShareViewController(_ viewController: ContactShareViewController) -> String? {
return SSKEnvironment.shared.databaseStorageRef.read { transaction in
SSKEnvironment.shared.contactManagerRef.displayName(for: self.thread, transaction: transaction)
}
}
public func approvalModeForContactShareViewController(_ viewController: ContactShareViewController) -> ApprovalMode {
return .send
}
private func send(contactShareDraft: ContactShareDraft) {
let thread = self.thread
SSKEnvironment.shared.databaseStorageRef.asyncWrite { transaction in
let didAddToProfileWhitelist = ThreadUtil.addThreadToProfileWhitelistIfEmptyOrPendingRequest(
thread,
setDefaultTimerIfNecessary: true,
tx: transaction,
)
transaction.addSyncCompletion {
Task { @MainActor in
ThreadUtil.enqueueMessage(withContactShare: contactShareDraft, thread: thread)
self.messageWasSent()
if didAddToProfileWhitelist {
self.ensureBannerState()
}
}
}
}
}
}
// MARK: -
extension ConversationViewController: ConversationHeaderViewDelegate {
func didTapConversationHeaderView(_ conversationHeaderView: ConversationHeaderView) {
AssertIsOnMainThread()
showConversationSettings()
}
func didTapConversationHeaderViewAvatar(_ conversationHeaderView: ConversationHeaderView) {
AssertIsOnMainThread()
if conversationHeaderView.avatarView.configuration.hasStoriesToDisplay {
let vc = StoryPageViewController(
context: thread.storyContext,
spoilerState: spoilerState,
)
present(vc, animated: true)
} else {
showConversationSettings()
}
}
}
// MARK: -
extension ConversationViewController: ConversationInputTextViewDelegate {
public func didAttemptAttachmentPaste() {
let attachmentLimits = OutgoingAttachmentLimits.currentLimits()
// If trying to paste a sticker, forego anything async since
// the pasteboard will be cleared as soon as paste() exits.
if PasteboardAttachment.hasStickerAttachment() {
do {
self.didPasteAttachments(
[try PasteboardAttachment.loadPreviewableStickerAttachment()].compacted(),
attachmentLimits: attachmentLimits,
)
} catch {
self.showErrorAlert(attachmentError: error as? SignalAttachmentError)
}
return
}
ModalActivityIndicatorViewController.present(
fromViewController: self,
title: OWSLocalizedString(
"ATTACHMENT_PASTING",
comment: "Displayed in a full screen modal when app is processing media that was pasted into message compose field.",
),
asyncBlock: { modal in
do {
let attachments = try await PasteboardAttachment.loadPreviewableAttachments(attachmentLimits: attachmentLimits)
modal.dismiss {
// Note: attachment array might be nil at this point; that's fine.
self.didPasteAttachments(attachments, attachmentLimits: attachmentLimits)
}
} catch {
modal.dismiss {
self.showErrorAlert(attachmentError: error as? SignalAttachmentError)
}
}
},
)
}
func didPasteAttachments(
_ attachments: [PreviewableAttachment]?,
attachmentLimits: OutgoingAttachmentLimits,
) {
AssertIsOnMainThread()
guard let attachments, attachments.count > 0 else {
owsFailDebug("Missing attachments")
return
}
// If the thing we pasted is sticker-like, send it immediately
// and render it borderless.
if attachments.count == 1, let a = attachments.first, a.rawValue.isBorderless {
Task {
await self.sendAttachments(
ApprovedAttachments(nonViewOnceAttachments: [a], imageQuality: .standard),
messageBody: nil,
from: self,
attachmentLimits: attachmentLimits,
)
}
} else {
dismissKeyBoard()
showApprovalDialog(forAttachments: attachments, attachmentLimits: attachmentLimits)
}
}
public func didAttemptAccountEntropyPoolPaste(completePaste: @escaping () -> Void) {
let warningSheet = BackupNeverShareRecoveryKeySheet(
primaryButton: .dismissing(
title: OWSLocalizedString(
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_WARNING_SHEET_DO_NOT_SHARE_BUTTON_TITLE",
comment: "Title for the button in a warning sheet shown before the user pastes their 'Recovery Key' into the chat input text field that dismisses the sheet without pasting the key.",
),
),
secondaryButton: HeroSheetViewController.Button(
title: OWSLocalizedString(
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_WARNING_SHEET_SHARE_BUTTON_TITLE",
comment: "Title for the button in a warning sheet shown before the user pastes their 'Recovery Key' into the chat input text field that acknowledges the warning and proceeds with the paste.",
),
style: .secondaryDestructive,
action: .custom({ [weak self] sheet in
sheet.dismiss(animated: true) {
let doubleWarningSheet = ActionSheetController(
title: OWSLocalizedString(
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_CONFIRM_SHEET_TITLE",
comment: "Title for a second confirmation sheet shown after the user opts to paste their 'Recovery Key' into the chat input text field anyway.",
),
message: OWSLocalizedString(
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_CONFIRM_SHEET_MESSAGE",
comment: "Message body for a second confirmation sheet shown after the user opts to paste their 'Recovery Key' into the chat input text field anyway, warning them not to share it.",
),
)
doubleWarningSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_CONFIRM_SHEET_PASTE_BUTTON_TITLE",
comment: "Title for the destructive button in a second confirmation sheet shown after the user opts to paste their 'Recovery Key' into the chat input text field anyway, that proceeds with the paste.",
),
style: .destructive,
handler: { _ in
completePaste()
},
))
doubleWarningSheet.addAction(ActionSheetAction(
title: OWSLocalizedString(
"CONVERSATION_INPUT_PASTE_RECOVERY_KEY_CONFIRM_SHEET_DO_NOT_SHARE_BUTTON_TITLE",
comment: "Title for the button in a second confirmation sheet shown after the user opts to paste their 'Recovery Key' into the chat input text field anyway, that dismisses the sheet without pasting the key.",
),
))
self?.present(doubleWarningSheet, animated: true)
}
}),
),
)
present(warningSheet, animated: true)
}
public func inputTextViewSendMessagePressed() {
AssertIsOnMainThread()
sendButtonPressed()
}
public func textViewDidChange(_ textView: UITextView) {
AssertIsOnMainThread()
if textView.text.strippedOrNil != nil {
SSKEnvironment.shared.typingIndicatorsRef.didStartTypingOutgoingInput(inThread: thread)
}
}
}
// MARK: -
extension ConversationViewController: ConversationSearchControllerDelegate {
public func didDismissSearchController(_ searchController: UISearchController) {
AssertIsOnMainThread()
// This method is called not only when the user taps "cancel" in the searchController, but also
// called when the searchController was dismissed because we switched to another uiMode, like
// "selection". We only want to revert to "normal" in the former case - when the user tapped
// "cancel" in the search controller. Otherwise, if we're already in another mode, like
// "selection", we want to stay in that mode.
if uiMode == .search {
uiMode = .normal
}
}
public func conversationSearchController(
_ conversationSearchController: ConversationSearchController,
didUpdateSearchResults resultSet: ConversationScreenSearchResultSet?,
) {
AssertIsOnMainThread()
self.lastSearchedText = resultSet?.searchText
loadCoordinator.enqueueReload()
}
public func conversationSearchController(
_ conversationSearchController: ConversationSearchController,
didSelectMessageId messageId: String,
) {
AssertIsOnMainThread()
ensureInteractionLoadedThenScrollToInteraction(
messageId,
onScreenPercentage: 1,
alignment: .centerIfNotEntirelyOnScreen,
isAnimated: true,
)
}
}
// MARK: -
extension ConversationViewController: ConversationCollectionViewDelegate {
public func collectionViewWillChangeSize(from oldSize: CGSize, to newSize: CGSize) {
AssertIsOnMainThread()
// Do nothing.
}
public func collectionViewDidChangeSize(from oldSize: CGSize, to newSize: CGSize) {
AssertIsOnMainThread()
if oldSize.width != newSize.width {
resetForSizeOrOrientationChange()
}
updateScrollingContent()
}
public func collectionViewWillAnimate() {
AssertIsOnMainThread()
scrollingAnimationDidStart()
}
public func collectionViewShouldRecognizeSimultaneously(with otherGestureRecognizer: UIGestureRecognizer) -> Bool {
return otherGestureRecognizer == collectionViewContextMenuGestureRecognizer
}
public func scrollingAnimationDidStart() {
AssertIsOnMainThread()
// scrollingAnimationStartDate blocks landing of loads, so we must ensure
// that it is always cleared in a timely way, even if the animation
// is cancelled. Wait no more than N seconds.
scrollingAnimationCompletionTimer?.invalidate()
scrollingAnimationCompletionTimer = .scheduledTimer(withTimeInterval: 5, repeats: false) { [weak self] _ in
self?.scrollingAnimationCompletionTimerDidFire()
}
}
private func scrollingAnimationCompletionTimerDidFire() {
AssertIsOnMainThread()
Logger.warn("Scrolling animation did not complete in a timely way.")
// scrollingAnimationCompletionTimer should already have been cleared,
// but we need to ensure that it is cleared in a timely way.
scrollingAnimationDidComplete()
}
}
// MARK: -
extension ConversationViewController {
func scrollingAnimationDidComplete() {
AssertIsOnMainThread()
scrollingAnimationCompletionTimer?.invalidate()
scrollingAnimationCompletionTimer = nil
autoLoadMoreIfNecessary()
performMessageHighlightAnimationIfNeeded()
focusVoiceoverElementAfterScroll()
}
func resetForSizeOrOrientationChange() {
AssertIsOnMainThread()
updateConversationStyle()
}
}