Improve handling of many large collapse sets

This commit is contained in:
Elaine 2026-06-08 20:17:12 -04:00 committed by GitHub
parent 82ce1ead86
commit 800a5cc0bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 769 additions and 309 deletions

View File

@ -148,15 +148,7 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
componentView.outerStack.isAccessibilityElement = true
componentView.outerStack.accessibilityLabel = titleString
componentView.outerStack.accessibilityTraits = .button
componentView.outerStack.accessibilityHint = collapseSet.isExpanded
? OWSLocalizedString(
"COLLAPSE_SET_ACCESSIBILITY_HINT_COLLAPSE",
comment: "VoiceOver hint for an expanded collapse set button.",
)
: OWSLocalizedString(
"COLLAPSE_SET_ACCESSIBILITY_HINT_EXPAND",
comment: "VoiceOver hint for a collapsed collapse set button.",
)
componentView.outerStack.accessibilityHint = accessibilityHint(isExpanded: collapseSet.isExpanded)
}
// MARK: - Events
@ -187,6 +179,7 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
componentView.chevronLabel.transform = willBeExpanded
? CGAffineTransform(rotationAngle: expandedRotation)
: .identity
componentView.outerStack.accessibilityHint = accessibilityHint(isExpanded: willBeExpanded)
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
animation.fromValue = fromAngle
@ -347,6 +340,18 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
)
}
private func accessibilityHint(isExpanded: Bool) -> String {
isExpanded
? OWSLocalizedString(
"COLLAPSE_SET_ACCESSIBILITY_HINT_COLLAPSE",
comment: "VoiceOver hint for an expanded collapse set button.",
)
: OWSLocalizedString(
"COLLAPSE_SET_ACCESSIBILITY_HINT_EXPAND",
comment: "VoiceOver hint for a collapsed collapse set button.",
)
}
private func summaryLabel(
count: Int,
type: CollapseSetInteraction.MessagesType,

View File

@ -528,7 +528,6 @@ public struct CVComponentState: Equatable {
static func ==(lhs: CollapseSet, rhs: CollapseSet) -> Bool {
return lhs.collapsedInteractions.map(\.uniqueId) == rhs.collapsedInteractions.map(\.uniqueId)
&& lhs.collapseSetType == rhs.collapseSetType
&& lhs.isExpanded == rhs.isExpanded
&& lhs.finalTimerDescription == rhs.finalTimerDescription
}
}
@ -1197,7 +1196,7 @@ private extension CVComponentState.Builder {
self.collapseSet = CVComponentState.CollapseSet(
collapsedInteractions: collapseSetInteraction.collapsedInteractions,
collapseSetType: collapseSetInteraction.collapseSetType,
isExpanded: collapseSetInteraction.isExpanded,
isExpanded: viewStateSnapshot.expandedCollapseSetIds.contains(collapseSetInteraction.uniqueId),
finalTimerDescription: collapseSetInteraction.finalTimerDescription,
)
return build()

View File

@ -33,6 +33,8 @@ extension ConversationViewController: CVComponentDelegate {
viewState.expandedCollapseSets.insert(collapseSetId)
}
loadCoordinator.enqueueReload(
updatedInteractionIds: [collapseSetId],
deletedInteractionIds: [],
preferredScrollContinuityAnchorInteractionId: collapseSetId,
)
}

View File

@ -5,7 +5,7 @@
import SignalServiceKit
class CollapseSetInteraction: TSInteraction {
final class CollapseSetInteraction: TSInteraction {
enum MessagesType: Equatable {
case groupUpdates
@ -18,8 +18,6 @@ class CollapseSetInteraction: TSInteraction {
let collapseSetType: MessagesType
let isExpanded: Bool
let finalTimerDescription: String?
override var isDynamicInteraction: Bool { true }
@ -32,12 +30,10 @@ class CollapseSetInteraction: TSInteraction {
thread: TSThread,
collapsedInteractions: [TSInteraction],
collapseSetType: MessagesType,
isExpanded: Bool = false,
) {
owsPrecondition(!collapsedInteractions.isEmpty)
self.collapsedInteractions = collapsedInteractions
self.collapseSetType = collapseSetType
self.isExpanded = isExpanded
self.finalTimerDescription = Self.disappearingTimerDescription(
for: collapsedInteractions,
type: collapseSetType,
@ -45,13 +41,17 @@ class CollapseSetInteraction: TSInteraction {
let firstInteraction = collapsedInteractions[0]
super.init(
customUniqueId: "CollapseSet_\(firstInteraction.timestamp)",
customUniqueId: Self.id(firstInteraction: firstInteraction),
timestamp: firstInteraction.timestamp,
receivedAtTimestamp: firstInteraction.receivedAtTimestamp,
thread: thread,
)
}
static func id(firstInteraction: TSInteraction) -> String {
"CollapseSet_\(firstInteraction.timestamp)"
}
private static func disappearingTimerDescription(
for interactions: [TSInteraction],
type: MessagesType,

View File

@ -427,6 +427,23 @@ public class CVLoadCoordinator: NSObject {
loadIfNecessary()
}
public func enqueueReload(
updatedInteractionIds: Set<String>,
deletedInteractionIds: Set<String>,
preferredScrollContinuityAnchorInteractionId: String,
) {
AssertIsOnMainThread()
loadRequestBuilder.reload(
updatedInteractionIds: updatedInteractionIds,
deletedInteractionIds: deletedInteractionIds,
)
loadRequestBuilder.reload(
preferredScrollContinuityAnchorInteractionId: preferredScrollContinuityAnchorInteractionId,
)
loadIfNecessary()
}
public func enqueueReloadWithoutCaches() {
AssertIsOnMainThread()

View File

@ -80,6 +80,10 @@ public class CVLoader: NSObject {
localAci: localAci,
transaction: transaction,
)
let preprocessingContext = MessageLoaderPreprocessingContext(
thread: loadContext.thread,
oldestUnreadSortId: viewStateSnapshot.oldestUnreadMessageSortId,
)
// Don't cache in the reset() case.
let canReuseInteractions = loadRequest.canReuseInteractionModels && !loadRequest.didReset
@ -132,30 +136,35 @@ public class CVLoader: NSObject {
focusMessageId: focusMessageIdOnOpen,
reusableInteractions: [:],
deletedInteractionIds: [],
preprocessingContext: preprocessingContext,
tx: transaction,
)
case .loadSameLocation:
try messageLoader.loadSameLocation(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: transaction,
)
case .loadOlder:
try messageLoader.loadOlderMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: transaction,
)
case .loadNewer:
try messageLoader.loadNewerMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: transaction,
)
case .loadNewest:
try messageLoader.loadNewestMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: transaction,
)
case .loadPageAroundInteraction(let interactionId, _):
@ -163,6 +172,7 @@ public class CVLoader: NSObject {
aroundInteractionId: interactionId,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: transaction,
)
}
@ -171,36 +181,18 @@ public class CVLoader: NSObject {
throw error
}
let initialLoadCount = messageLoader.loadedInteractions.count
var processedInteractions = Self.preprocessInteractions(
messageLoader.loadedInteractions,
loadContext: loadContext,
)
if case .loadInitialMapping = loadRequest.loadType {
let maxExtraLoads = 5
var extraLoads = 0
while
processedInteractions.count < initialLoadCount,
messageLoader.canLoadOlder,
extraLoads < maxExtraLoads
let expandedInteractions = messageLoader.loadedDisplayableInteractions.flatMap { interaction in
if
let collapseSet = interaction as? CollapseSetInteraction,
viewStateSnapshot.expandedCollapseSetIds.contains(collapseSet.uniqueId)
{
try messageLoader.loadOlderMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: transaction,
)
processedInteractions = Self.preprocessInteractions(
messageLoader.loadedInteractions,
loadContext: loadContext,
)
extraLoads += 1
return [collapseSet] + collapseSet.collapsedInteractions
}
return [interaction]
}
let itemModels = self.buildItemModels(
interactions: processedInteractions,
interactions: expandedInteractions,
loadContext: loadContext,
updatedInteractionIds: updatedInteractionIds,
localAci: localAci,
@ -272,214 +264,6 @@ public class CVLoader: NSObject {
return itemModelBuilder.buildItems(localAci: localAci, interactions: interactions)
}
// MARK: - Interaction Preprocessing
private static let maxCollapseSetSize = 50
/// Takes a list of interactions and applies preprocessing before the expensive task of creating `CVItemModel`s via `CVItemModelBuilder.buildItems`.
///
/// 1. Inserts date headers
/// 2. Inserts unread indicator
/// 3. Collapses chat events
private static func preprocessInteractions(
_ interactions: [TSInteraction],
loadContext: CVLoadContext,
) -> [TSInteraction] {
let thread = loadContext.thread
let isGroupThread = thread.isGroupThread
let expandedCollapseSets = loadContext.viewStateSnapshot.expandedCollapseSets
let oldestUnreadSortId = loadContext.viewStateSnapshot.oldestUnreadMessageSortId
let todayDate = Date()
var result = [TSInteraction]()
var currentRun = [TSInteraction]()
var currentRunType: CollapseSetInteraction.MessagesType?
var pastUnreadIndicator = false
var shouldShowDateOnNextViewItem = true
var previousDaysBeforeToday: Int?
func finalizeSet() {
defer {
currentRun.removeAll()
currentRunType = nil
}
guard currentRun.count >= 2, let runType = currentRunType else {
result.append(contentsOf: currentRun)
return
}
let collapseId = "CollapseSet_\(currentRun[0].timestamp)"
let isExpanded = expandedCollapseSets.contains(collapseId)
let collapseSetInteraction = CollapseSetInteraction(
thread: thread,
collapsedInteractions: currentRun,
collapseSetType: runType,
isExpanded: isExpanded,
)
result.append(collapseSetInteraction)
if isExpanded {
result.append(contentsOf: currentRun)
}
}
for interaction in interactions {
let timestamp = interaction.timestamp
let daysBeforeToday = DateUtil.daysFrom(
firstDate: Date(millisecondsSince1970: timestamp),
toSecondDate: todayDate,
)
if let previousDaysBeforeToday {
if daysBeforeToday != previousDaysBeforeToday {
shouldShowDateOnNextViewItem = true
}
} else {
// Only show for the first item if the date is not today
shouldShowDateOnNextViewItem = daysBeforeToday != 0
}
if
shouldShowDateOnNextViewItem,
canShowDateHeader(before: interaction)
{
// Collapse sets shouldn't cross date boundaries
finalizeSet()
result.append(DateHeaderInteraction(thread: thread, timestamp: timestamp))
shouldShowDateOnNextViewItem = false
}
previousDaysBeforeToday = daysBeforeToday
// Only insert one unread indicator and don't collapse unread events
if pastUnreadIndicator {
result.append(interaction)
continue
}
if let oldestUnreadSortId, oldestUnreadSortId <= interaction.sortId {
finalizeSet()
let unreadIndicatorInteraction = UnreadIndicatorInteraction(
thread: thread,
timestamp: timestamp,
receivedAtTimestamp: interaction.receivedAtTimestamp,
)
result.append(unreadIndicatorInteraction)
pastUnreadIndicator = true
result.append(interaction)
continue
}
guard BuildFlags.collapsingChatEvents else {
result.append(interaction)
continue
}
let collapseType = collapseSetType(for: interaction, isGroupThread: isGroupThread)
if let collapseType {
let isDifferentSetThanCurrentRun = currentRunType != nil && currentRunType != collapseType
let exceededCurrentRunLimit = currentRun.count >= maxCollapseSetSize
if isDifferentSetThanCurrentRun || exceededCurrentRunLimit {
finalizeSet()
}
currentRun.append(interaction)
currentRunType = collapseType
} else {
finalizeSet()
result.append(interaction)
}
}
finalizeSet()
return result
}
private static func canShowDateHeader(before interaction: TSInteraction) -> Bool {
switch interaction.interactionType {
case .unknown, .typingIndicator, .threadDetails, .dateHeader, .unknownThreadWarning, .defaultDisappearingMessageTimer, .collapseSet:
return false
case .info:
guard let infoMessage = interaction as? TSInfoMessage else {
owsFailDebug("Invalid interaction.")
return false
}
// Only show the date for non-synced thread messages;
return infoMessage.messageType != .syncedThread
case .unreadIndicator, .incomingMessage, .outgoingMessage, .error, .call:
return true
}
}
private static func collapseSetType(
for interaction: TSInteraction,
isGroupThread: Bool,
) -> CollapseSetInteraction.MessagesType? {
switch interaction.interactionType {
case .info:
guard let infoMessage = interaction as? TSInfoMessage else {
owsFailDebug("info interaction is not TSInfoMessage")
return nil
}
switch infoMessage.messageType {
case .typeDisappearingMessagesUpdate:
return .timerChanges
case .typeGroupUpdate:
if
let wrapper = infoMessage.infoMessageUserInfo?[.groupUpdateItems]
as? TSInfoMessage.PersistableGroupUpdateItemsWrapper
{
for event in wrapper.updateItems {
switch event {
case
.groupTerminatedByLocalUser,
.groupTerminatedByOtherUser,
.groupTerminatedByUnknownUser:
return nil
case
.disappearingMessagesEnabledByLocalUser,
.disappearingMessagesEnabledByOtherUser,
.disappearingMessagesEnabledByUnknownUser,
.disappearingMessagesDisabledByLocalUser,
.disappearingMessagesDisabledByOtherUser,
.disappearingMessagesDisabledByUnknownUser:
return .timerChanges
default:
break
}
}
}
return isGroupThread ? .groupUpdates : .chatUpdates
case .verificationStateChange,
.profileUpdate,
.phoneNumberChange,
.typeEndPoll,
.typePinnedMessage:
return isGroupThread ? .groupUpdates : .chatUpdates
default:
return nil
}
case .error:
guard let errorMessage = interaction as? TSErrorMessage else {
owsFailDebug("error interaction is not TSErrorMessage")
return nil
}
if errorMessage.errorType == .nonBlockingIdentityChange {
return isGroupThread ? .groupUpdates : .chatUpdates
}
return nil
case .call:
// Don't collapse an active group call.
if
let groupCallMessage = interaction as? OWSGroupCallMessage,
!groupCallMessage.hasEnded
{
return nil
}
return .callEvents
default:
return nil
}
}
// MARK: -
#if USE_DEBUG_UI
public static func debugui_buildStandaloneRenderItem(

View File

@ -42,7 +42,7 @@ struct CVViewStateSnapshot {
let hasActiveCall: Bool
let currentGroupThreadCallGroupId: GroupIdentifier?
let expandedCollapseSets: Set<String>
let expandedCollapseSetIds: Set<String>
private static var currentCallProvider: any CurrentCallProvider { DependenciesBridge.shared.currentCallProvider }
@ -64,7 +64,7 @@ struct CVViewStateSnapshot {
oldestUnreadMessageSortId: oldestUnreadMessageSortId,
hasActiveCall: currentCallProvider.hasCurrentCall,
currentGroupThreadCallGroupId: currentCallProvider.currentGroupThreadCallGroupId,
expandedCollapseSets: viewState.expandedCollapseSets,
expandedCollapseSetIds: viewState.expandedCollapseSets,
)
}
@ -84,7 +84,7 @@ struct CVViewStateSnapshot {
oldestUnreadMessageSortId: nil,
hasActiveCall: false,
currentGroupThreadCallGroupId: nil,
expandedCollapseSets: [],
expandedCollapseSetIds: [],
)
}
}

View File

@ -7,11 +7,13 @@ import Foundation
import SignalServiceKit
private enum Constants {
/// The maximum number of interactions to keep in memory. We start dropping
/// interactions (in an LRU fashion) once we've exceeded this value.
/// The maximum number of top-level interactions to keep in memory. We start
/// dropping interactions (in an LRU fashion) once we've exceeded this value.
///
/// TODO: Should we reduce this value?
static let maxInteractionCount = 500
static let maxDisplayableInteractionCount = 500
static let maxCollapseSetSize = 50
}
protocol MessageLoaderBatchFetcher {
@ -28,11 +30,19 @@ protocol MessageLoaderInteractionFetcher {
// MARK: -
struct MessageLoaderPreprocessingContext {
let thread: TSThread
let oldestUnreadSortId: UInt64?
}
// MARK: -
class MessageLoader {
private let batchFetcher: MessageLoaderBatchFetcher
private let interactionFetchers: [MessageLoaderInteractionFetcher]
private(set) var loadedInteractions: [TSInteraction] = []
private(set) var loadedDisplayableInteractions: [TSInteraction] = []
/// If true, there might be older messages that could be loaded. If false,
/// we believe we've reached the beginning of the chat.
@ -90,10 +100,61 @@ class MessageLoader {
case sameLocation
}
/// A single display unit: one standalone interaction or a collapse set.
private struct LoadedSegment {
/// Either a single item to be displayed or multiple updates to be
/// grouped in a collapse set.
var rawInteractions: [TSInteraction]
/// Zero or more generated elements (date header or unread indicator)
/// followed by the elements to be displayed. The single raw item
/// itself, or a collapse set which would be followed by
/// `rawInteractions` if expanded.
var displayableInteractions: [TSInteraction]
}
/// Groups raw interactions with the displayable interactions they produce
/// during preprocessing, so trimming can drop complete display units.
private struct LoadedPage {
let segments: [LoadedSegment]
var rawInteractions: [TSInteraction] {
segments.flatMap(\.rawInteractions)
}
var displayableInteractions: [TSInteraction] {
segments.flatMap(\.displayableInteractions)
}
var rawInteractionCount: Int {
segments.lazy.map(\.rawInteractions.count).reduce(0, +)
}
func trimmingDisplayableInteractions(
trimOlder: Bool,
) -> LoadedPage {
let segments = trimOlder ? self.segments.reversed() : self.segments
var trimmedSegments: [LoadedSegment] = []
var displayableCount = 0
for segment in segments {
let segmentDisplayableCount = segment.displayableInteractions.count
displayableCount += segmentDisplayableCount
guard displayableCount <= Constants.maxDisplayableInteractionCount else {
break
}
trimmedSegments.append(segment)
}
if trimOlder {
trimmedSegments.reverse()
}
return LoadedPage(segments: trimmedSegments)
}
}
func loadMessagePage(
aroundInteractionId interactionUniqueId: String,
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
tx: DBReadTransaction,
) throws {
try ensureLoaded(
@ -101,6 +162,7 @@ class MessageLoader {
count: initialLoadCount,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
}
@ -108,6 +170,7 @@ class MessageLoader {
func loadNewerMessagePage(
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
tx: DBReadTransaction,
) throws {
try ensureLoaded(
@ -115,6 +178,7 @@ class MessageLoader {
count: initialLoadCount * 2,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
}
@ -122,6 +186,7 @@ class MessageLoader {
func loadOlderMessagePage(
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
tx: DBReadTransaction,
) throws {
try ensureLoaded(
@ -129,6 +194,7 @@ class MessageLoader {
count: initialLoadCount * 2,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
}
@ -136,6 +202,7 @@ class MessageLoader {
func loadNewestMessagePage(
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
tx: DBReadTransaction,
) throws {
try ensureLoaded(
@ -143,6 +210,7 @@ class MessageLoader {
count: initialLoadCount,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
}
@ -151,6 +219,7 @@ class MessageLoader {
focusMessageId: String?,
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
tx: DBReadTransaction,
) throws {
if let focusMessageId {
@ -159,12 +228,14 @@ class MessageLoader {
count: initialLoadCount,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
} else {
try loadNewestMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
}
@ -173,13 +244,15 @@ class MessageLoader {
func loadSameLocation(
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
tx: DBReadTransaction,
) throws {
try ensureLoaded(
.sameLocation,
count: max(initialLoadCount, loadedInteractions.count),
count: max(initialLoadCount, loadedDisplayableInteractions.count),
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
preprocessingContext: preprocessingContext,
tx: tx,
)
}
@ -195,21 +268,122 @@ class MessageLoader {
count: Int,
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
preprocessingContext: MessageLoaderPreprocessingContext?,
tx: DBReadTransaction,
) throws {
owsAssertDebug(count > 0)
let count = count.clamp(1, Constants.maxInteractionCount)
let loadBatch = try buildLoadBatch(
let maxRawInteractionFetchCount = Constants.maxDisplayableInteractionCount * Constants.maxCollapseSetSize
let count = count.clamp(1, maxRawInteractionFetchCount)
let loadedDisplayableCount = loadedDisplayableInteractions.count
let desiredDisplayableInteractionCount: Int = switch direction {
case .older, .newer:
loadedDisplayableCount + count
case .sameLocation:
max(initialLoadCount, loadedDisplayableCount)
case .around, .newest:
count
}
var loadBatch = try buildLoadBatch(
direction,
count: count,
deletedInteractionIds: deletedInteractionIds,
tx: tx,
)
loadedInteractions = fetchInteractions(
uniqueIds: loadBatch.uniqueIds,
var loadedPage = buildLoadedPage(
for: loadBatch,
reusableInteractions: reusableInteractions,
preprocessingContext: preprocessingContext,
tx: tx,
)
func loadMoreIfNeeded(context: MessageLoaderPreprocessingContext) throws -> Bool {
let loadedDisplayableInteractionCount = loadedPage.displayableInteractions.count
guard loadedDisplayableInteractionCount < desiredDisplayableInteractionCount else {
return false
}
// Heuristically adjust fetch size based on the proportion of
// messages so far that are collapsed.
let remainingCount = desiredDisplayableInteractionCount - loadedDisplayableInteractionCount
let estimatedRawInteractionsPerDisplayableInteraction = min(
Constants.maxCollapseSetSize,
max(
1,
Int(ceil(Double(loadedPage.rawInteractionCount) / Double(max(loadedDisplayableInteractionCount, 1)))),
),
)
let fetchCount = min(
maxRawInteractionFetchCount,
max(count, remainingCount * estimatedRawInteractionsPerDisplayableInteraction),
)
guard fetchCount > 0 else {
return false
}
func fetchOlder() throws -> Bool {
guard
loadBatch.canLoadOlder,
let firstInteraction = loadedPage.segments.first?.rawInteractions.first,
let rowId = firstInteraction.sqliteRowId
else {
return false
}
return try self.fetchOlder(before: rowId, count: fetchCount, batch: &loadBatch, tx: tx) > 0
}
func fetchNewer() throws -> Bool {
guard
loadBatch.canLoadNewer,
let lastInteraction = loadedPage.segments.last?.rawInteractions.last,
let rowId = lastInteraction.sqliteRowId
else {
return false
}
return try self.fetchNewer(after: rowId, count: fetchCount, batch: &loadBatch, tx: tx) > 0
}
let didLoadMore: Bool
switch direction {
case .older, .newest:
didLoadMore = try fetchOlder()
case .newer:
didLoadMore = try fetchNewer()
case .sameLocation, .around:
if try fetchOlder() {
didLoadMore = true
} else {
didLoadMore = try fetchNewer()
}
}
guard didLoadMore else {
return false
}
loadedPage = buildLoadedPage(
for: loadBatch,
reusableInteractions: reusableInteractions,
preprocessingContext: context,
tx: tx,
)
return true
}
if let preprocessingContext {
while try loadMoreIfNeeded(context: preprocessingContext) {
// Loading more messages...
}
}
trimLoadedPageIfNeeded(
&loadBatch,
loadedPage: &loadedPage,
loadDirection: direction,
)
loadedInteractions = loadedPage.rawInteractions
loadedDisplayableInteractions = loadedPage.displayableInteractions
canLoadNewer = loadBatch.canLoadNewer
canLoadOlder = loadBatch.canLoadOlder
}
@ -228,24 +402,6 @@ class MessageLoader {
)
}
/// Expands `batch` with `count` messages preceding `rowId`.
@discardableResult
func fetchOlder(before rowId: Int64, count: Int, batch: inout MessageLoaderBatch) throws -> Int {
let uniqueIds: [String] = try fetch(filter: .before(rowId), limit: count)
batch.insertOlder(uniqueIds: uniqueIds, didReachOldest: uniqueIds.count < count)
batch.trimNewer()
return uniqueIds.count
}
/// Expands `batch` with `count` messages succeeding `rowId`.
@discardableResult
func fetchNewer(after rowId: Int64, count: Int, batch: inout MessageLoaderBatch) throws -> Int {
let uniqueIds: [String] = try fetch(filter: .after(rowId), limit: count)
batch.insertNewer(uniqueIds: uniqueIds, didReachNewest: uniqueIds.count < count)
batch.trimOlder()
return uniqueIds.count
}
/// Fetches uniqueIds in the range of provided rowIds.
func fetchRange(_ rowIds: ClosedRange<Int64>) throws -> [String] {
return try fetch(filter: .range(rowIds), limit: rowIds.count)
@ -265,8 +421,8 @@ class MessageLoader {
return try loadNewest()
}
var batch = MessageLoaderBatch(canLoadNewer: true, canLoadOlder: true, uniqueIds: [uniqueId])
let olderCount = try fetchOlder(before: rowId, count: count / 2, batch: &batch)
try fetchNewer(after: rowId, count: count - olderCount, batch: &batch)
let olderCount = try fetchOlder(before: rowId, count: count / 2, batch: &batch, tx: tx)
try fetchNewer(after: rowId, count: count - olderCount, batch: &batch, tx: tx)
return batch
}
@ -311,7 +467,7 @@ class MessageLoader {
return batch
case .older:
var batch = priorLoad.batch
try fetchOlder(before: priorLoad.range.lowerBound, count: count, batch: &batch)
try fetchOlder(before: priorLoad.range.lowerBound, count: count, batch: &batch, tx: tx)
return batch
case .sameLocation where !priorLoad.batch.canLoadNewer:
// If we're loading at the same location and are already at the end of the
@ -319,13 +475,13 @@ class MessageLoader {
fallthrough
case .newer:
var batch = priorLoad.batch
try fetchNewer(after: priorLoad.range.upperBound, count: count, batch: &batch)
try fetchNewer(after: priorLoad.range.upperBound, count: count, batch: &batch, tx: tx)
return batch
case .sameLocation:
var batch = priorLoad.batch
if batch.uniqueIds.count < initialLoadCount {
try fetchOlder(before: priorLoad.range.lowerBound, count: initialLoadCount, batch: &batch)
try fetchNewer(after: priorLoad.range.upperBound, count: initialLoadCount, batch: &batch)
try fetchOlder(before: priorLoad.range.lowerBound, count: initialLoadCount, batch: &batch, tx: tx)
try fetchNewer(after: priorLoad.range.upperBound, count: initialLoadCount, batch: &batch, tx: tx)
}
return batch
case .around(interactionUniqueId: let uniqueId):
@ -343,6 +499,32 @@ class MessageLoader {
}
}
/// Expands `batch` with `count` messages preceding `rowId`.
@discardableResult
private func fetchOlder(
before rowId: Int64,
count: Int,
batch: inout MessageLoaderBatch,
tx: DBReadTransaction,
) throws -> Int {
let uniqueIds = try batchFetcher.fetchUniqueIds(filter: .before(rowId), limit: count, tx: tx)
batch.insertOlder(uniqueIds: uniqueIds, didReachOldest: uniqueIds.count < count)
return uniqueIds.count
}
/// Expands `batch` with `count` messages succeeding `rowId`.
@discardableResult
private func fetchNewer(
after rowId: Int64,
count: Int,
batch: inout MessageLoaderBatch,
tx: DBReadTransaction,
) throws -> Int {
let uniqueIds = try batchFetcher.fetchUniqueIds(filter: .after(rowId), limit: count, tx: tx)
batch.insertNewer(uniqueIds: uniqueIds, didReachNewest: uniqueIds.count < count)
return uniqueIds.count
}
private func fetchInteractions(
uniqueIds interactionIds: [String],
reusableInteractions: [String: TSInteraction] = [:],
@ -360,6 +542,268 @@ class MessageLoader {
}
return refinery.values.compacted()
}
private func buildLoadedPage(
for batch: MessageLoaderBatch,
reusableInteractions: [String: TSInteraction],
preprocessingContext: MessageLoaderPreprocessingContext?,
tx: DBReadTransaction,
) -> LoadedPage {
let rawInteractions = fetchInteractions(
uniqueIds: batch.uniqueIds,
reusableInteractions: reusableInteractions,
tx: tx,
)
return LoadedPage(
segments: Self.preprocessInteractions(
rawInteractions,
preprocessingContext: preprocessingContext,
),
)
}
private func trimLoadedPageIfNeeded(
_ loadBatch: inout MessageLoaderBatch,
loadedPage: inout LoadedPage,
loadDirection: LoadWindowDirection,
) {
guard loadedPage.displayableInteractions.count > Constants.maxDisplayableInteractionCount else {
return
}
let trimOlder: Bool = switch loadDirection {
case .newer, .around, .newest, .sameLocation:
true
case .older:
false
}
loadedPage = loadedPage.trimmingDisplayableInteractions(trimOlder: trimOlder)
loadBatch.uniqueIds = loadedPage.rawInteractions.map(\.uniqueId)
if trimOlder {
loadBatch.canLoadOlder = true
} else {
loadBatch.canLoadNewer = true
}
}
/// Converts interactions into page segments. When a preprocessing context
/// is provided, this also inserts dynamic items (date headers and unread
/// indicators) and collapse sets.
private static func preprocessInteractions(
_ interactions: [TSInteraction],
preprocessingContext: MessageLoaderPreprocessingContext?,
) -> [LoadedSegment] {
guard let preprocessingContext else {
return interactions.map { interaction in
LoadedSegment(rawInteractions: [interaction], displayableInteractions: [interaction])
}
}
let thread = preprocessingContext.thread
let isGroupThread = thread.isGroupThread
let oldestUnreadSortId = preprocessingContext.oldestUnreadSortId
let todayDate = Date()
var result = [LoadedSegment]()
var pendingDisplayableInteractions = [TSInteraction]()
var currentRun = [TSInteraction]()
var currentRunType: CollapseSetInteraction.MessagesType?
var pastUnreadIndicator = false
var shouldShowDateOnNextViewItem = true
var previousDaysBeforeToday: Int?
func appendItem(_ interaction: TSInteraction) {
result.append(LoadedSegment(
rawInteractions: [interaction],
displayableInteractions: pendingDisplayableInteractions + [interaction],
))
pendingDisplayableInteractions.removeAll()
}
func finalizeSet() {
defer {
currentRun.removeAll()
currentRunType = nil
}
guard !currentRun.isEmpty else {
return
}
guard currentRun.count >= 2, let runType = currentRunType else {
for interaction in currentRun {
appendItem(interaction)
}
return
}
let collapseSetInteraction = CollapseSetInteraction(
thread: thread,
collapsedInteractions: currentRun,
collapseSetType: runType,
)
result.append(LoadedSegment(
rawInteractions: currentRun,
displayableInteractions: pendingDisplayableInteractions + [collapseSetInteraction],
))
pendingDisplayableInteractions.removeAll()
}
for interaction in interactions {
let timestamp = interaction.timestamp
let daysBeforeToday = DateUtil.daysFrom(
firstDate: Date(millisecondsSince1970: timestamp),
toSecondDate: todayDate,
)
if let previousDaysBeforeToday {
if daysBeforeToday != previousDaysBeforeToday {
shouldShowDateOnNextViewItem = true
}
} else {
// Only show for the first item if the date is not today
shouldShowDateOnNextViewItem = daysBeforeToday != 0
}
if
shouldShowDateOnNextViewItem,
canShowDateHeader(before: interaction)
{
// Collapse sets shouldn't cross date boundaries
finalizeSet()
pendingDisplayableInteractions.append(DateHeaderInteraction(thread: thread, timestamp: timestamp))
shouldShowDateOnNextViewItem = false
}
previousDaysBeforeToday = daysBeforeToday
// Only insert one unread indicator and don't collapse unread events
if pastUnreadIndicator {
appendItem(interaction)
continue
}
if let oldestUnreadSortId, oldestUnreadSortId <= interaction.sortId {
finalizeSet()
let unreadIndicatorInteraction = UnreadIndicatorInteraction(
thread: thread,
timestamp: timestamp,
receivedAtTimestamp: interaction.receivedAtTimestamp,
)
pendingDisplayableInteractions.append(unreadIndicatorInteraction)
pastUnreadIndicator = true
appendItem(interaction)
continue
}
guard BuildFlags.collapsingChatEvents else {
appendItem(interaction)
continue
}
let collapseType = collapseSetType(for: interaction, isGroupThread: isGroupThread)
if let collapseType {
let isDifferentSetThanCurrentRun = currentRunType != nil && currentRunType != collapseType
let exceededCurrentRunLimit = currentRun.count >= Constants.maxCollapseSetSize
if isDifferentSetThanCurrentRun || exceededCurrentRunLimit {
finalizeSet()
}
currentRun.append(interaction)
currentRunType = collapseType
} else {
finalizeSet()
appendItem(interaction)
}
}
finalizeSet()
return result
}
private static func canShowDateHeader(before interaction: TSInteraction) -> Bool {
switch interaction.interactionType {
case .unknown, .typingIndicator, .threadDetails, .dateHeader, .unknownThreadWarning, .defaultDisappearingMessageTimer, .collapseSet:
return false
case .info:
guard let infoMessage = interaction as? TSInfoMessage else {
owsFailDebug("Invalid interaction.")
return false
}
// Only show the date for non-synced thread messages;
return infoMessage.messageType != .syncedThread
case .unreadIndicator, .incomingMessage, .outgoingMessage, .error, .call:
return true
}
}
private static func collapseSetType(
for interaction: TSInteraction,
isGroupThread: Bool,
) -> CollapseSetInteraction.MessagesType? {
switch interaction.interactionType {
case .info:
guard let infoMessage = interaction as? TSInfoMessage else {
owsFailDebug("info interaction is not TSInfoMessage")
return nil
}
switch infoMessage.messageType {
case .typeDisappearingMessagesUpdate:
return .timerChanges
case .typeGroupUpdate:
if
let wrapper = infoMessage.infoMessageUserInfo?[.groupUpdateItems]
as? TSInfoMessage.PersistableGroupUpdateItemsWrapper
{
for event in wrapper.updateItems {
switch event {
case
.groupTerminatedByLocalUser,
.groupTerminatedByOtherUser,
.groupTerminatedByUnknownUser:
return nil
case
.disappearingMessagesEnabledByLocalUser,
.disappearingMessagesEnabledByOtherUser,
.disappearingMessagesEnabledByUnknownUser,
.disappearingMessagesDisabledByLocalUser,
.disappearingMessagesDisabledByOtherUser,
.disappearingMessagesDisabledByUnknownUser:
return .timerChanges
default:
break
}
}
}
return isGroupThread ? .groupUpdates : .chatUpdates
case .verificationStateChange,
.profileUpdate,
.phoneNumberChange,
.typeEndPoll,
.typePinnedMessage:
return isGroupThread ? .groupUpdates : .chatUpdates
default:
return nil
}
case .error:
guard let errorMessage = interaction as? TSErrorMessage else {
owsFailDebug("error interaction is not TSErrorMessage")
return nil
}
if errorMessage.errorType == .nonBlockingIdentityChange {
return isGroupThread ? .groupUpdates : .chatUpdates
}
return nil
case .call:
// Don't collapse an active group call.
if
let groupCallMessage = interaction as? OWSGroupCallMessage,
!groupCallMessage.hasEnded
{
return nil
}
return .callEvents
default:
return nil
}
}
}
// MARK: -
@ -447,8 +891,6 @@ struct MessageLoaderBatch {
}
uniqueIds = otherUniqueIds.dropLast(overlappingCount) + uniqueIds
mergeCanLoad(otherLoadBatch)
// Make sure we keep all of `self`, so trim entries we just added if needed.
trimOlder()
case (let firstIndex?, nil):
let overlappingCount = uniqueIds.endIndex - firstIndex
guard uniqueIds.suffix(overlappingCount) == otherUniqueIds.prefix(overlappingCount) else {
@ -458,8 +900,6 @@ struct MessageLoaderBatch {
}
uniqueIds += otherUniqueIds.dropFirst(overlappingCount)
mergeCanLoad(otherLoadBatch)
// Make sure we keep all of `self`, so trim entries we just added if needed.
trimNewer()
case (let firstIndex?, let lastIndex?):
guard uniqueIds[firstIndex...lastIndex] == otherUniqueIds[...] else {
// If this breaks, it probably means `deletedInteractionIds` is broken (or
@ -494,24 +934,4 @@ struct MessageLoaderBatch {
canLoadNewer = false
}
}
mutating func trimOlder() {
guard uniqueIds.count > Constants.maxInteractionCount else {
return
}
uniqueIds = Array(uniqueIds.suffix(Constants.maxInteractionCount))
// We trimmed from the beginning. If the oldest had been marked as loaded,
// it's no longer loaded.
canLoadOlder = true
}
mutating func trimNewer() {
guard uniqueIds.count > Constants.maxInteractionCount else {
return
}
uniqueIds = Array(uniqueIds.prefix(Constants.maxInteractionCount))
// We trimmed from the end. If the newest had already been marked as
// loaded, it's no longer loaded.
canLoadNewer = true
}
}

View File

@ -45,6 +45,62 @@ class DebugUIMisc: DebugUIPage {
}
}),
]
if let groupThread = thread as? TSGroupThread {
items += [
OWSTableItem(title: "Insert 50 group update messages", actionBlock: {
let updateItems: [TSInfoMessage.PersistableGroupUpdateItem] = [
.genericUpdateByLocalUser,
.genericUpdateByUnknownUser,
.nameRemovedByLocalUser,
.nameRemovedByUnknownUser,
.avatarChangedByLocalUser,
.avatarChangedByUnknownUser,
.avatarRemovedByLocalUser,
.avatarRemovedByUnknownUser,
.localUserLeft,
.localUserRemovedByUnknownUser,
.localUserWasInvitedByLocalUser,
.localUserWasInvitedByUnknownUser,
.localUserAcceptedInviteFromUnknownUser,
.localUserJoined,
.localUserAddedByLocalUser,
.localUserAddedByUnknownUser,
.localUserDeclinedInviteFromUnknownUser,
.localUserInviteRevokedByUnknownUser,
.localUserRequestedToJoin,
.localUserRequestApprovedByUnknownUser,
.localUserRequestCanceledByLocalUser,
.localUserRequestRejectedByUnknownUser,
.inviteLinkResetByLocalUser,
.inviteLinkResetByUnknownUser,
.inviteLinkEnabledWithoutApprovalByLocalUser,
.inviteLinkEnabledWithApprovalByLocalUser,
.inviteLinkDisabledByLocalUser,
.inviteLinkApprovalDisabledByLocalUser,
.inviteLinkApprovalEnabledByLocalUser,
.localUserJoinedViaInviteLink,
.wasMigrated,
.localUserInvitedAfterMigration,
.createdByLocalUser,
.createdByUnknownUser,
.inviteFriendsToNewlyCreatedGroup,
].shuffled()
SSKEnvironment.shared.databaseStorageRef.write { tx in
for i in 0..<50 {
let item = updateItems[i % updateItems.count]
let infoMessage = TSInfoMessage(
thread: groupThread,
messageType: .typeGroupUpdate,
infoMessageUserInfo: [.groupUpdateItems: TSInfoMessage.PersistableGroupUpdateItemsWrapper([item])],
)
infoMessage.anyInsert(transaction: tx)
}
}
}),
]
}
return OWSTableSection(title: name, items: items)
}

View File

@ -59,11 +59,87 @@ class MessageLoaderTest: XCTestCase {
}
}
private func createInfoMessage(
rowId: Int64,
thread: TSThread,
messageType: TSInfoMessageType,
) -> TSInteraction {
return TSInfoMessage(
grdbId: rowId,
uniqueId: UUID().uuidString,
receivedAtTimestamp: UInt64(rowId),
sortId: UInt64(rowId),
timestamp: UInt64(rowId),
uniqueThreadId: thread.uniqueId,
body: nil,
bodyRanges: nil,
contactShare: nil,
deprecated_attachmentIds: nil,
editState: .none,
expireStartedAt: 0,
expireTimerVersion: nil,
expiresAt: 0,
expiresInSeconds: 0,
giftBadge: nil,
isGroupStoryReply: false,
isPoll: false,
isSmsMessageRestoredFromBackup: false,
isViewOnceComplete: false,
isViewOnceMessage: false,
linkPreview: nil,
messageSticker: nil,
quotedMessage: nil,
storedShouldStartExpireTimer: false,
storyAuthorUuidString: nil,
storyReactionEmoji: nil,
storyTimestamp: nil,
wasRemotelyDeleted: false,
customMessage: nil,
infoMessageUserInfo: nil,
messageType: messageType,
read: true,
serverGuid: nil,
unregisteredAddress: nil,
)
}
private func createInteraction(rowId: Int64, thread: TSThread) -> TSInteraction {
return createInfoMessage(rowId: rowId, thread: thread, messageType: .userJoinedSignal)
}
private func createCollapsibleInteraction(rowId: Int64, thread: TSThread) -> TSInteraction {
return createInfoMessage(rowId: rowId, thread: thread, messageType: .typeDisappearingMessagesUpdate)
}
private func createCollapsibleInteractions(_ count: Int64, thread: TSThread) -> [TSInteraction] {
return ((1 as Int64)...count).map { rowId in
createCollapsibleInteraction(rowId: rowId, thread: thread)
}
}
private func createMixedInteractions(_ chunkCount: Int64, thread: TSThread) -> [TSInteraction] {
return ((0 as Int64)..<chunkCount).flatMap { chunkIndex -> [TSInteraction] in
let rowId = chunkIndex * 3 + 1
return [
createCollapsibleInteraction(rowId: rowId, thread: thread),
createCollapsibleInteraction(rowId: rowId + 1, thread: thread),
createInteraction(rowId: rowId + 2, thread: thread),
]
}
}
private func setInteractions(_ interactions: [TSInteraction]) {
batchFetcher.interactions = interactions
interactionFetcher.interactions = interactions
}
private func preprocessingContext(thread: TSThread) -> MessageLoaderPreprocessingContext {
return MessageLoaderPreprocessingContext(
thread: thread,
oldestUnreadSortId: nil,
)
}
func test_loadInitialMessagePage_empty() throws {
try mockDB.read { tx in
try self.messageLoader.loadInitialMessagePage(
@ -91,6 +167,107 @@ class MessageLoaderTest: XCTestCase {
XCTAssertEqual(initialMessages.map { $0.uniqueId }, messageLoader.loadedInteractions.map { $0.uniqueId })
}
func test_loadInitialMessagePage_countsCollapsedInteractionsAsTopLevel() throws {
let thread = TSContactThread(contactUUID: UUID().uuidString, contactPhoneNumber: nil)
let initialMessages = createCollapsibleInteractions(2_000, thread: thread)
setInteractions(initialMessages)
try mockDB.read { tx in
try self.messageLoader.loadInitialMessagePage(
focusMessageId: nil,
reusableInteractions: [:],
deletedInteractionIds: [],
preprocessingContext: MessageLoaderPreprocessingContext(
thread: thread,
oldestUnreadSortId: nil,
),
tx: tx,
)
}
let newestInteraction = try XCTUnwrap(initialMessages.last)
XCTAssertEqual(messageLoader.loadedInteractions.last?.uniqueId, newestInteraction.uniqueId)
let newestCollapseSet = try XCTUnwrap(messageLoader.loadedDisplayableInteractions.last as? CollapseSetInteraction)
XCTAssertEqual(newestCollapseSet.collapsedInteractions.last?.uniqueId, newestInteraction.uniqueId)
XCTAssertGreaterThan(messageLoader.loadedInteractions.count, 500)
XCTAssertLessThanOrEqual(messageLoader.loadedDisplayableInteractions.count, 500)
XCTAssertTrue(messageLoader.loadedDisplayableInteractions.contains { $0 is CollapseSetInteraction })
}
func test_loadOlderMessagePage_withMixedCollapseSets_trimsNewerSide() throws {
let thread = TSContactThread(contactUUID: UUID().uuidString, contactPhoneNumber: nil)
let initialMessages = createMixedInteractions(900, thread: thread)
setInteractions(initialMessages)
try mockDB.read { tx in
try self.messageLoader.loadInitialMessagePage(
focusMessageId: nil,
reusableInteractions: [:],
deletedInteractionIds: [],
preprocessingContext: preprocessingContext(thread: thread),
tx: tx,
)
}
let newestInteraction = try XCTUnwrap(initialMessages.last)
XCTAssertEqual(messageLoader.loadedInteractions.last?.uniqueId, newestInteraction.uniqueId)
try mockDB.read { tx in
var loadCount = 0
while self.messageLoader.canLoadOlder, loadCount < 100 {
try self.messageLoader.loadOlderMessagePage(
reusableInteractions: [:],
deletedInteractionIds: [],
preprocessingContext: preprocessingContext(thread: thread),
tx: tx,
)
loadCount += 1
}
}
XCTAssertLessThanOrEqual(messageLoader.loadedDisplayableInteractions.count, 500)
XCTAssertTrue(messageLoader.loadedDisplayableInteractions.contains { $0 is CollapseSetInteraction })
XCTAssertFalse(messageLoader.loadedInteractions.contains { $0.uniqueId == newestInteraction.uniqueId })
}
func test_loadNewerMessagePage_withMixedCollapseSets_trimsOlderSide() throws {
let thread = TSContactThread(contactUUID: UUID().uuidString, contactPhoneNumber: nil)
let initialMessages = createMixedInteractions(900, thread: thread)
setInteractions(initialMessages)
let focusInteraction = initialMessages[100]
try mockDB.read { tx in
try self.messageLoader.loadInitialMessagePage(
focusMessageId: focusInteraction.uniqueId,
reusableInteractions: [:],
deletedInteractionIds: [],
preprocessingContext: preprocessingContext(thread: thread),
tx: tx,
)
}
XCTAssertTrue(messageLoader.loadedInteractions.contains { $0.uniqueId == focusInteraction.uniqueId })
try mockDB.read { tx in
var loadCount = 0
while self.messageLoader.canLoadNewer, loadCount < 100 {
try self.messageLoader.loadNewerMessagePage(
reusableInteractions: [:],
deletedInteractionIds: [],
preprocessingContext: preprocessingContext(thread: thread),
tx: tx,
)
loadCount += 1
}
}
let newestInteraction = try XCTUnwrap(initialMessages.last)
XCTAssertLessThanOrEqual(messageLoader.loadedDisplayableInteractions.count, 500)
XCTAssertTrue(messageLoader.loadedDisplayableInteractions.contains { $0 is CollapseSetInteraction })
XCTAssertEqual(messageLoader.loadedInteractions.last?.uniqueId, newestInteraction.uniqueId)
XCTAssertFalse(messageLoader.loadedInteractions.contains { $0.uniqueId == focusInteraction.uniqueId })
}
func test_reloadInteractions_deletes() throws {
let initialMessages = createInteractions(5)
setInteractions(initialMessages)