// // Copyright 2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Photos import SignalServiceKit import SignalUI protocol ConversationInputToolbarDelegate: AnyObject { func sendButtonPressed() func sendSticker(_ sticker: StickerInfo) func presentManageStickersView() func updateToolbarHeight() func isBlockedConversation() -> Bool func isGroup() -> Bool // MARK: Voice Memo func voiceMemoGestureDidStart() func voiceMemoGestureDidLock() func voiceMemoGestureDidComplete() func voiceMemoGestureDidCancel() func voiceMemoGestureWasInterrupted() func sendVoiceMemoDraft(_ draft: VoiceMessageInterruptedDraft) // MARK: Attachments func cameraButtonPressed() func photosButtonPressed() func gifButtonPressed() func fileButtonPressed() func contactButtonPressed() func locationButtonPressed() func paymentButtonPressed() func didSelectRecentPhoto(asset: PHAsset, attachment: SignalAttachment) func showUnblockConversationUI(completion: ((Bool) -> Void)?) } public class ConversationInputToolbar: UIView, LinkPreviewViewDraftDelegate, QuotedReplyPreviewDelegate { private var conversationStyle: ConversationStyle private let spoilerState: SpoilerRenderState private let mediaCache: CVMediaCache private weak var inputToolbarDelegate: ConversationInputToolbarDelegate? init( conversationStyle: ConversationStyle, spoilerState: SpoilerRenderState, mediaCache: CVMediaCache, messageDraft: MessageBody?, quotedReplyDraft: DraftQuotedReplyModel?, editTarget: TSOutgoingMessage?, inputToolbarDelegate: ConversationInputToolbarDelegate, inputTextViewDelegate: ConversationInputTextViewDelegate, mentionDelegate: BodyRangesTextViewDelegate ) { self.conversationStyle = conversationStyle self.spoilerState = spoilerState self.mediaCache = mediaCache self.editTarget = editTarget self.inputToolbarDelegate = inputToolbarDelegate self.linkPreviewFetcher = LinkPreviewFetcher( db: DependenciesBridge.shared.db, linkPreviewManager: DependenciesBridge.shared.linkPreviewManager, schedulers: DependenciesBridge.shared.schedulers ) super.init(frame: .zero) self.linkPreviewFetcher.onStateChange = { [weak self] in self?.updateLinkPreviewView() } createContentsWithMessageDraft( messageDraft, quotedReplyDraft: quotedReplyDraft, inputTextViewDelegate: inputTextViewDelegate, mentionDelegate: mentionDelegate ) NotificationCenter.default.addObserver( self, selector: #selector(applicationDidBecomeActive(notification:)), name: .OWSApplicationDidBecomeActive, object: nil ) NotificationCenter.default.addObserver( self, selector: #selector(keyboardFrameDidChange(notification:)), name: UIResponder.keyboardDidChangeFrameNotification, object: nil ) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } // MARK: Layout public override var intrinsicContentSize: CGSize { // Since we have `self.autoresizingMask = UIViewAutoresizingFlexibleHeight`, we must specify // an intrinsicContentSize. Specifying CGSize.zero causes the height to be determined by autolayout. .zero } public override var frame: CGRect { didSet { guard oldValue.size.height != frame.size.height else { return } inputToolbarDelegate?.updateToolbarHeight() } } public override var bounds: CGRect { didSet { guard abs(oldValue.size.height - bounds.size.height) > 1 else { return } // Compensate for autolayout frame/bounds changes when animating in/out the quoted reply view. // This logic ensures the input toolbar stays pinned to the keyboard visually if isAnimatingHeightChange && inputTextView.isFirstResponder { var frame = frame frame.origin.y = 0 // In this conditional, bounds change is captured in an animation block, which we don't want here. UIView.performWithoutAnimation { self.frame = frame } } inputToolbarDelegate?.updateToolbarHeight() } } func update(conversationStyle: ConversationStyle) { AssertIsOnMainThread() self.conversationStyle = conversationStyle } private var receivedSafeAreaInsets = UIEdgeInsets.zero private enum LayoutMetrics { static let minTextViewHeight: CGFloat = 36 static let maxTextViewHeight: CGFloat = 98 static let maxIPadTextViewHeight: CGFloat = 142 static let minToolbarItemHeight: CGFloat = 52 } private lazy var inputTextView: ConversationInputTextView = { let inputTextView = ConversationInputTextView() inputTextView.textViewToolbarDelegate = self inputTextView.font = .dynamicTypeBody inputTextView.setContentHuggingLow() inputTextView.setCompressionResistanceLow() inputTextView.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "inputTextView") return inputTextView }() private lazy var addOrCancelButton: AddOrCancelButton = { let button = AddOrCancelButton() button.accessibilityLabel = OWSLocalizedString( "ATTACHMENT_LABEL", comment: "Accessibility label for attaching photos" ) button.accessibilityHint = OWSLocalizedString( "ATTACHMENT_HINT", comment: "Accessibility hint describing what you can do with the attachment button" ) button.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "attachmentButton") button.addTarget(self, action: #selector(addOrCancelButtonPressed), for: .touchUpInside) button.autoSetDimensions(to: CGSize(square: LayoutMetrics.minToolbarItemHeight)) button.setContentHuggingHorizontalHigh() button.setCompressionResistanceHorizontalHigh() return button }() private lazy var stickerButton: UIButton = { let button = UIButton(type: .system) button.tintColor = Theme.primaryIconColor button.accessibilityLabel = OWSLocalizedString( "INPUT_TOOLBAR_STICKER_BUTTON_ACCESSIBILITY_LABEL", comment: "accessibility label for the button which shows the sticker picker" ) button.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "stickerButton") button.setImage(UIImage(imageLiteralResourceName: "sticker"), for: .normal) button.addTarget(self, action: #selector(stickerButtonPressed), for: .touchUpInside) button.autoSetDimensions(to: CGSize(width: 40, height: LayoutMetrics.minTextViewHeight)) button.setContentHuggingHorizontalHigh() button.setCompressionResistanceHorizontalHigh() return button }() private lazy var keyboardButton: UIButton = { let button = UIButton(type: .system) button.tintColor = Theme.primaryIconColor button.accessibilityLabel = OWSLocalizedString( "INPUT_TOOLBAR_KEYBOARD_BUTTON_ACCESSIBILITY_LABEL", comment: "accessibility label for the button which shows the regular keyboard instead of sticker picker" ) button.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "keyboardButton") button.setImage(UIImage(imageLiteralResourceName: "keyboard"), for: .normal) button.addTarget(self, action: #selector(keyboardButtonPressed), for: .touchUpInside) button.autoSetDimensions(to: CGSize(width: 40, height: LayoutMetrics.minTextViewHeight)) button.setContentHuggingHorizontalHigh() button.setCompressionResistanceHorizontalHigh() return button }() private lazy var editMessageThumbnailView: UIImageView = { let imageView = UIImageView() imageView.layer.cornerRadius = 4 imageView.clipsToBounds = true imageView.autoSetDimensions(to: .init(square: 20)) return imageView }() private lazy var editMessageLabelWrapper: UIView = { let view = UIView.container() let editIconView = UIImageView(image: Theme.iconImage(.contextMenuEdit)) editIconView.contentMode = .scaleAspectFit editIconView.autoSetDimension(.height, toSize: 16.0) editIconView.setContentHuggingHigh() editIconView.tintColor = Theme.primaryTextColor let editLabel = UILabel() editLabel.text = OWSLocalizedString( "INPUT_TOOLBAR_EDIT_MESSAGE_LABEL", comment: "Label at the top of the input text when editing a message" ) editLabel.font = UIFont.dynamicTypeSubheadlineClamped.semibold() editLabel.textColor = Theme.primaryTextColor let stack = UIStackView(arrangedSubviews: [editIconView, editLabel, editMessageThumbnailView]) stack.axis = .horizontal stack.alignment = .center stack.distribution = .fill view.addSubview(stack) stack.autoPinEdgesToSuperviewEdges(with: .init(hMargin: 10, vMargin: 8)) view.setContentHuggingHorizontalLow() view.setCompressionResistanceHorizontalLow() view.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "editMessageWrapper") return view }() private lazy var quotedReplyWrapper: UIView = { let view = UIView.container() view.setContentHuggingHorizontalLow() view.setCompressionResistanceHorizontalLow() view.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "quotedReplyWrapper") return view }() private lazy var linkPreviewWrapper: UIView = { let view = UIView.container() view.clipsToBounds = true view.setContentHuggingHorizontalLow() view.setCompressionResistanceHorizontalLow() view.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "linkPreviewWrapper") return view }() private lazy var voiceMemoContentView: UIView = { let view = UIView.container() view.isHidden = true view.setContentHuggingHorizontalLow() view.setCompressionResistanceHorizontalLow() view.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "voiceMemoContentView") return view }() private lazy var rightEdgeControlsView: RightEdgeControlsView = { let view = RightEdgeControlsView() view.sendButton.addTarget(self, action: #selector(sendButtonPressed), for: .touchUpInside) view.cameraButton.addTarget(self, action: #selector(cameraButtonPressed), for: .touchUpInside) // We want to be permissive about the voice message gesture, so we hang // the long press GR on the button's wrapper, not the button itself. let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleVoiceMemoLongPress(gesture:))) longPressGestureRecognizer.minimumPressDuration = 0 view.voiceMemoButton.addGestureRecognizer(longPressGestureRecognizer) return view }() private lazy var suggestedStickerView: StickerHorizontalListView = { let suggestedStickerSize: CGFloat = 48 let suggestedStickerSpacing: CGFloat = 12 let stickerListContentInset = UIEdgeInsets( hMargin: OWSTableViewController2.defaultHOuterMargin, vMargin: suggestedStickerSpacing ) let view = StickerHorizontalListView(cellSize: suggestedStickerSize, cellInset: 0, spacing: suggestedStickerSpacing) view.backgroundColor = Theme.conversationButtonBackgroundColor view.contentInset = stickerListContentInset view.autoSetDimension(.height, toSize: suggestedStickerSize + stickerListContentInset.bottom + stickerListContentInset.top) return view }() private let suggestedStickerWrapper = UIView.container() private let messageContentView = UIView.container() private let mainPanelView: UIView = { let view = UIView() view.layoutMargins = UIEdgeInsets(hMargin: OWSTableViewController2.defaultHOuterMargin - 16, vMargin: 0) return view }() private let mainPanelWrapperView = UIView.container() private var isConfigurationComplete = false private var textViewHeight: CGFloat = 0 private var textViewHeightConstraint: NSLayoutConstraint? class var heightChangeAnimationDuration: TimeInterval { 0.25 } private(set) var isAnimatingHeightChange = false private var layoutConstraints: [NSLayoutConstraint]? private func createContentsWithMessageDraft( _ messageDraft: MessageBody?, quotedReplyDraft: DraftQuotedReplyModel?, inputTextViewDelegate: ConversationInputTextViewDelegate, mentionDelegate: BodyRangesTextViewDelegate ) { // The input toolbar should *always* be laid out left-to-right, even when using // a right-to-left language. The convention for messaging apps is for the send // button to always be to the right of the input field, even in RTL layouts. // This means, in most places you'll want to pin deliberately to left/right // instead of leading/trailing. You'll also want to the semanticContentAttribute // to ensure horizontal stack views layout left-to-right. layoutMargins = .zero autoresizingMask = .flexibleHeight isUserInteractionEnabled = true // NOTE: Don't set inputTextViewDelegate until configuration is complete. inputTextView.mentionDelegate = mentionDelegate inputTextView.inputTextViewDelegate = inputTextViewDelegate textViewHeightConstraint = inputTextView.autoSetDimension(.height, toSize: LayoutMetrics.minTextViewHeight) editMessageLabelWrapper.isHidden = !shouldShowEditUI quotedReplyWrapper.isHidden = quotedReplyDraft == nil self.quotedReplyDraft = quotedReplyDraft // Vertical stack of message component views in the center: Link Preview, Reply Quote, Text Input View. let messageContentVStack = UIStackView(arrangedSubviews: [ editMessageLabelWrapper, quotedReplyWrapper, linkPreviewWrapper, inputTextView ]) messageContentVStack.axis = .vertical messageContentVStack.alignment = .fill messageContentVStack.setContentHuggingHorizontalLow() messageContentVStack.setCompressionResistanceHorizontalLow() // Voice Message UI is added to the same vertical stack, but not as arranged subview. // The view is constrained to text input view's edges. messageContentVStack.addSubview(voiceMemoContentView) voiceMemoContentView.autoPinEdges(toEdgesOf: inputTextView) // Wrap vertical stack into a view with rounded corners. let vStackRoundingView = UIView.container() vStackRoundingView.backgroundColor = Theme.isDarkThemeEnabled ? UIColor(white: 1, alpha: 0.16) : UIColor(white: 0, alpha: 0.1) vStackRoundingView.layer.cornerRadius = 18 vStackRoundingView.clipsToBounds = true vStackRoundingView.addSubview(messageContentVStack) messageContentVStack.autoPinEdgesToSuperviewEdges() messageContentView.addSubview(vStackRoundingView) // This margin defines amount of padding above and below visible text input box. let textViewVInset = 0.5 * (LayoutMetrics.minToolbarItemHeight - LayoutMetrics.minTextViewHeight) vStackRoundingView.autoPinWidthToSuperview() vStackRoundingView.autoPinHeightToSuperview(withMargin: textViewVInset) // Sticker button: looks like is a part of the text input view, // but is reality it located a couple levels up in the view hierarchy. vStackRoundingView.addSubview(stickerButton) vStackRoundingView.addSubview(keyboardButton) stickerButton.autoPinEdge(toSuperviewEdge: .trailing, withInset: 4) stickerButton.autoAlignAxis(.horizontal, toSameAxisOf: inputTextView) keyboardButton.autoAlignAxis(.vertical, toSameAxisOf: stickerButton) keyboardButton.autoAlignAxis(.horizontal, toSameAxisOf: stickerButton) // Horizontal Stack: Attachment button, message components, Camera|VoiceNote|Send button. // // + Attachment button: pinned to the bottom left corner. mainPanelView.addSubview(addOrCancelButton) addOrCancelButton.autoPinEdge(toSuperviewMargin: .left) addOrCancelButton.autoPinEdge(toSuperviewEdge: .bottom) // Camera | Voice Message | Send: pinned to the bottom right corner. mainPanelView.addSubview(rightEdgeControlsView) rightEdgeControlsView.autoPinEdge(toSuperviewMargin: .right) rightEdgeControlsView.autoPinEdge(toSuperviewEdge: .bottom) // Message components view: pinned to attachment button on the left, Camera button on the right, // taking entire superview's height. mainPanelView.addSubview(messageContentView) messageContentView.autoPinHeightToSuperview() messageContentView.autoPinEdge(.right, to: .left, of: rightEdgeControlsView) updateMessageContentViewLeftEdgeConstraint(isViewHidden: false) // Put main panel view into a wrapper view that would also contain background view. mainPanelWrapperView.addSubview(mainPanelView) mainPanelView.autoPinEdgesToSuperviewEdges() // "Suggested Stickers" panel is created as needed and will placed in a wrapper view to allow for slide in / slide out animation. suggestedStickerWrapper.clipsToBounds = true updateSuggestedStickersViewConstraint() let outerVStack = UIStackView(arrangedSubviews: [ suggestedStickerWrapper, mainPanelWrapperView ] ) outerVStack.axis = .vertical addSubview(outerVStack) outerVStack.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom) outerVStack.autoPinEdge(toSuperviewSafeArea: .bottom) // When presenting or dismissing the keyboard, there may be a slight // gap between the keyboard and the bottom of the input bar during // the animation. Extend the background below the toolbar's bounds // by this much to mask that extra space. let backgroundExtension: CGFloat = 500 let extendedBackgroundView = UIView() if UIAccessibility.isReduceTransparencyEnabled { extendedBackgroundView.backgroundColor = Theme.toolbarBackgroundColor } else { extendedBackgroundView.backgroundColor = Theme.toolbarBackgroundColor.withAlphaComponent(OWSNavigationBar.backgroundBlurMutingFactor) let blurEffectView = UIVisualEffectView(effect: Theme.barBlurEffect) // Alter the visual effect view's tint to match our background color // so the input bar, when over a solid color background matching `toolbarBackgroundColor`, // exactly matches the background color. This is brittle, but there is no way to get // this behavior from UIVisualEffectView otherwise. if let tintingView = blurEffectView.subviews.first(where: { String(describing: type(of: $0)) == "_UIVisualEffectSubview" }) { tintingView.backgroundColor = extendedBackgroundView.backgroundColor } extendedBackgroundView.addSubview(blurEffectView) blurEffectView.autoPinEdgesToSuperviewEdges() } mainPanelWrapperView.insertSubview(extendedBackgroundView, at: 0) extendedBackgroundView.autoPinWidthToSuperview() extendedBackgroundView.autoPinEdge(toSuperviewEdge: .top) extendedBackgroundView.autoPinEdge(toSuperviewEdge: .bottom, withInset: -backgroundExtension) // See comments on updateLayout(withSafeAreaInsets:). messageContentVStack.insetsLayoutMarginsFromSafeArea = false messageContentView.insetsLayoutMarginsFromSafeArea = false mainPanelWrapperView.insetsLayoutMarginsFromSafeArea = false outerVStack.insetsLayoutMarginsFromSafeArea = false insetsLayoutMarginsFromSafeArea = false messageContentVStack.preservesSuperviewLayoutMargins = false messageContentView.preservesSuperviewLayoutMargins = false mainPanelWrapperView.preservesSuperviewLayoutMargins = false preservesSuperviewLayoutMargins = false setMessageBody(messageDraft, animated: false, doLayout: false) isConfigurationComplete = true } @discardableResult class func setView(_ view: UIView, hidden isHidden: Bool, usingAnimator animator: UIViewPropertyAnimator?) -> Bool { let viewAlpha: CGFloat = isHidden ? 0 : 1 guard viewAlpha != view.alpha else { return false } let viewUpdateBlock = { view.alpha = viewAlpha view.transform = isHidden ? .scale(0.1) : .identity } if let animator { animator.addAnimations(viewUpdateBlock) } else { viewUpdateBlock() } return true } private func ensureButtonVisibility(withAnimation isAnimated: Bool, doLayout: Bool) { var hasLayoutChanged = false var rightEdgeControlsState = rightEdgeControlsView.state // Voice Memo UI. if isShowingVoiceMemoUI { voiceMemoContentView.setIsHidden(false, animated: isAnimated) // Send button would be visible if there's voice recording in progress in "locked" state. let hideSendButton = voiceMemoRecordingState == .recordingHeld || voiceMemoRecordingState == .idle rightEdgeControlsState = hideSendButton ? .hiddenSendButton : .sendButton } else { voiceMemoContentView.setIsHidden(true, animated: isAnimated) // Show Send button instead of Camera and Voice Message buttons only when text input isn't empty. let hasNonWhitespaceTextInput = !inputTextView.trimmedText.isEmpty || shouldShowEditUI rightEdgeControlsState = hasNonWhitespaceTextInput ? .sendButton : .default } let animator: UIViewPropertyAnimator? if isAnimated { animator = UIViewPropertyAnimator(duration: 0.25, springDamping: 0.645, springResponse: 0.25) } else { animator = nil } // Attachment Button let hideAttachmentButton = isShowingVoiceMemoUI if setAddOrCancelButtonHidden(hideAttachmentButton, usingAnimator: animator) { hasLayoutChanged = true } // Attachment button has more complex animations and cannot be grouped with the rest. let addOrCancelButtonAppearance: AddOrCancelButton.Appearance = { if shouldShowEditUI { return .close } else { return desiredKeyboardType == .attachment ? .close : .add } }() addOrCancelButton.setAppearance(addOrCancelButtonAppearance, usingAnimator: animator) // Show / hide Sticker or Keyboard buttons inside of the text input field. // Either buttons are only visible if there's no any text input, including whitespace-only. let hideStickerOrKeyboardButton = shouldShowEditUI || !inputTextView.untrimmedText.isEmpty || isShowingVoiceMemoUI || quotedReplyDraft != nil let hideStickerButton = hideStickerOrKeyboardButton || desiredKeyboardType == .sticker let hideKeyboardButton = hideStickerOrKeyboardButton || !hideStickerButton ConversationInputToolbar.setView(stickerButton, hidden: hideStickerButton, usingAnimator: animator) ConversationInputToolbar.setView(keyboardButton, hidden: hideKeyboardButton, usingAnimator: animator) // Hide text input field if Voice Message UI is presented or make it visible otherwise. // Do not change "isHidden" because that'll cause inputTextView to lose focus. let inputTextViewAlpha: CGFloat = isShowingVoiceMemoUI ? 0 : 1 if let animator { animator.addAnimations { self.inputTextView.alpha = inputTextViewAlpha } } else { inputTextView.alpha = inputTextViewAlpha } if rightEdgeControlsView.state != rightEdgeControlsState { hasLayoutChanged = true if let animator { // `state` in implicitly animatable. animator.addAnimations { self.rightEdgeControlsView.state = rightEdgeControlsState } } else { rightEdgeControlsView.state = rightEdgeControlsState } } if let animator { if doLayout && hasLayoutChanged { animator.addAnimations { self.mainPanelView.setNeedsLayout() self.mainPanelView.layoutIfNeeded() } } animator.startAnimation() } else { if doLayout && hasLayoutChanged { self.mainPanelView.setNeedsLayout() self.mainPanelView.layoutIfNeeded() } } updateSuggestedStickers(animated: isAnimated) } private var messageContentViewLeftEdgeConstraint: NSLayoutConstraint? private func updateMessageContentViewLeftEdgeConstraint(isViewHidden: Bool) { if let messageContentViewLeftEdgeConstraint { removeConstraint(messageContentViewLeftEdgeConstraint) } let constraint: NSLayoutConstraint if isViewHidden { constraint = messageContentView.leftAnchor.constraint( equalTo: mainPanelView.layoutMarginsGuide.leftAnchor, constant: 16 ) } else { constraint = messageContentView.leftAnchor.constraint(equalTo: addOrCancelButton.rightAnchor) } addConstraint(constraint) messageContentViewLeftEdgeConstraint = constraint } private func setAddOrCancelButtonHidden(_ isHidden: Bool, usingAnimator animator: UIViewPropertyAnimator?) -> Bool { guard ConversationInputToolbar.setView(addOrCancelButton, hidden: isHidden, usingAnimator: animator) else { return false } updateMessageContentViewLeftEdgeConstraint(isViewHidden: isHidden) return true } func updateLayout(withSafeAreaInsets safeAreaInsets: UIEdgeInsets) -> Bool { let insetsChanged = receivedSafeAreaInsets != safeAreaInsets let needLayoutConstraints = layoutConstraints == nil guard insetsChanged || needLayoutConstraints else { return false } // iOS doesn't always update the safeAreaInsets correctly & in a timely // way for the inputAccessoryView after a orientation change. The best // workaround appears to be to use the safeAreaInsets from // ConversationViewController's view. ConversationViewController updates // this input toolbar using updateLayoutWithIsLandscape:. receivedSafeAreaInsets = safeAreaInsets return true } func scrollToBottom() { inputTextView.scrollToBottom() } func updateFontSizes() { inputTextView.font = .dynamicTypeBody } // MARK: Right Edge Buttons private class RightEdgeControlsView: UIView { enum State { case `default` case sendButton case hiddenSendButton } private var _state: State = .default var state: State { get { _state } set { guard _state != newValue else { return } _state = newValue configureViewsForState(_state) invalidateIntrinsicContentSize() } } static let sendButtonHMargin: CGFloat = 4 static let cameraButtonHMargin: CGFloat = 8 lazy var sendButton: UIButton = { let button = UIButton(type: .system) button.accessibilityLabel = MessageStrings.sendButton button.adjustsImageWhenDisabled = true button.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "sendButton") button.setImage(UIImage(imageLiteralResourceName: "send-blue-28"), for: .normal) button.bounds.size = CGSize(width: 48, height: LayoutMetrics.minToolbarItemHeight) return button }() lazy var cameraButton: UIButton = { let button = UIButton(type: .system) button.tintColor = Theme.primaryIconColor button.accessibilityLabel = OWSLocalizedString( "CAMERA_BUTTON_LABEL", comment: "Accessibility label for camera button." ) button.accessibilityHint = OWSLocalizedString( "CAMERA_BUTTON_HINT", comment: "Accessibility hint describing what you can do with the camera button" ) button.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "cameraButton") button.setImage(Theme.iconImage(.buttonCamera), for: .normal) button.bounds.size = CGSize(width: 40, height: LayoutMetrics.minToolbarItemHeight) return button }() lazy var voiceMemoButton: UIButton = { let button = UIButton(type: .system) button.tintColor = Theme.primaryIconColor button.accessibilityLabel = OWSLocalizedString( "INPUT_TOOLBAR_VOICE_MEMO_BUTTON_ACCESSIBILITY_LABEL", comment: "accessibility label for the button which records voice memos" ) button.accessibilityHint = OWSLocalizedString( "INPUT_TOOLBAR_VOICE_MEMO_BUTTON_ACCESSIBILITY_HINT", comment: "accessibility hint for the button which records voice memos" ) button.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "voiceMemoButton") button.setImage(Theme.iconImage(.buttonMicrophone), for: .normal) button.bounds.size = CGSize(width: 40, height: LayoutMetrics.minToolbarItemHeight) return button }() override init(frame: CGRect) { super.init(frame: frame) for button in [ cameraButton, voiceMemoButton, sendButton ] { button.setContentHuggingHorizontalHigh() button.setCompressionResistanceHorizontalHigh() addSubview(button) } configureViewsForState(state) setContentHuggingHigh() setCompressionResistanceHigh() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func layoutSubviews() { super.layoutSubviews() sendButton.center = CGPoint( x: bounds.maxX - Self.sendButtonHMargin - 0.5 * sendButton.bounds.width, y: bounds.midY ) switch state { case .default: cameraButton.center = CGPoint( x: bounds.minX + Self.cameraButtonHMargin + 0.5 * cameraButton.bounds.width, y: bounds.midY ) voiceMemoButton.center = sendButton.center case .sendButton, .hiddenSendButton: cameraButton.center = sendButton.center voiceMemoButton.center = sendButton.center } } private func configureViewsForState(_ state: State) { switch state { case .default: cameraButton.transform = .identity cameraButton.alpha = 1 voiceMemoButton.transform = .identity voiceMemoButton.alpha = 1 sendButton.transform = .scale(0.1) sendButton.alpha = 0 case .sendButton, .hiddenSendButton: cameraButton.transform = .scale(0.1) cameraButton.alpha = 0 voiceMemoButton.transform = .scale(0.1) voiceMemoButton.alpha = 0 sendButton.transform = .identity sendButton.alpha = state == .hiddenSendButton ? 0 : 1 } } override var intrinsicContentSize: CGSize { let width: CGFloat = { switch state { case .default: return cameraButton.width + voiceMemoButton.width + 2 * Self.cameraButtonHMargin case .sendButton, .hiddenSendButton: return sendButton.width + 2 * Self.sendButtonHMargin } }() return CGSize(width: width, height: LayoutMetrics.minToolbarItemHeight) } } // MARK: Add/Cancel Button private class AddOrCancelButton: UIButton { private let roundedCornersBackground: UIView = { let view = UIView() view.backgroundColor = .init(rgbHex: 0x3B3B3B) view.clipsToBounds = true view.layer.cornerRadius = 14 view.isUserInteractionEnabled = false return view }() private let iconImageView = UIImageView(image: UIImage(imageLiteralResourceName: "plus")) private override init(frame: CGRect) { super.init(frame: frame) addSubview(roundedCornersBackground) roundedCornersBackground.autoCenterInSuperview() roundedCornersBackground.autoSetDimensions(to: CGSize(square: 28)) updateImageColorAndBackground() addSubview(iconImageView) iconImageView.autoCenterInSuperview() updateImageTransform() } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override var isHighlighted: Bool { didSet { // When user releases their finger appearance change animations will be fired. // We don't want changes performed by this method to interfere with animations. guard !isAnimatingAppearance else { return } // Mimic behavior of a standard system button. let opacity: CGFloat = isHighlighted ? (Theme.isDarkThemeEnabled ? 0.4 : 0.2) : 1 switch appearance { case .add: iconImageView.alpha = opacity case .close: roundedCornersBackground.alpha = opacity } } } enum Appearance { case add case close } private var _appearance: Appearance = .add private var isAnimatingAppearance = false var appearance: Appearance { get { _appearance } set { setAppearance(newValue, usingAnimator: nil) } } func setAppearance(_ appearance: Appearance, usingAnimator animator: UIViewPropertyAnimator?) { guard appearance != _appearance else { return } _appearance = appearance guard let animator else { updateImageColorAndBackground() updateImageTransform() return } isAnimatingAppearance = true animator.addAnimations({ self.updateImageColorAndBackground() }, delayFactor: appearance == .add ? 0 : 0.2 ) animator.addAnimations { self.updateImageTransform() } animator.addCompletion { _ in self.isAnimatingAppearance = false } } private func updateImageColorAndBackground() { switch appearance { case .add: iconImageView.alpha = 1 iconImageView.tintColor = Theme.primaryIconColor roundedCornersBackground.alpha = 0 roundedCornersBackground.transform = .scale(0.05) case .close: iconImageView.alpha = 1 iconImageView.tintColor = .white roundedCornersBackground.alpha = 1 roundedCornersBackground.transform = .identity } } private func updateImageTransform() { switch appearance { case .add: iconImageView.transform = .identity case .close: iconImageView.transform = .rotate(1.5 * .halfPi) } } } // MARK: Message Body var hasUnsavedDraft: Bool { let currentDraft = messageBodyForSending ?? .empty if let editTarget { let editTargetMessage = MessageBody( text: editTarget.body ?? "", ranges: editTarget.bodyRanges ?? .empty ) return currentDraft != editTargetMessage } return !currentDraft.isEmpty } var messageBodyForSending: MessageBody? { inputTextView.messageBodyForSending } func setMessageBody(_ messageBody: MessageBody?, animated: Bool, doLayout: Bool = true) { inputTextView.setMessageBody(messageBody, txProvider: DependenciesBridge.shared.db.readTxProvider) // It's important that we set the textViewHeight before // doing any animation in `ensureButtonVisibility(withAnimation:doLayout)` // Otherwise, the resultant keyboard frame posted in `keyboardWillChangeFrame` // could reflect the inputTextView height *before* the new text was set. // // This bug was surfaced to the user as: // - have a quoted reply draft in the input toolbar // - type a multiline message // - hit send // - quoted reply preview and message text is cleared // - input toolbar is shrunk to it's expected empty-text height // - *but* the conversation's bottom content inset was too large. Specifically, it was // still sized as if the input textview was multiple lines. // Presumably this bug only surfaced when an animation coincides with more complicated layout // changes (in this case while simultaneous with removing quoted reply subviews, hiding the // wrapper view *and* changing the height of the input textView ensureTextViewHeight() updateInputLinkPreview() if let text = messageBody?.text, !text.isEmpty { clearDesiredKeyboard() } ensureButtonVisibility(withAnimation: animated, doLayout: doLayout) } func ensureTextViewHeight() { updateHeightWithTextView(inputTextView) } func acceptAutocorrectSuggestion() { inputTextView.acceptAutocorrectSuggestion() } func clearTextMessage(animated: Bool) { editTarget = nil setMessageBody(nil, animated: animated) inputTextView.undoManager?.removeAllActions() } // MARK: Edit Message var shouldShowEditUI: Bool { editTarget != nil } var editTarget: TSOutgoingMessage? { didSet { let animateChanges = window != nil // Show the 'editing' tag if let editTarget = editTarget { // Fetch the original text (including any oversized text attachments) let componentState = databaseStorage.read { tx in CVLoader.buildStandaloneComponentState( interaction: editTarget, spoilerState: SpoilerRenderState(), transaction: tx) } let messageBody: MessageBody let ranges = editTarget.bodyRanges ?? .empty switch componentState?.bodyText?.displayableText?.fullTextValue { case .attributedText(let string): messageBody = MessageBody(text: string.string, ranges: ranges) case .messageBody(let body): messageBody = body.asMessageBodyForForwarding(preservingAllMentions: true) case .text(let text): messageBody = MessageBody(text: text, ranges: ranges) case .none: messageBody = MessageBody(text: "", ranges: .empty) } self.setMessageBody(messageBody, animated: true) showEditMessageView(animated: animateChanges) } else if oldValue != nil { editThumbnail = nil self.setMessageBody(nil, animated: true) hideEditMessageView(animated: animateChanges) } } } var editThumbnail: UIImage? { get { editMessageThumbnailView.image } set { editMessageThumbnailView.image = newValue } } private func showEditMessageView(animated: Bool) { toggleMessageComponentVisibility(hide: false, component: editMessageLabelWrapper, animated: animated) rightEdgeControlsView.sendButton.isEnabled = false } private func hideEditMessageView(animated: Bool) { owsAssertDebug(editTarget == nil) toggleMessageComponentVisibility(hide: true, component: editMessageLabelWrapper, animated: animated) rightEdgeControlsView.sendButton.isEnabled = true } // MARK: Quoted Reply var quotedReplyDraft: DraftQuotedReplyModel? { didSet { guard oldValue != quotedReplyDraft else { return } layer.removeAllAnimations() let animateChanges = window != nil if quotedReplyDraft != nil { showQuotedReplyView(animated: animateChanges) } else { hideQuotedReplyView(animated: animateChanges) } // This would show / hide Stickers|Keyboard button. ensureButtonVisibility(withAnimation: true, doLayout: false) clearDesiredKeyboard() } } private func showQuotedReplyView(animated: Bool) { guard let quotedReplyDraft else { owsFailDebug("quotedReply == nil") return } let quotedMessagePreview = QuotedReplyPreview( quotedReplyDraft: quotedReplyDraft, conversationStyle: conversationStyle, spoilerState: spoilerState ) quotedMessagePreview.delegate = self quotedMessagePreview.setContentHuggingHorizontalLow() quotedMessagePreview.setCompressionResistanceHorizontalLow() quotedMessagePreview.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "quotedMessagePreview") quotedReplyWrapper.addSubview(quotedMessagePreview) quotedMessagePreview.autoPinEdgesToSuperviewEdges() updateInputLinkPreview() toggleMessageComponentVisibility(hide: false, component: quotedReplyWrapper, animated: animated) } private func hideQuotedReplyView(animated: Bool) { owsAssertDebug(quotedReplyDraft == nil) toggleMessageComponentVisibility(hide: true, component: quotedReplyWrapper, animated: animated) { _ in self.quotedReplyWrapper.removeAllSubviews() } } private func toggleMessageComponentVisibility( hide: Bool, component: UIView, animated: Bool, completion: ((Bool) -> Void)? = nil ) { if animated, component.isHidden != hide { isAnimatingHeightChange = true UIView.animate( withDuration: ConversationInputToolbar.heightChangeAnimationDuration, animations: { component.isHidden = hide }, completion: { completed in self.isAnimatingHeightChange = false completion?(completed) } ) } else { component.isHidden = hide completion?(true) } } var draftReply: ThreadReplyInfo? { guard let quotedReplyDraft else { return nil } guard let originalMessageTimestamp = quotedReplyDraft.originalMessageTimestamp, let aci = quotedReplyDraft.originalMessageAuthorAddress.aci else { return nil } return ThreadReplyInfo(timestamp: originalMessageTimestamp, author: aci) } func quotedReplyPreviewDidPressCancel(_ preview: QuotedReplyPreview) { quotedReplyDraft = nil } // MARK: Link Preview private let linkPreviewFetcher: LinkPreviewFetcher private var linkPreviewView: LinkPreviewView? private var isLinkPreviewHidden = true private var linkPreviewConstraint: NSLayoutConstraint? private func updateLinkPreviewConstraint() { guard let linkPreviewView else { owsFailDebug("linkPreviewView == nil") return } if let linkPreviewConstraint { removeConstraint(linkPreviewConstraint) } // To hide link preview I constrain both top and bottom edges of the linkPreviewWrapper // to top edge of linkPreviewView, effectively making linkPreviewWrapper a zero height view. // But since linkPreviewView keeps it size animating this change results in a nice slide in/out animation. // To make link preview visible I constrain linkPreviewView to linkPreviewWrapper normally. let linkPreviewContstraint: NSLayoutConstraint if isLinkPreviewHidden { linkPreviewContstraint = linkPreviewWrapper.bottomAnchor.constraint(equalTo: linkPreviewView.topAnchor) } else { linkPreviewContstraint = linkPreviewWrapper.bottomAnchor.constraint(equalTo: linkPreviewView.bottomAnchor) } addConstraint(linkPreviewContstraint) self.linkPreviewConstraint = linkPreviewContstraint } var linkPreviewDraft: OWSLinkPreviewDraft? { AssertIsOnMainThread() return linkPreviewFetcher.linkPreviewDraftIfLoaded } private func updateInputLinkPreview() { AssertIsOnMainThread() let messageBody = messageBodyForSending ?? .init(text: "", ranges: .empty) linkPreviewFetcher.update(messageBody, enableIfEmpty: true) } private func updateLinkPreviewView() { let animateChanges = window != nil switch linkPreviewFetcher.currentState { case .none, .failed: hideLinkPreviewView(animated: animateChanges) case .loading: ensureLinkPreviewView(withState: LinkPreviewLoading(linkType: .preview)) case .loaded(let linkPreviewDraft): ensureLinkPreviewView(withState: LinkPreviewDraft(linkPreviewDraft: linkPreviewDraft)) } } private func ensureLinkPreviewView(withState state: LinkPreviewState) { AssertIsOnMainThread() let linkPreviewView: LinkPreviewView if let existingLinkPreviewView = self.linkPreviewView { linkPreviewView = existingLinkPreviewView } else { linkPreviewView = LinkPreviewView(draftDelegate: self) linkPreviewWrapper.addSubview(linkPreviewView) // See comment in `updateLinkPreviewConstraint` why `bottom` isn't constrained. linkPreviewView.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom) self.linkPreviewView = linkPreviewView updateLinkPreviewConstraint() } linkPreviewView.configureForNonCVC( state: state, isDraft: true, hasAsymmetricalRounding: quotedReplyDraft == nil ) UIView.performWithoutAnimation { self.mainPanelView.layoutIfNeeded() } guard isLinkPreviewHidden else { return } isLinkPreviewHidden = false let animateChanges = window != nil guard animateChanges else { updateLinkPreviewConstraint() layoutIfNeeded() return } isAnimatingHeightChange = true let animator = UIViewPropertyAnimator( duration: ConversationInputToolbar.heightChangeAnimationDuration, springDamping: 0.9, springResponse: 0.3 ) animator.addAnimations { self.updateLinkPreviewConstraint() self.layoutIfNeeded() } animator.addCompletion { _ in self.isAnimatingHeightChange = false } animator.startAnimation() } private func hideLinkPreviewView(animated: Bool) { AssertIsOnMainThread() guard !isLinkPreviewHidden else { return } isLinkPreviewHidden = true guard animated else { updateLinkPreviewConstraint() layoutIfNeeded() return } isAnimatingHeightChange = true let animator = UIViewPropertyAnimator( duration: ConversationInputToolbar.heightChangeAnimationDuration, springDamping: 0.9, springResponse: 0.3 ) animator.addAnimations { self.updateLinkPreviewConstraint() self.layoutIfNeeded() } animator.addCompletion { _ in self.isAnimatingHeightChange = false } animator.startAnimation() } // MARK: LinkPreviewViewDraftDelegate public func linkPreviewDidCancel() { AssertIsOnMainThread() linkPreviewFetcher.disable() } // MARK: Stickers private let suggestedStickerViewCache = StickerViewCache(maxSize: 12) private var suggestedStickerInfos: [StickerInfo] = [] private var suggestedStickersViewConstraint: NSLayoutConstraint? private func updateSuggestedStickersViewConstraint() { if let suggestedStickersViewConstraint { removeConstraint(suggestedStickersViewConstraint) } // suggestedStickerView is created lazily and isn't accessed until it is needed. // Set wrapper's height to zero if it is not needed yet. guard suggestedStickerWrapper.subviews.count > 0 else { let zeroHeightConstraint = suggestedStickerWrapper.heightAnchor.constraint(equalToConstant: 0) addConstraint(zeroHeightConstraint) suggestedStickersViewConstraint = zeroHeightConstraint return } // To hide suggested stickers panel I constrain both top and bottom edges of the `suggestedStickerView` // to the top edge of its wrapper view, effectively making that wrapper view a zero height view. // `suggestedStickerView` has a fixed height so animating this change results in a nice slide in/out animation. // `suggestedStickerView` is made visible by constraining all of its edges to wrapper view normally. let constraint: NSLayoutConstraint if isSuggestedStickersViewHidden { constraint = suggestedStickerWrapper.bottomAnchor.constraint(equalTo: suggestedStickerView.topAnchor) } else { constraint = suggestedStickerWrapper.bottomAnchor.constraint(equalTo: suggestedStickerView.bottomAnchor) } addConstraint(constraint) suggestedStickersViewConstraint = constraint } private var isSuggestedStickersViewHidden = true private func updateSuggestedStickers(animated: Bool) { let suggestedStickerInfos = StickerManager.shared.suggestedStickers(forTextInput: inputTextView.trimmedText).map { $0.info } guard suggestedStickerInfos != self.suggestedStickerInfos else { return } self.suggestedStickerInfos = suggestedStickerInfos guard !suggestedStickerInfos.isEmpty else { hideSuggestedStickersView(animated: animated) return } showSuggestedStickersView(animated: animated) } private func showSuggestedStickersView(animated: Bool) { owsAssertDebug(!suggestedStickerInfos.isEmpty) if suggestedStickerView.superview == nil { suggestedStickerWrapper.addSubview(suggestedStickerView) suggestedStickerView.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom) UIView.performWithoutAnimation { suggestedStickerWrapper.layoutIfNeeded() } } suggestedStickerView.items = suggestedStickerInfos.map { stickerInfo in StickerHorizontalListViewItemSticker( stickerInfo: stickerInfo, didSelectBlock: { [weak self] in self?.didSelectSuggestedSticker(stickerInfo) }, cache: suggestedStickerViewCache ) } guard isSuggestedStickersViewHidden else { return } isSuggestedStickersViewHidden = false UIView.performWithoutAnimation { self.suggestedStickerView.layoutIfNeeded() self.suggestedStickerView.contentOffset = CGPoint( x: -self.suggestedStickerView.contentInset.left, y: -self.suggestedStickerView.contentInset.top ) } guard animated else { updateSuggestedStickersViewConstraint() return } isAnimatingHeightChange = true let animator = UIViewPropertyAnimator( duration: ConversationInputToolbar.heightChangeAnimationDuration, springDamping: 0.9, springResponse: 0.3 ) animator.addAnimations { self.updateSuggestedStickersViewConstraint() self.layoutIfNeeded() } animator.addCompletion { _ in self.isAnimatingHeightChange = false } animator.startAnimation() } private func hideSuggestedStickersView(animated: Bool) { guard !isSuggestedStickersViewHidden else { return } isSuggestedStickersViewHidden = true guard animated else { updateSuggestedStickersViewConstraint() return } isAnimatingHeightChange = true let animator = UIViewPropertyAnimator( duration: ConversationInputToolbar.heightChangeAnimationDuration, springDamping: 0.9, springResponse: 0.3 ) animator.addAnimations { self.updateSuggestedStickersViewConstraint() self.layoutIfNeeded() } animator.addCompletion { _ in self.isAnimatingHeightChange = false } animator.startAnimation() } private func didSelectSuggestedSticker(_ stickerInfo: StickerInfo) { AssertIsOnMainThread() clearTextMessage(animated: true) inputToolbarDelegate?.sendSticker(stickerInfo) } // MARK: Voice Memo private enum VoiceMemoRecordingState { case idle case recordingHeld case recordingLocked case draft } private var voiceMemoRecordingState: VoiceMemoRecordingState = .idle { didSet { guard oldValue != voiceMemoRecordingState else { return } ensureButtonVisibility(withAnimation: true, doLayout: true) } } private var voiceMemoGestureStartLocation: CGPoint? private var isShowingVoiceMemoUI: Bool = false { didSet { guard isShowingVoiceMemoUI != oldValue else { return } ensureButtonVisibility(withAnimation: true, doLayout: true) } } var voiceMemoDraft: VoiceMessageInterruptedDraft? private var voiceMemoStartTime: Date? private var voiceMemoUpdateTimer: Timer? private var voiceMemoTooltipView: UIView? private var voiceMemoRecordingLabel: UILabel? private var voiceMemoCancelLabel: UILabel? private var voiceMemoRedRecordingCircle: UIView? private var voiceMemoLockView: VoiceMemoLockView? func showVoiceMemoUI() { AssertIsOnMainThread() isShowingVoiceMemoUI = true removeVoiceMemoTooltip() voiceMemoStartTime = Date() voiceMemoRedRecordingCircle?.removeFromSuperview() voiceMemoLockView?.removeFromSuperview() voiceMemoContentView.removeAllSubviews() let recordingLabel = UILabel() recordingLabel.textAlignment = .left recordingLabel.textColor = Theme.primaryTextColor recordingLabel.font = .dynamicTypeBodyClamped.monospaced().medium() recordingLabel.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "recordingLabel") voiceMemoContentView.addSubview(recordingLabel) self.voiceMemoRecordingLabel = recordingLabel updateVoiceMemo() let cancelArrowFontSize = CGFloat.scaleFromIPhone5To7Plus(18.4, 20) let cancelString = NSMutableAttributedString( string: "\u{F104}", attributes: [ .font: UIFont.awesomeFont(ofSize: cancelArrowFontSize), .foregroundColor: Theme.secondaryTextAndIconColor, .baselineOffset: -1 ] ) cancelString.append( NSAttributedString( string: " ", attributes: [ .font: UIFont.awesomeFont(ofSize: cancelArrowFontSize), .foregroundColor: Theme.secondaryTextAndIconColor, .baselineOffset: -1 ] ) ) cancelString.append( NSAttributedString( string: OWSLocalizedString("VOICE_MESSAGE_CANCEL_INSTRUCTIONS", comment: "Indicates how to cancel a voice message."), attributes: [ .font: UIFont.dynamicTypeSubheadlineClamped, .foregroundColor: Theme.secondaryTextAndIconColor ] ) ) let cancelLabel = UILabel() cancelLabel.textAlignment = .right cancelLabel.attributedText = cancelString voiceMemoContentView.addSubview(cancelLabel) self.voiceMemoCancelLabel = cancelLabel let redCircleView = CircleView(diameter: 80) redCircleView.backgroundColor = .ows_accentRed let whiteIconView = UIImageView(image: UIImage(imageLiteralResourceName: "mic-fill")) whiteIconView.tintColor = .white whiteIconView.autoSetDimensions(to: .square(36)) redCircleView.addSubview(whiteIconView) whiteIconView.autoCenterInSuperview() addSubview(redCircleView) redCircleView.autoAlignAxis(.horizontal, toSameAxisOf: voiceMemoContentView) redCircleView.autoPinEdge(toSuperviewEdge: .right, withInset: 12) self.voiceMemoRedRecordingCircle = redCircleView let imageView = UIImageView(image: UIImage(imageLiteralResourceName: "mic-fill")) imageView.tintColor = .ows_accentRed imageView.autoSetDimensions(to: .square(24)) voiceMemoContentView.addSubview(imageView) imageView.autoVCenterInSuperview() imageView.autoPinEdge(toSuperviewEdge: .left, withInset: 12) recordingLabel.autoVCenterInSuperview() recordingLabel.autoPinEdge(.left, to: .right, of: imageView, withOffset: 8) cancelLabel.autoVCenterInSuperview() cancelLabel.autoPinEdge(toSuperviewEdge: .right, withInset: 72) cancelLabel.autoPinEdge(.left, to: .right, of: recordingLabel) let voiceMemoLockView = VoiceMemoLockView() insertSubview(voiceMemoLockView, belowSubview: redCircleView) voiceMemoLockView.autoAlignAxis(.vertical, toSameAxisOf: redCircleView) voiceMemoLockView.autoPinEdge(.bottom, to: .top, of: redCircleView) voiceMemoLockView.setCompressionResistanceHigh() self.voiceMemoLockView = voiceMemoLockView voiceMemoLockView.transform = CGAffineTransform.scale(0) voiceMemoLockView.layoutIfNeeded() UIView.animate(withDuration: 0.2, delay: 1) { voiceMemoLockView.transform = .identity } redCircleView.transform = CGAffineTransform.scale(0) UIView.animate(withDuration: 0.2) { redCircleView.transform = .identity } // Pulse the icon. imageView.alpha = 1 UIView.animate( withDuration: 0.5, delay: 0.2, options: [.repeat, .autoreverse, .curveEaseIn], animations: { imageView.alpha = 0 } ) voiceMemoUpdateTimer?.invalidate() voiceMemoUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in guard let self else { timer.invalidate() return } self.updateVoiceMemo() } } func showVoiceMemoDraft(_ voiceMemoDraft: VoiceMessageInterruptedDraft) { AssertIsOnMainThread() isShowingVoiceMemoUI = true self.voiceMemoDraft = voiceMemoDraft voiceMemoRecordingState = .draft removeVoiceMemoTooltip() voiceMemoRedRecordingCircle?.removeFromSuperview() voiceMemoLockView?.removeFromSuperview() voiceMemoContentView.removeAllSubviews() voiceMemoUpdateTimer?.invalidate() voiceMemoUpdateTimer = nil let draftView = VoiceMessageDraftView( voiceMessageInterruptedDraft: voiceMemoDraft, mediaCache: mediaCache, deleteAction: { [weak self] in Self.databaseStorage.asyncWrite { voiceMemoDraft.clearDraft(transaction: $0) } completion: { self?.hideVoiceMemoUI(animated: true) } } ) voiceMemoContentView.addSubview(draftView) draftView.autoPinEdgesToSuperviewEdges() } func hideVoiceMemoUI(animated: Bool) { AssertIsOnMainThread() isShowingVoiceMemoUI = false voiceMemoContentView.removeAllSubviews() voiceMemoRecordingState = .idle voiceMemoDraft = nil let oldVoiceMemoRedRecordingCircle = voiceMemoRedRecordingCircle let oldVoiceMemoLockView = voiceMemoLockView voiceMemoCancelLabel = nil voiceMemoRedRecordingCircle = nil voiceMemoLockView = nil voiceMemoRecordingLabel = nil voiceMemoUpdateTimer?.invalidate() voiceMemoUpdateTimer = nil voiceMemoDraft = nil if animated { UIView.animate( withDuration: 0.2, animations: { oldVoiceMemoRedRecordingCircle?.alpha = 0 oldVoiceMemoLockView?.alpha = 0 }, completion: { _ in oldVoiceMemoRedRecordingCircle?.removeFromSuperview() oldVoiceMemoLockView?.removeFromSuperview() } ) } else { oldVoiceMemoRedRecordingCircle?.removeFromSuperview() oldVoiceMemoLockView?.removeFromSuperview() } } func lockVoiceMemoUI() { guard let voiceMemoRecordingLabel = voiceMemoRecordingLabel else { owsFailDebug("voiceMemoRecordingLabel == nil") return } ImpactHapticFeedback.impactOccurred(style: .medium) let cancelButton = OWSButton(block: { [weak self] in self?.inputToolbarDelegate?.voiceMemoGestureDidCancel() }) cancelButton.alpha = 0 cancelButton.setTitle(CommonStrings.cancelButton, for: .normal) cancelButton.setTitleColor(.ows_accentRed, for: .normal) cancelButton.setTitleColor(.ows_accentRed.withAlphaComponent(0.4), for: .highlighted) cancelButton.titleLabel?.textAlignment = .right cancelButton.titleLabel?.font = .dynamicTypeBodyClamped.medium() cancelButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "cancelButton") voiceMemoContentView.addSubview(cancelButton) voiceMemoRecordingLabel.setContentHuggingHigh() NSLayoutConstraint.autoSetPriority(.defaultLow) { cancelButton.autoHCenterInSuperview() } cancelButton.autoPinEdge(toSuperviewMargin: .right, withInset: 40) cancelButton.autoPinEdge(.left, to: .right, of: voiceMemoRecordingLabel, withOffset: 4, relation: .greaterThanOrEqual) cancelButton.autoVCenterInSuperview() voiceMemoCancelLabel?.removeFromSuperview() voiceMemoContentView.layoutIfNeeded() UIView.animate( withDuration: 0.2, animations: { self.voiceMemoRedRecordingCircle?.alpha = 0 self.voiceMemoLockView?.alpha = 0 cancelButton.alpha = 1 }, completion: { _ in self.voiceMemoRedRecordingCircle?.removeFromSuperview() self.voiceMemoLockView?.removeFromSuperview() UIAccessibility.post(notification: .layoutChanged, argument: nil) } ) } private func setVoiceMemoUICancelAlpha(_ cancelAlpha: CGFloat) { AssertIsOnMainThread() // Fade out the voice message views as the cancel gesture // proceeds as feedback. voiceMemoCancelLabel?.alpha = CGFloat.clamp01(1 - cancelAlpha) } private func updateVoiceMemo() { AssertIsOnMainThread() guard let voiceMemoStartTime = voiceMemoStartTime, let voiceMemoRecordingLabel = voiceMemoRecordingLabel else { return } let durationSeconds = abs(voiceMemoStartTime.timeIntervalSinceNow) voiceMemoRecordingLabel.text = OWSFormat.formatDurationSeconds(Int(round(durationSeconds))) voiceMemoRecordingLabel.sizeToFit() } func showVoiceMemoTooltip() { guard voiceMemoTooltipView == nil else { return } let tooltipView = VoiceMessageTooltip( fromView: self, widthReferenceView: self, tailReferenceView: rightEdgeControlsView.voiceMemoButton) { [weak self] in self?.removeVoiceMemoTooltip() } voiceMemoTooltipView = tooltipView DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in self?.removeVoiceMemoTooltip() } } private func removeVoiceMemoTooltip() { guard let voiceMemoTooltipView = voiceMemoTooltipView else { return } self.voiceMemoTooltipView = nil UIView.animate( withDuration: 0.2, animations: { voiceMemoTooltipView.alpha = 0 }, completion: { _ in voiceMemoTooltipView.removeFromSuperview() } ) } @objc private func handleVoiceMemoLongPress(gesture: UILongPressGestureRecognizer) { switch gesture.state { case .possible, .cancelled, .failed: guard voiceMemoRecordingState != .idle else { return } // Record a draft if we were actively recording. voiceMemoRecordingState = .idle inputToolbarDelegate?.voiceMemoGestureWasInterrupted() case .began: switch voiceMemoRecordingState { case .idle: break case .recordingHeld: owsFailDebug("while recording held, shouldn't be possible to restart gesture.") inputToolbarDelegate?.voiceMemoGestureDidCancel() case .recordingLocked, .draft: owsFailDebug("once locked, shouldn't be possible to interact with gesture.") inputToolbarDelegate?.voiceMemoGestureDidCancel() } // Start voice message. voiceMemoRecordingState = .recordingHeld voiceMemoGestureStartLocation = gesture.location(in: self) inputToolbarDelegate?.voiceMemoGestureDidStart() case .changed: guard isShowingVoiceMemoUI else { return } guard let voiceMemoGestureStartLocation = voiceMemoGestureStartLocation else { owsFailDebug("voiceMemoGestureStartLocation is nil") return } // Check for "slide to cancel" gesture. let location = gesture.location(in: self) // For LTR/RTL, swiping in either direction will cancel. // This is okay because there's only space on screen to perform the // gesture in one direction. let xOffset = abs(voiceMemoGestureStartLocation.x - location.x) let yOffset = abs(voiceMemoGestureStartLocation.y - location.y) // Require a certain threshold before we consider the user to be // interacting with the lock ui, otherwise there's perceptible wobble // of the lock slider even when the user isn't intended to interact with it. let lockThresholdPoints: CGFloat = 20 let lockOffsetPoints: CGFloat = 80 let yOffsetBeyondThreshold = max(yOffset - lockThresholdPoints, 0) let lockAlpha = yOffsetBeyondThreshold / lockOffsetPoints let isLocked = lockAlpha >= 1 if isLocked { switch voiceMemoRecordingState { case .recordingHeld: voiceMemoRecordingState = .recordingLocked inputToolbarDelegate?.voiceMemoGestureDidLock() setVoiceMemoUICancelAlpha(0) case .recordingLocked, .draft: // already locked break case .idle: owsFailDebug("failure: unexpeceted idle state") inputToolbarDelegate?.voiceMemoGestureDidCancel() } } else { voiceMemoLockView?.update(ratioComplete: lockAlpha) // The lower this value, the easier it is to cancel by accident. // The higher this value, the harder it is to cancel. let cancelOffsetPoints: CGFloat = 100 let cancelAlpha = xOffset / cancelOffsetPoints let isCancelled = cancelAlpha >= 1 guard !isCancelled else { voiceMemoRecordingState = .idle inputToolbarDelegate?.voiceMemoGestureDidCancel() return } setVoiceMemoUICancelAlpha(cancelAlpha) if xOffset > yOffset { voiceMemoRedRecordingCircle?.transform = CGAffineTransform(translationX: min(-xOffset, 0), y: 0) } else if yOffset > xOffset { voiceMemoRedRecordingCircle?.transform = CGAffineTransform(translationX: 0, y: min(-yOffset, 0)) } else { voiceMemoRedRecordingCircle?.transform = .identity } } case .ended: switch voiceMemoRecordingState { case .idle: break case .recordingHeld: // End voice message. voiceMemoRecordingState = .idle inputToolbarDelegate?.voiceMemoGestureDidComplete() case .recordingLocked, .draft: // Continue recording. break } @unknown default: break } } // MARK: Keyboards private(set) var isMeasuringKeyboardHeight = false private var hasMeasuredKeyboardHeight = false // Workaround for keyboard & chat flashing when switching between keyboards on iOS 17. // When swithing keyboards sometimes! we get "keyboard will show" notification // with keyboard frame being slightly (45 dp) shorter, immediately followed by another // notification with previous (correct) keyboard frame. // // Because this does not always happen and because this does not happen on a Simulator // I concluded this is an iOS 17 bug. // // In the future it might be better to implement different keyboard managing // by making UIViewController a first responder and vending input bar as `inputView`. private(set) var isSwitchingKeyboard = false private enum KeyboardType { case system case sticker case attachment } private var _desiredKeyboardType: KeyboardType = .system private var desiredKeyboardType: KeyboardType { get { _desiredKeyboardType } set { setDesiredKeyboardType(newValue, animated: false) } } private var _stickerKeyboard: StickerKeyboard? private var stickerKeyboard: StickerKeyboard { if let stickerKeyboard = _stickerKeyboard { return stickerKeyboard } let keyboard = StickerKeyboard() keyboard.delegate = self keyboard.registerWithView(self) _stickerKeyboard = keyboard return keyboard } private var stickerKeyboardIfLoaded: StickerKeyboard? { _stickerKeyboard } func showStickerKeyboard() { AssertIsOnMainThread() guard desiredKeyboardType != .sticker else { return } toggleKeyboardType(.sticker, animated: false) } private var _attachmentKeyboard: AttachmentKeyboard? private var attachmentKeyboard: AttachmentKeyboard { if let attachmentKeyboard = _attachmentKeyboard { return attachmentKeyboard } let keyboard = AttachmentKeyboard(delegate: self) keyboard.registerWithView(self) _attachmentKeyboard = keyboard return keyboard } private var attachmentKeyboardIfLoaded: AttachmentKeyboard? { _attachmentKeyboard } func showAttachmentKeyboard() { AssertIsOnMainThread() guard desiredKeyboardType != .attachment else { return } toggleKeyboardType(.attachment, animated: false) } private func toggleKeyboardType(_ keyboardType: KeyboardType, animated: Bool) { guard let inputToolbarDelegate = inputToolbarDelegate else { owsFailDebug("inputToolbarDelegate is nil") return } if desiredKeyboardType == keyboardType { setDesiredKeyboardType(.system, animated: animated) } else { // For switching to anything other than the system keyboard, // make sure this conversation isn't blocked before presenting it. if inputToolbarDelegate.isBlockedConversation() { inputToolbarDelegate.showUnblockConversationUI { [weak self] isBlocked in guard let self = self, !isBlocked else { return } self.toggleKeyboardType(keyboardType, animated: animated) } return } setDesiredKeyboardType(keyboardType, animated: animated) } beginEditingMessage() } private func setDesiredKeyboardType(_ keyboardType: KeyboardType, animated: Bool) { guard _desiredKeyboardType != keyboardType else { return } _desiredKeyboardType = keyboardType ensureButtonVisibility(withAnimation: animated, doLayout: true) if isInputViewFirstResponder { isSwitchingKeyboard = true // If any keyboard is presented, make sure the correct // keyboard is presented. beginEditingMessage() DispatchQueue.main.async { self.isSwitchingKeyboard = false } } else { // Make sure neither keyboard is presented. endEditingMessage() } } func clearDesiredKeyboard() { AssertIsOnMainThread() desiredKeyboardType = .system } private func restoreDesiredKeyboardIfNecessary() { AssertIsOnMainThread() if desiredKeyboardType != .system && !desiredFirstResponder.isFirstResponder { desiredFirstResponder.becomeFirstResponder() } } private func cacheKeyboardIfNecessary() { // Preload the keyboard if we're not showing it already, this // allows us to calculate the appropriate initial height for // our custom inputViews and in general to present it faster // We disable animations so this preload is invisible to the // user. // // We only measure the keyboard if the toolbar isn't hidden. // If it's hidden, we're likely here from a peek interaction // and don't want to show the keyboard. We'll measure it later. guard !hasMeasuredKeyboardHeight && !inputTextView.isFirstResponder && !isHidden else { return } // Flag that we're measuring the system keyboard's height, so // even if though it won't be the first responder by the time // the notifications fire, we'll still read its measurement isMeasuringKeyboardHeight = true UIView.setAnimationsEnabled(false) _ = inputTextView.becomeFirstResponder() _ = inputTextView.resignFirstResponder() inputTextView.reloadMentionState() UIView.setAnimationsEnabled(true) } var isInputViewFirstResponder: Bool { return inputTextView.isFirstResponder || stickerKeyboardIfLoaded?.isFirstResponder ?? false || attachmentKeyboardIfLoaded?.isFirstResponder ?? false } private func ensureFirstResponderState() { restoreDesiredKeyboardIfNecessary() } private var desiredFirstResponder: UIResponder { switch desiredKeyboardType { case .system: return inputTextView case .sticker: return stickerKeyboard case .attachment: return attachmentKeyboard } } func beginEditingMessage() { guard !desiredFirstResponder.isFirstResponder else { return } desiredFirstResponder.becomeFirstResponder() } func endEditingMessage() { _ = inputTextView.resignFirstResponder() _ = stickerKeyboardIfLoaded?.resignFirstResponder() _ = attachmentKeyboardIfLoaded?.resignFirstResponder() } func viewDidAppear() { ensureButtonVisibility(withAnimation: false, doLayout: false) cacheKeyboardIfNecessary() } @objc private func applicationDidBecomeActive(notification: Notification) { AssertIsOnMainThread() restoreDesiredKeyboardIfNecessary() } @objc private func keyboardFrameDidChange(notification: Notification) { guard let keyboardEndFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { owsFailDebug("keyboardEndFrame is nil") return } guard inputTextView.isFirstResponder || isMeasuringKeyboardHeight else { return } let newHeight = keyboardEndFrame.size.height - frame.size.height guard newHeight > 0 else { return } stickerKeyboard.updateSystemKeyboardHeight(newHeight) attachmentKeyboard.updateSystemKeyboardHeight(newHeight) if isMeasuringKeyboardHeight { isMeasuringKeyboardHeight = false hasMeasuredKeyboardHeight = true } } } // MARK: Button Actions extension ConversationInputToolbar { @objc private func cameraButtonPressed() { guard let inputToolbarDelegate = inputToolbarDelegate else { owsFailDebug("inputToolbarDelegate == nil") return } ImpactHapticFeedback.impactOccurred(style: .light) inputToolbarDelegate.cameraButtonPressed() } @objc private func addOrCancelButtonPressed() { ImpactHapticFeedback.impactOccurred(style: .light) if shouldShowEditUI { editTarget = nil quotedReplyDraft = nil clearTextMessage(animated: true) } else { toggleKeyboardType(.attachment, animated: true) } } @objc private func sendButtonPressed() { guard let inputToolbarDelegate = inputToolbarDelegate else { owsFailDebug("inputToolbarDelegate == nil") return } guard !isShowingVoiceMemoUI else { voiceMemoRecordingState = .idle guard let voiceMemoDraft = voiceMemoDraft else { inputToolbarDelegate.voiceMemoGestureDidComplete() return } inputToolbarDelegate.sendVoiceMemoDraft(voiceMemoDraft) return } inputToolbarDelegate.sendButtonPressed() } @objc private func stickerButtonPressed() { ImpactHapticFeedback.impactOccurred(style: .light) var hasInstalledStickerPacks: Bool = false databaseStorage.read { transaction in hasInstalledStickerPacks = !StickerManager.installedStickerPacks(transaction: transaction).isEmpty } guard hasInstalledStickerPacks else { presentManageStickersView() return } toggleKeyboardType(.sticker, animated: true) } @objc private func keyboardButtonPressed() { ImpactHapticFeedback.impactOccurred(style: .light) toggleKeyboardType(.system, animated: true) } } extension ConversationInputToolbar: ConversationTextViewToolbarDelegate { private func updateHeightWithTextView(_ textView: UITextView) { let maxSize = CGSize(width: textView.width - textView.textContainerInset.totalWidth, height: CGFloat.greatestFiniteMagnitude) var contentSize = textView.attributedText.boundingRect(with: maxSize, options: [.usesLineFragmentOrigin, .usesFontLeading], context: nil).size contentSize.height += textView.textContainerInset.top contentSize.height += textView.textContainerInset.bottom let newHeight = CGFloat.clamp( contentSize.height, min: LayoutMetrics.minTextViewHeight, max: UIDevice.current.isIPad ? LayoutMetrics.maxIPadTextViewHeight : LayoutMetrics.maxTextViewHeight ) guard newHeight != textViewHeight else { return } guard let textViewHeightConstraint else { owsFailDebug("textViewHeightConstraint == nil") return } textViewHeight = newHeight textViewHeightConstraint.constant = newHeight if let superview, inputToolbarDelegate != nil { isAnimatingHeightChange = true let animator = UIViewPropertyAnimator( duration: ConversationInputToolbar.heightChangeAnimationDuration, springDamping: 1, springResponse: 0.25 ) animator.addAnimations { self.invalidateIntrinsicContentSize() superview.layoutIfNeeded() } animator.addCompletion { _ in self.isAnimatingHeightChange = false } animator.startAnimation() } else { invalidateIntrinsicContentSize() } } func textViewDidChange(_ textView: UITextView) { owsAssertDebug(inputToolbarDelegate != nil) // Ignore change events during configuration. guard isConfigurationComplete else { return } updateHeightWithTextView(textView) ensureButtonVisibility(withAnimation: true, doLayout: true) updateInputLinkPreview() if editTarget != nil { rightEdgeControlsView.sendButton.isEnabled = textView.hasText } } func textViewDidChangeSelection(_ textView: UITextView) { } func textViewDidBecomeFirstResponder(_ textView: UITextView) { setDesiredKeyboardType(.system, animated: true) } } extension ConversationInputToolbar: StickerPickerDelegate { public func didSelectSticker(stickerInfo: StickerInfo) { AssertIsOnMainThread() inputToolbarDelegate?.sendSticker(stickerInfo) } public var storyStickerConfiguration: SignalUI.StoryStickerConfiguration { .hide } } extension ConversationInputToolbar: StickerPacksToolbarDelegate { public func presentManageStickersView() { AssertIsOnMainThread() inputToolbarDelegate?.presentManageStickersView() } } extension ConversationInputToolbar: AttachmentKeyboardDelegate { func didSelectRecentPhoto(asset: PHAsset, attachment: SignalAttachment) { inputToolbarDelegate?.didSelectRecentPhoto(asset: asset, attachment: attachment) } func didTapPhotos() { inputToolbarDelegate?.photosButtonPressed() } func didTapCamera() { inputToolbarDelegate?.cameraButtonPressed() } func didTapGif() { inputToolbarDelegate?.gifButtonPressed() } func didTapFile() { inputToolbarDelegate?.fileButtonPressed() } func didTapContact() { inputToolbarDelegate?.contactButtonPressed() } func didTapLocation() { inputToolbarDelegate?.locationButtonPressed() } func didTapPayment() { inputToolbarDelegate?.paymentButtonPressed() } var isGroup: Bool { inputToolbarDelegate?.isGroup() ?? false } }