Path: blob/main/Signal/src/ViewControllers/HomeView/ConversationSplitViewController.swift
1 views
//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import MultipeerConnectivity
import SignalServiceKit
import SignalUI
class ConversationSplitViewController: UISplitViewController, ConversationSplit {
let homeVC: HomeTabBarController
private let detailPlaceholderVC = NoSelectedConversationViewController()
private var chatListNavController: OWSNavigationController { homeVC.chatListNavController }
private var callsListNavController: OWSNavigationController { homeVC.callsListNavController }
private var storiesNavController: OWSNavigationController { homeVC.storiesNavController }
private lazy var detailNavController = OWSNavigationController()
private var lastActiveInterfaceOrientation = UIInterfaceOrientation.unknown
private(set) weak var selectedConversationViewController: ConversationViewController?
weak var navigationTransitionDelegate: UINavigationControllerDelegate?
/// The thread, if any, that is currently presented in the view hieararchy. It may be currently
/// covered by a modal presentation or a pushed view controller.
var selectedThread: TSThread? {
// If the placeholder view is in the view hierarchy, there is no selected thread.
guard detailPlaceholderVC.view.superview == nil else { return nil }
guard let selectedConversationViewController else { return nil }
// In order to not show selected when collapsed during an interactive dismissal,
// we verify the conversation is still in the nav stack when collapsed. There is
// no interactive dismissal when expanded, so we don't have to do any special check.
guard !isCollapsed || chatListNavController.viewControllers.contains(selectedConversationViewController) else { return nil }
return selectedConversationViewController.thread
}
/// Returns the currently selected thread if it is visible on screen, otherwise
/// returns nil.
var visibleThread: TSThread? {
guard view.window?.isKeyWindow == true else { return nil }
guard selectedConversationViewController?.isViewVisible == true else { return nil }
return selectedThread
}
var topViewController: UIViewController? {
let selectedNavController: OWSNavigationController = switch homeVC.selectedHomeTab {
case .chatList:
chatListNavController
case .calls:
callsListNavController
case .stories:
storiesNavController
}
if isCollapsed {
return selectedNavController.topViewController
}
return detailNavController.topViewController ?? selectedNavController.topViewController
}
private let appReadiness: AppReadinessSetter
init(appReadiness: AppReadinessSetter) {
self.appReadiness = appReadiness
self.homeVC = HomeTabBarController(appReadiness: appReadiness)
super.init(nibName: nil, bundle: nil)
viewControllers = [homeVC, detailPlaceholderVC]
chatListNavController.delegate = self
delegate = self
preferredDisplayMode = .oneBesideSecondary
presentsWithGesture = false
minimumPrimaryColumnWidth = 280
maximumPrimaryColumnWidth = 400
preferredPrimaryColumnWidthFraction = 0.42
NotificationCenter.default.addObserver(self, selector: #selector(applyTheme), name: .themeDidChange, object: nil)
NotificationCenter.default.addObserver(
self,
selector: #selector(orientationDidChange),
name: UIDevice.orientationDidChangeNotification,
object: UIDevice.current,
)
NotificationCenter.default.addObserver(self, selector: #selector(didBecomeActive), name: .OWSApplicationDidBecomeActive, object: nil)
applyTheme()
}
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if let windowScene = view.window?.windowScene, windowScene.activationState == .foregroundActive {
lastActiveInterfaceOrientation = windowScene.interfaceOrientation
}
}
@objc
private func applyTheme() {
view.backgroundColor = Theme.isDarkThemeEnabled ? UIColor(rgbHex: 0x292929) : UIColor(rgbHex: 0xd6d6d6)
}
@objc
private func orientationDidChange() {
AssertIsOnMainThread()
if let windowScene = view.window?.windowScene, windowScene.activationState == .foregroundActive {
lastActiveInterfaceOrientation = windowScene.interfaceOrientation
}
}
@objc
private func didBecomeActive() {
AssertIsOnMainThread()
if let windowScene = view.window?.windowScene {
lastActiveInterfaceOrientation = windowScene.interfaceOrientation
}
}
func closeSelectedConversation(animated: Bool) {
guard let selectedConversationViewController else { return }
if isCollapsed {
// If we're currently displaying the conversation in the primary nav controller, remove it
// and everything it pushed to the navigation stack from the nav controller. We don't want
// to just pop to root as we might have opened this conversation from the archive.
if let selectedConversationIndex = chatListNavController.viewControllers.firstIndex(of: selectedConversationViewController) {
let targetViewController = chatListNavController.viewControllers[max(0, selectedConversationIndex - 1)]
chatListNavController.popToViewController(targetViewController, animated: animated)
}
} else {
viewControllers[1] = detailPlaceholderVC
}
}
func presentThread(
threadUniqueId: String,
action: ConversationViewAction,
focusMessageId: String?,
animated: Bool,
) {
AssertIsOnMainThread()
// On iOS 13, and possibly later versions, there is/was a bug in
// `UISplitViewController` that caused the `isCollapsed` state to fall
// out of sync while the app isn't active and the orientation has
// changed while backgrounded. That results in conversations opening in
// the wrong pane when the app was in portrait but is re-opened in
// landscape. We work around this by dispatching to the next runloop, at
// which point things have stabilized.
if
let windowScene = view.window?.windowScene,
windowScene.activationState != .foregroundActive,
lastActiveInterfaceOrientation != windowScene.interfaceOrientation
{
lastActiveInterfaceOrientation = windowScene.interfaceOrientation
DispatchQueue.main.async {
self.presentThread(
threadUniqueId: threadUniqueId,
action: action,
focusMessageId: focusMessageId,
animated: animated,
)
}
return
}
if homeVC.selectedHomeTab != .chatList {
guard homeVC.presentedViewController == nil else {
homeVC.dismiss(animated: true) {
self.presentThread(
threadUniqueId: threadUniqueId,
action: action,
focusMessageId: focusMessageId,
animated: animated,
)
}
return
}
// Ensure the tab bar is on the chat list.
homeVC.selectedHomeTab = .chatList
}
if
let selectedThreadUniqueId = selectedThread?.uniqueId,
selectedThreadUniqueId == threadUniqueId
{
guard let selectedConversationVC = selectedConversationViewController else { return }
// This thread is already selected, so pop to it.
if isCollapsed {
chatListNavController.popToViewController(selectedConversationVC, animated: animated)
} else {
detailNavController.popToViewController(selectedConversationVC, animated: animated)
}
if let focusMessageId {
selectedConversationVC.ensureInteractionLoadedThenScrollToInteraction(
focusMessageId,
alignment: .centerIfNotEntirelyOnScreen,
isAnimated: animated,
)
}
return
}
// Update the last viewed thread on the conversation list so it
// can maintain its scroll position when navigating back.
homeVC.chatListViewController.updateLastViewedThreadUniqueId(
threadUniqueId,
animated: animated,
)
let conversationViewController = SSKEnvironment.shared.databaseStorageRef.read { tx in
return ConversationViewController.load(
appReadiness: appReadiness,
threadViewModel: ThreadViewModel(
threadUniqueId: threadUniqueId,
forChatList: false,
transaction: tx,
),
action: action,
focusMessageId: focusMessageId,
tx: tx,
)
}
selectedConversationViewController = conversationViewController
let detailVC: UIViewController = {
guard !isCollapsed else { return conversationViewController }
detailNavController.viewControllers = [conversationViewController]
return detailNavController
}()
showDetailViewController(viewController: detailVC, animated: animated)
conversationViewController.threadActionProviderDelegate = homeVC.chatListViewController
}
func showMyStoriesController(animated: Bool) {
AssertIsOnMainThread()
// On iOS 13, there is a bug with UISplitViewController that causes the `isCollapsed` state to
// get out of sync while the app isn't active and the orientation has changed while backgrounded.
// This results in conversations opening up in the wrong pane when you were in portrait and then
// try and open the app in landscape. We work around this by dispatching to the next runloop
// at which point things have stabilized.
if let windowScene = view.window?.windowScene, windowScene.activationState != .foregroundActive, lastActiveInterfaceOrientation != windowScene.interfaceOrientation {
owsFailDebug("check if this still happens")
// Reset this to avoid getting stuck in a loop. We're becoming active.
lastActiveInterfaceOrientation = windowScene.interfaceOrientation
DispatchQueue.main.async { self.showMyStoriesController(animated: animated) }
return
}
if homeVC.selectedHomeTab != .stories {
guard homeVC.presentedViewController == nil else {
homeVC.dismiss(animated: true) {
self.showMyStoriesController(animated: animated)
}
return
}
// Ensure the tab bar is on the stories tab.
homeVC.selectedHomeTab = .stories
}
homeVC.storiesViewController.showMyStories(animated: animated)
}
override var shouldAutorotate: Bool {
if let presentedViewController {
return presentedViewController.shouldAutorotate
} else if let selectedConversationViewController {
return selectedConversationViewController.shouldAutorotate
} else {
return super.shouldAutorotate
}
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
if let presentedViewController {
return presentedViewController.supportedInterfaceOrientations
} else {
return super.supportedInterfaceOrientations
}
}
// The stock implementation of `showDetailViewController` will in some cases,
// particularly when launching a conversation from another window, fail to
// recognize the right context to present the view controller. When this happens,
// it presents the view modally instead of within the split view controller.
// We never want this to happen, so we implement a version that knows the
// correct context is always the split view controller.
override func showDetailViewController(_ vc: UIViewController, sender _: Any?) {
showDetailViewController(viewController: vc, animated: true)
}
/// Present the given controller as our detail view controller as
/// appropriate for our current context.
private weak var currentDetailViewController: UIViewController?
func showDetailViewController(viewController: UIViewController, animated: Bool) {
if isCollapsed {
var viewControllersToDisplay = chatListNavController.viewControllers
// If we already have a detail VC displayed, we want to replace it.
// The normal behavior of `showDetailViewController` pushes on
// top of it in collapsed mode.
if
let currentDetailVC = currentDetailViewController,
let detailVCIndex = viewControllersToDisplay.firstIndex(of: currentDetailVC)
{
viewControllersToDisplay = Array(viewControllersToDisplay[0..<detailVCIndex])
}
viewControllersToDisplay.append(viewController)
chatListNavController.setViewControllers(viewControllersToDisplay, animated: animated)
} else {
// There is a race condition at app launch where `isCollapsed` cannot be
// relied upon. This leads to a crash where viewControllers is empty, so
// setting index 1 is not possible. We know what the primary view controller
// should always be, so we attempt to fill it in when that happens. The only
// ways this could really be happening is if, somehow, before `viewControllers`
// is set in init this method is getting called OR this `viewControllers` is
// returning stale information. The latter seems most plausible, but is near
// impossible to reproduce.
owsAssertDebug(viewControllers.first == homeVC)
viewControllers = [homeVC, viewController]
}
// If the detail VC is a nav controller, we want to keep track of
// the root view controller. We use this to determine the start
// point of the current detail view when replacing it while
// collapsed. At that point, this nav controller's view controllers
// will have been merged into the primary nav controller.
if let navController = viewController as? UINavigationController {
currentDetailViewController = navController.viewControllers.first
} else {
currentDetailViewController = viewController
}
}
// MARK: - Keyboard Shortcuts
override var canBecomeFirstResponder: Bool {
return true
}
let chatListKeyCommands = [
UIKeyCommand(
action: #selector(showNewConversationView),
input: "n",
modifierFlags: .command,
discoverabilityTitle: OWSLocalizedString(
"KEY_COMMAND_NEW_MESSAGE",
comment: "A keyboard command to present the new message dialog.",
),
),
UIKeyCommand(
action: #selector(showNewGroupView),
input: "g",
modifierFlags: .command,
discoverabilityTitle: OWSLocalizedString(
"KEY_COMMAND_NEW_GROUP",
comment: "A keyboard command to present the new group dialog.",
),
),
UIKeyCommand(
action: #selector(showAppSettings),
input: ",",
modifierFlags: .command,
discoverabilityTitle: CommonStrings.openAppSettingsButton,
),
UIKeyCommand(
action: #selector(focusSearch),
input: "f",
modifierFlags: .command,
discoverabilityTitle: OWSLocalizedString(
"KEY_COMMAND_SEARCH",
comment: "A keyboard command to begin a search on the conversation list.",
),
),
UIKeyCommand(
action: #selector(selectPreviousConversation),
input: UIKeyCommand.inputUpArrow,
modifierFlags: .alternate,
discoverabilityTitle: OWSLocalizedString(
"KEY_COMMAND_PREVIOUS_CONVERSATION",
comment: "A keyboard command to jump to the previous conversation in the list.",
),
),
UIKeyCommand(
action: #selector(selectNextConversation),
input: UIKeyCommand.inputDownArrow,
modifierFlags: .alternate,
discoverabilityTitle: OWSLocalizedString(
"KEY_COMMAND_NEXT_CONVERSATION",
comment: "A keyboard command to jump to the next conversation in the list.",
),
),
] + [
UIKeyCommand(
action: #selector(selectPreviousConversation),
input: "\t",
modifierFlags: [.control, .shift],
discoverabilityTitle: OWSLocalizedString(
"KEY_COMMAND_PREVIOUS_CONVERSATION",
comment: "A keyboard command to jump to the previous conversation in the list.",
),
),
UIKeyCommand(
action: #selector(selectNextConversation),
input: "\t",
modifierFlags: .control,
discoverabilityTitle: OWSLocalizedString(
"KEY_COMMAND_NEXT_CONVERSATION",
comment: "A keyboard command to jump to the next conversation in the list.",
),
),
].map {
$0.wantsPriorityOverSystemBehavior = true
return $0
}
var selectedConversationKeyCommands: [UIKeyCommand] {
return [
UIKeyCommand(
action: #selector(openConversationSettings),
input: "i",
modifierFlags: [.command, .shift],
discoverabilityTitle: OWSLocalizedString(
"KEY_COMMAND_CONVERSATION_INFO",
comment: "A keyboard command to open the current conversation's settings.",
),
),
UIKeyCommand(
action: #selector(openAllMedia),
input: "m",
modifierFlags: [.command, .shift],
discoverabilityTitle: OWSLocalizedString(
"KEY_COMMAND_ALL_MEDIA",
comment: "A keyboard command to open the current conversation's all media view.",
),
),
UIKeyCommand(
action: #selector(openGifSearch),
input: "g",
modifierFlags: [.command, .shift],
discoverabilityTitle: OWSLocalizedString(
"KEY_COMMAND_GIF_SEARCH",
comment: "A keyboard command to open the current conversations GIF picker.",
),
),
UIKeyCommand(
action: #selector(openAttachmentKeyboard),
input: "u",
modifierFlags: .command,
discoverabilityTitle: OWSLocalizedString(
"KEY_COMMAND_ATTACHMENTS",
comment: "A keyboard command to open the current conversation's attachment picker.",
),
),
UIKeyCommand(
action: #selector(openStickerKeyboard),
input: "s",
modifierFlags: [.command, .shift],
discoverabilityTitle: OWSLocalizedString(
"KEY_COMMAND_STICKERS",
comment: "A keyboard command to open the current conversation's sticker picker.",
),
),
UIKeyCommand(
action: #selector(archiveSelectedConversation),
input: "a",
modifierFlags: [.command, .shift],
discoverabilityTitle: OWSLocalizedString(
"KEY_COMMAND_ARCHIVE",
comment: "A keyboard command to archive the current conversation.",
),
),
UIKeyCommand(
action: #selector(unarchiveSelectedConversation),
input: "u",
modifierFlags: [.command, .shift],
discoverabilityTitle: OWSLocalizedString(
"KEY_COMMAND_UNARCHIVE",
comment: "A keyboard command to unarchive the current conversation.",
),
),
UIKeyCommand(
action: #selector(focusInputToolbar),
input: "t",
modifierFlags: [.command, .shift],
discoverabilityTitle: OWSLocalizedString(
"KEY_COMMAND_FOCUS_COMPOSER",
comment: "A keyboard command to focus the current conversation's input field.",
),
),
]
}
override var keyCommands: [UIKeyCommand]? {
// If there is a modal presented over us, or another window above us, don't respond to keyboard commands.
guard presentedViewController == nil || view.window?.isKeyWindow != true else { return nil }
// Don't allow keyboard commands while presenting context menu.
guard selectedConversationViewController?.isPresentingContextMenu != true else { return nil }
var keyCommands = [UIKeyCommand]()
if selectedThread != nil {
keyCommands += selectedConversationKeyCommands
}
if homeVC.selectedHomeTab == .chatList {
keyCommands += chatListKeyCommands
}
return keyCommands
}
@objc
func showNewConversationView() {
homeVC.chatListViewController.showNewConversationView()
}
@objc
func showNewGroupView() {
homeVC.chatListViewController.showNewGroupView()
}
@objc
func showAppSettings() {
homeVC.chatListViewController.showAppSettings()
}
@objc
func showCameraView(completion: ((UINavigationController) -> Void)? = nil) {
homeVC.chatListViewController.presentCameraView(completion: completion)
}
func showAppSettingsWithMode(_ mode: ChatListViewController.ShowAppSettingsMode, completion: (() -> Void)? = nil) {
homeVC.chatListViewController.showAppSettings(mode: mode, completion: completion)
}
@objc
func focusSearch() {
homeVC.chatListViewController.focusSearch()
}
@objc
func selectPreviousConversation() {
homeVC.chatListViewController.selectPreviousConversation()
}
@objc
func selectNextConversation(_ sender: UIKeyCommand) {
homeVC.chatListViewController.selectNextConversation()
}
@objc
func archiveSelectedConversation() {
homeVC.chatListViewController.archiveSelectedConversation()
}
@objc
func unarchiveSelectedConversation() {
homeVC.chatListViewController.unarchiveSelectedConversation()
}
@objc
func openConversationSettings() {
guard let selectedConversationViewController else {
return owsFailDebug("unexpectedly missing selected conversation")
}
selectedConversationViewController.showConversationSettings()
}
@objc
func focusInputToolbar() {
guard let selectedConversationViewController else {
return owsFailDebug("unexpectedly missing selected conversation")
}
selectedConversationViewController.focusInputToolbar()
}
@objc
func openAllMedia() {
guard let selectedConversationViewController else {
return owsFailDebug("unexpectedly missing selected conversation")
}
selectedConversationViewController.openAllMedia()
}
@objc
func openStickerKeyboard() {
guard let selectedConversationViewController else {
return owsFailDebug("unexpectedly missing selected conversation")
}
selectedConversationViewController.openStickerKeyboard()
}
@objc
func openAttachmentKeyboard() {
guard let selectedConversationViewController else {
return owsFailDebug("unexpectedly missing selected conversation")
}
selectedConversationViewController.openAttachmentKeyboard()
}
@objc
func openGifSearch() {
guard let selectedConversationViewController else {
return owsFailDebug("unexpectedly missing selected conversation")
}
selectedConversationViewController.openGifSearch()
}
}
extension ConversationSplitViewController: UISplitViewControllerDelegate {
func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController: UIViewController, onto primaryViewController: UIViewController) -> Bool {
// If we're currently showing the placeholder view, we want to do nothing with in
// when collapsing into a single nav controller without a side panel.
guard secondaryViewController != detailPlaceholderVC else { return true }
assert(secondaryViewController == detailNavController)
// Move all the views from the detail nav controller onto the primary nav controller.
let detailViewControllers = detailNavController.viewControllers
// Clear the detailNavController's view controllers first to avoid a UIKit
// crash that happens if you don't.
detailNavController.viewControllers = []
chatListNavController.viewControllers += detailViewControllers
return true
}
func splitViewController(_ splitViewController: UISplitViewController, separateSecondaryFrom primaryViewController: UIViewController) -> UIViewController? {
assert(primaryViewController == homeVC)
// See if the current conversation is currently in the view hierarchy. If not,
// show the placeholder view as no conversation is selected. The conversation
// was likely popped from the stack while the split view was collapsed.
guard
let currentConversationVC = selectedConversationViewController,
let conversationVCIndex = chatListNavController.viewControllers.firstIndex(of: currentConversationVC)
else {
self.selectedConversationViewController = nil
return detailPlaceholderVC
}
// Move everything on the nav stack from the conversation view on back onto
// the detail nav controller.
let allViewControllers = chatListNavController.viewControllers
chatListNavController.viewControllers = Array(allViewControllers[0..<conversationVCIndex]).filter { vc in
// Don't ever allow a conversation view controller to be transferred on the master
// stack when expanding from collapsed mode. This should never happen.
guard let vc = vc as? ConversationViewController else { return true }
owsFailDebug("Unexpected conversation in view hierarchy: \(vc.thread.logString)")
return false
}
// Create a new detail nav because reusing the existing one causes
// some strange behavior around the title view + input accessory view.
// TODO iPad: Maybe investigate this further.
detailNavController = OWSNavigationController()
detailNavController.viewControllers = Array(allViewControllers[conversationVCIndex..<allViewControllers.count])
return detailNavController
}
func splitViewControllerDidExpand(_ svc: UISplitViewController) {
homeVC.chatListViewController.updateBarButtonItems()
homeVC.callsListViewController.updateBarButtonItems()
homeVC.storiesViewController.updateNavigationBar()
}
func splitViewControllerDidCollapse(_ svc: UISplitViewController) {
homeVC.chatListViewController.updateBarButtonItems()
homeVC.callsListViewController.updateBarButtonItems()
homeVC.storiesViewController.updateNavigationBar()
}
}
extension ConversationSplitViewController: UINavigationControllerDelegate {
func navigationController(_ navigationController: UINavigationController, interactionControllerFor animationController: UIViewControllerAnimatedTransitioning) -> UIViewControllerInteractiveTransitioning? {
return navigationTransitionDelegate?.navigationController?(
navigationController,
interactionControllerFor: animationController,
)
}
func navigationController(_ navigationController: UINavigationController, animationControllerFor operation: UINavigationController.Operation, from fromVC: UIViewController, to toVC: UIViewController) -> UIViewControllerAnimatedTransitioning? {
return navigationTransitionDelegate?.navigationController?(
navigationController,
animationControllerFor: operation,
from: fromVC,
to: toVC,
)
}
}
extension ConversationViewController {
var conversationSplitViewController: ConversationSplitViewController? {
return splitViewController as? ConversationSplitViewController
}
}
private class NoSelectedConversationViewController: OWSViewController {
let logoImageView = UIImageView()
override func loadView() {
view = UIView()
view.backgroundColor = UIColor.Signal.background
logoImageView.image = #imageLiteral(resourceName: "signal-logo-128").withRenderingMode(.alwaysTemplate)
logoImageView.tintColor = UIColor.Signal.quaternaryLabel
logoImageView.contentMode = .scaleAspectFit
logoImageView.autoSetDimension(.height, toSize: 112)
view.addSubview(logoImageView)
logoImageView.autoCenterInSuperview()
}
}