The main effect of this is that mentions should now get their names properly updated when the corresponding profile is fetched, fixing the situation where someone just added to a group shows up as "Unknown" or as their phone number until some point in the future. (The "Unknown" can still flicker in here, but at least it will get fixed, just like the top line with the sender name.) This does remove a layer of caching, but as noted in the now-deleted code, there's already a *higher* level of caching for an entire CVComponentState, and I verified that we're not actually hitting the deleted cache more than once while a conversation is open. The higher-level cache already knows that some kinds of reloads require rebuilding the displayed content, so removing this cache was sufficient to get things to work.
1217 lines
50 KiB
Swift
1217 lines
50 KiB
Swift
//
|
|
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import SignalUI
|
|
|
|
public class CVComponentState: Equatable, Dependencies {
|
|
let messageCellType: CVMessageCellType
|
|
|
|
struct SenderName: Equatable {
|
|
let senderName: NSAttributedString
|
|
let senderNameColor: UIColor
|
|
}
|
|
let senderName: SenderName?
|
|
|
|
struct SenderAvatar: Equatable {
|
|
let avatarDataSource: ConversationAvatarDataSource
|
|
}
|
|
let senderAvatar: SenderAvatar?
|
|
|
|
enum BodyText: Equatable {
|
|
case bodyText(displayableText: DisplayableText)
|
|
|
|
// TODO: Should we have oversizeTextFailed?
|
|
case oversizeTextDownloading
|
|
|
|
// We use the "body text" component to
|
|
// render the "remotely deleted" indicator.
|
|
case remotelyDeleted
|
|
|
|
var displayableText: DisplayableText? {
|
|
if case .bodyText(let displayableText) = self {
|
|
return displayableText
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func textValue(isTextExpanded: Bool) -> CVTextValue? {
|
|
switch self {
|
|
case .bodyText(let displayableText):
|
|
return displayableText.textValue(isTextExpanded: isTextExpanded)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var jumbomojiCount: UInt? {
|
|
switch self {
|
|
case .bodyText(let displayableText):
|
|
return displayableText.jumbomojiCount
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
var isJumbomojiMessage: Bool {
|
|
guard let jumbomojiCount = jumbomojiCount else {
|
|
return false
|
|
}
|
|
return jumbomojiCount > 0
|
|
}
|
|
}
|
|
let bodyText: BodyText?
|
|
|
|
struct BodyMedia: Equatable {
|
|
let items: [CVMediaAlbumItem]
|
|
let mediaAlbumHasFailedAttachment: Bool
|
|
let mediaAlbumHasPendingAttachment: Bool
|
|
}
|
|
let bodyMedia: BodyMedia?
|
|
|
|
struct GenericAttachment: Equatable {
|
|
let attachment: TSAttachment
|
|
|
|
var attachmentStream: TSAttachmentStream? {
|
|
attachment as? TSAttachmentStream
|
|
}
|
|
var attachmentPointer: TSAttachmentPointer? {
|
|
attachment as? TSAttachmentPointer
|
|
}
|
|
}
|
|
let genericAttachment: GenericAttachment?
|
|
|
|
// It's not practical to reload the audio cell every time
|
|
// playback state or progress changes. Therefore we only
|
|
// capture the stable state related to the audio. Dynamic
|
|
// state is obtained from CVAudioPlayer.
|
|
//
|
|
// NOTE: We use the existing AudioAttachment entity,
|
|
// not a custom struct.
|
|
let audioAttachment: AudioAttachment?
|
|
|
|
struct ViewOnce: Equatable {
|
|
let viewOnceState: ViewOnceState
|
|
}
|
|
let viewOnce: ViewOnce?
|
|
|
|
struct QuotedReply: Equatable {
|
|
let viewState: QuotedMessageView.State
|
|
|
|
// TODO: convert OWSQuotedReplyModel to Swift.
|
|
var quotedReplyModel: OWSQuotedReplyModel { viewState.quotedReplyModel }
|
|
var displayableQuotedText: DisplayableText? { viewState.displayableQuotedText }
|
|
}
|
|
let quotedReply: QuotedReply?
|
|
|
|
enum Sticker: Equatable {
|
|
case available(stickerMetadata: StickerMetadata,
|
|
attachmentStream: TSAttachmentStream)
|
|
case downloading(attachmentPointer: TSAttachmentPointer)
|
|
case failedOrPending(attachmentPointer: TSAttachmentPointer)
|
|
|
|
public var stickerMetadata: StickerMetadata? {
|
|
switch self {
|
|
case .available(let stickerMetadata, _):
|
|
return stickerMetadata
|
|
case .downloading, .failedOrPending:
|
|
return nil
|
|
}
|
|
}
|
|
public var attachmentStream: TSAttachmentStream? {
|
|
switch self {
|
|
case .available(_, let attachmentStream):
|
|
return attachmentStream
|
|
case .downloading:
|
|
return nil
|
|
case .failedOrPending:
|
|
return nil
|
|
}
|
|
}
|
|
public var attachmentPointer: TSAttachmentPointer? {
|
|
switch self {
|
|
case .available:
|
|
return nil
|
|
case .downloading(let attachmentPointer):
|
|
return attachmentPointer
|
|
case .failedOrPending(let attachmentPointer):
|
|
return attachmentPointer
|
|
}
|
|
}
|
|
}
|
|
let sticker: Sticker?
|
|
|
|
struct ContactShare: Equatable {
|
|
// TODO: convert ContactShareViewModel to Swift?
|
|
let state: CVContactShareView.State
|
|
}
|
|
let contactShare: ContactShare?
|
|
|
|
struct LinkPreview: Equatable {
|
|
// TODO: convert OWSLinkPreview to Swift?
|
|
let linkPreview: OWSLinkPreview
|
|
let linkPreviewAttachment: TSAttachment?
|
|
let state: LinkPreviewState
|
|
|
|
// MARK: - Equatable
|
|
|
|
public static func == (lhs: LinkPreview, rhs: LinkPreview) -> Bool {
|
|
guard let lhs = lhs.state as? NSObject else {
|
|
owsFailDebug("Invalid value.")
|
|
return false
|
|
}
|
|
guard let rhs = rhs.state as? NSObject else {
|
|
owsFailDebug("Invalid value.")
|
|
return false
|
|
}
|
|
return NSObject.isNullableObject(lhs, equalTo: rhs)
|
|
}
|
|
}
|
|
let linkPreview: LinkPreview?
|
|
|
|
struct SystemMessage: Equatable {
|
|
let title: NSAttributedString
|
|
let titleColor: UIColor
|
|
let action: CVMessageAction?
|
|
}
|
|
let systemMessage: SystemMessage?
|
|
|
|
struct DateHeader: Equatable {
|
|
}
|
|
let dateHeader: DateHeader?
|
|
|
|
struct UnreadIndicator: Equatable {
|
|
}
|
|
let unreadIndicator: UnreadIndicator?
|
|
|
|
struct Reactions: Equatable {
|
|
let reactionState: InteractionReactionState
|
|
let viewState: CVReactionCountsView.State
|
|
}
|
|
let reactions: Reactions?
|
|
|
|
struct TypingIndicator: Equatable {
|
|
let address: SignalServiceAddress
|
|
let avatarDataSource: ConversationAvatarDataSource?
|
|
}
|
|
let typingIndicator: TypingIndicator?
|
|
|
|
struct ThreadDetails: Equatable {
|
|
let avatarDataSource: ConversationAvatarDataSource?
|
|
let isAvatarBlurred: Bool
|
|
let titleText: String
|
|
let bioText: String?
|
|
let detailsText: String?
|
|
let mutualGroupsText: NSAttributedString?
|
|
let groupDescriptionText: String?
|
|
}
|
|
let threadDetails: ThreadDetails?
|
|
|
|
typealias UnknownThreadWarning = CVComponentState.SystemMessage
|
|
let unknownThreadWarning: UnknownThreadWarning?
|
|
|
|
typealias DefaultDisappearingMessageTimer = CVComponentState.SystemMessage
|
|
let defaultDisappearingMessageTimer: DefaultDisappearingMessageTimer?
|
|
|
|
struct BottomButtons: Equatable {
|
|
let actions: [CVMessageAction]
|
|
}
|
|
let bottomButtons: BottomButtons?
|
|
|
|
struct FailedOrPendingDownloads: Equatable {
|
|
let attachmentPointers: [TSAttachmentPointer]
|
|
}
|
|
let failedOrPendingDownloads: FailedOrPendingDownloads?
|
|
|
|
struct SendFailureBadge: Equatable {
|
|
let color: UIColor
|
|
}
|
|
let sendFailureBadge: SendFailureBadge?
|
|
|
|
fileprivate init(messageCellType: CVMessageCellType,
|
|
senderName: SenderName?,
|
|
senderAvatar: SenderAvatar?,
|
|
bodyText: BodyText?,
|
|
bodyMedia: BodyMedia?,
|
|
genericAttachment: GenericAttachment?,
|
|
audioAttachment: AudioAttachment?,
|
|
viewOnce: ViewOnce?,
|
|
quotedReply: QuotedReply?,
|
|
sticker: Sticker?,
|
|
contactShare: ContactShare?,
|
|
linkPreview: LinkPreview?,
|
|
systemMessage: SystemMessage?,
|
|
dateHeader: DateHeader?,
|
|
unreadIndicator: UnreadIndicator?,
|
|
reactions: Reactions?,
|
|
typingIndicator: TypingIndicator?,
|
|
threadDetails: ThreadDetails?,
|
|
unknownThreadWarning: UnknownThreadWarning?,
|
|
defaultDisappearingMessageTimer: DefaultDisappearingMessageTimer?,
|
|
bottomButtons: BottomButtons?,
|
|
failedOrPendingDownloads: FailedOrPendingDownloads?,
|
|
sendFailureBadge: SendFailureBadge?) {
|
|
|
|
self.messageCellType = messageCellType
|
|
self.senderName = senderName
|
|
self.senderAvatar = senderAvatar
|
|
self.bodyText = bodyText
|
|
self.bodyMedia = bodyMedia
|
|
self.genericAttachment = genericAttachment
|
|
self.audioAttachment = audioAttachment
|
|
self.viewOnce = viewOnce
|
|
self.quotedReply = quotedReply
|
|
self.sticker = sticker
|
|
self.contactShare = contactShare
|
|
self.linkPreview = linkPreview
|
|
self.systemMessage = systemMessage
|
|
self.dateHeader = dateHeader
|
|
self.unreadIndicator = unreadIndicator
|
|
self.reactions = reactions
|
|
self.typingIndicator = typingIndicator
|
|
self.threadDetails = threadDetails
|
|
self.unknownThreadWarning = unknownThreadWarning
|
|
self.defaultDisappearingMessageTimer = defaultDisappearingMessageTimer
|
|
self.bottomButtons = bottomButtons
|
|
self.failedOrPendingDownloads = failedOrPendingDownloads
|
|
self.sendFailureBadge = sendFailureBadge
|
|
}
|
|
|
|
// MARK: - Equatable
|
|
|
|
public static func == (lhs: CVComponentState, rhs: CVComponentState) -> Bool {
|
|
return (lhs.messageCellType == rhs.messageCellType &&
|
|
lhs.senderName == rhs.senderName &&
|
|
lhs.senderAvatar == rhs.senderAvatar &&
|
|
lhs.bodyText == rhs.bodyText &&
|
|
lhs.bodyMedia == rhs.bodyMedia &&
|
|
lhs.genericAttachment == rhs.genericAttachment &&
|
|
lhs.audioAttachment == rhs.audioAttachment &&
|
|
lhs.viewOnce == rhs.viewOnce &&
|
|
lhs.quotedReply == rhs.quotedReply &&
|
|
lhs.sticker == rhs.sticker &&
|
|
lhs.contactShare == rhs.contactShare &&
|
|
lhs.linkPreview == rhs.linkPreview &&
|
|
lhs.systemMessage == rhs.systemMessage &&
|
|
lhs.dateHeader == rhs.dateHeader &&
|
|
lhs.unreadIndicator == rhs.unreadIndicator &&
|
|
lhs.reactions == rhs.reactions &&
|
|
lhs.typingIndicator == rhs.typingIndicator &&
|
|
lhs.threadDetails == rhs.threadDetails &&
|
|
lhs.unknownThreadWarning == rhs.unknownThreadWarning &&
|
|
lhs.defaultDisappearingMessageTimer == rhs.defaultDisappearingMessageTimer &&
|
|
lhs.bottomButtons == rhs.bottomButtons &&
|
|
lhs.failedOrPendingDownloads == rhs.failedOrPendingDownloads &&
|
|
lhs.sendFailureBadge == rhs.sendFailureBadge)
|
|
}
|
|
|
|
// MARK: - Building
|
|
|
|
fileprivate struct Builder: CVItemBuilding {
|
|
typealias SenderName = CVComponentState.SenderName
|
|
typealias SenderAvatar = CVComponentState.SenderAvatar
|
|
typealias BodyText = CVComponentState.BodyText
|
|
typealias BodyMedia = CVComponentState.BodyMedia
|
|
typealias GenericAttachment = CVComponentState.GenericAttachment
|
|
typealias ViewOnce = CVComponentState.ViewOnce
|
|
typealias QuotedReply = CVComponentState.QuotedReply
|
|
typealias Sticker = CVComponentState.Sticker
|
|
typealias SystemMessage = CVComponentState.SystemMessage
|
|
typealias ContactShare = CVComponentState.ContactShare
|
|
typealias Reactions = CVComponentState.Reactions
|
|
typealias LinkPreview = CVComponentState.LinkPreview
|
|
typealias DateHeader = CVComponentState.DateHeader
|
|
typealias UnreadIndicator = CVComponentState.UnreadIndicator
|
|
typealias TypingIndicator = CVComponentState.TypingIndicator
|
|
typealias ThreadDetails = CVComponentState.ThreadDetails
|
|
typealias UnknownThreadWarning = CVComponentState.UnknownThreadWarning
|
|
typealias DefaultDisappearingMessageTimer = CVComponentState.DefaultDisappearingMessageTimer
|
|
typealias FailedOrPendingDownloads = CVComponentState.FailedOrPendingDownloads
|
|
typealias BottomButtons = CVComponentState.BottomButtons
|
|
typealias SendFailureBadge = CVComponentState.SendFailureBadge
|
|
|
|
let interaction: TSInteraction
|
|
let itemBuildingContext: CVItemBuildingContext
|
|
|
|
var senderName: SenderName?
|
|
var senderAvatar: SenderAvatar?
|
|
var bodyText: BodyText?
|
|
var bodyMedia: BodyMedia?
|
|
var genericAttachment: GenericAttachment?
|
|
var audioAttachment: AudioAttachment?
|
|
var viewOnce: ViewOnce?
|
|
var quotedReply: QuotedReply?
|
|
var sticker: Sticker?
|
|
var systemMessage: SystemMessage?
|
|
var contactShare: ContactShare?
|
|
var linkPreview: LinkPreview?
|
|
var dateHeader: DateHeader?
|
|
var unreadIndicator: UnreadIndicator?
|
|
var typingIndicator: TypingIndicator?
|
|
var threadDetails: ThreadDetails?
|
|
var unknownThreadWarning: UnknownThreadWarning?
|
|
var defaultDisappearingMessageTimer: DefaultDisappearingMessageTimer?
|
|
var reactions: Reactions?
|
|
var failedOrPendingDownloads: FailedOrPendingDownloads?
|
|
var sendFailureBadge: SendFailureBadge?
|
|
|
|
var bottomButtonsActions = [CVMessageAction]()
|
|
|
|
init(interaction: TSInteraction, itemBuildingContext: CVItemBuildingContext) {
|
|
self.interaction = interaction
|
|
self.itemBuildingContext = itemBuildingContext
|
|
}
|
|
|
|
mutating func build() -> CVComponentState {
|
|
var bottomButtons: BottomButtons?
|
|
if !bottomButtonsActions.isEmpty {
|
|
bottomButtons = BottomButtons(actions: bottomButtonsActions)
|
|
}
|
|
|
|
return CVComponentState(messageCellType: messageCellType,
|
|
senderName: senderName,
|
|
senderAvatar: senderAvatar,
|
|
bodyText: bodyText,
|
|
bodyMedia: bodyMedia,
|
|
genericAttachment: genericAttachment,
|
|
audioAttachment: audioAttachment,
|
|
viewOnce: viewOnce,
|
|
quotedReply: quotedReply,
|
|
sticker: sticker,
|
|
contactShare: contactShare,
|
|
linkPreview: linkPreview,
|
|
systemMessage: systemMessage,
|
|
dateHeader: dateHeader,
|
|
unreadIndicator: unreadIndicator,
|
|
reactions: reactions,
|
|
typingIndicator: typingIndicator,
|
|
threadDetails: threadDetails,
|
|
unknownThreadWarning: unknownThreadWarning,
|
|
defaultDisappearingMessageTimer: defaultDisappearingMessageTimer,
|
|
bottomButtons: bottomButtons,
|
|
failedOrPendingDownloads: failedOrPendingDownloads,
|
|
sendFailureBadge: sendFailureBadge)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
lazy var isIncoming: Bool = {
|
|
interaction as? TSIncomingMessage != nil
|
|
}()
|
|
|
|
lazy var isOutgoing: Bool = {
|
|
interaction as? TSOutgoingMessage != nil
|
|
}()
|
|
|
|
lazy var messageCellType: CVMessageCellType = {
|
|
if dateHeader != nil {
|
|
return .dateHeader
|
|
}
|
|
if unreadIndicator != nil {
|
|
return .unreadIndicator
|
|
}
|
|
if typingIndicator != nil {
|
|
return .typingIndicator
|
|
}
|
|
if threadDetails != nil {
|
|
return .threadDetails
|
|
}
|
|
if unknownThreadWarning != nil {
|
|
return .unknownThreadWarning
|
|
}
|
|
if defaultDisappearingMessageTimer != nil {
|
|
return .defaultDisappearingMessageTimer
|
|
}
|
|
if systemMessage != nil {
|
|
return .systemMessage
|
|
}
|
|
if let sticker = self.sticker {
|
|
return .stickerMessage
|
|
}
|
|
if viewOnce != nil {
|
|
return .viewOnce
|
|
}
|
|
if contactShare != nil {
|
|
return .contactShare
|
|
}
|
|
if audioAttachment != nil {
|
|
return .audio
|
|
}
|
|
if genericAttachment != nil {
|
|
return .genericAttachment
|
|
}
|
|
if bodyMedia != nil {
|
|
return .bodyMedia
|
|
}
|
|
if bodyText != nil {
|
|
return .textOnlyMessage
|
|
}
|
|
|
|
Logger.verbose("interaction: \(interaction.debugDescription)")
|
|
owsFailDebug("Unknown state.")
|
|
return .unknown
|
|
}()
|
|
}
|
|
|
|
// MARK: - Convenience
|
|
|
|
lazy var isSticker: Bool = {
|
|
sticker != nil
|
|
}()
|
|
|
|
lazy var activeComponentStateKeys: Set<CVComponentKey> = {
|
|
|
|
var result = Set<CVComponentKey>()
|
|
|
|
if senderName != nil {
|
|
result.insert(.senderName)
|
|
}
|
|
if senderAvatar != nil {
|
|
result.insert(.senderAvatar)
|
|
}
|
|
if bodyText != nil {
|
|
result.insert(.bodyText)
|
|
}
|
|
if bodyMedia != nil {
|
|
result.insert(.bodyMedia)
|
|
}
|
|
if genericAttachment != nil {
|
|
result.insert(.genericAttachment)
|
|
}
|
|
if audioAttachment != nil {
|
|
result.insert(.audioAttachment)
|
|
}
|
|
if viewOnce != nil {
|
|
result.insert(.viewOnce)
|
|
}
|
|
if quotedReply != nil {
|
|
result.insert(.quotedReply)
|
|
}
|
|
if sticker != nil {
|
|
result.insert(.sticker)
|
|
}
|
|
if contactShare != nil {
|
|
result.insert(.contactShare)
|
|
}
|
|
if linkPreview != nil {
|
|
result.insert(.linkPreview)
|
|
}
|
|
if systemMessage != nil {
|
|
result.insert(.systemMessage)
|
|
}
|
|
if dateHeader != nil {
|
|
result.insert(.dateHeader)
|
|
}
|
|
if unreadIndicator != nil {
|
|
result.insert(.unreadIndicator)
|
|
}
|
|
if reactions != nil {
|
|
result.insert(.reactions)
|
|
}
|
|
if typingIndicator != nil {
|
|
result.insert(.typingIndicator)
|
|
}
|
|
if threadDetails != nil {
|
|
result.insert(.threadDetails)
|
|
}
|
|
if unknownThreadWarning != nil {
|
|
result.insert(.unknownThreadWarning)
|
|
}
|
|
if defaultDisappearingMessageTimer != nil {
|
|
result.insert(.defaultDisappearingMessageTimer)
|
|
}
|
|
if bottomButtons != nil {
|
|
result.insert(.bottomButtons)
|
|
}
|
|
if failedOrPendingDownloads != nil {
|
|
result.insert(.failedOrPendingDownloads)
|
|
}
|
|
if sendFailureBadge != nil {
|
|
result.insert(.sendFailureBadge)
|
|
}
|
|
return result
|
|
}()
|
|
|
|
lazy var isTextOnlyMessage: Bool = {
|
|
let validKeys: [CVComponentKey] = [
|
|
.senderName,
|
|
.senderAvatar,
|
|
.bodyText,
|
|
.footer,
|
|
.reactions,
|
|
.sendFailureBadge
|
|
]
|
|
return activeComponentStateKeys.isSubset(of: Set(validKeys))
|
|
}()
|
|
|
|
lazy var isJumbomojiMessage: Bool = {
|
|
isTextOnlyMessage && (bodyText?.isJumbomojiMessage == true)
|
|
}()
|
|
|
|
lazy var isBodyMediaOnlyMessage: Bool = {
|
|
let validKeys: [CVComponentKey] = [
|
|
.senderName,
|
|
.senderAvatar,
|
|
.bodyMedia,
|
|
.footer,
|
|
.reactions,
|
|
.sendFailureBadge
|
|
]
|
|
return activeComponentStateKeys.isSubset(of: Set(validKeys))
|
|
}()
|
|
|
|
lazy var isBorderlessBodyMediaMessage: Bool = {
|
|
if isBodyMediaOnlyMessage,
|
|
let bodyMedia = bodyMedia,
|
|
bodyMedia.items.count == 1,
|
|
let firstItem = bodyMedia.items.first,
|
|
let attachmentStream = firstItem.attachmentStream,
|
|
attachmentStream.isBorderless {
|
|
return true
|
|
}
|
|
return false
|
|
}()
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
extension CVComponentState {
|
|
|
|
static func buildDateHeader(interaction: TSInteraction,
|
|
itemBuildingContext: CVItemBuildingContext) -> CVComponentState {
|
|
var builder = CVComponentState.Builder(interaction: interaction,
|
|
itemBuildingContext: itemBuildingContext)
|
|
builder.dateHeader = DateHeader()
|
|
return builder.build()
|
|
}
|
|
|
|
static func buildUnreadIndicator(interaction: TSInteraction,
|
|
itemBuildingContext: CVItemBuildingContext) -> CVComponentState {
|
|
var builder = CVComponentState.Builder(interaction: interaction,
|
|
itemBuildingContext: itemBuildingContext)
|
|
builder.unreadIndicator = UnreadIndicator()
|
|
return builder.build()
|
|
}
|
|
|
|
static func build(interaction: TSInteraction,
|
|
itemBuildingContext: CVItemBuildingContext) throws -> CVComponentState {
|
|
var builder = CVComponentState.Builder(interaction: interaction,
|
|
itemBuildingContext: itemBuildingContext)
|
|
return try builder.populateAndBuild()
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
fileprivate extension CVComponentState.Builder {
|
|
|
|
mutating func populateAndBuild() throws -> CVComponentState {
|
|
|
|
if let reactionState = InteractionReactionState(interaction: interaction,
|
|
transaction: transaction),
|
|
reactionState.hasReactions {
|
|
self.reactions = Reactions(reactionState: reactionState,
|
|
viewState: CVReactionCountsView.buildState(with: reactionState))
|
|
}
|
|
|
|
self.senderAvatar = tryToBuildSenderAvatar()
|
|
|
|
self.failedOrPendingDownloads = tryToBuildFailedOrPendingDownloads()
|
|
|
|
switch interaction.interactionType {
|
|
case .threadDetails:
|
|
self.threadDetails = buildThreadDetails()
|
|
return build()
|
|
case .unknownThreadWarning:
|
|
self.unknownThreadWarning = CVComponentSystemMessage.buildUnknownThreadWarningState(interaction: interaction,
|
|
threadViewModel: threadViewModel,
|
|
transaction: transaction)
|
|
return build()
|
|
case .defaultDisappearingMessageTimer:
|
|
self.defaultDisappearingMessageTimer = CVComponentSystemMessage.buildDefaultDisappearingMessageTimerState(
|
|
interaction: interaction,
|
|
threadViewModel: threadViewModel,
|
|
transaction: transaction
|
|
)
|
|
return build()
|
|
case .typingIndicator:
|
|
guard let typingIndicatorInteraction = interaction as? TypingIndicatorInteraction else {
|
|
owsFailDebug("Invalid typingIndicator.")
|
|
return build()
|
|
}
|
|
let avatarDataSource = { () -> ConversationAvatarDataSource? in
|
|
guard thread.isGroupThread else {
|
|
return nil
|
|
}
|
|
return self.avatarBuilder.buildAvatarDataSource(
|
|
forAddress: typingIndicatorInteraction.address,
|
|
includingBadge: true,
|
|
localUserDisplayMode: .asUser,
|
|
diameterPoints: UInt(ConversationStyle.groupMessageAvatarSizeClass.diameter))
|
|
}()
|
|
self.typingIndicator = TypingIndicator(address: typingIndicatorInteraction.address,
|
|
avatarDataSource: avatarDataSource)
|
|
return build()
|
|
case .info, .error, .call:
|
|
let currentCallThreadId = viewStateSnapshot.currentCallThreadId
|
|
self.systemMessage = CVComponentSystemMessage.buildComponentState(interaction: interaction,
|
|
threadViewModel: threadViewModel,
|
|
currentCallThreadId: currentCallThreadId,
|
|
transaction: transaction)
|
|
return build()
|
|
case .unreadIndicator:
|
|
unreadIndicator = CVComponentState.UnreadIndicator()
|
|
return build()
|
|
case .dateHeader:
|
|
dateHeader = CVComponentState.DateHeader()
|
|
return build()
|
|
case .incomingMessage, .outgoingMessage:
|
|
guard let message = interaction as? TSMessage else {
|
|
owsFailDebug("Invalid message.")
|
|
return build()
|
|
}
|
|
return try populateAndBuild(message: message)
|
|
case .unknown:
|
|
owsFailDebug("Unknown interaction type.")
|
|
return build()
|
|
default:
|
|
owsFailDebug("Invalid interaction type: \(NSStringFromOWSInteractionType(interaction.interactionType))")
|
|
return build()
|
|
}
|
|
}
|
|
|
|
private func tryToBuildSenderAvatar() -> SenderAvatar? {
|
|
guard thread.isGroupThread,
|
|
let incomingMessage = interaction as? TSIncomingMessage else {
|
|
return nil
|
|
}
|
|
guard let avatarDataSource = self.avatarBuilder.buildAvatarDataSource(
|
|
forAddress: incomingMessage.authorAddress,
|
|
includingBadge: true,
|
|
localUserDisplayMode: .asUser,
|
|
diameterPoints: UInt(ConversationStyle.groupMessageAvatarSizeClass.diameter)
|
|
) else {
|
|
owsFailDebug("Could build avatar image")
|
|
return nil
|
|
}
|
|
return SenderAvatar(avatarDataSource: avatarDataSource)
|
|
}
|
|
|
|
private func tryToBuildFailedOrPendingDownloads() -> FailedOrPendingDownloads? {
|
|
guard let message = interaction as? TSMessage else {
|
|
return nil
|
|
}
|
|
let attachmentPointers = message.failedOrPendingAttachments(transaction: transaction)
|
|
guard !attachmentPointers.isEmpty else {
|
|
return nil
|
|
}
|
|
return FailedOrPendingDownloads(attachmentPointers: attachmentPointers)
|
|
}
|
|
|
|
mutating func populateAndBuild(message: TSMessage) throws -> CVComponentState {
|
|
|
|
if message.wasRemotelyDeleted {
|
|
// If the message has been remotely deleted, suppress everything else.
|
|
self.bodyText = .remotelyDeleted
|
|
return build()
|
|
}
|
|
|
|
if message.isViewOnceMessage {
|
|
return try buildViewOnceMessage(message: message)
|
|
}
|
|
|
|
if let contact = message.contactShare {
|
|
return try buildContact(message: message, contact: contact)
|
|
}
|
|
|
|
if let messageSticker = message.messageSticker {
|
|
return try buildSticker(message: message, messageSticker: messageSticker)
|
|
}
|
|
|
|
// Check for quoted replies _before_ media album handling,
|
|
// since that logic may exit early.
|
|
buildQuotedReply(message: message)
|
|
|
|
try buildBodyText(message: message)
|
|
|
|
if let outgoingMessage = message as? TSOutgoingMessage {
|
|
switch outgoingMessage.messageState {
|
|
case .failed:
|
|
sendFailureBadge = SendFailureBadge(color: .ows_accentRed)
|
|
case .pending:
|
|
sendFailureBadge = SendFailureBadge(color: .ows_gray60)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
do {
|
|
// TODO: Rename this method to TSMessage.bodyAttachments(...)?
|
|
let bodyAttachments = message.mediaAttachments(with: transaction.unwrapGrdbRead)
|
|
let mediaAlbumItems = buildMediaAlbumItems(for: bodyAttachments, message: message)
|
|
if mediaAlbumItems.count > 0 {
|
|
var mediaAlbumHasFailedAttachment = false
|
|
var mediaAlbumHasPendingAttachment = false
|
|
for attachment in bodyAttachments {
|
|
guard let attachmentPointer = attachment as? TSAttachmentPointer else {
|
|
continue
|
|
}
|
|
switch attachmentPointer.state {
|
|
case .downloading, .enqueued:
|
|
continue
|
|
case .failed:
|
|
mediaAlbumHasFailedAttachment = true
|
|
case .pendingMessageRequest:
|
|
mediaAlbumHasPendingAttachment = true
|
|
case .pendingManualDownload:
|
|
mediaAlbumHasPendingAttachment = true
|
|
}
|
|
}
|
|
|
|
self.bodyMedia = BodyMedia(items: mediaAlbumItems,
|
|
mediaAlbumHasFailedAttachment: mediaAlbumHasFailedAttachment,
|
|
mediaAlbumHasPendingAttachment: mediaAlbumHasPendingAttachment)
|
|
return build()
|
|
}
|
|
|
|
// Only media galleries should have more than one attachment.
|
|
owsAssertDebug(bodyAttachments.count <= 1)
|
|
if let bodyAttachment = bodyAttachments.first {
|
|
try buildNonMediaAttachment(bodyAttachment: bodyAttachment)
|
|
}
|
|
}
|
|
|
|
if !threadViewModel.hasPendingMessageRequest,
|
|
let linkPreview = message.linkPreview {
|
|
try buildLinkPreview(message: message, linkPreview: linkPreview)
|
|
}
|
|
|
|
let result = build()
|
|
if result.messageCellType == .unknown {
|
|
Logger.verbose("message: \(message.debugDescription)")
|
|
owsFailDebug("Unknown message cell type.")
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
fileprivate extension CVComponentState.Builder {
|
|
|
|
mutating func buildThreadDetails() -> ThreadDetails {
|
|
owsAssertDebug(interaction as? ThreadDetailsInteraction != nil)
|
|
|
|
return CVComponentThreadDetails.buildComponentState(thread: thread,
|
|
transaction: transaction,
|
|
avatarBuilder: avatarBuilder)
|
|
}
|
|
|
|
// TODO: Should we throw more?
|
|
mutating func buildViewOnceMessage(message: TSMessage) throws -> CVComponentState {
|
|
owsAssertDebug(message.isViewOnceMessage)
|
|
|
|
func buildViewOnce(viewOnceState: ViewOnceState) -> CVComponentState {
|
|
self.viewOnce = ViewOnce(viewOnceState: viewOnceState)
|
|
return self.build()
|
|
}
|
|
|
|
if let outgoingMessage = message as? TSOutgoingMessage {
|
|
let viewOnceState: ViewOnceState
|
|
if message.isViewOnceComplete {
|
|
viewOnceState = .outgoingSentExpired
|
|
} else {
|
|
switch outgoingMessage.messageState {
|
|
case .sending, .pending:
|
|
viewOnceState = .outgoingSending
|
|
case .failed:
|
|
viewOnceState = .outgoingFailed
|
|
default:
|
|
viewOnceState = .outgoingSentExpired
|
|
}
|
|
}
|
|
return buildViewOnce(viewOnceState: viewOnceState)
|
|
} else if nil != message as? TSIncomingMessage {
|
|
if message.isViewOnceComplete {
|
|
return buildViewOnce(viewOnceState: .incomingExpired)
|
|
}
|
|
let hasMoreThanOneAttachment: Bool = message.attachmentIds.count > 1
|
|
let hasBodyText: Bool = !(message.body?.isEmpty ?? true)
|
|
if hasMoreThanOneAttachment || hasBodyText {
|
|
// Refuse to render incoming "view once" messages if they
|
|
// have more than one attachment or any body text.
|
|
owsFailDebug("Invalid content.")
|
|
return buildViewOnce(viewOnceState: .incomingInvalidContent)
|
|
}
|
|
let mediaAttachments: [TSAttachment] = message.mediaAttachments(with: transaction.unwrapGrdbRead)
|
|
// We currently only support single attachments for view-once messages.
|
|
guard let mediaAttachment = mediaAttachments.first else {
|
|
owsFailDebug("Missing attachment.")
|
|
return buildViewOnce(viewOnceState: .incomingInvalidContent)
|
|
}
|
|
if let attachmentPointer = mediaAttachment as? TSAttachmentPointer {
|
|
switch attachmentPointer.state {
|
|
case .enqueued, .downloading:
|
|
return buildViewOnce(viewOnceState: .incomingDownloading(attachmentPointer: attachmentPointer))
|
|
case .failed:
|
|
return buildViewOnce(viewOnceState: .incomingFailed)
|
|
case .pendingMessageRequest, .pendingManualDownload:
|
|
return buildViewOnce(viewOnceState: .incomingPending)
|
|
}
|
|
} else if let attachmentStream = mediaAttachment as? TSAttachmentStream {
|
|
if attachmentStream.isValidVisualMedia
|
|
&& (attachmentStream.isImage || attachmentStream.isAnimated || attachmentStream.isVideo) {
|
|
return buildViewOnce(viewOnceState: .incomingAvailable(attachmentStream: attachmentStream))
|
|
}
|
|
}
|
|
|
|
owsFailDebug("Invalid content.")
|
|
return buildViewOnce(viewOnceState: .incomingInvalidContent)
|
|
} else {
|
|
throw OWSAssertionError("Invalid message.")
|
|
}
|
|
}
|
|
|
|
// TODO: Should we throw more?
|
|
mutating func buildContact(message: TSMessage, contact: OWSContact) throws -> CVComponentState {
|
|
let contactShare = ContactShareViewModel(contactShareRecord: contact, transaction: transaction)
|
|
let state = CVContactShareView.buildState(contactShare: contactShare,
|
|
isIncoming: isIncoming,
|
|
conversationStyle: conversationStyle,
|
|
transaction: transaction)
|
|
self.contactShare = ContactShare(state: state)
|
|
|
|
let hasSendTextButton = !contactShare.systemContactsWithSignalAccountPhoneNumbers(transaction: transaction).isEmpty
|
|
let hasInviteButton = !contactShare.systemContactPhoneNumbers(transaction: transaction).isEmpty
|
|
let hasAddToContactsButton = !contactShare.e164PhoneNumbers().isEmpty
|
|
|
|
if hasSendTextButton {
|
|
let action = CVMessageAction(title: CommonStrings.sendMessage,
|
|
accessibilityIdentifier: "send_message_to_contact_share",
|
|
action: .cvc_didTapSendMessage(contactShare: contactShare))
|
|
bottomButtonsActions.append(action)
|
|
} else if hasInviteButton {
|
|
let action = CVMessageAction(title: NSLocalizedString("ACTION_INVITE",
|
|
comment: "Label for 'invite' button in contact view."),
|
|
accessibilityIdentifier: "invite_contact_share",
|
|
action: .cvc_didTapSendInvite(contactShare: contactShare))
|
|
bottomButtonsActions.append(action)
|
|
} else if hasAddToContactsButton {
|
|
let action = CVMessageAction(title: NSLocalizedString("CONVERSATION_VIEW_ADD_TO_CONTACTS_OFFER",
|
|
comment: "Message shown in conversation view that offers to add an unknown user to your phone's contacts."),
|
|
accessibilityIdentifier: "add_to_contacts",
|
|
action: .cvc_didTapAddToContacts(contactShare: contactShare))
|
|
bottomButtonsActions.append(action)
|
|
}
|
|
|
|
return build()
|
|
}
|
|
|
|
// TODO: Should we throw more?
|
|
mutating func buildSticker(message: TSMessage, messageSticker: MessageSticker) throws -> CVComponentState {
|
|
|
|
guard let attachment = TSAttachment.anyFetch(uniqueId: messageSticker.attachmentId,
|
|
transaction: transaction) else {
|
|
throw OWSAssertionError("Missing sticker attachment.")
|
|
}
|
|
if let attachmentStream = attachment as? TSAttachmentStream {
|
|
let mediaSize = attachmentStream.imageSizePoints
|
|
guard attachmentStream.isValidImage,
|
|
mediaSize.isNonEmpty else {
|
|
throw OWSAssertionError("Invalid sticker.")
|
|
}
|
|
let stickerType = StickerManager.stickerType(forContentType: attachmentStream.contentType)
|
|
guard let stickerDataUrl = attachmentStream.originalMediaURL else {
|
|
throw OWSAssertionError("Invalid sticker.")
|
|
}
|
|
let stickerMetadata = StickerMetadata(stickerInfo: messageSticker.info,
|
|
stickerType: stickerType,
|
|
stickerDataUrl: stickerDataUrl,
|
|
emojiString: messageSticker.emoji)
|
|
self.sticker = .available(stickerMetadata: stickerMetadata,
|
|
attachmentStream: attachmentStream)
|
|
return build()
|
|
} else if let attachmentPointer = attachment as? TSAttachmentPointer {
|
|
switch attachmentPointer.state {
|
|
case .enqueued, .downloading:
|
|
Logger.verbose("Sticker downloading.")
|
|
self.sticker = .downloading(attachmentPointer: attachmentPointer)
|
|
case .failed, .pendingManualDownload, .pendingMessageRequest:
|
|
Logger.verbose("Sticker failed or pending.")
|
|
self.sticker = .failedOrPending(attachmentPointer: attachmentPointer)
|
|
}
|
|
return build()
|
|
} else {
|
|
throw OWSAssertionError("Invalid sticker.")
|
|
}
|
|
}
|
|
|
|
// TODO: Should we validate and throw errors?
|
|
mutating func buildQuotedReply(message: TSMessage) {
|
|
guard let quotedReplyModel = OWSQuotedReplyModel.quotedReply(from: message, transaction: transaction) else {
|
|
return
|
|
}
|
|
var displayableQuotedText: DisplayableText?
|
|
if let quotedBody = quotedReplyModel.body,
|
|
!quotedBody.isEmpty {
|
|
displayableQuotedText = CVComponentState.displayableQuotedText(text: quotedBody,
|
|
ranges: quotedReplyModel.bodyRanges,
|
|
interaction: message,
|
|
transaction: transaction)
|
|
}
|
|
let viewState = QuotedMessageView.stateForConversation(quotedReplyModel: quotedReplyModel,
|
|
displayableQuotedText: displayableQuotedText,
|
|
conversationStyle: conversationStyle,
|
|
isOutgoing: isOutgoing,
|
|
transaction: transaction)
|
|
self.quotedReply = QuotedReply(viewState: viewState)
|
|
}
|
|
|
|
mutating func buildBodyText(message: TSMessage) throws {
|
|
bodyText = try CVComponentBodyText.buildComponentState(message: message,
|
|
transaction: transaction)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
func buildMediaAlbumItems(for mediaAttachments: [TSAttachment],
|
|
message: TSMessage) -> [CVMediaAlbumItem] {
|
|
|
|
var mediaAlbumItems = [CVMediaAlbumItem]()
|
|
for attachment in mediaAttachments {
|
|
guard attachment.isVisualMedia else {
|
|
// Well behaving clients should not send a mix of visual media (like JPG) and non-visual media (like PDF's)
|
|
// Since we're not coped to handle a mix of media, return @[]
|
|
owsAssertDebug(mediaAlbumItems.count == 0)
|
|
return []
|
|
}
|
|
|
|
var caption: String?
|
|
if let rawCaption = attachment.caption {
|
|
caption = CVComponentState.displayableCaption(text: rawCaption,
|
|
attachmentId: attachment.uniqueId,
|
|
transaction: transaction).displayTextValue.stringValue
|
|
}
|
|
|
|
guard let attachmentStream = attachment as? TSAttachmentStream else {
|
|
var mediaSize: CGSize = .zero
|
|
if let attachmentPointer = attachment as? TSAttachmentPointer,
|
|
attachmentPointer.mediaSize.isNonEmpty {
|
|
mediaSize = attachmentPointer.mediaSize
|
|
} else {
|
|
owsFailDebug("Invalid attachment.")
|
|
}
|
|
mediaAlbumItems.append(CVMediaAlbumItem(attachment: attachment,
|
|
attachmentStream: nil,
|
|
caption: caption,
|
|
mediaSize: mediaSize))
|
|
continue
|
|
}
|
|
|
|
guard attachmentStream.isValidVisualMedia else {
|
|
Logger.warn("Filtering invalid media.")
|
|
mediaAlbumItems.append(CVMediaAlbumItem(attachment: attachment,
|
|
attachmentStream: nil,
|
|
caption: caption,
|
|
mediaSize: .zero))
|
|
continue
|
|
}
|
|
let mediaSize = attachmentStream.imageSizePixels
|
|
if !mediaSize.isNonEmpty {
|
|
Logger.warn("Filtering media with invalid size.")
|
|
mediaAlbumItems.append(CVMediaAlbumItem(attachment: attachment,
|
|
attachmentStream: nil,
|
|
caption: caption,
|
|
mediaSize: .zero))
|
|
continue
|
|
}
|
|
|
|
mediaAlbumItems.append(CVMediaAlbumItem(attachment: attachment,
|
|
attachmentStream: attachmentStream,
|
|
caption: caption,
|
|
mediaSize: mediaSize))
|
|
}
|
|
return mediaAlbumItems
|
|
}
|
|
|
|
mutating func buildNonMediaAttachment(bodyAttachment: TSAttachment?) throws {
|
|
|
|
guard let attachment = bodyAttachment else {
|
|
throw OWSAssertionError("Missing attachment.")
|
|
}
|
|
|
|
if attachment.isAudio, let audioAttachment = AudioAttachment(attachment: attachment, owningMessage: interaction as? TSMessage) {
|
|
self.audioAttachment = audioAttachment
|
|
return
|
|
}
|
|
|
|
self.genericAttachment = GenericAttachment(attachment: attachment)
|
|
}
|
|
|
|
mutating func buildLinkPreview(message: TSMessage, linkPreview: OWSLinkPreview) throws {
|
|
guard bodyText != nil else {
|
|
owsFailDebug("Missing body text.")
|
|
return
|
|
}
|
|
guard let urlString = linkPreview.urlString else {
|
|
owsFailDebug("Missing urlString.")
|
|
return
|
|
}
|
|
guard let url = URL(string: urlString) else {
|
|
owsFailDebug("Invalid urlString.")
|
|
return
|
|
}
|
|
if let groupInviteLinkInfo = GroupManager.parseGroupInviteLink(url) {
|
|
let groupInviteLinkViewModel = CVComponentState.configureGroupInviteLink(url, message: message,
|
|
groupInviteLinkInfo: groupInviteLinkInfo)
|
|
if !groupInviteLinkViewModel.isExpired {
|
|
let linkType: LinkPreviewLinkType = (isIncoming
|
|
? .incomingMessageGroupInviteLink
|
|
: .outgoingMessageGroupInviteLink)
|
|
let state = LinkPreviewGroupLink(linkType: linkType,
|
|
linkPreview: linkPreview,
|
|
groupInviteLinkViewModel: groupInviteLinkViewModel,
|
|
conversationStyle: conversationStyle)
|
|
self.linkPreview = LinkPreview(linkPreview: linkPreview,
|
|
linkPreviewAttachment: nil,
|
|
state: state)
|
|
}
|
|
} else {
|
|
let linkPreviewAttachment = { () -> TSAttachment? in
|
|
guard let imageAttachmentId = linkPreview.imageAttachmentId,
|
|
!imageAttachmentId.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
guard let linkPreviewAttachment = TSAttachment.anyFetch(uniqueId: imageAttachmentId,
|
|
transaction: self.transaction) else {
|
|
owsFailDebug("Could not load link preview image attachment.")
|
|
return nil
|
|
}
|
|
guard linkPreviewAttachment.isImage else {
|
|
owsFailDebug("Link preview attachment isn't an image.")
|
|
return nil
|
|
}
|
|
guard let attachmentStream = linkPreviewAttachment as? TSAttachmentStream else {
|
|
return nil
|
|
}
|
|
guard attachmentStream.isValidImage else {
|
|
owsFailDebug("Link preview image attachment isn't valid.")
|
|
return nil
|
|
}
|
|
return attachmentStream
|
|
}()
|
|
|
|
let state = LinkPreviewSent(linkPreview: linkPreview,
|
|
imageAttachment: linkPreviewAttachment,
|
|
conversationStyle: conversationStyle)
|
|
self.linkPreview = LinkPreview(linkPreview: linkPreview,
|
|
linkPreviewAttachment: linkPreviewAttachment,
|
|
state: state)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - DisplayableText
|
|
|
|
public extension CVComponentState {
|
|
|
|
static func displayableBodyText(text: String,
|
|
ranges: MessageBodyRanges?,
|
|
interaction: TSInteraction,
|
|
transaction: SDSAnyReadTransaction) -> DisplayableText {
|
|
let mentionStyle: Mention.Style = (interaction as? TSOutgoingMessage != nil) ? .outgoing : .incoming
|
|
return DisplayableText.displayableText(withMessageBody: MessageBody(text: text, ranges: ranges ?? .empty),
|
|
mentionStyle: mentionStyle,
|
|
transaction: transaction)
|
|
}
|
|
|
|
static func displayableBodyText(oversizeTextAttachment attachmentStream: TSAttachmentStream,
|
|
ranges: MessageBodyRanges?,
|
|
interaction: TSInteraction,
|
|
transaction: SDSAnyReadTransaction) -> DisplayableText {
|
|
|
|
let text = { () -> String in
|
|
do {
|
|
guard let url = attachmentStream.originalMediaURL else {
|
|
owsFailDebug("Missing originalMediaURL.")
|
|
return ""
|
|
}
|
|
let data = try Data(contentsOf: url)
|
|
guard let string = String(data: data, encoding: .utf8) else {
|
|
owsFailDebug("Couldn't parse oversize text.")
|
|
return ""
|
|
}
|
|
return string
|
|
} catch {
|
|
owsFailDebug("Couldn't load oversize text: \(error).")
|
|
return ""
|
|
}
|
|
}()
|
|
|
|
let mentionStyle: Mention.Style = (interaction as? TSOutgoingMessage != nil) ? .outgoing : .incoming
|
|
return DisplayableText.displayableText(withMessageBody: MessageBody(text: text, ranges: ranges ?? .empty),
|
|
mentionStyle: mentionStyle,
|
|
transaction: transaction)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
fileprivate extension CVComponentState {
|
|
|
|
static func displayableQuotedText(text: String,
|
|
ranges: MessageBodyRanges?,
|
|
interaction: TSInteraction,
|
|
transaction: SDSAnyReadTransaction) -> DisplayableText {
|
|
return DisplayableText.displayableText(withMessageBody: MessageBody(text: text, ranges: ranges ?? .empty),
|
|
mentionStyle: .quotedReply,
|
|
transaction: transaction)
|
|
}
|
|
|
|
static func displayableCaption(text: String,
|
|
attachmentId: String,
|
|
transaction: SDSAnyReadTransaction) -> DisplayableText {
|
|
return DisplayableText.displayableText(withMessageBody: MessageBody(text: text, ranges: .empty),
|
|
mentionStyle: .incoming,
|
|
transaction: transaction)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
public extension CVComponentState {
|
|
var hasPrimaryAndSecondaryContentForSelection: Bool {
|
|
var hasPrimaryContent = false
|
|
|
|
// Search for a component that qualifies as "non-body text primary".
|
|
for key in activeComponentStateKeys {
|
|
switch key {
|
|
case .bodyText, .linkPreview:
|
|
// "Primary" content is not body text.
|
|
// A link preview is associated with the body text.
|
|
break
|
|
case .bodyMedia, .sticker, .audioAttachment, .genericAttachment, .contactShare:
|
|
hasPrimaryContent = true
|
|
case .senderName, .senderAvatar, .footer, .reactions, .bottomButtons, .sendFailureBadge, .dateHeader, .unreadIndicator, .typingIndicator, .threadDetails, .failedOrPendingDownloads, .unknownThreadWarning, .defaultDisappearingMessageTimer, .messageRoot:
|
|
// "Primary" content is not just metadata / UI.
|
|
break
|
|
case .viewOnce:
|
|
// We should never forward view-once messages.
|
|
break
|
|
case .systemMessage:
|
|
// We should never forward system messages.
|
|
break
|
|
case .quotedReply:
|
|
// Quoted replies are never forwarded.
|
|
break
|
|
}
|
|
}
|
|
|
|
let hasSecondaryContent = bodyText != nil
|
|
|
|
return hasPrimaryContent && hasSecondaryContent
|
|
}
|
|
}
|