Signal-iOS/Signal/src/ViewControllers/ConversationView/CV/CVComponentState.swift
2021-09-03 11:41:34 -07:00

1262 lines
52 KiB
Swift

//
// Copyright (c) 2021 Open Whisper Systems. All rights reserved.
//
import Foundation
public class CVComponentState: Equatable, Dependencies {
let messageCellType: CVMessageCellType
struct SenderName: Equatable {
let senderName: NSAttributedString
let senderNameColor: UIColor
}
let senderName: SenderName?
struct SenderAvatar: Equatable {
let senderAvatar: UIImage
}
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 avatar: UIImage?
}
let typingIndicator: TypingIndicator?
struct ThreadDetails: Equatable {
let avatar: UIImage?
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 avatar = { () -> UIImage? in
guard thread.isGroupThread else {
return nil
}
return self.avatarBuilder.buildAvatar(forAddress: typingIndicatorInteraction.address,
localUserDisplayMode: .asUser,
diameterPoints: ConversationStyle.groupMessageAvatarDiameter)
}()
self.typingIndicator = TypingIndicator(address: typingIndicatorInteraction.address,
avatar: avatar)
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 avatar = self.avatarBuilder.buildAvatar(forAddress: incomingMessage.authorAddress,
localUserDisplayMode: .asUser,
diameterPoints: ConversationStyle.groupMessageAvatarDiameter) else {
owsFailDebug("Could build avatar image")
return nil
}
return SenderAvatar(senderAvatar: avatar)
}
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
@unknown default:
owsFailDebug("Invalid attachment pointer state.")
continue
}
}
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)
@unknown default:
owsFailDebug("Invalid value.")
return buildViewOnce(viewOnceState: .incomingFailed)
}
} 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().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)
@unknown default:
throw OWSAssertionError("Invalid sticker.")
}
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 cacheKey = "body-\(interaction.uniqueId)"
let mentionStyle: Mention.Style = (interaction as? TSOutgoingMessage != nil) ? .outgoing : .incoming
return Self.displayableText(cacheKey: cacheKey,
mentionStyle: mentionStyle,
transaction: transaction) {
MessageBody(text: text, ranges: ranges ?? .empty)
}
}
static func displayableBodyText(oversizeTextAttachment attachmentStream: TSAttachmentStream,
ranges: MessageBodyRanges?,
interaction: TSInteraction,
transaction: SDSAnyReadTransaction) -> DisplayableText {
let cacheKey = "oversize-body-\(interaction.uniqueId)"
let mentionStyle: Mention.Style = (interaction as? TSOutgoingMessage != nil) ? .outgoing : .incoming
return Self.displayableText(cacheKey: cacheKey,
mentionStyle: mentionStyle,
transaction: transaction) {
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 ""
}
}()
return MessageBody(text: text, ranges: ranges ?? .empty)
}
}
}
// MARK: -
fileprivate extension CVComponentState {
static func displayableQuotedText(text: String,
ranges: MessageBodyRanges?,
interaction: TSInteraction,
transaction: SDSAnyReadTransaction) -> DisplayableText {
let cacheKey = "quoted-\(interaction.uniqueId)"
let mentionStyle: Mention.Style = .quotedReply
return Self.displayableText(cacheKey: cacheKey,
mentionStyle: mentionStyle,
transaction: transaction) {
MessageBody(text: text, ranges: ranges ?? .empty)
}
}
static func displayableCaption(text: String,
attachmentId: String,
transaction: SDSAnyReadTransaction) -> DisplayableText {
let cacheKey = "attachment-caption-\(attachmentId)"
let mentionStyle: Mention.Style = .incoming
return CVComponentState.displayableText(cacheKey: cacheKey,
mentionStyle: mentionStyle,
transaction: transaction) {
MessageBody(text: text, ranges: .empty)
}
}
// TODO: Now that we're caching the displayable text on the view items,
// I'm not sure if we still need this cache.
//
// Cache the results for up to 1,000 messages.
private static let displayableTextCache = LRUCache<String, DisplayableText>(maxSize: 1000)
static func displayableText(cacheKey: String,
mentionStyle: Mention.Style,
transaction: SDSAnyReadTransaction,
messageBodyBlock: () -> MessageBody) -> DisplayableText {
owsAssertDebug(!cacheKey.isEmpty)
if let displayableText = displayableTextCache.object(forKey: cacheKey) {
return displayableText
}
let messageBody = messageBodyBlock()
let displayableText = DisplayableText.displayableText(withMessageBody: messageBody,
mentionStyle: mentionStyle,
transaction: transaction)
displayableTextCache.setObject(displayableText, forKey: cacheKey)
return displayableText
}
}
// 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
}
}