435 lines
16 KiB
Swift
435 lines
16 KiB
Swift
//
|
|
// Copyright 2021 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import AVFoundation
|
|
import Foundation
|
|
public import SignalServiceKit
|
|
public import SignalUI
|
|
|
|
extension ConversationViewController: AttachmentApprovalViewControllerDelegate {
|
|
|
|
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 func attachmentApproval(_ attachmentApproval: AttachmentApprovalViewController, didRemoveAttachment attachment: SignalAttachment) { }
|
|
|
|
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 attachmentApprovalMentionableAddresses(tx: DBReadTransaction) -> [SignalServiceAddress] {
|
|
supportsMentions ? thread.recipientAddresses(with: SDSDB.shimOnlyBridge(tx)) : []
|
|
}
|
|
|
|
public func attachmentApprovalMentionCacheInvalidationKey() -> String {
|
|
return thread.uniqueId
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationViewController: ContactPickerDelegate {
|
|
|
|
public func contactPickerDidCancel(_: ContactPickerViewController) {
|
|
dismiss(animated: true, completion: nil)
|
|
}
|
|
|
|
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.addAsyncCompletionOnMain {
|
|
ThreadUtil.enqueueMessage(withContactShare: contactShareDraft, thread: thread)
|
|
self.messageWasSent()
|
|
|
|
if didAddToProfileWhitelist {
|
|
self.ensureBannerState()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension ConversationViewController: ConversationHeaderViewDelegate {
|
|
public func didTapConversationHeaderView(_ conversationHeaderView: ConversationHeaderView) {
|
|
AssertIsOnMainThread()
|
|
|
|
showConversationSettings()
|
|
}
|
|
|
|
public 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 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 {
|
|
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: InputAccessoryViewPlaceholderDelegate {
|
|
public func inputAccessoryPlaceholderKeyboardIsPresenting(animationDuration: TimeInterval,
|
|
animationCurve: UIView.AnimationCurve) {
|
|
AssertIsOnMainThread()
|
|
|
|
handleKeyboardStateChange(animationDuration: animationDuration,
|
|
animationCurve: animationCurve)
|
|
}
|
|
|
|
public func inputAccessoryPlaceholderKeyboardDidPresent() {
|
|
AssertIsOnMainThread()
|
|
|
|
updateBottomBarPosition()
|
|
updateContentInsets()
|
|
}
|
|
|
|
public func inputAccessoryPlaceholderKeyboardIsDismissing(animationDuration: TimeInterval,
|
|
animationCurve: UIView.AnimationCurve) {
|
|
AssertIsOnMainThread()
|
|
|
|
handleKeyboardStateChange(animationDuration: animationDuration,
|
|
animationCurve: animationCurve)
|
|
}
|
|
|
|
public func inputAccessoryPlaceholderKeyboardDidDismiss() {
|
|
AssertIsOnMainThread()
|
|
|
|
updateBottomBarPosition()
|
|
updateContentInsets()
|
|
updateScrollingContent()
|
|
}
|
|
|
|
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 isAnimatingHeightChange = viewState.inputToolbar?.isAnimatingHeightChange ?? false
|
|
let duration = isAnimatingHeightChange ? ConversationInputToolbar.heightChangeAnimationDuration : 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 animationCompletionDate = Date().addingTimeInterval(duration)
|
|
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.animate(
|
|
withDuration: duration,
|
|
delay: 0,
|
|
options: animationCurve.asAnimationOptions,
|
|
animations: { [self] in
|
|
updateBottomBarPosition()
|
|
// To minimize risk, only animatedly update insets when animating quoted reply for now
|
|
if isAnimatingHeightChange { updateContentInsets() }
|
|
}
|
|
)
|
|
if !isAnimatingHeightChange { updateContentInsets() }
|
|
} else {
|
|
updateBottomBarPosition()
|
|
updateContentInsets()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
func resetForSizeOrOrientationChange() {
|
|
AssertIsOnMainThread()
|
|
|
|
updateConversationStyle()
|
|
}
|
|
}
|