Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/ConversationView/Components/CVComponentBodyMedia.swift
1 views
//
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit
import SignalUI

class CVComponentBodyMedia: CVComponentBase, CVComponent {

    var componentKey: CVComponentKey { .bodyMedia }

    private let bodyMedia: CVComponentState.BodyMedia
    private var items: [CVMediaAlbumItem] {
        bodyMedia.items
    }

    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
    }

    private let footerOverlay: CVComponent?

    init(itemModel: CVItemModel, bodyMedia: CVComponentState.BodyMedia, footerOverlay: CVComponent?) {
        self.bodyMedia = bodyMedia
        self.footerOverlay = footerOverlay

        super.init(itemModel: itemModel)
    }

    func buildComponentView(componentDelegate: CVComponentDelegate) -> CVComponentView {
        CVComponentViewBodyMedia()
    }

    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 tintColor = Theme.primaryTextColor
        let blurEffect = UIBlurEffect(style: .systemThinMaterial)

        let albumView = componentView.albumView
        albumView.configure(
            mediaCache: mediaCache,
            items: items,
            interaction: interaction,
            isBorderless: 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 {
            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 bodyMedia.mediaAlbumHasSkippedAttachment {
            let iconView = CVImageView()
            iconView.setTemplateImageName(Theme.iconName(.arrowDown), tintColor: tintColor)
            if albumView.itemViews.count > 1 {
                let downloadStackConfig = ManualStackView.Config(
                    axis: .horizontal,
                    alignment: .center,
                    spacing: 6,
                    layoutMargins: UIEdgeInsets(top: 8, leading: 10, bottom: 8, trailing: 14),
                )
                let downloadStack = ManualStackView(name: "downloadStack")
                downloadStack.apply(config: downloadStackConfig)
                var subviewInfos = [ManualStackSubviewInfo]()

                let pillView = ManualLayoutViewWithLayer.pillView(name: "pillView")
                pillView.clipsToBounds = true
                let blurView = UIVisualEffectView(effect: blurEffect)
                pillView.addSubviewToFillSuperviewEdges(blurView)
                downloadStack.addSubviewToFillSuperviewEdges(pillView)

                downloadStack.addArrangedSubview(iconView)
                subviewInfos.append(CGSize.square(24).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: tintColor),
                    font: .dynamicTypeSubheadline,
                    textColor: tintColor,
                )
                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 = ManualLayoutViewWithLayer.circleView(name: "circleView")
                circleView.clipsToBounds = true
                let blurView = UIVisualEffectView(effect: blurEffect)
                circleView.addSubviewToFillSuperviewEdges(blurView)
                stackView.addSubviewToCenterOnSuperview(circleView, size: .square(circleSize))
                stackView.addSubviewToCenterOnSuperview(iconView, size: .square(24))
            }

            if bodyMedia.mediaAlbumHasSkippedAttachment {
                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 downloadState):
                            if item.threadHasPendingMessageRequest {
                                // Doesn't count.
                                return nil
                            }
                            switch downloadState {
                            case .none:
                                return attachment
                            case .enqueuedOrDownloading, .failed:
                                return nil
                            }
                        case .undownloadable:
                            return nil
                        }
                    }
                let totalSize = pendingManualDownloadAttachments.map {
                    $0.attachment.asAnyPointer()?.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.layoutMargins = UIEdgeInsets(hMargin: 8, vMargin: 4)
                    downloadSizeView.clipsToBounds = true

                    let blurView = UIVisualEffectView(effect: blurEffect)
                    downloadSizeView.addSubviewToFillSuperviewEdges(blurView)

                    let downloadSizeLabelConfig = CVLabelConfig(
                        text: .text(downloadSizeText.joined(separator: " • ")),
                        displayConfig: .forUnstyledText(font: .dynamicTypeCaption1, textColor: tintColor),
                        font: .dynamicTypeCaption1,
                        textColor: tintColor,
                    )
                    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 inset: CGFloat = 6
                        let x = (
                            CurrentAppContext().isRTL
                                ? view.width - (downloadSizeViewSize.width - inset)
                                : inset,
                        )
                        downloadSizeView.frame = CGRect(
                            x: x,
                            y: inset,
                            width: downloadSizeViewSize.width,
                            height: downloadSizeViewSize.height,
                        )
                    }
                }
            }
        }
    }

    func bubbleViewPartner(componentView: CVComponentView) -> OWSBubbleViewPartner? {
        guard let componentView = componentView as? CVComponentViewBodyMedia else {
            owsFailDebug("Unexpected componentView.")
            return nil
        }
        return componentView.innerShadowView
    }

    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"

    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

    override func cellWillBecomeVisible(
        componentDelegate: CVComponentDelegate,
    ) {
        AssertIsOnMainThread()

        if
            let message = interaction as? TSMessage,
            bodyMedia.mediaAlbumHasFailedAttachment || bodyMedia.mediaAlbumHasSkippedAttachment
        {
            componentDelegate.willBecomeVisibleWithSkippedDownloads(message)
        }
    }

    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 bodyMedia.mediaAlbumHasSkippedAttachment {
            componentDelegate.didTapSkippedDownloads(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
        }

        if
            albumView.isMoreItemsView(mediaView: mediaView),
            bodyMedia.mediaAlbumHasFailedAttachment
        {
            componentDelegate.didTapSkippedDownloads(message)
            return true
        }

        switch mediaView.attachment {
        case .pointer(let pointer, let downloadState):
            switch downloadState {
            case .failed, .none:
                componentDelegate.didTapSkippedDownloads(message)
                return true
            case .enqueuedOrDownloading:
                componentDelegate.didCancelDownload(message, attachmentId: pointer.attachment.id)
                return true
            }
        case .stream(let stream, isUploading: _):
            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:
            // Download the fullsize attachment
            componentDelegate.didTapSkippedDownloads(message)
            return true
        case .undownloadable:
            componentDelegate.didTapUndownloadableMedia()
            return true
        }
    }

    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?

        override open 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.
    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?

        var isDedicatedCellView = false

        var rootView: UIView {
            stackView
        }

        // MARK: - Subcomponents

        fileprivate var footerOverlayView: CVComponentView? {
            get { stackView.footerOverlayView }
            set { stackView.footerOverlayView = newValue }
        }

        // MARK: -

        func setIsCellVisible(_ isCellVisible: Bool) {
            if isCellVisible {
                albumView.loadMedia()
            } else {
                albumView.unloadMedia()
            }
        }

        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 {
            result.append(footerOverlayView.rootView)
        }
        if let bodyMediaGradientView {
            result.append(bodyMediaGradientView)
        }
        return result
    }
}

// MARK: -

extension CVComponentBodyMedia: CVAccessibilityComponent {
    var accessibilityDescription: String {
        let genericMediaString = OWSLocalizedString(
            "ACCESSIBILITY_LABEL_MEDIA",
            comment: "Accessibility label for media.",
        )

        if bodyMedia.items.count > 1 {
            return String.localizedStringWithFormat(
                OWSLocalizedString(
                    "ACCESSIBILITY_LABEL_MULTIPLE_ATTACHMENTS_%d",
                    tableName: "PluralAware",
                    comment: "Accessibility label for multiple attachment items. Embeds {{ number of attachments }}.",
                ),
                bodyMedia.items.count,
            )
        }

        guard let mediaItem = bodyMedia.items.first else {
            return genericMediaString
        }

        switch mediaItem.attachment {
        case .stream(let referencedAttachmentStream, isUploading: _):
            switch referencedAttachmentStream.attachmentStream.contentType {
            case .invalid:
                return genericMediaString
            case .file:
                return CommonStrings.attachmentTypeFile
            case .image:
                return CommonStrings.attachmentTypePhoto
            case .video:
                if referencedAttachmentStream.reference.renderingFlag == .shouldLoop {
                    return CommonStrings.attachmentTypeAnimated
                }
                return CommonStrings.attachmentTypeVideo
            case .animatedImage:
                return CommonStrings.attachmentTypeAnimated
            case .audio:
                return CommonStrings.attachmentTypeAudio
            }
        case .pointer(let referencedAttachmentPointer, _):
            let mimeType = referencedAttachmentPointer.attachmentPointer.attachment.mimeType
            if MimeTypeUtil.isSupportedDefinitelyAnimatedMimeType(mimeType) {
                return CommonStrings.attachmentTypeAnimated
            }

            if MimeTypeUtil.isSupportedImageMimeType(mimeType) {
                return CommonStrings.attachmentTypePhoto
            }

            if MimeTypeUtil.isSupportedVideoMimeType(mimeType) {
                return CommonStrings.attachmentTypeVideo
            }
            return genericMediaString
        case .backupThumbnail, .undownloadable:
            return genericMediaString
        }
    }
}