Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/ConversationView/ConversationViewController+Scroll.swift
1 views
//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

public import SignalServiceKit
import SignalUI

public enum ScrollAlignment: Int {
    case top
    case bottom
    case center

    // These match the behavior of UICollectionView.ScrollPosition and
    // noop if the view is already entirely on screen.
    case topIfNotEntirelyOnScreen
    case bottomIfNotEntirelyOnScreen
    case centerIfNotEntirelyOnScreen

    var scrollsOnlyIfNotEntirelyOnScreen: Bool {
        switch self {
        case .top, .bottom, .center:
            return false
        case .topIfNotEntirelyOnScreen,
             .bottomIfNotEntirelyOnScreen,
             .centerIfNotEntirelyOnScreen:
            return true
        }
    }
}

// MARK: -

// TODO: Do we need to specify the load alignment (top, bottom, center)
// or that implicit in the value?
public struct CVScrollAction: Equatable, CustomStringConvertible {

    // TODO: Do we need to specify the load alignment (top, bottom, center)
    // or that implicit in the value?
    public enum Action: Equatable, CustomStringConvertible {
        case none
        case scrollTo(interactionId: String, onScreenPercentage: CGFloat, alignment: ScrollAlignment)
        case bottomOfLoadWindow
        case initialPosition
        case bottomForNewMessage

        // MARK: - CustomStringConvertible

        public var description: String {
            switch self {
            case .none:
                return "none"
            case .scrollTo(let interactionId, _, _):
                return "scrollTo(\(interactionId))"
            case .bottomOfLoadWindow:
                return "bottomOfLoadWindow"
            case .initialPosition:
                return "initialPosition"
            case .bottomForNewMessage:
                return "bottomForNewMessage"
            }
        }
    }

    let action: Action
    let isAnimated: Bool

    public static var none: CVScrollAction {
        CVScrollAction(action: .none, isAnimated: false)
    }

    // MARK: - CustomStringConvertible

    public var description: String {
        "[scrollAction: \(action), isAnimated: \(isAnimated)]"
    }
}

// MARK: -

extension ConversationViewController {

    func perform(scrollAction: CVScrollAction) {
        AssertIsOnMainThread()

        switch scrollAction.action {
        case .none:
            break
        case .scrollTo(let interactionId, let onScreenPercentage, let alignment):
            if let indexPath = self.indexPath(forInteractionUniqueId: interactionId) {
                viewState.highlightedMessageId = interactionId

                // TODO: Set position and animated.
                scrollToInteraction(
                    indexPath: indexPath,
                    interactionUniqueId: interactionId,
                    onScreenPercentage: onScreenPercentage,
                    alignment: alignment,
                    animated: scrollAction.isAnimated,
                )
            } else {
                owsFailDebug("Could not locate interaction.")
            }
        case .bottomOfLoadWindow, .bottomForNewMessage:
            scrollToBottomOfLoadWindow(animated: scrollAction.isAnimated)
        case .initialPosition:
            scrollToInitialPosition(animated: scrollAction.isAnimated)
        }
    }

    func scrollToTopOfLoadWindow(animated: Bool) {
        guard let interactionId = renderItems.first?.interactionUniqueId else {
            return
        }
        scrollToInteraction(uniqueId: interactionId, alignment: .top, animated: animated)
    }

    func scrollToBottomOfLoadWindow(animated: Bool) {
        let newContentOffset = CGPoint(x: 0, y: maxContentOffsetY)
        collectionView.setContentOffset(newContentOffset, animated: animated)
    }

    func scrollToInitialPosition(animated: Bool) {

        guard loadCoordinator.hasRenderState else {
            // TODO: We should scroll to default position after first load completes.
            return
        }

        guard let initialScrollState else {
            owsAssertDebug(hasViewDidAppearEverBegun)
            return
        }

        // TODO: Should we load any of these interactions before we scroll?
        if let focusMessageId = initialScrollState.focusMessageId {
            if focusMessageId == lastVisibleInteractionWithSneakyTransaction()?.uniqueId {
                scrollToLastVisibleInteraction(animated: animated)
                return
            } else if let indexPath = indexPath(forInteractionUniqueId: focusMessageId) {
                scrollToInteraction(
                    indexPath: indexPath,
                    interactionUniqueId: focusMessageId,
                    alignment: .top,
                    animated: animated,
                )
                return
            } else if hasRenderState {
                owsFailDebug("focusMessageId not in the load window.")
            }
        }

        if let indexPath = indexPathOfUnreadMessagesIndicator {
            scrollToInteraction(
                indexPath: indexPath,
                interactionUniqueId: nil,
                alignment: .top,
                animated: animated,
            )
        } else {
            scrollToLastVisibleInteraction(animated: animated)
        }
    }

    // This method scrolls to the bottom of the _conversation_,
    // not the load window.
    func scrollToBottomOfConversation(animated: Bool) {
        if canLoadNewerItems {
            loadCoordinator.loadAndScrollToNewestItems(isAnimated: animated)
        } else {
            scrollToBottomOfLoadWindow(animated: animated)
        }
    }

    func scrollToLastVisibleInteraction(animated: Bool) {
        guard let lastVisibleInteraction = lastVisibleInteractionWithSneakyTransaction() else {
            return scrollToBottomOfConversation(animated: animated)
        }

        // IFF the lastVisibleInteraction is the last non-dynamic interaction in the thread,
        // we want to scroll to the bottom to also show any active typing indicators.
        if
            lastVisibleInteraction.sortId == lastSortIdInLoadedWindow,
            SSKEnvironment.shared.typingIndicatorsRef.typingAddress(forThread: thread) != nil
        {
            return scrollToBottomOfConversation(animated: animated)
        }

        guard
            let renderedId = safeUniqueIdForScrolling(interactionUniqueId: lastVisibleInteraction.uniqueId),
            let indexPath = indexPath(forInteractionUniqueId: renderedId)
        else {
            owsFailDebug("No index path for interaction, scrolling to bottom")
            scrollToBottomOfConversation(animated: animated)
            return
        }

        scrollToInteraction(
            indexPath: indexPath,
            interactionUniqueId: renderedId,
            onScreenPercentage: CGFloat(lastVisibleInteraction.onScreenPercentage),
            alignment: .bottom,
            animated: animated,
        )
    }

    func scrollToInteraction(
        uniqueId: String,
        onScreenPercentage: CGFloat = 1,
        alignment: ScrollAlignment,
        animated: Bool,
    ) {
        guard let indexPath = indexPath(forInteractionUniqueId: uniqueId) else {
            owsFailDebug("No index path for interaction, scrolling to bottom")
            return
        }
        scrollToInteraction(
            indexPath: indexPath,
            interactionUniqueId: uniqueId,
            onScreenPercentage: onScreenPercentage,
            alignment: alignment,
            animated: animated,
        )
    }

    func scrollToInteraction(
        indexPath: IndexPath,
        interactionUniqueId: String?,
        onScreenPercentage: CGFloat = 1,
        alignment: ScrollAlignment,
        animated: Bool = true,
    ) {
        guard !isUserScrolling else { return }

        view.layoutIfNeeded()

        guard let attributes = layout.layoutAttributesForItem(at: indexPath) else {
            return owsFailDebug("failed to get attributes for indexPath \(indexPath)")
        }

        viewState.focusedMessageId = interactionUniqueId

        let topInset = collectionView.adjustedContentInset.top
        let bottomInset = collectionView.adjustedContentInset.bottom
        let collectionViewHeightUnobscuredByBottomBar = collectionView.height - bottomInset

        let topDestinationY = topInset
        let bottomDestinationY = safeContentHeight - collectionViewHeightUnobscuredByBottomBar

        let currentMinimumVisibleOffset = collectionView.contentOffset.y + topInset
        let currentMaximumVisibleOffset = collectionView.contentOffset.y + collectionViewHeightUnobscuredByBottomBar

        let rowIsEntirelyOnScreen = attributes.frame.minY > currentMinimumVisibleOffset
            && attributes.frame.maxY < currentMaximumVisibleOffset

        // If the collection view contents aren't scrollable, do nothing.
        guard safeContentHeight > collectionViewHeightUnobscuredByBottomBar else {
            performMessageHighlightAnimationIfNeeded()
            focusVoiceoverElementAfterScroll()
            return
        }

        // If the destination row is entirely visible AND the desired position
        // is only valid for when the view is not on screen, do nothing.
        guard !alignment.scrollsOnlyIfNotEntirelyOnScreen || !rowIsEntirelyOnScreen else {
            performMessageHighlightAnimationIfNeeded()
            focusVoiceoverElementAfterScroll()
            return
        }

        guard indexPath != lastIndexPathInLoadedWindow || !onScreenPercentage.isEqual(to: 1) else {
            // If we're scrolling to the last index AND we want it entirely on screen,
            // scroll directly to the bottom regardless of the requested destination.
            let contentOffset = CGPoint(x: 0, y: bottomDestinationY)
            collectionView.setContentOffset(contentOffset, animated: animated)
            updateLastKnownDistanceFromBottom()
            focusVoiceoverElementAfterScroll()
            return
        }

        var destinationY: CGFloat

        switch alignment {
        case .top, .topIfNotEntirelyOnScreen:
            destinationY = attributes.frame.minY - topInset
            destinationY += attributes.frame.height * (1 - onScreenPercentage)
        case .bottom, .bottomIfNotEntirelyOnScreen:
            destinationY = attributes.frame.minY
            destinationY -= collectionViewHeightUnobscuredByBottomBar
            destinationY += attributes.frame.height * onScreenPercentage
        case .center, .centerIfNotEntirelyOnScreen:
            assert(onScreenPercentage.isEqual(to: 1))
            destinationY = attributes.frame.midY
            destinationY -= collectionView.height / 2
        }

        // If the target destination would cause us to scroll beyond
        // the top of the collection view, scroll to top
        if destinationY < topDestinationY { destinationY = topDestinationY }

        // If the target destination would cause us to scroll beyond
        // the bottom of the collection view, scroll to bottom
        else if destinationY > bottomDestinationY { destinationY = bottomDestinationY }

        let contentOffset = CGPoint(x: 0, y: destinationY)
        collectionView.setContentOffset(contentOffset, animated: animated)
        updateLastKnownDistanceFromBottom()
    }

    func scrollToQuotedMessage(_ quotedReply: QuotedReplyModel, isAnimated: Bool) {
        if quotedReply.sourceOfOriginal == .remote {
            presentRemotelySourcedQuotedReplyToast()
            return
        }
        let quotedMessage: TSMessage?
        if let timestamp = quotedReply.originalMessageTimestamp {
            quotedMessage = SSKEnvironment.shared.databaseStorageRef.read { transaction in
                InteractionFinder.findMessage(
                    withTimestamp: timestamp,
                    threadId: self.thread.uniqueId,
                    author: quotedReply.originalMessageAuthorAddress,
                    transaction: transaction,
                )
            }
        } else {
            quotedMessage = nil
        }
        if let quotedMessage {
            if quotedMessage.wasRemotelyDeleted {
                presentMissingQuotedReplyToast()
                return
            }

            let targetUniqueId: String
            switch quotedMessage.editState {
            case .latestRevisionRead, .latestRevisionUnread, .none:
                targetUniqueId = quotedMessage.uniqueId
            case .pastRevision:
                // If this is an older edit revision, find the current
                // edit and use that uniqueId instead of the old one.
                let currentEdit = SSKEnvironment.shared.databaseStorageRef.read { transaction in
                    DependenciesBridge.shared.editMessageStore.findMessage(
                        fromEdit: quotedMessage,
                        tx: transaction,
                    )
                }
                if let currentEdit {
                    targetUniqueId = currentEdit.uniqueId
                } else {
                    owsFailDebug("Couldn't find original edit")
                    return
                }
            }

            ensureInteractionLoadedThenScrollToInteraction(
                targetUniqueId,
                alignment: .centerIfNotEntirelyOnScreen,
                isAnimated: isAnimated,
            )
        }
    }

    func ensureInteractionLoadedThenScrollToInteraction(
        _ interactionId: String,
        onScreenPercentage: CGFloat = 1,
        alignment: ScrollAlignment,
        isAnimated: Bool = true,
    ) {
        if let indexPath = self.indexPath(forInteractionUniqueId: interactionId) {
            viewState.highlightedMessageId = interactionId
            scrollToInteraction(
                indexPath: indexPath,
                interactionUniqueId: interactionId,
                onScreenPercentage: onScreenPercentage,
                alignment: alignment,
                animated: isAnimated,
            )
        } else {
            expandCollapseSetContaining(interactionId: interactionId)
            loadCoordinator.enqueueLoadAndScrollToInteraction(
                interactionId: interactionId,
                onScreenPercentage: onScreenPercentage,
                alignment: alignment,
                isAnimated: isAnimated,
            )
        }
    }

    /// Finds the uniqueId of the rendered item representing the given interaction.
    /// If the interaction is inside a CollapseSetInteraction, returns the set's uniqueId.
    private func safeUniqueIdForScrolling(interactionUniqueId: String) -> String? {
        if indexPath(forInteractionUniqueId: interactionUniqueId) != nil {
            return interactionUniqueId
        }
        return renderState.collapseSetUniqueId(forCollapsedInteractionId: interactionUniqueId)
    }

    private func expandCollapseSetContaining(interactionId: String) {
        guard let parentUniqueId = renderState.collapseSetUniqueId(forCollapsedInteractionId: interactionId) else { return }
        viewState.expandedCollapseSets.insert(parentUniqueId)
        loadCoordinator.enqueueReload()
    }

    func setScrollActionForSizeTransition() {
        AssertIsOnMainThread()

        owsAssertDebug(viewState.scrollActionForSizeTransition == nil)

        viewState.scrollActionForSizeTransition = {
            if self.isScrolledToBottom {
                return CVScrollAction(action: .bottomOfLoadWindow, isAnimated: false)
            }
            guard let lastVisibleInteraction = lastVisibleInteractionWithSneakyTransaction() else {
                return CVScrollAction(action: .bottomOfLoadWindow, isAnimated: false)
            }
            // IFF the lastVisibleInteraction is the last non-dynamic interaction in the thread,
            // we want to scroll to the bottom to also show any active typing indicators.
            if
                lastVisibleInteraction.sortId == lastSortIdInLoadedWindow,
                SSKEnvironment.shared.typingIndicatorsRef.typingAddress(forThread: thread) != nil
            {
                return CVScrollAction(action: .bottomOfLoadWindow, isAnimated: false)
            }
            if
                let lastKnownDistanceFromBottom = self.lastKnownDistanceFromBottom,
                lastKnownDistanceFromBottom < 50
            {
                return CVScrollAction(action: .bottomOfLoadWindow, isAnimated: false)
            }

            let renderedId = safeUniqueIdForScrolling(
                interactionUniqueId: lastVisibleInteraction.uniqueId,
            ) ?? lastVisibleInteraction.uniqueId
            return CVScrollAction(
                action: .scrollTo(
                    interactionId: renderedId,
                    onScreenPercentage: lastVisibleInteraction.onScreenPercentage,
                    alignment: .bottom,
                ),
                isAnimated: false,
            )
        }()
    }

    func clearScrollActionForSizeTransition() {
        AssertIsOnMainThread()

        owsAssertDebug(viewState.scrollActionForSizeTransition != nil)
        if let scrollAction = viewState.scrollActionForSizeTransition {
            owsAssertDebug(!scrollAction.isAnimated)
            perform(scrollAction: scrollAction)
        }
        viewState.scrollActionForSizeTransition = nil
    }

    @objc
    func scrollDownButtonTapped() {
        AssertIsOnMainThread()

        // TODO: I'm not sure this will do the right thing if there's an unread indicator
        // below current scroll position but outside the load window, e.g. if we entered
        // the conversation view a search result.
        if let indexPathOfUnreadMessagesIndicator = self.indexPathOfUnreadMessagesIndicator {
            let unreadRow = indexPathOfUnreadMessagesIndicator.row

            var isScrolledAboveUnreadIndicator = true
            let visibleIndices = collectionView.indexPathsForVisibleItems
            for indexPath in visibleIndices {
                if indexPath.row > unreadRow {
                    isScrolledAboveUnreadIndicator = false
                    break
                }
            }

            if isScrolledAboveUnreadIndicator {
                // Only scroll as far as the unread indicator if we're scrolled above the unread indicator.
                scrollToInteraction(
                    indexPath: indexPathOfUnreadMessagesIndicator,
                    interactionUniqueId: nil,
                    onScreenPercentage: 1,
                    alignment: .top,
                    animated: true,
                )
                return
            }
        }

        scrollToBottomOfConversation(animated: true)
    }

    public func recordInitialScrollState(_ focusMessageId: String?) {
        initialScrollState = CVInitialScrollState(focusMessageId: focusMessageId)
    }

    public func clearInitialScrollState() {
        initialScrollState = nil
    }

    @objc
    func scrollToNextMentionButtonTapped() {
        if let nextMessageId = conversationViewModel.unreadMentionMessageIds.first {
            ensureInteractionLoadedThenScrollToInteraction(
                nextMessageId,
                alignment: .bottomIfNotEntirelyOnScreen,
                isAnimated: true,
            )
        }
    }

    @discardableResult
    func updateLastKnownDistanceFromBottom() -> CGFloat? {
        guard hasAppearedAndHasAppliedFirstLoad else {
            return nil
        }

        let lastKnownDistanceFromBottom = self.safeDistanceFromBottom
        self.lastKnownDistanceFromBottom = lastKnownDistanceFromBottom
        return lastKnownDistanceFromBottom
    }

    // We use this hook to ensure scroll state continuity.  As the collection
    // view's content size changes, we want to keep the same cells in view.
    func contentOffset(forLastKnownDistanceFromBottom distanceFromBottom: CGFloat) -> CGPoint {
        // Adjust the content offset to reflect the "last known" distance
        // from the bottom of the content.
        let contentOffsetYBottom = maxContentOffsetY
        var contentOffsetY = contentOffsetYBottom - max(0, distanceFromBottom)
        let minContentOffsetY = -collectionView.safeAreaInsets.top
        contentOffsetY = max(minContentOffsetY, contentOffsetY)
        return CGPoint(x: 0, y: contentOffsetY)
    }

    var isScrolledToBottom: Bool {
        isScrolledToBottom(tolerancePoints: 5)
    }

    func isScrolledToBottom(tolerancePoints: CGFloat) -> Bool {
        safeDistanceFromBottom <= tolerancePoints
    }

    func isScrolledToTop(tolerancePoints: CGFloat) -> Bool {
        safeDistanceFromTop <= tolerancePoints
    }

    public var safeDistanceFromTop: CGFloat {
        collectionView.contentOffset.y - minContentOffsetY
    }

    public var safeDistanceFromBottom: CGFloat {
        // This is a bit subtle.
        //
        // The _wrong_ way to determine if we're scrolled to the bottom is to
        // measure whether the collection view's content is "near" the bottom edge
        // of the collection view.  This is wrong because the collection view
        // might not have enough content to fill the collection view's bounds
        // _under certain conditions_ (e.g. with the keyboard dismissed).
        //
        // What we're really interested in is something a bit more subtle:
        // "Is the scroll view scrolled down as far as it can, "at rest".
        //
        // To determine that, we find the appropriate "content offset y" if
        // the scroll view were scrolled down as far as possible.  IFF the
        // actual "content offset y" is "near" that value, we return YES.
        maxContentOffsetY - collectionView.contentOffset.y
    }

    // The lowest valid content offset when the view is at rest.
    private var minContentOffsetY: CGFloat {
        -collectionView.adjustedContentInset.top
    }

    // The highest valid content offset when the view is at rest.
    var maxContentOffsetY: CGFloat {
        let contentHeight = self.safeContentHeight
        let adjustedContentInset = collectionView.adjustedContentInset
        let rawValue = contentHeight + adjustedContentInset.bottom - collectionView.bounds.size.height
        // Note the usage of MAX() to handle the case where there isn't enough
        // content to fill the collection view at its current size.
        let clampedValue = max(minContentOffsetY, rawValue)
        return clampedValue
    }

    // We use this hook to ensure scroll state continuity.  As the collection
    // view's content size changes, we want to keep the same cells in view.
    public func targetContentOffset(
        forProposedContentOffset proposedContentOffset: CGPoint,
        lastKnownDistanceFromBottom: CGFloat?,
    ) -> CGPoint {
        // TODO: Consider handling these transitions using a scroll
        // continuity token.
        if let contentOffset = targetContentOffsetForSizeTransition() {
            return contentOffset
        }

        // TODO: Consider handling these transitions using a scroll
        // continuity token.
        if let contentOffset = targetContentOffsetForUpdate() {
            return contentOffset
        }

        // TODO: Can we improve this case?
        if let contentOffset = targetContentOffsetForBottom(lastKnownDistanceFromBottom: lastKnownDistanceFromBottom) {
            return contentOffset
        }

        return proposedContentOffset
    }

    var shouldUseDelegateScrollContinuity: Bool {
        if
            let scrollAction = viewState.scrollActionForSizeTransition,
            scrollAction != .none
        {
            return true
        }
        if let scrollAction = viewState.scrollActionForUpdate {
            switch scrollAction.action {
            case .bottomOfLoadWindow, .scrollTo:
                if !scrollAction.isAnimated {
                    return true
                }
            case .bottomForNewMessage:
                return true
            default:
                break
            }
        }
        return false
    }

    private func targetContentOffsetForBottom(lastKnownDistanceFromBottom: CGFloat?) -> CGPoint? {
        guard let lastKnownDistanceFromBottom = self.lastKnownDistanceFromBottom else {
            return nil
        }

        let contentOffset = self.contentOffset(forLastKnownDistanceFromBottom: lastKnownDistanceFromBottom)
        return contentOffset
    }

    private func targetContentOffsetForSizeTransition() -> CGPoint? {
        guard let scrollAction = viewState.scrollActionForSizeTransition else {
            return nil
        }
        owsAssertDebug(!scrollAction.isAnimated)
        return targetContentOffsetForScrollAction(scrollAction)
    }

    private func targetContentOffsetForUpdate() -> CGPoint? {
        guard let scrollAction = viewState.scrollActionForUpdate else {
            return nil
        }
        guard scrollAction.action != .none, !scrollAction.isAnimated else {
            return nil
        }
        return targetContentOffsetForScrollAction(scrollAction)
    }

    private func targetContentOffsetForScrollAction(_ scrollAction: CVScrollAction) -> CGPoint? {
        owsAssertDebug(!scrollAction.isAnimated)

        switch scrollAction.action {
        case .bottomOfLoadWindow, .bottomForNewMessage:
            let minContentOffsetY = -collectionView.safeAreaInsets.top
            var contentOffset = self.contentOffset(forLastKnownDistanceFromBottom: 0)
            contentOffset.y = max(minContentOffsetY, contentOffset.y)
            return contentOffset
        case .scrollTo(let referenceUniqueId, let onScreenPercentage, _):

            // Start with a content offset for being scrolled to the bottom.
            var contentOffset = self.contentOffset(forLastKnownDistanceFromBottom: 0)

            guard let referenceIndexPath = indexPath(forInteractionUniqueId: referenceUniqueId) else {
                owsFailDebug("Missing referenceIndexPath.")
                return nil
            }
            guard let referenceLayoutAttributes = layout.layoutAttributesForItem(at: referenceIndexPath) else {
                owsFailDebug("Missing layoutAttributes.")
                return nil
            }

            // Adjust content offset to reflect onScreenPercentage.
            let onScreenAlpha = (1 - onScreenPercentage).clamp01()
            contentOffset.y -= referenceLayoutAttributes.frame.height * onScreenAlpha

            if
                let lastIndexPath = allIndexPaths.last,
                let lastLayoutAttributes = layout.layoutAttributesForItem(at: lastIndexPath)
            {
                // Only offset if the reference interaction is not last.
                if lastIndexPath != referenceIndexPath {
                    owsAssertDebug(lastLayoutAttributes.frame.maxY > referenceLayoutAttributes.frame.maxY)
                    let distanceToLastInteraction = (
                        lastLayoutAttributes.frame.maxY -
                            referenceLayoutAttributes.frame.maxY,
                    )
                    contentOffset.y -= distanceToLastInteraction
                }
            } else {
                owsFailDebug("Missing lastIndexPath.")
            }

            let minContentOffsetY = -collectionView.safeAreaInsets.top
            contentOffset.y = max(minContentOffsetY, contentOffset.y)

            return contentOffset
        default:
            owsFailDebug("Invalid scroll action: \(scrollAction.description)")
            return nil
        }
    }

    // MARK: -

    private struct LastVisibleInteraction {
        let interaction: TSInteraction
        let onScreenPercentage: CGFloat

        var sortId: UInt64 { interaction.sortId }
        var uniqueId: String { interaction.uniqueId }
    }

    public static func lastVisibleInteractionId(for thread: TSThread, tx: DBReadTransaction) -> String? {
        return lastVisibleInteraction(for: thread, tx: tx)?.uniqueId
    }

    private func lastVisibleInteractionWithSneakyTransaction() -> LastVisibleInteraction? {
        return SSKEnvironment.shared.databaseStorageRef.read { tx in Self.lastVisibleInteraction(for: thread, tx: tx) }
    }

    public func focusInitialVoiceoverElement() {
        let focusIndexPath: IndexPath
        if let _indexPath = indexPathOfUnreadMessagesIndicator {
            focusIndexPath = _indexPath
        } else if
            let lastVisibleInteraction = lastVisibleInteractionWithSneakyTransaction(),
            let _indexPath = indexPath(forInteractionUniqueId: lastVisibleInteraction.uniqueId)
        {
            focusIndexPath = _indexPath
        } else {
            return
        }

        if
            let cell = self.collectionView.cellForItem(at: focusIndexPath) as? CVCell,
            let componentView = cell.componentView
        {
            UIAccessibility.post(
                notification: .screenChanged,
                argument: componentView.rootView,
            )
        }
    }

    public func focusVoiceoverElementAfterScroll() {
        if
            let scrolledMessageId = viewState.focusedMessageId,
            let scrolledMessageIndexPath = indexPath(forInteractionUniqueId: scrolledMessageId),
            let cell = self.collectionView.cellForItem(at: scrolledMessageIndexPath) as? CVCell,
            let componentView = cell.componentView
        {
            UIAccessibility.post(
                notification: .screenChanged,
                argument: componentView.rootView,
            )
            viewState.focusedMessageId = nil
        }
    }

    private static func lastVisibleInteraction(for thread: TSThread, tx: DBReadTransaction) -> LastVisibleInteraction? {
        guard
            let lastVisibleInteraction = DependenciesBridge.shared.lastVisibleInteractionStore
                .lastVisibleInteraction(for: thread, tx: tx),
            let interaction = thread.firstInteraction(atOrAroundSortId: lastVisibleInteraction.sortId, transaction: tx)
        else {
            return nil
        }
        let onScreenPercentage = lastVisibleInteraction.onScreenPercentage
        return LastVisibleInteraction(interaction: interaction, onScreenPercentage: onScreenPercentage)
    }
}