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)
}
}