Signal-iOS/Signal/ConversationView/ConversationViewController+BottomBar.swift
Igor Solomennikov 0bd0e0c845
Fixes for incorrect UI state in chat screen bottom panel.
Fix 1. UIScrollEdgeElementContainerInteraction appears to switch our chat bar buttons into dark mode. The fix is to not specify a dynamic color for button icons, but a resolved one from Theme.
Fix 2. UIScrollEdgeElementContainerInteraction does turn bottom input-blocking panels (like the "only admins can send messages") into dark mode too. The fix is to add UIScrollEdgeElementContainerInteraction not to the whole panel, but only to chat search results toolbar, message selection toolbar and chat input toolbar individually - potential subviews of the panel.
2025-10-28 09:57:36 -07:00

344 lines
11 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
}
bottomBarContainer.removeAllSubviews()
if let bottomView {
bottomView.translatesAutoresizingMaskIntoConstraints = false
bottomBarContainer.addSubview(bottomView)
bottomBarContainer.addConstraints([
bottomView.topAnchor.constraint(equalTo: bottomBarContainer.topAnchor),
bottomView.leadingAnchor.constraint(equalTo: bottomBarContainer.leadingAnchor),
bottomView.trailingAnchor.constraint(equalTo: bottomBarContainer.trailingAnchor),
bottomView.bottomAnchor.constraint(equalTo: bottomBarContainer.bottomAnchor),
])
if let viewWithContentLayoutGuide = bottomView as? ConversationInputPanelWithContentLayoutGuide {
NSLayoutConstraint.activate([
viewWithContentLayoutGuide.contentLayoutGuide.bottomAnchor.constraint(equalTo: keyboardLayoutGuide.topAnchor)
])
}
}
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 compiler(>=6.2)
// Obscures content underneath bottom bar to improve legibility.
if #available(iOS 26, *) {
let scrollInteraction = UIScrollEdgeElementContainerInteraction()
scrollInteraction.scrollView = collectionView
scrollInteraction.edge = .bottom
newInputToolbar.addInteraction(scrollInteraction)
}
#endif
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
}
}