diff --git a/Frameworks/Account/Account.swift b/Frameworks/Account/Account.swift index 69462f931..0b1e47fe0 100644 --- a/Frameworks/Account/Account.swift +++ b/Frameworks/Account/Account.swift @@ -47,7 +47,7 @@ public enum FetchType { case starred case unread case today - case unreadForFolder(Folder) + case folder(Folder, Bool) case webFeed(WebFeed) case articleIDs(Set) case search(String) @@ -84,6 +84,10 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, public var isDeleted = false + public var containerID: ContainerIdentifier? { + return ContainerIdentifier.account(accountID) + } + public var account: Account? { return self } @@ -594,8 +598,12 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, return fetchUnreadArticles() case .today: return fetchTodayArticles() - case .unreadForFolder(let folder): - return fetchArticles(folder: folder) + case .folder(let folder, let readFilter): + if readFilter { + return fetchUnreadArticles(folder: folder) + } else { + return fetchArticles(folder: folder) + } case .webFeed(let webFeed): return fetchArticles(webFeed: webFeed) case .articleIDs(let articleIDs): @@ -615,8 +623,12 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, fetchUnreadArticlesAsync(callback) case .today: fetchTodayArticlesAsync(callback) - case .unreadForFolder(let folder): - fetchArticlesAsync(folder: folder, callback) + case .folder(let folder, let readFilter): + if readFilter { + return fetchUnreadArticlesAsync(folder: folder, callback) + } else { + return fetchArticlesAsync(folder: folder, callback) + } case .webFeed(let webFeed): fetchArticlesAsync(webFeed: webFeed, callback) case .articleIDs(let articleIDs): @@ -892,10 +904,18 @@ private extension Account { } func fetchArticles(folder: Folder) -> Set
{ - return fetchUnreadArticles(forContainer: folder) + return fetchArticles(forContainer: folder) } func fetchArticlesAsync(folder: Folder, _ callback: @escaping ArticleSetBlock) { + fetchArticlesAsync(forContainer: folder, callback) + } + + func fetchUnreadArticles(folder: Folder) -> Set
{ + return fetchUnreadArticles(forContainer: folder) + } + + func fetchUnreadArticlesAsync(folder: Folder, _ callback: @escaping ArticleSetBlock) { fetchUnreadArticlesAsync(forContainer: folder, callback) } @@ -950,6 +970,21 @@ private extension Account { } + func fetchArticles(forContainer container: Container) -> Set
{ + let feeds = container.flattenedWebFeeds() + let articles = database.fetchArticles(feeds.webFeedIDs()) + validateUnreadCountsAfterFetchingUnreadArticles(feeds, articles) + return articles + } + + func fetchArticlesAsync(forContainer container: Container, _ callback: @escaping ArticleSetBlock) { + let webFeeds = container.flattenedWebFeeds() + database.fetchArticlesAsync(webFeeds.webFeedIDs()) { [weak self] (articles) in + self?.validateUnreadCountsAfterFetchingUnreadArticles(webFeeds, articles) + callback(articles) + } + } + func fetchUnreadArticles(forContainer container: Container) -> Set
{ let feeds = container.flattenedWebFeeds() let articles = database.fetchUnreadArticles(feeds.webFeedIDs()) diff --git a/Frameworks/Account/Account.xcodeproj/project.pbxproj b/Frameworks/Account/Account.xcodeproj/project.pbxproj index 63585f829..a835e2639 100644 --- a/Frameworks/Account/Account.xcodeproj/project.pbxproj +++ b/Frameworks/Account/Account.xcodeproj/project.pbxproj @@ -43,6 +43,7 @@ 5170743C232AEDB500A461A3 /* OPMLFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5170743B232AEDB500A461A3 /* OPMLFile.swift */; }; 51BB7B84233531BC008E8144 /* AccountBehaviors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */; }; 51BC8FCC237EC055004F8B56 /* Feed.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BC8FCB237EC055004F8B56 /* Feed.swift */; }; + 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */; }; 51D58755227F53BE00900287 /* FeedbinTag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51D58754227F53BE00900287 /* FeedbinTag.swift */; }; 51D5875A227F630B00900287 /* tags_delete.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58757227F630B00900287 /* tags_delete.json */; }; 51D5875B227F630B00900287 /* tags_add.json in Resources */ = {isa = PBXBuildFile; fileRef = 51D58758227F630B00900287 /* tags_add.json */; }; @@ -255,6 +256,7 @@ 518B2EA52351306200400001 /* Account_project_test.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Account_project_test.xcconfig; sourceTree = ""; }; 51BB7B83233531BC008E8144 /* AccountBehaviors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountBehaviors.swift; sourceTree = ""; }; 51BC8FCB237EC055004F8B56 /* Feed.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Feed.swift; sourceTree = ""; }; + 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerIdentifier.swift; sourceTree = ""; }; 51D58754227F53BE00900287 /* FeedbinTag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeedbinTag.swift; sourceTree = ""; }; 51D58757227F630B00900287 /* tags_delete.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_delete.json; sourceTree = ""; }; 51D58758227F630B00900287 /* tags_add.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = tags_add.json; sourceTree = ""; }; @@ -572,6 +574,7 @@ 84F73CF0202788D80000BCEF /* ArticleFetcher.swift */, 84C365491F899F3B001EC85C /* CombinedRefreshProgress.swift */, 8419740D1F6DD25F006346C4 /* Container.swift */, + 51BFDECD238B508D00216323 /* ContainerIdentifier.swift */, 84B99C9E1FAE8D3200ECDEDB /* ContainerPath.swift */, 84C8B3F31F89DE430053CCA6 /* DataExtensions.swift */, 51BC8FCB237EC055004F8B56 /* Feed.swift */, @@ -1123,6 +1126,7 @@ 515E4EB52324FF8C0057B0E7 /* CredentialsManager.swift in Sources */, 844B297F210CE37E004020B3 /* UnreadCountProvider.swift in Sources */, 9E1773D5234570E30056A5A8 /* FeedlyEntryParser.swift in Sources */, + 51BFDECE238B508D00216323 /* ContainerIdentifier.swift in Sources */, 9E1D1555233431A600F4944C /* FeedlyOperation.swift in Sources */, 9E1AF38B2353D41A008BD1D5 /* FeedlySetStarredArticlesOperation.swift in Sources */, 84F1F06E2243524700DA0616 /* AccountMetadata.swift in Sources */, diff --git a/Frameworks/Account/ArticleFetcher.swift b/Frameworks/Account/ArticleFetcher.swift index 4bf3f647e..bf4b08dac 100644 --- a/Frameworks/Account/ArticleFetcher.swift +++ b/Frameworks/Account/ArticleFetcher.swift @@ -49,11 +49,20 @@ extension WebFeed: ArticleFetcher { extension Folder: ArticleFetcher { public func fetchArticles() -> Set
{ - return fetchUnreadArticles() + guard let account = account else { + assertionFailure("Expected folder.account, but got nil.") + return Set
() + } + return account.fetchArticles(.folder(self, false)) } public func fetchArticlesAsync(_ callback: @escaping ArticleSetBlock) { - fetchUnreadArticlesAsync(callback) + guard let account = account else { + assertionFailure("Expected folder.account, but got nil.") + callback(Set
()) + return + } + account.fetchArticlesAsync(.folder(self, false), callback) } public func fetchUnreadArticles() -> Set
{ @@ -61,7 +70,7 @@ extension Folder: ArticleFetcher { assertionFailure("Expected folder.account, but got nil.") return Set
() } - return account.fetchArticles(.unreadForFolder(self)) + return account.fetchArticles(.folder(self, true)) } public func fetchUnreadArticlesAsync(_ callback: @escaping ArticleSetBlock) { @@ -70,6 +79,6 @@ extension Folder: ArticleFetcher { callback(Set
()) return } - account.fetchArticlesAsync(.unreadForFolder(self), callback) + account.fetchArticlesAsync(.folder(self, true), callback) } } diff --git a/Frameworks/Account/Container.swift b/Frameworks/Account/Container.swift index 4c7d36e85..f9dbaacb1 100644 --- a/Frameworks/Account/Container.swift +++ b/Frameworks/Account/Container.swift @@ -16,7 +16,7 @@ extension Notification.Name { public static let ChildrenDidChange = Notification.Name("ChildrenDidChange") } -public protocol Container: class { +public protocol Container: class, ContainerIdentifiable { var account: Account? { get } var topLevelWebFeeds: Set { get set } diff --git a/Frameworks/Account/ContainerIdentifier.swift b/Frameworks/Account/ContainerIdentifier.swift new file mode 100644 index 000000000..9745a80db --- /dev/null +++ b/Frameworks/Account/ContainerIdentifier.swift @@ -0,0 +1,19 @@ +// +// ContainerIdentifier.swift +// Account +// +// Created by Maurice Parker on 11/24/19. +// Copyright © 2019 Ranchero Software, LLC. All rights reserved. +// + +import Foundation + +public protocol ContainerIdentifiable { + var containerID: ContainerIdentifier? { get } +} + +public enum ContainerIdentifier: Hashable { + case smartFeedController + case account(String) // accountID + case folder(String, String) // accountID, folderName +} diff --git a/Frameworks/Account/Feed.swift b/Frameworks/Account/Feed.swift index 3fcf1e3d2..4a14a43af 100644 --- a/Frameworks/Account/Feed.swift +++ b/Frameworks/Account/Feed.swift @@ -9,6 +9,14 @@ import Foundation import RSCore +public enum ReadFilterType { + case read + case none + case alwaysRead +} + public protocol Feed: FeedIdentifiable, ArticleFetcher, DisplayNameProvider, UnreadCountProvider { + var defaultReadFilterType: ReadFilterType { get } + } diff --git a/Frameworks/Account/Folder.swift b/Frameworks/Account/Folder.swift index cf6f1df57..211e58b79 100644 --- a/Frameworks/Account/Folder.swift +++ b/Frameworks/Account/Folder.swift @@ -12,6 +12,18 @@ import RSCore public final class Folder: Feed, Renamable, Container, Hashable { + public var defaultReadFilterType: ReadFilterType { + return .read + } + + public var containerID: ContainerIdentifier? { + guard let accountID = account?.accountID else { + assertionFailure("Expected feed.account, but got nil.") + return nil + } + return ContainerIdentifier.folder(accountID, nameForDisplay) + } + public var feedID: FeedIdentifier? { guard let accountID = account?.accountID else { assertionFailure("Expected feed.account, but got nil.") diff --git a/Frameworks/Account/WebFeed.swift b/Frameworks/Account/WebFeed.swift index 929701123..757d31e6e 100644 --- a/Frameworks/Account/WebFeed.swift +++ b/Frameworks/Account/WebFeed.swift @@ -13,6 +13,10 @@ import Articles public final class WebFeed: Feed, Renamable, Hashable { + public var defaultReadFilterType: ReadFilterType { + return .none + } + public var feedID: FeedIdentifier? { guard let accountID = account?.accountID else { assertionFailure("Expected feed.account, but got nil.") diff --git a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift index cdc116511..d80eae09f 100644 --- a/Frameworks/ArticlesDatabase/ArticlesDatabase.swift +++ b/Frameworks/ArticlesDatabase/ArticlesDatabase.swift @@ -48,12 +48,16 @@ public final class ArticlesDatabase { return articlesTable.fetchArticles(webFeedID) } + public func fetchArticles(_ webFeedIDs: Set) -> Set
{ + return articlesTable.fetchArticles(webFeedIDs) + } + public func fetchArticles(articleIDs: Set) -> Set
{ return articlesTable.fetchArticles(articleIDs: articleIDs) } - public func fetchUnreadArticles(_ webFeedID: Set) -> Set
{ - return articlesTable.fetchUnreadArticles(webFeedID) + public func fetchUnreadArticles(_ webFeedIDs: Set) -> Set
{ + return articlesTable.fetchUnreadArticles(webFeedIDs) } public func fetchTodayArticles(_ webFeedIDs: Set) -> Set
{ @@ -78,6 +82,10 @@ public final class ArticlesDatabase { articlesTable.fetchArticlesAsync(webFeedID, callback) } + public func fetchArticlesAsync(_ webFeedIDs: Set, _ callback: @escaping ArticleSetBlock) { + articlesTable.fetchArticlesAsync(webFeedIDs, callback) + } + public func fetchArticlesAsync(articleIDs: Set, _ callback: @escaping ArticleSetBlock) { articlesTable.fetchArticlesAsync(articleIDs: articleIDs, callback) } diff --git a/Frameworks/ArticlesDatabase/ArticlesTable.swift b/Frameworks/ArticlesDatabase/ArticlesTable.swift index 02629bff6..153a74368 100644 --- a/Frameworks/ArticlesDatabase/ArticlesTable.swift +++ b/Frameworks/ArticlesDatabase/ArticlesTable.swift @@ -60,6 +60,25 @@ final class ArticlesTable: DatabaseTable { return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [webFeedID as AnyObject], withLimits: withLimits) } + func fetchArticles(_ webFeedIDs: Set) -> Set
{ + return fetchArticles{ self.fetchArticles(webFeedIDs, $0) } + } + + func fetchArticlesAsync(_ webFeedIDs: Set, _ callback: @escaping ArticleSetBlock) { + fetchArticlesAsync({ self.fetchArticles(webFeedIDs, $0) }, callback) + } + + private func fetchArticles(_ webFeedIDs: Set, _ database: FMDatabase) -> Set
{ + // select * from articles natural join statuses where feedID in ('http://ranchero.com/xml/rss.xml') and read=0 + if webFeedIDs.isEmpty { + return Set
() + } + let parameters = webFeedIDs.map { $0 as AnyObject } + let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(webFeedIDs.count))! + let whereClause = "feedID in \(placeholders)" + return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters, withLimits: true) + } + // MARK: - Fetching Articles by articleID func fetchArticles(articleIDs: Set) -> Set
{ diff --git a/Mac/Base.lproj/Main.storyboard b/Mac/Base.lproj/Main.storyboard index a077b7153..19fbdd9f3 100644 --- a/Mac/Base.lproj/Main.storyboard +++ b/Mac/Base.lproj/Main.storyboard @@ -1,7 +1,7 @@ - + - + @@ -336,6 +336,12 @@ + + + + + + @@ -362,6 +368,12 @@ + + + + + + diff --git a/Mac/MainWindow/Detail/DetailWebViewController.swift b/Mac/MainWindow/Detail/DetailWebViewController.swift index 9ae240bfc..1ef693fbe 100644 --- a/Mac/MainWindow/Detail/DetailWebViewController.swift +++ b/Mac/MainWindow/Detail/DetailWebViewController.swift @@ -221,7 +221,7 @@ private extension DetailWebViewController { var render = "error();" if let data = try? encoder.encode(templateData) { let json = String(data: data, encoding: .utf8)! - render = "render(\(json));" + render = "render(\(json), 0);" } webView.evaluateJavaScript(render) diff --git a/Mac/MainWindow/MainWindowController.swift b/Mac/MainWindow/MainWindowController.swift index ff050a501..f77524c1c 100644 --- a/Mac/MainWindow/MainWindowController.swift +++ b/Mac/MainWindow/MainWindowController.swift @@ -237,6 +237,14 @@ class MainWindowController : NSWindowController, NSUserInterfaceValidations { return currentSearchField != nil } + if item.action == #selector(toggleReadFeedsFilter(_:)) { + return validateToggleReadFeeds(item) + } + + if item.action == #selector(toggleReadArticlesFilter(_:)) { + return validateToggleReadArticles(item) + } + if item.action == #selector(toggleSidebar(_:)) { guard let splitViewItem = sidebarSplitViewItem else { return false @@ -438,6 +446,15 @@ class MainWindowController : NSWindowController, NSUserInterfaceValidations { } window?.makeFirstResponder(searchField) } + + @IBAction func toggleReadFeedsFilter(_ sender: Any?) { + sidebarViewController?.toggleReadFilter() + } + + @IBAction func toggleReadArticlesFilter(_ sender: Any?) { + timelineContainerViewController?.toggleReadFilter() + } + } // MARK: - SidebarDelegate @@ -810,6 +827,30 @@ private extension MainWindowController { return result } + + func validateToggleReadFeeds(_ item: NSValidatedUserInterfaceItem) -> Bool { + guard let menuItem = item as? NSMenuItem else { return false } + + let showCommand = NSLocalizedString("Show Read Feeds", comment: "Command") + let hideCommand = NSLocalizedString("Hide Read Feeds", comment: "Command") + menuItem.title = sidebarViewController?.isReadFiltered ?? false ? showCommand : hideCommand + return true + } + + func validateToggleReadArticles(_ item: NSValidatedUserInterfaceItem) -> Bool { + guard let menuItem = item as? NSMenuItem else { return false } + + let showCommand = NSLocalizedString("Show Read Articles", comment: "Command") + let hideCommand = NSLocalizedString("Hide Read Articles", comment: "Command") + + if let isReadFiltered = timelineContainerViewController?.isReadFiltered { + menuItem.title = isReadFiltered ? showCommand : hideCommand + return true + } else { + menuItem.title = hideCommand + return false + } + } // MARK: - Misc. diff --git a/Mac/MainWindow/Sidebar/SidebarViewController.swift b/Mac/MainWindow/Sidebar/SidebarViewController.swift index ba74c57be..c03adc08c 100644 --- a/Mac/MainWindow/Sidebar/SidebarViewController.swift +++ b/Mac/MainWindow/Sidebar/SidebarViewController.swift @@ -30,6 +30,9 @@ protocol SidebarDelegate: class { lazy var dataSource: SidebarOutlineDataSource = { return SidebarOutlineDataSource(treeController: treeController) }() + var isReadFiltered: Bool { + return treeControllerDelegate.isReadFiltered + } var undoableCommands = [UndoableCommand]() private var animatingChanges = false @@ -333,7 +336,16 @@ protocol SidebarDelegate: class { } revealAndSelectRepresentedObject(feedNode.representedObject) } - + + func toggleReadFilter() { + if treeControllerDelegate.isReadFiltered { + treeControllerDelegate.isReadFiltered = false + } else { + treeControllerDelegate.isReadFiltered = true + } + rebuildTreeAndRestoreSelection() + } + } // MARK: - NSUserInterfaceValidations diff --git a/Mac/MainWindow/Timeline/TimelineContainerViewController.swift b/Mac/MainWindow/Timeline/TimelineContainerViewController.swift index b4f3b3a27..0130fc535 100644 --- a/Mac/MainWindow/Timeline/TimelineContainerViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineContainerViewController.swift @@ -30,6 +30,11 @@ final class TimelineContainerViewController: NSViewController { weak var delegate: TimelineContainerViewControllerDelegate? + var isReadFiltered: Bool? { + guard let currentTimelineViewController = currentTimelineViewController, mode(for: currentTimelineViewController) == .regular else { return nil } + return regularTimelineViewController.isReadFiltered + } + lazy var regularTimelineViewController = { return TimelineViewController(delegate: self) }() @@ -79,6 +84,11 @@ final class TimelineContainerViewController: NSViewController { } return true } + + func toggleReadFilter() { + regularTimelineViewController.toggleReadFilter() + } + } extension TimelineContainerViewController: TimelineDelegate { diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift index 54ff1e9e9..a6f88080d 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController.swift @@ -20,6 +20,12 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr @IBOutlet var tableView: TimelineTableView! + private var articleReadFilterType: ReadFilterType? + var isReadFiltered: Bool? { + guard let articleReadFilterType = articleReadFilterType, articleReadFilterType != .alwaysRead else { return nil} + return articleReadFilterType != .none + } + var representedObjects: [AnyObject]? { didSet { if !representedObjectArraysAreEqual(oldValue, representedObjects) { @@ -36,6 +42,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr showFeedNames = false } + determineReadFilterType() selectionDidChange(nil) if showsSearchResults { fetchAndReplaceArticlesAsync() @@ -213,6 +220,19 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr return representedObjects.first! === object } + func toggleReadFilter() { + guard let filterType = articleReadFilterType else { return } + switch filterType { + case .alwaysRead: + break + case .read: + articleReadFilterType = ReadFilterType.none + case .none: + articleReadFilterType = ReadFilterType.read + } + fetchAndReplaceArticlesAsync() + } + // MARK: - Actions @objc func openArticleInBrowser(_ sender: Any?) { @@ -944,6 +964,14 @@ private extension TimelineViewController { } // MARK: - Fetching Articles + + func determineReadFilterType() { + if representedObjects?.count ?? 0 == 1, let feed = representedObjects?.first as? Feed { + articleReadFilterType = feed.defaultReadFilterType + } else { + articleReadFilterType = .read + } + } func fetchAndReplaceArticlesSync() { // To be called when the user has made a change of selection in the sidebar. @@ -990,7 +1018,11 @@ private extension TimelineViewController { var fetchedArticles = Set
() for articleFetcher in articleFetchers { - fetchedArticles.formUnion(articleFetcher.fetchArticles()) + if articleReadFilterType != ReadFilterType.none { + fetchedArticles.formUnion(articleFetcher.fetchUnreadArticles()) + } else { + fetchedArticles.formUnion(articleFetcher.fetchArticles()) + } } return fetchedArticles } @@ -1000,7 +1032,8 @@ private extension TimelineViewController { // 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 fetchOperation = FetchRequestOperation(id: fetchSerialNumber, representedObjects: representedObjects) { [weak self] (articles, operation) in + let readFilter = articleReadFilterType != ReadFilterType.none + let fetchOperation = FetchRequestOperation(id: fetchSerialNumber, readFilter: readFilter, representedObjects: representedObjects) { [weak self] (articles, operation) in precondition(Thread.isMainThread) guard !operation.isCanceled, let strongSelf = self, operation.id == strongSelf.fetchSerialNumber else { return diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index fdb4470f4..bd3a5a57e 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -39,7 +39,6 @@ 512E08E62268800D00BDCFDD /* FolderTreeControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97A11ED9F180007D329B /* FolderTreeControllerDelegate.swift */; }; 512E08E72268801200BDCFDD /* WebFeedTreeControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97611ED9EB96007D329B /* WebFeedTreeControllerDelegate.swift */; }; 512E09012268907400BDCFDD /* MasterFeedTableViewSectionHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512E08F722688F7C00BDCFDD /* MasterFeedTableViewSectionHeader.swift */; }; - 512E09352268B25900BDCFDD /* UISplitViewController-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 512E092B2268B25500BDCFDD /* UISplitViewController-Extensions.swift */; }; 512E094D2268B8AB00BDCFDD /* DeleteCommand.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84B99C9C1FAE83C600ECDEDB /* DeleteCommand.swift */; }; 5131463E235A7BBE00387FDC /* NetNewsWire iOS Intents Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 51314637235A7BBE00387FDC /* NetNewsWire iOS Intents Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 51314668235A7E4600387FDC /* IntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51314666235A7E4600387FDC /* IntentHandler.swift */; }; @@ -95,6 +94,10 @@ 515D4FC123257A3200EE1167 /* FolderTreeControllerDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97A11ED9F180007D329B /* FolderTreeControllerDelegate.swift */; }; 515D4FCA23257CB500EE1167 /* Node-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 849A97971ED9EFAA007D329B /* Node-Extensions.swift */; }; 515D4FCC2325815A00EE1167 /* SafariExt.js in Resources */ = {isa = PBXBuildFile; fileRef = 515D4FCB2325815A00EE1167 /* SafariExt.js */; }; + 51627A6723861DA3007B3B4B /* MasterFeedViewController+Drag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51627A6623861DA3007B3B4B /* MasterFeedViewController+Drag.swift */; }; + 51627A6923861DED007B3B4B /* MasterFeedViewController+Drop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51627A6823861DED007B3B4B /* MasterFeedViewController+Drop.swift */; }; + 51627A6B238629D8007B3B4B /* MasterFeedDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51627A6A238629D8007B3B4B /* MasterFeedDataSource.swift */; }; + 51627A93238A3836007B3B4B /* CroppingPreviewParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51627A92238A3836007B3B4B /* CroppingPreviewParameters.swift */; }; 516A093723609A3600EAE89B /* SettingsAccountTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 516A091D23609A3600EAE89B /* SettingsAccountTableViewCell.xib */; }; 516A09392360A2AE00EAE89B /* SettingsAccountTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 516A09382360A2AE00EAE89B /* SettingsAccountTableViewCell.swift */; }; 516A093B2360A4A000EAE89B /* SettingsTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 516A093A2360A4A000EAE89B /* SettingsTableViewCell.xib */; }; @@ -217,7 +220,6 @@ 51C452AF2265108300C03939 /* ArticleArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84F204DF1FAACBB30076E152 /* ArticleArray.swift */; }; 51C452B42265141B00C03939 /* WebKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 51C452B32265141B00C03939 /* WebKit.framework */; }; 51C452B82265178500C03939 /* styleSheet.css in Resources */ = {isa = PBXBuildFile; fileRef = 51C452B72265178500C03939 /* styleSheet.css */; }; - 51CC9B3E231720B2000E842F /* MasterFeedDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51CC9B3D231720B2000E842F /* MasterFeedDataSource.swift */; }; 51CE1C0923621EDA005548FC /* RefreshProgressView.xib in Resources */ = {isa = PBXBuildFile; fileRef = 51CE1C0823621EDA005548FC /* RefreshProgressView.xib */; }; 51CE1C0B23622007005548FC /* RefreshProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51CE1C0A23622006005548FC /* RefreshProgressView.swift */; }; 51CE1C712367721A005548FC /* testURLsOfCurrentArticle.applescript in Sources */ = {isa = PBXBuildFile; fileRef = 84F9EADB213660A100CF2DE4 /* testURLsOfCurrentArticle.applescript */; }; @@ -1239,7 +1241,6 @@ 512AF9C1236ED52C0066F8BE /* ImageHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageHeaderView.swift; sourceTree = ""; }; 512AF9DC236F05230066F8BE /* InteractiveLabel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InteractiveLabel.swift; sourceTree = ""; }; 512E08F722688F7C00BDCFDD /* MasterFeedTableViewSectionHeader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterFeedTableViewSectionHeader.swift; sourceTree = ""; }; - 512E092B2268B25500BDCFDD /* UISplitViewController-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UISplitViewController-Extensions.swift"; sourceTree = ""; }; 51314617235A797400387FDC /* NetNewsWire_iOSintentextension_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_iOSintentextension_target.xcconfig; sourceTree = ""; }; 51314637235A7BBE00387FDC /* NetNewsWire iOS Intents Extension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "NetNewsWire iOS Intents Extension.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; 51314665235A7E4600387FDC /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; @@ -1274,6 +1275,10 @@ 515D4FCB2325815A00EE1167 /* SafariExt.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = SafariExt.js; sourceTree = ""; }; 515D4FCD2325909200EE1167 /* NetNewsWire_iOS_ShareExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = NetNewsWire_iOS_ShareExtension.entitlements; sourceTree = ""; }; 515D4FCE2325B3D000EE1167 /* NetNewsWire_iOSshareextension_target.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = NetNewsWire_iOSshareextension_target.xcconfig; sourceTree = ""; }; + 51627A6623861DA3007B3B4B /* MasterFeedViewController+Drag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MasterFeedViewController+Drag.swift"; sourceTree = ""; }; + 51627A6823861DED007B3B4B /* MasterFeedViewController+Drop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MasterFeedViewController+Drop.swift"; sourceTree = ""; }; + 51627A6A238629D8007B3B4B /* MasterFeedDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MasterFeedDataSource.swift; sourceTree = ""; }; + 51627A92238A3836007B3B4B /* CroppingPreviewParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CroppingPreviewParameters.swift; sourceTree = ""; }; 516A091D23609A3600EAE89B /* SettingsAccountTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = SettingsAccountTableViewCell.xib; sourceTree = ""; }; 516A09382360A2AE00EAE89B /* SettingsAccountTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsAccountTableViewCell.swift; sourceTree = ""; }; 516A093A2360A4A000EAE89B /* SettingsTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = SettingsTableViewCell.xib; sourceTree = ""; }; @@ -1334,7 +1339,6 @@ 51C4528B2265095F00C03939 /* AddFolderViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AddFolderViewController.swift; sourceTree = ""; }; 51C452B32265141B00C03939 /* WebKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WebKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS12.2.sdk/System/Library/Frameworks/WebKit.framework; sourceTree = DEVELOPER_DIR; }; 51C452B72265178500C03939 /* styleSheet.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = styleSheet.css; sourceTree = ""; }; - 51CC9B3D231720B2000E842F /* MasterFeedDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterFeedDataSource.swift; sourceTree = ""; }; 51CE1C0823621EDA005548FC /* RefreshProgressView.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = RefreshProgressView.xib; sourceTree = ""; }; 51CE1C0A23622006005548FC /* RefreshProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshProgressView.swift; sourceTree = ""; }; 51D6A5BB23199C85001C27D8 /* MasterTimelineDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterTimelineDataSource.swift; sourceTree = ""; }; @@ -1858,24 +1862,24 @@ children = ( 51F85BFA2275D85000C787DC /* Array-Extensions.swift */, 51F85BF42273625800C787DC /* Bundle-Extensions.swift */, + 51627A92238A3836007B3B4B /* CroppingPreviewParameters.swift */, 512AF9C1236ED52C0066F8BE /* ImageHeaderView.swift */, 512AF9DC236F05230066F8BE /* InteractiveLabel.swift */, + 51934CC1230F5963006127BE /* InteractiveNavigationController.swift */, + 51A9A5F42380F6A60033AADF /* ModalNavigationController.swift */, 51EAED95231363EF00A9EEE3 /* NonIntrinsicButton.swift */, 5183CCD9226E31A50010922C /* NonIntrinsicImageView.swift */, 5183CCCF226E1E880010922C /* NonIntrinsicLabel.swift */, + 51A9A6092382FD240033AADF /* PoppableGestureRecognizerDelegate.swift */, 512363372369155100951F16 /* RoundedProgressView.swift */, 51C45250226506F400C03939 /* String-Extensions.swift */, - 51934CC1230F5963006127BE /* InteractiveNavigationController.swift */, 5108F6D723763094001ABC45 /* TickMarkSlider.swift */, 51F85BF82274AA7B00C787DC /* UIBarButtonItem-Extensions.swift */, 51F85BF622749FA100C787DC /* UIFont-Extensions.swift */, - 512E092B2268B25500BDCFDD /* UISplitViewController-Extensions.swift */, 51C4524E226506F400C03939 /* UIStoryboard-Extensions.swift */, 51FFF0C3235EE8E5002762AA /* VibrantButton.swift */, 5186A634235EF3A800C97195 /* VibrantLabel.swift */, 5F323808231DF9F000706F6B /* VibrantTableViewCell.swift */, - 51A9A5F42380F6A60033AADF /* ModalNavigationController.swift */, - 51A9A6092382FD240033AADF /* PoppableGestureRecognizerDelegate.swift */, ); path = "UIKit Extensions"; sourceTree = ""; @@ -1884,7 +1888,9 @@ isa = PBXGroup; children = ( 51C45264226508F600C03939 /* MasterFeedViewController.swift */, - 51CC9B3D231720B2000E842F /* MasterFeedDataSource.swift */, + 51627A6A238629D8007B3B4B /* MasterFeedDataSource.swift */, + 51627A6623861DA3007B3B4B /* MasterFeedViewController+Drag.swift */, + 51627A6823861DED007B3B4B /* MasterFeedViewController+Drop.swift */, 51CE1C0A23622006005548FC /* RefreshProgressView.swift */, 51CE1C0823621EDA005548FC /* RefreshProgressView.xib */, 51C45260226508F600C03939 /* Cell */, @@ -3962,7 +3968,6 @@ 51C452A622650A3500C03939 /* Node-Extensions.swift in Sources */, 51C45294226509C800C03939 /* SearchFeedDelegate.swift in Sources */, 5F323809231DF9F000706F6B /* VibrantTableViewCell.swift in Sources */, - 512E09352268B25900BDCFDD /* UISplitViewController-Extensions.swift in Sources */, 51FE10042345529D0056195D /* UserNotificationManager.swift in Sources */, 51C452A022650A1900C03939 /* WebFeedIconDownloader.swift in Sources */, 51C4529E22650A1900C03939 /* ImageDownloader.swift in Sources */, @@ -3971,6 +3976,7 @@ 51C452A222650A1900C03939 /* RSHTMLMetadata+Extension.swift in Sources */, 514B7D1F23219F3C00BAC947 /* AddControllerType.swift in Sources */, 5141E7562374A2890013FF27 /* ArticleIconSchemeHandler.swift in Sources */, + 51627A93238A3836007B3B4B /* CroppingPreviewParameters.swift in Sources */, 512AF9DD236F05230066F8BE /* InteractiveLabel.swift in Sources */, 51E3EB3D229AB08300645299 /* ErrorHandler.swift in Sources */, 5183CCE5226F4DFA0010922C /* RefreshInterval.swift in Sources */, @@ -3991,6 +3997,7 @@ 51F85BF722749FA100C787DC /* UIFont-Extensions.swift in Sources */, 51C452AF2265108300C03939 /* ArticleArray.swift in Sources */, 51C4528E2265099C00C03939 /* SmartFeedsController.swift in Sources */, + 51627A6B238629D8007B3B4B /* MasterFeedDataSource.swift in Sources */, 51102165233A7D6C0007A5F7 /* ArticleExtractorButton.swift in Sources */, 5141E7392373C18B0013FF27 /* WebFeedInspectorViewController.swift in Sources */, 5108F6D42375EEEF001ABC45 /* TimelinePreviewTableViewController.swift in Sources */, @@ -4016,6 +4023,7 @@ 51934CCE2310792F006127BE /* ActivityManager.swift in Sources */, 5108F6B72375E612001ABC45 /* CacheCleaner.swift in Sources */, 518651DA235621840078E021 /* ImageTransition.swift in Sources */, + 51627A6923861DED007B3B4B /* MasterFeedViewController+Drop.swift in Sources */, 514219372352510100E07E2C /* ImageScrollView.swift in Sources */, 516AE9B32371C372007DEEAA /* MasterFeedTableViewSectionHeaderLayout.swift in Sources */, 51C4529B22650A1000C03939 /* FaviconDownloader.swift in Sources */, @@ -4027,12 +4035,12 @@ 51C4529F22650A1900C03939 /* AuthorAvatarDownloader.swift in Sources */, 5108F6D22375EED2001ABC45 /* TimelineCustomizerViewController.swift in Sources */, 519E743D22C663F900A78E47 /* SceneDelegate.swift in Sources */, - 51CC9B3E231720B2000E842F /* MasterFeedDataSource.swift in Sources */, FFD43E412340F488009E5CA3 /* UndoAvailableAlertController.swift in Sources */, 51C452A322650A1E00C03939 /* HTMLMetadataDownloader.swift in Sources */, 51C4528D2265095F00C03939 /* AddFolderViewController.swift in Sources */, 51C452782265091600C03939 /* MasterTimelineCellData.swift in Sources */, 5148F4552336DB7000F8CD8B /* MasterTimelineTitleView.swift in Sources */, + 51627A6723861DA3007B3B4B /* MasterFeedViewController+Drag.swift in Sources */, 51FFF0C4235EE8E5002762AA /* VibrantButton.swift in Sources */, 513228FC233037630033D4ED /* Reachability.swift in Sources */, 51C45259226508D300C03939 /* AppDefaults.swift in Sources */, diff --git a/Shared/Article Rendering/main.js b/Shared/Article Rendering/main.js index 22a7a8442..4ecf8eb5f 100644 --- a/Shared/Article Rendering/main.js +++ b/Shared/Article Rendering/main.js @@ -30,11 +30,11 @@ function error() { document.body.innerHTML = "error"; } -function render(data) { +function render(data, scrollY) { document.getElementsByTagName("style")[0].innerHTML = data.style; document.body.innerHTML = data.body; - window.scrollTo(0, 0); + window.scrollTo(0, scrollY); wrapFrames() stripStyles() diff --git a/Shared/Data/ArticleStringFormatter.swift b/Shared/Data/ArticleStringFormatter.swift index 7fa6f808a..6bd00baf3 100644 --- a/Shared/Data/ArticleStringFormatter.swift +++ b/Shared/Data/ArticleStringFormatter.swift @@ -89,7 +89,7 @@ struct ArticleStringFormatter { return cachedBody } var s = body.rsparser_stringByDecodingHTMLEntities() - s = s.rs_string(byStrippingHTML: 150) + s = s.rs_string(byStrippingHTML: 250) s = s.rs_stringByTrimmingWhitespace() s = s.rs_stringWithCollapsedWhitespace() if s == "Comments" { // Hacker News. diff --git a/Shared/SmartFeeds/SmartFeed.swift b/Shared/SmartFeeds/SmartFeed.swift index 0419c47a1..b6302ad44 100644 --- a/Shared/SmartFeeds/SmartFeed.swift +++ b/Shared/SmartFeeds/SmartFeed.swift @@ -13,6 +13,10 @@ import Account final class SmartFeed: PseudoFeed { + public var defaultReadFilterType: ReadFilterType { + return .none + } + var feedID: FeedIdentifier? { delegate.feedID } diff --git a/Shared/SmartFeeds/SmartFeedsController.swift b/Shared/SmartFeeds/SmartFeedsController.swift index 37a424a17..75288e496 100644 --- a/Shared/SmartFeeds/SmartFeedsController.swift +++ b/Shared/SmartFeeds/SmartFeedsController.swift @@ -8,13 +8,18 @@ import Foundation import RSCore +import Account -final class SmartFeedsController: DisplayNameProvider { +final class SmartFeedsController: DisplayNameProvider, ContainerIdentifiable { + + var containerID: ContainerIdentifier? { + return ContainerIdentifier.smartFeedController + } public static let shared = SmartFeedsController() let nameForDisplay = NSLocalizedString("Smart Feeds", comment: "Smart Feeds group title") - var smartFeeds = [AnyObject]() + var smartFeeds = [Feed]() let todayFeed = SmartFeed(delegate: TodayFeedDelegate()) let unreadFeed = UnreadFeed() let starredFeed = SmartFeed(delegate: StarredFeedDelegate()) @@ -23,14 +28,19 @@ final class SmartFeedsController: DisplayNameProvider { self.smartFeeds = [todayFeed, unreadFeed, starredFeed] } - func find(by identifier: String) -> PseudoFeed? { + func find(by identifier: FeedIdentifier) -> PseudoFeed? { switch identifier { - case String(describing: TodayFeedDelegate.self): - return todayFeed - case String(describing: UnreadFeed.self): - return unreadFeed - case String(describing: StarredFeedDelegate.self): - return starredFeed + case .smartFeed(let stringIdentifer): + switch stringIdentifer { + case String(describing: TodayFeedDelegate.self): + return todayFeed + case String(describing: UnreadFeed.self): + return unreadFeed + case String(describing: StarredFeedDelegate.self): + return starredFeed + default: + return nil + } default: return nil } diff --git a/Shared/SmartFeeds/UnreadFeed.swift b/Shared/SmartFeeds/UnreadFeed.swift index f41793988..053390c4c 100644 --- a/Shared/SmartFeeds/UnreadFeed.swift +++ b/Shared/SmartFeeds/UnreadFeed.swift @@ -19,6 +19,10 @@ import Articles final class UnreadFeed: PseudoFeed { + public var defaultReadFilterType: ReadFilterType { + return .alwaysRead + } + var feedID: FeedIdentifier? { return FeedIdentifier.smartFeed(String(describing: UnreadFeed.self)) } diff --git a/Shared/Timeline/FetchRequestOperation.swift b/Shared/Timeline/FetchRequestOperation.swift index ff9e0664b..08f9cddc0 100644 --- a/Shared/Timeline/FetchRequestOperation.swift +++ b/Shared/Timeline/FetchRequestOperation.swift @@ -19,14 +19,16 @@ typealias FetchRequestOperationResultBlock = (Set
, FetchRequestOperatio final class FetchRequestOperation { let id: Int + let readFilter: Bool let resultBlock: FetchRequestOperationResultBlock var isCanceled = false var isFinished = false private let representedObjects: [Any] - init(id: Int, representedObjects: [Any], resultBlock: @escaping FetchRequestOperationResultBlock) { + init(id: Int, readFilter: Bool, representedObjects: [Any], resultBlock: @escaping FetchRequestOperationResultBlock) { precondition(Thread.isMainThread) self.id = id + self.readFilter = readFilter self.representedObjects = representedObjects self.resultBlock = resultBlock } @@ -60,25 +62,38 @@ final class FetchRequestOperation { let numberOfFetchers = articleFetchers.count var fetchersReturned = 0 var fetchedArticles = Set
() - for articleFetcher in articleFetchers { - articleFetcher.fetchArticlesAsync { (articles) in - precondition(Thread.isMainThread) - guard !self.isCanceled else { - callCompletionIfNeeded() - return - } - - assert(!self.isFinished) + + func process(articles: Set
) { + precondition(Thread.isMainThread) + guard !self.isCanceled else { + callCompletionIfNeeded() + return + } + + assert(!self.isFinished) - fetchedArticles.formUnion(articles) - fetchersReturned += 1 - if fetchersReturned == numberOfFetchers { - self.isFinished = true - self.resultBlock(fetchedArticles, self) - callCompletionIfNeeded() + fetchedArticles.formUnion(articles) + fetchersReturned += 1 + if fetchersReturned == numberOfFetchers { + self.isFinished = true + self.resultBlock(fetchedArticles, self) + callCompletionIfNeeded() + } + } + + for articleFetcher in articleFetchers { + if readFilter { + articleFetcher.fetchUnreadArticlesAsync { (articles) in + process(articles: articles) + } + } else { + articleFetcher.fetchArticlesAsync { (articles) in + process(articles: articles) } } } + } + } diff --git a/Shared/Tree/WebFeedTreeControllerDelegate.swift b/Shared/Tree/WebFeedTreeControllerDelegate.swift index 62409cd14..6f572581d 100644 --- a/Shared/Tree/WebFeedTreeControllerDelegate.swift +++ b/Shared/Tree/WebFeedTreeControllerDelegate.swift @@ -13,8 +13,9 @@ import Account final class WebFeedTreeControllerDelegate: TreeControllerDelegate { + var isReadFiltered = false + func treeController(treeController: TreeController, childNodesFor node: Node) -> [Node]? { - if node.isRoot { return childNodesForRootNode(node) } @@ -32,29 +33,47 @@ final class WebFeedTreeControllerDelegate: TreeControllerDelegate { private extension WebFeedTreeControllerDelegate { func childNodesForRootNode(_ rootNode: Node) -> [Node]? { + var topLevelNodes = [Node]() - // The top-level nodes are Smart Feeds and accounts. + // Check to see if we should show the SmartFeeds top level by checking the unreadFeed + if !(isReadFiltered && SmartFeedsController.shared.unreadFeed.unreadCount == 0) { + let smartFeedsNode = rootNode.existingOrNewChildNode(with: SmartFeedsController.shared) + smartFeedsNode.canHaveChildNodes = true + smartFeedsNode.isGroupItem = true + topLevelNodes.append(smartFeedsNode) + } - let smartFeedsNode = rootNode.existingOrNewChildNode(with: SmartFeedsController.shared) - smartFeedsNode.canHaveChildNodes = true - smartFeedsNode.isGroupItem = true - - return [smartFeedsNode] + sortedAccountNodes(rootNode) + topLevelNodes.append(contentsOf: sortedAccountNodes(rootNode)) + + return topLevelNodes } func childNodesForSmartFeeds(_ parentNode: Node) -> [Node] { - - return SmartFeedsController.shared.smartFeeds.map { parentNode.existingOrNewChildNode(with: $0) } + return SmartFeedsController.shared.smartFeeds.compactMap { (feed) -> Node? in + if isReadFiltered && feed.unreadCount == 0 { + return nil + } + return parentNode.existingOrNewChildNode(with: feed as AnyObject) + } } func childNodesForContainerNode(_ containerNode: Node) -> [Node]? { - let container = containerNode.representedObject as! Container var children = [AnyObject]() - children.append(contentsOf: Array(container.topLevelWebFeeds)) + + for webFeed in container.topLevelWebFeeds { + if !(isReadFiltered && webFeed.unreadCount == 0) { + children.append(webFeed) + } + } + if let folders = container.folders { - children.append(contentsOf: Array(folders)) + for folder in folders { + if !(isReadFiltered && folder.unreadCount == 0) { + children.append(folder) + } + } } var updatedChildNodes = [Node]() @@ -77,13 +96,14 @@ private extension WebFeedTreeControllerDelegate { } func createNode(representedObject: Any, parent: Node) -> Node? { - if let webFeed = representedObject as? WebFeed { return createNode(webFeed: webFeed, parent: parent) } + if let folder = representedObject as? Folder { return createNode(folder: folder, parent: parent) } + if let account = representedObject as? Account { return createNode(account: account, parent: parent) } @@ -92,19 +112,16 @@ private extension WebFeedTreeControllerDelegate { } func createNode(webFeed: WebFeed, parent: Node) -> Node { - return parent.createChildNode(webFeed) } func createNode(folder: Folder, parent: Node) -> Node { - let node = parent.createChildNode(folder) node.canHaveChildNodes = true return node } func createNode(account: Account, parent: Node) -> Node { - let node = parent.createChildNode(account) node.canHaveChildNodes = true node.isGroupItem = true @@ -112,8 +129,10 @@ private extension WebFeedTreeControllerDelegate { } func sortedAccountNodes(_ parent: Node) -> [Node] { - - let nodes = AccountManager.shared.sortedActiveAccounts.map { (account) -> Node in + let nodes = AccountManager.shared.sortedActiveAccounts.compactMap { (account) -> Node? in + if isReadFiltered && account.unreadCount == 0 { + return nil + } let accountNode = parent.existingOrNewChildNode(with: account) accountNode.canHaveChildNodes = true accountNode.isGroupItem = true @@ -123,7 +142,6 @@ private extension WebFeedTreeControllerDelegate { } func nodeInArrayRepresentingObject(_ nodes: [Node], _ representedObject: AnyObject) -> Node? { - for oneNode in nodes { if oneNode.representedObject === representedObject { return oneNode diff --git a/iOS/Account/FeedbinAccountViewController.swift b/iOS/Account/FeedbinAccountViewController.swift index 71f4fce38..45fcb8237 100644 --- a/iOS/Account/FeedbinAccountViewController.swift +++ b/iOS/Account/FeedbinAccountViewController.swift @@ -44,7 +44,7 @@ class FeedbinAccountViewController: UITableViewController { } override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return section == 0 ? 64.0 : super.tableView(tableView, heightForHeaderInSection: section) + return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: section) } override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { diff --git a/iOS/Account/LocalAccountViewController.swift b/iOS/Account/LocalAccountViewController.swift index 6aed1eddf..be0c1596d 100644 --- a/iOS/Account/LocalAccountViewController.swift +++ b/iOS/Account/LocalAccountViewController.swift @@ -36,7 +36,7 @@ class LocalAccountViewController: UITableViewController { } override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return section == 0 ? 64.0 : super.tableView(tableView, heightForHeaderInSection: section) + return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: section) } override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { diff --git a/iOS/AppAssets.swift b/iOS/AppAssets.swift index 20993af6f..081725a9a 100644 --- a/iOS/AppAssets.swift +++ b/iOS/AppAssets.swift @@ -89,6 +89,14 @@ struct AppAssets { return RSImage(named: "faviconTemplateImage")! }() + static var filterInactiveImage: UIImage = { + UIImage(systemName: "line.horizontal.3.decrease.circle")! + }() + + static var filterActiveImage: UIImage = { + UIImage(systemName: "line.horizontal.3.decrease.circle.fill")! + }() + static var fullScreenBackgroundColor: UIColor = { return UIColor(named: "fullScreenBackgroundColor")! }() diff --git a/iOS/AppDefaults.swift b/iOS/AppDefaults.swift index 0942df915..f29b7d191 100644 --- a/iOS/AppDefaults.swift +++ b/iOS/AppDefaults.swift @@ -23,6 +23,7 @@ struct AppDefaults { static let timelineNumberOfLines = "timelineNumberOfLines" static let timelineIconSize = "timelineIconSize" static let timelineSortDirection = "timelineSortDirection" + static let articleFullscreenEnabled = "articleFullscreenEnabled" static let displayUndoAvailableTip = "displayUndoAvailableTip" static let lastRefresh = "lastRefresh" static let addWebFeedAccountID = "addWebFeedAccountID" @@ -92,6 +93,15 @@ struct AppDefaults { } } + static var articleFullscreenEnabled: Bool { + get { + return bool(for: Key.articleFullscreenEnabled) + } + set { + setBool(for: Key.articleFullscreenEnabled, newValue) + } + } + static var displayUndoAvailableTip: Bool { get { return bool(for: Key.displayUndoAvailableTip) @@ -135,6 +145,7 @@ struct AppDefaults { Key.timelineNumberOfLines: 2, Key.timelineIconSize: IconSize.medium.rawValue, Key.timelineSortDirection: ComparisonResult.orderedDescending.rawValue, + Key.articleFullscreenEnabled: false, Key.displayUndoAvailableTip: true] AppDefaults.shared.register(defaults: defaults) } diff --git a/iOS/Article/ArticleViewController.swift b/iOS/Article/ArticleViewController.swift index 3f8689d9c..ffbdb48e7 100644 --- a/iOS/Article/ArticleViewController.swift +++ b/iOS/Article/ArticleViewController.swift @@ -36,6 +36,8 @@ class ArticleViewController: UIViewController { @IBOutlet private weak var webViewContainer: UIView! @IBOutlet private weak var showNavigationView: UIView! @IBOutlet private weak var showToolbarView: UIView! + @IBOutlet private weak var showNavigationViewConstraint: NSLayoutConstraint! + @IBOutlet private weak var showToolbarViewConstraint: NSLayoutConstraint! private var articleExtractorButton: ArticleExtractorButton = { let button = ArticleExtractorButton(type: .system) @@ -59,6 +61,8 @@ class ArticleViewController: UIViewController { } } + var restoreOffset = 0 + var currentArticle: Article? { switch state { case .article(let article): @@ -131,11 +135,23 @@ class ArticleViewController: UIViewController { } + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + if AppDefaults.articleFullscreenEnabled { + hideBars() + } + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(true) coordinator.isArticleViewControllerPending = false } + override func viewSafeAreaInsetsDidChange() { + // This will animate if the show/hide bars animation is happening. + view.layoutIfNeeded() + } + func updateUI() { guard let article = currentArticle else { @@ -190,9 +206,11 @@ class ArticleViewController: UIViewController { var render = "error();" if let data = try? encoder.encode(templateData) { let json = String(data: data, encoding: .utf8)! - render = "render(\(json));" + render = "render(\(json), \(restoreOffset));" } + restoreOffset = 0 + ArticleViewControllerWebViewProvider.shared.articleIconSchemeHandler.currentArticle = currentArticle webView?.scrollView.setZoomScale(1.0, animated: false) webView?.evaluateJavaScript(render) @@ -231,7 +249,10 @@ class ArticleViewController: UIViewController { } @objc func willEnterForeground(_ note: Notification) { - showBars() + // The toolbar will come back on you if you don't hide it again + if AppDefaults.articleFullscreenEnabled { + hideBars() + } } // MARK: Actions @@ -319,6 +340,21 @@ class ArticleViewController: UIViewController { webView?.evaluateJavaScript("showClickedImage();") } + func fullReload() { + if let offset = webView?.scrollView.contentOffset.y { + restoreOffset = Int(offset) + webView?.reload() + } + } + +} + +// MARK: InteractiveNavigationControllerTappable + +extension ArticleViewController: InteractiveNavigationControllerTappable { + func didTapNavigationBar() { + hideBars() + } } // MARK: WKNavigationDelegate @@ -357,14 +393,6 @@ extension ArticleViewController: WKNavigationDelegate { } -// MARK: InteractiveNavigationControllerTappable - -extension ArticleViewController: InteractiveNavigationControllerTappable { - func didTapNavigationBar() { - hideBars() - } -} - // MARK: WKUIDelegate extension ArticleViewController: WKUIDelegate { @@ -466,7 +494,10 @@ private extension ArticleViewController { func showBars() { if traitCollection.userInterfaceIdiom == .phone && coordinator.isRootSplitCollapsed { + AppDefaults.articleFullscreenEnabled = false coordinator.showStatusBar() + showNavigationViewConstraint.constant = 0 + showToolbarViewConstraint.constant = 0 navigationController?.setNavigationBarHidden(false, animated: true) navigationController?.setToolbarHidden(false, animated: true) } @@ -474,7 +505,10 @@ private extension ArticleViewController { func hideBars() { if traitCollection.userInterfaceIdiom == .phone && coordinator.isRootSplitCollapsed { + AppDefaults.articleFullscreenEnabled = true coordinator.hideStatusBar() + showNavigationViewConstraint.constant = 44.0 + showToolbarViewConstraint.constant = 44.0 navigationController?.setNavigationBarHidden(true, animated: true) navigationController?.setToolbarHidden(true, animated: true) } diff --git a/iOS/Base.lproj/LaunchScreenPad.storyboard b/iOS/Base.lproj/LaunchScreenPad.storyboard index 070aca5aa..edb676e06 100644 --- a/iOS/Base.lproj/LaunchScreenPad.storyboard +++ b/iOS/Base.lproj/LaunchScreenPad.storyboard @@ -1,8 +1,8 @@ - + - + @@ -70,7 +70,7 @@ - + @@ -114,5 +114,6 @@ + diff --git a/iOS/Base.lproj/LaunchScreenPhone.storyboard b/iOS/Base.lproj/LaunchScreenPhone.storyboard index a61b6f5d9..6ac462068 100644 --- a/iOS/Base.lproj/LaunchScreenPhone.storyboard +++ b/iOS/Base.lproj/LaunchScreenPhone.storyboard @@ -1,8 +1,8 @@ - + - + @@ -70,7 +70,7 @@ - + @@ -114,5 +114,6 @@ + diff --git a/iOS/Base.lproj/Main.storyboard b/iOS/Base.lproj/Main.storyboard index dab0d6343..276aee563 100644 --- a/iOS/Base.lproj/Main.storyboard +++ b/iOS/Base.lproj/Main.storyboard @@ -21,14 +21,14 @@ - + - + @@ -37,14 +37,14 @@ - + - + @@ -121,7 +121,9 @@ + + @@ -167,10 +169,17 @@ - + + + + + + + + @@ -214,9 +223,17 @@ + + + + + + + + @@ -289,6 +306,7 @@ + diff --git a/iOS/Inspector/AccountInspectorViewController.swift b/iOS/Inspector/AccountInspectorViewController.swift index cc0095643..5f56a3605 100644 --- a/iOS/Inspector/AccountInspectorViewController.swift +++ b/iOS/Inspector/AccountInspectorViewController.swift @@ -128,7 +128,7 @@ extension AccountInspectorViewController { } override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return section == 0 ? 64.0 : super.tableView(tableView, heightForHeaderInSection: section) + return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: section) } override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { diff --git a/iOS/Inspector/WebFeedInspectorViewController.swift b/iOS/Inspector/WebFeedInspectorViewController.swift index ff790e84f..00066f4de 100644 --- a/iOS/Inspector/WebFeedInspectorViewController.swift +++ b/iOS/Inspector/WebFeedInspectorViewController.swift @@ -78,7 +78,7 @@ class WebFeedInspectorViewController: UITableViewController { extension WebFeedInspectorViewController { override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { - return section == 0 ? 64.0 : super.tableView(tableView, heightForHeaderInSection: section) + return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: section) } override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? { diff --git a/iOS/MasterFeed/Cell/MasterFeedTableViewCellLayout.swift b/iOS/MasterFeed/Cell/MasterFeedTableViewCellLayout.swift index 3ba4d5239..4ff894978 100644 --- a/iOS/MasterFeed/Cell/MasterFeedTableViewCellLayout.swift +++ b/iOS/MasterFeed/Cell/MasterFeedTableViewCellLayout.swift @@ -98,7 +98,7 @@ struct MasterFeedTableViewCellLayout { } } - let rLabel = CGRect(x: rLabelx, y: rLabely, width: labelWidth, height: labelSizeInfo.size.height) + var rLabel = CGRect(x: rLabelx, y: rLabely, width: labelWidth, height: labelSizeInfo.size.height) // Determine cell height let paddedLabelHeight = rLabel.maxY + UIFontMetrics.default.scaledValue(for: MasterFeedTableViewCellLayout.verticalPadding) @@ -117,6 +117,12 @@ struct MasterFeedTableViewCellLayout { rDisclosure = MasterFeedTableViewCellLayout.centerVertically(rDisclosure, newBounds) } + // Small fonts and the Favicon need centered if we hit the minimum row height + if cellHeight == MasterFeedTableViewCellLayout.minRowHeight { + rLabel = MasterFeedTableViewCellLayout.centerVertically(rLabel, newBounds) + rFavicon = MasterFeedTableViewCellLayout.centerVertically(rFavicon, newBounds) + } + // Separator Insets let separatorInset = MasterFeedTableViewCellLayout.disclosureButtonSize.width separatorRect = CGRect(x: separatorInset, y: cellHeight - 0.5, width: cellWidth - separatorInset, height: 0.5) diff --git a/iOS/MasterFeed/Cell/MasterFeedTableViewSectionHeaderLayout.swift b/iOS/MasterFeed/Cell/MasterFeedTableViewSectionHeaderLayout.swift index 5b487f837..1421e16f0 100644 --- a/iOS/MasterFeed/Cell/MasterFeedTableViewSectionHeaderLayout.swift +++ b/iOS/MasterFeed/Cell/MasterFeedTableViewSectionHeaderLayout.swift @@ -57,7 +57,7 @@ struct MasterFeedTableViewSectionHeaderLayout { labelWidth = cellWidth - (rLabelx + MasterFeedTableViewSectionHeaderLayout.labelMarginRight + maxUnreadCountSize.width + MasterFeedTableViewSectionHeaderLayout.unreadCountMarginRight) let labelSizeInfo = MultilineUILabelSizer.size(for: label.text ?? "", font: label.font, numberOfLines: 0, width: Int(floor(labelWidth))) - let rLabel = CGRect(x: rLabelx, y: rLabely, width: labelWidth, height: labelSizeInfo.size.height) + var rLabel = CGRect(x: rLabelx, y: rLabely, width: labelWidth, height: labelSizeInfo.size.height) // Determine cell height let paddedLabelHeight = rLabel.maxY + UIFontMetrics.default.scaledValue(for: MasterFeedTableViewSectionHeaderLayout.verticalPadding) @@ -74,6 +74,11 @@ struct MasterFeedTableViewSectionHeaderLayout { } rDisclosure = MasterFeedTableViewCellLayout.centerVertically(rDisclosure, newBounds) + // Small fonts need centered if we hit the minimum row height + if cellHeight == MasterFeedTableViewSectionHeaderLayout.minRowHeight { + rLabel = MasterFeedTableViewCellLayout.centerVertically(rLabel, newBounds) + } + // Assign the properties self.height = cellHeight self.unreadCountRect = rUnread diff --git a/iOS/MasterFeed/MasterFeedDataSource.swift b/iOS/MasterFeed/MasterFeedDataSource.swift index b2353fe0f..feec33e2e 100644 --- a/iOS/MasterFeed/MasterFeedDataSource.swift +++ b/iOS/MasterFeed/MasterFeedDataSource.swift @@ -13,16 +13,6 @@ import Account class MasterFeedDataSource: UITableViewDiffableDataSource { - private var coordinator: SceneCoordinator! - private var errorHandler: ((Error) -> ())! - - init(coordinator: SceneCoordinator, errorHandler: @escaping (Error) -> (), tableView: UITableView, cellProvider: @escaping UITableViewDiffableDataSource.CellProvider) { - super.init(tableView: tableView, cellProvider: cellProvider) - self.coordinator = coordinator - self.errorHandler = errorHandler - self.defaultRowAnimation = .middle - } - override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { guard let node = itemIdentifier(for: indexPath), !(node.representedObject is PseudoFeed) else { return false @@ -30,140 +20,4 @@ class MasterFeedDataSource: UITableViewDiffableDataSource { return true } - override func tableView(_ tableView: UITableView, canMoveRowAt indexPath: IndexPath) -> Bool { - guard let node = itemIdentifier(for: indexPath) else { - return false - } - return node.representedObject is WebFeed - } - - override func tableView(_ tableView: UITableView, moveRowAt sourceIndexPath: IndexPath, to destinationIndexPath: IndexPath) { - - guard let sourceNode = itemIdentifier(for: sourceIndexPath), let webFeed = sourceNode.representedObject as? WebFeed else { - return - } - - // Based on the drop we have to determine a node to start looking for a parent container. - let destNode: Node = { - if destinationIndexPath.row == 0 { - return coordinator.rootNode.childAtIndex(destinationIndexPath.section)! - } else { - let movementAdjustment = sourceIndexPath > destinationIndexPath ? 1 : 0 - let adjustedDestIndexPath = IndexPath(row: destinationIndexPath.row - movementAdjustment, section: destinationIndexPath.section) - return itemIdentifier(for: adjustedDestIndexPath)! - } - }() - - // Now we start looking for the parent container - let destParentNode: Node? = { - if destNode.representedObject is Container { - return destNode - } else { - if destNode.parent?.representedObject is Container { - return destNode.parent! - } else { - return nil - } - } - }() - - // Move the Web Feed - guard let source = sourceNode.parent?.representedObject as? Container, let destination = destParentNode?.representedObject as? Container else { - return - } - - if sameAccount(sourceNode, destParentNode!) { - moveWebFeedInAccount(feed: webFeed, sourceContainer: source, destinationContainer: destination) - } else { - moveWebFeedBetweenAccounts(feed: webFeed, sourceContainer: source, destinationContainer: destination) - } - - } - - private func sameAccount(_ node: Node, _ parentNode: Node) -> Bool { - if let accountID = nodeAccountID(node), let parentAccountID = nodeAccountID(parentNode) { - if accountID == parentAccountID { - return true - } - } - return false - } - - private func nodeAccount(_ node: Node) -> Account? { - if let account = node.representedObject as? Account { - return account - } else if let folder = node.representedObject as? Folder { - return folder.account - } else if let webFeed = node.representedObject as? WebFeed { - return webFeed.account - } else { - return nil - } - - } - - private func nodeAccountID(_ node: Node) -> String? { - return nodeAccount(node)?.accountID - } - - func moveWebFeedInAccount(feed: WebFeed, sourceContainer: Container, destinationContainer: Container) { - BatchUpdate.shared.start() - sourceContainer.account?.moveWebFeed(feed, from: sourceContainer, to: destinationContainer) { result in - BatchUpdate.shared.end() - switch result { - case .success: - break - case .failure(let error): - self.errorHandler(error) - } - } - } - - func moveWebFeedBetweenAccounts(feed: WebFeed, sourceContainer: Container, destinationContainer: Container) { - - if let existingFeed = destinationContainer.account?.existingWebFeed(withURL: feed.url) { - - BatchUpdate.shared.start() - destinationContainer.account?.addWebFeed(existingFeed, to: destinationContainer) { result in - switch result { - case .success: - sourceContainer.account?.removeWebFeed(feed, from: sourceContainer) { result in - BatchUpdate.shared.end() - switch result { - case .success: - break - case .failure(let error): - self.errorHandler(error) - } - } - case .failure(let error): - BatchUpdate.shared.end() - self.errorHandler(error) - } - } - - } else { - - BatchUpdate.shared.start() - destinationContainer.account?.createWebFeed(url: feed.url, name: feed.editedName, container: destinationContainer) { result in - switch result { - case .success: - sourceContainer.account?.removeWebFeed(feed, from: sourceContainer) { result in - BatchUpdate.shared.end() - switch result { - case .success: - break - case .failure(let error): - self.errorHandler(error) - } - } - case .failure(let error): - BatchUpdate.shared.end() - self.errorHandler(error) - } - } - - } - } - } diff --git a/iOS/MasterFeed/MasterFeedViewController+Drag.swift b/iOS/MasterFeed/MasterFeedViewController+Drag.swift new file mode 100644 index 000000000..65bd16610 --- /dev/null +++ b/iOS/MasterFeed/MasterFeedViewController+Drag.swift @@ -0,0 +1,33 @@ +// +// MasterFeedViewController+Drag.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 11/20/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import UIKit +import MobileCoreServices +import Account + +extension MasterFeedViewController: UITableViewDragDelegate { + + func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { + guard let node = dataSource.itemIdentifier(for: indexPath), let webFeed = node.representedObject as? WebFeed else { + return [UIDragItem]() + } + + let data = webFeed.url.data(using: .utf8) + let itemProvider = NSItemProvider() + + itemProvider.registerDataRepresentation(forTypeIdentifier: kUTTypeURL as String, visibility: .ownProcess) { completion in + completion(data, nil) + return nil + } + + let dragItem = UIDragItem(itemProvider: itemProvider) + dragItem.localObject = node + return [dragItem] + } + +} diff --git a/iOS/MasterFeed/MasterFeedViewController+Drop.swift b/iOS/MasterFeed/MasterFeedViewController+Drop.swift new file mode 100644 index 000000000..bd759e710 --- /dev/null +++ b/iOS/MasterFeed/MasterFeedViewController+Drop.swift @@ -0,0 +1,195 @@ +// +// MasterFeedViewController+Drop.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 11/20/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import UIKit +import RSCore +import Account +import RSTree + +extension MasterFeedViewController: UITableViewDropDelegate { + + func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool { + return session.localDragSession != nil + } + + func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal { + guard let destIndexPath = destinationIndexPath, + destIndexPath.section > 0, + tableView.hasActiveDrag, + let destNode = dataSource.itemIdentifier(for: destIndexPath), + let destCell = tableView.cellForRow(at: destIndexPath) else { + return UITableViewDropProposal(operation: .forbidden) + } + + if destNode.representedObject is Folder { + if session.location(in: destCell).y >= 0 { + return UITableViewDropProposal(operation: .move, intent: .insertIntoDestinationIndexPath) + } else { + return UITableViewDropProposal(operation: .move, intent: .unspecified) + } + } else { + return UITableViewDropProposal(operation: .move, intent: .insertAtDestinationIndexPath) + } + + } + + func tableView(_ tableView: UITableView, performDropWith dropCoordinator: UITableViewDropCoordinator) { + guard let dragItem = dropCoordinator.items.first?.dragItem, + let sourceNode = dragItem.localObject as? Node, + let webFeed = sourceNode.representedObject as? WebFeed, + let destIndexPath = dropCoordinator.destinationIndexPath else { + return + } + + let isFolderDrop: Bool = { + if let propDestNode = dataSource.itemIdentifier(for: destIndexPath), let propCell = tableView.cellForRow(at: destIndexPath) { + return propDestNode.representedObject is Folder && dropCoordinator.session.location(in: propCell).y >= 0 + } + return false + }() + + // Based on the drop we have to determine a node to start looking for a parent container. + let destNode: Node? = { + + if destIndexPath.row == 0 { + + return coordinator.rootNode.childAtIndex(destIndexPath.section)! + + } else { + + if isFolderDrop { + return dataSource.itemIdentifier(for: destIndexPath) + } else { + if destIndexPath.row > 0 { + return dataSource.itemIdentifier(for: IndexPath(row: destIndexPath.row - 1, section: destIndexPath.section)) + } else { + return nil + } + } + + } + + }() + + // Now we start looking for the parent container + let destParentNode: Node? = { + if destNode?.representedObject is Container { + return destNode + } else { + if destNode?.parent?.representedObject is Container { + return destNode!.parent! + } else { + return nil + } + } + }() + + // Move the Web Feed + guard let source = sourceNode.parent?.representedObject as? Container, let destination = destParentNode?.representedObject as? Container else { + return + } + + if sameAccount(sourceNode, destParentNode!) { + moveWebFeedInAccount(feed: webFeed, sourceContainer: source, destinationContainer: destination) + } else { + moveWebFeedBetweenAccounts(feed: webFeed, sourceContainer: source, destinationContainer: destination) + } + + + } + + private func sameAccount(_ node: Node, _ parentNode: Node) -> Bool { + if let accountID = nodeAccountID(node), let parentAccountID = nodeAccountID(parentNode) { + if accountID == parentAccountID { + return true + } + } + return false + } + + private func nodeAccount(_ node: Node) -> Account? { + if let account = node.representedObject as? Account { + return account + } else if let folder = node.representedObject as? Folder { + return folder.account + } else if let webFeed = node.representedObject as? WebFeed { + return webFeed.account + } else { + return nil + } + + } + + private func nodeAccountID(_ node: Node) -> String? { + return nodeAccount(node)?.accountID + } + + func moveWebFeedInAccount(feed: WebFeed, sourceContainer: Container, destinationContainer: Container) { + guard sourceContainer !== destinationContainer else { return } + + BatchUpdate.shared.start() + sourceContainer.account?.moveWebFeed(feed, from: sourceContainer, to: destinationContainer) { result in + BatchUpdate.shared.end() + switch result { + case .success: + break + case .failure(let error): + self.presentError(error) + } + } + } + + func moveWebFeedBetweenAccounts(feed: WebFeed, sourceContainer: Container, destinationContainer: Container) { + + if let existingFeed = destinationContainer.account?.existingWebFeed(withURL: feed.url) { + + BatchUpdate.shared.start() + destinationContainer.account?.addWebFeed(existingFeed, to: destinationContainer) { result in + switch result { + case .success: + sourceContainer.account?.removeWebFeed(feed, from: sourceContainer) { result in + BatchUpdate.shared.end() + switch result { + case .success: + break + case .failure(let error): + self.presentError(error) + } + } + case .failure(let error): + BatchUpdate.shared.end() + self.presentError(error) + } + } + + } else { + + BatchUpdate.shared.start() + destinationContainer.account?.createWebFeed(url: feed.url, name: feed.editedName, container: destinationContainer) { result in + switch result { + case .success: + sourceContainer.account?.removeWebFeed(feed, from: sourceContainer) { result in + BatchUpdate.shared.end() + switch result { + case .success: + break + case .failure(let error): + self.presentError(error) + } + } + case .failure(let error): + BatchUpdate.shared.end() + self.presentError(error) + } + } + + } + } + + +} diff --git a/iOS/MasterFeed/MasterFeedViewController.swift b/iOS/MasterFeed/MasterFeedViewController.swift index f31a16525..3e42727cf 100644 --- a/iOS/MasterFeed/MasterFeedViewController.swift +++ b/iOS/MasterFeed/MasterFeedViewController.swift @@ -14,10 +14,11 @@ import RSTree class MasterFeedViewController: UITableViewController, UndoableCommandRunner { + @IBOutlet weak var filterButton: UIBarButtonItem! private var refreshProgressView: RefreshProgressView? private var addNewItemButton: UIBarButtonItem! - private lazy var dataSource = makeDataSource() + lazy var dataSource = makeDataSource() var undoableCommands = [UndoableCommand]() weak var coordinator: SceneCoordinator! @@ -38,11 +39,6 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { navigationController?.navigationBar.prefersLargeTitles = true } - navigationItem.rightBarButtonItem = editButtonItem - - // Set the bar button item so that it doesn't show on the timeline view - navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) - // If you don't have an empty table header, UIKit tries to help out by putting one in for you // that makes a gap between the first section header and the navigation bar var frame = CGRect.zero @@ -51,6 +47,9 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { tableView.register(MasterFeedTableViewSectionHeader.self, forHeaderFooterViewReuseIdentifier: "SectionHeader") tableView.dataSource = dataSource + tableView.dragDelegate = self + tableView.dropDelegate = self + tableView.dragInteractionEnabled = true resetEstimatedRowHeight() tableView.separatorStyle = .none @@ -192,7 +191,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { } headerView.tag = section - headerView.disclosureExpanded = sectionNode.isExpanded + headerView.disclosureExpanded = coordinator.isExpanded(sectionNode) if section == tableView.numberOfSections - 1 { headerView.isLastSection = true @@ -292,11 +291,22 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { return nil } if node.representedObject is WebFeed { - return makeFeedContextMenu(indexPath: indexPath, includeDeleteRename: true) + return makeFeedContextMenu(node: node, indexPath: indexPath, includeDeleteRename: true) } else { - return makeFolderContextMenu(indexPath: indexPath) + return makeFolderContextMenu(node: node, indexPath: indexPath) } } + + override func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + guard let nodeUniqueId = configuration.identifier as? Int, + let node = coordinator.rootNode.descendantNode(where: { $0.uniqueID == nodeUniqueId }), + let indexPath = dataSource.indexPath(for: node), + let cell = tableView.cellForRow(at: indexPath) else { + return nil + } + + return UITargetedPreview(view: cell, parameters: CroppingPreviewParameters(view: cell)) + } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { becomeFirstResponder() @@ -324,9 +334,8 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { } // If this is a folder and isn't expanded or doesn't have any entries, let the users drop on it - if destNode.representedObject is Folder && (destNode.numberOfChildNodes == 0 || !destNode.isExpanded) { - let movementAdjustment = sourceIndexPath > destIndexPath ? 1 : 0 - return IndexPath(row: destIndexPath.row + movementAdjustment, section: destIndexPath.section) + if destNode.representedObject is Folder && (destNode.numberOfChildNodes == 0 || !coordinator.isExpanded(destNode)) { + return proposedDestinationIndexPath } // If we are dragging around in the same container, just return the original source @@ -351,14 +360,14 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { } else { sortedNodes.remove(at: index) - - let movementAdjustment = sourceIndexPath < destIndexPath ? 1 : 0 - let adjustedIndex = index - movementAdjustment - if adjustedIndex >= sortedNodes.count { + + if index >= sortedNodes.count { let lastSortedIndexPath = dataSource.indexPath(for: sortedNodes[sortedNodes.count - 1])! - return IndexPath(row: lastSortedIndexPath.row + 1, section: lastSortedIndexPath.section) + let movementAdjustment = sourceIndexPath > destIndexPath ? 1 : 0 + return IndexPath(row: lastSortedIndexPath.row + movementAdjustment, section: lastSortedIndexPath.section) } else { - return dataSource.indexPath(for: sortedNodes[adjustedIndex])! + let movementAdjustment = sourceIndexPath < destIndexPath ? 1 : 0 + return dataSource.indexPath(for: sortedNodes[index - movementAdjustment])! } } @@ -371,6 +380,16 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { coordinator.showSettings() } + @IBAction func toggleFilter(_ sender: Any) { + if coordinator.isUnreadFeedsFiltered { + filterButton.image = AppAssets.filterInactiveImage + coordinator.showAllFeeds() + } else { + filterButton.image = AppAssets.filterActiveImage + coordinator.hideUnreadFeeds() + } + } + @IBAction func add(_ sender: UIBarButtonItem) { coordinator.showAdd(.feed) } @@ -384,7 +403,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { return } - if sectionNode.isExpanded { + if coordinator.isExpanded(sectionNode) { headerView.disclosureExpanded = false coordinator.collapse(sectionNode) self.applyChanges(animated: true) @@ -501,7 +520,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner { return } - if !sectionNode.isExpanded { + if !coordinator.isExpanded(sectionNode) { coordinator.expand(sectionNode) self.applyChanges(animated: true) { completion?() @@ -561,12 +580,22 @@ extension MasterFeedViewController: UIContextMenuInteractionDelegate { return nil } - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil) { suggestedActions in + return UIContextMenuConfiguration(identifier: sectionIndex as NSCopying, previewProvider: nil) { suggestedActions in let accountInfoAction = self.getAccountInfoAction(account: account) let deactivateAction = self.deactivateAccountAction(account: account) return UIMenu(title: "", children: [accountInfoAction, deactivateAction]) } } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + + guard let sectionIndex = configuration.identifier as? Int, + let cell = tableView.headerView(forSection: sectionIndex) else { + return nil + } + + return UITargetedPreview(view: cell, parameters: CroppingPreviewParameters(view: cell)) + } } // MARK: MasterTableViewCellDelegate @@ -630,11 +659,13 @@ private extension MasterFeedViewController { } func makeDataSource() -> UITableViewDiffableDataSource { - return MasterFeedDataSource(coordinator: coordinator, errorHandler: ErrorHandler.present(self), tableView: tableView, cellProvider: { [weak self] tableView, indexPath, node in + let dataSource = MasterFeedDataSource(tableView: tableView, cellProvider: { [weak self] tableView, indexPath, node in let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! MasterFeedTableViewCell self?.configure(cell, node) return cell }) + dataSource.defaultRowAnimation = .middle + return dataSource } func resetEstimatedRowHeight() { @@ -656,7 +687,7 @@ private extension MasterFeedViewController { } else { cell.indentationLevel = 1 } - cell.setDisclosure(isExpanded: node.isExpanded, animated: false) + cell.setDisclosure(isExpanded: coordinator.isExpanded(node), animated: false) cell.isDisclosureAvailable = node.canHaveChildNodes cell.name = nameFor(node) @@ -772,8 +803,8 @@ private extension MasterFeedViewController { } } - func makeFeedContextMenu(indexPath: IndexPath, includeDeleteRename: Bool) -> UIContextMenuConfiguration { - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: { [ weak self] suggestedActions in + func makeFeedContextMenu(node: Node, indexPath: IndexPath, includeDeleteRename: Bool) -> UIContextMenuConfiguration { + return UIContextMenuConfiguration(identifier: node.uniqueID as NSCopying, previewProvider: nil, actionProvider: { [ weak self] suggestedActions in guard let self = self else { return nil } @@ -806,8 +837,8 @@ private extension MasterFeedViewController { } - func makeFolderContextMenu(indexPath: IndexPath) -> UIContextMenuConfiguration { - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: { [weak self] suggestedActions in + func makeFolderContextMenu(node: Node, indexPath: IndexPath) -> UIContextMenuConfiguration { + return UIContextMenuConfiguration(identifier: node.uniqueID as NSCopying, previewProvider: nil, actionProvider: { [weak self] suggestedActions in guard let self = self else { return nil } diff --git a/iOS/MasterTimeline/MasterTimelineDataSource.swift b/iOS/MasterTimeline/MasterTimelineDataSource.swift index ad160e8af..3647a93ae 100644 --- a/iOS/MasterTimeline/MasterTimelineDataSource.swift +++ b/iOS/MasterTimeline/MasterTimelineDataSource.swift @@ -9,13 +9,6 @@ import UIKit class MasterTimelineDataSource: UITableViewDiffableDataSource where SectionIdentifierType : Hashable, ItemIdentifierType : Hashable { - - private var coordinator: SceneCoordinator! - - init(coordinator: SceneCoordinator, tableView: UITableView, cellProvider: @escaping UITableViewDiffableDataSource.CellProvider) { - super.init(tableView: tableView, cellProvider: cellProvider) - self.coordinator = coordinator - } override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool { return true diff --git a/iOS/MasterTimeline/MasterTimelineViewController.swift b/iOS/MasterTimeline/MasterTimelineViewController.swift index e1ec464b8..7ce7a0123 100644 --- a/iOS/MasterTimeline/MasterTimelineViewController.swift +++ b/iOS/MasterTimeline/MasterTimelineViewController.swift @@ -13,10 +13,11 @@ import Articles class MasterTimelineViewController: UITableViewController, UndoableCommandRunner { - private var titleView: MasterTimelineTitleView? private var numberOfTextLines = 0 private var iconSize = IconSize.medium + private lazy var feedTapGestureRecognizer = UITapGestureRecognizer(target: self, action:#selector(showFeedInspector(_:))) + @IBOutlet weak var filterButton: UIBarButtonItem! @IBOutlet weak var markAllAsReadButton: UIBarButtonItem! @IBOutlet weak var firstUnreadButton: UIBarButtonItem! @@ -68,12 +69,13 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner iconSize = AppDefaults.timelineIconSize resetEstimatedRowHeight() + if let titleView = Bundle.main.loadNibNamed("MasterTimelineTitleView", owner: self, options: nil)?[0] as? MasterTimelineTitleView { + navigationItem.titleView = titleView + } + resetUI() applyChanges(animated: false) - // Set the bar button item so that it doesn't show on the article view - navigationItem.backBarButtonItem = UIBarButtonItem(title: "", style: .plain, target: nil, action: nil) - // Restore the scroll position if we have one stored if let restoreIndexPath = coordinator.timelineMiddleIndexPath { tableView.scrollToRow(at: restoreIndexPath, at: .middle, animated: false) @@ -81,13 +83,38 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner } + override func viewWillAppear(_ animated: Bool) { + // If the nav bar is hidden, fade it in to avoid it showing stuff as it is getting laid out + if navigationController?.navigationBar.isHidden ?? false { + navigationController?.navigationBar.alpha = 0 + } + } + override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(true) coordinator.isTimelineViewControllerPending = false + + if navigationController?.navigationBar.alpha == 0 { + UIView.animate(withDuration: 0.5) { + self.navigationController?.navigationBar.alpha = 1 + } + } } // MARK: Actions - + @IBAction func toggleFilter(_ sender: Any) { + switch coordinator.articleReadFilterType { + case .none: + filterButton.image = AppAssets.filterActiveImage + coordinator.hideUnreadArticles() + case .read: + filterButton.image = AppAssets.filterInactiveImage + coordinator.showAllArticles() + case .alwaysRead: + break + } + } + @IBAction func markAllAsRead(_ sender: Any) { if coordinator.displayUndoAvailableTip { let alertController = UndoAvailableAlertController.alert { [weak self] _ in @@ -129,10 +156,6 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner // MARK: API - func restoreTimelinePosition() { - - } - func restoreSelectionIfNecessary(adjustScroll: Bool) { if let article = coordinator.currentArticle, let indexPath = dataSource.indexPath(for: article) { if adjustScroll { @@ -263,7 +286,7 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner guard let article = dataSource.itemIdentifier(for: indexPath) else { return nil } - return UIContextMenuConfiguration(identifier: nil, previewProvider: nil, actionProvider: { [weak self] suggestedActions in + return UIContextMenuConfiguration(identifier: indexPath.row as NSCopying, previewProvider: nil, actionProvider: { [weak self] suggestedActions in guard let self = self else { return nil } @@ -294,6 +317,15 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner } + override func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? { + guard let row = configuration.identifier as? Int, + let cell = tableView.cellForRow(at: IndexPath(row: row, section: 0)) else { + return nil + } + + return UITargetedPreview(view: cell, parameters: CroppingPreviewParameters(view: cell)) + } + override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { becomeFirstResponder() let article = dataSource.itemIdentifier(for: indexPath) @@ -328,7 +360,11 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner } @objc func webFeedIconDidBecomeAvailable(_ note: Notification) { - titleView?.iconView.iconImage = coordinator.timelineIconImage + + if let titleView = navigationItem.titleView as? MasterTimelineTitleView { + titleView.iconView.iconImage = coordinator.timelineIconImage + } + guard let feed = note.userInfo?[UserInfoKey.webFeed] as? WebFeed else { return } @@ -359,7 +395,9 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner } @objc func faviconDidBecomeAvailable(_ note: Notification) { - titleView?.iconView.iconImage = coordinator.timelineIconImage + if let titleView = navigationItem.titleView as? MasterTimelineTitleView { + titleView.iconView.iconImage = coordinator.timelineIconImage + } if coordinator.showIcons { queueReloadAvailableCells() } @@ -379,7 +417,9 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner } @objc func displayNameDidChange(_ note: Notification) { - titleView?.label.text = coordinator.timelineFeed?.nameForDisplay + if let titleView = navigationItem.titleView as? MasterTimelineTitleView { + titleView.label.text = coordinator.timelineFeed?.nameForDisplay + } } @objc func scrollPositionDidChange() { @@ -466,24 +506,34 @@ extension MasterTimelineViewController: UISearchBarDelegate { private extension MasterTimelineViewController { func resetUI() { - title = coordinator.timelineFeed?.nameForDisplay - if let titleView = Bundle.main.loadNibNamed("MasterTimelineTitleView", owner: self, options: nil)?[0] as? MasterTimelineTitleView { - self.titleView = titleView - + title = coordinator.timelineFeed?.nameForDisplay ?? "Timeline" + + if let titleView = navigationItem.titleView as? MasterTimelineTitleView { titleView.iconView.iconImage = coordinator.timelineIconImage titleView.label.text = coordinator.timelineFeed?.nameForDisplay updateTitleUnreadCount() if coordinator.timelineFeed is WebFeed { titleView.heightAnchor.constraint(equalToConstant: 44.0).isActive = true - let tap = UITapGestureRecognizer(target: self, action:#selector(showFeedInspector(_:))) - titleView.addGestureRecognizer(tap) + titleView.addGestureRecognizer(feedTapGestureRecognizer) + } else { + titleView.removeGestureRecognizer(feedTapGestureRecognizer) } navigationItem.titleView = titleView } + switch coordinator.articleReadFilterType { + case .none: + filterButton.isHidden = false + filterButton.image = AppAssets.filterInactiveImage + case .read: + filterButton.isHidden = false + filterButton.image = AppAssets.filterActiveImage + case .alwaysRead: + filterButton.isHidden = true + } tableView.selectRow(at: nil, animated: false, scrollPosition: .top) if dataSource.snapshot().itemIdentifiers(inSection: 0).count > 0 { @@ -505,7 +555,9 @@ private extension MasterTimelineViewController { } func updateTitleUnreadCount() { - self.titleView?.unreadCountView.unreadCount = coordinator.unreadCount + if let titleView = navigationItem.titleView as? MasterTimelineTitleView { + titleView.unreadCountView.unreadCount = coordinator.unreadCount + } } func applyChanges(animated: Bool, completion: (() -> Void)? = nil) { @@ -521,12 +573,12 @@ private extension MasterTimelineViewController { func makeDataSource() -> UITableViewDiffableDataSource { let dataSource: UITableViewDiffableDataSource = - MasterTimelineDataSource(coordinator: coordinator, tableView: tableView, cellProvider: { [weak self] tableView, indexPath, article in + MasterTimelineDataSource(tableView: tableView, cellProvider: { [weak self] tableView, indexPath, article in let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! MasterTimelineTableViewCell self?.configure(cell, article: article) return cell }) - dataSource.defaultRowAnimation = .left + dataSource.defaultRowAnimation = .middle return dataSource } diff --git a/iOS/Resources/styleSheet.css b/iOS/Resources/styleSheet.css index 1b44588d3..a95e21bc3 100644 --- a/iOS/Resources/styleSheet.css +++ b/iOS/Resources/styleSheet.css @@ -67,6 +67,7 @@ body .headerTable { border-bottom: 1px solid var(--header-table-border-color); } body .header { + font: -apple-system-body; color: var(--header-color); } body .header a:link, body .header a:visited { diff --git a/iOS/RootSplitViewController.swift b/iOS/RootSplitViewController.swift index 007425424..2e2ca5e68 100644 --- a/iOS/RootSplitViewController.swift +++ b/iOS/RootSplitViewController.swift @@ -22,9 +22,7 @@ class RootSplitViewController: UISplitViewController { } override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - if UIApplication.shared.applicationState != .background { - self.coordinator.configureThreePanelMode(for: size) - } + self.coordinator.configurePanelMode(for: size) super.viewWillTransition(to: size, with: coordinator) } diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index 71bd4dc91..17e82d3c4 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -13,6 +13,11 @@ import Articles import RSCore import RSTree +enum PanelMode { + case unset + case three + case standard +} enum SearchScope: Int { case timeline = 0 case global = 1 @@ -25,6 +30,8 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { return rootSplitViewController.undoManager } + private var panelMode: PanelMode = .unset + private var activityManager = ActivityManager() private var isShowingExtractedArticle = false @@ -34,10 +41,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { private var masterNavigationController: UINavigationController! private var masterFeedViewController: MasterFeedViewController! private var masterTimelineViewController: MasterTimelineViewController? - - private var subSplitViewController: UISplitViewController? { - return rootSplitViewController.children.last as? UISplitViewController - } + private var subSplitViewController: UISplitViewController? private var articleViewController: ArticleViewController? { if let detail = masterNavigationController.viewControllers.last as? ArticleViewController { @@ -55,11 +59,14 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { return nil } + private var wasRootSplitViewControllerCollapsed = false + private let fetchAndMergeArticlesQueue = CoalescingQueue(name: "Fetch and Merge Articles", interval: 0.5) private var fetchSerialNumber = 0 private let fetchRequestQueue = FetchRequestQueue() private var animatingChanges = false + private var expandedTable = Set() private var shadowTable = [[Node]]() private var lastSearchString = "" private var lastSearchScope: SearchScope? = nil @@ -103,9 +110,15 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { } var isThreePanelMode: Bool { - return subSplitViewController != nil + return panelMode == .three } + var isUnreadFeedsFiltered: Bool { + return treeControllerDelegate.isReadFiltered + } + + var articleReadFilterType: ReadFilterType = .none + var rootNode: Node { return treeController.rootNode } @@ -257,8 +270,8 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { super.init() - for section in treeController.rootNode.childNodes { - section.isExpanded = true + for sectionNode in treeController.rootNode.childNodes { + markExpanded(sectionNode) shadowTable.append([Node]()) } @@ -297,7 +310,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { let detailNavigationController = addNavControllerIfNecessary(articleViewController, showButton: true) rootSplitViewController.showDetailViewController(detailNavigationController, sender: self) - configureThreePanelMode(for: size) + configurePanelMode(for: size) return rootSplitViewController } @@ -325,19 +338,24 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { handleReadArticle(userInfo) } - func configureThreePanelMode(for size: CGSize) { - guard rootSplitViewController.traitCollection.userInterfaceIdiom == .pad && !rootSplitViewController.isCollapsed else { + func configurePanelMode(for size: CGSize) { + guard rootSplitViewController.traitCollection.userInterfaceIdiom == .pad else { return } + if (size.width / size.height) > 1.2 { - if !isThreePanelMode { - transitionToThreePanelMode() + if panelMode == .unset || panelMode == .standard { + panelMode = .three + configureThreePanelMode() } } else { - if isThreePanelMode { - transitionFromThreePanelMode() + if panelMode == .unset || panelMode == .three { + panelMode = .standard + configureStandardPanelMode() } } + + wasRootSplitViewControllerCollapsed = rootSplitViewController.isCollapsed } func selectFirstUnreadInAllUnread() { @@ -363,7 +381,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { @objc func containerChildrenDidChange(_ note: Notification) { if timelineFetcherContainsAnyPseudoFeed() || timelineFetcherContainsAnyFolder() { - fetchAndReplaceArticlesAsync() {} + refreshTimeline() } rebuildBackingStores() } @@ -377,60 +395,58 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { } @objc func accountStateDidChange(_ note: Notification) { - - let rebuildAndExpand = { - guard let account = note.userInfo?[Account.UserInfoKey.account] as? Account else { - assertionFailure() - return - } - - self.rebuildBackingStores() { - // If we are activating an account, then automatically expand it - if account.isActive, let node = self.treeController.rootNode.childNodeRepresentingObject(account) { - node.isExpanded = true - } - } - } - if timelineFetcherContainsAnyPseudoFeed() { - fetchAndReplaceArticlesAsync { - rebuildAndExpand() + fetchAndReplaceArticlesAsync(animated: true) { + self.masterTimelineViewController?.reinitializeArticles() + self.rebuildBackingStores() } } else { - rebuildAndExpand() + rebuildBackingStores() } } @objc func userDidAddAccount(_ note: Notification) { - - let rebuildAndExpand = { - self.rebuildBackingStores() { - // Automatically expand any new accounts - if let account = note.userInfo?[Account.UserInfoKey.account] as? Account, - let node = self.treeController.rootNode.childNodeRepresentingObject(account) { - node.isExpanded = true - } + let expandNewAccount = { + if let account = note.userInfo?[Account.UserInfoKey.account] as? Account, + let node = self.treeController.rootNode.childNodeRepresentingObject(account) { + self.markExpanded(node) } } if timelineFetcherContainsAnyPseudoFeed() { - fetchAndReplaceArticlesAsync { - rebuildAndExpand() + fetchAndReplaceArticlesAsync(animated: true) { + self.masterTimelineViewController?.reinitializeArticles() + self.rebuildBackingStores() { + expandNewAccount() + } } } else { - rebuildAndExpand() + rebuildBackingStores() { + 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() { - fetchAndReplaceArticlesAsync { - self.rebuildBackingStores() + fetchAndReplaceArticlesAsync(animated: true) { + self.masterTimelineViewController?.reinitializeArticles() + self.rebuildBackingStores() { + cleanupAccount() + } } } else { - rebuildBackingStores() + rebuildBackingStores() { + cleanupAccount() + } } } @@ -473,9 +489,54 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { } return 0 } + + func refreshTimeline() { + fetchAndReplaceArticlesAsync(animated: true) { + self.masterTimelineViewController?.reinitializeArticles() + } + } + + func showAllFeeds() { + treeControllerDelegate.isReadFiltered = false + rebuildBackingStores() + } + + func hideUnreadFeeds() { + treeControllerDelegate.isReadFiltered = true + rebuildBackingStores() + } + + func showAllArticles() { + articleReadFilterType = .none + refreshTimeline() + } + + func hideUnreadArticles() { + articleReadFilterType = .read + refreshTimeline() + } + + func markExpanded(_ node: Node) { + if let containerID = (node.representedObject as? ContainerIdentifiable)?.containerID { + expandedTable.insert(containerID) + } + } + + func unmarkExpanded(_ node: Node) { + if let containerID = (node.representedObject as? ContainerIdentifiable)?.containerID { + expandedTable.remove(containerID) + } + } + + func isExpanded(_ node: Node) -> Bool { + if let containerID = (node.representedObject as? ContainerIdentifiable)?.containerID { + return expandedTable.contains(containerID) + } + return false + } func expand(_ node: Node) { - node.isExpanded = true + markExpanded(node) animatingChanges = true rebuildShadowTable() animatingChanges = false @@ -483,10 +544,10 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { func expandAllSectionsAndFolders() { for sectionNode in treeController.rootNode.childNodes { - sectionNode.isExpanded = true + markExpanded(sectionNode) for topLevelNode in sectionNode.childNodes { if topLevelNode.representedObject is Folder { - topLevelNode.isExpanded = true + markExpanded(topLevelNode) } } } @@ -496,7 +557,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { } func collapse(_ node: Node) { - node.isExpanded = false + unmarkExpanded(node) animatingChanges = true rebuildShadowTable() animatingChanges = false @@ -504,10 +565,10 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { func collapseAllFolders() { for sectionNode in treeController.rootNode.childNodes { - sectionNode.isExpanded = true + unmarkExpanded(sectionNode) for topLevelNode in sectionNode.childNodes { if topLevelNode.representedObject is Folder { - topLevelNode.isExpanded = true + unmarkExpanded(topLevelNode) } } } @@ -523,7 +584,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { return indexPathFor(node) } - func selectFeed(_ indexPath: IndexPath?, animated: Bool, completion: (() -> Void)? = nil) { + func selectFeed(_ indexPath: IndexPath?, animated: Bool, deselectArticle: Bool = true, completion: (() -> Void)? = nil) { guard indexPath != currentFeedIndexPath else { completion?() return @@ -533,19 +594,21 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { masterFeedViewController.updateFeedSelection(animated: animated) emptyTheTimeline() - selectArticle(nil) + if deselectArticle { + selectArticle(nil) + } if let ip = indexPath, let node = nodeFor(ip), let feed = node.representedObject as? Feed { self.activityManager.selecting(feed: feed) self.installTimelineControllerIfNecessary(animated: animated) - setTimelineFeed(feed) { + setTimelineFeed(feed, animated: false) { completion?() } } else { - setTimelineFeed(nil) { + setTimelineFeed(nil, animated: false) { self.activityManager.invalidateSelecting() if self.rootSplitViewController.isCollapsed && self.navControllerForTimeline().viewControllers.last is MasterTimelineViewController { self.navControllerForTimeline().popViewController(animated: animated) @@ -608,9 +671,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { let currentArticleViewController: ArticleViewController if articleViewController == nil { - currentArticleViewController = UIStoryboard.main.instantiateController(ofType: ArticleViewController.self) - currentArticleViewController.coordinator = self - installArticleController(currentArticleViewController, animated: animated) + currentArticleViewController = installArticleController(animated: animated) } else { currentArticleViewController = articleViewController! } @@ -632,7 +693,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { isSearching = true savedSearchArticles = articles savedSearchArticleIds = Set(articles.map { $0.articleID }) - setTimelineFeed(nil) + setTimelineFeed(nil, animated: true) selectArticle(nil) } @@ -640,9 +701,9 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { if let ip = currentFeedIndexPath, let node = nodeFor(ip), let feed = node.representedObject as? Feed { timelineFeed = feed masterTimelineViewController?.reinitializeArticles() - replaceArticles(with: savedSearchArticles!, animate: true) + replaceArticles(with: savedSearchArticles!, animated: true) } else { - setTimelineFeed(nil) + setTimelineFeed(nil, animated: true) } lastSearchString = "" @@ -658,7 +719,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { guard isSearching else { return } if searchString.count < 3 { - setTimelineFeed(nil) + setTimelineFeed(nil, animated: true) return } @@ -666,9 +727,9 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { switch searchScope { case .global: - setTimelineFeed(SmartFeed(delegate: SearchFeedDelegate(searchString: searchString))) + setTimelineFeed(SmartFeed(delegate: SearchFeedDelegate(searchString: searchString)), animated: true) case .timeline: - setTimelineFeed(SmartFeed(delegate: SearchTimelineFeedDelegate(searchString: searchString, articleIDs: savedSearchArticleIds!))) + setTimelineFeed(SmartFeed(delegate: SearchTimelineFeedDelegate(searchString: searchString, articleIDs: savedSearchArticleIds!)), animated: true) } lastSearchString = searchString @@ -724,9 +785,10 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { return } - selectNextUnreadFeedFetcher() - if selectNextUnreadArticleInTimeline() { - activityManager.selectingNextUnread() + selectNextUnreadFeed() { + if self.selectNextUnreadArticleInTimeline() { + self.activityManager.selectingNextUnread() + } } } @@ -963,15 +1025,36 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { extension SceneCoordinator: UISplitViewControllerDelegate { func splitViewController(_ splitViewController: UISplitViewController, collapseSecondary secondaryViewController:UIViewController, onto primaryViewController:UIViewController) -> Bool { + guard !isThreePanelMode else { + return true + } + + if let articleViewController = (secondaryViewController as? UINavigationController)?.topViewController as? ArticleViewController { + masterNavigationController.pushViewController(articleViewController, animated: false) + return false + } + return currentArticle == nil } func splitViewController(_ splitViewController: UISplitViewController, separateSecondaryFrom primaryViewController: UIViewController) -> UIViewController? { - if currentArticle == nil { + guard !isThreePanelMode else { + return subSplitViewController + } + + guard currentArticle != nil else { let articleViewController = UIStoryboard.main.instantiateController(ofType: ArticleViewController.self) articleViewController.coordinator = self - return articleViewController + let controller = addNavControllerIfNecessary(articleViewController, showButton: true) + return controller } + + if let articleViewController = masterNavigationController.viewControllers.last as? ArticleViewController { + masterNavigationController.popViewController(animated: false) + let controller = addNavControllerIfNecessary(articleViewController, showButton: true) + return controller + } + return nil } @@ -986,7 +1069,6 @@ extension SceneCoordinator: UINavigationControllerDelegate { if UIApplication.shared.applicationState == .background { return } - // If we are showing the Feeds and only the feeds start clearing stuff if viewController === masterFeedViewController && !isThreePanelMode && !isTimelineViewControllerPending { @@ -1055,7 +1137,7 @@ private extension SceneCoordinator { } unreadCount = count } - + func rebuildBackingStores(_ updateExpandedNodes: (() -> Void)? = nil) { if !animatingChanges && !BatchUpdate.shared.isPerforming { treeController.rebuild() @@ -1073,10 +1155,10 @@ private extension SceneCoordinator { var result = [Node]() let sectionNode = treeController.rootNode.childAtIndex(i)! - if sectionNode.isExpanded { + if isExpanded(sectionNode) { for node in sectionNode.childNodes { result.append(node) - if node.isExpanded { + if isExpanded(node) { for child in node.childNodes { result.append(child) } @@ -1112,11 +1194,12 @@ private extension SceneCoordinator { return indexPathFor(node) } - func setTimelineFeed(_ feed: Feed?, completion: (() -> Void)? = nil) { + func setTimelineFeed(_ feed: Feed?, animated: Bool, completion: (() -> Void)? = nil) { timelineFeed = feed timelineMiddleIndexPath = nil + articleReadFilterType = feed?.defaultReadFilterType ?? .none - fetchAndReplaceArticlesAsync { + fetchAndReplaceArticlesAsync(animated: animated) { self.masterTimelineViewController?.reinitializeArticles() completion?() } @@ -1234,7 +1317,7 @@ private extension SceneCoordinator { return true } - if node.isExpanded { + if isExpanded(node) { continue } @@ -1289,7 +1372,7 @@ private extension SceneCoordinator { } - func selectNextUnreadFeedFetcher() { + func selectNextUnreadFeed(completion: @escaping () -> Void) { let indexPath: IndexPath = { if currentFeedIndexPath == nil { @@ -1312,15 +1395,19 @@ private extension SceneCoordinator { } }() - if selectNextUnreadFeedFetcher(startingWith: nextIndexPath) { - return + selectNextUnreadFeed(startingWith: nextIndexPath) { found in + if !found { + self.selectNextUnreadFeed(startingWith: IndexPath(row: 0, section: 0)) { _ in + completion() + } + } else { + completion() + } } - selectNextUnreadFeedFetcher(startingWith: IndexPath(row: 0, section: 0)) } - @discardableResult - func selectNextUnreadFeedFetcher(startingWith indexPath: IndexPath) -> Bool { + func selectNextUnreadFeed(startingWith indexPath: IndexPath, completion: @escaping (Bool) -> Void) { for i in indexPath.section.. 0 { - selectFeed(nextIndexPath, animated: true) - return true + selectFeed(nextIndexPath, animated: false, deselectArticle: false) { + self.currentArticle = nil + completion(true) + } + return } } } - return false + completion(false) } @@ -1377,25 +1468,25 @@ private extension SceneCoordinator { func emptyTheTimeline() { if !articles.isEmpty { - replaceArticles(with: Set
(), animate: false) + replaceArticles(with: Set
(), animated: false) } } func sortParametersDidChange() { - replaceArticles(with: Set(articles), animate: true) + replaceArticles(with: Set(articles), animated: true) } - func replaceArticles(with unsortedArticles: Set
, animate: Bool) { + func replaceArticles(with unsortedArticles: Set
, animated: Bool) { let sortedArticles = Array(unsortedArticles).sortedByDate(sortDirection, groupByFeed: groupByFeed) - replaceArticles(with: sortedArticles, animate: animate) + replaceArticles(with: sortedArticles, animated: animated) } - func replaceArticles(with sortedArticles: ArticleArray, animate: Bool) { + func replaceArticles(with sortedArticles: ArticleArray, animated: Bool) { if articles != sortedArticles { articles = sortedArticles updateShowNamesAndIcons() updateUnreadCount() - masterTimelineViewController?.reloadArticles(animated: animate) + masterTimelineViewController?.reloadArticles(animated: animated) } } @@ -1422,7 +1513,7 @@ private extension SceneCoordinator { } } - strongSelf.replaceArticles(with: updatedArticles, animate: true) + strongSelf.replaceArticles(with: updatedArticles, animated: true) } } @@ -1432,7 +1523,7 @@ private extension SceneCoordinator { fetchRequestQueue.cancelAllRequests() } - func fetchAndReplaceArticlesAsync(completion: @escaping () -> Void) { + 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() @@ -1443,7 +1534,7 @@ private extension SceneCoordinator { } fetchUnsortedArticlesAsync(for: [timelineFetcher]) { [weak self] (articles) in - self?.replaceArticles(with: articles, animate: true) + self?.replaceArticles(with: articles, animated: animated) completion() } @@ -1455,10 +1546,10 @@ private extension SceneCoordinator { precondition(Thread.isMainThread) cancelPendingAsyncFetches() - let fetchOperation = FetchRequestOperation(id: fetchSerialNumber, representedObjects: representedObjects) { [weak self] (articles, operation) in + let readFilter = articleReadFilterType != .none + let fetchOperation = FetchRequestOperation(id: fetchSerialNumber, readFilter: readFilter, representedObjects: representedObjects) { [weak self] (articles, operation) in precondition(Thread.isMainThread) guard !operation.isCanceled, let strongSelf = self, operation.id == strongSelf.fetchSerialNumber else { - callback(Set
()) return } callback(articles) @@ -1507,37 +1598,50 @@ private extension SceneCoordinator { func installTimelineControllerIfNecessary(animated: Bool) { if navControllerForTimeline().viewControllers.filter({ $0 is MasterTimelineViewController }).count < 1 { - isTimelineViewControllerPending = true - masterTimelineViewController = UIStoryboard.main.instantiateController(ofType: MasterTimelineViewController.self) masterTimelineViewController!.coordinator = self navControllerForTimeline().pushViewController(masterTimelineViewController!, animated: animated) - - masterTimelineViewController?.reloadArticles(animated: false) } } - func installArticleController(_ articleController: UIViewController, animated: Bool) { + @discardableResult + func installArticleController(_ recycledArticleController: ArticleViewController? = nil, animated: Bool) -> ArticleViewController { isArticleViewControllerPending = true + let articleController: ArticleViewController = { + if let controller = recycledArticleController { + return controller + } else { + let controller = UIStoryboard.main.instantiateController(ofType: ArticleViewController.self) + controller.coordinator = self + return controller + } + }() + if let subSplit = subSplitViewController { let controller = addNavControllerIfNecessary(articleController, showButton: false) subSplit.showDetailViewController(controller, sender: self) - } else if rootSplitViewController.isCollapsed { - let controller = addNavControllerIfNecessary(articleController, showButton: false) - masterNavigationController.pushViewController(controller, animated: animated) + } else if rootSplitViewController.isCollapsed || wasRootSplitViewControllerCollapsed { + masterNavigationController.pushViewController(articleController, animated: animated) } else { let controller = addNavControllerIfNecessary(articleController, showButton: true) rootSplitViewController.showDetailViewController(controller, sender: self) } + // We have to do a full reload when installing an article controller. We may have changed color contexts + // and need to update the article colors. An example is in dark mode. Split screen doesn't use true black + // like darkmode usually does. + articleController.fullReload() + return articleController + } func addNavControllerIfNecessary(_ controller: UIViewController, showButton: Bool) -> UIViewController { - if rootSplitViewController.traitCollection.horizontalSizeClass == .compact { + // You will sometimes get a compact horizontal size class while in three panel mode. Dunno why it lies. + if rootSplitViewController.traitCollection.horizontalSizeClass == .compact && !isThreePanelMode { return controller @@ -1560,14 +1664,16 @@ private extension SceneCoordinator { } - func configureDoubleSplit() { + func installSubSplit() { rootSplitViewController.preferredPrimaryColumnWidthFraction = 0.30 - let subSplit = UISplitViewController.template() - subSplit.preferredDisplayMode = .allVisible - subSplit.preferredPrimaryColumnWidthFraction = 0.4285 + subSplitViewController = UISplitViewController() + subSplitViewController!.preferredDisplayMode = .allVisible + subSplitViewController!.viewControllers = [InteractiveNavigationController.template()] + subSplitViewController!.preferredPrimaryColumnWidthFraction = 0.4285 - rootSplitViewController.showDetailViewController(subSplit, sender: self) + rootSplitViewController.showDetailViewController(subSplitViewController!, sender: self) + rootSplitViewController.setOverrideTraitCollection(UITraitCollection(horizontalSizeClass: .regular), forChild: subSplitViewController!) } func navControllerForTimeline() -> UINavigationController { @@ -1578,67 +1684,50 @@ private extension SceneCoordinator { } } - @discardableResult - func transitionToThreePanelMode() -> UIViewController { - + func configureThreePanelMode() { + let recycledArticleController = articleViewController defer { masterNavigationController.viewControllers = [masterFeedViewController] } - let controller: UIViewController = { - if let result = articleViewController { - return result - } else { - let articleViewController = UIStoryboard.main.instantiateController(ofType: ArticleViewController.self) - articleViewController.coordinator = self - return articleViewController - } - }() - configureDoubleSplit() + if rootSplitViewController.viewControllers.last is InteractiveNavigationController { + _ = rootSplitViewController.viewControllers.popLast() + } + + installSubSplit() installTimelineControllerIfNecessary(animated: false) masterTimelineViewController?.navigationItem.leftBarButtonItem = rootSplitViewController.displayModeButtonItem masterTimelineViewController?.navigationItem.leftItemsSupplementBackButton = true - // Create the new sub split controller and add the timeline in the primary position - let masterTimelineNavController = subSplitViewController!.viewControllers.first as! UINavigationController - masterTimelineNavController.viewControllers = [masterTimelineViewController!] - - // Put the detail or no selection controller in the secondary (or detail) position of the sub split - let navController = addNavControllerIfNecessary(controller, showButton: false) - subSplitViewController!.showDetailViewController(navController, sender: self) + installArticleController(recycledArticleController, animated: false) masterFeedViewController.restoreSelectionIfNecessary(adjustScroll: true) masterTimelineViewController!.restoreSelectionIfNecessary(adjustScroll: false) - - // We made sure this was there above when we called configureDoubleSplit - return subSplitViewController! - } - func transitionFromThreePanelMode() { - + func configureStandardPanelMode() { + let recycledArticleController = articleViewController rootSplitViewController.preferredPrimaryColumnWidthFraction = UISplitViewController.automaticDimension - if let subSplit = rootSplitViewController.viewControllers.last as? UISplitViewController { - - // Push a new timeline on to the master navigation controller. For some reason recycling the timeline can freak - // the system out and throw it into an infinite loop. - if currentFeedIndexPath != nil { - masterTimelineViewController = UIStoryboard.main.instantiateController(ofType: MasterTimelineViewController.self) - masterTimelineViewController!.coordinator = self - masterNavigationController.pushViewController(masterTimelineViewController!, animated: false) - } - - // Pull the detail or no selection controller out of the sub split second position and move it to the root split controller - // secondary (detail) position. - if let detailNav = subSplit.viewControllers.last as? UINavigationController, let topController = detailNav.topViewController { - let newNav = addNavControllerIfNecessary(topController, showButton: true) - rootSplitViewController.showDetailViewController(newNav, sender: self) - } + // Set the is Pending flags early to prevent the navigation controller delegate from thinking that we + // swiping around in the user interface + isTimelineViewControllerPending = true + isArticleViewControllerPending = true + masterNavigationController.viewControllers = [masterFeedViewController] + if rootSplitViewController.viewControllers.last is UISplitViewController { + subSplitViewController = nil + _ = rootSplitViewController.viewControllers.popLast() } - + + if currentFeedIndexPath != nil { + masterTimelineViewController = UIStoryboard.main.instantiateController(ofType: MasterTimelineViewController.self) + masterTimelineViewController!.coordinator = self + masterNavigationController.pushViewController(masterTimelineViewController!, animated: false) + } + + installArticleController(recycledArticleController, animated: false) } // MARK: NSUserActivity @@ -1646,14 +1735,14 @@ private extension SceneCoordinator { func handleSelectFeed(_ userInfo: [AnyHashable : Any]?) { guard let userInfo = userInfo, let feedIdentifierUserInfo = userInfo[UserInfoKey.feedIdentifier] as? [AnyHashable : Any], - let articleFetcherType = FeedIdentifier(userInfo: feedIdentifierUserInfo) else { + let feedIdentifier = FeedIdentifier(userInfo: feedIdentifierUserInfo) else { return } - switch articleFetcherType { + switch feedIdentifier { - case .smartFeed(let identifier): - guard let smartFeed = SmartFeedsController.shared.find(by: identifier) else { return } + case .smartFeed: + guard let smartFeed = SmartFeedsController.shared.find(by: feedIdentifier) else { return } if let indexPath = indexPathFor(smartFeed) { selectFeed(indexPath, animated: false) } @@ -1706,14 +1795,14 @@ private extension SceneCoordinator { func restoreFeed(_ userInfo: [AnyHashable : Any], accountID: String, webFeedID: String, articleID: String) -> Bool { guard let feedIdentifierUserInfo = userInfo[UserInfoKey.feedIdentifier] as? [AnyHashable : Any], - let articleFetcherType = FeedIdentifier(userInfo: feedIdentifierUserInfo) else { + let feedIdentifier = FeedIdentifier(userInfo: feedIdentifierUserInfo) else { return false } - switch articleFetcherType { + switch feedIdentifier { - case .smartFeed(let identifier): - guard let smartFeed = SmartFeedsController.shared.find(by: identifier) else { return false } + case .smartFeed: + guard let smartFeed = SmartFeedsController.shared.find(by: feedIdentifier) else { return false } if smartFeed.fetchArticles().contains(accountID: accountID, articleID: articleID) { if let indexPath = indexPathFor(smartFeed) { selectFeed(indexPath, animated: false) { diff --git a/iOS/SceneDelegate.swift b/iOS/SceneDelegate.swift index 05df42d02..0a8868d1a 100644 --- a/iOS/SceneDelegate.swift +++ b/iOS/SceneDelegate.swift @@ -58,7 +58,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate { func sceneWillEnterForeground(_ scene: UIScene) { appDelegate.prepareAccountsForForeground() - self.coordinator.configureThreePanelMode(for: window!.frame.size) + self.coordinator.configurePanelMode(for: window!.frame.size) } func stateRestorationActivity(for scene: UIScene) -> NSUserActivity? { diff --git a/iOS/Settings/Settings.storyboard b/iOS/Settings/Settings.storyboard index bcb9ff294..c81c9ed12 100644 --- a/iOS/Settings/Settings.storyboard +++ b/iOS/Settings/Settings.storyboard @@ -19,7 +19,7 @@ - + @@ -184,7 +184,7 @@ - - + - + - - + + - + + + + + + + + + + + + + + + + + + + + + - + @@ -233,7 +267,7 @@ - + @@ -250,7 +284,7 @@ - + @@ -267,7 +301,7 @@ - + @@ -284,7 +318,7 @@ - + @@ -300,6 +334,23 @@ + + + + + + + + + + + @@ -317,6 +368,7 @@ + diff --git a/iOS/Settings/SettingsViewController.swift b/iOS/Settings/SettingsViewController.swift index ec690552a..cde10c3a2 100644 --- a/iOS/Settings/SettingsViewController.swift +++ b/iOS/Settings/SettingsViewController.swift @@ -17,6 +17,7 @@ class SettingsViewController: UITableViewController { @IBOutlet weak var timelineSortOrderSwitch: UISwitch! @IBOutlet weak var groupByFeedSwitch: UISwitch! + @IBOutlet weak var showFullscreenArticlesSwitch: UISwitch! weak var presentingParentController: UIViewController? @@ -50,10 +51,16 @@ class SettingsViewController: UITableViewController { groupByFeedSwitch.isOn = false } + if AppDefaults.articleFullscreenEnabled { + showFullscreenArticlesSwitch.isOn = true + } else { + showFullscreenArticlesSwitch.isOn = false + } + let buildLabel = NonIntrinsicLabel(frame: CGRect(x: 20.0, y: 0.0, width: 0.0, height: 0.0)) buildLabel.font = UIFont.systemFont(ofSize: 11.0) buildLabel.textColor = UIColor.gray - buildLabel.text = "\(Bundle.main.appName) v \(Bundle.main.versionNumber) (Build \(Bundle.main.buildNumber))" + buildLabel.text = "\(Bundle.main.appName) \(Bundle.main.versionNumber) (Build \(Bundle.main.buildNumber))" buildLabel.sizeToFit() buildLabel.translatesAutoresizingMaskIntoConstraints = false @@ -71,24 +78,42 @@ class SettingsViewController: UITableViewController { // MARK: UITableView + override func numberOfSections(in tableView: UITableView) -> Int { + var sections = super.numberOfSections(in: tableView) + if traitCollection.userInterfaceIdiom != .phone { + sections = sections - 1 + } + return sections + } + override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { - switch section { + var adjustedSection = section + if traitCollection.userInterfaceIdiom != .phone && section > 3 { + adjustedSection = adjustedSection + 1 + } + + switch adjustedSection { case 1: return AccountManager.shared.accounts.count + 1 case 2: - let defaultNumberOfRows = super.tableView(tableView, numberOfRowsInSection: section) + let defaultNumberOfRows = super.tableView(tableView, numberOfRowsInSection: adjustedSection) if AccountManager.shared.activeAccounts.isEmpty || AccountManager.shared.anyAccountHasFeedWithURL(appNewsURLString) { return defaultNumberOfRows - 1 } return defaultNumberOfRows default: - return super.tableView(tableView, numberOfRowsInSection: section) + return super.tableView(tableView, numberOfRowsInSection: adjustedSection) } } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + var adjustedSection = indexPath.section + if traitCollection.userInterfaceIdiom != .phone && adjustedSection > 3 { + adjustedSection = adjustedSection + 1 + } + let cell: UITableViewCell - switch indexPath.section { + switch adjustedSection { case 1: let sortedAccounts = AccountManager.shared.sortedAccounts @@ -105,8 +130,8 @@ class SettingsViewController: UITableViewController { } default: - - cell = super.tableView(tableView, cellForRowAt: indexPath) + let adjustedIndexPath = IndexPath(row: indexPath.row, section: adjustedSection) + cell = super.tableView(tableView, cellForRowAt: adjustedIndexPath) } @@ -114,7 +139,12 @@ class SettingsViewController: UITableViewController { } override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { - switch indexPath.section { + var adjustedSection = indexPath.section + if traitCollection.userInterfaceIdiom != .phone && adjustedSection > 3 { + adjustedSection = adjustedSection + 1 + } + + switch adjustedSection { case 0: UIApplication.shared.open(URL(string: "\(UIApplication.openSettingsURLString)")!) tableView.selectRow(at: nil, animated: true, scrollPosition: .none) @@ -156,11 +186,11 @@ class SettingsViewController: UITableViewController { default: break } - case 4: + case 5: switch indexPath.row { case 0: - let timeline = UIStoryboard.settings.instantiateController(ofType: AboutViewController.self) - self.navigationController?.pushViewController(timeline, animated: true) + openURL("https://ranchero.com/netnewswire/help/ios/5.0/en/") + tableView.selectRow(at: nil, animated: true, scrollPosition: .none) case 1: openURL("https://ranchero.com/netnewswire/") tableView.selectRow(at: nil, animated: true, scrollPosition: .none) @@ -176,6 +206,9 @@ class SettingsViewController: UITableViewController { case 5: openURL("https://github.com/brentsimmons/NetNewsWire/tree/master/Technotes") tableView.selectRow(at: nil, animated: true, scrollPosition: .none) + case 6: + let timeline = UIStoryboard.settings.instantiateController(ofType: AboutViewController.self) + self.navigationController?.pushViewController(timeline, animated: true) default: break } @@ -197,19 +230,11 @@ class SettingsViewController: UITableViewController { } override func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { - if indexPath.section == 1 { - return super.tableView(tableView, heightForRowAt: IndexPath(row: 0, section: 1)) - } else { - return super.tableView(tableView, heightForRowAt: indexPath) - } + return super.tableView(tableView, heightForRowAt: IndexPath(row: 0, section: 1)) } override func tableView(_ tableView: UITableView, indentationLevelForRowAt indexPath: IndexPath) -> Int { - if indexPath.section == 1 { - return super.tableView(tableView, indentationLevelForRowAt: IndexPath(row: 0, section: 1)) - } else { - return super.tableView(tableView, indentationLevelForRowAt: indexPath) - } + return super.tableView(tableView, indentationLevelForRowAt: IndexPath(row: 0, section: 1)) } // MARK: Actions @@ -234,6 +259,14 @@ class SettingsViewController: UITableViewController { } } + @IBAction func switchFullscreenArticles(_ sender: Any) { + if showFullscreenArticlesSwitch.isOn { + AppDefaults.articleFullscreenEnabled = true + } else { + AppDefaults.articleFullscreenEnabled = false + } + } + // MARK: Notifications @objc func contentSizeCategoryDidChange() { diff --git a/iOS/UIKit Extensions/CroppingPreviewParameters.swift b/iOS/UIKit Extensions/CroppingPreviewParameters.swift new file mode 100644 index 000000000..58a19dfbf --- /dev/null +++ b/iOS/UIKit Extensions/CroppingPreviewParameters.swift @@ -0,0 +1,20 @@ +// +// CroppingPreviewParameters.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 11/23/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import UIKit + +class CroppingPreviewParameters: UIPreviewParameters { + + init(view: UIView) { + super.init() + let newBounds = CGRect(x: 1, y: 1, width: view.bounds.width - 2, height: view.bounds.height - 2) + let visiblePath = UIBezierPath(roundedRect: newBounds, cornerRadius: 10) + self.visiblePath = visiblePath + } + +} diff --git a/iOS/UIKit Extensions/ImageHeaderView.swift b/iOS/UIKit Extensions/ImageHeaderView.swift index 3c26eda89..2e8a25420 100644 --- a/iOS/UIKit Extensions/ImageHeaderView.swift +++ b/iOS/UIKit Extensions/ImageHeaderView.swift @@ -10,6 +10,8 @@ import UIKit class ImageHeaderView: UITableViewHeaderFooterView { + static let rowHeight = CGFloat(integerLiteral: 88) + var imageView = UIImageView() override init(reuseIdentifier: String?) { diff --git a/iOS/UIKit Extensions/TickMarkSlider.swift b/iOS/UIKit Extensions/TickMarkSlider.swift index 9c111c738..a6b8f7b2c 100644 --- a/iOS/UIKit Extensions/TickMarkSlider.swift +++ b/iOS/UIKit Extensions/TickMarkSlider.swift @@ -13,9 +13,12 @@ class TickMarkSlider: UISlider { private var enableFeedback = false private let feedbackGenerator = UISelectionFeedbackGenerator() + private var roundedValue: Float? override var value: Float { didSet { - if enableFeedback && value.truncatingRemainder(dividingBy: 1) == 0 { + let testValue = value.rounded() + if testValue != roundedValue && enableFeedback && value.truncatingRemainder(dividingBy: 1) == 0 { + roundedValue = testValue feedbackGenerator.selectionChanged() } } @@ -66,6 +69,12 @@ class TickMarkSlider: UISlider { } } + + override func continueTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { + let result = super.continueTracking(touch, with: event) + value = value.rounded() + return result + } override func endTracking(_ touch: UITouch?, with event: UIEvent?) { value = value.rounded() diff --git a/iOS/UIKit Extensions/UISplitViewController-Extensions.swift b/iOS/UIKit Extensions/UISplitViewController-Extensions.swift deleted file mode 100644 index 63e9acc18..000000000 --- a/iOS/UIKit Extensions/UISplitViewController-Extensions.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// UISplitViewController-Extensions.swift -// NetNewsWire -// -// Created by Maurice Parker on 4/18/19. -// Copyright © 2019 Ranchero Software. All rights reserved. -// - -import UIKit - -extension UISplitViewController { - - static func template() -> UISplitViewController { - let splitViewController = UISplitViewController() - splitViewController.preferredDisplayMode = .automatic - splitViewController.viewControllers = [InteractiveNavigationController.template()] - return splitViewController - } - -} diff --git a/submodules/RSTree b/submodules/RSTree index 3dc1c288b..2fc9b9cff 160000 --- a/submodules/RSTree +++ b/submodules/RSTree @@ -1 +1 @@ -Subproject commit 3dc1c288bb4e15fedf17fa8fc43c1d5cec36af5e +Subproject commit 2fc9b9cff60032a272303ff6d6df5b39ec297179