820 lines
32 KiB
Swift
820 lines
32 KiB
Swift
//
|
|
// Copyright 2022 Signal Messenger, LLC
|
|
// SPDX-License-Identifier: AGPL-3.0-only
|
|
//
|
|
|
|
import Foundation
|
|
import UIKit
|
|
public import SignalServiceKit
|
|
|
|
open class TextAttachmentView: UIView {
|
|
|
|
private var linkPreviewUrlString: String? { linkPreview?.urlString }
|
|
|
|
public let contentLayoutGuide = UILayoutGuide()
|
|
|
|
// Only set in viewing contexts; spoilers can't be added when editing.
|
|
private let interactionIdentifier: InteractionSnapshotIdentifier?
|
|
private let spoilerState: SpoilerRenderState?
|
|
|
|
private var revealedSpoilerIds: Set<StyleIdType> {
|
|
guard let spoilerState, let interactionIdentifier else {
|
|
return Set()
|
|
}
|
|
return spoilerState.revealState.revealedSpoilerIds(interactionIdentifier: interactionIdentifier)
|
|
}
|
|
|
|
convenience public init(
|
|
attachment: PreloadedTextAttachment,
|
|
interactionIdentifier: InteractionSnapshotIdentifier,
|
|
spoilerState: SpoilerRenderState
|
|
) {
|
|
self.init(
|
|
textContent: attachment.textAttachment.textContent,
|
|
textForegroundColor: attachment.textAttachment.textForegroundColor,
|
|
textBackgroundColor: attachment.textAttachment.textBackgroundColor,
|
|
background: attachment.textAttachment.background,
|
|
linkPreview: attachment.textAttachment.preview,
|
|
linkPreviewImageAttachment: attachment.linkPreviewAttachment,
|
|
interactionIdentifier: interactionIdentifier,
|
|
spoilerState: spoilerState
|
|
)
|
|
}
|
|
|
|
convenience public init(attachment: UnsentTextAttachment) {
|
|
self.init(
|
|
textContent: attachment.textContent,
|
|
textForegroundColor: attachment.textForegroundColor,
|
|
textBackgroundColor: attachment.textBackgroundColor,
|
|
background: attachment.background,
|
|
linkPreview: nil,
|
|
linkPreviewImageAttachment: nil,
|
|
linkPreviewDraft: attachment.linkPreviewDraft,
|
|
interactionIdentifier: nil,
|
|
spoilerState: nil
|
|
)
|
|
}
|
|
|
|
public init(
|
|
text: String,
|
|
style: TextAttachment.TextStyle,
|
|
textForegroundColor: UIColor?,
|
|
textBackgroundColor: UIColor?,
|
|
background: TextAttachment.Background,
|
|
linkPreviewDraft: OWSLinkPreviewDraft? = nil
|
|
) {
|
|
self.textContent = .styled(body: text, style: style)
|
|
self.textForegroundColor = textForegroundColor ?? Theme.darkThemePrimaryColor
|
|
self.textBackgroundColor = textBackgroundColor
|
|
self.background = background
|
|
self.interactionIdentifier = nil
|
|
self.spoilerState = nil
|
|
|
|
super.init(frame: .zero)
|
|
performSetup(linkPreview: nil, linkPreviewImageAttachment: nil, linkPreviewDraft: linkPreviewDraft)
|
|
}
|
|
|
|
private init(
|
|
textContent: TextAttachment.TextContent,
|
|
textForegroundColor: UIColor?,
|
|
textBackgroundColor: UIColor?,
|
|
background: TextAttachment.Background,
|
|
linkPreview: OWSLinkPreview?,
|
|
linkPreviewImageAttachment: Attachment?,
|
|
linkPreviewDraft: OWSLinkPreviewDraft? = nil,
|
|
interactionIdentifier: InteractionSnapshotIdentifier?,
|
|
spoilerState: SpoilerRenderState?
|
|
) {
|
|
self.textContent = textContent
|
|
self.textForegroundColor = textForegroundColor ?? Theme.darkThemePrimaryColor
|
|
self.textBackgroundColor = textBackgroundColor
|
|
self.background = background
|
|
self.interactionIdentifier = interactionIdentifier
|
|
self.spoilerState = spoilerState
|
|
|
|
super.init(frame: .zero)
|
|
performSetup(
|
|
linkPreview: linkPreview,
|
|
linkPreviewImageAttachment: linkPreviewImageAttachment,
|
|
linkPreviewDraft: linkPreviewDraft
|
|
)
|
|
}
|
|
|
|
private func performSetup(
|
|
linkPreview: OWSLinkPreview?,
|
|
linkPreviewImageAttachment: Attachment?,
|
|
linkPreviewDraft: OWSLinkPreviewDraft?
|
|
) {
|
|
clipsToBounds = true
|
|
|
|
addLayoutGuide(contentLayoutGuide)
|
|
let constraints = [
|
|
contentLayoutGuide.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 24),
|
|
contentLayoutGuide.topAnchor.constraint(equalTo: topAnchor, constant: 20),
|
|
contentLayoutGuide.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -24),
|
|
contentLayoutGuide.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -20)
|
|
]
|
|
constraints.forEach { $0.priority = .defaultHigh }
|
|
addConstraints(constraints)
|
|
|
|
if let linkPreview = linkPreview {
|
|
self.linkPreview = LinkPreviewSent(
|
|
linkPreview: linkPreview,
|
|
imageAttachment: linkPreviewImageAttachment,
|
|
conversationStyle: nil
|
|
)
|
|
} else if let linkPreviewDraft = linkPreviewDraft {
|
|
let state: LinkPreviewState
|
|
if let callLink = CallLink(url: linkPreviewDraft.url) {
|
|
state = LinkPreviewCallLink(previewType: .draft(linkPreviewDraft), callLink: callLink)
|
|
} else {
|
|
state = LinkPreviewDraft(linkPreviewDraft: linkPreviewDraft)
|
|
}
|
|
self.linkPreview = state
|
|
}
|
|
|
|
updateTextAttributes()
|
|
reloadLinkPreviewAppearance()
|
|
updateBackground()
|
|
}
|
|
|
|
public func asThumbnailView() -> TextAttachmentThumbnailView { TextAttachmentThumbnailView(self) }
|
|
|
|
required public init?(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
public enum LayoutConstants {
|
|
public static let textBackgroundHMargin: CGFloat = 16
|
|
public static let textBackgroundVMargin: CGFloat = 16
|
|
public static let textBackgroundCornerRadius: CGFloat = 18
|
|
public static let linkPreviewAreaTopMargin: CGFloat = 8
|
|
public static let linkPreviewHMargin: CGFloat = 12
|
|
public static let linkPreviewVMargin: CGFloat = 20
|
|
}
|
|
|
|
private var expandedLinkPreviewAreaHeight: CGFloat?
|
|
|
|
open var isEditing: Bool { false }
|
|
|
|
public private(set) var textContentSize: CGSize = .zero
|
|
|
|
open override func layoutSubviews() {
|
|
super.layoutSubviews()
|
|
|
|
// Resize link preview view to its desired size.
|
|
if let linkPreviewView = linkPreviewView {
|
|
let linkPreviewMaxSize = contentLayoutGuide.layoutFrame.inset(by: linkPreviewWrapperView.layoutMargins).size
|
|
let linkPreviewSize = linkPreviewView.systemLayoutSizeFitting(
|
|
linkPreviewMaxSize,
|
|
withHorizontalFittingPriority: .required,
|
|
verticalFittingPriority: .fittingSizeLevel
|
|
)
|
|
|
|
linkPreviewWrapperView.bounds.size = CGSize(
|
|
width: linkPreviewSize.width + 2 * LayoutConstants.linkPreviewHMargin,
|
|
height: linkPreviewSize.height + 2 * LayoutConstants.linkPreviewVMargin
|
|
)
|
|
linkPreviewView.frame = linkPreviewWrapperView.bounds.insetBy(
|
|
dx: LayoutConstants.linkPreviewHMargin,
|
|
dy: LayoutConstants.linkPreviewVMargin
|
|
)
|
|
|
|
// Save height of link preview with "regular" layout so that we can calculate
|
|
// if there's enough room to go back from "compact" to "regular".
|
|
if linkPreviewView.layout == .regular {
|
|
expandedLinkPreviewAreaHeight = linkPreviewWrapperView.frame.height
|
|
}
|
|
}
|
|
|
|
textContentSize = calculateTextContentSize()
|
|
|
|
// If link preview view has "regular" (tall) layout and there's no enough vertical space for both link and text,
|
|
// we force "compact" layout for the link preview and trigger a new layout pass.
|
|
if let linkPreviewView = linkPreviewView, linkPreviewView.layout == .regular, textContentSize.height > 0 {
|
|
let contentHeight = textContentSize.height + LayoutConstants.linkPreviewAreaTopMargin + linkPreviewWrapperView.frame.height
|
|
if contentHeight > contentLayoutGuide.layoutFrame.height {
|
|
forceCompactLayoutForLinkPreview = true
|
|
reloadLinkPreviewAppearance()
|
|
return
|
|
}
|
|
}
|
|
|
|
// If link preview view has "compact" layout and there's enough vertical space for both text
|
|
// and link in "regular" size, we disable forcing link preview to be compact.
|
|
if let linkPreviewView = linkPreviewView, linkPreviewView.layout == .compact,
|
|
let expandedLinkPreviewAreaHeight = expandedLinkPreviewAreaHeight {
|
|
if forceCompactLayoutForLinkPreview {
|
|
var contentHeight = expandedLinkPreviewAreaHeight
|
|
if textContentSize.height > 0 {
|
|
contentHeight += (LayoutConstants.linkPreviewAreaTopMargin + textContentSize.height)
|
|
}
|
|
if contentHeight < contentLayoutGuide.layoutFrame.height {
|
|
forceCompactLayoutForLinkPreview = false
|
|
}
|
|
}
|
|
if !shouldUseCompactLayoutForLinkPreview() {
|
|
reloadLinkPreviewAppearance()
|
|
return
|
|
}
|
|
}
|
|
|
|
layoutTextContentAndLinkPreview()
|
|
}
|
|
|
|
open func layoutTextContentAndLinkPreview() {
|
|
var maxTextAreaHeight = contentLayoutGuide.layoutFrame.height
|
|
var linkPreviewAreaHeight: CGFloat = 0
|
|
if linkPreviewView != nil {
|
|
linkPreviewAreaHeight = linkPreviewWrapperView.frame.height
|
|
maxTextAreaHeight -= (linkPreviewAreaHeight + LayoutConstants.linkPreviewAreaTopMargin)
|
|
}
|
|
|
|
var textAreaHeight: CGFloat = 0
|
|
|
|
// Position text and/or link preview.
|
|
if hasNonEmptyTextContent, textContentSize.height > 0 {
|
|
textLabel.bounds.size = textContentSize
|
|
|
|
let cappedTextContentHeight = min(textContentSize.height, maxTextAreaHeight - 2 * LayoutConstants.textBackgroundVMargin)
|
|
let scaleFactor = min(1, cappedTextContentHeight / textContentSize.height)
|
|
textLabel.transform = CGAffineTransform.scale(scaleFactor)
|
|
|
|
let verticalOffset = linkPreviewAreaHeight > 0 ? 0.5 * (linkPreviewAreaHeight + LayoutConstants.linkPreviewAreaTopMargin) : 0
|
|
textLabel.center = CGPoint(
|
|
x: contentLayoutGuide.layoutFrame.center.x,
|
|
y: contentLayoutGuide.layoutFrame.center.y - verticalOffset
|
|
)
|
|
if let textBackgroundView = textBackgroundView {
|
|
textBackgroundView.frame = convert(textLabel.bounds, from: textLabel).insetBy(
|
|
dx: -LayoutConstants.textBackgroundHMargin,
|
|
dy: -LayoutConstants.textBackgroundVMargin
|
|
)
|
|
}
|
|
|
|
textAreaHeight = cappedTextContentHeight + 2 * LayoutConstants.textBackgroundVMargin
|
|
}
|
|
if linkPreviewView != nil {
|
|
let verticalOffset = textAreaHeight > 0 ? 0.5 * (textAreaHeight + LayoutConstants.linkPreviewAreaTopMargin) : 0
|
|
linkPreviewWrapperView.center = CGPoint(
|
|
x: contentLayoutGuide.layoutFrame.center.x,
|
|
y: contentLayoutGuide.layoutFrame.center.y + verticalOffset
|
|
)
|
|
}
|
|
}
|
|
|
|
open func calculateTextContentSize() -> CGSize {
|
|
guard hasNonEmptyTextContent else {
|
|
return .zero
|
|
}
|
|
|
|
let maxTextLabelSize = contentLayoutGuide.layoutFrame.insetBy(
|
|
dx: LayoutConstants.textBackgroundHMargin,
|
|
dy: LayoutConstants.textBackgroundVMargin
|
|
).size
|
|
return textLabel.systemLayoutSizeFitting(
|
|
maxTextLabelSize,
|
|
withHorizontalFittingPriority: .required,
|
|
verticalFittingPriority: .fittingSizeLevel
|
|
)
|
|
}
|
|
|
|
// MARK: - Attributes
|
|
|
|
public var textContent: TextAttachment.TextContent {
|
|
didSet { updateTextAttributes() }
|
|
}
|
|
|
|
public var hasNonEmptyTextContent: Bool {
|
|
switch textContent {
|
|
case .empty:
|
|
return false
|
|
case .styled, .styledRanges:
|
|
return true
|
|
}
|
|
}
|
|
|
|
private var tappableItems: [HydratedMessageBody.TappableItem]?
|
|
|
|
public private(set) var textForegroundColor: UIColor = Theme.darkThemePrimaryColor
|
|
|
|
public private(set) var textBackgroundColor: UIColor?
|
|
|
|
public func setTextForegroundColor(_ textForegroundColor: UIColor, backgroundColor: UIColor?) {
|
|
self.textForegroundColor = textForegroundColor
|
|
self.textBackgroundColor = backgroundColor
|
|
updateTextAttributes()
|
|
}
|
|
|
|
// MARK: - Text
|
|
|
|
public func sizeAndAlignment(forText text: String) -> (fontPointSize: CGFloat, textAlignment: NSTextAlignment) {
|
|
switch text.count {
|
|
case ..<50: return (34, .center)
|
|
case 50...199: return (24, .center)
|
|
default: return (18, .natural)
|
|
}
|
|
}
|
|
|
|
public func updateTextAttributes() {
|
|
defer { updateVisibilityOfComponents(animated: false) }
|
|
|
|
switch textContent {
|
|
case .empty:
|
|
tappableItems = nil
|
|
textLabelSpoilerConfig.text = nil
|
|
return
|
|
case .styled(let text, let textStyle):
|
|
if textLabel.superview == nil { addSubview(textLabel) }
|
|
let (fontPointSize, textAlignment) = sizeAndAlignment(forText: text)
|
|
textLabel.text = transformedText(text, for: textStyle)
|
|
textLabel.textAlignment = textAlignment
|
|
textLabel.font = .font(for: textStyle, withPointSize: fontPointSize)
|
|
textLabel.textColor = textForegroundColor
|
|
tappableItems = nil
|
|
textLabelSpoilerConfig.text = nil
|
|
case .styledRanges(let body):
|
|
if textLabel.superview == nil { addSubview(textLabel) }
|
|
let (fontPointSize, textAlignment) = sizeAndAlignment(forText: body.text)
|
|
let font = UIFont.font(for: .regular, withPointSize: fontPointSize)
|
|
|
|
let displayConfig = HydratedMessageBody.DisplayConfiguration.textStory(
|
|
font: font,
|
|
textColor: textForegroundColor,
|
|
revealedSpoilerIds: self.revealedSpoilerIds
|
|
)
|
|
let hydratedBody = body.asHydratedMessageBody()
|
|
self.tappableItems = hydratedBody.tappableItems(
|
|
revealedSpoilerIds: displayConfig.style.revealedIds,
|
|
dataDetector: nil
|
|
)
|
|
|
|
let attrText = body.asAttributedStringForDisplay(
|
|
config: displayConfig.style,
|
|
isDarkThemeEnabled: Theme.isDarkThemeEnabled
|
|
)
|
|
textLabel.font = font
|
|
textLabel.textColor = textForegroundColor
|
|
textLabel.attributedText = attrText
|
|
textLabel.textAlignment = textAlignment
|
|
textLabelSpoilerConfig.displayConfig = displayConfig
|
|
textLabelSpoilerConfig.text = .messageBody(hydratedBody)
|
|
textLabelSpoilerConfig.animationManager = spoilerState?.animationManager
|
|
}
|
|
|
|
if let textBackgroundColor = textBackgroundColor {
|
|
var textBackgroundView: UIView
|
|
if let existingBackgroundView = self.textBackgroundView {
|
|
textBackgroundView = existingBackgroundView
|
|
} else {
|
|
textBackgroundView = UIView()
|
|
textBackgroundView.layer.cornerRadius = LayoutConstants.textBackgroundCornerRadius
|
|
insertSubview(textBackgroundView, belowSubview: textLabel)
|
|
self.textBackgroundView = textBackgroundView
|
|
}
|
|
textBackgroundView.backgroundColor = textBackgroundColor
|
|
}
|
|
|
|
setNeedsLayout()
|
|
}
|
|
|
|
public func transformedText(_ text: String, for textStyle: TextAttachment.TextStyle) -> String {
|
|
guard case .condensed = textStyle else { return text }
|
|
return text.uppercased()
|
|
}
|
|
|
|
open func updateVisibilityOfComponents(animated: Bool) {
|
|
let isEditing = isEditing
|
|
switch textContent {
|
|
case .styledRanges, .styled:
|
|
textLabel.setIsHidden(isEditing, animated: animated)
|
|
textBackgroundView?.setIsHidden(isEditing || textBackgroundColor == nil, animated: animated)
|
|
textLabelSpoilerConfig.isViewVisible = !isEditing
|
|
case .empty:
|
|
textLabel.setIsHidden(true, animated: animated)
|
|
textBackgroundView?.setIsHidden(true, animated: animated)
|
|
textLabelSpoilerConfig.isViewVisible = false
|
|
}
|
|
}
|
|
|
|
private lazy var textLabel: UILabel = {
|
|
let textLabel = UILabel()
|
|
textLabel.adjustsFontSizeToFitWidth = true
|
|
textLabel.allowsDefaultTighteningForTruncation = true
|
|
textLabel.lineBreakMode = .byWordWrapping
|
|
textLabel.minimumScaleFactor = 0.2
|
|
textLabel.numberOfLines = 0
|
|
return textLabel
|
|
}()
|
|
|
|
private lazy var textLabelSpoilerConfig = SpoilerableTextConfig.Builder(isViewVisible: false) {
|
|
didSet {
|
|
textLabelSpoilerAnimator.updateAnimationState(textLabelSpoilerConfig)
|
|
}
|
|
}
|
|
private lazy var textLabelSpoilerAnimator = SpoilerableLabelAnimator(label: textLabel)
|
|
|
|
public private(set) var textBackgroundView: UIView?
|
|
|
|
// MARK: - Background
|
|
|
|
public var background: TextAttachment.Background {
|
|
didSet { updateBackground() }
|
|
}
|
|
|
|
private var gradientView: GradientView?
|
|
|
|
private func updateBackground() {
|
|
switch background {
|
|
case .color(let color):
|
|
if let gradientView = gradientView {
|
|
gradientView.isHidden = true
|
|
}
|
|
backgroundColor = color
|
|
|
|
case .gradient(let gradient):
|
|
var gradientView: GradientView
|
|
if let existingGradientView = self.gradientView {
|
|
gradientView = existingGradientView
|
|
} else {
|
|
gradientView = GradientView(colors: [])
|
|
insertSubview(gradientView, at: 0)
|
|
gradientView.autoPinEdgesToSuperviewEdges()
|
|
self.gradientView = gradientView
|
|
}
|
|
gradientView.isHidden = false
|
|
gradientView.colors = gradient.colors
|
|
gradientView.locations = gradient.locations
|
|
gradientView.setAngle(gradient.angle)
|
|
}
|
|
}
|
|
|
|
// MARK: - Link Preview
|
|
|
|
public var linkPreview: LinkPreviewState? {
|
|
didSet {
|
|
expandedLinkPreviewAreaHeight = nil
|
|
reloadLinkPreviewAppearance()
|
|
}
|
|
}
|
|
|
|
public private(set) var linkPreviewView: LinkPreviewView?
|
|
|
|
public private(set) lazy var linkPreviewWrapperView = UIView()
|
|
|
|
private var forceCompactLayoutForLinkPreview = false
|
|
|
|
private func shouldUseCompactLayoutForLinkPreview() -> Bool {
|
|
let text: String
|
|
switch textContent {
|
|
case .empty:
|
|
return forceCompactLayoutForLinkPreview
|
|
case .styledRanges(let body):
|
|
text = body.text
|
|
case .styled(let body, _):
|
|
text = body
|
|
}
|
|
if text.count >= 50 { return true }
|
|
return forceCompactLayoutForLinkPreview
|
|
}
|
|
|
|
open func reloadLinkPreviewAppearance() {
|
|
if let linkPreviewView = linkPreviewView {
|
|
linkPreviewView.removeFromSuperview()
|
|
self.linkPreviewView = nil
|
|
}
|
|
|
|
defer {
|
|
setNeedsLayout()
|
|
}
|
|
|
|
guard let linkPreview = linkPreview else {
|
|
linkPreviewWrapperView.isHidden = true
|
|
return
|
|
}
|
|
|
|
if linkPreviewWrapperView.superview == nil {
|
|
addSubview(linkPreviewWrapperView)
|
|
}
|
|
linkPreviewWrapperView.isHidden = false
|
|
|
|
let linkPreviewView = TextAttachmentView.LinkPreviewView(
|
|
linkPreview: linkPreview,
|
|
forceCompactSize: shouldUseCompactLayoutForLinkPreview()
|
|
)
|
|
linkPreviewWrapperView.addSubview(linkPreviewView)
|
|
self.linkPreviewView = linkPreviewView
|
|
}
|
|
|
|
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, completion: nil)
|
|
} else {
|
|
linkPreviewTooltipView.removeFromSuperview()
|
|
self.linkPreviewTooltipView = nil
|
|
}
|
|
|
|
return true
|
|
} else if let linkPreviewView = linkPreviewView,
|
|
let urlString = linkPreviewUrlString,
|
|
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
|
|
}
|
|
|
|
// Note: the tap targeting here is not perfect.
|
|
// Eventually, move this to a better system than UILabel
|
|
// indexing, once we do custom spoiler animations.
|
|
let labelLocation = gesture.location(in: textLabel)
|
|
if
|
|
hasNonEmptyTextContent,
|
|
let spoilerState,
|
|
let interactionIdentifier,
|
|
textLabel.bounds.contains(labelLocation),
|
|
let tapIndex = textLabel.characterIndex(of: labelLocation)
|
|
{
|
|
let spoilerItem = tappableItems?.lazy
|
|
.compactMap {
|
|
switch $0 {
|
|
case .unrevealedSpoiler(let unrevealedSpoiler):
|
|
return unrevealedSpoiler
|
|
case .data, .mention:
|
|
return nil
|
|
}
|
|
}
|
|
.first(where: {
|
|
$0.range.contains(tapIndex)
|
|
})
|
|
if let spoilerItem {
|
|
spoilerState.revealState.setSpoilerRevealed(
|
|
withID: spoilerItem.id,
|
|
interactionIdentifier: interactionIdentifier
|
|
)
|
|
updateTextAttributes()
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// MARK: - LinkPreviewView
|
|
|
|
public class LinkPreviewView: UIStackView {
|
|
|
|
public enum Layout {
|
|
case regular
|
|
case compact
|
|
case draft
|
|
case domainOnly
|
|
}
|
|
public private(set) var layout: Layout = .regular
|
|
|
|
public init(linkPreview: LinkPreviewState, isDraft: Bool = false, forceCompactSize: Bool = false) {
|
|
super.init(frame: .zero)
|
|
|
|
let backgroundColor: UIColor = isDraft ? Theme.darkThemeTableView2PresentedBackgroundColor : .ows_gray02
|
|
let backgroundView = addBackgroundView(withBackgroundColor: backgroundColor)
|
|
|
|
let title = linkPreview.title
|
|
let description = linkPreview.previewDescription
|
|
let hasTitleOrDescription = title != nil || description != nil
|
|
if isDraft {
|
|
layout = .draft
|
|
} else if hasTitleOrDescription {
|
|
layout = forceCompactSize ? .compact : .regular
|
|
} else {
|
|
layout = .domainOnly
|
|
}
|
|
|
|
let thumbnailImageView = UIImageView()
|
|
thumbnailImageView.clipsToBounds = true
|
|
if layout != .domainOnly && linkPreview.imageState == .loaded {
|
|
thumbnailImageView.contentMode = .scaleAspectFill
|
|
|
|
// Downgrade "regular" to "compact" if thumbnail is too small.
|
|
let imageSize = linkPreview.imagePixelSize
|
|
if layout == .regular && (imageSize.width < 300 || imageSize.height < 300) {
|
|
layout = .compact
|
|
}
|
|
let thumbnailQuality: AttachmentThumbnailQuality = layout == .regular ? .mediumLarge : .small
|
|
if let cacheKey = linkPreview.imageCacheKey(thumbnailQuality: thumbnailQuality),
|
|
let image = Self.mediaCache.get(key: cacheKey) as? UIImage {
|
|
thumbnailImageView.image = image
|
|
} else {
|
|
linkPreview.imageAsync(thumbnailQuality: thumbnailQuality) { image in
|
|
DispatchQueue.main.async {
|
|
thumbnailImageView.image = image
|
|
}
|
|
}
|
|
}
|
|
} else {
|
|
// Dark placeholder icon on light background if there's no thumbnail associated with the link preview.
|
|
layout = .compact
|
|
thumbnailImageView.backgroundColor = .ows_gray02
|
|
thumbnailImageView.contentMode = .center
|
|
thumbnailImageView.image = UIImage(imageLiteralResourceName: "link")
|
|
thumbnailImageView.tintColor = Theme.lightThemePrimaryColor
|
|
}
|
|
|
|
alignment = .fill
|
|
axis = layout == .regular ? .vertical : .horizontal
|
|
|
|
switch layout {
|
|
case .regular:
|
|
backgroundView.layer.cornerRadius = 18
|
|
thumbnailImageView.autoSetDimension(.height, toSize: 152)
|
|
thumbnailImageView.layer.maskedCorners = [ .layerMinXMinYCorner, .layerMaxXMinYCorner ]
|
|
|
|
case .compact:
|
|
backgroundView.layer.cornerRadius = 18
|
|
thumbnailImageView.autoSetDimension(.width, toSize: 88)
|
|
// Allow thumbnail to grow vertically with the text.
|
|
thumbnailImageView.autoSetDimension(.height, toSize: 88, relation: .greaterThanOrEqual)
|
|
thumbnailImageView.layer.maskedCorners = [ .layerMinXMinYCorner, .layerMinXMaxYCorner ]
|
|
|
|
case .draft:
|
|
backgroundView.layer.cornerRadius = 8
|
|
thumbnailImageView.autoSetDimension(.width, toSize: 76)
|
|
// Allow thumbnail to grow vertically with the text.
|
|
thumbnailImageView.autoSetDimension(.height, toSize: 76, relation: .greaterThanOrEqual)
|
|
thumbnailImageView.layer.maskedCorners = .all
|
|
|
|
case .domainOnly:
|
|
backgroundView.layer.cornerRadius = 12
|
|
thumbnailImageView.autoSetDimensions(to: CGSize(width: 50, height: 50))
|
|
}
|
|
thumbnailImageView.layer.cornerRadius = backgroundView.layer.cornerRadius
|
|
addArrangedSubview(thumbnailImageView)
|
|
|
|
let previewVStack = UIStackView()
|
|
previewVStack.axis = .vertical
|
|
previewVStack.spacing = 2
|
|
previewVStack.alignment = .leading
|
|
previewVStack.isLayoutMarginsRelativeArrangement = true
|
|
previewVStack.layoutMargins = UIEdgeInsets(hMargin: 12, vMargin: 8)
|
|
// Make placeholder icon look centered between leading edge of the panel and text.
|
|
if layout == .domainOnly {
|
|
previewVStack.layoutMargins.leading = 0
|
|
}
|
|
addArrangedSubview(previewVStack)
|
|
|
|
if let title = title {
|
|
let titleLabel = UILabel()
|
|
titleLabel.text = title
|
|
titleLabel.font = .dynamicTypeSubheadlineClamped.semibold()
|
|
titleLabel.textColor = isDraft ? Theme.darkThemePrimaryColor : Theme.lightThemePrimaryColor
|
|
titleLabel.numberOfLines = 2
|
|
titleLabel.setCompressionResistanceVerticalHigh()
|
|
titleLabel.setContentHuggingVerticalHigh()
|
|
previewVStack.addArrangedSubview(titleLabel)
|
|
}
|
|
|
|
if let description = description {
|
|
let descriptionLabel = UILabel()
|
|
descriptionLabel.text = description
|
|
descriptionLabel.font = .dynamicTypeFootnoteClamped
|
|
descriptionLabel.textColor = isDraft ? Theme.darkThemePrimaryColor : Theme.lightThemePrimaryColor
|
|
descriptionLabel.numberOfLines = 2
|
|
descriptionLabel.setCompressionResistanceVerticalHigh()
|
|
descriptionLabel.setContentHuggingVerticalHigh()
|
|
previewVStack.addArrangedSubview(descriptionLabel)
|
|
}
|
|
|
|
let footerLabel = UILabel()
|
|
footerLabel.numberOfLines = 1
|
|
if hasTitleOrDescription {
|
|
footerLabel.font = .dynamicTypeCaption1Clamped
|
|
footerLabel.textColor = isDraft ? Theme.darkThemeSecondaryTextAndIconColor : .ows_gray60
|
|
} else {
|
|
footerLabel.font = .dynamicTypeSubheadlineClamped.semibold()
|
|
footerLabel.textColor = isDraft ? Theme.darkThemePrimaryColor : Theme.lightThemePrimaryColor
|
|
}
|
|
footerLabel.setCompressionResistanceVerticalHigh()
|
|
footerLabel.setContentHuggingVerticalHigh()
|
|
previewVStack.addArrangedSubview(footerLabel)
|
|
|
|
var footerText: String
|
|
if
|
|
let urlString = linkPreview.urlString,
|
|
let url = URL(string: urlString),
|
|
let displayDomain = LinkPreviewHelper.displayDomain(forUrl: url)
|
|
{
|
|
footerText = displayDomain.lowercased()
|
|
} else {
|
|
footerText = OWSLocalizedString(
|
|
"LINK_PREVIEW_UNKNOWN_DOMAIN",
|
|
comment: "Label for link previews with an unknown host."
|
|
).uppercased()
|
|
}
|
|
if let date = linkPreview.date {
|
|
footerText.append(" ⋅ \(Self.dateFormatter.string(from: date))")
|
|
}
|
|
footerLabel.text = footerText
|
|
}
|
|
|
|
required init(coder: NSCoder) {
|
|
fatalError("init(coder:) has not been implemented")
|
|
}
|
|
|
|
private static let dateFormatter: DateFormatter = {
|
|
let formatter = DateFormatter()
|
|
formatter.dateStyle = .medium
|
|
formatter.timeStyle = .none
|
|
return formatter
|
|
}()
|
|
|
|
fileprivate static let mediaCache = LRUCache<LinkPreviewImageCacheKey, NSObject>(maxSize: 32, shouldEvacuateInBackground: true)
|
|
}
|
|
}
|
|
|
|
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 = OWSLocalizedString(
|
|
"STORY_LINK_PREVIEW_VISIT_LINK_TOOLTIP",
|
|
comment: "Tooltip prompting the user to visit a story link."
|
|
)
|
|
titleLabel.font = UIFont.dynamicTypeBody2Clamped.semibold()
|
|
titleLabel.textColor = .ows_white
|
|
|
|
let urlLabel = UILabel()
|
|
urlLabel.text = url.absoluteString
|
|
urlLabel.font = .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
|
|
}
|
|
}
|