Signal-iOS/Signal/ConversationView/ConversationInputToolbar+LinkPreview.swift
Igor Solomennikov 345ddca4d0
Initial round of iOS 26 appearance updates to chat input bar.
• new appearance for controls.
• new look for quoted message and link preview panels.
2025-10-23 19:45:31 -07:00

367 lines
16 KiB
Swift

//
// Copyright 2025 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import SignalServiceKit
import SignalUI
class OutgoingLinkPreviewView: UIView {
init(state: LinkPreviewFetchState.State) {
super.init(frame: .zero)
directionalLayoutMargins = .init(top: 0, leading: 12, bottom: 0, trailing: 0)
translatesAutoresizingMaskIntoConstraints = false
addConstraint(heightConstraint)
addSubview(backgroundView)
backgroundView.translatesAutoresizingMaskIntoConstraints = false
addConstraints([
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
addConstraints([
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)
}
override var bounds: CGRect {
didSet {
// Mask to round corners.
let maskLayer = CAShapeLayer()
maskLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 12).cgPath
layer.mask = maskLayer
if oldValue.width != bounds.width {
updateHeight()
}
}
}
override func layoutMarginsDidChange() {
super.layoutMarginsDidChange()
updateHeight()
}
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 backgroundView: UIView = {
let view = UIView()
view.backgroundColor = .Signal.secondaryFill
return view
}()
private let contentView = UIView()
private let imageView: UIImageView = {
let imageView = UIImageView()
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
imageView.translatesAutoresizingMaskIntoConstraints = false
imageView.setContentHuggingVerticalHigh()
return imageView
}()
private static let imageSize = CGSize(width: 77, height: 77)
// (X) button.
let cancelButton: UIButton = {
let cancelButton = UIButton(configuration: .bordered())
cancelButton.configuration?.image = UIImage(imageLiteralResourceName: "x-compact-bold")
cancelButton.configuration?.baseBackgroundColor = .init(dynamicProvider: { traitCollection in
traitCollection.userInterfaceStyle == .dark
? UIColor(rgbHex: 0x787880, alpha: 0.4)
: UIColor(rgbHex: 0xF5F5F5, alpha: 0.9)
})
cancelButton.configuration?.background.visualEffect = UIBlurEffect(style: .systemUltraThinMaterial)
cancelButton.tintColor = .Signal.label
cancelButton.configuration?.cornerStyle = .capsule
cancelButton.translatesAutoresizingMaskIntoConstraints = false
cancelButton.addConstraints([
cancelButton.widthAnchor.constraint(equalToConstant: 24),
cancelButton.heightAnchor.constraint(equalToConstant: 24),
])
return cancelButton
}()
private lazy var heightConstraint = heightAnchor.constraint(equalToConstant: 0)
private func updateHeight() {
guard layoutMarginsGuide.layoutFrame.width > 0 else { return }
let size = contentView.systemLayoutSizeFitting(
CGSize(width: layoutMarginsGuide.layoutFrame.width, height: .greatestFiniteMagnitude),
withHorizontalFittingPriority: .required,
verticalFittingPriority: .fittingSizeLevel
)
heightConstraint.constant = size.height + directionalLayoutMargins.totalHeight
}
func resetContent() {
contentView.removeAllSubviews()
imageView.image = nil
}
func configure(withState state: LinkPreviewFetchState.State) {
resetContent()
switch state {
case .loading:
configureAsLoading()
case .loaded(let linkPreviewDraft):
let draft = LinkPreviewDraft(linkPreviewDraft: linkPreviewDraft)
if CallLink(url: linkPreviewDraft.url) != nil {
configureAsCallLinkPreviewDraft(draft: draft)
} else {
configureAsLinkPreviewDraft(draft: draft)
}
default:
owsFailBeta("Invalid link preview state: [\(state)]")
}
updateHeight()
}
private func configureAsLoading() {
let activityIndicator = UIActivityIndicatorView(style: .medium)
activityIndicator.tintColor = .Signal.secondaryLabel
activityIndicator.translatesAutoresizingMaskIntoConstraints = false
contentView.addSubview(activityIndicator)
contentView.addConstraints([
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.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.label
label.numberOfLines = 2
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.font = .dynamicTypeCaption1
label.lineBreakMode = .byTruncatingTail
label.setContentHuggingVerticalHigh()
textStack.addArrangedSubview(label)
}
let textStackContainer = UIView.container()
textStackContainer.addSubview(textStack)
textStack.translatesAutoresizingMaskIntoConstraints = false
textStackContainer.addConstraints([
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)
contentView.addConstraints([
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)
horizontalStack.addConstraints([
imageView.widthAnchor.constraint(equalToConstant: Self.imageSize.width),
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)
cancelButtonContainer.addConstraints([
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 configureAsCallLinkPreviewDraft(draft: LinkPreviewDraft) {
// 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
imageContainer.addConstraints([
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 = CallStrings.signalCall
titleLabel.textColor = .Signal.label
titleLabel.numberOfLines = 2
titleLabel.font = .dynamicTypeFootnote.semibold()
titleLabel.lineBreakMode = .byTruncatingTail
titleLabel.setContentHuggingVerticalHigh()
textStack.addArrangedSubview(titleLabel)
let subtitleLabel = UILabel()
subtitleLabel.text = CallStrings.callLinkDescription
subtitleLabel.textColor = .Signal.label
subtitleLabel.numberOfLines = 2
subtitleLabel.font = .dynamicTypeFootnote
subtitleLabel.lineBreakMode = .byTruncatingTail
subtitleLabel.setContentHuggingVerticalHigh()
textStack.addArrangedSubview(subtitleLabel)
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.font = .dynamicTypeCaption1
label.lineBreakMode = .byTruncatingTail
label.setContentHuggingVerticalHigh()
textStack.addArrangedSubview(label)
}
// Cancel button
let cancelButtonContainer = UIView.container()
cancelButtonContainer.addSubview(cancelButton)
cancelButtonContainer.addConstraints([
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)
contentView.addConstraints([
horizontalStack.topAnchor.constraint(equalTo: contentView.topAnchor),
horizontalStack.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
horizontalStack.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
horizontalStack.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
])
}
}