diff --git a/Mac/AppDefaults.swift b/Mac/AppDefaults.swift index 51985e00d..e14b68aba 100644 --- a/Mac/AppDefaults.swift +++ b/Mac/AppDefaults.swift @@ -30,6 +30,7 @@ final class AppDefaults: Sendable { static let timelineFontSize = "timelineFontSize" static let timelineSortDirection = "timelineSortDirection" static let timelineGroupByFeed = "timelineGroupByFeed" + static let timelineReadFilterEnabled = "timelineReadFilterEnabled" static let detailFontSize = "detailFontSize" static let openInBrowserInBackground = "openInBrowserInBackground" static let subscribeToFeedsInDefaultBrowser = "subscribeToFeedsInDefaultBrowser" @@ -149,6 +150,15 @@ final class AppDefaults: Sendable { } } + var timelineReadFilterEnabled: Bool { + get { + return UserDefaults.standard.bool(forKey: Key.timelineReadFilterEnabled) + } + set { + UserDefaults.standard.set(newValue, forKey: Key.timelineReadFilterEnabled) + } + } + var detailFontSize: FontSize { get { return fontSize(for: Key.detailFontSize) diff --git a/Mac/MainWindow/AddFolder/AddFolderWindowController.swift b/Mac/MainWindow/AddFolder/AddFolderWindowController.swift index 0e9c1626d..ca44c77b1 100644 --- a/Mac/MainWindow/AddFolder/AddFolderWindowController.swift +++ b/Mac/MainWindow/AddFolder/AddFolderWindowController.swift @@ -40,24 +40,16 @@ final class AddFolderWindowController: NSWindowController { // MARK: - NSViewController override func windowDidLoad() { - let preferredAccountID = AppDefaults.shared.addFolderAccountID accountPopupButton.removeAllItems() let menu = NSMenu() accountPopupButton.menu = menu - let sortedAccounts = AccountManager.shared.sortedActiveAccounts - let accounts: [Account] = sortedAccounts.filter { !$0.behaviors.contains(.disallowFolderManagement) } - - for oneAccount in accounts { - - let oneMenuItem = NSMenuItem() - oneMenuItem.title = oneAccount.nameForDisplay - oneMenuItem.representedObject = oneAccount - menu.addItem(oneMenuItem) - - if oneAccount.accountID == preferredAccountID { - accountPopupButton.select(oneMenuItem) + let preferredAccountID = AppDefaults.shared.addFolderAccountID + for account in allowedAccountsForFolderCreation() { + let menuItem = addMenuItem(to: menu, for: account) + if account.accountID == preferredAccountID { + accountPopupButton.select(menuItem) } } } @@ -98,6 +90,21 @@ extension AddFolderWindowController: NSTextFieldDelegate { private extension AddFolderWindowController { + func allowedAccountsForFolderCreation() -> [Account] { + AccountManager.shared.sortedActiveAccounts.filter { account in + !account.behaviors.contains(.disallowFolderManagement) + } + } + + @discardableResult + func addMenuItem(to menu: NSMenu, for account: Account) -> NSMenuItem { + let menuItem = NSMenuItem() + menuItem.title = account.nameForDisplay + menuItem.representedObject = account + menu.addItem(menuItem) + return menuItem + } + private func addFolderIfNeeded() { guard let menuItem = accountPopupButton.selectedItem else { return diff --git a/Mac/MainWindow/MainWindowController.swift b/Mac/MainWindow/MainWindowController.swift index d0affc9e6..77afc67ef 100644 --- a/Mac/MainWindow/MainWindowController.swift +++ b/Mac/MainWindow/MainWindowController.swift @@ -555,11 +555,11 @@ final class MainWindowController: NSWindowController, NSUserInterfaceValidations } @IBAction func toggleReadFeedsFilter(_ sender: Any?) { - sidebarViewController?.toggleReadFilter() + toggleGlobalReadFilter() } @IBAction func toggleReadArticlesFilter(_ sender: Any?) { - timelineContainerViewController?.toggleReadFilter() + toggleGlobalReadFilter() } @objc func selectArticleTheme(_ menuItem: NSMenuItem) { @@ -1045,6 +1045,7 @@ private extension MainWindowController { sidebarViewController?.restoreState(from: state.sidebarWindowState) timelineContainerViewController?.restoreState(from: state.timelineWindowState) + syncSidebarReadFilter() restoreArticleWindowScrollY = state.detailWindowState?.windowScrollY let isShowingExtractedArticle = state.detailWindowState?.isShowingExtractedArticle ?? false @@ -1071,6 +1072,7 @@ private extension MainWindowController { let articleWindowScrollY = state[UserInfoKey.articleWindowScrollY] as? CGFloat restoreArticleWindowScrollY = articleWindowScrollY timelineContainerViewController?.restoreLegacyState(from: state) + syncSidebarReadFilter() let isShowingExtractedArticle = state[UserInfoKey.isShowingExtractedArticle] as? Bool ?? false if isShowingExtractedArticle { @@ -1081,6 +1083,20 @@ private extension MainWindowController { // MARK: - Command Validation + func syncSidebarReadFilter() { + let isReadFiltered = AppDefaults.shared.timelineReadFilterEnabled + sidebarViewController?.setReadFilterEnabled(isReadFiltered) + timelineContainerViewController?.setReadFilterEnabled(isReadFiltered) + } + + func toggleGlobalReadFilter() { + let newValue = !AppDefaults.shared.timelineReadFilterEnabled + AppDefaults.shared.timelineReadFilterEnabled = newValue + syncSidebarReadFilter() + makeToolbarValidate() + invalidateRestorableState() + } + func canCopyArticleURL() -> Bool { guard let selectedArticles else { return false @@ -1249,22 +1265,14 @@ private extension MainWindowController { let showCommand = NSLocalizedString("Show Read Feeds", comment: "Command") let hideCommand = NSLocalizedString("Hide Read Feeds", comment: "Command") - menuItem.title = sidebarViewController?.isReadFiltered ?? false ? showCommand : hideCommand + menuItem.title = AppDefaults.shared.timelineReadFilterEnabled ? showCommand : hideCommand return true } func validateToggleReadArticles(_ item: NSValidatedUserInterfaceItem) -> Bool { let showCommand = NSLocalizedString("Show Read Articles", comment: "Command") let hideCommand = NSLocalizedString("Hide Read Articles", comment: "Command") - - guard let isReadFiltered = timelineContainerViewController?.isReadFiltered else { - (item as? NSMenuItem)?.title = hideCommand - if let toolbarItem = item as? NSToolbarItem, let button = toolbarItem.view as? NSButton { - toolbarItem.toolTip = hideCommand - button.image = Assets.Images.filterInactive - } - return false - } + let isReadFiltered = AppDefaults.shared.timelineReadFilterEnabled if isReadFiltered { (item as? NSMenuItem)?.title = showCommand diff --git a/Mac/MainWindow/OPML/ImportOPMLWindowController.swift b/Mac/MainWindow/OPML/ImportOPMLWindowController.swift index 7f68d6ab3..ddc985db5 100644 --- a/Mac/MainWindow/OPML/ImportOPMLWindowController.swift +++ b/Mac/MainWindow/OPML/ImportOPMLWindowController.swift @@ -32,19 +32,20 @@ final class ImportOPMLWindowController: NSWindowController { if oneAccount.behaviors.contains(.disallowOPMLImports) { continue } - addMenuItem(to: menu, for: oneAccount, savedAccountID: savedAccountID) + let menuItem = addMenuItem(to: menu, for: oneAccount) + if oneAccount.accountID == savedAccountID { + accountPopUpButton.select(menuItem) + } } } - private func addMenuItem(to menu: NSMenu, for account: Account, savedAccountID: String?) { + @discardableResult + private func addMenuItem(to menu: NSMenu, for account: Account) -> NSMenuItem { let menuItem = NSMenuItem() menuItem.title = account.nameForDisplay menuItem.representedObject = account menu.addItem(menuItem) - - if account.accountID == savedAccountID { - accountPopUpButton.select(menuItem) - } + return menuItem } // MARK: API diff --git a/Mac/MainWindow/Sidebar/SidebarViewController.swift b/Mac/MainWindow/Sidebar/SidebarViewController.swift index c7a762349..2fabc1d65 100644 --- a/Mac/MainWindow/Sidebar/SidebarViewController.swift +++ b/Mac/MainWindow/Sidebar/SidebarViewController.swift @@ -47,9 +47,10 @@ extension Notification.Name { var isReadFiltered: Bool { get { - return treeControllerDelegate.isReadFiltered + return AppDefaults.shared.timelineReadFilterEnabled } set { + AppDefaults.shared.timelineReadFilterEnabled = newValue treeControllerDelegate.isReadFiltered = newValue } } @@ -69,6 +70,7 @@ extension Notification.Name { // MARK: - NSViewController override func viewDidLoad() { + treeControllerDelegate.isReadFiltered = isReadFiltered outlineView.dataSource = dataSource outlineView.doubleAction = #selector(doubleClickedSidebar(_:)) outlineView.setDraggingSourceOperationMask([.move, .copy], forLocal: true) @@ -518,15 +520,19 @@ extension Notification.Name { } func toggleReadFilter() { - if treeControllerDelegate.isReadFiltered { - isReadFiltered = false - } else { - isReadFiltered = true - } + isReadFiltered.toggle() delegate?.sidebarInvalidatedRestorationState(self) rebuildTreeAndRestoreSelection() } + func setReadFilterEnabled(_ isEnabled: Bool) { + guard treeControllerDelegate.isReadFiltered != isEnabled else { + return + } + isReadFiltered = isEnabled + rebuildTreeAndRestoreSelection() + } + } // MARK: - NSUserInterfaceValidations @@ -629,6 +635,7 @@ private extension SidebarViewController { func rebuildTreeAndReloadDataIfNeeded() { if !animatingChanges && !BatchUpdate.shared.isPerforming { + treeControllerDelegate.isReadFiltered = isReadFiltered addAllSelectedToFilterExceptions() treeController.rebuild() treeControllerDelegate.resetFilterExceptions() diff --git a/Mac/MainWindow/Timeline/TimelineContainerViewController.swift b/Mac/MainWindow/Timeline/TimelineContainerViewController.swift index e581ea199..c9586b5d5 100644 --- a/Mac/MainWindow/Timeline/TimelineContainerViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineContainerViewController.swift @@ -130,6 +130,11 @@ final class TimelineContainerViewController: NSViewController { updateReadFilterButton() } + func setReadFilterEnabled(_ isEnabled: Bool) { + regularTimelineViewController.setReadFilterEnabled(isEnabled) + updateReadFilterButton() + } + // MARK: State Restoration func restoreState(from state: TimelineWindowState?) { diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift index 2c57dc299..6dbccd37f 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController.swift @@ -30,7 +30,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr var sharingServicePickerDelegate: NSSharingServicePickerDelegate? - private var readFilterEnabledTable = [SidebarItemIdentifier: Bool]() + private var globalReadFilterEnabled = AppDefaults.shared.timelineReadFilterEnabled var isReadFiltered: Bool? { guard representedObjects?.count == 1, let timelineFeed = representedObjects?.first as? SidebarItem else { @@ -39,10 +39,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr guard timelineFeed.defaultReadFilterType != .alwaysRead else { return nil } - if let sidebarItemID = timelineFeed.sidebarItemID, let readFiltered = readFilterEnabledTable[sidebarItemID] { - return readFiltered - } - return timelineFeed.defaultReadFilterType == .read + return globalReadFilterEnabled } var isCleanUpAvailable: Bool { @@ -66,7 +63,6 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr var representedObjects: [AnyObject]? { didSet { if !representedObjectArraysAreEqual(oldValue, representedObjects) { - seedReadFilterForFolders() unreadCount = 0 selectionDidChange(nil) @@ -84,18 +80,15 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr } var windowState: TimelineWindowState { - let readArticlesFilterStateKeys = readFilterEnabledTable.keys.compactMap { $0.userInfo } - let readArticlesFilterStateValues = readFilterEnabledTable.values.compactMap( { $0 }) - if selectedArticles.count == 1 { let path = selectedArticles.first!.pathUserInfo - return TimelineWindowState(readArticlesFilterStateKeys: readArticlesFilterStateKeys, - readArticlesFilterStateValues: readArticlesFilterStateValues, + return TimelineWindowState(readArticlesFilterStateKeys: [], + readArticlesFilterStateValues: [], selectedAccountID: path[ArticlePathKey.accountID] as? String, selectedArticleID: path[ArticlePathKey.articleID] as? String) } else { - return TimelineWindowState(readArticlesFilterStateKeys: readArticlesFilterStateKeys, - readArticlesFilterStateValues: readArticlesFilterStateValues, + return TimelineWindowState(readArticlesFilterStateKeys: [], + readArticlesFilterStateValues: [], selectedAccountID: nil, selectedArticleID: nil) } @@ -289,42 +282,31 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr fetchAndReplacePreservingSelection() } - func toggleReadFilter() { - guard let filter = isReadFiltered, - let sidebarItemID = (representedObjects?.first as? SidebarItem)?.sidebarItemID else { + func setReadFilterEnabled(_ isEnabled: Bool) { + guard globalReadFilterEnabled != isEnabled else { return } - if filter { - noteSidebarItemShowsReadArticles(sidebarItemID) - } else { - noteSidebarItemHidesReadArticles(sidebarItemID) + globalReadFilterEnabled = isEnabled + AppDefaults.shared.timelineReadFilterEnabled = isEnabled + if representedObjects != nil { + fetchAndReplacePreservingSelection() + } + } + + func toggleReadFilter() { + guard let filter = isReadFiltered else { + return } + let newValue = !filter + globalReadFilterEnabled = newValue + AppDefaults.shared.timelineReadFilterEnabled = newValue delegate?.timelineInvalidatedRestorationState(self) fetchAndReplacePreservingSelection() } // MARK: State Restoration - private func noteSidebarItemHidesReadArticles(_ sidebarItemID: SidebarItemIdentifier) { - readFilterEnabledTable[sidebarItemID] = true - } - - private func noteSidebarItemShowsReadArticles(_ sidebarItemID: SidebarItemIdentifier) { - readFilterEnabledTable[sidebarItemID] = false - } - func restoreState(from state: TimelineWindowState) { - for i in 0..() } + let readFilterEnabledTable = readFilterTableForCurrentFetchers(fetchers) var fetchedArticles = Set
() for fetchers in fetchers { @@ -1245,6 +1217,7 @@ private extension TimelineViewController { precondition(Thread.isMainThread) cancelPendingAsyncFetches() let fetchers = representedObjects.compactMap { $0 as? ArticleFetcher } + let readFilterEnabledTable = readFilterTableForCurrentFetchers(fetchers) let fetchOperation = FetchRequestOperation(id: fetchSerialNumber, readFilterEnabledTable: readFilterEnabledTable, fetchers: fetchers) { [weak self] (articles, operation) in precondition(Thread.isMainThread) guard !operation.isCanceled, let strongSelf = self, operation.id == strongSelf.fetchSerialNumber else { @@ -1301,18 +1274,17 @@ private extension TimelineViewController { return representedObjects?.contains(where: { $0 is Folder }) ?? false } - func seedReadFilterForFolders() { - guard let representedObjects else { - return - } - for object in representedObjects { - guard let folder = object as? Folder, let sidebarItemID = folder.sidebarItemID else { + func readFilterTableForCurrentFetchers(_ fetchers: [ArticleFetcher]) -> [SidebarItemIdentifier: Bool] { + var table = [SidebarItemIdentifier: Bool]() + for fetcher in fetchers { + guard let sidebarItem = fetcher as? SidebarItem, + sidebarItem.defaultReadFilterType != .alwaysRead, + let sidebarItemID = sidebarItem.sidebarItemID else { continue } - if readFilterEnabledTable[sidebarItemID] == nil { - readFilterEnabledTable[sidebarItemID] = true - } + table[sidebarItemID] = globalReadFilterEnabled } + return table } func representedObjectsContainsAnyFeed(_ feeds: Set) -> Bool { diff --git a/Mac/Preferences/General/GeneralPrefencesViewController.swift b/Mac/Preferences/General/GeneralPrefencesViewController.swift index 2b906dd2d..f0ffa75e9 100644 --- a/Mac/Preferences/General/GeneralPrefencesViewController.swift +++ b/Mac/Preferences/General/GeneralPrefencesViewController.swift @@ -52,12 +52,8 @@ final class GeneralPreferencesViewController: NSViewController { NSWorkspace.shared.open(url) } - @IBAction func articleThemePopUpDidChange(_ sender: Any) { - guard let menuItem = articleThemePopup.selectedItem else { - return - } - ArticleThemesManager.shared.currentThemeName = menuItem.title - updateArticleThemePopup() + @IBAction func articleThemePopUpDidChange(_ sender: Any?) { + applySelectedArticleTheme() } @IBAction func browserPopUpDidChangeValue(_ sender: Any?) { @@ -133,6 +129,18 @@ private extension GeneralPreferencesViewController { } } + func applySelectedArticleTheme() { + let selectedThemeName: String + if let selectedItem = articleThemePopup.selectedItem { + selectedThemeName = selectedItem.title + } else { + selectedThemeName = ArticleTheme.defaultTheme.name + } + + ArticleThemesManager.shared.currentThemeName = selectedThemeName + updateArticleThemePopup() + } + func updateBrowserPopup() { let menu = defaultBrowserPopup.menu! let allBrowsers = MacWebBrowser.sortedBrowsers() diff --git a/iOS/HidingReadArticlesState.swift b/iOS/HidingReadArticlesState.swift index 67a0b1aad..842003c43 100644 --- a/iOS/HidingReadArticlesState.swift +++ b/iOS/HidingReadArticlesState.swift @@ -10,20 +10,15 @@ import Foundation import Account @MainActor final class HidingReadArticlesState { - private var smartFeedsHidingReadArticles = Set() - private var feedsHidingReadArticles = [String: Set]() // accountID: Set - private var foldersShowingReadArticles = [String: Set]() // accountID: Set - - func copy(from stateRestorationInfo: StateRestorationInfo) { - smartFeedsHidingReadArticles = stateRestorationInfo.smartFeedsHidingReadArticles - feedsHidingReadArticles = stateRestorationInfo.feedsHidingReadArticles - foldersShowingReadArticles = stateRestorationInfo.foldersShowingReadArticles + func copy(from _: StateRestorationInfo) { + // Uses global read filter state from AppDefaults.hideReadFeeds. } func save() { - saveSmartFeedsHidingReadArticles() - saveFeedsHidingReadArticles() - saveFoldersShowingReadArticles() + // Clear per-sidebar-item legacy state now that read filtering is global. + AppDefaults.shared.smartFeedsHidingReadArticles = [] + AppDefaults.shared.feedsHidingReadArticles = [:] + AppDefaults.shared.foldersShowingReadArticles = [:] } func toggleHidingReadArticles(for sidebarItemID: SidebarItemIdentifier) { @@ -32,35 +27,12 @@ import Account return } - let hidesReadArticles = isHidingReadArticles(for: sidebarItemID) - let toggledValue = !hidesReadArticles - saveHidingReadArticles(for: sidebarItemID, hiding: toggledValue) + AppDefaults.shared.hideReadFeeds.toggle() + save() } func isHidingReadArticles(for sidebarItemID: SidebarItemIdentifier) -> Bool { - switch sidebarItemID { - - case .smartFeed(let id): - if isUnreadSmartFeed(sidebarItemID) { - return true - } - return smartFeedsHidingReadArticles.contains(id) - - case .feed(let accountID, let feedID): - var isHidingReadArticles = false - if let feedIDs = feedsHidingReadArticles[accountID] { - isHidingReadArticles = feedIDs.contains(feedID) - } - return isHidingReadArticles - - case .folder(let accountID, let folderName): - // Folders hide read articles by default, so we check if not showing read articles. - var isHidingReadArticles = true - if let folderNames = foldersShowingReadArticles[accountID] { - isHidingReadArticles = !folderNames.contains(folderName) - } - return isHidingReadArticles - } + isUnreadSmartFeed(sidebarItemID) ? true : AppDefaults.shared.hideReadFeeds } func canToggleHidingReadArticles(for sidebarItemID: SidebarItemIdentifier) -> Bool { @@ -74,77 +46,4 @@ private extension HidingReadArticlesState { func isUnreadSmartFeed(_ sidebarItemID: SidebarItemIdentifier) -> Bool { sidebarItemID == SmartFeedsController.shared.unreadFeed.sidebarItemID } - - func saveHidingReadArticles(for sidebarItemID: SidebarItemIdentifier, hiding: Bool) { - switch sidebarItemID { - - case .smartFeed(let id): - if isUnreadSmartFeed(sidebarItemID) { - return - } - if hiding { - smartFeedsHidingReadArticles.insert(id) - } else { - smartFeedsHidingReadArticles.remove(id) - } - saveSmartFeedsHidingReadArticles() - - case .feed(let accountID, let feedID): - if hiding { - var feedIDs = feedsHidingReadArticles[accountID] ?? Set() - feedIDs.insert(feedID) - feedsHidingReadArticles[accountID] = feedIDs - } else { - feedsHidingReadArticles[accountID]?.remove(feedID) - } - saveFeedsHidingReadArticles() - - case .folder(let accountID, let folderName): - // Folders hide read articles by default, so we store the folder - // only if it's showing read articles. It's the opposite of - // feedsHidingReadArticles. - if hiding { - foldersShowingReadArticles[accountID]?.remove(folderName) - } else { - var folderNames = foldersShowingReadArticles[accountID] ?? Set() - folderNames.insert(folderName) - foldersShowingReadArticles[accountID] = folderNames - } - saveFoldersShowingReadArticles() - } - } - - func saveFoldersShowingReadArticles() { - var d = foldersShowingReadArticles - - // Filter out accounts and folders that no longer exist. - for accountID in Array(d.keys) { - guard let account = AccountManager.shared.existingAccount(accountID: accountID) else { - d[accountID] = nil - continue - } - d[accountID] = d[accountID]?.filter { account.existingFolder(withDisplayName: $0) != nil } - } - - AppDefaults.shared.foldersShowingReadArticles = d - } - - func saveFeedsHidingReadArticles() { - var d = feedsHidingReadArticles - - // Filter out accounts and feeds that no longer exist. - for accountID in Array(d.keys) { - guard let account = AccountManager.shared.existingAccount(accountID: accountID) else { - d[accountID] = nil - continue - } - d[accountID] = d[accountID]?.filter { account.existingFeed(withFeedID: $0) != nil } - } - - AppDefaults.shared.feedsHidingReadArticles = d - } - - func saveSmartFeedsHidingReadArticles() { - AppDefaults.shared.smartFeedsHidingReadArticles = smartFeedsHidingReadArticles - } } diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index feca7e731..25777c0a9 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -377,6 +377,7 @@ struct SidebarItemNode: Hashable, Sendable { // You can't assign the Feeds Read Filter until we've built the backing stores at least once or there is nothing // for state restoration to work with while we are waiting for the unread counts to initialize. + AppDefaults.shared.hideReadFeeds = stateInfo.hideReadFeeds treeControllerDelegate.isReadFiltered = stateInfo.hideReadFeeds restoreSelectedSidebarItemAndArticle(stateInfo) @@ -757,6 +758,7 @@ struct SidebarItemNode: Hashable, Sendable { treeControllerDelegate.isReadFiltered = newValue AppDefaults.shared.hideReadFeeds = newValue rebuildBackingStores() + refreshTimeline(resetScroll: false) mainFeedCollectionViewController?.updateUI() } @@ -772,6 +774,9 @@ struct SidebarItemNode: Hashable, Sendable { return } hidingReadArticlesState.toggleHidingReadArticles(for: sidebarItemID) + treeControllerDelegate.isReadFiltered = AppDefaults.shared.hideReadFeeds + rebuildBackingStores() + mainFeedCollectionViewController?.updateUI() refreshTimeline(resetScroll: false) }