Path: blob/main/Signal/ConversationView/ConversationViewController.swift
1 views
//
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import SignalServiceKit
public import SignalUI
public enum ConversationUIMode: UInt {
case normal
case search
case selection
// These two modes are used to select interactions.
public var hasSelectionUI: Bool {
switch self {
case .normal, .search:
return false
case .selection:
return true
}
}
}
public enum ConversationViewAction {
case none
case compose
case voiceCall
case videoCall
case groupCallLobby
case newGroupActionSheet
case updateDraft
}
// MARK: -
public final class ConversationViewController: OWSViewController {
let context: ViewControllerContext
public let appReadiness: AppReadinessSetter
public let viewState: CVViewState
public let loadCoordinator: CVLoadCoordinator
public let layout: ConversationViewLayout
public let collectionView: ConversationCollectionView
public let searchController: ConversationSearchController
public var pinnedMessageIndex: Int
var memberLabelCoordinator: MemberLabelCoordinator?
weak var threadActionProviderDelegate: ThreadContextualActionProvider?
var selectionToolbar: MessageActionsToolbar?
var otherUsersProfileDidChangeEvent: DebouncedEvent?
/// See `ConversationViewController+OWS.updateContentInsetsDebounced`
lazy var updateContentInsetsEvent = DebouncedEvents.build(
mode: .lastOnly,
maxFrequencySeconds: 0.01,
onQueue: .main,
notifyBlock: { [weak self] in
self?.updateContentInsets()
},
)
// MARK: -
public static func load(
appReadiness: AppReadinessSetter,
threadViewModel: ThreadViewModel,
action: ConversationViewAction,
focusMessageId: String?,
tx: DBReadTransaction,
) -> ConversationViewController {
let thread = threadViewModel.threadRecord
// We always need to find where the unread divider should be placed, even
// if we opened the chat by tapping on a search result.
let interactionFinder = InteractionFinder(threadUniqueId: thread.uniqueId)
let oldestUnreadMessage = try? interactionFinder.oldestUnreadInteraction(transaction: tx)
let loadAroundMessageId: String?
let scrollToMessageId: String?
if let focusMessageId {
loadAroundMessageId = focusMessageId
scrollToMessageId = focusMessageId
} else if let oldestUnreadMessage {
loadAroundMessageId = oldestUnreadMessage.uniqueId
// Set this to `nil` so that we scroll to the unread divider.
scrollToMessageId = nil
} else {
// If we're not scrolling to a specific message AND we don't have any
// unread messages, try to focus on the last visible interaction.
let lastVisibleMessageId = Self.lastVisibleInteractionId(for: threadViewModel.threadRecord, tx: tx)
loadAroundMessageId = lastVisibleMessageId
scrollToMessageId = lastVisibleMessageId
}
let chatColor = Self.loadChatColor(for: thread, tx: tx)
let wallpaperViewBuilder = Self.loadWallpaperViewBuilder(for: thread, tx: tx)
let conversationStyle = Self.buildInitialConversationStyle(
for: thread,
chatColor: chatColor,
wallpaperViewBuilder: wallpaperViewBuilder,
)
let conversationViewModel = ConversationViewModel.load(for: thread, tx: tx)
let cvc = ConversationViewController(
appReadiness: appReadiness,
threadViewModel: threadViewModel,
conversationViewModel: conversationViewModel,
action: action,
conversationStyle: conversationStyle,
loadAroundMessageId: loadAroundMessageId,
scrollToMessageId: scrollToMessageId,
oldestUnreadMessage: oldestUnreadMessage,
chatColor: chatColor,
wallpaperViewBuilder: wallpaperViewBuilder,
)
return cvc
}
static func loadChatColor(for thread: TSThread, tx: DBReadTransaction) -> ColorOrGradientSetting {
return DependenciesBridge.shared.chatColorSettingStore.resolvedChatColor(
for: thread,
tx: tx,
)
}
static func loadWallpaperViewBuilder(for thread: TSThread, tx: DBReadTransaction) -> WallpaperViewBuilder? {
return Wallpaper.viewBuilder(for: thread, tx: tx)
}
private init(
appReadiness: AppReadinessSetter,
threadViewModel: ThreadViewModel,
conversationViewModel: ConversationViewModel,
action: ConversationViewAction,
conversationStyle: ConversationStyle,
loadAroundMessageId: String?,
scrollToMessageId: String?,
oldestUnreadMessage: TSInteraction?,
chatColor: ColorOrGradientSetting,
wallpaperViewBuilder: WallpaperViewBuilder?,
) {
AssertIsOnMainThread()
self.appReadiness = appReadiness
self.context = ViewControllerContext.shared
self.viewState = CVViewState(
threadUniqueId: threadViewModel.threadRecord.uniqueId,
conversationStyle: conversationStyle,
chatColor: chatColor,
wallpaperViewBuilder: wallpaperViewBuilder,
)
self.loadCoordinator = CVLoadCoordinator(
viewState: viewState,
threadViewModel: threadViewModel,
conversationViewModel: conversationViewModel,
oldestUnreadMessageSortId: oldestUnreadMessage?.sortId,
)
self.layout = ConversationViewLayout(conversationStyle: conversationStyle)
self.collectionView = ConversationCollectionView(frame: .zero, collectionViewLayout: self.layout)
self.searchController = ConversationSearchController(thread: threadViewModel.threadRecord)
self.pinnedMessageIndex = 0
super.init()
self.viewState.delegate = self
self.viewState.selectionState.delegate = self
self.hidesBottomBarWhenPushed = true
SUIEnvironment.shared.contactsViewHelperRef.addObserver(self)
self.actionOnOpen = action
self.recordInitialScrollState(scrollToMessageId)
loadCoordinator.configure(
delegate: self,
componentDelegate: self,
focusMessageIdOnOpen: loadAroundMessageId,
)
searchController.delegate = self
self.otherUsersProfileDidChangeEvent = DebouncedEvents.build(
mode: .firstLast,
maxFrequencySeconds: 1.0,
onQueue: .main,
) { [weak self] in
// Reload all cells if this is a group conversation,
// since we may need to update the sender names on the messages.
self?.loadCoordinator.enqueueReload(canReuseInteractionModels: true, canReuseComponentStates: false)
}
let tsAccountManager = DependenciesBridge.shared.tsAccountManager
if
let groupModelV2 = currentGroupModel as? TSGroupModelV2,
let localIdentifiers = tsAccountManager.localIdentifiersWithMaybeSneakyTransaction
{
let groupNameColors = GroupNameColors.forThread(threadViewModel.threadRecord)
self.memberLabelCoordinator = MemberLabelCoordinator(
groupModel: groupModelV2,
groupNameColors: groupNameColors,
localIdentifiers: localIdentifiers,
)
}
}
deinit {
reloadTimer?.invalidate()
scrollUpdateTimer?.invalidate()
}
// MARK: - View Lifecycle
override public func viewDidLoad() {
AssertIsOnMainThread()
// We won't have a navigation controller if we're presented in a preview
owsAssertDebug(self.navigationController != nil || self.isInPreviewPlatter)
super.viewDidLoad()
createContents()
createConversationScrollButtons()
createHeaderViews()
addNotificationListeners()
loadCoordinator.viewDidLoad()
self.startReloadTimer()
}
private func createContents() {
AssertIsOnMainThread()
self.layout.delegate = self.loadCoordinator
// We use the root view bounds as the initial frame for the collection
// view so that its contents can be laid out immediately.
//
// TODO: To avoid relayout, it'd be better to take into account safeAreaInsets,
// but they're not yet set when this method is called.
self.collectionView.frame = view.bounds
self.collectionView.layoutDelegate = self
self.collectionView.delegate = self.loadCoordinator
self.collectionView.dataSource = self.loadCoordinator
self.collectionView.showsVerticalScrollIndicator = true
self.collectionView.showsHorizontalScrollIndicator = false
self.collectionView.keyboardDismissMode = .interactive
self.collectionView.allowsMultipleSelection = true
self.collectionView.backgroundColor = .clear
// To minimize time to initial apearance, we initially disable prefetching, but then
// re-enable it once the view has appeared.
self.collectionView.isPrefetchingEnabled = false
self.view.addSubview(self.collectionView)
self.collectionView.autoPinEdge(toSuperviewEdge: .top)
self.collectionView.autoPinEdge(toSuperviewEdge: .bottom)
self.collectionView.autoPinEdge(toSuperviewSafeArea: .leading)
self.collectionView.autoPinEdge(toSuperviewSafeArea: .trailing)
self.collectionView.accessibilityIdentifier = "collectionView"
self.registerReuseIdentifiers()
// The view controller will only automatically adjust content insets for a
// scrollView at index 0, so we need the collection view to remain subview index 0.
// But the background views should appear visually behind the collection view.
let backgroundContainer = self.backgroundContainer
backgroundContainer.delegate = self
self.view.addSubview(backgroundContainer)
backgroundContainer.autoPinEdgesToSuperviewEdges()
setUpWallpaper()
self.view.addSubview(bottomBarContainer)
bottomBarContainer.autoPinWidthToSuperview()
bottomBarContainer.autoPinEdge(toSuperviewEdge: .bottom)
selectionToolbar = self.buildSelectionToolbar()
// Obscures content underneath bottom bar to improve legibility.
if #available(iOS 26, *) {
let scrollInteraction = UIScrollEdgeElementContainerInteraction()
scrollInteraction.scrollView = collectionView
scrollInteraction.edge = .bottom
if let selectionToolbar {
selectionToolbar.addInteraction(scrollInteraction)
}
searchController.resultsBar.addInteraction(scrollInteraction)
}
// This should kick off the first load.
owsAssertDebug(!self.hasRenderState)
self.updateConversationStyle()
}
override public var canBecomeFirstResponder: Bool {
return true
}
override public func becomeFirstResponder() -> Bool {
let result = super.becomeFirstResponder()
guard hasViewWillAppearEverBegun else {
return result
}
guard let inputToolbar else {
return result
}
// If we become the first responder, it means that the
// input toolbar is not the first responder. As such,
// we should clear out the desired keyboard since an
// interactive dismissal may have just occurred and we
// need to update the UI to reflect that fact. We don't
// actually ever want to be the first responder, so resign
// immediately. We just want to know when the responder
// state of our children changed and that information is
// conveniently bubbled up the responder chain.
if result {
self.resignFirstResponder()
inputToolbar.clearDesiredKeyboard()
}
return result
}
override public var textInputContextIdentifier: String? {
thread.uniqueId
}
public func dismissPresentedViewControllerIfNecessary() {
guard let presentedViewController = self.presentedViewController else {
return
}
if presentedViewController is ActionSheetController || presentedViewController is UIAlertController {
dismiss(animated: false, completion: nil)
return
}
}
override public func viewWillAppear(_ animated: Bool) {
self.viewWillAppearDidBegin()
super.viewWillAppear(animated)
configureGestureRecognizersIfNeeded()
ensureBannerState()
self.isViewVisible = true
self.viewWillAppearForLoad()
// We should have already requested contact access at this point, so this should be a no-op
// unless it ever becomes possible to load this VC without going via the ChatListViewController.
SSKEnvironment.shared.contactManagerImplRef.requestSystemContactsOnce()
self.updateBarButtonItems()
self.updateNavigationTitle()
self.ensureBottomViewType()
inputToolbar?.scrollToBottom()
self.refreshCallState()
self.showMessageRequestDialogIfRequired()
self.viewWillAppearDidComplete()
}
private var groupAndProfileRefresherTask: Task<Void, any Error>?
override public func viewDidAppear(_ animated: Bool) {
self.viewDidAppearDidBegin()
super.viewDidAppear(animated)
// We don't present incoming message notifications for the presented
// conversation. But there's a narrow window *while* the conversationVC
// is being presented where a message notification for the not-quite-yet
// presented conversation can be shown. If that happens, dismiss it as soon
// as we enter the conversation.
SSKEnvironment.shared.notificationPresenterRef.cancelNotifications(threadId: thread.uniqueId)
// recover status bar when returning from PhotoPicker, which is dark (uses light status bar)
self.setNeedsStatusBarAppearanceUpdate()
self.markVisibleMessagesAsRead()
self.startReadTimer()
self.updateNavigationBarSubtitleLabel()
self.autoLoadMoreIfNecessary()
let serviceIds = thread.recipientAddressesWithSneakyTransaction.compactMap(\.serviceId)
self.groupAndProfileRefresherTask?.cancel()
self.groupAndProfileRefresherTask = Task {
await self.updateV2GroupIfNecessary()
// Fetch profiles AFTER refreshing the group to ensure GSEs are available.
let profileFetcher = SSKEnvironment.shared.profileFetcherRef
for serviceId in Set(serviceIds).shuffled() {
try Task.checkCancellation()
let thread = self.thread as? TSGroupThread
let context = ProfileFetchContext(groupId: try? thread?.groupIdentifier, isOpportunistic: true)
_ = try? await profileFetcher.fetchProfile(for: serviceId, context: context)
}
}
if !self.viewHasEverAppeared {
// To minimize time to initial apearance, we initially disable prefetching, but then
// re-enable it once the view has appeared.
self.collectionView.isPrefetchingEnabled = true
}
self.isViewCompletelyAppeared = true
self.shouldAnimateKeyboardChanges = true
switch self.actionOnOpen {
case .none:
break
case .compose:
// Don't pop the keyboard if we have a pending message request, since
// the user can't currently send a message until acting on this
if nil == requestView {
self.popKeyBoard()
}
case .voiceCall:
self.startIndividualAudioCall()
case .videoCall:
self.startIndividualVideoCall()
case .groupCallLobby:
self.showGroupLobbyOrActiveCall()
case .newGroupActionSheet:
DispatchQueue.main.async { [weak self] in
self?.showGroupLinkPromotionActionSheet()
}
case .updateDraft:
// Do nothing input toolbar was just created with the latest draft.
break
}
scrollToInitialPosition(animated: false)
if viewState.hasAppliedFirstLoad {
self.clearInitialScrollState()
}
// Clear the "on open" state after the view has been presented.
self.actionOnOpen = .none
self.configureScrollDownButtons()
inputToolbar?.viewDidAppear()
self.focusInitialVoiceoverElement()
self.viewDidAppearDidComplete()
}
// `viewWillDisappear` is called whenever the view *starts* to disappear,
// but, as is the case with the "pan left for message details view" gesture,
// this can be canceled. As such, we shouldn't tear down anything expensive
// until `viewDidDisappear`.
override public func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
self.isViewCompletelyAppeared = false
dismissMessageContextMenu(animated: false)
self.dismissReactionsDetailSheet(animated: false)
self.saveLastVisibleSortIdAndOnScreenPercentage(async: true)
self.groupAndProfileRefresherTask?.cancel()
self.groupAndProfileRefresherTask = nil
}
override public func viewDidDisappear(_ animated: Bool) {
super.viewDidDisappear(animated)
self.userHasScrolled = false
self.isViewVisible = false
self.shouldAnimateKeyboardChanges = false
AppEnvironment.shared.cvAudioPlayerRef.stopAll()
self.cancelReadTimer()
self.saveDraft()
self.markVisibleMessagesAsRead()
self.finishRecordingVoiceMessage(sendImmediately: false)
self.mediaCache.removeAllObjects()
inputToolbar?.clearDesiredKeyboard()
self.isUserScrolling = false
self.isWaitingForDeceleration = false
self.scrollingAnimationCompletionTimer?.invalidate()
self.scrollingAnimationCompletionTimer = nil
}
override public func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
// Title view sometimes disappears when orientation changes.
// Reset it here as a workaround.
navigationItem.titleView = nil
navigationItem.titleView = headerView
guard hasViewWillAppearEverBegun else {
return
}
guard let inputToolbar else {
return
}
// We resize the inputToolbar whenever it's text is modified, including when setting saved draft-text.
// However it's possible this draft-text is set before the inputToolbar (an inputAccessoryView) is mounted
// in the view hierarchy. Since it's not in the view hierarchy, it hasn't been laid out and has no width,
// which is used to determine height.
// So here we unsure the proper height once we know everything's been laid out.
inputToolbar.ensureTextViewHeight()
updateContentInsets()
}
override public var shouldAutorotate: Bool {
// Don't allow orientation changes while recording voice messages.
if viewState.inProgressVoiceMessage?.isRecording == true {
return false
}
return super.shouldAutorotate
}
override public func contentSizeCategoryDidChange() {
super.contentSizeCategoryDidChange()
Logger.info("didChangePreferredContentSize")
resetForSizeOrOrientationChange()
}
override public func themeDidChange() {
super.themeDidChange()
applyTheme()
self.updateThemeIfNecessary()
}
private func updateThemeIfNecessary() {
AssertIsOnMainThread()
if self.isDarkThemeEnabled == Theme.isDarkThemeEnabled {
return
}
self.isDarkThemeEnabled = Theme.isDarkThemeEnabled
self.updateConversationStyle()
self.applyTheme()
}
private func applyTheme() {
guard hasViewWillAppearEverBegun else {
owsFailDebug("Not yet ready.")
return
}
// make sure toolbar extends below iPhoneX home button.
self.view.backgroundColor = Theme.toolbarBackgroundColor
self.updateWallpaperView()
self.updateNavigationTitle()
self.updateNavigationBarSubtitleLabel()
self.updateBarButtonItems()
self.ensureBannerState()
dismissReactionsDetailSheet(animated: false)
}
func reloadCollectionViewForReset() {
AssertIsOnMainThread()
guard hasAppearedAndHasAppliedFirstLoad else {
return
}
// We use an obj-c free function so that we can handle NSException.
self.collectionView.cvc_reloadData(animated: false, cvc: self)
}
var isViewVisible: Bool {
get { viewState.isViewVisible }
set {
viewState.isViewVisible = newValue
updateCellsVisible()
}
}
func updateCellsVisible() {
AssertIsOnMainThread()
let isAppInBackground = CurrentAppContext().isInBackground()
let isCellVisible = self.isViewVisible && !isAppInBackground
for cell in self.collectionView.visibleCells {
guard let cell = cell as? CVCell else {
owsFailDebug("Invalid cell.")
continue
}
cell.isCellVisible = isCellVisible
}
self.updateScrollingContent()
}
// MARK: - Orientation
override public func viewWillTransition(
to size: CGSize,
with coordinator: UIViewControllerTransitionCoordinator,
) {
AssertIsOnMainThread()
super.viewWillTransition(to: size, with: coordinator)
dismissReactionsDetailSheet(animated: false)
guard hasAppearedAndHasAppliedFirstLoad else {
return
}
self.setScrollActionForSizeTransition()
_ = coordinator.animate(
alongsideTransition: { _ in
},
completion: { [weak self] _ in
self?.clearScrollActionForSizeTransition()
},
)
}
override public func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
AssertIsOnMainThread()
self.updateBarButtonItems()
self.updateNavigationBarSubtitleLabel()
}
override public func viewSafeAreaInsetsDidChange() {
AssertIsOnMainThread()
super.viewSafeAreaInsetsDidChange()
// Workaround for iOS 26 animating bottom bar getting in its final position
// during view presentation animation.
if #available(iOS 26, *) {
UIView.performWithoutAnimation {
bottomBarContainer.setNeedsLayout()
bottomBarContainer.layoutIfNeeded()
bottomBarContainer.frame = CGRect(
origin: CGPoint(
x: 0,
y: view.bounds.maxY - bottomBarContainer.frame.height,
),
size: bottomBarContainer.bounds.size,
)
}
}
updateContentInsetsDebounced()
viewSafeAreaInsetsDidChangeForLoad()
updateConversationStyle()
}
}
// MARK: -
// TODO: Is this necessary?
extension ConversationViewController: UINavigationControllerDelegate {
}
// MARK: -
extension ConversationViewController: ContactsViewHelperObserver {
public func contactsViewHelperDidUpdateContacts() {
AssertIsOnMainThread()
loadCoordinator.enqueueReload(canReuseInteractionModels: true, canReuseComponentStates: false)
}
}