NetNewsWire/Multiplatform/iOS/Article/WebViewController.swift

766 lines
24 KiB
Swift

//
// WebViewController.swift
// Multiplatform iOS
//
// Created by Maurice Parker on 7/6/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
import WebKit
import RSCore
import Account
import Articles
import SafariServices
import MessageUI
protocol WebViewControllerDelegate: class {
func webViewController(_: WebViewController, articleExtractorButtonStateDidUpdate: ArticleExtractorButtonState)
}
class WebViewController: UIViewController {
private struct MessageName {
static let imageWasClicked = "imageWasClicked"
static let imageWasShown = "imageWasShown"
static let showFeedInspector = "showFeedInspector"
}
private var topShowBarsView: UIView!
private var bottomShowBarsView: UIView!
private var topShowBarsViewConstraint: NSLayoutConstraint!
private var bottomShowBarsViewConstraint: NSLayoutConstraint!
private var webView: PreloadedWebView? {
guard view.subviews.count > 0 else { return nil }
return view.subviews[0] as? PreloadedWebView
}
// private lazy var contextMenuInteraction = UIContextMenuInteraction(delegate: self)
private var isFullScreenAvailable: Bool {
return AppDefaults.shared.articleFullscreenAvailable && traitCollection.userInterfaceIdiom == .phone // && coordinator.isRootSplitCollapsed
}
private lazy var transition = ImageTransition(controller: self)
private var clickedImageCompletion: (() -> Void)?
private var articleExtractor: ArticleExtractor? = nil
var extractedArticle: ExtractedArticle? {
didSet {
windowScrollY = 0
}
}
var isShowingExtractedArticle = false
var articleExtractorButtonState: ArticleExtractorButtonState = .off {
didSet {
delegate?.webViewController(self, articleExtractorButtonStateDidUpdate: articleExtractorButtonState)
}
}
var sceneModel: SceneModel?
weak var delegate: WebViewControllerDelegate?
private(set) var article: Article?
let scrollPositionQueue = CoalescingQueue(name: "Article Scroll Position", interval: 0.3, maxInterval: 0.3)
var windowScrollY = 0
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
// Configure the tap zones
// configureTopShowBarsView()
// configureBottomShowBarsView()
loadWebView()
}
// MARK: Notifications
@objc func webFeedIconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func avatarDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func faviconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
// MARK: Actions
// @objc func showBars(_ sender: Any) {
// showBars()
// }
// MARK: API
func setArticle(_ article: Article?, updateView: Bool = true) {
stopArticleExtractor()
if article != self.article {
self.article = article
if updateView {
if article?.webFeed?.isArticleExtractorAlwaysOn ?? false {
startArticleExtractor()
}
windowScrollY = 0
loadWebView()
}
}
}
func focus() {
webView?.becomeFirstResponder()
}
func canScrollDown() -> Bool {
guard let webView = webView else { return false }
return webView.scrollView.contentOffset.y < finalScrollPosition()
}
func scrollPageDown() {
guard let webView = webView else { return }
let overlap = 2 * UIFont.systemFont(ofSize: UIFont.systemFontSize).lineHeight * UIScreen.main.scale
let scrollToY: CGFloat = {
let fullScroll = webView.scrollView.contentOffset.y + webView.scrollView.layoutMarginsGuide.layoutFrame.height - overlap
let final = finalScrollPosition()
return fullScroll < final ? fullScroll : final
}()
let convertedPoint = self.view.convert(CGPoint(x: 0, y: 0), to: webView.scrollView)
let scrollToPoint = CGPoint(x: convertedPoint.x, y: scrollToY)
webView.scrollView.setContentOffset(scrollToPoint, animated: true)
}
func hideClickedImage() {
webView?.evaluateJavaScript("hideClickedImage();")
}
func showClickedImage(completion: @escaping () -> Void) {
clickedImageCompletion = completion
webView?.evaluateJavaScript("showClickedImage();")
}
func fullReload() {
loadWebView(replaceExistingWebView: true)
}
// func showBars() {
// AppDefaults.shared.articleFullscreenEnabled = false
// coordinator.showStatusBar()
// topShowBarsViewConstraint?.constant = 0
// bottomShowBarsViewConstraint?.constant = 0
// navigationController?.setNavigationBarHidden(false, animated: true)
// navigationController?.setToolbarHidden(false, animated: true)
// configureContextMenuInteraction()
// }
//
// func hideBars() {
// if isFullScreenAvailable {
// AppDefaults.shared.articleFullscreenEnabled = true
// coordinator.hideStatusBar()
// topShowBarsViewConstraint?.constant = -44.0
// bottomShowBarsViewConstraint?.constant = 44.0
// navigationController?.setNavigationBarHidden(true, animated: true)
// navigationController?.setToolbarHidden(true, animated: true)
// configureContextMenuInteraction()
// }
// }
func toggleArticleExtractor() {
guard let article = article else {
return
}
guard articleExtractor?.state != .processing else {
stopArticleExtractor()
loadWebView()
return
}
guard !isShowingExtractedArticle else {
isShowingExtractedArticle = false
loadWebView()
articleExtractorButtonState = .off
return
}
if let articleExtractor = articleExtractor {
if article.preferredLink == articleExtractor.articleLink {
isShowingExtractedArticle = true
loadWebView()
articleExtractorButtonState = .on
}
} else {
startArticleExtractor()
}
}
func stopArticleExtractorIfProcessing() {
if articleExtractor?.state == .processing {
stopArticleExtractor()
}
}
func stopWebViewActivity() {
if let webView = webView {
stopMediaPlayback(webView)
cancelImageLoad(webView)
}
}
}
// MARK: ArticleExtractorDelegate
extension WebViewController: ArticleExtractorDelegate {
func articleExtractionDidFail(with: Error) {
stopArticleExtractor()
articleExtractorButtonState = .error
loadWebView()
}
func articleExtractionDidComplete(extractedArticle: ExtractedArticle) {
if articleExtractor?.state != .cancelled {
self.extractedArticle = extractedArticle
isShowingExtractedArticle = true
loadWebView()
articleExtractorButtonState = .on
}
}
}
// MARK: UIContextMenuInteractionDelegate
//extension WebViewController: UIContextMenuInteractionDelegate {
// func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
//
// return UIContextMenuConfiguration(identifier: nil, previewProvider: contextMenuPreviewProvider) { [weak self] suggestedActions in
// guard let self = self else { return nil }
// var actions = [UIAction]()
//
// if let action = self.prevArticleAction() {
// actions.append(action)
// }
// if let action = self.nextArticleAction() {
// actions.append(action)
// }
// if let action = self.toggleReadAction() {
// actions.append(action)
// }
// actions.append(self.toggleStarredAction())
// if let action = self.nextUnreadArticleAction() {
// actions.append(action)
// }
// actions.append(self.toggleArticleExtractorAction())
// actions.append(self.shareAction())
//
// return UIMenu(title: "", children: actions)
// }
// }
//
// func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
// coordinator.showBrowserForCurrentArticle()
// }
//
//}
// MARK: WKNavigationDelegate
extension WebViewController: WKNavigationDelegate {
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
for (index, view) in view.subviews.enumerated() {
if index != 0, let oldWebView = view as? PreloadedWebView {
oldWebView.removeFromSuperview()
}
}
}
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if navigationAction.navigationType == .linkActivated {
guard let url = navigationAction.request.url else {
decisionHandler(.allow)
return
}
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
if components?.scheme == "http" || components?.scheme == "https" {
decisionHandler(.cancel)
// If the resource cannot be opened with an installed app, present the web view.
UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { didOpen in
assert(Thread.isMainThread)
guard didOpen == false else {
return
}
let vc = SFSafariViewController(url: url)
self.present(vc, animated: true)
}
} else if components?.scheme == "mailto" {
decisionHandler(.cancel)
guard let emailAddress = url.percentEncodedEmailAddress else {
return
}
if UIApplication.shared.canOpenURL(emailAddress) {
UIApplication.shared.open(emailAddress, options: [.universalLinksOnly : false], completionHandler: nil)
} else {
let alert = UIAlertController(title: NSLocalizedString("Error", comment: "Error"), message: NSLocalizedString("This device cannot send emails.", comment: "This device cannot send emails."), preferredStyle: .alert)
alert.addAction(.init(title: NSLocalizedString("Dismiss", comment: "Dismiss"), style: .cancel, handler: nil))
self.present(alert, animated: true, completion: nil)
}
} else if components?.scheme == "tel" {
decisionHandler(.cancel)
if UIApplication.shared.canOpenURL(url) {
UIApplication.shared.open(url, options: [.universalLinksOnly : false], completionHandler: nil)
}
} else {
decisionHandler(.allow)
}
} else {
decisionHandler(.allow)
}
}
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
fullReload()
}
}
// MARK: WKUIDelegate
extension WebViewController: WKUIDelegate {
func webView(_ webView: WKWebView, contextMenuForElement elementInfo: WKContextMenuElementInfo, willCommitWithAnimator animator: UIContextMenuInteractionCommitAnimating) {
// We need to have at least an unimplemented WKUIDelegate assigned to the WKWebView. This makes the
// link preview launch Safari when the link preview is tapped. In theory, you shoud be able to get
// the link from the elementInfo above and transition to SFSafariViewController instead of launching
// Safari. As the time of this writing, the link in elementInfo is always nil. ¯\_()_/¯
}
}
// MARK: WKScriptMessageHandler
extension WebViewController: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
switch message.name {
case MessageName.imageWasShown:
clickedImageCompletion?()
case MessageName.imageWasClicked:
imageWasClicked(body: message.body as? String)
case MessageName.showFeedInspector:
return
// if let webFeed = article?.webFeed {
// coordinator.showFeedInspector(for: webFeed)
// }
default:
return
}
}
}
// MARK: UIViewControllerTransitioningDelegate
extension WebViewController: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition.presenting = true
return transition
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition.presenting = false
return transition
}
}
// MARK:
extension WebViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
scrollPositionQueue.add(self, #selector(scrollPositionDidChange))
}
@objc func scrollPositionDidChange() {
webView?.evaluateJavaScript("window.scrollY") { (scrollY, error) in
guard error == nil else { return }
let javascriptScrollY = scrollY as? Int ?? 0
// I don't know why this value gets returned sometimes, but it is in error
guard javascriptScrollY != 33554432 else { return }
self.windowScrollY = javascriptScrollY
}
}
}
// MARK: JSON
private struct ImageClickMessage: Codable {
let x: Float
let y: Float
let width: Float
let height: Float
let imageTitle: String?
let imageURL: String
}
// MARK: Private
private extension WebViewController {
func loadWebView(replaceExistingWebView: Bool = false) {
guard isViewLoaded else { return }
if !replaceExistingWebView, let webView = webView {
self.renderPage(webView)
return
}
sceneModel?.webViewProvider?.dequeueWebView() { webView in
webView.ready {
// Add the webview
webView.translatesAutoresizingMaskIntoConstraints = false
self.view.insertSubview(webView, at: 0)
NSLayoutConstraint.activate([
self.view.leadingAnchor.constraint(equalTo: webView.leadingAnchor),
self.view.trailingAnchor.constraint(equalTo: webView.trailingAnchor),
self.view.topAnchor.constraint(equalTo: webView.topAnchor),
self.view.bottomAnchor.constraint(equalTo: webView.bottomAnchor)
])
// UISplitViewController reports the wrong size to WKWebView which can cause horizontal
// rubberbanding on the iPad. This interferes with our UIPageViewController preventing
// us from easily swiping between WKWebViews. This hack fixes that.
webView.scrollView.contentInset = UIEdgeInsets(top: 0, left: -1, bottom: 0, right: 0)
webView.scrollView.setZoomScale(1.0, animated: false)
self.view.setNeedsLayout()
self.view.layoutIfNeeded()
// Configure the webview
webView.navigationDelegate = self
webView.uiDelegate = self
webView.scrollView.delegate = self
// self.configureContextMenuInteraction()
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasClicked)
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasShown)
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.showFeedInspector)
self.renderPage(webView)
}
}
}
func renderPage(_ webView: PreloadedWebView?) {
guard let webView = webView else { return }
let style = ArticleStylesManager.shared.currentStyle
let rendering: ArticleRenderer.Rendering
if let articleExtractor = articleExtractor, articleExtractor.state == .processing {
rendering = ArticleRenderer.loadingHTML(style: style)
} else if let articleExtractor = articleExtractor, articleExtractor.state == .failedToParse, let article = article {
rendering = ArticleRenderer.articleHTML(article: article, style: style)
} else if let article = article, let extractedArticle = extractedArticle {
if isShowingExtractedArticle {
rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, style: style)
} else {
rendering = ArticleRenderer.articleHTML(article: article, style: style)
}
} else if let article = article {
rendering = ArticleRenderer.articleHTML(article: article, style: style)
} else {
rendering = ArticleRenderer.noSelectionHTML(style: style)
}
let substitutions = [
"title": rendering.title,
"baseURL": rendering.baseURL,
"style": rendering.style,
"body": rendering.html,
"windowScrollY": String(windowScrollY)
]
let html = try! MacroProcessor.renderedText(withTemplate: ArticleRenderer.page.html, substitutions: substitutions)
webView.loadHTMLString(html, baseURL: ArticleRenderer.page.baseURL)
}
func finalScrollPosition() -> CGFloat {
guard let webView = webView else { return 0 }
return webView.scrollView.contentSize.height - webView.scrollView.bounds.height + webView.scrollView.safeAreaInsets.bottom
}
func startArticleExtractor() {
if let link = article?.preferredLink, let extractor = ArticleExtractor(link) {
extractor.delegate = self
extractor.process()
articleExtractor = extractor
articleExtractorButtonState = .animated
}
}
func stopArticleExtractor() {
articleExtractor?.cancel()
articleExtractor = nil
isShowingExtractedArticle = false
articleExtractorButtonState = .off
}
func reloadArticleImage() {
guard let article = article else { return }
var components = URLComponents()
components.scheme = ArticleRenderer.imageIconScheme
components.path = article.articleID
if let imageSrc = components.string {
webView?.evaluateJavaScript("reloadArticleImage(\"\(imageSrc)\")")
}
}
func imageWasClicked(body: String?) {
guard let webView = webView,
let body = body,
let data = body.data(using: .utf8),
let clickMessage = try? JSONDecoder().decode(ImageClickMessage.self, from: data),
let range = clickMessage.imageURL.range(of: ";base64,")
else { return }
let base64Image = String(clickMessage.imageURL.suffix(from: range.upperBound))
if let imageData = Data(base64Encoded: base64Image), let image = UIImage(data: imageData) {
let y = CGFloat(clickMessage.y) + webView.safeAreaInsets.top
let rect = CGRect(x: CGFloat(clickMessage.x), y: y, width: CGFloat(clickMessage.width), height: CGFloat(clickMessage.height))
transition.originFrame = webView.convert(rect, to: nil)
if navigationController?.navigationBar.isHidden ?? false {
transition.maskFrame = webView.convert(webView.frame, to: nil)
} else {
transition.maskFrame = webView.convert(webView.safeAreaLayoutGuide.layoutFrame, to: nil)
}
transition.originImage = image
// coordinator.showFullScreenImage(image: image, imageTitle: clickMessage.imageTitle, transitioningDelegate: self)
}
}
func stopMediaPlayback(_ webView: WKWebView) {
webView.evaluateJavaScript("stopMediaPlayback();")
}
func cancelImageLoad(_ webView: WKWebView) {
webView.evaluateJavaScript("cancelImageLoad();")
}
// func configureTopShowBarsView() {
// topShowBarsView = UIView()
// topShowBarsView.backgroundColor = .clear
// topShowBarsView.translatesAutoresizingMaskIntoConstraints = false
// view.addSubview(topShowBarsView)
//
// if AppDefaults.shared.articleFullscreenEnabled {
// topShowBarsViewConstraint = view.topAnchor.constraint(equalTo: topShowBarsView.bottomAnchor, constant: -44.0)
// } else {
// topShowBarsViewConstraint = view.topAnchor.constraint(equalTo: topShowBarsView.bottomAnchor, constant: 0.0)
// }
//
// NSLayoutConstraint.activate([
// topShowBarsViewConstraint,
// view.leadingAnchor.constraint(equalTo: topShowBarsView.leadingAnchor),
// view.trailingAnchor.constraint(equalTo: topShowBarsView.trailingAnchor),
// topShowBarsView.heightAnchor.constraint(equalToConstant: 44.0)
// ])
// topShowBarsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:))))
// }
//
// func configureBottomShowBarsView() {
// bottomShowBarsView = UIView()
// topShowBarsView.backgroundColor = .clear
// bottomShowBarsView.translatesAutoresizingMaskIntoConstraints = false
// view.addSubview(bottomShowBarsView)
// if AppDefaults.shared.articleFullscreenEnabled {
// bottomShowBarsViewConstraint = view.bottomAnchor.constraint(equalTo: bottomShowBarsView.topAnchor, constant: 44.0)
// } else {
// bottomShowBarsViewConstraint = view.bottomAnchor.constraint(equalTo: bottomShowBarsView.topAnchor, constant: 0.0)
// }
// NSLayoutConstraint.activate([
// bottomShowBarsViewConstraint,
// view.leadingAnchor.constraint(equalTo: bottomShowBarsView.leadingAnchor),
// view.trailingAnchor.constraint(equalTo: bottomShowBarsView.trailingAnchor),
// bottomShowBarsView.heightAnchor.constraint(equalToConstant: 44.0)
// ])
// bottomShowBarsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:))))
// }
// func configureContextMenuInteraction() {
// if isFullScreenAvailable {
// if navigationController?.isNavigationBarHidden ?? false {
// webView?.addInteraction(contextMenuInteraction)
// } else {
// webView?.removeInteraction(contextMenuInteraction)
// }
// }
// }
//
// func contextMenuPreviewProvider() -> UIViewController {
// let previewProvider = UIStoryboard.main.instantiateController(ofType: ContextMenuPreviewViewController.self)
// previewProvider.article = article
// return previewProvider
// }
//
// func prevArticleAction() -> UIAction? {
// guard coordinator.isPrevArticleAvailable else { return nil }
// let title = NSLocalizedString("Previous Article", comment: "Previous Article")
// return UIAction(title: title, image: AppAssets.prevArticleImage) { [weak self] action in
// self?.coordinator.selectPrevArticle()
// }
// }
//
// func nextArticleAction() -> UIAction? {
// guard coordinator.isNextArticleAvailable else { return nil }
// let title = NSLocalizedString("Next Article", comment: "Next Article")
// return UIAction(title: title, image: AppAssets.nextArticleImage) { [weak self] action in
// self?.coordinator.selectNextArticle()
// }
// }
//
// func toggleReadAction() -> UIAction? {
// guard let article = article, !article.status.read || article.isAvailableToMarkUnread else { return nil }
//
// let title = article.status.read ? NSLocalizedString("Mark as Unread", comment: "Mark as Unread") : NSLocalizedString("Mark as Read", comment: "Mark as Read")
// let readImage = article.status.read ? AppAssets.circleClosedImage : AppAssets.circleOpenImage
// return UIAction(title: title, image: readImage) { [weak self] action in
// self?.coordinator.toggleReadForCurrentArticle()
// }
// }
//
// func toggleStarredAction() -> UIAction {
// let starred = article?.status.starred ?? false
// let title = starred ? NSLocalizedString("Mark as Unstarred", comment: "Mark as Unstarred") : NSLocalizedString("Mark as Starred", comment: "Mark as Starred")
// let starredImage = starred ? AppAssets.starOpenImage : AppAssets.starClosedImage
// return UIAction(title: title, image: starredImage) { [weak self] action in
// self?.coordinator.toggleStarredForCurrentArticle()
// }
// }
//
// func nextUnreadArticleAction() -> UIAction? {
// guard coordinator.isAnyUnreadAvailable else { return nil }
// let title = NSLocalizedString("Next Unread Article", comment: "Next Unread Article")
// return UIAction(title: title, image: AppAssets.nextUnreadArticleImage) { [weak self] action in
// self?.coordinator.selectNextUnread()
// }
// }
//
// func toggleArticleExtractorAction() -> UIAction {
// let extracted = articleExtractorButtonState == .on
// let title = extracted ? NSLocalizedString("Show Feed Article", comment: "Show Feed Article") : NSLocalizedString("Show Reader View", comment: "Show Reader View")
// let extractorImage = extracted ? AppAssets.articleExtractorOffSF : AppAssets.articleExtractorOnSF
// return UIAction(title: title, image: extractorImage) { [weak self] action in
// self?.toggleArticleExtractor()
// }
// }
//
// func shareAction() -> UIAction {
// let title = NSLocalizedString("Share", comment: "Share")
// return UIAction(title: title, image: AppAssets.shareImage) { [weak self] action in
// self?.showActivityDialog()
// }
// }
}
// MARK: Find in Article
private struct FindInArticleOptions: Codable {
var text: String
var caseSensitive = false
var regex = false
}
internal struct FindInArticleState: Codable {
struct WebViewClientRect: Codable {
let x: Double
let y: Double
let width: Double
let height: Double
}
struct FindInArticleResult: Codable {
let rects: [WebViewClientRect]
let bounds: WebViewClientRect
let index: UInt
let matchGroups: [String]
}
let index: UInt?
let results: [FindInArticleResult]
let count: UInt
}
extension WebViewController {
func searchText(_ searchText: String, completionHandler: @escaping (FindInArticleState) -> Void) {
guard let json = try? JSONEncoder().encode(FindInArticleOptions(text: searchText)) else {
return
}
let encoded = json.base64EncodedString()
webView?.evaluateJavaScript("updateFind(\"\(encoded)\")") {
(result, error) in
guard error == nil,
let b64 = result as? String,
let rawData = Data(base64Encoded: b64),
let findState = try? JSONDecoder().decode(FindInArticleState.self, from: rawData) else {
return
}
completionHandler(findState)
}
}
func endSearch() {
webView?.evaluateJavaScript("endFind()")
}
func selectNextSearchResult() {
webView?.evaluateJavaScript("selectNextResult()")
}
func selectPreviousSearchResult() {
webView?.evaluateJavaScript("selectPreviousResult()")
}
}