TM-SGNL-iOS/Signal/ConversationView/ConversationViewController+BottomBar.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

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
}
}