diff --git a/Mac/AppDefaults.swift b/Mac/AppDefaults.swift index df9b7c618..9a1ce53f0 100644 --- a/Mac/AppDefaults.swift +++ b/Mac/AppDefaults.swift @@ -33,6 +33,7 @@ final class AppDefaults { static let detailFontSize = "detailFontSize" static let openInBrowserInBackground = "openInBrowserInBackground" static let subscribeToFeedsInDefaultBrowser = "subscribeToFeedsInDefaultBrowser" + static let markArticlesAsReadOnScroll = "markArticlesAsReadOnScroll" static let articleTextSize = "articleTextSize" static let refreshInterval = "refreshInterval" static let addWebFeedAccountID = "addWebFeedAccountID" @@ -289,6 +290,16 @@ final class AppDefaults { return AppDefaults.bool(for: Key.timelineShowsSeparators) } + + var markArticlesAsReadOnScroll: Bool { + get { + return AppDefaults.bool(for: Key.markArticlesAsReadOnScroll) + } + set { + AppDefaults.setBool(for: Key.markArticlesAsReadOnScroll, newValue) + } + } + var articleTextSize: ArticleTextSize { get { let rawValue = UserDefaults.standard.integer(forKey: Key.articleTextSize) diff --git a/Mac/Base.lproj/Preferences.storyboard b/Mac/Base.lproj/Preferences.storyboard index bbe72eefa..051bc1082 100644 --- a/Mac/Base.lproj/Preferences.storyboard +++ b/Mac/Base.lproj/Preferences.storyboard @@ -1,8 +1,8 @@ - + - + @@ -31,23 +31,31 @@ - - + + - + - + + + + + + + + + - + @@ -76,7 +84,7 @@ - + @@ -91,7 +99,7 @@ - + - + @@ -112,7 +120,7 @@ - + @@ -127,7 +135,7 @@ - + @@ -155,10 +163,10 @@ - + - + @@ -166,7 +174,7 @@ - + @@ -201,7 +209,7 @@ - + @@ -209,7 +217,7 @@ - + + @@ -251,11 +279,14 @@ + + - + + @@ -268,6 +299,7 @@ + @@ -282,6 +314,8 @@ + + @@ -307,7 +341,7 @@ - + @@ -487,16 +521,16 @@ - + - + - + - + @@ -603,7 +637,7 @@ - + @@ -658,16 +692,16 @@ - + - + - + - + @@ -770,7 +804,7 @@ - + diff --git a/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift b/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift index dd83afdcb..42ec68d95 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift @@ -111,6 +111,9 @@ private extension TimelineViewController { func markArticles(_ articles: [Article], read: Bool) { markArticles(articles, statusKey: .read, flag: read) + for article in articles { + articlesWithManuallyChangedReadStatus.insert(article) + } } func markArticles(_ articles: [Article], starred: Bool) { diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift index 38cec256e..3234ca050 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController.swift @@ -70,6 +70,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr if showsSearchResults { fetchAndReplaceArticlesAsync() } else { + resetMarkAsReadOnScroll() fetchAndReplaceArticlesSync() if articles.count > 0 { tableView.scrollRowToVisible(0) @@ -138,6 +139,11 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr } var undoableCommands = [UndoableCommand]() + + var articlesWithManuallyChangedReadStatus: Set
= Set() + + private var isScrolling = false + private var fetchSerialNumber = 0 private let fetchRequestQueue = FetchRequestQueue() private var exceptionArticleFetcher: ArticleFetcher? @@ -191,6 +197,10 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr private let keyboardDelegate = TimelineKeyboardDelegate() private var timelineShowsSeparatorsObserver: NSKeyValueObservation? + private let scrollPositionQueue = CoalescingQueue(name: "Timeline Scroll Position", interval: 0.3, maxInterval: 1.0) + + private var markBottomArticlesAsReadWorkItem: DispatchWorkItem? + convenience init(delegate: TimelineDelegate) { self.init(nibName: "TimelineTableView", bundle: nil) self.delegate = delegate @@ -223,6 +233,16 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr NotificationCenter.default.addObserver(self, selector: #selector(containerChildrenDidChange(_:)), name: .ChildrenDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil) + if let scrollView = self.tableView.enclosingScrollView { + scrollView.contentView.postsBoundsChangedNotifications = true + + NotificationCenter.default.addObserver(self, selector: #selector(scrollViewDidScroll(notification:)), name: NSView.boundsDidChangeNotification, object: scrollView.contentView) + + NotificationCenter.default.addObserver(self, selector: #selector(scrollViewWillStartLiveScroll(notification:)), name: NSScrollView.willStartLiveScrollNotification, object: scrollView) + NotificationCenter.default.addObserver(self, selector: #selector(scrollViewDidEndLiveScroll(notification:)), name: NSScrollView.didEndLiveScrollNotification, object: scrollView) + + } + didRegisterForNotifications = true } } @@ -281,6 +301,8 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr } func restoreState(from state: [AnyHashable : Any]) { + resetMarkAsReadOnScroll() + guard let readArticlesFilterStateKeys = state[UserInfoKey.readArticlesFilterStateKeys] as? [[AnyHashable: AnyHashable]], let readArticlesFilterStateValues = state[UserInfoKey.readArticlesFilterStateValues] as? [Bool] else { return @@ -324,6 +346,66 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr } } + @objc func scrollViewDidScroll(notification: Notification){ + if isScrolling { + scrollPositionQueue.add(self, #selector(scrollPositionDidChange)) + } + } + + @objc func scrollViewWillStartLiveScroll(notification: Notification){ + isScrolling = true + } + + @objc func scrollViewDidEndLiveScroll(notification: Notification){ + isScrolling = false + } + + @objc func scrollPositionDidChange(){ + if !AppDefaults.shared.markArticlesAsReadOnScroll { + return + } + + // Mark all articles as read when the bottom of the feed is reached + let lastRowIndex = articles.count - 1 + let atBottom = tableView.rows(in: tableView.visibleRect).contains(lastRowIndex) + + if atBottom && markBottomArticlesAsReadWorkItem == nil { + let task = DispatchWorkItem { + let articlesToMarkAsRead = self.articles.filter { !$0.status.read && !self.articlesWithManuallyChangedReadStatus.contains($0) } + + if articlesToMarkAsRead.isEmpty { return } + guard let undoManager = self.undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articlesToMarkAsRead, markingRead: true, undoManager: undoManager) else { + return + } + self.runCommand(markReadCommand) + self.markBottomArticlesAsReadWorkItem = nil + } + + markBottomArticlesAsReadWorkItem = task + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) + } else if !atBottom, let task = markBottomArticlesAsReadWorkItem { + task.cancel() + markBottomArticlesAsReadWorkItem = nil + } + + + // Mark articles scrolled out of sight at the top as read + let firstVisibleRowIndex = tableView.rows(in: tableView.visibleRect).location + let unreadArticlesScrolledAway = articles.articlesAbove(position: firstVisibleRowIndex).filter { !$0.status.read && !articlesWithManuallyChangedReadStatus.contains($0) } + + if unreadArticlesScrolledAway.isEmpty { return } + + guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: unreadArticlesScrolledAway, markingRead: true, undoManager: undoManager) else { + return + } + runCommand(markReadCommand) + } + + func resetMarkAsReadOnScroll() { + articlesWithManuallyChangedReadStatus.removeAll() + markBottomArticlesAsReadWorkItem?.cancel() + } + @IBAction func toggleStatusOfSelectedArticles(_ sender: Any?) { guard !selectedArticles.isEmpty else { return @@ -345,6 +427,9 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr return } runCommand(markReadCommand) + for article in selectedArticles { + articlesWithManuallyChangedReadStatus.insert(article) + } } @IBAction func markSelectedArticlesAsUnread(_ sender: Any?) { @@ -352,6 +437,9 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr return } runCommand(markUnreadCommand) + for article in selectedArticles { + articlesWithManuallyChangedReadStatus.insert(article) + } } @IBAction func copy(_ sender: Any?) { @@ -903,6 +991,7 @@ extension TimelineViewController: NSTableViewDelegate { return } self.runCommand(markUnreadCommand) + articlesWithManuallyChangedReadStatus.insert(article) } private func toggleArticleStarred(_ article: Article) { diff --git a/iOS/AppDefaults.swift b/iOS/AppDefaults.swift index c7a01f33a..17ffbeb1a 100644 --- a/iOS/AppDefaults.swift +++ b/iOS/AppDefaults.swift @@ -46,6 +46,7 @@ final class AppDefaults { static let firstRunDate = "firstRunDate" static let timelineGroupByFeed = "timelineGroupByFeed" static let refreshClearsReadArticles = "refreshClearsReadArticles" + static let markArticlesAsReadOnScroll = "markArticlesAsReadOnScroll" static let timelineNumberOfLines = "timelineNumberOfLines" static let timelineIconDimension = "timelineIconSize" static let timelineSortDirection = "timelineSortDirection" @@ -159,6 +160,15 @@ final class AppDefaults { } } + var markArticlesAsReadOnScroll: Bool { + get { + return AppDefaults.bool(for: Key.markArticlesAsReadOnScroll) + } + set { + AppDefaults.setBool(for: Key.markArticlesAsReadOnScroll, newValue) + } + } + var timelineSortDirection: ComparisonResult { get { return AppDefaults.sortDirection(for: Key.timelineSortDirection) @@ -236,6 +246,7 @@ final class AppDefaults { let defaults: [String : Any] = [Key.userInterfaceColorPalette: UserInterfaceColorPalette.automatic.rawValue, Key.timelineGroupByFeed: false, Key.refreshClearsReadArticles: false, + Key.markArticlesAsReadOnScroll: false, Key.timelineNumberOfLines: 2, Key.timelineIconDimension: IconSize.medium.rawValue, Key.timelineSortDirection: ComparisonResult.orderedDescending.rawValue, diff --git a/iOS/MasterTimeline/MasterTimelineViewController.swift b/iOS/MasterTimeline/MasterTimelineViewController.swift index 72a019828..4b05cea59 100644 --- a/iOS/MasterTimeline/MasterTimelineViewController.swift +++ b/iOS/MasterTimeline/MasterTimelineViewController.swift @@ -418,7 +418,9 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner } override func scrollViewDidScroll(_ scrollView: UIScrollView) { - scrollPositionQueue.add(self, #selector(scrollPositionDidChange)) + if scrollView.isTracking { + scrollPositionQueue.add(self, #selector(scrollPositionDidChange)) + } } // MARK: Notifications @@ -516,6 +518,53 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner @objc func scrollPositionDidChange() { coordinator.timelineMiddleIndexPath = tableView.middleVisibleRow() + + if !AppDefaults.shared.markArticlesAsReadOnScroll { + return + } + + // Mark all articles as read when the bottom of the feed is reached + if let lastVisibleRowIndexPath = tableView.indexPathsForVisibleRows?.last { + let atBottom = dataSource.itemIdentifier(for: lastVisibleRowIndexPath) == coordinator.articles.last + + if atBottom && coordinator.markBottomArticlesAsReadWorkItem == nil { + let task = DispatchWorkItem { + let articlesToMarkAsRead = self.coordinator.articles.filter { !$0.status.read && !self.coordinator.articlesWithManuallyChangedReadStatus.contains($0) } + + if articlesToMarkAsRead.isEmpty { return } + self.coordinator.markAllAsRead(articlesToMarkAsRead) + self.coordinator.markBottomArticlesAsReadWorkItem = nil + } + + coordinator.markBottomArticlesAsReadWorkItem = task + DispatchQueue.main.asyncAfter(deadline: .now() + 2, execute: task) + } else if !atBottom, let task = coordinator.markBottomArticlesAsReadWorkItem { + task.cancel() + coordinator.markBottomArticlesAsReadWorkItem = nil + } + } + + // Mark articles scrolled out of sight at the top as read + guard let firstVisibleRowIndexPath = tableView.indexPathsForVisibleRows?[0] else { return } + + guard let firstVisibleArticle = dataSource.itemIdentifier(for: firstVisibleRowIndexPath) else { + return + } + + guard let unreadArticlesScrolledAway = coordinator.articles + .articlesAbove(article: firstVisibleArticle) + .filter({ !coordinator.articlesWithManuallyChangedReadStatus.contains($0) }) + .unreadArticles() else { return } + + coordinator.markAllAsRead(unreadArticlesScrolledAway) + + for article in unreadArticlesScrolledAway { + if let indexPath = dataSource.indexPath(for: article) { + if let cell = tableView.cellForRow(at: indexPath) as? MasterTimelineTableViewCell { + configure(cell, article: article) + } + } + } } // MARK: Reloading diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index 2f411e3be..c03018039 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -182,6 +182,9 @@ class SceneCoordinator: NSObject, UndoableCommandRunner { private(set) var showFeedNames = ShowFeedName.none private(set) var showIcons = false + var articlesWithManuallyChangedReadStatus: Set
= Set() + var markBottomArticlesAsReadWorkItem: DispatchWorkItem? + var prevFeedIndexPath: IndexPath? { guard let indexPath = currentFeedIndexPath else { return nil @@ -783,6 +786,9 @@ class SceneCoordinator: NSObject, UndoableCommandRunner { return } + articlesWithManuallyChangedReadStatus.removeAll() + markBottomArticlesAsReadWorkItem?.cancel() + currentFeedIndexPath = indexPath masterFeedViewController.updateFeedSelection(animations: animations) @@ -1073,24 +1079,28 @@ class SceneCoordinator: NSObject, UndoableCommandRunner { func markAsReadForCurrentArticle() { if let article = currentArticle { markArticlesWithUndo([article], statusKey: .read, flag: true) + articlesWithManuallyChangedReadStatus.insert(article) } } func markAsUnreadForCurrentArticle() { if let article = currentArticle { markArticlesWithUndo([article], statusKey: .read, flag: false) + articlesWithManuallyChangedReadStatus.insert(article) } } func toggleReadForCurrentArticle() { if let article = currentArticle { toggleRead(article) + articlesWithManuallyChangedReadStatus.insert(article) } } func toggleRead(_ article: Article) { guard !article.status.read || article.isAvailableToMarkUnread else { return } markArticlesWithUndo([article], statusKey: .read, flag: !article.status.read) + articlesWithManuallyChangedReadStatus.insert(article) } func toggleStarredForCurrentArticle() { diff --git a/iOS/Settings/Settings.storyboard b/iOS/Settings/Settings.storyboard index 9cb9246b5..e77cb6ce0 100644 --- a/iOS/Settings/Settings.storyboard +++ b/iOS/Settings/Settings.storyboard @@ -237,6 +237,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -617,6 +650,7 @@ + diff --git a/iOS/Settings/SettingsViewController.swift b/iOS/Settings/SettingsViewController.swift index 23811c861..0d39bbe15 100644 --- a/iOS/Settings/SettingsViewController.swift +++ b/iOS/Settings/SettingsViewController.swift @@ -19,6 +19,7 @@ class SettingsViewController: UITableViewController { @IBOutlet weak var timelineSortOrderSwitch: UISwitch! @IBOutlet weak var groupByFeedSwitch: UISwitch! @IBOutlet weak var refreshClearsReadArticlesSwitch: UISwitch! + @IBOutlet weak var markArticlesAsReadOnScrollSwitch: UISwitch! @IBOutlet weak var articleThemeDetailLabel: UILabel! @IBOutlet weak var confirmMarkAllAsReadSwitch: UISwitch! @IBOutlet weak var showFullscreenArticlesSwitch: UISwitch! @@ -66,6 +67,13 @@ class SettingsViewController: UITableViewController { } else { refreshClearsReadArticlesSwitch.isOn = false } + + + if AppDefaults.shared.markArticlesAsReadOnScroll { + markArticlesAsReadOnScrollSwitch.isOn = true + } else { + markArticlesAsReadOnScrollSwitch.isOn = false + } articleThemeDetailLabel.text = ArticleThemesManager.shared.currentTheme.name @@ -326,6 +334,14 @@ class SettingsViewController: UITableViewController { } } + @IBAction func switchMarkArticlesAsReadOnScroll(_ sender: Any) { + if markArticlesAsReadOnScrollSwitch.isOn { + AppDefaults.shared.markArticlesAsReadOnScroll = true + } else { + AppDefaults.shared.markArticlesAsReadOnScroll = false + } + } + @IBAction func switchConfirmMarkAllAsRead(_ sender: Any) { if confirmMarkAllAsReadSwitch.isOn { AppDefaults.shared.confirmMarkAllAsRead = true