// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // public import Foundation public import SignalServiceKit public import SignalUI import UIKit extension ConversationViewController { public var isGroupConversation: Bool { thread.isGroupThread } public static var messageSection: Int { CVLoadCoordinator.messageSection } public var hasRenderState: Bool { !renderState.isEmptyInitialState } public var hasAppearedAndHasAppliedFirstLoad: Bool { hasRenderState && hasViewDidAppearEverBegun && !loadCoordinator.shouldHideCollectionViewContent } public var lastReloadDate: Date { renderState.loadDate } public func indexPath(forInteractionUniqueId interactionUniqueId: String) -> IndexPath? { loadCoordinator.indexPath(forInteractionUniqueId: interactionUniqueId) } public func indexPath(forItemViewModel itemViewModel: CVItemViewModelImpl) -> IndexPath? { indexPath(forInteractionUniqueId: itemViewModel.interaction.uniqueId) } public func interaction(forIndexPath indexPath: IndexPath) -> TSInteraction? { guard let renderItem = self.renderItem(forIndex: indexPath.row) else { return nil } return renderItem.interaction } var indexPathOfUnreadMessagesIndicator: IndexPath? { loadCoordinator.indexPathOfUnreadIndicator } public var canLoadOlderItems: Bool { loadCoordinator.canLoadOlderItems } public var canLoadNewerItems: Bool { loadCoordinator.canLoadNewerItems } public var currentRenderStateDebugDescription: String { renderState.debugDescription } public var areCellsAnimating: Bool { viewState.activeCellAnimations.count > 0 } } // MARK: - Message Highlighting // // The purpose of the code below is to briefly dim message bubble to indicate the message of interest to the user. // Because bubble highlighting is designed to be very brief, all the logic operates exclusively with the // presentation layer and no state is saved or restored. extension ConversationViewController { func performMessageHighlightAnimationIfNeeded() { if let messageId = viewState.highlightedMessageId { performHighlightAnimationSequenceFor(messageId: messageId) viewState.highlightedMessageId = nil } } private func performHighlightAnimationSequenceFor(messageId: String) { if let indexPath = indexPath(forInteractionUniqueId: messageId) { guard let cell = collectionView.cellForItem(at: indexPath) as? CVCell, let componentViewMessage = cell.componentView as? CVComponentMessage.CVComponentViewMessage else { owsFailDebug("Could not find CVComponentViewMessage") return } componentViewMessage.performMessageBubbleHighlightAnimation() } else { owsFailDebug("Unable to find a message to highlight. [\(messageId)]") } } } // MARK: - extension ConversationViewController: CVLoadCoordinatorDelegate { public var conversationViewController: ConversationViewController? { self } func chatColorDidChange() { viewState.chatColor = SSKEnvironment.shared.databaseStorageRef.read { tx in Self.loadChatColor(for: thread, tx: tx) } updateConversationStyle() } func updateAccessibilityCustomActionsForCell(_ cell: CVCell) { guard let renderItem = cell.renderItem else { return } let itemViewModel = CVItemViewModelImpl(renderItem: renderItem) let shouldAllowMessageSendActions = shouldAllowMessageSendActionsForItem(itemViewModel) let messageActions: [MessageAction] if itemViewModel.messageCellType == .systemMessage { messageActions = MessageActions.infoMessageActions( itemViewModel: itemViewModel, delegate: self, ) } else if itemViewModel.messageCellType == .stickerMessage || itemViewModel.messageCellType == .genericAttachment { messageActions = MessageActions.mediaActions( itemViewModel: itemViewModel, shouldAllowMessageSendActions: shouldAllowMessageSendActions, delegate: self, ) } else { messageActions = MessageActions.textActions( itemViewModel: itemViewModel, shouldAllowMessageSendActions: shouldAllowMessageSendActions, delegate: self, ) } var actions: [CVAccessibilityCustomAction] = [] for messageAction in messageActions { let action = CVAccessibilityCustomAction( name: messageAction.accessibilityLabel ?? messageAction.accessibilityIdentifier, target: self, selector: #selector(handleCustomAccessibilityActionInvoked(sender:)), ) action.messageAction = messageAction actions.append(action) } cell.accessibilityCustomActions = actions } @objc private func handleCustomAccessibilityActionInvoked(sender: UIAccessibilityCustomAction) { guard let cvCustomAction = sender as? CVAccessibilityCustomAction else { return } cvCustomAction.messageAction?.block(self) } func willUpdateWithNewRenderState(_ update: CVUpdate) -> 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 // Snapshot CVC layout state before we land the load; // we use this to ensure scroll continuity when landing the load. let scrollContinuityToken = layout.buildScrollContinuityToken( preferredAnchorInteractionId: update.loadRequest.preferredScrollContinuityAnchorInteractionId, ) // CVC will often use this state to ensure scroll continuity // when landing loads, so ensure the value is updated before // landing loads. let lastKnownDistanceFromBottom = self.updateLastKnownDistanceFromBottom() return CVUpdateToken( isScrolledToBottom: self.isScrolledToBottom, lastMessageForInboxSortId: threadViewModel.lastMessageForInbox?.sortId, scrollContinuityToken: scrollContinuityToken, lastKnownDistanceFromBottom: lastKnownDistanceFromBottom, ) } 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 renderState = update.renderState layout.update(conversationStyle: renderState.conversationStyle) 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 updateNavigationBarSubtitleLabel() updateBarButtonItems() let pinnedMessagesChanged = renderState.prevThreadViewModel?.pinnedMessages != threadViewModel.pinnedMessages // This will be nil for non-group threads. let newGroupModel = thread.groupModelIfGroupThread if oldGroupModel != newGroupModel || pinnedMessagesChanged { ensureBannerState() } let groupNameColors = GroupNameColors.forThread(thread) if let groupModelV2 = newGroupModel as? TSGroupModelV2 { self.memberLabelCoordinator?.updateWithNewThreadInfo( groupModel: groupModelV2, groupNameColors: groupNameColors, ) } // If the message has been deleted / disappeared, we need to dismiss dismissMessageContextMenuIfNecessary() showMessageRequestDialogIfRequiredAsync() updateNavigationTitle() updateShouldHideCollectionViewContent(reloadIfClearingFlag: false) if loadCoordinator.shouldHideCollectionViewContent { updateViewToReflectLoad(loadedRenderState: self.renderState) loadDidLand() } else { if !viewState.hasAppliedFirstLoad { // Ignore scrollAction; we need to scroll to .initialPosition. updateWithFirstLoad(update: update) } else { switch update.type { case .minor: updateForMinorUpdate(update: update, scrollAction: scrollAction) case .reloadAll: updateReloadingAll(renderState: renderState, scrollAction: scrollAction) case .diff(let items, let shouldAnimateUpdate): updateWithDiff( update: update, items: items, shouldAnimateUpdate: shouldAnimateUpdate, scrollAction: scrollAction, updateToken: updateToken, ) } } setHasAppliedFirstLoadIfNecessary() } if thread.isReleaseNotesThread { updateReleaseNotesBarButtonItems() } } // 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 } self.updateLastKnownDistanceFromBottom() self.showMessageRequestDialogIfRequired() self.configureScrollDownButtons() let hasViewDidAppearEverCompleted = self.hasViewDidAppearEverCompleted DispatchQueue.main.async { self.reloadReactionsDetailSheetWithSneakyTransaction() if hasViewDidAppearEverCompleted { self.autoLoadMoreIfNecessary() } } } private func loadDidLand() { switch viewState.selectionAnimationState { case .willAnimate: viewState.selectionAnimationState = .animating case .animating, .idle: viewState.selectionAnimationState = .idle ensureBottomViewType() } } // 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. public func viewWillAppearForLoad() { updateShouldHideCollectionViewContent(reloadIfClearingFlag: true) } 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: TimeInterval = .second * 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() self.collectionView.cvc_reloadData(animated: false, cvc: self) } private func updateForMinorUpdate(update: CVUpdate, scrollAction: CVScrollAction) { // 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(update: CVUpdate) { reloadCollectionViewImmediately() scrollToInitialPosition(animated: false) if self.hasViewDidAppearEverCompleted { clearInitialScrollState() } updateViewToReflectLoad(loadedRenderState: self.renderState) loadDidLand() } private func setHasAppliedFirstLoadIfNecessary() { guard !viewState.hasAppliedFirstLoad else { return } viewState.hasAppliedFirstLoad = true if self.hasViewDidAppearEverCompleted { clearInitialScrollState() } } private func updateReloadingAll(renderState: CVRenderState, scrollAction: CVScrollAction) { reloadCollectionViewImmediately() DispatchQueue.main.async { [weak self] in guard let 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() { reloadCollectionViewForReset() // Try to update the lastKnownDistanceFromBottom; the content size may have changed. updateLastKnownDistanceFromBottom() } private func updateWithDiff( update: CVUpdate, items: [CVUpdate.Item], shouldAnimateUpdate: Bool, scrollAction scrollActionParam: CVScrollAction, updateToken: CVUpdateToken, ) { AssertIsOnMainThread() owsAssertDebug(!items.isEmpty) 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 { let renderItem = item.value switch item.updateType { case .insert: 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.wasNotCreatedLocally, 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: .bottomForNewMessage, isAnimated: true) 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: .bottomForNewMessage, isAnimated: true) break } default: break } } } if scrollAction.action == .none, update.loadRequest.preferredScrollContinuityAnchorInteractionId != nil, isScrolledToBottom { scrollAction = CVScrollAction(action: .bottomOfLoadWindow, isAnimated: false) } if .loadOlder == renderState.loadType { scrollAction = .none } viewState.scrollActionForUpdate = scrollAction // We have two scroll continuity mechanisms: // // * The first is in the targetContentOffset(forProposedContentOffset:) method in CVC+Scroll.swift. // This handles scroll continuity in most cases. // * The second is in ConversationViewLayout.willPerformBatchUpdates(). // We manipulate the content offset using // UICollectionViewLayoutInvalidationContext.contentOffsetAdjustment. // // We prefer the second mechanism and only use the first mechanism to // handle special cases (ie. when shouldUseDelegateScrollContinuity is true). let scrollContinuity: ScrollContinuity = { guard let loadType = renderState.loadType else { owsFailDebug("Missing loadType.") return .delegateScrollContinuity } // TODO: We could extend the layout's invalidation-based approach // to scroll continuity to support more of these cases. if shouldUseDelegateScrollContinuity { return .delegateScrollContinuity } let scrollContinuityToken = updateToken.scrollContinuityToken switch loadType { case .loadInitialMapping: return .none case .loadSameLocation: return .contentRelativeToViewport( token: scrollContinuityToken, isRelativeToTop: false, ) case .loadOlder: return .contentRelativeToViewport( token: scrollContinuityToken, isRelativeToTop: true, ) case .loadNewer, .loadNewest: return .contentRelativeToViewport( token: scrollContinuityToken, isRelativeToTop: false, ) case .loadPageAroundInteraction: return .contentRelativeToViewport( token: scrollContinuityToken, isRelativeToTop: false, ) } }() let batchUpdatesBlock = { AssertIsOnMainThread() let section = Self.messageSection for item in items { switch item.updateType { case .delete(let oldIndex): let indexPath = IndexPath(row: oldIndex, section: section) self.collectionView.deleteItems(at: [indexPath]) case .insert(let newIndex): let indexPath = IndexPath(row: newIndex, section: section) self.collectionView.insertItems(at: [indexPath]) case .move(let oldIndex, let newIndex): let oldIndexPath = IndexPath(row: oldIndex, section: section) let newIndexPath = IndexPath(row: newIndex, section: section) self.collectionView.moveItem(at: oldIndexPath, to: newIndexPath) case .update(let oldIndex, _): let indexPath = IndexPath(row: oldIndex, section: section) self.collectionView.reloadItems(at: [indexPath]) } } } let completion = { [weak self] (finished: Bool) in AssertIsOnMainThread() guard let 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) if shouldAnimateUpdate { self.loadDidLand() } if scrollAction.isAnimated { self.perform(scrollAction: scrollAction) } viewState.scrollActionForUpdate = nil if !finished { // If animations were interrupted, reset to get back to a known good state. DispatchQueue.main.async { [weak self] in self?.resetViewStateAfterError() } } } // We use an obj-c free function so that we can handle NSException. self.collectionView.cvc_performBatchUpdates( batchUpdatesBlock, completion: completion, animated: shouldAnimateUpdate, scrollContinuity: scrollContinuity, lastKnownDistanceFromBottom: updateToken.lastKnownDistanceFromBottom, cvc: self, ) if !shouldAnimateUpdate { self.loadDidLand() } } 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) } 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, ) } public static func buildInitialConversationStyle( for thread: TSThread, chatColor: ColorOrGradientSetting, wallpaperViewBuilder: WallpaperViewBuilder?, ) -> ConversationStyle { buildConversationStyle( type: .initial, thread: thread, viewWidth: 0, chatColor: chatColor, wallpaperViewBuilder: wallpaperViewBuilder, ) } private static func buildConversationStyle( type: ConversationStyle.`Type`, thread: TSThread, viewWidth: CGFloat, chatColor: ColorOrGradientSetting, wallpaperViewBuilder: WallpaperViewBuilder?, ) -> ConversationStyle { let hasWallpaper: Bool let shouldDimWallpaperInDarkMode: Bool switch wallpaperViewBuilder { case .customPhoto(_, let shouldDimInDarkMode): hasWallpaper = true shouldDimWallpaperInDarkMode = shouldDimInDarkMode case .colorOrGradient(_, let shouldDimInDarkMode): hasWallpaper = true shouldDimWallpaperInDarkMode = shouldDimInDarkMode case .releaseNotes: hasWallpaper = true shouldDimWallpaperInDarkMode = false case .none: hasWallpaper = false shouldDimWallpaperInDarkMode = false } return ConversationStyle( type: type, thread: thread, viewWidth: viewWidth, hasWallpaper: hasWallpaper, shouldDimWallpaperInDarkMode: shouldDimWallpaperInDarkMode, chatColor: chatColor, ) } private func buildConversationStyle() -> ConversationStyle { AssertIsOnMainThread() func buildConversationStyle(type: ConversationStyle.`Type`, viewWidth: CGFloat) -> ConversationStyle { Self.buildConversationStyle( type: type, thread: thread, viewWidth: viewWidth, chatColor: viewState.chatColor, wallpaperViewBuilder: viewState.wallpaperViewBuilder, ) } func buildDefaultConversationStyle(type: ConversationStyle.`Type`) -> ConversationStyle { // Treat all styles as "initial" (not to be trusted) until // we have a view config. let viewWidth = floor(collectionView.width) return buildConversationStyle(type: type, viewWidth: viewWidth) } 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 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: TimeInterval = .second * 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 buildConversationStyle(type: .placeholder, viewWidth: viewWidth) } } @discardableResult public func updateConversationStyle() -> Bool { AssertIsOnMainThread() let newConversationStyle = buildConversationStyle() guard newConversationStyle != conversationStyle else { return false } self.conversationStyle = newConversationStyle if let inputToolbar { inputToolbar.update(conversationStyle: newConversationStyle) } // We need to kick off a reload cycle if conversationStyle changes. loadCoordinator.updateConversationStyle(newConversationStyle) return true } } // MARK: - extension ConversationViewController: CVViewStateDelegate { public func viewStateUIModeDidChange(oldValue: ConversationUIMode) { if oldValue != uiMode, oldValue == .selection || uiMode == .selection { // Proactively update bottom bar before load lands ensureBottomViewType() // Block loads while things animate. viewState.selectionAnimationState = .willAnimate loadCoordinator.enqueueReload() DispatchQueue.main.asyncAfter(deadline: .now() + CVComponentMessage.selectionAnimationDuration) { self.viewState.selectionAnimationState = .idle // Enqueue a new load after animation so the "wasShowingSelectionUI" state is updated. self.loadCoordinator.enqueueReload() } } else { loadCoordinator.enqueueReload() } } } // MARK: - Load More extension ConversationViewController { @discardableResult 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 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 } public var showLoadOlderHeader: Bool { loadCoordinator.showLoadOlderHeader } public var showLoadNewerHeader: Bool { loadCoordinator.showLoadNewerHeader } } extension ConversationViewController: MemberLabelViewControllerPresenter { func reloadMemberLabelIfNeeded() { /* handled in updateWithNewRenderState */ } }