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)