938 lines
36 KiB
Swift
938 lines
36 KiB
Swift
//
|
|
// 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<String>?,
|
|
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<String>?,
|
|
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<String>?,
|
|
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<String>?,
|
|
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<String>?,
|
|
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<String>?,
|
|
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<String>?,
|
|
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<String>?,
|
|
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<Int64>) 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<Int64>, 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<String, TSInteraction>(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<String>, didReachOldest: Bool) {
|
|
uniqueIds = olderUniqueIds + uniqueIds
|
|
if didReachOldest {
|
|
canLoadOlder = false
|
|
}
|
|
}
|
|
|
|
mutating func insertNewer(uniqueIds newerUniqueIds: any Sequence<String>, didReachNewest: Bool) {
|
|
uniqueIds += newerUniqueIds
|
|
if didReachNewest {
|
|
canLoadNewer = false
|
|
}
|
|
}
|
|
}
|