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

import SignalServiceKit
import SignalUI

class CVMediaAlbumView: ManualStackViewWithLayer {

    private var items = [CVMediaAlbumItem]()

    var itemViews = [CVMediaView]()

    var moreItemsView: CVMediaView?

    private enum Constants {
        static let itemSpacing: CGFloat = 2
        static let maxItemCount: Int = 5
    }

    // Not all of these sub-stacks maybe used.
    private let subStack1 = ManualStackView(name: "CVMediaAlbumView.subStack1")
    private let subStack2 = ManualStackView(name: "CVMediaAlbumView.subStack2")

    init() {
        super.init(name: "media album view")
    }

    func configure(
        mediaCache: CVMediaCache,
        items: [CVMediaAlbumItem],
        interaction: TSInteraction,
        isBorderless: Bool,
        cellMeasurement: CVCellMeasurement,
        conversationStyle: ConversationStyle,
    ) {

        guard let maxMessageWidth = cellMeasurement.value(key: Self.measurementKey_maxMessageWidth) else {
            owsFailDebug("Missing maxMessageWidth.")
            return
        }
        guard let imageArrangementWrapper: CVMeasurementImageArrangement = cellMeasurement.object(key: Self.measurementKey_imageArrangement) else {
            owsFailDebug("Missing imageArrangement.")
            return
        }

        self.items = items

        backgroundColor = isBorderless ? .clear : .Signal.background

        let imageArrangement = imageArrangementWrapper.imageArrangement
        let viewSizePoints = imageArrangement.worstCaseMediaRenderSizePoints(conversationStyle: conversationStyle)
        itemViews = CVMediaAlbumView.itemsToDisplay(forItems: items).map { item in
            let thumbnailQuality = Self.thumbnailQuality(
                mediaSizePoints: item.mediaSize,
                viewSizePoints: viewSizePoints,
            )
            return CVMediaView(
                mediaCache: mediaCache,
                attachment: item.attachment,
                interaction: interaction,
                maxMessageWidth: maxMessageWidth,
                isBorderless: isBorderless,
                isLoopingVideo: item.renderingFlag == .shouldLoop,
                isBroken: item.isBroken,
                thumbnailQuality: thumbnailQuality,
                conversationStyle: conversationStyle,
            )
        }

        createContents(
            imageArrangement: imageArrangement,
            cellMeasurement: cellMeasurement,
        )
    }

    override func reset() {
        super.reset()

        subStack1.reset()
        subStack2.reset()

        items.removeAll()
        itemViews.removeAll()
        moreItemsView = nil

        removeAllSubviews()
    }

    private func createContents(
        imageArrangement: ImageArrangement,
        cellMeasurement: CVCellMeasurement,
    ) {

        let outerStackView = self

        subStack1.reset()
        subStack2.reset()

        var outerViews = [UIView]()
        let imageGroup1 = imageArrangement.imageGroup1
        let itemViews1 = Array(itemViews.prefix(imageGroup1.imageCount))
        subStack1.configure(
            config: imageArrangement.innerStackConfig,
            cellMeasurement: cellMeasurement,
            measurementKey: Self.measurementKey_substack1,
            subviews: itemViews1,
        )
        outerViews.append(subStack1)

        if let imageGroup2 = imageArrangement.imageGroup2 {
            owsAssertDebug(itemViews.count == imageGroup1.imageCount + imageGroup2.imageCount)

            let itemViews2 = Array(itemViews.suffix(from: imageGroup1.imageCount))

            if items.count > Constants.maxItemCount {
                guard let lastView = itemViews2.last else {
                    owsFailDebug("Missing lastView")
                    return
                }

                moreItemsView = lastView

                let tintView = UIView()
                tintView.backgroundColor = UIColor(white: 0, alpha: 0.4)
                lastView.addSubview(tintView)
                subStack2.layoutSubviewToFillSuperviewEdges(tintView)

                let moreCount = max(1, items.count - Constants.maxItemCount)
                let moreCountText = OWSFormat.formatInt(moreCount)
                let moreText = String.nonPluralLocalizedStringWithFormat(
                    OWSLocalizedString(
                        "MEDIA_GALLERY_MORE_ITEMS_FORMAT",
                        comment: "Format for the 'more items' indicator for media galleries. Embeds {{the number of additional items}}.",
                    ),
                    moreCountText,
                )
                let moreLabel = CVLabel()
                moreLabel.text = moreText
                moreLabel.textColor = UIColor.ows_white
                // We don't want to use dynamic text here.
                moreLabel.font = UIFont.systemFont(ofSize: 24)
                lastView.addSubview(moreLabel)
                subStack2.addLayoutBlock { _ in
                    let labelSize = moreLabel.sizeThatFitsMaxSize
                    let labelOrigin = ((lastView.bounds.size - labelSize) * 0.5).asPoint
                    moreLabel.frame = CGRect(origin: labelOrigin, size: labelSize)
                }
            }

            subStack2.configure(
                config: imageArrangement.innerStackConfig,
                cellMeasurement: cellMeasurement,
                measurementKey: Self.measurementKey_substack2,
                subviews: itemViews2,
            )
            outerViews.append(subStack2)
        } else {
            owsAssertDebug(itemViews.count == imageGroup1.imageCount)
        }

        outerStackView.configure(
            config: imageArrangement.outerStackConfig,
            cellMeasurement: cellMeasurement,
            measurementKey: Self.measurementKey_outerStack,
            subviews: outerViews,
        )

        for itemView in itemViews {
            guard moreItemsView != itemView else {
                // Don't display the caption indicator on
                // the "more" item, if any.
                continue
            }
            guard let index = itemViews.firstIndex(of: itemView) else {
                owsFailDebug("Couldn't determine index of item view.")
                continue
            }
            let item = items[index]
            guard item.hasCaption else {
                continue
            }
            guard let icon = UIImage(named: "media_album_caption") else {
                owsFailDebug("Couldn't load icon.")
                continue
            }
            let iconView = CVImageView(image: icon)
            itemView.addSubview(iconView)
            itemView.addLayoutBlock { view in
                let inset: CGFloat = 6
                let x = (
                    CurrentAppContext().isRTL
                        ? view.width - (icon.size.width + inset)
                        : inset,
                )
                iconView.frame = CGRect(
                    x: x,
                    y: inset,
                    width: icon.size.width,
                    height: icon.size.height,
                )
            }
        }
    }

    func loadMedia() {
        for itemView in itemViews {
            itemView.loadMedia()
        }
    }

    func unloadMedia() {
        for itemView in itemViews {
            itemView.unloadMedia()
        }
    }

    private class func itemsToDisplay(forItems items: [CVMediaAlbumItem]) -> [CVMediaAlbumItem] {
        // We want to display items which are still downloading and invalid items.
        return Array(items.prefix(Constants.maxItemCount))
    }

    private static var hStackConfig: CVStackViewConfig {
        CVStackViewConfig(
            axis: .horizontal,
            alignment: .fill,
            spacing: Constants.itemSpacing,
            layoutMargins: .zero,
        )
    }

    private static var vStackConfig: CVStackViewConfig {
        CVStackViewConfig(
            axis: .vertical,
            alignment: .fill,
            spacing: Constants.itemSpacing,
            layoutMargins: .zero,
        )
    }

    private static let measurementKey_maxMessageWidth: String = "CVMediaAlbumView.maxMessageWidth"
    private static let measurementKey_imageArrangement: String = "CVMediaAlbumView.imageArrangement"
    private static let measurementKey_outerStack = "CVMediaAlbumView.measurementKey_outerStack"
    private static let measurementKey_substack1 = "CVMediaAlbumView.measurementKey_substack1"
    private static let measurementKey_substack2 = "CVMediaAlbumView.measurementKey_substack2"

    class func measure(
        maxWidth: CGFloat,
        minWidth: CGFloat,
        items: [CVMediaAlbumItem],
        measurementBuilder: CVCellMeasurement.Builder,
    ) -> CGSize {

        func measureImageStackLayout(
            imageSize: CGSize,
            imageCount: Int,
            stackConfig: CVStackViewConfig,
            measurementKey: String,
        ) -> CGSize {
            let subviewInfos: [ManualStackSubviewInfo] = (0..<imageCount).map { _ in
                imageSize.asManualSubviewInfo
            }
            let stackMeasurement = ManualStackView.measure(
                config: stackConfig,
                measurementBuilder: measurementBuilder,
                measurementKey: measurementKey,
                subviewInfos: subviewInfos,
            )
            return stackMeasurement.measuredSize
        }

        let imageArrangement = Self.imageArrangement(
            minWidth: minWidth,
            maxWidth: maxWidth,
            items: items,
        )

        measurementBuilder.setObject(
            key: Self.measurementKey_imageArrangement,
            value: CVMeasurementImageArrangement(imageArrangement: imageArrangement),
        )
        measurementBuilder.setValue(key: Self.measurementKey_maxMessageWidth, value: maxWidth)

        var groupInfos = [ManualStackSubviewInfo]()
        let imageGroup1 = imageArrangement.imageGroup1
        groupInfos.append(measureImageStackLayout(
            imageSize: imageGroup1.imageSize,
            imageCount: imageGroup1.imageCount,
            stackConfig: imageArrangement.innerStackConfig,
            measurementKey: Self.measurementKey_substack1,
        ).asManualSubviewInfo)
        if let imageGroup2 = imageArrangement.imageGroup2 {
            groupInfos.append(measureImageStackLayout(
                imageSize: imageGroup2.imageSize,
                imageCount: imageGroup2.imageCount,
                stackConfig: imageArrangement.innerStackConfig,
                measurementKey: Self.measurementKey_substack2,
            ).asManualSubviewInfo)
        }
        let outerStackMeasurement = ManualStackView.measure(
            config: imageArrangement.outerStackConfig,
            measurementBuilder: measurementBuilder,
            measurementKey: Self.measurementKey_outerStack,
            subviewInfos: groupInfos,
            maxWidth: maxWidth,
        )
        return outerStackMeasurement.measuredSize
    }

    fileprivate struct ImageGroup: Equatable {
        let imageCount: Int
        let imageSize: CGSize

        var imageSizes: [CGSize] {
            [CGSize](repeating: imageSize, count: imageCount)
        }
    }

    fileprivate enum ImageArrangement: Equatable {
        case single(row: ImageGroup)
        case oneHorizontalRow(row: ImageGroup)
        case twoHorizontalRows(row1: ImageGroup, row2: ImageGroup)
        case twoVerticalColumns(column1: ImageGroup, column2: ImageGroup)

        var outerStackConfig: CVStackViewConfig {
            switch self {
            case .single,
                 .oneHorizontalRow,
                 .twoHorizontalRows:
                return CVMediaAlbumView.vStackConfig
            case .twoVerticalColumns:
                return CVMediaAlbumView.hStackConfig
            }
        }

        var innerStackConfig: CVStackViewConfig {
            switch self {
            case .single,
                 .oneHorizontalRow,
                 .twoHorizontalRows:
                return CVMediaAlbumView.hStackConfig
            case .twoVerticalColumns:
                return CVMediaAlbumView.vStackConfig
            }
        }

        var imageGroup1: ImageGroup {
            switch self {
            case .single(let row):
                return row
            case .oneHorizontalRow(let row):
                return row
            case .twoHorizontalRows(let row1, _):
                return row1
            case .twoVerticalColumns(let column1, _):
                return column1
            }
        }

        var imageGroup2: ImageGroup? {
            switch self {
            case .single:
                return nil
            case .oneHorizontalRow:
                return nil
            case .twoHorizontalRows(_, let row2):
                return row2
            case .twoVerticalColumns(_, let column2):
                return column2
            }
        }

        func worstCaseMediaRenderSizePoints(conversationStyle: ConversationStyle) -> CGSize {
            let maxMediaMessageWidth = conversationStyle.maxMediaMessageWidth

            func worstCaseMediaRenderSize(
                horizontalRow row: ImageGroup,
                rowSize: CGSize,
            ) -> CGSize {
                return CGSize(
                    width: rowSize.width / CGFloat(row.imageCount),
                    height: rowSize.height,
                )
            }

            switch self {
            case .single:
                return .square(maxMediaMessageWidth)
            default:
                let imageSizes = self.imageSizes
                return CGSize(
                    width: imageSizes.map { $0.width }.reduce(0, max),
                    height: imageSizes.map { $0.height }.reduce(0, max),
                )
            }
        }

        var imageSizes: [CGSize] {
            switch self {
            case .single(let row):
                return row.imageSizes
            case .oneHorizontalRow(let row):
                return row.imageSizes
            case .twoHorizontalRows(let row1, let row2):
                return row1.imageSizes + row2.imageSizes
            case .twoVerticalColumns(let column1, let column2):
                return column1.imageSizes + column2.imageSizes
            }
        }
    }

    fileprivate class CVMeasurementImageArrangement: CVMeasurementObject {
        fileprivate let imageArrangement: ImageArrangement

        fileprivate init(imageArrangement: ImageArrangement) {
            self.imageArrangement = imageArrangement

            super.init()
        }

        // MARK: - Equatable

        static func ==(lhs: CVMeasurementImageArrangement, rhs: CVMeasurementImageArrangement) -> Bool {
            lhs.imageArrangement == rhs.imageArrangement
        }
    }

    private class func imageArrangement(
        minWidth: CGFloat,
        maxWidth: CGFloat,
        items: [CVMediaAlbumItem],
    ) -> ImageArrangement {

        let itemCount = itemsToDisplay(forItems: items).count
        switch itemCount {
        case 0:
            // X
            // Reflects content size.
            owsFailDebug("Missing items.")

            let imageSize = CGSize(square: maxWidth)
            let row = ImageGroup(imageCount: 1, imageSize: imageSize)
            return .single(row: row)
        case 1:
            // X
            // Reflects content size.

            // TODO: I'm not sure this is yielding the ideal results,
            // e.g. for extremely wide or tall images.
            let buildSingleMediaSize = { () -> CGSize? in
                guard items.count == 1 else {
                    // More than one piece of media.
                    return nil
                }
                guard let mediaAlbumItem = items.first else {
                    owsFailDebug("Missing mediaAlbumItem.")
                    return nil
                }

                let mediaSize = mediaAlbumItem.mediaSize
                guard mediaSize.width > 0, mediaSize.height > 0 else {
                    // This could be a pending or invalid attachment.
                    return nil
                }
                // Honor the content aspect ratio for single media.
                var contentAspectRatio = mediaSize.width / mediaSize.height
                // Clamp the aspect ratio so that very thin/wide content is presented
                // in a reasonable way.
                let minAspectRatio: CGFloat = 0.35
                let maxAspectRatio: CGFloat = 1 / minAspectRatio
                owsAssertDebug(minAspectRatio <= maxAspectRatio)
                contentAspectRatio = contentAspectRatio.clamp(minAspectRatio, maxAspectRatio)

                let maxMediaWidth: CGFloat = maxWidth
                let maxMediaHeight: CGFloat = maxWidth
                var mediaWidth: CGFloat = maxMediaHeight * contentAspectRatio

                // We may need to reserve space for a footer overlay.
                mediaWidth = max(mediaWidth, minWidth)

                var mediaHeight: CGFloat = maxMediaHeight
                if mediaWidth > maxMediaWidth {
                    mediaWidth = maxMediaWidth
                    mediaHeight = maxMediaWidth / contentAspectRatio
                }

                // We don't want to blow up small images unnecessarily.
                let minimumSize: CGFloat = max(150, minWidth)
                let shortSrcDimension: CGFloat = min(mediaSize.width, mediaSize.height)
                let shortDstDimension: CGFloat = min(mediaWidth, mediaHeight)
                if shortDstDimension > minimumSize, shortDstDimension > shortSrcDimension {
                    let factor: CGFloat = minimumSize / shortDstDimension
                    mediaWidth *= factor
                    mediaHeight *= factor
                }

                return CGSize(width: mediaWidth, height: mediaHeight).round
            }

            let imageSize = buildSingleMediaSize() ?? CGSize(square: maxWidth)
            let row = ImageGroup(imageCount: 1, imageSize: imageSize)
            return .single(row: row)
        case 2:
            // X X
            // side-by-side.
            let imageSize = CGSize(square: floor((maxWidth - Constants.itemSpacing) / 2))
            return .oneHorizontalRow(row: ImageGroup(imageCount: 2, imageSize: imageSize))
        case 3:
            //   x
            // X x
            // Big on left, 2 small on right.
            let smallImageSize: CGFloat = floor((maxWidth - Constants.itemSpacing * 2) / 3)
            let bigImageSize: CGFloat = smallImageSize * 2 + Constants.itemSpacing
            return .twoVerticalColumns(
                column1: ImageGroup(imageCount: 1, imageSize: .square(bigImageSize)),
                column2: ImageGroup(imageCount: 2, imageSize: .square(smallImageSize)),
            )
        case 4:
            // XX
            // XX
            // Square
            let imageSize = CGSize(square: floor((maxWidth - CVMediaAlbumView.Constants.itemSpacing) / 2))
            return .twoHorizontalRows(
                row1: ImageGroup(imageCount: 2, imageSize: imageSize),
                row2: ImageGroup(imageCount: 2, imageSize: imageSize),
            )
        default:
            // X X
            // xxx
            // 2 big on top, 3 small on bottom.
            let bigImageSize: CGFloat = floor((maxWidth - Constants.itemSpacing) / 2)
            let smallImageSize: CGFloat = floor((maxWidth - Constants.itemSpacing * 2) / 3)
            return .twoHorizontalRows(
                row1: ImageGroup(imageCount: 2, imageSize: .square(bigImageSize)),
                row2: ImageGroup(imageCount: 3, imageSize: .square(smallImageSize)),
            )
        }
    }

    func mediaView(forLocation location: CGPoint) -> CVMediaView? {
        var bestMediaView: CVMediaView?
        var bestDistance: CGFloat = 0
        for itemView in itemViews {
            let itemCenter = convert(itemView.center, from: itemView.superview)
            let distance = location.distance(itemCenter)
            if bestMediaView != nil, distance > bestDistance {
                continue
            }
            bestMediaView = itemView
            bestDistance = distance
        }
        return bestMediaView
    }

    func isMoreItemsView(mediaView: CVMediaView) -> Bool {
        return moreItemsView == mediaView
    }

    private static func thumbnailQuality(
        mediaSizePoints: CGSize,
        viewSizePoints: CGSize,
    ) -> AttachmentThumbnailQuality {
        guard
            mediaSizePoints.isNonEmpty,
            viewSizePoints.isNonEmpty
        else {
            owsFailDebug("Invalid sizes. mediaSizePoints: \(mediaSizePoints), viewSizePoints: \(viewSizePoints).")
            return .medium
        }
        // Determine render size for .scaleAspectFill.
        let renderSizeByWidth = CGSize(
            width: viewSizePoints.width,
            height: viewSizePoints.width * mediaSizePoints.height / mediaSizePoints.width,
        )
        let renderSizeByHeight = CGSize(
            width: viewSizePoints.height * mediaSizePoints.width / mediaSizePoints.height,
            height: viewSizePoints.height,
        )
        let renderSizePoints = (
            renderSizeByWidth.width > renderSizeByHeight.width
                ? renderSizeByWidth
                : renderSizeByHeight,
        )
        let renderDimensionPoints = renderSizePoints.largerAxis
        let quality: AttachmentThumbnailQuality = {
            // Find the smallest quality of acceptable size.
            let qualities: [AttachmentThumbnailQuality] = [
                .small,
                .medium,
                .mediumLarge,
                // Skip .large
            ]
            for quality in qualities {
                // The image will .scaleAspectFill the bounds of the media view.
                // We want to ensure that we more-or-less have sufficient pixel
                // data for the screen. There are only a few thumbnail sizes,
                // so falling over to the next largest size is expensive. Therefore
                // we include a small measure of slack in our calculation.
                //
                // targetQuality is expressed in terms of "the worst case ratio of
                // image pixels per screen pixels that we will accept."
                let targetQuality: CGFloat = 0.8
                let sizeTolerance: CGFloat = 1 / targetQuality
                let thumbnailDimensionPoints = quality.thumbnailDimensionPoints()
                if renderDimensionPoints <= CGFloat(thumbnailDimensionPoints) * sizeTolerance {
                    return quality
                }
            }
            return .large
        }()
        return quality
    }
}

// MARK: -

struct CVMediaAlbumItem: Equatable {

    let attachment: CVAttachment

    /// This property will only be set if the attachment is downloaded and valid.
    let attachmentStream: AttachmentStream?

    var renderingFlag: AttachmentReference.RenderingFlag {
        attachment.attachment.reference.renderingFlag
    }

    let hasCaption: Bool

    /// This property will be non-zero if the attachment is valid.
    let mediaSize: CGSize

    let isBroken: Bool

    /// Whether the containing thread has a pending message request
    let threadHasPendingMessageRequest: Bool

    static func ==(lhs: CVMediaAlbumItem, rhs: CVMediaAlbumItem) -> Bool {
        return lhs.attachment == rhs.attachment
            && lhs.hasCaption == rhs.hasCaption
            && lhs.mediaSize == rhs.mediaSize
            && lhs.isBroken == rhs.isBroken
            && lhs.threadHasPendingMessageRequest == rhs.threadHasPendingMessageRequest
    }
}