Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 35 additions & 1 deletion Mac/AppDefaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions Mac/AppDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -191,6 +195,7 @@ let appName = "NetNewsWire"

updateSortMenuItems()
updateGroupByFeedMenuItem()
updateSortFeedsMenuItems()

if mainWindowController == nil {
let mainWindowController = createAndShowMainWindow()
Expand Down Expand Up @@ -371,6 +376,7 @@ let appName = "NetNewsWire"
func userDefaultsDidChange() {
updateSortMenuItems()
updateGroupByFeedMenuItem()
updateSortFeedsMenuItems()

if lastRefreshInterval != AppDefaults.shared.refreshInterval {
refreshTimer?.update()
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions Mac/Base.lproj/Main.storyboard
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,38 @@
</items>
</menu>
</menuItem>
<menuItem title="Sort Feeds By" id="gT7-sF-kRd">
<modifierMask key="keyEquivalentModifierMask"/>
<menu key="submenu" title="Sort Feeds By" id="hY3-pK-mW9">
<items>
<menuItem title="Name" id="qN4-wJ-fR2">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="sortFeedsByName:" target="Voe-Tx-rLC" id="vK8-hN-3pQ"/>
</connections>
</menuItem>
<menuItem title="Unread Count" id="bL5-rT-xW7">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="sortFeedsByUnreadCount:" target="Voe-Tx-rLC" id="mD9-jP-4sR"/>
</connections>
</menuItem>
<menuItem isSeparatorItem="YES" id="kV2-sN-8wQ"/>
<menuItem title="Ascending" id="aF3-qR-7vN">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="sortFeedsAscending:" target="Voe-Tx-rLC" id="wH5-tK-9bP"/>
</connections>
</menuItem>
<menuItem title="Descending" id="dG6-uW-2mL">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="sortFeedsDescending:" target="Voe-Tx-rLC" id="xJ8-vM-4cR"/>
</connections>
</menuItem>
</items>
</menu>
</menuItem>
<menuItem title="Group By Feed" id="Zxm-O6-NRE">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
Expand Down Expand Up @@ -695,6 +727,10 @@
<outlet property="groupArticlesByFeedMenuItem" destination="Zxm-O6-NRE" id="gwn-VT-2YZ"/>
<outlet property="sortByNewestArticleOnTopMenuItem" destination="TNS-TV-n0U" id="gix-Nd-9k4"/>
<outlet property="sortByOldestArticleOnTopMenuItem" destination="iii-kP-qoF" id="fTe-Tf-EWG"/>
<outlet property="sortFeedsAscendingMenuItem" destination="aF3-qR-7vN" id="rS4-mH-6yK"/>
<outlet property="sortFeedsByNameMenuItem" destination="qN4-wJ-fR2" id="pR6-kL-2wT"/>
<outlet property="sortFeedsByUnreadCountMenuItem" destination="bL5-rT-xW7" id="tQ3-nM-7xS"/>
<outlet property="sortFeedsDescendingMenuItem" destination="dG6-uW-2mL" id="uT7-nJ-3zL"/>
</connections>
</customObject>
<customObject id="Ady-hI-5gd" userLabel="First Responder" customClass="NSResponder" sceneMemberID="firstResponder"/>
Expand Down
6 changes: 2 additions & 4 deletions Mac/MainWindow/Sidebar/SidebarOutlineDataSource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down
5 changes: 5 additions & 0 deletions Mac/MainWindow/Sidebar/SidebarViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -213,6 +214,10 @@ extension Notification.Name {
rebuildTreeAndRestoreSelection()
}

@objc func sidebarSortTypeDidChange(_ note: Notification) {
rebuildTreeAndRestoreSelection()
}

@objc func accountsDidChange(_ notification: Notification) {
rebuildTreeAndRestoreSelection()
}
Expand Down
1 change: 1 addition & 0 deletions NetNewsWire.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -467,6 +467,7 @@
ShareExtension/SafariExt.js,
ShareExtension/ShareDefaultContainer.swift,
Timer/RefreshInterval.swift,
Tree/SidebarSortType.swift,
);
target = 510C415B24E5CDE3008226FD /* NetNewsWire Share Extension */;
};
Expand Down
53 changes: 53 additions & 0 deletions Shared/Extensions/Node+Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import Foundation
import RSTree
import Articles
import RSCore
import Account

@MainActor extension Array where Element == Node {

Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
}
}
18 changes: 18 additions & 0 deletions Shared/Tree/SidebarSortType.swift
Original file line number Diff line number Diff line change
@@ -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")
}
2 changes: 1 addition & 1 deletion Shared/Tree/SidebarTreeControllerDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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? {
Expand Down
8 changes: 8 additions & 0 deletions iOS/AppDefaults.swift
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,14 @@ final class AppDefaults: Sendable {
}
}

var sidebarSortType: SidebarSortType {
.alphabetically
}

var sidebarSortAscending: Bool {
true
}

Comment thread
willie marked this conversation as resolved.
var splitViewPreferredDisplayMode: Int {
get {
return AppDefaults.int(for: Key.splitViewPreferredDisplayMode)
Expand Down