Handle undownloadable attachment rendering

This commit is contained in:
Elaine 2025-02-11 09:38:31 -07:00 committed by GitHub
parent 3c159a4ec6
commit a26ebaa2de
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 536 additions and 147 deletions

View File

@ -1648,6 +1648,7 @@
B9A53B992D0250FC0000578B /* EditCallLinkNameViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9A53B982D0250FC0000578B /* EditCallLinkNameViewController.swift */; };
B9A87A362A9D1D25009FCA13 /* EditorSticker.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9A87A352A9D1D25009FCA13 /* EditorSticker.swift */; };
B9A87A382A9E34BD009FCA13 /* Hatsuishi-UPM800.otf in Resources */ = {isa = PBXBuildFile; fileRef = B9A87A372A9E34BD009FCA13 /* Hatsuishi-UPM800.otf */; };
B9AAC9512D5A6FA900E223C0 /* CVComponentUndownloadableAttachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9AAC9502D5A6FA900E223C0 /* CVComponentUndownloadableAttachment.swift */; };
B9B2AA942BC598B60060B56C /* ContactNoteSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B2AA932BC598B60060B56C /* ContactNoteSheet.swift */; };
B9B7BC652D41C61500C26E42 /* NewLinkedDeviceNotificationMegaphone.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B7BC642D41C61500C26E42 /* NewLinkedDeviceNotificationMegaphone.swift */; };
B9B89EED2C064E760093A2FA /* notification_simple-01.caf in Resources */ = {isa = PBXBuildFile; fileRef = B9B89EEC2C064E700093A2FA /* notification_simple-01.caf */; };
@ -5475,6 +5476,7 @@
B9A53B982D0250FC0000578B /* EditCallLinkNameViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditCallLinkNameViewController.swift; sourceTree = "<group>"; };
B9A87A352A9D1D25009FCA13 /* EditorSticker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorSticker.swift; sourceTree = "<group>"; };
B9A87A372A9E34BD009FCA13 /* Hatsuishi-UPM800.otf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "Hatsuishi-UPM800.otf"; sourceTree = "<group>"; };
B9AAC9502D5A6FA900E223C0 /* CVComponentUndownloadableAttachment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CVComponentUndownloadableAttachment.swift; sourceTree = "<group>"; };
B9B2AA932BC598B60060B56C /* ContactNoteSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactNoteSheet.swift; sourceTree = "<group>"; };
B9B7BC642D41C61500C26E42 /* NewLinkedDeviceNotificationMegaphone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewLinkedDeviceNotificationMegaphone.swift; sourceTree = "<group>"; };
B9B89EEC2C064E700093A2FA /* notification_simple-01.caf */ = {isa = PBXFileReference; lastKnownFileType = file; path = "notification_simple-01.caf"; sourceTree = "<group>"; };
@ -7931,6 +7933,7 @@
348815BB2552E67900D4F4C4 /* CVComponentSystemMessage.swift */,
347C3856252E1E2300F3D941 /* CVComponentThreadDetails.swift */,
347C384C252D0FDC00F3D941 /* CVComponentTypingIndicator.swift */,
B9AAC9502D5A6FA900E223C0 /* CVComponentUndownloadableAttachment.swift */,
347C384A252D085900F3D941 /* CVComponentUnreadIndicator.swift */,
348815BF2553291200D4F4C4 /* CVComponentViewOnce.swift */,
667AF9DD2B4C5824008AEE5D /* PersistableGroupUpdateItem+CVComponentSystemMessageAction.swift */,
@ -16482,6 +16485,7 @@
348815BC2552E67900D4F4C4 /* CVComponentSystemMessage.swift in Sources */,
347C3857252E1E2300F3D941 /* CVComponentThreadDetails.swift in Sources */,
347C384D252D0FDC00F3D941 /* CVComponentTypingIndicator.swift in Sources */,
B9AAC9512D5A6FA900E223C0 /* CVComponentUndownloadableAttachment.swift in Sources */,
347C384B252D085900F3D941 /* CVComponentUnreadIndicator.swift in Sources */,
348815C02553291300D4F4C4 /* CVComponentViewOnce.swift in Sources */,
3470C8802555F25200F5847C /* CVContactShareView.swift in Sources */,

View File

@ -18,9 +18,6 @@ public class AudioAttachment {
attachmentPointer: ReferencedAttachmentPointer,
downloadState: AttachmentDownloadState
)
/// The attachment has no stream and cannot be downloaded because there is no cdn info.
/// Typically happens if we restore from a free-tier backup with old media expired from transit tier.
case undownloadable(ReferencedAttachment)
public static func == (lhs: AudioAttachment.State, rhs: AudioAttachment.State) -> Bool {
switch (lhs, rhs) {
@ -38,16 +35,9 @@ public class AudioAttachment {
return lhsPointer.attachment.id == rhsPointer.attachment.id
&& lhsPointer.reference.hasSameOwner(as: rhsPointer.reference)
&& lhsState == rhsState
case let (
.undownloadable(lhsAttachment),
.undownloadable(rhsAttachment)
):
return lhsAttachment.attachment.id == rhsAttachment.attachment.id
&& lhsAttachment.reference.hasSameOwner(as: rhsAttachment.reference)
case
(.attachmentStream, _),
(.attachmentPointer, _),
(.undownloadable, _):
(.attachmentPointer, _):
return false
}
}
@ -60,8 +50,6 @@ public class AudioAttachment {
return attachmentStream.reference.sourceFilename
case .attachmentPointer(let attachmentPointer, _):
return attachmentPointer.reference.sourceFilename
case .undownloadable(let attachment):
return attachment.reference.sourceFilename
}
}
@ -123,18 +111,6 @@ public class AudioAttachment {
self.owningMessage = owningMessage
}
public init(
undownloadableAttachment: ReferencedAttachment,
owningMessage: TSMessage?,
metadata: MediaMetadata?,
receivedAtDate: Date
) {
state = .undownloadable(undownloadableAttachment)
self.isDownloading = false
self.receivedAtDate = receivedAtDate
self.owningMessage = owningMessage
}
private static let cachedAttachmentDurations = AtomicDictionary<Int64, TimeInterval>([:], lock: .init())
private static func cachedAudioDuration(forAttachment attachmentStream: AttachmentStream) -> TimeInterval {
let attachmentId = attachmentStream.attachment.id
@ -163,8 +139,6 @@ extension AudioAttachment {
return attachmentStream.attachment
case .attachmentPointer(let attachmentPointer, _):
return attachmentPointer.attachment
case .undownloadable(let attachment):
return attachment.attachment
}
}
@ -174,8 +148,6 @@ extension AudioAttachment {
return attachmentStream
case .attachmentPointer:
return nil
case .undownloadable:
return nil
}
}
@ -185,8 +157,6 @@ extension AudioAttachment {
return nil
case .attachmentPointer(let attachmentPointer, _):
return attachmentPointer
case .undownloadable:
return nil
}
}
@ -196,8 +166,6 @@ extension AudioAttachment {
return audioDurationSeconds
case .attachmentPointer:
return 0
case .undownloadable:
return 0
}
}
@ -208,8 +176,6 @@ extension AudioAttachment {
return attachmentStream.reference.renderingFlag
case .attachmentPointer(let attachmentPointer, _):
return attachmentPointer.reference.renderingFlag
case .undownloadable(let attachment):
return attachment.reference.renderingFlag
}
}() == .voiceMessage
}

View File

@ -262,7 +262,7 @@ extension CVItemViewModelImpl {
return false
case .textOnlyMessage, .audio, .genericAttachment, .contactShare, .bodyMedia, .viewOnce, .stickerMessage, .quoteOnlyMessage:
return !hasUnloadedAttachments
case .paymentAttachment, .archivedPaymentAttachment:
case .paymentAttachment, .archivedPaymentAttachment, .undownloadableAttachment:
return false
}
}

View File

@ -15,6 +15,7 @@ public enum CVMessageCellType: Int, CustomStringConvertible, Equatable {
case genericAttachment
case paymentAttachment
case archivedPaymentAttachment
case undownloadableAttachment
case contactShare
case bodyMedia
case viewOnce
@ -42,6 +43,7 @@ public enum CVMessageCellType: Int, CustomStringConvertible, Equatable {
case .genericAttachment: return "genericAttachment"
case .paymentAttachment: return "paymentAttachment"
case .archivedPaymentAttachment: return "archivedPaymentAttachment"
case .undownloadableAttachment: return "undownloadableAttachment"
case .contactShare: return "contactShare"
case .bodyMedia: return "bodyMedia"
case .viewOnce: return "viewOnce"

View File

@ -153,11 +153,6 @@ class AudioMessageView: ManualStackView {
isDarkThemeEnabled: conversationStyle.isDarkThemeEnabled,
mediaCache: mediaCache
)
case .undownloadable:
// TODO[AttachmentRendering]: render undownloadable audio attachments.
// Can either reuse this component or use a totally different component
// in which case this should owsFailDebug here.
return
}
let topInnerStack = ManualStackView(name: "playerStack")
@ -518,7 +513,7 @@ extension AudioAttachment {
switch state {
case .attachmentStream(let stream, _):
return ByteCountFormatter().string(for: stream.attachmentStream.unencryptedByteCount) ?? ""
case .attachmentPointer, .undownloadable:
case .attachmentPointer:
owsFailDebug("Shouldn't get here - undownloaded media not implemented")
return ""
}
@ -529,7 +524,7 @@ extension AudioAttachment {
let dateFormatter = DateFormatter()
dateFormatter.setLocalizedDateFormatFromTemplate("Mdyy")
return dateFormatter.string(from: receivedAtDate)
case .attachmentPointer, .undownloadable:
case .attachmentPointer:
owsFailDebug("Shouldn't get here - undownloaded media not implemented")
return ""
}

View File

@ -349,6 +349,7 @@ public enum CVComponentKey: CustomStringConvertible, CaseIterable {
case genericAttachment
case paymentAttachment
case archivedPaymentAttachment
case undownloadableAttachment
case contactShare
case bottomButtons
case sendFailureBadge
@ -395,6 +396,8 @@ public enum CVComponentKey: CustomStringConvertible, CaseIterable {
return ".paymentAttchment"
case .archivedPaymentAttachment:
return ".archivedPaymentAttachment"
case .undownloadableAttachment:
return ".undownloadableAttachment"
case .contactShare:
return ".contactShare"
case .bottomButtons:

View File

@ -94,6 +94,10 @@ public protocol CVComponentDelegate: AnyObject, AudioMessageViewDelegate {
func didTapUndownloadableOversizeText()
func didTapUndownloadableAudio()
func didTapUndownloadableSticker()
func didTapBrokenVideo()
// MARK: - Messages

View File

@ -61,6 +61,8 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent {
private var archivedPaymentAttachment: CVComponent?
private var undownloadableAttachment: CVComponent?
private var contactShare: CVComponent?
private var bottomButtons: CVComponent?
@ -127,6 +129,8 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent {
return self.paymentAttachment
case .archivedPaymentAttachment:
return self.archivedPaymentAttachment
case .undownloadableAttachment:
return self.undownloadableAttachment
case .quotedReply:
return self.quotedReply
case .linkPreview:
@ -168,7 +172,7 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent {
private var isBubbleTransparent: Bool {
if wasRemotelyDeleted {
return false
} else if componentState.isSticker {
} else if componentState.shouldRenderAsSticker {
return true
} else if isBorderlessViewOnceMessage {
return false
@ -196,16 +200,45 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent {
standaloneFooter?.tapForMoreState ?? .none
}
private func footerOverlayIfItShouldShow() -> CVComponentFooter? {
let footerShouldOverlay = (bodyText == nil && !itemViewState.shouldHideFooter && !tapForMoreState.shouldShowFooter)
guard footerShouldOverlay else { return nil }
if let footerState = itemViewState.footerState {
return CVComponentFooter(
itemModel: itemModel,
footerState: footerState,
isOverlayingMedia: false,
isOutsideBubble: false
)
} else {
owsFailDebug("Missing footerState.")
}
return nil
}
private func buildComponentStates() {
hasSendFailureBadge = componentState.sendFailureBadge != nil
var footerOverlay: CVComponentFooter?
if let senderNameState = itemViewState.senderNameState {
self.senderName = CVComponentSenderName(itemModel: itemModel, senderNameState: senderNameState)
}
if let senderAvatar = componentState.senderAvatar {
self.senderAvatar = senderAvatar
}
if let undownloadableAttachment = componentState.undownloadableAttachment {
footerOverlay = self.footerOverlayIfItShouldShow()
self.undownloadableAttachment = CVComponentUndownloadableAttachment(
itemModel: itemModel,
attachmentType: undownloadableAttachment,
footerOverlay: footerOverlay
)
}
if let stickerState = componentState.sticker {
self.sticker = CVComponentSticker(itemModel: itemModel, sticker: stickerState)
}
@ -230,8 +263,6 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent {
bottomButtonsState: bottomButtonsState)
}
var footerOverlay: CVComponentFooter?
if let paymentAttachment = componentState.paymentAttachment {
let paymentAmount: UInt64? = {
let receipt = paymentAttachment.notification.mcReceiptData
@ -305,18 +336,7 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent {
}
if let audioAttachmentState = componentState.audioAttachment {
let shouldFooterOverlayAudio = (bodyText == nil && !itemViewState.shouldHideFooter && !tapForMoreState.shouldShowFooter)
if shouldFooterOverlayAudio {
if let footerState = itemViewState.footerState {
footerOverlay = CVComponentFooter(itemModel: itemModel,
footerState: footerState,
isOverlayingMedia: false,
isOutsideBubble: false)
} else {
owsFailDebug("Missing footerState.")
}
}
footerOverlay = self.footerOverlayIfItShouldShow()
self.audioAttachment = CVComponentAudioAttachment(
itemModel: itemModel,
audioAttachment: audioAttachmentState,
@ -905,7 +925,7 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent {
private static var topNestedCVComponentKeys: [CVComponentKey] { [.senderName] }
private static var bottomFullWidthCVComponentKeys: [CVComponentKey] { [.quotedReply, .bodyMedia] }
private static var bottomNestedShareCVComponentKeys: [CVComponentKey] { [.viewOnce, .audioAttachment, .genericAttachment, .paymentAttachment, .archivedPaymentAttachment, .contactShare, .giftBadge] }
private static var bottomNestedTextCVComponentKeys: [CVComponentKey] { [.bodyText, .footer] }
private static var bottomNestedTextCVComponentKeys: [CVComponentKey] { [.bodyText, .footer, .undownloadableAttachment] }
// The "message" contents of this component for most messages are vertically
// stacked in four sections.
@ -1161,6 +1181,8 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent {
return false
case .bodyMedia, .sticker, .quotedReply, .linkPreview, .viewOnce, .audioAttachment, .genericAttachment, .paymentAttachment, .archivedPaymentAttachment, .contactShare:
return true
case .undownloadableAttachment:
return false
case .giftBadge:
// TODO: (GB) Confirm that Gift Badges should use large component spacing.
return true
@ -1961,6 +1983,7 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent {
var audioAttachmentView: CVComponentView?
var genericAttachmentView: CVComponentView?
var paymentAttachmentView: CVComponentView?
var undownloadableAttachmentView: CVComponentView?
var archivedPaymentView: CVComponentView?
var contactShareView: CVComponentView?
var bottomButtonsView: CVComponentView?
@ -1980,6 +2003,7 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent {
audioAttachmentView,
genericAttachmentView,
paymentAttachmentView,
undownloadableAttachmentView,
archivedPaymentView,
contactShareView,
bottomButtonsView
@ -2016,6 +2040,8 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent {
return paymentAttachmentView
case .archivedPaymentAttachment:
return archivedPaymentView
case .undownloadableAttachment:
return undownloadableAttachmentView
case .contactShare:
return contactShareView
case .bottomButtons:
@ -2061,6 +2087,8 @@ public class CVComponentMessage: CVComponentBase, CVRootComponent {
paymentAttachmentView = subcomponentView
case .archivedPaymentAttachment:
archivedPaymentView = subcomponentView
case .undownloadableAttachment:
undownloadableAttachmentView = subcomponentView
case .contactShare:
contactShareView = subcomponentView
case .bottomButtons:

View File

@ -250,15 +250,12 @@ public class CVComponentState: Equatable {
attachmentPointer: ReferencedAttachmentPointer,
downloadState: AttachmentDownloadState
)
/// The attachment has no stream and cannot be downloaded because there is no cdn info.
/// Typically happens if we restore from a free-tier backup with old media expired from transit tier.
case undownloadable(ReferencedAttachment)
public var stickerMetadata: (any StickerMetadata)? {
switch self {
case .available(let stickerMetadata, _):
return stickerMetadata
case .downloading, .failedOrPending, .undownloadable:
case .downloading, .failedOrPending:
return nil
}
}
@ -270,8 +267,6 @@ public class CVComponentState: Equatable {
return nil
case .failedOrPending:
return nil
case .undownloadable:
return nil
}
}
public var attachmentPointer: ReferencedAttachmentPointer? {
@ -282,8 +277,6 @@ public class CVComponentState: Equatable {
return attachmentPointer
case .failedOrPending(let attachmentPointer, _):
return attachmentPointer
case .undownloadable:
return nil
}
}
@ -300,16 +293,21 @@ public class CVComponentState: Equatable {
return lhsPointer.attachment.id == rhsPointer.attachment.id
&& lhsPointer.reference.hasSameOwner(as: rhsPointer.reference)
&& lhsState == rhsState
case let (.undownloadable(lhsAttachment), .undownloadable(rhsAttachment)):
return lhsAttachment.attachment.id == rhsAttachment.attachment.id
&& lhsAttachment.reference.hasSameOwner(as: rhsAttachment.reference)
case (.available, _), (.downloading, _), (.failedOrPending, _), (.undownloadable, _):
case (.available, _), (.downloading, _), (.failedOrPending, _):
return false
}
}
}
let sticker: Sticker?
/// The attachment has no stream and cannot be downloaded because there is no cdn info.
/// Typically happens if we restore from a free-tier backup with old media expired from transit tier.
enum UndownloadableAttachment: Equatable {
case audio
case sticker
}
let undownloadableAttachment: UndownloadableAttachment?
struct ContactShare: Equatable {
let state: CVContactShareView.State
}
@ -452,34 +450,37 @@ public class CVComponentState: Equatable {
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) {
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?,
undownloadableAttachment: UndownloadableAttachment?,
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
@ -493,6 +494,7 @@ public class CVComponentState: Equatable {
self.viewOnce = viewOnce
self.quotedReply = quotedReply
self.sticker = sticker
self.undownloadableAttachment = undownloadableAttachment
self.contactShare = contactShare
self.linkPreview = linkPreview
self.giftBadge = giftBadge
@ -526,6 +528,7 @@ public class CVComponentState: Equatable {
lhs.viewOnce == rhs.viewOnce &&
lhs.quotedReply == rhs.quotedReply &&
lhs.sticker == rhs.sticker &&
lhs.undownloadableAttachment == rhs.undownloadableAttachment &&
lhs.contactShare == rhs.contactShare &&
lhs.linkPreview == rhs.linkPreview &&
lhs.giftBadge == rhs.giftBadge &&
@ -555,6 +558,7 @@ public class CVComponentState: Equatable {
typealias ViewOnce = CVComponentState.ViewOnce
typealias QuotedReply = CVComponentState.QuotedReply
typealias Sticker = CVComponentState.Sticker
typealias UndownloadableAttachment = CVComponentState.UndownloadableAttachment
typealias SystemMessage = CVComponentState.SystemMessage
typealias ContactShare = CVComponentState.ContactShare
typealias Reactions = CVComponentState.Reactions
@ -588,6 +592,7 @@ public class CVComponentState: Equatable {
var viewOnce: ViewOnce?
var quotedReply: QuotedReply?
var sticker: Sticker?
var undownloadableAttachment: UndownloadableAttachment?
var systemMessage: SystemMessage?
var contactShare: ContactShare?
var linkPreview: LinkPreview?
@ -619,34 +624,37 @@ public class CVComponentState: Equatable {
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)
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,
undownloadableAttachment: undownloadableAttachment,
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: -
@ -681,9 +689,12 @@ public class CVComponentState: Equatable {
if systemMessage != nil {
return .systemMessage
}
if let sticker = self.sticker {
if self.sticker != nil {
return .stickerMessage
}
if self.undownloadableAttachment != nil {
return .undownloadableAttachment
}
if viewOnce != nil {
return .viewOnce
}
@ -722,7 +733,7 @@ public class CVComponentState: Equatable {
// MARK: - Convenience
lazy var isSticker: Bool = {
lazy var shouldRenderAsSticker: Bool = {
sticker != nil
}()
@ -1330,10 +1341,7 @@ fileprivate extension CVComponentState.Builder {
}
return build()
} else {
// TODO[AttachmentRendering]: represent state needed to render
// an undownloadable sticker attachment, which bears no visual
// relationship to other sticker attachment components.
self.sticker = .undownloadable(attachment)
self.undownloadableAttachment = .sticker
return build()
}
}
@ -1562,15 +1570,7 @@ fileprivate extension CVComponentState.Builder {
downloadState: attachmentPointer.attachmentPointer.downloadState(tx: transaction.asV2Read)
)
} else {
// TODO[AttachmentRendering]: represent state needed to render
// an undownloadable audio attachment, which bears no visual
// relationship to other audio attachment components.
self.audioAttachment = AudioAttachment(
undownloadableAttachment: attachment,
owningMessage: interaction as? TSMessage,
metadata: nil,
receivedAtDate: interaction.receivedAtDate
)
self.undownloadableAttachment = .audio
}
}
@ -1811,6 +1811,8 @@ public extension CVComponentState {
case .paymentAttachment, .archivedPaymentAttachment:
// Payments can't be forwarded.
break
case .undownloadableAttachment:
break
}
}

View File

@ -105,11 +105,6 @@ public class CVComponentSticker: CVComponentBase, CVComponent {
stackView: stackView,
cellMeasurement: cellMeasurement
)
case .undownloadable(_):
// TODO[AttachmentRendering]: render undownloadable audio attachments.
// Can either reuse this component or use a totally different component
// in which case this should owsFailDebug here.
owsFailDebug("Unimplemented")
}
}

View File

@ -0,0 +1,232 @@
//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
class CVComponentUndownloadableAttachment: CVComponentBase, CVComponent {
var componentKey: CVComponentKey { .undownloadableAttachment }
private var icon: UIImage {
switch attachmentType {
case .audio:
UIImage(named: "audio-square-slash")!
case .sticker:
UIImage(named: "sticker-slash")!
}
}
private var message: String {
switch attachmentType {
case .audio:
OWSLocalizedString(
"AUDIO_UNAVAILABLE_MESSAGE_LABEL",
value: "Voice message not available",
comment: "Message when trying to show a voice message that has expired and is unavailable for download"
)
case .sticker:
OWSLocalizedString(
"STICKER_UNAVAILABLE_MESSAGE_LABEL",
value: "Sticker not available",
comment: "Message when trying to show a sticker that has expired and is unavailable for download"
)
}
}
private let attachmentType: CVComponentState.UndownloadableAttachment
private let footerOverlay: CVComponent?
init(
itemModel: CVItemModel,
attachmentType: CVComponentState.UndownloadableAttachment,
footerOverlay: CVComponent?
) {
self.attachmentType = attachmentType
self.footerOverlay = footerOverlay
super.init(itemModel: itemModel)
}
private static let measurementKey_stackView = "CVComponentUndownloadableAttachment.measurementKey_stackView"
private static let measurementKey_footerSize = "CVComponentUndownloadableAttachment.measurementKey_footerSize"
public func buildComponentView(componentDelegate: any CVComponentDelegate) -> any CVComponentView {
CVComponentViewUndownloadableAttachment()
}
public func configureForRendering(
componentView: any CVComponentView,
cellMeasurement: SignalUI.CVCellMeasurement,
componentDelegate: any CVComponentDelegate
) {
guard let componentView = componentView as? CVComponentViewUndownloadableAttachment else {
owsFailDebug("Unexpected componentView.")
componentView.reset()
return
}
let stackView = componentView.stackView
let conversationStyle = self.conversationStyle
if let footerOverlay = self.footerOverlay {
let footerView: CVComponentView
if let footerOverlayView = componentView.footerOverlayView {
footerView = footerOverlayView
} else {
let footerOverlayView = CVComponentFooter.CVComponentViewFooter()
componentView.footerOverlayView = footerOverlayView
footerView = footerOverlayView
}
footerOverlay.configureForRendering(
componentView: footerView,
cellMeasurement: cellMeasurement,
componentDelegate: componentDelegate
)
let footerRootView = footerView.rootView
let footerSize = cellMeasurement.size(key: Self.measurementKey_footerSize) ?? .zero
stackView.addSubview(footerRootView) { view in
var footerFrame = view.bounds
footerFrame.height = min(view.bounds.height, footerSize.height)
footerFrame.y = view.bounds.height - footerSize.height
footerRootView.frame = footerFrame
}
}
let label = CVTextLabel()
label.configureForRendering(
config: self.labelConfig(
conversationStyle: conversationStyle,
isIncoming: itemModel.interaction is TSIncomingMessage
),
spoilerAnimationManager: .init()
)
stackView.configure(
config: self.stackViewConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_stackView,
subviews: [label.view]
)
}
public func measure(maxWidth: CGFloat, measurementBuilder: SignalUI.CVCellMeasurement.Builder) -> CGSize {
owsAssertDebug(maxWidth > 0)
let config = self.labelConfig(
conversationStyle: conversationStyle,
isIncoming: false // Used for color. Doesn't matter for measurement
)
let footerSize: CGSize
if let footerOverlay {
let maxFooterWidth = max(0, maxWidth - conversationStyle.textInsets.totalWidth)
footerSize = footerOverlay.measure(
maxWidth: maxFooterWidth,
measurementBuilder: measurementBuilder
)
measurementBuilder.setSize(key: Self.measurementKey_footerSize, size: footerSize)
} else {
footerSize = .zero
}
let info = CVText.measureBodyTextLabelInManualStackView(
config: config,
footerSize: footerSize,
maxWidth: maxWidth,
measurementBuilder: measurementBuilder
)
let stackMeasurement = ManualStackView.measure(
config: stackViewConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_stackView,
subviewInfos: info,
maxWidth: maxWidth
)
return stackMeasurement.measuredSize
}
public var stackViewConfig: CVStackViewConfig {
CVStackViewConfig(
axis: .vertical,
alignment: .leading,
spacing: 0,
layoutMargins: .zero
)
}
private func labelConfig(
conversationStyle: ConversationStyle,
isIncoming: Bool
) -> CVTextLabel.Config {
let font = UIFont.dynamicTypeBodyClamped
let textColor = conversationStyle.bubbleTextColor(isIncoming: isIncoming)
return CVTextLabel.Config(
text: .attributedText(
.composed(of: [
NSAttributedString.with(
image: self.icon,
font: .dynamicTypeSubheadlineClamped,
centerVerticallyRelativeTo: font
),
" ",
self.message,
SignalSymbol.LeadingCharacter.nonBreakingSpace.rawValue,
SignalSymbol.chevronTrailing.attributedString(
dynamicTypeBaseSize: 16,
clamped: true,
weight: .bold,
leadingCharacter: .nonBreakingSpace,
attributes: [.foregroundColor: UIColor.Signal.secondaryLabel]
),
]).styled(with: .font(font), .color(textColor))
),
displayConfig: .forUnstyledText(font: font, textColor: textColor),
font: font,
textColor: textColor,
selectionStyling: [:],
textAlignment: .natural,
lineBreakMode: .byWordWrapping,
items: [],
linkifyStyle: .linkAttribute
)
}
override func handleTap(
sender: UIGestureRecognizer,
componentDelegate: any CVComponentDelegate,
componentView: any CVComponentView,
renderItem: CVRenderItem
) -> Bool {
switch self.attachmentType {
case .audio:
componentDelegate.didTapUndownloadableAudio()
case .sticker:
componentDelegate.didTapUndownloadableSticker()
}
return true
}
public class CVComponentViewUndownloadableAttachment: NSObject, CVComponentView {
fileprivate let stackView = ManualStackView(name: "CVComponentViewAudioAttachment.stackView")
fileprivate var footerOverlayView: CVComponentView?
public var isDedicatedCellView: Bool = false
public var rootView: UIView {
stackView
}
public func setIsCellVisible(_ isCellVisible: Bool) {}
public func reset() {
stackView.reset()
footerOverlayView?.reset()
footerOverlayView = nil
}
}
}

View File

@ -253,6 +253,7 @@ extension ConversationViewController: CVComponentDelegate {
)
)
actionSheet.addAction(.okay)
actionSheet.isCancelable = true
(conversationSplitViewController ?? self).present(actionSheet, animated: true)
}
@ -268,6 +269,39 @@ extension ConversationViewController: CVComponentDelegate {
)
)
actionSheet.addAction(.okay)
actionSheet.isCancelable = true
(conversationSplitViewController ?? self).present(actionSheet, animated: true)
}
public func didTapUndownloadableAudio() {
let actionSheet = ActionSheetController(
title: OWSLocalizedString(
"AUDIO_UNAVAILABLE_SHEET_TITLE",
comment: "Title for sheet shown when tapping a voice message that has expired and is unavailable for download"
),
message: OWSLocalizedString(
"AUDIO_UNAVAILABLE_SHEET_MESSAGE",
comment: "Message for sheet shown when tapping a voice message that has expired and is unavailable for download"
)
)
actionSheet.addAction(.okay)
actionSheet.isCancelable = true
(conversationSplitViewController ?? self).present(actionSheet, animated: true)
}
public func didTapUndownloadableSticker() {
let actionSheet = ActionSheetController(
title: OWSLocalizedString(
"STICKER_UNAVAILABLE_SHEET_TITLE",
comment: "Title for sheet shown when tapping a sticker that has expired and is unavailable for download"
),
message: OWSLocalizedString(
"STICKER_UNAVAILABLE_SHEET_MESSAGE",
comment: "Message for sheet shown when tapping a sticker that has expired and is unavailable for download"
)
)
actionSheet.addAction(.okay)
actionSheet.isCancelable = true
(conversationSplitViewController ?? self).present(actionSheet, animated: true)
}

View File

@ -433,6 +433,7 @@ public class CVLoader: NSObject {
rootComponent = CVComponentSystemMessage(itemModel: itemModel,
systemMessage: defaultDisappearingMessageTimer)
case .textOnlyMessage, .audio, .genericAttachment, .paymentAttachment, .archivedPaymentAttachment,
.undownloadableAttachment,
.contactShare, .bodyMedia, .viewOnce, .stickerMessage, .quoteOnlyMessage,
.giftBadge:
rootComponent = CVComponentMessage(itemModel: itemModel)

View File

@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "audio-square-slash.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View File

@ -0,0 +1,16 @@
{
"images" : [
{
"filename" : "sticker-slash.pdf",
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
},
"properties" : {
"preserves-vector-representation" : true,
"template-rendering-intent" : "template"
}
}

View File

@ -361,6 +361,10 @@ extension EditHistoryTableSheetViewController: CVComponentDelegate {
func didTapUndownloadableOversizeText() {}
func didTapUndownloadableAudio() {}
func didTapUndownloadableSticker() {}
func didTapBrokenVideo() {}
func didTapBodyMedia(

View File

@ -370,6 +370,10 @@ extension MediaGalleryFileCell: CVComponentDelegate {
func didTapUndownloadableOversizeText() {}
func didTapUndownloadableAudio() {}
func didTapUndownloadableSticker() {}
func didTapBrokenVideo() {}
func didTapBodyMedia(

View File

@ -1066,6 +1066,10 @@ extension MessageDetailViewController: CVComponentDelegate {
func didTapUndownloadableOversizeText() {}
func didTapUndownloadableAudio() {}
func didTapUndownloadableSticker() {}
func didTapBrokenVideo() {}
// MARK: - Messages

View File

@ -339,6 +339,10 @@ extension MockConversationView: CVComponentDelegate {
func didTapUndownloadableOversizeText() {}
func didTapUndownloadableAudio() {}
func didTapUndownloadableSticker() {}
func didTapBrokenVideo() {}
// MARK: - Messages

View File

@ -463,6 +463,15 @@
/* action sheet button title to enable built in speaker during a call */
"AUDIO_ROUTE_BUILT_IN_SPEAKER" = "Speaker";
/* Message when trying to show a voice message that has expired and is unavailable for download */
"AUDIO_UNAVAILABLE_MESSAGE_LABEL" = "Voice message not available";
/* Message for sheet shown when tapping a voice message that has expired and is unavailable for download */
"AUDIO_UNAVAILABLE_SHEET_MESSAGE" = "This voice message was not transferred from your phone when this device was linked. Files and media older than 45 days at the time of device linking can not be synced.";
/* Title for sheet shown when tapping a voice message that has expired and is unavailable for download */
"AUDIO_UNAVAILABLE_SHEET_TITLE" = "Voice Message Not Available";
/* Text prompting the user to choose a color when editing their avatar */
"AVATAR_EDIT_VIEW_CHOOSE_A_COLOR" = "Choose a Color";
@ -7690,6 +7699,15 @@
/* Preview text shown in notifications and conversation list for sticker messages. */
"STICKER_MESSAGE_PREVIEW" = "Sticker Message";
/* Message when trying to show a sticker that has expired and is unavailable for download */
"STICKER_UNAVAILABLE_MESSAGE_LABEL" = "Sticker not available";
/* Message for sheet shown when tapping a sticker that has expired and is unavailable for download */
"STICKER_UNAVAILABLE_SHEET_MESSAGE" = "This sticker was not transferred from your phone when this device was linked. Media and files older than 45 days at the time of device linking can not be synced.";
/* Title for sheet shown when tapping a sticker that has expired and is unavailable for download */
"STICKER_UNAVAILABLE_SHEET_TITLE" = "Sticker Not Available";
/* Label for the 'install sticker pack' button. */
"STICKERS_INSTALL_BUTTON" = "Install";

View File

@ -352,6 +352,57 @@ public class CVText {
return measurement
}
public static func measureBodyTextLabelInManualStackView(
config: CVTextLabel.Config,
footerSize: CGSize,
maxWidth: CGFloat,
measurementBuilder: CVCellMeasurement.Builder
) -> [ManualStackSubviewInfo] {
let footerWidthWithSpacing = footerSize.width + 6
let maxTextWidthForAdjacentFooter = maxWidth - footerWidthWithSpacing
let measurementWithSpaceForAdjacentFooter = CVText.measureBodyTextLabel(
config: config,
maxWidth: maxTextWidthForAdjacentFooter
)
let canFitOnOneLineWithAdjacentFooter =
if let lastLineRect = measurementWithSpaceForAdjacentFooter.lastLineRect {
lastLineRect.height == measurementWithSpaceForAdjacentFooter.size.height
} else {
true
}
let info: [ManualStackSubviewInfo]
if canFitOnOneLineWithAdjacentFooter {
let textSize = measurementWithSpaceForAdjacentFooter.size
info = [CGSize(width: textSize.width + footerWidthWithSpacing, height: textSize.height).ceil.asManualSubviewInfo]
} else {
let measurementForFullWidth = CVText.measureBodyTextLabel(
config: config,
maxWidth: maxWidth
)
let textInfo = measurementForFullWidth.size.ceil.asManualSubviewInfo
let footerShouldOverlapWithLastLine = measurementForFullWidth
.lastLineRect.map { lastLineRect in
lastLineRect.width <= maxTextWidthForAdjacentFooter
} ?? false
if footerShouldOverlapWithLastLine {
info = [textInfo]
} else {
let footerSpacerInfo = CGSize(
width: footerSize.width,
height: footerSize.height + 3
).ceil.asManualSubviewInfo
info = [textInfo, footerSpacerInfo]
}
}
return info
}
}
// MARK: -