Add read item user activity for handoff
This commit is contained in:
parent
00096e6df8
commit
c1291b0d96
|
@ -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 {
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.read-all</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.read-feed</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.read-group</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.read-item</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.preferences</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.add-account</string>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.activate-account</string>
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -66,6 +66,33 @@ class AppSplitViewController: UISplitViewController {
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension AppSplitViewController: ItemsViewControllerDelegate {
|
||||
|
|
|
@ -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<Void, Never>()
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
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 <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 {
|
||||
|
@ -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
|
||||
|
|
|
@ -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 = [
|
||||
|
@ -47,6 +48,27 @@ extension NSUserActivity {
|
|||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue