diff --git a/Articles/Sources/Articles/ArticleStatus.swift b/Articles/Sources/Articles/ArticleStatus.swift index 4ccc6b95c..cbd0698bf 100644 --- a/Articles/Sources/Articles/ArticleStatus.swift +++ b/Articles/Sources/Articles/ArticleStatus.swift @@ -11,7 +11,7 @@ import Foundation // Threading rules: // * Main-thread only // * Except: may be created on background thread by StatusesTable. -// Which is safe, because at creation time it’t not yet shared, +// Which is safe, because at creation time it’s not yet shared, // and it won’t be mutated ever on a background thread. public final class ArticleStatus: Hashable { 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/Sidebar/UnreadCountView.swift b/Mac/MainWindow/Sidebar/UnreadCountView.swift index 25ee1c2a4..9cb9e59c8 100644 --- a/Mac/MainWindow/Sidebar/UnreadCountView.swift +++ b/Mac/MainWindow/Sidebar/UnreadCountView.swift @@ -27,7 +27,7 @@ class UnreadCountView : NSView { } } var unreadCountString: String { - return unreadCount < 1 ? "" : "\(unreadCount)" + return unreadCount < 1 ? "" : numberFormatter.string(from: NSNumber(value: unreadCount))! } private var intrinsicContentSizeIsValid = false @@ -92,5 +92,21 @@ class UnreadCountView : NSView { unreadCountString.draw(at: textRect().origin, withAttributes: Appearance.textAttributes) } } + + var numberFormatter: NumberFormatter! + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + self.frame = frameRect + + let formatter = NumberFormatter() + formatter.locale = Locale.current + formatter.numberStyle = .decimal + numberFormatter = formatter + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } diff --git a/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift b/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift index 88f0ac440..5e5425728 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift @@ -157,7 +157,7 @@ private extension TimelineViewController { func menu(for articles: [Article]) -> NSMenu? { let menu = NSMenu(title: "") - if articles.anyArticleIsUnread() { + if articles.anyArticleIsUnreadAndCanMarkRead() { menu.addItem(markReadMenuItem(articles)) } if articles.anyArticleIsReadAndCanMarkUnread() { @@ -169,10 +169,10 @@ private extension TimelineViewController { if articles.anyArticleIsStarred() { menu.addItem(markUnstarredMenuItem(articles)) } - if let first = articles.first, self.articles.articlesAbove(article: first).canMarkAllAsRead() { + if let first = articles.first, self.articles.articlesAbove(article: first).canMarkAllAsRead(exemptArticles: directlyMarkedAsUnreadArticles) { menu.addItem(markAboveReadMenuItem(articles)) } - if let last = articles.last, self.articles.articlesBelow(article: last).canMarkAllAsRead() { + if let last = articles.last, self.articles.articlesBelow(article: last).canMarkAllAsRead(exemptArticles: directlyMarkedAsUnreadArticles) { menu.addItem(markBelowReadMenuItem(articles)) } diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift index c15a59a7b..ad650a52d 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
() + lastVerticalPosition = 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 lastVerticalPosition: 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, @@ -248,7 +259,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr } func canMarkAllAsRead() -> Bool { - return articles.canMarkAllAsRead() + return articles.canMarkAllAsRead(exemptArticles: directlyMarkedAsUnreadArticles) } func canMarkSelectedArticlesAsRead() -> Bool { @@ -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 { @@ -474,7 +489,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr func markReadCommandStatus() -> MarkCommandValidationStatus { let articles = selectedArticles - if articles.anyArticleIsUnread() { + if articles.anyArticleIsUnreadAndCanMarkRead() { return .canMark } @@ -499,12 +514,12 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr func canMarkAboveArticlesAsRead() -> Bool { guard let first = selectedArticles.first else { return false } - return articles.articlesAbove(article: first).canMarkAllAsRead() + return articles.articlesAbove(article: first).canMarkAllAsRead(exemptArticles: directlyMarkedAsUnreadArticles) } func canMarkBelowArticlesAsRead() -> Bool { guard let last = selectedArticles.last else { return false } - return articles.articlesBelow(article: last).canMarkAllAsRead() + return articles.articlesBelow(article: last).canMarkAllAsRead(exemptArticles: directlyMarkedAsUnreadArticles) } func markOlderArticlesRead(_ selectedArticles: [Article]) { @@ -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 { + lastVerticalPosition = tableView.enclosingScrollView?.documentVisibleRect.origin.y ?? 0 + } + guard lastVerticalPosition < 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/Shared/Article Rendering/stylesheet.css b/Shared/Article Rendering/stylesheet.css index ae37b23ca..eabf7a21b 100644 --- a/Shared/Article Rendering/stylesheet.css +++ b/Shared/Article Rendering/stylesheet.css @@ -36,24 +36,26 @@ a:hover { :root { --header-table-border-color: rgba(0, 0, 0, 0.1); - --header-color: rgba(0, 0, 0, 0.3); - --body-code-color: #666; + --header-color: rgba(0, 0, 0, 0.66); + --body-code-color: #111; + --code-background-color: #eee; --system-message-color: #cbcbcb; --feedlink-color: rgba(255, 0, 0, 0.6); --article-title-color: #333; - --article-date-color: rgba(0, 0, 0, 0.3); + --article-date-color: rgba(0, 0, 0, 0.5); --table-cell-border-color: lightgray; } @media(prefers-color-scheme: dark) { :root { --header-color: rgba(94, 158, 244, 1); - --body-code-color: #b2b2b2; + --body-code-color: #dcdcdc; --system-message-color: #5f5f5f; --feedlink-color: rgba(94, 158, 244, 1); --article-title-color: #e0e0e0; --article-date-color: rgba(255, 255, 255, 0.5); --table-cell-border-color: dimgray; + --code-background-color: #333; } } @@ -106,6 +108,8 @@ body > .systemMessage { .articleDateline { margin-bottom: 5px; font-weight: bold; + font-variant-caps: all-small-caps; + letter-spacing: 0.025em; } .articleDateline a:link, .articleDateline a:visited { @@ -115,6 +119,7 @@ body > .systemMessage { .articleDatelineTitle { margin-bottom: 5px; font-weight: bold; + font-variant-caps: all-small-caps; } .articleDatelineTitle a:link, .articleDatelineTitle a:visited { @@ -122,19 +127,37 @@ body > .systemMessage { } .externalLink { - margin-bottom: 5px; + margin-top: 15px; + margin-bottom: 15px; +/* + font-variant-caps: all-small-caps; + letter-spacing: 0.025em; + */ + font-size: 0.875em; font-style: italic; + color: var(--article-date-color); width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.externalLink a { + font-family: "SF Mono", Menlo, Courier, monospace; + font-size: 0.85em; + font-variant-caps: normal; + letter-spacing: 0em; +} + .articleBody { margin-top: 20px; line-height: 1.6em; } +.articleBody a { + padding: 0px 1px; +} + h1 { line-height: 1.15em; font-weight: bold; @@ -149,6 +172,7 @@ pre { overflow-y: hidden; word-wrap: normal; word-break: normal; + border-radius: 3px; } pre { @@ -156,9 +180,15 @@ pre { } code, pre { - font-family: "SF Mono", Menlo, "Courier New", Courier, monospace; + font-family: "SF Mono", Menlo, Courier, monospace; font-size: 1em; -webkit-hyphens: none; + background: var(--code-background-color); +} + +code { + padding: 1px 2px; + border-radius: 2px; } pre code { @@ -219,10 +249,6 @@ img, figure, video, div, object { margin: 0 auto; } -video { - width: 100% !important; -} - iframe { max-width: 100%; margin: 0 auto; @@ -238,7 +264,6 @@ figure { } figcaption { - margin-top: 0.5em; font-size: 14px; line-height: 1.3em; } @@ -286,6 +311,30 @@ blockquote { border-top: 1px solid var(--header-table-border-color); } +/* Twitter */ + +.twitterAvatar { + vertical-align: middle; + border-radius: 4px; + height: 1.7em; + width: 1.7em; +} + +.twitterUsername { + line-height: 1.2; + margin-left: 4px; + display: inline-block; + vertical-align: middle; +} + +.twitterScreenName { + font-size: 66%; +} + +.twitterTimestamp { + font-size: 66%; +} + /* Newsfoot theme for light mode (default) */ .newsfoot-footnote-popover { background: #ccc; @@ -359,7 +408,6 @@ a.footnote:hover, padding-right: 20px; word-break: break-word; - -webkit-hyphens: auto; -webkit-text-size-adjust: none; } @@ -370,7 +418,8 @@ a.footnote:hover, font-size: [[font-size]]px; --primary-accent-color: #086AEE; --secondary-accent-color: #086AEE; - --block-quote-border-color: rgba(8, 106, 238, 0.75); + --block-quote-border-color: rgba(0, 0, 0, 0.25); + --ios-hover-color: lightgray; /* placeholder */ } @media(prefers-color-scheme: dark) { @@ -379,24 +428,44 @@ a.footnote:hover, --secondary-accent-color: #5E9EF4; --block-quote-border-color: rgba(94, 158, 244, 0.75); --header-table-border-color: rgba(255, 255, 255, 0.2); + --ios-hover-color: #444444; /* placeholder */ } } - + body a, body a:visited, body a * { color: var(--secondary-accent-color); } + .externalLink a { + font-size: inherit; + } + + .articleBody a:link, .articleBody a:visited { + text-decoration: none; + border-bottom: 1px solid var(--primary-accent-color); + color: var(--secondary-accent-color); + } + body .header { font: -apple-system-body; font-size: [[font-size]]px; } body .header a:link, body .header a:visited { - color: var(--primary-accent-color); + color: var(--secondary-accent-color); } + + @media (hover: hover) and (pointer: coarse) { + .articleBody a:hover { + background: var(--ios-hover-color); + } + } + pre { +/* border: 1px solid var(--secondary-accent-color); + */ padding: 5px; } @@ -439,15 +508,19 @@ a.footnote:hover, :root { color-scheme: light dark; - --accent-color: rgba(8, 106, 238, 1); - --block-quote-border-color: rgba(8, 106, 238, .50); + --accent-color: rgba( 8, 106, 238, 1); + --block-quote-border-color: rgba( 0, 0, 0, 0.25); + --hover-gradient-color-start: rgba(60, 146, 251, 1); + --hover-gradient-color-end: rgba(67, 149, 251, 1); } @media(prefers-color-scheme: dark) { :root { - --accent-color: rgba(94, 158, 244, 1); - --block-quote-border-color: rgba(94, 158, 244, .50); - --header-table-border-color: rgba(255, 255, 255, 0.1); + --accent-color: rgba( 94, 158, 244, 1); + --block-quote-border-color: rgba( 94, 158, 244, 0.50); + --header-table-border-color: rgba(255, 255, 255, 0.1); + --hover-gradient-color-start: rgba( 41, 121, 213, 1); + --hover-gradient-color-end: rgba( 42, 120, 212, 1); } } @@ -455,8 +528,26 @@ a.footnote:hover, color: var(--accent-color); } + .articleBody a:link: not(a > img, a > code), .articleBody a:visited: not(a > img, a > code) { + /* text-decoration: underline; */ + border-bottom: 1px solid var(--accent-color); + } + .articleBody a:hover { + border-radius: 2px; +/* + background: var(--accent-color); + */ + background: linear-gradient(0deg, var(--hover-gradient-color-start) 0%, var(--hover-gradient-color-end) 100%); + border-bottom: 1px solid var(--hover-gradient-color-end); + color: white; + text-decoration: none; + } + + pre { +/* border: 1px solid var(--accent-color); + */ padding: 10px; } diff --git a/Shared/Commands/MarkStatusCommand.swift b/Shared/Commands/MarkStatusCommand.swift index aa3e39079..f4af7869a 100644 --- a/Shared/Commands/MarkStatusCommand.swift +++ b/Shared/Commands/MarkStatusCommand.swift @@ -56,7 +56,7 @@ final class MarkStatusCommand: UndoableCommand { } convenience init?(initialArticles: [Article], statusKey: ArticleStatus.Key, flag: Bool, directlyMarked: Bool, undoManager: UndoManager, completion: (() -> Void)? = nil) { - self.init(initialArticles: Set(initialArticles), statusKey: .read, flag: flag, directlyMarked: directlyMarked, undoManager: undoManager, completion: completion) + self.init(initialArticles: Set(initialArticles), statusKey: statusKey, flag: flag, directlyMarked: directlyMarked, undoManager: undoManager, completion: completion) } convenience init?(initialArticles: Set
, markingRead: Bool, directlyMarked: Bool, undoManager: UndoManager, completion: (() -> Void)? = nil) { diff --git a/Shared/Importers/DefaultFeeds.opml b/Shared/Importers/DefaultFeeds.opml index 9e809df40..04132dbaa 100644 --- a/Shared/Importers/DefaultFeeds.opml +++ b/Shared/Importers/DefaultFeeds.opml @@ -4,25 +4,6 @@ Default Feeds -<<<<<<< HEAD - - - - - - - - - - - - - - - - - -======= @@ -33,6 +14,5 @@ ->>>>>>> ios-release diff --git a/Shared/Secrets.swift.gyb b/Shared/Secrets.swift.gyb index 5b7814cb1..05b993a20 100644 --- a/Shared/Secrets.swift.gyb +++ b/Shared/Secrets.swift.gyb @@ -2,15 +2,7 @@ %{ import os -<<<<<<< HEAD -<<<<<<< HEAD -secrets = ['MERCURY_CLIENT_ID', 'MERCURY_CLIENT_SECRET', 'FEEDLY_CLIENT_ID', 'FEEDLY_CLIENT_SECRET', 'TWITTER_CONSUMER_KEY', 'TWITTER_CONSUMER_SECRET', 'REDDIT_CONSUMER_KEY', 'INOREADER_APP_ID', 'INOREADER_APP_KEY'] -======= -secrets = ['FEED_WRANGLER_KEY', 'MERCURY_CLIENT_ID', 'MERCURY_CLIENT_SECRET', 'FEEDLY_CLIENT_ID', 'FEEDLY_CLIENT_SECRET', 'REDDIT_CONSUMER_KEY', 'INOREADER_APP_ID', 'INOREADER_APP_KEY'] ->>>>>>> mac-release -======= -secrets = ['FEED_WRANGLER_KEY', 'MERCURY_CLIENT_ID', 'MERCURY_CLIENT_SECRET', 'FEEDLY_CLIENT_ID', 'FEEDLY_CLIENT_SECRET', 'REDDIT_CONSUMER_KEY', 'INOREADER_APP_ID', 'INOREADER_APP_KEY'] ->>>>>>> ios-release +secrets = ['MERCURY_CLIENT_ID', 'MERCURY_CLIENT_SECRET', 'FEEDLY_CLIENT_ID', 'FEEDLY_CLIENT_SECRET', 'REDDIT_CONSUMER_KEY', 'INOREADER_APP_ID', 'INOREADER_APP_KEY'] def chunks(seq, size): return (seq[i:(i + size)] for i in range(0, len(seq), size)) diff --git a/Shared/Timeline/ArticleArray.swift b/Shared/Timeline/ArticleArray.swift index 2f8d69e06..7f99e8e6e 100644 --- a/Shared/Timeline/ArticleArray.swift +++ b/Shared/Timeline/ArticleArray.swift @@ -54,8 +54,8 @@ extension Array where Element == Article { return ArticleSorter.sortedByDate(articles: self, sortDirection: sortDirection, groupByFeed: groupByFeed) } - func canMarkAllAsRead() -> Bool { - return anyArticleIsUnread() + func canMarkAllAsRead(exemptArticles: Set
= .init()) -> Bool { + return anyArticleIsUnreadAndCanMarkRead(exemptArticles: exemptArticles) } func anyArticlePassesTest(_ test: ((Article) -> Bool)) -> Bool { @@ -71,8 +71,8 @@ extension Array where Element == Article { return anyArticlePassesTest { $0.status.read && $0.isAvailableToMarkUnread } } - func anyArticleIsUnread() -> Bool { - return anyArticlePassesTest { !$0.status.read } + func anyArticleIsUnreadAndCanMarkRead(exemptArticles: Set
= .init()) -> Bool { + return anyArticlePassesTest { !(exemptArticles.contains($0) || $0.status.read) } } func anyArticleIsStarred() -> Bool { diff --git a/iOS/AppDefaults.swift b/iOS/AppDefaults.swift index 37689fce7..f7b153aaf 100644 --- a/iOS/AppDefaults.swift +++ b/iOS/AppDefaults.swift @@ -61,6 +61,7 @@ final class AppDefaults: ObservableObject { static let useSystemBrowser = "useSystemBrowser" static let currentThemeName = "currentThemeName" static let twitterDeprecationAlertShown = "twitterDeprecationAlertShown" + static let markArticlesAsReadOnScroll = "markArticlesAsReadOnScroll" } let isDeveloperBuild: Bool = { @@ -266,6 +267,15 @@ final class AppDefaults: ObservableObject { } } + 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/MasterFeed/Cell/MasterFeedUnreadCountView.swift b/iOS/MasterFeed/Cell/MasterFeedUnreadCountView.swift index e084036b9..17db54492 100644 --- a/iOS/MasterFeed/Cell/MasterFeedUnreadCountView.swift +++ b/iOS/MasterFeed/Cell/MasterFeedUnreadCountView.swift @@ -35,8 +35,10 @@ class MasterFeedUnreadCountView : UIView { } var unreadCountString: String { - return unreadCount < 1 ? "" : "\(unreadCount)" + return unreadCount < 1 ? "" : numberFormatter.string(from: NSNumber(value: unreadCount))! } + + var numberFormatter: NumberFormatter! private var contentSizeIsValid = false private var _contentSize = CGSize.zero @@ -44,11 +46,21 @@ class MasterFeedUnreadCountView : UIView { override init(frame: CGRect) { super.init(frame: frame) self.isOpaque = false + + let formatter = NumberFormatter() + formatter.locale = Locale.current + formatter.numberStyle = .decimal + numberFormatter = formatter } required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) self.isOpaque = false + + let formatter = NumberFormatter() + formatter.locale = Locale.current + formatter.numberStyle = .decimal + numberFormatter = formatter } override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { diff --git a/iOS/MasterFeed/MasterFeedViewController.swift b/iOS/MasterFeed/MasterFeedViewController.swift index 0409d0e8b..422c3f71f 100644 --- a/iOS/MasterFeed/MasterFeedViewController.swift +++ b/iOS/MasterFeed/MasterFeedViewController.swift @@ -564,6 +564,11 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner, Ma return } + if tableView.window == nil { + completion?() + return + } + tableView.performBatchUpdates { if let deletes = changes.deletes, !deletes.isEmpty { tableView.deleteSections(IndexSet(deletes), with: .middle) diff --git a/iOS/MasterTimeline/MasterTimelineViewController.swift b/iOS/MasterTimeline/MasterTimelineViewController.swift index 0297387b4..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() { @@ -678,7 +679,7 @@ private extension MasterTimelineViewController { func updateToolbar() { guard firstUnreadButton != nil else { return } - markAllAsReadButton.isEnabled = coordinator.isTimelineUnreadAvailable + markAllAsReadButton.isEnabled = coordinator.canMarkAllAsRead() firstUnreadButton.isEnabled = coordinator.isTimelineUnreadAvailable if coordinator.isRootSplitCollapsed { @@ -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 } @@ -875,7 +925,7 @@ private extension MasterTimelineViewController { } let articles = Array(fetchedArticles) - guard articles.canMarkAllAsRead(), let contentView = self.tableView.cellForRow(at: indexPath)?.contentView else { + guard coordinator.canMarkAllAsRead(articles), let contentView = self.tableView.cellForRow(at: indexPath)?.contentView else { return nil } @@ -898,7 +948,7 @@ private extension MasterTimelineViewController { } let articles = Array(fetchedArticles) - guard articles.canMarkAllAsRead(), let contentView = self.tableView.cellForRow(at: indexPath)?.contentView else { + guard coordinator.canMarkAllAsRead(articles), let contentView = self.tableView.cellForRow(at: indexPath)?.contentView else { return nil } diff --git a/iOS/Resources/Assets.xcassets/About.imageset/AppIcon-1024px 1.png b/iOS/Resources/Assets.xcassets/About.imageset/AppIcon-1024px 1.png deleted file mode 100644 index efdd38490..000000000 Binary files a/iOS/Resources/Assets.xcassets/About.imageset/AppIcon-1024px 1.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/About.imageset/AppIcon-1024px.png b/iOS/Resources/Assets.xcassets/About.imageset/AppIcon-1024px.png deleted file mode 100644 index efdd38490..000000000 Binary files a/iOS/Resources/Assets.xcassets/About.imageset/AppIcon-1024px.png and /dev/null differ diff --git a/iOS/Resources/Assets.xcassets/About.imageset/Contents.json b/iOS/Resources/Assets.xcassets/About.imageset/Contents.json deleted file mode 100644 index d3a718431..000000000 --- a/iOS/Resources/Assets.xcassets/About.imageset/Contents.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "scale" : "1x" - }, - { - "filename" : "AppIcon-1024px 1.png", - "idiom" : "universal", - "scale" : "2x" - }, - { - "filename" : "AppIcon-1024px.png", - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index 52fca151b..691d1ea24 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -111,7 +111,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, Logging { } } - private var directlyMarkedAsUnreadArticles = Set
() + var directlyMarkedAsUnreadArticles = Set
() var prefersStatusBarHidden = false @@ -1043,10 +1043,18 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, Logging { completion?() } } + + func canMarkAllAsRead() -> Bool { + return articles.canMarkAllAsRead(exemptArticles: directlyMarkedAsUnreadArticles) + } + + func canMarkAllAsRead(_ articles: [Article]) -> Bool { + return articles.canMarkAllAsRead(exemptArticles: directlyMarkedAsUnreadArticles) + } func canMarkAboveAsRead(for article: Article) -> Bool { let articlesAboveArray = articles.articlesAbove(article: article) - return articlesAboveArray.canMarkAllAsRead() + return articlesAboveArray.canMarkAllAsRead(exemptArticles: directlyMarkedAsUnreadArticles) } func markAboveAsRead() { @@ -1064,7 +1072,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, Logging { func canMarkBelowAsRead(for article: Article) -> Bool { let articleBelowArray = articles.articlesBelow(article: article) - return articleBelowArray.canMarkAllAsRead() + return articleBelowArray.canMarkAllAsRead(exemptArticles: directlyMarkedAsUnreadArticles) } func markBelowAsRead() { diff --git a/iOS/Settings/Help/AboutView.swift b/iOS/Settings/Help/AboutView.swift index 69b4b3a55..be4bc3e36 100644 --- a/iOS/Settings/Help/AboutView.swift +++ b/iOS/Settings/Help/AboutView.swift @@ -7,6 +7,7 @@ // import SwiftUI +import RSCore struct AboutView: View, LoadableAboutData { @@ -35,9 +36,10 @@ struct AboutView: View, LoadableAboutData { HStack { Spacer() VStack(alignment: .center, spacing: 8) { - Image("About") + Image(uiImage: RSImage.appIconImage!) .resizable() .frame(width: 75, height: 75) + .cornerRadius(11) Text(Bundle.main.appName) .font(.headline)