787 lines
31 KiB
Swift
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
|
|
}
|
|
}
|