571 lines
23 KiB
Swift
571 lines
23 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,
|
|
)
|
|
let preprocessingContext = MessageLoaderPreprocessingContext(
|
|
thread: loadContext.thread,
|
|
oldestUnreadSortId: viewStateSnapshot.oldestUnreadMessageSortId,
|
|
)
|
|
|
|
// 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: [],
|
|
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, _):
|
|
try messageLoader.loadMessagePage(
|
|
aroundInteractionId: interactionId,
|
|
reusableInteractions: reusableInteractions,
|
|
deletedInteractionIds: deletedInteractionIds,
|
|
preprocessingContext: preprocessingContext,
|
|
tx: transaction,
|
|
)
|
|
}
|
|
} catch {
|
|
owsFailDebug("Couldn't load conversation view messages \(error)")
|
|
throw error
|
|
}
|
|
|
|
let expandedInteractions = messageLoader.loadedDisplayableInteractions.flatMap { interaction in
|
|
if
|
|
let collapseSet = interaction as? CollapseSetInteraction,
|
|
viewStateSnapshot.expandedCollapseSetIds.contains(collapseSet.uniqueId)
|
|
{
|
|
return [collapseSet] + collapseSet.collapsedInteractions
|
|
}
|
|
return [interaction]
|
|
}
|
|
|
|
let itemModels = self.buildItemModels(
|
|
interactions: expandedInteractions,
|
|
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)
|
|
}
|
|
|
|
#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
|
|
}
|
|
}
|