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: 36 additions & 0 deletions 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 feedReadFilterOverrides = "feedReadFilterOverrides"
static let hideReadArticles = "hideReadArticles"

// Hidden prefs
static let showDebugMenu = "ShowDebugMenu"
Expand Down Expand Up @@ -319,6 +321,40 @@ final class AppDefaults: Sendable {
}
}

var feedReadFilterOverrides: FeedReadFilterOverrides {
get {
FeedReadFilterOverrides(data: UserDefaults.standard.data(forKey: Key.feedReadFilterOverrides))
}
set {
UserDefaults.standard.set(newValue.data, forKey: Key.feedReadFilterOverrides)
}
}

var hideReadArticles: Bool {
get {
return AppDefaults.bool(for: Key.hideReadArticles)
}
set {
AppDefaults.setBool(for: Key.hideReadArticles, newValue)
}
}

func setFeedHideReadOverride(accountID: String, feedID: String, enabled: Bool) {
var overrides = feedReadFilterOverrides
if enabled {
overrides.setOverride(accountID: accountID, feedID: feedID, hideReadArticles ? .show : .hide)
} else {
overrides.clearOverride(accountID: accountID, feedID: feedID)
}
feedReadFilterOverrides = overrides
}

func clearFeedHideReadOverrides(accountID: String) {
var overrides = feedReadFilterOverrides
overrides.clearAll(accountID: accountID)
feedReadFilterOverrides = overrides
}

init() {
// Migrate every-10-minute refresh interval to 30 minutes.
let rawValue = UserDefaults.standard.integer(forKey: Key.refreshInterval)
Expand Down
40 changes: 37 additions & 3 deletions Mac/Base.lproj/Preferences.storyboard
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,11 @@
<objects>
<viewController title="General" storyboardIdentifier="General" id="iuH-lz-18x" customClass="GeneralPreferencesViewController" customModule="NetNewsWire" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" id="WnV-px-wCT">
<rect key="frame" x="0.0" y="0.0" width="509" height="460"/>
<rect key="frame" x="0.0" y="0.0" width="509" height="525"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<customView horizontalHuggingPriority="1000" verticalHuggingPriority="1000" horizontalCompressionResistancePriority="1000" verticalCompressionResistancePriority="1000" translatesAutoresizingMaskIntoConstraints="NO" id="Ut3-yd-q6G">
<rect key="frame" x="57" y="16" width="393" height="428"/>
<rect key="frame" x="57" y="16" width="393" height="493"/>
<subviews>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="pR2-Bf-7Fd">
<rect key="frame" x="-2" y="404" width="106" height="16"/>
Expand Down Expand Up @@ -277,6 +277,34 @@
<constraint firstAttribute="width" relation="greaterThanOrEqual" constant="200" id="N1a-qV-4Os"/>
</constraints>
</popUpButton>
<textField focusRingType="none" horizontalHuggingPriority="251" verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="av1-Lb-Hdr">
<rect key="frame" x="-2" y="0.0" width="106" height="16"/>
<textFieldCell key="cell" lineBreakMode="truncatingTail" sendsActionOnEndEditing="YES" alignment="right" title="Article Visibility:" id="av1-Tc-Hdr">
<font key="font" metaFont="system"/>
<color key="textColor" name="labelColor" catalog="System" colorSpace="catalog"/>
<color key="backgroundColor" name="controlColor" catalog="System" colorSpace="catalog"/>
</textFieldCell>
</textField>
<button verticalHuggingPriority="750" translatesAutoresizingMaskIntoConstraints="NO" id="hr1-Cb-Hrd">
<rect key="frame" x="110" y="0.0" width="283" height="16"/>
<buttonCell key="cell" type="check" title="Hide read articles by default" bezelStyle="regularSquare" imagePosition="left" inset="2" id="hr1-Bc-Hrd">
<behavior key="behavior" changeContents="YES" doesNotDimImage="YES" lightByContents="YES"/>
<font key="font" metaFont="system"/>
</buttonCell>
<connections>
<binding destination="mAF-gO-1PI" name="value" keyPath="values.hideReadArticles" id="hr1-Bd-Hrd">
<dictionary key="options">
<bool key="NSAllowsEditingMultipleValuesSelection" value="NO"/>
<bool key="NSConditionallySetsEnabled" value="NO"/>
<integer key="NSMultipleValuesPlaceholder" value="0"/>
<integer key="NSNoSelectionPlaceholder" value="0"/>
<integer key="NSNotApplicablePlaceholder" value="0"/>
<integer key="NSNullPlaceholder" value="0"/>
<bool key="NSRaisesForNotApplicableKeys" value="NO"/>
</dictionary>
</binding>
</connections>
</button>
</subviews>
<constraints>
<constraint firstItem="Ci4-fW-KjU" firstAttribute="leading" secondItem="Z6O-Zt-V1g" secondAttribute="leading" id="0Do-jh-Hqq"/>
Expand All @@ -291,7 +319,7 @@
<constraint firstItem="yrg-M3-Dbz" firstAttribute="leading" secondItem="Ut3-yd-q6G" secondAttribute="leading" id="Bmt-Mn-CCl"/>
<constraint firstItem="UI6-sq-M15" firstAttribute="top" secondItem="1w0-nA-DEO" secondAttribute="bottom" constant="12" id="DMk-OP-dpa"/>
<constraint firstItem="Ubm-Pk-l7x" firstAttribute="top" secondItem="Ci4-fW-KjU" secondAttribute="bottom" constant="12" id="E3r-xf-7aZ"/>
<constraint firstAttribute="bottom" secondItem="SFF-mL-yc8" secondAttribute="bottom" constant="4" id="FAd-wh-IFu"/>
<constraint firstAttribute="bottom" secondItem="hr1-Cb-Hrd" secondAttribute="bottom" constant="4" id="FAd-wh-IFu"/>
<constraint firstItem="pR2-Bf-7Fd" firstAttribute="leading" secondItem="Ut3-yd-q6G" secondAttribute="leading" id="G0C-1M-LW1"/>
<constraint firstItem="yrg-M3-Dbz" firstAttribute="trailing" secondItem="pR2-Bf-7Fd" secondAttribute="trailing" id="JbY-CP-pK8"/>
<constraint firstAttribute="trailing" secondItem="Ubm-Pk-l7x" secondAttribute="trailing" id="KIg-w5-ceG"/>
Expand Down Expand Up @@ -337,6 +365,12 @@
<constraint firstItem="j0t-Wa-UTL" firstAttribute="leading" secondItem="Z6O-Zt-V1g" secondAttribute="leading" constant="22" id="xSA-4l-R2v"/>
<constraint firstAttribute="trailing" secondItem="ISO-Wu-R60" secondAttribute="trailing" constant="1" id="xaY-qS-LSN"/>
<constraint firstItem="ucw-vG-yLt" firstAttribute="leading" secondItem="Ut3-yd-q6G" secondAttribute="leading" id="xmq-Ob-wCJ"/>
<constraint firstItem="av1-Lb-Hdr" firstAttribute="leading" secondItem="Ut3-yd-q6G" secondAttribute="leading" id="av1-C2-Ldg"/>
<constraint firstItem="av1-Lb-Hdr" firstAttribute="trailing" secondItem="pR2-Bf-7Fd" secondAttribute="trailing" id="av1-C3-Trl"/>
<constraint firstItem="av1-Lb-Hdr" firstAttribute="firstBaseline" secondItem="hr1-Cb-Hrd" secondAttribute="firstBaseline" id="av1-C4-Bsl"/>
<constraint firstItem="hr1-Cb-Hrd" firstAttribute="leading" secondItem="Z6O-Zt-V1g" secondAttribute="leading" id="hr1-C7-Cbx"/>
<constraint firstItem="hr1-Cb-Hrd" firstAttribute="top" secondItem="SFF-mL-yc8" secondAttribute="bottom" constant="12" id="hr1-C8-Cbx"/>
<constraint firstAttribute="trailing" secondItem="hr1-Cb-Hrd" secondAttribute="trailing" id="hr1-C9-Cbx"/>
</constraints>
</customView>
</subviews>
Expand Down
154 changes: 128 additions & 26 deletions Mac/MainWindow/Timeline/TimelineViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,19 +31,33 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr

var sharingServicePickerDelegate: NSSharingServicePickerDelegate?

private var hideReadArticles = AppDefaults.shared.hideReadArticles
private var cachedFeedReadFilterOverrides = AppDefaults.shared.feedReadFilterOverrides
private var readFilterEnabledTable = [SidebarItemIdentifier: Bool]()

var isReadFiltered: Bool? {
guard representedObjects?.count == 1, let timelineFeed = representedObjects?.first as? SidebarItem else {
guard representedObjects?.count == 1,
let timelineFeed = representedObjects?.first as? SidebarItem,
timelineFeed.defaultReadFilterType != .alwaysRead else {
return nil
}
guard timelineFeed.defaultReadFilterType != .alwaysRead else {
return nil
if let sidebarItemID = timelineFeed.sidebarItemID {
if let readFiltered = readFilterEnabledTable[sidebarItemID] {
return readFiltered
}
if let override = persistedFeedOverride(sidebarItemID) {
return override
}
}
if let sidebarItemID = timelineFeed.sidebarItemID, let readFiltered = readFilterEnabledTable[sidebarItemID] {
return readFiltered
return hideReadArticles || timelineFeed.defaultReadFilterType == .read
}

private func persistedFeedOverride(_ sidebarItemID: SidebarItemIdentifier) -> Bool? {
guard case .feed(let accountID, let feedID) = sidebarItemID,
let override = cachedFeedReadFilterOverrides.override(accountID: accountID, feedID: feedID) else {
return nil
}
return timelineFeed.defaultReadFilterType == .read
return override == .hide
}

var isCleanUpAvailable: Bool {
Expand All @@ -68,6 +82,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
didSet {
if !representedObjectArraysAreEqual(oldValue, representedObjects) {
seedReadFilterForFolders()
seedReadFilterFromOverrides()
unreadCount = 0

selectionDidChange(nil)
Expand All @@ -85,8 +100,17 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
}

var windowState: TimelineWindowState {
let readArticlesFilterStateKeys = readFilterEnabledTable.keys.compactMap { $0.userInfo }
let readArticlesFilterStateValues = readFilterEnabledTable.values.compactMap( { $0 })
// Feed read-filter state lives in feedReadFilterOverrides (UserDefaults); window
// state persists only folder and smart-feed entries.
var readArticlesFilterStateKeys = [[String: String]]()
var readArticlesFilterStateValues = [Bool]()
for (sidebarItemID, hidesReadArticles) in readFilterEnabledTable {
if case .feed = sidebarItemID {
continue
}
readArticlesFilterStateKeys.append(sidebarItemID.userInfo)
readArticlesFilterStateValues.append(hidesReadArticles)
}

if selectedArticles.count == 1 {
let path = selectedArticles.first!.pathUserInfo
Expand Down Expand Up @@ -309,21 +333,50 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr

private func noteSidebarItemHidesReadArticles(_ sidebarItemID: SidebarItemIdentifier) {
readFilterEnabledTable[sidebarItemID] = true
persistFeedOverride(sidebarItemID, hiding: true)
}

private func noteSidebarItemShowsReadArticles(_ sidebarItemID: SidebarItemIdentifier) {
readFilterEnabledTable[sidebarItemID] = false
persistFeedOverride(sidebarItemID, hiding: false)
}

/// Restore a sidebar item's read-filter state from window state.
///
/// For feeds, `feedReadFilterOverrides` is authoritative: migrate the legacy
/// window-state value only when no override exists yet, and never let it
/// overwrite a stored override in the active table. Folders and smart feeds
/// continue to restore directly from window state.
private func restoreReadFilterState(_ sidebarItemID: SidebarItemIdentifier, hidesReadArticles: Bool) {
if case .feed = sidebarItemID {
migrateLegacyFeedReadFilterIfNeeded(sidebarItemID, hiding: hidesReadArticles)
} else {
readFilterEnabledTable[sidebarItemID] = hidesReadArticles
}
}

private func persistFeedOverride(_ sidebarItemID: SidebarItemIdentifier, hiding: Bool) {
guard case let .feed(accountID, feedID) = sidebarItemID else {
return
}
cachedFeedReadFilterOverrides.setOverride(accountID: accountID, feedID: feedID, hiding ? .hide : .show)
AppDefaults.shared.feedReadFilterOverrides = cachedFeedReadFilterOverrides
}

/// Migrate a feed's legacy window-state read-filter setting into
/// feedReadFilterOverrides, without overwriting an existing override.
private func migrateLegacyFeedReadFilterIfNeeded(_ sidebarItemID: SidebarItemIdentifier, hiding: Bool) {
guard case let .feed(accountID, feedID) = sidebarItemID,
cachedFeedReadFilterOverrides.override(accountID: accountID, feedID: feedID) == nil else {
return
}
persistFeedOverride(sidebarItemID, hiding: hiding)
}

func restoreState(from state: TimelineWindowState) {
for i in 0..<state.readArticlesFilterStateKeys.count {
if let sidebarItemID = SidebarItemIdentifier(userInfo: state.readArticlesFilterStateKeys[i]) {
let hidesReadArticles = state.readArticlesFilterStateValues[i]
if hidesReadArticles {
noteSidebarItemHidesReadArticles(sidebarItemID)
} else {
noteSidebarItemShowsReadArticles(sidebarItemID)
}
for (key, hidesReadArticles) in zip(state.readArticlesFilterStateKeys, state.readArticlesFilterStateValues) {
if let sidebarItemID = SidebarItemIdentifier(userInfo: key) {
restoreReadFilterState(sidebarItemID, hidesReadArticles: hidesReadArticles)
}
}

Expand Down Expand Up @@ -355,14 +408,9 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
return
}

for i in 0..<readArticlesFilterStateKeys.count {
if let sidebarItemID = SidebarItemIdentifier(userInfo: readArticlesFilterStateKeys[i]) {
let hidesReadArticles = readArticlesFilterStateValues[i]
if hidesReadArticles {
noteSidebarItemHidesReadArticles(sidebarItemID)
} else {
noteSidebarItemShowsReadArticles(sidebarItemID)
}
for (key, hidesReadArticles) in zip(readArticlesFilterStateKeys, readArticlesFilterStateValues) {
if let sidebarItemID = SidebarItemIdentifier(userInfo: key) {
restoreReadFilterState(sidebarItemID, hidesReadArticles: hidesReadArticles)
}
}

Expand Down Expand Up @@ -734,6 +782,9 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr

@objc func userDidDeleteAccount(_ note: Notification) {
undoManager?.removeAllActions() // Undo stack may contain actions for the deleted account.
if let account = note.userInfo?[Account.UserInfoKey.account] as? Account {
AppDefaults.shared.clearFeedHideReadOverrides(accountID: account.accountID)
}
if representedObjectsContainsAnyPseudoFeed() {
fetchAndReplaceArticlesAsync()
}
Expand All @@ -747,8 +798,43 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr

@MainActor func userDefaultsDidChange() {
fontSize = AppDefaults.shared.timelineFontSize
let newHideReadArticles = AppDefaults.shared.hideReadArticles
let hideReadArticlesDidChange = hideReadArticles != newHideReadArticles
hideReadArticles = newHideReadArticles
let readFilterTableDidChange = syncReadFilterTableFromDefaults()
sortDirection = AppDefaults.shared.timelineSortDirection
groupByFeed = AppDefaults.shared.timelineGroupByFeed
if hideReadArticlesDidChange || readFilterTableDidChange {
fetchAndReplacePreservingSelection()
}
}

private func syncReadFilterTableFromDefaults() -> Bool {
let overrides = AppDefaults.shared.feedReadFilterOverrides
guard overrides != cachedFeedReadFilterOverrides else {
return false
}
cachedFeedReadFilterOverrides = overrides

var persistedOverrides = [SidebarItemIdentifier: Bool]()
for entry in overrides.allFeeds() {
persistedOverrides[.feed(entry.accountID, entry.feedID)] = (entry.override == .hide)
}

var changed = false
for (id, value) in persistedOverrides {
if readFilterEnabledTable[id] == nil || readFilterEnabledTable[id] != value {
readFilterEnabledTable[id] = value
changed = true
}
}
for (id, _) in readFilterEnabledTable {
if case .feed = id, persistedOverrides[id] == nil {
readFilterEnabledTable[id] = nil
changed = true
}
}
return changed
}

// MARK: - Reloading Data
Expand Down Expand Up @@ -1227,7 +1313,7 @@ private extension TimelineViewController {

var fetchedArticles = Set<Article>()
for fetchers in fetchers {
if (fetchers as? SidebarItem)?.readFiltered(readFilterEnabledTable: readFilterEnabledTable) ?? true {
if (fetchers as? SidebarItem)?.readFiltered(readFilterEnabledTable: readFilterEnabledTable, globalHideReadArticles: hideReadArticles) ?? true {
let articles = fetchers.fetchUnreadArticles()
fetchedArticles.formUnion(articles)
} else {
Expand All @@ -1244,7 +1330,7 @@ private extension TimelineViewController {
precondition(Thread.isMainThread)
cancelPendingAsyncFetches()
let fetchers = representedObjects.compactMap { $0 as? ArticleFetcher }
let fetchOperation = FetchRequestOperation(id: fetchSerialNumber, readFilterEnabledTable: readFilterEnabledTable, fetchers: fetchers) { [weak self] (articles, operation) in
let fetchOperation = FetchRequestOperation(id: fetchSerialNumber, readFilterEnabledTable: readFilterEnabledTable, globalHideReadArticles: hideReadArticles, fetchers: fetchers) { [weak self] (articles, operation) in
precondition(Thread.isMainThread)
guard !operation.isCanceled, let strongSelf = self, operation.id == strongSelf.fetchSerialNumber else {
return
Expand Down Expand Up @@ -1314,6 +1400,22 @@ private extension TimelineViewController {
}
}

/// Seed the represented feed's read-filter state from feedReadFilterOverrides,
/// since feed entries are no longer restored from window state.
private func seedReadFilterFromOverrides() {
guard let representedObjects else {
return
}
for object in representedObjects {
guard let feed = object as? Feed, let sidebarItemID = feed.sidebarItemID,
readFilterEnabledTable[sidebarItemID] == nil,
let override = cachedFeedReadFilterOverrides.override(accountID: feed.accountID, feedID: feed.feedID) else {
continue
}
readFilterEnabledTable[sidebarItemID] = override == .hide
}
}

func representedObjectsContainsAnyFeed(_ feeds: Set<Feed>) -> Bool {
// Return true if there’s a match or if a folder contains (recursively) one of feeds

Expand Down
Loading