// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit import SignalUI protocol CVLoadCoordinatorDelegate: UIScrollViewDelegate { var viewState: CVViewState { get } func willUpdateWithNewRenderState(_ renderState: CVRenderState) -> CVUpdateToken func updateWithNewRenderState(update: CVUpdate, scrollAction: CVScrollAction, updateToken: CVUpdateToken) func updateScrollingContent() func chatColorDidChange() func updateAccessibilityCustomActionsForCell(_ cell: CVItemCell) var isScrolledToBottom: Bool { get } var isScrollNearTopOfLoadWindow: Bool { get } var isScrollNearBottomOfLoadWindow: Bool { get } var areCellsAnimating: Bool { get } var conversationViewController: ConversationViewController? { get } } // MARK: - // This token lets CVC capture state from "before" a load // lands that can be used when landing that load. struct CVUpdateToken { let isScrolledToBottom: Bool let lastMessageForInboxSortId: UInt64? let scrollContinuityToken: CVScrollContinuityToken let lastKnownDistanceFromBottom: CGFloat? } public class CVLoadCoordinator: NSObject { private weak var delegate: CVLoadCoordinatorDelegate? private weak var componentDelegate: CVComponentDelegate? private let viewState: CVViewState private var mediaCache: CVMediaCache { viewState.mediaCache } private let threadUniqueId: String private var conversationStyle: ConversationStyle private let spoilerState: SpoilerRenderState var renderState: CVRenderState // CVC is perf-sensitive during its initial load and // presentation. We can use this flag to skip any expensive // work before the first load is complete. public var hasRenderState: Bool { !renderState.isEmptyInitialState } public var shouldHideCollectionViewContent = true { didSet { owsAssertDebug(!shouldHideCollectionViewContent) } } private var oldestUnreadMessageSortId: UInt64? private let messageLoader: MessageLoader // TODO: Remove. This model will get stale. private let thread: TSThread private var callService: CallService { AppEnvironment.shared.callService } init( viewState: CVViewState, threadViewModel: ThreadViewModel, conversationViewModel: ConversationViewModel, oldestUnreadMessageSortId: UInt64? ) { self.viewState = viewState self.threadUniqueId = threadViewModel.threadRecord.uniqueId self.thread = threadViewModel.threadRecord self.conversationStyle = viewState.conversationStyle self.spoilerState = viewState.spoilerState self.oldestUnreadMessageSortId = oldestUnreadMessageSortId let viewStateSnapshot = CVViewStateSnapshot.snapshot( viewState: viewState, typingIndicatorsSender: nil, oldestUnreadMessageSortId: oldestUnreadMessageSortId, previousViewStateSnapshot: nil ) self.renderState = CVRenderState.defaultRenderState( threadViewModel: threadViewModel, conversationViewModel: conversationViewModel, viewStateSnapshot: viewStateSnapshot ) self.messageLoader = MessageLoader( batchFetcher: ConversationViewBatchFetcher(interactionFinder: InteractionFinder(threadUniqueId: thread.uniqueId)), interactionFetchers: [Self.modelReadCaches.interactionReadCache, SDSInteractionFetcherImpl()] ) super.init() } func configure(delegate: CVLoadCoordinatorDelegate, componentDelegate: CVComponentDelegate, focusMessageIdOnOpen: String?) { self.delegate = delegate self.componentDelegate = componentDelegate Self.databaseStorage.appendDatabaseChangeDelegate(self) // Kick off async load. loadInitialMapping(focusMessageIdOnOpen: focusMessageIdOnOpen) } // MARK: - public func viewDidLoad() { addNotificationListeners() } // MARK: - Notifications private func addNotificationListeners() { NotificationCenter.default.addObserver(self, selector: #selector(applicationDidEnterBackground), name: .OWSApplicationDidEnterBackground, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(typingIndicatorStateDidChange), name: TypingIndicatorsImpl.typingIndicatorStateDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(profileWhitelistDidChange), name: UserProfileNotifications.profileWhitelistDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(blockListDidChange), name: BlockingManager.blockListDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(localProfileDidChange), name: UserProfileNotifications.localProfileDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(otherUsersProfileDidChange(notification:)), name: UserProfileNotifications.otherUsersProfileDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(skipContactAvatarBlurDidChange(notification:)), name: OWSContactsManager.skipContactAvatarBlurDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(skipGroupAvatarBlurDidChange(notification:)), name: OWSContactsManager.skipGroupAvatarBlurDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(chatColorsDidChange), name: ChatColors.chatColorsDidChangeNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(didLearnRecipientAssociation(notification:)), name: .didLearnRecipientAssociation, object: nil) callService.callServiceState.addObserver(self, syncStateImmediately: false) } @objc private func applicationDidEnterBackground() { resetClearedUnreadMessagesIndicator() } @objc private func typingIndicatorStateDidChange(notification: Notification) { AssertIsOnMainThread() guard let notificationThreadId = notification.object as? String else { return } guard notificationThreadId == thread.uniqueId else { return } enqueueReload() } @objc private func profileWhitelistDidChange() { AssertIsOnMainThread() enqueueReload(canReuseInteractionModels: true, canReuseComponentStates: false) } @objc private func blockListDidChange() { AssertIsOnMainThread() enqueueReload(canReuseInteractionModels: true, canReuseComponentStates: false) } @objc private func localProfileDidChange() { AssertIsOnMainThread() enqueueReload(canReuseInteractionModels: true, canReuseComponentStates: false) } @objc private func otherUsersProfileDidChange(notification: Notification) { AssertIsOnMainThread() if let contactThread = thread as? TSContactThread { guard let address = notification.userInfo?[UserProfileNotifications.profileAddressKey] as? SignalServiceAddress, address.isValid else { owsFailDebug("Missing or invalid address.") return } if contactThread.contactAddress == address { enqueueReloadWithoutCaches() } } else { // TODO: In groups, we could reload if any group member's profile changed. // Ideally we would only reload cells that use that member's profile state. } } @objc private func didLearnRecipientAssociation(notification: Notification) { AssertIsOnMainThread() enqueueReloadWithoutCaches() } @objc private func skipContactAvatarBlurDidChange(notification: Notification) { guard let address = notification.userInfo?[OWSContactsManager.skipContactAvatarBlurAddressKey] as? SignalServiceAddress else { owsFailDebug("Missing address.") return } if let contactThread = thread as? TSContactThread { if contactThread.contactAddress == address { enqueueReloadWithoutCaches() } } else if let groupThread = thread as? TSGroupThread { if groupThread.groupMembership.allMembersOfAnyKind.contains(address) { enqueueReloadWithoutCaches() } } else { owsFailDebug("Invalid thread.") } } @objc private func skipGroupAvatarBlurDidChange(notification: Notification) { guard let groupUniqueId = notification.userInfo?[OWSContactsManager.skipGroupAvatarBlurGroupUniqueIdKey] as? String else { owsFailDebug("Missing groupId.") return } guard let groupThread = thread as? TSGroupThread, groupThread.uniqueId == groupUniqueId else { return } enqueueReloadWithoutCaches() } @objc private func chatColorsDidChange(_ notification: NSNotification) { guard notification.object == nil || (notification.object as? String) == thread.uniqueId else { return } delegate?.chatColorDidChange() } // MARK: - public var canLoadOlderItems: Bool { renderState.canLoadOlderItems } public var canLoadNewerItems: Bool { renderState.canLoadNewerItems } // MARK: - Load Requests // This property should only be accessed on the main thread. private var loadRequestBuilder = CVLoadRequest.Builder() private var isBuildingLoad = false private let autoLoadMoreThreshold: TimeInterval = 2 * kSecondInterval private var lastLoadOlderDate: Date? public var didLoadOlderRecently: Bool { AssertIsOnMainThread() guard let lastLoadOlderDate = lastLoadOlderDate else { return false } return abs(lastLoadOlderDate.timeIntervalSinceNow) < autoLoadMoreThreshold } private var lastLoadNewerDate: Date? public var didLoadNewerRecently: Bool { AssertIsOnMainThread() guard let lastLoadNewerDate = lastLoadNewerDate else { return false } return abs(lastLoadNewerDate.timeIntervalSinceNow) < autoLoadMoreThreshold } private func loadInitialMapping(focusMessageIdOnOpen: String?) { owsAssertDebug(renderState.isEmptyInitialState) loadRequestBuilder.loadInitialMapping(focusMessageIdOnOpen: focusMessageIdOnOpen) loadIfNecessary() } public func loadOlderItems() { guard !renderState.isEmptyInitialState else { return } loadRequestBuilder.loadOlderItems() loadIfNecessary() } public func loadNewerItems() { guard !renderState.isEmptyInitialState else { return } loadRequestBuilder.loadNewerItems() loadIfNecessary() } public func loadAndScrollToNewestItems(isAnimated: Bool) { loadRequestBuilder.loadAndScrollToNewestItems(isAnimated: isAnimated) loadIfNecessary() } public func enqueueReload() { loadRequestBuilder.reload() loadIfNecessary() } public func enqueueReload(scrollAction: CVScrollAction) { loadRequestBuilder.reload(scrollAction: scrollAction) loadIfNecessary() } public func enqueueReload(updatedInteractionIds: Set, deletedInteractionIds: Set) { AssertIsOnMainThread() loadRequestBuilder.reload(updatedInteractionIds: updatedInteractionIds, deletedInteractionIds: deletedInteractionIds) loadIfNecessary() } public func enqueueLoadAndScrollToInteraction(interactionId: String, onScreenPercentage: CGFloat, alignment: ScrollAlignment, isAnimated: Bool) { AssertIsOnMainThread() loadRequestBuilder.loadAndScrollToInteraction(interactionId: interactionId, onScreenPercentage: onScreenPercentage, alignment: alignment, isAnimated: isAnimated) loadIfNecessary() } public func enqueueReloadWithoutCaches() { AssertIsOnMainThread() loadRequestBuilder.reloadWithoutCaches() loadIfNecessary() } public func enqueueReload(canReuseInteractionModels: Bool, canReuseComponentStates: Bool) { AssertIsOnMainThread() loadRequestBuilder.reload(canReuseInteractionModels: canReuseInteractionModels, canReuseComponentStates: canReuseComponentStates) loadIfNecessary() } // MARK: - Conversation Style public func updateConversationStyle(_ conversationStyle: ConversationStyle) { AssertIsOnMainThread() self.conversationStyle = conversationStyle // We need to kick off a reload cycle if conversationStyle changes. enqueueReload(canReuseInteractionModels: true, canReuseComponentStates: false) } // MARK: - Unread Indicator func clearUnreadMessagesIndicator() { AssertIsOnMainThread() guard oldestUnreadMessageSortId != nil else { return } oldestUnreadMessageSortId = nil enqueueReload() } // MARK: - func resetClearedUnreadMessagesIndicator() { AssertIsOnMainThread() // TODO: Implement this method correctly by allowing the unread indicator // to be shown past initial load so we don't mark all messages as read on // foreground if we have a chat open. } // MARK: - private func loadIfNecessary() { AssertIsOnMainThread() loadIfNecessaryEvent.requestNotify() } private lazy var loadIfNecessaryEvent: DebouncedEvent = { DebouncedEvents.build(mode: .lastOnly, maxFrequencySeconds: DebouncedEvents.thetaInterval, onQueue: .asyncOnQueue(queue: .main)) { [weak self] in AssertIsOnMainThread() self?.loadIfNecessaryDebounced() } }() private func loadIfNecessaryDebounced() { AssertIsOnMainThread() let conversationStyle = self.conversationStyle let spoilerState = self.spoilerState guard conversationStyle.viewWidth > 0 else { Logger.info("viewWidth not yet set.") return } guard !isBuildingLoad, let loadRequest = loadRequestBuilder.build() else { return } isBuildingLoad = true loadRequestBuilder = CVLoadRequest.Builder() // We should do an "initial" load IFF this is our first load. owsAssertDebug(loadRequest.isInitialLoad == renderState.isEmptyInitialState) let prevRenderState = renderState if loadRequest.loadType == .loadOlder { lastLoadOlderDate = Date() } else if loadRequest.loadType == .loadNewer { lastLoadNewerDate = Date() } let typingIndicatorsSender = typingIndicatorsImpl.typingAddress(forThread: thread) let viewStateSnapshot = CVViewStateSnapshot.snapshot( viewState: viewState, typingIndicatorsSender: typingIndicatorsSender, oldestUnreadMessageSortId: oldestUnreadMessageSortId, previousViewStateSnapshot: prevRenderState.viewStateSnapshot ) let loader = CVLoader( threadUniqueId: threadUniqueId, loadRequest: loadRequest, viewStateSnapshot: viewStateSnapshot, spoilerState: spoilerState, prevRenderState: prevRenderState, messageLoader: messageLoader ) firstly { loader.loadPromise() }.then(on: DispatchQueue.main) { [weak self] (update: CVUpdate) -> Promise in guard let self else { return .value(()) } return self.loadLandWhenSafePromise(update: update) }.ensure(on: DispatchQueue.main) { [weak self] in guard let self else { return } owsAssertDebug(self.isBuildingLoad) self.isBuildingLoad = false // Initiate new load if necessary. self.loadIfNecessary() }.catch(on: DispatchQueue.main) { error in owsFailDebug("Load failed[\(loadRequest.requestId)]: \(error)") } } // MARK: - Safe Landing // Lands the load when it is safe, blocking on animations, // previous loads landing, etc. private func loadLandWhenSafePromise(update: CVUpdate) -> Promise { AssertIsOnMainThread() let (loadPromise, loadFuture) = Promise.pending() loadLandWhenSafe(update: update, loadFuture: loadFuture) return loadPromise } private func loadLandWhenSafe(update: CVUpdate, loadFuture: Future) { guard let delegate = self.delegate else { loadFuture.reject(OWSGenericError("Missing delegate.")) return } func canLandLoad() -> Bool { AssertIsOnMainThread() // Allow multi selection animation load to land, even if keyboard is animating. if let lastKeyboardAnimationDate = viewState.lastKeyboardAnimationDate, lastKeyboardAnimationDate.isAfterNow, viewState.selectionAnimationState != .willAnimate { return false } guard viewState.selectionAnimationState != .animating else { return false } if let interaction = viewState.collectionViewActiveContextMenuInteraction, interaction.contextMenuVisible { return false } guard !delegate.areCellsAnimating else { return false } return true } let loadRequest = update.loadRequest guard canLandLoad() else { // We wait in a pretty tight loop to ensure loads land in a timely way. // // DispatchQueue.asyncAfter() will take longer to perform // its block than DispatchQueue.async() if the CPU is under // heavy load. That's desirable in this case. DispatchQueue.main.asyncAfter(deadline: .now() + 0.001) { [weak self] in guard let self else { loadFuture.resolve() return } self.loadLandWhenSafe(update: update, loadFuture: loadFuture) } return } let renderState = update.renderState let updateToken = delegate.willUpdateWithNewRenderState(renderState) self.renderState = renderState delegate.updateWithNewRenderState(update: update, scrollAction: loadRequest.scrollAction, updateToken: updateToken) loadFuture.resolve() } } // MARK: - extension CVLoadCoordinator: DatabaseChangeDelegate { public func databaseChangesDidUpdate(databaseChanges: DatabaseChanges) { AssertIsOnMainThread() owsAssertDebug(AppReadiness.isAppReady) guard databaseChanges.threadUniqueIds.contains(threadUniqueId) else { return } enqueueReload(updatedInteractionIds: databaseChanges.interactionUniqueIds, deletedInteractionIds: databaseChanges.interactionDeletedUniqueIds) } public func databaseChangesDidUpdateExternally() { AssertIsOnMainThread() owsAssertDebug(AppReadiness.isAppReady) enqueueReloadWithoutCaches() } public func databaseChangesDidReset() { AssertIsOnMainThread() owsAssertDebug(AppReadiness.isAppReady) enqueueReloadWithoutCaches() } } // MARK: - extension CVLoadCoordinator: UICollectionViewDataSource { public static let messageSection: Int = 0 public var renderItems: [CVRenderItem] { AssertIsOnMainThread() return shouldHideCollectionViewContent ? [] : renderState.items } public var renderStateId: UInt { return shouldHideCollectionViewContent ? CVRenderState.renderStateId_unknown : renderState.renderStateId } var allIndexPaths: [IndexPath] { AssertIsOnMainThread() return renderState.allIndexPaths } public func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection sectionIdx: Int) -> Int { owsAssertDebug(sectionIdx == Self.messageSection) return renderItems.count } public func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { owsAssertDebug(indexPath.section == Self.messageSection) guard let componentDelegate = self.componentDelegate else { owsFailDebug("Missing componentDelegate.") return UICollectionViewCell() } guard let renderItem = renderItems[safe: indexPath.row] else { owsFailDebug("Missing renderItem.") return UICollectionViewCell() } let cellReuseIdentifier = renderItem.cellReuseIdentifier guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: cellReuseIdentifier, for: indexPath) as? CVCell else { owsFailDebug("Missing cell.") return UICollectionViewCell() } guard let delegate = delegate else { owsFailDebug("Missing delegate.") return UICollectionViewCell() } let messageSwipeActionState = delegate.viewState.messageSwipeActionState cell.configure(renderItem: renderItem, componentDelegate: componentDelegate, messageSwipeActionState: messageSwipeActionState) return cell // // This must happen after load for display, since the tap // // gesture doesn't get added to a view until this point. // if ([cell isKindOfClass:[OWSMessageCell class]]) { // OWSMessageCell *messageCell = (OWSMessageCell *)cell; // [self.tapGestureRecognizer requireGestureRecognizerToFail:messageCell.messageViewTapGestureRecognizer]; // [self.tapGestureRecognizer requireGestureRecognizerToFail:messageCell.contentViewTapGestureRecognizer]; // // [messageCell.messageViewTapGestureRecognizer requireGestureRecognizerToFail:self.panGestureRecognizer]; // [messageCell.contentViewTapGestureRecognizer requireGestureRecognizerToFail:self.panGestureRecognizer]; // } // // #ifdef DEBUG // // TODO: Confirm with nancy if this will work. // NSString *cellName = [NSString stringWithFormat:@"interaction.%@", NSUUID.UUID.UUIDString]; // if (viewItem.hasBodyText && viewItem.displayableBodyText.displayAttributedText.length > 0) { // NSString *textForId = // [viewItem.displayableBodyText.displayAttributedText.string stringByReplacingOccurrencesOfString:@" " // withString:@"_"]; // cellName = [NSString stringWithFormat:@"message.text.%@", textForId]; // } else if (viewItem.stickerInfo) { // cellName = [NSString stringWithFormat:@"message.sticker.%@", [viewItem.stickerInfo asKey]]; // } // cell.accessibilityIdentifier = "CVLoadCoordinator.\(cellName)" // #endif // // return cell; } public func collectionView(_ collectionView: UICollectionView, viewForSupplementaryElementOfKind kind: String, at indexPath: IndexPath) -> UICollectionReusableView { guard kind == UICollectionView.elementKindSectionHeader || kind == UICollectionView.elementKindSectionFooter else { owsFailDebug("unexpected supplementaryElement: \(kind)") return UICollectionReusableView() } guard let loadMoreView = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: LoadMoreMessagesView.reuseIdentifier, for: indexPath) as? LoadMoreMessagesView else { owsFailDebug("Couldn't load supplementary view: \(kind)") return UICollectionReusableView() } loadMoreView.configureForDisplay() return loadMoreView } public var indexPathOfUnreadIndicator: IndexPath? { renderState.indexPathOfUnreadIndicator } public func indexPath(forInteractionUniqueId interactionUniqueId: String) -> IndexPath? { renderState.indexPath(forInteractionUniqueId: interactionUniqueId) } } // MARK: - extension CVLoadCoordinator: UICollectionViewDelegate { public func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { guard let cell = cell as? CVItemCell else { owsFailDebug("Unexpected cell type.") return } cell.isCellVisible = true let delegate = self.delegate DispatchQueue.main.async { delegate?.updateScrollingContent() delegate?.updateAccessibilityCustomActionsForCell(cell) } } public func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { guard let cell = cell as? CVItemCell else { owsFailDebug("Unexpected cell type.") return } cell.isCellVisible = false delegate?.updateScrollingContent() } } // MARK: - extension CVLoadCoordinator: ConversationViewLayoutDelegate { public var layoutItems: [ConversationViewLayoutItem] { renderItems } public var showLoadOlderHeader: Bool { // We need to have at least one item to hang the supplementary view on. return canLoadOlderItems && !renderItems.isEmpty } public var showLoadNewerHeader: Bool { // We need to have at least one item to hang the supplementary view on. // // We could show both the "load older" and "load newer" items. If so we // need two items to hang the supplementary views on. let minItemCount = showLoadOlderHeader ? 2 : 1 return canLoadNewerItems && renderItems.count >= minItemCount } public var layoutHeaderHeight: CGFloat { showLoadOlderHeader ? LoadMoreMessagesView.fixedHeight : 0 } public var layoutFooterHeight: CGFloat { showLoadNewerHeader ? LoadMoreMessagesView.fixedHeight : 0 } public var conversationViewController: ConversationViewController? { guard let delegate = self.delegate else { owsFailDebug("Missing delegate.") return nil } return delegate.conversationViewController } } // MARK: - extension CVLoadCoordinator: UIScrollViewDelegate { public func scrollViewDidScroll(_ scrollView: UIScrollView) { delegate?.scrollViewDidScroll?(scrollView) } public func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { delegate?.scrollViewWillBeginDragging?(scrollView) } public func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { delegate?.scrollViewDidEndDragging?(scrollView, willDecelerate: decelerate) } public func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { delegate?.scrollViewDidEndDecelerating?(scrollView) } public func scrollViewShouldScrollToTop(_ scrollView: UIScrollView) -> Bool { delegate?.scrollViewShouldScrollToTop?(scrollView) ?? false } public func scrollViewDidScrollToTop(_ scrollView: UIScrollView) { delegate?.scrollViewDidScrollToTop?(scrollView) } public func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { delegate?.scrollViewDidEndScrollingAnimation?(scrollView) } } // MARK: - extension CVLoadCoordinator: CallServiceStateObserver { private func doesGroupThreadCall(_ signalCall: SignalCall?, matchThread thread: TSThread) -> Bool { switch signalCall?.mode { case .groupThread(let call): return call.groupThread.uniqueId == thread.uniqueId case nil, .individual, .callLink: return false } } func didUpdateCall(from oldValue: SignalCall?, to newValue: SignalCall?) { let matchesThread: Bool = ( doesGroupThreadCall(oldValue, matchThread: thread) || doesGroupThreadCall(newValue, matchThread: thread) ) guard matchesThread else { return } enqueueReload( canReuseInteractionModels: true, canReuseComponentStates: false ) } }