384 lines
15 KiB
Swift
384 lines
15 KiB
Swift
//
|
|
// Copyright (c) 2022 Open Whisper Systems. All rights reserved.
|
|
//
|
|
|
|
import Foundation
|
|
import UIKit
|
|
import SignalServiceKit
|
|
|
|
public class TextAttachmentView: UIView {
|
|
private(set) weak var linkPreviewView: UIView?
|
|
private let textAttachment: TextAttachment
|
|
|
|
public init(attachment: TextAttachment) {
|
|
self.textAttachment = attachment
|
|
|
|
super.init(frame: .zero)
|
|
|
|
switch attachment.background {
|
|
case .color(let color):
|
|
backgroundColor = color
|
|
case .gradient(let gradient):
|
|
addGradientBackground(gradient)
|
|
}
|
|
|
|
let contentStackView = UIStackView()
|
|
contentStackView.axis = .vertical
|
|
contentStackView.alignment = .center
|
|
contentStackView.spacing = 16
|
|
addSubview(contentStackView)
|
|
contentStackView.autoPinEdgesToSuperviewEdges()
|
|
|
|
if let text = attachment.text {
|
|
let label = UILabel()
|
|
label.numberOfLines = 0
|
|
label.textColor = attachment.textForegroundColor ?? Theme.darkThemePrimaryColor
|
|
label.text = transformedText(text, for: attachment.textStyle)
|
|
label.textAlignment = .center
|
|
label.font = font(for: attachment.textStyle)
|
|
label.adjustsFontSizeToFitWidth = true
|
|
label.minimumScaleFactor = 0.2
|
|
|
|
if let textBackgroundColor = attachment.textBackgroundColor {
|
|
let labelBackgroundView = UIView()
|
|
labelBackgroundView.layoutMargins = UIEdgeInsets(hMargin: 16, vMargin: 16)
|
|
labelBackgroundView.backgroundColor = textBackgroundColor
|
|
labelBackgroundView.layer.cornerRadius = 18
|
|
|
|
labelBackgroundView.addSubview(label)
|
|
label.autoPinEdgesToSuperviewMargins()
|
|
|
|
let labelWrapper = UIView()
|
|
labelWrapper.addSubview(labelBackgroundView)
|
|
labelBackgroundView.autoPinWidthToSuperview(withMargin: 24)
|
|
labelBackgroundView.autoPinHeightToSuperview()
|
|
contentStackView.addArrangedSubview(labelWrapper)
|
|
} else {
|
|
let labelWrapper = UIView()
|
|
labelWrapper.addSubview(label)
|
|
label.autoPinWidthToSuperview(withMargin: 40)
|
|
label.autoPinHeightToSuperview()
|
|
contentStackView.addArrangedSubview(labelWrapper)
|
|
}
|
|
}
|
|
|
|
if let linkPreviewView = buildLinkPreviewView(attachment.preview) {
|
|
let previewWrapper = UIView()
|
|
previewWrapper.addSubview(linkPreviewView)
|
|
linkPreviewView.autoPinWidthToSuperview(withMargin: 36)
|
|
linkPreviewView.autoPinHeightToSuperview()
|
|
contentStackView.addArrangedSubview(previewWrapper)
|
|
self.linkPreviewView = linkPreviewView
|
|
}
|
|
|
|
// Keep content vertically centered, but limit to screen size.
|
|
let topSpacer = UIView.vStretchingSpacer()
|
|
let bottomSpacer = UIView.vStretchingSpacer()
|
|
contentStackView.insertArrangedSubview(topSpacer, at: 0)
|
|
contentStackView.addArrangedSubview(bottomSpacer)
|
|
topSpacer.autoMatch(.height, to: .height, of: bottomSpacer)
|
|
}
|
|
|
|
public func asThumbnailView() -> TextAttachmentThumbnailView { TextAttachmentThumbnailView(self) }
|
|
|
|
public var isPresentingLinkTooltip: Bool { linkPreviewTooltipView != nil }
|
|
|
|
private var linkPreviewTooltipView: LinkPreviewTooltipView?
|
|
public func willHandleTapGesture(_ gesture: UITapGestureRecognizer) -> Bool {
|
|
if let linkPreviewTooltipView = linkPreviewTooltipView {
|
|
if let container = linkPreviewTooltipView.superview,
|
|
linkPreviewTooltipView.frame.contains(gesture.location(in: container)) {
|
|
CurrentAppContext().open(linkPreviewTooltipView.url)
|
|
} else {
|
|
linkPreviewTooltipView.removeFromSuperview()
|
|
self.linkPreviewTooltipView = nil
|
|
}
|
|
|
|
return true
|
|
} else if let linkPreviewView = linkPreviewView,
|
|
let urlString = textAttachment.preview?.urlString,
|
|
let container = linkPreviewView.superview,
|
|
linkPreviewView.frame.contains(gesture.location(in: container)) {
|
|
let tooltipView = LinkPreviewTooltipView(
|
|
fromView: self,
|
|
tailReferenceView: linkPreviewView,
|
|
url: URL(string: urlString)!
|
|
)
|
|
self.linkPreviewTooltipView = tooltipView
|
|
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
private func transformedText(_ text: String, for textStyle: TextAttachment.TextStyle) -> String {
|
|
guard case .condensed = textStyle else { return text }
|
|
return text.uppercased()
|
|
}
|
|
|
|
private func font(for textStyle: TextAttachment.TextStyle) -> UIFont {
|
|
let attributes: [UIFontDescriptor.AttributeName: Any]
|
|
|
|
switch textStyle {
|
|
case .regular:
|
|
attributes = [.name: "Inter-Regular_Bold"]
|
|
case .bold:
|
|
attributes = [.name: "Inter-Regular_Black"]
|
|
case .serif:
|
|
attributes = [.name: "EBGaramond-Regular"]
|
|
case .script:
|
|
attributes = [.name: "Parisienne-Regular"]
|
|
case .condensed:
|
|
// TODO: Ideally we could set an attribute to make this font
|
|
// all caps, but iOS deprecated that ability and didn't add
|
|
// a new equivalent function.
|
|
attributes = [.name: "BarlowCondensed-Medium"]
|
|
}
|
|
|
|
// TODO: Eventually we'll want to provide a cascadeList here to fallback
|
|
// to different fonts for different scripts rather than just relying on
|
|
// the built in OS fallbacks that don't tend to match the desired style.
|
|
let descriptor = UIFontDescriptor(fontAttributes: attributes)
|
|
|
|
return UIFont(descriptor: descriptor, size: 28)
|
|
}
|
|
|
|
private func addGradientBackground(_ gradient: TextAttachment.Background.Gradient) {
|
|
class GradientLayerView: UIView {
|
|
override class var layerClass: AnyClass { CAGradientLayer.self }
|
|
var gradientLayer: CAGradientLayer { layer as! CAGradientLayer }
|
|
}
|
|
|
|
let gradientView = GradientLayerView()
|
|
gradientView.gradientLayer.colors = [
|
|
gradient.startColor.cgColor,
|
|
gradient.endColor.cgColor
|
|
]
|
|
gradientView.gradientLayer.setAngle(gradient.angle)
|
|
|
|
addSubview(gradientView)
|
|
gradientView.autoPinEdgesToSuperviewEdges()
|
|
}
|
|
|
|
private static let dateFormatter: DateFormatter = {
|
|
let formatter = DateFormatter()
|
|
formatter.dateStyle = .medium
|
|
formatter.timeStyle = .none
|
|
return formatter
|
|
}()
|
|
|
|
private func buildLinkPreviewView(_ preview: OWSLinkPreview?) -> UIView? {
|
|
guard let preview = preview else { return nil }
|
|
|
|
let previewHStack = UIStackView()
|
|
previewHStack.axis = .horizontal
|
|
previewHStack.alignment = .center
|
|
previewHStack.spacing = 8
|
|
previewHStack.isLayoutMarginsRelativeArrangement = true
|
|
previewHStack.layoutMargins = UIEdgeInsets(hMargin: 12, vMargin: 12)
|
|
previewHStack.addBackgroundView(withBackgroundColor: .ows_blackAlpha40, cornerRadius: 12)
|
|
|
|
if let imageAttachmentId = preview.imageAttachmentId {
|
|
if let attachment = databaseStorage.read(block: { TSAttachment.anyFetch(uniqueId: imageAttachmentId, transaction: $0) }) {
|
|
if let stream = attachment as? TSAttachmentStream {
|
|
let thumbnailImageView = UIImageView()
|
|
thumbnailImageView.layer.cornerRadius = 8
|
|
thumbnailImageView.clipsToBounds = true
|
|
thumbnailImageView.contentMode = .scaleAspectFill
|
|
thumbnailImageView.autoSetDimensions(to: CGSize(square: 76))
|
|
previewHStack.addArrangedSubview(thumbnailImageView)
|
|
|
|
stream.thumbnailImageSmall { thumbnail in
|
|
thumbnailImageView.image = thumbnail
|
|
} failure: {
|
|
owsFailDebug("Failed to generate thumbnail preview")
|
|
}
|
|
} else {
|
|
Logger.warn("Not rendering thumbnail for undownloaded attachment")
|
|
}
|
|
} else {
|
|
owsFailDebug("Missing attachment with id \(imageAttachmentId)")
|
|
}
|
|
}
|
|
|
|
let previewVStack = UIStackView()
|
|
previewVStack.axis = .vertical
|
|
previewVStack.alignment = .leading
|
|
previewHStack.addArrangedSubview(previewVStack)
|
|
|
|
if let title = preview.title {
|
|
let titleLabel = UILabel()
|
|
titleLabel.text = title
|
|
titleLabel.font = .boldSystemFont(ofSize: 16)
|
|
titleLabel.textColor = Theme.darkThemePrimaryColor
|
|
titleLabel.numberOfLines = 2
|
|
titleLabel.setCompressionResistanceVerticalHigh()
|
|
titleLabel.setContentHuggingVerticalHigh()
|
|
previewVStack.addArrangedSubview(titleLabel)
|
|
}
|
|
|
|
if let description = preview.previewDescription {
|
|
let descriptionLabel = UILabel()
|
|
descriptionLabel.text = description
|
|
descriptionLabel.font = .systemFont(ofSize: 12)
|
|
descriptionLabel.textColor = Theme.darkThemePrimaryColor
|
|
descriptionLabel.numberOfLines = 3
|
|
descriptionLabel.setCompressionResistanceVerticalHigh()
|
|
descriptionLabel.setContentHuggingVerticalHigh()
|
|
previewVStack.addArrangedSubview(descriptionLabel)
|
|
}
|
|
|
|
let footerLabel = UILabel()
|
|
footerLabel.font = .systemFont(ofSize: 12)
|
|
footerLabel.numberOfLines = 2
|
|
footerLabel.textColor = Theme.darkThemeSecondaryTextAndIconColor
|
|
footerLabel.setCompressionResistanceVerticalHigh()
|
|
footerLabel.setContentHuggingVerticalHigh()
|
|
previewVStack.addArrangedSubview(footerLabel)
|
|
|
|
var footerText: String
|
|
if let displayDomain = OWSLinkPreviewManager.displayDomain(forUrl: preview.urlString) {
|
|
footerText = displayDomain.lowercased()
|
|
} else {
|
|
footerText = NSLocalizedString(
|
|
"LINK_PREVIEW_UNKNOWN_DOMAIN",
|
|
comment: "Label for link previews with an unknown host."
|
|
).uppercased()
|
|
}
|
|
if let date = preview.date {
|
|
footerText.append(" ⋅ \(Self.dateFormatter.string(from: date))")
|
|
}
|
|
footerLabel.text = footerText
|
|
|
|
return previewHStack
|
|
}
|
|
}
|
|
|
|
private extension CAGradientLayer {
|
|
/// Sets the `startPoint` and `endPoint` of the layer to reflect an angle in degrees
|
|
/// where 0° starts at 12 o'clock and proceeds in a clockwise direction.
|
|
func setAngle(_ angle: UInt32) {
|
|
// While design provides gradients with 0° at 12 o'clock, core animation's
|
|
// coordinate system works with 0° at 3 o'clock moving in a counter clockwise
|
|
// direction. We need to convert the provided angle accordingly before
|
|
// calculating the gradient's start and end points.
|
|
|
|
let caAngle =
|
|
(360 - angle) // Invert to counter clockwise direction
|
|
+ 90 // Rotate 90° counter clockwise to shift the start from 3 o'clock to 12 o'clock
|
|
|
|
let radians = CGFloat(caAngle) * .pi / 180.0
|
|
|
|
// (x,y) in terms of the signed unit circle
|
|
var endPoint = CGPoint(x: cos(radians), y: sin(radians))
|
|
|
|
// extrapolate to signed unit square
|
|
if abs(endPoint.x) > abs(endPoint.y) {
|
|
endPoint.x = endPoint.x > 0 ? 1 : -1
|
|
endPoint.y = endPoint.x * tan(radians)
|
|
} else {
|
|
endPoint.y = endPoint.y > 0 ? 1 : -1
|
|
endPoint.x = endPoint.y / tan(radians)
|
|
}
|
|
|
|
// The signed unit square is a coordinate space from:
|
|
// (-1,-1) to (1,1), but the gradient coordinate space
|
|
// ranges from (0,0) to (1,1) with 0 being the top
|
|
// left. Convert each point accordingly to calculate
|
|
// the final points.
|
|
func convertPointToGradientSpace(_ point: CGPoint) -> CGPoint {
|
|
return CGPoint(
|
|
x: (point.x + 1) * 0.5,
|
|
y: 1.0 - (point.y + 1) * 0.5
|
|
)
|
|
}
|
|
|
|
// The start point will always be at the opposite side of the signed unit square.
|
|
self.startPoint = convertPointToGradientSpace(CGPoint(x: -endPoint.x, y: -endPoint.y))
|
|
self.endPoint = convertPointToGradientSpace(endPoint)
|
|
}
|
|
}
|
|
|
|
private class LinkPreviewTooltipView: TooltipView {
|
|
let url: URL
|
|
init(fromView: UIView, tailReferenceView: UIView, url: URL) {
|
|
self.url = url
|
|
super.init(
|
|
fromView: fromView,
|
|
widthReferenceView: fromView,
|
|
tailReferenceView: tailReferenceView,
|
|
wasTappedBlock: nil
|
|
)
|
|
}
|
|
|
|
required init?(coder aDecoder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
public override func bubbleContentView() -> UIView {
|
|
let titleLabel = UILabel()
|
|
titleLabel.text = NSLocalizedString(
|
|
"STORY_LINK_PREVIEW_VISIT_LINK_TOOLTIP",
|
|
comment: "Tooltip prompting the user to visit a story link."
|
|
)
|
|
titleLabel.font = UIFont.ows_dynamicTypeBody2Clamped.ows_semibold
|
|
titleLabel.textColor = .ows_white
|
|
|
|
let urlLabel = UILabel()
|
|
urlLabel.text = url.absoluteString
|
|
urlLabel.font = .ows_dynamicTypeCaption1Clamped
|
|
urlLabel.textColor = .ows_white
|
|
|
|
let stackView = UIStackView(arrangedSubviews: [titleLabel, urlLabel])
|
|
stackView.axis = .vertical
|
|
stackView.alignment = .leading
|
|
stackView.spacing = 1
|
|
stackView.layoutMargins = UIEdgeInsets(hMargin: 16, vMargin: 14)
|
|
stackView.isLayoutMarginsRelativeArrangement = true
|
|
|
|
return stackView
|
|
}
|
|
|
|
public override var bubbleColor: UIColor { .ows_black }
|
|
public override var bubbleHSpacing: CGFloat { 16 }
|
|
|
|
public override var tailDirection: TooltipView.TailDirection { .down }
|
|
public override var dismissOnTap: Bool { false }
|
|
}
|
|
|
|
public class TextAttachmentThumbnailView: UIView {
|
|
// By default, we render the textView at a large 3:2 size (matching the aspect
|
|
// of the thumbnail container), so the fonts and gradients all render properly
|
|
// for the preview. We then scale it down to render a "thumbnail" view.
|
|
public static let defaultRenderSize = CGSize(width: 375, height: 563)
|
|
|
|
public lazy var renderSize = Self.defaultRenderSize {
|
|
didSet {
|
|
textAttachmentView.transform = .scale(width / renderSize.width)
|
|
}
|
|
}
|
|
|
|
private let textAttachmentView: TextAttachmentView
|
|
public init(_ textAttachmentView: TextAttachmentView) {
|
|
self.textAttachmentView = textAttachmentView
|
|
super.init(frame: .zero)
|
|
addSubview(textAttachmentView)
|
|
}
|
|
|
|
required init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
public override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
|
|
textAttachmentView.transform = .scale(width / renderSize.width)
|
|
textAttachmentView.frame = bounds
|
|
}
|
|
}
|