TM-SGNL-iOS/SignalUI/LinkPreview/LinkPreviewView.swift
TeleMessage developers dde0620daf initial commit
2025-05-03 12:28:28 -07:00

1516 lines
51 KiB
Swift

//
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
//
import YYImage
import SignalServiceKit
public protocol LinkPreviewViewDraftDelegate: AnyObject {
func linkPreviewDidCancel()
}
// MARK: -
public class LinkPreviewView: ManualStackViewWithLayer {
private weak var draftDelegate: LinkPreviewViewDraftDelegate?
static let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .medium
formatter.timeStyle = .none
return formatter
}()
@available(*, unavailable, message: "use other constructor instead.")
required init(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
private var state: LinkPreviewState?
private var configurationSize: CGSize?
private var shouldReconfigureForBounds = false
fileprivate let rightStack = ManualStackView(name: "rightStack")
fileprivate let textStack = ManualStackView(name: "textStack")
fileprivate let titleStack = ManualStackView(name: "titleStack")
fileprivate let titleLabel = CVLabel()
fileprivate let descriptionLabel = CVLabel()
fileprivate let displayDomainLabel = CVLabel()
fileprivate let linkPreviewImageView = LinkPreviewImageView()
fileprivate var cancelButton: UIView?
public init(draftDelegate: LinkPreviewViewDraftDelegate?) {
self.draftDelegate = draftDelegate
super.init(name: "LinkPreviewView")
if draftDelegate != nil {
self.isUserInteractionEnabled = true
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(wasTapped)))
}
}
private var nonCvcLayoutConstraint: NSLayoutConstraint?
// This view is used in a number of places to display "drafts"
// of outgoing link previews. In these cases, the view will
// be embedded within views using iOS auto layout and will need
// to reconfigure its contents whenever the view size changes.
public func configureForNonCVC(state: LinkPreviewState, isDraft: Bool, hasAsymmetricalRounding: Bool = false) {
self.shouldDeactivateConstraints = false
self.shouldReconfigureForBounds = true
applyConfigurationForNonCVC(
state: state,
isDraft: isDraft,
hasAsymmetricalRounding: hasAsymmetricalRounding
)
addLayoutBlock { view in
guard let linkPreviewView = view as? LinkPreviewView else {
owsFailDebug("Invalid view.")
return
}
if
let state = linkPreviewView.state,
linkPreviewView.shouldReconfigureForBounds,
linkPreviewView.configurationSize != linkPreviewView.bounds.size
{
linkPreviewView.applyConfigurationForNonCVC(
state: state,
isDraft: isDraft,
hasAsymmetricalRounding: hasAsymmetricalRounding
)
}
}
}
private func applyConfigurationForNonCVC(state: LinkPreviewState, isDraft: Bool, hasAsymmetricalRounding: Bool) {
self.reset()
self.configurationSize = bounds.size
let maxWidth = (self.bounds.width > 0 ? self.bounds.width : CGFloat.greatestFiniteMagnitude)
let measurementBuilder = CVCellMeasurement.Builder()
let linkPreviewSize = Self.measure(
maxWidth: maxWidth,
measurementBuilder: measurementBuilder,
state: state,
isDraft: isDraft
)
let cellMeasurement = measurementBuilder.build()
configureForRendering(
state: state,
isDraft: isDraft,
hasAsymmetricalRounding: hasAsymmetricalRounding,
cellMeasurement: cellMeasurement
)
if let nonCvcLayoutConstraint = self.nonCvcLayoutConstraint {
nonCvcLayoutConstraint.constant = linkPreviewSize.height
} else {
self.nonCvcLayoutConstraint = self.autoSetDimension(.height, toSize: linkPreviewSize.height)
}
}
public func configureForRendering(
state: LinkPreviewState,
isDraft: Bool,
hasAsymmetricalRounding: Bool,
cellMeasurement: CVCellMeasurement
) {
self.state = state
let adapter = Self.adapter(forState: state, isDraft: isDraft)
adapter.configureForRendering(
linkPreviewView: self,
hasAsymmetricalRounding: hasAsymmetricalRounding,
cellMeasurement: cellMeasurement
)
}
private static func adapter(forState state: LinkPreviewState, isDraft: Bool) -> LinkPreviewViewAdapter {
if !state.isLoaded {
return LinkPreviewViewAdapterDraftLoading(state: state)
} else if isDraft {
return LinkPreviewViewAdapterDraft(state: state)
} else if state.isGroupInviteLink {
return LinkPreviewViewAdapterGroupLink(state: state)
} else {
if state.hasLoadedImage {
if Self.sentIsHero(state: state) {
return LinkPreviewViewAdapterSentHero(state: state)
} else if state.previewDescription?.isEmpty == false, state.title?.isEmpty == false {
return LinkPreviewViewAdapterSentWithDescription(state: state)
} else {
return LinkPreviewViewAdapterSent(state: state)
}
} else {
return LinkPreviewViewAdapterSent(state: state)
}
}
}
fileprivate static let sentTitleFontSizePoints: CGFloat = 17
fileprivate static let sentDomainFontSizePoints: CGFloat = 12
fileprivate static let sentVSpacing: CGFloat = 4
// The "sent message" mode has two submodes: "hero" and "non-hero".
fileprivate static let sentNonHeroHMargin: CGFloat = 12
fileprivate static let sentNonHeroVMargin: CGFloat = 12
fileprivate static var sentNonHeroLayoutMargins: UIEdgeInsets {
UIEdgeInsets(
top: sentNonHeroVMargin,
left: sentNonHeroHMargin,
bottom: sentNonHeroVMargin,
right: sentNonHeroHMargin
)
}
fileprivate static let sentNonHeroImageSize: CGFloat = 64
fileprivate static let sentNonHeroHSpacing: CGFloat = 8
fileprivate static let sentHeroHMargin: CGFloat = 12
fileprivate static let sentHeroVMargin: CGFloat = 12
fileprivate static var sentHeroLayoutMargins: UIEdgeInsets {
UIEdgeInsets(
top: sentHeroVMargin,
left: sentHeroHMargin,
bottom: sentHeroVMargin,
right: sentHeroHMargin
)
}
fileprivate static let sentTitleLineCount: Int = 2
fileprivate static let sentDescriptionLineCount: Int = 3
fileprivate static func sentIsHero(state: LinkPreviewState) -> Bool {
if isSticker(state: state) || state.isGroupInviteLink {
return false
}
guard let heroWidthPoints = state.conversationStyle?.maxMessageWidth else {
return false
}
// On a 1x device, even tiny images like avatars can satisfy the max message width
// On a 3x device, achieving a 3x pixel match on an og:image is rare
// By fudging the required scaling a bit towards 2.0, we get more consistency at the
// cost of slightly blurrier images on 3x devices.
// These are totally made up numbers so feel free to adjust as necessary.
let heroScalingFactors: [CGFloat: CGFloat] = [
1.0: 2.0,
2.0: 2.0,
3.0: 2.3333
]
let scalingFactor = heroScalingFactors[UIScreen.main.scale] ?? {
// Oh neat a new device! Might want to add it.
owsFailDebug("Unrecognized device scale")
return 2.0
}()
let minimumHeroWidth = heroWidthPoints * scalingFactor
let minimumHeroHeight = minimumHeroWidth * 0.33
let widthSatisfied = state.imagePixelSize.width >= minimumHeroWidth
let heightSatisfied = state.imagePixelSize.height >= minimumHeroHeight
return widthSatisfied && heightSatisfied
}
private static func isSticker(state: LinkPreviewState) -> Bool {
guard let urlString = state.urlString else {
owsFailDebug("Link preview is missing url.")
return false
}
guard let url = URL(string: urlString) else {
owsFailDebug("Could not parse URL.")
return false
}
return StickerPackInfo.isStickerPackShare(url)
}
public static var defaultActivityIndicatorStyle: UIActivityIndicatorView.Style {
.medium
}
// MARK: Events
@objc
private func wasTapped(sender: UIGestureRecognizer) {
guard sender.state == .recognized else {
return
}
if let cancelButton = cancelButton {
// Permissive hot area to make it very easy to cancel the link preview.
if cancelButton.containsGestureLocation(sender, hotAreaInsets: .init(margin: -20)) {
self.draftDelegate?.linkPreviewDidCancel()
return
}
}
}
// MARK: Measurement
public static func measure(
maxWidth: CGFloat,
measurementBuilder: CVCellMeasurement.Builder,
state: LinkPreviewState,
isDraft: Bool
) -> CGSize {
let adapter = Self.adapter(forState: state, isDraft: isDraft)
let size = adapter.measure(
maxWidth: maxWidth,
measurementBuilder: measurementBuilder,
state: state
)
if size.width > maxWidth {
owsFailDebug("size.width: \(size.width) > maxWidth: \(maxWidth)")
}
return size
}
@objc
fileprivate func didTapCancel() {
draftDelegate?.linkPreviewDidCancel()
}
public override func reset() {
super.reset()
self.backgroundColor = nil
rightStack.reset()
textStack.reset()
titleStack.reset()
titleLabel.text = nil
descriptionLabel.text = nil
displayDomainLabel.text = nil
linkPreviewImageView.reset()
for subview in [
rightStack, textStack, titleStack,
titleLabel, descriptionLabel, displayDomainLabel,
linkPreviewImageView
] {
subview.removeFromSuperview()
}
cancelButton = nil
nonCvcLayoutConstraint?.autoRemove()
nonCvcLayoutConstraint = nil
}
}
// MARK: -
private protocol LinkPreviewViewAdapter {
func configureForRendering(
linkPreviewView: LinkPreviewView,
hasAsymmetricalRounding: Bool,
cellMeasurement: CVCellMeasurement
)
func measure(
maxWidth: CGFloat,
measurementBuilder: CVCellMeasurement.Builder,
state: LinkPreviewState
) -> CGSize
}
// MARK: -
extension LinkPreviewViewAdapter {
fileprivate static var measurementKey_rootStack: String { "LinkPreviewView.measurementKey_rootStack" }
fileprivate static var measurementKey_rightStack: String { "LinkPreviewView.measurementKey_rightStack" }
fileprivate static var measurementKey_textStack: String { "LinkPreviewView.measurementKey_textStack" }
fileprivate static var measurementKey_titleStack: String { "LinkPreviewView.measurementKey_titleStack" }
func sentTitleLabel(state: LinkPreviewState) -> UILabel? {
guard let config = sentTitleLabelConfig(state: state) else {
return nil
}
let label = CVLabel()
config.applyForRendering(label: label)
return label
}
func sentTitleLabelConfig(state: LinkPreviewState) -> CVLabelConfig? {
guard let text = state.title else {
return nil
}
return CVLabelConfig.unstyledText(
text,
font: UIFont.dynamicTypeSubheadline.semibold(),
textColor: Theme.primaryTextColor,
numberOfLines: LinkPreviewView.sentTitleLineCount,
lineBreakMode: .byTruncatingTail
)
}
func sentDescriptionLabel(state: LinkPreviewState) -> UILabel? {
guard let config = sentDescriptionLabelConfig(state: state) else {
return nil
}
let label = CVLabel()
config.applyForRendering(label: label)
return label
}
func sentDescriptionLabelConfig(state: LinkPreviewState) -> CVLabelConfig? {
guard let text = state.previewDescription else { return nil }
return CVLabelConfig.unstyledText(
text,
font: UIFont.dynamicTypeSubheadline,
textColor: Theme.primaryTextColor,
numberOfLines: LinkPreviewView.sentDescriptionLineCount,
lineBreakMode: .byTruncatingTail
)
}
func sentDomainLabel(state: LinkPreviewState) -> UILabel {
let label = CVLabel()
sentDomainLabelConfig(state: state).applyForRendering(label: label)
return label
}
func sentDomainLabelConfig(state: LinkPreviewState) -> CVLabelConfig {
var labelText: String
if let displayDomain = state.displayDomain?.nilIfEmpty {
labelText = displayDomain.lowercased()
} else {
labelText = OWSLocalizedString("LINK_PREVIEW_UNKNOWN_DOMAIN", comment: "Label for link previews with an unknown host.").uppercased()
}
if let date = state.date {
labelText.append("\(LinkPreviewView.dateFormatter.string(from: date))")
}
return CVLabelConfig.unstyledText(
labelText,
font: UIFont.dynamicTypeCaption1,
textColor: Theme.secondaryTextAndIconColor,
lineBreakMode: .byTruncatingTail
)
}
func configureSentTextStack(
linkPreviewView: LinkPreviewView,
state: LinkPreviewState,
textStack: ManualStackView,
textStackConfig: ManualStackView.Config,
cellMeasurement: CVCellMeasurement
) {
var subviews = [UIView]()
if let titleLabel = sentTitleLabel(state: state) {
subviews.append(titleLabel)
}
if let descriptionLabel = sentDescriptionLabel(state: state) {
subviews.append(descriptionLabel)
}
let domainLabel = sentDomainLabel(state: state)
subviews.append(domainLabel)
textStack.configure(
config: textStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_textStack,
subviews: subviews
)
}
func measureSentTextStack(
state: LinkPreviewState,
textStackConfig: ManualStackView.Config,
measurementBuilder: CVCellMeasurement.Builder,
maxLabelWidth: CGFloat
) -> CGSize {
var subviewInfos = [ManualStackSubviewInfo]()
if let labelConfig = sentTitleLabelConfig(state: state) {
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: maxLabelWidth)
subviewInfos.append(labelSize.asManualSubviewInfo)
}
if let labelConfig = sentDescriptionLabelConfig(state: state) {
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: maxLabelWidth)
subviewInfos.append(labelSize.asManualSubviewInfo)
}
let labelConfig = sentDomainLabelConfig(state: state)
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: maxLabelWidth)
subviewInfos.append(labelSize.asManualSubviewInfo)
let measurement = ManualStackView.measure(
config: textStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_textStack,
subviewInfos: subviewInfos
)
return measurement.measuredSize
}
}
// MARK: -
private class LinkPreviewViewAdapterDraft: LinkPreviewViewAdapter {
static let draftHeight: CGFloat = 72
static let draftMarginTop: CGFloat = 6
var imageSize: CGFloat { Self.draftHeight }
let cancelSize: CGFloat = 20
let state: LinkPreviewState
init(state: LinkPreviewState) {
self.state = state
}
var rootStackConfig: ManualStackView.Config {
let hMarginLeading: CGFloat = state.hasLoadedImage ? 6 : 12
let hMarginTrailing: CGFloat = 12
let layoutMargins = UIEdgeInsets(
top: Self.draftMarginTop,
leading: hMarginLeading,
bottom: 0,
trailing: hMarginTrailing
)
return ManualStackView.Config(
axis: .horizontal,
alignment: .fill,
spacing: 8,
layoutMargins: layoutMargins
)
}
var rightStackConfig: ManualStackView.Config {
return ManualStackView.Config(
axis: .horizontal,
alignment: .fill,
spacing: 8,
layoutMargins: .zero
)
}
var textStackConfig: ManualStackView.Config {
return ManualStackView.Config(
axis: .vertical,
alignment: .leading,
spacing: 2,
layoutMargins: .zero
)
}
var titleLabelConfig: CVLabelConfig? {
guard let text = state.title?.nilIfEmpty else {
return nil
}
return CVLabelConfig.unstyledText(
text,
font: .dynamicTypeBody,
textColor: Theme.primaryTextColor,
lineBreakMode: .byTruncatingTail
)
}
var descriptionLabelConfig: CVLabelConfig? {
guard let text = state.previewDescription?.nilIfEmpty else {
return nil
}
return CVLabelConfig.unstyledText(
text,
font: .dynamicTypeSubheadline,
textColor: Theme.isDarkThemeEnabled ? .ows_gray05 : .ows_gray90,
lineBreakMode: .byTruncatingTail
)
}
var displayDomainLabelConfig: CVLabelConfig? {
guard let displayDomain = state.displayDomain?.nilIfEmpty else {
return nil
}
var text = displayDomain.lowercased()
if let date = state.date {
text.append("\(LinkPreviewView.dateFormatter.string(from: date))")
}
return CVLabelConfig.unstyledText(
text,
font: .dynamicTypeCaption1,
textColor: Theme.secondaryTextAndIconColor,
lineBreakMode: .byTruncatingTail
)
}
func configureForRendering(
linkPreviewView: LinkPreviewView,
hasAsymmetricalRounding: Bool,
cellMeasurement: CVCellMeasurement
) {
var rootStackSubviews = [UIView]()
var rightStackSubviews = [UIView]()
// Image
if state.hasLoadedImage {
let linkPreviewImageView = linkPreviewView.linkPreviewImageView
if let imageView = linkPreviewImageView.configureForDraft(state: state, hasAsymmetricalRounding: hasAsymmetricalRounding) {
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
rootStackSubviews.append(imageView)
} else {
owsFailDebug("Could not load image.")
let imageView = UIView.transparentSpacer()
rootStackSubviews.append(imageView)
}
}
// Text
var textStackSubviews = [UIView]()
if let titleLabelConfig = self.titleLabelConfig {
let titleLabel = linkPreviewView.titleLabel
titleLabelConfig.applyForRendering(label: titleLabel)
textStackSubviews.append(titleLabel)
}
if let descriptionLabelConfig = self.descriptionLabelConfig {
let descriptionLabel = linkPreviewView.descriptionLabel
descriptionLabelConfig.applyForRendering(label: descriptionLabel)
textStackSubviews.append(descriptionLabel)
}
if let displayDomainLabelConfig = self.displayDomainLabelConfig {
let displayDomainLabel = linkPreviewView.displayDomainLabel
displayDomainLabelConfig.applyForRendering(label: displayDomainLabel)
textStackSubviews.append(displayDomainLabel)
}
let textStack = linkPreviewView.textStack
textStack.configure(
config: textStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_textStack,
subviews: textStackSubviews
)
guard let textMeasurement = cellMeasurement.measurement(key: Self.measurementKey_textStack) else {
owsFailDebug("Missing measurement.")
return
}
let textWrapper = ManualLayoutView(name: "textWrapper")
textWrapper.addSubview(textStack) { view in
var textStackFrame = view.bounds
textStackFrame.size.height = min(textStackFrame.height, textMeasurement.measuredSize.height)
textStackFrame.y = (view.bounds.height - textStackFrame.height) * 0.5
textStack.frame = textStackFrame
}
rightStackSubviews.append(textWrapper)
// Cancel
let cancelButton = OWSButton { [weak linkPreviewView] in
linkPreviewView?.didTapCancel()
}
cancelButton.accessibilityLabel = MessageStrings.removePreviewButtonLabel
linkPreviewView.cancelButton = cancelButton
cancelButton.setTemplateImageName("x-20", tintColor: Theme.secondaryTextAndIconColor)
let cancelSize = self.cancelSize
let cancelContainer = ManualLayoutView(name: "cancelContainer")
cancelContainer.addSubview(cancelButton) { view in
cancelButton.frame = CGRect(
x: 0,
y: view.bounds.width - cancelSize,
width: cancelSize,
height: cancelSize
)
}
rightStackSubviews.append(cancelContainer)
// Right
let rightStack = linkPreviewView.rightStack
rightStack.configure(
config: rightStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_rightStack,
subviews: rightStackSubviews
)
rootStackSubviews.append(rightStack)
// Stroke
let strokeView = UIView()
strokeView.backgroundColor = Theme.secondaryTextAndIconColor
rightStack.addSubviewAsBottomStroke(strokeView)
linkPreviewView.configure(
config: rootStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_rootStack,
subviews: rootStackSubviews
)
}
func measure(
maxWidth: CGFloat,
measurementBuilder: CVCellMeasurement.Builder,
state: LinkPreviewState
) -> CGSize {
var maxLabelWidth = (maxWidth - (
textStackConfig.layoutMargins.totalWidth + rootStackConfig.layoutMargins.totalWidth
))
maxLabelWidth -= cancelSize + rightStackConfig.spacing
var rootStackSubviewInfos = [ManualStackSubviewInfo]()
var rightStackSubviewInfos = [ManualStackSubviewInfo]()
// Image
if state.hasLoadedImage {
rootStackSubviewInfos.append(CGSize.square(imageSize).asManualSubviewInfo(hasFixedSize: true))
maxLabelWidth -= imageSize + rootStackConfig.spacing
}
// Text
var textStackSubviewInfos = [ManualStackSubviewInfo]()
if let labelConfig = titleLabelConfig {
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: maxLabelWidth)
textStackSubviewInfos.append(labelSize.asManualSubviewInfo)
}
if let labelConfig = self.descriptionLabelConfig {
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: maxLabelWidth)
textStackSubviewInfos.append(labelSize.asManualSubviewInfo)
}
if let labelConfig = self.displayDomainLabelConfig {
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: maxLabelWidth)
textStackSubviewInfos.append(labelSize.asManualSubviewInfo)
}
let textStackMeasurement = ManualStackView.measure(
config: textStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_textStack,
subviewInfos: textStackSubviewInfos
)
rightStackSubviewInfos.append(textStackMeasurement.measuredSize.asManualSubviewInfo)
// Right
rightStackSubviewInfos.append(CGSize.square(cancelSize).asManualSubviewInfo(hasFixedWidth: true))
let rightStackMeasurement = ManualStackView.measure(
config: rightStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_rightStack,
subviewInfos: rightStackSubviewInfos
)
rootStackSubviewInfos.append(rightStackMeasurement.measuredSize.asManualSubviewInfo)
let rootStackMeasurement = ManualStackView.measure(
config: rootStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_rootStack,
subviewInfos: rootStackSubviewInfos,
maxWidth: maxWidth
)
var rootStackSize = rootStackMeasurement.measuredSize
rootStackSize.height = LinkPreviewViewAdapterDraft.draftHeight + LinkPreviewViewAdapterDraft.draftMarginTop
return rootStackSize
}
}
// MARK: -
private class LinkPreviewViewAdapterDraftLoading: LinkPreviewViewAdapter {
let activityIndicatorSize = CGSize.square(25)
let state: LinkPreviewState
init(state: LinkPreviewState) {
self.state = state
}
var rootStackConfig: ManualStackView.Config {
ManualStackView.Config(
axis: .vertical,
alignment: .fill,
spacing: 0,
layoutMargins: .zero
)
}
func configureForRendering(
linkPreviewView: LinkPreviewView,
hasAsymmetricalRounding: Bool,
cellMeasurement: CVCellMeasurement
) {
let activityIndicatorStyle = state.activityIndicatorStyle
let activityIndicator = UIActivityIndicatorView(style: activityIndicatorStyle)
activityIndicator.startAnimating()
linkPreviewView.addSubviewToCenterOnSuperview(activityIndicator, size: activityIndicatorSize)
let strokeView = UIView()
strokeView.backgroundColor = Theme.secondaryTextAndIconColor
linkPreviewView.addSubviewAsBottomStroke(strokeView, layoutMargins: UIEdgeInsets(hMargin: 12, vMargin: 0))
linkPreviewView.configure(
config: rootStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_rootStack,
subviews: []
)
}
func measure(
maxWidth: CGFloat,
measurementBuilder: CVCellMeasurement.Builder,
state: LinkPreviewState
) -> CGSize {
let rootStackMeasurement = ManualStackView.measure(
config: rootStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_rootStack,
subviewInfos: [],
maxWidth: maxWidth
)
var rootStackSize = rootStackMeasurement.measuredSize
rootStackSize.height = LinkPreviewViewAdapterDraft.draftHeight + LinkPreviewViewAdapterDraft.draftMarginTop
return rootStackSize
}
}
// MARK: -
private class LinkPreviewViewAdapterGroupLink: LinkPreviewViewAdapter {
let state: LinkPreviewState
init(state: LinkPreviewState) {
self.state = state
}
var rootStackConfig: ManualStackView.Config {
ManualStackView.Config(
axis: .horizontal,
alignment: .fill,
spacing: LinkPreviewView.sentNonHeroHSpacing,
layoutMargins: LinkPreviewView.sentNonHeroLayoutMargins
)
}
var textStackConfig: ManualStackView.Config {
return ManualStackView.Config(
axis: .vertical,
alignment: .leading,
spacing: LinkPreviewView.sentVSpacing,
layoutMargins: .zero
)
}
func configureForRendering(
linkPreviewView: LinkPreviewView,
hasAsymmetricalRounding: Bool,
cellMeasurement: CVCellMeasurement
) {
linkPreviewView.backgroundColor = Theme.secondaryBackgroundColor
var rootStackSubviews = [UIView]()
let linkPreviewImageView = linkPreviewView.linkPreviewImageView
if state.hasLoadedImage {
if let imageView = linkPreviewImageView.configure(state: state, rounding: .circular) {
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
rootStackSubviews.append(imageView)
} else {
owsFailDebug("Could not load image.")
rootStackSubviews.append(UIView.transparentSpacer())
}
}
let textStack = linkPreviewView.textStack
var textStackSubviews = [UIView]()
if let titleLabel = sentTitleLabel(state: state) {
textStackSubviews.append(titleLabel)
}
if let descriptionLabel = sentDescriptionLabel(state: state) {
textStackSubviews.append(descriptionLabel)
}
textStack.configure(
config: textStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_textStack,
subviews: textStackSubviews
)
rootStackSubviews.append(textStack)
linkPreviewView.configure(
config: rootStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_rootStack,
subviews: rootStackSubviews
)
}
func measure(
maxWidth: CGFloat,
measurementBuilder: CVCellMeasurement.Builder,
state: LinkPreviewState
) -> CGSize {
var maxLabelWidth = (maxWidth - (
textStackConfig.layoutMargins.totalWidth + rootStackConfig.layoutMargins.totalWidth
))
var rootStackSubviewInfos = [ManualStackSubviewInfo]()
if state.hasLoadedImage {
let imageSize = LinkPreviewView.sentNonHeroImageSize
rootStackSubviewInfos.append(CGSize.square(imageSize).asManualSubviewInfo(hasFixedSize: true))
maxLabelWidth -= imageSize + rootStackConfig.spacing
}
maxLabelWidth = max(0, maxLabelWidth)
var textStackSubviewInfos = [ManualStackSubviewInfo]()
if let labelConfig = sentTitleLabelConfig(state: state) {
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: maxLabelWidth)
textStackSubviewInfos.append(labelSize.asManualSubviewInfo)
}
if let labelConfig = sentDescriptionLabelConfig(state: state) {
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: maxLabelWidth)
textStackSubviewInfos.append(labelSize.asManualSubviewInfo)
}
let textStackMeasurement = ManualStackView.measure(
config: textStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_textStack,
subviewInfos: textStackSubviewInfos
)
rootStackSubviewInfos.append(textStackMeasurement.measuredSize.asManualSubviewInfo)
let rootStackMeasurement = ManualStackView.measure(
config: rootStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_rootStack,
subviewInfos: rootStackSubviewInfos,
maxWidth: maxWidth
)
return rootStackMeasurement.measuredSize
}
}
// MARK: -
private class LinkPreviewViewAdapterSentHero: LinkPreviewViewAdapter {
let state: LinkPreviewState
init(state: LinkPreviewState) {
self.state = state
}
var rootStackConfig: ManualStackView.Config {
ManualStackView.Config(
axis: .vertical,
alignment: .fill,
spacing: 0,
layoutMargins: .zero
)
}
var textStackConfig: ManualStackView.Config {
return ManualStackView.Config(
axis: .vertical,
alignment: .leading,
spacing: LinkPreviewView.sentVSpacing,
layoutMargins: LinkPreviewView.sentHeroLayoutMargins
)
}
func configureForRendering(
linkPreviewView: LinkPreviewView,
hasAsymmetricalRounding: Bool,
cellMeasurement: CVCellMeasurement
) {
linkPreviewView.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray75 : .ows_gray02
var rootStackSubviews = [UIView]()
let linkPreviewImageView = linkPreviewView.linkPreviewImageView
if let imageView = linkPreviewImageView.configure(state: state) {
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
rootStackSubviews.append(imageView)
} else {
owsFailDebug("Could not load image.")
rootStackSubviews.append(UIView.transparentSpacer())
}
let textStack = linkPreviewView.textStack
configureSentTextStack(
linkPreviewView: linkPreviewView,
state: state,
textStack: textStack,
textStackConfig: textStackConfig,
cellMeasurement: cellMeasurement
)
rootStackSubviews.append(textStack)
linkPreviewView.configure(
config: rootStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_rootStack,
subviews: rootStackSubviews
)
}
func measure(
maxWidth: CGFloat,
measurementBuilder: CVCellMeasurement.Builder,
state: LinkPreviewState
) -> CGSize {
guard let conversationStyle = state.conversationStyle else {
owsFailDebug("Missing conversationStyle.")
return .zero
}
var rootStackSubviewInfos = [ManualStackSubviewInfo]()
let heroImageSize = sentHeroImageSize(
state: state,
conversationStyle: conversationStyle,
maxWidth: maxWidth
)
rootStackSubviewInfos.append(heroImageSize.asManualSubviewInfo)
var maxLabelWidth = (maxWidth - (
textStackConfig.layoutMargins.totalWidth + rootStackConfig.layoutMargins.totalWidth
))
maxLabelWidth = max(0, maxLabelWidth)
let textStackSize = measureSentTextStack(
state: state,
textStackConfig: textStackConfig,
measurementBuilder: measurementBuilder,
maxLabelWidth: maxLabelWidth
)
rootStackSubviewInfos.append(textStackSize.asManualSubviewInfo)
let rootStackMeasurement = ManualStackView.measure(
config: rootStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_rootStack,
subviewInfos: rootStackSubviewInfos,
maxWidth: maxWidth
)
return rootStackMeasurement.measuredSize
}
func sentHeroImageSize(
state: LinkPreviewState,
conversationStyle: ConversationStyle,
maxWidth: CGFloat
) -> CGSize {
let imageHeightWidthRatio = (state.imagePixelSize.height / state.imagePixelSize.width)
let maxMessageWidth = min(maxWidth, conversationStyle.maxMessageWidth)
let minImageHeight: CGFloat = maxMessageWidth * 0.5
let maxImageHeight: CGFloat = maxMessageWidth
let rawImageHeight = maxMessageWidth * imageHeightWidthRatio
let normalizedHeight: CGFloat = min(maxImageHeight, max(minImageHeight, rawImageHeight))
return CGSize.ceil(CGSize(width: maxMessageWidth, height: normalizedHeight))
}
}
// MARK: -
private class LinkPreviewViewAdapterSent: LinkPreviewViewAdapter {
let state: LinkPreviewState
init(state: LinkPreviewState) {
self.state = state
}
var rootStackConfig: ManualStackView.Config {
ManualStackView.Config(
axis: .horizontal,
alignment: .center,
spacing: LinkPreviewView.sentNonHeroHSpacing,
layoutMargins: LinkPreviewView.sentNonHeroLayoutMargins
)
}
var textStackConfig: ManualStackView.Config {
return ManualStackView.Config(
axis: .vertical,
alignment: .leading,
spacing: LinkPreviewView.sentVSpacing,
layoutMargins: .zero
)
}
func configureForRendering(
linkPreviewView: LinkPreviewView,
hasAsymmetricalRounding: Bool,
cellMeasurement: CVCellMeasurement
) {
linkPreviewView.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray75 : .ows_gray02
var rootStackSubviews = [UIView]()
if state.hasLoadedImage {
let linkPreviewImageView = linkPreviewView.linkPreviewImageView
if let imageView = linkPreviewImageView.configure(state: state) {
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
rootStackSubviews.append(imageView)
} else {
owsFailDebug("Could not load image.")
rootStackSubviews.append(UIView.transparentSpacer())
}
}
let textStack = linkPreviewView.textStack
configureSentTextStack(
linkPreviewView: linkPreviewView,
state: state,
textStack: textStack,
textStackConfig: textStackConfig,
cellMeasurement: cellMeasurement
)
rootStackSubviews.append(textStack)
linkPreviewView.configure(
config: rootStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_rootStack,
subviews: rootStackSubviews
)
}
func measure(
maxWidth: CGFloat,
measurementBuilder: CVCellMeasurement.Builder,
state: LinkPreviewState
) -> CGSize {
var maxLabelWidth = (maxWidth - (
textStackConfig.layoutMargins.totalWidth + rootStackConfig.layoutMargins.totalWidth
))
var rootStackSubviewInfos = [ManualStackSubviewInfo]()
if state.hasLoadedImage {
let imageSize = LinkPreviewView.sentNonHeroImageSize
rootStackSubviewInfos.append(CGSize.square(imageSize).asManualSubviewInfo(hasFixedSize: true))
maxLabelWidth -= imageSize + rootStackConfig.spacing
}
maxLabelWidth = max(0, maxLabelWidth)
let textStackSize = measureSentTextStack(
state: state,
textStackConfig: textStackConfig,
measurementBuilder: measurementBuilder,
maxLabelWidth: maxLabelWidth
)
rootStackSubviewInfos.append(textStackSize.asManualSubviewInfo)
let rootStackMeasurement = ManualStackView.measure(
config: rootStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_rootStack,
subviewInfos: rootStackSubviewInfos,
maxWidth: maxWidth
)
return rootStackMeasurement.measuredSize
}
}
// MARK: -
private class LinkPreviewViewAdapterSentWithDescription: LinkPreviewViewAdapter {
let state: LinkPreviewState
init(state: LinkPreviewState) {
self.state = state
}
var rootStackConfig: ManualStackView.Config {
ManualStackView.Config(
axis: .vertical,
alignment: .fill,
spacing: LinkPreviewView.sentVSpacing,
layoutMargins: LinkPreviewView.sentNonHeroLayoutMargins
)
}
var titleStackConfig: ManualStackView.Config {
return ManualStackView.Config(
axis: .horizontal,
alignment: .center,
spacing: LinkPreviewView.sentNonHeroHSpacing,
layoutMargins: UIEdgeInsets(top: 0, left: 0, bottom: LinkPreviewView.sentVSpacing, right: 0)
)
}
func configureForRendering(
linkPreviewView: LinkPreviewView,
hasAsymmetricalRounding: Bool,
cellMeasurement: CVCellMeasurement
) {
linkPreviewView.backgroundColor = Theme.isDarkThemeEnabled ? .ows_gray75 : .ows_gray02
var titleStackSubviews = [UIView]()
if state.hasLoadedImage {
let linkPreviewImageView = linkPreviewView.linkPreviewImageView
if let imageView = linkPreviewImageView.configure(state: state) {
imageView.contentMode = .scaleAspectFill
imageView.clipsToBounds = true
titleStackSubviews.append(imageView)
} else {
owsFailDebug("Could not load image.")
titleStackSubviews.append(UIView.transparentSpacer())
}
}
if let titleLabel = sentTitleLabel(state: state) {
titleStackSubviews.append(titleLabel)
} else {
owsFailDebug("Text stack required")
}
var rootStackSubviews = [UIView]()
let titleStack = linkPreviewView.titleStack
titleStack.configure(
config: titleStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_titleStack,
subviews: titleStackSubviews
)
rootStackSubviews.append(titleStack)
if let descriptionLabel = sentDescriptionLabel(state: state) {
rootStackSubviews.append(descriptionLabel)
} else {
owsFailDebug("Description label required")
}
let domainLabel = sentDomainLabel(state: state)
rootStackSubviews.append(domainLabel)
linkPreviewView.configure(
config: rootStackConfig,
cellMeasurement: cellMeasurement,
measurementKey: Self.measurementKey_rootStack,
subviews: rootStackSubviews
)
}
func measure(
maxWidth: CGFloat,
measurementBuilder: CVCellMeasurement.Builder,
state: LinkPreviewState
) -> CGSize {
var maxRootLabelWidth = (maxWidth - (
titleStackConfig.layoutMargins.totalWidth + rootStackConfig.layoutMargins.totalWidth
))
maxRootLabelWidth = max(0, maxRootLabelWidth)
var maxTitleLabelWidth = maxRootLabelWidth
var titleStackSubviewInfos = [ManualStackSubviewInfo]()
if state.hasLoadedImage {
let imageSize = LinkPreviewView.sentNonHeroImageSize
titleStackSubviewInfos.append(CGSize.square(imageSize).asManualSubviewInfo(hasFixedSize: true))
maxTitleLabelWidth -= imageSize + titleStackConfig.spacing
}
maxTitleLabelWidth = max(0, maxTitleLabelWidth)
if let labelConfig = sentTitleLabelConfig(state: state) {
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: maxTitleLabelWidth)
titleStackSubviewInfos.append(labelSize.asManualSubviewInfo)
} else {
owsFailDebug("Text stack required")
}
var rootStackSubviewInfos = [ManualStackSubviewInfo]()
let titleStackMeasurement = ManualStackView.measure(
config: titleStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_titleStack,
subviewInfos: titleStackSubviewInfos
)
rootStackSubviewInfos.append(titleStackMeasurement.measuredSize.asManualSubviewInfo)
if let labelConfig = sentDescriptionLabelConfig(state: state) {
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: maxRootLabelWidth)
rootStackSubviewInfos.append(labelSize.asManualSubviewInfo)
} else {
owsFailDebug("Description label required")
}
do {
let labelConfig = sentDomainLabelConfig(state: state)
let labelSize = CVText.measureLabel(config: labelConfig, maxWidth: maxRootLabelWidth)
rootStackSubviewInfos.append(labelSize.asManualSubviewInfo)
}
let rootStackMeasurement = ManualStackView.measure(
config: rootStackConfig,
measurementBuilder: measurementBuilder,
measurementKey: Self.measurementKey_rootStack,
subviewInfos: rootStackSubviewInfos,
maxWidth: maxWidth
)
return rootStackMeasurement.measuredSize
}
}
// MARK: -
private class LinkPreviewImageView: CVImageView {
fileprivate enum Rounding: UInt {
case standard
case asymmetrical
case circular
}
fileprivate var rounding: Rounding = .standard {
didSet {
if rounding == .asymmetrical {
layer.mask = asymmetricCornerMask
} else {
layer.mask = nil
}
updateMaskLayer()
}
}
fileprivate var isHero = false {
didSet {
updateMaskLayer()
}
}
// We only need to use a more complicated corner mask if we're
// drawing asymmetric corners. This is an exceptional case to match
// the input toolbar curve.
private let asymmetricCornerMask = CAShapeLayer()
private static let configurationIdCounter = AtomicUInt(0, lock: .sharedGlobal)
private var configurationId: UInt = 0
init() {
super.init(frame: .zero)
}
@available(*, unavailable, message: "use other constructor instead.")
required init?(coder aDecoder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public override func reset() {
super.reset()
rounding = .standard
isHero = false
configurationId = 0
}
override var bounds: CGRect {
didSet {
updateMaskLayer()
}
}
override var frame: CGRect {
didSet {
updateMaskLayer()
}
}
override var center: CGPoint {
didSet {
updateMaskLayer()
}
}
private func updateMaskLayer() {
let layerBounds = self.bounds
let bigRounding: CGFloat = 14
let smallRounding: CGFloat = 6
switch rounding {
case .standard:
layer.cornerRadius = smallRounding
layer.maskedCorners = isHero ? .top : .all
case .circular:
layer.cornerRadius = bounds.size.smallerAxis / 2
layer.maskedCorners = .all
case .asymmetrical:
// This uses a more expensive layer mask to clip corners
// with different radii.
// This should only be used in the input toolbar so perf is
// less of a concern here.
owsAssertDebug(!isHero, "Link preview drafts never use hero images")
let upperLeft = CGPoint(x: 0, y: 0)
let upperRight = CGPoint(x: layerBounds.size.width, y: 0)
let lowerRight = CGPoint(x: layerBounds.size.width, y: layerBounds.size.height)
let lowerLeft = CGPoint(x: 0, y: layerBounds.size.height)
let upperLeftRounding: CGFloat = CurrentAppContext().isRTL ? smallRounding : bigRounding
let upperRightRounding: CGFloat = CurrentAppContext().isRTL ? bigRounding : smallRounding
let lowerRightRounding = smallRounding
let lowerLeftRounding = smallRounding
let path = UIBezierPath()
// It's sufficient to "draw" the rounded corners and not the edges that connect them.
path.addArc(
withCenter: upperLeft.offsetBy(dx: +upperLeftRounding).offsetBy(dy: +upperLeftRounding),
radius: upperLeftRounding,
startAngle: CGFloat.pi * 1.0,
endAngle: CGFloat.pi * 1.5,
clockwise: true
)
path.addArc(
withCenter: upperRight.offsetBy(dx: -upperRightRounding).offsetBy(dy: +upperRightRounding),
radius: upperRightRounding,
startAngle: CGFloat.pi * 1.5,
endAngle: CGFloat.pi * 0.0,
clockwise: true
)
path.addArc(
withCenter: lowerRight.offsetBy(dx: -lowerRightRounding).offsetBy(dy: -lowerRightRounding),
radius: lowerRightRounding,
startAngle: CGFloat.pi * 0.0,
endAngle: CGFloat.pi * 0.5,
clockwise: true
)
path.addArc(
withCenter: lowerLeft.offsetBy(dx: +lowerLeftRounding).offsetBy(dy: -lowerLeftRounding),
radius: lowerLeftRounding,
startAngle: CGFloat.pi * 0.5,
endAngle: CGFloat.pi * 1.0,
clockwise: true
)
asymmetricCornerMask.path = path.cgPath
}
}
// MARK: -
func configureForDraft(state: LinkPreviewState, hasAsymmetricalRounding: Bool) -> UIImageView? {
guard state.isLoaded else {
owsFailDebug("State not loaded.")
return nil
}
guard state.imageState == .loaded else {
return nil
}
self.rounding = hasAsymmetricalRounding ? .asymmetrical : .standard
let configurationId = Self.configurationIdCounter.increment()
self.configurationId = configurationId
state.imageAsync(thumbnailQuality: .small) { [weak self] image in
DispatchMainThreadSafe {
guard let self = self else { return }
guard self.configurationId == configurationId else { return }
self.image = image
}
}
return self
}
fileprivate static let mediaCache = LRUCache<LinkPreviewImageCacheKey, UIImage>(
maxSize: 2, shouldEvacuateInBackground: true
)
func configure(state: LinkPreviewState, rounding: LinkPreviewImageView.Rounding? = nil) -> UIImageView? {
guard state.isLoaded else {
owsFailDebug("State not loaded.")
return nil
}
guard state.imageState == .loaded else {
return nil
}
self.rounding = rounding ?? .standard
let isHero = LinkPreviewView.sentIsHero(state: state)
self.isHero = isHero
let configurationId = Self.configurationIdCounter.increment()
self.configurationId = configurationId
let thumbnailQuality: AttachmentThumbnailQuality = isHero ? .medium : .small
if
let cacheKey = state.imageCacheKey(thumbnailQuality: thumbnailQuality),
let image = Self.mediaCache.get(key: cacheKey)
{
self.image = image
} else {
state.imageAsync(thumbnailQuality: thumbnailQuality) { [weak self] image in
DispatchMainThreadSafe {
guard let self = self else { return }
guard self.configurationId == configurationId else { return }
self.image = image
if let cacheKey = state.imageCacheKey(thumbnailQuality: thumbnailQuality) {
Self.mediaCache.set(key: cacheKey, value: image)
}
}
}
}
return self
}
}
// MARK: -
public extension CGPoint {
func offsetBy(dx: CGFloat = 0.0, dy: CGFloat = 0.0) -> CGPoint {
return offsetBy(CGVector(dx: dx, dy: dy))
}
func offsetBy(_ vector: CGVector) -> CGPoint {
return CGPoint(x: x + vector.dx, y: y + vector.dy)
}
}
// MARK: -
public extension ManualLayoutView {
func addSubviewAsBottomStroke(_ subview: UIView, layoutMargins: UIEdgeInsets = .zero) {
addSubview(subview) { view in
var subviewFrame = view.bounds.inset(by: layoutMargins)
subviewFrame.size.height = .hairlineWidth
subviewFrame.y = view.bounds.height - (subviewFrame.height + layoutMargins.bottom)
subview.frame = subviewFrame
}
}
}