// // 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() 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, ) { 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) } }