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),
])
}
}