Add read item user activity for handoff

This commit is contained in:
Shadowfacts 2022-03-13 21:12:05 -04:00
parent 00096e6df8
commit c1291b0d96
6 changed files with 208 additions and 3 deletions

View File

@ -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 { extension FervorController {

View File

@ -8,6 +8,7 @@
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.read-all</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.read-all</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.read-feed</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.read-feed</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.read-group</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.preferences</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.add-account</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.add-account</string>
<string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.activate-account</string> <string>$(PRODUCT_BUNDLE_IDENTIFIER).activity.activate-account</string>

View File

@ -47,7 +47,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
syncFromServer() syncFromServer()
createAppUI() createAppUI()
if let activity = activity { if let activity = activity {
setupUI(from: activity) await setupUI(from: activity)
} }
setupSceneActivationConditions() setupSceneActivationConditions()
} }
@ -109,10 +109,12 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
} }
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) { 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 { guard let split = window?.rootViewController as? AppSplitViewController else {
logger.error("Failed to setup UI for user activity: missing split VC") logger.error("Failed to setup UI for user activity: missing split VC")
return return
@ -140,7 +142,23 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
if let group = try? fervorController.persistentContainer.viewContext.fetch(req).first { if let group = try? fervorController.persistentContainer.viewContext.fetch(req).first {
split.showItemList(.group(group)) 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: default:
logger.warning("Unknown user activity type: \(activity.activityType)")
break break
} }
} }

View File

@ -66,6 +66,33 @@ class AppSplitViewController: UISplitViewController {
home.selectItem(type) 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 { extension AppSplitViewController: ItemsViewControllerDelegate {

View File

@ -9,6 +9,7 @@ import UIKit
import WebKit import WebKit
import HTMLEntities import HTMLEntities
import SafariServices import SafariServices
import Combine
class ReadViewController: UIViewController { class ReadViewController: UIViewController {
@ -22,6 +23,10 @@ class ReadViewController: UIViewController {
let fervorController: FervorController let fervorController: FervorController
let item: Item let item: Item
private let scrollPositionChangedSubject = PassthroughSubject<Void, Never>()
private var cancellables = Set<AnyCancellable>()
private var scrollTargetSelector: String?
private var webView: WKWebView! private var webView: WKWebView!
#if targetEnvironment(macCatalyst) #if targetEnvironment(macCatalyst)
@ -41,6 +46,8 @@ class ReadViewController: UIViewController {
self.item = item self.item = item
super.init(nibName: nil, bundle: nil) super.init(nibName: nil, bundle: nil)
self.userActivity = .readItem(item, account: fervorController.account!)
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -59,6 +66,7 @@ class ReadViewController: UIViewController {
webView.translatesAutoresizingMaskIntoConstraints = false webView.translatesAutoresizingMaskIntoConstraints = false
webView.navigationDelegate = self webView.navigationDelegate = self
webView.uiDelegate = self webView.uiDelegate = self
webView.scrollView.delegate = self
// transparent background required to prevent white flash in dark mode, just using .appBackground doesn't work // transparent background required to prevent white flash in dark mode, just using .appBackground doesn't work
webView.isOpaque = false webView.isOpaque = false
webView.backgroundColor = .clear webView.backgroundColor = .clear
@ -78,6 +86,13 @@ class ReadViewController: UIViewController {
activityItemsConfiguration = UIActivityItemsConfiguration(objects: [url as NSURL]) 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) #if targetEnvironment(macCatalyst)
itemReadObservation = item.observe(\.read) { [unowned self] _, _ in itemReadObservation = item.observe(\.read) { [unowned self] _, _ in
self.updateToggleReadToolbarImage() self.updateToggleReadToolbarImage()
@ -97,6 +112,44 @@ class ReadViewController: UIViewController {
updateScrollIndicatorStyle() 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() { private func updateScrollIndicatorStyle() {
guard #available(iOS 15.4, *) else { guard #available(iOS 15.4, *) else {
// different workaround pre-iOS 15.4 // different workaround pre-iOS 15.4
@ -171,6 +224,55 @@ class ReadViewController: UIViewController {
return vc 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) #if targetEnvironment(macCatalyst)
@objc func toggleItemRead(_ item: NSToolbarItem) { @objc func toggleItemRead(_ item: NSToolbarItem) {
Task { Task {
@ -208,6 +310,11 @@ extension ReadViewController: WKNavigationDelegate {
return .allow 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 { 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 { extension ReadViewController: StretchyMenuInteractionDelegate {
func stretchyMenuTitle() -> String? { func stretchyMenuTitle() -> String? {
return nil return nil

View File

@ -16,6 +16,7 @@ extension NSUserActivity {
static let readAllType = "net.shadowfacts.Reader.activity.read-all" static let readAllType = "net.shadowfacts.Reader.activity.read-all"
static let readFeedType = "net.shadowfacts.Reader.activity.read-feed" static let readFeedType = "net.shadowfacts.Reader.activity.read-feed"
static let readGroupType = "net.shadowfacts.Reader.activity.read-group" static let readGroupType = "net.shadowfacts.Reader.activity.read-group"
static let readItemType = "net.shadowfacts.Reader.activity.read-item"
func accountID() -> Data? { func accountID() -> Data? {
let types = [ 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 { static func preferences() -> NSUserActivity {
return NSUserActivity(activityType: preferencesType) return NSUserActivity(activityType: preferencesType)
} }
@ -113,4 +135,14 @@ extension NSUserActivity {
return activity 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
}
} }