diff --git a/Account/Sources/Account/Account.swift b/Account/Sources/Account/Account.swift index ab8d39f15..b3f7ccad8 100644 --- a/Account/Sources/Account/Account.swift +++ b/Account/Sources/Account/Account.swift @@ -517,10 +517,6 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, addOPMLItems(OPMLNormalizer.normalize(items)) } - public func markArticles(_ articles: Set
, statusKey: ArticleStatus.Key, flag: Bool, completion: @escaping (Result) -> Void) { - delegate.markArticles(for: self, articles: articles, statusKey: statusKey, flag: flag, completion: completion) - } - func existingContainer(withExternalID externalID: String) -> Container? { guard self.externalID != externalID else { return self @@ -639,6 +635,10 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, delegate.restoreFolder(for: self, folder: folder, completion: completion) } + public func mark(articles: Set
, statusKey: ArticleStatus.Key, flag: Bool, completion: @escaping (Result) -> Void) { + delegate.markArticles(for: self, articles: articles, statusKey: statusKey, flag: flag, completion: completion) + } + func clearWebFeedMetadata(_ feed: WebFeed) { webFeedMetadata[feed.url] = nil } @@ -832,40 +832,46 @@ public final class Account: DisplayNameProvider, UnreadCountProvider, Container, completion?(nil) } } - + /// Mark articleIDs statuses based on statusKey and flag. /// Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification. - /// Returns a set of new article statuses. - func markAndFetchNew(articleIDs: Set, statusKey: ArticleStatus.Key, flag: Bool, completion: DatabaseCompletionBlock? = nil) { + func mark(articleIDs: Set, statusKey: ArticleStatus.Key, flag: Bool, completion: DatabaseCompletionBlock? = nil) { guard !articleIDs.isEmpty else { completion?(nil) return } - database.mark(articleIDs: articleIDs, statusKey: statusKey, flag: flag, completion: completion) + database.mark(articleIDs: articleIDs, statusKey: statusKey, flag: flag) { databaseError in + if let databaseError = databaseError { + completion?(databaseError) + } else { + self.noteStatusesForArticleIDsDidChange(articleIDs: articleIDs, statusKey: statusKey, flag: flag) + completion?(nil) + } + } } /// Mark articleIDs as read. Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification. /// Returns a set of new article statuses. func markAsRead(_ articleIDs: Set, completion: DatabaseCompletionBlock? = nil) { - markAndFetchNew(articleIDs: articleIDs, statusKey: .read, flag: true, completion: completion) + mark(articleIDs: articleIDs, statusKey: .read, flag: true, completion: completion) } /// Mark articleIDs as unread. Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification. /// Returns a set of new article statuses. func markAsUnread(_ articleIDs: Set, completion: DatabaseCompletionBlock? = nil) { - markAndFetchNew(articleIDs: articleIDs, statusKey: .read, flag: false, completion: completion) + mark(articleIDs: articleIDs, statusKey: .read, flag: false, completion: completion) } /// Mark articleIDs as starred. Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification. /// Returns a set of new article statuses. func markAsStarred(_ articleIDs: Set, completion: DatabaseCompletionBlock? = nil) { - markAndFetchNew(articleIDs: articleIDs, statusKey: .starred, flag: true, completion: completion) + mark(articleIDs: articleIDs, statusKey: .starred, flag: true, completion: completion) } /// Mark articleIDs as unstarred. Will create statuses in the database and in memory as needed. Sends a .StatusesDidChange notification. /// Returns a set of new article statuses. func markAsUnstarred(_ articleIDs: Set, completion: DatabaseCompletionBlock? = nil) { - markAndFetchNew(articleIDs: articleIDs, statusKey: .starred, flag: false, completion: completion) + mark(articleIDs: articleIDs, statusKey: .starred, flag: false, completion: completion) } // Delete the articles associated with the given set of articleIDs diff --git a/Account/Sources/Account/AccountManager.swift b/Account/Sources/Account/AccountManager.swift index 07bd893d9..fbf5fa32c 100644 --- a/Account/Sources/Account/AccountManager.swift +++ b/Account/Sources/Account/AccountManager.swift @@ -70,6 +70,9 @@ public final class AccountManager: UnreadCountProvider { if lastArticleFetchEndTime == nil || lastArticleFetchEndTime! < accountLastArticleFetchEndTime { lastArticleFetchEndTime = accountLastArticleFetchEndTime } + } else { + lastArticleFetchEndTime = nil + break } } return lastArticleFetchEndTime @@ -265,34 +268,6 @@ public final class AccountManager: UnreadCountProvider { } } - public func refreshAll(completion: (() -> Void)? = nil) { - guard let reachability = try? Reachability(hostname: "apple.com"), reachability.connection != .unavailable else { return } - - var syncErrors = [AccountSyncError]() - let group = DispatchGroup() - - activeAccounts.forEach { account in - group.enter() - account.refreshAll() { result in - group.leave() - switch result { - case .success: - break - case .failure(let error): - syncErrors.append(AccountSyncError(account: account, error: error)) - } - } - } - - group.notify(queue: DispatchQueue.main) { - if syncErrors.count > 0 { - NotificationCenter.default.post(Notification(name: .AccountsDidFailToSyncWithErrors, object: self, userInfo: [Account.UserInfoKey.syncErrors: syncErrors])) - } - completion?() - } - - } - public func sendArticleStatusAll(completion: (() -> Void)? = nil) { let group = DispatchGroup() diff --git a/Account/Sources/Account/AccountSyncError.swift b/Account/Sources/Account/AccountSyncError.swift deleted file mode 100644 index 6f7148700..000000000 --- a/Account/Sources/Account/AccountSyncError.swift +++ /dev/null @@ -1,28 +0,0 @@ -// -// AccountSyncError.swift -// Account -// -// Created by Stuart Breckenridge on 24/7/20. -// Copyright © 2020 Ranchero Software, LLC. All rights reserved. -// - -import Foundation -import RSCore - -public extension Notification.Name { - static let AccountsDidFailToSyncWithErrors = Notification.Name("AccountsDidFailToSyncWithErrors") -} - -public struct AccountSyncError: Logging { - - public let account: Account - public let error: Error - - init(account: Account, error: Error) { - self.account = account - self.error = error - AccountSyncError.logger.error("Account Sync Error: \(error.localizedDescription, privacy: .public)") - } - -} - diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift index 80e522bfd..07079b5fe 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountDelegate.swift @@ -804,7 +804,7 @@ private extension CloudKitAccountDelegate { self.sendArticleStatus(for: account, showProgress: true) { result in switch result { case .success: - self.articlesZone.fetchChangesInZone() { _ in } + self.refreshArticleStatus(for: account) { _ in } case .failure(let error): self.logger.error("CloudKit Feed send articles error: \(error.localizedDescription, privacy: .public)") } diff --git a/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift b/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift index e5f971c55..e25f23d8c 100644 --- a/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift +++ b/Account/Sources/Account/CloudKit/CloudKitAccountZone.swift @@ -23,8 +23,6 @@ final class CloudKitAccountZone: CloudKitZone { var zoneID: CKRecordZone.ID - var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") - weak var container: CKContainer? weak var database: CKDatabase? var delegate: CloudKitZoneDelegate? diff --git a/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift b/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift index 0553513e1..91cd788b9 100644 --- a/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift +++ b/Account/Sources/Account/CloudKit/CloudKitArticlesZone.swift @@ -19,8 +19,6 @@ final class CloudKitArticlesZone: CloudKitZone { var zoneID: CKRecordZone.ID - var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "CloudKit") - weak var container: CKContainer? weak var database: CKDatabase? var delegate: CloudKitZoneDelegate? = nil @@ -64,28 +62,6 @@ final class CloudKitArticlesZone: CloudKitZone { migrateChangeToken() } - func refreshArticles(completion: @escaping ((Result) -> Void)) { - fetchChangesInZone() { result in - switch result { - case .success: - completion(.success(())) - case .failure(let error): - if case CloudKitZoneError.userDeletedZone = error { - self.createZoneRecord() { result in - switch result { - case .success: - self.refreshArticles(completion: completion) - case .failure(let error): - completion(.failure(error)) - } - } - } else { - completion(.failure(error)) - } - } - } - } - func saveNewArticles(_ articles: Set
, completion: @escaping ((Result) -> Void)) { guard !articles.isEmpty else { completion(.success(())) diff --git a/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift b/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift index 8cc5acdb7..613640046 100644 --- a/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift +++ b/Account/Sources/Account/CloudKit/CloudKitArticlesZoneDelegate.swift @@ -37,14 +37,16 @@ class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate, Logging { self.database.selectPendingStarredStatusArticleIDs() { result in switch result { case .success(let pendingStarredStatusArticleIDs): - - self.delete(recordKeys: deleted, pendingStarredStatusArticleIDs: pendingStarredStatusArticleIDs) { - self.update(records: updated, - pendingReadStatusArticleIDs: pendingReadStatusArticleIDs, - pendingStarredStatusArticleIDs: pendingStarredStatusArticleIDs, - completion: completion) + self.delete(recordKeys: deleted, pendingStarredStatusArticleIDs: pendingStarredStatusArticleIDs) { error in + if let error = error { + completion(.failure(error)) + } else { + self.update(records: updated, + pendingReadStatusArticleIDs: pendingReadStatusArticleIDs, + pendingStarredStatusArticleIDs: pendingStarredStatusArticleIDs, + completion: completion) + } } - case .failure(let error): self.logger.error("Error occurred getting pending starred records: \(error.localizedDescription, privacy: .public)") completion(.failure(CloudKitZoneError.unknown)) @@ -63,19 +65,27 @@ class CloudKitArticlesZoneDelegate: CloudKitZoneDelegate, Logging { private extension CloudKitArticlesZoneDelegate { - func delete(recordKeys: [CloudKitRecordKey], pendingStarredStatusArticleIDs: Set, completion: @escaping () -> Void) { + func delete(recordKeys: [CloudKitRecordKey], pendingStarredStatusArticleIDs: Set, completion: @escaping (Error?) -> Void) { let receivedRecordIDs = recordKeys.filter({ $0.recordType == CloudKitArticlesZone.CloudKitArticleStatus.recordType }).map({ $0.recordID }) let receivedArticleIDs = Set(receivedRecordIDs.map({ stripPrefix($0.externalID) })) let deletableArticleIDs = receivedArticleIDs.subtracting(pendingStarredStatusArticleIDs) guard !deletableArticleIDs.isEmpty else { - completion() + completion(nil) return } - database.deleteSelectedForProcessing(Array(deletableArticleIDs)) { _ in - self.account?.delete(articleIDs: deletableArticleIDs) { _ in - completion() + database.deleteSelectedForProcessing(Array(deletableArticleIDs)) { databaseError in + if let databaseError = databaseError { + completion(databaseError) + } else { + self.account?.delete(articleIDs: deletableArticleIDs) { databaseError in + if let databaseError = databaseError { + completion(databaseError) + } else { + completion(nil) + } + } } } } diff --git a/Account/Sources/Account/CloudKit/CloudKitReceiveStatusOperation.swift b/Account/Sources/Account/CloudKit/CloudKitReceiveStatusOperation.swift index 700bcef17..91d0f0789 100644 --- a/Account/Sources/Account/CloudKit/CloudKitReceiveStatusOperation.swift +++ b/Account/Sources/Account/CloudKit/CloudKitReceiveStatusOperation.swift @@ -32,7 +32,7 @@ class CloudKitReceiveStatusOperation: MainThreadOperation, Logging { logger.debug("Refreshing article statuses...") - articlesZone.refreshArticles() { result in + articlesZone.fetchChangesInZone() { result in self.logger.debug("Done refreshing article statuses.") switch result { case .success: diff --git a/Mac/AppDefaults.swift b/Mac/AppDefaults.swift index df9b7c618..685d6fd44 100644 --- a/Mac/AppDefaults.swift +++ b/Mac/AppDefaults.swift @@ -42,6 +42,7 @@ final class AppDefaults { static let exportOPMLAccountID = "exportOPMLAccountID" static let defaultBrowserID = "defaultBrowserID" static let currentThemeName = "currentThemeName" + static let hasSeenNotAllArticlesHaveURLsAlert = "hasSeenNotAllArticlesHaveURLsAlert" // Hidden prefs static let showDebugMenu = "ShowDebugMenu" @@ -220,6 +221,15 @@ final class AppDefaults { AppDefaults.setString(for: Key.currentThemeName, newValue) } } + + var hasSeenNotAllArticlesHaveURLsAlert: Bool { + get { + return UserDefaults.standard.bool(forKey: Key.hasSeenNotAllArticlesHaveURLsAlert) + } + set { + UserDefaults.standard.set(newValue, forKey: Key.hasSeenNotAllArticlesHaveURLsAlert) + } + } var showTitleOnMainWindow: Bool { return AppDefaults.bool(for: Key.showTitleOnMainWindow) diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index 4dbe1da45..d155b34a4 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -241,7 +241,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserInterfaceValidations, refreshTimer = AccountRefreshTimer() syncTimer = ArticleStatusSyncTimer() - UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .alert, .badge]) { (granted, error) in } + UNUserNotificationCenter.current().requestAuthorization(options:[.badge, .alert, .sound]) { (granted, error) in } UNUserNotificationCenter.current().getNotificationSettings { (settings) in if settings.authorizationStatus == .authorized { @@ -1050,41 +1050,31 @@ extension AppDelegate: NSWindowRestoration { private extension AppDelegate { func handleMarkAsRead(userInfo: [AnyHashable: Any]) { + markArticle(userInfo: userInfo, statusKey: .read) + } + + func handleMarkAsStarred(userInfo: [AnyHashable: Any]) { + markArticle(userInfo: userInfo, statusKey: .starred) + } + + func markArticle(userInfo: [AnyHashable: Any], statusKey: ArticleStatus.Key) { guard let articlePathUserInfo = userInfo[UserInfoKey.articlePath] as? [AnyHashable : Any], let accountID = articlePathUserInfo[ArticlePathKey.accountID] as? String, let articleID = articlePathUserInfo[ArticlePathKey.articleID] as? String else { return } - let account = AccountManager.shared.existingAccount(with: accountID) - guard account != nil else { + guard let account = AccountManager.shared.existingAccount(with: accountID) else { logger.debug("No account found from notification.") return } - let article = try? account!.fetchArticles(.articleIDs([articleID])) - guard article != nil else { + + guard let articles = try? account.fetchArticles(.articleIDs([articleID])), !articles.isEmpty else { logger.debug("No article found from search using: \(articleID, privacy: .public)") return } - account!.markArticles(article!, statusKey: .read, flag: true) { _ in } + + account.mark(articles: articles, statusKey: statusKey, flag: true) { _ in } } - func handleMarkAsStarred(userInfo: [AnyHashable: Any]) { - guard let articlePathUserInfo = userInfo[UserInfoKey.articlePath] as? [AnyHashable : Any], - let accountID = articlePathUserInfo[ArticlePathKey.accountID] as? String, - let articleID = articlePathUserInfo[ArticlePathKey.articleID] as? String else { - return - } - let account = AccountManager.shared.existingAccount(with: accountID) - guard account != nil else { - logger.debug("No account found from notification.") - return - } - let article = try? account!.fetchArticles(.articleIDs([articleID])) - guard article != nil else { - logger.debug("No article found from search using: \(articleID, privacy: .public)") - return - } - account!.markArticles(article!, statusKey: .starred, flag: true) { _ in } - } } diff --git a/Mac/Browser.swift b/Mac/Browser.swift index d574bc7ad..7b47dad85 100644 --- a/Mac/Browser.swift +++ b/Mac/Browser.swift @@ -73,3 +73,48 @@ extension Browser { NSLocalizedString("Open in Browser in Background", comment: "Open in Browser in Background menu item title") } } + +extension Browser { + + /// Open multiple pages in the default browser, warning if over a certain number of URLs are passed. + /// - Parameters: + /// - urlStrings: The URL strings to open. + /// - window: The window on which to display the "over limit" alert sheet. If `nil`, will be displayed as a + /// modal dialog. + /// - invertPreference: Whether to invert the user's "Open web pages in background in browser" preference. + static func open(_ urlStrings: [String], fromWindow window: NSWindow?, invertPreference: Bool = false) { + if urlStrings.count > 500 { + return + } + + func doOpenURLs() { + for urlString in urlStrings { + Browser.open(urlString, invertPreference: invertPreference) + } + } + + if urlStrings.count > 20 { + let alert = NSAlert() + let messageFormat = NSLocalizedString("Are you sure you want to open %ld articles in your browser?", comment: "Open in Browser confirmation alert message format") + alert.messageText = String.localizedStringWithFormat(messageFormat, urlStrings.count) + let confirmButtonTitleFormat = NSLocalizedString("Open %ld Articles", comment: "Open URLs in Browser confirm button format") + alert.addButton(withTitle: String.localizedStringWithFormat(confirmButtonTitleFormat, urlStrings.count)) + alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "Cancel button")) + + if let window { + alert.beginSheetModal(for: window) { response in + if response == .alertFirstButtonReturn { + doOpenURLs() + } + } + } else { + if alert.runModal() == .alertFirstButtonReturn { + doOpenURLs() + } + } + } else { + doOpenURLs() + } + } + +} diff --git a/Mac/MainWindow/About/CreditsNetNewsWireView.swift b/Mac/MainWindow/About/CreditsNetNewsWireView.swift index 1e88be2d9..88a63fe75 100644 --- a/Mac/MainWindow/About/CreditsNetNewsWireView.swift +++ b/Mac/MainWindow/About/CreditsNetNewsWireView.swift @@ -49,6 +49,7 @@ struct CreditsNetNewsWireView: View, LoadableAboutData { .frame(height: 12) } .padding(.horizontal) + .frame(width: 400, height: 400) } func contributorView(_ appCredit: AboutData.Contributor) -> some View { diff --git a/Mac/MainWindow/Detail/DetailWebView.swift b/Mac/MainWindow/Detail/DetailWebView.swift index f66555500..56f5d7812 100644 --- a/Mac/MainWindow/Detail/DetailWebView.swift +++ b/Mac/MainWindow/Detail/DetailWebView.swift @@ -117,7 +117,7 @@ private extension NSUserInterfaceItemIdentifier { private extension DetailWebView { - static let menuItemIdentifiersToHide: [NSUserInterfaceItemIdentifier] = [.DetailMenuItemIdentifierReload, .DetailMenuItemIdentifierOpenLink] + static let menuItemIdentifiersToHide: [NSUserInterfaceItemIdentifier] = [.DetailMenuItemIdentifierReload] static let menuItemIdentifierMatchStrings = ["newwindow", "download"] func shouldHideMenuItem(_ menuItem: NSMenuItem) -> Bool { diff --git a/Mac/MainWindow/MainWindowController.swift b/Mac/MainWindow/MainWindowController.swift index 64dd77e59..36b89dddb 100644 --- a/Mac/MainWindow/MainWindowController.swift +++ b/Mac/MainWindow/MainWindowController.swift @@ -204,7 +204,15 @@ class MainWindowController : NSWindowController, NSUserInterfaceValidations { public func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { if item.action == #selector(copyArticleURL(_:)) { - return canCopyArticleURL() + let canCopyArticleURL = canCopyArticleURL() + + if let item = item as? NSMenuItem { + let format = NSLocalizedString("Copy Article URL", comment: "Copy Article URL"); + + item.title = String.localizedStringWithFormat(format, selectedArticles?.count ?? 0) + } + + return canCopyArticleURL } if item.action == #selector(copyExternalURL(_:)) { @@ -321,21 +329,21 @@ class MainWindowController : NSWindowController, NSUserInterfaceValidations { } @IBAction func copyArticleURL(_ sender: Any?) { - if let link = oneSelectedArticle?.preferredURL?.absoluteString { - URLPasteboardWriter.write(urlString: link, to: .general) + if let currentLinks { + URLPasteboardWriter.write(urlStrings: currentLinks, alertingIn: window) } } @IBAction func copyExternalURL(_ sender: Any?) { - if let link = oneSelectedArticle?.externalLink { - URLPasteboardWriter.write(urlString: link, to: .general) + if let links = selectedArticles?.compactMap({ $0.externalLink }) { + URLPasteboardWriter.write(urlStrings: links, to: .general) } } @IBAction func openArticleInBrowser(_ sender: Any?) { - if let link = currentLink { - Browser.open(link, invertPreference: NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false) - } + guard let selectedArticles else { return } + let urlStrings = selectedArticles.compactMap { $0.preferredLink } + Browser.open(urlStrings, fromWindow: window, invertPreference: NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false) } @IBAction func openInBrowser(_ sender: Any?) { @@ -529,16 +537,17 @@ class MainWindowController : NSWindowController, NSUserInterfaceValidations { assertionFailure("Expected toolbarShowShareMenu to be called only by the Share item in the toolbar.") return } - guard let view = shareToolbarItem.view else { - // TODO: handle menu form representation - return - } let sortedArticles = selectedArticles.sortedByDate(.orderedAscending) let items = sortedArticles.map { ArticlePasteboardWriter(article: $0) } let sharingServicePicker = NSSharingServicePicker(items: items) sharingServicePicker.delegate = sharingServicePickerDelegate - sharingServicePicker.show(relativeTo: view.bounds, of: view, preferredEdge: .minY) + + if let view = shareToolbarItem.view, view.window != nil { + sharingServicePicker.show(relativeTo: view.bounds, of: view, preferredEdge: .minY) + } else if let view = window?.contentView { + sharingServicePicker.show(relativeTo: CGRect(x: view.frame.width / 2.0, y: view.frame.height - 4, width: 1, height: 1), of: view, preferredEdge: .minY) + } } @IBAction func moveFocusToSearchField(_ sender: Any?) { @@ -628,6 +637,10 @@ extension MainWindowController: NSWindowDelegate { extension MainWindowController: SidebarDelegate { + var directlyMarkedAsUnreadArticles: Set
? { + return timelineContainerViewController?.currentTimelineViewController?.directlyMarkedAsUnreadArticles + } + func sidebarSelectionDidChange(_: SidebarViewController, selectedObjects: [AnyObject]?) { // Don’t update the timeline if it already has those objects. let representedObjectsAreTheSame = timelineContainerViewController?.regularTimelineViewControllerHasRepresentedObjects(selectedObjects) ?? false @@ -666,6 +679,9 @@ extension MainWindowController: TimelineContainerViewControllerDelegate { articleExtractor = nil isShowingExtractedArticle = false makeToolbarValidate() + if #available(macOS 13.0, *) { } else { + updateShareToolbarItemMenu() + } let detailState: DetailState if let articles = articles { @@ -894,11 +910,23 @@ extension MainWindowController: NSToolbarDelegate { button.action = #selector(toggleArticleExtractor(_:)) button.rightClickAction = #selector(showArticleExtractorMenu(_:)) toolbarItem.view = button + toolbarItem.menuFormRepresentation = NSMenuItem(title: description, action: #selector(toggleArticleExtractor(_:)), keyEquivalent: "") return toolbarItem case .share: let title = NSLocalizedString("Share", comment: "Share") - return buildToolbarButton(.share, title, AppAssets.shareImage, "toolbarShowShareMenu:") + let image = AppAssets.shareImage + if #available(macOS 13.0, *) { + // `item.view` is required for properly positioning the sharing picker. + return buildToolbarButton(.share, title, image, "toolbarShowShareMenu:", usesCustomButtonView: true) + } else { + let item = NSMenuToolbarItem(itemIdentifier: .share) + item.image = image + item.toolTip = title + item.label = title + item.showsIndicator = false + return item + } case .openInBrowser: let title = NSLocalizedString("Open in Browser", comment: "Open in Browser") @@ -1043,7 +1071,11 @@ private extension MainWindowController { } var currentLink: String? { - return oneSelectedArticle?.preferredLink + return selectedArticles?.first { $0.preferredLink != nil }?.preferredLink + } + + var currentLinks: [String?]? { + return selectedArticles?.map { $0.preferredLink } } // MARK: - State Restoration @@ -1081,7 +1113,11 @@ private extension MainWindowController { // MARK: - Command Validation func canCopyArticleURL() -> Bool { - return currentLink != nil + if let currentLinks, currentLinks.count != 0 { + return true + } + + return false } func canCopyExternalURL() -> Bool { @@ -1130,16 +1166,13 @@ private extension MainWindowController { if let toolbarItem = item as? NSToolbarItem { toolbarItem.toolTip = commandName + toolbarItem.image = markingRead ? AppAssets.readClosedImage : AppAssets.readOpenImage } if let menuItem = item as? NSMenuItem { menuItem.title = commandName } - if let toolbarItem = item as? NSToolbarItem, let button = toolbarItem.view as? NSButton { - button.image = markingRead ? AppAssets.readClosedImage : AppAssets.readOpenImage - } - return result } @@ -1220,16 +1253,13 @@ private extension MainWindowController { if let toolbarItem = item as? NSToolbarItem { toolbarItem.toolTip = commandName + toolbarItem.image = starring ? AppAssets.starOpenImage : AppAssets.starClosedImage } if let menuItem = item as? NSMenuItem { menuItem.title = commandName } - if let toolbarItem = item as? NSToolbarItem, let button = toolbarItem.view as? NSButton { - button.image = starring ? AppAssets.starOpenImage : AppAssets.starClosedImage - } - return result } @@ -1252,24 +1282,24 @@ private extension MainWindowController { guard let isReadFiltered = timelineContainerViewController?.isReadFiltered else { (item as? NSMenuItem)?.title = hideCommand - if let toolbarItem = item as? NSToolbarItem, let button = toolbarItem.view as? NSButton { + if let toolbarItem = item as? NSToolbarItem { toolbarItem.toolTip = hideCommand - button.image = AppAssets.filterInactive + toolbarItem.image = AppAssets.filterInactive } return false } if isReadFiltered { (item as? NSMenuItem)?.title = showCommand - if let toolbarItem = item as? NSToolbarItem, let button = toolbarItem.view as? NSButton { + if let toolbarItem = item as? NSToolbarItem { toolbarItem.toolTip = showCommand - button.image = AppAssets.filterActive + toolbarItem.image = AppAssets.filterActive } } else { (item as? NSMenuItem)?.title = hideCommand - if let toolbarItem = item as? NSToolbarItem, let button = toolbarItem.view as? NSButton { + if let toolbarItem = item as? NSToolbarItem { toolbarItem.toolTip = hideCommand - button.image = AppAssets.filterInactive + toolbarItem.image = AppAssets.filterInactive } } @@ -1386,19 +1416,26 @@ private extension MainWindowController { } } - func buildToolbarButton(_ itemIdentifier: NSToolbarItem.Identifier, _ title: String, _ image: NSImage, _ selector: String) -> NSToolbarItem { + func buildToolbarButton(_ itemIdentifier: NSToolbarItem.Identifier, _ title: String, _ image: NSImage, _ selector: String, usesCustomButtonView: Bool = false) -> NSToolbarItem { let toolbarItem = RSToolbarItem(itemIdentifier: itemIdentifier) toolbarItem.autovalidates = true - let button = NSButton() - button.bezelStyle = .texturedRounded - button.image = image - button.imageScaling = .scaleProportionallyDown - button.action = Selector((selector)) - - toolbarItem.view = button toolbarItem.toolTip = title toolbarItem.label = title + + if usesCustomButtonView { + let button = NSButton() + button.bezelStyle = .texturedRounded + button.image = image + button.imageScaling = .scaleProportionallyDown + button.action = Selector((selector)) + toolbarItem.view = button + toolbarItem.menuFormRepresentation = NSMenuItem(title: title, action: Selector((selector)), keyEquivalent: "") + } else { + toolbarItem.image = image + toolbarItem.isBordered = true + toolbarItem.action = Selector((selector)) + } return toolbarItem } @@ -1434,7 +1471,7 @@ private extension MainWindowController { let defaultThemeItem = NSMenuItem() defaultThemeItem.title = ArticleTheme.defaultTheme.name defaultThemeItem.action = #selector(selectArticleTheme(_:)) - defaultThemeItem.state = defaultThemeItem.title == ArticleThemesManager.shared.currentThemeName ? .on : .off + defaultThemeItem.state = defaultThemeItem.title == ArticleThemesManager.shared.currentTheme.name ? .on : .off articleThemeMenu.addItem(defaultThemeItem) articleThemeMenu.addItem(NSMenuItem.separator()) @@ -1443,7 +1480,7 @@ private extension MainWindowController { let themeItem = NSMenuItem() themeItem.title = themeName themeItem.action = #selector(selectArticleTheme(_:)) - themeItem.state = themeItem.title == ArticleThemesManager.shared.currentThemeName ? .on : .off + themeItem.state = themeItem.title == ArticleThemesManager.shared.currentTheme.name ? .on : .off articleThemeMenu.addItem(themeItem) } @@ -1451,5 +1488,17 @@ private extension MainWindowController { articleThemePopUpButton?.menu = articleThemeMenu } + func updateShareToolbarItemMenu() { + guard let shareToolbarItem = shareToolbarItem as? NSMenuToolbarItem else { + return + } + if let shareMenu = shareMenu { + shareToolbarItem.isEnabled = true + shareToolbarItem.menu = shareMenu + } else { + shareToolbarItem.isEnabled = false + } + } + } diff --git a/Mac/MainWindow/Sidebar/SidebarViewController+ContextualMenus.swift b/Mac/MainWindow/Sidebar/SidebarViewController+ContextualMenus.swift index 46286cd64..4f1819daa 100644 --- a/Mac/MainWindow/Sidebar/SidebarViewController+ContextualMenus.swift +++ b/Mac/MainWindow/Sidebar/SidebarViewController+ContextualMenus.swift @@ -69,8 +69,16 @@ extension SidebarViewController { return } - let articles = unreadArticles(for: objects) - guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: Array(articles), markingRead: true, undoManager: undoManager) else { + var markableArticles = unreadArticles(for: objects) + if let directlyMarkedAsUnreadArticles = delegate?.directlyMarkedAsUnreadArticles { + markableArticles = markableArticles.subtracting(directlyMarkedAsUnreadArticles) + } + + guard let undoManager = undoManager, + let markReadCommand = MarkStatusCommand(initialArticles: markableArticles, + markingRead: true, + directlyMarked: false, + undoManager: undoManager) else { return } runCommand(markReadCommand) diff --git a/Mac/MainWindow/Sidebar/SidebarViewController.swift b/Mac/MainWindow/Sidebar/SidebarViewController.swift index 99b33cb90..8dbd08563 100644 --- a/Mac/MainWindow/Sidebar/SidebarViewController.swift +++ b/Mac/MainWindow/Sidebar/SidebarViewController.swift @@ -17,6 +17,7 @@ extension Notification.Name { } protocol SidebarDelegate: AnyObject { + var directlyMarkedAsUnreadArticles: Set
? { get } func sidebarSelectionDidChange(_: SidebarViewController, selectedObjects: [AnyObject]?) func unreadCount(for: AnyObject) -> Int func sidebarInvalidatedRestorationState(_: SidebarViewController) @@ -256,7 +257,11 @@ protocol SidebarDelegate: AnyObject { return } if AppDefaults.shared.feedDoubleClickMarkAsRead, let articles = try? singleSelectedWebFeed?.fetchUnreadArticles() { - if let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: Array(articles), markingRead: true, undoManager: undoManager) { + if let undoManager = undoManager, + let markReadCommand = MarkStatusCommand(initialArticles: Array(articles), + markingRead: true, + directlyMarked: false, + undoManager: undoManager) { runCommand(markReadCommand) } } diff --git a/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift b/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift index 4a50eadf7..88f0ac440 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift @@ -39,12 +39,12 @@ extension TimelineViewController { @objc func markArticlesReadFromContextualMenu(_ sender: Any?) { guard let articles = articles(from: sender) else { return } - markArticles(articles, read: true) + markArticles(articles, read: true, directlyMarked: true) } @objc func markArticlesUnreadFromContextualMenu(_ sender: Any?) { guard let articles = articles(from: sender) else { return } - markArticles(articles, read: false) + markArticles(articles, read: false, directlyMarked: true) } @objc func markAboveArticlesReadFromContextualMenu(_ sender: Any?) { @@ -59,14 +59,14 @@ extension TimelineViewController { @objc func markArticlesStarredFromContextualMenu(_ sender: Any?) { guard let articles = articles(from: sender) else { return } - markArticles(articles, starred: true) + markArticles(articles, starred: true, directlyMarked: true) } @objc func markArticlesUnstarredFromContextualMenu(_ sender: Any?) { guard let articles = articles(from: sender) else { return } - markArticles(articles, starred: false) + markArticles(articles, starred: false, directlyMarked: true) } @objc func selectFeedInSidebarFromContextualMenu(_ sender: Any?) { @@ -81,7 +81,11 @@ extension TimelineViewController { return } - guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: feedArticles, markingRead: true, undoManager: undoManager) else { + guard let undoManager = undoManager, + let markReadCommand = MarkStatusCommand(initialArticles: feedArticles, + markingRead: true, + directlyMarked: false, + undoManager: undoManager) else { return } @@ -89,18 +93,19 @@ extension TimelineViewController { } @objc func openInBrowserFromContextualMenu(_ sender: Any?) { - - guard let menuItem = sender as? NSMenuItem, let urlString = menuItem.representedObject as? String else { + guard let menuItem = sender as? NSMenuItem, let urlStrings = menuItem.representedObject as? [String] else { return } - Browser.open(urlString, inBackground: false) + + Browser.open(urlStrings, fromWindow: self.view.window, invertPreference: NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false) } @objc func copyURLFromContextualMenu(_ sender: Any?) { - guard let menuItem = sender as? NSMenuItem, let urlString = menuItem.representedObject as? String else { + guard let menuItem = sender as? NSMenuItem, let urlStrings = menuItem.representedObject as? [String?] else { return } - URLPasteboardWriter.write(urlString: urlString, to: .general) + + URLPasteboardWriter.write(urlStrings: urlStrings, alertingIn: self.view.window) } @objc func performShareServiceFromContextualMenu(_ sender: Any?) { @@ -114,16 +119,21 @@ extension TimelineViewController { private extension TimelineViewController { - func markArticles(_ articles: [Article], read: Bool) { - markArticles(articles, statusKey: .read, flag: read) + func markArticles(_ articles: [Article], read: Bool, directlyMarked: Bool) { + markArticles(articles, statusKey: .read, flag: read, directlyMarked: directlyMarked) } - func markArticles(_ articles: [Article], starred: Bool) { - markArticles(articles, statusKey: .starred, flag: starred) + func markArticles(_ articles: [Article], starred: Bool, directlyMarked: Bool) { + markArticles(articles, statusKey: .starred, flag: starred, directlyMarked: directlyMarked) } - func markArticles(_ articles: [Article], statusKey: ArticleStatus.Key, flag: Bool) { - guard let undoManager = undoManager, let markStatusCommand = MarkStatusCommand(initialArticles: articles, statusKey: statusKey, flag: flag, undoManager: undoManager) else { + func markArticles(_ articles: [Article], statusKey: ArticleStatus.Key, flag: Bool, directlyMarked: Bool) { + guard let undoManager = undoManager, + let markStatusCommand = MarkStatusCommand(initialArticles: articles, + statusKey: statusKey, + flag: flag, + directlyMarked: directlyMarked, + undoManager: undoManager) else { return } @@ -176,14 +186,19 @@ private extension TimelineViewController { menu.addItem(markAllMenuItem) } } - - if articles.count == 1, let link = articles.first!.preferredLink { + + let links = articles.map { $0.preferredLink } + let compactLinks = links.compactMap { $0 } + + if compactLinks.count > 0 { menu.addSeparatorIfNeeded() - menu.addItem(openInBrowserMenuItem(link)) + menu.addItem(openInBrowserMenuItem(compactLinks)) + menu.addItem(openInBrowserReversedMenuItem(compactLinks)) + menu.addSeparatorIfNeeded() - menu.addItem(copyArticleURLMenuItem(link)) - - if let externalLink = articles.first?.externalLink, externalLink != link { + menu.addItem(copyArticleURLsMenuItem(links)) + + if let externalLink = articles.first?.externalLink, externalLink != links.first { menu.addItem(copyExternalURLMenuItem(externalLink)) } } @@ -274,13 +289,21 @@ private extension TimelineViewController { return menuItem(menuText, #selector(markAllInFeedAsRead(_:)), articles) } - func openInBrowserMenuItem(_ urlString: String) -> NSMenuItem { + func openInBrowserMenuItem(_ urlStrings: [String]) -> NSMenuItem { + return menuItem(NSLocalizedString("Open in Browser", comment: "Command"), #selector(openInBrowserFromContextualMenu(_:)), urlStrings) + } - return menuItem(NSLocalizedString("Open in Browser", comment: "Command"), #selector(openInBrowserFromContextualMenu(_:)), urlString) + func openInBrowserReversedMenuItem(_ urlStrings: [String]) -> NSMenuItem { + let item = menuItem(Browser.titleForOpenInBrowserInverted, #selector(openInBrowserFromContextualMenu(_:)), urlStrings) + item.keyEquivalentModifierMask = .shift + item.isAlternate = true + return item; } - func copyArticleURLMenuItem(_ urlString: String) -> NSMenuItem { - return menuItem(NSLocalizedString("Copy Article URL", comment: "Command"), #selector(copyURLFromContextualMenu(_:)), urlString) + func copyArticleURLsMenuItem(_ urlStrings: [String?]) -> NSMenuItem { + let format = NSLocalizedString("Copy Article URL", comment: "Command") + let title = String.localizedStringWithFormat(format, urlStrings.count) + return menuItem(title, #selector(copyURLFromContextualMenu(_:)), urlStrings) } func copyExternalURLMenuItem(_ urlString: String) -> NSMenuItem { diff --git a/Mac/MainWindow/Timeline/TimelineViewController.swift b/Mac/MainWindow/Timeline/TimelineViewController.swift index cb9c7e9d3..c15a59a7b 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController.swift @@ -65,7 +65,6 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr if !representedObjectArraysAreEqual(oldValue, representedObjects) { unreadCount = 0 - selectionDidChange(nil) if showsSearchResults { fetchAndReplaceArticlesAsync() } else { @@ -75,6 +74,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr } updateUnreadCount() } + selectionDidChange(nil) } } } @@ -123,10 +123,13 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr showFeedNames = .feed } + directlyMarkedAsUnreadArticles = Set
() articleRowMap = [String: [Int]]() tableView.reloadData() } } + + var directlyMarkedAsUnreadArticles = Set
() var unreadCount: Int = 0 { didSet { @@ -219,6 +222,8 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr NotificationCenter.default.addObserver(self, selector: #selector(accountsDidChange(_:)), name: .UserDidDeleteAccount, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(containerChildrenDidChange(_:)), name: .ChildrenDidChange, object: nil) 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) didRegisterForNotifications = true } } @@ -230,7 +235,13 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr // MARK: - API func markAllAsRead(completion: (() -> Void)? = nil) { - guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articles, markingRead: true, undoManager: undoManager, completion: completion) else { + let markableArticles = Set(articles).subtracting(directlyMarkedAsUnreadArticles) + guard let undoManager = undoManager, + let markReadCommand = MarkStatusCommand(initialArticles: markableArticles, + markingRead: true, + directlyMarked: false, + undoManager: undoManager, + completion: completion) else { return } runCommand(markReadCommand) @@ -315,9 +326,8 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr // MARK: - Actions @objc func openArticleInBrowser(_ sender: Any?) { - if let link = oneSelectedArticle?.preferredLink { - Browser.open(link, invertPreference: NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false) - } + let urlStrings = selectedArticles.compactMap { $0.preferredLink } + Browser.open(urlStrings, fromWindow: self.view.window, invertPreference: NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false) } @IBAction func toggleStatusOfSelectedArticles(_ sender: Any?) { @@ -337,14 +347,22 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr } @IBAction func markSelectedArticlesAsRead(_ sender: Any?) { - guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: selectedArticles, markingRead: true, undoManager: undoManager) else { + guard let undoManager = undoManager, + let markReadCommand = MarkStatusCommand(initialArticles: selectedArticles, + markingRead: true, + directlyMarked: true, + undoManager: undoManager) else { return } runCommand(markReadCommand) } @IBAction func markSelectedArticlesAsUnread(_ sender: Any?) { - guard let undoManager = undoManager, let markUnreadCommand = MarkStatusCommand(initialArticles: selectedArticles, markingRead: false, undoManager: undoManager) else { + guard let undoManager = undoManager, + let markUnreadCommand = MarkStatusCommand(initialArticles: selectedArticles, + markingRead: false, + directlyMarked: true, + undoManager: undoManager) else { return } runCommand(markUnreadCommand) @@ -412,7 +430,11 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr return } - guard let undoManager = undoManager, let markStarredCommand = MarkStatusCommand(initialArticles: selectedArticles, markingRead: markingRead, undoManager: undoManager) else { + guard let undoManager = undoManager, + let markStarredCommand = MarkStatusCommand(initialArticles: selectedArticles, + markingRead: markingRead, + directlyMarked: true, + undoManager: undoManager) else { return } @@ -435,7 +457,11 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr return } - guard let undoManager = undoManager, let markStarredCommand = MarkStatusCommand(initialArticles: selectedArticles, markingStarred: starring, undoManager: undoManager) else { + guard let undoManager = undoManager, + let markStarredCommand = MarkStatusCommand(initialArticles: selectedArticles, + markingStarred: starring, + directlyMarked: true, + undoManager: undoManager) else { return } runCommand(markStarredCommand) @@ -502,7 +528,12 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr return } - guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articlesToMark, markingRead: true, undoManager: undoManager) else { + let markableArticles = Set(articlesToMark).subtracting(directlyMarkedAsUnreadArticles) + guard let undoManager = undoManager, + let markReadCommand = MarkStatusCommand(initialArticles: markableArticles, + markingRead: true, + directlyMarked: false, + undoManager: undoManager) else { return } runCommand(markReadCommand) @@ -510,9 +541,16 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr func markAboveArticlesRead(_ selectedArticles: [Article]) { guard let first = selectedArticles.first else { return } + let articlesToMark = articles.articlesAbove(article: first) guard !articlesToMark.isEmpty else { return } - guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articlesToMark, markingRead: true, undoManager: undoManager) else { + + let markableArticles = Set(articlesToMark).subtracting(directlyMarkedAsUnreadArticles) + guard let undoManager = undoManager, + let markReadCommand = MarkStatusCommand(initialArticles: markableArticles, + markingRead: true, + directlyMarked: false, + undoManager: undoManager) else { return } runCommand(markReadCommand) @@ -520,9 +558,16 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr func markBelowArticlesRead(_ selectedArticles: [Article]) { guard let last = selectedArticles.last else { return } + let articlesToMark = articles.articlesBelow(article: last) guard !articlesToMark.isEmpty else { return } - guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articlesToMark, markingRead: true, undoManager: undoManager) else { + + let markableArticles = Set(articlesToMark).subtracting(directlyMarkedAsUnreadArticles) + guard let undoManager = undoManager, + let markReadCommand = MarkStatusCommand(initialArticles: markableArticles, + markingRead: true, + directlyMarked: false, + undoManager: undoManager) else { return } runCommand(markReadCommand) @@ -666,6 +711,28 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr self.groupByFeed = AppDefaults.shared.timelineGroupByFeed } + @objc func markStatusCommandDidDirectMarking(_ note: Notification) { + guard let userInfo = note.userInfo, + let articles = userInfo[Account.UserInfoKey.articles] as? Set
, + let statusKey = userInfo[Account.UserInfoKey.statusKey] as? ArticleStatus.Key, + let flag = userInfo[Account.UserInfoKey.statusFlag] as? Bool else { return } + + if statusKey == .read && flag == false { + directlyMarkedAsUnreadArticles.formUnion(articles) + } + } + + @objc func markStatusCommandDidUndoDirectMarking(_ note: Notification) { + guard let userInfo = note.userInfo, + let articles = userInfo[Account.UserInfoKey.articles] as? Set
, + let statusKey = userInfo[Account.UserInfoKey.statusKey] as? ArticleStatus.Key, + let flag = userInfo[Account.UserInfoKey.statusFlag] as? Bool else { return } + + if statusKey == .read && flag == false { + directlyMarkedAsUnreadArticles.subtract(articles) + } + } + // MARK: - Reloading Data private func cellForRowView(_ rowView: NSView) -> NSView? { @@ -779,8 +846,7 @@ extension TimelineViewController: NSUserInterfaceValidations { item.title = Browser.titleForOpenInBrowserInverted } - let currentLink = oneSelectedArticle?.preferredLink - return currentLink != nil + return selectedArticles.first { $0.preferredLink != nil } != nil } if item.action == #selector(copy(_:)) { @@ -901,14 +967,22 @@ extension TimelineViewController: NSTableViewDelegate { } private func toggleArticleRead(_ article: Article) { - guard let undoManager = undoManager, let markUnreadCommand = MarkStatusCommand(initialArticles: [article], markingRead: !article.status.read, undoManager: undoManager) else { + guard let undoManager = undoManager, + let markUnreadCommand = MarkStatusCommand(initialArticles: [article], + markingRead: !article.status.read, + directlyMarked: true, + undoManager: undoManager) else { return } self.runCommand(markUnreadCommand) } - + private func toggleArticleStarred(_ article: Article) { - guard let undoManager = undoManager, let markUnreadCommand = MarkStatusCommand(initialArticles: [article], markingStarred: !article.status.starred, undoManager: undoManager) else { + guard let undoManager = undoManager, + let markUnreadCommand = MarkStatusCommand(initialArticles: [article], + markingStarred: !article.status.starred, + directlyMarked: true, + undoManager: undoManager) else { return } self.runCommand(markUnreadCommand) diff --git a/Mac/MainWindow/URLPasteboardWriter+NetNewsWire.swift b/Mac/MainWindow/URLPasteboardWriter+NetNewsWire.swift new file mode 100644 index 000000000..81c3e3ce4 --- /dev/null +++ b/Mac/MainWindow/URLPasteboardWriter+NetNewsWire.swift @@ -0,0 +1,36 @@ +// +// URLPasteboardWriter+NetNewsWire.swift +// NetNewsWire +// +// Created by Nate Weaver on 2022-10-10. +// Copyright © 2022 Ranchero Software. All rights reserved. +// + +import RSCore + +extension URLPasteboardWriter { + + /// Copy URL strings, alerting the user the first time the array of URL strings contains `nil`. + /// - Parameters: + /// - urlStrings: The URL strings to copy. + /// - pasteboard: The pastebaord to copy to. + /// - window: The window to use as a sheet parent for the alert. If `nil`, will run the alert modally. + static func write(urlStrings: [String?], to pasteboard: NSPasteboard = .general, alertingIn window: NSWindow?) { + URLPasteboardWriter.write(urlStrings: urlStrings.compactMap { $0 }, to: pasteboard) + + if urlStrings.contains(nil), !AppDefaults.shared.hasSeenNotAllArticlesHaveURLsAlert { + let alert = NSAlert() + alert.messageText = NSLocalizedString("Some articles don’t have links, so they weren't copied.", comment: "\"Some articles have no links\" copy alert message text") + alert.informativeText = NSLocalizedString("You won't see this message again.", comment: "You won't see this message again") + + if let window { + alert.beginSheetModal(for: window) + } else { + alert.runModal() // this should never happen + } + + AppDefaults.shared.hasSeenNotAllArticlesHaveURLsAlert = true + } + } + +} diff --git a/Mac/Resources/Assets.xcassets/preferencesToolbarExtensions.symbolset/preferencesToolbarExtensions.svg b/Mac/Resources/Assets.xcassets/preferencesToolbarExtensions.symbolset/preferencesToolbarExtensions.svg index 7d8c50913..58cd4b435 100644 --- a/Mac/Resources/Assets.xcassets/preferencesToolbarExtensions.symbolset/preferencesToolbarExtensions.svg +++ b/Mac/Resources/Assets.xcassets/preferencesToolbarExtensions.symbolset/preferencesToolbarExtensions.svg @@ -5,10 +5,6 @@ PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> - @@ -52,8 +48,8 @@ PUBLIC "-//W3C//DTD SVG 1.1//EN" Exporting Symbols should be outlined when exporting to ensure the design is preserved when submitting to Xcode. - Template v.4.0 - Requires Xcode 14 or greater + Template v.3.0 + Requires Xcode 13 or greater Generated from puzzlepiece.extension Typeset at 100 points Small @@ -85,13 +81,13 @@ PUBLIC "-//W3C//DTD SVG 1.1//EN" - + - + - + diff --git a/Mac/Resources/Localizable.stringsdict b/Mac/Resources/Localizable.stringsdict new file mode 100644 index 000000000..bbe875a1f --- /dev/null +++ b/Mac/Resources/Localizable.stringsdict @@ -0,0 +1,22 @@ + + + + + Copy Article URL + + NSStringLocalizedFormatKey + %#@copy_article_url@ + copy_article_url + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + other + Copy Article URLs + one + Copy Article URL + + + + diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index ee4d676ea..3aebc0e08 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -192,6 +192,7 @@ 513F32882593EF8F0003048F /* RSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 513F32872593EF8F0003048F /* RSCore */; }; 513F32892593EF8F0003048F /* RSCore in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 513F32872593EF8F0003048F /* RSCore */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 5141E7392373C18B0013FF27 /* WebFeedInspectorViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5141E7382373C18B0013FF27 /* WebFeedInspectorViewController.swift */; }; + 514217062921C9DD00963F14 /* Bundle-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51F85BF42273625800C787DC /* Bundle-Extensions.swift */; }; 5142192A23522B5500E07E2C /* ImageViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5142192923522B5500E07E2C /* ImageViewController.swift */; }; 514219372352510100E07E2C /* ImageScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 514219362352510100E07E2C /* ImageScrollView.swift */; }; 5142194B2353C1CF00E07E2C /* main_mac.js in Resources */ = {isa = PBXBuildFile; fileRef = 5142194A2353C1CF00E07E2C /* main_mac.js */; }; @@ -818,6 +819,7 @@ 84F9EAF4213660A100CF2DE4 /* testGenericScript.applescript in Sources */ = {isa = PBXBuildFile; fileRef = 84F9EAE1213660A100CF2DE4 /* testGenericScript.applescript */; }; 84F9EAF5213660A100CF2DE4 /* establishMainWindowStartingState.applescript in Sources */ = {isa = PBXBuildFile; fileRef = 84F9EAE2213660A100CF2DE4 /* establishMainWindowStartingState.applescript */; }; 84FF69B11FC3793300DC198E /* FaviconURLFinder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84FF69B01FC3793300DC198E /* FaviconURLFinder.swift */; }; + B20180AB28E3B76F0059686A /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = B20180AA28E3B76F0059686A /* Localizable.stringsdict */; }; B24E9ADC245AB88400DA5718 /* NSAttributedString+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = B24E9ABA245AB88300DA5718 /* NSAttributedString+NetNewsWire.swift */; }; B24E9ADD245AB88400DA5718 /* NSAttributedString+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = B24E9ABA245AB88300DA5718 /* NSAttributedString+NetNewsWire.swift */; }; B24E9ADE245AB88400DA5718 /* NSAttributedString+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = B24E9ABA245AB88300DA5718 /* NSAttributedString+NetNewsWire.swift */; }; @@ -827,6 +829,8 @@ B2B8075E239C49D300F191E0 /* RSImage-AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */; }; B2B80778239C4C7000F191E0 /* RSImage-AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */; }; B2B80779239C4C7300F191E0 /* RSImage-AppIcons.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */; }; + B2C12C6628F4C46800373730 /* URLPasteboardWriter+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C12C6528F4C46800373730 /* URLPasteboardWriter+NetNewsWire.swift */; }; + B2C12C6728F4C46800373730 /* URLPasteboardWriter+NetNewsWire.swift in Sources */ = {isa = PBXBuildFile; fileRef = B2C12C6528F4C46800373730 /* URLPasteboardWriter+NetNewsWire.swift */; }; B528F81E23333C7E00E735DD /* page.html in Resources */ = {isa = PBXBuildFile; fileRef = B528F81D23333C7E00E735DD /* page.html */; }; BDCB516724282C8A00102A80 /* AccountsNewsBlur.xib in Resources */ = {isa = PBXBuildFile; fileRef = BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */; }; BDCB516824282C8A00102A80 /* AccountsNewsBlur.xib in Resources */ = {isa = PBXBuildFile; fileRef = BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */; }; @@ -1578,11 +1582,13 @@ 84F9EAE2213660A100CF2DE4 /* establishMainWindowStartingState.applescript */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.applescript; path = establishMainWindowStartingState.applescript; sourceTree = ""; }; 84F9EAE4213660A100CF2DE4 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 84FF69B01FC3793300DC198E /* FaviconURLFinder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconURLFinder.swift; sourceTree = ""; }; + B20180AA28E3B76F0059686A /* Localizable.stringsdict */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.stringsdict; path = Localizable.stringsdict; sourceTree = ""; }; B24E9ABA245AB88300DA5718 /* NSAttributedString+NetNewsWire.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NSAttributedString+NetNewsWire.swift"; sourceTree = ""; }; B24EFD482330FF99006C6242 /* NetNewsWire-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NetNewsWire-Bridging-Header.h"; sourceTree = ""; }; B24EFD5923310109006C6242 /* WKPreferencesPrivate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = WKPreferencesPrivate.h; sourceTree = ""; }; B27EEBDF244D15F2000932E6 /* stylesheet.css */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.css; path = stylesheet.css; sourceTree = ""; }; B2B8075D239C49D300F191E0 /* RSImage-AppIcons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RSImage-AppIcons.swift"; sourceTree = ""; }; + B2C12C6528F4C46800373730 /* URLPasteboardWriter+NetNewsWire.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URLPasteboardWriter+NetNewsWire.swift"; sourceTree = ""; }; B528F81D23333C7E00E735DD /* page.html */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.html; path = page.html; sourceTree = ""; }; BDCB514D24282C8A00102A80 /* AccountsNewsBlur.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = AccountsNewsBlur.xib; sourceTree = ""; }; C5A6ED5123C9AF4300AB6BE2 /* TitleActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleActivityItemSource.swift; sourceTree = ""; }; @@ -2074,7 +2080,6 @@ children = ( 51F9F3FA23DFB25700A314FD /* Animations.swift */, 51F85BFA2275D85000C787DC /* Array-Extensions.swift */, - 51F85BF42273625800C787DC /* Bundle-Extensions.swift */, 51627A92238A3836007B3B4B /* CroppingPreviewParameters.swift */, 512AF9C1236ED52C0066F8BE /* ImageHeaderView.swift */, 512AF9DC236F05230066F8BE /* InteractiveLabel.swift */, @@ -2293,6 +2298,7 @@ 5117715424E1EA0F00A2A836 /* ArticleExtractorButton.swift */, 51FA73B62332D5F70090D516 /* LegacyArticleExtractorButton.swift */, 847CD6C9232F4CBF00FAC46D /* IconView.swift */, + B2C12C6528F4C46800373730 /* URLPasteboardWriter+NetNewsWire.swift */, 844B5B6B1FEA224B00C7C76A /* Keyboard */, 849A975F1ED9EB95007D329B /* Sidebar */, 849A97681ED9EBC8007D329B /* Timeline */, @@ -2423,6 +2429,7 @@ 51A66684238075AE00CB272D /* AddWebFeedDefaultContainer.swift */, 849A97731ED9EC04007D329B /* ArticleStringFormatter.swift */, 849A97581ED9EB0D007D329B /* ArticleUtilities.swift */, + 51F85BF42273625800C787DC /* Bundle-Extensions.swift */, 5108F6B52375E612001ABC45 /* CacheCleaner.swift */, 516AE9DE2372269A007DEEAA /* IconImage.swift */, 849A97971ED9EFAA007D329B /* Node-Extensions.swift */, @@ -2698,6 +2705,7 @@ children = ( 849C64671ED37A5D003D8FC0 /* Assets.xcassets */, 84C9FC8922629E8F00D921D6 /* Credits.rtf */, + B20180AA28E3B76F0059686A /* Localizable.stringsdict */, 84C9FC8A22629E8F00D921D6 /* NetNewsWire.sdef */, 84C9FC9022629ECB00D921D6 /* NetNewsWire.entitlements */, 51F805D32428499E0022C792 /* NetNewsWire-dev.entitlements */, @@ -3603,6 +3611,7 @@ BDCB516724282C8A00102A80 /* AccountsNewsBlur.xib in Resources */, 514A89A2244FD63F0085E65D /* AddTwitterFeedSheet.xib in Resources */, 5103A9982421643300410853 /* blank.html in Resources */, + B20180AB28E3B76F0059686A /* Localizable.stringsdict in Resources */, 515A516E243E7F950089E588 /* ExtensionPointDetail.xib in Resources */, 84BAE64921CEDAF20046DB56 /* CrashReporterWindow.xib in Resources */, 51DEE81226FB9233006DAA56 /* Appanoose.nnwtheme in Resources */, @@ -3953,6 +3962,7 @@ 65ED3FD0235DEF6C0081F399 /* Author+Scriptability.swift in Sources */, 65ED3FD1235DEF6C0081F399 /* PseudoFeed.swift in Sources */, 65ED3FD3235DEF6C0081F399 /* NSScriptCommand+NetNewsWire.swift in Sources */, + B2C12C6728F4C46800373730 /* URLPasteboardWriter+NetNewsWire.swift in Sources */, 65ED3FD4235DEF6C0081F399 /* Article+Scriptability.swift in Sources */, 515A5172243E802B0089E588 /* ExtensionPointDetailViewController.swift in Sources */, 65ED3FD5235DEF6C0081F399 /* SmartFeed.swift in Sources */, @@ -4008,6 +4018,7 @@ 65ED3FFA235DEF6C0081F399 /* WebFeedInspectorViewController.swift in Sources */, 65ED3FFB235DEF6C0081F399 /* AccountsReaderAPIWindowController.swift in Sources */, 65ED3FFC235DEF6C0081F399 /* AccountsAddLocalWindowController.swift in Sources */, + 514217062921C9DD00963F14 /* Bundle-Extensions.swift in Sources */, 65ED3FFD235DEF6C0081F399 /* PasteboardFolder.swift in Sources */, 51386A8F25673277005F3762 /* AccountCell.swift in Sources */, 65ED3FFE235DEF6C0081F399 /* AccountsFeedbinWindowController.swift in Sources */, @@ -4314,6 +4325,7 @@ 848B937221C8C5540038DC0D /* CrashReporter.swift in Sources */, 515A5171243E802B0089E588 /* ExtensionPointDetailViewController.swift in Sources */, 847CD6CA232F4CBF00FAC46D /* IconView.swift in Sources */, + B2C12C6628F4C46800373730 /* URLPasteboardWriter+NetNewsWire.swift in Sources */, 84BBB12E20142A4700F054F5 /* InspectorWindowController.swift in Sources */, 51EF0F7A22771B890050506E /* ColorHash.swift in Sources */, 84E46C7D1F75EF7B005ECFB3 /* AppDefaults.swift in Sources */, diff --git a/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1ba1722c4..89207c710 100644 --- a/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/NetNewsWire.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -60,8 +60,8 @@ "repositoryURL": "https://github.com/Ranchero-Software/RSCore.git", "state": { "branch": null, - "revision": "b0d9ac8811cc35f8cce7b099f552bc947bfcddf5", - "version": "1.1.0" + "revision": "fd64fb77de2c4b6a87a971d353e7eea75100f694", + "version": "1.1.3" } }, { diff --git a/Shared/Article Rendering/stylesheet.css b/Shared/Article Rendering/stylesheet.css index a1059a182..902be2fa6 100644 --- a/Shared/Article Rendering/stylesheet.css +++ b/Shared/Article Rendering/stylesheet.css @@ -219,6 +219,10 @@ img, figure, video, div, object { margin: 0 auto; } +video { + width: 100% !important; +} + iframe { max-width: 100%; margin: 0 auto; diff --git a/Shared/Commands/MarkStatusCommand.swift b/Shared/Commands/MarkStatusCommand.swift index 79cf9777c..aa3e39079 100644 --- a/Shared/Commands/MarkStatusCommand.swift +++ b/Shared/Commands/MarkStatusCommand.swift @@ -8,9 +8,20 @@ import Foundation import RSCore +import Account import Articles // Mark articles read/unread, starred/unstarred, deleted/undeleted. +// +// Directly marked articles are ones that were statused by selecting with a cursor or were selected by group. +// Indirectly marked articles didn't have any focus and were picked up using a Mark All command like Mark All as Read. +// +// See discussion for details: https://github.com/Ranchero-Software/NetNewsWire/issues/3734 + +public extension Notification.Name { + static let MarkStatusCommandDidDirectMarking = Notification.Name("MarkStatusCommandDid√DirectMarking") + static let MarkStatusCommandDidUndoDirectMarking = Notification.Name("MarkStatusCommandDidUndoDirectMarking") +} final class MarkStatusCommand: UndoableCommand { @@ -19,10 +30,11 @@ final class MarkStatusCommand: UndoableCommand { let articles: Set
let undoManager: UndoManager let flag: Bool + let directlyMarked: Bool let statusKey: ArticleStatus.Key var completion: (() -> Void)? = nil - init?(initialArticles: [Article], statusKey: ArticleStatus.Key, flag: Bool, undoManager: UndoManager, completion: (() -> Void)? = nil) { + init?(initialArticles: Set
, statusKey: ArticleStatus.Key, flag: Bool, directlyMarked: Bool, undoManager: UndoManager, completion: (() -> Void)? = nil) { // Filter out articles that already have the desired status or can't be marked. let articlesToMark = MarkStatusCommand.filteredArticles(initialArticles, statusKey, flag) @@ -30,8 +42,9 @@ final class MarkStatusCommand: UndoableCommand { completion?() return nil } - self.articles = Set(articlesToMark) + self.articles = articlesToMark + self.directlyMarked = directlyMarked self.flag = flag self.statusKey = statusKey self.undoManager = undoManager @@ -42,21 +55,39 @@ final class MarkStatusCommand: UndoableCommand { self.redoActionName = actionName } - convenience init?(initialArticles: [Article], markingRead: Bool, undoManager: UndoManager, completion: (() -> Void)? = nil) { - self.init(initialArticles: initialArticles, statusKey: .read, flag: markingRead, undoManager: undoManager, completion: completion) + 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) } - convenience init?(initialArticles: [Article], markingStarred: Bool, undoManager: UndoManager, completion: (() -> Void)? = nil) { - self.init(initialArticles: initialArticles, statusKey: .starred, flag: markingStarred, undoManager: undoManager, completion: completion) + convenience init?(initialArticles: Set
, markingRead: Bool, directlyMarked: Bool, undoManager: UndoManager, completion: (() -> Void)? = nil) { + self.init(initialArticles: initialArticles, statusKey: .read, flag: markingRead, directlyMarked: directlyMarked, undoManager: undoManager, completion: completion) + } + + convenience init?(initialArticles: [Article], markingRead: Bool, directlyMarked: Bool, undoManager: UndoManager, completion: (() -> Void)? = nil) { + self.init(initialArticles: initialArticles, statusKey: .read, flag: markingRead, directlyMarked: directlyMarked, undoManager: undoManager, completion: completion) + } + + convenience init?(initialArticles: Set
, markingStarred: Bool, directlyMarked: Bool, undoManager: UndoManager, completion: (() -> Void)? = nil) { + self.init(initialArticles: initialArticles, statusKey: .starred, flag: markingStarred, directlyMarked: directlyMarked, undoManager: undoManager, completion: completion) + } + + convenience init?(initialArticles: [Article], markingStarred: Bool, directlyMarked: Bool, undoManager: UndoManager, completion: (() -> Void)? = nil) { + self.init(initialArticles: initialArticles, statusKey: .starred, flag: markingStarred, directlyMarked: directlyMarked, undoManager: undoManager, completion: completion) } func perform() { mark(statusKey, flag) + if directlyMarked { + markStatusCommandDidDirectMarking() + } registerUndo() } func undo() { mark(statusKey, !flag) + if directlyMarked { + markStatusCommandDidUndoDirectMarking() + } registerRedo() } } @@ -67,6 +98,18 @@ private extension MarkStatusCommand { markArticles(articles, statusKey: statusKey, flag: flag, completion: completion) completion = nil } + + func markStatusCommandDidDirectMarking() { + NotificationCenter.default.post(name: .MarkStatusCommandDidDirectMarking, object: self, userInfo: [Account.UserInfoKey.articles: articles, + Account.UserInfoKey.statusKey: statusKey, + Account.UserInfoKey.statusFlag: flag]) + } + + func markStatusCommandDidUndoDirectMarking() { + NotificationCenter.default.post(name: .MarkStatusCommandDidUndoDirectMarking, object: self, userInfo: [Account.UserInfoKey.articles: articles, + Account.UserInfoKey.statusKey: statusKey, + Account.UserInfoKey.statusFlag: flag]) + } static private let markReadActionName = NSLocalizedString("Mark Read", comment: "command") static private let markUnreadActionName = NSLocalizedString("Mark Unread", comment: "command") @@ -83,7 +126,7 @@ private extension MarkStatusCommand { } } - static func filteredArticles(_ articles: [Article], _ statusKey: ArticleStatus.Key, _ flag: Bool) -> [Article] { + static func filteredArticles(_ articles: Set
, _ statusKey: ArticleStatus.Key, _ flag: Bool) -> Set
{ return articles.filter{ article in guard article.status.boolStatus(forKey: statusKey) != flag else { return false } @@ -93,4 +136,5 @@ private extension MarkStatusCommand { } } + } diff --git a/Shared/Extensions/ArticleUtilities.swift b/Shared/Extensions/ArticleUtilities.swift index 0b52c15d8..1d19a4742 100644 --- a/Shared/Extensions/ArticleUtilities.swift +++ b/Shared/Extensions/ArticleUtilities.swift @@ -14,7 +14,6 @@ import Account // These handle multiple accounts. func markArticles(_ articles: Set
, statusKey: ArticleStatus.Key, flag: Bool, completion: (() -> Void)? = nil) { - let d: [String: Set
] = accountAndArticlesDictionary(articles) let group = DispatchGroup() @@ -24,7 +23,7 @@ func markArticles(_ articles: Set
, statusKey: ArticleStatus.Key, flag: continue } group.enter() - account.markArticles(accountArticles, statusKey: statusKey, flag: flag) { _ in + account.mark(articles: accountArticles, statusKey: statusKey, flag: flag) { _ in group.leave() } } diff --git a/iOS/UIKit Extensions/Bundle-Extensions.swift b/Shared/Extensions/Bundle-Extensions.swift similarity index 100% rename from iOS/UIKit Extensions/Bundle-Extensions.swift rename to Shared/Extensions/Bundle-Extensions.swift diff --git a/Shared/Extensions/URL-Extensions.swift b/Shared/Extensions/URL-Extensions.swift index f28ade9c1..7824ebec8 100644 --- a/Shared/Extensions/URL-Extensions.swift +++ b/Shared/Extensions/URL-Extensions.swift @@ -22,15 +22,17 @@ extension URL { /// URL pointing to current app version release notes. static var releaseNotes: URL { - let appVersion = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "" var gitHub = "https://github.com/Ranchero-Software/NetNewsWire/releases/tag/" + #if os(macOS) - gitHub += "mac-\(String(describing: appVersion))" - return URL(string: gitHub)! + gitHub += "mac-" #else - gitHub += "ios-\(String(describing: appVersion))" - return URL(string: gitHub)! + gitHub += "ios-" #endif + + gitHub += "\(Bundle.main.versionNumber)-\(Bundle.main.buildNumber)" + + return URL(string: gitHub)! } func valueFor(_ parameter: String) -> String? { diff --git a/Shared/Importers/DefaultFeeds.opml b/Shared/Importers/DefaultFeeds.opml index 7c1c6efac..f0581edaf 100644 --- a/Shared/Importers/DefaultFeeds.opml +++ b/Shared/Importers/DefaultFeeds.opml @@ -5,11 +5,12 @@ - + + diff --git a/Shared/Resources/Appanoose.nnwtheme/stylesheet.css b/Shared/Resources/Appanoose.nnwtheme/stylesheet.css index e47730139..0662fbd39 100644 --- a/Shared/Resources/Appanoose.nnwtheme/stylesheet.css +++ b/Shared/Resources/Appanoose.nnwtheme/stylesheet.css @@ -277,6 +277,10 @@ img, figure, video, div, object { margin: 0 auto; } +video { + width: 100% !important; +} + iframe { max-width: 100%; margin: 0 auto; diff --git a/Shared/Resources/Hyperlegible.nnwtheme/stylesheet.css b/Shared/Resources/Hyperlegible.nnwtheme/stylesheet.css index c929ac40e..0e2c0c91a 100644 --- a/Shared/Resources/Hyperlegible.nnwtheme/stylesheet.css +++ b/Shared/Resources/Hyperlegible.nnwtheme/stylesheet.css @@ -224,6 +224,10 @@ img, figure, video, div, object { margin: 0 auto; } +video { + width: 100% !important; +} + iframe { max-width: 100%; margin: 0 auto; diff --git a/Shared/Resources/NewsFax.nnwtheme/stylesheet.css b/Shared/Resources/NewsFax.nnwtheme/stylesheet.css index 63260d030..f8ad9ea23 100644 --- a/Shared/Resources/NewsFax.nnwtheme/stylesheet.css +++ b/Shared/Resources/NewsFax.nnwtheme/stylesheet.css @@ -273,6 +273,10 @@ img, figure, video, div, object { margin: 0 auto; } +video { + width: 100% !important; +} + iframe { max-width: 100%; margin: 0 auto; diff --git a/Shared/Resources/Promenade.nnwtheme/stylesheet.css b/Shared/Resources/Promenade.nnwtheme/stylesheet.css index 8b78fa56d..b76e4a58b 100644 --- a/Shared/Resources/Promenade.nnwtheme/stylesheet.css +++ b/Shared/Resources/Promenade.nnwtheme/stylesheet.css @@ -246,6 +246,10 @@ img, figure, video, div, object { margin: 0 auto; } +video { + width: 100% !important; +} + iframe { max-width: 100%; margin: 0 auto; diff --git a/Shared/Resources/Sepia.nnwtheme/stylesheet.css b/Shared/Resources/Sepia.nnwtheme/stylesheet.css index b7d9a02dc..b0c81d1d0 100644 --- a/Shared/Resources/Sepia.nnwtheme/stylesheet.css +++ b/Shared/Resources/Sepia.nnwtheme/stylesheet.css @@ -241,6 +241,10 @@ img, figure, video, div, object { margin: 0 auto; } +video { + width: 100% !important; +} + iframe { max-width: 100%; margin: 0 auto; diff --git a/Technotes/ReleaseNotes-Mac.markdown b/Technotes/ReleaseNotes-Mac.markdown index 0abd4eb87..52ecf0762 100644 --- a/Technotes/ReleaseNotes-Mac.markdown +++ b/Technotes/ReleaseNotes-Mac.markdown @@ -1,5 +1,10 @@ # Mac Release Notes +### 6.1.1b1 build 6107 3 Nov 2022 + +Fixed a bug that could prevent users from accessing BazQux if an article was missing a field +Fixed an issue that could prevent Feedly users from syncing if they tried to mark too many articles as read at the same time + ### 6.1 build 6106 6 April 2022 Small cosmetic change — better alignment for items in General Preferences pane diff --git a/Technotes/ReleaseNotes-iOS.markdown b/Technotes/ReleaseNotes-iOS.markdown index b32bac3a2..fba9e6a22 100644 --- a/Technotes/ReleaseNotes-iOS.markdown +++ b/Technotes/ReleaseNotes-iOS.markdown @@ -1,6 +1,32 @@ # iOS Release Notes +### 6.1 Release build 6110 - 9 Nov 2022 + +Changes since 6.0.1… + +Article themes. Several themes ship with the app, and you can create your own. You can change the theme in Preferences. +Fixed a bug that could prevent BazQux syncing when an article may not contain all the info we expect +Fixed a bug that could prevent Feedly syncing when marking a large number of articles as read +Disallow creation of iCloud account in the app if iCloud and iCloud Drive aren’t both enabled +Added links to iCloud Syncing Limitations & Solutions on iCloud Account Management UI +Copy URLs using repaired, rather than raw, feed links +Fixed bug showing quote tweets that only included an image +Video autoplay is now disallowed +Article view now supports RTL layout +Fixed a few crashing bugs +Fixed a layout bug that could happen on returning to the Feeds list +Fixed a bug where go-to-feed might not properly expand disclosure triangles +Prevented the Delete option from showing in the Edit menu on the Article View +Fixed Widget article icon lookup bug + + +### 6.1 TestFlight build 6109 - 31 Oct 2022 + +Enhanced Widget integration to make counts more accurate +Enhanced Widget integration to make make it more efficient and save on battery life + ### 6.1 TestFlight build 6108 - 28 Oct 2022 + Fixed a bug that could prevent BazQux syncing when an article may not contain all the info we expect Fixed a bug that could prevent Feedly syncing when marking a large number of articles as read Prevent Widget integration from running while in the background to remove some crashes @@ -43,6 +69,14 @@ Fixed a bug where go-to-feed might not properly expand disclosure triangles * Video autoplay is now disallowed. * Article view now supports RTL layout. +### 6.0.2 Release - 15 Oct 2021 + +Makes a particular crash on startup, that happens only on iPad, far less likely. + +### 6.0.2 TestFlight build 610 - 25 Sep 2021 + +Fixed bug with state restoration on launch (bug introduced in previous TestFlight build) + ### 6.0.1 TestFlight build 608 - 28 Aug 2021 * Fixed our top crashing bug — it could happen when updating a table view diff --git a/iOS/AppDefaults.swift b/iOS/AppDefaults.swift index e681e7b01..2bcb5eb17 100644 --- a/iOS/AppDefaults.swift +++ b/iOS/AppDefaults.swift @@ -55,7 +55,6 @@ final class AppDefaults: ObservableObject { static let articleFullscreenEnabled = "articleFullscreenEnabled" static let hasUsedFullScreenPreviously = "hasUsedFullScreenPreviously" static let confirmMarkAllAsRead = "confirmMarkAllAsRead" - static let lastRefresh = "lastRefresh" static let addWebFeedAccountID = "addWebFeedAccountID" static let addWebFeedFolderName = "addWebFeedFolderName" static let addFolderAccountID = "addFolderAccountID" @@ -223,17 +222,6 @@ final class AppDefaults: ObservableObject { } set { AppDefaults.setBool(for: Key.confirmMarkAllAsRead, newValue) - AppDefaults.shared.objectWillChange.send() - } - } - - var lastRefresh: Date? { - get { - return AppDefaults.date(for: Key.lastRefresh) - } - set { - AppDefaults.setDate(for: Key.lastRefresh, newValue) - AppDefaults.shared.objectWillChange.send() } } diff --git a/iOS/AppDelegate.swift b/iOS/AppDelegate.swift index 0bac49d03..bf145a3b0 100644 --- a/iOS/AppDelegate.swift +++ b/iOS/AppDelegate.swift @@ -10,6 +10,7 @@ import UIKit import RSCore import RSWeb import Account +import Articles import BackgroundTasks import Secrets import WidgetKit @@ -73,7 +74,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD FeedProviderManager.shared.delegate = ExtensionPointManager.shared NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(accountRefreshDidFinish(_:)), name: .AccountRefreshDidFinish, object: nil) } func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { @@ -150,10 +150,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD } } - @objc func accountRefreshDidFinish(_ note: Notification) { - AppDefaults.shared.lastRefresh = Date() - } - // MARK: - API func manualRefresh(errorHandler: @escaping (Error) -> ()) { @@ -183,7 +179,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD extensionFeedAddRequestFile.resume() syncTimer?.update() - if let lastRefresh = AppDefaults.shared.lastRefresh { + if let lastRefresh = AccountManager.shared.lastArticleFetchEndTime { if Date() > lastRefresh.addingTimeInterval(15 * 60) { AccountManager.shared.refreshAll(errorHandler: ErrorHandler.log) } else { @@ -432,55 +428,40 @@ private extension AppDelegate { private extension AppDelegate { func handleMarkAsRead(userInfo: [AnyHashable: Any]) { - guard let articlePathUserInfo = userInfo[UserInfoKey.articlePath] as? [AnyHashable : Any], - let accountID = articlePathUserInfo[ArticlePathKey.accountID] as? String, - let articleID = articlePathUserInfo[ArticlePathKey.articleID] as? String else { - return - } - resumeDatabaseProcessingIfNecessary() - let account = AccountManager.shared.existingAccount(with: accountID) - guard account != nil else { - logger.debug("No account found from notification.") - return - } - let article = try? account!.fetchArticles(.articleIDs([articleID])) - guard article != nil else { - logger.debug("No account found from search using \(articleID, privacy: .public)") - return - } - account!.markArticles(article!, statusKey: .read, flag: true) { _ in } - self.prepareAccountsForBackground() - account!.syncArticleStatus(completion: { [weak self] _ in - if !AccountManager.shared.isSuspended { - self?.prepareAccountsForBackground() - self?.suspendApplication() - } - }) + markArticle(userInfo: userInfo, statusKey: .read) } func handleMarkAsStarred(userInfo: [AnyHashable: Any]) { + markArticle(userInfo: userInfo, statusKey: .starred) + } + + func markArticle(userInfo: [AnyHashable: Any], statusKey: ArticleStatus.Key) { guard let articlePathUserInfo = userInfo[UserInfoKey.articlePath] as? [AnyHashable : Any], let accountID = articlePathUserInfo[ArticlePathKey.accountID] as? String, let articleID = articlePathUserInfo[ArticlePathKey.articleID] as? String else { return } + resumeDatabaseProcessingIfNecessary() - let account = AccountManager.shared.existingAccount(with: accountID) - guard account != nil else { + + guard let account = AccountManager.shared.existingAccount(with: accountID) else { logger.debug("No account found from notification.") return } - let article = try? account!.fetchArticles(.articleIDs([articleID])) - guard article != nil else { + + guard let articles = try? account.fetchArticles(.articleIDs([articleID])), !articles.isEmpty else { logger.debug("No article found from search using \(articleID, privacy: .public)") return } - account!.markArticles(article!, statusKey: .starred, flag: true) { _ in } - account!.syncArticleStatus(completion: { [weak self] _ in - if !AccountManager.shared.isSuspended { - self?.prepareAccountsForBackground() - self?.suspendApplication() - } - }) + + account.mark(articles: articles, statusKey: statusKey, flag: true) { [weak self] _ in + account.syncArticleStatus(completion: { [weak self] _ in + if !AccountManager.shared.isSuspended { + self?.prepareAccountsForBackground() + self?.suspendApplication() + } + }) + } } + } diff --git a/iOS/Article/ArticleViewController.swift b/iOS/Article/ArticleViewController.swift index a12370ba6..b76ece0ba 100644 --- a/iOS/Article/ArticleViewController.swift +++ b/iOS/Article/ArticleViewController.swift @@ -11,8 +11,9 @@ import WebKit import Account import Articles import SafariServices +import RSCore -class ArticleViewController: UIViewController, MainControllerIdentifiable { +class ArticleViewController: UIViewController, MainControllerIdentifiable, Logging { typealias State = (extractedArticle: ExtractedArticle?, isShowingExtractedArticle: Bool, @@ -259,7 +260,7 @@ class ArticleViewController: UIViewController, MainControllerIdentifiable { identifier: nil, discoverabilityTitle: nil, attributes: [], - state: ArticleThemesManager.shared.currentThemeName == themeName ? .on : .off, + state: ArticleThemesManager.shared.currentTheme.name == themeName ? .on : .off, handler: { action in ArticleThemesManager.shared.currentThemeName = themeName }) @@ -271,7 +272,7 @@ class ArticleViewController: UIViewController, MainControllerIdentifiable { identifier: nil, discoverabilityTitle: nil, attributes: [], - state: ArticleThemesManager.shared.currentThemeName == AppDefaults.defaultThemeName ? .on : .off, + state: ArticleThemesManager.shared.currentTheme.name == AppDefaults.defaultThemeName ? .on : .off, handler: { _ in ArticleThemesManager.shared.currentThemeName = AppDefaults.defaultThemeName }) diff --git a/iOS/Inspector/AccountInspectorViewController.swift b/iOS/Inspector/AccountInspectorViewController.swift index 747dfd139..95c3c7e01 100644 --- a/iOS/Inspector/AccountInspectorViewController.swift +++ b/iOS/Inspector/AccountInspectorViewController.swift @@ -37,7 +37,7 @@ class AccountInspectorViewController: UITableViewController { @IBOutlet weak var nameTextField: UITextField! @IBOutlet weak var activeSwitch: UISwitch! @IBOutlet weak var deleteAccountButton: VibrantButton! - @IBOutlet weak var limitationsAndSolutionsButton: UIButton! + @IBOutlet weak var limitationsAndSolutionsView: UIView! var isModal = false weak var account: Account? @@ -59,7 +59,7 @@ class AccountInspectorViewController: UITableViewController { } if account.type != .cloudKit { - limitationsAndSolutionsButton.isHidden = true + limitationsAndSolutionsView.isHidden = true } if isModal { diff --git a/iOS/Inspector/Inspector.storyboard b/iOS/Inspector/Inspector.storyboard index 7dd8ddd8f..b9c566da5 100644 --- a/iOS/Inspector/Inspector.storyboard +++ b/iOS/Inspector/Inspector.storyboard @@ -1,9 +1,9 @@ - + - + @@ -174,7 +174,7 @@ - + diff --git a/iOS/MasterFeed/MasterFeedViewController.swift b/iOS/MasterFeed/MasterFeedViewController.swift index 459033260..43f949678 100644 --- a/iOS/MasterFeed/MasterFeedViewController.swift +++ b/iOS/MasterFeed/MasterFeedViewController.swift @@ -1059,8 +1059,7 @@ private extension MasterFeedViewController { return nil } - let localizedMenuText = NSLocalizedString("Mark All as Read in “%@”", comment: "Command") - let title = NSString.localizedStringWithFormat(localizedMenuText as NSString, webFeed.nameForDisplay) as String + let title = NSLocalizedString("Mark All as Read", comment: "Command") let cancel = { completion(true) } @@ -1140,8 +1139,7 @@ private extension MasterFeedViewController { return nil } - let localizedMenuText = NSLocalizedString("Mark All as Read in “%@”", comment: "Command") - let title = NSString.localizedStringWithFormat(localizedMenuText as NSString, feed.nameForDisplay) as String + let title = NSLocalizedString("Mark All as Read", comment: "Command") let action = UIAction(title: title, image: AppAssets.markAllAsReadImage) { [weak self] action in MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in if let articles = try? feed.fetchUnreadArticles() { @@ -1158,8 +1156,7 @@ private extension MasterFeedViewController { return nil } - let localizedMenuText = NSLocalizedString("Mark All as Read in “%@”", comment: "Command") - let title = NSString.localizedStringWithFormat(localizedMenuText as NSString, account.nameForDisplay) as String + let title = NSLocalizedString("Mark All as Read", comment: "Command") let action = UIAction(title: title, image: AppAssets.markAllAsReadImage) { [weak self] action in MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in // If you don't have this delay the screen flashes when it executes this code diff --git a/iOS/MasterFeed/RefreshProgressView.swift b/iOS/MasterFeed/RefreshProgressView.swift index a93e1c1ce..5ecd13e1e 100644 --- a/iOS/MasterFeed/RefreshProgressView.swift +++ b/iOS/MasterFeed/RefreshProgressView.swift @@ -53,14 +53,16 @@ struct RefreshProgressView: View { .offset(x: -Self.width * 0.6, y: 0) .offset(x: Self.width * 1.2 * self.offset, y: 0) .animation(.default.repeatForever().speed(0.265), value: self.offset) - .onAppear{ + .onAppear { withAnimation { self.offset = 1 } } + .onDisappear { + self.offset = 0 + } ) .clipShape(Capsule()) - .animation(.default, value: refreshProgressModel.isRefreshing) .frame(width: Self.width, height: Self.height) } diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index a749e8e23..e57db0edf 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -111,6 +111,8 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, Logging { } } + private var directlyMarkedAsUnreadArticles = Set
() + var prefersStatusBarHidden = false private let treeControllerDelegate = WebFeedTreeControllerDelegate() @@ -331,6 +333,8 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, Logging { NotificationCenter.default.addObserver(self, selector: #selector(accountDidDownloadArticles(_:)), name: .AccountDidDownloadArticles, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(importDownloadedTheme(_:)), name: .didEndDownloadingTheme, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(markStatusCommandDidDirectMarking(_:)), name: .MarkStatusCommandDidDirectMarking, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(markStatusCommandDidUndoDirectMarking(_:)), name: .MarkStatusCommandDidUndoDirectMarking, object: nil) } func restoreWindowState(_ activity: NSUserActivity?) { @@ -547,6 +551,28 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, Logging { } } + @objc func markStatusCommandDidDirectMarking(_ note: Notification) { + guard let userInfo = note.userInfo, + let articles = userInfo[Account.UserInfoKey.articles] as? Set
, + let statusKey = userInfo[Account.UserInfoKey.statusKey] as? ArticleStatus.Key, + let flag = userInfo[Account.UserInfoKey.statusFlag] as? Bool else { return } + + if statusKey == .read && flag == false { + directlyMarkedAsUnreadArticles.formUnion(articles) + } + } + + @objc func markStatusCommandDidUndoDirectMarking(_ note: Notification) { + guard let userInfo = note.userInfo, + let articles = userInfo[Account.UserInfoKey.articles] as? Set
, + let statusKey = userInfo[Account.UserInfoKey.statusKey] as? ArticleStatus.Key, + let flag = userInfo[Account.UserInfoKey.statusFlag] as? Bool else { return } + + if statusKey == .read && flag == false { + directlyMarkedAsUnreadArticles.subtract(articles) + } + } + // MARK: API func suspend() { @@ -611,7 +637,10 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, Logging { } func nodeFor(_ indexPath: IndexPath) -> Node? { - guard indexPath.section < shadowTable.count && indexPath.row < shadowTable[indexPath.section].feedNodes.count else { + guard indexPath.section > -1 && + indexPath.row > -1 && + indexPath.section < shadowTable.count && + indexPath.row < shadowTable[indexPath.section].feedNodes.count else { return nil } return shadowTable[indexPath.section].feedNodes[indexPath.row].node @@ -863,8 +892,8 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, Logging { if let oldTimelineFeed = preSearchTimelineFeed { emptyTheTimeline() timelineFeed = oldTimelineFeed - masterTimelineViewController?.reinitializeArticles(resetScroll: true) replaceArticles(with: savedSearchArticles!, animated: true) + masterTimelineViewController?.reinitializeArticles(resetScroll: true) } else { setTimelineFeed(nil, animated: true) } @@ -997,7 +1026,9 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, Logging { } func markAllAsRead(_ articles: [Article], completion: (() -> Void)? = nil) { - markArticlesWithUndo(articles, statusKey: .read, flag: true, completion: completion) + var markableArticles = Set(articles) + markableArticles.subtract(directlyMarkedAsUnreadArticles) + markArticlesWithUndo(markableArticles, statusKey: .read, flag: true, directlyMarked: false, completion: completion) } func markAllAsReadInTimeline(completion: (() -> Void)? = nil) { @@ -1045,13 +1076,13 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, Logging { func markAsReadForCurrentArticle() { if let article = currentArticle { - markArticlesWithUndo([article], statusKey: .read, flag: true) + markArticlesWithUndo([article], statusKey: .read, flag: true, directlyMarked: true) } } func markAsUnreadForCurrentArticle() { if let article = currentArticle { - markArticlesWithUndo([article], statusKey: .read, flag: false) + markArticlesWithUndo([article], statusKey: .read, flag: false, directlyMarked: true) } } @@ -1063,7 +1094,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, Logging { func toggleRead(_ article: Article) { guard !article.status.read || article.isAvailableToMarkUnread else { return } - markArticlesWithUndo([article], statusKey: .read, flag: !article.status.read) + markArticlesWithUndo([article], statusKey: .read, flag: !article.status.read, directlyMarked: true) } func toggleStarredForCurrentArticle() { @@ -1073,7 +1104,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, Logging { } func toggleStar(_ article: Article) { - markArticlesWithUndo([article], statusKey: .starred, flag: !article.status.starred) + markArticlesWithUndo([article], statusKey: .starred, flag: !article.status.starred, directlyMarked: true) } func timelineFeedIsEqualTo(_ feed: WebFeed) -> Bool { @@ -1413,9 +1444,18 @@ private extension SceneCoordinator { navController.toolbar.tintColor = AppAssets.primaryAccentColor } - func markArticlesWithUndo(_ articles: [Article], statusKey: ArticleStatus.Key, flag: Bool, completion: (() -> Void)? = nil) { + func markArticlesWithUndo(_ articles: [Article], statusKey: ArticleStatus.Key, flag: Bool, directlyMarked: Bool, completion: (() -> Void)? = nil) { + markArticlesWithUndo(Set(articles), statusKey: statusKey, flag: flag, directlyMarked: directlyMarked, completion: completion) + } + + func markArticlesWithUndo(_ articles: Set
, statusKey: ArticleStatus.Key, flag: Bool, directlyMarked: Bool, completion: (() -> Void)? = nil) { guard let undoManager = undoManager, - let markReadCommand = MarkStatusCommand(initialArticles: articles, statusKey: statusKey, flag: flag, undoManager: undoManager, completion: completion) else { + let markReadCommand = MarkStatusCommand(initialArticles: articles, + statusKey: statusKey, + flag: flag, + directlyMarked: directlyMarked, + undoManager: undoManager, + completion: completion) else { completion?() return } @@ -1933,6 +1973,7 @@ private extension SceneCoordinator { func emptyTheTimeline() { if !articles.isEmpty { + directlyMarkedAsUnreadArticles = Set
() replaceArticles(with: Set
(), animated: false) } } diff --git a/xcconfig/common/NetNewsWire_ios_target_common.xcconfig b/xcconfig/common/NetNewsWire_ios_target_common.xcconfig index 79897844a..0a90fb07f 100644 --- a/xcconfig/common/NetNewsWire_ios_target_common.xcconfig +++ b/xcconfig/common/NetNewsWire_ios_target_common.xcconfig @@ -1,7 +1,7 @@ // High Level Settings common to both the iOS application and any extensions we bundle with it MARKETING_VERSION = 6.1 -CURRENT_PROJECT_VERSION = 6108 +CURRENT_PROJECT_VERSION = 6110 ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon diff --git a/xcconfig/common/NetNewsWire_mac_target_common.xcconfig b/xcconfig/common/NetNewsWire_mac_target_common.xcconfig index 70ad30b32..fc8972368 100644 --- a/xcconfig/common/NetNewsWire_mac_target_common.xcconfig +++ b/xcconfig/common/NetNewsWire_mac_target_common.xcconfig @@ -1,6 +1,6 @@ // High Level Settings common to both the Mac application and any extensions we bundle with it -MARKETING_VERSION = 6.1 -CURRENT_PROJECT_VERSION = 6106 +MARKETING_VERSION = 6.1.1 +CURRENT_PROJECT_VERSION = 6107 ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = NO ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;