diff --git a/Mac/MainWindow/Sidebar/SidebarViewController+ContextualMenus.swift b/Mac/MainWindow/Sidebar/SidebarViewController+ContextualMenus.swift index 83637d2bb4..635ab794b1 100644 --- a/Mac/MainWindow/Sidebar/SidebarViewController+ContextualMenus.swift +++ b/Mac/MainWindow/Sidebar/SidebarViewController+ContextualMenus.swift @@ -76,6 +76,19 @@ extension SidebarViewController { runCommand(markReadCommand) } + @objc func markObjectsReadExceptStarredFromContextualMenu(_ sender: Any?) { + + guard let menuItem = sender as? NSMenuItem, let objects = menuItem.representedObject as? [Any] else { + return + } + + let articles = unreadUnstarredArticles(for: objects) + guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: Array(articles), markingRead: true, undoManager: undoManager) else { + return + } + runCommand(markReadCommand) + } + @objc func deleteFromContextualMenu(_ sender: Any?) { guard let menuItem = sender as? NSMenuItem, let objects = menuItem.representedObject as? [AnyObject] else { return @@ -212,6 +225,9 @@ private extension SidebarViewController { if feed.unreadCount > 0 { menu.addItem(markAllReadMenuItem([feed])) + if anyObjectHasUnreadUnstarredArticles([feed]) { + menu.addItem(markAllAsReadExceptStarredMenuItem([feed])) + } menu.addItem(NSMenuItem.separator()) } @@ -264,6 +280,9 @@ private extension SidebarViewController { if folder.unreadCount > 0 { menu.addItem(markAllReadMenuItem([folder])) + if anyObjectHasUnreadUnstarredArticles([folder]) { + menu.addItem(markAllAsReadExceptStarredMenuItem([folder])) + } menu.addItem(NSMenuItem.separator()) } @@ -279,6 +298,9 @@ private extension SidebarViewController { if smartFeed.unreadCount > 0 { menu.addItem(markAllReadMenuItem([smartFeed])) + if anyObjectHasUnreadUnstarredArticles([smartFeed]) { + menu.addItem(markAllAsReadExceptStarredMenuItem([smartFeed])) + } } return menu.numberOfItems > 0 ? menu : nil } @@ -289,6 +311,9 @@ private extension SidebarViewController { if anyObjectInArrayHasNonZeroUnreadCount(objects) { menu.addItem(markAllReadMenuItem(objects)) + if anyObjectHasUnreadUnstarredArticles(objects) { + menu.addItem(markAllAsReadExceptStarredMenuItem(objects)) + } } if allObjectsAreFeedsAndOrFolders(objects) { @@ -304,6 +329,11 @@ private extension SidebarViewController { return menuItem(NSLocalizedString("Mark All as Read", comment: "Command"), #selector(markObjectsReadFromContextualMenu(_:)), objects, image: Assets.Images.markAllAsReadMenu) } + func markAllAsReadExceptStarredMenuItem(_ objects: [Any]) -> NSMenuItem { + + return menuItem(NSLocalizedString("Mark Unstarred as Read", comment: "Command"), #selector(markObjectsReadExceptStarredFromContextualMenu(_:)), objects, image: Assets.Images.markAllAsReadMenu) + } + func deleteMenuItem(_ objects: [Any]) -> NSMenuItem { return menuItem(NSLocalizedString("Delete", comment: "Command"), #selector(deleteFromContextualMenu(_:)), objects, image: Assets.Images.delete) @@ -364,4 +394,32 @@ private extension SidebarViewController { } return articles } + + func unreadUnstarredArticles(for objects: [Any]) -> Set
{ + + var articles = Set
() + for object in objects { + if let articleFetcher = object as? ArticleFetcher { + if let unreadArticles = try? articleFetcher.fetchUnreadArticles() { + let unstarred = unreadArticles.filter { !$0.status.starred } + articles.formUnion(unstarred) + } + } + } + return articles + } + + func anyObjectHasUnreadUnstarredArticles(_ objects: [Any]) -> Bool { + + for object in objects { + if let articleFetcher = object as? ArticleFetcher { + if let unreadArticles = try? articleFetcher.fetchUnreadArticles() { + if unreadArticles.contains(where: { !$0.status.starred }) { + return true + } + } + } + } + return false + } } diff --git a/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift b/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift index 8d31fbfd05..5e041fd41d 100644 --- a/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift +++ b/Mac/MainWindow/Timeline/TimelineViewController+ContextualMenus.swift @@ -52,6 +52,13 @@ extension TimelineViewController { markBelowArticlesRead(articles) } + @objc func markAllAsReadExceptStarredFromContextualMenu(_ sender: Any?) { + guard let unreadUnstarred = self.articles.unreadUnstarredArticles() else { + return + } + markArticles(unreadUnstarred, read: true) + } + @objc func markArticlesStarredFromContextualMenu(_ sender: Any?) { guard let articles = articles(from: sender) else { return } markArticles(articles, starred: true) @@ -167,6 +174,9 @@ private extension TimelineViewController { if let last = articles.last, self.articles.articlesBelow(article: last).canMarkAllAsRead() { menu.addItem(markBelowReadMenuItem(articles)) } + if self.articles.canMarkAllAsReadExceptStarred() { + menu.addItem(markAllAsReadExceptStarredMenuItem(articles)) + } menu.addSeparatorIfNeeded() @@ -252,6 +262,10 @@ private extension TimelineViewController { return menuItem(NSLocalizedString("Mark Below as Read", comment: "Command"), #selector(markBelowArticlesReadFromContextualMenu(_:)), articles, image: Assets.Images.markBelowAsRead) } + func markAllAsReadExceptStarredMenuItem(_ articles: [Article]) -> NSMenuItem { + return menuItem(NSLocalizedString("Mark Unstarred as Read", comment: "Command"), #selector(markAllAsReadExceptStarredFromContextualMenu(_:)), articles, image: Assets.Images.markAllAsReadMenu) + } + func selectFeedInSidebarMenuItem(_ feed: Feed) -> NSMenuItem { let localizedMenuText = NSLocalizedString("Select “%@” in Sidebar", comment: "Command") let formattedMenuText = NSString.localizedStringWithFormat(localizedMenuText as NSString, feed.nameForDisplay) diff --git a/Shared/Timeline/ArticleArray.swift b/Shared/Timeline/ArticleArray.swift index 071d1fae5c..4475be8b76 100644 --- a/Shared/Timeline/ArticleArray.swift +++ b/Shared/Timeline/ArticleArray.swift @@ -92,6 +92,15 @@ typealias ArticleArray = [Article] return articles.isEmpty ? nil : articles } + func canMarkAllAsReadExceptStarred() -> Bool { + return anyArticlePassesTest { !$0.status.read && !$0.status.starred } + } + + func unreadUnstarredArticles() -> [Article]? { + let articles = self.filter { !$0.status.read && !$0.status.starred } + return articles.isEmpty ? nil : articles + } + func representSameArticlesInSameOrder(as otherArticles: [Article]) -> Bool { if self.count != otherArticles.count { return false diff --git a/iOS/MainFeed/MainFeedCollectionViewController.swift b/iOS/MainFeed/MainFeedCollectionViewController.swift index 4421a49a73..ba40d39ef1 100644 --- a/iOS/MainFeed/MainFeedCollectionViewController.swift +++ b/iOS/MainFeed/MainFeedCollectionViewController.swift @@ -212,6 +212,10 @@ final class MainFeedCollectionViewController: UICollectionViewController, Undoab alert.addAction(action) } + if let action = self.markUnstarredAsReadAlertAction(indexPath: indexPath, completion: completion) { + alert.addAction(action) + } + let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel") alert.addAction(UIAlertAction(title: cancelTitle, style: .cancel) { _ in completion(true) @@ -860,6 +864,10 @@ extension MainFeedCollectionViewController: UIContextMenuInteractionDelegate { menuElements.append(UIMenu(title: "", options: .displayInline, children: [markAllAction])) } + if let markUnstarredAction = self.markUnstarredAsReadAction(account: account, contentView: interaction.view) { + menuElements.append(UIMenu(title: "", options: .displayInline, children: [markUnstarredAction])) + } + menuElements.append(UIMenu(title: "", options: .displayInline, children: [self.deactivateAccountAction(account: account)])) return UIMenu(title: "", children: menuElements) @@ -910,6 +918,10 @@ extension MainFeedCollectionViewController { menuElements.append(UIMenu(title: "", options: .displayInline, children: [markAllAction])) } + if let markUnstarredAction = self.markUnstarredAsReadAction(indexPath: indexPath) { + menuElements.append(UIMenu(title: "", options: .displayInline, children: [markUnstarredAction])) + } + if includeDeleteRename { menuElements.append(UIMenu(title: "", options: .displayInline, @@ -936,6 +948,10 @@ extension MainFeedCollectionViewController { menuElements.append(UIMenu(title: "", options: .displayInline, children: [markAllAction])) } + if let markUnstarredAction = self.markUnstarredAsReadAction(indexPath: indexPath) { + menuElements.append(UIMenu(title: "", options: .displayInline, children: [markUnstarredAction])) + } + menuElements.append(UIMenu(title: "", options: .displayInline, children: [ @@ -949,12 +965,21 @@ extension MainFeedCollectionViewController { } func makePseudoFeedContextMenu(indexPath: IndexPath) -> UIContextMenuConfiguration? { - guard let markAllAction = self.markAllAsReadAction(indexPath: indexPath) else { + var actions = [UIAction]() + + if let markAllAction = self.markAllAsReadAction(indexPath: indexPath) { + actions.append(markAllAction) + } + if let markUnstarredAction = self.markUnstarredAsReadAction(indexPath: indexPath) { + actions.append(markUnstarredAction) + } + + guard !actions.isEmpty else { return nil } return UIContextMenuConfiguration(identifier: MainFeedRowIdentifier(indexPath: indexPath), previewProvider: nil, actionProvider: { _ in - return UIMenu(title: "", children: [markAllAction]) + return UIMenu(title: "", children: actions) }) } @@ -1164,6 +1189,76 @@ extension MainFeedCollectionViewController { return action } + func markUnstarredAsReadAction(indexPath: IndexPath) -> UIAction? { + guard let sidebarItem = dataSource.itemIdentifier(for: indexPath)?.node.representedObject as? SidebarItem, + let contentView = self.collectionView.cellForItem(at: indexPath)?.contentView, + sidebarItem.unreadCount > 0, + let articles = try? sidebarItem.fetchUnreadArticles(), + Array(articles).unreadUnstarredArticles() != nil else { + return nil + } + + let title = NSLocalizedString("Mark Unstarred as Read", comment: "Command") + let action = UIAction(title: title, image: Assets.Images.markAllAsRead) { [weak self] _ in + MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in + if let articles = try? sidebarItem.fetchUnreadArticles(), + let unstarred = Array(articles).unreadUnstarredArticles() { + self?.coordinator.markAllAsRead(unstarred) + } + } + } + return action + } + + func markUnstarredAsReadAction(account: Account, contentView: UIView?) -> UIAction? { + guard account.unreadCount > 0, let contentView else { + return nil + } + guard let articles = try? account.fetchArticles(.unread()), + Array(articles).unreadUnstarredArticles() != nil else { + return nil + } + + let title = NSLocalizedString("Mark Unstarred as Read", comment: "Command") + let action = UIAction(title: title, image: Assets.Images.markAllAsRead) { [weak self] _ in + MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + if let articles = try? account.fetchArticles(.unread()), + let unstarred = Array(articles).unreadUnstarredArticles() { + self?.coordinator.markAllAsRead(unstarred) + } + } + } + } + return action + } + + func markUnstarredAsReadAlertAction(indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? { + guard let feed = dataSource.itemIdentifier(for: indexPath)?.node.representedObject as? Feed, + feed.unreadCount > 0, + let articles = try? feed.fetchArticles(), + Array(articles).unreadUnstarredArticles() != nil, + let contentView = self.collectionView.cellForItem(at: indexPath)?.contentView else { + return nil + } + + let title = NSLocalizedString("Mark Unstarred as Read", comment: "Command") + let cancel = { + completion(true) + } + + let action = UIAlertAction(title: title, style: .default) { [weak self] _ in + MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView, cancelCompletion: cancel) { [weak self] in + if let articles = try? feed.fetchArticles(), + let unstarred = Array(articles).unreadUnstarredArticles() { + self?.coordinator.markAllAsRead(unstarred) + } + completion(true) + } + } + return action + } + func rename(indexPath: IndexPath) { guard let sidebarItem = dataSource.itemIdentifier(for: indexPath)?.node.representedObject as? SidebarItem else { return diff --git a/iOS/MainTimeline/MainTimelineModernViewController.swift b/iOS/MainTimeline/MainTimelineModernViewController.swift index 33174ba6da..dab65d2206 100644 --- a/iOS/MainTimeline/MainTimelineModernViewController.swift +++ b/iOS/MainTimeline/MainTimelineModernViewController.swift @@ -436,6 +436,11 @@ final class MainTimelineModernViewController: UIViewController, UndoableCommandR coordinator?.markAllAsReadInTimeline() } + private func markAllAsReadExceptStarredInTimeline() { + assert(coordinator != nil) + coordinator?.markAllAsReadExceptStarredInTimeline() + } + @IBAction func markAllAsRead(_ sender: Any?) { let title = NSLocalizedString("Mark All as Read", comment: "Mark All as Read") @@ -516,6 +521,9 @@ extension MainTimelineModernViewController: UICollectionViewDelegate { if let action = self.markAllInFeedAsReadAction(article, indexPath: firstIndex) { secondaryActions.append(action) } + if let action = self.markAllAsReadExceptStarredAction(firstIndex) { + secondaryActions.append(action) + } if !secondaryActions.isEmpty { menuElements.append(UIMenu(title: "", options: .displayInline, children: secondaryActions)) } @@ -689,6 +697,10 @@ private extension MainTimelineModernViewController { alert.addAction(action) } + if let action = self.markAllAsReadExceptStarredAlertAction(indexPath, completion: completion) { + alert.addAction(action) + } + if let action = self.openInBrowserAlertAction(article, completion: completion) { alert.addAction(action) } @@ -1239,6 +1251,45 @@ extension MainTimelineModernViewController { return action } + func markAllAsReadExceptStarredAction(_ indexPath: IndexPath) -> UIAction? { + guard coordinator?.canMarkAllAsReadExceptStarred() ?? false else { + return nil + } + guard let collectionView, let contentView = collectionView.cellForItem(at: indexPath)?.contentView else { + return nil + } + + let title = NSLocalizedString("Mark Unstarred as Read", comment: "Command") + let action = UIAction(title: title, image: Assets.Images.markAllAsRead) { [weak self] _ in + MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in + self?.markAllAsReadExceptStarredInTimeline() + } + } + return action + } + + func markAllAsReadExceptStarredAlertAction(_ indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? { + guard coordinator?.canMarkAllAsReadExceptStarred() ?? false else { + return nil + } + guard let collectionView, let contentView = collectionView.cellForItem(at: indexPath)?.contentView else { + return nil + } + + let title = NSLocalizedString("Mark Unstarred as Read", comment: "Command") + let cancel = { + completion(true) + } + + let action = UIAlertAction(title: title, style: .default) { [weak self] _ in + MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView, cancelCompletion: cancel) { [weak self] in + self?.markAllAsReadExceptStarredInTimeline() + completion(true) + } + } + return action + } + func copyArticleURLAction(_ article: Article) -> UIAction? { guard let url = article.preferredURL else { return nil } let title = NSLocalizedString("Copy Article URL", comment: "Copy Article URL") diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index 5021a0c3e7..ec898150a0 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -1195,6 +1195,22 @@ struct SidebarItemNode: Hashable, Sendable { } } + func canMarkAllAsReadExceptStarred() -> Bool { + return articles.canMarkAllAsReadExceptStarred() + } + + func markAllAsReadExceptStarredInTimeline(completion: (() -> Void)? = nil) { + guard let unreadUnstarred = articles.unreadUnstarredArticles() else { + completion?() + return + } + markAllAsRead(unreadUnstarred) { + self.rootSplitViewController.preferredDisplayMode = .twoBesideSecondary + self.rootSplitViewController.show(.primary) + completion?() + } + } + func canMarkAboveAsRead(for article: Article) -> Bool { let articlesAboveArray = articles.articlesAbove(article: article) return articlesAboveArray.canMarkAllAsRead()