// // Copyright (c) 2021 Open Whisper Systems. All rights reserved. // import Foundation extension ConversationViewController { @objc public var isGroupConversation: Bool { thread.isGroupThread } @objc public static var messageSection: Int { CVLoadCoordinator.messageSection } @objc public var hasRenderState: Bool { !renderState.isEmptyInitialState } @objc public var hasAppearedAndHasAppliedFirstLoad: Bool { (hasRenderState && hasViewDidAppearEverBegun && !loadCoordinator.shouldHideCollectionViewContent) } @objc public var lastReloadDate: Date { renderState.loadDate } @objc public func indexPath(forInteractionUniqueId interactionUniqueId: String) -> IndexPath? { loadCoordinator.indexPath(forInteractionUniqueId: interactionUniqueId) } @objc public func indexPath(forItemViewModel itemViewModel: CVItemViewModelImpl) -> IndexPath? { indexPath(forInteractionUniqueId: itemViewModel.interaction.uniqueId) } @objc public func interaction(forIndexPath indexPath: IndexPath) -> TSInteraction? { guard let renderItem = self.renderItem(forIndex: indexPath.row) else { return nil } return renderItem.interaction } @objc var indexPathOfUnreadMessagesIndicator: IndexPath? { loadCoordinator.indexPathOfUnreadIndicator } @objc public var canLoadOlderItems: Bool { loadCoordinator.canLoadOlderItems } @objc public var canLoadNewerItems: Bool { loadCoordinator.canLoadNewerItems } @objc public var currentRenderStateDebugDescription: String { renderState.debugDescription } @objc public var isLayoutApplyingUpdate: Bool { layout.isApplyingUpdate } @objc public var areCellsAnimating: Bool { viewState.activeCellAnimations.count > 0 } @objc public var isShowingSelectionUI: Bool { viewState.isShowingSelectionUI } } // MARK: - extension ConversationViewController: CVLoadCoordinatorDelegate { func chatColorDidChange() { updateConversationStyle() } @objc public func buildLoadCoordinator(conversationStyle: ConversationStyle, focusMessageIdOnOpen: String?) -> CVLoadCoordinator { CVLoadCoordinator(delegate: self, componentDelegate: self, conversationStyle: conversationStyle, focusMessageIdOnOpen: focusMessageIdOnOpen) } func willUpdateWithNewRenderState(_ renderState: CVRenderState) -> CVUpdateToken { AssertIsOnMainThread() // HACK to work around radar #28167779 // "UICollectionView performBatchUpdates can trigger a crash if the collection view is flagged for layout" // more: https://github.com/PSPDFKit-labs/radar.apple.com/tree/master/28167779%20-%20CollectionViewBatchingIssue // This was our #2 crash, and much exacerbated by the refactoring somewhere between 2.6.2.0-2.6.3.8 // // NOTE: It's critical we do this before beginLongLivedReadTransaction. // We want to relayout our contents using the old message mappings and // view items before they are updated. collectionView.layoutIfNeeded() // ENDHACK to work around radar #28167779 return CVUpdateToken(isScrolledToBottom: self.isScrolledToBottom, lastMessageForInboxSortId: threadViewModel.lastMessageForInbox?.sortId) } func updateWithNewRenderState(update: CVUpdate, scrollAction: CVScrollAction, updateToken: CVUpdateToken) { AssertIsOnMainThread() guard hasViewWillAppearEverBegun else { // It's safe to ignore updates before viewWillAppear // if called for the first time. Logger.info("View is not yet loaded.") loadDidLand() return } let benchSteps = BenchSteps(title: "updateWithNewRenderState") let renderState = update.renderState let isFirstLoad = renderState.isFirstLoad var scrollAction = scrollAction if !viewState.hasAppliedFirstLoad { scrollAction = CVScrollAction(action: .initialPosition, isAnimated: false) } else if let scrollActionForSizeTransition = viewState.scrollActionForSizeTransition { // If we're in a size transition, honor the relevant scroll action. scrollAction = scrollActionForSizeTransition } // Capture old group model before we update threadViewModel. // This will be nil for non-group threads. let oldGroupModel = renderState.prevThreadViewModel?.threadRecord.groupModelIfGroupThread benchSteps.step("1") updateNavigationBarSubtitleLabel() updateBarButtonItems() // This will be nil for non-group threads. let newGroupModel = thread.groupModelIfGroupThread if oldGroupModel != newGroupModel { ensureBannerState() } // If the message has been deleted / disappeared, we need to dismiss dismissMessageActionsIfNecessary() showMessageRequestDialogIfRequiredAsync() if thread.isGroupThread { updateNavigationTitle() } Logger.verbose("Landing load: \(update.type.debugName), load: \(update.loadType), isFirstLoad: \(isFirstLoad), renderItems: \(update.prevRenderState.items.count) -> \(renderItems.count), scrollAction: \(scrollAction.description)") benchSteps.step("2") updateShouldHideCollectionViewContent(reloadIfClearingFlag: false) benchSteps.step("3") if loadCoordinator.shouldHideCollectionViewContent { Logger.verbose("Not applying load.") updateViewToReflectLoad(loadedRenderState: self.renderState) benchSteps.step("4a") loadDidLand() benchSteps.step("5a") } else { if !viewState.hasAppliedFirstLoad { // Ignore scrollAction; we need to scroll to .initialPosition. updateWithFirstLoad() } else { switch update.type { case .minor: updateForMinorUpdate(scrollAction: scrollAction) case .reloadAll: updateReloadingAll(renderState: renderState, scrollAction: scrollAction) case .diff(let items, let threadInteractionCount, let shouldAnimateUpdate): updateWithDiff(update: update, items: items, shouldAnimateUpdate: shouldAnimateUpdate, scrollAction: scrollAction, threadInteractionCount: threadInteractionCount, updateToken: updateToken) } } benchSteps.step("4b") setHasAppliedFirstLoadIfNecessary() benchSteps.step("5b") } benchSteps.logAll() } // The more work we put into this method, the greater our // confidence we have that CVC view state is always up-to-date. // But that can make "minor update" updates more expensive. private func updateViewToReflectLoad(loadedRenderState: CVRenderState) { // We can skip some of this work guard self.hasViewWillAppearEverBegun else { return } let benchSteps = BenchSteps() self.scrollContinuity = .bottom self.updateLastKnownDistanceFromBottom() self.updateInputToolbarLayout() self.ensureSelectionViewState() self.showMessageRequestDialogIfRequired() self.configureScrollDownButtons() benchSteps.step("loadCompletion.1") let hasViewDidAppearEverCompleted = self.hasViewDidAppearEverCompleted DispatchQueue.main.async { let benchSteps = BenchSteps() Self.databaseStorage.uiRead { transaction in self.reloadReactionsDetailSheet(with: transaction) self.updateUnreadMessageFlag(transaction: transaction) } if hasViewDidAppearEverCompleted { _ = self.autoLoadMoreIfNecessary() } benchSteps.step("loadCompletion.2") } } private func loadDidLand() { self.loadCoordinator.loadDidLand() } // The view's first appearance and the first load can race. // We need to handle them completing in either order. // // This means performing much of the work we do when we land // the first load. @objc public func viewWillAppearForLoad() { updateShouldHideCollectionViewContent(reloadIfClearingFlag: true) } @objc public func viewSafeAreaInsetsDidChangeForLoad() { updateShouldHideCollectionViewContent(reloadIfClearingFlag: true) } // One of the inconveniences of iOS view presentation is that the // safeAreaInsets are set after viewWillAppear() and before // viewDidAppear(). We kick off our first load when view presentation // begins, but that load will have the wrong layout. // // Another considerations is that the view events (viewWillAppear(), // safeAreaInsets being set) can race with the first load(s). // // We use the shouldHideCollectionViewContent flag to handle these // issues. We don't "apply" loads until this flag is set. The flag // isn't set until: // // * viewWillAppear() has occurred at least once. // * safeAreaInsets is non-zero (if appropriate). // * At least one load has landed that has an appropriate safeAreaInsets // value. // // This ensures that we don't render mis-formatted content during // view presentation. private func updateShouldHideCollectionViewContent(reloadIfClearingFlag: Bool) { // We hide collection view content until the view // appears for the first time. Once we've cleared // the flag, never set it again. guard loadCoordinator.shouldHideCollectionViewContent else { return } let shouldHideCollectionViewContent: Bool = { // Don't hide content for more than a couple of seconds. let viewAge = abs(self.viewState.viewCreationDate.timeIntervalSinceNow) let maxHideTime = kSecondInterval * 2 guard viewAge < maxHideTime else { // This should only occur on very slow devices. Logger.warn("View taking a long time to render content.") return false } // Hide content until "viewWillAppear()" is called for the // first time. guard self.hasViewWillAppearEverBegun else { return true } // Hide content until the first load lands. guard self.hasRenderState else { return true } guard renderState.conversationStyle.isValidStyle else { return true } return false }() guard !shouldHideCollectionViewContent else { return } loadCoordinator.shouldHideCollectionViewContent = false // Completion of the first load can race with the // view appearing for the first time. If the first load // completes first, we need to update the collection view // to reflect its contents. if reloadIfClearingFlag, hasRenderState { reloadCollectionViewImmediately() scrollToInitialPosition(animated: false) updateViewToReflectLoad(loadedRenderState: self.renderState) loadCoordinator.enqueueReload() setHasAppliedFirstLoadIfNecessary() } } private func reloadCollectionViewImmediately() { AssertIsOnMainThread() layout.willReloadData() UIView.performWithoutAnimation { self.collectionView.reloadData() self.layout.invalidateLayout() } layout.didReloadData() } private func updateForMinorUpdate(scrollAction: CVScrollAction) { Logger.verbose("") // If the scroll action is not animated, perform it _before_ // updateViewToReflectLoad(). if !scrollAction.isAnimated { self.perform(scrollAction: scrollAction) } updateViewToReflectLoad(loadedRenderState: self.renderState) loadDidLand() if scrollAction.isAnimated { self.perform(scrollAction: scrollAction) } } private func updateWithFirstLoad() { let benchSteps = BenchSteps(title: "updateWithFirstLoad") #if TESTABLE_BUILD initialLoadBenchSteps.step("updateWithFirstLoad.1") #endif Logger.verbose("") benchSteps.step("1") reloadCollectionViewImmediately() benchSteps.step("2") scrollToInitialPosition(animated: false) if self.hasViewDidAppearEverCompleted { clearInitialScrollState() } benchSteps.step("3") updateViewToReflectLoad(loadedRenderState: self.renderState) benchSteps.step("4") loadDidLand() benchSteps.step("5") benchSteps.logAll() #if TESTABLE_BUILD initialLoadBenchSteps.step("updateWithFirstLoad.2") initialLoadBenchSteps.logAll() #endif } private func setHasAppliedFirstLoadIfNecessary() { guard !viewState.hasAppliedFirstLoad else { return } viewState.hasAppliedFirstLoad = true if self.hasViewDidAppearEverCompleted { clearInitialScrollState() } } private func updateReloadingAll(renderState: CVRenderState, scrollAction: CVScrollAction) { Logger.verbose("") reloadCollectionViewImmediately() DispatchQueue.main.async { [weak self] in guard let self = self else { return } // If the scroll action is not animated, perform it _before_ // updateViewToReflectLoad(). if !scrollAction.isAnimated { self.perform(scrollAction: scrollAction) } self.updateViewToReflectLoad(loadedRenderState: renderState) self.loadDidLand() if scrollAction.isAnimated { self.perform(scrollAction: scrollAction) } } } private func resetViewStateAfterError() { Logger.verbose("") scrollContinuity = .bottom reloadCollectionViewForReset() // Try to update the lastKnownDistanceFromBottom; the content size may have changed. updateLastKnownDistanceFromBottom() ensureSelectionViewState() } private func updateWithDiff(update: CVUpdate, items: [CVUpdate.Item], shouldAnimateUpdate: Bool, scrollAction scrollActionParam: CVScrollAction, threadInteractionCount: UInt, updateToken: CVUpdateToken) { owsAssertDebug(!items.isEmpty) Logger.verbose("") let renderState = update.renderState let isScrolledToBottom = updateToken.isScrolledToBottom let viewState = self.viewState var scrollAction = scrollActionParam // Update scroll action to auto-scroll if necessary. if scrollAction.action == .none, !self.isUserScrolling { for item in items { switch item { case .insert(let renderItem, _): var wasJustInserted = false if let lastMessageForInboxSortId = updateToken.lastMessageForInboxSortId { if lastMessageForInboxSortId < renderItem.interaction.sortId { wasJustInserted = true } } else { // The first interaction in the thread. wasJustInserted = true } // We want to auto-scroll to the bottom of the conversation // if the user is inserting new interactions. let isAutoScrollInteraction: Bool switch renderItem.interactionType { case .typingIndicator: isAutoScrollInteraction = true case .incomingMessage, .outgoingMessage, .call, .error, .info: isAutoScrollInteraction = wasJustInserted default: isAutoScrollInteraction = false } if let outgoingMessage = renderItem.interaction as? TSOutgoingMessage, !outgoingMessage.isFromLinkedDevice, wasJustInserted { // Whenever we send an outgoing message from the local device, // auto-scroll to the bottom of the conversation, regardless // of scroll state. scrollAction = CVScrollAction(action: .bottomOfLoadWindow, isAnimated: false) break } else if isAutoScrollInteraction, isScrolledToBottom { // If we're already at the bottom of the conversation and // a freshly inserted message or typing indicator appears, // auto-scroll to show it. scrollAction = CVScrollAction(action: .bottomOfLoadWindow, isAnimated: false) break } default: break } } } scrollContinuity = isScrolledToBottom ? .bottom : .top var isLoadAdjacent = false if let loadType = renderState.loadType { switch loadType { case .loadOlder: scrollContinuity = .bottom scrollAction = .none isLoadAdjacent = true case .loadNewer, .loadNewest: isLoadAdjacent = true default: break } } else { owsFailDebug("Missing loadType.") } viewState.scrollActionForUpdate = scrollAction let batchUpdatesBlock = { AssertIsOnMainThread() let section = Self.messageSection var hasInserted = false var hasUpdated = false for item in items { switch item { case .insert(_, let newIndex): // Always perform inserts before updates. owsAssertDebug(!hasUpdated) if !DebugFlags.reduceLogChatter { Logger.verbose("insert newIndex: \(newIndex)") } let indexPath = IndexPath(row: newIndex, section: section) self.collectionView.insertItems(at: [indexPath]) hasInserted = true case .update(_, let oldIndex, _): if !DebugFlags.reduceLogChatter { Logger.verbose("update oldIndex: \(oldIndex)") } let indexPath = IndexPath(row: oldIndex, section: section) self.collectionView.reloadItems(at: [indexPath]) hasUpdated = true case .delete(_, let oldIndex): if !DebugFlags.reduceLogChatter { Logger.verbose("delete oldIndex: \(oldIndex)") } // Always perform deletes before inserts and updates. owsAssertDebug(!hasInserted && !hasUpdated) let indexPath = IndexPath(row: oldIndex, section: section) self.collectionView.deleteItems(at: [indexPath]) } } } let completion = { [weak self] (finished: Bool) in AssertIsOnMainThread() guard let self = self else { return } // If the scroll action is not animated, perform it _before_ // updateViewToReflectLoad(). if !scrollAction.isAnimated { self.perform(scrollAction: scrollAction) } self.updateViewToReflectLoad(loadedRenderState: renderState) self.loadDidLand() if scrollAction.isAnimated { self.perform(scrollAction: scrollAction) } viewState.scrollActionForUpdate = nil if !finished { Logger.warn("performBatchUpdates did not finish") if DebugFlags.internalLogging { Logger.warn("Layout: \(self.layout.debugDescription)") Logger.warn("prevRenderState: \(update.prevRenderState.debugDescription)") Logger.warn("renderState: \(update.renderState.debugDescription)") } // If animations were interrupted, reset to get back to a known good state. DispatchQueue.main.async { [weak self] in self?.resetViewStateAfterError() } } } let logFailureBlock = { for item in items { Logger.warn("item: \(item.debugDescription)") } } self.performBatchUpdates(batchUpdatesBlock, completion: completion, logFailureBlock: logFailureBlock, shouldAnimateUpdates: shouldAnimateUpdate, isLoadAdjacent: isLoadAdjacent) } private var scrolledToEdgeTolerancePoints: CGFloat { let deviceFrame = CurrentAppContext().frame // Within 1 screenful of the edge of the load window. return max(deviceFrame.width, deviceFrame.height) } var isScrollNearTopOfLoadWindow: Bool { return isScrolledToTop(tolerancePoints: scrolledToEdgeTolerancePoints) } var isScrollNearBottomOfLoadWindow: Bool { return isScrolledToBottom(tolerancePoints: scrolledToEdgeTolerancePoints) } @objc public func registerReuseIdentifiers() { CVCell.registerReuseIdentifiers(collectionView: self.collectionView) collectionView.register(LoadMoreMessagesView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionHeader, withReuseIdentifier: LoadMoreMessagesView.reuseIdentifier) collectionView.register(LoadMoreMessagesView.self, forSupplementaryViewOfKind: UICollectionView.elementKindSectionFooter, withReuseIdentifier: LoadMoreMessagesView.reuseIdentifier) } @objc public static func buildInitialConversationStyle(threadViewModel: ThreadViewModel) -> ConversationStyle { ConversationStyle(type: .initial, thread: threadViewModel.threadRecord, viewWidth: 0, hasWallpaper: threadViewModel.hasWallpaper, chatColor: .placeholderValue) } @objc public static func buildDefaultConversationStyle(thread: TSThread) -> ConversationStyle { ConversationStyle(type: .default, thread: thread, viewWidth: 0, hasWallpaper: false, chatColor: .placeholderValue) } private func buildConversationStyle() -> ConversationStyle { AssertIsOnMainThread() var hasWallpaper: Bool = false var chatColor: ChatColor = .placeholderValue databaseStorage.read { transaction in hasWallpaper = Wallpaper.exists(for: self.thread, transaction: transaction) chatColor = ChatColors.chatColorForRendering(thread: self.thread, transaction: transaction) } func buildDefaultConversationStyle(type: ConversationStyleType) -> ConversationStyle { // Treat all styles as "initial" (not to be trusted) until // we have a view config. let viewWidth = floor(collectionView.width) return ConversationStyle(type: type, thread: thread, viewWidth: viewWidth, hasWallpaper: hasWallpaper, chatColor: chatColor) } guard self.conversationStyle.type != .`default` else { // Once we built a normal style, never go back to // building an initial or placeholder style. owsAssertDebug(navigationController != nil || viewState.isInPreviewPlatter) return buildDefaultConversationStyle(type: .`default`) } guard let navigationController = navigationController else { if viewState.isInPreviewPlatter { // In a preview platter, we'll never have a navigation controller return buildDefaultConversationStyle(type: .`default`) } else { // Treat all styles as "initial" (not to be trusted) until // we have a navigationController. return buildDefaultConversationStyle(type: .initial) } } let collectionViewWidth = self.collectionView.width let rootViewWidth = self.view.width let viewSafeAreaInsets = self.view.safeAreaInsets let navigationViewWidth = navigationController.view.width let navigationSafeAreaInsets = navigationController.view.safeAreaInsets let isMissingSafeAreaInsets = (viewSafeAreaInsets == .zero && navigationSafeAreaInsets != .zero) let hasInvalidWidth = (collectionViewWidth > navigationViewWidth || rootViewWidth > navigationViewWidth) let hasValidStyle = !isMissingSafeAreaInsets && !hasInvalidWidth if hasValidStyle { // No need to rewrite style; style is already valid. return buildDefaultConversationStyle(type: .`default`) } else { let viewAge = abs(self.viewState.viewCreationDate.timeIntervalSinceNow) let maxHideTime = kSecondInterval * 2 guard viewAge < maxHideTime else { // This should never happen, but we want to put an upper bound on // how long we're willing to infer view state from the // navigationController. It might not always be safe to assume that // navigationController view and CVC view state converge. Logger.warn("View state taking a long time to be configured.") return buildDefaultConversationStyle(type: .placeholder) } // We can derive a style that reflects what the correct style will be, // using values from the navigationController. let viewWidth = floor(navigationViewWidth) return ConversationStyle(type: .placeholder, thread: thread, viewWidth: viewWidth, hasWallpaper: hasWallpaper, chatColor: chatColor) } } @objc @discardableResult public func updateConversationStyle() -> Bool { AssertIsOnMainThread() let oldConversationStyle = self.conversationStyle let newConversationStyle = buildConversationStyle() let didChange = !newConversationStyle.isEqualForCellRendering(oldConversationStyle) if !didChange { return false } self.conversationStyle = newConversationStyle if let inputToolbar = inputToolbar { inputToolbar.update(conversationStyle: newConversationStyle) } // We need to kick off a reload cycle if conversationStyle changes. loadCoordinator.updateConversationStyle(newConversationStyle) // TODO: In "new CVC" we shouldn't update the layout's style until the render state changes. layout.update(conversationStyle: newConversationStyle) return true } } // MARK: - extension ConversationViewController: CVViewStateDelegate { public func uiModeDidChange() { loadCoordinator.enqueueReload() } } // MARK: - Load More extension ConversationViewController { @objc public func autoLoadMoreIfNecessary() -> Bool { AssertIsOnMainThread() guard hasAppearedAndHasAppliedFirstLoad else { return false } let isMainAppAndActive = CurrentAppContext().isMainAppAndActive guard isViewVisible, isMainAppAndActive else { return false } guard showLoadOlderHeader || showLoadNewerHeader else { return false } guard let navigationController = navigationController else { return false } navigationController.view.layoutIfNeeded() let navControllerSize = navigationController.view.frame.size let loadThreshold = navControllerSize.largerAxis * 3 let distanceFromTop = collectionView.contentOffset.y let isCloseToTop = distanceFromTop < loadThreshold if showLoadOlderHeader, isCloseToTop { if loadCoordinator.didLoadOlderRecently { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in _ = self?.autoLoadMoreIfNecessary() } return false } loadCoordinator.loadOlderItems() return true } let distanceFromBottom = collectionView.contentSize.height - collectionView.bounds.size.height - collectionView.contentOffset.y let isCloseToBottom = distanceFromBottom < loadThreshold if showLoadNewerHeader, isCloseToBottom { if loadCoordinator.didLoadNewerRecently { DispatchQueue.main.asyncAfter(deadline: .now() + 1) { [weak self] in _ = self?.autoLoadMoreIfNecessary() } return false } loadCoordinator.loadNewerItems() return true } return false } @objc public var showLoadOlderHeader: Bool { loadCoordinator.showLoadOlderHeader } @objc public var showLoadNewerHeader: Bool { loadCoordinator.showLoadNewerHeader } }