diff --git a/Reader.xcodeproj/project.pbxproj b/Reader.xcodeproj/project.pbxproj index 345dc31..e7b0249 100644 --- a/Reader.xcodeproj/project.pbxproj +++ b/Reader.xcodeproj/project.pbxproj @@ -17,6 +17,8 @@ D65B18C127505348004A9448 /* HomeViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D65B18C027505348004A9448 /* HomeViewController.swift */; }; D68B303627907D9200E8B3FA /* ExcerptGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68B303527907D9200E8B3FA /* ExcerptGenerator.swift */; }; D68B303D2792204B00E8B3FA /* read.js in Resources */ = {isa = PBXBuildFile; fileRef = D68B303C2792204B00E8B3FA /* read.js */; }; + D68B30402792729A00E8B3FA /* AppSplitViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68B303F2792729A00E8B3FA /* AppSplitViewController.swift */; }; + D68B304227932ED500E8B3FA /* UserActivities.swift in Sources */ = {isa = PBXBuildFile; fileRef = D68B304127932ED500E8B3FA /* UserActivities.swift */; }; D6A8A33427766C2800CCEC72 /* PersistentContainer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6A8A33327766C2800CCEC72 /* PersistentContainer.swift */; }; D6C687EC272CD27600874C10 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C687EB272CD27600874C10 /* AppDelegate.swift */; }; D6C687EE272CD27600874C10 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = D6C687ED272CD27600874C10 /* SceneDelegate.swift */; }; @@ -104,6 +106,8 @@ D68B3037279099FD00E8B3FA /* liblolhtml.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; name = liblolhtml.a; path = "lol-html/c-api/target/aarch64-apple-ios-sim/release/liblolhtml.a"; sourceTree = ""; }; D68B303C2792204B00E8B3FA /* read.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = read.js; sourceTree = ""; }; D68B303E27923C0000E8B3FA /* Reader.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Reader.entitlements; sourceTree = ""; }; + D68B303F2792729A00E8B3FA /* AppSplitViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppSplitViewController.swift; sourceTree = ""; }; + D68B304127932ED500E8B3FA /* UserActivities.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserActivities.swift; sourceTree = ""; }; D6A8A33327766C2800CCEC72 /* PersistentContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PersistentContainer.swift; sourceTree = ""; }; D6C687E8272CD27600874C10 /* Reader.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Reader.app; sourceTree = BUILT_PRODUCTS_DIR; }; D6C687EB272CD27600874C10 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -179,6 +183,7 @@ isa = PBXGroup; children = ( D6EB531C278C89C300AD2E61 /* AppNavigationController.swift */, + D68B303F2792729A00E8B3FA /* AppSplitViewController.swift */, D65B18BF2750533E004A9448 /* Home */, D65B18B027504691004A9448 /* Login */, D6E2434A278B455C0005E546 /* Items */, @@ -262,6 +267,7 @@ D6E24368278BABB40005E546 /* UIColor+App.swift */, D6EB531E278E4A7500AD2E61 /* StretchyMenuInteraction.swift */, D68B303527907D9200E8B3FA /* ExcerptGenerator.swift */, + D68B304127932ED500E8B3FA /* UserActivities.swift */, D6A8A33527766E9300CCEC72 /* CoreData */, D65B18AF2750468B004A9448 /* Screens */, D6C687F7272CD27700874C10 /* Assets.xcassets */, @@ -532,6 +538,7 @@ D6A8A33427766C2800CCEC72 /* PersistentContainer.swift in Sources */, D6E24357278B96E40005E546 /* Feed+CoreDataClass.swift in Sources */, D65B18B627504920004A9448 /* FervorController.swift in Sources */, + D68B304227932ED500E8B3FA /* UserActivities.swift in Sources */, D6C687EC272CD27600874C10 /* AppDelegate.swift in Sources */, D6C687F6272CD27600874C10 /* Reader.xcdatamodeld in Sources */, D6E2436B278BB1880005E546 /* HomeCollectionViewCell.swift in Sources */, @@ -544,6 +551,7 @@ D6E2435E278B97240005E546 /* Item+CoreDataProperties.swift in Sources */, D6EB531F278E4A7500AD2E61 /* StretchyMenuInteraction.swift in Sources */, D6E24358278B96E40005E546 /* Feed+CoreDataProperties.swift in Sources */, + D68B30402792729A00E8B3FA /* AppSplitViewController.swift in Sources */, D65B18BE275051A1004A9448 /* LocalData.swift in Sources */, D65B18B22750469D004A9448 /* LoginViewController.swift in Sources */, D68B303627907D9200E8B3FA /* ExcerptGenerator.swift in Sources */, diff --git a/Reader/AppDelegate.swift b/Reader/AppDelegate.swift index 3ad6529..d4e25c2 100644 --- a/Reader/AppDelegate.swift +++ b/Reader/AppDelegate.swift @@ -23,7 +23,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { func application(_ application: UIApplication, configurationForConnecting connectingSceneSession: UISceneSession, options: UIScene.ConnectionOptions) -> UISceneConfiguration { // Called when a new scene session is being created. // Use this method to select a configuration to create the new scene with. - return UISceneConfiguration(name: "Default Configuration", sessionRole: connectingSceneSession.role) + return UISceneConfiguration(name: "main", sessionRole: connectingSceneSession.role) } func application(_ application: UIApplication, didDiscardSceneSessions sceneSessions: Set) { @@ -32,6 +32,43 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Use this method to release any resources that were specific to the discarded scenes, as they will not return. } + override func buildMenu(with builder: UIMenuBuilder) { + if builder.system == .main { + var children: [UIMenuElement] = LocalData.accounts.map { account in + var title = account.instanceURL.host! + if let port = account.instanceURL.port, port != 80 && port != 443 { + title += ":\(port)" + } + + let state: UIAction.State + if let activeScene = UIApplication.shared.connectedScenes.first(where: { $0.activationState == .foregroundActive }), + (activeScene.delegate as! SceneDelegate).fervorController?.account?.id == account.id { + state = .on + } else { + state = .off + } + return UIAction(title: title, attributes: [], state: state) { _ in + let activity = NSUserActivity.activateAccount(account) + let options = UIScene.ActivationRequestOptions() + #if targetEnvironment(macCatalyst) + options.collectionJoinBehavior = .disallowed + #endif + UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: options, errorHandler: nil) + } + } + children.append(UIAction(title: "Add Account...", handler: { _ in + let activity = NSUserActivity.addAccount() + let options = UIScene.ActivationRequestOptions() + #if targetEnvironment(macCatalyst) + options.collectionJoinBehavior = .disallowed + #endif + UIApplication.shared.requestSceneSessionActivation(nil, userActivity: activity, options: options, errorHandler: nil) + })) + let account = UIMenu(title: "Account", image: nil, identifier: nil, options: [], children: children) + builder.insertSibling(account, afterMenu: .file) + } + } + private func swizzleWKWebView() { let selector = Selector(("_updateScrollViewBackground")) var originalIMP: IMP? diff --git a/Reader/Info.plist b/Reader/Info.plist index 0eb786d..81d0b72 100644 --- a/Reader/Info.plist +++ b/Reader/Info.plist @@ -2,17 +2,22 @@ + NSUserActivityTypes + + $(PRODUCT_BUNDLE_IDENTIFIER).activity.add-account + $(PRODUCT_BUNDLE_IDENTIFIER).activity.activate-account + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes - + UISceneConfigurations UIWindowSceneSessionRoleApplication UISceneConfigurationName - Default Configuration + main UISceneDelegateClassName $(PRODUCT_MODULE_NAME).SceneDelegate diff --git a/Reader/SceneDelegate.swift b/Reader/SceneDelegate.swift index 1f12f57..828b17b 100644 --- a/Reader/SceneDelegate.swift +++ b/Reader/SceneDelegate.swift @@ -25,7 +25,16 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window = UIWindow(windowScene: windowScene) window!.tintColor = .appTintColor - if let account = LocalData.mostRecentAccount() { + let activity = connectionOptions.userActivities.first + if activity?.activityType == NSUserActivity.addAccountType { + let loginVC = LoginViewController() + loginVC.delegate = self + window!.rootViewController = loginVC + } else if activity?.activityType == NSUserActivity.activateAccountType, + let account = LocalData.accounts.first(where: { $0.id.uuidString == activity!.userInfo?["accountID"] as? String }) { + fervorController = FervorController(account: account) + createAppUI() + } else if let account = LocalData.mostRecentAccount() { fervorController = FervorController(account: account) createAppUI() } else { @@ -34,6 +43,15 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { window!.rootViewController = loginVC } + #if targetEnvironment(macCatalyst) + if let titlebar = windowScene.titlebar { + titlebar.toolbarStyle = .unifiedCompact + titlebar.toolbar = NSToolbar(identifier: .init("ReaderToolbar")) + titlebar.toolbar!.delegate = self + titlebar.toolbar!.allowsUserCustomization = false + } + #endif + window!.makeKeyAndVisible() } @@ -47,17 +65,23 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneDidBecomeActive(_ scene: UIScene) { // Called when the scene has moved from an inactive state to an active state. // Use this method to restart any tasks that were paused (or not yet started) when the scene was inactive. + + UIMenuSystem.main.setNeedsRebuild() + + syncFromServer() } func sceneWillResignActive(_ scene: UIScene) { // Called when the scene will move from an active state to an inactive state. // This may occur due to temporary interruptions (ex. an incoming phone call). - Task(priority: .userInitiated) { - do { - try await self.fervorController?.syncReadToServer() - } catch { - logger.error("Unable to sync read state to server: \(error.localizedDescription, privacy: .public)") + if let fervorController = fervorController { + Task(priority: .userInitiated) { + do { + try await fervorController.syncReadToServer() + } catch { + logger.error("Unable to sync read state to server: \(error.localizedDescription, privacy: .public)") + } } } } @@ -74,12 +98,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { } private func createAppUI() { - let home = HomeViewController(fervorController: fervorController) - home.delegate = self - let nav = AppNavigationController(rootViewController: home) - nav.navigationBar.prefersLargeTitles = true - window!.rootViewController = nav - + window!.rootViewController = AppSplitViewController(fervorController: fervorController) + } + + private func syncFromServer() { + guard let fervorController = fervorController else { + return + } Task(priority: .userInitiated) { do { try await self.fervorController.syncAll() @@ -99,7 +124,11 @@ extension SceneDelegate: LoginViewControllerDelegate { LocalData.accounts.append(account) LocalData.mostRecentAccountID = account.id fervorController = FervorController(account: account) + createAppUI() + syncFromServer() + + UIMenuSystem.main.setNeedsRebuild() } } @@ -108,5 +137,30 @@ extension SceneDelegate: HomeViewControllerDelegate { LocalData.mostRecentAccountID = account.id fervorController = FervorController(account: account) createAppUI() + syncFromServer() } } + +#if targetEnvironment(macCatalyst) +extension NSToolbarItem.Identifier { + static let toggleItemRead = NSToolbarItem.Identifier("ToggleItemRead") +} + +extension SceneDelegate: NSToolbarDelegate { + func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? { + let item = NSToolbarItem(itemIdentifier: .toggleItemRead) + item.target = nil + item.action = #selector(AppSplitViewController.toggleItemRead) + item.image = UIImage(systemName: "checkmark.circle") + return item + } + + func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + return [.toggleItemRead] + } + + func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] { + return [.toggleItemRead] + } +} +#endif diff --git a/Reader/Screens/AppSplitViewController.swift b/Reader/Screens/AppSplitViewController.swift new file mode 100644 index 0000000..bbc05b2 --- /dev/null +++ b/Reader/Screens/AppSplitViewController.swift @@ -0,0 +1,103 @@ +// +// AppSplitViewController.swift +// Reader +// +// Created by Shadowfacts on 1/14/22. +// + +import UIKit +#if targetEnvironment(macCatalyst) +import AppKit +#endif + +class AppSplitViewController: UISplitViewController { + + private let fervorController: FervorController + + private var secondaryNav: UINavigationController! + + init(fervorController: FervorController) { + self.fervorController = fervorController + + super.init(style: .doubleColumn) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + preferredDisplayMode = .oneBesideSecondary + preferredSplitBehavior = .tile + presentsWithGesture = true + showsSecondaryOnlyButton = true + primaryBackgroundStyle = .sidebar + + let sidebarHome = HomeViewController(fervorController: fervorController) + sidebarHome.enableStretchyMenu = false + sidebarHome.itemsDelegate = self + let sidebarNav = UINavigationController(rootViewController: sidebarHome) + sidebarNav.navigationBar.prefersLargeTitles = true + setViewController(sidebarNav, for: .primary) + + secondaryNav = UINavigationController() + secondaryNav.isNavigationBarHidden = true + secondaryNav.view.backgroundColor = .appBackground + setViewController(secondaryNav, for: .secondary) + + let home = HomeViewController(fervorController: fervorController) + let nav = AppNavigationController(rootViewController: home) + setViewController(nav, for: .compact) + } + + #if targetEnvironment(macCatalyst) + @objc func toggleItemRead(_ item: NSToolbarItem) { + guard let nav = viewController(for: .secondary) as? UINavigationController, + let read = nav.topViewController as? ReadViewController else { + return + } + read.item.read = !read.item.read + updateImage(toolbarItem: item) + } + + private func updateImage(toolbarItem: NSToolbarItem) { + if let nav = viewController(for: .secondary) as? UINavigationController, + let read = nav.topViewController as? ReadViewController { + toolbarItem.image = UIImage(systemName: read.item.read ? "checkmark.circle.fill" : "checkmark.circle") + } else { + toolbarItem.image = UIImage(systemName: "checkmark.circle") + } + } + #endif + +} + +extension AppSplitViewController: ItemsViewControllerDelegate { + func showReadItem(_ item: Item) { + secondaryNav.setViewControllers([ReadViewController(item: item, fervorController: fervorController)], animated: false) + + #if targetEnvironment(macCatalyst) + if let titlebar = view.window?.windowScene?.titlebar, + let toggleRead = titlebar.toolbar?.items.first(where: { $0.itemIdentifier == .toggleItemRead }) { + updateImage(toolbarItem: toggleRead) + } + #endif + } +} + +#if targetEnvironment(macCatalyst) +extension AppSplitViewController { + override func responds(to aSelector: Selector!) -> Bool { + if aSelector == #selector(toggleItemRead) { + guard let nav = viewController(for: .secondary) as? UINavigationController else { + return false + } + return nav.topViewController is ReadViewController + } else { + return super.responds(to: aSelector) + } + } +} +#endif diff --git a/Reader/Screens/Home/HomeCollectionViewCell.swift b/Reader/Screens/Home/HomeCollectionViewCell.swift index 71c022d..a61682f 100644 --- a/Reader/Screens/Home/HomeCollectionViewCell.swift +++ b/Reader/Screens/Home/HomeCollectionViewCell.swift @@ -9,6 +9,7 @@ import UIKit class HomeCollectionViewCell: UICollectionViewListCell { + #if !targetEnvironment(macCatalyst) override func updateConfiguration(using state: UICellConfigurationState) { var backgroundConfig = UIBackgroundConfiguration.listGroupedCell().updated(for: state) if state.isHighlighted || state.isSelected { @@ -18,5 +19,6 @@ class HomeCollectionViewCell: UICollectionViewListCell { } self.backgroundConfiguration = backgroundConfig } + #endif } diff --git a/Reader/Screens/Home/HomeViewController.swift b/Reader/Screens/Home/HomeViewController.swift index 2571478..d487af7 100644 --- a/Reader/Screens/Home/HomeViewController.swift +++ b/Reader/Screens/Home/HomeViewController.swift @@ -15,9 +15,12 @@ protocol HomeViewControllerDelegate: AnyObject { class HomeViewController: UIViewController { weak var delegate: HomeViewControllerDelegate? + weak var itemsDelegate: ItemsViewControllerDelegate? let fervorController: FervorController + var enableStretchyMenu = true + private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private var groupResultsController: NSFetchedResultsController! @@ -39,13 +42,17 @@ class HomeViewController: UIViewController { // todo: account info title = "Reader" - view.addInteraction(StretchyMenuInteraction(delegate: self)) + if enableStretchyMenu { + view.addInteraction(StretchyMenuInteraction(delegate: self)) + } - view.backgroundColor = .appBackground + if UIDevice.current.userInterfaceIdiom != .mac { + view.backgroundColor = .appBackground + } - var config = UICollectionLayoutListConfiguration(appearance: .grouped) + var config = UICollectionLayoutListConfiguration(appearance: UIDevice.current.userInterfaceIdiom == .mac ? .sidebar : .grouped) config.headerMode = .supplementary - config.backgroundColor = .appBackground + config.backgroundColor = .clear config.separatorConfiguration.topSeparatorVisibility = .visible config.separatorConfiguration.topSeparatorInsets = NSDirectionalEdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 0) config.separatorConfiguration.bottomSeparatorVisibility = .hidden @@ -224,6 +231,7 @@ extension HomeViewController: UICollectionViewDelegate { } let vc = ItemsViewController(fetchRequest: item.fetchRequest, fervorController: fervorController) vc.title = item.title + vc.delegate = itemsDelegate show(vc, sender: nil) UISelectionFeedbackGenerator().selectionChanged() } diff --git a/Reader/Screens/Items/ItemsViewController.swift b/Reader/Screens/Items/ItemsViewController.swift index 3fe9bba..4261faa 100644 --- a/Reader/Screens/Items/ItemsViewController.swift +++ b/Reader/Screens/Items/ItemsViewController.swift @@ -9,8 +9,14 @@ import UIKit import CoreData import SafariServices +protocol ItemsViewControllerDelegate: AnyObject { + func showReadItem(_ item: Item) +} + class ItemsViewController: UIViewController { + weak var delegate: ItemsViewControllerDelegate? + let fervorController: FervorController let fetchRequest: NSFetchRequest @@ -32,17 +38,28 @@ class ItemsViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - + + if UIDevice.current.userInterfaceIdiom != .mac { + view.backgroundColor = .appBackground + } + var configuration = UICollectionLayoutListConfiguration(appearance: .plain) - configuration.backgroundColor = .appBackground + configuration.backgroundColor = .clear let layout = UICollectionViewCompositionalLayout.list(using: configuration) collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) collectionView.delegate = self collectionView.dataSource = self - collectionView.autoresizingMask = [.flexibleWidth, .flexibleHeight] + collectionView.translatesAutoresizingMaskIntoConstraints = false collectionView.register(ItemCollectionViewCell.self, forCellWithReuseIdentifier: "itemCell") view.addSubview(collectionView) + NSLayoutConstraint.activate([ + collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), + collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), + collectionView.topAnchor.constraint(equalTo: view.topAnchor), + collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + ]) + fetchRequest.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)] fetchRequest.fetchBatchSize = 20 resultsController = NSFetchedResultsController(fetchRequest: fetchRequest, managedObjectContext: fervorController.persistentContainer.viewContext, sectionNameKeyPath: nil, cacheName: nil) @@ -148,6 +165,10 @@ extension ItemsViewController: UICollectionViewDelegate { extension ItemsViewController: ItemCollectionViewCellDelegate { func itemCellSelected(cell: ItemCollectionViewCell, item: Item) { cell.setRead(true, animated: true) - show(ReadViewController(item: item, fervorController: fervorController), sender: nil) + if let delegate = delegate { + delegate.showReadItem(item) + } else { + show(ReadViewController(item: item, fervorController: fervorController), sender: nil) + } } } diff --git a/Reader/UserActivities.swift b/Reader/UserActivities.swift new file mode 100644 index 0000000..10a865a --- /dev/null +++ b/Reader/UserActivities.swift @@ -0,0 +1,27 @@ +// +// UserActivities.swift +// Reader +// +// Created by Shadowfacts on 1/15/22. +// + +import Foundation + +extension NSUserActivity { + + static let addAccountType = "net.shadowfacts.Reader.activity.add-account" + static let activateAccountType = "net.shadowfacts.Reader.activity.activate-account" + + static func addAccount() -> NSUserActivity { + return NSUserActivity(activityType: addAccountType) + } + + static func activateAccount(_ account: LocalData.Account) -> NSUserActivity { + let activity = NSUserActivity(activityType: activateAccountType) + activity.userInfo = [ + "accountID": account.id.uuidString + ] + return activity + } + +}