835 lines
33 KiB
Swift
835 lines
33 KiB
Swift
//
|
|
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import LibSignalClient
|
|
import SignalServiceKit
|
|
import SignalUI
|
|
|
|
// CVItemViewState represents the transient, un-persisted values
|
|
// that may affect item appearance.
|
|
//
|
|
// Compare with CVComponentState which represents the persisted values
|
|
// that may affect item appearance.
|
|
//
|
|
// CVItemViewState might be affected by adjacent items, profile changes,
|
|
// the passage of time, etc.
|
|
public struct CVItemViewState: Equatable {
|
|
let shouldShowSenderAvatar: Bool
|
|
let accessibilityAuthorName: String?
|
|
let shouldHideFooter: Bool
|
|
let isFirstInCluster: Bool
|
|
let isLastInCluster: Bool
|
|
let shouldCollapseSystemMessageAction: Bool
|
|
|
|
// Some components have transient state.
|
|
let senderNameState: CVComponentState.SenderName?
|
|
let footerState: CVComponentFooter.State?
|
|
let dateHeaderState: CVComponentDateHeader.State?
|
|
let bodyTextState: CVComponentBodyText.State?
|
|
let giftBadgeState: CVComponentGiftBadge.ViewState?
|
|
let nextAudioAttachment: AudioAttachment?
|
|
let audioPlaybackRate: Float
|
|
|
|
let uiMode: ConversationUIMode
|
|
let previousUIMode: ConversationUIMode
|
|
|
|
public var isShowingSelectionUI: Bool { uiMode.hasSelectionUI }
|
|
public var wasShowingSelectionUI: Bool { previousUIMode.hasSelectionUI }
|
|
|
|
public class Builder {
|
|
var shouldShowSenderAvatar = false
|
|
var accessibilityAuthorName: String?
|
|
var shouldHideFooter = false
|
|
var isFirstInCluster = false
|
|
var isLastInCluster = false
|
|
var shouldCollapseSystemMessageAction = false
|
|
var senderNameState: CVComponentState.SenderName?
|
|
var footerState: CVComponentFooter.State?
|
|
var dateHeaderState: CVComponentDateHeader.State?
|
|
var bodyTextState: CVComponentBodyText.State?
|
|
var giftBadgeState: CVComponentGiftBadge.ViewState?
|
|
var nextAudioAttachment: AudioAttachment?
|
|
var audioPlaybackRate: Float = 1
|
|
var uiMode: ConversationUIMode = .normal
|
|
var previousUIMode: ConversationUIMode = .normal
|
|
|
|
func build() -> CVItemViewState {
|
|
CVItemViewState(
|
|
shouldShowSenderAvatar: shouldShowSenderAvatar,
|
|
accessibilityAuthorName: accessibilityAuthorName,
|
|
shouldHideFooter: shouldHideFooter,
|
|
isFirstInCluster: isFirstInCluster,
|
|
isLastInCluster: isLastInCluster,
|
|
shouldCollapseSystemMessageAction: shouldCollapseSystemMessageAction,
|
|
senderNameState: senderNameState,
|
|
footerState: footerState,
|
|
dateHeaderState: dateHeaderState,
|
|
bodyTextState: bodyTextState,
|
|
giftBadgeState: giftBadgeState,
|
|
nextAudioAttachment: nextAudioAttachment,
|
|
audioPlaybackRate: audioPlaybackRate,
|
|
uiMode: uiMode,
|
|
previousUIMode: previousUIMode,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
struct CVItemModelBuilder: CVItemBuilding {
|
|
|
|
let itemBuildingContext: CVItemBuildingContext
|
|
let messageLoader: MessageLoader
|
|
|
|
// MARK: -
|
|
|
|
private var items = [ItemBuilder]()
|
|
private var previousItem: ItemBuilder? {
|
|
items.last
|
|
}
|
|
|
|
init(loadContext: CVLoadContext) {
|
|
self.itemBuildingContext = loadContext
|
|
self.messageLoader = loadContext.messageLoader
|
|
}
|
|
|
|
// TODO: How should we handle failed stickers?
|
|
// TODO: Do we need a new equivalent of clearNeedsUpdate?
|
|
mutating func buildItems(localAci: Aci, interactions: [TSInteraction]) -> [CVItemModel] {
|
|
// Contact Offers / Thread Details are the first item in the thread
|
|
if messageLoader.shouldShowThreadDetails {
|
|
// The thread details should have a stable timestamp.
|
|
let threadDetailsTimestamp: UInt64
|
|
if let firstInteraction = interactions.first {
|
|
threadDetailsTimestamp = max(1, firstInteraction.timestamp) - 2
|
|
} else {
|
|
threadDetailsTimestamp = 1
|
|
}
|
|
let threadDetails = ThreadDetailsInteraction(
|
|
thread: thread,
|
|
timestamp: threadDetailsTimestamp,
|
|
)
|
|
let item = addItem(interaction: threadDetails)
|
|
owsAssertDebug(item != nil)
|
|
}
|
|
|
|
var interactionIds = Set<String>()
|
|
for interaction in interactions {
|
|
guard !interactionIds.contains(interaction.uniqueId) else {
|
|
owsFailDebug("Duplicate interaction(1): \(interaction.uniqueId)")
|
|
continue
|
|
}
|
|
interactionIds.insert(interaction.uniqueId)
|
|
|
|
let item = addItem(interaction: interaction)
|
|
owsAssertDebug(item != nil)
|
|
}
|
|
|
|
if
|
|
messageLoader.shouldShowDefaultDisappearingMessageTimer(
|
|
thread: thread,
|
|
transaction: transaction,
|
|
)
|
|
{
|
|
let interaction = DefaultDisappearingMessageTimerInteraction(
|
|
thread: thread,
|
|
timestamp: NSDate.ows_millisecondTimeStamp() - 1,
|
|
)
|
|
let item = addItem(interaction: interaction)
|
|
owsAssertDebug(item != nil)
|
|
}
|
|
|
|
if let typingIndicatorsSender = viewStateSnapshot.typingIndicatorsSender {
|
|
let interaction = TypingIndicatorInteraction(
|
|
thread: thread,
|
|
timestamp: NSDate.ows_millisecondTimeStamp(),
|
|
address: typingIndicatorsSender,
|
|
)
|
|
let item = addItem(interaction: interaction)
|
|
owsAssertDebug(item != nil)
|
|
}
|
|
|
|
let groupNameColors = GroupNameColors.forThread(thread)
|
|
let displayNameCache = DisplayNameCache()
|
|
|
|
// Update the properties of the view items.
|
|
//
|
|
// NOTE: This logic uses the break properties which are set in the previous pass.
|
|
for (index, item) in items.enumerated() {
|
|
let previousItem: ItemBuilder? = items[safe: index - 1]
|
|
let nextItem: ItemBuilder? = items[safe: index + 1]
|
|
|
|
Self.configureItemViewState(
|
|
item: item,
|
|
previousItem: previousItem,
|
|
nextItem: nextItem,
|
|
thread: thread,
|
|
threadViewModel: threadViewModel,
|
|
viewStateSnapshot: viewStateSnapshot,
|
|
groupNameColors: groupNameColors,
|
|
displayNameCache: displayNameCache,
|
|
transaction: transaction,
|
|
)
|
|
}
|
|
|
|
return items.map { (itemBuilder: ItemBuilder) in
|
|
itemBuilder.build(coreState: viewStateSnapshot.coreState)
|
|
}
|
|
}
|
|
|
|
static func buildStandaloneItem(
|
|
interaction: TSInteraction,
|
|
thread: TSThread,
|
|
threadAssociatedData: ThreadAssociatedData,
|
|
threadViewModel: ThreadViewModel,
|
|
itemBuildingContext: CVItemBuildingContext,
|
|
groupNameColors: GroupNameColors,
|
|
transaction: DBReadTransaction,
|
|
) -> CVItemModel? {
|
|
AssertIsOnMainThread()
|
|
|
|
let viewStateSnapshot = itemBuildingContext.viewStateSnapshot
|
|
|
|
guard
|
|
let itemBuilder = Self.itemBuilder(
|
|
forInteraction: interaction,
|
|
thread: thread,
|
|
threadAssociatedData: threadAssociatedData,
|
|
itemBuildingContext: itemBuildingContext,
|
|
componentStateCache: ComponentStateCache(),
|
|
)
|
|
else {
|
|
owsFailDebug("Could not create itemBuilder.")
|
|
return nil
|
|
}
|
|
|
|
let displayNameCache = DisplayNameCache()
|
|
|
|
configureItemViewState(
|
|
item: itemBuilder,
|
|
previousItem: nil,
|
|
nextItem: nil,
|
|
thread: thread,
|
|
threadViewModel: threadViewModel,
|
|
viewStateSnapshot: viewStateSnapshot,
|
|
groupNameColors: groupNameColors,
|
|
displayNameCache: displayNameCache,
|
|
transaction: transaction,
|
|
)
|
|
|
|
return itemBuilder.build(coreState: viewStateSnapshot.coreState)
|
|
}
|
|
|
|
private static func configureItemViewState(
|
|
item: ItemBuilder,
|
|
previousItem: ItemBuilder?,
|
|
nextItem: ItemBuilder?,
|
|
thread: TSThread,
|
|
threadViewModel: ThreadViewModel,
|
|
viewStateSnapshot: CVViewStateSnapshot,
|
|
groupNameColors: GroupNameColors,
|
|
displayNameCache: DisplayNameCache,
|
|
transaction: DBReadTransaction,
|
|
) {
|
|
let itemViewState = item.itemViewState
|
|
itemViewState.shouldShowSenderAvatar = false
|
|
itemViewState.shouldHideFooter = false
|
|
itemViewState.isFirstInCluster = true
|
|
itemViewState.isLastInCluster = true
|
|
|
|
let interaction = item.interaction
|
|
let timestampText = DateUtil.formatTimestampShort(interaction.timestamp)
|
|
|
|
let tapForMoreState: CVComponentFooter.TapForMoreState
|
|
switch item.componentState.bodyText {
|
|
case .bodyText(_, let hasTapForMore):
|
|
tapForMoreState = hasTapForMore ? .tapForMore : .none
|
|
case .oversizeTextUndownloadable:
|
|
tapForMoreState = .undownloadableLongText
|
|
case .oversizeTextSkipped:
|
|
tapForMoreState = .skippedLongText
|
|
case .oversizeTextDownloading:
|
|
tapForMoreState = .downloadingLongText
|
|
default:
|
|
tapForMoreState = .none
|
|
}
|
|
|
|
var isPinnedMessage: Bool = false
|
|
if let interactionId = interaction.grdbId?.int64Value {
|
|
isPinnedMessage = threadViewModel.pinnedMessages.contains(where: { $0.grdbId?.int64Value == interactionId })
|
|
}
|
|
var adminDeleteRecipientAddressStates: AdminDeleteManager.RecipientAddressStates?
|
|
if let message = interaction as? TSMessage, message.wasRemotelyDeleted {
|
|
adminDeleteRecipientAddressStates = AdminDeleteManager.recipientAddressStates(message: message, tx: transaction)
|
|
}
|
|
|
|
if let paymentMessage = interaction as? OWSPaymentMessage {
|
|
itemViewState.footerState = CVComponentFooter.buildPaymentState(
|
|
interaction: interaction,
|
|
paymentNotification: paymentMessage.paymentNotification,
|
|
tapForMoreState: tapForMoreState,
|
|
transaction: transaction,
|
|
)
|
|
} else {
|
|
itemViewState.footerState = CVComponentFooter.buildState(
|
|
interaction: interaction,
|
|
tapForMoreState: tapForMoreState,
|
|
isPinnedMessage: isPinnedMessage,
|
|
adminDeleteRecipientStates: adminDeleteRecipientAddressStates,
|
|
transaction: transaction,
|
|
)
|
|
}
|
|
|
|
if let giftBadge = item.componentState.giftBadge {
|
|
itemViewState.giftBadgeState = CVComponentGiftBadge.buildViewState(giftBadge)
|
|
}
|
|
|
|
itemViewState.audioPlaybackRate = threadViewModel.associatedData.audioPlaybackRate
|
|
|
|
if interaction.interactionType == .dateHeader {
|
|
itemViewState.dateHeaderState = CVComponentDateHeader.buildState(interaction: interaction)
|
|
}
|
|
if let bodyText = item.componentState.bodyText {
|
|
itemViewState.bodyTextState = CVComponentBodyText.buildState(
|
|
interaction: interaction,
|
|
bodyText: bodyText,
|
|
viewStateSnapshot: viewStateSnapshot,
|
|
hasPendingMessageRequest: threadViewModel.hasPendingMessageRequest,
|
|
)
|
|
}
|
|
itemViewState.uiMode = viewStateSnapshot.uiMode
|
|
itemViewState.previousUIMode = viewStateSnapshot.previousUIMode
|
|
|
|
func canClusterMessages(_ left: ItemBuilder, _ right: ItemBuilder) -> Bool {
|
|
let leftTime = left.interaction.receivedAtTimestamp
|
|
let rightTime = right.interaction.receivedAtTimestamp
|
|
if rightTime < leftTime {
|
|
// Ensure left was received first.
|
|
return canClusterMessages(right, left)
|
|
}
|
|
if left.componentState.reactions != nil {
|
|
// Don't cluster message if the earlier message has a reaction.
|
|
return false
|
|
}
|
|
let maxClusterTimeDifferenceMs = UInt64.minuteInMs * 3
|
|
let elapsedMs = rightTime - leftTime
|
|
return elapsedMs < maxClusterTimeDifferenceMs
|
|
}
|
|
|
|
if let outgoingMessage = interaction as? TSOutgoingMessage {
|
|
let receiptStatus = MessageRecipientStatusUtils.recipientStatus(outgoingMessage: outgoingMessage, transaction: transaction)
|
|
let isDisappearingMessage = outgoingMessage.hasPerConversationExpiration
|
|
itemViewState.accessibilityAuthorName = CommonStrings.you
|
|
|
|
// clustering
|
|
if
|
|
let previousItem,
|
|
previousItem.interactionType == .outgoingMessage,
|
|
canClusterMessages(previousItem, item)
|
|
{
|
|
itemViewState.isFirstInCluster = false
|
|
} else {
|
|
itemViewState.isFirstInCluster = true
|
|
}
|
|
|
|
if
|
|
let nextItem,
|
|
let nextOutgoingMessage = nextItem.interaction as? TSOutgoingMessage,
|
|
canClusterMessages(item, nextItem)
|
|
{
|
|
itemViewState.isLastInCluster = false
|
|
|
|
// We can skip the "outgoing message status" footer if the next message
|
|
// has the same footer and no "date break" separates us...
|
|
// ...but always show the "sending" and "failed to send" statuses...
|
|
// ...and always show the "disappearing messages" animation...
|
|
// ...and always show the "tap to read more" footer.
|
|
let nextReceiptStatus = MessageRecipientStatusUtils.recipientStatus(outgoingMessage: nextOutgoingMessage, transaction: transaction)
|
|
let nextTimestampText = DateUtil.formatTimestampShort(nextOutgoingMessage.timestamp)
|
|
itemViewState.shouldHideFooter = (
|
|
timestampText == nextTimestampText &&
|
|
receiptStatus == nextReceiptStatus &&
|
|
outgoingMessage.messageState != .failed &&
|
|
outgoingMessage.messageState != .sending &&
|
|
outgoingMessage.messageState != .pending &&
|
|
outgoingMessage.editState == .none &&
|
|
!isDisappearingMessage &&
|
|
!tapForMoreState.shouldShowFooter,
|
|
)
|
|
} else {
|
|
itemViewState.isLastInCluster = true
|
|
}
|
|
} else if let incomingMessage = interaction as? TSIncomingMessage {
|
|
let incomingSenderAddress: SignalServiceAddress = incomingMessage.authorAddress
|
|
owsAssertDebug(incomingSenderAddress.isValid)
|
|
let isDisappearingMessage = incomingMessage.hasPerConversationExpiration
|
|
|
|
// clustering
|
|
|
|
if
|
|
let previousItem,
|
|
let previousIncomingMessage = previousItem.interaction as? TSIncomingMessage,
|
|
incomingSenderAddress == previousIncomingMessage.authorAddress,
|
|
canClusterMessages(previousItem, item)
|
|
{
|
|
itemViewState.isFirstInCluster = false
|
|
} else {
|
|
itemViewState.isFirstInCluster = true
|
|
}
|
|
|
|
if
|
|
let nextItem,
|
|
let nextIncomingMessage = nextItem.interaction as? TSIncomingMessage,
|
|
incomingSenderAddress == nextIncomingMessage.authorAddress,
|
|
canClusterMessages(item, nextItem)
|
|
{
|
|
itemViewState.isLastInCluster = false
|
|
|
|
// We can skip the "incoming message status" footer in a cluster if the next message
|
|
// has the same footer and no "date break" separates us...
|
|
// ...but always show the "disappearing messages" animation...
|
|
// ...and always show the "tap to read more" footer.
|
|
let nextTimestampText = DateUtil.formatTimestampShort(nextIncomingMessage.timestamp)
|
|
itemViewState.shouldHideFooter = (
|
|
timestampText == nextTimestampText &&
|
|
!isDisappearingMessage &&
|
|
incomingMessage.editState == .none &&
|
|
!tapForMoreState.shouldShowFooter,
|
|
)
|
|
} else {
|
|
itemViewState.isLastInCluster = true
|
|
}
|
|
|
|
if thread.isGroupThread {
|
|
// Show the sender name for incoming group messages unless
|
|
// the previous message has the same sender name and
|
|
// no "date break" separates us.
|
|
var shouldShowSenderName = true
|
|
let authorName = displayNameCache.displayName(address: incomingSenderAddress, transaction: transaction)
|
|
itemViewState.accessibilityAuthorName = authorName
|
|
|
|
if
|
|
let previousItem,
|
|
let previousIncomingMessage = previousItem.interaction as? TSIncomingMessage
|
|
{
|
|
let previousIncomingSenderAddress = previousIncomingMessage.authorAddress
|
|
owsAssertDebug(previousIncomingSenderAddress.isValid)
|
|
|
|
shouldShowSenderName = incomingSenderAddress != previousIncomingSenderAddress
|
|
}
|
|
|
|
var memberLabel: String?
|
|
if
|
|
let groupThread = thread as? TSGroupThread,
|
|
!threadViewModel.hasPendingMessageRequest,
|
|
let senderAci = incomingSenderAddress.aci
|
|
{
|
|
memberLabel = groupThread.groupModel.groupMembership.memberLabel(for: senderAci)?.labelForRendering()
|
|
memberLabel = memberLabel?
|
|
.components(separatedBy: .whitespaces)
|
|
.joined(separator: SignalSymbol.LeadingCharacter.nonBreakingSpace.rawValue)
|
|
|
|
if let memberLabel {
|
|
itemViewState.accessibilityAuthorName = authorName + "," + memberLabel
|
|
}
|
|
}
|
|
|
|
if shouldShowSenderName {
|
|
let senderName = NSAttributedString(string: authorName)
|
|
let senderNameColor = groupNameColors.color(for: incomingSenderAddress.aci)
|
|
itemViewState.senderNameState = CVComponentState.SenderName(
|
|
senderName: senderName,
|
|
senderNameColor: senderNameColor,
|
|
memberLabel: memberLabel,
|
|
)
|
|
}
|
|
|
|
// Show the sender avatar for incoming group messages unless
|
|
// the next message has the same sender avatar and
|
|
// no "date break" separates us.
|
|
itemViewState.shouldShowSenderAvatar = true
|
|
if
|
|
let nextItem,
|
|
let nextIncomingMessage = nextItem.interaction as? TSIncomingMessage
|
|
{
|
|
let nextIncomingSenderAddress: SignalServiceAddress = nextIncomingMessage.authorAddress
|
|
itemViewState.shouldShowSenderAvatar = incomingSenderAddress != nextIncomingSenderAddress
|
|
}
|
|
} else {
|
|
// In a 1:1 thread, we can avoid cluttering up voiceover string with the recipient's
|
|
// full name. Group thread's will continue to read off the full name.
|
|
itemViewState.accessibilityAuthorName = displayNameCache.shortDisplayName(
|
|
address: incomingSenderAddress,
|
|
transaction: transaction,
|
|
)
|
|
}
|
|
|
|
} else if [.call, .info, .error].contains(interaction.interactionType) {
|
|
// clustering
|
|
|
|
if
|
|
let previousItem,
|
|
interaction.interactionType == previousItem.interaction.interactionType
|
|
{
|
|
|
|
switch previousItem.interaction.interactionType {
|
|
case .error:
|
|
if
|
|
let errorMessage = interaction as? TSErrorMessage,
|
|
let previousErrorMessage = previousItem.interaction as? TSErrorMessage,
|
|
errorMessage.errorType == .nonBlockingIdentityChange
|
|
|| previousErrorMessage.errorType != errorMessage.errorType
|
|
{
|
|
itemViewState.isFirstInCluster = true
|
|
} else {
|
|
itemViewState.isFirstInCluster = false
|
|
}
|
|
case .info:
|
|
if
|
|
let infoMessage = interaction as? TSInfoMessage,
|
|
let previousInfoMessage = previousItem.interaction as? TSInfoMessage,
|
|
infoMessage.messageType == .verificationStateChange
|
|
|| previousInfoMessage.messageType != infoMessage.messageType
|
|
{
|
|
itemViewState.isFirstInCluster = true
|
|
} else {
|
|
itemViewState.isFirstInCluster = false
|
|
}
|
|
case .call:
|
|
itemViewState.isFirstInCluster = false
|
|
default:
|
|
itemViewState.isFirstInCluster = true
|
|
}
|
|
} else {
|
|
itemViewState.isFirstInCluster = true
|
|
}
|
|
|
|
if
|
|
let nextItem,
|
|
interaction.interactionType == nextItem.interaction.interactionType
|
|
{
|
|
switch nextItem.interaction.interactionType {
|
|
case .error:
|
|
if
|
|
let errorMessage = interaction as? TSErrorMessage,
|
|
let nextErrorMessage = nextItem.interaction as? TSErrorMessage,
|
|
errorMessage.errorType == .nonBlockingIdentityChange
|
|
|| nextErrorMessage.errorType != errorMessage.errorType
|
|
{
|
|
itemViewState.isLastInCluster = true
|
|
} else {
|
|
itemViewState.isLastInCluster = false
|
|
}
|
|
case .info:
|
|
if
|
|
let infoMessage = interaction as? TSInfoMessage,
|
|
let nextInfoMessage = nextItem.interaction as? TSInfoMessage,
|
|
infoMessage.messageType == .verificationStateChange
|
|
|| nextInfoMessage.messageType != infoMessage.messageType
|
|
{
|
|
itemViewState.isLastInCluster = true
|
|
} else {
|
|
itemViewState.isLastInCluster = false
|
|
}
|
|
case .call:
|
|
itemViewState.isLastInCluster = false
|
|
default:
|
|
itemViewState.isLastInCluster = true
|
|
}
|
|
} else {
|
|
itemViewState.isLastInCluster = true
|
|
}
|
|
}
|
|
|
|
let collapseCutoffTimestamp = viewStateSnapshot.collapseCutoffDate.ows_millisecondsSince1970
|
|
if interaction.receivedAtTimestamp > collapseCutoffTimestamp {
|
|
itemViewState.shouldHideFooter = false
|
|
}
|
|
|
|
if
|
|
let nextMessage = nextItem?.interaction as? TSMessage,
|
|
let rowId = nextMessage.sqliteRowId,
|
|
let attachment = DependenciesBridge.shared.attachmentStore
|
|
.fetchAnyReferencedAttachment(for: .messageBodyAttachment(messageRowId: rowId), tx: transaction),
|
|
attachment.attachment.asStream()?.contentType.isAudio
|
|
?? MimeTypeUtil.isSupportedAudioMimeType(attachment.attachment.mimeType)
|
|
{
|
|
|
|
if let stream = attachment.asReferencedStream {
|
|
itemViewState.nextAudioAttachment = AudioAttachment(
|
|
attachmentStream: stream,
|
|
owningMessage: nextMessage,
|
|
metadata: nil,
|
|
receivedAtDate: nextMessage.receivedAtDate,
|
|
)
|
|
} else if let pointer = attachment.asReferencedAnyPointer {
|
|
itemViewState.nextAudioAttachment = AudioAttachment(
|
|
attachmentPointer: pointer,
|
|
owningMessage: nextMessage,
|
|
metadata: nil,
|
|
receivedAtDate: nextMessage.receivedAtDate,
|
|
downloadState: pointer.attachmentPointer.downloadState(tx: transaction),
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
private class ComponentStateCache {
|
|
var cache = [String: CVComponentState]()
|
|
|
|
func add(interactionId: String, componentState: CVComponentState) {
|
|
cache[interactionId] = componentState
|
|
}
|
|
|
|
func get(interactionId: String) -> CVComponentState? {
|
|
cache[interactionId]
|
|
}
|
|
}
|
|
|
|
private var componentStateCache = ComponentStateCache()
|
|
|
|
mutating func reuseComponentStates(
|
|
prevRenderState: CVRenderState,
|
|
updatedInteractionIds: Set<String>,
|
|
) {
|
|
for renderItem in prevRenderState.items {
|
|
guard !updatedInteractionIds.contains(renderItem.interactionUniqueId) else {
|
|
continue
|
|
}
|
|
componentStateCache.add(
|
|
interactionId: renderItem.interactionUniqueId,
|
|
componentState: renderItem.rootComponent.componentState,
|
|
)
|
|
}
|
|
}
|
|
|
|
private static func buildComponentState(
|
|
interaction: TSInteraction,
|
|
itemBuildingContext: CVItemBuildingContext,
|
|
componentStateCache: ComponentStateCache,
|
|
) throws -> CVComponentState {
|
|
if let componentState = componentStateCache.get(interactionId: interaction.uniqueId) {
|
|
// CVComponentState is immutable and safe to re-use without copying. It's currently a struct.
|
|
return componentState
|
|
}
|
|
return try CVComponentState.build(
|
|
interaction: interaction,
|
|
itemBuildingContext: itemBuildingContext,
|
|
)
|
|
}
|
|
|
|
private mutating func addItem(interaction: TSInteraction) -> ItemBuilder? {
|
|
guard
|
|
let item = Self.itemBuilder(
|
|
forInteraction: interaction,
|
|
thread: thread,
|
|
threadAssociatedData: threadAssociatedData,
|
|
itemBuildingContext: itemBuildingContext,
|
|
componentStateCache: componentStateCache,
|
|
)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
if let previousItem {
|
|
configureAdjacent(
|
|
item: item,
|
|
previousItem: previousItem,
|
|
viewStateSnapshot: viewStateSnapshot,
|
|
)
|
|
}
|
|
|
|
// Hide "call" buttons if there is an active call in another thread.
|
|
func isCurrentGroupCallForCurrentThread() -> Bool {
|
|
guard
|
|
let currentGroupThreadCallGroupId = viewStateSnapshot.currentGroupThreadCallGroupId,
|
|
let groupThread = thread as? TSGroupThread
|
|
else {
|
|
return false
|
|
}
|
|
return currentGroupThreadCallGroupId.serialize() == groupThread.groupId
|
|
}
|
|
|
|
if
|
|
item.interactionType == .call,
|
|
viewStateSnapshot.hasActiveCall,
|
|
!isCurrentGroupCallForCurrentThread()
|
|
{
|
|
item.itemViewState.shouldCollapseSystemMessageAction = true
|
|
}
|
|
|
|
items.append(item)
|
|
|
|
return item
|
|
}
|
|
|
|
private static func itemBuilder(
|
|
forInteraction interaction: TSInteraction,
|
|
thread: TSThread,
|
|
threadAssociatedData: ThreadAssociatedData,
|
|
itemBuildingContext: CVItemBuildingContext,
|
|
componentStateCache: ComponentStateCache,
|
|
) -> ItemBuilder? {
|
|
let componentState: CVComponentState
|
|
do {
|
|
componentState = try buildComponentState(
|
|
interaction: interaction,
|
|
itemBuildingContext: itemBuildingContext,
|
|
componentStateCache: componentStateCache,
|
|
)
|
|
} catch {
|
|
owsFailDebug("Error: \(error)")
|
|
return nil
|
|
}
|
|
|
|
return ItemBuilder(
|
|
interaction: interaction,
|
|
thread: thread,
|
|
threadAssociatedData: threadAssociatedData,
|
|
componentState: componentState,
|
|
)
|
|
}
|
|
|
|
private func configureAdjacent(
|
|
item: ItemBuilder,
|
|
previousItem: ItemBuilder,
|
|
viewStateSnapshot: CVViewStateSnapshot,
|
|
) {
|
|
let interaction = item.interaction
|
|
guard previousItem.interactionType == item.interactionType else {
|
|
return
|
|
}
|
|
|
|
switch item.interactionType {
|
|
case .error:
|
|
guard
|
|
let errorMessage = interaction as? TSErrorMessage,
|
|
let previousErrorMessage = previousItem.interaction as? TSErrorMessage
|
|
else {
|
|
owsFailDebug("Invalid interactions.")
|
|
return
|
|
}
|
|
if errorMessage.errorType == .nonBlockingIdentityChange {
|
|
return
|
|
}
|
|
previousItem.itemViewState.shouldCollapseSystemMessageAction
|
|
= previousErrorMessage.errorType == errorMessage.errorType
|
|
case .info:
|
|
guard
|
|
let infoMessage = interaction as? TSInfoMessage,
|
|
let previousInfoMessage = previousItem.interaction as? TSInfoMessage
|
|
else {
|
|
owsFailDebug("Invalid interactions.")
|
|
return
|
|
}
|
|
switch infoMessage.messageType {
|
|
case .verificationStateChange, .typeGroupUpdate, .threadMerge, .sessionSwitchover, .typePinnedMessage:
|
|
return // never collapse
|
|
case .phoneNumberChange:
|
|
// Only collapse if the previous message was a change number for the same user
|
|
guard
|
|
let previousAci = previousInfoMessage.phoneNumberChangeInfo()?.aci,
|
|
let currentAci = infoMessage.phoneNumberChangeInfo()?.aci
|
|
else {
|
|
return
|
|
}
|
|
previousItem.itemViewState.shouldCollapseSystemMessageAction = previousAci == currentAci
|
|
default:
|
|
// always collapse matching types
|
|
previousItem.itemViewState.shouldCollapseSystemMessageAction
|
|
= previousInfoMessage.messageType == infoMessage.messageType
|
|
}
|
|
case .call:
|
|
previousItem.itemViewState.shouldCollapseSystemMessageAction = true
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private extension MessageLoader {
|
|
var shouldShowThreadDetails: Bool {
|
|
!canLoadOlder
|
|
}
|
|
|
|
func shouldShowDefaultDisappearingMessageTimer(thread: TSThread, transaction: DBReadTransaction) -> Bool {
|
|
guard let contactThread = thread as? TSContactThread else {
|
|
// Group threads get their initial disappearing message timer during
|
|
// group creation.
|
|
return false
|
|
}
|
|
|
|
return ThreadFinder().shouldSetDefaultDisappearingMessageTimer(
|
|
contactThread: contactThread,
|
|
transaction: transaction,
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
private class ItemBuilder {
|
|
let interaction: TSInteraction
|
|
let thread: TSThread
|
|
let threadAssociatedData: ThreadAssociatedData
|
|
let componentState: CVComponentState
|
|
var itemViewState = CVItemViewState.Builder()
|
|
|
|
init(
|
|
interaction: TSInteraction,
|
|
thread: TSThread,
|
|
threadAssociatedData: ThreadAssociatedData,
|
|
componentState: CVComponentState,
|
|
) {
|
|
self.interaction = interaction
|
|
self.thread = thread
|
|
self.threadAssociatedData = threadAssociatedData
|
|
self.componentState = componentState
|
|
}
|
|
|
|
func build(coreState: CVCoreState) -> CVItemModel {
|
|
CVItemModel(
|
|
interaction: interaction,
|
|
thread: thread,
|
|
threadAssociatedData: threadAssociatedData,
|
|
componentState: componentState,
|
|
itemViewState: itemViewState.build(),
|
|
coreState: coreState,
|
|
)
|
|
}
|
|
|
|
var interactionType: OWSInteractionType {
|
|
interaction.interactionType
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
class DisplayNameCache {
|
|
private var displayNameCache = [ServiceId: DisplayName]()
|
|
|
|
private func _displayName(for address: SignalServiceAddress, tx: DBReadTransaction) -> DisplayName {
|
|
if let serviceId = address.serviceId, let displayName = displayNameCache[serviceId] {
|
|
return displayName
|
|
}
|
|
let displayName = SSKEnvironment.shared.contactManagerRef.displayName(for: address, tx: tx)
|
|
if let serviceId = address.serviceId {
|
|
displayNameCache[serviceId] = displayName
|
|
}
|
|
return displayName
|
|
}
|
|
|
|
func shortDisplayName(address: SignalServiceAddress, transaction: DBReadTransaction) -> String {
|
|
return _displayName(for: address, tx: transaction).resolvedValue(useShortNameIfAvailable: true)
|
|
}
|
|
|
|
func displayName(address: SignalServiceAddress, transaction: DBReadTransaction) -> String {
|
|
return _displayName(for: address, tx: transaction).resolvedValue(useShortNameIfAvailable: false)
|
|
}
|
|
}
|