mirror of https://github.com/oxen-io/session-ios
				
				
				
			
			You cannot select more than 25 topics
			Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
		
		
		
		
		
			
		
			
				
	
	
		
			349 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
			
		
		
	
	
			349 lines
		
	
	
		
			16 KiB
		
	
	
	
		
			Swift
		
	
 | 
						|
final class InputView : UIView, InputViewButtonDelegate, InputTextViewDelegate, QuoteViewDelegate, LinkPreviewViewDelegate, MentionSelectionViewDelegate {
 | 
						|
    private weak var delegate: InputViewDelegate?
 | 
						|
    var quoteDraftInfo: (model: OWSQuotedReplyModel, isOutgoing: Bool)? { didSet { handleQuoteDraftChanged() } }
 | 
						|
    var linkPreviewInfo: (url: String, draft: OWSLinkPreviewDraft?)?
 | 
						|
    private var voiceMessageRecordingView: VoiceMessageRecordingView?
 | 
						|
    private lazy var mentionsViewHeightConstraint = mentionsView.set(.height, to: 0)
 | 
						|
 | 
						|
    private lazy var linkPreviewView: LinkPreviewView = {
 | 
						|
        let maxWidth = self.additionalContentContainer.bounds.width - InputView.linkPreviewViewInset
 | 
						|
        return LinkPreviewView(for: nil, maxWidth: maxWidth, delegate: self)
 | 
						|
    }()
 | 
						|
 | 
						|
    var text: String {
 | 
						|
        get { inputTextView.text }
 | 
						|
        set { inputTextView.text = newValue }
 | 
						|
    }
 | 
						|
    
 | 
						|
    override var intrinsicContentSize: CGSize { CGSize.zero }
 | 
						|
    var lastSearchedText: String? { nil }
 | 
						|
    
 | 
						|
    // MARK: UI Components
 | 
						|
    private lazy var attachmentsButton = ExpandingAttachmentsButton(delegate: delegate)
 | 
						|
    
 | 
						|
    private lazy var voiceMessageButton = InputViewButton(icon: #imageLiteral(resourceName: "Microphone"), delegate: self)
 | 
						|
    
 | 
						|
    private lazy var sendButton: InputViewButton = {
 | 
						|
        let result = InputViewButton(icon: #imageLiteral(resourceName: "ArrowUp"), isSendButton: true, delegate: self)
 | 
						|
        result.isHidden = true
 | 
						|
        return result
 | 
						|
    }()
 | 
						|
    private lazy var voiceMessageButtonContainer = container(for: voiceMessageButton)
 | 
						|
 | 
						|
    private lazy var mentionsView: MentionSelectionView = {
 | 
						|
        let result = MentionSelectionView()
 | 
						|
        result.delegate = self
 | 
						|
        return result
 | 
						|
    }()
 | 
						|
 | 
						|
    private lazy var mentionsViewContainer: UIView = {
 | 
						|
        let result = UIView()
 | 
						|
        let backgroundView = UIView()
 | 
						|
        backgroundView.backgroundColor = isLightMode ? .white : .black
 | 
						|
        backgroundView.alpha = Values.lowOpacity
 | 
						|
        result.addSubview(backgroundView)
 | 
						|
        backgroundView.pin(to: result)
 | 
						|
        let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
 | 
						|
        result.addSubview(blurView)
 | 
						|
        blurView.pin(to: result)
 | 
						|
        result.alpha = 0
 | 
						|
        return result
 | 
						|
    }()
 | 
						|
    
 | 
						|
    private lazy var inputTextView: InputTextView = {
 | 
						|
        // 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.
 | 
						|
        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 additionalContentContainer = UIView()
 | 
						|
 | 
						|
    // MARK: Settings
 | 
						|
    private static let linkPreviewViewInset: CGFloat = 6
 | 
						|
    
 | 
						|
    // MARK: Lifecycle
 | 
						|
    init(delegate: InputViewDelegate) {
 | 
						|
        self.delegate = delegate
 | 
						|
        super.init(frame: CGRect.zero)
 | 
						|
        setUpViewHierarchy()
 | 
						|
    }
 | 
						|
    
 | 
						|
    override init(frame: CGRect) {
 | 
						|
        preconditionFailure("Use init(delegate:) instead.")
 | 
						|
    }
 | 
						|
    
 | 
						|
    required init?(coder: NSCoder) {
 | 
						|
        preconditionFailure("Use init(delegate:) instead.")
 | 
						|
    }
 | 
						|
    
 | 
						|
    private func setUpViewHierarchy() {
 | 
						|
        autoresizingMask = .flexibleHeight
 | 
						|
        // Background & blur
 | 
						|
        let backgroundView = UIView()
 | 
						|
        backgroundView.backgroundColor = isLightMode ? .white : .black
 | 
						|
        backgroundView.alpha = Values.lowOpacity
 | 
						|
        addSubview(backgroundView)
 | 
						|
        backgroundView.pin(to: self)
 | 
						|
        let blurView = UIVisualEffectView(effect: UIBlurEffect(style: .regular))
 | 
						|
        addSubview(blurView)
 | 
						|
        blurView.pin(to: self)
 | 
						|
        // 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)
 | 
						|
        // Bottom stack view
 | 
						|
        let bottomStackView = UIStackView(arrangedSubviews: [ attachmentsButton, inputTextView, container(for: sendButton) ])
 | 
						|
        bottomStackView.axis = .horizontal
 | 
						|
        bottomStackView.spacing = Values.smallSpacing
 | 
						|
        bottomStackView.alignment = .center
 | 
						|
        // Main stack view
 | 
						|
        let mainStackView = UIStackView(arrangedSubviews: [ additionalContentContainer, bottomStackView ])
 | 
						|
        mainStackView.axis = .vertical
 | 
						|
        mainStackView.isLayoutMarginsRelativeArrangement = true
 | 
						|
        let adjustment = (InputViewButton.expandedSize - InputViewButton.size) / 2
 | 
						|
        mainStackView.layoutMargins = UIEdgeInsets(top: 2, leading: Values.mediumSpacing - adjustment, bottom: 2, trailing: Values.mediumSpacing - adjustment)
 | 
						|
        addSubview(mainStackView)
 | 
						|
        mainStackView.pin(.top, to: .bottom, of: separator)
 | 
						|
        mainStackView.pin([ UIView.HorizontalEdge.leading, UIView.HorizontalEdge.trailing ], to: self)
 | 
						|
        mainStackView.pin(.bottom, to: .bottom, of: self)
 | 
						|
        // Mentions
 | 
						|
        insertSubview(mentionsViewContainer, belowSubview: mainStackView)
 | 
						|
        mentionsViewContainer.pin([ UIView.HorizontalEdge.left, UIView.HorizontalEdge.right ], to: self)
 | 
						|
        mentionsViewContainer.pin(.bottom, to: .top, of: self)
 | 
						|
        mentionsViewContainer.addSubview(mentionsView)
 | 
						|
        mentionsView.pin(to: mentionsViewContainer)
 | 
						|
        mentionsViewHeightConstraint.isActive = true
 | 
						|
        // Voice message button
 | 
						|
        addSubview(voiceMessageButtonContainer)
 | 
						|
        voiceMessageButtonContainer.center(in: sendButton)
 | 
						|
    }
 | 
						|
    
 | 
						|
    // MARK: Updating
 | 
						|
    func inputTextViewDidChangeSize(_ inputTextView: InputTextView) {
 | 
						|
        invalidateIntrinsicContentSize()
 | 
						|
    }
 | 
						|
 | 
						|
    func inputTextViewDidChangeContent(_ inputTextView: InputTextView) {
 | 
						|
        let hasText = !text.isEmpty
 | 
						|
        sendButton.isHidden = !hasText
 | 
						|
        voiceMessageButtonContainer.isHidden = hasText
 | 
						|
        autoGenerateLinkPreviewIfPossible()
 | 
						|
        delegate?.inputTextViewDidChangeContent(inputTextView)
 | 
						|
    }
 | 
						|
 | 
						|
    // 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.
 | 
						|
    
 | 
						|
    private func handleQuoteDraftChanged() {
 | 
						|
        additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
 | 
						|
        linkPreviewInfo = nil
 | 
						|
        guard let quoteDraftInfo = quoteDraftInfo else { return }
 | 
						|
        let direction: QuoteView.Direction = quoteDraftInfo.isOutgoing ? .outgoing : .incoming
 | 
						|
        let hInset: CGFloat = 6 // Slight visual adjustment
 | 
						|
        let maxWidth = additionalContentContainer.bounds.width
 | 
						|
        let quoteView = QuoteView(for: quoteDraftInfo.model, direction: direction, hInset: hInset, maxWidth: maxWidth, delegate: self)
 | 
						|
        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)
 | 
						|
    }
 | 
						|
 | 
						|
    private func autoGenerateLinkPreviewIfPossible() {
 | 
						|
        // Suggest that the user enable link previews if they haven't already and we haven't
 | 
						|
        // told them about link previews yet
 | 
						|
        let text = inputTextView.text!
 | 
						|
        let userDefaults = UserDefaults.standard
 | 
						|
        if !OWSLinkPreview.allPreviewUrls(forMessageBodyText: text).isEmpty && !SSKPreferences.areLinkPreviewsEnabled
 | 
						|
            && !userDefaults[.hasSeenLinkPreviewSuggestion] {
 | 
						|
            delegate?.showLinkPreviewSuggestionModal()
 | 
						|
            userDefaults[.hasSeenLinkPreviewSuggestion] = true
 | 
						|
            return
 | 
						|
        }
 | 
						|
        // Check that link previews are enabled
 | 
						|
        guard SSKPreferences.areLinkPreviewsEnabled else { return }
 | 
						|
        // Proceed
 | 
						|
        autoGenerateLinkPreview()
 | 
						|
    }
 | 
						|
 | 
						|
    func autoGenerateLinkPreview() {
 | 
						|
        // Check that a valid URL is present
 | 
						|
        guard let linkPreviewURL = OWSLinkPreview.previewUrl(forRawBodyText: text, selectedRange: inputTextView.selectedRange) else {
 | 
						|
            return
 | 
						|
        }
 | 
						|
        // Guard against obsolete updates
 | 
						|
        guard linkPreviewURL != self.linkPreviewInfo?.url else { return }
 | 
						|
        // Clear content container
 | 
						|
        additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
 | 
						|
        quoteDraftInfo = nil
 | 
						|
        // Set the state to loading
 | 
						|
        linkPreviewInfo = (url: linkPreviewURL, draft: nil)
 | 
						|
        linkPreviewView.linkPreviewState = LinkPreviewLoading()
 | 
						|
        // Add the link preview view
 | 
						|
        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)
 | 
						|
        // Build the link preview
 | 
						|
        OWSLinkPreview.tryToBuildPreviewInfo(previewUrl: linkPreviewURL).done { [weak self] draft in
 | 
						|
            guard let self = self else { return }
 | 
						|
            guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
 | 
						|
            self.linkPreviewInfo = (url: linkPreviewURL, draft: draft)
 | 
						|
            self.linkPreviewView.linkPreviewState = LinkPreviewDraft(linkPreviewDraft: draft)
 | 
						|
        }.catch { _ in
 | 
						|
            guard self.linkPreviewInfo?.url == linkPreviewURL else { return } // Obsolete
 | 
						|
            self.linkPreviewInfo = nil
 | 
						|
            self.additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
 | 
						|
        }.retainUntilComplete()
 | 
						|
    }
 | 
						|
    
 | 
						|
    // MARK: Interaction
 | 
						|
    override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
 | 
						|
        // Needed so that the user can tap the buttons when the expanding attachments button is expanded
 | 
						|
        let buttonContainers = [ attachmentsButton.mainButton, attachmentsButton.cameraButton,
 | 
						|
            attachmentsButton.libraryButton, attachmentsButton.documentButton, attachmentsButton.gifButton ]
 | 
						|
        let buttonContainer = buttonContainers.first { $0.superview!.convert($0.frame, to: self).contains(point) }
 | 
						|
        if let buttonContainer = buttonContainer {
 | 
						|
            return buttonContainer
 | 
						|
        } else {
 | 
						|
            return super.hitTest(point, with: event)
 | 
						|
        }
 | 
						|
    }
 | 
						|
    
 | 
						|
    override func point(inside point: CGPoint, with event: UIEvent?) -> Bool {
 | 
						|
        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) }
 | 
						|
        if isPointInsideAttachmentsButton {
 | 
						|
            // Needed so that the user can tap the buttons when the expanding attachments button is expanded
 | 
						|
            return true
 | 
						|
        } else if mentionsViewContainer.frame.contains(point) {
 | 
						|
            // Needed so that the user can tap mentions
 | 
						|
            return true
 | 
						|
        } else {
 | 
						|
            return super.point(inside: point, with: event)
 | 
						|
        }
 | 
						|
    }
 | 
						|
    
 | 
						|
    func handleInputViewButtonTapped(_ inputViewButton: InputViewButton) {
 | 
						|
        if inputViewButton == sendButton { delegate?.handleSendButtonTapped() }
 | 
						|
    }
 | 
						|
 | 
						|
    func handleInputViewButtonLongPressBegan(_ inputViewButton: InputViewButton) {
 | 
						|
        guard inputViewButton == voiceMessageButton else { return }
 | 
						|
        delegate?.startVoiceMessageRecording()
 | 
						|
        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) {
 | 
						|
        guard let voiceMessageRecordingView = voiceMessageRecordingView, inputViewButton == voiceMessageButton else { return }
 | 
						|
        let location = touch.location(in: voiceMessageRecordingView)
 | 
						|
        voiceMessageRecordingView.handleLongPressEnded(at: location)
 | 
						|
    }
 | 
						|
 | 
						|
    func handleQuoteViewCancelButtonTapped() {
 | 
						|
        delegate?.handleQuoteViewCancelButtonTapped()
 | 
						|
    }
 | 
						|
 | 
						|
    override func resignFirstResponder() -> Bool {
 | 
						|
        inputTextView.resignFirstResponder()
 | 
						|
    }
 | 
						|
 | 
						|
    func handleLongPress() {
 | 
						|
        // Not relevant in this case
 | 
						|
    }
 | 
						|
 | 
						|
    func handleLinkPreviewCanceled() {
 | 
						|
        linkPreviewInfo = nil
 | 
						|
        additionalContentContainer.subviews.forEach { $0.removeFromSuperview() }
 | 
						|
    }
 | 
						|
 | 
						|
    @objc private func showVoiceMessageUI() {
 | 
						|
        voiceMessageRecordingView?.removeFromSuperview()
 | 
						|
        let voiceMessageButtonFrame = voiceMessageButton.superview!.convert(voiceMessageButton.frame, to: self)
 | 
						|
        let voiceMessageRecordingView = VoiceMessageRecordingView(voiceMessageButtonFrame: voiceMessageButtonFrame, delegate: delegate)
 | 
						|
        voiceMessageRecordingView.alpha = 0
 | 
						|
        addSubview(voiceMessageRecordingView)
 | 
						|
        voiceMessageRecordingView.pin(to: self)
 | 
						|
        self.voiceMessageRecordingView = voiceMessageRecordingView
 | 
						|
        voiceMessageRecordingView.animate()
 | 
						|
        let allOtherViews = [ attachmentsButton, sendButton, inputTextView, additionalContentContainer ]
 | 
						|
        UIView.animate(withDuration: 0.25) {
 | 
						|
            allOtherViews.forEach { $0.alpha = 0 }
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    func hideVoiceMessageUI() {
 | 
						|
        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
 | 
						|
        })
 | 
						|
    }
 | 
						|
 | 
						|
    func hideMentionsUI() {
 | 
						|
        UIView.animate(withDuration: 0.25, animations: {
 | 
						|
            self.mentionsViewContainer.alpha = 0
 | 
						|
        }, completion: { _ in
 | 
						|
            self.mentionsViewHeightConstraint.constant = 0
 | 
						|
            self.mentionsView.tableView.contentOffset = CGPoint.zero
 | 
						|
        })
 | 
						|
    }
 | 
						|
 | 
						|
    func showMentionsUI(for candidates: [Mention], in thread: TSThread) {
 | 
						|
        if let openGroupV2 = Storage.shared.getV2OpenGroup(for: thread.uniqueId!) {
 | 
						|
            mentionsView.openGroupServer = openGroupV2.server
 | 
						|
            mentionsView.openGroupRoom = openGroupV2.room
 | 
						|
        }
 | 
						|
        mentionsView.candidates = candidates
 | 
						|
        let mentionCellHeight = Values.smallProfilePictureSize + 2 * Values.smallSpacing
 | 
						|
        mentionsViewHeightConstraint.constant = CGFloat(min(3, candidates.count)) * mentionCellHeight
 | 
						|
        layoutIfNeeded()
 | 
						|
        UIView.animate(withDuration: 0.25) {
 | 
						|
            self.mentionsViewContainer.alpha = 1
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView) {
 | 
						|
        delegate?.handleMentionSelected(mention, from: view)
 | 
						|
    }
 | 
						|
 | 
						|
    // MARK: Convenience
 | 
						|
    private func container(for button: InputViewButton) -> UIView {
 | 
						|
        let result = UIView()
 | 
						|
        result.addSubview(button)
 | 
						|
        result.set(.width, to: InputViewButton.expandedSize)
 | 
						|
        result.set(.height, to: InputViewButton.expandedSize)
 | 
						|
        button.center(in: result)
 | 
						|
        return result
 | 
						|
    }
 | 
						|
}
 | 
						|
 | 
						|
// MARK: Delegate
 | 
						|
protocol InputViewDelegate : AnyObject, ExpandingAttachmentsButtonDelegate, VoiceMessageRecordingViewDelegate {
 | 
						|
 | 
						|
    func showLinkPreviewSuggestionModal()
 | 
						|
    func handleSendButtonTapped()
 | 
						|
    func handleQuoteViewCancelButtonTapped()
 | 
						|
    func inputTextViewDidChangeContent(_ inputTextView: InputTextView)
 | 
						|
    func handleMentionSelected(_ mention: Mention, from view: MentionSelectionView)
 | 
						|
}
 |