376 lines
15 KiB
Swift
376 lines
15 KiB
Swift
//
|
|
// Copyright (c) 2020 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
|
|
@objc
|
|
public class ConversationMessageMapping: NSObject {
|
|
|
|
private let interactionFinder: InteractionFinder
|
|
|
|
@objc
|
|
public var loadedUniqueIds: [String] {
|
|
return loadedInteractions.map { $0.uniqueId }
|
|
}
|
|
|
|
@objc
|
|
public private(set) var loadedInteractions: [TSInteraction] = []
|
|
|
|
@objc
|
|
public var canLoadOlder = false
|
|
|
|
@objc
|
|
public var canLoadNewer = false
|
|
|
|
private let thread: TSThread
|
|
|
|
@objc
|
|
public required init(thread: TSThread) {
|
|
self.thread = thread
|
|
self.interactionFinder = InteractionFinder(threadUniqueId: thread.uniqueId)
|
|
}
|
|
|
|
// 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 var initialLoadCount: Int {
|
|
let avgMessageHeight: CGFloat = 60
|
|
let referenceSize = UIScreen.main.bounds
|
|
let messageCountToFillScreen = (referenceSize.height / avgMessageHeight)
|
|
|
|
let result = Int(messageCountToFillScreen * 2)
|
|
Logger.verbose("initialLoadCount: \(result)")
|
|
guard result >= 10 else {
|
|
owsFailDebug("unexpectedly small initialLoadCount: \(result)")
|
|
return 10
|
|
}
|
|
return result
|
|
}
|
|
|
|
// After this size, we'll start unloading interactions
|
|
private let maxInteractionLimit: Int = 200
|
|
|
|
// oldest saved message in a conversation has an index of 0, the most recent message has index conversationCount - 1.
|
|
private var loadedIndexSet = IndexSet()
|
|
|
|
enum LoadWindowDirection {
|
|
case before(interactionUniqueId: String)
|
|
case after(interactionUniqueId: String)
|
|
case around(interactionUniqueId: String)
|
|
case newest
|
|
}
|
|
|
|
@objc(loadMessagePageAroundInteractionId:transaction:error:)
|
|
public func loadMessagePage(aroundInteractionId interactionUniqueId: String, transaction: SDSAnyReadTransaction) throws {
|
|
try ensureLoaded(.around(interactionUniqueId: interactionUniqueId),
|
|
count: initialLoadCount,
|
|
transaction: transaction)
|
|
}
|
|
|
|
@objc
|
|
public func loadNewerMessagePage(transaction: SDSAnyReadTransaction) throws {
|
|
guard let newestLoadedId = loadedUniqueIds.last else {
|
|
// empty convo
|
|
return
|
|
}
|
|
|
|
try ensureLoaded(.after(interactionUniqueId: newestLoadedId),
|
|
count: initialLoadCount * 2,
|
|
transaction: transaction)
|
|
}
|
|
|
|
@objc
|
|
public func loadOlderMessagePage(transaction: SDSAnyReadTransaction) throws {
|
|
guard let oldestLoadedId = loadedUniqueIds.first else {
|
|
// empty convo
|
|
return
|
|
}
|
|
|
|
try ensureLoaded(.before(interactionUniqueId: oldestLoadedId),
|
|
count: initialLoadCount * 2,
|
|
transaction: transaction)
|
|
}
|
|
|
|
@objc
|
|
public func loadNewestMessagePage(transaction: SDSAnyReadTransaction) throws {
|
|
try ensureLoaded(.newest,
|
|
count: initialLoadCount,
|
|
transaction: transaction)
|
|
}
|
|
|
|
@objc
|
|
public func loadInitialMessagePage(focusMessageId: String?, transaction: SDSAnyReadTransaction) throws {
|
|
try updateOldestUnreadInteraction(transaction: transaction)
|
|
|
|
if let focusMessageId = focusMessageId {
|
|
try ensureLoaded(.around(interactionUniqueId: focusMessageId),
|
|
count: initialLoadCount * 2,
|
|
transaction: transaction)
|
|
} else if let oldestUnreadInteraction = self.oldestUnreadInteraction {
|
|
try ensureLoaded(.around(interactionUniqueId: oldestUnreadInteraction.uniqueId),
|
|
count: initialLoadCount * 2,
|
|
transaction: transaction)
|
|
} else {
|
|
try loadNewestMessagePage(transaction: transaction)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private func ensureLoaded(_ direction: LoadWindowDirection, count: Int, transaction: SDSAnyReadTransaction) throws {
|
|
let conversationSize = interactionFinder.count(transaction: transaction)
|
|
|
|
let getDistanceFromEnd = { (interactionUniqueId: String) throws -> Int in
|
|
guard let sortIndex = try self.interactionFinder.sortIndex(interactionUniqueId: interactionUniqueId, transaction: transaction) else {
|
|
throw OWSAssertionError("viewIndex was unexpectedly nil")
|
|
}
|
|
return Int(sortIndex)
|
|
}
|
|
|
|
let lowerBound: Int
|
|
switch direction {
|
|
case .before(let interactionUniqueId):
|
|
let distanceFromEnd = try getDistanceFromEnd(interactionUniqueId)
|
|
lowerBound = distanceFromEnd - count + 1
|
|
case .after(let interactionUniqueId):
|
|
let distanceFromEnd = try getDistanceFromEnd(interactionUniqueId)
|
|
lowerBound = distanceFromEnd
|
|
case .around(let interactionUniqueId):
|
|
let distanceFromEnd = try getDistanceFromEnd(interactionUniqueId)
|
|
lowerBound = distanceFromEnd - count / 2
|
|
case .newest:
|
|
lowerBound = Int(conversationSize) - count
|
|
}
|
|
let upperBound = lowerBound + count
|
|
let requestRange = (lowerBound..<upperBound).clamped(to: 0..<Int(conversationSize))
|
|
let requestSet = IndexSet(integersIn: requestRange)
|
|
|
|
let unfetchedSet = requestSet.subtracting(loadedIndexSet)
|
|
guard unfetchedSet.count > 0 else {
|
|
Logger.debug("ignoring empty fetch request: \(unfetchedSet.count)")
|
|
return
|
|
}
|
|
|
|
// For perf we only want to fetch a substantially full batch...
|
|
let isSubstantialRequest = unfetchedSet.count > (requestSet.count / 2)
|
|
// ...but we always fulfill even small requests if we're getting just the tail end
|
|
let isFetchingEdge = unfetchedSet.contains(0) || unfetchedSet.contains(Int(conversationSize - 1))
|
|
|
|
guard isSubstantialRequest || isFetchingEdge else {
|
|
Logger.debug("ignoring small fetch request: \(unfetchedSet.count)")
|
|
return
|
|
}
|
|
|
|
let nsRange: NSRange = NSRange(location: unfetchedSet.min()!, length: unfetchedSet.count)
|
|
Logger.debug("fetching set: \(unfetchedSet), nsRange: \(nsRange)")
|
|
let newItems = try fetchInteractions(nsRange: nsRange, transaction: transaction)
|
|
|
|
switch direction {
|
|
case .before:
|
|
self.loadedIndexSet = loadedIndexSet.union(requestSet)
|
|
let items = (newItems + self.loadedInteractions)
|
|
let trimmedItems = items.prefix(maxInteractionLimit)
|
|
if (items.count != trimmedItems.count) {
|
|
let trimCount = items.count - trimmedItems.count
|
|
let trimmedSet = loadedIndexSet.suffix(trimCount)
|
|
loadedIndexSet.subtract(IndexSet(trimmedSet))
|
|
Logger.verbose("trimmed newest \(trimCount) items")
|
|
}
|
|
self.loadedInteractions = Array(trimmedItems)
|
|
case .after:
|
|
self.loadedIndexSet = loadedIndexSet.union(requestSet)
|
|
let items = (self.loadedInteractions + newItems)
|
|
let trimmedItems = items.suffix(maxInteractionLimit)
|
|
if (items.count != trimmedItems.count) {
|
|
let trimCount = items.count - trimmedItems.count
|
|
let trimmedSet = loadedIndexSet.prefix(trimCount)
|
|
loadedIndexSet.subtract(IndexSet(trimmedSet))
|
|
Logger.verbose("trimmed oldest \(trimCount) items")
|
|
}
|
|
self.loadedInteractions = Array(trimmedItems)
|
|
case .around, .newest:
|
|
// replace, rather than append, because the new results might not be contiguous
|
|
// with the existing loadedIndexSet
|
|
self.loadedIndexSet = requestSet
|
|
self.loadedInteractions = newItems
|
|
}
|
|
|
|
updateCanLoadMore(conversationSize: conversationSize)
|
|
}
|
|
|
|
@objc
|
|
public class ConversationMessageMappingDiff: NSObject {
|
|
@objc
|
|
public let addedItemIds: Set<String>
|
|
@objc
|
|
public let removedItemIds: Set<String>
|
|
@objc
|
|
public let updatedItemIds: Set<String>
|
|
|
|
init(addedItemIds: Set<String>, removedItemIds: Set<String>, updatedItemIds: Set<String>) {
|
|
self.addedItemIds = addedItemIds
|
|
self.removedItemIds = removedItemIds
|
|
self.updatedItemIds = updatedItemIds
|
|
}
|
|
}
|
|
|
|
// Updates and then calculates which items were inserted, removed or modified.
|
|
@objc
|
|
public func updateAndCalculateDiff(updatedInteractionIds: Set<String>,
|
|
transaction: SDSAnyReadTransaction) throws -> ConversationMessageMappingDiff {
|
|
let oldItemIds = Set(self.loadedUniqueIds)
|
|
try reloadInteractions(transaction: transaction)
|
|
let newItemIds = Set(self.loadedUniqueIds)
|
|
|
|
let removedItemIds = oldItemIds.subtracting(newItemIds)
|
|
let addedItemIds = newItemIds.subtracting(oldItemIds)
|
|
// We only notify for updated items that a) were previously loaded b) weren't also inserted or removed.
|
|
let exclusivelyUpdatedItemIds = updatedInteractionIds.subtracting(addedItemIds)
|
|
.subtracting(removedItemIds)
|
|
.intersection(oldItemIds)
|
|
|
|
return ConversationMessageMappingDiff(addedItemIds: addedItemIds,
|
|
removedItemIds: removedItemIds,
|
|
updatedItemIds: exclusivelyUpdatedItemIds)
|
|
}
|
|
|
|
func updateCanLoadMore(conversationSize: UInt) {
|
|
guard conversationSize > 0 else {
|
|
self.canLoadOlder = false
|
|
self.canLoadNewer = false
|
|
return
|
|
}
|
|
|
|
self.canLoadOlder = !loadedIndexSet.contains(0)
|
|
self.canLoadNewer = !loadedIndexSet.contains(Int(conversationSize) - 1)
|
|
Logger.verbose("canLoadOlder: \(canLoadOlder) canLoadNewer: \(canLoadNewer)")
|
|
}
|
|
|
|
private func fetchInteractions(nsRange: NSRange, transaction: SDSAnyReadTransaction) throws -> [TSInteraction] {
|
|
var newItems: [TSInteraction] = []
|
|
try interactionFinder.enumerateInteractions(range: nsRange, transaction: transaction) { (interaction: TSInteraction, _) in
|
|
newItems.append(interaction)
|
|
}
|
|
return newItems
|
|
}
|
|
|
|
@objc
|
|
var oldestUnreadInteraction: TSInteraction?
|
|
private func updateOldestUnreadInteraction(transaction: SDSAnyReadTransaction) throws {
|
|
self.oldestUnreadInteraction = try interactionFinder.oldestUnseenInteraction(transaction: transaction)
|
|
}
|
|
|
|
private func reloadInteractions(transaction: SDSAnyReadTransaction) throws {
|
|
if self.oldestUnreadInteraction == nil {
|
|
try updateOldestUnreadInteraction(transaction: transaction)
|
|
}
|
|
let conversationSize = interactionFinder.count(transaction: transaction)
|
|
|
|
let hasLoadedBottomEdge = !canLoadNewer
|
|
guard hasLoadedBottomEdge else {
|
|
let reloadingSet = loadedIndexSet
|
|
let nsRange: NSRange = NSRange(location: reloadingSet.min()!, length: reloadingSet.count)
|
|
Logger.debug("reloadingSet: \(reloadingSet), nsRange: \(nsRange)")
|
|
loadedInteractions = try fetchInteractions(nsRange: nsRange, transaction: transaction)
|
|
updateCanLoadMore(conversationSize: conversationSize)
|
|
return
|
|
}
|
|
|
|
guard let oldestLoadedIndex = loadedIndexSet.min() else {
|
|
// no existing interactions until now
|
|
try loadInitialMessagePage(focusMessageId: nil, transaction: transaction)
|
|
return
|
|
}
|
|
|
|
let updatingSet = IndexSet(integersIn: oldestLoadedIndex..<Int(conversationSize))
|
|
guard updatingSet.count > 0 else {
|
|
Logger.verbose("conversation is now empty")
|
|
loadedIndexSet = []
|
|
loadedInteractions = []
|
|
updateCanLoadMore(conversationSize: conversationSize)
|
|
return
|
|
}
|
|
|
|
loadedIndexSet = updatingSet
|
|
let nsRange: NSRange = NSRange(location: updatingSet.min()!, length: updatingSet.count)
|
|
Logger.debug("updatingSet: \(updatingSet), nsRange: \(nsRange)")
|
|
loadedInteractions = try fetchInteractions(nsRange: nsRange, transaction: transaction)
|
|
updateCanLoadMore(conversationSize: conversationSize)
|
|
}
|
|
|
|
// For performance reasons, the database modification notifications are used
|
|
// to determine which items were modified. If YapDatabase ever changes the
|
|
// structure or semantics of these notifications, we'll need to update this
|
|
// code to reflect that.
|
|
// POST GRDB: remove this yap-only method
|
|
@objc
|
|
public func updatedItemIds(for notifications: [NSNotification]) -> Set<String> {
|
|
// We'll move this into the Yap adapter when addressing updates/observation
|
|
let viewName: String = TSMessageDatabaseViewExtensionName
|
|
|
|
var updatedItemIds = Set<String>()
|
|
for notification in notifications {
|
|
// Unpack the YDB notification, looking for row changes.
|
|
guard let userInfo =
|
|
notification.userInfo else {
|
|
owsFailDebug("Missing userInfo.")
|
|
continue
|
|
}
|
|
guard let viewChangesets =
|
|
userInfo[YapDatabaseExtensionsKey] as? NSDictionary else {
|
|
// No changes for any views, skip.
|
|
continue
|
|
}
|
|
guard let changeset =
|
|
viewChangesets[viewName] as? NSDictionary else {
|
|
// No changes for this view, skip.
|
|
continue
|
|
}
|
|
// This constant matches a private constant in YDB.
|
|
let changeset_key_changes: String = "changes"
|
|
guard let changesetChanges = changeset[changeset_key_changes] as? [Any] else {
|
|
owsFailDebug("Missing changeset changes.")
|
|
continue
|
|
}
|
|
for change in changesetChanges {
|
|
if change as? YapDatabaseViewSectionChange != nil {
|
|
// Ignore.
|
|
} else if let rowChange = change as? YapDatabaseViewRowChange {
|
|
updatedItemIds.insert(rowChange.collectionKey.key)
|
|
} else {
|
|
owsFailDebug("Invalid change: \(type(of: change)).")
|
|
continue
|
|
}
|
|
}
|
|
}
|
|
|
|
return updatedItemIds
|
|
}
|
|
}
|
|
|
|
@objc
|
|
public class ConversationScrollState: NSObject {
|
|
|
|
@objc
|
|
public let referenceViewItem: ConversationViewItem
|
|
|
|
@objc
|
|
public let referenceFrame: CGRect
|
|
|
|
@objc
|
|
public let contentOffset: CGPoint
|
|
|
|
@objc
|
|
public init(referenceViewItem: ConversationViewItem, referenceFrame: CGRect, contentOffset: CGPoint) {
|
|
self.referenceViewItem = referenceViewItem
|
|
self.referenceFrame = referenceFrame
|
|
self.contentOffset = contentOffset
|
|
}
|
|
}
|