diff --git a/Mac/AppDefaults.swift b/Mac/AppDefaults.swift index f4180440b..9afd69ecb 100644 --- a/Mac/AppDefaults.swift +++ b/Mac/AppDefaults.swift @@ -22,6 +22,7 @@ struct AppDefaults { static let sidebarFontSize = "sidebarFontSize" static let timelineFontSize = "timelineFontSize" static let timelineSortDirection = "timelineSortDirection" + static let timelineGroupByFeed = "timelineGroupByFeed" static let detailFontSize = "detailFontSize" static let openInBrowserInBackground = "openInBrowserInBackground" static let mainWindowWidths = "mainWindowWidths" @@ -137,6 +138,15 @@ struct AppDefaults { } } + static var timelineGroupByFeed: Bool { + get { + return bool(for: Key.timelineGroupByFeed) + } + set { + setBool(for: Key.timelineGroupByFeed, newValue) + } + } + static var timelineShowsSeparators: Bool { return bool(for: Key.timelineShowsSeparators) } @@ -161,7 +171,13 @@ struct AppDefaults { } static func registerDefaults() { - let defaults: [String : Any] = [Key.sidebarFontSize: FontSize.medium.rawValue, Key.timelineFontSize: FontSize.medium.rawValue, Key.detailFontSize: FontSize.medium.rawValue, Key.timelineSortDirection: ComparisonResult.orderedDescending.rawValue, "NSScrollViewShouldScrollUnderTitlebar": false, Key.refreshInterval: RefreshInterval.everyHour.rawValue] + let defaults: [String : Any] = [Key.sidebarFontSize: FontSize.medium.rawValue, + Key.timelineFontSize: FontSize.medium.rawValue, + Key.detailFontSize: FontSize.medium.rawValue, + Key.timelineSortDirection: ComparisonResult.orderedDescending.rawValue, + Key.timelineGroupByFeed: false, + "NSScrollViewShouldScrollUnderTitlebar": false, + Key.refreshInterval: RefreshInterval.everyHour.rawValue] UserDefaults.standard.register(defaults: defaults) diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index a0a34dba3..d5a2565a0 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -41,6 +41,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, @IBOutlet var debugMenuItem: NSMenuItem! @IBOutlet var sortByOldestArticleOnTopMenuItem: NSMenuItem! @IBOutlet var sortByNewestArticleOnTopMenuItem: NSMenuItem! + @IBOutlet var groupArticlesByFeedMenuItem: NSMenuItem! @IBOutlet var checkForUpdatesMenuItem: NSMenuItem! var unreadCount = 0 { @@ -148,6 +149,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, feedIconDownloader = FeedIconDownloader(imageDownloader: imageDownloader, folder: cacheFolder) updateSortMenuItems() + updateGroupByFeedMenuItem() createAndShowMainWindow() if isFirstRun { mainWindowController?.window?.center() @@ -259,6 +261,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, @objc func userDefaultsDidChange(_ note: Notification) { updateSortMenuItems() + updateGroupByFeedMenuItem() refreshTimer?.update() updateDockBadge() } @@ -509,6 +512,11 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, AppDefaults.timelineSortDirection = .orderedDescending } + + @IBAction func groupByFeedToggled(_ sender: NSMenuItem) { + AppDefaults.timelineGroupByFeed.toggle() + } + } // MARK: - Debug Menu @@ -546,6 +554,11 @@ private extension AppDelegate { sortByNewestArticleOnTopMenuItem.state = sortByNewestOnTop ? .on : .off sortByOldestArticleOnTopMenuItem.state = sortByNewestOnTop ? .off : .on } + + func updateGroupByFeedMenuItem() { + let groupByFeedEnabled = AppDefaults.timelineGroupByFeed + groupArticlesByFeedMenuItem.state = groupByFeedEnabled ? .on : .off + } } /* diff --git a/Mac/Base.lproj/Main.storyboard b/Mac/Base.lproj/Main.storyboard index 8d270183a..13c78b0d4 100644 --- a/Mac/Base.lproj/Main.storyboard +++ b/Mac/Base.lproj/Main.storyboard @@ -1,7 +1,8 @@ - + - + + @@ -330,9 +331,9 @@ - + - + @@ -349,6 +350,12 @@ + + + + + + @@ -581,6 +588,7 @@ + diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift index 8db10b216..16a694d09 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController.swift @@ -126,7 +126,14 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr private var sortDirection = AppDefaults.timelineSortDirection { didSet { if sortDirection != oldValue { - sortDirectionDidChange() + sortParametersDidChange() + } + } + } + private var groupByFeed = AppDefaults.timelineGroupByFeed { + didSet { + if groupByFeed != oldValue { + sortParametersDidChange() } } } @@ -555,6 +562,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr self.fontSize = AppDefaults.timelineFontSize self.sortDirection = AppDefaults.timelineSortDirection + self.groupByFeed = AppDefaults.timelineGroupByFeed } @objc func appleInterfaceThemeChanged(_ note: Notification) { @@ -876,14 +884,13 @@ private extension TimelineViewController { } } - func sortDirectionDidChange() { - + func sortParametersDidChange() { performBlockAndRestoreSelection { let unsortedArticles = Set(articles) replaceArticles(with: unsortedArticles) } } - + func selectedArticleIDs() -> [String] { return selectedArticles.articleIDs() @@ -980,7 +987,7 @@ private extension TimelineViewController { } func replaceArticles(with unsortedArticles: Set
) { - articles = Array(unsortedArticles).sortedByDate(sortDirection) + articles = Array(unsortedArticles).sortedByDate(sortDirection, groupByFeed: groupByFeed) } func fetchUnsortedArticlesSync(for representedObjects: [Any]) -> Set
{ diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 0e225a244..0f6df4fbc 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -366,6 +366,9 @@ D5F4EDB920074D7C00B9E363 /* Folder+Scriptability.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5F4EDB820074D7C00B9E363 /* Folder+Scriptability.swift */; }; DD82AB0A231003F6002269DF /* SharingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DD82AB09231003F6002269DF /* SharingTests.swift */; }; DF999FF722B5AEFA0064B687 /* SafariView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF999FF622B5AEFA0064B687 /* SafariView.swift */; }; + FF3ABF13232599810074C542 /* ArticleSorterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF3ABF09232599450074C542 /* ArticleSorterTests.swift */; }; + FF3ABF1523259DDB0074C542 /* ArticleSorter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF3ABF1423259DDB0074C542 /* ArticleSorter.swift */; }; + FF3ABF162325AF5D0074C542 /* ArticleSorter.swift in Sources */ = {isa = PBXBuildFile; fileRef = FF3ABF1423259DDB0074C542 /* ArticleSorter.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -1046,6 +1049,8 @@ D5F4EDB820074D7C00B9E363 /* Folder+Scriptability.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Folder+Scriptability.swift"; sourceTree = ""; }; DD82AB09231003F6002269DF /* SharingTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SharingTests.swift; sourceTree = ""; }; DF999FF622B5AEFA0064B687 /* SafariView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SafariView.swift; sourceTree = ""; }; + FF3ABF09232599450074C542 /* ArticleSorterTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleSorterTests.swift; sourceTree = ""; }; + FF3ABF1423259DDB0074C542 /* ArticleSorter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleSorter.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1354,8 +1359,9 @@ isa = PBXGroup; children = ( 84F204DF1FAACBB30076E152 /* ArticleArray.swift */, - 84CAFCA322BC8C08007694F0 /* FetchRequestQueue.swift */, + FF3ABF1423259DDB0074C542 /* ArticleSorter.swift */, 84CAFCAE22BC8C35007694F0 /* FetchRequestOperation.swift */, + 84CAFCA322BC8C08007694F0 /* FetchRequestQueue.swift */, 849A97731ED9EC04007D329B /* TimelineStringFormatter.swift */, ); path = Timeline; @@ -1979,6 +1985,7 @@ isa = PBXGroup; children = ( 84F9EAD0213660A100CF2DE4 /* ScriptingTests */, + FF3ABF09232599450074C542 /* ArticleSorterTests.swift */, 84F9EAE3213660A100CF2DE4 /* NetNewsWireTests.swift */, DD82AB09231003F6002269DF /* SharingTests.swift */, 84F9EAE4213660A100CF2DE4 /* Info.plist */, @@ -2643,6 +2650,7 @@ 51D5948722668EFA00DFC836 /* MarkStatusCommand.swift in Sources */, 514B7C8323205EFB00BAC947 /* RootSplitViewController.swift in Sources */, 5152E0F923248F6200E5C7AD /* SettingsLocalAccountView.swift in Sources */, + FF3ABF162325AF5D0074C542 /* ArticleSorter.swift in Sources */, 51C4525C226508DF00C03939 /* String-Extensions.swift in Sources */, 51C452792265091600C03939 /* MasterTimelineTableViewCell.swift in Sources */, 51C452852265093600C03939 /* FlattenedAccountFolderPickerData.swift in Sources */, @@ -2777,6 +2785,7 @@ 849A978A1ED9ECEF007D329B /* ArticleStylesManager.swift in Sources */, 8405DD8A2213E0E3008CE1BF /* DetailContainerView.swift in Sources */, 519B8D332143397200FA689C /* SharingServiceDelegate.swift in Sources */, + FF3ABF1523259DDB0074C542 /* ArticleSorter.swift in Sources */, 84E8E0DB202EC49300562D8F /* TimelineViewController+ContextualMenus.swift in Sources */, 849A97791ED9EC04007D329B /* TimelineStringFormatter.swift in Sources */, 84E185C3203BB12600F69BFA /* MultilineTextFieldSizer.swift in Sources */, @@ -2855,6 +2864,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + FF3ABF13232599810074C542 /* ArticleSorterTests.swift in Sources */, DD82AB0A231003F6002269DF /* SharingTests.swift in Sources */, 84F9EAEB213660A100CF2DE4 /* testIterativeCreateAndDeleteFeed.applescript in Sources */, 84F9EAF4213660A100CF2DE4 /* testGenericScript.applescript in Sources */, diff --git a/Shared/Data/ArticleUtilities.swift b/Shared/Data/ArticleUtilities.swift index fb053bcce..a2c6d733c 100644 --- a/Shared/Data/ArticleUtilities.swift +++ b/Shared/Data/ArticleUtilities.swift @@ -90,3 +90,25 @@ extension Article { } } + +// MARK: SortableArticle + +extension Article: SortableArticle { + + var sortableName: String { + return feed?.name ?? "" + } + + var sortableDate: Date { + return logicalDatePublished + } + + var sortableArticleID: String { + return articleID + } + + var sortableFeedID: String { + return feedID + } + +} diff --git a/Shared/Timeline/ArticleArray.swift b/Shared/Timeline/ArticleArray.swift index 1e40e7593..60394f084 100644 --- a/Shared/Timeline/ArticleArray.swift +++ b/Shared/Timeline/ArticleArray.swift @@ -49,22 +49,11 @@ extension Array where Element == Article { return articleAtRow(oneIndex) }) } - - func sortedByDate(_ sortDirection: ComparisonResult) -> ArticleArray { - - let articles = sorted { (article1, article2) -> Bool in - if article1.logicalDatePublished == article2.logicalDatePublished { - return article1.articleID < article2.articleID - } - if sortDirection == .orderedDescending { - return article1.logicalDatePublished > article2.logicalDatePublished - } - return article1.logicalDatePublished < article2.logicalDatePublished - } - return articles + func sortedByDate(_ sortDirection: ComparisonResult, groupByFeed: Bool = false) -> ArticleArray { + return ArticleSorter.sortedByDate(articles: self, sortDirection: sortDirection, groupByFeed: groupByFeed) } - + func canMarkAllAsRead() -> Bool { return anyArticleIsUnread() diff --git a/Shared/Timeline/ArticleSorter.swift b/Shared/Timeline/ArticleSorter.swift new file mode 100644 index 000000000..c7ba1a748 --- /dev/null +++ b/Shared/Timeline/ArticleSorter.swift @@ -0,0 +1,61 @@ +// +// ArticleSorter.swift +// NetNewsWire +// +// Created by Phil Viso on 9/8/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import Articles +import Foundation + +protocol SortableArticle { + var sortableName: String { get } + var sortableDate: Date { get } + var sortableArticleID: String { get } + var sortableFeedID: String { get } +} + +struct ArticleSorter { + + static func sortedByDate(articles: [T], + sortDirection: ComparisonResult, + groupByFeed: Bool) -> [T] { + if groupByFeed { + return sortedByFeedName(articles: articles, sortByDateDirection: sortDirection) + } else { + return sortedByDate(articles: articles, sortDirection: sortDirection) + } + } + + // MARK: - + + private static func sortedByFeedName(articles: [T], + sortByDateDirection: ComparisonResult) -> [T] { + // Group articles by "feed-feedID" - feed ID is used to differentiate between + // two feeds that have the same name + let groupedArticles = Dictionary(grouping: articles) { "\($0.sortableName.lowercased())-\($0.sortableFeedID)" } + return groupedArticles + .sorted { $0.key < $1.key } + .flatMap { (tuple) -> [T] in + let (_, articles) = tuple + + return sortedByDate(articles: articles, sortDirection: sortByDateDirection) + } + } + + private static func sortedByDate(articles: [T], + sortDirection: ComparisonResult) -> [T] { + return articles.sorted { (article1, article2) -> Bool in + if article1.sortableDate == article2.sortableDate { + return article1.sortableArticleID < article2.sortableArticleID + } + if sortDirection == .orderedDescending { + return article1.sortableDate > article2.sortableDate + } + + return article1.sortableDate < article2.sortableDate + } + } + +} diff --git a/Tests/NetNewsWireTests/ArticleSorterTests.swift b/Tests/NetNewsWireTests/ArticleSorterTests.swift new file mode 100644 index 000000000..1991e88ff --- /dev/null +++ b/Tests/NetNewsWireTests/ArticleSorterTests.swift @@ -0,0 +1,240 @@ +// +// ArticleSorterTests.swift +// NetNewsWire +// +// Created by Phil Viso on 9/8/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import Articles +import Foundation +import XCTest + +@testable import NetNewsWire + +class ArticleSorterTests: XCTestCase { + + // MARK: sortByDate ascending tests + + func testSortByDateAscending() { + let now = Date() + + let article1 = TestArticle(sortableName: "Susie's Feed", sortableDate: now.addingTimeInterval(-60.0), sortableArticleID: "1", sortableFeedID: "4") + let article2 = TestArticle(sortableName: "Phil's Feed", sortableDate: now.addingTimeInterval(60.0), sortableArticleID: "2", sortableFeedID: "6") + let article3 = TestArticle(sortableName: "Phil's Feed", sortableDate: now.addingTimeInterval(120.0), sortableArticleID: "3", sortableFeedID: "6") + let article4 = TestArticle(sortableName: "Susie's Feed", sortableDate: now.addingTimeInterval(-120.0), sortableArticleID: "4", sortableFeedID: "5") + + let articles = [article1, article2, article3, article4] + let sortedArticles = ArticleSorter.sortedByDate(articles: articles, + sortDirection: .orderedAscending, + groupByFeed: false) + + XCTAssertEqual(sortedArticles.count, articles.count) + XCTAssertEqual(sortedArticles.articleAtRow(0), article4) + XCTAssertEqual(sortedArticles.articleAtRow(1), article1) + XCTAssertEqual(sortedArticles.articleAtRow(2), article2) + XCTAssertEqual(sortedArticles.articleAtRow(3), article3) + } + + func testSortByDateAscendingWithSameDate() { + let now = Date() + + // Articles with the same date should end up being sorted by their article ID + let article1 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableArticleID: "1", sortableFeedID: "1") + let article2 = TestArticle(sortableName: "Matt's Feed", sortableDate: now, sortableArticleID: "2", sortableFeedID: "2") + let article3 = TestArticle(sortableName: "Sally's Feed", sortableDate: now, sortableArticleID: "3", sortableFeedID: "3") + let article4 = TestArticle(sortableName: "Susie's Feed", sortableDate: Date(timeInterval: -60.0, since: now), sortableArticleID: "4", sortableFeedID: "4") + let article5 = TestArticle(sortableName: "Paul's Feed", sortableDate: Date(timeInterval: -120.0, since: now), sortableArticleID: "5", sortableFeedID: "5") + + let articles = [article1, article2, article3, article4, article5] + let sortedArticles = ArticleSorter.sortedByDate(articles: articles, + sortDirection: .orderedAscending, + groupByFeed: false) + + XCTAssertEqual(sortedArticles.count, articles.count) + XCTAssertEqual(sortedArticles.articleAtRow(0), article5) + XCTAssertEqual(sortedArticles.articleAtRow(1), article4) + XCTAssertEqual(sortedArticles.articleAtRow(2), article1) + XCTAssertEqual(sortedArticles.articleAtRow(3), article2) + XCTAssertEqual(sortedArticles.articleAtRow(4), article3) + } + + func testSortByDateAscendingWithGroupByFeed() { + let now = Date() + + let article1 = TestArticle(sortableName: "Phil's Feed", sortableDate: Date(timeInterval: -100.0, since: now), sortableArticleID: "1", sortableFeedID: "1") + let article2 = TestArticle(sortableName: "Jenny's Feed", sortableDate: now, sortableArticleID: "1", sortableFeedID: "2") + let article3 = TestArticle(sortableName: "Jenny's Feed", sortableDate: Date(timeInterval: -10.0, since: now), sortableArticleID: "2", sortableFeedID: "2") + let article4 = TestArticle(sortableName: "Gordy's Blog", sortableDate: Date(timeInterval: -1000.0, since: now), sortableArticleID: "1", sortableFeedID: "3") + let article5 = TestArticle(sortableName: "Gordy's Blog", sortableDate: Date(timeInterval: -10.0, since: now), sortableArticleID: "2", sortableFeedID: "3") + let article6 = TestArticle(sortableName: "Jenny's Feed", sortableDate: Date(timeInterval: 10.0, since: now), sortableArticleID: "3", sortableFeedID: "2") + let article7 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableArticleID: "2", sortableFeedID: "1") + let article8 = TestArticle(sortableName: "Zippy's Feed", sortableDate: now, sortableArticleID: "1", sortableFeedID: "0") + let article9 = TestArticle(sortableName: "Zippy's Feed", sortableDate: now, sortableArticleID: "2", sortableFeedID: "0") + + let articles = [article1, article2, article3, article4, article5, article6, article7, article8, article9] + let sortedArticles = ArticleSorter.sortedByDate(articles: articles, sortDirection: .orderedAscending, groupByFeed: true) + + XCTAssertEqual(sortedArticles.count, 9) + + // Gordy's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(0), article4) + XCTAssertEqual(sortedArticles.articleAtRow(1), article5) + // Jenny's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(2), article3) + XCTAssertEqual(sortedArticles.articleAtRow(3), article2) + XCTAssertEqual(sortedArticles.articleAtRow(4), article6) + // Phil's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(5), article1) + XCTAssertEqual(sortedArticles.articleAtRow(6), article7) + // Zippy's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(7), article8) + XCTAssertEqual(sortedArticles.articleAtRow(8), article9) + } + + // MARK: sortByDate descending tests + + func testSortByDateDescending() { + let now = Date() + + let article1 = TestArticle(sortableName: "Susie's Feed", sortableDate: now.addingTimeInterval(-60.0), sortableArticleID: "1", sortableFeedID: "4") + let article2 = TestArticle(sortableName: "Phil's Feed", sortableDate: now.addingTimeInterval(60.0), sortableArticleID: "2", sortableFeedID: "6") + let article3 = TestArticle(sortableName: "Phil's Feed", sortableDate: now.addingTimeInterval(120.0), sortableArticleID: "3", sortableFeedID: "6") + let article4 = TestArticle(sortableName: "Susie's Feed", sortableDate: now.addingTimeInterval(-120.0), sortableArticleID: "4", sortableFeedID: "5") + + let articles = [article1, article2, article3, article4] + let sortedArticles = ArticleSorter.sortedByDate(articles: articles, + sortDirection: .orderedDescending, + groupByFeed: false) + + XCTAssertEqual(sortedArticles.count, articles.count) + XCTAssertEqual(sortedArticles.articleAtRow(0), article3) + XCTAssertEqual(sortedArticles.articleAtRow(1), article2) + XCTAssertEqual(sortedArticles.articleAtRow(2), article1) + XCTAssertEqual(sortedArticles.articleAtRow(3), article4) + } + + func testSortByDateDescendingWithSameDate() { + let now = Date() + + // Articles with the same date should end up being sorted by their article ID + let article1 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableArticleID: "1", sortableFeedID: "1") + let article2 = TestArticle(sortableName: "Matt's Feed", sortableDate: now, sortableArticleID: "2", sortableFeedID: "2") + let article3 = TestArticle(sortableName: "Sally's Feed", sortableDate: now, sortableArticleID: "3", sortableFeedID: "3") + let article4 = TestArticle(sortableName: "Susie's Feed", sortableDate: Date(timeInterval: -60.0, since: now), sortableArticleID: "4", sortableFeedID: "4") + let article5 = TestArticle(sortableName: "Paul's Feed", sortableDate: Date(timeInterval: -120.0, since: now), sortableArticleID: "5", sortableFeedID: "5") + + let articles = [article1, article2, article3, article4, article5] + let sortedArticles = ArticleSorter.sortedByDate(articles: articles, + sortDirection: .orderedDescending, + groupByFeed: false) + + XCTAssertEqual(sortedArticles.count, articles.count) + XCTAssertEqual(sortedArticles.articleAtRow(0), article1) + XCTAssertEqual(sortedArticles.articleAtRow(1), article2) + XCTAssertEqual(sortedArticles.articleAtRow(2), article3) + XCTAssertEqual(sortedArticles.articleAtRow(3), article4) + XCTAssertEqual(sortedArticles.articleAtRow(4), article5) + } + + func testSortByDateDescendingWithGroupByFeed() { + let now = Date() + + let article1 = TestArticle(sortableName: "Phil's Feed", sortableDate: Date(timeInterval: -100.0, since: now), sortableArticleID: "1", sortableFeedID: "1") + let article2 = TestArticle(sortableName: "Jenny's Feed", sortableDate: now, sortableArticleID: "1", sortableFeedID: "2") + let article3 = TestArticle(sortableName: "Jenny's Feed", sortableDate: Date(timeInterval: -10.0, since: now), sortableArticleID: "2", sortableFeedID: "2") + let article4 = TestArticle(sortableName: "Gordy's Blog", sortableDate: Date(timeInterval: -1000.0, since: now), sortableArticleID: "1", sortableFeedID: "3") + let article5 = TestArticle(sortableName: "Gordy's Blog", sortableDate: Date(timeInterval: -10.0, since: now), sortableArticleID: "2", sortableFeedID: "3") + let article6 = TestArticle(sortableName: "Jenny's Feed", sortableDate: Date(timeInterval: 10.0, since: now), sortableArticleID: "3", sortableFeedID: "2") + let article7 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableArticleID: "2", sortableFeedID: "1") + let article8 = TestArticle(sortableName: "Zippy's Feed", sortableDate: now, sortableArticleID: "1", sortableFeedID: "0") + let article9 = TestArticle(sortableName: "Zippy's Feed", sortableDate: now, sortableArticleID: "2", sortableFeedID: "0") + + let articles = [article1, article2, article3, article4, article5, article6, article7, article8, article9] + let sortedArticles = ArticleSorter.sortedByDate(articles: articles, sortDirection: .orderedDescending, groupByFeed: true) + + XCTAssertEqual(sortedArticles.count, 9) + + // Gordy's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(0), article5) + XCTAssertEqual(sortedArticles.articleAtRow(1), article4) + // Jenny's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(2), article6) + XCTAssertEqual(sortedArticles.articleAtRow(3), article2) + XCTAssertEqual(sortedArticles.articleAtRow(4), article3) + // Phil's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(5), article7) + XCTAssertEqual(sortedArticles.articleAtRow(6), article1) + // Zippy's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(7), article8) + XCTAssertEqual(sortedArticles.articleAtRow(8), article9) + } + + // MARK: Additional group by feed tests + + func testGroupByFeedWithCaseInsensitiveFeedNames() { + let now = Date() + + let article1 = TestArticle(sortableName: "phil's feed", sortableDate: now, sortableArticleID: "1", sortableFeedID: "1") + let article2 = TestArticle(sortableName: "PhIl's FEed", sortableDate: now, sortableArticleID: "2", sortableFeedID: "1") + let article3 = TestArticle(sortableName: "APPLE's feed", sortableDate: now, sortableArticleID: "3", sortableFeedID: "2") + let article4 = TestArticle(sortableName: "PHIL'S FEED", sortableDate: now, sortableArticleID: "4", sortableFeedID: "1") + let article5 = TestArticle(sortableName: "apple's feed", sortableDate: now, sortableArticleID: "5", sortableFeedID: "2") + + let articles = [article1, article2, article3, article4, article5] + let sortedArticles = ArticleSorter.sortedByDate(articles: articles, + sortDirection: .orderedAscending, + groupByFeed: true) + + XCTAssertEqual(sortedArticles.count, articles.count) + + // Apple's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(0), article3) + XCTAssertEqual(sortedArticles.articleAtRow(1), article5) + // Phil's feed articles + XCTAssertEqual(sortedArticles.articleAtRow(2), article1) + XCTAssertEqual(sortedArticles.articleAtRow(3), article2) + XCTAssertEqual(sortedArticles.articleAtRow(4), article4) + } + + func testGroupByFeedWithSameFeedNames() { + let now = Date() + + // Articles with the same feed name should be sorted by feed ID + let article1 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableArticleID: "1", sortableFeedID: "2") + let article2 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableArticleID: "2", sortableFeedID: "2") + let article3 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableArticleID: "3", sortableFeedID: "1") + let article4 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableArticleID: "4", sortableFeedID: "2") + let article5 = TestArticle(sortableName: "Phil's Feed", sortableDate: now, sortableArticleID: "5", sortableFeedID: "1") + + let articles = [article1, article2, article3, article4, article5] + let sortedArticles = ArticleSorter.sortedByDate(articles: articles, + sortDirection: .orderedAscending, + groupByFeed: true) + + XCTAssertEqual(sortedArticles.count, articles.count) + XCTAssertEqual(sortedArticles.articleAtRow(0), article3) + XCTAssertEqual(sortedArticles.articleAtRow(1), article5) + XCTAssertEqual(sortedArticles.articleAtRow(2), article1) + XCTAssertEqual(sortedArticles.articleAtRow(3), article2) + XCTAssertEqual(sortedArticles.articleAtRow(4), article4) + } + +} + +private struct TestArticle: SortableArticle, Equatable { + let sortableName: String + let sortableDate: Date + let sortableArticleID: String + let sortableFeedID: String +} + +private extension Array where Element == TestArticle { + func articleAtRow(_ row: Int) -> TestArticle? { + if row < 0 || row == NSNotFound || row > count - 1 { + return nil + } + return self[row] + } + +} diff --git a/iOS/AppDefaults.swift b/iOS/AppDefaults.swift index 52b98d30d..98c59261a 100644 --- a/iOS/AppDefaults.swift +++ b/iOS/AppDefaults.swift @@ -12,10 +12,11 @@ struct AppDefaults { struct Key { static let firstRunDate = "firstRunDate" + static let timelineGroupByFeed = "timelineGroupByFeed" + static let timelineNumberOfLines = "timelineNumberOfLines" static let timelineSortDirection = "timelineSortDirection" static let refreshInterval = "refreshInterval" static let lastRefresh = "lastRefresh" - static let timelineNumberOfLines = "timelineNumberOfLines" } static let isFirstRun: Bool = { @@ -35,6 +36,15 @@ struct AppDefaults { UserDefaults.standard.set(newValue.rawValue, forKey: Key.refreshInterval) } } + + static var timelineGroupByFeed: Bool { + get { + return bool(for: Key.timelineGroupByFeed) + } + set { + setBool(for: Key.timelineGroupByFeed, newValue) + } + } static var timelineSortDirection: ComparisonResult { get { @@ -64,7 +74,10 @@ struct AppDefaults { } static func registerDefaults() { - let defaults: [String : Any] = [Key.timelineSortDirection: ComparisonResult.orderedDescending.rawValue, Key.refreshInterval: RefreshInterval.everyHour.rawValue, Key.timelineNumberOfLines: 3] + let defaults: [String : Any] = [Key.refreshInterval: RefreshInterval.everyHour.rawValue, + Key.timelineGroupByFeed: false, + Key.timelineNumberOfLines: 3, + Key.timelineSortDirection: ComparisonResult.orderedDescending.rawValue] UserDefaults.standard.register(defaults: defaults) } diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index 2f45bb0d7..275a2d3ac 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -66,7 +66,14 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { private(set) var sortDirection = AppDefaults.timelineSortDirection { didSet { if sortDirection != oldValue { - sortDirectionDidChange() + sortParametersDidChange() + } + } + } + private(set) var groupByFeed = AppDefaults.timelineGroupByFeed { + didSet { + if groupByFeed != oldValue { + sortParametersDidChange() } } } @@ -400,6 +407,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider { @objc func userDefaultsDidChange(_ note: Notification) { self.sortDirection = AppDefaults.timelineSortDirection + self.groupByFeed = AppDefaults.timelineGroupByFeed } @objc func accountDidDownloadArticles(_ note: Notification) { @@ -1226,12 +1234,12 @@ private extension SceneCoordinator { } } - func sortDirectionDidChange() { + func sortParametersDidChange() { replaceArticles(with: Set(articles), animate: true) } - + func replaceArticles(with unsortedArticles: Set
, animate: Bool) { - let sortedArticles = Array(unsortedArticles).sortedByDate(sortDirection) + let sortedArticles = Array(unsortedArticles).sortedByDate(sortDirection, groupByFeed: groupByFeed) if articles != sortedArticles { diff --git a/iOS/Settings/SettingsView.swift b/iOS/Settings/SettingsView.swift index 3636dcaed..253530f66 100644 --- a/iOS/Settings/SettingsView.swift +++ b/iOS/Settings/SettingsView.swift @@ -54,7 +54,10 @@ struct SettingsView : View { func buildTimelineSection() -> some View { Section(header: Text("TIMELINE")) { Toggle(isOn: $viewModel.sortOldestToNewest) { - Text("Sort Oldest to Newest") + Text("Sort Newest to Oldest") + } + Toggle(isOn: $viewModel.groupByFeed) { + Text("Group By Feed") } Stepper(value: $viewModel.timelineNumberOfLines, in: 2...6) { Text("Number of Text Lines: \(viewModel.timelineNumberOfLines)") @@ -221,6 +224,16 @@ struct SettingsView : View { } } + var groupByFeed: Bool { + get { + return AppDefaults.timelineGroupByFeed + } + set { + objectWillChange.send() + AppDefaults.timelineGroupByFeed = newValue + } + } + var timelineNumberOfLines: Int { get { return AppDefaults.timelineNumberOfLines