// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import Foundation import SignalServiceKit 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 { 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 Self.databaseStorage.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? = 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) -> [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 = ChatColors.resolvedChatColor(for: thread, tx: transaction) 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 = ChatColors.resolvedChatColor(for: thread, tx: transaction) 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, .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 } }