TM-SGNL-iOS/Signal/ConversationView/Loading/MessageLoader.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

517 lines
21 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 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
}
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: -
class MessageLoader {
private let batchFetcher: MessageLoaderBatchFetcher
private let interactionFetchers: [MessageLoaderInteractionFetcher]
public private(set) var loadedInteractions: [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) public 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) public 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.
public 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
}
public func loadMessagePage(
aroundInteractionId interactionUniqueId: String,
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
tx: DBReadTransaction
) throws {
try ensureLoaded(
.around(interactionUniqueId: interactionUniqueId),
count: initialLoadCount,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: tx
)
}
public func loadNewerMessagePage(
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
tx: DBReadTransaction
) throws {
try ensureLoaded(
.newer,
count: initialLoadCount * 2,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: tx
)
}
public func loadOlderMessagePage(
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
tx: DBReadTransaction
) throws {
try ensureLoaded(
.older,
count: initialLoadCount * 2,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: tx
)
}
public func loadNewestMessagePage(
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
tx: DBReadTransaction
) throws {
try ensureLoaded(
.newest,
count: initialLoadCount,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: tx
)
}
public func loadInitialMessagePage(
focusMessageId: String?,
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
tx: DBReadTransaction
) throws {
if let focusMessageId {
try ensureLoaded(
.around(interactionUniqueId: focusMessageId),
count: initialLoadCount,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: tx
)
} else {
try loadNewestMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: tx
)
}
}
public func loadSameLocation(
reusableInteractions: [String: TSInteraction],
deletedInteractionIds: Set<String>?,
tx: DBReadTransaction
) throws {
try ensureLoaded(
.sameLocation,
count: max(initialLoadCount, loadedInteractions.count),
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
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>?,
tx: DBReadTransaction
) throws {
owsAssertDebug(count > 0)
let count = count.clamp(1, Constants.maxInteractionCount)
let loadBatch = try buildLoadBatch(
direction,
count: count,
deletedInteractionIds: deletedInteractionIds,
tx: tx
)
loadedInteractions = fetchInteractions(
uniqueIds: loadBatch.uniqueIds,
reusableInteractions: reusableInteractions,
tx: tx
)
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
)
}
/// Expands `batch` with `count` messages preceding `rowId`.
@discardableResult
func fetchOlder(before rowId: Int64, count: Int, batch: inout MessageLoaderBatch) throws -> Int {
let uniqueIds: [String] = try fetch(filter: .before(rowId), limit: count)
batch.insertOlder(uniqueIds: uniqueIds, didReachOldest: uniqueIds.count < count)
batch.trimNewer()
return uniqueIds.count
}
/// Expands `batch` with `count` messages succeeding `rowId`.
@discardableResult
func fetchNewer(after rowId: Int64, count: Int, batch: inout MessageLoaderBatch) throws -> Int {
let uniqueIds: [String] = try fetch(filter: .after(rowId), limit: count)
batch.insertNewer(uniqueIds: uniqueIds, didReachNewest: uniqueIds.count < count)
batch.trimOlder()
return uniqueIds.count
}
/// Fetches uniqueIds in the range of provided rowIds.
func fetchRange(_ rowIds: ClosedRange<Int64>) throws -> [String] {
return try fetch(filter: .range(rowIds), limit: rowIds.count)
}
/// 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)
try fetchNewer(after: rowId, count: count - olderCount, batch: &batch)
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)
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)
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)
}
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)
}
}
}
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()
}
}
// MARK: -
extension InteractionReadCache: MessageLoaderInteractionFetcher {
func fetchInteractions(for uniqueIds: [String], tx: DBReadTransaction) -> [String: TSInteraction] {
return getInteractionsIfInCache(for: Array(uniqueIds), transaction: SDSDB.shimOnlyBridge(tx))
}
}
// MARK: -
class SDSInteractionFetcherImpl: MessageLoaderInteractionFetcher {
func fetchInteractions(for uniqueIds: [String], tx: DBReadTransaction) -> [String: TSInteraction] {
let fetchedInteractions = InteractionFinder.interactions(
withInteractionIds: Set(uniqueIds),
transaction: SDSDB.shimOnlyBridge(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: SDSDB.shimOnlyBridge(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)
// 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 {
// 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)
// 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
// 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
}
}
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
}
}