413 lines
15 KiB
Swift
413 lines
15 KiB
Swift
//
|
|
// 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<Void, Never>()
|
|
private var cancellables = Set<AnyCancellable>()
|
|
private var scrollTargetSelector: String?
|
|
|
|
private var webView: WKWebView!
|
|
|
|
override var prefersStatusBarHidden: Bool {
|
|
if navigationController?.isNavigationBarHidden == true,
|
|
let webView,
|
|
webView.scrollView.contentOffset.y > -webView.scrollView.safeAreaInsets.top {
|
|
return true
|
|
} else {
|
|
return 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
|
|
view.addInteraction(StretchyMenuInteraction(delegate: self))
|
|
|
|
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.alwaysBounceVertical = true
|
|
webView.scrollView.alwaysBounceHorizontal = false
|
|
view.addSubview(webView)
|
|
|
|
// subtract 0.5, because otherwise, on ipad, the web view's scroll content view ends up being wider than the scroll view itself, causing the content to bounce horizontally
|
|
let webViewWidthFix = UIDevice.current.userInterfaceIdiom == .pad ? -0.5 : 0
|
|
|
|
NSLayoutConstraint.activate([
|
|
webView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
|
webView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: webViewWidthFix),
|
|
webView.topAnchor.constraint(equalTo: view.topAnchor),
|
|
webView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
])
|
|
|
|
if let url = item.url {
|
|
activityItemsConfiguration = UIActivityItemsConfiguration(objects: [url as NSURL])
|
|
}
|
|
|
|
webView.scrollView.publisher(for: \.contentOffset)
|
|
.map { [unowned self] _ in
|
|
self.prefersStatusBarHidden
|
|
}
|
|
.removeDuplicates()
|
|
.sink { [unowned self] _ in
|
|
UIView.animate(withDuration: 0.2, delay: 0) {
|
|
self.setNeedsStatusBarAppearanceUpdate()
|
|
}
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
scrollPositionChangedSubject
|
|
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
|
|
.sink { [unowned self] in
|
|
self.updateUserActivityScrollPosition()
|
|
}
|
|
.store(in: &cancellables)
|
|
|
|
#if targetEnvironment(macCatalyst)
|
|
item.publisher(for: \.read)
|
|
.sink { [unowned self] _ in
|
|
self.updateToggleReadToolbarImage()
|
|
}
|
|
.store(in: &cancellables)
|
|
#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 += "<h1 id=\"item-title\">"
|
|
if let url = item.url {
|
|
info += "<a href=\"\(url.absoluteString)\">"
|
|
}
|
|
info += title.htmlEscape()
|
|
if item.url != nil {
|
|
info += "</a>"
|
|
}
|
|
info += "</h1>"
|
|
}
|
|
if let feedTitle = item.feed!.title, !feedTitle.isEmpty {
|
|
info += "<h2 id=\"item-feed-title\">\(feedTitle.htmlEscape())</h2>"
|
|
}
|
|
if let author = item.author, !author.isEmpty {
|
|
info += "<h3 id=\"item-author\">\(author)</h3>"
|
|
}
|
|
if let published = item.published {
|
|
let formatted = ReadViewController.publishedFormatter.string(from: published)
|
|
info += "<h3 id=\"item-published\">\(formatted)</h3>"
|
|
}
|
|
return """
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no" />
|
|
<style>\(ReadViewController.css)</style>
|
|
<script async>\(ReadViewController.js)</script>
|
|
</head>
|
|
<body>
|
|
<div id="item-info">
|
|
\(info)
|
|
</div>
|
|
<div id="item-content">
|
|
\(content)
|
|
</div>
|
|
</body>
|
|
</html>
|
|
"""
|
|
}
|
|
|
|
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 <source> 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", 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", action: { [unowned self] in
|
|
self.present(UIActivityViewController(activityItems: [url], applicationActivities: nil), animated: true)
|
|
}), at: 1)
|
|
#endif
|
|
return items
|
|
}
|
|
}
|