// // OpenGraphView.swift // // // Created by Kyle Bashour on 11/11/22. // import AlamofireImage import Combine import MastodonAsset import MastodonCore import MastodonLocalization import CoreDataStack import UIKit import WebKit public protocol StatusCardControlDelegate: AnyObject { func statusCardControl(_ statusCardControl: StatusCardControl, didTapURL url: URL) func statusCardControlMenu(_ statusCardControl: StatusCardControl) -> [LabeledAction]? } public final class StatusCardControl: UIControl { public weak var delegate: StatusCardControlDelegate? private var disposeBag = Set() private let containerStackView = UIStackView() private let labelStackView = UIStackView() private let highlightView = UIView() private let dividerView = UIView() private let imageView = UIImageView() private let titleLabel = UILabel() private let linkLabel = UILabel() private lazy var showEmbedButton: UIButton = { var configuration = UIButton.Configuration.gray() configuration.background.visualEffect = UIBlurEffect(style: .systemUltraThinMaterial) configuration.baseBackgroundColor = .clear configuration.cornerStyle = .capsule configuration.buttonSize = .large configuration.title = L10n.Common.Controls.Status.loadEmbed configuration.image = UIImage(systemName: "play.fill") configuration.imagePadding = 12 return UIButton(configuration: configuration, primaryAction: UIAction { [weak self] _ in self?.showWebView() }) }() private var html = "" private static let cardContentPool = WKProcessPool() private var webView: WKWebView? private var layout: Layout? private var layoutConstraints: [NSLayoutConstraint] = [] private var dividerConstraint: NSLayoutConstraint? public override var isHighlighted: Bool { didSet { // override UIKit behavior of highlighting subviews when cell is highlighted if isHighlighted, let cell = sequence(first: self, next: \.superview).first(where: { $0 is UITableViewCell }) as? UITableViewCell { highlightView.isHidden = cell.isHighlighted } else { highlightView.isHidden = !isHighlighted } } } public override init(frame: CGRect) { super.init(frame: frame) apply(theme: ThemeService.shared.currentTheme.value) ThemeService.shared.currentTheme.sink { [weak self] theme in self?.apply(theme: theme) }.store(in: &disposeBag) clipsToBounds = true layer.cornerCurve = .continuous layer.cornerRadius = 10 maximumContentSizeCategory = .accessibilityLarge highlightView.backgroundColor = UIColor.label.withAlphaComponent(0.1) highlightView.isHidden = true titleLabel.numberOfLines = 2 titleLabel.textColor = Asset.Colors.Label.primary.color titleLabel.font = .preferredFont(forTextStyle: .body) linkLabel.numberOfLines = 1 linkLabel.textColor = Asset.Colors.Label.secondary.color linkLabel.font = .preferredFont(forTextStyle: .subheadline) imageView.tintColor = Asset.Colors.Label.secondary.color imageView.contentMode = .scaleAspectFill imageView.clipsToBounds = true imageView.setContentHuggingPriority(.zero, for: .horizontal) imageView.setContentHuggingPriority(.zero, for: .vertical) imageView.setContentCompressionResistancePriority(.zero, for: .horizontal) imageView.setContentCompressionResistancePriority(.zero, for: .vertical) labelStackView.addArrangedSubview(titleLabel) labelStackView.addArrangedSubview(linkLabel) labelStackView.layoutMargins = .init(top: 10, left: 10, bottom: 10, right: 10) labelStackView.isLayoutMarginsRelativeArrangement = true labelStackView.axis = .vertical labelStackView.spacing = 2 containerStackView.addArrangedSubview(imageView) containerStackView.addArrangedSubview(dividerView) containerStackView.addArrangedSubview(labelStackView) containerStackView.isUserInteractionEnabled = false containerStackView.distribution = .fill addSubview(containerStackView) addSubview(highlightView) addSubview(showEmbedButton) containerStackView.translatesAutoresizingMaskIntoConstraints = false highlightView.translatesAutoresizingMaskIntoConstraints = false showEmbedButton.translatesAutoresizingMaskIntoConstraints = false dividerView.translatesAutoresizingMaskIntoConstraints = false containerStackView.pinToParent() highlightView.pinToParent() NSLayoutConstraint.activate([ showEmbedButton.centerXAnchor.constraint(equalTo: imageView.centerXAnchor), showEmbedButton.centerYAnchor.constraint(equalTo: imageView.centerYAnchor), ]) addInteraction(UIContextMenuInteraction(delegate: self)) isAccessibilityElement = true accessibilityTraits.insert(.link) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } public func configure(card: Card) { let title = card.title.trimmingCharacters(in: .whitespacesAndNewlines) if let host = card.url?.host { accessibilityLabel = "\(title) \(host)" } else { accessibilityLabel = title } titleLabel.text = title linkLabel.text = card.url?.host imageView.contentMode = .center imageView.sd_setImage( with: card.imageURL, placeholderImage: icon(for: card.layout) ) { [weak self] image, _, _, _ in if image != nil { self?.imageView.contentMode = .scaleAspectFill } self?.containerStackView.setNeedsLayout() self?.containerStackView.layoutIfNeeded() } if let html = card.html, !html.isEmpty { showEmbedButton.isHidden = false self.html = html } else { webView?.removeFromSuperview() webView = nil showEmbedButton.isHidden = true self.html = "" } updateConstraints(for: card.layout) } public override func didMoveToWindow() { super.didMoveToWindow() if let window = window { layer.borderWidth = window.screen.pixelSize dividerConstraint?.constant = window.screen.pixelSize } } private func updateConstraints(for layout: Layout) { guard layout != self.layout else { return } self.layout = layout NSLayoutConstraint.deactivate(layoutConstraints) dividerConstraint?.deactivate() let pixelSize = (window?.screen.pixelSize ?? 1) switch layout { case .large(let aspectRatio): containerStackView.alignment = .fill containerStackView.axis = .vertical layoutConstraints = [ imageView.widthAnchor.constraint( equalTo: imageView.heightAnchor, multiplier: aspectRatio ) // This priority is important or constraints break; // it still renders the card correctly. .priority(.defaultLow - 1), // set a reasonable max height for very tall images imageView.heightAnchor .constraint(lessThanOrEqualToConstant: 400), ] dividerConstraint = dividerView.heightAnchor.constraint(equalToConstant: pixelSize).activate() case .compact: containerStackView.alignment = .center containerStackView.axis = .horizontal layoutConstraints = [ imageView.heightAnchor.constraint(equalTo: heightAnchor), imageView.widthAnchor.constraint(equalToConstant: 85), heightAnchor.constraint(equalToConstant: 85).priority(.defaultLow - 1), heightAnchor.constraint(greaterThanOrEqualToConstant: 85), dividerView.heightAnchor.constraint(equalTo: containerStackView.heightAnchor), ] dividerConstraint = dividerView.widthAnchor.constraint(equalToConstant: pixelSize).activate() } NSLayoutConstraint.activate(layoutConstraints) } private func icon(for layout: Layout) -> UIImage? { switch layout { case .compact: return UIImage(systemName: "newspaper.fill") case .large: let configuration = UIImage.SymbolConfiguration(pointSize: 32) return UIImage(systemName: "photo", withConfiguration: configuration) } } private func apply(theme: Theme) { layer.borderColor = theme.separator.cgColor dividerView.backgroundColor = theme.separator imageView.backgroundColor = UIColor.tertiarySystemFill } public override var accessibilityCustomActions: [UIAccessibilityCustomAction]? { get { delegate?.statusCardControlMenu(self)?.map(\.accessibilityCustomAction) } set {} } } // MARK: WKWebView delegates extension StatusCardControl: WKNavigationDelegate, WKUIDelegate { fileprivate func showWebView() { let webView = setupWebView() webView.loadHTMLString("" + html, baseURL: nil) if webView.superview == nil { addSubview(webView) webView.pinTo(to: imageView) } } private func setupWebView() -> WKWebView { if let webView { return webView } let config = WKWebViewConfiguration() config.processPool = Self.cardContentPool config.websiteDataStore = .nonPersistent() // private/incognito mode config.suppressesIncrementalRendering = true config.allowsInlineMediaPlayback = true let webView = WKWebView(frame: .zero, configuration: config) webView.uiDelegate = self webView.navigationDelegate = self webView.translatesAutoresizingMaskIntoConstraints = false webView.isOpaque = false webView.backgroundColor = .clear self.webView = webView return webView } public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { let isTopLevelNavigation: Bool if let frame = navigationAction.targetFrame { isTopLevelNavigation = frame.isMainFrame } else { isTopLevelNavigation = true } if isTopLevelNavigation, // ignore form submits and such navigationAction.navigationType == .linkActivated || navigationAction.navigationType == .other, let url = navigationAction.request.url, url.absoluteString != "about:blank" { delegate?.statusCardControl(self, didTapURL: url) return .cancel } return .allow } public func webViewDidClose(_ webView: WKWebView) { webView.removeFromSuperview() self.webView = nil } } // MARK: UIContextMenuInteractionDelegate extension StatusCardControl { public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { _ in if let elements = self.delegate?.statusCardControlMenu(self)?.map(\.menuElement) { return UIMenu(children: elements) } return nil } } public override func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForDismissingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { UITargetedPreview(view: self) } } private extension StatusCardControl { enum Layout: Equatable { case compact case large(aspectRatio: CGFloat) } } private extension Card { var layout: StatusCardControl.Layout { var aspectRatio = CGFloat(width) / CGFloat(height) if !aspectRatio.isFinite { aspectRatio = 1 } return (abs(aspectRatio - 1) < 0.05 || image == nil) && html == nil ? .compact : .large(aspectRatio: aspectRatio) } } private extension UILayoutPriority { static let zero = UILayoutPriority(rawValue: 0) }