session-ios/Session/Conversations/Input View/InputView.swift

481 lines
20 KiB
Swift
Raw Normal View History

// Copyright © 2022 Rangeproof Pty Ltd. All rights reserved.
import UIKit
import SessionUIKit
import SessionMessagingKit
2021-01-29 01:46:32 +01:00
final class InputView: UIView, InputViewButtonDelegate, InputTextViewDelegate, MentionSelectionViewDelegate {
// MARK: - Variables
private static let linkPreviewViewInset: CGFloat = 6
private let threadVariant: SessionThread.Variant
2021-04-01 05:24:10 +02:00
private weak var delegate: InputViewDelegate?
var quoteDraftInfo: (model: QuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } }
var linkPreviewInfo: (url: String, draft: LinkPreviewDraft?)?
2021-02-16 03:57:30 +01:00
private var voiceMessageRecordingView: VoiceMessageRecordingView?
2021-02-17 04:26:43 +01:00
private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0)
2021-02-15 03:51:26 +01:00
2021-02-19 06:02:19 +01:00
private lazy var linkPreviewView: LinkPreviewView = {
let maxWidth: CGFloat = (self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset)
return LinkPreviewView(maxWidth: maxWidth) { [weak self] in
self?.linkPreviewInfo = nil
self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
}
2021-02-15 03:51:26 +01:00
}()
2021-01-29 01:46:32 +01:00
var text: String {
get { inputTextView.text ?? "" }
2021-01-29 01:46:32 +01:00
set { inputTextView.text = newValue }
}
var selectedRange: NSRange {
get { inputTextView.selectedRange }
set { inputTextView.selectedRange = newValue }
}
var inputTextViewIsFirstResponder: Bool { inputTextView.isFirstResponder }
var enabledMessageTypes: MessageInputTypes = .all {
didSet {
setEnabledMessageTypes(enabledMessageTypes, message: nil)
}
}
2021-01-29 01:46:32 +01:00
override var intrinsicContentSize: CGSize { CGSize.zero }
2021-02-19 03:25:31 +01:00
var lastSearchedText: String? { nil }
// MARK: - UI
private var bottomStackView: UIStackView?
2021-02-22 00:49:35 +01:00
private lazy var attachmentsButton = ExpandingAttachmentsButton(delegate: delegate)
private lazy var voiceMessageButton: InputViewButton = {
let result = InputViewButton(icon: #imageLiteral(resourceName: "Microphone"), delegate: self)
result.accessibilityLabel = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_TITLE", comment: "")
result.accessibilityHint = NSLocalizedString("VOICE_MESSAGE_TOO_SHORT_ALERT_MESSAGE", comment: "")
return result
}()
private lazy var sendButton: InputViewButton = {
let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self)
result.isHidden = true
result.accessibilityLabel = NSLocalizedString("ATTACHMENT_APPROVAL_SEND_BUTTON", comment: "")
return result
}()
private lazy var voiceMessageButtonContainer = container(for: voiceMessageButton)
2021-02-17 04:26:43 +01:00
private lazy var mentionsView: MentionSelectionView = {
let result: MentionSelectionView = MentionSelectionView()
2021-02-17 04:26:43 +01:00
result.delegate = self
2021-02-17 04:26:43 +01:00
return result
}()
2021-02-17 05:57:07 +01:00
private lazy var mentionsViewContainer: UIView = {
let result: UIView = UIView()
2021-02-17 05:57:07 +01:00
let backgroundView = UIView()
backgroundView.backgroundColor = (isLightMode ? .white : .black)
2021-02-17 05:57:07 +01:00
backgroundView.alpha = Values.lowOpacity
result.addSubview(backgroundView)
backgroundView.pin(to: result)
let blurView: UIVisualEffectView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
2021-02-17 05:57:07 +01:00
result.addSubview(blurView)
blurView.pin(to: result)
2021-02-22 03:36:26 +01:00
result.alpha = 0
2021-02-17 05:57:07 +01:00
return result
}()
2021-03-01 23:33:31 +01:00
private lazy var inputTextView: InputTextView = {
2021-03-02 00:18:08 +01:00
// HACK: When restoring a draft the input text view won't have a frame yet, and therefore it won't
// be able to calculate what size it should be to accommodate the draft text. As a workaround, we
// just calculate the max width that the input text view is allowed to be and pass it in. See
// setUpViewHierarchy() for why these values are the way they are.
2021-03-01 23:33:31 +01:00
let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2
let maxWidth = UIScreen.main.bounds.width - 2 * InputViewButton.expandedSize - 2 * Values.smallSpacing - 2 * (Values.mediumSpacing - adjustment)
return InputTextView(delegate: self, maxWidth: maxWidth)
}()
private lazy var disabledInputLabel: UILabel = {
let label: UILabel = UILabel()
label.translatesAutoresizingMaskIntoConstraints = false
label.font = UIFont.systemFont(ofSize: Values.smallFontSize)
label.textColor = Colors.text.withAlphaComponent(Values.mediumOpacity)
label.textAlignment = .center
label.alpha = 0
return label
}()
2021-02-10 04:43:57 +01:00
2021-02-26 04:11:58 +01:00
private lazy var additionalContentContainer = UIView()
2021-02-15 03:51:26 +01:00
// MARK: - Initialization
2021-01-29 01:46:32 +01:00
init(threadVariant: SessionThread.Variant, delegate: InputViewDelegate) {
self.threadVariant = threadVariant
2021-01-29 01:46:32 +01:00
self.delegate = delegate
2021-01-29 01:46:32 +01:00
super.init(frame: CGRect.zero)
2021-01-29 01:46:32 +01:00
setUpViewHierarchy()
}
2021-01-29 01:46:32 +01:00
override init(frame: CGRect) {
preconditionFailure("Use init(delegate:) instead.")
}
2021-01-29 01:46:32 +01:00
required init?(coder: NSCoder) {
preconditionFailure("Use init(delegate:) instead.")
}
2021-01-29 01:46:32 +01:00
private func setUpViewHierarchy() {
autoresizingMask = .flexibleHeight
2021-01-29 01:46:32 +01:00
// Background & blur
let backgroundView = UIView()
backgroundView.backgroundColor = isLightMode ? .white : .black
backgroundView.alpha = Values.lowOpacity
addSubview(backgroundView)
backgroundView.pin(to: self)
2021-01-29 01:46:32 +01:00
let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
addSubview(blurView)
blurView.pin(to: self)
2021-01-29 01:46:32 +01:00
// Separator
let separator = UIView()
separator.backgroundColor = Colors.text.withAlphaComponent(0.2)
separator.set(.height, to: 1 / UIScreen.main.scale)
addSubview(separator)
separator.pin([ UIView.HorizontalEdge.leading, UIView.VerticalEdge.top, UIView.HorizontalEdge.trailing ], to: self)
2021-01-29 01:46:32 +01:00
// Bottom stack view
2021-02-22 00:49:35 +01:00
let bottomStackView = UIStackView(arrangedSubviews: [ attachmentsButton, inputTextView, container(for: sendButton) ])
2021-01-29 01:46:32 +01:00
bottomStackView.axis = .horizontal
bottomStackView.spacing = Values.smallSpacing
2021-02-10 05:33:39 +01:00
bottomStackView.alignment = .center
self.bottomStackView = bottomStackView
2021-01-29 01:46:32 +01:00
// Main stack view
2021-02-22 00:49:35 +01:00
let mainStackView = UIStackView(arrangedSubviews: [ additionalContentContainer, bottomStackView ])
2021-01-29 01:46:32 +01:00
mainStackView.axis = .vertical
mainStackView.isLayoutMarginsRelativeArrangement = true
2021-01-29 01:46:32 +01:00
let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2
2021-02-23 05:30:05 +01:00
mainStackView.layoutMargins = UIEdgeInsets(top: 2, leading: Values.mediumSpacing - adjustment, bottom: 2, trailing: Values.mediumSpacing - adjustment)
2021-01-29 01:46:32 +01:00
addSubview(mainStackView)
mainStackView.pin(.top, to: .bottom, of: separator)
mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self)
2021-02-26 04:11:58 +01:00
mainStackView.pin(.bottom, to: .bottom, of: self)
addSubview(disabledInputLabel)
disabledInputLabel.pin(.top, to: .top, of: mainStackView)
disabledInputLabel.pin(.left, to: .left, of: mainStackView)
disabledInputLabel.pin(.right, to: .right, of: mainStackView)
disabledInputLabel.set(.height, to: InputViewButton.expandedSize)
2021-02-17 04:26:43 +01:00
// Mentions
2021-02-22 03:36:26 +01:00
insertSubview(mentionsViewContainer, belowSubview: mainStackView)
2021-02-17 05:57:07 +01:00
mentionsViewContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: self)
mentionsViewContainer.pin(.bottom, to: .top, of: self)
mentionsViewContainer.addSubview(mentionsView)
mentionsView.pin(to: mentionsViewContainer)
2021-02-17 04:26:43 +01:00
mentionsViewHeightConstraint.isActive = true
// Voice message button
addSubview(voiceMessageButtonContainer)
voiceMessageButtonContainer.center(in: sendButton)
2021-01-29 01:46:32 +01:00
}
// MARK: - Updating
2021-01-29 01:46:32 +01:00
func inputTextViewDidChangeSize(_ inputTextView: InputTextView) {
invalidateIntrinsicContentSize()
}
func inputTextViewDidChangeContent(_ inputTextView: InputTextView) {
let hasText = !text.isEmpty
sendButton.isHidden = !hasText
voiceMessageButtonContainer.isHidden = hasText
2021-02-15 05:07:38 +01:00
autoGenerateLinkPreviewIfPossible()
2021-04-01 05:24:10 +02:00
delegate?.inputTextViewDidChangeContent(inputTextView)
2021-01-29 01:46:32 +01:00
}
2021-12-13 05:51:42 +01:00
func didPasteImageFromPasteboard(_ inputTextView: InputTextView, image: UIImage) {
delegate?.didPasteImageFromPasteboard(image)
}
2021-02-10 04:43:57 +01:00
2021-03-02 00:18:08 +01:00
// We want to show either a link preview or a quote draft, but never both at the same time. When trying to
// generate a link preview, wait until we're sure that we'll be able to build a link preview from the given
// URL before removing the quote draft.
2021-02-10 04:43:57 +01:00
private func handleQuoteDraftChanged() {
2021-02-15 03:51:26 +01:00
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
2021-02-15 04:45:46 +01:00
linkPreviewInfo = nil
2021-02-10 04:43:57 +01:00
guard let quoteDraftInfo = quoteDraftInfo else { return }
2021-03-02 00:18:08 +01:00
let hInset: CGFloat = 6 // Slight visual adjustment
2021-02-15 03:51:26 +01:00
let maxWidth = additionalContentContainer.bounds.width
let quoteView: QuoteView = QuoteView(
for: .draft,
authorId: quoteDraftInfo.model.authorId,
quotedText: quoteDraftInfo.model.body,
threadVariant: threadVariant,
currentUserPublicKey: nil,
currentUserBlindedPublicKey: nil,
direction: (quoteDraftInfo.isOutgoing ? .outgoing : .incoming),
attachment: quoteDraftInfo.model.attachment,
hInset: hInset,
maxWidth: maxWidth
) { [weak self] in
self?.quoteDraftInfo = nil
}
2021-02-15 03:51:26 +01:00
additionalContentContainer.addSubview(quoteView)
quoteView.pin(.left, to: .left, of: additionalContentContainer, withInset: hInset)
quoteView.pin(.top, to: .top, of: additionalContentContainer, withInset: 12)
quoteView.pin(.right, to: .right, of: additionalContentContainer, withInset: -hInset)
quoteView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -6)
}
2021-02-15 05:07:38 +01:00
private func autoGenerateLinkPreviewIfPossible() {
// Don't allow link previews on 'none' or 'textOnly' input
guard enabledMessageTypes == .all else { return }
2021-02-15 05:07:38 +01:00
// Suggest that the user enable link previews if they haven't already and we haven't
2021-02-15 04:45:46 +01:00
// told them about link previews yet
2021-02-15 03:51:26 +01:00
let text = inputTextView.text!
let areLinkPreviewsEnabled: Bool = Storage.shared[.areLinkPreviewsEnabled]
if
!LinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty &&
!areLinkPreviewsEnabled &&
!UserDefaults.standard[.hasSeenLinkPreviewSuggestion]
{
2021-04-01 05:24:10 +02:00
delegate?.showLinkPreviewSuggestionModal()
UserDefaults.standard[.hasSeenLinkPreviewSuggestion] = true
2021-02-15 05:07:38 +01:00
return
2021-02-15 03:51:26 +01:00
}
2021-02-15 04:45:46 +01:00
// Check that link previews are enabled
guard areLinkPreviewsEnabled else { return }
2021-02-15 05:07:38 +01:00
// Proceed
autoGenerateLinkPreview()
}
func autoGenerateLinkPreview() {
2021-02-15 04:45:46 +01:00
// Check that a valid URL is present
guard let linkPreviewURL = LinkPreview.previewUrl(for: text, selectedRange: inputTextView.selectedRange) else {
2021-02-15 03:51:26 +01:00
return
}
2021-02-15 04:45:46 +01:00
// Guard against obsolete updates
guard linkPreviewURL != self.linkPreviewInfo?.url else { return }
2021-02-19 04:33:04 +01:00
// Clear content container
additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
quoteDraftInfo = nil
2021-02-15 04:45:46 +01:00
// Set the state to loading
linkPreviewInfo = (url: linkPreviewURL, draft: nil)
linkPreviewView.update(with: LinkPreview.LoadingState(), isOutgoing: false)
2021-02-15 04:45:46 +01:00
// Add the link preview view
2021-02-15 03:51:26 +01:00
additionalContentContainer.addSubview(linkPreviewView)
linkPreviewView.pin(.left, to: .left, of: additionalContentContainer, withInset: InputView.linkPreviewViewInset)
linkPreviewView.pin(.top, to: .top, of: additionalContentContainer, withInset: 10)
linkPreviewView.pin(.right, to: .right, of: additionalContentContainer)
linkPreviewView.pin(.bottom, to: .bottom, of: additionalContentContainer, withInset: -4)
2021-02-15 04:45:46 +01:00
// Build the link preview
LinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL)
.done { [weak self] draft in
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
self?.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
self?.linkPreviewView.update(with: LinkPreview.DraftState(linkPreviewDraft: draft), isOutgoing: false)
}
.catch { [weak self] _ in
guard self?.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
self?.linkPreviewInfo = nil
self?.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
}
.retainUntilComplete()
2021-02-10 04:43:57 +01:00
}
func setEnabledMessageTypes(_ messageTypes: MessageInputTypes, message: String?) {
guard enabledMessageTypes != messageTypes else { return }
enabledMessageTypes = messageTypes
disabledInputLabel.text = (message ?? "")
attachmentsButton.isUserInteractionEnabled = (messageTypes == .all)
voiceMessageButton.isUserInteractionEnabled = (messageTypes == .all)
UIView.animate(withDuration: 0.3) { [weak self] in
self?.bottomStackView?.alpha = (messageTypes != .none ? 1 : 0)
self?.attachmentsButton.alpha = (messageTypes == .all ?
1 :
(messageTypes == .textOnly ? 0.4 : 0)
)
self?.voiceMessageButton.alpha = (messageTypes == .all ?
1 :
(messageTypes == .textOnly ? 0.4 : 0)
)
self?.disabledInputLabel.alpha = (messageTypes != .none ? 0 : 1)
}
}
// MARK: - Interaction
2021-02-22 00:49:35 +01:00
override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
2021-03-02 00:18:08 +01:00
// Needed so that the user can tap the buttons when the expanding attachments button is expanded
2021-02-22 00:49:35 +01:00
let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton,
attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ]
if let buttonContainer: InputViewButton = buttonContainers.first(where: { $0.superview?.convert($0.frame, to: self).contains(point) == true }) {
2021-02-22 00:49:35 +01:00
return buttonContainer
}
return super.hitTest(point, with: event)
2021-02-22 00:49:35 +01:00
}
2021-02-17 04:26:43 +01:00
override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
2021-02-22 00:49:35 +01:00
let buttonContainers = [ attachmentsButton.gifButtonContainer, attachmentsButton.documentButtonContainer,
attachmentsButton.libraryButtonContainer, attachmentsButton.cameraButtonContainer, attachmentsButton.mainButtonContainer ]
let isPointInsideAttachmentsButton = buttonContainers
.contains { $0.superview!.convert($0.frame, to: self).contains(point) }
2021-02-22 00:49:35 +01:00
if isPointInsideAttachmentsButton {
2021-03-02 00:18:08 +01:00
// Needed so that the user can tap the buttons when the expanding attachments button is expanded
2021-02-22 00:49:35 +01:00
return true
}
if mentionsViewContainer.frame.contains(point) {
2021-03-02 00:18:08 +01:00
// Needed so that the user can tap mentions
2021-02-17 04:26:43 +01:00
return true
}
return super.point(inside: point, with: event)
2021-02-17 04:26:43 +01:00
}
2021-01-29 01:46:32 +01:00
func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) {
2021-04-01 05:24:10 +02:00
if inputViewButton == sendButton { delegate?.handleSendButtonTapped() }
2021-01-29 01:46:32 +01:00
}
2021-02-10 07:04:26 +01:00
func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton) {
2021-02-16 09:28:32 +01:00
guard inputViewButton == voiceMessageButton else { return }
2021-04-01 05:24:10 +02:00
delegate?.startVoiceMessageRecording()
2021-02-16 09:28:32 +01:00
showVoiceMessageUI()
}
func handleInputViewButtonLongPressMoved(_ inputViewButton: InputViewButton, with touch: UITouch) {
guard let voiceMessageRecordingView = voiceMessageRecordingView, inputViewButton == voiceMessageButton else { return }
let location = touch.location(in: voiceMessageRecordingView)
voiceMessageRecordingView.handleLongPressMoved(to: location)
}
func handleInputViewButtonLongPressEnded(_ inputViewButton: InputViewButton, with touch: UITouch) {
2021-02-16 09:28:32 +01:00
guard let voiceMessageRecordingView = voiceMessageRecordingView, inputViewButton == voiceMessageButton else { return }
let location = touch.location(in: voiceMessageRecordingView)
voiceMessageRecordingView.handleLongPressEnded(at: location)
}
2021-01-29 01:46:32 +01:00
override func resignFirstResponder() -> Bool {
inputTextView.resignFirstResponder()
}
func inputTextViewBecomeFirstResponder() {
inputTextView.becomeFirstResponder()
}
2021-02-15 03:51:26 +01:00
func handleLongPress() {
// Not relevant in this case
}
2021-02-15 04:45:46 +01:00
@objc private func showVoiceMessageUI() {
2021-02-16 03:57:30 +01:00
voiceMessageRecordingView?.removeFromSuperview()
let voiceMessageButtonFrame = voiceMessageButton.superview!.convert(voiceMessageButton.frame, to: self)
let voiceMessageRecordingView = VoiceMessageRecordingView(voiceMessageButtonFrame: voiceMessageButtonFrame, delegate: delegate)
2021-02-16 03:57:30 +01:00
voiceMessageRecordingView.alpha = 0
addSubview(voiceMessageRecordingView)
voiceMessageRecordingView.pin(to: self)
self.voiceMessageRecordingView = voiceMessageRecordingView
voiceMessageRecordingView.animate()
2021-02-22 00:49:35 +01:00
let allOtherViews = [ attachmentsButton, sendButton, inputTextView, additionalContentContainer ]
UIView.animate(withDuration: 0.25) {
allOtherViews.forEach { $0.alpha = 0 }
}
}
func hideVoiceMessageUI() {
2021-02-22 00:49:35 +01:00
let allOtherViews = [ attachmentsButton, sendButton, inputTextView, additionalContentContainer ]
UIView.animate(withDuration: 0.25, animations: {
allOtherViews.forEach { $0.alpha = 1 }
self.voiceMessageRecordingView?.alpha = 0
}, completion: { _ in
self.voiceMessageRecordingView?.removeFromSuperview()
self.voiceMessageRecordingView = nil
})
}
2021-02-17 04:26:43 +01:00
func hideMentionsUI() {
UIView.animate(
withDuration: 0.25,
animations: { [weak self] in
self?.mentionsViewContainer.alpha = 0
},
completion: { [weak self] _ in
self?.mentionsViewHeightConstraint.constant = 0
self?.mentionsView.contentOffset = CGPoint.zero
}
)
2021-02-17 04:26:43 +01:00
}
func showMentionsUI(for candidates: [ConversationViewModel.MentionInfo]) {
2021-02-17 05:57:07 +01:00
mentionsView.candidates = candidates
let mentionCellHeight = (Values.smallProfilePictureSize + 2 * Values.smallSpacing)
2021-02-17 05:57:07 +01:00
mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight
2021-02-17 04:26:43 +01:00
layoutIfNeeded()
2021-02-17 04:26:43 +01:00
UIView.animate(withDuration: 0.25) {
2021-02-17 05:57:07 +01:00
self.mentionsViewContainer.alpha = 1
2021-02-17 04:26:43 +01:00
}
}
func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView) {
delegate?.handleMentionSelected(mentionInfo, from: view)
2021-02-17 04:26:43 +01:00
}
// MARK: - Convenience
private func container(for button: InputViewButton) -> UIView {
let result: UIView = UIView()
result.addSubview(button)
result.set(.width, to: InputViewButton.expandedSize)
result.set(.height, to: InputViewButton.expandedSize)
button.center(in: result)
return result
}
2021-01-29 01:46:32 +01:00
}
// MARK: - Delegate
2021-02-15 03:51:26 +01:00
protocol InputViewDelegate: ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate {
2021-02-15 05:07:38 +01:00
func showLinkPreviewSuggestionModal()
2021-01-29 01:46:32 +01:00
func handleSendButtonTapped()
2021-02-17 04:26:43 +01:00
func inputTextViewDidChangeContent(_ inputTextView: InputTextView)
func handleMentionSelected(_ mentionInfo: ConversationViewModel.MentionInfo, from view: MentionSelectionView)
2021-12-13 05:51:42 +01:00
func didPasteImageFromPasteboard(_ image: UIImage)
2021-01-29 01:46:32 +01:00
}