478 lines
21 KiB
Swift
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
|
|
}
|
|
}
|