Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
signalapp
GitHub Repository: signalapp/Signal-iOS
Path: blob/main/Signal/src/ViewControllers/MediaGallery/Cells/WidePhotoCell.swift
1 views
//
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//

import SignalServiceKit
import SignalUI
import UIKit

/// This is the collection view cell for "list mode" in All Media.
class WidePhotoCell: MediaTileListModeCell {

    static let reuseIdentifier = "WidePhotoCell"

    private let thumbnailView: ThumbnailView = {
        let thumbnailView = ThumbnailView()
        thumbnailView.autoSetDimensions(to: .square(48))
        return thumbnailView
    }()

    private let titleLabel: UILabel = {
        let label = UILabel()
        label.font = .dynamicTypeSubheadlineClamped
        label.adjustsFontForContentSizeCategory = true
        label.setCompressionResistanceVerticalHigh()
        label.textColor = UIColor(dynamicProvider: { _ in Theme.primaryTextColor })
        return label
    }()

    private let subtitleLabel: UILabel = {
        let label = UILabel()
        label.font = .dynamicTypeFootnoteClamped
        label.adjustsFontForContentSizeCategory = true
        label.setCompressionResistanceVerticalHigh()
        label.textColor = UIColor(dynamicProvider: { _ in Theme.secondaryTextAndIconColor })
        return label
    }()

    override init(frame: CGRect) {
        super.init(frame: frame)

        contentView.backgroundColor = UIColor(dynamicProvider: { _ in Theme.tableCell2PresentedBackgroundColor })

        setupViews()
    }

    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    private func setupViews() {
        let vStack = UIStackView(arrangedSubviews: [titleLabel, subtitleLabel])
        vStack.alignment = .leading
        vStack.axis = .vertical
        vStack.spacing = 2

        let hStack = UIStackView(arrangedSubviews: [thumbnailView, vStack])
        hStack.alignment = .center
        hStack.axis = .horizontal
        hStack.spacing = 12

        contentView.addSubview(hStack)
        hStack.autoPinHeightToSuperview(withMargin: 8)
        hStack.autoPinTrailingToSuperviewMargin()
        let constraintWithSelectionButton = hStack.leadingAnchor.constraint(equalTo: selectionButton.trailingAnchor, constant: 11)
        let constraintWithoutSelectionButton = hStack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16)

        separator.autoPinEdge(.leading, to: .leading, of: vStack)

        super.setupViews(
            constraintWithSelectionButton: constraintWithSelectionButton,
            constraintWithoutSelectionButton: constraintWithoutSelectionButton,
        )
    }

    override func prepareForReuse() {
        super.prepareForReuse()

        thumbnailView.image = nil
    }

    private func setUpAccessibility(item: PhotoGridItem?) {
        isAccessibilityElement = true

        if let item {
            accessibilityLabel = [
                item.type.localizedString,
                MediaTileDateFormatter.formattedDateString(for: item.mediaMetadata?.creationDate),
            ]
            .compactMap { $0 }
            .joined(separator: ", ")
        } else {
            accessibilityLabel = ""
        }
    }

    override func makePlaceholder() {
        thumbnailView.image = nil
        setUpAccessibility(item: nil)
    }

    override func configure(item: MediaGalleryCellItem, spoilerState: SpoilerRenderState) {
        switch item {
        case .photoVideo(let photoGridItem):
            super.configure(item: item, spoilerState: spoilerState)
            configure(photoGridItem)
        default:
            owsFail("Unexpected item type \(item)")
        }
    }

    private var photoGridItem: PhotoGridItem? {
        guard case .photoVideo(let photoGridItem) = item else { return nil }
        return photoGridItem
    }

    private func configure(_ item: PhotoGridItem) {
        // PHCachingImageManager returns multiple progressively better
        // thumbnails in the async block. We want to avoid calling
        // `configure(item:)` multiple times because the high-quality image eventually applied
        // last time it was called will be momentarily replaced by a progression of lower
        // quality images.
        thumbnailView.image = nil
        item.asyncThumbnail { [weak self] image in
            guard let self else { return }

            guard let currentItem = self.photoGridItem, currentItem === item else { return }

            if image == nil {
                Logger.debug("image == nil")
            }
            self.thumbnailView.image = image
        }

        if let metadata = item.mediaMetadata {
            titleLabel.text = metadata.sender

            if let date = metadata.formattedDate {
                subtitleLabel.text = "\(metadata.formattedSize) · \(item.type.formattedType) · \(date)"
            } else {
                subtitleLabel.text = "\(metadata.formattedSize) · \(item.type.formattedType)"
            }
        } else {
            titleLabel.text = ""
            subtitleLabel.text = ""
        }
        setUpAccessibility(item: item)
    }

    class func cellHeight() -> CGFloat {
        let measurementCell = WidePhotoCell(frame: CGRect(origin: .zero, size: .square(100)))
        measurementCell.titleLabel.text = "M"
        measurementCell.subtitleLabel.text = "M"
        let cellSize = measurementCell.contentView.systemLayoutSizeFitting(layoutFittingCompressedSize)
        return cellSize.height
    }

    override func mediaPresentationContext(collectionView: UICollectionView, in coordinateSpace: UICoordinateSpace) -> MediaPresentationContext? {
        let presentationFrame = coordinateSpace.convert(thumbnailView.imageView.frame, from: thumbnailView)
        let clippingAreaInsets = UIEdgeInsets(
            top: collectionView.adjustedContentInset.top,
            leading: 0,
            bottom: collectionView.adjustedContentInset.bottom,
            trailing: 0,
        )
        return MediaPresentationContext(
            mediaView: thumbnailView.imageView,
            presentationFrame: presentationFrame,
            clippingAreaInsets: clippingAreaInsets,
        )
    }

    private class ThumbnailView: UIView {

        let imageView: UIImageView = {
            let imageView = UIImageView()
            imageView.translatesAutoresizingMaskIntoConstraints = false
            imageView.contentMode = .scaleAspectFit
            imageView.layer.masksToBounds = true
            imageView.layer.cornerRadius = 4
            return imageView
        }()

        var image: UIImage? {
            get { imageView.image }
            set {
                imageView.image = newValue
                setNeedsLayout()
            }
        }

        override init(frame: CGRect) {
            super.init(frame: frame)
            addSubview(imageView)
        }

        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }

        override func layoutSubviews() {
            super.layoutSubviews()

            guard bounds.size.isNonEmpty else { return }

            guard let imageSize = imageView.image?.size else {
                imageView.frame = bounds
                return
            }

            let scaleX = bounds.width / imageSize.width
            let scaleY = bounds.height / imageSize.height
            let scale = min(scaleX, scaleY)
            let thumbnailSize = imageSize * scale
            imageView.frame = CGRect(
                x: 0.5 * (bounds.width - thumbnailSize.width),
                y: 0.5 * (bounds.height - thumbnailSize.height),
                width: thumbnailSize.width,
                height: thumbnailSize.height,
            )
        }
    }
}

private extension MediaMetadata {

    var formattedSize: String {
        let byteCount = Int64(byteSize)
        let formatter = ByteCountFormatter()
        formatter.allowedUnits = [.useKB, .useMB, .useGB, .useTB]
        formatter.countStyle = .file
        return formatter.string(fromByteCount: byteCount)
    }

    var formattedDate: String? {
        guard let creationDate else {
            return nil
        }
        let dateFormatter = DateFormatter()
        dateFormatter.dateStyle = .short
        dateFormatter.timeStyle = .none
        dateFormatter.locale = Locale.current
        return dateFormatter.string(from: creationDate)
    }
}