// // ReadViewController.swift // Reader // // Created by Shadowfacts on 1/9/22. // import UIKit import WebKit import HTMLEntities import SafariServices import Combine import Persistence class ReadViewController: UIViewController { private static let publishedFormatter: DateFormatter = { let f = DateFormatter() f.dateStyle = .medium f.timeStyle = .medium return f }() let fervorController: FervorController let item: Item private let scrollPositionChangedSubject = PassthroughSubject() private var cancellables = Set() private var scrollTargetSelector: String? private var webView: WKWebView! #if targetEnvironment(macCatalyst) private var itemReadObservation: NSKeyValueObservation? #endif override var prefersStatusBarHidden: Bool { navigationController?.isNavigationBarHidden ?? false } override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation { .slide } init(item: Item, fervorController: FervorController) { self.fervorController = fervorController self.item = item super.init(nibName: nil, bundle: nil) self.userActivity = .readItem(item, account: fervorController.account!) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() navigationItem.largeTitleDisplayMode = .never view.backgroundColor = .appBackground let menuInteraction = StretchyMenuInteraction(delegate: self) view.addInteraction(menuInteraction) webView = WKWebView() webView.translatesAutoresizingMaskIntoConstraints = false webView.navigationDelegate = self webView.uiDelegate = self webView.scrollView.delegate = self // transparent background required to prevent white flash in dark mode, just using .appBackground doesn't work webView.isOpaque = false webView.backgroundColor = .clear if #available(iOS 16.0, *) { webView.isFindInteractionEnabled = true } if let content = itemContentHTML() { webView.loadHTMLString(content, baseURL: item.url) } webView.scrollView.alwaysBounceHorizontal = false webView.scrollView.panGestureRecognizer.require(toFail: menuInteraction.panRecognizer!) view.addSubview(webView) NSLayoutConstraint.activate([ webView.leadingAnchor.constraint(equalTo: view.leadingAnchor), webView.trailingAnchor.constraint(equalTo: view.trailingAnchor), webView.topAnchor.constraint(equalTo: view.topAnchor), webView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) if let url = item.url { activityItemsConfiguration = UIActivityItemsConfiguration(objects: [url as NSURL]) } scrollPositionChangedSubject .debounce(for: .milliseconds(500), scheduler: RunLoop.main) .sink { [unowned self] in self.updateUserActivityScrollPosition() } .store(in: &cancellables) #if targetEnvironment(macCatalyst) itemReadObservation = item.observe(\.read) { [unowned self] _, _ in self.updateToggleReadToolbarImage() } #endif } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) updateScrollIndicatorStyle() } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) updateScrollIndicatorStyle() } override func restoreUserActivityState(_ activity: NSUserActivity) { if let selector = activity.topElementSelector { scrollTargetSelector = selector if isViewLoaded { scrollToTargetSelector() } } } private func scrollToTargetSelector() { guard let selector = scrollTargetSelector else { return } let js = """ const doScroll = () => { const el = document.querySelector(sel); if (!el) { throw new Error("not ready yet"); } el.scrollIntoView(); }; if (document.readyState !== "complete") { window.addEventListener("load", doScroll); } else { doScroll(); } """ webView.callAsyncJavaScript(js, arguments: ["sel": selector], in: nil, in: .defaultClient) { result in switch result { case .failure(let error): print(error) case .success(_): self.scrollTargetSelector = nil } } } private func updateScrollIndicatorStyle() { guard #available(iOS 15.4, *) else { // different workaround pre-iOS 15.4 return } // can't use .default because that causes the WKScrollView to think the indicator style has not been set by the client (us): // https://github.com/WebKit/WebKit/blob/1dbd34cf01d8b5aedcb8820b13cb6553ed60e8ed/Source/WebKit/UIProcess/ios/WKScrollView.mm#L247 // if that happens, it goes back to trying to set it based on background color which doesn't work because we give it a clear background // https://github.com/WebKit/WebKit/blob/d085008d57f784d0913a8c37351f60b4a0eb8a3a/Source/WebKit/UIProcess/API/ios/WKWebViewIOS.mm#L562 // https://github.com/WebKit/WebKit/blob/d085008d57f784d0913a8c37351f60b4a0eb8a3a/Source/WebKit/UIProcess/API/ios/WKWebViewIOS.mm#L520 // so, we set it ourselves based on the user interface style if traitCollection.userInterfaceStyle == .dark { webView.scrollView.indicatorStyle = .white } else { webView.scrollView.indicatorStyle = .black } } private static let css = try! String(contentsOf: Bundle.main.url(forResource: "read", withExtension: "css")!) private static let js = try! String(contentsOf: Bundle.main.url(forResource: "read", withExtension: "js")!) private func itemContentHTML() -> String? { guard let content = item.content else { return nil } var info = "" if let title = item.title, !title.isEmpty { info += "

" if let url = item.url { info += "" } info += title.htmlEscape() if item.url != nil { info += "" } info += "

" } if let feedTitle = item.feed!.title, !feedTitle.isEmpty { info += "

\(feedTitle.htmlEscape())

" } if let author = item.author, !author.isEmpty { info += "

\(author)

" } if let published = item.published { let formatted = ReadViewController.publishedFormatter.string(from: published) info += "

\(formatted)

" } return """
\(info)
\(content)
""" } private func createSafariVC(url: URL) -> SFSafariViewController { let vc = SFSafariViewController(url: url) vc.preferredControlTintColor = .appTintColor return vc } func updateUserActivityScrollPosition() { // can't use needsSave and updateUserActivityState(_:) because running JS is asynchronous let js = """ (() => { function topVisibleChild(element) { if (element.children.length <= 0 || element.tagName === "svg") { return element; } let min, minDistanceToTop; for (const c of element.children) { const rect = c.getBoundingClientRect(); if (!min || Math.abs(rect.top) < minDistanceToTop) { min = c; minDistanceToTop = Math.abs(rect.top); } } return topVisibleChild(min); } function uniqueSelector(self) { if (!self) { return null; } else if (self.tagName === "BODY") { return "body"; } else { let selfSel; if (self.id) { return `#${self.id}`; } else if (Array.from(self.parentNode.children).filter((e) => e.tagName === self.tagName).length === 1) { selfSel = ` > ${self.tagName.toLowerCase()}`; } else { const index = Array.from(self.parentNode.children).indexOf(self); selfSel = ` > ${self.tagName.toLowerCase()}:nth-child(${index + 1})`; } const parentSel = uniqueSelector(self.parentNode); return parentSel + selfSel; } } return uniqueSelector(topVisibleChild(document.getElementById("item-content"))); })() """ // todo: check if can be scrolled into view webView.evaluateJavaScript(js) { result, error in guard let result = result as? String else { return } self.userActivity?.topElementSelector = result } } #if targetEnvironment(macCatalyst) @objc func toggleItemRead(_ item: NSToolbarItem) { Task { await fervorController.markItem(self.item, read: !self.item.read) updateToggleReadToolbarImage() } } private func updateToggleReadToolbarImage() { guard let titlebar = view.window?.windowScene?.titlebar, let item = titlebar.toolbar?.items.first(where: { $0.itemIdentifier == .toggleItemRead }) else { return } item.image = UIImage(systemName: self.item.read ? "checkmark.circle.fill" : "checkmark.circle") } #endif } extension ReadViewController: WKNavigationDelegate { func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction) async -> WKNavigationActionPolicy { if navigationAction.navigationType == .linkActivated { let url = navigationAction.request.url! if url.fragment != nil { var components = URLComponents(url: url, resolvingAgainstBaseURL: false)! components.fragment = nil if components.url == item.url { return .allow } } present(createSafariVC(url: url), animated: true) return .cancel } else { return .allow } } func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { // we try to scroll after the navigation triggered by the loadHTML, since that's asynchronous but doesn't have a callback scrollToTargetSelector() } } extension ReadViewController: WKUIDelegate { func webView(_ webView: WKWebView, contextMenuConfigurationFor elementInfo: WKContextMenuElementInfo) async -> UIContextMenuConfiguration? { guard let url = elementInfo.linkURL, ["http", "https"].contains(url.scheme?.lowercased()) else { return nil } return UIContextMenuConfiguration(identifier: nil) { [unowned self] in self.createSafariVC(url: url) } actionProvider: { _ in return UIMenu(children: [ UIAction(title: "Open in Safari", image: UIImage(systemName: "safari"), handler: { [weak self] _ in guard let self = self else { return } self.present(self.createSafariVC(url: url), animated: true) }), UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up"), handler: { [weak self] _ in self?.present(UIActivityViewController(activityItems: [url], applicationActivities: nil), animated: true) }), ]) } } func webView(_ webView: WKWebView, contextMenuForElement elementInfo: WKContextMenuElementInfo, willCommitWithAnimator animator: UIContextMenuInteractionCommitAnimating) { if let vc = animator.previewViewController as? SFSafariViewController { animator.preferredCommitStyle = .pop animator.addCompletion { self.present(vc, animated: true) } } } } extension ReadViewController: UIScrollViewDelegate { func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { scrollPositionChangedSubject.send() } func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { scrollPositionChangedSubject.send() } } extension ReadViewController: StretchyMenuInteractionDelegate { func stretchyMenuTitle() -> String? { return nil } func stretchyMenuItems() -> [StretchyMenuItem] { guard let url = item.url else { return [] } var items = [ StretchyMenuItem(title: "Open in Safari", subtitle: nil, action: { [unowned self] in self.present(createSafariVC(url: url), animated: true) }), StretchyMenuItem(title: item.read ? "Mark as Unread" : "Mark as Read", subtitle: nil, action: { [unowned self] in Task { await self.fervorController.markItem(item, read: !item.read) } }), ] #if !targetEnvironment(macCatalyst) items.insert(StretchyMenuItem(title: "Share", subtitle: nil, action: { [unowned self] in self.present(UIActivityViewController(activityItems: [url], applicationActivities: nil), animated: true) }), at: 1) #endif return items } }