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..ec3c0ba73 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 +455,40 @@ - - - + + - + + + - + @@ -475,7 +508,6 @@ - @@ -495,16 +527,16 @@ - + - + - + - + @@ -579,7 +611,7 @@ - + @@ -611,7 +643,7 @@ - + @@ -642,7 +674,7 @@ - + @@ -666,16 +698,16 @@ - + - + - + - + @@ -744,7 +776,7 @@ - + @@ -778,7 +810,7 @@ - + @@ -809,7 +841,7 @@ - + diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift index 30ce24961..c348af5bd 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController.swift @@ -124,6 +124,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr } directlyMarkedAsUnreadArticles = Set
() + lastVerticlePosition = 0 articleRowMap = [String: [Int]]() tableView.reloadData() } @@ -194,6 +195,11 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr private let keyboardDelegate = TimelineKeyboardDelegate() private var timelineShowsSeparatorsObserver: NSKeyValueObservation? + private var markAsReadOnScrollWorkItem: DispatchWorkItem? + private var markAsReadOnScrollStart: Int? + private var markAsReadOnScrollEnd: Int? + private var lastVerticlePosition: CGFloat = 0 + convenience init(delegate: TimelineDelegate) { self.init(nibName: "TimelineTableView", bundle: nil) self.delegate = delegate @@ -224,6 +230,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(markStatusCommandDidDirectMarking(_:)), name: .MarkStatusCommandDidDirectMarking, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(markStatusCommandDidUndoDirectMarking(_:)), name: .MarkStatusCommandDidUndoDirectMarking, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(scrollViewDidScroll), name: NSScrollView.didLiveScrollNotification, object: tableView.enclosingScrollView) didRegisterForNotifications = true } } @@ -235,6 +242,10 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr // MARK: - API func markAllAsRead(completion: (() -> Void)? = nil) { + markAllAsRead(articles, completion: completion) + } + + func markAllAsRead(_ articles: [Article], completion: (() -> Void)? = nil) { let markableArticles = Set(articles).subtracting(directlyMarkedAsUnreadArticles) guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: markableArticles, @@ -329,6 +340,10 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr let urlStrings = selectedArticles.compactMap { $0.preferredLink } Browser.open(urlStrings, fromWindow: self.view.window, invertPreference: NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false) } + + @objc func scrollViewDidScroll(notification: Notification) { + markAsReadOnScroll() + } @IBAction func toggleStatusOfSelectedArticles(_ sender: Any?) { guard !selectedArticles.isEmpty else { @@ -1326,4 +1341,51 @@ private extension TimelineViewController { } return false } + + func markAsReadOnScroll() { + guard AppDefaults.shared.markArticlesAsReadOnScroll else { return } + + // Only try to mark if we are scrolling up + defer { + lastVerticlePosition = tableView.enclosingScrollView?.documentVisibleRect.origin.y ?? 0 + } + guard lastVerticlePosition < tableView.enclosingScrollView?.documentVisibleRect.origin.y ?? 0 else { + return + } + + // Make sure we are a little past the visible area so that marking isn't too touchy + let firstVisibleRowIndex = tableView.rows(in: tableView.visibleRect).location + guard let firstVisibleRowRect = tableView.rowView(atRow: firstVisibleRowIndex, makeIfNecessary: false)?.frame, + tableView.convert(firstVisibleRowRect, to: tableView.enclosingScrollView).origin.y < tableView.safeAreaInsets.top - 20 else { + return + } + + // We only mark immediately after scrolling stops, not during, to prevent scroll hitching + markAsReadOnScrollWorkItem?.cancel() + markAsReadOnScrollWorkItem = DispatchWorkItem { [weak self] in + defer { + self?.markAsReadOnScrollStart = nil + self?.markAsReadOnScrollEnd = nil + } + + guard let start: Int = self?.markAsReadOnScrollStart, + let end: Int = self?.markAsReadOnScrollEnd ?? self?.markAsReadOnScrollStart, + start <= end, + let self = self else { + return + } + + let articles = self.articles[start...end].filter({ $0.status.read == false }) + self.markAllAsRead(articles) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: markAsReadOnScrollWorkItem!) + + // Here we are creating a range of rows to attempt to mark later with the work item + guard markAsReadOnScrollStart != nil else { + markAsReadOnScrollStart = max(firstVisibleRowIndex - 5, 0) + return + } + markAsReadOnScrollEnd = max(markAsReadOnScrollEnd ?? 0, firstVisibleRowIndex) + } + } 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 15f974941..1fbf7fd85 100644 --- a/iOS/MasterTimeline/MasterTimelineViewController.swift +++ b/iOS/MasterTimeline/MasterTimelineViewController.swift @@ -31,11 +31,15 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner private lazy var dataSource = makeDataSource() private let searchController = UISearchController(searchResultsController: nil) + private var markAsReadOnScrollWorkItem: DispatchWorkItem? + private var markAsReadOnScrollStart: Int? + private var markAsReadOnScrollEnd: Int? + private var lastVerticlePosition: CGFloat = 0 + var mainControllerIdentifier = MainControllerIdentifier.masterTimeline weak var coordinator: SceneCoordinator! var undoableCommands = [UndoableCommand]() - let scrollPositionQueue = CoalescingQueue(name: "Timeline Scroll Position", interval: 0.3, maxInterval: 1.0) private let keyboardManager = KeyboardManager(type: .timeline) override var keyCommands: [UIKeyCommand]? { @@ -434,7 +438,8 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner } override func scrollViewDidScroll(_ scrollView: UIScrollView) { - scrollPositionQueue.add(self, #selector(scrollPositionDidChange)) + coordinator.timelineMiddleIndexPath = tableView.middleVisibleRow() + markAsReadOnScroll() } // MARK: Notifications @@ -530,10 +535,6 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner updateUI() } - @objc func scrollPositionDidChange() { - coordinator.timelineMiddleIndexPath = tableView.middleVisibleRow() - } - // MARK: Reloading func queueReloadAvailableCells() { @@ -695,6 +696,8 @@ private extension MasterTimelineViewController { } func applyChanges(animated: Bool, completion: (() -> Void)? = nil) { + lastVerticlePosition = 0 + if coordinator.articles.count == 0 { tableView.rowHeight = tableView.estimatedRowHeight } else { @@ -723,7 +726,6 @@ private extension MasterTimelineViewController { } func configure(_ cell: MasterTimelineTableViewCell, article: Article, indexPath: IndexPath) { - let iconImage = iconImageFor(article) let featuredImage = featuredImageFor(article) @@ -748,6 +750,54 @@ private extension MasterTimelineViewController { return nil } + func markAsReadOnScroll() { + // Only try to mark if we are scrolling up + defer { + lastVerticlePosition = tableView.contentOffset.y + } + guard lastVerticlePosition < tableView.contentOffset.y else { + return + } + + // Implement Mark As Read on Scroll where we mark after the leading edge goes a little beyond the safe area inset + guard AppDefaults.shared.markArticlesAsReadOnScroll, + lastVerticlePosition < tableView.contentOffset.y, + let firstVisibleIndexPath = tableView.indexPathsForVisibleRows?.first else { return } + + let firstVisibleRowRect = tableView.rectForRow(at: firstVisibleIndexPath) + guard tableView.convert(firstVisibleRowRect, to: nil).origin.y < tableView.safeAreaInsets.top - 20 else { return } + + // We only mark immediately after scrolling stops, not during, to prevent scroll hitching + markAsReadOnScrollWorkItem?.cancel() + markAsReadOnScrollWorkItem = DispatchWorkItem { [weak self] in + defer { + self?.markAsReadOnScrollStart = nil + self?.markAsReadOnScrollEnd = nil + } + + guard let start: Int = self?.markAsReadOnScrollStart, + let end: Int = self?.markAsReadOnScrollEnd ?? self?.markAsReadOnScrollStart, + start <= end, + let self = self else { + return + } + + let articles = Array(start...end) + .map({ IndexPath(row: $0, section: 0) }) + .compactMap({ self.dataSource.itemIdentifier(for: $0) }) + .filter({ $0.status.read == false }) + self.coordinator.markAllAsRead(articles) + } + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: markAsReadOnScrollWorkItem!) + + // Here we are creating a range of rows to attempt to mark later with the work item + guard markAsReadOnScrollStart != nil else { + markAsReadOnScrollStart = max(firstVisibleIndexPath.row - 5, 0) + return + } + markAsReadOnScrollEnd = max(markAsReadOnScrollEnd ?? 0, firstVisibleIndexPath.row) + } + func toggleArticleReadStatusAction(_ article: Article) -> UIAction? { guard !article.status.read || article.isAvailableToMarkUnread else { return nil } diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index c46ee94d5..85f4febc5 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..9db7de044 100644 --- a/iOS/Settings/Settings.storyboard +++ b/iOS/Settings/Settings.storyboard @@ -254,9 +254,42 @@ - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -282,20 +315,20 @@ - + - + - + - + - - + - + -