Improve handling of many large collapse sets
This commit is contained in:
parent
82ce1ead86
commit
800a5cc0bc
@ -148,15 +148,7 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
|
||||
componentView.outerStack.isAccessibilityElement = true
|
||||
componentView.outerStack.accessibilityLabel = titleString
|
||||
componentView.outerStack.accessibilityTraits = .button
|
||||
componentView.outerStack.accessibilityHint = collapseSet.isExpanded
|
||||
? OWSLocalizedString(
|
||||
"COLLAPSE_SET_ACCESSIBILITY_HINT_COLLAPSE",
|
||||
comment: "VoiceOver hint for an expanded collapse set button.",
|
||||
)
|
||||
: OWSLocalizedString(
|
||||
"COLLAPSE_SET_ACCESSIBILITY_HINT_EXPAND",
|
||||
comment: "VoiceOver hint for a collapsed collapse set button.",
|
||||
)
|
||||
componentView.outerStack.accessibilityHint = accessibilityHint(isExpanded: collapseSet.isExpanded)
|
||||
}
|
||||
|
||||
// MARK: - Events
|
||||
@ -187,6 +179,7 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
|
||||
componentView.chevronLabel.transform = willBeExpanded
|
||||
? CGAffineTransform(rotationAngle: expandedRotation)
|
||||
: .identity
|
||||
componentView.outerStack.accessibilityHint = accessibilityHint(isExpanded: willBeExpanded)
|
||||
|
||||
let animation = CABasicAnimation(keyPath: "transform.rotation.z")
|
||||
animation.fromValue = fromAngle
|
||||
@ -347,6 +340,18 @@ class CVComponentCollapseSet: CVComponentBase, CVRootComponent {
|
||||
)
|
||||
}
|
||||
|
||||
private func accessibilityHint(isExpanded: Bool) -> String {
|
||||
isExpanded
|
||||
? OWSLocalizedString(
|
||||
"COLLAPSE_SET_ACCESSIBILITY_HINT_COLLAPSE",
|
||||
comment: "VoiceOver hint for an expanded collapse set button.",
|
||||
)
|
||||
: OWSLocalizedString(
|
||||
"COLLAPSE_SET_ACCESSIBILITY_HINT_EXPAND",
|
||||
comment: "VoiceOver hint for a collapsed collapse set button.",
|
||||
)
|
||||
}
|
||||
|
||||
private func summaryLabel(
|
||||
count: Int,
|
||||
type: CollapseSetInteraction.MessagesType,
|
||||
|
||||
@ -528,7 +528,6 @@ public struct CVComponentState: Equatable {
|
||||
static func ==(lhs: CollapseSet, rhs: CollapseSet) -> Bool {
|
||||
return lhs.collapsedInteractions.map(\.uniqueId) == rhs.collapsedInteractions.map(\.uniqueId)
|
||||
&& lhs.collapseSetType == rhs.collapseSetType
|
||||
&& lhs.isExpanded == rhs.isExpanded
|
||||
&& lhs.finalTimerDescription == rhs.finalTimerDescription
|
||||
}
|
||||
}
|
||||
@ -1197,7 +1196,7 @@ private extension CVComponentState.Builder {
|
||||
self.collapseSet = CVComponentState.CollapseSet(
|
||||
collapsedInteractions: collapseSetInteraction.collapsedInteractions,
|
||||
collapseSetType: collapseSetInteraction.collapseSetType,
|
||||
isExpanded: collapseSetInteraction.isExpanded,
|
||||
isExpanded: viewStateSnapshot.expandedCollapseSetIds.contains(collapseSetInteraction.uniqueId),
|
||||
finalTimerDescription: collapseSetInteraction.finalTimerDescription,
|
||||
)
|
||||
return build()
|
||||
|
||||
@ -33,6 +33,8 @@ extension ConversationViewController: CVComponentDelegate {
|
||||
viewState.expandedCollapseSets.insert(collapseSetId)
|
||||
}
|
||||
loadCoordinator.enqueueReload(
|
||||
updatedInteractionIds: [collapseSetId],
|
||||
deletedInteractionIds: [],
|
||||
preferredScrollContinuityAnchorInteractionId: collapseSetId,
|
||||
)
|
||||
}
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
|
||||
import SignalServiceKit
|
||||
|
||||
class CollapseSetInteraction: TSInteraction {
|
||||
final class CollapseSetInteraction: TSInteraction {
|
||||
|
||||
enum MessagesType: Equatable {
|
||||
case groupUpdates
|
||||
@ -18,8 +18,6 @@ class CollapseSetInteraction: TSInteraction {
|
||||
|
||||
let collapseSetType: MessagesType
|
||||
|
||||
let isExpanded: Bool
|
||||
|
||||
let finalTimerDescription: String?
|
||||
|
||||
override var isDynamicInteraction: Bool { true }
|
||||
@ -32,12 +30,10 @@ class CollapseSetInteraction: TSInteraction {
|
||||
thread: TSThread,
|
||||
collapsedInteractions: [TSInteraction],
|
||||
collapseSetType: MessagesType,
|
||||
isExpanded: Bool = false,
|
||||
) {
|
||||
owsPrecondition(!collapsedInteractions.isEmpty)
|
||||
self.collapsedInteractions = collapsedInteractions
|
||||
self.collapseSetType = collapseSetType
|
||||
self.isExpanded = isExpanded
|
||||
self.finalTimerDescription = Self.disappearingTimerDescription(
|
||||
for: collapsedInteractions,
|
||||
type: collapseSetType,
|
||||
@ -45,13 +41,17 @@ class CollapseSetInteraction: TSInteraction {
|
||||
|
||||
let firstInteraction = collapsedInteractions[0]
|
||||
super.init(
|
||||
customUniqueId: "CollapseSet_\(firstInteraction.timestamp)",
|
||||
customUniqueId: Self.id(firstInteraction: firstInteraction),
|
||||
timestamp: firstInteraction.timestamp,
|
||||
receivedAtTimestamp: firstInteraction.receivedAtTimestamp,
|
||||
thread: thread,
|
||||
)
|
||||
}
|
||||
|
||||
static func id(firstInteraction: TSInteraction) -> String {
|
||||
"CollapseSet_\(firstInteraction.timestamp)"
|
||||
}
|
||||
|
||||
private static func disappearingTimerDescription(
|
||||
for interactions: [TSInteraction],
|
||||
type: MessagesType,
|
||||
|
||||
@ -427,6 +427,23 @@ public class CVLoadCoordinator: NSObject {
|
||||
loadIfNecessary()
|
||||
}
|
||||
|
||||
public func enqueueReload(
|
||||
updatedInteractionIds: Set<String>,
|
||||
deletedInteractionIds: Set<String>,
|
||||
preferredScrollContinuityAnchorInteractionId: String,
|
||||
) {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
loadRequestBuilder.reload(
|
||||
updatedInteractionIds: updatedInteractionIds,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
)
|
||||
loadRequestBuilder.reload(
|
||||
preferredScrollContinuityAnchorInteractionId: preferredScrollContinuityAnchorInteractionId,
|
||||
)
|
||||
loadIfNecessary()
|
||||
}
|
||||
|
||||
public func enqueueReloadWithoutCaches() {
|
||||
AssertIsOnMainThread()
|
||||
|
||||
|
||||
@ -80,6 +80,10 @@ public class CVLoader: NSObject {
|
||||
localAci: localAci,
|
||||
transaction: transaction,
|
||||
)
|
||||
let preprocessingContext = MessageLoaderPreprocessingContext(
|
||||
thread: loadContext.thread,
|
||||
oldestUnreadSortId: viewStateSnapshot.oldestUnreadMessageSortId,
|
||||
)
|
||||
|
||||
// Don't cache in the reset() case.
|
||||
let canReuseInteractions = loadRequest.canReuseInteractionModels && !loadRequest.didReset
|
||||
@ -132,30 +136,35 @@ public class CVLoader: NSObject {
|
||||
focusMessageId: focusMessageIdOnOpen,
|
||||
reusableInteractions: [:],
|
||||
deletedInteractionIds: [],
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: transaction,
|
||||
)
|
||||
case .loadSameLocation:
|
||||
try messageLoader.loadSameLocation(
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: transaction,
|
||||
)
|
||||
case .loadOlder:
|
||||
try messageLoader.loadOlderMessagePage(
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: transaction,
|
||||
)
|
||||
case .loadNewer:
|
||||
try messageLoader.loadNewerMessagePage(
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: transaction,
|
||||
)
|
||||
case .loadNewest:
|
||||
try messageLoader.loadNewestMessagePage(
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: transaction,
|
||||
)
|
||||
case .loadPageAroundInteraction(let interactionId, _):
|
||||
@ -163,6 +172,7 @@ public class CVLoader: NSObject {
|
||||
aroundInteractionId: interactionId,
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: transaction,
|
||||
)
|
||||
}
|
||||
@ -171,36 +181,18 @@ public class CVLoader: NSObject {
|
||||
throw error
|
||||
}
|
||||
|
||||
let initialLoadCount = messageLoader.loadedInteractions.count
|
||||
|
||||
var processedInteractions = Self.preprocessInteractions(
|
||||
messageLoader.loadedInteractions,
|
||||
loadContext: loadContext,
|
||||
)
|
||||
|
||||
if case .loadInitialMapping = loadRequest.loadType {
|
||||
let maxExtraLoads = 5
|
||||
var extraLoads = 0
|
||||
while
|
||||
processedInteractions.count < initialLoadCount,
|
||||
messageLoader.canLoadOlder,
|
||||
extraLoads < maxExtraLoads
|
||||
let expandedInteractions = messageLoader.loadedDisplayableInteractions.flatMap { interaction in
|
||||
if
|
||||
let collapseSet = interaction as? CollapseSetInteraction,
|
||||
viewStateSnapshot.expandedCollapseSetIds.contains(collapseSet.uniqueId)
|
||||
{
|
||||
try messageLoader.loadOlderMessagePage(
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
tx: transaction,
|
||||
)
|
||||
processedInteractions = Self.preprocessInteractions(
|
||||
messageLoader.loadedInteractions,
|
||||
loadContext: loadContext,
|
||||
)
|
||||
extraLoads += 1
|
||||
return [collapseSet] + collapseSet.collapsedInteractions
|
||||
}
|
||||
return [interaction]
|
||||
}
|
||||
|
||||
let itemModels = self.buildItemModels(
|
||||
interactions: processedInteractions,
|
||||
interactions: expandedInteractions,
|
||||
loadContext: loadContext,
|
||||
updatedInteractionIds: updatedInteractionIds,
|
||||
localAci: localAci,
|
||||
@ -272,214 +264,6 @@ public class CVLoader: NSObject {
|
||||
return itemModelBuilder.buildItems(localAci: localAci, interactions: interactions)
|
||||
}
|
||||
|
||||
// MARK: - Interaction Preprocessing
|
||||
|
||||
private static let maxCollapseSetSize = 50
|
||||
|
||||
/// Takes a list of interactions and applies preprocessing before the expensive task of creating `CVItemModel`s via `CVItemModelBuilder.buildItems`.
|
||||
///
|
||||
/// 1. Inserts date headers
|
||||
/// 2. Inserts unread indicator
|
||||
/// 3. Collapses chat events
|
||||
private static func preprocessInteractions(
|
||||
_ interactions: [TSInteraction],
|
||||
loadContext: CVLoadContext,
|
||||
) -> [TSInteraction] {
|
||||
let thread = loadContext.thread
|
||||
let isGroupThread = thread.isGroupThread
|
||||
let expandedCollapseSets = loadContext.viewStateSnapshot.expandedCollapseSets
|
||||
let oldestUnreadSortId = loadContext.viewStateSnapshot.oldestUnreadMessageSortId
|
||||
|
||||
let todayDate = Date()
|
||||
var result = [TSInteraction]()
|
||||
var currentRun = [TSInteraction]()
|
||||
var currentRunType: CollapseSetInteraction.MessagesType?
|
||||
var pastUnreadIndicator = false
|
||||
var shouldShowDateOnNextViewItem = true
|
||||
var previousDaysBeforeToday: Int?
|
||||
|
||||
func finalizeSet() {
|
||||
defer {
|
||||
currentRun.removeAll()
|
||||
currentRunType = nil
|
||||
}
|
||||
guard currentRun.count >= 2, let runType = currentRunType else {
|
||||
result.append(contentsOf: currentRun)
|
||||
return
|
||||
}
|
||||
let collapseId = "CollapseSet_\(currentRun[0].timestamp)"
|
||||
let isExpanded = expandedCollapseSets.contains(collapseId)
|
||||
let collapseSetInteraction = CollapseSetInteraction(
|
||||
thread: thread,
|
||||
collapsedInteractions: currentRun,
|
||||
collapseSetType: runType,
|
||||
isExpanded: isExpanded,
|
||||
)
|
||||
result.append(collapseSetInteraction)
|
||||
if isExpanded {
|
||||
result.append(contentsOf: currentRun)
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
result.append(DateHeaderInteraction(thread: thread, timestamp: timestamp))
|
||||
shouldShowDateOnNextViewItem = false
|
||||
}
|
||||
previousDaysBeforeToday = daysBeforeToday
|
||||
|
||||
// Only insert one unread indicator and don't collapse unread events
|
||||
if pastUnreadIndicator {
|
||||
result.append(interaction)
|
||||
continue
|
||||
}
|
||||
|
||||
if let oldestUnreadSortId, oldestUnreadSortId <= interaction.sortId {
|
||||
finalizeSet()
|
||||
let unreadIndicatorInteraction = UnreadIndicatorInteraction(
|
||||
thread: thread,
|
||||
timestamp: timestamp,
|
||||
receivedAtTimestamp: interaction.receivedAtTimestamp,
|
||||
)
|
||||
result.append(unreadIndicatorInteraction)
|
||||
pastUnreadIndicator = true
|
||||
result.append(interaction)
|
||||
continue
|
||||
}
|
||||
|
||||
guard BuildFlags.collapsingChatEvents else {
|
||||
result.append(interaction)
|
||||
continue
|
||||
}
|
||||
|
||||
let collapseType = collapseSetType(for: interaction, isGroupThread: isGroupThread)
|
||||
if let collapseType {
|
||||
let isDifferentSetThanCurrentRun = currentRunType != nil && currentRunType != collapseType
|
||||
let exceededCurrentRunLimit = currentRun.count >= maxCollapseSetSize
|
||||
if isDifferentSetThanCurrentRun || exceededCurrentRunLimit {
|
||||
finalizeSet()
|
||||
}
|
||||
currentRun.append(interaction)
|
||||
currentRunType = collapseType
|
||||
} else {
|
||||
finalizeSet()
|
||||
result.append(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: -
|
||||
|
||||
#if USE_DEBUG_UI
|
||||
|
||||
public static func debugui_buildStandaloneRenderItem(
|
||||
|
||||
@ -42,7 +42,7 @@ struct CVViewStateSnapshot {
|
||||
let hasActiveCall: Bool
|
||||
let currentGroupThreadCallGroupId: GroupIdentifier?
|
||||
|
||||
let expandedCollapseSets: Set<String>
|
||||
let expandedCollapseSetIds: Set<String>
|
||||
|
||||
private static var currentCallProvider: any CurrentCallProvider { DependenciesBridge.shared.currentCallProvider }
|
||||
|
||||
@ -64,7 +64,7 @@ struct CVViewStateSnapshot {
|
||||
oldestUnreadMessageSortId: oldestUnreadMessageSortId,
|
||||
hasActiveCall: currentCallProvider.hasCurrentCall,
|
||||
currentGroupThreadCallGroupId: currentCallProvider.currentGroupThreadCallGroupId,
|
||||
expandedCollapseSets: viewState.expandedCollapseSets,
|
||||
expandedCollapseSetIds: viewState.expandedCollapseSets,
|
||||
)
|
||||
}
|
||||
|
||||
@ -84,7 +84,7 @@ struct CVViewStateSnapshot {
|
||||
oldestUnreadMessageSortId: nil,
|
||||
hasActiveCall: false,
|
||||
currentGroupThreadCallGroupId: nil,
|
||||
expandedCollapseSets: [],
|
||||
expandedCollapseSetIds: [],
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,11 +7,13 @@ 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.
|
||||
/// 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 maxInteractionCount = 500
|
||||
static let maxDisplayableInteractionCount = 500
|
||||
|
||||
static let maxCollapseSetSize = 50
|
||||
}
|
||||
|
||||
protocol MessageLoaderBatchFetcher {
|
||||
@ -28,11 +30,19 @@ protocol MessageLoaderInteractionFetcher {
|
||||
|
||||
// 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.
|
||||
@ -90,10 +100,61 @@ class MessageLoader {
|
||||
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(
|
||||
@ -101,6 +162,7 @@ class MessageLoader {
|
||||
count: initialLoadCount,
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
@ -108,6 +170,7 @@ class MessageLoader {
|
||||
func loadNewerMessagePage(
|
||||
reusableInteractions: [String: TSInteraction],
|
||||
deletedInteractionIds: Set<String>?,
|
||||
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
|
||||
tx: DBReadTransaction,
|
||||
) throws {
|
||||
try ensureLoaded(
|
||||
@ -115,6 +178,7 @@ class MessageLoader {
|
||||
count: initialLoadCount * 2,
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
@ -122,6 +186,7 @@ class MessageLoader {
|
||||
func loadOlderMessagePage(
|
||||
reusableInteractions: [String: TSInteraction],
|
||||
deletedInteractionIds: Set<String>?,
|
||||
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
|
||||
tx: DBReadTransaction,
|
||||
) throws {
|
||||
try ensureLoaded(
|
||||
@ -129,6 +194,7 @@ class MessageLoader {
|
||||
count: initialLoadCount * 2,
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
@ -136,6 +202,7 @@ class MessageLoader {
|
||||
func loadNewestMessagePage(
|
||||
reusableInteractions: [String: TSInteraction],
|
||||
deletedInteractionIds: Set<String>?,
|
||||
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
|
||||
tx: DBReadTransaction,
|
||||
) throws {
|
||||
try ensureLoaded(
|
||||
@ -143,6 +210,7 @@ class MessageLoader {
|
||||
count: initialLoadCount,
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
@ -151,6 +219,7 @@ class MessageLoader {
|
||||
focusMessageId: String?,
|
||||
reusableInteractions: [String: TSInteraction],
|
||||
deletedInteractionIds: Set<String>?,
|
||||
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
|
||||
tx: DBReadTransaction,
|
||||
) throws {
|
||||
if let focusMessageId {
|
||||
@ -159,12 +228,14 @@ class MessageLoader {
|
||||
count: initialLoadCount,
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: tx,
|
||||
)
|
||||
} else {
|
||||
try loadNewestMessagePage(
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
@ -173,13 +244,15 @@ class MessageLoader {
|
||||
func loadSameLocation(
|
||||
reusableInteractions: [String: TSInteraction],
|
||||
deletedInteractionIds: Set<String>?,
|
||||
preprocessingContext: MessageLoaderPreprocessingContext? = nil,
|
||||
tx: DBReadTransaction,
|
||||
) throws {
|
||||
try ensureLoaded(
|
||||
.sameLocation,
|
||||
count: max(initialLoadCount, loadedInteractions.count),
|
||||
count: max(initialLoadCount, loadedDisplayableInteractions.count),
|
||||
reusableInteractions: reusableInteractions,
|
||||
deletedInteractionIds: deletedInteractionIds,
|
||||
preprocessingContext: preprocessingContext,
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
@ -195,21 +268,122 @@ class MessageLoader {
|
||||
count: Int,
|
||||
reusableInteractions: [String: TSInteraction],
|
||||
deletedInteractionIds: Set<String>?,
|
||||
preprocessingContext: MessageLoaderPreprocessingContext?,
|
||||
tx: DBReadTransaction,
|
||||
) throws {
|
||||
owsAssertDebug(count > 0)
|
||||
let count = count.clamp(1, Constants.maxInteractionCount)
|
||||
let loadBatch = try buildLoadBatch(
|
||||
|
||||
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,
|
||||
)
|
||||
loadedInteractions = fetchInteractions(
|
||||
uniqueIds: loadBatch.uniqueIds,
|
||||
|
||||
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
|
||||
}
|
||||
@ -228,24 +402,6 @@ class MessageLoader {
|
||||
)
|
||||
}
|
||||
|
||||
/// 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)
|
||||
@ -265,8 +421,8 @@ class MessageLoader {
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
@ -311,7 +467,7 @@ class MessageLoader {
|
||||
return batch
|
||||
case .older:
|
||||
var batch = priorLoad.batch
|
||||
try fetchOlder(before: priorLoad.range.lowerBound, count: count, batch: &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
|
||||
@ -319,13 +475,13 @@ class MessageLoader {
|
||||
fallthrough
|
||||
case .newer:
|
||||
var batch = priorLoad.batch
|
||||
try fetchNewer(after: priorLoad.range.upperBound, count: count, batch: &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)
|
||||
try fetchNewer(after: priorLoad.range.upperBound, count: initialLoadCount, batch: &batch)
|
||||
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):
|
||||
@ -343,6 +499,32 @@ class MessageLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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] = [:],
|
||||
@ -360,6 +542,268 @@ class MessageLoader {
|
||||
}
|
||||
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: -
|
||||
@ -447,8 +891,6 @@ struct MessageLoaderBatch {
|
||||
}
|
||||
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 {
|
||||
@ -458,8 +900,6 @@ struct MessageLoaderBatch {
|
||||
}
|
||||
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
|
||||
@ -494,24 +934,4 @@ struct MessageLoaderBatch {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@ -45,6 +45,62 @@ class DebugUIMisc: DebugUIPage {
|
||||
}
|
||||
}),
|
||||
]
|
||||
|
||||
if let groupThread = thread as? TSGroupThread {
|
||||
items += [
|
||||
OWSTableItem(title: "Insert 50 group update messages", actionBlock: {
|
||||
let updateItems: [TSInfoMessage.PersistableGroupUpdateItem] = [
|
||||
.genericUpdateByLocalUser,
|
||||
.genericUpdateByUnknownUser,
|
||||
.nameRemovedByLocalUser,
|
||||
.nameRemovedByUnknownUser,
|
||||
.avatarChangedByLocalUser,
|
||||
.avatarChangedByUnknownUser,
|
||||
.avatarRemovedByLocalUser,
|
||||
.avatarRemovedByUnknownUser,
|
||||
.localUserLeft,
|
||||
.localUserRemovedByUnknownUser,
|
||||
.localUserWasInvitedByLocalUser,
|
||||
.localUserWasInvitedByUnknownUser,
|
||||
.localUserAcceptedInviteFromUnknownUser,
|
||||
.localUserJoined,
|
||||
.localUserAddedByLocalUser,
|
||||
.localUserAddedByUnknownUser,
|
||||
.localUserDeclinedInviteFromUnknownUser,
|
||||
.localUserInviteRevokedByUnknownUser,
|
||||
.localUserRequestedToJoin,
|
||||
.localUserRequestApprovedByUnknownUser,
|
||||
.localUserRequestCanceledByLocalUser,
|
||||
.localUserRequestRejectedByUnknownUser,
|
||||
.inviteLinkResetByLocalUser,
|
||||
.inviteLinkResetByUnknownUser,
|
||||
.inviteLinkEnabledWithoutApprovalByLocalUser,
|
||||
.inviteLinkEnabledWithApprovalByLocalUser,
|
||||
.inviteLinkDisabledByLocalUser,
|
||||
.inviteLinkApprovalDisabledByLocalUser,
|
||||
.inviteLinkApprovalEnabledByLocalUser,
|
||||
.localUserJoinedViaInviteLink,
|
||||
.wasMigrated,
|
||||
.localUserInvitedAfterMigration,
|
||||
.createdByLocalUser,
|
||||
.createdByUnknownUser,
|
||||
.inviteFriendsToNewlyCreatedGroup,
|
||||
].shuffled()
|
||||
SSKEnvironment.shared.databaseStorageRef.write { tx in
|
||||
for i in 0..<50 {
|
||||
let item = updateItems[i % updateItems.count]
|
||||
let infoMessage = TSInfoMessage(
|
||||
thread: groupThread,
|
||||
messageType: .typeGroupUpdate,
|
||||
infoMessageUserInfo: [.groupUpdateItems: TSInfoMessage.PersistableGroupUpdateItemsWrapper([item])],
|
||||
)
|
||||
infoMessage.anyInsert(transaction: tx)
|
||||
}
|
||||
}
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
return OWSTableSection(title: name, items: items)
|
||||
}
|
||||
|
||||
|
||||
@ -59,11 +59,87 @@ class MessageLoaderTest: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
private func createInfoMessage(
|
||||
rowId: Int64,
|
||||
thread: TSThread,
|
||||
messageType: TSInfoMessageType,
|
||||
) -> TSInteraction {
|
||||
return TSInfoMessage(
|
||||
grdbId: rowId,
|
||||
uniqueId: UUID().uuidString,
|
||||
receivedAtTimestamp: UInt64(rowId),
|
||||
sortId: UInt64(rowId),
|
||||
timestamp: UInt64(rowId),
|
||||
uniqueThreadId: thread.uniqueId,
|
||||
body: nil,
|
||||
bodyRanges: nil,
|
||||
contactShare: nil,
|
||||
deprecated_attachmentIds: nil,
|
||||
editState: .none,
|
||||
expireStartedAt: 0,
|
||||
expireTimerVersion: nil,
|
||||
expiresAt: 0,
|
||||
expiresInSeconds: 0,
|
||||
giftBadge: nil,
|
||||
isGroupStoryReply: false,
|
||||
isPoll: false,
|
||||
isSmsMessageRestoredFromBackup: false,
|
||||
isViewOnceComplete: false,
|
||||
isViewOnceMessage: false,
|
||||
linkPreview: nil,
|
||||
messageSticker: nil,
|
||||
quotedMessage: nil,
|
||||
storedShouldStartExpireTimer: false,
|
||||
storyAuthorUuidString: nil,
|
||||
storyReactionEmoji: nil,
|
||||
storyTimestamp: nil,
|
||||
wasRemotelyDeleted: false,
|
||||
customMessage: nil,
|
||||
infoMessageUserInfo: nil,
|
||||
messageType: messageType,
|
||||
read: true,
|
||||
serverGuid: nil,
|
||||
unregisteredAddress: nil,
|
||||
)
|
||||
}
|
||||
|
||||
private func createInteraction(rowId: Int64, thread: TSThread) -> TSInteraction {
|
||||
return createInfoMessage(rowId: rowId, thread: thread, messageType: .userJoinedSignal)
|
||||
}
|
||||
|
||||
private func createCollapsibleInteraction(rowId: Int64, thread: TSThread) -> TSInteraction {
|
||||
return createInfoMessage(rowId: rowId, thread: thread, messageType: .typeDisappearingMessagesUpdate)
|
||||
}
|
||||
|
||||
private func createCollapsibleInteractions(_ count: Int64, thread: TSThread) -> [TSInteraction] {
|
||||
return ((1 as Int64)...count).map { rowId in
|
||||
createCollapsibleInteraction(rowId: rowId, thread: thread)
|
||||
}
|
||||
}
|
||||
|
||||
private func createMixedInteractions(_ chunkCount: Int64, thread: TSThread) -> [TSInteraction] {
|
||||
return ((0 as Int64)..<chunkCount).flatMap { chunkIndex -> [TSInteraction] in
|
||||
let rowId = chunkIndex * 3 + 1
|
||||
return [
|
||||
createCollapsibleInteraction(rowId: rowId, thread: thread),
|
||||
createCollapsibleInteraction(rowId: rowId + 1, thread: thread),
|
||||
createInteraction(rowId: rowId + 2, thread: thread),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
private func setInteractions(_ interactions: [TSInteraction]) {
|
||||
batchFetcher.interactions = interactions
|
||||
interactionFetcher.interactions = interactions
|
||||
}
|
||||
|
||||
private func preprocessingContext(thread: TSThread) -> MessageLoaderPreprocessingContext {
|
||||
return MessageLoaderPreprocessingContext(
|
||||
thread: thread,
|
||||
oldestUnreadSortId: nil,
|
||||
)
|
||||
}
|
||||
|
||||
func test_loadInitialMessagePage_empty() throws {
|
||||
try mockDB.read { tx in
|
||||
try self.messageLoader.loadInitialMessagePage(
|
||||
@ -91,6 +167,107 @@ class MessageLoaderTest: XCTestCase {
|
||||
XCTAssertEqual(initialMessages.map { $0.uniqueId }, messageLoader.loadedInteractions.map { $0.uniqueId })
|
||||
}
|
||||
|
||||
func test_loadInitialMessagePage_countsCollapsedInteractionsAsTopLevel() throws {
|
||||
let thread = TSContactThread(contactUUID: UUID().uuidString, contactPhoneNumber: nil)
|
||||
let initialMessages = createCollapsibleInteractions(2_000, thread: thread)
|
||||
setInteractions(initialMessages)
|
||||
|
||||
try mockDB.read { tx in
|
||||
try self.messageLoader.loadInitialMessagePage(
|
||||
focusMessageId: nil,
|
||||
reusableInteractions: [:],
|
||||
deletedInteractionIds: [],
|
||||
preprocessingContext: MessageLoaderPreprocessingContext(
|
||||
thread: thread,
|
||||
oldestUnreadSortId: nil,
|
||||
),
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
|
||||
let newestInteraction = try XCTUnwrap(initialMessages.last)
|
||||
XCTAssertEqual(messageLoader.loadedInteractions.last?.uniqueId, newestInteraction.uniqueId)
|
||||
let newestCollapseSet = try XCTUnwrap(messageLoader.loadedDisplayableInteractions.last as? CollapseSetInteraction)
|
||||
XCTAssertEqual(newestCollapseSet.collapsedInteractions.last?.uniqueId, newestInteraction.uniqueId)
|
||||
XCTAssertGreaterThan(messageLoader.loadedInteractions.count, 500)
|
||||
XCTAssertLessThanOrEqual(messageLoader.loadedDisplayableInteractions.count, 500)
|
||||
XCTAssertTrue(messageLoader.loadedDisplayableInteractions.contains { $0 is CollapseSetInteraction })
|
||||
}
|
||||
|
||||
func test_loadOlderMessagePage_withMixedCollapseSets_trimsNewerSide() throws {
|
||||
let thread = TSContactThread(contactUUID: UUID().uuidString, contactPhoneNumber: nil)
|
||||
let initialMessages = createMixedInteractions(900, thread: thread)
|
||||
setInteractions(initialMessages)
|
||||
|
||||
try mockDB.read { tx in
|
||||
try self.messageLoader.loadInitialMessagePage(
|
||||
focusMessageId: nil,
|
||||
reusableInteractions: [:],
|
||||
deletedInteractionIds: [],
|
||||
preprocessingContext: preprocessingContext(thread: thread),
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
|
||||
let newestInteraction = try XCTUnwrap(initialMessages.last)
|
||||
XCTAssertEqual(messageLoader.loadedInteractions.last?.uniqueId, newestInteraction.uniqueId)
|
||||
|
||||
try mockDB.read { tx in
|
||||
var loadCount = 0
|
||||
while self.messageLoader.canLoadOlder, loadCount < 100 {
|
||||
try self.messageLoader.loadOlderMessagePage(
|
||||
reusableInteractions: [:],
|
||||
deletedInteractionIds: [],
|
||||
preprocessingContext: preprocessingContext(thread: thread),
|
||||
tx: tx,
|
||||
)
|
||||
loadCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
XCTAssertLessThanOrEqual(messageLoader.loadedDisplayableInteractions.count, 500)
|
||||
XCTAssertTrue(messageLoader.loadedDisplayableInteractions.contains { $0 is CollapseSetInteraction })
|
||||
XCTAssertFalse(messageLoader.loadedInteractions.contains { $0.uniqueId == newestInteraction.uniqueId })
|
||||
}
|
||||
|
||||
func test_loadNewerMessagePage_withMixedCollapseSets_trimsOlderSide() throws {
|
||||
let thread = TSContactThread(contactUUID: UUID().uuidString, contactPhoneNumber: nil)
|
||||
let initialMessages = createMixedInteractions(900, thread: thread)
|
||||
setInteractions(initialMessages)
|
||||
|
||||
let focusInteraction = initialMessages[100]
|
||||
try mockDB.read { tx in
|
||||
try self.messageLoader.loadInitialMessagePage(
|
||||
focusMessageId: focusInteraction.uniqueId,
|
||||
reusableInteractions: [:],
|
||||
deletedInteractionIds: [],
|
||||
preprocessingContext: preprocessingContext(thread: thread),
|
||||
tx: tx,
|
||||
)
|
||||
}
|
||||
|
||||
XCTAssertTrue(messageLoader.loadedInteractions.contains { $0.uniqueId == focusInteraction.uniqueId })
|
||||
|
||||
try mockDB.read { tx in
|
||||
var loadCount = 0
|
||||
while self.messageLoader.canLoadNewer, loadCount < 100 {
|
||||
try self.messageLoader.loadNewerMessagePage(
|
||||
reusableInteractions: [:],
|
||||
deletedInteractionIds: [],
|
||||
preprocessingContext: preprocessingContext(thread: thread),
|
||||
tx: tx,
|
||||
)
|
||||
loadCount += 1
|
||||
}
|
||||
}
|
||||
|
||||
let newestInteraction = try XCTUnwrap(initialMessages.last)
|
||||
XCTAssertLessThanOrEqual(messageLoader.loadedDisplayableInteractions.count, 500)
|
||||
XCTAssertTrue(messageLoader.loadedDisplayableInteractions.contains { $0 is CollapseSetInteraction })
|
||||
XCTAssertEqual(messageLoader.loadedInteractions.last?.uniqueId, newestInteraction.uniqueId)
|
||||
XCTAssertFalse(messageLoader.loadedInteractions.contains { $0.uniqueId == focusInteraction.uniqueId })
|
||||
}
|
||||
|
||||
func test_reloadInteractions_deletes() throws {
|
||||
let initialMessages = createInteractions(5)
|
||||
setInteractions(initialMessages)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user