Compare commits

...

4 Commits

10 changed files with 347 additions and 91 deletions

View File

@ -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 */,

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 {

View File

@ -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>

60
Reader/ItemListType.swift Normal file
View File

@ -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
}
}

View File

@ -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
}
}

View File

@ -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
}
}
}
}

View File

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

View File

@ -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) {

View File

@ -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

View File

@ -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,13 +40,34 @@ extension NSUserActivity {
}
}
func groupID() -> String? {
var groupID: String? {
if activityType == NSUserActivity.readGroupType {
return userInfo?["groupID"] as? String
} else {
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
}
}