diff --git a/Reader/FervorController.swift b/Reader/FervorController.swift
index f2956ab..9c32f89 100644
--- a/Reader/FervorController.swift
+++ b/Reader/FervorController.swift
@@ -155,6 +155,17 @@ actor FervorController {
}
}
+ @MainActor
+ func fetchItem(id: String) async throws -> Item? {
+ guard let serverItem = try await client.item(id: id) else {
+ return nil
+ }
+ let item = Item(context: persistentContainer.viewContext)
+ item.updateFromServer(serverItem)
+ try persistentContainer.saveViewContext()
+ return item
+ }
+
}
extension FervorController {
diff --git a/Reader/Info.plist b/Reader/Info.plist
index 4f9bd6a..1542fc1 100644
--- a/Reader/Info.plist
+++ b/Reader/Info.plist
@@ -8,6 +8,7 @@
$(PRODUCT_BUNDLE_IDENTIFIER).activity.read-all
$(PRODUCT_BUNDLE_IDENTIFIER).activity.read-feed
$(PRODUCT_BUNDLE_IDENTIFIER).activity.read-group
+ $(PRODUCT_BUNDLE_IDENTIFIER).activity.read-item
$(PRODUCT_BUNDLE_IDENTIFIER).activity.preferences
$(PRODUCT_BUNDLE_IDENTIFIER).activity.add-account
$(PRODUCT_BUNDLE_IDENTIFIER).activity.activate-account
diff --git a/Reader/SceneDelegate.swift b/Reader/SceneDelegate.swift
index 08c44be..28c7432 100644
--- a/Reader/SceneDelegate.swift
+++ b/Reader/SceneDelegate.swift
@@ -47,7 +47,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
syncFromServer()
createAppUI()
if let activity = activity {
- setupUI(from: activity)
+ await setupUI(from: activity)
}
setupSceneActivationConditions()
}
@@ -109,10 +109,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
- setupUI(from: userActivity)
+ Task { @MainActor in
+ await setupUI(from: userActivity)
+ }
}
- private func setupUI(from activity: NSUserActivity) {
+ private func setupUI(from activity: NSUserActivity) async {
guard let split = window?.rootViewController as? AppSplitViewController else {
logger.error("Failed to setup UI for user activity: missing split VC")
return
@@ -140,7 +142,23 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
if let group = try? fervorController.persistentContainer.viewContext.fetch(req).first {
split.showItemList(.group(group))
}
+ case NSUserActivity.readItemType:
+ guard let itemID = activity.itemID else {
+ break
+ }
+ let req = Item.fetchRequest()
+ req.predicate = NSPredicate(format: "id = %@", itemID)
+ var read: ReadViewController? = nil
+ if let item = try? fervorController.persistentContainer.viewContext.fetch(req).first {
+ read = split.showItem(item)
+ } else {
+ if let item = try? await fervorController.fetchItem(id: itemID) {
+ read = split.showItem(item)
+ }
+ }
+ read?.restoreUserActivityState(activity)
default:
+ logger.warning("Unknown user activity type: \(activity.activityType)")
break
}
}
diff --git a/Reader/Screens/AppSplitViewController.swift b/Reader/Screens/AppSplitViewController.swift
index 3ff8ad1..9383c6c 100644
--- a/Reader/Screens/AppSplitViewController.swift
+++ b/Reader/Screens/AppSplitViewController.swift
@@ -65,6 +65,33 @@ class AppSplitViewController: UISplitViewController {
let home = nav.viewControllers.first! as! HomeViewController
home.selectItem(type)
}
+
+ func showItem(_ item: Item) -> ReadViewController {
+ if traitCollection.horizontalSizeClass == .compact {
+ let nav = viewController(for: .compact) as! UINavigationController
+ // todo: should we try to show the right items list?
+ while nav.viewControllers.count > 2 {
+ if let current = nav.topViewController as? ReadViewController,
+ current.item == item {
+ return current
+ } else {
+ nav.popViewController(animated: false)
+ }
+ }
+ let read = ReadViewController(item: item, fervorController: fervorController)
+ nav.pushViewController(read, animated: false)
+ return read
+ } else {
+ if let current = secondaryNav.topViewController as? ReadViewController,
+ current.item == item {
+ return current
+ } else {
+ let read = ReadViewController(item: item, fervorController: fervorController)
+ secondaryNav.setViewControllers([read], animated: false)
+ return read
+ }
+ }
+ }
}
diff --git a/Reader/Screens/Read/ReadViewController.swift b/Reader/Screens/Read/ReadViewController.swift
index ef60f16..d4e1d54 100644
--- a/Reader/Screens/Read/ReadViewController.swift
+++ b/Reader/Screens/Read/ReadViewController.swift
@@ -9,6 +9,7 @@ import UIKit
import WebKit
import HTMLEntities
import SafariServices
+import Combine
class ReadViewController: UIViewController {
@@ -22,6 +23,10 @@ class ReadViewController: UIViewController {
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)
@@ -41,6 +46,8 @@ class ReadViewController: UIViewController {
self.item = item
super.init(nibName: nil, bundle: nil)
+
+ self.userActivity = .readItem(item, account: fervorController.account!)
}
required init?(coder: NSCoder) {
@@ -59,6 +66,7 @@ class ReadViewController: UIViewController {
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
@@ -78,6 +86,13 @@ class ReadViewController: UIViewController {
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()
@@ -97,6 +112,44 @@ class ReadViewController: UIViewController {
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
@@ -171,6 +224,55 @@ class ReadViewController: UIViewController {
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