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

678 lines
23 KiB
Swift

//
// 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 {
internal 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
var selectionToolbar: MessageActionsToolbar?
var otherUsersProfileDidChangeEvent: DebouncedEvent?
/// See `ConversationViewController+OWS.updateContentInsetsDebounced`
lazy var updateContentInsetsEvent = DebouncedEvents.build(
mode: .lastOnly,
maxFrequencySeconds: 0.01,
onQueue: .asyncOnQueue(queue: .main),
notifyBlock: { [weak self] in
self?.updateContentInsets()
})
// MARK: -
public static func load(
appReadiness: AppReadinessSetter,
threadViewModel: ThreadViewModel,
action: ConversationViewAction,
focusMessageId: String?,
tx: SDSAnyReadTransaction
) -> 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 didAlreadyShowGroupCallTooltipEnoughTimes = SSKEnvironment.shared.preferencesRef.wasGroupCallTooltipShown(withTransaction: tx)
let cvc = ConversationViewController(
appReadiness: appReadiness,
threadViewModel: threadViewModel,
conversationViewModel: conversationViewModel,
action: action,
conversationStyle: conversationStyle,
didAlreadyShowGroupCallTooltipEnoughTimes: didAlreadyShowGroupCallTooltipEnoughTimes,
loadAroundMessageId: loadAroundMessageId,
scrollToMessageId: scrollToMessageId,
oldestUnreadMessage: oldestUnreadMessage,
chatColor: chatColor,
wallpaperViewBuilder: wallpaperViewBuilder
)
return cvc
}
static func loadChatColor(for thread: TSThread, tx: SDSAnyReadTransaction) -> ColorOrGradientSetting {
return DependenciesBridge.shared.chatColorSettingStore.resolvedChatColor(
for: thread,
tx: tx.asV2Read
)
}
static func loadWallpaperViewBuilder(for thread: TSThread, tx: SDSAnyReadTransaction) -> WallpaperViewBuilder? {
return Wallpaper.viewBuilder(for: thread, tx: tx)
}
private init(
appReadiness: AppReadinessSetter,
threadViewModel: ThreadViewModel,
conversationViewModel: ConversationViewModel,
action: ConversationViewAction,
conversationStyle: ConversationStyle,
didAlreadyShowGroupCallTooltipEnoughTimes: Bool,
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,
didAlreadyShowGroupCallTooltipEnoughTimes: didAlreadyShowGroupCallTooltipEnoughTimes,
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)
super.init()
self.viewState.delegate = self
self.viewState.selectionState.delegate = self
self.hidesBottomBarWhenPushed = true
self.inputAccessoryPlaceholder.delegate = self
SUIEnvironment.shared.contactsViewHelperRef.addObserver(self)
self.actionOnOpen = action
self.recordInitialScrollState(scrollToMessageId)
loadCoordinator.configure(
delegate: self,
componentDelegate: self,
focusMessageIdOnOpen: loadAroundMessageId
)
searchController.delegate = self
// because the search bar view is hosted in the navigation bar, it's not in the CVC's responder
// chain, and thus won't inherit our inputAccessoryView, so we manually set it here.
searchController.uiSearchController.searchBar.inputAccessoryView = self.inputAccessoryPlaceholder
self.otherUsersProfileDidChangeEvent = DebouncedEvents.build(
mode: .firstLast,
maxFrequencySeconds: 1.0,
onQueue: .asyncOnQueue(queue: .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)
}
}
deinit {
reloadTimer?.invalidate()
scrollUpdateTimer?.invalidate()
}
// MARK: - View Lifecycle
public override 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(bottomBar)
self.bottomBarBottomConstraint = bottomBar.autoPinEdge(toSuperviewEdge: .bottom)
bottomBar.autoPinWidthToSuperview()
self.selectionToolbar = self.buildSelectionToolbar()
// This should kick off the first load.
owsAssertDebug(!self.hasRenderState)
self.updateConversationStyle()
}
public override var canBecomeFirstResponder: Bool {
return true
}
public override func becomeFirstResponder() -> Bool {
let result = super.becomeFirstResponder()
guard hasViewWillAppearEverBegun else {
return result
}
guard let inputToolbar = inputToolbar else {
owsFailDebug("Missing inputToolbar.")
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
}
public override var inputAccessoryView: UIView? {
inputAccessoryPlaceholder
}
public override 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
}
}
public override func viewWillAppear(_ animated: Bool) {
self.viewWillAppearDidBegin()
super.viewWillAppear(animated)
if self.inputToolbar == nil {
// This will create the input toolbar for the first time.
// It's important that we do this at the "last moment" to
// avoid expensive work that delays CVC presentation.
self.applyTheme()
owsAssertDebug(self.inputToolbar != nil)
self.createGestureRecognizers()
} else {
self.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()
self.updateInputToolbarLayout(initialLayout: true)
self.refreshCallState()
self.showMessageRequestDialogIfRequired()
self.viewWillAppearDidComplete()
}
public override 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()
if !DebugFlags.reduceLogChatter {
var addresses = Set(thread.recipientAddressesWithSneakyTransaction)
if let groupThread = thread as? TSGroupThread, let groupModel = groupThread.groupModel as? TSGroupModelV2 {
addresses.formUnion(groupModel.droppedMembers)
}
let serviceIds = addresses.compactMap { $0.serviceId }
Task {
let profileFetcher = SSKEnvironment.shared.profileFetcherRef
for serviceId in serviceIds {
_ = try? await profileFetcher.fetchProfile(for: serviceId, options: [.opportunistic])
}
}
self.updateV2GroupIfNecessary()
}
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.updateInputToolbarLayout()
self.configureScrollDownButtons()
inputToolbar?.viewDidAppear()
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`.
public override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
self.isViewCompletelyAppeared = false
dismissMessageContextMenu(animated: false)
self.dismissReactionsDetailSheet(animated: false)
self.saveLastVisibleSortIdAndOnScreenPercentage(async: true)
}
public override 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
}
public override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
guard hasViewWillAppearEverBegun else {
return
}
guard nil != inputToolbar else {
owsFailDebug("Missing inputToolbar.")
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.
self.inputToolbar?.ensureTextViewHeight()
self.positionGroupCallTooltip()
}
public override var shouldAutorotate: Bool {
// Don't allow orientation changes while recording voice messages.
if viewState.inProgressVoiceMessage?.isRecording == true {
return false
}
return super.shouldAutorotate
}
public override func contentSizeCategoryDidChange() {
super.contentSizeCategoryDidChange()
Logger.info("didChangePreferredContentSize")
resetForSizeOrOrientationChange()
guard hasViewWillAppearEverBegun else {
return
}
guard let inputToolbar = inputToolbar else {
owsFailDebug("Missing inputToolbar.")
return
}
inputToolbar.updateFontSizes()
}
public override 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.updateInputToolbar()
self.updateInputToolbarLayout()
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
public override 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()
})
}
public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
AssertIsOnMainThread()
self.updateBarButtonItems()
self.updateNavigationBarSubtitleLabel()
// Invoking -ensureBannerState synchronously can lead to reenterant updates to the
// trait collection while building the banners. This can lead us to blow out the stack
// on unrelated trait collection changes (e.g. rotating to landscape).
// We workaround this by just asyncing any banner updates to break the synchronous
// dependency chain.
DispatchQueue.main.async {
self.ensureBannerState()
}
}
public override func viewSafeAreaInsetsDidChange() {
AssertIsOnMainThread()
super.viewSafeAreaInsetsDidChange()
updateContentInsetsDebounced()
self.updateInputToolbarLayout()
self.viewSafeAreaInsetsDidChangeForLoad()
self.updateConversationStyle()
}
}
// MARK: -
// TODO: Is this necessary?
extension ConversationViewController: UINavigationControllerDelegate {
}
// MARK: -
extension ConversationViewController: ContactsViewHelperObserver {
public func contactsViewHelperDidUpdateContacts() {
AssertIsOnMainThread()
loadCoordinator.enqueueReload(canReuseInteractionModels: true, canReuseComponentStates: false)
}
}