// // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only // import SignalServiceKit 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?.computeContentType() { 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) -> ReferencedTSResource? in switch item.attachment { case .stream: return nil case .pointer(let attachment, let transitTierDownloadState): if item.threadHasPendingMessageRequest { // Doesn't count. return nil } switch transitTierDownloadState { case .none: return attachment case .enqueued, .downloading, .failed: return nil } } } let totalSize = pendingManualDownloadAttachments.map { $0.attachment.unencryptedResourceByteCount ?? 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 .enqueued, .downloading: Logger.warn("Media attachment not yet downloaded.") self.databaseStorage.write { tx in DependenciesBridge.shared.tsResourceDownloadManager.cancelDownload( for: pointer.attachment.resourceId, tx: tx.asV2Write ) } return true } case .stream(let stream): let itemViewModel = CVItemViewModelImpl(renderItem: renderItem) if let item = items.first(where: { $0.attachment.attachment.attachment.resourceId == stream.attachment.resourceId }), item.isBroken { componentDelegate.didTapBrokenVideo() return true } componentDelegate.didTapBodyMedia( itemViewModel: itemViewModel, attachmentStream: stream, imageView: mediaView ) return true } } public func albumItemView( forAttachment attachment: ReferencedTSResource, 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.resourceId == attachment.attachment.resourceId && $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.") } }