Signal-iOS/Signal/src/ViewControllers/ConversationView/ConversationViewController+Delegates.swift
2021-08-19 16:57:01 -03:00

495 lines
18 KiB
Swift

//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
//
import Foundation
import AVFoundation
extension ConversationViewController: AttachmentApprovalViewControllerDelegate {
public func attachmentApprovalDidAppear(_ attachmentApproval: AttachmentApprovalViewController) {
// no-op
}
public func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController,
didApproveAttachments attachments: [SignalAttachment],
messageBody: MessageBody?) {
AssertIsOnMainThread()
guard hasViewWillAppearEverBegun else {
owsFailDebug("InputToolbar not yet ready.")
return
}
guard let inputToolbar = inputToolbar else {
owsFailDebug("Missing inputToolbar.")
return
}
tryToSendAttachments(attachments, messageBody: messageBody)
inputToolbar.clearTextMessage(animated: false)
dismiss(animated: true, completion: nil)
// We always want to scroll to the bottom of the conversation after the local user
// sends a message.
scrollToBottomOfConversation(animated: false)
}
public func attachmentApprovalDidCancel(_ attachmentApproval: AttachmentApprovalViewController) {
dismiss(animated: true, completion: nil)
}
public func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController,
didChangeMessageBody newMessageBody: MessageBody?) {
AssertIsOnMainThread()
guard hasViewWillAppearEverBegun else {
owsFailDebug("InputToolbar not yet ready.")
return
}
guard let inputToolbar = inputToolbar else {
owsFailDebug("Missing inputToolbar.")
return
}
inputToolbar.setMessageBody(newMessageBody, animated: false)
}
public var attachmentApprovalTextInputContextIdentifier: String? { textInputContextIdentifier }
public var attachmentApprovalRecipientNames: [String] {
[ Self.contactsManager.displayNameWithSneakyTransaction(thread: thread) ]
}
public var attachmentApprovalMentionableAddresses: [SignalServiceAddress] {
supportsMentions ? thread.recipientAddresses : []
}
}
// MARK: -
extension ConversationViewController: ContactsPickerDelegate {
public func contactsPicker(_: ContactsPicker, contactFetchDidFail error: NSError) {
AssertIsOnMainThread()
Logger.verbose("Error: \(error)")
dismiss(animated: true, completion: nil)
}
public func contactsPickerDidCancel(_: ContactsPicker) {
AssertIsOnMainThread()
Logger.verbose("")
dismiss(animated: true, completion: nil)
}
public func contactsPicker(_ contactsPicker: ContactsPicker, didSelectContact contact: Contact) {
AssertIsOnMainThread()
owsAssertDebug(contact.cnContactId != nil)
guard let cnContact = contactsManager.cnContact(withId: contact.cnContactId) else {
owsFailDebug("Could not load system contact.")
return
}
Logger.verbose("Contact: \(contact)")
guard let contactShareRecord = OWSContacts.contact(forSystemContact: cnContact) else {
owsFailDebug("Could not convert system contact.")
return
}
var isProfileAvatar = false
var avatarImageData: Data? = contactsManager.avatarData(forCNContactId: cnContact.identifier)
for address in contact.registeredAddresses() {
if avatarImageData != nil {
break
}
avatarImageData = contactsManagerImpl.profileImageDataForAddress(withSneakyTransaction: address)
if avatarImageData != nil {
isProfileAvatar = true
}
}
contactShareRecord.isProfileAvatar = isProfileAvatar
let contactShare = ContactShareViewModel(contactShareRecord: contactShareRecord,
avatarImageData: avatarImageData)
let approveContactShare = ContactShareApprovalViewController(contactShare: contactShare)
approveContactShare.delegate = self
guard let navigationController = contactsPicker.navigationController else {
owsFailDebug("Missing contactsPicker.navigationController.")
return
}
navigationController.pushViewController(approveContactShare, animated: true)
}
public func contactsPicker(_: ContactsPicker, didSelectMultipleContacts contacts: [Contact]) {
AssertIsOnMainThread()
owsFailDebug("Contacts: \(contacts)")
dismiss(animated: true, completion: nil)
}
public func contactsPicker(_: ContactsPicker, shouldSelectContact contact: Contact) -> Bool {
AssertIsOnMainThread()
// Any reason to preclude contacts?
return true
}
}
// MARK: -
extension ConversationViewController: ContactShareApprovalViewControllerDelegate {
public func approveContactShare(_ approveContactShare: ContactShareApprovalViewController,
didApproveContactShare contactShare: ContactShareViewModel) {
AssertIsOnMainThread()
Logger.info("")
dismiss(animated: true) {
self.send(contactShare: contactShare)
}
}
private func send(contactShare: ContactShareViewModel) {
AssertIsOnMainThread()
Logger.verbose("Sending contact share.")
let thread = self.thread
Self.databaseStorage.asyncWrite { transaction in
let didAddToProfileWhitelist = ThreadUtil.addThreadToProfileWhitelistIfEmptyOrPendingRequestAndSetDefaultTimer(thread: thread,
transaction: transaction)
// TODO - in line with QuotedReply and other message attachments, saving should happen as part of sending
// preparation rather than duplicated here and in the SAE
if let avatarImage = contactShare.avatarImage {
contactShare.dbRecord.saveAvatarImage(avatarImage, transaction: transaction)
}
transaction.addAsyncCompletionOnMain {
let message = ThreadUtil.enqueueMessage(withContactShare: contactShare.dbRecord, thread: thread)
self.messageWasSent(message)
if didAddToProfileWhitelist {
self.ensureBannerState()
}
}
}
}
public func approveContactShare(_ approveContactShare: ContactShareApprovalViewController,
didCancelContactShare contactShare: ContactShareViewModel) {
AssertIsOnMainThread()
Logger.info("")
dismiss(animated: true, completion: nil)
}
public func contactApprovalCustomTitle(_ contactApproval: ContactShareApprovalViewController) -> String? {
AssertIsOnMainThread()
return nil
}
public func contactApprovalRecipientsDescription(_ contactApproval: ContactShareApprovalViewController) -> String? {
AssertIsOnMainThread()
Logger.info("")
return databaseStorage.read { transaction in
Self.contactsManager.displayName(for: self.thread, transaction: transaction)
}
}
public func contactApprovalMode(_ contactApproval: ContactShareApprovalViewController) -> ApprovalMode {
AssertIsOnMainThread()
return .send
}
}
// MARK: -
extension ConversationViewController: ContactShareViewHelperDelegate {
public func didCreateOrEditContact() {
AssertIsOnMainThread()
Logger.info("")
self.dismiss(animated: true, completion: nil)
}
}
// MARK: -
extension ConversationViewController: ConversationHeaderViewDelegate {
public func didTapConversationHeaderView(_ conversationHeaderView: ConversationHeaderView) {
AssertIsOnMainThread()
showConversationSettings()
}
}
// MARK: -
extension ConversationViewController: ConversationInputTextViewDelegate {
public func didPasteAttachment(_ attachment: SignalAttachment?) {
AssertIsOnMainThread()
guard let attachment = attachment else {
owsFailDebug("Missing attachment.")
return
}
// If the thing we pasted is sticker-like, send it immediately
// and render it borderless.
if attachment.isBorderless {
tryToSendAttachments([ attachment ], messageBody: nil)
} else {
showApprovalDialog(forAttachment: attachment)
}
}
public func inputTextViewSendMessagePressed() {
AssertIsOnMainThread()
sendButtonPressed()
}
public func textViewDidChange(_ textView: UITextView) {
AssertIsOnMainThread()
if textView.text.strippedOrNil != nil {
typingIndicatorsImpl.didStartTypingOutgoingInput(inThread: thread)
}
}
}
// MARK: -
extension ConversationViewController: ConversationSearchControllerDelegate {
public func didDismissSearchController(_ searchController: UISearchController) {
AssertIsOnMainThread()
Logger.verbose("")
// 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()
Logger.verbose("conversationScreenSearchResultSet: \(resultSet.debugDescription)")
self.lastSearchedText = resultSet?.searchText
loadCoordinator.enqueueReload()
if let resultSet = resultSet {
BenchManager.completeEvent(eventId: resultSet.searchText)
}
}
public func conversationSearchController(_ conversationSearchController: ConversationSearchController,
didSelectMessageId messageId: String) {
AssertIsOnMainThread()
Logger.verbose("messageId: \(messageId)")
ensureInteractionLoadedThenScrollToInteraction(messageId,
onScreenPercentage: 1,
alignment: .centerIfNotEntirelyOnScreen,
isAnimated: true)
BenchManager.completeEvent(eventId: String(format: "Conversation Search Nav: \(messageId)"))
}
}
// MARK: -
extension ConversationViewController: UIDocumentMenuDelegate {
public func documentMenu(_ documentMenu: UIDocumentMenuViewController,
didPickDocumentPicker documentPicker: UIDocumentPickerViewController) {
AssertIsOnMainThread()
documentPicker.delegate = self
dismissKeyBoard()
presentFormSheet(documentPicker, animated: true)
}
}
// MARK: -
extension ConversationViewController: InputAccessoryViewPlaceholderDelegate {
public func inputAccessoryPlaceholderKeyboardIsPresenting(animationDuration: TimeInterval,
animationCurve: UIView.AnimationCurve) {
AssertIsOnMainThread()
handleKeyboardStateChange(animationDuration: animationDuration,
animationCurve: animationCurve)
}
public func inputAccessoryPlaceholderKeyboardDidPresent() {
AssertIsOnMainThread()
updateBottomBarPosition()
updateContentInsets(animated: false)
}
public func inputAccessoryPlaceholderKeyboardIsDismissing(animationDuration: TimeInterval,
animationCurve: UIView.AnimationCurve) {
AssertIsOnMainThread()
handleKeyboardStateChange(animationDuration: animationDuration,
animationCurve: animationCurve)
}
public func inputAccessoryPlaceholderKeyboardDidDismiss() {
AssertIsOnMainThread()
updateBottomBarPosition()
updateContentInsets(animated: false)
}
public func inputAccessoryPlaceholderKeyboardIsDismissingInteractively() {
AssertIsOnMainThread()
// No animation, just follow along with the keyboard.
self.isDismissingInteractively = true
updateBottomBarPosition()
self.isDismissingInteractively = false
}
private func handleKeyboardStateChange(animationDuration: TimeInterval,
animationCurve: UIView.AnimationCurve) {
AssertIsOnMainThread()
if let transitionCoordinator = self.transitionCoordinator,
transitionCoordinator.isInteractive {
return
}
let isAnimatingQuotedReply = viewState.inputToolbar?.isAnimatingQuotedReply ?? false
let duration = isAnimatingQuotedReply ? ConversationInputToolbar.quotedReplyAnimationDuration() : animationDuration
if shouldAnimateKeyboardChanges, duration > 0 {
if hasViewDidAppearEverCompleted {
// Make note of when the keyboard animation will block
// loads from landing during the keyboard animation.
// It isn't safe to block loads for long, so we cap
// how long they will be blocked for.
let keyboardAnimationBlockLoadInterval: TimeInterval = kSecondInterval * 1.0
let animationCompletionDate = Date().addingTimeInterval(keyboardAnimationBlockLoadInterval)
let lastKeyboardAnimationDate = Date().addingTimeInterval(-1.0)
if viewState.lastKeyboardAnimationDate == nil ||
viewState.lastKeyboardAnimationDate?.isBefore(lastKeyboardAnimationDate) == true {
viewState.lastKeyboardAnimationDate = animationCompletionDate
}
}
// The animation curve provided by the keyboard notifications
// is a private value not represented in UIViewAnimationOptions.
// We don't use a block based animation here because it's not
// possible to pass a curve directly to block animations.
UIView.beginAnimations("keyboardStateChange", context: nil)
UIView.setAnimationBeginsFromCurrentState(true)
UIView.setAnimationCurve(animationCurve)
UIView.setAnimationDuration(duration)
updateBottomBarPosition()
// To minimize risk, only animatedly update insets when animating quoted reply for now
if isAnimatingQuotedReply { updateContentInsets(animated: true) }
UIView.commitAnimations()
if !isAnimatingQuotedReply { updateContentInsets(animated: true) }
} else {
updateBottomBarPosition()
updateContentInsets(animated: false)
}
}
}
// 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 = Timer.weakScheduledTimer(withTimeInterval: 5,
target: self,
selector: #selector(scrollingAnimationCompletionTimerDidFire),
userInfo: nil,
repeats: false)
}
@objc
private func scrollingAnimationCompletionTimerDidFire(_ timer: Timer) {
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()
}
func resetForSizeOrOrientationChange() {
AssertIsOnMainThread()
updateConversationStyle()
}
}