392 lines
13 KiB
Swift
392 lines
13 KiB
Swift
//
|
|
// 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)
|
|
}
|
|
}
|
|
|
|
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 {
|
|
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.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 = (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)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|