Signal-iOS/Signal/ConversationView/Loading/CVLoader.swift
2026-05-12 18:11:45 -04:00

787 lines
31 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import Foundation
import LibSignalClient
public import SignalServiceKit
public import SignalUI
// This entity performs a single load.
public class CVLoader: NSObject {
private let threadUniqueId: String
private let loadRequest: CVLoadRequest
private let viewStateSnapshot: CVViewStateSnapshot
private let spoilerState: SpoilerRenderState
private let prevRenderState: CVRenderState
private let messageLoader: MessageLoader
init(
threadUniqueId: String,
loadRequest: CVLoadRequest,
viewStateSnapshot: CVViewStateSnapshot,
spoilerState: SpoilerRenderState,
prevRenderState: CVRenderState,
messageLoader: MessageLoader,
) {
self.threadUniqueId = threadUniqueId
self.loadRequest = loadRequest
self.viewStateSnapshot = viewStateSnapshot
self.spoilerState = spoilerState
self.prevRenderState = prevRenderState
self.messageLoader = messageLoader
}
func loadPromise() -> Promise<CVUpdate> {
let threadUniqueId = self.threadUniqueId
let loadRequest = self.loadRequest
let viewStateSnapshot = self.viewStateSnapshot
let spoilerState = self.spoilerState
let prevRenderState = self.prevRenderState
let messageLoader = self.messageLoader
struct LoadState {
let threadViewModel: ThreadViewModel
let conversationViewModel: ConversationViewModel
let items: [CVRenderItem]
}
return firstly(on: CVUtils.workQueue(isInitialLoad: loadRequest.isInitialLoad)) { () -> CVUpdate in
// To ensure coherency, the entire load should be done with a single transaction.
let loadState: LoadState = try SSKEnvironment.shared.databaseStorageRef.read { transaction in
let thread = TSThread.fetchViaCache(uniqueId: threadUniqueId, transaction: transaction)
let threadViewModel = { () -> ThreadViewModel in
guard let thread else {
// If thread has been deleted from the database, use last known model.
return prevRenderState.threadViewModel
}
return ThreadViewModel(thread: thread, forChatList: false, transaction: transaction)
}()
let conversationViewModel = { () -> ConversationViewModel in
guard let thread else {
// If thread has been deleted from the database, use last known model.
return prevRenderState.conversationViewModel
}
return ConversationViewModel.load(for: thread, tx: transaction)
}()
guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction)?.aci else {
throw OWSAssertionError("User not registered")
}
let loadContext = CVLoadContext(
loadRequest: loadRequest,
threadViewModel: threadViewModel,
viewStateSnapshot: viewStateSnapshot,
spoilerState: spoilerState,
messageLoader: messageLoader,
prevRenderState: prevRenderState,
localAci: localAci,
transaction: transaction,
)
// Don't cache in the reset() case.
let canReuseInteractions = loadRequest.canReuseInteractionModels && !loadRequest.didReset
var updatedInteractionIds = loadRequest.updatedInteractionIds
let deletedInteractionIds: Set<String>? = loadRequest.didReset ? loadRequest.deletedInteractionIds : nil
let didThreadDetailsChange: Bool = {
let prevThreadViewModel = prevRenderState.threadViewModel
guard let groupModel = threadViewModel.threadRecord.groupModelIfGroupThread else {
return false
}
guard let prevGroupModel = prevThreadViewModel.threadRecord.groupModelIfGroupThread else {
owsFailDebug("Missing groupModel.")
return false
}
let groupDescriptionDidChange = (groupModel as? TSGroupModelV2)?.descriptionText
!= (prevGroupModel as? TSGroupModelV2)?.descriptionText
return groupModel.groupName != prevGroupModel.groupName ||
groupDescriptionDidChange ||
groupModel.avatarHash != prevGroupModel.avatarHash ||
groupModel.groupMembership.fullMembers.count != prevGroupModel.groupMembership.fullMembers.count
}()
// If the thread details did change, reload the thread details
// item if one is in the load window.
if
didThreadDetailsChange,
let prevFirstRenderItem = prevRenderState.items.first,
prevFirstRenderItem.interactionType == .threadDetails
{
updatedInteractionIds.insert(prevFirstRenderItem.interactionUniqueId)
}
var reusableInteractions = [String: TSInteraction]()
if canReuseInteractions {
for renderItem in prevRenderState.items {
let interaction = renderItem.interaction
let interactionId = interaction.uniqueId
if !updatedInteractionIds.contains(interactionId) {
reusableInteractions[interactionId] = interaction
}
}
}
do {
switch loadRequest.loadType {
case .loadInitialMapping(let focusMessageIdOnOpen, _):
owsAssertDebug(reusableInteractions.isEmpty)
try messageLoader.loadInitialMessagePage(
focusMessageId: focusMessageIdOnOpen,
reusableInteractions: [:],
deletedInteractionIds: [],
tx: transaction,
)
case .loadSameLocation:
try messageLoader.loadSameLocation(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: transaction,
)
case .loadOlder:
try messageLoader.loadOlderMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: transaction,
)
case .loadNewer:
try messageLoader.loadNewerMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: transaction,
)
case .loadNewest:
try messageLoader.loadNewestMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: transaction,
)
case .loadPageAroundInteraction(let interactionId, _):
try messageLoader.loadMessagePage(
aroundInteractionId: interactionId,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: transaction,
)
}
} catch {
owsFailDebug("Couldn't load conversation view messages \(error)")
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
{
try messageLoader.loadOlderMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: transaction,
)
processedInteractions = Self.preprocessInteractions(
messageLoader.loadedInteractions,
loadContext: loadContext,
)
extraLoads += 1
}
}
let itemModels = self.buildItemModels(
interactions: processedInteractions,
loadContext: loadContext,
updatedInteractionIds: updatedInteractionIds,
localAci: localAci,
)
let items = itemModels.compactMap { item in
Self.buildRenderItem(
itemBuildingContext: loadContext,
itemModel: item,
)
}
return LoadState(
threadViewModel: threadViewModel,
conversationViewModel: conversationViewModel,
items: items,
)
}
let renderState = CVRenderState(
threadViewModel: loadState.threadViewModel,
prevThreadViewModel: prevRenderState.threadViewModel,
conversationViewModel: loadState.conversationViewModel,
items: loadState.items,
canLoadOlderItems: messageLoader.canLoadOlder,
canLoadNewerItems: messageLoader.canLoadNewer,
viewStateSnapshot: viewStateSnapshot,
loadType: loadRequest.loadType,
)
let update = CVUpdate.build(
renderState: renderState,
prevRenderState: prevRenderState,
loadRequest: loadRequest,
)
return update
}
}
// MARK: -
private func buildItemModels(
interactions: [TSInteraction],
loadContext: CVLoadContext,
updatedInteractionIds: Set<String>,
localAci: Aci,
) -> [CVItemModel] {
let conversationStyle = loadContext.conversationStyle
// Don't cache in the reset() case.
let canReuseState = loadRequest.canReuseComponentStates && conversationStyle == prevRenderState.conversationStyle
var itemModelBuilder = CVItemModelBuilder(loadContext: loadContext)
// CVComponentStates are loaded from the database; these loads
// can be expensive. Therefore we want to reuse them _unless_:
//
// * The corresponding interaction was updated.
// * We're doing a "reset" reload where we deliberately reload
// everything, e.g. in response to an error or a cross-process write.
if canReuseState {
itemModelBuilder.reuseComponentStates(
prevRenderState: prevRenderState,
updatedInteractionIds: updatedInteractionIds,
)
}
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(
interaction: TSInteraction,
thread: TSThread,
threadAssociatedData: ThreadAssociatedData,
containerView: UIView,
transaction: DBReadTransaction,
) -> CVRenderItem? {
buildStandaloneRenderItem(
interaction: interaction,
thread: thread,
threadAssociatedData: threadAssociatedData,
containerView: containerView,
spoilerState: SpoilerRenderState(),
transaction: transaction,
)
}
#endif
public static func buildStandaloneRenderItem(
interaction: TSInteraction,
thread: TSThread,
threadAssociatedData: ThreadAssociatedData,
containerView: UIView,
spoilerState: SpoilerRenderState,
transaction: DBReadTransaction,
) -> CVRenderItem? {
let chatColor = DependenciesBridge.shared.chatColorSettingStore.resolvedChatColor(
for: thread,
tx: transaction,
)
let conversationStyle = ConversationStyle(
type: .`default`,
thread: thread,
viewWidth: containerView.width,
hasWallpaper: false,
shouldDimWallpaperInDarkMode: false,
chatColor: chatColor,
)
let coreState = CVCoreState(
conversationStyle: conversationStyle,
mediaCache: CVMediaCache(),
)
let groupNameColors = GroupNameColors.forThread(thread)
return CVLoader.buildStandaloneRenderItem(
interaction: interaction,
thread: thread,
threadAssociatedData: threadAssociatedData,
coreState: coreState,
spoilerState: spoilerState,
groupNameColors: groupNameColors,
transaction: transaction,
)
}
public static func buildStandaloneRenderItem(
interaction: TSInteraction,
thread: TSThread,
threadAssociatedData: ThreadAssociatedData,
conversationStyle: ConversationStyle,
spoilerState: SpoilerRenderState,
groupNameColors: GroupNameColors,
transaction: DBReadTransaction,
) -> CVRenderItem? {
let coreState = CVCoreState(
conversationStyle: conversationStyle,
mediaCache: CVMediaCache(),
)
return CVLoader.buildStandaloneRenderItem(
interaction: interaction,
thread: thread,
threadAssociatedData: threadAssociatedData,
coreState: coreState,
spoilerState: spoilerState,
groupNameColors: groupNameColors,
transaction: transaction,
)
}
private static func buildStandaloneRenderItem(
interaction: TSInteraction,
thread: TSThread,
threadAssociatedData: ThreadAssociatedData,
coreState: CVCoreState,
spoilerState: SpoilerRenderState,
groupNameColors: GroupNameColors,
transaction: DBReadTransaction,
) -> CVRenderItem? {
AssertIsOnMainThread()
guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction)?.aci else {
owsFailDebug("User not registered")
return nil
}
let threadViewModel = ThreadViewModel(
thread: thread,
forChatList: false,
transaction: transaction,
)
let viewStateSnapshot = CVViewStateSnapshot.mockSnapshotForStandaloneItems(
coreState: coreState,
spoilerReveal: spoilerState.revealState,
)
let avatarBuilder = CVAvatarBuilder(transaction: transaction)
let itemBuildingContext = CVItemBuildingContextImpl(
prevRenderState: nil,
threadViewModel: threadViewModel,
viewStateSnapshot: viewStateSnapshot,
transaction: transaction,
avatarBuilder: avatarBuilder,
localAci: localAci,
)
guard
let itemModel = CVItemModelBuilder.buildStandaloneItem(
interaction: interaction,
thread: thread,
threadAssociatedData: threadAssociatedData,
threadViewModel: threadViewModel,
itemBuildingContext: itemBuildingContext,
groupNameColors: groupNameColors,
transaction: transaction,
)
else {
owsFailDebug("Couldn't build item model.")
return nil
}
return Self.buildRenderItem(
itemBuildingContext: itemBuildingContext,
itemModel: itemModel,
)
}
public static func buildStandaloneComponentState(
interaction: TSInteraction,
spoilerState: SpoilerRenderState,
transaction: DBReadTransaction,
) -> CVComponentState? {
AssertIsOnMainThread()
guard let thread = interaction.thread(tx: transaction) else {
owsFailDebug("Missing thread for interaction.")
return nil
}
guard let localAci = DependenciesBridge.shared.tsAccountManager.localIdentifiers(tx: transaction)?.aci else {
owsFailDebug("User not registered")
return nil
}
let chatColor = DependenciesBridge.shared.chatColorSettingStore.resolvedChatColor(
for: thread,
tx: transaction,
)
let mockViewWidth: CGFloat = 800
let conversationStyle = ConversationStyle(
type: .`default`,
thread: thread,
viewWidth: mockViewWidth,
hasWallpaper: false,
shouldDimWallpaperInDarkMode: false,
chatColor: chatColor,
)
let coreState = CVCoreState(
conversationStyle: conversationStyle,
mediaCache: CVMediaCache(),
)
let threadViewModel = ThreadViewModel(
thread: thread,
forChatList: false,
transaction: transaction,
)
let viewStateSnapshot = CVViewStateSnapshot.mockSnapshotForStandaloneItems(
coreState: coreState,
spoilerReveal: spoilerState.revealState,
)
let avatarBuilder = CVAvatarBuilder(transaction: transaction)
let itemBuildingContext = CVItemBuildingContextImpl(
prevRenderState: nil,
threadViewModel: threadViewModel,
viewStateSnapshot: viewStateSnapshot,
transaction: transaction,
avatarBuilder: avatarBuilder,
localAci: localAci,
)
do {
return try CVComponentState.build(
interaction: interaction,
itemBuildingContext: itemBuildingContext,
)
} catch {
owsFailDebug("Error: \(error)")
return nil
}
}
private static func buildRenderItem(
itemBuildingContext: CVItemBuildingContext,
itemModel: CVItemModel,
) -> CVRenderItem? {
let conversationStyle = itemBuildingContext.conversationStyle
let rootComponent: CVRootComponent
switch itemModel.messageCellType {
case .dateHeader:
guard let dateHeaderState = itemModel.itemViewState.dateHeaderState else {
owsFailDebug("Missing dateHeader.")
return nil
}
rootComponent = CVComponentDateHeader(
itemModel: itemModel,
dateHeaderState: dateHeaderState,
)
case .unreadIndicator:
rootComponent = CVComponentUnreadIndicator(itemModel: itemModel)
case .threadDetails:
guard let threadDetails = itemModel.componentState.threadDetails else {
owsFailDebug("Missing threadDetails.")
return nil
}
rootComponent = CVComponentThreadDetails(itemModel: itemModel, threadDetails: threadDetails)
case .unknownThreadWarning:
guard let unknownThreadWarning = itemModel.componentState.unknownThreadWarning else {
owsFailDebug("Missing unknownThreadWarning.")
return nil
}
rootComponent = CVComponentSystemMessage(
itemModel: itemModel,
systemMessage: unknownThreadWarning,
)
case .defaultDisappearingMessageTimer:
guard let defaultDisappearingMessageTimer = itemModel.componentState.defaultDisappearingMessageTimer else {
owsFailDebug("Missing unknownThreadWarning.")
return nil
}
rootComponent = CVComponentSystemMessage(
itemModel: itemModel,
systemMessage: defaultDisappearingMessageTimer,
)
case .textOnlyMessage, .audio, .genericAttachment, .paymentAttachment, .archivedPaymentAttachment,
.undownloadableAttachment,
.contactShare, .bodyMedia, .viewOnce, .stickerMessage, .quoteOnlyMessage,
.giftBadge, .poll:
rootComponent = CVComponentMessage(itemModel: itemModel)
case .typingIndicator:
guard let typingIndicator = itemModel.componentState.typingIndicator else {
owsFailDebug("Missing typingIndicator.")
return nil
}
rootComponent = CVComponentTypingIndicator(
itemModel: itemModel,
typingIndicator: typingIndicator,
)
case .systemMessage:
guard let systemMessage = itemModel.componentState.systemMessage else {
owsFailDebug("Missing systemMessage.")
return nil
}
rootComponent = CVComponentSystemMessage(itemModel: itemModel, systemMessage: systemMessage)
case .collapseSet:
guard let collapseSet = itemModel.componentState.collapseSet else {
owsFailDebug("Missing collapseSet.")
return nil
}
rootComponent = CVComponentCollapseSet(itemModel: itemModel, collapseSet: collapseSet)
case .unknown:
Logger.warn("Discarding item: \(itemModel.messageCellType).")
return nil
}
let cellMeasurement = buildCellMeasurement(
rootComponent: rootComponent,
conversationStyle: conversationStyle,
)
return CVRenderItem(
itemModel: itemModel,
rootComponent: rootComponent,
cellMeasurement: cellMeasurement,
)
}
private static func buildEmptyCellMeasurement() -> CVCellMeasurement {
CVCellMeasurement.Builder().build()
}
private static func buildCellMeasurement(
rootComponent: CVRootComponent,
conversationStyle: ConversationStyle,
) -> CVCellMeasurement {
let measurementBuilder = CVCellMeasurement.Builder()
measurementBuilder.cellSize = rootComponent.measure(
maxWidth: conversationStyle.viewWidth,
measurementBuilder: measurementBuilder,
)
let cellMeasurement = measurementBuilder.build()
owsAssertDebug(cellMeasurement.cellSize.width <= conversationStyle.viewWidth)
return cellMeasurement
}
}