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

import Photos
import PhotosUI
import SignalServiceKit
import SignalUI

protocol RecentPhotosDelegate: AnyObject {
    func didSelectRecentPhoto(asset: PHAsset, attachment: PreviewableAttachment, attachmentLimits: OutgoingAttachmentLimits)
}

class RecentPhotosCollectionView: UICollectionView {

    static let maxRecentPhotos = 96
    static let itemSpacing: CGFloat = 12

    weak var recentPhotosDelegate: RecentPhotosDelegate?

    var mediaLibraryAuthorizationStatus: PHAuthorizationStatus = .notDetermined {
        didSet {
            guard oldValue != mediaLibraryAuthorizationStatus else { return }
            DispatchQueue.main.async {
                self.reloadUIOnMediaLibraryAuthorizationStatusChange()
            }
        }
    }

    private var fetchingAttachmentIndex: IndexPath? {
        didSet {
            var indexPaths = [IndexPath]()

            if let oldValue {
                indexPaths.append(oldValue)
            }
            if let newValue = fetchingAttachmentIndex {
                indexPaths.append(newValue)
            }

            reloadItems(at: indexPaths)
        }
    }

    private lazy var photoLibrary: PhotoLibrary = {
        let library = PhotoLibrary()
        library.delegate = self
        return library
    }()

    private lazy var collection = photoLibrary.defaultPhotoAlbum()
    private lazy var collectionContents = collection.contents(limit: RecentPhotosCollectionView.maxRecentPhotos)

    // Cell Sizing

    private static let initialCellSize: CGSize = .square(50)

    private var cellSize: CGSize = initialCellSize {
        didSet {
            guard oldValue != cellSize else { return }

            thumbnailSize = cellSize * UIScreen.main.scale

            // Replacing the collection view layout is the only reliable way
            // to change cell size when `collectionView(_:layout:sizeForItemAt:)` is implemented.
            // That delegate method is necessary to allow custom size for "manage access" helper UI.
            setCollectionViewLayout(RecentPhotosCollectionView.collectionViewLayout(itemSize: cellSize), animated: false)
            reloadData() // Needed in order to reload photos with better quality on size change.
        }
    }

    private var lastKnownHeight: CGFloat = 0

    override var bounds: CGRect {
        didSet {
            let height = frame.height
            guard height != lastKnownHeight, height > 0 else { return }

            lastKnownHeight = height
            recalculateCellSize()
        }
    }

    private func recalculateCellSize() {
        guard lastKnownHeight > 0 else { return }

        if lastKnownHeight > 250 {
            cellSize = CGSize(square: 0.5 * (lastKnownHeight - RecentPhotosCollectionView.itemSpacing))
            // Otherwise, assume the recent photos take up the full height of the collection view.
        } else {
            cellSize = CGSize(square: lastKnownHeight)
        }
    }

    private var thumbnailSize = initialCellSize * UIScreen.main.scale

    private static func collectionViewLayout(itemSize: CGSize) -> UICollectionViewFlowLayout {
        let layout = RTLEnabledCollectionViewFlowLayout()
        layout.scrollDirection = .horizontal
        layout.minimumLineSpacing = itemSpacing
        layout.minimumInteritemSpacing = itemSpacing
        layout.itemSize = itemSize
        return layout
    }

    init() {
        let layout = RecentPhotosCollectionView.collectionViewLayout(itemSize: RecentPhotosCollectionView.initialCellSize)
        super.init(frame: .zero, collectionViewLayout: layout)

        dataSource = self
        delegate = self

        backgroundColor = .clear
        showsHorizontalScrollIndicator = false
        let horizontalInset = OWSTableViewController2.defaultHOuterMargin
        contentInset = UIEdgeInsets(top: 0, leading: horizontalInset, bottom: 0, trailing: horizontalInset)
        register(RecentPhotoCell.self, forCellWithReuseIdentifier: RecentPhotoCell.reuseIdentifier)
    }

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

    // Presentation Animations

    func prepareForPresentation() {
        UIView.performWithoutAnimation {
            self.alpha = 0
        }
    }

    func performPresentationAnimation() {
        UIView.animate(withDuration: 0.3) {
            self.alpha = 1
        }
    }

    // Background view

    private var hasPhotos: Bool {
        guard hasAccessToPhotos else { return false }
        return collectionContents.assetCount > 0
    }

    private var hasAccessToPhotos: Bool {
        return [.authorized, .limited].contains(mediaLibraryAuthorizationStatus)
    }

    private func reloadUIOnMediaLibraryAuthorizationStatusChange() {
        guard hasPhotos else {
            backgroundView = noPhotosBackgroundView()
            reloadData()
            return
        }
        backgroundView = nil
        reloadData()
    }

    private func noPhotosBackgroundView() -> UIView {
        let contentView: UIView
        if !hasAccessToPhotos {
            contentView = noAccessToPhotosView()
        } else {
            contentView = noPhotosView()
        }

        let view = UIView()
        view.addSubview(contentView)
        contentView.autoPinHeightToSuperviewMargins(relation: .lessThanOrEqual)
        contentView.autoCenterInSuperview()
        contentView.widthAnchor.constraint(lessThanOrEqualTo: view.layoutMarginsGuide.widthAnchor, multiplier: 0.75).isActive = true
        return view
    }

    private func noPhotosView() -> UIView {
        let titleLabel = UILabel()
        titleLabel.adjustsFontForContentSizeCategory = true
        titleLabel.font = .dynamicTypeHeadlineClamped
        titleLabel.textColor = .Signal.label
        titleLabel.textAlignment = .center
        titleLabel.numberOfLines = 2
        titleLabel.text = OWSLocalizedString(
            "ATTACHMENT_KEYBOARD_NO_MEDIA_TITLE",
            comment: "First block of text in chat attachment panel when there's no recent photos to show.",
        )
        let bodyLabel = textLabel(text: OWSLocalizedString(
            "ATTACHMENT_KEYBOARD_NO_MEDIA_BODY",
            comment: "Second block of text in chat attachment panel when there's no recent photos to show.",
        ))
        let stackView = UIStackView(arrangedSubviews: [titleLabel, bodyLabel])
        stackView.axis = .vertical
        stackView.alignment = .center
        stackView.spacing = 4
        return stackView
    }

    private func noAccessToPhotosView() -> UIView {
        let textLabel = textLabel(text: OWSLocalizedString(
            "ATTACHMENT_KEYBOARD_NO_PHOTO_ACCESS",
            comment: "Text in chat attachment panel explaining that user needs to give Signal permission to access photos.",
        ))
        let button = UIButton(
            configuration: .smallSecondary(title: OWSLocalizedString(
                "ATTACHMENT_KEYBOARD_OPEN_SETTINGS",
                comment: "Button in chat attachment panel to let user open Settings app and give Signal persmission to access photos.",
            )),
            primaryAction: UIAction { _ in
                let openAppSettingsUrl = URL(string: UIApplication.openSettingsURLString)!
                UIApplication.shared.open(openAppSettingsUrl)
            },
        )
        let stackView = UIStackView(arrangedSubviews: [textLabel, button])
        stackView.axis = .vertical
        stackView.spacing = 16
        stackView.alignment = .center
        return stackView
    }

    private func textLabel(text: String) -> UILabel {
        let label = UILabel()
        label.adjustsFontForContentSizeCategory = true
        label.font = .dynamicTypeSubheadlineClamped
        label.lineBreakMode = .byWordWrapping
        label.textColor = .Signal.secondaryLabel
        label.textAlignment = .center
        label.numberOfLines = 0
        label.text = text
        return label
    }
}

extension RecentPhotosCollectionView: PhotoLibraryDelegate {

    func photoLibraryDidChange(_ photoLibrary: PhotoLibrary) {
        let hadPhotos = hasPhotos
        collectionContents = collection.contents(limit: RecentPhotosCollectionView.maxRecentPhotos)
        reloadData()
        if hasPhotos != hadPhotos {
            reloadUIOnMediaLibraryAuthorizationStatusChange()
        }
    }
}

// MARK: - UICollectionViewDelegate

extension RecentPhotosCollectionView: UICollectionViewDelegate, UICollectionViewDelegateFlowLayout {

    func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
        guard fetchingAttachmentIndex == nil else { return }

        guard indexPath.row < collectionContents.assetCount else {
            owsFailDebug("Asset does not exist.")
            return
        }

        self.fetchingAttachmentIndex = indexPath
        let asset = collectionContents.asset(at: indexPath.item)
        let attachmentLimits = OutgoingAttachmentLimits.currentLimits()
        Task {
            defer {
                self.fetchingAttachmentIndex = nil
            }
            do {
                let attachment = try await collectionContents.outgoingAttachment(for: asset, attachmentLimits: attachmentLimits)
                self.recentPhotosDelegate?.didSelectRecentPhoto(asset: asset, attachment: attachment, attachmentLimits: attachmentLimits)
            } catch {
                Logger.warn("\(error)")
                switch error {
                case SignalAttachmentError.fileSizeTooLarge:
                    OWSActionSheets.showActionSheet(
                        title: OWSLocalizedString(
                            "ATTACHMENT_ERROR_FILE_SIZE_TOO_LARGE",
                            comment: "Attachment error message for attachments whose data exceed file size limits",
                        ),
                    )
                default:
                    OWSActionSheets.showActionSheet(
                        title: OWSLocalizedString(
                            "IMAGE_PICKER_FAILED_TO_PROCESS_ATTACHMENTS",
                            comment: "alert title",
                        ),
                    )
                }
            }
        }
    }

    func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize {
        return cellSize
    }
}

// MARK: - UICollectionViewDataSource

extension RecentPhotosCollectionView: UICollectionViewDataSource {

    func numberOfSections(in collectionView: UICollectionView) -> Int {
        guard hasPhotos else { return 0 }
        return 1
    }

    func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection sectionIdx: Int) -> Int {
        guard hasPhotos else { return 0 }
        return collectionContents.assetCount
    }

    func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell {
        guard let cell = collectionView.dequeueReusableCell(withReuseIdentifier: RecentPhotoCell.reuseIdentifier, for: indexPath) as? RecentPhotoCell else {
            owsFail("cell was unexpectedly nil")
        }

        let assetItem = collectionContents.assetItem(at: indexPath.item, thumbnailSize: thumbnailSize)
        cell.configure(item: assetItem, isLoading: fetchingAttachmentIndex == indexPath)
#if DEBUG
        // These accessibilityIdentifiers won't be stable, but they
        // should work for the purposes of our automated testing.
        cell.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "recent-photo-\(indexPath.row)")
#endif
        return cell
    }
}

private class RecentPhotoCell: UICollectionViewCell {

    static let reuseIdentifier = "RecentPhotoCell"

    private let imageView = UIImageView()
    private var contentTypeBadgeView: UIImageView?
    private var durationLabel: UILabel?
    private var durationLabelBackground: UIView?
    private let loadingIndicator = UIActivityIndicatorView(style: .large)

    private var item: PhotoPickerAssetItem?

    override init(frame: CGRect) {

        super.init(frame: frame)

        contentView.clipsToBounds = true

        imageView.contentMode = .scaleAspectFill
        contentView.addSubview(imageView)
        imageView.autoPinEdgesToSuperviewEdges()

        loadingIndicator.layer.shadowColor = UIColor.black.cgColor
        loadingIndicator.layer.shadowOffset = .zero
        loadingIndicator.layer.shadowOpacity = 0.7
        loadingIndicator.layer.shadowRadius = 3.0

        contentView.addSubview(loadingIndicator)
        loadingIndicator.autoCenterInSuperview()

        if #available(iOS 26, *) {
            updateCornerRadius()
            registerForTraitChanges([UITraitVerticalSizeClass.self]) { (self: Self, _) in
                self.updateCornerRadius()
            }
        }
    }

    @available(*, unavailable, message: "Unimplemented")
    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override var bounds: CGRect {
        didSet {
            guard #unavailable(iOS 26) else { return }
            updateCornerRadiusLegacy()
        }
    }

    @available(iOS 26, *)
    private func updateCornerRadius() {
        let cornerRadius: CGFloat = traitCollection.verticalSizeClass == .compact ? 20 : 36
        contentView.cornerConfiguration = .uniformCorners(radius: .fixed(cornerRadius))
    }

    @available(iOS, deprecated: 26)
    private func updateCornerRadiusLegacy() {
        let cellSize = min(bounds.width, bounds.height)
        guard cellSize > 0 else { return }
        contentView.layer.cornerRadius = (cellSize * 13 / 84).rounded()
    }

    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
        super.traitCollectionDidChange(previousTraitCollection)

        if
            let durationLabel,
            previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory
        {
            durationLabel.font = RecentPhotoCell.durationLabelFont()
        }
    }

    private var image: UIImage? {
        get { return imageView.image }
        set {
            imageView.image = newValue
            imageView.backgroundColor = newValue == nil ? Theme.washColor : .clear
        }
    }

    private static func durationLabelFont() -> UIFont {
        let fontDescriptor = UIFontDescriptor.preferredFontDescriptor(withTextStyle: .footnote)
        return UIFont.semiboldFont(ofSize: max(13, fontDescriptor.pointSize))
    }

    private func setContentTypeBadge(image: UIImage?) {
        guard image != nil else {
            contentTypeBadgeView?.isHidden = true
            return
        }

        if contentTypeBadgeView == nil {
            let contentTypeBadgeView = UIImageView()
            contentView.addSubview(contentTypeBadgeView)
            contentTypeBadgeView.autoPinEdge(toSuperviewEdge: .trailing, withInset: 12)
            contentTypeBadgeView.autoPinEdge(toSuperviewEdge: .bottom, withInset: 12)
            self.contentTypeBadgeView = contentTypeBadgeView
        }
        contentTypeBadgeView?.isHidden = false
        contentTypeBadgeView?.image = image
        contentTypeBadgeView?.sizeToFit()
    }

    private func setMedia(itemType: PhotoGridItemType) {
        guard case .video(let promisedDuration) = itemType, let duration = promisedDuration.value else {
            durationLabel?.isHidden = true
            durationLabelBackground?.isHidden = true
            return
        }

        if durationLabel == nil {
            let durationLabel = UILabel()
            durationLabel.textColor = .white
            durationLabel.font = RecentPhotoCell.durationLabelFont()
            durationLabel.layer.shadowColor = UIColor.ows_blackAlpha20.cgColor
            durationLabel.layer.shadowOffset = CGSize(width: -1, height: -1)
            durationLabel.layer.shadowOpacity = 1
            durationLabel.layer.shadowRadius = 4
            durationLabel.shadowOffset = CGSize(width: 0, height: 1)
            durationLabel.adjustsFontForContentSizeCategory = true
            self.durationLabel = durationLabel
        }
        if durationLabelBackground == nil {
            let gradientView = GradientView(from: .ows_blackAlpha40, to: .clear)
            gradientView.gradientLayer.type = .radial
            gradientView.gradientLayer.startPoint = CGPoint(x: 0.5, y: 0.5)
            gradientView.gradientLayer.endPoint = CGPoint(x: 0, y: 90 / 122) // 122 x 58 oval
            self.durationLabelBackground = gradientView
        }

        guard let durationLabel, let durationLabelBackground else {
            return
        }

        if durationLabel.superview == nil {
            contentView.addSubview(durationLabel)
            durationLabel.autoPinEdge(toSuperviewEdge: .trailing, withInset: 12)
            durationLabel.autoPinEdge(toSuperviewEdge: .bottom, withInset: 12)
        }
        if durationLabelBackground.superview == nil {
            contentView.insertSubview(durationLabelBackground, belowSubview: durationLabel)
            durationLabelBackground.autoPinEdge(.top, to: .top, of: durationLabel, withOffset: -10)
            durationLabelBackground.autoPinEdge(.leading, to: .leading, of: durationLabel, withOffset: -24)
            durationLabelBackground.centerXAnchor.constraint(equalTo: contentView.trailingAnchor).isActive = true
            durationLabelBackground.centerYAnchor.constraint(equalTo: contentView.bottomAnchor).isActive = true
        }

        durationLabel.isHidden = false
        durationLabelBackground.isHidden = false
        durationLabel.text = OWSFormat.localizedDurationString(from: duration)
        durationLabel.sizeToFit()
    }

    func configure(item: PhotoPickerAssetItem, isLoading: Bool) {
        self.item = item

        image = nil
        item.asyncThumbnail { [weak self] image in
            guard let self, let currentItem = self.item, currentItem === item else { return }
            self.image = image
        }

        setMedia(itemType: item.type)

        switch item.type {
        case .animated:
            setContentTypeBadge(image: UIImage(imageLiteralResourceName: "gif-rectangle"))
        case .photo, .video:
            setContentTypeBadge(image: nil)
        }

        if isLoading { startLoading() }
    }

    override func prepareForReuse() {
        super.prepareForReuse()

        item = nil
        imageView.image = nil
        stopLoading()
    }

    private func startLoading() {
        loadingIndicator.startAnimating()
    }

    private func stopLoading() {
        loadingIndicator.stopAnimating()
    }
}