// // 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() } }