Signal-iOS/Signal/src/ViewControllers/ConversationView/ConversationViewController+BottomBar.swift

355 lines
11 KiB
Swift

//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
//
import Foundation
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 blockingGroupMigration
}
// 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()
}
}
}
@objc
func ensureBottomViewType() {
AssertIsOnMainThread()
bottomViewType = { () -> CVCBottomViewType in
// The ordering of this method determines
// precendence 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 hasBlockingGroupMigration {
return .blockingGroupMigration
} 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 .blockingGroupMigration:
let migrationView = BlockingGroupMigrationView(threadViewModel: threadViewModel,
fromViewController: self)
requestView = migrationView
bottomView = migrationView
}
for subView in bottomBar.subviews {
subView.removeFromSuperview()
}
if let newBottomView = bottomView {
bottomBar.addSubview(newBottomView)
// The request views expect to extend into the safe area.
if requestView != nil {
newBottomView.autoPinEdgesToSuperviewEdges()
} else {
newBottomView.autoPinEdgesToSuperviewMargins()
}
}
updateInputAccessoryPlaceholderHeight()
updateBottomBarPosition()
updateContentInsets(animated: hasAppearedAndHasAppliedFirstLoad)
}
// 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.
@objc
func updateInputToolbar() {
AssertIsOnMainThread()
guard hasViewWillAppearEverBegun else {
return
}
let messageDraft: MessageBody?
if let oldInputToolbar = self.inputToolbar {
// Maintain draft continuity.
messageDraft = oldInputToolbar.messageBody()
} else {
messageDraft = Self.databaseStorage.uiRead { transaction in
self.thread.currentDraft(with: transaction)
}
}
let newInputToolbar = buildInputToolbar(conversationStyle: conversationStyle,
messageDraft: messageDraft)
self.inputToolbar = newInputToolbar
newInputToolbar.updateFontSizes()
updateBottomBar()
}
@objc
func reloadDraft() {
AssertIsOnMainThread()
guard let messageDraft = (Self.databaseStorage.uiRead { transaction in
self.thread.currentDraft(with: transaction)
}) else {
return
}
guard let inputToolbar = self.inputToolbar else {
owsFailDebug("Missing inputToolbar.")
return
}
inputToolbar.setMessageBody(messageDraft, animated: false)
}
@objc
func updateBottomBarPosition() {
AssertIsOnMainThread()
guard hasViewWillAppearEverBegun 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()
}
@objc
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
@objc
func showMessageRequestDialogIfRequiredAsync() {
AssertIsOnMainThread()
DispatchQueue.main.async { [weak self] in
self?.showMessageRequestDialogIfRequired()
}
}
@objc
func showMessageRequestDialogIfRequired() {
AssertIsOnMainThread()
ensureBottomViewType()
}
@objc
func updateInputToolbarLayout() {
AssertIsOnMainThread()
guard hasViewWillAppearEverBegun else {
return
}
guard let inputToolbar = inputToolbar else {
owsFailDebug("Missing inputToolbar.")
return
}
inputToolbar.updateLayout(withSafeAreaInsets: view.safeAreaInsets)
}
@objc
func popKeyBoard() {
AssertIsOnMainThread()
guard hasViewWillAppearEverBegun else {
owsFailDebug("InputToolbar not yet ready.")
return
}
guard let inputToolbar = inputToolbar else {
owsFailDebug("Missing inputToolbar.")
return
}
inputToolbar.beginEditingMessage()
}
@objc
func dismissKeyBoard() {
AssertIsOnMainThread()
guard hasViewWillAppearEverBegun else {
owsFailDebug("InputToolbar not yet ready.")
return
}
guard let inputToolbar = inputToolbar else {
owsFailDebug("Missing inputToolbar.")
return
}
inputToolbar.endEditingMessage()
inputToolbar.clearDesiredKeyboard()
}
private func dismissRequestView() {
AssertIsOnMainThread()
guard let requestView = self.requestView else {
return
}
// Slide the request view off the bottom of the screen.
let bottomInset: CGFloat = view.safeAreaInsets.bottom
let dismissingView = requestView
self.requestView = nil
// 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(dismissingView)
dismissingView.autoPinWidthToSuperview()
dismissingView.autoPinEdge(toSuperviewEdge: .bottom)
var endFrame = dismissingView.bounds
endFrame.origin.y -= endFrame.size.height + bottomInset
UIView.animate(withDuration: 0.2, delay: 0, options: []) {
dismissingView.bounds = endFrame
} completion: { (_) in
dismissingView.removeFromSuperview()
}
}
private var isLocalUserRequestingMember: Bool {
guard let groupThread = thread as? TSGroupThread else {
return false
}
return groupThread.isLocalUserRequestingMember
}
@objc
var userLeftGroup: Bool {
guard let groupThread = thread as? TSGroupThread else {
return false
}
return !groupThread.isLocalUserFullMember
}
private var hasBlockingGroupMigration: Bool {
thread.isBlockedByMigration
}
}