TM-SGNL-iOS/Signal/ConversationView/Components/CVComponentBodyMedia.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

528 lines
23 KiB
Swift

//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
public import SignalServiceKit
public import SignalUI
public class CVComponentBodyMedia: CVComponentBase, CVComponent {
public var componentKey: CVComponentKey { .bodyMedia }
private let bodyMedia: CVComponentState.BodyMedia
private var items: [CVMediaAlbumItem] {
bodyMedia.items
}
private var mediaAlbumHasFailedAttachment: Bool {
bodyMedia.mediaAlbumHasFailedAttachment
}
private var mediaAlbumHasPendingAttachment: Bool {
bodyMedia.mediaAlbumHasPendingAttachment
}
private var areAllItemsImages: Bool {
for item in items {
// This potentially reads the image data on disk.
// We will eventually have better guarantees about this
// state being cached and not requiring a disk read.
switch item.attachmentStream?.contentType {
case .image, .animatedImage:
continue
case .none:
if !MimeTypeUtil.isSupportedImageMimeType(item.attachment.attachment.attachment.mimeType) {
return false
}
case .video, .audio, .file, .invalid:
return false
}
}
return true
}
var hasDownloadButton: Bool {
mediaAlbumHasPendingAttachment
}
private let footerOverlay: CVComponent?
init(itemModel: CVItemModel, bodyMedia: CVComponentState.BodyMedia, footerOverlay: CVComponent?) {
self.bodyMedia = bodyMedia
self.footerOverlay = footerOverlay
super.init(itemModel: itemModel)
}
public func buildComponentView(componentDelegate: CVComponentDelegate) -> CVComponentView {
CVComponentViewBodyMedia()
}
private var bodyTextColor: UIColor {
guard let message = interaction as? TSMessage else {
return .black
}
return conversationStyle.bubbleTextColor(message: message)
}
public func configureForRendering(componentView componentViewParam: CVComponentView,
cellMeasurement: CVCellMeasurement,
componentDelegate: CVComponentDelegate) {
guard let componentView = componentViewParam as? CVComponentViewBodyMedia else {
owsFailDebug("Unexpected componentView.")
componentViewParam.reset()
return
}
let conversationStyle = self.conversationStyle
let albumView = componentView.albumView
albumView.configure(mediaCache: self.mediaCache,
items: self.items,
interaction: self.interaction,
isBorderless: self.isBorderless,
cellMeasurement: cellMeasurement,
conversationStyle: conversationStyle)
let stackView = componentView.stackView
stackView.reset()
stackView.configure(config: stackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_stackView,
subviews: [ albumView ])
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
stackView.addSubview(footerRootView)
let footerSize = cellMeasurement.size(key: Self.measurementKey_footerSize) ?? .zero
stackView.addLayoutBlock { view in
var footerFrame = view.bounds
// Apply h-insets.
footerFrame.x += conversationStyle.textInsetHorizontal
footerFrame.width -= conversationStyle.textInsetHorizontal * 2
// Ensure footer height fits within text insets.
let maxFooterHeight = (view.bounds.height -
(conversationStyle.textInsetTop + conversationStyle.textInsetBottom))
footerFrame.height = min(maxFooterHeight, footerSize.height)
// Bottom align.
footerFrame.y = (view.bounds.height -
(footerFrame.height +
conversationStyle.textInsetBottom))
footerRootView.frame = footerFrame
}
let maxGradientHeight: CGFloat = 40
let gradientLayer = CAGradientLayer()
gradientLayer.colors = [
UIColor(white: 0, alpha: 0.0).cgColor,
UIColor(white: 0, alpha: 0.4).cgColor
]
let gradientView = OWSLayerView(frame: .zero) { layerView in
var layerFrame = layerView.bounds
layerFrame.height = min(maxGradientHeight, layerView.height)
layerFrame.y = layerView.height - layerFrame.height
gradientLayer.frame = layerFrame
}
componentView.bodyMediaGradientView = gradientView
gradientView.layer.addSublayer(gradientLayer)
albumView.addSubview(gradientView)
stackView.layoutSubviewToFillSuperviewEdges(gradientView)
}
// Only apply "inner shadow" for single media, not albums.
if !isBorderless,
albumView.itemViews.count == 1,
let firstMediaView = albumView.itemViews.first {
let shadowColor: UIColor = isDarkThemeEnabled ? .white : .black
let innerShadowView = OWSBubbleShapeView(mode: .innerShadow(color: shadowColor,
radius: 0.5,
opacity: 0.15))
componentView.innerShadowView = innerShadowView
firstMediaView.addSubview(innerShadowView)
stackView.layoutSubviewToFillSuperviewEdges(innerShadowView)
}
if hasDownloadButton {
let iconView = CVImageView()
iconView.setTemplateImageName(Theme.iconName(.arrowDown), tintColor: UIColor.ows_white)
if albumView.itemViews.count > 1 {
let downloadStackConfig = ManualStackView.Config(axis: .horizontal,
alignment: .center,
spacing: 8,
layoutMargins: UIEdgeInsets(hMargin: 16, vMargin: 10))
let downloadStack = ManualStackView(name: "downloadStack")
downloadStack.apply(config: downloadStackConfig)
var subviewInfos = [ManualStackSubviewInfo]()
let pillView = ManualLayoutViewWithLayer.pillView(name: "pillView")
pillView.backgroundColor = UIColor.ows_black.withAlphaComponent(0.8)
downloadStack.addSubviewToFillSuperviewEdges(pillView)
downloadStack.addArrangedSubview(iconView)
subviewInfos.append(CGSize.square(20).asManualSubviewInfo(hasFixedSize: true))
let downloadLabel = CVLabel()
let downloadFormat = (areAllItemsImages
? OWSLocalizedString("MEDIA_GALLERY_ITEM_IMAGE_COUNT_%d", tableName: "PluralAware",
comment: "Format for an indicator of the number of image items in a media gallery. Embeds {{ the number of items in the media gallery }}.")
: OWSLocalizedString("MEDIA_GALLERY_ITEM_MIXED_COUNT_%d", tableName: "PluralAware",
comment: "Format for an indicator of the number of image or video items in a media gallery. Embeds {{ the number of items in the media gallery }}."))
downloadStack.addArrangedSubview(downloadLabel)
let downloadLabelConfig = CVLabelConfig(
text: .text(String.localizedStringWithFormat(downloadFormat, items.count)),
displayConfig: .forUnstyledText(font: .dynamicTypeSubheadline, textColor: .ows_white),
font: .dynamicTypeSubheadline,
textColor: UIColor.ows_white
)
downloadLabelConfig.applyForRendering(label: downloadLabel)
let downloadLabelSize = CVText.measureLabel(config: downloadLabelConfig,
maxWidth: CGFloat.greatestFiniteMagnitude)
subviewInfos.append(downloadLabelSize.asManualSubviewInfo)
let downloadStackMeasurement = ManualStackView.measure(config: downloadStackConfig,
subviewInfos: subviewInfos)
downloadStack.measurement = downloadStackMeasurement
stackView.addSubviewToCenterOnSuperview(downloadStack,
size: downloadStackMeasurement.measuredSize)
} else {
let circleSize: CGFloat = 44
let circleView = OWSLayerView.circleView(size: circleSize)
circleView.backgroundColor = UIColor.ows_black.withAlphaComponent(0.8)
stackView.addSubviewToCenterOnSuperview(circleView, size: .square(circleSize))
stackView.addSubviewToCenterOnSuperview(iconView, size: .square(24))
}
if mediaAlbumHasPendingAttachment {
let pendingManualDownloadAttachments = items
.lazy
.compactMap { (item: CVMediaAlbumItem) -> ReferencedAttachment? in
switch item.attachment {
case .stream:
return nil
case .backupThumbnail:
// TODO[Backups]: Check for media tier download state
return nil
case .pointer(let attachment, let transitTierDownloadState):
if item.threadHasPendingMessageRequest {
// Doesn't count.
return nil
}
switch transitTierDownloadState {
case .none:
return attachment
case .enqueuedOrDownloading, .failed:
return nil
}
}
}
let totalSize = pendingManualDownloadAttachments.map {
$0.attachment.asTransitTierPointer()?.info.unencryptedByteCount ?? 0
}.reduce(0, +)
if totalSize > 0 {
var downloadSizeText = [OWSFormat.localizedFileSizeString(from: Int64(totalSize))]
if pendingManualDownloadAttachments.count == 1,
let firstAttachmentPointer = pendingManualDownloadAttachments.first {
let mimeType = firstAttachmentPointer.attachment.mimeType
if MimeTypeUtil.isSupportedDefinitelyAnimatedMimeType(mimeType)
|| firstAttachmentPointer.reference.renderingFlag == .shouldLoop
{
// Do nothing.
} else if MimeTypeUtil.isSupportedImageMimeType(mimeType) {
downloadSizeText.append(CommonStrings.attachmentTypePhoto)
} else if MimeTypeUtil.isSupportedVideoMimeType(mimeType) {
downloadSizeText.append(CommonStrings.attachmentTypeVideo)
}
}
let downloadSizeView = ManualLayoutViewWithLayer.pillView(name: "downloadSizeView")
downloadSizeView.backgroundColor = UIColor.ows_black.withAlphaComponent(0.8)
downloadSizeView.layoutMargins = UIEdgeInsets(hMargin: 8, vMargin: 1)
let downloadSizeLabelConfig = CVLabelConfig(
text: .text(downloadSizeText.joined(separator: "")),
displayConfig: .forUnstyledText(font: .dynamicTypeCaption1, textColor: .ows_white),
font: .dynamicTypeCaption1,
textColor: .ows_white
)
let downloadSizeLabel = CVLabel()
downloadSizeLabelConfig.applyForRendering(label: downloadSizeLabel)
let downloadSizeLabelSize = CVText.measureLabel(config: downloadSizeLabelConfig,
maxWidth: .greatestFiniteMagnitude)
downloadSizeView.addSubviewToFillSuperviewMargins(downloadSizeLabel)
let downloadSizeViewSize = downloadSizeLabelSize + downloadSizeView.layoutMargins.asSize
stackView.addSubview(downloadSizeView)
stackView.addLayoutBlock { view in
let hInset: CGFloat = 16
let x = (CurrentAppContext().isRTL
? view.width - (downloadSizeViewSize.width - hInset)
: hInset)
downloadSizeView.frame = CGRect(x: x,
y: 9,
width: downloadSizeViewSize.width,
height: downloadSizeViewSize.height)
}
}
}
}
}
public func bubbleViewPartner(componentView: CVComponentView) -> OWSBubbleViewPartner? {
guard let componentView = componentView as? CVComponentViewBodyMedia else {
owsFailDebug("Unexpected componentView.")
return nil
}
return componentView.innerShadowView
}
private static var senderNameFont: UIFont { UIFont.dynamicTypeCaption1.semibold() }
private var stackConfig: CVStackViewConfig {
CVStackViewConfig(axis: .vertical,
alignment: .fill,
spacing: 0,
layoutMargins: .zero)
}
private var maxMediaMessageWidth: CGFloat {
let maxMediaMessageWidth = conversationStyle.maxMediaMessageWidth
if self.isBorderless {
return min(175, maxMediaMessageWidth)
}
return maxMediaMessageWidth
}
private static let measurementKey_stackView = "CVComponentBodyMedia.measurementKey_stackView"
private static let measurementKey_footerSize = "CVComponentBodyMedia.measurementKey_footerSize"
public func measure(maxWidth: CGFloat, measurementBuilder: CVCellMeasurement.Builder) -> CGSize {
owsAssertDebug(maxWidth > 0)
owsAssertDebug(items.count > 0)
// We may need to reserve space for a footer overlay.
var minWidth: CGFloat = 0
if let footerOverlay = self.footerOverlay {
let maxFooterWidth = max(0, maxWidth - conversationStyle.textInsets.totalWidth)
let footerSize = footerOverlay.measure(maxWidth: maxFooterWidth,
measurementBuilder: measurementBuilder)
minWidth = min(maxWidth, footerSize.width + conversationStyle.textInsets.totalWidth)
measurementBuilder.setSize(key: Self.measurementKey_footerSize, size: footerSize)
}
let maxWidth = min(maxWidth, maxMediaMessageWidth)
let albumSize = CVMediaAlbumView.measure(maxWidth: maxWidth,
minWidth: minWidth,
items: self.items,
measurementBuilder: measurementBuilder)
let albumInfo = albumSize.asManualSubviewInfo
let stackMeasurement = ManualStackView.measure(config: stackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_stackView,
subviewInfos: [ albumInfo ],
maxWidth: maxWidth)
return stackMeasurement.measuredSize
}
// MARK: - Events
public override func handleTap(sender: UIGestureRecognizer,
componentDelegate: CVComponentDelegate,
componentView: CVComponentView,
renderItem: CVRenderItem) -> Bool {
AssertIsOnMainThread()
guard let componentView = componentView as? CVComponentViewBodyMedia else {
owsFailDebug("Unexpected componentView.")
return false
}
guard let message = interaction as? TSMessage else {
owsFailDebug("Invalid interaction.")
return false
}
if hasDownloadButton {
componentDelegate.didTapFailedOrPendingDownloads(message)
return true
}
let albumView = componentView.albumView
let location = sender.location(in: albumView)
guard let mediaView = albumView.mediaView(forLocation: location) else {
Logger.warn("Missing mediaView.")
return false
}
let isMoreItemsWithMediaView = albumView.isMoreItemsView(mediaView: mediaView)
if isMoreItemsWithMediaView,
mediaAlbumHasFailedAttachment {
componentDelegate.didTapFailedOrPendingDownloads(message)
return true
}
switch mediaView.attachment {
case .pointer(let pointer, let transitTierDownloadState):
switch transitTierDownloadState {
case .failed, .none:
componentDelegate.didTapFailedOrPendingDownloads(message)
return true
case .enqueuedOrDownloading:
Logger.warn("Media attachment not yet downloaded.")
SSKEnvironment.shared.databaseStorageRef.write { tx in
DependenciesBridge.shared.attachmentDownloadManager.cancelDownload(
for: pointer.attachment.id,
tx: tx.asV2Write
)
}
return true
}
case .stream(let stream):
let itemViewModel = CVItemViewModelImpl(renderItem: renderItem)
if let item = items.first(where: { $0.attachment.attachment.attachment.id == stream.attachment.id }), item.isBroken {
componentDelegate.didTapBrokenVideo()
return true
}
componentDelegate.didTapBodyMedia(
itemViewModel: itemViewModel,
attachmentStream: stream,
imageView: mediaView
)
return true
case .backupThumbnail:
// TODO[Backups]: If the media tier download hasn't been started, enqueue it
return true
}
}
public func albumItemView(
forAttachment attachment: ReferencedAttachment,
componentView: CVComponentView
) -> UIView? {
guard let componentView = componentView as? CVComponentViewBodyMedia else {
owsFailDebug("Unexpected componentView.")
return nil
}
let albumView = componentView.albumView
guard let albumItemView = (albumView.itemViews.first {
$0.attachment.attachment.attachment.id == attachment.attachment.id
&& $0.attachment.attachment.reference.hasSameOwner(as: attachment.reference)
}) else {
assert(albumView.moreItemsView != nil)
return albumView.moreItemsView
}
return albumItemView
}
// MARK: -
// We use this view to implement BodyMediaPresentationContext below.
class CVComponentViewBodyMediaRootView: ManualStackView {
fileprivate var bodyMediaGradientView: UIView?
fileprivate var footerOverlayView: CVComponentView?
open override func reset() {
bodyMediaGradientView = nil
footerOverlayView = nil
super.reset()
}
}
// MARK: -
// Used for rendering some portion of an Conversation View item.
// It could be the entire item or some part thereof.
public class CVComponentViewBodyMedia: NSObject, CVComponentView {
fileprivate let stackView = CVComponentViewBodyMediaRootView(name: "stackView")
fileprivate let albumView = CVMediaAlbumView()
fileprivate var bodyMediaGradientView: UIView? {
get { stackView.bodyMediaGradientView }
set { stackView.bodyMediaGradientView = newValue }
}
fileprivate var innerShadowView: OWSBubbleShapeView?
public var isDedicatedCellView = false
public var rootView: UIView {
stackView
}
// MARK: - Subcomponents
fileprivate var footerOverlayView: CVComponentView? {
get { stackView.footerOverlayView }
set { stackView.footerOverlayView = newValue }
}
// MARK: -
public func setIsCellVisible(_ isCellVisible: Bool) {
if isCellVisible {
albumView.loadMedia()
} else {
albumView.unloadMedia()
}
}
public func reset() {
albumView.reset()
stackView.reset()
footerOverlayView?.reset()
bodyMediaGradientView?.removeFromSuperview()
bodyMediaGradientView = nil
innerShadowView?.removeFromSuperview()
innerShadowView = nil
}
}
}
// MARK: -
protocol BodyMediaPresentationContext {
var mediaOverlayViews: [UIView] { get }
}
// MARK: -
extension CVComponentBodyMedia.CVComponentViewBodyMediaRootView: BodyMediaPresentationContext {
var mediaOverlayViews: [UIView] {
var result = [UIView]()
if let footerOverlayView = footerOverlayView {
result.append(footerOverlayView.rootView)
}
if let bodyMediaGradientView = bodyMediaGradientView {
result.append(bodyMediaGradientView)
}
return result
}
}
// MARK: -
extension CVComponentBodyMedia: CVAccessibilityComponent {
public var accessibilityDescription: String {
// TODO: We could describe how many media
// and their type (video, image, animated image).
OWSLocalizedString("ACCESSIBILITY_LABEL_MEDIA",
comment: "Accessibility label for media.")
}
}