// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import SignalServiceKit enum CVCBottomViewType: Equatable { // For perf reasons, we don't use a bottom view until // the view is about to appear for the first time. case none case inputToolbar case memberRequestView case messageRequestView(messageRequestType: MessageRequestType) case search case selection case blockingLegacyGroup case announcementOnlyGroup } // MARK: - public extension ConversationViewController { internal var bottomViewType: CVCBottomViewType { get { viewState.bottomViewType } set { // For perf reasons, we avoid adding any "bottom view" // to the view hierarchy until its necessary, e.g. when // the view is about to appear. owsAssertDebug(hasViewWillAppearEverBegun) if viewState.bottomViewType != newValue { if viewState.bottomViewType == .inputToolbar { // Dismiss the keyboard if we're swapping out the input toolbar dismissKeyBoard() } viewState.bottomViewType = newValue updateBottomBar() } } } func ensureBottomViewType() { AssertIsOnMainThread() guard viewState.selectionAnimationState == .idle else { return } bottomViewType = { () -> CVCBottomViewType in // The ordering of this method determines // precedence of the bottom views. if !hasViewWillAppearEverBegun { return .none } else if threadViewModel.hasPendingMessageRequest { let messageRequestType = Self.databaseStorage.read { transaction in MessageRequestView.messageRequestType(forThread: self.threadViewModel.threadRecord, transaction: transaction) } return .messageRequestView(messageRequestType: messageRequestType) } else if isLocalUserRequestingMember { return .memberRequestView } else if hasBlockingLegacyGroup { return .blockingLegacyGroup } else if isBlockedFromSendingByAnnouncementOnlyGroup { return .announcementOnlyGroup } else { switch uiMode { case .search: return .search case .selection: return .selection case .normal: if viewState.isInPreviewPlatter || userLeftGroup { return .none } else { return .inputToolbar } } } }() } private func updateBottomBar() { AssertIsOnMainThread() guard hasViewWillAppearEverBegun else { return } // Animate the dismissal of any existing request view. dismissRequestView() requestView?.removeFromSuperview() requestView = nil let bottomView: UIView? switch bottomViewType { case .none: bottomView = nil case .messageRequestView: let messageRequestView = MessageRequestView(threadViewModel: threadViewModel) messageRequestView.delegate = self requestView = messageRequestView bottomView = messageRequestView case .memberRequestView: let memberRequestView = MemberRequestView(threadViewModel: threadViewModel, fromViewController: self) memberRequestView.delegate = self requestView = memberRequestView bottomView = memberRequestView case .search: bottomView = searchController.resultsBar case .selection: bottomView = selectionToolbar case .inputToolbar: bottomView = inputToolbar case .blockingLegacyGroup: let legacyGroupView = BlockingLegacyGroupView(fromViewController: self) requestView = legacyGroupView bottomView = legacyGroupView case .announcementOnlyGroup: let announcementOnlyView = BlockingAnnouncementOnlyView(threadViewModel: threadViewModel, fromViewController: self) requestView = announcementOnlyView bottomView = announcementOnlyView } bottomBar.removeAllSubviews() if let bottomView { bottomBar.addSubview(bottomView) bottomView.autoPinEdgesToSuperviewEdges(with: .zero, excludingEdge: .bottom) if bottomView is UIToolbar { // UIToolbar will extend its background to cover bottom safe area. bottomView.autoPinEdge(toSuperviewSafeArea: .bottom) } else { bottomView.autoPinEdge(toSuperviewEdge: .bottom) } } updateInputAccessoryPlaceholderHeight() updateBottomBarPosition() updateContentInsets() } // This is expensive. We only need to do it if conversationStyle has changed. // // TODO: Once conversationStyle is immutable, compare the old and new // conversationStyle values and exit early if it hasn't changed. func updateInputToolbar() { AssertIsOnMainThread() guard hasViewWillAppearEverBegun else { return } var messageDraft: MessageBody? var replyDraft: ThreadReplyInfo? var voiceMemoDraft: VoiceMessageInterruptedDraft? var editTarget: TSOutgoingMessage? if let oldInputToolbar = self.inputToolbar { // Maintain draft continuity. messageDraft = oldInputToolbar.messageBodyForSending replyDraft = oldInputToolbar.draftReply editTarget = oldInputToolbar.editTarget voiceMemoDraft = oldInputToolbar.voiceMemoDraft } else { Self.databaseStorage.read { transaction in messageDraft = self.thread.currentDraft(transaction: transaction) voiceMemoDraft = VoiceMessageInterruptedDraft.currentDraft(for: self.thread, transaction: transaction) if messageDraft != nil || voiceMemoDraft != nil { replyDraft = DependenciesBridge.shared.threadReplyInfoStore.fetch(for: self.thread.uniqueId, tx: transaction.asV2Read) } editTarget = self.thread.editTarget(transaction: transaction) } } let newInputToolbar = buildInputToolbar( conversationStyle: conversationStyle, messageDraft: messageDraft, draftReply: replyDraft, voiceMemoDraft: voiceMemoDraft, editTarget: editTarget ) let hadFocus = self.inputToolbar?.isInputViewFirstResponder ?? false self.inputToolbar = newInputToolbar if hadFocus { self.inputToolbar?.beginEditingMessage() } newInputToolbar.updateFontSizes() updateBottomBar() } func reloadDraft() { AssertIsOnMainThread() guard let messageDraft = (Self.databaseStorage.read { transaction in self.thread.currentDraft(transaction: transaction) }) else { return } guard let inputToolbar = self.inputToolbar else { owsFailDebug("Missing inputToolbar.") return } inputToolbar.setMessageBody(messageDraft, animated: false) } func updateBottomBarPosition() { AssertIsOnMainThread() guard hasViewWillAppearEverBegun else { return } guard !isSwitchingKeyboard else { return } if let interactivePopGestureRecognizer = navigationController?.interactivePopGestureRecognizer { // Don't update the bottom bar position if an interactive pop is in progress switch interactivePopGestureRecognizer.state { case .possible, .failed: break default: return } } guard let bottomBarBottomConstraint = bottomBarBottomConstraint, let bottomBarSuperview = bottomBar.superview else { return } let bottomBarPosition = -inputAccessoryPlaceholder.keyboardOverlap let didChange = bottomBarBottomConstraint.constant != bottomBarPosition guard didChange else { return } bottomBarBottomConstraint.constant = bottomBarPosition // We always want to apply the new bottom bar position immediately, // as this only happens during animations (interactive or otherwise) bottomBarSuperview.layoutIfNeeded() } func updateInputAccessoryPlaceholderHeight() { AssertIsOnMainThread() // If we're currently dismissing interactively, skip updating the // input accessory height. Changing it while dismissing can lead to // an infinite loop of keyboard frame changes as the listeners in // InputAcessoryViewPlaceholder will end up calling back here if // a dismissal is in progress. if isDismissingInteractively { return } // Apply any pending layout changes to ensure we're measuring the up-to-date height. bottomBar.superview?.layoutIfNeeded() inputAccessoryPlaceholder.desiredHeight = bottomBar.height } // MARK: - Message Request func showMessageRequestDialogIfRequiredAsync() { AssertIsOnMainThread() DispatchQueue.main.async { [weak self] in self?.showMessageRequestDialogIfRequired() } } func showMessageRequestDialogIfRequired() { AssertIsOnMainThread() ensureBottomViewType() } func updateInputToolbarLayout(initialLayout: Bool = false) { AssertIsOnMainThread() guard hasViewWillAppearEverBegun else { return } guard let inputToolbar = inputToolbar else { owsFailDebug("Missing inputToolbar.") return } if inputToolbar.updateLayout(withSafeAreaInsets: view.safeAreaInsets) { // Ensure that if the toolbar has its insets changed, we trigger a re-layout. // Without this, UIKit does a bad job of picking up the final safe area for // constraints on the toolbar on its own. self.view.setNeedsLayout() self.updateContentInsets() } if initialLayout { inputToolbar.scrollToBottom() } } func popKeyBoard() { AssertIsOnMainThread() guard hasViewWillAppearEverBegun else { owsFailDebug("InputToolbar not yet ready.") return } guard let inputToolbar = inputToolbar else { owsFailDebug("Missing inputToolbar.") return } inputToolbar.beginEditingMessage() } func dismissKeyBoard() { AssertIsOnMainThread() guard hasViewWillAppearEverBegun else { owsFailDebug("InputToolbar not yet ready.") return } guard viewState.selectionAnimationState == .idle else { return } guard let inputToolbar = inputToolbar else { owsFailDebug("Missing inputToolbar.") return } inputToolbar.endEditingMessage() inputToolbar.clearDesiredKeyboard() } private func dismissRequestView() { AssertIsOnMainThread() guard let requestView else { return } self.requestView = nil // Slide the request view off the bottom of the screen. // Add the view on top of the new bottom bar (if there is one), // and then slide it off screen to reveal the new input view. view.addSubview(requestView) requestView.autoPinWidthToSuperview() requestView.autoPinEdge(toSuperviewEdge: .bottom) let bottomInset: CGFloat = view.safeAreaInsets.bottom var endFrame = requestView.bounds endFrame.origin.y -= endFrame.size.height + bottomInset UIView.animate(withDuration: 0.2, delay: 0, options: []) { requestView.bounds = endFrame } completion: { (_) in requestView.removeFromSuperview() } } private var isLocalUserRequestingMember: Bool { guard let groupThread = thread as? TSGroupThread else { return false } return groupThread.isLocalUserRequestingMember } var userLeftGroup: Bool { guard let groupThread = thread as? TSGroupThread else { return false } return !groupThread.isLocalUserFullMember } private var hasBlockingLegacyGroup: Bool { thread.isGroupV1Thread } private var isBlockedFromSendingByAnnouncementOnlyGroup: Bool { thread.isBlockedByAnnouncementOnly } }