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

import SignalServiceKit

/// Class designed to show link preview in the message composer UI.
/// Unlike CVLinkPreviewView, this component is designed to show "loading" state and contains
/// ( X ) cancel button to dismiss the link preview.
public class LinkPreviewView: UIView {

    public init(state: LinkPreviewFetchState.State) {
        super.init(frame: .zero)

        directionalLayoutMargins = .init(top: 0, leading: 12, bottom: 0, trailing: 0)

        if #available(iOS 26, *) {
            clipsToBounds = true
            cornerConfiguration = .uniformCorners(radius: .containerConcentric(minimum: 12))
        }

        let backgroundView = UIView()
        backgroundView.backgroundColor = .Signal.secondaryFill
        backgroundView.translatesAutoresizingMaskIntoConstraints = false
        addSubview(backgroundView)
        NSLayoutConstraint.activate([
            backgroundView.topAnchor.constraint(equalTo: topAnchor),
            backgroundView.leadingAnchor.constraint(equalTo: leadingAnchor),
            backgroundView.trailingAnchor.constraint(equalTo: trailingAnchor),
            backgroundView.bottomAnchor.constraint(equalTo: bottomAnchor),
        ])

        addSubview(contentView)
        contentView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            contentView.topAnchor.constraint(equalTo: layoutMarginsGuide.topAnchor),
            contentView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor),
            contentView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor),
            contentView.bottomAnchor.constraint(equalTo: layoutMarginsGuide.bottomAnchor),
        ])

        configure(withState: state)
    }

    // We need rounded corners on the whole view (and not background) because image view
    // is constrained to view's top, bottom and trailing edges.
    override public var bounds: CGRect {
        didSet {
            // Use `cornerConfiguration`.
            if #available(iOS 26, *) { return }

            // Mask to round corners.
            let maskLayer = CAShapeLayer()
            maskLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 12).cgPath
            layer.mask = maskLayer
        }
    }

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

    // MARK: Layout

    private static let dateFormatter: DateFormatter = {
        let formatter = DateFormatter()
        formatter.dateStyle = .medium
        formatter.timeStyle = .none
        return formatter
    }()

    private let contentView = UIView()

    private let imageView: UIImageView = {
        let imageView = UIImageView()
        imageView.contentMode = .scaleAspectFill
        imageView.clipsToBounds = true
        imageView.translatesAutoresizingMaskIntoConstraints = false
        imageView.setContentHuggingVerticalHigh()
        // Lower than vertical hugging of text labels so image view's height is constrained by text.
        imageView.setCompressionResistanceVerticalLow()
        return imageView
    }()

    private static let imageSize = CGSize(width: 77, height: 77)

    // (X) button.
    public let cancelButton: UIButton = {
        let cancelButton = UIButton(configuration: .bordered())
        cancelButton.configuration?.image = UIImage(imageLiteralResourceName: "x-compact-bold")
        cancelButton.configuration?.baseBackgroundColor = UIColor(
            light: UIColor(rgbHex: 0xF5F5F5, alpha: 0.9),
            dark: UIColor(rgbHex: 0x787880, alpha: 0.4),
        )
        cancelButton.configuration?.background.visualEffect = UIBlurEffect(style: .systemUltraThinMaterial)
        cancelButton.tintColor = .Signal.label
        cancelButton.configuration?.cornerStyle = .capsule
        cancelButton.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            cancelButton.widthAnchor.constraint(equalToConstant: 24),
            cancelButton.heightAnchor.constraint(equalToConstant: 24),
        ])
        return cancelButton
    }()

    public func resetContent() {
        contentView.removeAllSubviews()
        imageView.image = nil
    }

    public func configure(withState state: LinkPreviewFetchState.State) {
        resetContent()

        switch state {
        case .loading:
            configureAsLoading()
        case .loaded(let linkPreviewDraft):
            let draft = LinkPreviewDraft(linkPreviewDraft: linkPreviewDraft)
            if let callLink = CallLink(url: linkPreviewDraft.url) {
                let state = LinkPreviewCallLink(previewType: .draft(linkPreviewDraft), callLink: callLink)
                configureAsCallLinkPreview(state)
            } else {
                configureAsLinkPreviewDraft(draft: draft)
            }
        default:
            owsFailBeta("Invalid link preview state: [\(state)]")
        }
    }

    private func configureAsLoading() {
        let activityIndicator = UIActivityIndicatorView(style: .medium)
        activityIndicator.tintColor = .Signal.secondaryLabel
        activityIndicator.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(activityIndicator)
        NSLayoutConstraint.activate([
            activityIndicator.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
            activityIndicator.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
            activityIndicator.centerYAnchor.constraint(equalTo: contentView.topAnchor, constant: 0.5 * Self.imageSize.height),
        ])

        activityIndicator.startAnimating()
    }

    private func configureAsLinkPreviewDraft(draft: LinkPreviewDraft) {
        // Text
        let textStack = UIStackView()
        textStack.axis = .vertical
        textStack.alignment = .leading
        textStack.directionalLayoutMargins = .zero
        textStack.isLayoutMarginsRelativeArrangement = true

        if let text = draft.title?.nilIfEmpty {
            let label = UILabel()
            label.text = text
            label.textColor = .Signal.label
            label.numberOfLines = 2
            label.adjustsFontForContentSizeCategory = true
            label.font = .dynamicTypeFootnote.semibold()
            label.lineBreakMode = .byTruncatingTail
            label.setContentHuggingVerticalHigh()
            textStack.addArrangedSubview(label)
            textStack.setCustomSpacing(2, after: label)
        }

        if let text = draft.previewDescription?.nilIfEmpty {
            let label = UILabel()
            label.text = text
            label.textColor = .Signal.secondaryLabel
            label.numberOfLines = 2
            label.adjustsFontForContentSizeCategory = true
            label.font = .dynamicTypeFootnote
            label.lineBreakMode = .byTruncatingTail
            label.setContentHuggingVerticalHigh()
            textStack.addArrangedSubview(label)
        }

        if let displayDomain = draft.displayDomain?.nilIfEmpty {
            var text = displayDomain.lowercased()
            if let date = draft.date {
                text.append(" ⋅ \(Self.dateFormatter.string(from: date))")
            }
            let label = UILabel()
            label.text = text
            label.textColor = .Signal.secondaryLabel
            label.numberOfLines = 1
            label.adjustsFontForContentSizeCategory = true
            label.font = .dynamicTypeCaption1
            label.lineBreakMode = .byTruncatingTail
            label.setContentHuggingVerticalHigh()
            textStack.addArrangedSubview(label)
        }

        let textStackContainer = UIView.container()
        textStackContainer.addSubview(textStack)
        textStack.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            textStack.topAnchor.constraint(greaterThanOrEqualTo: textStackContainer.topAnchor, constant: 12),
            textStack.centerYAnchor.constraint(equalTo: textStackContainer.centerYAnchor),
            textStack.leadingAnchor.constraint(equalTo: textStackContainer.leadingAnchor),
            textStack.trailingAnchor.constraint(equalTo: textStackContainer.trailingAnchor),
            {
                let c = textStack.topAnchor.constraint(equalTo: textStackContainer.topAnchor)
                c.priority = .defaultHigh
                return c
            }(),
        ])

        let horizontalStack = UIStackView(arrangedSubviews: [textStackContainer])
        horizontalStack.axis = .horizontal
        horizontalStack.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(horizontalStack)
        NSLayoutConstraint.activate([
            horizontalStack.topAnchor.constraint(equalTo: contentView.topAnchor),
            horizontalStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            horizontalStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            horizontalStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
        ])

        // Image
        let cancelButtonPadding: CGFloat = 8 // around all edges
        if draft.imageState == .loaded {
            textStack.directionalLayoutMargins.trailing = 12 // spacing between text and image

            imageView.contentMode = .scaleAspectFill
            draft.imageAsync(thumbnailQuality: .small) { [weak self] image in
                DispatchMainThreadSafe {
                    guard let self else { return }
                    self.imageView.image = image
                }
            }
            horizontalStack.addArrangedSubview(imageView)
            horizontalStack.addSubview(cancelButton)
            NSLayoutConstraint.activate([
                imageView.widthAnchor.constraint(equalToConstant: Self.imageSize.width),
                // Allow image view to grow with text.
                imageView.heightAnchor.constraint(greaterThanOrEqualToConstant: Self.imageSize.height),

                cancelButton.topAnchor.constraint(equalTo: imageView.topAnchor, constant: cancelButtonPadding),
                cancelButton.trailingAnchor.constraint(equalTo: imageView.trailingAnchor, constant: -cancelButtonPadding),
            ])
        } else {
            textStack.directionalLayoutMargins.trailing = 0 // `cancelButtonContainer` has enough spacing between cancel button and text

            let cancelButtonContainer = UIView.container()
            cancelButtonContainer.addSubview(cancelButton)
            NSLayoutConstraint.activate([
                cancelButton.topAnchor.constraint(equalTo: cancelButtonContainer.topAnchor, constant: cancelButtonPadding),
                cancelButton.leadingAnchor.constraint(equalTo: cancelButtonContainer.leadingAnchor, constant: cancelButtonPadding),
                cancelButton.trailingAnchor.constraint(equalTo: cancelButtonContainer.trailingAnchor, constant: -cancelButtonPadding),
                cancelButton.bottomAnchor.constraint(lessThanOrEqualTo: cancelButtonContainer.bottomAnchor, constant: -cancelButtonPadding),
            ])
            horizontalStack.addArrangedSubview(cancelButtonContainer)
        }
    }

    private func configureAsCallLinkPreview(_ linkPreview: LinkPreviewCallLink) {
        // Image
        let imageSize: CGFloat = 27
        let cameraIcon = UIImageView(image: UIImage(imageLiteralResourceName: "video"))
        cameraIcon.tintColor = .init(rgbHex: 0x4F4F69)

        let circleSize: CGFloat = 48
        let circleView = CircleView()
        circleView.backgroundColor = .init(rgbHex: 0xD2D2DA)
        circleView.addSubview(cameraIcon)

        let imageContainer = UIView.container()
        imageContainer.addSubview(circleView)

        cameraIcon.translatesAutoresizingMaskIntoConstraints = false
        circleView.translatesAutoresizingMaskIntoConstraints = false
        NSLayoutConstraint.activate([
            cameraIcon.widthAnchor.constraint(equalToConstant: imageSize),
            cameraIcon.heightAnchor.constraint(equalToConstant: imageSize),

            cameraIcon.centerXAnchor.constraint(equalTo: circleView.centerXAnchor),
            cameraIcon.centerYAnchor.constraint(equalTo: circleView.centerYAnchor),

            circleView.widthAnchor.constraint(equalToConstant: circleSize),
            circleView.heightAnchor.constraint(equalToConstant: circleSize),

            circleView.topAnchor.constraint(equalTo: imageContainer.topAnchor, constant: 4),
            circleView.leadingAnchor.constraint(equalTo: imageContainer.leadingAnchor),
            circleView.trailingAnchor.constraint(equalTo: imageContainer.trailingAnchor),
            circleView.bottomAnchor.constraint(lessThanOrEqualTo: imageContainer.bottomAnchor),
        ])

        // Text
        let textStack = UIStackView(arrangedSubviews: [])
        textStack.axis = .vertical
        textStack.alignment = .leading
        textStack.spacing = 2
        textStack.isLayoutMarginsRelativeArrangement = true
        textStack.directionalLayoutMargins = .init(hMargin: 0, vMargin: 4)

        let titleLabel = UILabel()
        titleLabel.text = linkPreview.title
        titleLabel.textColor = .Signal.label
        titleLabel.numberOfLines = 2
        titleLabel.adjustsFontForContentSizeCategory = true
        titleLabel.font = .dynamicTypeFootnote.semibold()
        titleLabel.lineBreakMode = .byTruncatingTail
        titleLabel.setContentHuggingVerticalHigh()
        textStack.addArrangedSubview(titleLabel)

        let subtitleLabel = UILabel()
        subtitleLabel.text = CallStrings.callLinkDescription
        subtitleLabel.textColor = .Signal.secondaryLabel
        subtitleLabel.numberOfLines = 2
        subtitleLabel.adjustsFontForContentSizeCategory = true
        subtitleLabel.font = .dynamicTypeFootnote
        subtitleLabel.lineBreakMode = .byTruncatingTail
        subtitleLabel.setContentHuggingVerticalHigh()
        textStack.addArrangedSubview(subtitleLabel)

        if let displayDomain = linkPreview.displayDomain?.nilIfEmpty {
            var text = displayDomain.lowercased()
            if let date = linkPreview.date {
                text.append(" ⋅ \(Self.dateFormatter.string(from: date))")
            }
            let label = UILabel()
            label.text = text
            label.textColor = .Signal.secondaryLabel
            label.numberOfLines = 1
            label.adjustsFontForContentSizeCategory = true
            label.font = .dynamicTypeCaption1
            label.lineBreakMode = .byTruncatingTail
            label.setContentHuggingVerticalHigh()
            textStack.addArrangedSubview(label)
        }

        // Cancel button
        let cancelButtonContainer = UIView.container()
        cancelButtonContainer.addSubview(cancelButton)
        NSLayoutConstraint.activate([
            cancelButton.topAnchor.constraint(equalTo: cancelButtonContainer.topAnchor),
            cancelButton.leadingAnchor.constraint(equalTo: cancelButtonContainer.leadingAnchor, constant: 6),
            cancelButton.trailingAnchor.constraint(equalTo: cancelButtonContainer.trailingAnchor, constant: -8),
            cancelButton.bottomAnchor.constraint(lessThanOrEqualTo: cancelButtonContainer.bottomAnchor),
        ])

        let horizontalStack = UIStackView(arrangedSubviews: [imageContainer, textStack, cancelButtonContainer])
        horizontalStack.axis = .horizontal
        horizontalStack.setCustomSpacing(12, after: imageContainer)
        horizontalStack.isLayoutMarginsRelativeArrangement = true
        horizontalStack.directionalLayoutMargins = .init(hMargin: 0, vMargin: 8)
        horizontalStack.translatesAutoresizingMaskIntoConstraints = false
        contentView.addSubview(horizontalStack)
        NSLayoutConstraint.activate([
            horizontalStack.topAnchor.constraint(equalTo: contentView.topAnchor),
            horizontalStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
            horizontalStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
            horizontalStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
        ])
    }
}