// // NavigationModelController.swift // NetNewsWire-iOS // // Created by Maurice Parker on 4/21/19. // Copyright © 2019 Ranchero Software. All rights reserved. // import UIKit import UIKitExtras import UserNotifications import Account import Articles import Tree import SafariServices import SwiftUI import Core import Images protocol MainControllerIdentifiable { var mainControllerIdentifier: MainControllerIdentifier { get } } enum MainControllerIdentifier { case none case mainFeed case mainTimeline case article } enum SearchScope: Int { case timeline = 0 case global = 1 } enum ShowFeedName { case none case byline case feed } struct SidebarItemNode: Hashable { var node: Node var sidebarItemID: SidebarItemIdentifier @MainActor init(_ node: Node) { self.node = node self.sidebarItemID = (node.representedObject as! SidebarItem).sidebarItemID! } func hash(into hasher: inout Hasher) { hasher.combine(sidebarItemID) } } @MainActor final class SceneCoordinator: NSObject, UndoableCommandRunner { var undoableCommands = [UndoableCommand]() var undoManager: UndoManager? { return rootSplitViewController.undoManager } private var activityManager = ActivityManager() private var rootSplitViewController: RootSplitViewController! private var sidebarViewController: SidebarViewController! private var timelineViewController: TimelineViewController? private var articleViewController: ArticleViewController? private var lastMainControllerToAppear = MainControllerIdentifier.none private let fetchAndMergeArticlesQueue = CoalescingQueue(name: "Fetch and Merge Articles", interval: 0.5) private let rebuildBackingStoresQueue = CoalescingQueue(name: "Rebuild The Backing Stores", interval: 0.5) private var fetchSerialNumber = 0 private let fetchRequestQueue = FetchRequestQueue() /// Which Containers are expanded private var expandedTable = Set() /// Which Containers used to be expanded. Reset by rebuilding the Shadow Table. private var lastExpandedTable = Set() /// Which Feeds have the Read Articles Filter enabled private var readFilterEnabledTable = [SidebarItemIdentifier: Bool]() /// Flattened tree structure for the Sidebar private var shadowTable = [(sectionID: String, sidebarItemNodes: [SidebarItemNode])]() private(set) var preSearchTimelineFeed: SidebarItem? private var lastSearchString = "" private var lastSearchScope: SearchScope? = nil private var isSearching: Bool = false private var savedSearchArticles: ArticleArray? = nil private var savedSearchArticleIDs: Set? = nil private(set) var sortDirection = AppDefaults.shared.timelineSortDirection { didSet { if sortDirection != oldValue { sortParametersDidChange() } } } private(set) var groupByFeed = AppDefaults.shared.timelineGroupByFeed { didSet { if groupByFeed != oldValue { sortParametersDidChange() } } } var prefersStatusBarHidden = false private let treeControllerDelegate = FeedTreeControllerDelegate() private let treeController: TreeController var stateRestorationActivity: NSUserActivity { let activity = activityManager.stateRestorationActivity var userInfo = activity.userInfo ?? [AnyHashable: Any]() userInfo[UserInfoKey.windowState] = windowState() let articleState = articleViewController?.currentState userInfo[UserInfoKey.isShowingExtractedArticle] = articleState?.isShowingExtractedArticle ?? false userInfo[UserInfoKey.articleWindowScrollY] = articleState?.windowScrollY ?? 0 activity.userInfo = userInfo return activity } var isNavigationDisabled = false var isRootSplitCollapsed: Bool { return rootSplitViewController.isCollapsed } var isReadFeedsFiltered: Bool { return treeControllerDelegate.isReadFiltered } var isReadArticlesFiltered: Bool { if let sidebarItemID = timelineFeed?.sidebarItemID, let readFilterEnabled = readFilterEnabledTable[sidebarItemID] { return readFilterEnabled } else { return timelineDefaultReadFilterType != .none } } var timelineDefaultReadFilterType: ReadFilterType { return timelineFeed?.defaultReadFilterType ?? .none } var rootNode: Node { return treeController.rootNode } // At some point we should refactor the current Feed IndexPath out and only use the timeline feed private(set) var currentFeedIndexPath: IndexPath? var timelineIconImage: IconImage? { guard let timelineFeed = timelineFeed else { return nil } return IconImageCache.shared.imageForFeed(timelineFeed) } private var exceptionArticleFetcher: ArticleFetcher? private(set) var timelineFeed: SidebarItem? var timelineMiddleIndexPath: IndexPath? private(set) var showFeedNames = ShowFeedName.none private(set) var showIcons = false var prevFeedIndexPath: IndexPath? { guard let indexPath = currentFeedIndexPath else { return nil } let prevIndexPath: IndexPath? = { if indexPath.row - 1 < 0 { for i in (0.. 0 { return IndexPath(row: shadowTable[i].sidebarItemNodes.count - 1, section: i) } } return nil } else { return IndexPath(row: indexPath.row - 1, section: indexPath.section) } }() return prevIndexPath } var nextFeedIndexPath: IndexPath? { guard let indexPath = currentFeedIndexPath else { return nil } let nextIndexPath: IndexPath? = { if indexPath.row + 1 >= shadowTable[indexPath.section].sidebarItemNodes.count { for i in indexPath.section + 1.. 0 { return IndexPath(row: 0, section: i) } } return nil } else { return IndexPath(row: indexPath.row + 1, section: indexPath.section) } }() return nextIndexPath } var isPrevArticleAvailable: Bool { guard let articleRow = currentArticleRow else { return false } return articleRow > 0 } var isNextArticleAvailable: Bool { guard let articleRow = currentArticleRow else { return false } return articleRow + 1 < articles.count } var prevArticle: Article? { guard isPrevArticleAvailable, let articleRow = currentArticleRow else { return nil } return articles[articleRow - 1] } var nextArticle: Article? { guard isNextArticleAvailable, let articleRow = currentArticleRow else { return nil } return articles[articleRow + 1] } var firstUnreadArticleIndexPath: IndexPath? { for (row, article) in articles.enumerated() { if !article.status.read { return IndexPath(row: row, section: 0) } } return nil } var currentArticle: Article? private(set) var articles = ArticleArray() { didSet { timelineMiddleIndexPath = nil articleDictionaryNeedsUpdate = true } } private var articleDictionaryNeedsUpdate = true private var _idToArticleDictionary = [String: Article]() private var idToAticleDictionary: [String: Article] { if articleDictionaryNeedsUpdate { rebuildArticleDictionaries() } return _idToArticleDictionary } private var currentArticleRow: Int? { guard let article = currentArticle else { return nil } return articles.firstIndex(of: article) } var isTimelineUnreadAvailable: Bool { return timelineUnreadCount > 0 } var isAnyUnreadAvailable: Bool { return appDelegate.unreadCount > 0 } var timelineUnreadCount: Int = 0 init(rootSplitViewController: RootSplitViewController) { self.rootSplitViewController = rootSplitViewController self.treeController = TreeController(delegate: treeControllerDelegate) super.init() self.sidebarViewController = rootSplitViewController.viewController(for: .primary) as? SidebarViewController self.sidebarViewController.coordinator = self if let navController = self.sidebarViewController?.navigationController { navController.delegate = self configureNavigationController(navController) } self.timelineViewController = rootSplitViewController.viewController(for: .supplementary) as? TimelineViewController self.timelineViewController?.coordinator = self if let navController = self.timelineViewController?.navigationController { navController.delegate = self configureNavigationController(navController) } self.articleViewController = rootSplitViewController.viewController(for: .secondary) as? ArticleViewController self.articleViewController?.coordinator = self if let navController = self.articleViewController?.navigationController { configureNavigationController(navController) } for sectionNode in treeController.rootNode.childNodes { markExpanded(sectionNode) shadowTable.append((sectionID: "", sidebarItemNodes: [SidebarItemNode]())) } NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidInitialize(_:)), name: .UnreadCountDidInitialize, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(statusesDidChange(_:)), name: .StatusesDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(containerChildrenDidChange(_:)), name: .ChildrenDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(batchUpdateDidPerform(_:)), name: .BatchUpdateDidPerform, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(accountStateDidChange(_:)), name: .AccountStateDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(userDidAddAccount(_:)), name: .UserDidAddAccount, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(userDidDeleteAccount(_:)), name: .UserDidDeleteAccount, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(userDidAddFeed(_:)), name: .UserDidAddFeed, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(accountDidDownloadArticles(_:)), name: .AccountDidDownloadArticles, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(importDownloadedTheme(_:)), name: .didEndDownloadingTheme, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(themeDownloadDidFail(_:)), name: .didFailToImportThemeWithError, object: nil) } func restoreWindowState(_ activity: NSUserActivity?) { if let activity = activity, let windowState = activity.userInfo?[UserInfoKey.windowState] as? [AnyHashable: Any] { if let containerExpandedWindowState = windowState[UserInfoKey.containerExpandedWindowState] as? [[AnyHashable: AnyHashable]] { let containerIdentifers = containerExpandedWindowState.compactMap( { ContainerIdentifier(userInfo: $0) }) expandedTable = Set(containerIdentifers) } if let readArticlesFilterState = windowState[UserInfoKey.readArticlesFilterState] as? [[AnyHashable: AnyHashable]: Bool] { for key in readArticlesFilterState.keys { if let sidebarItemID = SidebarItemIdentifier(userInfo: key) { readFilterEnabledTable[sidebarItemID] = readArticlesFilterState[key] } } } // if let isSidebarHidden = windowState[UserInfoKey.isSidebarHidden] as? Bool, isSidebarHidden { // DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { // self.rootSplitViewController.preferredDisplayMode = .secondaryOnly // } // } rebuildBackingStores(initialLoad: true) // You can't assign the Feeds Read Filter until we've built the backing stores at least once or there is nothing // for state restoration to work with while we are waiting for the unread counts to initialize. if let readFeedsFilterState = windowState[UserInfoKey.readFeedsFilterState] as? Bool { treeControllerDelegate.isReadFiltered = readFeedsFilterState } } else { rebuildBackingStores(initialLoad: true) } } func handle(_ activity: NSUserActivity) { selectFeed(indexPath: nil) { guard let activityType = ActivityType(rawValue: activity.activityType) else { return } switch activityType { case .restoration: break case .selectFeed: self.handleSelectFeed(activity.userInfo) case .nextUnread: self.selectFirstUnreadInAllUnread() case .readArticle: self.handleReadArticle(activity.userInfo) case .addFeedIntent: self.showAddFeed() } } } func handle(_ response: UNNotificationResponse) { let userInfo = response.notification.request.content.userInfo handleReadArticle(userInfo) } func resetFocus() { if currentArticle != nil { timelineViewController?.focus() } else { sidebarViewController?.focus() } } func selectFirstUnreadInAllUnread() { markExpanded(SmartFeedsController.shared) self.ensureFeedIsAvailableToSelect(SmartFeedsController.shared.unreadFeed) { self.selectFeed(SmartFeedsController.shared.unreadFeed) { self.selectFirstUnreadArticleInTimeline() } } } func showSearch() { selectFeed(indexPath: nil) { self.rootSplitViewController.show(.supplementary) DispatchQueue.main.asyncAfter(deadline: .now()) { self.timelineViewController!.showSearchAll() } } } // MARK: Notifications @objc func unreadCountDidInitialize(_ notification: Notification) { guard notification.object is AccountManager else { return } if isReadFeedsFiltered { rebuildBackingStores() } } @objc func unreadCountDidChange(_ note: Notification) { // We will handle the filtering of unread feeds in unreadCountDidInitialize after they have all be calculated guard AccountManager.shared.isUnreadCountsInitialized else { return } queueRebuildBackingStores() } @objc func statusesDidChange(_ note: Notification) { updateUnreadCount() } @objc func containerChildrenDidChange(_ note: Notification) { if timelineFetcherContainsAnyPseudoFeed() || timelineFetcherContainsAnyFolder() { fetchAndMergeArticlesAsync(animated: true) { self.timelineViewController?.reinitializeArticles(resetScroll: false) self.rebuildBackingStores() } } else { rebuildBackingStores() } } @objc func batchUpdateDidPerform(_ notification: Notification) { rebuildBackingStores() } @objc func displayNameDidChange(_ note: Notification) { rebuildBackingStores() } @objc func accountStateDidChange(_ note: Notification) { if timelineFetcherContainsAnyPseudoFeed() { fetchAndMergeArticlesAsync(animated: true) { self.timelineViewController?.reinitializeArticles(resetScroll: false) self.rebuildBackingStores() } } else { self.rebuildBackingStores() } } @objc func userDidAddAccount(_ note: Notification) { let expandNewAccount = { if let account = note.userInfo?[Account.UserInfoKey.account] as? Account, let node = self.treeController.rootNode.childNodeRepresentingObject(account) { self.markExpanded(node) } } if timelineFetcherContainsAnyPseudoFeed() { fetchAndMergeArticlesAsync(animated: true) { self.timelineViewController?.reinitializeArticles(resetScroll: false) self.rebuildBackingStores(updateExpandedNodes: expandNewAccount) } } else { self.rebuildBackingStores(updateExpandedNodes: expandNewAccount) } } @objc func userDidDeleteAccount(_ note: Notification) { let cleanupAccount = { if let account = note.userInfo?[Account.UserInfoKey.account] as? Account, let node = self.treeController.rootNode.childNodeRepresentingObject(account) { self.unmarkExpanded(node) } } if timelineFetcherContainsAnyPseudoFeed() { fetchAndMergeArticlesAsync(animated: true) { self.timelineViewController?.reinitializeArticles(resetScroll: false) self.rebuildBackingStores(updateExpandedNodes: cleanupAccount) } } else { self.rebuildBackingStores(updateExpandedNodes: cleanupAccount) } } @objc func userDidAddFeed(_ notification: Notification) { guard let feed = notification.userInfo?[UserInfoKey.feed] as? Feed else { return } discloseFeed(feed, animations: [.scroll, .navigation]) } @objc func userDefaultsDidChange(_ note: Notification) { self.sortDirection = AppDefaults.shared.timelineSortDirection self.groupByFeed = AppDefaults.shared.timelineGroupByFeed } @objc func accountDidDownloadArticles(_ note: Notification) { guard let feeds = note.userInfo?[Account.UserInfoKey.feeds] as? Set else { return } let shouldFetchAndMergeArticles = timelineFetcherContainsAnyFeed(feeds) || timelineFetcherContainsAnyPseudoFeed() if shouldFetchAndMergeArticles { queueFetchAndMergeArticles() } } @objc func willEnterForeground(_ note: Notification) { // Don't interfere with any fetch requests that we may have initiated before the app was returned to the foreground. // For example if you select Next Unread from the Home Screen Quick actions, you can start a request before we are // in the foreground. if !fetchRequestQueue.isAnyCurrentRequest { queueFetchAndMergeArticles() } } @objc func importDownloadedTheme(_ note: Notification) { guard let userInfo = note.userInfo, let url = userInfo["url"] as? URL else { return } DispatchQueue.main.async { self.importTheme(filename: url.path) } } @objc func themeDownloadDidFail(_ note: Notification) { guard let userInfo = note.userInfo, let error = userInfo["error"] as? Error else { return } DispatchQueue.main.async { self.rootSplitViewController.presentError(error, dismiss: nil) } } // MARK: API func suspend() { fetchAndMergeArticlesQueue.performCallsImmediately() rebuildBackingStoresQueue.performCallsImmediately() fetchRequestQueue.cancelAllRequests() } func cleanUp(conditional: Bool) { if isReadFeedsFiltered { rebuildBackingStores() } if isReadArticlesFiltered && (AppDefaults.shared.refreshClearsReadArticles || !conditional) { refreshTimeline(resetScroll: false) } } func toggleReadFeedsFilter() { if isReadFeedsFiltered { treeControllerDelegate.isReadFiltered = false } else { treeControllerDelegate.isReadFiltered = true } rebuildBackingStores() sidebarViewController?.updateUI() } func toggleReadArticlesFilter() { guard let sidebarItemID = timelineFeed?.sidebarItemID else { return } if isReadArticlesFiltered { readFilterEnabledTable[sidebarItemID] = false } else { readFilterEnabledTable[sidebarItemID] = true } refreshTimeline(resetScroll: false) } func nodeFor(sidebarItemID: SidebarItemIdentifier) -> Node? { return treeController.rootNode.descendantNode(where: { node in if let feed = node.representedObject as? SidebarItem { return feed.sidebarItemID == sidebarItemID } else { return false } }) } func numberOfSections() -> Int { return shadowTable.count } func numberOfRows(in section: Int) -> Int { return shadowTable[section].sidebarItemNodes.count } func nodeFor(_ section: Int) -> Node? { return treeController.rootNode.childAtIndex(section) } func nodeFor(_ indexPath: IndexPath) -> Node? { guard indexPath.section > -1 && indexPath.row > -1 && indexPath.section < shadowTable.count && indexPath.row < shadowTable[indexPath.section].sidebarItemNodes.count else { return nil } return shadowTable[indexPath.section].sidebarItemNodes[indexPath.row].node } func indexPathFor(_ object: AnyObject) -> IndexPath? { guard let node = treeController.rootNode.descendantNodeRepresentingObject(object) else { return nil } return indexPathFor(node) } func indexPathFor(_ node: Node) -> IndexPath? { let sidebarItemNode = SidebarItemNode(node) for i in 0.. Article? { return idToAticleDictionary[articleID] } func cappedIndexPath(_ indexPath: IndexPath) -> IndexPath { guard indexPath.section < shadowTable.count && indexPath.row < shadowTable[indexPath.section].sidebarItemNodes.count else { return IndexPath(row: shadowTable[shadowTable.count - 1].sidebarItemNodes.count - 1, section: shadowTable.count - 1) } return indexPath } func unreadCountFor(_ node: Node) -> Int { // The coordinator supplies the unread count for the currently selected feed if node.representedObject === timelineFeed as AnyObject { return timelineUnreadCount } if let unreadCountProvider = node.representedObject as? UnreadCountProvider { return unreadCountProvider.unreadCount } assertionFailure("This method should only be called for nodes that have an UnreadCountProvider as the represented object.") return 0 } func refreshTimeline(resetScroll: Bool) { if let article = self.currentArticle, let account = article.account { exceptionArticleFetcher = SingleArticleFetcher(account: account, articleID: article.articleID) } fetchAndReplaceArticlesAsync(animated: true) { self.timelineViewController?.reinitializeArticles(resetScroll: resetScroll) } } func isExpanded(_ containerID: ContainerIdentifier) -> Bool { return expandedTable.contains(containerID) } func isExpanded(_ containerIdentifiable: ContainerIdentifiable) -> Bool { if let containerID = containerIdentifiable.containerID { return isExpanded(containerID) } return false } func isExpanded(_ node: Node) -> Bool { if let containerIdentifiable = node.representedObject as? ContainerIdentifiable { return isExpanded(containerIdentifiable) } return false } func expand(_ containerID: ContainerIdentifier) { markExpanded(containerID) rebuildBackingStores() } /// This is a special function that expects the caller to change the disclosure arrow state outside this function. /// Failure to do so will get the Sidebar into an invalid state. func expand(_ node: Node) { guard let containerID = (node.representedObject as? ContainerIdentifiable)?.containerID else { return } lastExpandedTable.insert(containerID) expand(containerID) } func expandAllSectionsAndFolders() { for sectionNode in treeController.rootNode.childNodes { markExpanded(sectionNode) for topLevelNode in sectionNode.childNodes { if topLevelNode.representedObject is Folder { markExpanded(topLevelNode) } } } rebuildBackingStores() } func collapse(_ containerID: ContainerIdentifier) { unmarkExpanded(containerID) rebuildBackingStores() clearTimelineIfNoLongerAvailable() } /// This is a special function that expects the caller to change the disclosure arrow state outside this function. /// Failure to do so will get the Sidebar into an invalid state. func collapse(_ node: Node) { guard let containerID = (node.representedObject as? ContainerIdentifiable)?.containerID else { return } lastExpandedTable.remove(containerID) collapse(containerID) } func collapseAllFolders() { for sectionNode in treeController.rootNode.childNodes { for topLevelNode in sectionNode.childNodes { if topLevelNode.representedObject is Folder { unmarkExpanded(topLevelNode) } } } rebuildBackingStores() clearTimelineIfNoLongerAvailable() } func feedIndexPathForCurrentTimeline() -> IndexPath? { guard let node = treeController.rootNode.descendantNodeRepresentingObject(timelineFeed as AnyObject) else { return nil } return indexPathFor(node) } func selectFeed(_ feed: SidebarItem?, animations: Animations = [], deselectArticle: Bool = true, completion: (() -> Void)? = nil) { let indexPath: IndexPath? = { if let feed = feed, let indexPath = indexPathFor(feed as AnyObject) { return indexPath } else { return nil } }() selectFeed(indexPath: indexPath, animations: animations, deselectArticle: deselectArticle, completion: completion) } func selectFeed(indexPath: IndexPath?, animations: Animations = [], deselectArticle: Bool = true, completion: (() -> Void)? = nil) { guard indexPath != currentFeedIndexPath else { completion?() return } currentFeedIndexPath = indexPath sidebarViewController.updateFeedSelection(animations: animations) if deselectArticle { selectArticle(nil) } if let ip = indexPath, let node = nodeFor(ip), let feed = node.representedObject as? SidebarItem { self.activityManager.selecting(feed: feed) self.rootSplitViewController.show(.supplementary) setTimelineFeed(feed, animated: false) { if self.isReadFeedsFiltered { self.rebuildBackingStores() } completion?() } } else { setTimelineFeed(nil, animated: false) { if self.isReadFeedsFiltered { self.rebuildBackingStores() } self.activityManager.invalidateSelecting() self.rootSplitViewController.show(.primary) completion?() } } } func selectPrevFeed() { if let indexPath = prevFeedIndexPath { selectFeed(indexPath: indexPath, animations: [.navigation, .scroll]) } } func selectNextFeed() { if let indexPath = nextFeedIndexPath { selectFeed(indexPath: indexPath, animations: [.navigation, .scroll]) } } func selectTodayFeed(completion: (() -> Void)? = nil) { markExpanded(SmartFeedsController.shared) self.ensureFeedIsAvailableToSelect(SmartFeedsController.shared.todayFeed) { self.selectFeed(SmartFeedsController.shared.todayFeed, animations: [.navigation, .scroll], completion: completion) } } func selectAllUnreadFeed(completion: (() -> Void)? = nil) { markExpanded(SmartFeedsController.shared) self.ensureFeedIsAvailableToSelect(SmartFeedsController.shared.unreadFeed) { self.selectFeed(SmartFeedsController.shared.unreadFeed, animations: [.navigation, .scroll], completion: completion) } } func selectStarredFeed(completion: (() -> Void)? = nil) { markExpanded(SmartFeedsController.shared) self.ensureFeedIsAvailableToSelect(SmartFeedsController.shared.starredFeed) { self.selectFeed(SmartFeedsController.shared.starredFeed, animations: [.navigation, .scroll], completion: completion) } } func selectArticle(_ article: Article?, animations: Animations = [], isShowingExtractedArticle: Bool? = nil, articleWindowScrollY: Int? = nil) { guard article != currentArticle else { return } currentArticle = article activityManager.reading(feed: timelineFeed, article: article) if article == nil { rootSplitViewController.show(.supplementary) timelineViewController?.updateArticleSelection(animations: animations) articleViewController?.article = nil return } rootSplitViewController.show(.secondary) // Mark article as read before navigating to it, so the read status does not flash unread/read on display markArticles(Set([article!]), statusKey: .read, flag: true) timelineViewController?.updateArticleSelection(animations: animations) articleViewController?.article = article if let isShowingExtractedArticle = isShowingExtractedArticle, let articleWindowScrollY = articleWindowScrollY { articleViewController?.restoreScrollPosition = (isShowingExtractedArticle, articleWindowScrollY) } } func beginSearching() { isSearching = true preSearchTimelineFeed = timelineFeed savedSearchArticles = articles savedSearchArticleIDs = Set(articles.map { $0.articleID }) setTimelineFeed(nil, animated: true) selectArticle(nil) } func endSearching() { if let oldTimelineFeed = preSearchTimelineFeed { emptyTheTimeline() timelineFeed = oldTimelineFeed replaceArticles(with: savedSearchArticles!, animated: true) timelineViewController?.reinitializeArticles(resetScroll: true) } else { setTimelineFeed(nil, animated: true) } lastSearchString = "" lastSearchScope = nil preSearchTimelineFeed = nil savedSearchArticleIDs = nil savedSearchArticles = nil isSearching = false selectArticle(nil) timelineViewController?.focus() } func searchArticles(_ searchString: String, _ searchScope: SearchScope) { guard isSearching else { return } if searchString.count < 3 { setTimelineFeed(nil, animated: true) return } if searchString != lastSearchString || searchScope != lastSearchScope { switch searchScope { case .global: setTimelineFeed(SmartFeed(delegate: SearchFeedDelegate(searchString: searchString)), animated: true) case .timeline: setTimelineFeed(SmartFeed(delegate: SearchTimelineFeedDelegate(searchString: searchString, articleIDs: savedSearchArticleIDs!)), animated: true) } lastSearchString = searchString lastSearchScope = searchScope } } func findPrevArticle(_ article: Article) -> Article? { guard let index = articles.firstIndex(of: article), index > 0 else { return nil } return articles[index - 1] } func findNextArticle(_ article: Article) -> Article? { guard let index = articles.firstIndex(of: article), index + 1 != articles.count else { return nil } return articles[index + 1] } func selectPrevArticle() { if let article = prevArticle { selectArticle(article, animations: [.navigation, .scroll]) } } func selectNextArticle() { if let article = nextArticle { selectArticle(article, animations: [.navigation, .scroll]) } } func selectFirstUnread() { if selectFirstUnreadArticleInTimeline() { activityManager.selectingNextUnread() } } func selectPrevUnread() { // This should never happen, but I don't want to risk throwing us // into an infinite loop searching for an unread that isn't there. if appDelegate.unreadCount < 1 { return } isNavigationDisabled = true defer { isNavigationDisabled = false } if selectPrevUnreadArticleInTimeline() { return } selectPrevUnreadFeedFetcher() selectPrevUnreadArticleInTimeline() } func selectNextUnread() { // This should never happen, but I don't want to risk throwing us // into an infinite loop searching for an unread that isn't there. if appDelegate.unreadCount < 1 { return } isNavigationDisabled = true defer { isNavigationDisabled = false } if selectNextUnreadArticleInTimeline() { return } if self.isSearching { self.timelineViewController?.hideSearch() } selectNextUnreadFeed() { self.selectNextUnreadArticleInTimeline() } } func scrollOrGoToNextUnread() { if articleViewController?.canScrollDown() ?? false { articleViewController?.scrollPageDown() } else { selectNextUnread() } } func scrollUp() { if articleViewController?.canScrollUp() ?? false { articleViewController?.scrollPageUp() } } func markAllAsRead(_ articles: [Article], completion: (() -> Void)? = nil) { markArticlesWithUndo(articles, statusKey: .read, flag: true, completion: completion) } func markAllAsReadInTimeline(completion: (() -> Void)? = nil) { markAllAsRead(articles) { self.rootSplitViewController.show(.primary) completion?() } } func canMarkAboveAsRead(for article: Article) -> Bool { let articlesAboveArray = articles.articlesAbove(article: article) return articlesAboveArray.canMarkAllAsRead() } func markAboveAsRead() { guard let currentArticle = currentArticle else { return } markAboveAsRead(currentArticle) } func markAboveAsRead(_ article: Article) { let articlesAboveArray = articles.articlesAbove(article: article) markAllAsRead(articlesAboveArray) } func canMarkBelowAsRead(for article: Article) -> Bool { let articleBelowArray = articles.articlesBelow(article: article) return articleBelowArray.canMarkAllAsRead() } func markBelowAsRead() { guard let currentArticle = currentArticle else { return } markBelowAsRead(currentArticle) } func markBelowAsRead(_ article: Article) { let articleBelowArray = articles.articlesBelow(article: article) markAllAsRead(articleBelowArray) } func markAsReadForCurrentArticle() { if let article = currentArticle { markArticlesWithUndo([article], statusKey: .read, flag: true) } } func markAsUnreadForCurrentArticle() { if let article = currentArticle { markArticlesWithUndo([article], statusKey: .read, flag: false) } } func toggleReadForCurrentArticle() { if let article = currentArticle { toggleRead(article) } } func toggleRead(_ article: Article) { guard !article.status.read || article.isAvailableToMarkUnread else { return } markArticlesWithUndo([article], statusKey: .read, flag: !article.status.read) } func toggleStarredForCurrentArticle() { if let article = currentArticle { toggleStar(article) } } func toggleStar(_ article: Article) { markArticlesWithUndo([article], statusKey: .starred, flag: !article.status.starred) } func timelineFeedIsEqualTo(_ feed: Feed) -> Bool { guard let timelineFeed = timelineFeed as? Feed else { return false } return timelineFeed == feed } func discloseFeed(_ feed: Feed, initialLoad: Bool = false, animations: Animations = [], completion: (() -> Void)? = nil) { if isSearching { timelineViewController?.hideSearch() } guard let account = feed.account else { completion?() return } let parentFolder = account.sortedFolders?.first(where: { $0.objectIsChild(feed) }) markExpanded(account) if let parentFolder = parentFolder { markExpanded(parentFolder) } if let sidebarItemID = feed.sidebarItemID { self.treeControllerDelegate.addFilterException(sidebarItemID) } if let parentFolderFeedID = parentFolder?.sidebarItemID { self.treeControllerDelegate.addFilterException(parentFolderFeedID) } rebuildBackingStores(initialLoad: initialLoad, completion: { self.treeControllerDelegate.resetFilterExceptions() self.selectFeed(feed, animations: animations, completion: completion) }) } func showStatusBar() { prefersStatusBarHidden = false UIView.animate(withDuration: 0.15) { self.rootSplitViewController.setNeedsStatusBarAppearanceUpdate() } } func hideStatusBar() { prefersStatusBarHidden = true UIView.animate(withDuration: 0.15) { self.rootSplitViewController.setNeedsStatusBarAppearanceUpdate() } } func showSettings(scrollToArticlesSection: Bool = false) { let settingsNavController = UIStoryboard.settings.instantiateInitialViewController() as! UINavigationController let settingsViewController = settingsNavController.topViewController as! SettingsViewController settingsViewController.scrollToArticlesSection = scrollToArticlesSection settingsNavController.modalPresentationStyle = .formSheet settingsViewController.presentingParentController = rootSplitViewController rootSplitViewController.present(settingsNavController, animated: true) } func showAccountInspector(for account: Account) { let accountInspectorNavController = UIStoryboard.inspector.instantiateViewController(identifier: "AccountInspectorNavigationViewController") as! UINavigationController let accountInspectorController = accountInspectorNavController.topViewController as! AccountInspectorViewController accountInspectorNavController.modalPresentationStyle = .formSheet accountInspectorNavController.preferredContentSize = AccountInspectorViewController.preferredContentSizeForFormSheetDisplay accountInspectorController.isModal = true accountInspectorController.account = account rootSplitViewController.present(accountInspectorNavController, animated: true) } func showFeedInspector() { let timelineFeed = timelineFeed as? Feed let articleFeed = currentArticle?.feed guard let feed = timelineFeed ?? articleFeed else { return } showFeedInspector(for: feed) } func showFeedInspector(for feed: Feed) { let feedInspectorNavController = UIStoryboard.inspector.instantiateViewController(identifier: "FeedInspectorNavigationViewController") as! UINavigationController let feedInspectorController = feedInspectorNavController.topViewController as! FeedInspectorViewController feedInspectorNavController.modalPresentationStyle = .formSheet feedInspectorNavController.preferredContentSize = FeedInspectorViewController.preferredContentSizeForFormSheetDisplay feedInspectorController.feed = feed rootSplitViewController.present(feedInspectorNavController, animated: true) } func showAddFeed(initialFeed: String? = nil, initialFeedName: String? = nil) { // Since Add Feed can be opened from anywhere with a keyboard shortcut, we have to deselect any currently selected feeds selectFeed(nil) let addNavViewController = UIStoryboard.add.instantiateViewController(withIdentifier: "AddFeedViewControllerNav") as! UINavigationController let addViewController = addNavViewController.topViewController as! AddFeedViewController addViewController.initialFeed = initialFeed addViewController.initialFeedName = initialFeedName addNavViewController.modalPresentationStyle = .formSheet addNavViewController.preferredContentSize = AddFeedViewController.preferredContentSizeForFormSheetDisplay sidebarViewController.present(addNavViewController, animated: true) } func showAddFolder() { let addNavViewController = UIStoryboard.add.instantiateViewController(withIdentifier: "AddFolderViewControllerNav") as! UINavigationController addNavViewController.modalPresentationStyle = .formSheet addNavViewController.preferredContentSize = AddFolderViewController.preferredContentSizeForFormSheetDisplay sidebarViewController.present(addNavViewController, animated: true) } func showFullScreenImage(image: UIImage, imageTitle: String?, transitioningDelegate: UIViewControllerTransitioningDelegate) { let imageVC = UIStoryboard.main.instantiateController(ofType: ImageViewController.self) imageVC.image = image imageVC.imageTitle = imageTitle imageVC.modalPresentationStyle = .currentContext imageVC.transitioningDelegate = transitioningDelegate rootSplitViewController.present(imageVC, animated: true) } func homePageURLForFeed(_ indexPath: IndexPath) -> URL? { guard let node = nodeFor(indexPath), let feed = node.representedObject as? Feed, let homePageURL = feed.homePageURL, let url = URL(string: homePageURL) else { return nil } return url } func showBrowserForFeed(_ indexPath: IndexPath) { if let url = homePageURLForFeed(indexPath) { UIApplication.shared.open(url, options: [:]) } } func showBrowserForCurrentFeed() { if let ip = currentFeedIndexPath, let url = homePageURLForFeed(ip) { UIApplication.shared.open(url, options: [:]) } } func showBrowserForArticle(_ article: Article) { guard let url = article.preferredURL else { return } UIApplication.shared.open(url, options: [:]) } func showBrowserForCurrentArticle() { guard let url = currentArticle?.preferredURL else { return } UIApplication.shared.open(url, options: [:]) } func showInAppBrowser() { if currentArticle != nil { articleViewController?.openInAppBrowser() } else { sidebarViewController.openInAppBrowser() } } func navigateToFeeds() { sidebarViewController?.focus() selectArticle(nil) } func navigateToTimeline() { if currentArticle == nil && articles.count > 0 { selectArticle(articles[0]) } timelineViewController?.focus() } func navigateToDetail() { articleViewController?.focus() } func toggleSidebar() { rootSplitViewController.preferredDisplayMode = rootSplitViewController.displayMode == .oneBesideSecondary ? .secondaryOnly : .oneBesideSecondary } func selectArticleInCurrentFeed(_ articleID: String, isShowingExtractedArticle: Bool? = nil, articleWindowScrollY: Int? = nil) { if let article = self.articles.first(where: { $0.articleID == articleID }) { self.selectArticle(article, isShowingExtractedArticle: isShowingExtractedArticle, articleWindowScrollY: articleWindowScrollY) } } func importTheme(filename: String) { do { try ArticleThemeImporter.importTheme(controller: rootSplitViewController, filename: filename) } catch { NotificationCenter.default.post(name: .didFailToImportThemeWithError, object: nil, userInfo: ["error" : error]) } } /// This will dismiss the foremost view controller if the user /// has launched from an external action (i.e., a widget tap, or /// selecting an article via a notification). /// /// The dismiss is only applicable if the view controller is a /// `SFSafariViewController` or `SettingsViewController`, /// otherwise, this function does nothing. func dismissIfLaunchingFromExternalAction() { guard let presentedController = sidebarViewController.presentedViewController else { return } if presentedController.isKind(of: SFSafariViewController.self) { presentedController.dismiss(animated: true, completion: nil) } // There's no obvious way to detect if the presented controller // is the SwiftUI UIHostingController. Posting a notification // which it can react to seems to be the simplest solution. NotificationCenter.default.post(name: .LaunchedFromExternalAction, object: nil) } } // MARK: UISplitViewControllerDelegate extension SceneCoordinator: UISplitViewControllerDelegate { func splitViewController(_ svc: UISplitViewController, topColumnForCollapsingToProposedTopColumn proposedTopColumn: UISplitViewController.Column) -> UISplitViewController.Column { switch proposedTopColumn { case .supplementary: if currentFeedIndexPath != nil { return .supplementary } else { return .primary } case .secondary: if currentArticle != nil { return .secondary } else { if currentFeedIndexPath != nil { return .supplementary } else { return .primary } } default: return .primary } } func splitViewController(_ svc: UISplitViewController, willChangeTo displayMode: UISplitViewController.DisplayMode) { articleViewController?.splitViewControllerWillChangeTo(displayMode: displayMode) } } // MARK: UINavigationControllerDelegate extension SceneCoordinator: UINavigationControllerDelegate { func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) { guard UIApplication.shared.applicationState != .background else { return } guard rootSplitViewController.isCollapsed else { return } defer { if let mainController = viewController as? MainControllerIdentifiable { lastMainControllerToAppear = mainController.mainControllerIdentifier } else if let mainController = (viewController as? UINavigationController)?.topViewController as? MainControllerIdentifiable { lastMainControllerToAppear = mainController.mainControllerIdentifier } } // If we are showing the Feeds and only the feeds start clearing stuff if viewController === sidebarViewController && lastMainControllerToAppear == .mainTimeline { activityManager.invalidateCurrentActivities() selectFeed(nil, animations: [.scroll, .select, .navigation]) return } // If we are using a phone and navigate away from the detail, clear up the article resources (including activity). // Don't clear it if we have pushed an ArticleViewController, but don't yet see it on the navigation stack. // This happens when we are going to the next unread and we need to grab another timeline to continue. The // ArticleViewController will be pushed, but we will briefly show the Timeline. Don't clear things out when that happens. if viewController === timelineViewController && lastMainControllerToAppear == .article { selectArticle(nil, animations: [.scroll, .select, .navigation]) // Restore any bars hidden by the article controller showStatusBar() // We delay the showing of the navigation bars because it freaks out on iOS 15 with the new split view controller // if it is trying to show at the same time as the show timeline animation DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { navigationController.setNavigationBarHidden(false, animated: true) } navigationController.setToolbarHidden(false, animated: true) return } } } // MARK: Private private extension SceneCoordinator { func configureNavigationController(_ navController: UINavigationController) { let scrollEdge = UINavigationBarAppearance() scrollEdge.configureWithOpaqueBackground() scrollEdge.shadowColor = nil scrollEdge.shadowImage = UIImage() let standard = UINavigationBarAppearance() standard.shadowColor = .opaqueSeparator standard.shadowImage = UIImage() navController.navigationBar.standardAppearance = standard navController.navigationBar.compactAppearance = standard navController.navigationBar.scrollEdgeAppearance = scrollEdge navController.navigationBar.compactScrollEdgeAppearance = scrollEdge navController.navigationBar.tintColor = AppAssets.primaryAccentColor let toolbarAppearance = UIToolbarAppearance() navController.toolbar.standardAppearance = toolbarAppearance navController.toolbar.compactAppearance = toolbarAppearance navController.toolbar.scrollEdgeAppearance = toolbarAppearance navController.toolbar.tintColor = AppAssets.primaryAccentColor } func markArticlesWithUndo(_ articles: [Article], statusKey: ArticleStatus.Key, flag: Bool, completion: (() -> Void)? = nil) { guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articles, statusKey: statusKey, flag: flag, undoManager: undoManager, completion: completion) else { completion?() return } runCommand(markReadCommand) } func updateUnreadCount() { var count = 0 for article in articles { if !article.status.read { count += 1 } } timelineUnreadCount = count } func rebuildArticleDictionaries() { var idDictionary = [String: Article]() for article in articles { idDictionary[article.articleID] = article } _idToArticleDictionary = idDictionary articleDictionaryNeedsUpdate = false } func ensureFeedIsAvailableToSelect(_ feed: SidebarItem, completion: @escaping () -> Void) { addToFilterExeptionsIfNecessary(feed) addShadowTableToFilterExceptions() rebuildBackingStores(completion: { self.treeControllerDelegate.resetFilterExceptions() completion() }) } func addToFilterExeptionsIfNecessary(_ feed: SidebarItem?) { if isReadFeedsFiltered, let sidebarItemID = feed?.sidebarItemID { if feed is SmartFeed { treeControllerDelegate.addFilterException(sidebarItemID) } else if let folderFeed = feed as? Folder { if folderFeed.account?.existingFolder(withID: folderFeed.folderID) != nil { treeControllerDelegate.addFilterException(sidebarItemID) } } else if let feed = feed as? Feed { if feed.account?.existingFeed(withFeedID: feed.feedID) != nil { treeControllerDelegate.addFilterException(sidebarItemID) addParentFolderToFilterExceptions(feed) } } } } func addParentFolderToFilterExceptions(_ feed: SidebarItem) { guard let node = treeController.rootNode.descendantNodeRepresentingObject(feed as AnyObject), let folder = node.parent?.representedObject as? Folder, let folderFeedID = folder.sidebarItemID else { return } treeControllerDelegate.addFilterException(folderFeedID) } func addShadowTableToFilterExceptions() { for section in shadowTable { for sidebarItemNode in section.sidebarItemNodes { if let sidebarItem = sidebarItemNode.node.representedObject as? SidebarItem, let sidebarItemID = sidebarItem.sidebarItemID { treeControllerDelegate.addFilterException(sidebarItemID) } } } } func queueRebuildBackingStores() { rebuildBackingStoresQueue.add(self, #selector(rebuildBackingStoresWithDefaults)) } @objc func rebuildBackingStoresWithDefaults() { rebuildBackingStores() } func rebuildBackingStores(initialLoad: Bool = false, updateExpandedNodes: (() -> Void)? = nil, completion: (() -> Void)? = nil) { if !BatchUpdate.shared.isPerforming { addToFilterExeptionsIfNecessary(timelineFeed) treeController.rebuild() treeControllerDelegate.resetFilterExceptions() updateExpandedNodes?() let changes = rebuildShadowTable() sidebarViewController.reloadFeeds(initialLoad: initialLoad, changes: changes, completion: completion) } } func rebuildShadowTable() -> ShadowTableChanges { var newShadowTable = [(sectionID: String, sidebarItemNodes: [SidebarItemNode])]() for i in 0..() var inserts = Set() var deletes = Set() let oldFeedNodes = shadowTable.first(where: { $0.sectionID == newSectionRows.sectionID })?.sidebarItemNodes ?? [SidebarItemNode]() let diff = newSectionRows.sidebarItemNodes.difference(from: oldFeedNodes).inferringMoves() for change in diff { switch change { case .insert(let offset, _, let associated): if let associated = associated { moves.insert(ShadowTableChanges.Move(associated, offset)) } else { inserts.insert(offset) } case .remove(let offset, _, let associated): if let associated = associated { moves.insert(ShadowTableChanges.Move(offset, associated)) } else { deletes.insert(offset) } } } // We need to reload the difference in expanded rows to get the disclosure arrows correct when programmatically changing their state var reloads = Set() for (index, newFeedNode) in newSectionRows.sidebarItemNodes.enumerated() { if let newFeedNodeContainerID = (newFeedNode.node.representedObject as? Container)?.containerID { if expandedTableDifference.contains(newFeedNodeContainerID) { reloads.insert(index) } } } changes.append(ShadowTableChanges.RowChanges(section: section, deletes: deletes, inserts: inserts, reloads: reloads, moves: moves)) } lastExpandedTable = expandedTable // Compute the difference in the shadow table sections var moves = Set() var inserts = Set() var deletes = Set() let oldSections = shadowTable.map { $0.sectionID } let newSections = newShadowTable.map { $0.sectionID } let diff = newSections.difference(from: oldSections).inferringMoves() for change in diff { switch change { case .insert(let offset, _, let associated): if let associated = associated { moves.insert(ShadowTableChanges.Move(associated, offset)) } else { inserts.insert(offset) } case .remove(let offset, _, let associated): if let associated = associated { moves.insert(ShadowTableChanges.Move(offset, associated)) } else { deletes.insert(offset) } } } shadowTable = newShadowTable return ShadowTableChanges(deletes: deletes, inserts: inserts, moves: moves, rowChanges: changes) } func shadowTableContains(_ sidebarItem: SidebarItem) -> Bool { for section in shadowTable { for sidebarItemNode in section.sidebarItemNodes { if let node = sidebarItemNode.node.representedObject as? SidebarItem, node.sidebarItemID == sidebarItem.sidebarItemID { return true } } } return false } func clearTimelineIfNoLongerAvailable() { if let feed = timelineFeed, !shadowTableContains(feed) { selectFeed(nil, deselectArticle: true) } } func setTimelineFeed(_ feed: SidebarItem?, animated: Bool, completion: (() -> Void)? = nil) { timelineFeed = feed fetchAndReplaceArticlesAsync(animated: animated) { self.timelineViewController?.reinitializeArticles(resetScroll: true) completion?() } } func updateShowNamesAndIcons() { if timelineFeed is Feed { showFeedNames = { for article in articles { if !article.byline().isEmpty { return .byline } } return .none }() } else { showFeedNames = .feed } if showFeedNames == .feed { self.showIcons = true return } if showFeedNames == .none { self.showIcons = false return } for article in articles { if let authors = article.authors { for author in authors { if author.avatarURL != nil { self.showIcons = true return } } } } self.showIcons = false } func markExpanded(_ containerID: ContainerIdentifier) { expandedTable.insert(containerID) } func markExpanded(_ containerIdentifiable: ContainerIdentifiable) { if let containerID = containerIdentifiable.containerID { markExpanded(containerID) } } func markExpanded(_ node: Node) { if let containerIdentifiable = node.representedObject as? ContainerIdentifiable { markExpanded(containerIdentifiable) } } func unmarkExpanded(_ containerID: ContainerIdentifier) { expandedTable.remove(containerID) } func unmarkExpanded(_ containerIdentifiable: ContainerIdentifiable) { if let containerID = containerIdentifiable.containerID { unmarkExpanded(containerID) } } func unmarkExpanded(_ node: Node) { if let containerIdentifiable = node.representedObject as? ContainerIdentifiable { unmarkExpanded(containerIdentifiable) } } // MARK: Select Prev Unread @discardableResult func selectPrevUnreadArticleInTimeline() -> Bool { let startingRow: Int = { if let articleRow = currentArticleRow { return articleRow } else { return articles.count - 1 } }() return selectPrevArticleInTimeline(startingRow: startingRow) } func selectPrevArticleInTimeline(startingRow: Int) -> Bool { guard startingRow >= 0 else { return false } for i in (0...startingRow).reversed() { let article = articles[i] if !article.status.read { selectArticle(article) return true } } return false } func selectPrevUnreadFeedFetcher() { let indexPath: IndexPath = { if currentFeedIndexPath == nil { return IndexPath(row: 0, section: 0) } else { return currentFeedIndexPath! } }() // Increment or wrap around the IndexPath let nextIndexPath: IndexPath = { if indexPath.row - 1 < 0 { if indexPath.section - 1 < 0 { return IndexPath(row: shadowTable[shadowTable.count - 1].sidebarItemNodes.count - 1, section: shadowTable.count - 1) } else { return IndexPath(row: shadowTable[indexPath.section - 1].sidebarItemNodes.count - 1, section: indexPath.section - 1) } } else { return IndexPath(row: indexPath.row - 1, section: indexPath.section) } }() if selectPrevUnreadFeedFetcher(startingWith: nextIndexPath) { return } let maxIndexPath = IndexPath(row: shadowTable[shadowTable.count - 1].sidebarItemNodes.count - 1, section: shadowTable.count - 1) selectPrevUnreadFeedFetcher(startingWith: maxIndexPath) } @discardableResult func selectPrevUnreadFeedFetcher(startingWith indexPath: IndexPath) -> Bool { for i in (0...indexPath.section).reversed() { let startingRow: Int = { if indexPath.section == i { return indexPath.row } else { return shadowTable[i].sidebarItemNodes.count - 1 } }() for j in (0...startingRow).reversed() { let prevIndexPath = IndexPath(row: j, section: i) guard let node = nodeFor(prevIndexPath), let unreadCountProvider = node.representedObject as? UnreadCountProvider else { assertionFailure() return true } if isExpanded(node) { continue } if unreadCountProvider.unreadCount > 0 { selectFeed(indexPath: prevIndexPath, animations: [.scroll, .navigation]) return true } } } return false } // MARK: Select Next Unread @discardableResult func selectFirstUnreadArticleInTimeline() -> Bool { return selectNextArticleInTimeline(startingRow: 0, animated: true) } @discardableResult func selectNextUnreadArticleInTimeline() -> Bool { let startingRow: Int = { if let articleRow = currentArticleRow { return articleRow + 1 } else { return 0 } }() return selectNextArticleInTimeline(startingRow: startingRow, animated: false) } func selectNextArticleInTimeline(startingRow: Int, animated: Bool) -> Bool { guard startingRow < articles.count else { return false } for i in startingRow.. Void) { let indexPath: IndexPath = { if currentFeedIndexPath == nil { return IndexPath(row: -1, section: 0) } else { return currentFeedIndexPath! } }() // Increment or wrap around the IndexPath let nextIndexPath: IndexPath = { if indexPath.row + 1 >= shadowTable[indexPath.section].sidebarItemNodes.count { if indexPath.section + 1 >= shadowTable.count { return IndexPath(row: 0, section: 0) } else { return IndexPath(row: 0, section: indexPath.section + 1) } } else { return IndexPath(row: indexPath.row + 1, section: indexPath.section) } }() selectNextUnreadFeed(startingWith: nextIndexPath) { found in if !found { self.selectNextUnreadFeed(startingWith: IndexPath(row: 0, section: 0)) { _ in completion() } } else { completion() } } } func selectNextUnreadFeed(startingWith indexPath: IndexPath, completion: @escaping (Bool) -> Void) { for i in indexPath.section.. 0 { selectFeed(indexPath: nextIndexPath, animations: [.scroll, .navigation], deselectArticle: false) { self.currentArticle = nil completion(true) } return } } } completion(false) } // MARK: Fetching Articles func emptyTheTimeline() { if !articles.isEmpty { replaceArticles(with: Set
(), animated: false) } } func sortParametersDidChange() { replaceArticles(with: Set(articles), animated: true) } func replaceArticles(with unsortedArticles: Set
, animated: Bool) { let sortedArticles = Array(unsortedArticles).sortedByDate(sortDirection, groupByFeed: groupByFeed) replaceArticles(with: sortedArticles, animated: animated) } func replaceArticles(with sortedArticles: ArticleArray, animated: Bool) { if articles != sortedArticles { articles = sortedArticles updateShowNamesAndIcons() updateUnreadCount() timelineViewController?.reloadArticles(animated: animated) } } func queueFetchAndMergeArticles() { fetchAndMergeArticlesQueue.add(self, #selector(fetchAndMergeArticlesAsync)) } @objc func fetchAndMergeArticlesAsync() { fetchAndMergeArticlesAsync(animated: true) { self.timelineViewController?.reinitializeArticles(resetScroll: false) self.timelineViewController?.restoreSelectionIfNecessary(adjustScroll: false) } } func fetchAndMergeArticlesAsync(animated: Bool = true, completion: (() -> Void)? = nil) { guard let timelineFeed = timelineFeed else { return } fetchUnsortedArticlesAsync(for: [timelineFeed]) { [weak self] (unsortedArticles) in // Merge articles by articleID. For any unique articleID in current articles, add to unsortedArticles. guard let strongSelf = self else { return } let unsortedArticleIDs = unsortedArticles.articleIDs() var updatedArticles = unsortedArticles for article in strongSelf.articles { if !unsortedArticleIDs.contains(article.articleID) { updatedArticles.insert(article) } if article.account?.existingFeed(withFeedID: article.feedID) == nil { updatedArticles.remove(article) } } strongSelf.replaceArticles(with: updatedArticles, animated: animated) completion?() } } func cancelPendingAsyncFetches() { fetchSerialNumber += 1 fetchRequestQueue.cancelAllRequests() } func fetchAndReplaceArticlesAsync(animated: Bool, completion: @escaping () -> Void) { // To be called when we need to do an entire fetch, but an async delay is okay. // Example: we have the Today feed selected, and the calendar day just changed. cancelPendingAsyncFetches() emptyTheTimeline() guard let timelineFeed = timelineFeed else { completion() return } var fetchers = [ArticleFetcher]() fetchers.append(timelineFeed) if exceptionArticleFetcher != nil { fetchers.append(exceptionArticleFetcher!) exceptionArticleFetcher = nil } fetchUnsortedArticlesAsync(for: fetchers) { [weak self] (articles) in self?.replaceArticles(with: articles, animated: animated) completion() } } func fetchUnsortedArticlesAsync(for representedObjects: [Any], completion: @escaping ArticleSetBlock) { // The callback will *not* be called if the fetch is no longer relevant — that is, // if it’s been superseded by a newer fetch, or the timeline was emptied, etc., it won’t get called. precondition(Thread.isMainThread) cancelPendingAsyncFetches() let fetchers = representedObjects.compactMap { $0 as? ArticleFetcher } let fetchOperation = FetchRequestOperation(id: fetchSerialNumber, readFilterEnabledTable: readFilterEnabledTable, fetchers: fetchers) { [weak self] (articles, operation) in precondition(Thread.isMainThread) guard !operation.isCanceled, let strongSelf = self, operation.id == strongSelf.fetchSerialNumber else { return } completion(articles) } fetchRequestQueue.add(fetchOperation) } func timelineFetcherContainsAnyPseudoFeed() -> Bool { if timelineFeed is PseudoFeed { return true } return false } func timelineFetcherContainsAnyFolder() -> Bool { if timelineFeed is Folder { return true } return false } func timelineFetcherContainsAnyFeed(_ feeds: Set) -> Bool { // Return true if there’s a match or if a folder contains (recursively) one of feeds if let feed = timelineFeed as? Feed { for oneFeed in feeds { if feed.feedID == oneFeed.feedID || feed.url == oneFeed.url { return true } } } else if let folder = timelineFeed as? Folder { for oneFeed in feeds { if folder.hasFeed(with: oneFeed.feedID) || folder.hasFeed(withURL: oneFeed.url) { return true } } } return false } // MARK: NSUserActivity func windowState() -> [AnyHashable: Any] { let containerExpandedWindowState = expandedTable.map( { $0.userInfo }) var readArticlesFilterState = [[AnyHashable: AnyHashable]: Bool]() for key in readFilterEnabledTable.keys { readArticlesFilterState[key.userInfo] = readFilterEnabledTable[key] } return [ UserInfoKey.readFeedsFilterState: isReadFeedsFiltered, UserInfoKey.containerExpandedWindowState: containerExpandedWindowState, UserInfoKey.readArticlesFilterState: readArticlesFilterState, UserInfoKey.isSidebarHidden: rootSplitViewController.displayMode == .secondaryOnly ] } func handleSelectFeed(_ userInfo: [AnyHashable : Any]?) { guard let userInfo = userInfo, let feedIdentifierUserInfo = userInfo[UserInfoKey.feedIdentifier] as? [AnyHashable : AnyHashable], let sidebarItemID = SidebarItemIdentifier(userInfo: feedIdentifierUserInfo) else { return } treeControllerDelegate.addFilterException(sidebarItemID) switch sidebarItemID { case .smartFeed: guard let smartFeed = SmartFeedsController.shared.find(by: sidebarItemID) else { return } markExpanded(SmartFeedsController.shared) rebuildBackingStores(initialLoad: true, completion: { self.treeControllerDelegate.resetFilterExceptions() if let indexPath = self.indexPathFor(smartFeed) { self.selectFeed(indexPath: indexPath) { self.sidebarViewController.focus() } } }) case .script: break case .folder(let accountID, let folderName): guard let accountNode = self.findAccountNode(accountID: accountID), let account = accountNode.representedObject as? Account else { return } markExpanded(account) rebuildBackingStores(initialLoad: true, completion: { self.treeControllerDelegate.resetFilterExceptions() if let folderNode = self.findFolderNode(folderName: folderName, beginningAt: accountNode), let indexPath = self.indexPathFor(folderNode) { self.selectFeed(indexPath: indexPath) { self.sidebarViewController.focus() } } }) case .feed(let accountID, let feedID): guard let accountNode = findAccountNode(accountID: accountID), let account = accountNode.representedObject as? Account, let feed = account.existingFeed(withFeedID: feedID) else { return } self.discloseFeed(feed, initialLoad: true) { self.sidebarViewController.focus() } } } func handleReadArticle(_ userInfo: [AnyHashable : Any]?) { guard let userInfo = userInfo else { return } guard let articlePathUserInfo = userInfo[UserInfoKey.articlePath] as? [AnyHashable : Any], let accountID = articlePathUserInfo[ArticlePathKey.accountID] as? String, let accountName = articlePathUserInfo[ArticlePathKey.accountName] as? String, let feedID = articlePathUserInfo[ArticlePathKey.feedID] as? String, let articleID = articlePathUserInfo[ArticlePathKey.articleID] as? String, let accountNode = findAccountNode(accountID: accountID, accountName: accountName), let account = accountNode.representedObject as? Account else { return } exceptionArticleFetcher = SingleArticleFetcher(account: account, articleID: articleID) if restoreFeedSelection(userInfo, accountID: accountID, feedID: feedID, articleID: articleID) { return } guard let feed = account.existingFeed(withFeedID: feedID) else { return } discloseFeed(feed) { self.selectArticleInCurrentFeed(articleID) } } func restoreFeedSelection(_ userInfo: [AnyHashable : Any], accountID: String, feedID: String, articleID: String) -> Bool { guard let feedIdentifierUserInfo = userInfo[UserInfoKey.feedIdentifier] as? [AnyHashable : AnyHashable], let sidebarItemID = SidebarItemIdentifier(userInfo: feedIdentifierUserInfo), let isShowingExtractedArticle = userInfo[UserInfoKey.isShowingExtractedArticle] as? Bool, let articleWindowScrollY = userInfo[UserInfoKey.articleWindowScrollY] as? Int else { return false } switch sidebarItemID { case .script: return false case .smartFeed, .folder: let found = selectFeedAndArticle(sidebarItemID: sidebarItemID, articleID: articleID, isShowingExtractedArticle: isShowingExtractedArticle, articleWindowScrollY: articleWindowScrollY) if found { treeControllerDelegate.addFilterException(sidebarItemID) } return found case .feed: let found = selectFeedAndArticle(sidebarItemID: sidebarItemID, articleID: articleID, isShowingExtractedArticle: isShowingExtractedArticle, articleWindowScrollY: articleWindowScrollY) if found { treeControllerDelegate.addFilterException(sidebarItemID) if let sidebarItemNode = nodeFor(sidebarItemID: sidebarItemID), let folder = sidebarItemNode.parent?.representedObject as? Folder, let folderFeedID = folder.sidebarItemID { treeControllerDelegate.addFilterException(folderFeedID) } } return found } } func findAccountNode(accountID: String, accountName: String? = nil) -> Node? { if let node = treeController.rootNode.descendantNode(where: { ($0.representedObject as? Account)?.accountID == accountID }) { return node } if let accountName = accountName, let node = treeController.rootNode.descendantNode(where: { ($0.representedObject as? Account)?.nameForDisplay == accountName }) { return node } return nil } func findFolderNode(folderName: String, beginningAt startingNode: Node) -> Node? { if let node = startingNode.descendantNode(where: { ($0.representedObject as? Folder)?.nameForDisplay == folderName }) { return node } return nil } func findFeedNode(feedID: String, beginningAt startingNode: Node) -> Node? { if let node = startingNode.descendantNode(where: { ($0.representedObject as? Feed)?.feedID == feedID }) { return node } return nil } func selectFeedAndArticle(sidebarItemID: SidebarItemIdentifier, articleID: String, isShowingExtractedArticle: Bool, articleWindowScrollY: Int) -> Bool { guard let sidebarItemNode = nodeFor(sidebarItemID: sidebarItemID), let indexPath = indexPathFor(sidebarItemNode) else { return false } selectFeed(indexPath: indexPath) { self.selectArticleInCurrentFeed(articleID, isShowingExtractedArticle: isShowingExtractedArticle, articleWindowScrollY: articleWindowScrollY) } return true } }