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 {
|
extension FervorController {
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue