// // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import SignalServiceKit private enum Constants { /// 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 maxDisplayableInteractionCount = 500 static let maxCollapseSetSize = 50 } protocol MessageLoaderBatchFetcher { func fetchUniqueIds( filter: InteractionFinder.RowIdFilter, limit: Int, tx: DBReadTransaction, ) throws -> [String] } protocol MessageLoaderInteractionFetcher { func fetchInteractions(for uniqueIds: [String], tx: DBReadTransaction) -> [String: TSInteraction] } // 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. private(set) var canLoadOlder = true /// If true, there might be newer messages that could be loaded. If false, /// we believe we've loaded all the way to the end of the chat. private(set) var canLoadNewer = true /// Initializes a MessageLoader. /// /// - Parameter batchFetcher: An object responsible for fetching identifiers /// for the messages that should be displayed. /// /// - Parameter interactionFetchers: A list of objects that fetch /// fully-hydrated interaction objects for the identifiers returned from /// `batchFetcher`. When fetching interactions, we will try each fetcher in /// the order provided here. If the first fetcher returns a result for a /// particular interaction, then we won't try to fetch that interaction from /// any of the subsequent fetchers. init( batchFetcher: MessageLoaderBatchFetcher, interactionFetchers: [MessageLoaderInteractionFetcher], ) { self.batchFetcher = batchFetcher self.interactionFetchers = interactionFetchers } // The smaller this number is, the faster the conversation can display. // // However, too small and we'll immediately trigger a "load more" because // the user's viewports is too close to the conversation view's edge. // // Therefore we target a (slightly worse than) general case which will load // fast for most conversations, at the expense of a second fetch for // conversations with pathologically small messages (e.g. a bunch of 1-line // texts in a row from the same sender and timestamp) private lazy var initialLoadCount: Int = { let avgMessageHeight: CGFloat = 35 var deviceFrame = CGRect.zero DispatchSyncMainThreadSafe { deviceFrame = CurrentAppContext().frame } let referenceSize = max(deviceFrame.width, deviceFrame.height) let messageCountToFillScreen = (referenceSize / avgMessageHeight) let minCount: Int = 10 return max(minCount, Int(ceil(messageCountToFillScreen))) }() private enum LoadWindowDirection: Equatable { case older case newer case around(interactionUniqueId: String) case newest 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( .around(interactionUniqueId: interactionUniqueId), count: initialLoadCount, reusableInteractions: reusableInteractions, deletedInteractionIds: deletedInteractionIds, preprocessingContext: preprocessingContext, tx: tx, ) } func loadNewerMessagePage( reusableInteractions: [String: TSInteraction], deletedInteractionIds: Set?, preprocessingContext: MessageLoaderPreprocessingContext? = nil, tx: DBReadTransaction, ) throws { try ensureLoaded( .newer, count: initialLoadCount * 2, reusableInteractions: reusableInteractions, deletedInteractionIds: deletedInteractionIds, preprocessingContext: preprocessingContext, tx: tx, ) } func loadOlderMessagePage( reusableInteractions: [String: TSInteraction], deletedInteractionIds: Set?, preprocessingContext: MessageLoaderPreprocessingContext? = nil, tx: DBReadTransaction, ) throws { try ensureLoaded( .older, count: initialLoadCount * 2, reusableInteractions: reusableInteractions, deletedInteractionIds: deletedInteractionIds, preprocessingContext: preprocessingContext, tx: tx, ) } func loadNewestMessagePage( reusableInteractions: [String: TSInteraction], deletedInteractionIds: Set?, preprocessingContext: MessageLoaderPreprocessingContext? = nil, tx: DBReadTransaction, ) throws { try ensureLoaded( .newest, count: initialLoadCount, reusableInteractions: reusableInteractions, deletedInteractionIds: deletedInteractionIds, preprocessingContext: preprocessingContext, tx: tx, ) } func loadInitialMessagePage( focusMessageId: String?, reusableInteractions: [String: TSInteraction], deletedInteractionIds: Set?, preprocessingContext: MessageLoaderPreprocessingContext? = nil, tx: DBReadTransaction, ) throws { if let focusMessageId { try ensureLoaded( .around(interactionUniqueId: focusMessageId), count: initialLoadCount, reusableInteractions: reusableInteractions, deletedInteractionIds: deletedInteractionIds, preprocessingContext: preprocessingContext, tx: tx, ) } else { try loadNewestMessagePage( reusableInteractions: reusableInteractions, deletedInteractionIds: deletedInteractionIds, preprocessingContext: preprocessingContext, tx: tx, ) } } func loadSameLocation( reusableInteractions: [String: TSInteraction], deletedInteractionIds: Set?, preprocessingContext: MessageLoaderPreprocessingContext? = nil, tx: DBReadTransaction, ) throws { try ensureLoaded( .sameLocation, count: max(initialLoadCount, loadedDisplayableInteractions.count), reusableInteractions: reusableInteractions, deletedInteractionIds: deletedInteractionIds, preprocessingContext: preprocessingContext, tx: tx, ) } /// Loads (or reloads) messages for a conversation. /// /// - Parameter count: If we're creating a new load window, this represents /// the number of interactions in the new load window. If we're expanding an /// existing load window, this represents the number of interactions by /// which to expand the new window. private func ensureLoaded( _ direction: LoadWindowDirection, count: Int, reusableInteractions: [String: TSInteraction], deletedInteractionIds: Set?, preprocessingContext: MessageLoaderPreprocessingContext?, tx: DBReadTransaction, ) throws { owsAssertDebug(count > 0) 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, ) 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 } private func buildLoadBatch( _ direction: LoadWindowDirection, count: Int, deletedInteractionIds: Set?, tx: DBReadTransaction, ) throws -> MessageLoaderBatch { func fetch(filter: InteractionFinder.RowIdFilter, limit: Int) throws -> [String] { return try batchFetcher.fetchUniqueIds( filter: filter, limit: limit, tx: tx, ) } /// Fetches uniqueIds in the range of provided rowIds. func fetchRange(_ rowIds: ClosedRange) throws -> [String] { return try fetch(filter: .range(rowIds), limit: rowIds.count) } /// Fetches a batch containing the newest messages in the chat. func loadNewest() throws -> MessageLoaderBatch { let uniqueIds: [String] = try fetch(filter: .newest, limit: count) let didReachOldest = uniqueIds.count < count return MessageLoaderBatch(canLoadNewer: false, canLoadOlder: !didReachOldest, uniqueIds: uniqueIds) } /// Fetches a batch surrounding `uniqueId`. func loadAround(uniqueId: String) throws -> MessageLoaderBatch { guard let rowId = fetchInteractions(uniqueIds: [uniqueId], tx: tx).first?.sqliteRowId else { // We can't find the message, so just return the newest messages. return try loadNewest() } var batch = MessageLoaderBatch(canLoadNewer: true, canLoadOlder: true, uniqueIds: [uniqueId]) 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 } let priorLoad: (range: ClosedRange, batch: MessageLoaderBatch)? = try { guard let lowerBound = loadedInteractions.first?.sqliteRowId, let upperBound = loadedInteractions.last?.sqliteRowId else { return nil } let interactionIds: [String] if let deletedInteractionIds { // We can figure out what was deleted without any queries. (This may be a // premature optimization.) interactionIds = Array( loadedInteractions.lazy.map { $0.uniqueId }.filter { !deletedInteractionIds.contains($0) }, ) } else { // We can figure out what is left by re-checking prior rowids. interactionIds = try fetchRange(lowerBound...upperBound) } // We compute lowerBound & upperBound *before* filtering. Because we only // expect to filter deleted messages, and because rowids aren't reused, // it's fine to continue referring to rowids that no longer exist. For // example, if we ask for messages "before rowid 5" but rowid 5 has been // deleted, we'll still get the correct results. This helps in scenarios // where the ENTIRE prior batch of messages is deleted. We still know the // rowids, so we can properly fetch the messages that surround that batch // rather than falling back to fetching at some other arbitrary point in // the conversation. return ( range: lowerBound...upperBound, batch: MessageLoaderBatch(canLoadNewer: canLoadNewer, canLoadOlder: canLoadOlder, uniqueIds: interactionIds), ) }() if let priorLoad { switch direction { case .newest: var batch = try loadNewest() batch.mergeBatchIfOverlap(priorLoad.batch) return batch case .older: var batch = priorLoad.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 // chat, switch to a `.newer` fetch to check if there's new messages. fallthrough case .newer: var batch = priorLoad.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, tx: tx) try fetchNewer(after: priorLoad.range.upperBound, count: initialLoadCount, batch: &batch, tx: tx) } return batch case .around(interactionUniqueId: let uniqueId): var batch = try loadAround(uniqueId: uniqueId) batch.mergeBatchIfOverlap(priorLoad.batch) return batch } } else { switch direction { case .newest, .newer, .older, .sameLocation: return try loadNewest() case .around(interactionUniqueId: let uniqueId): return try loadAround(uniqueId: uniqueId) } } } /// 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] = [:], tx: DBReadTransaction, ) -> [TSInteraction] { var refinery = Refinery(interactionIds) refinery = refinery.refine { interactionIds -> [TSInteraction?] in return interactionIds.map { reusableInteractions[$0] } } for interactionFetcher in interactionFetchers { refinery = refinery.refine { interactionIds -> [TSInteraction?] in let fetchedInteractions = interactionFetcher.fetchInteractions(for: Array(interactionIds), tx: tx) return interactionIds.map { fetchedInteractions[$0] } } } 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: - extension InteractionReadCache: MessageLoaderInteractionFetcher { func fetchInteractions(for uniqueIds: [String], tx: DBReadTransaction) -> [String: TSInteraction] { return getInteractionsIfInCache(for: Array(uniqueIds), transaction: tx) } } // MARK: - class SDSInteractionFetcherImpl: MessageLoaderInteractionFetcher { func fetchInteractions(for uniqueIds: [String], tx: DBReadTransaction) -> [String: TSInteraction] { let fetchedInteractions = InteractionFinder.interactions( withInteractionIds: Set(uniqueIds), transaction: tx, ) return Dictionary(uniqueKeysWithValues: fetchedInteractions.lazy.map { ($0.uniqueId, $0) }) } } // MARK: - Batch Fetcher class ConversationViewBatchFetcher: MessageLoaderBatchFetcher { private let interactionFinder: InteractionFinder init(interactionFinder: InteractionFinder) { self.interactionFinder = interactionFinder } func fetchUniqueIds( filter: InteractionFinder.RowIdFilter, limit: Int, tx: DBReadTransaction, ) throws -> [String] { try interactionFinder.fetchUniqueIdsForConversationView( rowIdFilter: filter, limit: limit, tx: tx, ) } } // MARK: - struct MessageLoaderBatch { /// Whether or not there might be more newer messages. var canLoadNewer: Bool /// Whether or not there might be more older messages. var canLoadOlder: Bool /// An ordered list of TSInteraction uniqueIds. var uniqueIds: [String] mutating func mergeBatchIfOverlap(_ otherLoadBatch: MessageLoaderBatch) { // Assume that `self` contains the follow uniqueIds: // // D E F G // // Assume `otherLoadRange` contains each of the following: // // A B <-- No overlap, so there's no merge (nil, nil). // B C D E <-- Some overlap, so we build a combined result (nil, .some). // E F <-- Full overlap, so there's no need to merge (.some, .some). // F G H I <-- Some overlap, so we build a combined result (.some, nil). // I J <-- No overlap, so there's no merge (nil, nil). // If the other range doesn't contain any values, then the merge is a no-op. let otherUniqueIds = otherLoadBatch.uniqueIds guard let otherFirst = otherUniqueIds.first, let otherLast = otherUniqueIds.last else { return } // Otherwise, figure out where the range intersects the existing values. switch (uniqueIds.firstIndex(of: otherFirst), uniqueIds.firstIndex(of: otherLast)) { case (nil, nil): return case (nil, let lastIndex?): let overlappingCount = lastIndex - uniqueIds.startIndex + 1 guard uniqueIds.prefix(overlappingCount) == otherUniqueIds.suffix(overlappingCount) else { // If this breaks, it probably means `deletedInteractionIds` is broken (or // hit a race condition). Err on the safe side and skip merging the batch. return owsFailDebug("Overlapping IDs should always match within a single transaction.") } uniqueIds = otherUniqueIds.dropLast(overlappingCount) + uniqueIds mergeCanLoad(otherLoadBatch) case (let firstIndex?, nil): let overlappingCount = uniqueIds.endIndex - firstIndex guard uniqueIds.suffix(overlappingCount) == otherUniqueIds.prefix(overlappingCount) else { // If this breaks, it probably means `deletedInteractionIds` is broken (or // hit a race condition). Err on the safe side and skip merging the batch. return owsFailDebug("Overlapping IDs should always match within a single transaction.") } uniqueIds += otherUniqueIds.dropFirst(overlappingCount) mergeCanLoad(otherLoadBatch) case (let firstIndex?, let lastIndex?): guard uniqueIds[firstIndex...lastIndex] == otherUniqueIds[...] else { // If this breaks, it probably means `deletedInteractionIds` is broken (or // hit a race condition). Err on the safe side and skip merging the batch. return owsFailDebug("Overlapping IDs should always match within a single transaction.") } mergeCanLoad(otherLoadBatch) } } private mutating func mergeCanLoad(_ otherLoadBatch: MessageLoaderBatch) { // The merged range might know that it's hit the end even if the current // range doesn't. For example, if we fetch messages around a particular // point, that *might* include the latest message in the chat. However, if // we don't fetch *another* message, we won't know that we've hit the end. // If we merge a batch that already knows it hit the end, the merged batch // will also know that it's hit the end. canLoadNewer = canLoadNewer && otherLoadBatch.canLoadNewer canLoadOlder = canLoadOlder && otherLoadBatch.canLoadOlder } mutating func insertOlder(uniqueIds olderUniqueIds: any Sequence, didReachOldest: Bool) { uniqueIds = olderUniqueIds + uniqueIds if didReachOldest { canLoadOlder = false } } mutating func insertNewer(uniqueIds newerUniqueIds: any Sequence, didReachNewest: Bool) { uniqueIds += newerUniqueIds if didReachNewest { canLoadNewer = false } } }