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 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 { @@ -208,6 +310,11 @@ extension ReadViewController: WKNavigationDelegate { 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 { @@ -242,6 +349,15 @@ extension ReadViewController: WKUIDelegate { } } +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 diff --git a/Reader/UserActivities.swift b/Reader/UserActivities.swift index a92cc60..51d26af 100644 --- a/Reader/UserActivities.swift +++ b/Reader/UserActivities.swift @@ -16,6 +16,7 @@ extension NSUserActivity { static let readAllType = "net.shadowfacts.Reader.activity.read-all" static let readFeedType = "net.shadowfacts.Reader.activity.read-feed" static let readGroupType = "net.shadowfacts.Reader.activity.read-group" + static let readItemType = "net.shadowfacts.Reader.activity.read-item" func accountID() -> Data? { let types = [ @@ -46,6 +47,27 @@ extension NSUserActivity { return nil } } + + var itemID: String? { + if activityType == NSUserActivity.readItemType { + return userInfo?["itemID"] as? String + } else { + return nil + } + } + + var topElementSelector: String? { + get { + userInfo?["topElementSelector"] as? String + } + set { + if let newValue = newValue { + addUserInfoEntries(from: ["topElementSelector": newValue]) + } else { + userInfo?.removeValue(forKey: "topElementSelector") + } + } + } static func preferences() -> NSUserActivity { return NSUserActivity(activityType: preferencesType) @@ -113,4 +135,14 @@ extension NSUserActivity { return activity } + static func readItem(_ item: Item, account: LocalData.Account) -> NSUserActivity { + let activity = NSUserActivity(activityType: readItemType) + activity.isEligibleForHandoff = true + activity.userInfo = [ + "accountID": account.id, + "itemID": item.id!, + ] + return activity + } + }