session-ios/Signal/src/views/LinkPreviewView.swift

434 lines
12 KiB
Swift
Raw Normal View History

//
// Copyright (c) 2019 Open Whisper Systems. All rights reserved.
//
@objc
public enum LinkPreviewImageState: Int {
case none
case loading
case loaded
case invalid
}
// MARK: -
@objc
public protocol LinkPreviewState {
func isLoaded() -> Bool
func urlString() -> String?
func displayDomain() -> String?
func title() -> String?
func imageState() -> LinkPreviewImageState
func image() -> UIImage?
}
// MARK: -
@objc
public class LinkPreviewLoading: NSObject, LinkPreviewState {
override init() {
}
public func isLoaded() -> Bool {
return false
}
public func urlString() -> String? {
return nil
}
public func displayDomain() -> String? {
return nil
}
public func title() -> String? {
return nil
}
public func imageState() -> LinkPreviewImageState {
return .none
}
public func image() -> UIImage? {
return nil
}
}
// MARK: -
@objc
public class LinkPreviewDraft: NSObject, LinkPreviewState {
private let linkPreviewDraft: OWSLinkPreviewDraft
@objc
public required init(linkPreviewDraft: OWSLinkPreviewDraft) {
self.linkPreviewDraft = linkPreviewDraft
}
public func isLoaded() -> Bool {
return true
}
public func urlString() -> String? {
return linkPreviewDraft.urlString
}
public func displayDomain() -> String? {
guard let displayDomain = linkPreviewDraft.displayDomain() else {
owsFailDebug("Missing display domain")
return nil
}
return displayDomain
}
public func title() -> String? {
return linkPreviewDraft.title
}
public func imageState() -> LinkPreviewImageState {
if linkPreviewDraft.imageFilePath != nil {
return .loaded
} else {
return .none
}
}
public func image() -> UIImage? {
assert(imageState() == .loaded)
guard let imageFilepath = linkPreviewDraft.imageFilePath else {
return nil
}
guard let image = UIImage(contentsOfFile: imageFilepath) else {
owsFail("Could not load image: \(imageFilepath)")
}
return image
}
}
// MARK: -
@objc
public class LinkPreviewSent: NSObject, LinkPreviewState {
private let linkPreview: OWSLinkPreview
private let imageAttachment: TSAttachment?
@objc
public required init(linkPreview: OWSLinkPreview,
imageAttachment: TSAttachment?) {
self.linkPreview = linkPreview
self.imageAttachment = imageAttachment
}
public func isLoaded() -> Bool {
return true
}
public func urlString() -> String? {
guard let urlString = linkPreview.urlString else {
owsFailDebug("Missing url")
return nil
}
return urlString
}
public func displayDomain() -> String? {
guard let displayDomain = linkPreview.displayDomain() else {
owsFailDebug("Missing display domain")
return nil
}
return displayDomain
}
public func title() -> String? {
return linkPreview.title
}
public func imageState() -> LinkPreviewImageState {
guard linkPreview.imageAttachmentId != nil else {
return .none
}
guard let imageAttachment = imageAttachment else {
owsFailDebug("Missing imageAttachment.")
return .none
}
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
return .loading
}
guard attachmentStream.isValidImage else {
return .invalid
}
return .loaded
}
public func image() -> UIImage? {
assert(imageState() == .loaded)
guard let attachmentStream = imageAttachment as? TSAttachmentStream else {
owsFailDebug("Could not load image.")
return nil
}
guard attachmentStream.isValidImage else {
return nil
}
guard let imageFilepath = attachmentStream.originalFilePath else {
owsFailDebug("Attachment is missing file path.")
return nil
}
guard let image = UIImage(contentsOfFile: imageFilepath) else {
owsFail("Could not load image: \(imageFilepath)")
}
return image
}
}
// MARK: -
@objc
public protocol LinkPreviewViewDelegate {
func linkPreviewCanCancel() -> Bool
@objc optional func linkPreviewDidCancel()
@objc optional func linkPreviewDidTap(urlString: String?)
}
// MARK: -
@objc
public class LinkPreviewView: UIStackView {
private weak var delegate: LinkPreviewViewDelegate?
2019-01-18 16:23:07 +01:00
@objc
public var state: LinkPreviewState? {
didSet {
AssertIsOnMainThread()
assert(oldValue == nil)
updateContents()
}
}
@available(*, unavailable, message:"use other constructor instead.")
required init(coder aDecoder: NSCoder) {
notImplemented()
}
@available(*, unavailable, message:"use other constructor instead.")
override init(frame: CGRect) {
notImplemented()
}
2019-01-18 16:23:07 +01:00
private var cancelButton: UIButton?
private var layoutConstraints = [NSLayoutConstraint]()
@objc
2019-01-18 16:23:07 +01:00
public init(delegate: LinkPreviewViewDelegate?) {
self.delegate = delegate
super.init(frame: .zero)
2019-01-18 16:23:07 +01:00
self.isUserInteractionEnabled = true
self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(wasTapped)))
}
private var isApproval: Bool {
return delegate != nil
}
2019-01-18 16:23:07 +01:00
private func resetContents() {
for subview in subviews {
subview.removeFromSuperview()
}
self.axis = .horizontal
self.alignment = .center
self.distribution = .fill
self.spacing = 0
2019-01-18 16:23:07 +01:00
cancelButton = nil
2019-01-18 16:23:07 +01:00
NSLayoutConstraint.deactivate(layoutConstraints)
layoutConstraints = []
}
private func updateContents() {
resetContents()
guard let state = state else {
return
}
guard state.isLoaded() else {
createLoadingContents()
return
}
guard isApproval else {
createMessageContents()
return
}
2019-01-18 16:23:07 +01:00
createApprovalContents(state: state)
}
private func createMessageContents() {
2019-01-22 16:43:18 +01:00
// TODO:
}
private let approvalHeight: CGFloat = 76
2019-01-18 16:23:07 +01:00
private func createApprovalContents(state: LinkPreviewState) {
self.axis = .horizontal
self.alignment = .fill
self.distribution = .equalSpacing
self.spacing = 8
// Image
2019-01-18 16:23:07 +01:00
if let imageView = createImageView(state: state) {
imageView.contentMode = .scaleAspectFill
imageView.autoPinToSquareAspectRatio()
let imageSize = approvalHeight
imageView.autoSetDimensions(to: CGSize(width: imageSize, height: imageSize))
imageView.setContentHuggingHigh()
imageView.setCompressionResistanceHigh()
imageView.clipsToBounds = true
// TODO: Cropping, stroke.
addArrangedSubview(imageView)
}
// Right
let rightStack = UIStackView()
rightStack.axis = .horizontal
rightStack.alignment = .fill
rightStack.distribution = .equalSpacing
rightStack.spacing = 8
rightStack.setContentHuggingHorizontalLow()
rightStack.setCompressionResistanceHorizontalLow()
addArrangedSubview(rightStack)
// Text
let textStack = UIStackView()
textStack.axis = .vertical
textStack.alignment = .leading
textStack.spacing = 2
textStack.setContentHuggingHorizontalLow()
textStack.setCompressionResistanceHorizontalLow()
if let title = state.title(),
title.count > 0 {
let label = UILabel()
label.text = title
label.textColor = Theme.primaryColor
label.font = UIFont.ows_dynamicTypeBody
textStack.addArrangedSubview(label)
}
if let displayDomain = state.displayDomain(),
displayDomain.count > 0 {
let label = UILabel()
label.text = displayDomain.uppercased()
label.textColor = Theme.secondaryColor
label.font = UIFont.ows_dynamicTypeCaption1
textStack.addArrangedSubview(label)
}
let textWrapper = UIStackView(arrangedSubviews: [textStack])
textWrapper.axis = .horizontal
textWrapper.alignment = .center
textWrapper.setContentHuggingHorizontalLow()
textWrapper.setCompressionResistanceHorizontalLow()
rightStack.addArrangedSubview(textWrapper)
// Cancel
let cancelStack = UIStackView()
cancelStack.axis = .horizontal
cancelStack.alignment = .top
cancelStack.setContentHuggingHigh()
cancelStack.setCompressionResistanceHigh()
let cancelImage: UIImage = #imageLiteral(resourceName: "quoted-message-cancel").withRenderingMode(.alwaysTemplate)
2019-01-22 16:43:18 +01:00
let cancelButton = UIButton(type: .custom)
cancelButton.setImage(cancelImage, for: .normal)
cancelButton.addTarget(self, action: #selector(didTapCancel(sender:)), for: .touchUpInside)
self.cancelButton = cancelButton
cancelButton.tintColor = Theme.secondaryColor
cancelButton.setContentHuggingHigh()
cancelButton.setCompressionResistanceHigh()
cancelStack.addArrangedSubview(cancelButton)
rightStack.addArrangedSubview(cancelStack)
// Stroke
let strokeView = UIView()
strokeView.backgroundColor = Theme.secondaryColor
rightStack.addSubview(strokeView)
strokeView.autoPinWidthToSuperview()
strokeView.autoPinEdge(toSuperviewEdge: .bottom)
strokeView.autoSetDimension(.height, toSize: CGHairlineWidth())
}
2019-01-18 16:23:07 +01:00
private func createImageView(state: LinkPreviewState) -> UIImageView? {
guard state.isLoaded() else {
owsFailDebug("State not loaded.")
return nil
}
guard state.imageState() == .loaded else {
return nil
}
guard let image = state.image() else {
owsFailDebug("Could not load image.")
return nil
}
let imageView = UIImageView()
imageView.image = image
return imageView
}
private func createLoadingContents() {
self.axis = .vertical
self.alignment = .center
2019-01-18 16:23:07 +01:00
self.layoutConstraints.append(self.autoSetDimension(.height, toSize: approvalHeight))
let activityIndicator = UIActivityIndicatorView(activityIndicatorStyle: .gray)
activityIndicator.startAnimating()
addArrangedSubview(activityIndicator)
let activityIndicatorSize: CGFloat = 25
activityIndicator.autoSetDimensions(to: CGSize(width: activityIndicatorSize, height: activityIndicatorSize))
}
// MARK: Events
@objc func wasTapped(sender: UIGestureRecognizer) {
2019-01-18 16:23:07 +01:00
guard let state = state else {
return
}
guard sender.state == .recognized else {
return
}
if let cancelButton = cancelButton {
let cancelLocation = sender.location(in: cancelButton)
// Permissive hot area to make it very easy to cancel the link preview.
let hotAreaInset: CGFloat = -20
let cancelButtonHotArea = cancelButton.bounds.insetBy(dx: hotAreaInset, dy: hotAreaInset)
if cancelButtonHotArea.contains(cancelLocation) {
self.delegate?.linkPreviewDidCancel?()
return
}
}
2019-01-18 16:23:07 +01:00
self.delegate?.linkPreviewDidTap?(urlString: state.urlString())
}
// MARK: Measurement
@objc
public class func measure(withConversationViewItem item: ConversationViewItem) -> CGSize {
// TODO:
return CGSize.zero
}
2019-01-22 16:43:18 +01:00
@objc func didTapCancel(sender: UIButton) {
self.delegate?.linkPreviewDidCancel?()
}
}