Compare commits
4 Commits
c65b69cfbd
...
7d4f5ccba3
Author | SHA1 | Date |
---|---|---|
Shadowfacts | 7d4f5ccba3 | |
Shadowfacts | b7a4f369f6 | |
Shadowfacts | c1291b0d96 | |
Shadowfacts | 00096e6df8 |
|
@ -7,6 +7,7 @@
|
|||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
D608238D27DE729E00D7D5F9 /* ItemListType.swift in Sources */ = {isa = PBXBuildFile; fileRef = D608238C27DE729E00D7D5F9 /* ItemListType.swift */; };
|
||||
D65B18B22750469D004A9448 /* LoginViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18B12750469D004A9448 /* LoginViewController.swift */; };
|
||||
D65B18B4275048D9004A9448 /* ClientRegistration.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18B3275048D9004A9448 /* ClientRegistration.swift */; };
|
||||
D65B18B627504920004A9448 /* FervorController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18B527504920004A9448 /* FervorController.swift */; };
|
||||
|
@ -116,6 +117,7 @@
|
|||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
D608238C27DE729E00D7D5F9 /* ItemListType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemListType.swift; sourceTree = "<group>"; };
|
||||
D65B18B12750469D004A9448 /* LoginViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginViewController.swift; sourceTree = "<group>"; };
|
||||
D65B18B3275048D9004A9448 /* ClientRegistration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientRegistration.swift; sourceTree = "<group>"; };
|
||||
D65B18B527504920004A9448 /* FervorController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FervorController.swift; sourceTree = "<group>"; };
|
||||
|
@ -324,6 +326,7 @@
|
|||
D68B303527907D9200E8B3FA /* ExcerptGenerator.swift */,
|
||||
D68B304127932ED500E8B3FA /* UserActivities.swift */,
|
||||
D68408EE2794808E00E327D2 /* Preferences.swift */,
|
||||
D608238C27DE729E00D7D5F9 /* ItemListType.swift */,
|
||||
D6A8A33527766E9300CCEC72 /* CoreData */,
|
||||
D65B18AF2750468B004A9448 /* Screens */,
|
||||
D6C687F7272CD27700874C10 /* Assets.xcassets */,
|
||||
|
@ -632,6 +635,7 @@
|
|||
files = (
|
||||
D6A8A33427766C2800CCEC72 /* PersistentContainer.swift in Sources */,
|
||||
D6E24357278B96E40005E546 /* Feed+CoreDataClass.swift in Sources */,
|
||||
D608238D27DE729E00D7D5F9 /* ItemListType.swift in Sources */,
|
||||
D65B18B627504920004A9448 /* FervorController.swift in Sources */,
|
||||
D68B304227932ED500E8B3FA /* UserActivities.swift in Sources */,
|
||||
D6C687EC272CD27600874C10 /* AppDelegate.swift in Sources */,
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -0,0 +1,60 @@
|
|||
//
|
||||
// ItemListType.swift
|
||||
// Reader
|
||||
//
|
||||
// Created by Shadowfacts on 3/13/22.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
enum ItemListType: Hashable {
|
||||
case unread
|
||||
case all
|
||||
case group(Group)
|
||||
case feed(Feed)
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .unread:
|
||||
return "Unread Articles"
|
||||
case .all:
|
||||
return "All Articles"
|
||||
case let .group(group):
|
||||
return group.title
|
||||
case let .feed(feed):
|
||||
return feed.title!
|
||||
}
|
||||
}
|
||||
|
||||
var idFetchRequest: NSFetchRequest<NSManagedObjectID> {
|
||||
let req = NSFetchRequest<NSManagedObjectID>(entityName: "Item")
|
||||
req.resultType = .managedObjectIDResultType
|
||||
switch self {
|
||||
case .unread:
|
||||
req.predicate = NSPredicate(format: "read = NO")
|
||||
case .all:
|
||||
break
|
||||
case .group(let group):
|
||||
req.predicate = NSPredicate(format: "feed in %@", group.feeds!)
|
||||
case .feed(let feed):
|
||||
req.predicate = NSPredicate(format: "feed = %@", feed)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
var countFetchRequest: NSFetchRequest<Reader.Item>? {
|
||||
let req = Reader.Item.fetchRequest()
|
||||
switch self {
|
||||
case .unread:
|
||||
req.predicate = NSPredicate(format: "read = NO")
|
||||
case .all:
|
||||
return nil
|
||||
case .group(let group):
|
||||
req.predicate = NSPredicate(format: "read = NO AND feed in %@", group.feeds!)
|
||||
case .feed(let feed):
|
||||
req.predicate = NSPredicate(format: "read = NO AND feed = %@", feed)
|
||||
}
|
||||
return req
|
||||
}
|
||||
}
|
|
@ -27,7 +27,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
window = UIWindow(windowScene: windowScene)
|
||||
window!.tintColor = .appTintColor
|
||||
|
||||
var activity = connectionOptions.userActivities.first
|
||||
var activity = connectionOptions.userActivities.first ?? session.stateRestorationActivity
|
||||
|
||||
var account = LocalData.mostRecentAccount()
|
||||
|
||||
|
@ -47,7 +47,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
syncFromServer()
|
||||
createAppUI()
|
||||
if let activity = activity {
|
||||
setupUI(from: activity)
|
||||
await setupUI(from: activity)
|
||||
}
|
||||
setupSceneActivationConditions()
|
||||
}
|
||||
|
@ -108,39 +108,61 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
|||
// to restore the scene back to its current state.
|
||||
}
|
||||
|
||||
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||
setupUI(from: userActivity)
|
||||
func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? {
|
||||
return (window!.rootViewController as! AppSplitViewController).stateRestorationActivity()
|
||||
}
|
||||
|
||||
private func setupUI(from activity: NSUserActivity) {
|
||||
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
|
||||
Task { @MainActor in
|
||||
await setupUI(from: userActivity)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
switch activity.activityType {
|
||||
case NSUserActivity.readUnreadType:
|
||||
split.selectHomeItem(.unread)
|
||||
split.showItemList(.unread)
|
||||
case NSUserActivity.readAllType:
|
||||
split.selectHomeItem(.all)
|
||||
split.showItemList(.all)
|
||||
case NSUserActivity.readFeedType:
|
||||
guard let feedID = activity.feedID() else {
|
||||
guard let feedID = activity.feedID else {
|
||||
break
|
||||
}
|
||||
let req = Feed.fetchRequest()
|
||||
req.predicate = NSPredicate(format: "id = %@", feedID)
|
||||
if let feed = try? fervorController.persistentContainer.viewContext.fetch(req).first {
|
||||
split.selectHomeItem(.feed(feed))
|
||||
split.showItemList(.feed(feed))
|
||||
}
|
||||
case NSUserActivity.readGroupType:
|
||||
guard let groupID = activity.groupID() else {
|
||||
guard let groupID = activity.groupID else {
|
||||
break
|
||||
}
|
||||
let req = Group.fetchRequest()
|
||||
req.predicate = NSPredicate(format: "id = %@", groupID)
|
||||
if let group = try? fervorController.persistentContainer.viewContext.fetch(req).first {
|
||||
split.selectHomeItem(.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:
|
||||
logger.warning("Unknown user activity type: \(activity.activityType)")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
|
|
@ -54,7 +54,28 @@ class AppSplitViewController: UISplitViewController {
|
|||
setViewController(nav, for: .compact)
|
||||
}
|
||||
|
||||
func selectHomeItem(_ item: HomeViewController.Item) {
|
||||
func stateRestorationActivity() -> NSUserActivity? {
|
||||
if traitCollection.horizontalSizeClass == .compact {
|
||||
let nav = viewController(for: .compact) as! UINavigationController
|
||||
let top = nav.topViewController!
|
||||
if top is ReadViewController || top is ItemsViewController {
|
||||
print(top.userActivity?.activityType)
|
||||
return top.userActivity
|
||||
}
|
||||
} else {
|
||||
if let read = secondaryNav.topViewController {
|
||||
return read.userActivity
|
||||
} else {
|
||||
let homeNav = viewController(for: .primary) as! UINavigationController
|
||||
if homeNav.topViewController is ItemsViewController {
|
||||
return homeNav.topViewController!.userActivity
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func showItemList(_ type: ItemListType) {
|
||||
let column: Column
|
||||
if traitCollection.horizontalSizeClass == .compact {
|
||||
column = .compact
|
||||
|
@ -63,7 +84,34 @@ class AppSplitViewController: UISplitViewController {
|
|||
}
|
||||
let nav = viewController(for: column) as! UINavigationController
|
||||
let home = nav.viewControllers.first! as! HomeViewController
|
||||
home.selectItem(item)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -23,7 +23,7 @@ class HomeViewController: UIViewController {
|
|||
var enableStretchyMenu = true
|
||||
|
||||
private var collectionView: UICollectionView!
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, ItemListType>!
|
||||
private var groupResultsController: NSFetchedResultsController<Group>!
|
||||
private var feedResultsController: NSFetchedResultsController<Feed>!
|
||||
|
||||
|
@ -76,7 +76,7 @@ class HomeViewController: UIViewController {
|
|||
|
||||
dataSource = createDataSource()
|
||||
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, Item>()
|
||||
var snapshot = NSDiffableDataSourceSnapshot<Section, ItemListType>()
|
||||
snapshot.appendSections([.all, .groups, .feeds])
|
||||
snapshot.appendItems([.unread, .all], toSection: .all)
|
||||
dataSource.apply(snapshot, animatingDifferences: false)
|
||||
|
@ -116,14 +116,14 @@ class HomeViewController: UIViewController {
|
|||
dataSource.apply(snapshot)
|
||||
}
|
||||
|
||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, ItemListType> {
|
||||
let sectionHeaderCell = UICollectionView.SupplementaryRegistration<UICollectionViewListCell>(elementKind: UICollectionView.elementKindSectionHeader) { supplementaryView, elementKind, indexPath in
|
||||
let section = self.dataSource.sectionIdentifier(for: indexPath.section)!
|
||||
var config = supplementaryView.defaultContentConfiguration()
|
||||
config.text = section.title
|
||||
supplementaryView.contentConfiguration = config
|
||||
}
|
||||
let listCell = UICollectionView.CellRegistration<HomeCollectionViewCell, Item> { cell, indexPath, item in
|
||||
let listCell = UICollectionView.CellRegistration<HomeCollectionViewCell, ItemListType> { cell, indexPath, item in
|
||||
var config = UIListContentConfiguration.valueCell()
|
||||
config.text = item.title
|
||||
if let req = item.countFetchRequest,
|
||||
|
@ -135,7 +135,7 @@ class HomeViewController: UIViewController {
|
|||
|
||||
cell.accessories = [.disclosureIndicator()]
|
||||
}
|
||||
let dataSource = UICollectionViewDiffableDataSource<Section, Item>(collectionView: collectionView) { collectionView, indexPath, item in
|
||||
let dataSource = UICollectionViewDiffableDataSource<Section, ItemListType>(collectionView: collectionView) { collectionView, indexPath, item in
|
||||
return collectionView.dequeueConfiguredReusableCell(using: listCell, for: indexPath, item: item)
|
||||
}
|
||||
dataSource.supplementaryViewProvider = { collectionView, elementKind, indexPath in
|
||||
|
@ -206,26 +206,26 @@ class HomeViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
private func itemsViewController(for item: Item) -> ItemsViewController {
|
||||
let vc = ItemsViewController(fetchRequest: item.idFetchRequest, fervorController: fervorController)
|
||||
vc.title = item.title
|
||||
private func itemsViewController(for item: ItemListType) -> ItemsViewController {
|
||||
let vc = ItemsViewController(type: item, fervorController: fervorController)
|
||||
vc.delegate = itemsDelegate
|
||||
switch item {
|
||||
case .all:
|
||||
vc.userActivity = .readAll(account: fervorController.account!)
|
||||
case .unread:
|
||||
vc.userActivity = .readUnread(account: fervorController.account!)
|
||||
case .group(let group):
|
||||
vc.userActivity = .readGroup(group, account: fervorController.account!)
|
||||
case .feed(let feed):
|
||||
vc.userActivity = .readFeed(feed, account: fervorController.account!)
|
||||
}
|
||||
return vc
|
||||
}
|
||||
|
||||
func selectItem(_ item: Item) {
|
||||
navigationController!.popToRootViewController(animated: false)
|
||||
navigationController!.pushViewController(itemsViewController(for: item), animated: false)
|
||||
func selectItem(_ item: ItemListType) {
|
||||
guard let navigationController = navigationController else {
|
||||
return
|
||||
}
|
||||
if navigationController.viewControllers.count >= 2,
|
||||
let second = navigationController.viewControllers[1] as? ItemsViewController,
|
||||
second.type == item {
|
||||
while navigationController.viewControllers.count > 2 {
|
||||
navigationController.popViewController(animated: false)
|
||||
}
|
||||
} else {
|
||||
navigationController.popToRootViewController(animated: false)
|
||||
navigationController.pushViewController(itemsViewController(for: item), animated: false)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -247,56 +247,6 @@ extension HomeViewController {
|
|||
}
|
||||
}
|
||||
}
|
||||
enum Item: Hashable {
|
||||
case unread
|
||||
case all
|
||||
case group(Group)
|
||||
case feed(Feed)
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .unread:
|
||||
return "Unread Articles"
|
||||
case .all:
|
||||
return "All Articles"
|
||||
case let .group(group):
|
||||
return group.title
|
||||
case let .feed(feed):
|
||||
return feed.title!
|
||||
}
|
||||
}
|
||||
|
||||
var idFetchRequest: NSFetchRequest<NSManagedObjectID> {
|
||||
let req = NSFetchRequest<NSManagedObjectID>(entityName: "Item")
|
||||
req.resultType = .managedObjectIDResultType
|
||||
switch self {
|
||||
case .unread:
|
||||
req.predicate = NSPredicate(format: "read = NO")
|
||||
case .all:
|
||||
break
|
||||
case .group(let group):
|
||||
req.predicate = NSPredicate(format: "feed in %@", group.feeds!)
|
||||
case .feed(let feed):
|
||||
req.predicate = NSPredicate(format: "feed = %@", feed)
|
||||
}
|
||||
return req
|
||||
}
|
||||
|
||||
var countFetchRequest: NSFetchRequest<Reader.Item>? {
|
||||
let req = Reader.Item.fetchRequest()
|
||||
switch self {
|
||||
case .unread:
|
||||
req.predicate = NSPredicate(format: "read = NO")
|
||||
case .all:
|
||||
return nil
|
||||
case .group(let group):
|
||||
req.predicate = NSPredicate(format: "read = NO AND feed in %@", group.feeds!)
|
||||
case .feed(let feed):
|
||||
req.predicate = NSPredicate(format: "read = NO AND feed = %@", feed)
|
||||
}
|
||||
return req
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension HomeViewController: NSFetchedResultsControllerDelegate {
|
||||
|
|
|
@ -18,20 +18,32 @@ class ItemsViewController: UIViewController {
|
|||
weak var delegate: ItemsViewControllerDelegate?
|
||||
|
||||
let fervorController: FervorController
|
||||
let fetchRequest: NSFetchRequest<NSManagedObjectID>
|
||||
let type: ItemListType
|
||||
private let fetchRequest: NSFetchRequest<NSManagedObjectID>
|
||||
|
||||
private var collectionView: UICollectionView!
|
||||
private var dataSource: UICollectionViewDiffableDataSource<Section, NSManagedObjectID>!
|
||||
|
||||
private var batchUpdates: [() -> Void] = []
|
||||
|
||||
init(fetchRequest: NSFetchRequest<NSManagedObjectID>, fervorController: FervorController) {
|
||||
precondition(fetchRequest.entityName == "Item")
|
||||
|
||||
init(type: ItemListType, fervorController: FervorController) {
|
||||
self.fervorController = fervorController
|
||||
self.fetchRequest = fetchRequest
|
||||
self.type = type
|
||||
self.fetchRequest = type.idFetchRequest
|
||||
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
|
||||
self.title = type.title
|
||||
switch type {
|
||||
case .all:
|
||||
self.userActivity = .readAll(account: fervorController.account!)
|
||||
case .unread:
|
||||
self.userActivity = .readUnread(account: fervorController.account!)
|
||||
case .group(let group):
|
||||
self.userActivity = .readGroup(group, account: fervorController.account!)
|
||||
case .feed(let feed):
|
||||
self.userActivity = .readFeed(feed, account: fervorController.account!)
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
|
|
|
@ -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 = [
|
||||
|
@ -31,7 +32,7 @@ extension NSUserActivity {
|
|||
}
|
||||
}
|
||||
|
||||
func feedID() -> String? {
|
||||
var feedID: String? {
|
||||
if activityType == NSUserActivity.readFeedType {
|
||||
return userInfo?["feedID"] as? String
|
||||
} else {
|
||||
|
@ -39,7 +40,7 @@ extension NSUserActivity {
|
|||
}
|
||||
}
|
||||
|
||||
func groupID() -> String? {
|
||||
var groupID: String? {
|
||||
if activityType == NSUserActivity.readGroupType {
|
||||
return userInfo?["groupID"] as? String
|
||||
} else {
|
||||
|
@ -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