diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 75b9a8eca..cabb23b0a 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -22,6 +22,8 @@ 340872CB2239563500CB25B0 /* AttachmentPrepViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340872C62239563500CB25B0 /* AttachmentPrepViewController.swift */; }; 340872CC2239563500CB25B0 /* AttachmentCaptionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340872C72239563500CB25B0 /* AttachmentCaptionViewController.swift */; }; 340872CE2239596100CB25B0 /* AttachmentApprovalInputAccessoryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340872CD2239596000CB25B0 /* AttachmentApprovalInputAccessoryView.swift */; }; + 340872DD22399F9100CB25B0 /* AttachmentTextView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340872DB22399F9100CB25B0 /* AttachmentTextView.swift */; }; + 340872DE22399F9100CB25B0 /* AttachmentTextToolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 340872DC22399F9100CB25B0 /* AttachmentTextToolbar.swift */; }; 340B02BA1FA0D6C700F9CFEC /* ConversationViewItemTest.m in Sources */ = {isa = PBXBuildFile; fileRef = 340B02B91FA0D6C700F9CFEC /* ConversationViewItemTest.m */; }; 340FC8A9204DAC8D007AEB0F /* NotificationSettingsOptionsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC87B204DAC8C007AEB0F /* NotificationSettingsOptionsViewController.m */; }; 340FC8AA204DAC8D007AEB0F /* NotificationSettingsViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 340FC87C204DAC8C007AEB0F /* NotificationSettingsViewController.m */; }; @@ -657,6 +659,8 @@ 340872C62239563500CB25B0 /* AttachmentPrepViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentPrepViewController.swift; sourceTree = ""; }; 340872C72239563500CB25B0 /* AttachmentCaptionViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentCaptionViewController.swift; sourceTree = ""; }; 340872CD2239596000CB25B0 /* AttachmentApprovalInputAccessoryView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentApprovalInputAccessoryView.swift; sourceTree = ""; }; + 340872DB22399F9100CB25B0 /* AttachmentTextView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentTextView.swift; sourceTree = ""; }; + 340872DC22399F9100CB25B0 /* AttachmentTextToolbar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AttachmentTextToolbar.swift; sourceTree = ""; }; 340B02B61F9FD31800F9CFEC /* he */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = he; path = translations/he.lproj/Localizable.strings; sourceTree = ""; }; 340B02B91FA0D6C700F9CFEC /* ConversationViewItemTest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ConversationViewItemTest.m; sourceTree = ""; }; 340FC87B204DAC8C007AEB0F /* NotificationSettingsOptionsViewController.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = NotificationSettingsOptionsViewController.m; sourceTree = ""; }; @@ -1491,6 +1495,8 @@ 340872C72239563500CB25B0 /* AttachmentCaptionViewController.swift */, 340872C42239563500CB25B0 /* AttachmentItemCollection.swift */, 340872C62239563500CB25B0 /* AttachmentPrepViewController.swift */, + 340872DC22399F9100CB25B0 /* AttachmentTextToolbar.swift */, + 340872DB22399F9100CB25B0 /* AttachmentTextView.swift */, ); path = AttachmentApproval; sourceTree = ""; @@ -3403,6 +3409,7 @@ 340872CC2239563500CB25B0 /* AttachmentCaptionViewController.swift in Sources */, 34480B671FD0AA9400BC14EF /* UIFont+OWS.m in Sources */, 346129E61FD5C0C600532771 /* OWSDatabaseMigrationRunner.m in Sources */, + 340872DD22399F9100CB25B0 /* AttachmentTextView.swift in Sources */, 34AC0A11211B39EA00997B47 /* OWSLayerView.swift in Sources */, 34AC0A1B211B39EA00997B47 /* GradientView.swift in Sources */, 34AC09E2211B39B100997B47 /* ReturnToCallViewController.swift in Sources */, @@ -3475,6 +3482,7 @@ 34BBC84B220B2CB200857249 /* ImageEditorTextViewController.swift in Sources */, 34AC09FA211B39B100997B47 /* SharingThreadPickerViewController.m in Sources */, 45F59A082028E4FB00E8D2B0 /* OWSAudioSession.swift in Sources */, + 340872DE22399F9100CB25B0 /* AttachmentTextToolbar.swift in Sources */, 34612A071FD7238600532771 /* OWSSyncManager.m in Sources */, 450C801220AD1D5B00F3A091 /* UIDevice+featureSupport.swift in Sources */, 451F8A471FD715BA005CB9DA /* OWSAvatarBuilder.m in Sources */, diff --git a/SignalMessaging/ViewControllers/AttachmentApproval/AttachmentApprovalInputAccessoryView.swift b/SignalMessaging/ViewControllers/AttachmentApproval/AttachmentApprovalInputAccessoryView.swift index 3e21193b5..d520b307c 100644 --- a/SignalMessaging/ViewControllers/AttachmentApproval/AttachmentApprovalInputAccessoryView.swift +++ b/SignalMessaging/ViewControllers/AttachmentApproval/AttachmentApprovalInputAccessoryView.swift @@ -6,17 +6,17 @@ import Foundation import UIKit class AttachmentApprovalInputAccessoryView: UIView { - let mediaMessageTextToolbar: MediaMessageTextToolbar + let attachmentTextToolbar: AttachmentTextToolbar let galleryRailView: GalleryRailView var isEditingMediaMessage: Bool { - return mediaMessageTextToolbar.textView.isFirstResponder + return attachmentTextToolbar.textView.isFirstResponder } let kGalleryRailViewHeight: CGFloat = 72 required init(isAddMoreVisible: Bool) { - mediaMessageTextToolbar = MediaMessageTextToolbar(isAddMoreVisible: isAddMoreVisible) + attachmentTextToolbar = AttachmentTextToolbar(isAddMoreVisible: isAddMoreVisible) galleryRailView = GalleryRailView() galleryRailView.scrollFocusMode = .keepWithinBounds @@ -32,7 +32,7 @@ class AttachmentApprovalInputAccessoryView: UIView { backgroundColor = UIColor.black.withAlphaComponent(0.6) preservesSuperviewLayoutMargins = true - let stackView = UIStackView(arrangedSubviews: [self.galleryRailView, self.mediaMessageTextToolbar]) + let stackView = UIStackView(arrangedSubviews: [self.galleryRailView, self.attachmentTextToolbar]) stackView.axis = .vertical addSubview(stackView) @@ -53,348 +53,3 @@ class AttachmentApprovalInputAccessoryView: UIView { } } } - -// MARK: - - -// Coincides with Android's max text message length -let kMaxMessageBodyCharacterCount = 2000 - -protocol MediaMessageTextToolbarDelegate: class { - func mediaMessageTextToolbarDidTapSend(_ mediaMessageTextToolbar: MediaMessageTextToolbar) - func mediaMessageTextToolbarDidBeginEditing(_ mediaMessageTextToolbar: MediaMessageTextToolbar) - func mediaMessageTextToolbarDidEndEditing(_ mediaMessageTextToolbar: MediaMessageTextToolbar) - func mediaMessageTextToolbarDidAddMore(_ mediaMessageTextToolbar: MediaMessageTextToolbar) -} - -// MARK: - - -class MediaMessageTextToolbar: UIView, UITextViewDelegate { - - weak var mediaMessageTextToolbarDelegate: MediaMessageTextToolbarDelegate? - - var messageText: String? { - get { return textView.text } - - set { - textView.text = newValue - updatePlaceholderTextViewVisibility() - } - } - - // Layout Constants - - let kMinTextViewHeight: CGFloat = 38 - var maxTextViewHeight: CGFloat { - // About ~4 lines in portrait and ~3 lines in landscape. - // Otherwise we risk obscuring too much of the content. - return UIDevice.current.orientation.isPortrait ? 160 : 100 - } - var textViewHeightConstraint: NSLayoutConstraint! - var textViewHeight: CGFloat - - // MARK: - Initializers - - init(isAddMoreVisible: Bool) { - self.addMoreButton = UIButton(type: .custom) - self.sendButton = UIButton(type: .system) - self.textViewHeight = kMinTextViewHeight - - super.init(frame: CGRect.zero) - - // Specifying autorsizing mask and an intrinsic content size allows proper - // sizing when used as an input accessory view. - self.autoresizingMask = .flexibleHeight - self.translatesAutoresizingMaskIntoConstraints = false - self.backgroundColor = UIColor.clear - - textView.delegate = self - - let addMoreIcon = #imageLiteral(resourceName: "album_add_more").withRenderingMode(.alwaysTemplate) - addMoreButton.setImage(addMoreIcon, for: .normal) - addMoreButton.tintColor = Theme.darkThemePrimaryColor - addMoreButton.addTarget(self, action: #selector(didTapAddMore), for: .touchUpInside) - - let sendTitle = NSLocalizedString("ATTACHMENT_APPROVAL_SEND_BUTTON", comment: "Label for 'send' button in the 'attachment approval' dialog.") - sendButton.setTitle(sendTitle, for: .normal) - sendButton.addTarget(self, action: #selector(didTapSend), for: .touchUpInside) - - sendButton.titleLabel?.font = UIFont.ows_mediumFont(withSize: 16) - sendButton.titleLabel?.textAlignment = .center - sendButton.tintColor = Theme.galleryHighlightColor - - // Increase hit area of send button - sendButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 8, bottom: 6, right: 8) - - let contentView = UIView() - contentView.addSubview(sendButton) - contentView.addSubview(textContainer) - contentView.addSubview(lengthLimitLabel) - if isAddMoreVisible { - contentView.addSubview(addMoreButton) - } - - addSubview(contentView) - contentView.autoPinEdgesToSuperviewEdges() - - // Layout - let kToolbarMargin: CGFloat = 8 - - // We have to wrap the toolbar items in a content view because iOS (at least on iOS10.3) assigns the inputAccessoryView.layoutMargins - // when resigning first responder (verified by auditing with `layoutMarginsDidChange`). - // The effect of this is that if we were to assign these margins to self.layoutMargins, they'd be blown away if the - // user dismisses the keyboard, giving the input accessory view a wonky layout. - contentView.layoutMargins = UIEdgeInsets(top: kToolbarMargin, left: kToolbarMargin, bottom: kToolbarMargin, right: kToolbarMargin) - - self.textViewHeightConstraint = textView.autoSetDimension(.height, toSize: kMinTextViewHeight) - - // We pin all three edges explicitly rather than doing something like: - // textView.autoPinEdges(toSuperviewMarginsExcludingEdge: .right) - // because that method uses `leading` / `trailing` rather than `left` vs. `right`. - // So it doesn't work as expected with RTL layouts when we explicitly want something - // to be on the right side for both RTL and LTR layouts, like with the send button. - // I believe this is a bug in PureLayout. Filed here: https://github.com/PureLayout/PureLayout/issues/209 - textContainer.autoPinEdge(toSuperviewMargin: .top) - textContainer.autoPinEdge(toSuperviewMargin: .bottom) - if isAddMoreVisible { - addMoreButton.autoPinEdge(toSuperviewMargin: .left) - textContainer.autoPinEdge(.left, to: .right, of: addMoreButton, withOffset: kToolbarMargin) - addMoreButton.autoAlignAxis(.horizontal, toSameAxisOf: sendButton) - addMoreButton.setContentHuggingHigh() - addMoreButton.setCompressionResistanceHigh() - } else { - textContainer.autoPinEdge(toSuperviewMargin: .left) - } - - sendButton.autoPinEdge(.left, to: .right, of: textContainer, withOffset: kToolbarMargin) - sendButton.autoPinEdge(.bottom, to: .bottom, of: textContainer, withOffset: -3) - - sendButton.autoPinEdge(toSuperviewMargin: .right) - sendButton.setContentHuggingHigh() - sendButton.setCompressionResistanceHigh() - - lengthLimitLabel.autoPinEdge(toSuperviewMargin: .left) - lengthLimitLabel.autoPinEdge(toSuperviewMargin: .right) - lengthLimitLabel.autoPinEdge(.bottom, to: .top, of: textContainer, withOffset: -6) - lengthLimitLabel.setContentHuggingHigh() - lengthLimitLabel.setCompressionResistanceHigh() - } - - required init?(coder aDecoder: NSCoder) { - notImplemented() - } - - // MARK: - UIView Overrides - - override var intrinsicContentSize: CGSize { - get { - // Since we have `self.autoresizingMask = UIViewAutoresizingFlexibleHeight`, we must specify - // an intrinsicContentSize. Specifying CGSize.zero causes the height to be determined by autolayout. - return CGSize.zero - } - } - - // MARK: - Subviews - - private let addMoreButton: UIButton - private let sendButton: UIButton - - private lazy var lengthLimitLabel: UILabel = { - let lengthLimitLabel = UILabel() - - // Length Limit Label shown when the user inputs too long of a message - lengthLimitLabel.textColor = .white - lengthLimitLabel.text = NSLocalizedString("ATTACHMENT_APPROVAL_MESSAGE_LENGTH_LIMIT_REACHED", comment: "One-line label indicating the user can add no more text to the media message field.") - lengthLimitLabel.textAlignment = .center - - // Add shadow in case overlayed on white content - lengthLimitLabel.layer.shadowColor = UIColor.black.cgColor - lengthLimitLabel.layer.shadowOffset = .zero - lengthLimitLabel.layer.shadowOpacity = 0.8 - lengthLimitLabel.layer.shadowRadius = 2.0 - lengthLimitLabel.isHidden = true - - return lengthLimitLabel - }() - - lazy var textView: UITextView = { - let textView = buildTextView() - - textView.returnKeyType = .done - textView.scrollIndicatorInsets = UIEdgeInsets(top: 5, left: 0, bottom: 5, right: 3) - - return textView - }() - - private lazy var placeholderTextView: UITextView = { - let placeholderTextView = buildTextView() - - placeholderTextView.text = NSLocalizedString("MESSAGE_TEXT_FIELD_PLACEHOLDER", comment: "placeholder text for the editable message field") - placeholderTextView.isEditable = false - - return placeholderTextView - }() - - private lazy var textContainer: UIView = { - let textContainer = UIView() - - textContainer.layer.borderColor = Theme.darkThemePrimaryColor.cgColor - textContainer.layer.borderWidth = 0.5 - textContainer.layer.cornerRadius = kMinTextViewHeight / 2 - textContainer.clipsToBounds = true - - textContainer.addSubview(placeholderTextView) - placeholderTextView.autoPinEdgesToSuperviewEdges() - - textContainer.addSubview(textView) - textView.autoPinEdgesToSuperviewEdges() - - return textContainer - }() - - private func buildTextView() -> UITextView { - let textView = MessageTextView() - - textView.keyboardAppearance = Theme.darkThemeKeyboardAppearance - textView.backgroundColor = .clear - textView.tintColor = Theme.darkThemePrimaryColor - - textView.font = UIFont.ows_dynamicTypeBody - textView.textColor = Theme.darkThemePrimaryColor - textView.textContainerInset = UIEdgeInsets(top: 7, left: 7, bottom: 7, right: 7) - - return textView - } - - class MessageTextView: UITextView { - // When creating new lines, contentOffset is animated, but because - // we are simultaneously resizing the text view, this can cause the - // text in the textview to be "too high" in the text view. - // Solution is to disable animation for setting content offset. - override func setContentOffset(_ contentOffset: CGPoint, animated: Bool) { - super.setContentOffset(contentOffset, animated: false) - } - } - - // MARK: - Actions - - @objc func didTapSend() { - mediaMessageTextToolbarDelegate?.mediaMessageTextToolbarDidTapSend(self) - } - - @objc func didTapAddMore() { - mediaMessageTextToolbarDelegate?.mediaMessageTextToolbarDidAddMore(self) - } - - // MARK: - UITextViewDelegate - - public func textViewDidChange(_ textView: UITextView) { - updateHeight(textView: textView) - } - - public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { - - if !FeatureFlags.sendingMediaWithOversizeText { - let existingText: String = textView.text ?? "" - let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text) - - // Don't complicate things by mixing media attachments with oversize text attachments - guard proposedText.utf8.count < kOversizeTextMessageSizeThreshold else { - Logger.debug("long text was truncated") - self.lengthLimitLabel.isHidden = false - - // `range` represents the section of the existing text we will replace. We can re-use that space. - // Range is in units of NSStrings's standard UTF-16 characters. Since some of those chars could be - // represented as single bytes in utf-8, while others may be 8 or more, the only way to be sure is - // to just measure the utf8 encoded bytes of the replaced substring. - let bytesAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").utf8.count - - // Accept as much of the input as we can - let byteBudget: Int = Int(kOversizeTextMessageSizeThreshold) - bytesAfterDelete - if byteBudget >= 0, let acceptableNewText = text.truncated(toByteCount: UInt(byteBudget)) { - textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) - } - - return false - } - self.lengthLimitLabel.isHidden = true - - // After verifying the byte-length is sufficiently small, verify the character count is within bounds. - guard proposedText.count < kMaxMessageBodyCharacterCount else { - Logger.debug("hit attachment message body character count limit") - - self.lengthLimitLabel.isHidden = false - - // `range` represents the section of the existing text we will replace. We can re-use that space. - let charsAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").count - - // Accept as much of the input as we can - let charBudget: Int = Int(kMaxMessageBodyCharacterCount) - charsAfterDelete - if charBudget >= 0 { - let acceptableNewText = String(text.prefix(charBudget)) - textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) - } - - return false - } - } - - // Though we can wrap the text, we don't want to encourage multline captions, plus a "done" button - // allows the user to get the keyboard out of the way while in the attachment approval view. - if text == "\n" { - textView.resignFirstResponder() - return false - } else { - return true - } - } - - public func textViewDidBeginEditing(_ textView: UITextView) { - mediaMessageTextToolbarDelegate?.mediaMessageTextToolbarDidBeginEditing(self) - updatePlaceholderTextViewVisibility() - } - - public func textViewDidEndEditing(_ textView: UITextView) { - mediaMessageTextToolbarDelegate?.mediaMessageTextToolbarDidEndEditing(self) - updatePlaceholderTextViewVisibility() - } - - // MARK: - Helpers - - func updatePlaceholderTextViewVisibility() { - let isHidden: Bool = { - guard !self.textView.isFirstResponder else { - return true - } - - guard let text = self.textView.text else { - return false - } - - guard text.count > 0 else { - return false - } - - return true - }() - - placeholderTextView.isHidden = isHidden - } - - private func updateHeight(textView: UITextView) { - // compute new height assuming width is unchanged - let currentSize = textView.frame.size - let newHeight = clampedTextViewHeight(fixedWidth: currentSize.width) - - if newHeight != textViewHeight { - Logger.debug("TextView height changed: \(textViewHeight) -> \(newHeight)") - textViewHeight = newHeight - textViewHeightConstraint?.constant = textViewHeight - invalidateIntrinsicContentSize() - } - } - - private func clampedTextViewHeight(fixedWidth: CGFloat) -> CGFloat { - let contentSize = textView.sizeThatFits(CGSize(width: fixedWidth, height: CGFloat.greatestFiniteMagnitude)) - return CGFloatClamp(contentSize.height, kMinTextViewHeight, maxTextViewHeight) - } -} diff --git a/SignalMessaging/ViewControllers/AttachmentApproval/AttachmentApprovalViewController.swift b/SignalMessaging/ViewControllers/AttachmentApproval/AttachmentApprovalViewController.swift index 2606edf99..759995258 100644 --- a/SignalMessaging/ViewControllers/AttachmentApproval/AttachmentApprovalViewController.swift +++ b/SignalMessaging/ViewControllers/AttachmentApproval/AttachmentApprovalViewController.swift @@ -79,8 +79,8 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC return bottomToolView.galleryRailView } - var mediaMessageTextToolbar: MediaMessageTextToolbar { - return bottomToolView.mediaMessageTextToolbar + var attachmentTextToolbar: AttachmentTextToolbar { + return bottomToolView.attachmentTextToolbar } lazy var bottomToolView: AttachmentApprovalInputAccessoryView = { @@ -106,7 +106,7 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC // Bottom Toolbar galleryRailView.delegate = self - mediaMessageTextToolbar.mediaMessageTextToolbarDelegate = self + attachmentTextToolbar.attachmentTextToolbarDelegate = self // Navigation @@ -566,27 +566,27 @@ public class AttachmentApprovalViewController: UIPageViewController, UIPageViewC } } -extension AttachmentApprovalViewController: MediaMessageTextToolbarDelegate { - func mediaMessageTextToolbarDidBeginEditing(_ mediaMessageTextToolbar: MediaMessageTextToolbar) { +extension AttachmentApprovalViewController: AttachmentTextToolbarDelegate { + func attachmentTextToolbarDidBeginEditing(_ attachmentTextToolbar: AttachmentTextToolbar) { currentPageViewController.setAttachmentViewScale(.compact, animated: true) } - func mediaMessageTextToolbarDidEndEditing(_ mediaMessageTextToolbar: MediaMessageTextToolbar) { + func attachmentTextToolbarDidEndEditing(_ attachmentTextToolbar: AttachmentTextToolbar) { currentPageViewController.setAttachmentViewScale(.fullsize, animated: true) } - func mediaMessageTextToolbarDidTapSend(_ mediaMessageTextToolbar: MediaMessageTextToolbar) { + func attachmentTextToolbarDidTapSend(_ attachmentTextToolbar: AttachmentTextToolbar) { // Toolbar flickers in and out if there are errors // and remains visible momentarily after share extension is dismissed. // It's easiest to just hide it at this point since we're done with it. currentPageViewController.shouldAllowAttachmentViewResizing = false - mediaMessageTextToolbar.isUserInteractionEnabled = false - mediaMessageTextToolbar.isHidden = true + attachmentTextToolbar.isUserInteractionEnabled = false + attachmentTextToolbar.isHidden = true - approvalDelegate?.attachmentApproval(self, didApproveAttachments: attachments, messageText: mediaMessageTextToolbar.messageText) + approvalDelegate?.attachmentApproval(self, didApproveAttachments: attachments, messageText: attachmentTextToolbar.messageText) } - func mediaMessageTextToolbarDidAddMore(_ mediaMessageTextToolbar: MediaMessageTextToolbar) { + func attachmentTextToolbarDidAddMore(_ attachmentTextToolbar: AttachmentTextToolbar) { self.approvalDelegate?.attachmentApproval?(self, addMoreToAttachments: attachments) } } diff --git a/SignalMessaging/ViewControllers/AttachmentApproval/AttachmentTextToolbar.swift b/SignalMessaging/ViewControllers/AttachmentApproval/AttachmentTextToolbar.swift new file mode 100644 index 000000000..078893bdd --- /dev/null +++ b/SignalMessaging/ViewControllers/AttachmentApproval/AttachmentTextToolbar.swift @@ -0,0 +1,339 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +import Foundation +import UIKit + +// Coincides with Android's max text message length +let kMaxMessageBodyCharacterCount = 2000 + +protocol AttachmentTextToolbarDelegate: class { + func attachmentTextToolbarDidTapSend(_ attachmentTextToolbar: AttachmentTextToolbar) + func attachmentTextToolbarDidBeginEditing(_ attachmentTextToolbar: AttachmentTextToolbar) + func attachmentTextToolbarDidEndEditing(_ attachmentTextToolbar: AttachmentTextToolbar) + func attachmentTextToolbarDidAddMore(_ attachmentTextToolbar: AttachmentTextToolbar) +} + +// MARK: - + +class AttachmentTextToolbar: UIView, UITextViewDelegate { + + weak var attachmentTextToolbarDelegate: AttachmentTextToolbarDelegate? + + var messageText: String? { + get { return textView.text } + + set { + textView.text = newValue + updatePlaceholderTextViewVisibility() + } + } + + // Layout Constants + + let kMinTextViewHeight: CGFloat = 38 + var maxTextViewHeight: CGFloat { + // About ~4 lines in portrait and ~3 lines in landscape. + // Otherwise we risk obscuring too much of the content. + return UIDevice.current.orientation.isPortrait ? 160 : 100 + } + var textViewHeightConstraint: NSLayoutConstraint! + var textViewHeight: CGFloat + + // MARK: - Initializers + + init(isAddMoreVisible: Bool) { + self.addMoreButton = UIButton(type: .custom) + self.sendButton = UIButton(type: .system) + self.textViewHeight = kMinTextViewHeight + + super.init(frame: CGRect.zero) + + // Specifying autorsizing mask and an intrinsic content size allows proper + // sizing when used as an input accessory view. + self.autoresizingMask = .flexibleHeight + self.translatesAutoresizingMaskIntoConstraints = false + self.backgroundColor = UIColor.clear + + textView.delegate = self + + let addMoreIcon = #imageLiteral(resourceName: "album_add_more").withRenderingMode(.alwaysTemplate) + addMoreButton.setImage(addMoreIcon, for: .normal) + addMoreButton.tintColor = Theme.darkThemePrimaryColor + addMoreButton.addTarget(self, action: #selector(didTapAddMore), for: .touchUpInside) + + let sendTitle = NSLocalizedString("ATTACHMENT_APPROVAL_SEND_BUTTON", comment: "Label for 'send' button in the 'attachment approval' dialog.") + sendButton.setTitle(sendTitle, for: .normal) + sendButton.addTarget(self, action: #selector(didTapSend), for: .touchUpInside) + + sendButton.titleLabel?.font = UIFont.ows_mediumFont(withSize: 16) + sendButton.titleLabel?.textAlignment = .center + sendButton.tintColor = Theme.galleryHighlightColor + + // Increase hit area of send button + sendButton.contentEdgeInsets = UIEdgeInsets(top: 6, left: 8, bottom: 6, right: 8) + + let contentView = UIView() + contentView.addSubview(sendButton) + contentView.addSubview(textContainer) + contentView.addSubview(lengthLimitLabel) + if isAddMoreVisible { + contentView.addSubview(addMoreButton) + } + + addSubview(contentView) + contentView.autoPinEdgesToSuperviewEdges() + + // Layout + let kToolbarMargin: CGFloat = 8 + + // We have to wrap the toolbar items in a content view because iOS (at least on iOS10.3) assigns the inputAccessoryView.layoutMargins + // when resigning first responder (verified by auditing with `layoutMarginsDidChange`). + // The effect of this is that if we were to assign these margins to self.layoutMargins, they'd be blown away if the + // user dismisses the keyboard, giving the input accessory view a wonky layout. + contentView.layoutMargins = UIEdgeInsets(top: kToolbarMargin, left: kToolbarMargin, bottom: kToolbarMargin, right: kToolbarMargin) + + self.textViewHeightConstraint = textView.autoSetDimension(.height, toSize: kMinTextViewHeight) + + // We pin all three edges explicitly rather than doing something like: + // textView.autoPinEdges(toSuperviewMarginsExcludingEdge: .right) + // because that method uses `leading` / `trailing` rather than `left` vs. `right`. + // So it doesn't work as expected with RTL layouts when we explicitly want something + // to be on the right side for both RTL and LTR layouts, like with the send button. + // I believe this is a bug in PureLayout. Filed here: https://github.com/PureLayout/PureLayout/issues/209 + textContainer.autoPinEdge(toSuperviewMargin: .top) + textContainer.autoPinEdge(toSuperviewMargin: .bottom) + if isAddMoreVisible { + addMoreButton.autoPinEdge(toSuperviewMargin: .left) + textContainer.autoPinEdge(.left, to: .right, of: addMoreButton, withOffset: kToolbarMargin) + addMoreButton.autoAlignAxis(.horizontal, toSameAxisOf: sendButton) + addMoreButton.setContentHuggingHigh() + addMoreButton.setCompressionResistanceHigh() + } else { + textContainer.autoPinEdge(toSuperviewMargin: .left) + } + + sendButton.autoPinEdge(.left, to: .right, of: textContainer, withOffset: kToolbarMargin) + sendButton.autoPinEdge(.bottom, to: .bottom, of: textContainer, withOffset: -3) + + sendButton.autoPinEdge(toSuperviewMargin: .right) + sendButton.setContentHuggingHigh() + sendButton.setCompressionResistanceHigh() + + lengthLimitLabel.autoPinEdge(toSuperviewMargin: .left) + lengthLimitLabel.autoPinEdge(toSuperviewMargin: .right) + lengthLimitLabel.autoPinEdge(.bottom, to: .top, of: textContainer, withOffset: -6) + lengthLimitLabel.setContentHuggingHigh() + lengthLimitLabel.setCompressionResistanceHigh() + } + + required init?(coder aDecoder: NSCoder) { + notImplemented() + } + + // MARK: - UIView Overrides + + override var intrinsicContentSize: CGSize { + get { + // Since we have `self.autoresizingMask = UIViewAutoresizingFlexibleHeight`, we must specify + // an intrinsicContentSize. Specifying CGSize.zero causes the height to be determined by autolayout. + return CGSize.zero + } + } + + // MARK: - Subviews + + private let addMoreButton: UIButton + private let sendButton: UIButton + + private lazy var lengthLimitLabel: UILabel = { + let lengthLimitLabel = UILabel() + + // Length Limit Label shown when the user inputs too long of a message + lengthLimitLabel.textColor = .white + lengthLimitLabel.text = NSLocalizedString("ATTACHMENT_APPROVAL_MESSAGE_LENGTH_LIMIT_REACHED", comment: "One-line label indicating the user can add no more text to the media message field.") + lengthLimitLabel.textAlignment = .center + + // Add shadow in case overlayed on white content + lengthLimitLabel.layer.shadowColor = UIColor.black.cgColor + lengthLimitLabel.layer.shadowOffset = .zero + lengthLimitLabel.layer.shadowOpacity = 0.8 + lengthLimitLabel.layer.shadowRadius = 2.0 + lengthLimitLabel.isHidden = true + + return lengthLimitLabel + }() + + lazy var textView: UITextView = { + let textView = buildTextView() + + textView.returnKeyType = .done + textView.scrollIndicatorInsets = UIEdgeInsets(top: 5, left: 0, bottom: 5, right: 3) + + return textView + }() + + private lazy var placeholderTextView: UITextView = { + let placeholderTextView = buildTextView() + + placeholderTextView.text = NSLocalizedString("MESSAGE_TEXT_FIELD_PLACEHOLDER", comment: "placeholder text for the editable message field") + placeholderTextView.isEditable = false + + return placeholderTextView + }() + + private lazy var textContainer: UIView = { + let textContainer = UIView() + + textContainer.layer.borderColor = Theme.darkThemePrimaryColor.cgColor + textContainer.layer.borderWidth = 0.5 + textContainer.layer.cornerRadius = kMinTextViewHeight / 2 + textContainer.clipsToBounds = true + + textContainer.addSubview(placeholderTextView) + placeholderTextView.autoPinEdgesToSuperviewEdges() + + textContainer.addSubview(textView) + textView.autoPinEdgesToSuperviewEdges() + + return textContainer + }() + + private func buildTextView() -> UITextView { + let textView = AttachmentTextView() + + textView.keyboardAppearance = Theme.darkThemeKeyboardAppearance + textView.backgroundColor = .clear + textView.tintColor = Theme.darkThemePrimaryColor + + textView.font = UIFont.ows_dynamicTypeBody + textView.textColor = Theme.darkThemePrimaryColor + textView.textContainerInset = UIEdgeInsets(top: 7, left: 7, bottom: 7, right: 7) + + return textView + } + + // MARK: - Actions + + @objc func didTapSend() { + attachmentTextToolbarDelegate?.attachmentTextToolbarDidTapSend(self) + } + + @objc func didTapAddMore() { + attachmentTextToolbarDelegate?.attachmentTextToolbarDidAddMore(self) + } + + // MARK: - UITextViewDelegate + + public func textViewDidChange(_ textView: UITextView) { + updateHeight(textView: textView) + } + + public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool { + + if !FeatureFlags.sendingMediaWithOversizeText { + let existingText: String = textView.text ?? "" + let proposedText: String = (existingText as NSString).replacingCharacters(in: range, with: text) + + // Don't complicate things by mixing media attachments with oversize text attachments + guard proposedText.utf8.count < kOversizeTextMessageSizeThreshold else { + Logger.debug("long text was truncated") + self.lengthLimitLabel.isHidden = false + + // `range` represents the section of the existing text we will replace. We can re-use that space. + // Range is in units of NSStrings's standard UTF-16 characters. Since some of those chars could be + // represented as single bytes in utf-8, while others may be 8 or more, the only way to be sure is + // to just measure the utf8 encoded bytes of the replaced substring. + let bytesAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").utf8.count + + // Accept as much of the input as we can + let byteBudget: Int = Int(kOversizeTextMessageSizeThreshold) - bytesAfterDelete + if byteBudget >= 0, let acceptableNewText = text.truncated(toByteCount: UInt(byteBudget)) { + textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) + } + + return false + } + self.lengthLimitLabel.isHidden = true + + // After verifying the byte-length is sufficiently small, verify the character count is within bounds. + guard proposedText.count < kMaxMessageBodyCharacterCount else { + Logger.debug("hit attachment message body character count limit") + + self.lengthLimitLabel.isHidden = false + + // `range` represents the section of the existing text we will replace. We can re-use that space. + let charsAfterDelete: Int = (existingText as NSString).replacingCharacters(in: range, with: "").count + + // Accept as much of the input as we can + let charBudget: Int = Int(kMaxMessageBodyCharacterCount) - charsAfterDelete + if charBudget >= 0 { + let acceptableNewText = String(text.prefix(charBudget)) + textView.text = (existingText as NSString).replacingCharacters(in: range, with: acceptableNewText) + } + + return false + } + } + + // Though we can wrap the text, we don't want to encourage multline captions, plus a "done" button + // allows the user to get the keyboard out of the way while in the attachment approval view. + if text == "\n" { + textView.resignFirstResponder() + return false + } else { + return true + } + } + + public func textViewDidBeginEditing(_ textView: UITextView) { + attachmentTextToolbarDelegate?.attachmentTextToolbarDidBeginEditing(self) + updatePlaceholderTextViewVisibility() + } + + public func textViewDidEndEditing(_ textView: UITextView) { + attachmentTextToolbarDelegate?.attachmentTextToolbarDidEndEditing(self) + updatePlaceholderTextViewVisibility() + } + + // MARK: - Helpers + + func updatePlaceholderTextViewVisibility() { + let isHidden: Bool = { + guard !self.textView.isFirstResponder else { + return true + } + + guard let text = self.textView.text else { + return false + } + + guard text.count > 0 else { + return false + } + + return true + }() + + placeholderTextView.isHidden = isHidden + } + + private func updateHeight(textView: UITextView) { + // compute new height assuming width is unchanged + let currentSize = textView.frame.size + let newHeight = clampedTextViewHeight(fixedWidth: currentSize.width) + + if newHeight != textViewHeight { + Logger.debug("TextView height changed: \(textViewHeight) -> \(newHeight)") + textViewHeight = newHeight + textViewHeightConstraint?.constant = textViewHeight + invalidateIntrinsicContentSize() + } + } + + private func clampedTextViewHeight(fixedWidth: CGFloat) -> CGFloat { + let contentSize = textView.sizeThatFits(CGSize(width: fixedWidth, height: CGFloat.greatestFiniteMagnitude)) + return CGFloatClamp(contentSize.height, kMinTextViewHeight, maxTextViewHeight) + } +} diff --git a/SignalMessaging/ViewControllers/AttachmentApproval/AttachmentTextView.swift b/SignalMessaging/ViewControllers/AttachmentApproval/AttachmentTextView.swift new file mode 100644 index 000000000..4dac7dc52 --- /dev/null +++ b/SignalMessaging/ViewControllers/AttachmentApproval/AttachmentTextView.swift @@ -0,0 +1,16 @@ +// +// Copyright (c) 2019 Open Whisper Systems. All rights reserved. +// + +import Foundation +import UIKit + +class AttachmentTextView: UITextView { + // When creating new lines, contentOffset is animated, but because + // we are simultaneously resizing the text view, this can cause the + // text in the textview to be "too high" in the text view. + // Solution is to disable animation for setting content offset. + override func setContentOffset(_ contentOffset: CGPoint, animated: Bool) { + super.setContentOffset(contentOffset, animated: false) + } +}