From 800a5cc0bc5d991f544d0cb382323ed1002ba064 Mon Sep 17 00:00:00 2001 From: Elaine <138257830+elaine-signal@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:17:12 -0400 Subject: [PATCH] Improve handling of many large collapse sets --- .../Components/CVComponentCollapseSet.swift | 23 +- .../Components/CVComponentState.swift | 3 +- ...onViewController+CVComponentDelegate.swift | 2 + .../CollapseSetInteraction.swift | 12 +- .../Loading/CVLoadCoordinator.swift | 17 + .../ConversationView/Loading/CVLoader.swift | 250 +------- .../Loading/CVViewStateSnapshot.swift | 6 +- .../Loading/MessageLoader.swift | 532 ++++++++++++++++-- .../ViewControllers/DebugUI/DebugUIMisc.swift | 56 ++ .../ViewControllers/MessageLoaderTest.swift | 177 ++++++ 10 files changed, 769 insertions(+), 309 deletions(-) diff --git a/Signal/ConversationView/Components/CVComponentCollapseSet.swift b/Signal/ConversationView/Components/CVComponentCollapseSet.swift index 3ebe4de6d8..de22d27b2a 100644 --- a/Signal/ConversationView/Components/CVComponentCollapseSet.swift +++ b/Signal/ConversationView/Components/CVComponentCollapseSet.swift @@ -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, diff --git a/Signal/ConversationView/Components/CVComponentState.swift b/Signal/ConversationView/Components/CVComponentState.swift index a73609cbdb..00460a2718 100644 --- a/Signal/ConversationView/Components/CVComponentState.swift +++ b/Signal/ConversationView/Components/CVComponentState.swift @@ -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() diff --git a/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift b/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift index d54cdb1c6c..af845abd24 100644 --- a/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift +++ b/Signal/ConversationView/ConversationViewController+CVComponentDelegate.swift @@ -33,6 +33,8 @@ extension ConversationViewController: CVComponentDelegate { viewState.expandedCollapseSets.insert(collapseSetId) } loadCoordinator.enqueueReload( + updatedInteractionIds: [collapseSetId], + deletedInteractionIds: [], preferredScrollContinuityAnchorInteractionId: collapseSetId, ) } diff --git a/Signal/ConversationView/DynamicInteractions/CollapseSetInteraction.swift b/Signal/ConversationView/DynamicInteractions/CollapseSetInteraction.swift index 67269b6c0f..20329142f0 100644 --- a/Signal/ConversationView/DynamicInteractions/CollapseSetInteraction.swift +++ b/Signal/ConversationView/DynamicInteractions/CollapseSetInteraction.swift @@ -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, diff --git a/Signal/ConversationView/Loading/CVLoadCoordinator.swift b/Signal/ConversationView/Loading/CVLoadCoordinator.swift index d5280ae137..6352f69517 100644 --- a/Signal/ConversationView/Loading/CVLoadCoordinator.swift +++ b/Signal/ConversationView/Loading/CVLoadCoordinator.swift @@ -427,6 +427,23 @@ public class CVLoadCoordinator: NSObject { loadIfNecessary() } + public func enqueueReload( + updatedInteractionIds: Set, + deletedInteractionIds: Set, + preferredScrollContinuityAnchorInteractionId: String, + ) { + AssertIsOnMainThread() + + loadRequestBuilder.reload( + updatedInteractionIds: updatedInteractionIds, + deletedInteractionIds: deletedInteractionIds, + ) + loadRequestBuilder.reload( + preferredScrollContinuityAnchorInteractionId: preferredScrollContinuityAnchorInteractionId, + ) + loadIfNecessary() + } + public func enqueueReloadWithoutCaches() { AssertIsOnMainThread() diff --git a/Signal/ConversationView/Loading/CVLoader.swift b/Signal/ConversationView/Loading/CVLoader.swift index 7f3b63c43f..a5a8ab8b04 100644 --- a/Signal/ConversationView/Loading/CVLoader.swift +++ b/Signal/ConversationView/Loading/CVLoader.swift @@ -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( diff --git a/Signal/ConversationView/Loading/CVViewStateSnapshot.swift b/Signal/ConversationView/Loading/CVViewStateSnapshot.swift index fc5007f599..d6daa1e96d 100644 --- a/Signal/ConversationView/Loading/CVViewStateSnapshot.swift +++ b/Signal/ConversationView/Loading/CVViewStateSnapshot.swift @@ -42,7 +42,7 @@ struct CVViewStateSnapshot { let hasActiveCall: Bool let currentGroupThreadCallGroupId: GroupIdentifier? - let expandedCollapseSets: Set + let expandedCollapseSetIds: Set 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: [], ) } } diff --git a/Signal/ConversationView/Loading/MessageLoader.swift b/Signal/ConversationView/Loading/MessageLoader.swift index 3b6be985b2..e84e786eac 100644 --- a/Signal/ConversationView/Loading/MessageLoader.swift +++ b/Signal/ConversationView/Loading/MessageLoader.swift @@ -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?, + 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?, + 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?, + 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?, + 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?, + 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?, + 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?, + 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) 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 - } } diff --git a/Signal/src/ViewControllers/DebugUI/DebugUIMisc.swift b/Signal/src/ViewControllers/DebugUI/DebugUIMisc.swift index a5e6ff7559..885f002e8c 100644 --- a/Signal/src/ViewControllers/DebugUI/DebugUIMisc.swift +++ b/Signal/src/ViewControllers/DebugUI/DebugUIMisc.swift @@ -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) } diff --git a/Signal/test/ViewControllers/MessageLoaderTest.swift b/Signal/test/ViewControllers/MessageLoaderTest.swift index ba6a43dacd..b308643500 100644 --- a/Signal/test/ViewControllers/MessageLoaderTest.swift +++ b/Signal/test/ViewControllers/MessageLoaderTest.swift @@ -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).. [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)