517 lines
21 KiB
Swift
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
|
|
}
|
|
}
|