diff --git a/Mac/AppDefaults.swift b/Mac/AppDefaults.swift index 51985e00da..bc405c6f33 100644 --- a/Mac/AppDefaults.swift +++ b/Mac/AppDefaults.swift @@ -43,6 +43,8 @@ final class AppDefaults: Sendable { static let defaultBrowserID = "defaultBrowserID" static let currentThemeName = "currentThemeName" static let articleContentJavascriptEnabled = "articleContentJavascriptEnabled" + static let sidebarSortType = "sidebarSortType" + static let sidebarSortAscending = "sidebarSortAscending" // Hidden prefs static let showDebugMenu = "ShowDebugMenu" @@ -319,6 +321,36 @@ final class AppDefaults: Sendable { } } + var sidebarSortType: SidebarSortType { + get { + let rawValue = UserDefaults.standard.integer(forKey: Key.sidebarSortType) + return SidebarSortType(rawValue: rawValue) ?? .alphabetically + } + set { + guard newValue != sidebarSortType else { + return + } + UserDefaults.standard.set(newValue.rawValue, forKey: Key.sidebarSortType) + NotificationCenter.default.post(name: .SidebarSortTypeDidChange, object: nil) + } + } + + var sidebarSortAscending: Bool { + get { + if UserDefaults.standard.object(forKey: Key.sidebarSortAscending) == nil { + return true + } + return UserDefaults.standard.bool(forKey: Key.sidebarSortAscending) + } + set { + guard newValue != sidebarSortAscending else { + return + } + UserDefaults.standard.set(newValue, forKey: Key.sidebarSortAscending) + NotificationCenter.default.post(name: .SidebarSortTypeDidChange, object: nil) + } + } + init() { // Migrate every-10-minute refresh interval to 30 minutes. let rawValue = UserDefaults.standard.integer(forKey: Key.refreshInterval) @@ -344,7 +376,9 @@ final class AppDefaults: Sendable { Key.refreshInterval: RefreshInterval.every2Hours.rawValue, Key.showDebugMenu: showDebugMenu, Key.currentThemeName: Self.defaultThemeName, - Key.articleContentJavascriptEnabled: true + Key.articleContentJavascriptEnabled: true, + Key.sidebarSortType: SidebarSortType.alphabetically.rawValue, + Key.sidebarSortAscending: true ] UserDefaults.standard.register(defaults: defaults) diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index 54aa5eb75c..9f819c00b8 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -52,6 +52,10 @@ let appName = "NetNewsWire" @IBOutlet var sortByNewestArticleOnTopMenuItem: NSMenuItem! @IBOutlet var groupArticlesByFeedMenuItem: NSMenuItem! @IBOutlet var checkForUpdatesMenuItem: NSMenuItem! + @IBOutlet var sortFeedsByNameMenuItem: NSMenuItem! + @IBOutlet var sortFeedsByUnreadCountMenuItem: NSMenuItem! + @IBOutlet var sortFeedsAscendingMenuItem: NSMenuItem! + @IBOutlet var sortFeedsDescendingMenuItem: NSMenuItem! var unreadCount = 0 { didSet { @@ -191,6 +195,7 @@ let appName = "NetNewsWire" updateSortMenuItems() updateGroupByFeedMenuItem() + updateSortFeedsMenuItems() if mainWindowController == nil { let mainWindowController = createAndShowMainWindow() @@ -371,6 +376,7 @@ let appName = "NetNewsWire" func userDefaultsDidChange() { updateSortMenuItems() updateGroupByFeedMenuItem() + updateSortFeedsMenuItems() if lastRefreshInterval != AppDefaults.shared.refreshInterval { refreshTimer?.update() @@ -465,6 +471,11 @@ let appName = "NetNewsWire" return mainWindowController?.isOpen ?? false } + if item.action == #selector(sortFeedsByName(_:)) || item.action == #selector(sortFeedsByUnreadCount(_:)) || + item.action == #selector(sortFeedsAscending(_:)) || item.action == #selector(sortFeedsDescending(_:)) { + return mainWindowController?.isOpen ?? false + } + if item.action == #selector(showAddFeedWindow(_:)) || item.action == #selector(showAddFolderWindow(_:)) { return !isDisplayingSheet && !AccountManager.shared.activeAccounts.isEmpty } @@ -703,6 +714,22 @@ let appName = "NetNewsWire" AppDefaults.shared.timelineGroupByFeed.toggle() } + @IBAction func sortFeedsByName(_ sender: Any?) { + AppDefaults.shared.sidebarSortType = .alphabetically + } + + @IBAction func sortFeedsByUnreadCount(_ sender: Any?) { + AppDefaults.shared.sidebarSortType = .byUnreadCount + } + + @IBAction func sortFeedsAscending(_ sender: Any?) { + AppDefaults.shared.sidebarSortAscending = true + } + + @IBAction func sortFeedsDescending(_ sender: Any?) { + AppDefaults.shared.sidebarSortAscending = false + } + @IBAction func checkForUpdates(_ sender: Any?) { softwareUpdater?.checkForUpdates() } @@ -835,6 +862,16 @@ extension AppDelegate { sortByOldestArticleOnTopMenuItem.state = sortByNewestOnTop ? .off : .on } + @MainActor func updateSortFeedsMenuItems() { + let sortType = AppDefaults.shared.sidebarSortType + sortFeedsByNameMenuItem.state = sortType == .alphabetically ? .on : .off + sortFeedsByUnreadCountMenuItem.state = sortType == .byUnreadCount ? .on : .off + + let ascending = AppDefaults.shared.sidebarSortAscending + sortFeedsAscendingMenuItem.state = ascending ? .on : .off + sortFeedsDescendingMenuItem.state = ascending ? .off : .on + } + @MainActor func updateGroupByFeedMenuItem() { let groupByFeedEnabled = AppDefaults.shared.timelineGroupByFeed groupArticlesByFeedMenuItem.state = groupByFeedEnabled ? .on : .off diff --git a/Mac/Base.lproj/Main.storyboard b/Mac/Base.lproj/Main.storyboard index 6edf58fe28..5eddb7b0f8 100644 --- a/Mac/Base.lproj/Main.storyboard +++ b/Mac/Base.lproj/Main.storyboard @@ -367,6 +367,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -695,6 +727,10 @@ + + + + diff --git a/Mac/MainWindow/Sidebar/SidebarOutlineDataSource.swift b/Mac/MainWindow/Sidebar/SidebarOutlineDataSource.swift index 23401be3e7..a139c8ec7c 100644 --- a/Mac/MainWindow/Sidebar/SidebarOutlineDataSource.swift +++ b/Mac/MainWindow/Sidebar/SidebarOutlineDataSource.swift @@ -649,8 +649,7 @@ private extension SidebarOutlineDataSource { let draggedSidebarItemNode = Node(representedObject: draggedFeedWrapper, parent: nil) let nodes = parentNode.childNodes + [draggedSidebarItemNode] - // Revisit if the tree controller can ever be sorted in some other way. - let sortedNodes = nodes.sortedAlphabeticallyWithFoldersAtEnd() + let sortedNodes = nodes.sorted(by: AppDefaults.shared.sidebarSortType, ascending: AppDefaults.shared.sidebarSortAscending) let index = sortedNodes.firstIndex(of: draggedSidebarItemNode)! return index } @@ -661,8 +660,7 @@ private extension SidebarOutlineDataSource { draggedFolderNode.canHaveChildNodes = true let nodes = parentNode.childNodes + [draggedFolderNode] - // Revisit if the tree controller can ever be sorted in some other way. - let sortedNodes = nodes.sortedAlphabeticallyWithFoldersAtEnd() + let sortedNodes = nodes.sorted(by: AppDefaults.shared.sidebarSortType, ascending: AppDefaults.shared.sidebarSortAscending) let index = sortedNodes.firstIndex(of: draggedFolderNode)! return index } diff --git a/Mac/MainWindow/Sidebar/SidebarViewController.swift b/Mac/MainWindow/Sidebar/SidebarViewController.swift index c7a7623491..26e49e14d9 100644 --- a/Mac/MainWindow/Sidebar/SidebarViewController.swift +++ b/Mac/MainWindow/Sidebar/SidebarViewController.swift @@ -86,6 +86,7 @@ extension Notification.Name { NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .feedIconDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(feedSettingDidChange(_:)), name: .feedSettingDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange(_:)), name: .DisplayNameDidChange, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(sidebarSortTypeDidChange(_:)), name: .SidebarSortTypeDidChange, object: nil) DistributedNotificationCenter.default().addObserver(self, selector: #selector(appleSideBarDefaultIconSizeChanged(_:)), name: .appleSideBarDefaultIconSizeChanged, object: nil) outlineView.reloadData() @@ -213,6 +214,10 @@ extension Notification.Name { rebuildTreeAndRestoreSelection() } + @objc func sidebarSortTypeDidChange(_ note: Notification) { + rebuildTreeAndRestoreSelection() + } + @objc func accountsDidChange(_ notification: Notification) { rebuildTreeAndRestoreSelection() } diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 195cc5e67b..c6ee923123 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -467,6 +467,7 @@ ShareExtension/SafariExt.js, ShareExtension/ShareDefaultContainer.swift, Timer/RefreshInterval.swift, + Tree/SidebarSortType.swift, ); target = 510C415B24E5CDE3008226FD /* NetNewsWire Share Extension */; }; diff --git a/Shared/Extensions/Node+Extensions.swift b/Shared/Extensions/Node+Extensions.swift index 74d7dd6092..37aa9ebbec 100644 --- a/Shared/Extensions/Node+Extensions.swift +++ b/Shared/Extensions/Node+Extensions.swift @@ -10,6 +10,7 @@ import Foundation import RSTree import Articles import RSCore +import Account @MainActor extension Array where Element == Node { @@ -22,6 +23,31 @@ import RSCore return Node.nodesSortedAlphabeticallyWithFoldersAtEnd(self) } + + func sortedByUnreadCountWithFoldersAtEnd() -> [Node] { + + Node.nodesSortedByUnreadCountWithFoldersAtEnd(self) + } + + func sorted(by sortType: SidebarSortType, ascending: Bool = true) -> [Node] { + + let sorted: [Node] + switch sortType { + case .alphabetically: + sorted = sortedAlphabeticallyWithFoldersAtEnd() + case .byUnreadCount: + sorted = sortedByUnreadCountWithFoldersAtEnd() + } + + if ascending { + return sorted + } + + // Reverse feeds and folders separately to keep folders at end + let feeds: [Node] = sorted.filter { !$0.canHaveChildNodes } + let folders: [Node] = sorted.filter { $0.canHaveChildNodes } + return Array(feeds.reversed()) + Array(folders.reversed()) + } } @MainActor private extension Node { @@ -62,4 +88,31 @@ import RSCore return name1.localizedStandardCompare(name2) == .orderedAscending } } + + class func nodesSortedByUnreadCountWithFoldersAtEnd(_ nodes: [Node]) -> [Node] { + + // Sorts ascending: least unread first, with alphabetical tiebreaker + return nodes.sorted { (node1, node2) -> Bool in + + if node1.canHaveChildNodes != node2.canHaveChildNodes { + if node1.canHaveChildNodes { + return false + } + return true + } + + let count1 = (node1.representedObject as? UnreadCountProvider)?.unreadCount ?? 0 + let count2 = (node2.representedObject as? UnreadCountProvider)?.unreadCount ?? 0 + + if count1 != count2 { + return count1 < count2 + } + + guard let obj1 = node1.representedObject as? DisplayNameProvider, let obj2 = node2.representedObject as? DisplayNameProvider else { + return false + } + + return obj1.nameForDisplay.localizedStandardCompare(obj2.nameForDisplay) == .orderedAscending + } + } } diff --git a/Shared/Tree/SidebarSortType.swift b/Shared/Tree/SidebarSortType.swift new file mode 100644 index 0000000000..f61182584c --- /dev/null +++ b/Shared/Tree/SidebarSortType.swift @@ -0,0 +1,18 @@ +// +// SidebarSortType.swift +// NetNewsWire +// +// Created by Brent Simmons on 2/24/26. +// Copyright © 2026 Ranchero Software. All rights reserved. +// + +import Foundation + +enum SidebarSortType: Int { + case alphabetically = 0 + case byUnreadCount = 1 +} + +extension Notification.Name { + static let SidebarSortTypeDidChange = Notification.Name("SidebarSortTypeDidChange") +} diff --git a/Shared/Tree/SidebarTreeControllerDelegate.swift b/Shared/Tree/SidebarTreeControllerDelegate.swift index 249a6f79a2..0c96552fbc 100644 --- a/Shared/Tree/SidebarTreeControllerDelegate.swift +++ b/Shared/Tree/SidebarTreeControllerDelegate.swift @@ -95,7 +95,7 @@ private extension SidebarTreeControllerDelegate { } } - return updatedChildNodes.sortedAlphabeticallyWithFoldersAtEnd() + return updatedChildNodes.sorted(by: AppDefaults.shared.sidebarSortType, ascending: AppDefaults.shared.sidebarSortAscending) } func createNode(representedObject: Any, parent: Node) -> Node? { diff --git a/iOS/AppDefaults.swift b/iOS/AppDefaults.swift index e54d9bcfb8..5bd5b1fa12 100644 --- a/iOS/AppDefaults.swift +++ b/iOS/AppDefaults.swift @@ -219,6 +219,14 @@ final class AppDefaults: Sendable { } } + var sidebarSortType: SidebarSortType { + .alphabetically + } + + var sidebarSortAscending: Bool { + true + } + var splitViewPreferredDisplayMode: Int { get { return AppDefaults.int(for: Key.splitViewPreferredDisplayMode)