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

478 lines
21 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import Foundation
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.anyFetch(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)
}()
let loadContext = CVLoadContext(
loadRequest: loadRequest,
threadViewModel: threadViewModel,
viewStateSnapshot: viewStateSnapshot,
spoilerState: spoilerState,
messageLoader: messageLoader,
prevRenderState: prevRenderState,
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.asV2Read
)
case .loadSameLocation:
try messageLoader.loadSameLocation(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: transaction.asV2Read
)
case .loadOlder:
try messageLoader.loadOlderMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: transaction.asV2Read
)
case .loadNewer:
try messageLoader.loadNewerMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: transaction.asV2Read
)
case .loadNewest:
try messageLoader.loadNewestMessagePage(
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: transaction.asV2Read
)
case .loadPageAroundInteraction(let interactionId, _):
try messageLoader.loadMessagePage(
aroundInteractionId: interactionId,
reusableInteractions: reusableInteractions,
deletedInteractionIds: deletedInteractionIds,
tx: transaction.asV2Read
)
}
} catch {
owsFailDebug("Couldn't load conversation view messages \(error)")
throw error
}
return LoadState(
threadViewModel: threadViewModel,
conversationViewModel: conversationViewModel,
items: self.buildRenderItems(loadContext: loadContext, updatedInteractionIds: updatedInteractionIds)
)
}
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 buildRenderItems(loadContext: CVLoadContext,
updatedInteractionIds: Set<String>) -> [CVRenderItem] {
let conversationStyle = loadContext.conversationStyle
// Don't cache in the reset() case.
let canReuseState = (loadRequest.canReuseComponentStates &&
conversationStyle.isEqualForCellRendering(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 do a "reset" reload where we deliberately reload everything, e.g.
// in response to an error or a cross-process write, etc.
if canReuseState {
itemModelBuilder.reuseComponentStates(prevRenderState: prevRenderState,
updatedInteractionIds: updatedInteractionIds)
}
let itemModels: [CVItemModel] = itemModelBuilder.buildItems()
var renderItems = [CVRenderItem]()
for itemModel in itemModels {
guard let renderItem = buildRenderItem(itemBuildingContext: loadContext,
itemModel: itemModel) else {
continue
}
renderItems.append(renderItem)
}
return renderItems
}
private func buildRenderItem(itemBuildingContext: CVItemBuildingContext,
itemModel: CVItemModel) -> CVRenderItem? {
Self.buildRenderItem(itemBuildingContext: itemBuildingContext,
itemModel: itemModel)
}
#if USE_DEBUG_UI
public static func debugui_buildStandaloneRenderItem(
interaction: TSInteraction,
thread: TSThread,
threadAssociatedData: ThreadAssociatedData,
containerView: UIView,
transaction: SDSAnyReadTransaction
) -> 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: SDSAnyReadTransaction
) -> CVRenderItem? {
let chatColor = DependenciesBridge.shared.chatColorSettingStore.resolvedChatColor(
for: thread,
tx: transaction.asV2Read
)
let conversationStyle = ConversationStyle(
type: .`default`,
thread: thread,
viewWidth: containerView.width,
hasWallpaper: false,
isWallpaperPhoto: false,
chatColor: chatColor
)
let coreState = CVCoreState(conversationStyle: conversationStyle,
mediaCache: CVMediaCache())
return CVLoader.buildStandaloneRenderItem(
interaction: interaction,
thread: thread,
threadAssociatedData: threadAssociatedData,
coreState: coreState,
spoilerState: spoilerState,
transaction: transaction
)
}
public static func buildStandaloneRenderItem(
interaction: TSInteraction,
thread: TSThread,
threadAssociatedData: ThreadAssociatedData,
conversationStyle: ConversationStyle,
spoilerState: SpoilerRenderState,
transaction: SDSAnyReadTransaction
) -> CVRenderItem? {
let coreState = CVCoreState(
conversationStyle: conversationStyle,
mediaCache: CVMediaCache()
)
return CVLoader.buildStandaloneRenderItem(
interaction: interaction,
thread: thread,
threadAssociatedData: threadAssociatedData,
coreState: coreState,
spoilerState: spoilerState,
transaction: transaction
)
}
private static func buildStandaloneRenderItem(
interaction: TSInteraction,
thread: TSThread,
threadAssociatedData: ThreadAssociatedData,
coreState: CVCoreState,
spoilerState: SpoilerRenderState,
transaction: SDSAnyReadTransaction
) -> CVRenderItem? {
AssertIsOnMainThread()
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(
threadViewModel: threadViewModel,
viewStateSnapshot: viewStateSnapshot,
transaction: transaction,
avatarBuilder: avatarBuilder
)
guard let itemModel = CVItemModelBuilder.buildStandaloneItem(interaction: interaction,
thread: thread,
threadAssociatedData: threadAssociatedData,
threadViewModel: threadViewModel,
itemBuildingContext: itemBuildingContext,
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: SDSAnyReadTransaction
) -> CVComponentState? {
AssertIsOnMainThread()
guard let thread = interaction.thread(tx: transaction) else {
owsFailDebug("Missing thread for interaction.")
return nil
}
let chatColor = DependenciesBridge.shared.chatColorSettingStore.resolvedChatColor(
for: thread,
tx: transaction.asV2Read
)
let mockViewWidth: CGFloat = 800
let conversationStyle = ConversationStyle(
type: .`default`,
thread: thread,
viewWidth: mockViewWidth,
hasWallpaper: false,
isWallpaperPhoto: 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(
threadViewModel: threadViewModel,
viewStateSnapshot: viewStateSnapshot,
transaction: transaction,
avatarBuilder: avatarBuilder
)
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,
.contactShare, .bodyMedia, .viewOnce, .stickerMessage, .quoteOnlyMessage,
.giftBadge:
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 .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
}
}