// // 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 = SSKEnvironment.shared.databaseStorageRef.read { tx in return MessageRequestView.messageRequestType(forThread: self.threadViewModel.threadRecord, transaction: tx) } 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) } } 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 { SSKEnvironment.shared.databaseStorageRef.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) } editTarget = self.thread.editTarget(transaction: transaction) } } let newInputToolbar = buildInputToolbar( 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 = (SSKEnvironment.shared.databaseStorageRef.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) } // 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 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 } }