diff --git a/Mac/AppDefaults.swift b/Mac/AppDefaults.swift index 66d952b7d..5caac7432 100644 --- a/Mac/AppDefaults.swift +++ b/Mac/AppDefaults.swift @@ -44,6 +44,7 @@ final class AppDefaults { static let currentThemeName = "currentThemeName" static let hasSeenNotAllArticlesHaveURLsAlert = "hasSeenNotAllArticlesHaveURLsAlert" static let twitterDeprecationAlertShown = "twitterDeprecationAlertShown" + static let markArticlesAsReadOnScroll = "markArticlesAsReadOnScroll" // Hidden prefs static let showDebugMenu = "ShowDebugMenu" @@ -329,6 +330,14 @@ final class AppDefaults { } } + var markArticlesAsReadOnScroll: Bool { + get { + return AppDefaults.bool(for: Key.markArticlesAsReadOnScroll) + } + set { + AppDefaults.setBool(for: Key.markArticlesAsReadOnScroll, newValue) + } + } func registerDefaults() { #if DEBUG diff --git a/Mac/Base.lproj/Preferences.storyboard b/Mac/Base.lproj/Preferences.storyboard index e8cd72511..b234339b0 100644 --- a/Mac/Base.lproj/Preferences.storyboard +++ b/Mac/Base.lproj/Preferences.storyboard @@ -32,22 +32,30 @@ - + - + - - + + + + + + + + + + - + @@ -76,7 +84,7 @@ - + @@ -91,7 +99,7 @@ - + - - + + - + @@ -127,7 +135,7 @@ - + @@ -155,18 +163,18 @@ - + - - + + - + @@ -201,15 +209,15 @@ - - + + - - + + + - - - - + + - + + - - + - - - - - + + - + - + + - - - + @@ -427,35 +449,40 @@ - - - + + - + + + - + @@ -475,7 +502,6 @@ - @@ -495,16 +521,16 @@ - + - + - + - + @@ -579,7 +605,7 @@ - + @@ -611,7 +637,7 @@ - + @@ -666,16 +692,16 @@ - + - + - + - + @@ -744,7 +770,7 @@ - + @@ -778,7 +804,7 @@ - + diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift index c15a59a7b..b7e55b9f4 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController.swift @@ -141,6 +141,11 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr var undoableCommands = [UndoableCommand]() + var articlesWithManuallyChangedReadStatus: Set
= Set() + private var firstVisibleRowIndexWhenDraggingBegan: Int = 0 + + private var isScrolling = false + private var fetchSerialNumber = 0 private let fetchRequestQueue = FetchRequestQueue() private var exceptionArticleFetcher: ArticleFetcher? @@ -194,6 +199,8 @@ 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) + convenience init(delegate: TimelineDelegate) { self.init(nibName: "TimelineTableView", bundle: nil) self.delegate = delegate @@ -328,6 +335,48 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr @objc func openArticleInBrowser(_ sender: Any?) { let urlStrings = selectedArticles.compactMap { $0.preferredLink } Browser.open(urlStrings, fromWindow: self.view.window, invertPreference: NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false) + + if let link = oneSelectedArticle?.preferredLink { + Browser.open(link, invertPreference: NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false) + } + } + + @objc func scrollViewDidScroll(notification: Notification){ + if isScrolling { + scrollPositionQueue.add(self, #selector(scrollPositionDidChange)) + } + } + + @objc func scrollViewWillStartLiveScroll(notification: Notification){ + isScrolling = true + firstVisibleRowIndexWhenDraggingBegan = tableView.rows(in: tableView.visibleRect).location + } + + @objc func scrollViewDidEndLiveScroll(notification: Notification){ + isScrolling = false + } + + @objc func scrollPositionDidChange(){ + if !AppDefaults.shared.markArticlesAsReadOnScroll { + return + } + + // Mark articles scrolled out of sight at the top as read + let firstVisibleRowIndex = tableView.rows(in: tableView.visibleRect).location + + let unreadArticlesScrolledAway = articles.articlesBetween( + upperPosition: firstVisibleRowIndexWhenDraggingBegan, lowerPosition: firstVisibleRowIndex).filter { !$0.status.read && !articlesWithManuallyChangedReadStatus.contains($0) } + + if unreadArticlesScrolledAway.isEmpty { return } + + guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: unreadArticlesScrolledAway, markingRead: true, directlyMarked: false, undoManager: undoManager) else { + return + } + runCommand(markReadCommand) + } + + func resetMarkAsReadOnScroll() { + articlesWithManuallyChangedReadStatus.removeAll() } @IBAction func toggleStatusOfSelectedArticles(_ sender: Any?) { diff --git a/Shared/Timeline/ArticleArray.swift b/Shared/Timeline/ArticleArray.swift index 2f8d69e06..af2a7ce12 100644 --- a/Shared/Timeline/ArticleArray.swift +++ b/Shared/Timeline/ArticleArray.swift @@ -103,6 +103,17 @@ extension Array where Element == Article { return true } + func articlesBetween(upperArticle: Article, lowerArticle: Article) -> [Article] { + guard let upperPosition = firstIndex(of: upperArticle), let lowerPosition = firstIndex(of: lowerArticle) else { return [] } + return articlesBetween(upperPosition: upperPosition, lowerPosition: lowerPosition) + } + + func articlesBetween(upperPosition: Int, lowerPosition: Int) -> [Article] { + guard upperPosition < count, lowerPosition < count, upperPosition <= lowerPosition else { return [] } + let articlesAbove = self[upperPosition...lowerPosition] + return Array(articlesAbove) + } + func articlesAbove(article: Article) -> [Article] { guard let position = firstIndex(of: article) else { return [] } return articlesAbove(position: position) diff --git a/iOS/AppDefaults.swift b/iOS/AppDefaults.swift index 91c355853..604500269 100644 --- a/iOS/AppDefaults.swift +++ b/iOS/AppDefaults.swift @@ -59,6 +59,7 @@ final class AppDefaults { static let useSystemBrowser = "useSystemBrowser" static let currentThemeName = "currentThemeName" static let twitterDeprecationAlertShown = "twitterDeprecationAlertShown" + static let markArticlesAsReadOnScroll = "markArticlesAsReadOnScroll" } let isDeveloperBuild: Bool = { @@ -233,6 +234,15 @@ final class AppDefaults { } } + var markArticlesAsReadOnScroll: Bool { + get { + return AppDefaults.bool(for: Key.markArticlesAsReadOnScroll) + } + set { + AppDefaults.setBool(for: Key.markArticlesAsReadOnScroll, newValue) + } + } + static func registerDefaults() { let defaults: [String : Any] = [Key.userInterfaceColorPalette: UserInterfaceColorPalette.automatic.rawValue, Key.timelineGroupByFeed: false, diff --git a/iOS/MasterTimeline/MasterTimelineViewController.swift b/iOS/MasterTimeline/MasterTimelineViewController.swift index 0297387b4..4395bb4d2 100644 --- a/iOS/MasterTimeline/MasterTimelineViewController.swift +++ b/iOS/MasterTimeline/MasterTimelineViewController.swift @@ -37,6 +37,8 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner var undoableCommands = [UndoableCommand]() let scrollPositionQueue = CoalescingQueue(name: "Timeline Scroll Position", interval: 0.3, maxInterval: 1.0) + private var firstVisibleArticleWhenDraggingBegan: Article? + private let keyboardManager = KeyboardManager(type: .timeline) override var keyCommands: [UIKeyCommand]? { @@ -433,8 +435,21 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner coordinator.selectArticle(article, animations: [.scroll, .select, .navigation]) } + override func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + guard let visibleRowIndexPaths = tableView.indexPathsForVisibleRows, visibleRowIndexPaths.count > 0 else { return } + let firstVisibleRowIndexPath = visibleRowIndexPaths[0] + + if scrollView.isTracking { + firstVisibleArticleWhenDraggingBegan = dataSource.itemIdentifier(for: firstVisibleRowIndexPath) + } else { + firstVisibleArticleWhenDraggingBegan = nil + } + } + override func scrollViewDidScroll(_ scrollView: UIScrollView) { - scrollPositionQueue.add(self, #selector(scrollPositionDidChange)) + if scrollView.isTracking { + scrollPositionQueue.add(self, #selector(scrollPositionDidChange)) + } } // MARK: Notifications @@ -532,6 +547,33 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner @objc func scrollPositionDidChange() { coordinator.timelineMiddleIndexPath = tableView.middleVisibleRow() + + if !AppDefaults.shared.markArticlesAsReadOnScroll { + return + } + + // Mark articles scrolled out of sight at the top as read + guard let visibleRowIndexPaths = tableView.indexPathsForVisibleRows, visibleRowIndexPaths.count > 0 else { return } + let firstVisibleRowIndexPath = visibleRowIndexPaths[0] + + guard let firstVisibleArticle = dataSource.itemIdentifier(for: firstVisibleRowIndexPath), let firstArticleScrolledAway = firstVisibleArticleWhenDraggingBegan else { + return + } + + guard let unreadArticlesScrolledAway = coordinator.articles + .articlesBetween(upperArticle: firstArticleScrolledAway, lowerArticle: firstVisibleArticle) + .filter({ !coordinator.directlyMarkedAsUnreadArticles.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, indexPath: indexPath) + } + } + } } // MARK: Reloading @@ -723,7 +765,6 @@ private extension MasterTimelineViewController { } func configure(_ cell: MasterTimelineTableViewCell, article: Article, indexPath: IndexPath) { - let iconImage = iconImageFor(article) let featuredImage = featuredImageFor(article) diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index f680568ff..b65b2dd59 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -110,7 +110,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, Logging { } } - private var directlyMarkedAsUnreadArticles = Set
() + var directlyMarkedAsUnreadArticles = Set
() var prefersStatusBarHidden = false diff --git a/iOS/Settings/Settings.storyboard b/iOS/Settings/Settings.storyboard index 68ff0e72c..e77cb6ce0 100644 --- a/iOS/Settings/Settings.storyboard +++ b/iOS/Settings/Settings.storyboard @@ -1,9 +1,9 @@ - + - + @@ -21,31 +21,14 @@ - + - + - - - - - - - - - - -