1757 lines
68 KiB
Swift
1757 lines
68 KiB
Swift
//
|
|
// Copyright 2020 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import MobileCoin
|
|
public import SignalServiceKit
|
|
import SignalUI
|
|
|
|
public enum CVAttachment: Equatable {
|
|
case stream(ReferencedAttachmentStream)
|
|
case pointer(ReferencedAttachmentTransitPointer, transitTierDownloadState: AttachmentDownloadState)
|
|
case backupThumbnail(ReferencedAttachmentBackupThumbnail)
|
|
|
|
public var attachment: ReferencedAttachment {
|
|
switch self {
|
|
case .stream(let stream):
|
|
return stream
|
|
case .pointer(let pointer, _):
|
|
return pointer
|
|
case .backupThumbnail(let thumbnail):
|
|
return thumbnail
|
|
}
|
|
}
|
|
|
|
public var attachmentStream: AttachmentStream? {
|
|
switch self {
|
|
case .stream(let stream):
|
|
return stream.attachmentStream
|
|
case .pointer, .backupThumbnail:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public var attachmentPointer: AttachmentTransitPointer? {
|
|
switch self {
|
|
case .stream, .backupThumbnail:
|
|
return nil
|
|
case .pointer(let pointer, _):
|
|
return pointer.attachmentPointer
|
|
}
|
|
}
|
|
|
|
public var attachmentBackupThumbnail: AttachmentBackupThumbnail? {
|
|
switch self {
|
|
case .stream, .pointer:
|
|
return nil
|
|
case .backupThumbnail(let thumbnail):
|
|
return thumbnail.attachmentBackupThumbnail
|
|
}
|
|
}
|
|
|
|
public static func from(_ attachment: ReferencedAttachment, tx: SDSAnyReadTransaction) -> CVAttachment? {
|
|
if let stream = attachment.asReferencedStream {
|
|
return .stream(stream)
|
|
} else if let pointer = attachment.asReferencedTransitPointer {
|
|
return .pointer(pointer, transitTierDownloadState: pointer.attachmentPointer.downloadState(tx: tx.asV2Read))
|
|
} else if let thumbnail = attachment.asReferencedBackupThumbnail {
|
|
return .backupThumbnail(thumbnail)
|
|
} else {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
public static func == (lhs: CVAttachment, rhs: CVAttachment) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case (.stream(let lhsStream), .stream(let rhsStream)):
|
|
return lhsStream.attachment.id == rhsStream.attachment.id
|
|
&& lhsStream.reference.hasSameOwner(as: rhsStream.reference)
|
|
case (.pointer(let lhsPointer, let lhsState), .pointer(let rhsPointer, let rhsState)):
|
|
return lhsPointer.attachment.id == rhsPointer.attachment.id
|
|
&& lhsPointer.reference.hasSameOwner(as: rhsPointer.reference)
|
|
&& lhsState == rhsState
|
|
case (.backupThumbnail(let lhsThumbnail), .backupThumbnail(let rhsThumbnail)):
|
|
return lhsThumbnail.attachment.id == rhsThumbnail.attachment.id
|
|
&& lhsThumbnail.reference.hasSameOwner(as: rhsThumbnail.reference)
|
|
case
|
|
(.stream, .pointer),
|
|
(.stream, .backupThumbnail),
|
|
(.pointer, .stream),
|
|
(.pointer, .backupThumbnail),
|
|
(.backupThumbnail, .pointer),
|
|
(.backupThumbnail, .stream):
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
|
|
public class CVComponentState: Equatable {
|
|
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: CVAttachment
|
|
|
|
var attachmentStream: AttachmentStream? {
|
|
attachment.attachmentStream
|
|
}
|
|
|
|
var attachmentPointer: AttachmentTransitPointer? {
|
|
attachment.attachmentPointer
|
|
}
|
|
|
|
var attachmentBackupThumbnail: AttachmentBackupThumbnail? {
|
|
attachment.attachmentBackupThumbnail
|
|
}
|
|
}
|
|
let genericAttachment: GenericAttachment?
|
|
|
|
public struct PaymentAttachment: Equatable {
|
|
let notification: TSPaymentNotification
|
|
let model: TSPaymentModel?
|
|
let otherUserShortName: String
|
|
|
|
var status: TSPaymentState? { model?.paymentState }
|
|
}
|
|
var paymentAttachment: PaymentAttachment?
|
|
|
|
public struct ArchivedPaymentAttachment: Equatable {
|
|
let amount: String?
|
|
let fee: String?
|
|
let note: String?
|
|
let otherUserShortName: String
|
|
let archivedPayment: ArchivedPayment?
|
|
|
|
public static func == (lhs: Self, rhs: Self) -> Bool {
|
|
lhs.amount == rhs.amount
|
|
&& lhs.fee == rhs.fee
|
|
&& lhs.note == rhs.note
|
|
&& lhs.otherUserShortName == rhs.otherUserShortName
|
|
}
|
|
}
|
|
var archivedPaymentAttachment: ArchivedPaymentAttachment?
|
|
|
|
// 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?
|
|
|
|
let hasRenderableContent: Bool
|
|
|
|
struct QuotedReply: Equatable {
|
|
let viewState: QuotedMessageView.State
|
|
|
|
var quotedReplyModel: QuotedReplyModel { viewState.quotedReplyModel }
|
|
}
|
|
let quotedReply: QuotedReply?
|
|
|
|
enum Sticker: Equatable {
|
|
case available(
|
|
stickerMetadata: any StickerMetadata,
|
|
attachmentStream: ReferencedAttachmentStream
|
|
)
|
|
case downloading(attachmentPointer: ReferencedAttachmentTransitPointer)
|
|
case failedOrPending(
|
|
attachmentPointer: ReferencedAttachmentTransitPointer,
|
|
transitTierDownloadState: AttachmentDownloadState
|
|
)
|
|
|
|
public var stickerMetadata: (any StickerMetadata)? {
|
|
switch self {
|
|
case .available(let stickerMetadata, _):
|
|
return stickerMetadata
|
|
case .downloading, .failedOrPending:
|
|
return nil
|
|
}
|
|
}
|
|
public var attachmentStream: ReferencedAttachmentStream? {
|
|
switch self {
|
|
case .available(_, let attachmentStream):
|
|
return attachmentStream
|
|
case .downloading:
|
|
return nil
|
|
case .failedOrPending:
|
|
return nil
|
|
}
|
|
}
|
|
public var attachmentPointer: ReferencedAttachmentTransitPointer? {
|
|
switch self {
|
|
case .available:
|
|
return nil
|
|
case .downloading(let attachmentPointer):
|
|
return attachmentPointer
|
|
case .failedOrPending(let attachmentPointer, _):
|
|
return attachmentPointer
|
|
}
|
|
}
|
|
|
|
static func == (lhs: CVComponentState.Sticker, rhs: CVComponentState.Sticker) -> Bool {
|
|
switch (lhs, rhs) {
|
|
case let (.available(lhsData, lhsStream), .available(rhsData, rhsStream)):
|
|
return lhsData.stickerInfo.asKey() == rhsData.stickerInfo.asKey()
|
|
&& lhsStream.attachment.id == rhsStream.attachment.id
|
|
&& lhsStream.reference.hasSameOwner(as: rhsStream.reference)
|
|
case let (.downloading(lhsPointer), .downloading(rhsPointer)):
|
|
return lhsPointer.attachment.id == rhsPointer.attachment.id
|
|
&& lhsPointer.reference.hasSameOwner(as: rhsPointer.reference)
|
|
case let (.failedOrPending(lhsPointer, lhsState), .failedOrPending(rhsPointer, rhsState)):
|
|
return lhsPointer.attachment.id == rhsPointer.attachment.id
|
|
&& lhsPointer.reference.hasSameOwner(as: rhsPointer.reference)
|
|
&& lhsState == rhsState
|
|
case (.available, _), (.downloading, _), (.failedOrPending, _):
|
|
return false
|
|
}
|
|
}
|
|
}
|
|
let sticker: Sticker?
|
|
|
|
struct ContactShare: Equatable {
|
|
let state: CVContactShareView.State
|
|
}
|
|
let contactShare: ContactShare?
|
|
|
|
struct LinkPreview: Equatable {
|
|
// TODO: convert OWSLinkPreview to Swift?
|
|
let linkPreview: OWSLinkPreview
|
|
let linkPreviewAttachment: Attachment?
|
|
let state: LinkPreviewState
|
|
|
|
// MARK: - Equatable
|
|
|
|
public static func == (lhs: LinkPreview, rhs: LinkPreview) -> Bool {
|
|
return lhs.state === rhs.state
|
|
}
|
|
}
|
|
let linkPreview: LinkPreview?
|
|
|
|
struct GiftBadge: Equatable {
|
|
let messageUniqueId: String
|
|
let otherUserShortName: String
|
|
let cachedBadge: CachedBadge
|
|
let expirationDate: Date
|
|
let redemptionState: OWSGiftBadgeRedemptionState
|
|
}
|
|
let giftBadge: GiftBadge?
|
|
|
|
struct SystemMessage: Equatable {
|
|
typealias ReferencedUser = CVTextLabel.ReferencedUserItem
|
|
|
|
let title: NSAttributedString
|
|
let titleColor: UIColor
|
|
let titleSelectionBackgroundColor: UIColor
|
|
let action: CVMessageAction?
|
|
|
|
/// Represents users whose names appear in the title. Only applies to
|
|
/// system messages in group threads.
|
|
let namesInTitle: [ReferencedUser]
|
|
|
|
init(
|
|
title: NSAttributedString,
|
|
titleColor: UIColor,
|
|
titleSelectionBackgroundColor: UIColor,
|
|
action: CVMessageAction?
|
|
) {
|
|
let mutableTitle = NSMutableAttributedString(attributedString: title)
|
|
mutableTitle.removeAttribute(
|
|
.addressOfName,
|
|
range: NSRange(location: 0, length: mutableTitle.length)
|
|
)
|
|
self.title = NSAttributedString(attributedString: mutableTitle)
|
|
|
|
self.titleColor = titleColor
|
|
self.titleSelectionBackgroundColor = titleSelectionBackgroundColor
|
|
self.action = action
|
|
|
|
self.namesInTitle = {
|
|
// Extract the addresses for names in the string. These are only
|
|
// stored for system messages in group threads.
|
|
|
|
var referencedUsers = [ReferencedUser]()
|
|
|
|
title.enumerateAddressesOfNames { address, range, _ in
|
|
if let address = address {
|
|
referencedUsers.append(ReferencedUser(
|
|
address: address,
|
|
range: range
|
|
))
|
|
}
|
|
}
|
|
|
|
return referencedUsers
|
|
}()
|
|
}
|
|
}
|
|
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 {
|
|
enum MutualGroupsTapAction: Equatable {
|
|
case showContactSafetyTip
|
|
case showGroupSafetyTip
|
|
}
|
|
|
|
let avatarDataSource: ConversationAvatarDataSource?
|
|
let isAvatarBlurred: Bool
|
|
let titleText: String
|
|
let shouldShowVerifiedBadge: Bool
|
|
let bioText: String?
|
|
let detailsText: String?
|
|
let mutualGroupsText: NSAttributedString?
|
|
let mutualGroupsTapAction: MutualGroupsTapAction?
|
|
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: [AttachmentTransitPointer]
|
|
|
|
static func == (lhs: CVComponentState.FailedOrPendingDownloads, rhs: CVComponentState.FailedOrPendingDownloads) -> Bool {
|
|
return lhs.attachmentPointers.map(\.id) == rhs.attachmentPointers.map(\.id)
|
|
}
|
|
}
|
|
let failedOrPendingDownloads: FailedOrPendingDownloads?
|
|
|
|
struct SendFailureBadge: Equatable {
|
|
let color: UIColor
|
|
}
|
|
let sendFailureBadge: SendFailureBadge?
|
|
|
|
let messageHasBodyAttachments: Bool
|
|
|
|
fileprivate init(messageCellType: CVMessageCellType,
|
|
senderName: SenderName?,
|
|
senderAvatar: SenderAvatar?,
|
|
bodyText: BodyText?,
|
|
bodyMedia: BodyMedia?,
|
|
genericAttachment: GenericAttachment?,
|
|
paymentAttachment: PaymentAttachment?,
|
|
archivedPaymentAttachment: ArchivedPaymentAttachment?,
|
|
audioAttachment: AudioAttachment?,
|
|
viewOnce: ViewOnce?,
|
|
quotedReply: QuotedReply?,
|
|
sticker: Sticker?,
|
|
contactShare: ContactShare?,
|
|
linkPreview: LinkPreview?,
|
|
giftBadge: GiftBadge?,
|
|
systemMessage: SystemMessage?,
|
|
dateHeader: DateHeader?,
|
|
unreadIndicator: UnreadIndicator?,
|
|
reactions: Reactions?,
|
|
typingIndicator: TypingIndicator?,
|
|
threadDetails: ThreadDetails?,
|
|
unknownThreadWarning: UnknownThreadWarning?,
|
|
defaultDisappearingMessageTimer: DefaultDisappearingMessageTimer?,
|
|
bottomButtons: BottomButtons?,
|
|
failedOrPendingDownloads: FailedOrPendingDownloads?,
|
|
sendFailureBadge: SendFailureBadge?,
|
|
messageHasBodyAttachments: Bool,
|
|
hasRenderableContent: Bool) {
|
|
|
|
self.messageCellType = messageCellType
|
|
self.senderName = senderName
|
|
self.senderAvatar = senderAvatar
|
|
self.bodyText = bodyText
|
|
self.bodyMedia = bodyMedia
|
|
self.genericAttachment = genericAttachment
|
|
self.paymentAttachment = paymentAttachment
|
|
self.archivedPaymentAttachment = archivedPaymentAttachment
|
|
self.audioAttachment = audioAttachment
|
|
self.viewOnce = viewOnce
|
|
self.quotedReply = quotedReply
|
|
self.sticker = sticker
|
|
self.contactShare = contactShare
|
|
self.linkPreview = linkPreview
|
|
self.giftBadge = giftBadge
|
|
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
|
|
self.messageHasBodyAttachments = messageHasBodyAttachments
|
|
self.hasRenderableContent = hasRenderableContent
|
|
}
|
|
|
|
// 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.paymentAttachment == rhs.paymentAttachment &&
|
|
lhs.archivedPaymentAttachment == rhs.archivedPaymentAttachment &&
|
|
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.giftBadge == rhs.giftBadge &&
|
|
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 PaymentAttachment = CVComponentState.PaymentAttachment
|
|
typealias ArchivedPaymentAttachment = CVComponentState.ArchivedPaymentAttachment
|
|
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 GiftBadge = CVComponentState.GiftBadge
|
|
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 revealedSpoilerIdsSnapshot: Set<StyleIdType> {
|
|
return itemBuildingContext.viewStateSnapshot.spoilerReveal[.fromInteraction(interaction)] ?? Set()
|
|
}
|
|
|
|
var senderName: SenderName?
|
|
var senderAvatar: SenderAvatar?
|
|
var bodyText: BodyText?
|
|
var bodyMedia: BodyMedia?
|
|
var genericAttachment: GenericAttachment?
|
|
var paymentAttachment: PaymentAttachment?
|
|
var archivedPaymentAttachment: ArchivedPaymentAttachment?
|
|
var audioAttachment: AudioAttachment?
|
|
var viewOnce: ViewOnce?
|
|
var quotedReply: QuotedReply?
|
|
var sticker: Sticker?
|
|
var systemMessage: SystemMessage?
|
|
var contactShare: ContactShare?
|
|
var linkPreview: LinkPreview?
|
|
var giftBadge: GiftBadge?
|
|
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 messageHasBodyAttachments: Bool
|
|
var hasRenderableContent: Bool
|
|
|
|
var bottomButtonsActions = [CVMessageAction]()
|
|
|
|
init(interaction: TSInteraction, itemBuildingContext: CVItemBuildingContext) {
|
|
self.interaction = interaction
|
|
self.messageHasBodyAttachments = (interaction as? TSMessage)?.hasBodyAttachments(transaction: itemBuildingContext.transaction) ?? false
|
|
self.hasRenderableContent = (interaction as? TSMessage)?.hasRenderableContent(tx: itemBuildingContext.transaction) ?? false
|
|
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,
|
|
paymentAttachment: paymentAttachment,
|
|
archivedPaymentAttachment: archivedPaymentAttachment,
|
|
audioAttachment: audioAttachment,
|
|
viewOnce: viewOnce,
|
|
quotedReply: quotedReply,
|
|
sticker: sticker,
|
|
contactShare: contactShare,
|
|
linkPreview: linkPreview,
|
|
giftBadge: giftBadge,
|
|
systemMessage: systemMessage,
|
|
dateHeader: dateHeader,
|
|
unreadIndicator: unreadIndicator,
|
|
reactions: reactions,
|
|
typingIndicator: typingIndicator,
|
|
threadDetails: threadDetails,
|
|
unknownThreadWarning: unknownThreadWarning,
|
|
defaultDisappearingMessageTimer: defaultDisappearingMessageTimer,
|
|
bottomButtons: bottomButtons,
|
|
failedOrPendingDownloads: failedOrPendingDownloads,
|
|
sendFailureBadge: sendFailureBadge,
|
|
messageHasBodyAttachments: messageHasBodyAttachments,
|
|
hasRenderableContent: hasRenderableContent)
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
lazy var isIncoming: Bool = {
|
|
interaction is TSIncomingMessage
|
|
}()
|
|
|
|
lazy var isOutgoing: Bool = {
|
|
interaction is TSOutgoingMessage
|
|
}()
|
|
|
|
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 paymentAttachment != nil {
|
|
return .paymentAttachment
|
|
}
|
|
if archivedPaymentAttachment != nil {
|
|
return .archivedPaymentAttachment
|
|
}
|
|
if giftBadge != nil {
|
|
return .giftBadge
|
|
}
|
|
if bodyMedia != nil {
|
|
return .bodyMedia
|
|
}
|
|
if bodyText != nil {
|
|
return .textOnlyMessage
|
|
}
|
|
if quotedReply != nil {
|
|
return .quoteOnlyMessage
|
|
}
|
|
|
|
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 paymentAttachment != nil {
|
|
result.insert(.paymentAttachment)
|
|
}
|
|
if archivedPaymentAttachment != nil {
|
|
result.insert(.archivedPaymentAttachment)
|
|
}
|
|
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 giftBadge != nil {
|
|
result.insert(.giftBadge)
|
|
}
|
|
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 isBorderlessJumbomojiMessage: 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,
|
|
firstItem.renderingFlag == .borderless
|
|
{
|
|
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:
|
|
// TODO: Remove this case entirely?
|
|
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 currentGroupThreadCallGroupId = viewStateSnapshot.currentGroupThreadCallGroupId
|
|
self.systemMessage = CVComponentSystemMessage.buildComponentState(interaction: interaction,
|
|
threadViewModel: threadViewModel,
|
|
currentGroupThreadCallGroupId: currentGroupThreadCallGroupId,
|
|
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, revealedSpoilerIdsSnapshot: revealedSpoilerIdsSnapshot)
|
|
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,
|
|
revealedSpoilerIdsSnapshot: Set<StyleIdType>
|
|
) 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, revealedSpoilerIdsSnapshot: revealedSpoilerIdsSnapshot)
|
|
|
|
try buildBodyText(message: message)
|
|
|
|
if let outgoingMessage = message as? TSOutgoingMessage {
|
|
let messageStatus: MessageReceiptStatus
|
|
if
|
|
let paymentMessage = message as? OWSPaymentMessage,
|
|
let receipt = paymentMessage.paymentNotification?.mcReceiptData,
|
|
let model = PaymentFinder.paymentModels(
|
|
forMcReceiptData: receipt,
|
|
transaction: transaction
|
|
).first
|
|
{
|
|
messageStatus = MessageRecipientStatusUtils.recipientStatus(
|
|
outgoingMessage: outgoingMessage,
|
|
paymentModel: model
|
|
)
|
|
} else {
|
|
messageStatus = MessageRecipientStatusUtils.recipientStatus(
|
|
outgoingMessage: outgoingMessage,
|
|
transaction: transaction
|
|
)
|
|
}
|
|
|
|
switch messageStatus {
|
|
case .failed:
|
|
sendFailureBadge = SendFailureBadge(color: .ows_accentRed)
|
|
case .pending:
|
|
sendFailureBadge = SendFailureBadge(color: .ows_gray60)
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
|
|
// Could be incoming or outgoing; protocol covers both
|
|
if let paymentMessage = message as? OWSPaymentMessage,
|
|
let paymentNotification = paymentMessage.paymentNotification {
|
|
return buildPaymentAttachment(paymentNotification: paymentNotification)
|
|
}
|
|
|
|
if let archivedPaymentMessage = message as? OWSArchivedPaymentMessage {
|
|
do {
|
|
let archivedPayment = try DependenciesBridge.shared.archivedPaymentStore.fetch(
|
|
for: archivedPaymentMessage,
|
|
interactionUniqueId: message.uniqueId,
|
|
tx: transaction.asV2Read
|
|
)
|
|
return buildArchivedPaymentAttachment(
|
|
archivedPaymentMessage: archivedPaymentMessage,
|
|
archivedPayment: archivedPayment
|
|
)
|
|
} catch {
|
|
owsFail("\(error.grdbErrorForLogging)")
|
|
}
|
|
}
|
|
|
|
if let giftBadge = message.giftBadge {
|
|
return try buildGiftBadge(messageUniqueId: message.uniqueId, giftBadge: giftBadge)
|
|
}
|
|
|
|
do {
|
|
let bodyAttachments = message.sqliteRowId.map {
|
|
DependenciesBridge.shared.attachmentStore.fetchReferencedAttachments(
|
|
for: .messageBodyAttachment(messageRowId: $0),
|
|
tx: transaction.asV2Read
|
|
)
|
|
} ?? []
|
|
let mediaAlbumItems = buildMediaAlbumItems(for: bodyAttachments, message: message)
|
|
if mediaAlbumItems.count > 0 {
|
|
var mediaAlbumHasFailedAttachment = false
|
|
var mediaAlbumHasPendingAttachment = false
|
|
// TODO
|
|
for attachment in bodyAttachments {
|
|
guard
|
|
attachment.attachment.asStream() == nil,
|
|
let pointer = attachment.attachment.asTransitTierPointer()
|
|
else {
|
|
continue
|
|
}
|
|
switch pointer.downloadState(tx: transaction.asV2Read) {
|
|
case .enqueuedOrDownloading:
|
|
continue
|
|
case .failed:
|
|
mediaAlbumHasFailedAttachment = true
|
|
case .none:
|
|
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 {
|
|
owsFailDebug("Unknown message cell type.")
|
|
}
|
|
return result
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
fileprivate extension CVComponentState.Builder {
|
|
|
|
mutating func buildThreadDetails() -> ThreadDetails {
|
|
owsAssertDebug(interaction is ThreadDetailsInteraction)
|
|
|
|
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 message is TSIncomingMessage {
|
|
if message.isViewOnceComplete {
|
|
return buildViewOnce(viewOnceState: .incomingExpired)
|
|
}
|
|
let attachmentRefs = message.sqliteRowId.map {
|
|
DependenciesBridge.shared.attachmentStore.fetchReferences(
|
|
owners: [
|
|
.messageOversizeText(messageRowId: $0),
|
|
.messageBodyAttachment(messageRowId: $0)
|
|
],
|
|
tx: transaction.asV2Read
|
|
)
|
|
} ?? []
|
|
let hasMoreThanOneAttachment: Bool = attachmentRefs.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: [ReferencedAttachment] = message.sqliteRowId.map {
|
|
DependenciesBridge.shared.attachmentStore
|
|
.fetchReferencedAttachments(
|
|
for: .messageBodyAttachment(messageRowId: $0),
|
|
tx: transaction.asV2Read
|
|
)
|
|
} ?? []
|
|
// We currently only support single attachments for view-once messages.
|
|
guard let mediaAttachment = mediaAttachments.first else {
|
|
owsFailDebug("Missing attachment.")
|
|
return buildViewOnce(viewOnceState: .incomingInvalidContent)
|
|
}
|
|
let renderingFlag = mediaAttachment.reference.renderingFlag
|
|
if let attachmentStream = mediaAttachment.attachment.asStream() {
|
|
if attachmentStream.contentType.isVisualMedia
|
|
&& (
|
|
MimeTypeUtil.isSupportedImageMimeType(attachmentStream.mimeType)
|
|
|| MimeTypeUtil.isSupportedMaybeAnimatedMimeType(attachmentStream.mimeType)
|
|
|| MimeTypeUtil.isSupportedVideoMimeType(attachmentStream.mimeType)
|
|
)
|
|
{
|
|
return buildViewOnce(viewOnceState: .incomingAvailable(
|
|
attachmentStream: attachmentStream,
|
|
renderingFlag: renderingFlag
|
|
))
|
|
}
|
|
} else if let attachmentPointer = mediaAttachment.attachment.asTransitTierPointer() {
|
|
switch attachmentPointer.downloadState(tx: transaction.asV2Read) {
|
|
case .enqueuedOrDownloading:
|
|
return buildViewOnce(viewOnceState: .incomingDownloading(
|
|
attachmentPointer: attachmentPointer,
|
|
renderingFlag: renderingFlag
|
|
))
|
|
case .failed:
|
|
return buildViewOnce(viewOnceState: .incomingFailed)
|
|
case .none:
|
|
return buildViewOnce(viewOnceState: .incomingPending)
|
|
}
|
|
}
|
|
|
|
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,
|
|
parentMessage: message,
|
|
transaction: transaction
|
|
)
|
|
let state = CVContactShareView.buildState(
|
|
contactShare: contactShare,
|
|
isIncoming: isIncoming,
|
|
conversationStyle: conversationStyle,
|
|
transaction: transaction
|
|
)
|
|
self.contactShare = ContactShare(state: state)
|
|
|
|
let phoneNumberPartition = contactShare.dbRecord.phoneNumberPartition(tx: transaction)
|
|
let phoneNumberAction: CVMessageAction? = phoneNumberPartition.map(
|
|
ifSendablePhoneNumbers: {
|
|
// If system contacts are known/linkable, show a "Send" button.
|
|
return CVMessageAction(
|
|
title: CommonStrings.sendMessage,
|
|
accessibilityIdentifier: "send_message_to_contact_share",
|
|
action: .didTapSendMessage(phoneNumbers: $0)
|
|
)
|
|
},
|
|
elseIfInvitablePhoneNumbers: { _ in
|
|
// If system contacts aren't registered, show an "Invite" button.
|
|
return CVMessageAction(
|
|
title: OWSLocalizedString("ACTION_INVITE", comment: "Label for 'invite' button in contact view."),
|
|
accessibilityIdentifier: "invite_contact_share",
|
|
action: .didTapSendInvite(contactShare: contactShare)
|
|
)
|
|
},
|
|
elseIfAddablePhoneNumbers: { _ in
|
|
// Otherwise, offer to add the number(s) to your address book.
|
|
return CVMessageAction(
|
|
title: OWSLocalizedString("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: .didTapAddToContacts(contactShare: contactShare)
|
|
)
|
|
},
|
|
elseIfNoPhoneNumbers: {
|
|
return nil
|
|
}
|
|
)
|
|
if let phoneNumberAction {
|
|
bottomButtonsActions.append(phoneNumberAction)
|
|
}
|
|
|
|
return build()
|
|
}
|
|
|
|
// TODO: Should we throw more?
|
|
mutating func buildSticker(message: TSMessage, messageSticker: MessageSticker) throws -> CVComponentState {
|
|
|
|
guard
|
|
let rowId = message.sqliteRowId,
|
|
let attachment = DependenciesBridge.shared.attachmentStore.fetchFirstReferencedAttachment(
|
|
for: .messageSticker(messageRowId: rowId),
|
|
tx: transaction.asV2Read
|
|
)
|
|
else {
|
|
throw OWSAssertionError("Missing sticker attachment.")
|
|
}
|
|
if let attachmentStream = attachment.asReferencedStream {
|
|
switch attachmentStream.attachmentStream.contentType {
|
|
case .image(let pixelSize), .animatedImage(let pixelSize):
|
|
guard pixelSize.isNonEmpty else {
|
|
fallthrough
|
|
}
|
|
default:
|
|
throw OWSAssertionError("Invalid sticker.")
|
|
}
|
|
let stickerType = StickerManager.stickerType(forContentType: attachmentStream.attachment.mimeType)
|
|
guard
|
|
let stickerMetadata = attachmentStream.attachmentStream.asStickerMetadata(
|
|
stickerInfo: messageSticker.info,
|
|
stickerType: stickerType,
|
|
emojiString: messageSticker.emoji
|
|
)
|
|
else {
|
|
throw OWSAssertionError("Invalid sticker.")
|
|
}
|
|
self.sticker = .available(
|
|
stickerMetadata: stickerMetadata,
|
|
attachmentStream: attachmentStream
|
|
)
|
|
return build()
|
|
} else if let attachmentPointer = attachment.asReferencedTransitPointer {
|
|
let downloadState = attachmentPointer.attachmentPointer.downloadState(tx: transaction.asV2Read)
|
|
switch downloadState {
|
|
case .enqueuedOrDownloading:
|
|
self.sticker = .downloading(attachmentPointer: attachmentPointer)
|
|
case .failed, .none:
|
|
self.sticker = .failedOrPending(
|
|
attachmentPointer: attachmentPointer,
|
|
transitTierDownloadState: downloadState
|
|
)
|
|
}
|
|
return build()
|
|
} else {
|
|
throw OWSAssertionError("Invalid sticker.")
|
|
}
|
|
}
|
|
|
|
// TODO: Should we validate and throw errors?
|
|
mutating func buildQuotedReply(
|
|
message: TSMessage,
|
|
revealedSpoilerIdsSnapshot: Set<StyleIdType>
|
|
) {
|
|
let quotedReplyModel: QuotedReplyModel? = {
|
|
if
|
|
message.isStoryReply,
|
|
let storyTimestamp = message.storyTimestamp?.uint64Value,
|
|
let storyAuthorAci = message.storyAuthorAci?.wrappedAciValue
|
|
{
|
|
return QuotedReplyModel.build(
|
|
storyReplyMessage: message,
|
|
storyTimestamp: storyTimestamp,
|
|
storyAuthorAci: storyAuthorAci,
|
|
transaction: transaction
|
|
)
|
|
} else if let quotedMessage = message.quotedMessage {
|
|
return QuotedReplyModel.build(replyMessage: message, quotedMessage: quotedMessage, transaction: transaction)
|
|
} else {
|
|
return nil
|
|
}
|
|
}()
|
|
guard let quotedReplyModel else {
|
|
return
|
|
}
|
|
var displayableQuotedText: DisplayableText?
|
|
if let quotedBody = quotedReplyModel.originalMessageBody, !quotedBody.text.isEmpty {
|
|
displayableQuotedText = CVComponentState.displayableQuotedText(
|
|
text: quotedBody.text,
|
|
ranges: quotedBody.ranges,
|
|
interaction: message,
|
|
revealedSpoilerIdsSnapshot: revealedSpoilerIdsSnapshot,
|
|
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: [ReferencedAttachment],
|
|
message: TSMessage) -> [CVMediaAlbumItem] {
|
|
|
|
let threadHasPendingMessageRequest = message.thread(tx: transaction)?
|
|
.hasPendingMessageRequest(transaction: transaction)
|
|
?? false
|
|
|
|
var mediaAlbumItems = [CVMediaAlbumItem]()
|
|
for attachment in mediaAttachments {
|
|
guard
|
|
// Use the validated content type and only fall back to the mime type.
|
|
attachment.attachment.asStream()?.contentType.isVisualMedia
|
|
?? MimeTypeUtil.isSupportedVisualMediaMimeType(attachment.attachment.mimeType)
|
|
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 []
|
|
}
|
|
|
|
guard let cvAttachment = CVAttachment.from(attachment, tx: transaction) else {
|
|
owsFailDebug("Invalid attachment!")
|
|
continue
|
|
}
|
|
|
|
let caption = attachment.reference.legacyMessageCaption
|
|
let hasCaption = caption.map {
|
|
return CVComponentState.displayableCaption(
|
|
text: $0,
|
|
transaction: transaction
|
|
).fullTextValue.isEmpty.negated
|
|
} ?? false
|
|
|
|
switch cvAttachment {
|
|
case .pointer:
|
|
var mediaSize: CGSize = .zero
|
|
if let sourceMediaSizePixels = attachment.reference.sourceMediaSizePixels {
|
|
mediaSize = sourceMediaSizePixels
|
|
} else {
|
|
owsFailDebug("Invalid attachment.")
|
|
}
|
|
mediaAlbumItems.append(CVMediaAlbumItem(
|
|
attachment: cvAttachment,
|
|
attachmentStream: nil,
|
|
hasCaption: hasCaption,
|
|
mediaSize: mediaSize,
|
|
isBroken: false,
|
|
threadHasPendingMessageRequest: threadHasPendingMessageRequest
|
|
))
|
|
continue
|
|
case .stream(let attachmentStream):
|
|
let attachmentStream = attachmentStream.attachmentStream
|
|
guard attachmentStream.contentType.isVisualMedia else {
|
|
Logger.warn("Filtering invalid media.")
|
|
mediaAlbumItems.append(CVMediaAlbumItem(
|
|
attachment: cvAttachment,
|
|
attachmentStream: nil,
|
|
hasCaption: hasCaption,
|
|
mediaSize: .zero,
|
|
isBroken: true,
|
|
threadHasPendingMessageRequest: threadHasPendingMessageRequest
|
|
))
|
|
continue
|
|
}
|
|
let mediaSizePixels: CGSize
|
|
switch attachmentStream.contentType {
|
|
case let .image(pixelSize), let .video(_, pixelSize, _), let .animatedImage(pixelSize):
|
|
guard pixelSize.isNonEmpty else {
|
|
Logger.warn("Filtering media with invalid size.")
|
|
fallthrough
|
|
}
|
|
mediaSizePixels = pixelSize
|
|
case .audio, .file, .invalid:
|
|
Logger.warn("Filtering media with invalid size.")
|
|
mediaAlbumItems.append(CVMediaAlbumItem(
|
|
attachment: cvAttachment,
|
|
attachmentStream: nil,
|
|
hasCaption: hasCaption,
|
|
mediaSize: .zero,
|
|
isBroken: true,
|
|
threadHasPendingMessageRequest: threadHasPendingMessageRequest
|
|
))
|
|
continue
|
|
}
|
|
|
|
mediaAlbumItems.append(CVMediaAlbumItem(
|
|
attachment: cvAttachment,
|
|
attachmentStream: attachmentStream,
|
|
hasCaption: hasCaption,
|
|
mediaSize: mediaSizePixels,
|
|
isBroken: false,
|
|
threadHasPendingMessageRequest: threadHasPendingMessageRequest
|
|
))
|
|
case .backupThumbnail(let thumbnail):
|
|
// TODO: Need to make CVMediaAlbumItem take a thumbnail
|
|
var mediaSize: CGSize = .zero
|
|
// need to determine if a size is possible
|
|
// TODO Cache this
|
|
if let sourceMediaSizePixels = thumbnail.attachmentBackupThumbnail.image?.size {
|
|
mediaSize = sourceMediaSizePixels
|
|
} else {
|
|
owsFailDebug("Invalid attachment.")
|
|
}
|
|
mediaAlbumItems.append(CVMediaAlbumItem(
|
|
attachment: cvAttachment,
|
|
attachmentStream: nil,
|
|
hasCaption: hasCaption,
|
|
mediaSize: mediaSize,
|
|
isBroken: false,
|
|
threadHasPendingMessageRequest: threadHasPendingMessageRequest
|
|
))
|
|
continue
|
|
}
|
|
}
|
|
return mediaAlbumItems
|
|
}
|
|
|
|
mutating func buildNonMediaAttachment(bodyAttachment: ReferencedAttachment?) throws {
|
|
|
|
guard let attachment = bodyAttachment else {
|
|
throw OWSAssertionError("Missing attachment.")
|
|
}
|
|
|
|
func buildGenericAttachment() {
|
|
if let cvAttachment = CVAttachment.from(attachment, tx: transaction) {
|
|
self.genericAttachment = .init(attachment: cvAttachment)
|
|
}
|
|
}
|
|
|
|
guard
|
|
// Use the validated content type and only fall back to the mime type.
|
|
attachment.attachment.asStream()?.contentType.isAudio
|
|
?? MimeTypeUtil.isSupportedAudioMimeType(attachment.attachment.mimeType)
|
|
else {
|
|
buildGenericAttachment()
|
|
return
|
|
}
|
|
|
|
if
|
|
let attachmentStream = attachment.asReferencedStream,
|
|
let audioAttachment = AudioAttachment(
|
|
attachmentStream: attachmentStream,
|
|
owningMessage: interaction as? TSMessage,
|
|
metadata: nil,
|
|
receivedAtDate: interaction.receivedAtDate
|
|
)
|
|
{
|
|
self.audioAttachment = audioAttachment
|
|
} else if let attachmentPointer = attachment.asReferencedTransitPointer {
|
|
self.audioAttachment = AudioAttachment(
|
|
attachmentPointer: attachmentPointer,
|
|
owningMessage: interaction as? TSMessage,
|
|
metadata: nil,
|
|
receivedAtDate: interaction.receivedAtDate,
|
|
transitTierDownloadState: attachmentPointer.attachmentPointer.downloadState(tx: transaction.asV2Read)
|
|
)
|
|
} else {
|
|
buildGenericAttachment()
|
|
}
|
|
}
|
|
|
|
mutating func buildPaymentAttachment(
|
|
paymentNotification: TSPaymentNotification
|
|
) -> CVComponentState {
|
|
|
|
// Note: there should only ever be one payment model per receipt,
|
|
// but this is unenforced.
|
|
let paymentModel: TSPaymentModel? = PaymentFinder.paymentModels(
|
|
forMcReceiptData: paymentNotification.mcReceiptData,
|
|
transaction: itemBuildingContext.transaction
|
|
).first
|
|
|
|
self.paymentAttachment = PaymentAttachment(
|
|
notification: paymentNotification,
|
|
model: paymentModel,
|
|
// Only used for 1:1 threads, but not enforced.
|
|
otherUserShortName: threadViewModel.shortName ?? threadViewModel.name
|
|
)
|
|
|
|
return build()
|
|
}
|
|
|
|
mutating func buildArchivedPaymentAttachment(
|
|
archivedPaymentMessage: OWSArchivedPaymentMessage,
|
|
archivedPayment: ArchivedPayment?
|
|
) -> CVComponentState {
|
|
|
|
self.archivedPaymentAttachment = ArchivedPaymentAttachment(
|
|
amount: archivedPaymentMessage.archivedPaymentInfo.amount,
|
|
fee: archivedPaymentMessage.archivedPaymentInfo.fee,
|
|
note: archivedPaymentMessage.archivedPaymentInfo.note,
|
|
// Only used for 1:1 threads, but not enforced.
|
|
otherUserShortName: threadViewModel.shortName ?? threadViewModel.name,
|
|
archivedPayment: archivedPayment
|
|
)
|
|
|
|
return build()
|
|
}
|
|
|
|
private 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 = GroupInviteLinkInfo.parseFrom(url) {
|
|
let groupInviteLinkViewModel = CVComponentState.configureGroupInviteLink(
|
|
url,
|
|
message: message,
|
|
groupInviteLinkInfo: groupInviteLinkInfo
|
|
)
|
|
if !groupInviteLinkViewModel.isExpired {
|
|
let state = LinkPreviewGroupLink(
|
|
linkType: isIncoming ? .incomingMessageGroupInviteLink : .outgoingMessageGroupInviteLink,
|
|
linkPreview: linkPreview,
|
|
groupInviteLinkViewModel: groupInviteLinkViewModel,
|
|
conversationStyle: conversationStyle
|
|
)
|
|
self.linkPreview = LinkPreview(
|
|
linkPreview: linkPreview,
|
|
linkPreviewAttachment: nil,
|
|
state: state
|
|
)
|
|
}
|
|
} else if let callLink = CallLink(url: url) {
|
|
let bottomButtonAction = CVMessageAction(
|
|
title: OWSLocalizedString(
|
|
"CONVERSATION_VIEW_JOIN_CALL",
|
|
comment: "Message shown in conversation view that offers to join a Call Link call."
|
|
),
|
|
accessibilityIdentifier: "join_call_link_call",
|
|
action: .didTapJoinCallLinkCall(callLink: callLink)
|
|
)
|
|
bottomButtonsActions.append(bottomButtonAction)
|
|
let state = LinkPreviewCallLink(previewType: .sent(linkPreview, conversationStyle), callLink: callLink)
|
|
self.linkPreview = LinkPreview(
|
|
linkPreview: linkPreview,
|
|
linkPreviewAttachment: nil,
|
|
state: state
|
|
)
|
|
} else {
|
|
let linkPreviewAttachment = { () -> Attachment? in
|
|
guard
|
|
let rowId = message.sqliteRowId,
|
|
let linkPreviewAttachment = DependenciesBridge.shared.attachmentStore.fetchFirstReferencedAttachment(
|
|
for: .messageLinkPreview(messageRowId: rowId),
|
|
tx: transaction.asV2Read
|
|
)
|
|
else {
|
|
return nil
|
|
}
|
|
|
|
guard MimeTypeUtil.isSupportedImageMimeType(linkPreviewAttachment.attachment.mimeType) else {
|
|
owsFailDebug("Link preview attachment isn't an image.")
|
|
return nil
|
|
}
|
|
guard let attachmentStream = linkPreviewAttachment.asReferencedStream else {
|
|
return nil
|
|
}
|
|
guard attachmentStream.attachmentStream.contentType.isImage else {
|
|
owsFailDebug("Link preview image attachment isn't valid.")
|
|
return nil
|
|
}
|
|
return attachmentStream.attachment
|
|
}()
|
|
|
|
let state = LinkPreviewSent(
|
|
linkPreview: linkPreview,
|
|
imageAttachment: linkPreviewAttachment,
|
|
conversationStyle: conversationStyle
|
|
)
|
|
self.linkPreview = LinkPreview(
|
|
linkPreview: linkPreview,
|
|
linkPreviewAttachment: linkPreviewAttachment,
|
|
state: state
|
|
)
|
|
}
|
|
}
|
|
|
|
private mutating func buildGiftBadge(messageUniqueId: String, giftBadge: OWSGiftBadge) throws -> CVComponentState {
|
|
let (level, expirationDate) = try giftBadge.getReceiptDetails()
|
|
self.giftBadge = GiftBadge(
|
|
messageUniqueId: messageUniqueId,
|
|
otherUserShortName: threadViewModel.shortName ?? threadViewModel.name,
|
|
cachedBadge: DonationSubscriptionManager.getCachedBadge(level: .giftBadge(level)),
|
|
expirationDate: expirationDate,
|
|
redemptionState: giftBadge.redemptionState
|
|
)
|
|
return build()
|
|
}
|
|
}
|
|
|
|
// MARK: - DisplayableText
|
|
|
|
public extension CVComponentState {
|
|
|
|
static func displayableBodyText(text: String,
|
|
ranges: MessageBodyRanges?,
|
|
interaction: TSInteraction,
|
|
transaction: SDSAnyReadTransaction) -> DisplayableText {
|
|
return DisplayableText.displayableText(
|
|
withMessageBody: MessageBody(text: text, ranges: ranges ?? .empty),
|
|
transaction: transaction)
|
|
}
|
|
|
|
static func displayableBodyText(
|
|
oversizeTextAttachment attachmentStream: AttachmentStream,
|
|
ranges: MessageBodyRanges?,
|
|
interaction: TSInteraction,
|
|
transaction: SDSAnyReadTransaction
|
|
) -> DisplayableText {
|
|
|
|
let text = { () -> String in
|
|
do {
|
|
return try attachmentStream.decryptedLongText()
|
|
} catch {
|
|
owsFailDebug("Couldn't load oversize text: \(error).")
|
|
return ""
|
|
}
|
|
}()
|
|
|
|
return DisplayableText.displayableText(
|
|
withMessageBody: MessageBody(text: text, ranges: ranges ?? .empty),
|
|
transaction: transaction)
|
|
}
|
|
}
|
|
|
|
// MARK: -
|
|
|
|
fileprivate extension CVComponentState {
|
|
|
|
static func displayableQuotedText(
|
|
text: String,
|
|
ranges: MessageBodyRanges?,
|
|
interaction: TSInteraction,
|
|
revealedSpoilerIdsSnapshot: Set<StyleIdType>,
|
|
transaction: SDSAnyReadTransaction
|
|
) -> DisplayableText {
|
|
return DisplayableText.displayableText(
|
|
withMessageBody: MessageBody(text: text, ranges: ranges ?? .empty),
|
|
transaction: transaction
|
|
)
|
|
}
|
|
|
|
static func displayableCaption(
|
|
text: String,
|
|
transaction: SDSAnyReadTransaction
|
|
) -> DisplayableText {
|
|
return DisplayableText.displayableText(
|
|
withMessageBody: MessageBody(text: text, ranges: .empty),
|
|
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 .giftBadge:
|
|
// Gift badges can't be forwarded.
|
|
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
|
|
case .paymentAttachment, .archivedPaymentAttachment:
|
|
// Payments can't be forwarded.
|
|
break
|
|
}
|
|
}
|
|
|
|
let hasSecondaryContent = bodyText != nil
|
|
|
|
return hasPrimaryContent && hasSecondaryContent
|
|
}
|
|
}
|