520 lines
19 KiB
Swift
520 lines
19 KiB
Swift
//
|
|
// Copyright (c) 2020 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import UIKit
|
|
|
|
// Coincides with Android's max text message length
|
|
let kMaxMessageBodyCharacterCount = 2000
|
|
|
|
protocol AttachmentTextToolbarDelegate: class, MentionTextViewDelegate {
|
|
func attachmentTextToolbarDidTapSend(_ attachmentTextToolbar: AttachmentTextToolbar)
|
|
func attachmentTextToolbarDidBeginEditing(_ attachmentTextToolbar: AttachmentTextToolbar)
|
|
func attachmentTextToolbarDidEndEditing(_ attachmentTextToolbar: AttachmentTextToolbar)
|
|
func attachmentTextToolbarDidChange(_ attachmentTextToolbar: AttachmentTextToolbar)
|
|
func attachmentTextToolbarDidViewOnce(_ attachmentTextToolbar: AttachmentTextToolbar)
|
|
|
|
var isViewOnceEnabled: Bool { get set }
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
class AttachmentTextToolbar: UIView, MentionTextViewDelegate {
|
|
|
|
var options: AttachmentApprovalViewControllerOptions {
|
|
didSet {
|
|
updateContent()
|
|
}
|
|
}
|
|
|
|
weak var attachmentTextToolbarDelegate: AttachmentTextToolbarDelegate?
|
|
|
|
var isViewOnceEnabled: Bool {
|
|
return options.contains(.canToggleViewOnce) && attachmentTextToolbarDelegate?.isViewOnceEnabled ?? false
|
|
}
|
|
|
|
var messageBody: MessageBody? {
|
|
get {
|
|
// Ignore message text if "view-once" is enabled.
|
|
guard !isViewOnceEnabled else {
|
|
return nil
|
|
}
|
|
return textView.messageBody
|
|
}
|
|
|
|
set {
|
|
textView.messageBody = newValue
|
|
updatePlaceholderTextViewVisibility()
|
|
}
|
|
}
|
|
|
|
var recipientNames = [String]() {
|
|
didSet { updateRecipientNames() }
|
|
}
|
|
|
|
private let viewOnceWrapper = UIView()
|
|
|
|
// Layout Constants
|
|
|
|
let kMinToolbarItemHeight: CGFloat = 40
|
|
let kMinTextViewHeight: CGFloat = 36
|
|
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?
|
|
let kToolbarMargin: CGFloat = 8
|
|
|
|
// MARK: - Initializers
|
|
|
|
init(options: AttachmentApprovalViewControllerOptions, sendButtonImageName: String) {
|
|
self.options = options
|
|
|
|
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.mentionDelegate = self
|
|
|
|
let sendButton = OWSButton.sendButton(imageName: sendButtonImageName) { [weak self] in
|
|
guard let self = self else { return }
|
|
self.didTapSend()
|
|
}
|
|
sendButton.accessibilityLabel = NSLocalizedString("ATTACHMENT_APPROVAL_SEND_BUTTON", comment: "Label for 'send' button in the 'attachment approval' dialog.")
|
|
|
|
viewOnceButton.block = { [weak self] in
|
|
self?.didTapViewOnceMessagesButton()
|
|
}
|
|
// Vertically center and increase hit area of button, except on right side for symmetrical layout WRT the input text field
|
|
viewOnceButton.contentEdgeInsets =
|
|
UIEdgeInsets(top: 6,
|
|
left: 8,
|
|
bottom: (kMinToolbarItemHeight - timerHeight) / 2,
|
|
right: 0)
|
|
|
|
// Layout
|
|
|
|
let sendWrapper = UIView()
|
|
sendWrapper.addSubview(sendButton)
|
|
viewOnceWrapper.addSubview(viewOnceButton)
|
|
|
|
let hStackView = UIStackView()
|
|
hStackView.isLayoutMarginsRelativeArrangement = true
|
|
hStackView.layoutMargins = UIEdgeInsets(top: kToolbarMargin, left: kToolbarMargin, bottom: kToolbarMargin, right: kToolbarMargin)
|
|
hStackView.axis = .horizontal
|
|
hStackView.alignment = .bottom
|
|
hStackView.spacing = kToolbarMargin
|
|
|
|
var views = [ viewOnceWrapper, viewOnceSpacer, viewOnceRecipientNamesLabelScrollView, textContainer, sendWrapper ]
|
|
// UIStackView's horizontal layout is leading-to-trailing.
|
|
// We want left-to-right ordering, so reverse if RTL.
|
|
if CurrentAppContext().isRTL {
|
|
views.reverse()
|
|
}
|
|
for view in views {
|
|
hStackView.addArrangedSubview(view)
|
|
}
|
|
|
|
let vStackView = UIStackView(arrangedSubviews: [recipientNamesLabelScrollView, hStackView])
|
|
vStackView.axis = .vertical
|
|
vStackView.spacing = 12
|
|
self.addSubview(vStackView)
|
|
vStackView.autoPinEdgesToSuperviewEdges()
|
|
|
|
textViewHeightConstraint = textView.autoSetDimension(.height, toSize: kMinTextViewHeight)
|
|
viewOnceRecipientNamesLabelScrollView.autoSetDimension(.height, toSize: kMinTextViewHeight, relation: .greaterThanOrEqual)
|
|
viewOnceSpacer.autoSetDimension(.height, toSize: kMinTextViewHeight, relation: .greaterThanOrEqual)
|
|
|
|
// We pin 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)
|
|
|
|
let layoutButtonWithinWrapper = { (button: UIView) in
|
|
button.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .top)
|
|
button.autoPinEdge(toSuperviewEdge: .top, withInset: 0, relation: .greaterThanOrEqual)
|
|
NSLayoutConstraint.autoSetPriority(.defaultLow) {
|
|
button.autoPinEdge(toSuperviewEdge: .top)
|
|
}
|
|
|
|
button.setContentHuggingHigh()
|
|
button.setCompressionResistanceHigh()
|
|
}
|
|
layoutButtonWithinWrapper(sendButton)
|
|
layoutButtonWithinWrapper(viewOnceButton)
|
|
|
|
updateContent()
|
|
}
|
|
|
|
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
|
|
|
|
let timerHeight: CGFloat = 24
|
|
private func updateContent() {
|
|
AssertIsOnMainThread()
|
|
|
|
let imageName = isViewOnceEnabled ? "view-once-24" : "view-infinite-24"
|
|
viewOnceButton.setTemplateImageName(imageName, tintColor: Theme.darkThemePrimaryColor)
|
|
|
|
textContainer.isHidden = isViewOnceEnabled
|
|
viewOnceWrapper.isHidden = !options.contains(.canToggleViewOnce)
|
|
|
|
updateHeight(textView: textView)
|
|
|
|
showViewOnceTooltipIfNecessary()
|
|
|
|
updateRecipientNames()
|
|
}
|
|
|
|
lazy var textView: MentionTextView = {
|
|
let textView = buildTextView()
|
|
|
|
textView.returnKeyType = .done
|
|
textView.scrollIndicatorInsets = UIEdgeInsets(top: 5, left: 0, bottom: 5, right: 3)
|
|
textView.mentionDelegate = self
|
|
|
|
return textView
|
|
}()
|
|
|
|
private let placeholderText = NSLocalizedString("MESSAGE_TEXT_FIELD_PLACEHOLDER", comment: "placeholder text for the editable message field")
|
|
|
|
private lazy var placeholderTextView: UITextView = {
|
|
let placeholderTextView = buildTextView()
|
|
|
|
placeholderTextView.text = placeholderText
|
|
placeholderTextView.isEditable = false
|
|
placeholderTextView.textContainer.maximumNumberOfLines = 1
|
|
placeholderTextView.textContainer.lineBreakMode = .byTruncatingTail
|
|
placeholderTextView.textColor = .ows_whiteAlpha60
|
|
|
|
return placeholderTextView
|
|
}()
|
|
|
|
private lazy var recipientNamesLabelScrollView: UIScrollView = {
|
|
let scrollView = UIScrollView()
|
|
scrollView.isHidden = true
|
|
scrollView.showsHorizontalScrollIndicator = false
|
|
scrollView.contentInset = UIEdgeInsets(top: kToolbarMargin, leading: 16, bottom: 0, trailing: 16)
|
|
|
|
scrollView.addSubview(recipientNamesLabel)
|
|
recipientNamesLabel.autoPinEdgesToSuperviewEdges()
|
|
recipientNamesLabel.autoMatch(.height, to: .height, of: scrollView, withOffset: -kToolbarMargin)
|
|
|
|
return scrollView
|
|
}()
|
|
|
|
private lazy var recipientNamesLabel: UILabel = {
|
|
let label = UILabel()
|
|
label.font = .ows_dynamicTypeBody2
|
|
label.textColor = Theme.darkThemePrimaryColor
|
|
|
|
label.setContentHuggingLow()
|
|
|
|
return label
|
|
}()
|
|
|
|
private lazy var viewOnceRecipientNamesLabelScrollView: UIScrollView = {
|
|
let scrollView = UIScrollView()
|
|
scrollView.isHidden = true
|
|
scrollView.showsHorizontalScrollIndicator = false
|
|
|
|
scrollView.addSubview(viewOnceRecipientNamesLabel)
|
|
viewOnceRecipientNamesLabel.autoPinEdgesToSuperviewEdges()
|
|
viewOnceRecipientNamesLabel.autoMatch(.width, to: .width, of: scrollView, withOffset: 0, relation: .greaterThanOrEqual)
|
|
viewOnceRecipientNamesLabel.autoMatch(.height, to: .height, of: scrollView)
|
|
|
|
return scrollView
|
|
}()
|
|
|
|
private lazy var viewOnceRecipientNamesLabel: UILabel = {
|
|
let label = UILabel()
|
|
label.font = .ows_dynamicTypeBody2
|
|
label.textColor = Theme.darkThemePrimaryColor
|
|
label.textAlignment = .center
|
|
|
|
label.setContentHuggingLow()
|
|
|
|
return label
|
|
}()
|
|
|
|
private lazy var textContainer: UIView = {
|
|
let textContainer = UIView()
|
|
let textBorder = UIView()
|
|
textContainer.addSubview(textBorder)
|
|
let inset = (kMinToolbarItemHeight - kMinTextViewHeight) / 2
|
|
textBorder.autoPinEdgesToSuperviewEdges(with: UIEdgeInsets(top: 0, leading: 0, bottom: inset, trailing: 0))
|
|
|
|
textBorder.layer.borderColor = Theme.darkThemePrimaryColor.cgColor
|
|
textBorder.layer.borderWidth = CGHairlineWidthFraction(1.4)
|
|
textBorder.layer.cornerRadius = kMinTextViewHeight / 2
|
|
textBorder.clipsToBounds = true
|
|
|
|
textBorder.addSubview(placeholderTextView)
|
|
placeholderTextView.autoPinEdgesToSuperviewEdges()
|
|
|
|
textBorder.addSubview(textView)
|
|
textView.autoPinEdgesToSuperviewEdges()
|
|
|
|
return textContainer
|
|
}()
|
|
|
|
private func buildTextView() -> MentionTextView {
|
|
let textView = AttachmentTextView()
|
|
|
|
textView.keyboardAppearance = Theme.darkThemeKeyboardAppearance
|
|
textView.backgroundColor = .clear
|
|
textView.tintColor = Theme.darkThemePrimaryColor
|
|
|
|
let textViewFont = UIFont.ows_dynamicTypeBody
|
|
textView.font = textViewFont
|
|
textView.textColor = Theme.darkThemePrimaryColor
|
|
|
|
// Check the system font size and increase text inset accordingly
|
|
// to keep the text vertically centered
|
|
textView.updateVerticalInsetsForDynamicBodyType(defaultInsets: 7)
|
|
textView.textContainerInset.left = 7
|
|
textView.textContainerInset.right = 7
|
|
|
|
return textView
|
|
}
|
|
|
|
private let viewOnceButton = OWSButton()
|
|
|
|
private let viewOnceSpacer = UIView.hStretchingSpacer()
|
|
|
|
// MARK: - Actions
|
|
|
|
@objc
|
|
func didTapSend() {
|
|
assert(attachmentTextToolbarDelegate != nil)
|
|
|
|
textView.acceptAutocorrectSuggestion()
|
|
attachmentTextToolbarDelegate?.attachmentTextToolbarDidTapSend(self)
|
|
}
|
|
|
|
@objc
|
|
func didTapViewOnceMessagesButton() {
|
|
AssertIsOnMainThread()
|
|
|
|
// Toggle value.
|
|
attachmentTextToolbarDelegate?.isViewOnceEnabled = !isViewOnceEnabled
|
|
preferences.setWasViewOnceTooltipShown()
|
|
|
|
attachmentTextToolbarDelegate?.attachmentTextToolbarDidViewOnce(self)
|
|
|
|
if isViewOnceEnabled { textView.resignFirstResponder() }
|
|
|
|
updateContent()
|
|
}
|
|
|
|
// MARK: - MentionTextViewDelegate
|
|
|
|
func textViewDidBeginTypingMention(_ textView: MentionTextView) {
|
|
attachmentTextToolbarDelegate?.textViewDidBeginTypingMention(textView)
|
|
}
|
|
|
|
func textViewDidEndTypingMention(_ textView: MentionTextView) {
|
|
attachmentTextToolbarDelegate?.textViewDidEndTypingMention(textView)
|
|
}
|
|
|
|
func textViewMentionPickerParentView(_ textView: MentionTextView) -> UIView? {
|
|
return attachmentTextToolbarDelegate?.textViewMentionPickerParentView(textView)
|
|
}
|
|
|
|
func textViewMentionPickerReferenceView(_ textView: MentionTextView) -> UIView? {
|
|
return attachmentTextToolbarDelegate?.textViewMentionPickerReferenceView(textView)
|
|
}
|
|
|
|
func textViewMentionPickerPossibleAddresses(_ textView: MentionTextView) -> [SignalServiceAddress] {
|
|
return attachmentTextToolbarDelegate?.textViewMentionPickerPossibleAddresses(textView) ?? []
|
|
}
|
|
|
|
func textView(_ textView: MentionTextView, didDeleteMention mention: Mention) {}
|
|
|
|
func textView(_ textView: MentionTextView, shouldResolveMentionForAddress address: SignalServiceAddress) -> Bool {
|
|
owsAssertDebug(attachmentTextToolbarDelegate != nil)
|
|
return attachmentTextToolbarDelegate?.textView(textView, shouldResolveMentionForAddress: address) ?? false
|
|
}
|
|
|
|
func textViewMentionStyle(_ textView: MentionTextView) -> Mention.Style {
|
|
return .composingAttachment
|
|
}
|
|
|
|
// MARK: - UITextViewDelegate
|
|
|
|
public func textViewDidChange(_ textView: UITextView) {
|
|
updateHeight(textView: textView)
|
|
attachmentTextToolbarDelegate?.attachmentTextToolbarDidChange(self)
|
|
updatePlaceholderTextViewVisibility()
|
|
}
|
|
|
|
public func textView(_ textView: UITextView, shouldChangeTextIn range: NSRange, replacementText text: String) -> Bool {
|
|
// 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 let text = self.textView.text else {
|
|
return false
|
|
}
|
|
|
|
guard text.count > 0 else {
|
|
return false
|
|
}
|
|
|
|
return true
|
|
}()
|
|
|
|
placeholderTextView.isHidden = isHidden
|
|
}
|
|
|
|
func updateRecipientNames() {
|
|
viewOnceSpacer.isHidden = !isViewOnceEnabled || recipientNames.count > 0
|
|
viewOnceRecipientNamesLabelScrollView.isHidden = !isViewOnceEnabled || recipientNames.isEmpty
|
|
recipientNamesLabelScrollView.isHidden = isViewOnceEnabled || recipientNames.count < 2
|
|
|
|
switch recipientNames.count {
|
|
case 0:
|
|
placeholderTextView.text = placeholderText
|
|
case 1:
|
|
let messageToText = String(
|
|
format: NSLocalizedString(
|
|
"ATTACHMENT_APPROVAL_MESSAGE_TO_FORMAT",
|
|
comment: "Placeholder text indicating who this attachment will be sent to. Embeds: {{recipient name}}"
|
|
), recipientNames[0]
|
|
)
|
|
placeholderTextView.text = messageToText
|
|
viewOnceRecipientNamesLabel.text = messageToText
|
|
default:
|
|
let namesList = recipientNames.joined(separator: ", ")
|
|
placeholderTextView.text = placeholderText
|
|
recipientNamesLabel.text = namesList
|
|
viewOnceRecipientNamesLabel.text = namesList
|
|
}
|
|
}
|
|
|
|
private func updateHeight(textView: UITextView) {
|
|
guard let textViewHeightConstraint = textViewHeightConstraint else {
|
|
owsFailDebug("Missing constraint.")
|
|
return
|
|
}
|
|
|
|
// compute new height assuming width is unchanged
|
|
let currentSize = textView.frame.size
|
|
let textViewHeight = clampedTextViewHeight(fixedWidth: currentSize.width)
|
|
|
|
if textViewHeightConstraint.constant != textViewHeight {
|
|
Logger.debug("TextView height changed: \(textViewHeightConstraint.constant) -> \(textViewHeight)")
|
|
textViewHeightConstraint.constant = textViewHeight
|
|
invalidateIntrinsicContentSize()
|
|
}
|
|
textViewHeightConstraint.isActive = !isViewOnceEnabled
|
|
}
|
|
|
|
private func clampedTextViewHeight(fixedWidth: CGFloat) -> CGFloat {
|
|
let contentSize = textView.sizeThatFits(CGSize(width: fixedWidth, height: CGFloat.greatestFiniteMagnitude))
|
|
return CGFloatClamp(contentSize.height, kMinTextViewHeight, maxTextViewHeight)
|
|
}
|
|
|
|
// MARK: - Helpers
|
|
|
|
// The tooltip lies outside this view's bounds, so we
|
|
// need to special-case the hit testing so that it can
|
|
// intercept touches within its bounds.
|
|
@objc
|
|
public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
|
|
if let viewOnceTooltip = self.viewOnceTooltip {
|
|
let tooltipFrame = convert(viewOnceTooltip.bounds, from: viewOnceTooltip)
|
|
if tooltipFrame.contains(point) {
|
|
return true
|
|
}
|
|
}
|
|
return super.point(inside: point, with: event)
|
|
}
|
|
|
|
private var shouldShowViewOnceTooltip: Bool {
|
|
guard !isViewOnceEnabled else {
|
|
return false
|
|
}
|
|
guard !preferences.wasViewOnceTooltipShown() else {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
private var viewOnceTooltip: UIView?
|
|
|
|
// Show the tooltip if a) it should be shown b) isn't already showing.
|
|
private func showViewOnceTooltipIfNecessary() {
|
|
guard shouldShowViewOnceTooltip else {
|
|
return
|
|
}
|
|
guard nil == viewOnceTooltip else {
|
|
// Already showing the tooltip.
|
|
return
|
|
}
|
|
guard !viewOnceButton.isHidden && !viewOnceWrapper.isHidden else {
|
|
return
|
|
}
|
|
let tooltip = ViewOnceTooltip.present(fromView: self, widthReferenceView: self, tailReferenceView: viewOnceButton) { [weak self] in
|
|
self?.removeViewOnceTooltip()
|
|
}
|
|
viewOnceTooltip = tooltip
|
|
|
|
DispatchQueue.global().async {
|
|
self.preferences.setWasViewOnceTooltipShown()
|
|
|
|
DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 5) { [weak self] in
|
|
self?.removeViewOnceTooltip()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func removeViewOnceTooltip() {
|
|
viewOnceTooltip?.removeFromSuperview()
|
|
viewOnceTooltip = nil
|
|
}
|
|
}
|