diff --git a/Mac/AppDelegate.swift b/Mac/AppDelegate.swift index a54c50a06..a8e8f2ab0 100644 --- a/Mac/AppDelegate.swift +++ b/Mac/AppDelegate.swift @@ -87,6 +87,7 @@ let appName = "NetNewsWire" private var aboutWindowController: AboutWindowController? private var addFeedController: AddFeedController? private var addFolderWindowController: AddFolderWindowController? + private var addSmartFeedWindowController: AddSmartFeedWindowController? private var importOPMLController: ImportOPMLWindowController? private var exportOPMLController: ExportOPMLWindowController? private var keyboardShortcutsWindowController: WebViewWindowController? @@ -139,6 +140,11 @@ let appName = "NetNewsWire" addFeedController?.showAddFeedSheet(urlString, name, account, folder) } + func showAddSmartFeedSheetOnWindow(_ window: NSWindow, userSmartFeed: UserSmartFeed? = nil) { + addSmartFeedWindowController = AddSmartFeedWindowController(userSmartFeed: userSmartFeed) + addSmartFeedWindowController!.runSheetOnWindow(window) + } + // MARK: - NSApplicationDelegate func applicationWillFinishLaunching(_ notification: Notification) { @@ -498,7 +504,7 @@ let appName = "NetNewsWire" return mainWindowController?.isOpen ?? false } - if item.action == #selector(showAddFeedWindow(_:)) || item.action == #selector(showAddFolderWindow(_:)) { + if item.action == #selector(showAddFeedWindow(_:)) || item.action == #selector(showAddFolderWindow(_:)) || item.action == #selector(showAddSmartFeedWindow(_:)) { return !isDisplayingSheet && !AccountManager.shared.activeAccounts.isEmpty } @@ -629,6 +635,11 @@ let appName = "NetNewsWire" showAddFolderSheetOnWindow(windowController.window!) } + @IBAction func showAddSmartFeedWindow(_ sender: Any?) { + let windowController = createAndShowMainWindowIfNecessary() + showAddSmartFeedSheetOnWindow(windowController.window!) + } + @IBAction func showKeyboardShortcutsWindow(_ sender: Any?) { if keyboardShortcutsWindowController == nil { diff --git a/Mac/Base.lproj/Main.storyboard b/Mac/Base.lproj/Main.storyboard index fa3cf2fbe..887398f5f 100644 --- a/Mac/Base.lproj/Main.storyboard +++ b/Mac/Base.lproj/Main.storyboard @@ -78,6 +78,11 @@ + + + + + diff --git a/Mac/MainWindow/AddSmartFeed/AddSmartFeedWindowController.swift b/Mac/MainWindow/AddSmartFeed/AddSmartFeedWindowController.swift new file mode 100644 index 000000000..65414566a --- /dev/null +++ b/Mac/MainWindow/AddSmartFeed/AddSmartFeedWindowController.swift @@ -0,0 +1,45 @@ +// +// AddSmartFeedWindowController.swift +// NetNewsWire +// +// Hosts the shared SwiftUI smart-feed editor in a sheet on Mac. +// + +import AppKit +import SwiftUI + +@MainActor final class AddSmartFeedWindowController: NSWindowController { + + private var hostWindow: NSWindow? + + convenience init(userSmartFeed: UserSmartFeed? = nil) { + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 460, height: 400), + styleMask: [.titled], + backing: .buffered, + defer: false) + self.init(window: window) + + let editorView = SmartFeedEditorView(userSmartFeed: userSmartFeed) { [weak self] in + self?.endSheet() + } + window.contentViewController = NSHostingController(rootView: editorView) + } + + func runSheetOnWindow(_ hostWindow: NSWindow) { + guard let window else { + return + } + self.hostWindow = hostWindow + Task { @MainActor in + await hostWindow.beginSheet(window) + } + } + + private func endSheet() { + guard let hostWindow, let window else { + return + } + hostWindow.endSheet(window) + self.hostWindow = nil + } +} diff --git a/Mac/MainWindow/Sidebar/SidebarViewController+ContextualMenus.swift b/Mac/MainWindow/Sidebar/SidebarViewController+ContextualMenus.swift index facf79ac2..c59959064 100644 --- a/Mac/MainWindow/Sidebar/SidebarViewController+ContextualMenus.swift +++ b/Mac/MainWindow/Sidebar/SidebarViewController+ContextualMenus.swift @@ -104,6 +104,38 @@ extension SidebarViewController { window.beginSheet(renameSheet) } + @objc func editSmartFeedFromContextualMenu(_ sender: Any?) { + guard let menuItem = sender as? NSMenuItem, + let userSmartFeed = menuItem.representedObject as? UserSmartFeed, + let window = view.window else { + return + } + appDelegate.showAddSmartFeedSheetOnWindow(window, userSmartFeed: userSmartFeed) + } + + @objc func deleteSmartFeedFromContextualMenu(_ sender: Any?) { + guard let menuItem = sender as? NSMenuItem, + let userSmartFeed = menuItem.representedObject as? UserSmartFeed, + let window = view.window else { + return + } + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = NSLocalizedString("Delete Smart Feed", comment: "Command") + let formatString = NSLocalizedString("Are you sure you want to delete the “%@” smart feed?", comment: "Smart feed delete text") + alert.informativeText = NSString.localizedStringWithFormat(formatString as NSString, userSmartFeed.name) as String + alert.addButton(withTitle: NSLocalizedString("Delete", comment: "Delete button")) + alert.addButton(withTitle: NSLocalizedString("Cancel", comment: "Cancel button")) + alert.buttons[0].hasDestructiveAction = true + + alert.beginSheetModal(for: window) { response in + if response == .alertFirstButtonReturn { + SmartFeedsController.shared.removeUserSmartFeed(userSmartFeed) + } + } + } + @objc func toggleNotificationsFromContextMenu(_ sender: Any?) { guard let item = sender as? NSMenuItem, let feed = item.representedObject as? Feed else { @@ -199,6 +231,7 @@ private extension SidebarViewController { menu.addItem(withTitle: NSLocalizedString("New Feed", comment: "Command"), action: #selector(AppDelegate.showAddFeedWindow(_:)), keyEquivalent: "") menu.addItem(withTitle: NSLocalizedString("New Folder", comment: "Command"), action: #selector(AppDelegate.showAddFolderWindow(_:)), keyEquivalent: "") + menu.addItem(withTitle: NSLocalizedString("New Smart Feed", comment: "Command"), action: #selector(AppDelegate.showAddSmartFeedWindow(_:)), keyEquivalent: "") return menu } @@ -269,6 +302,14 @@ private extension SidebarViewController { if smartFeed.unreadCount > 0 { menu.addItem(markAllReadMenuItem([smartFeed])) } + + // Only user-created smart feeds can be edited or deleted. + if let userSmartFeed = SmartFeedsController.shared.userSmartFeed(for: smartFeed.sidebarItemID) { + menu.addSeparatorIfNeeded() + menu.addItem(menuItem(NSLocalizedString("Edit Smart Feed", comment: "Command"), #selector(editSmartFeedFromContextualMenu(_:)), userSmartFeed)) + menu.addItem(menuItem(NSLocalizedString("Delete Smart Feed", comment: "Command"), #selector(deleteSmartFeedFromContextualMenu(_:)), userSmartFeed)) + } + return menu.numberOfItems > 0 ? menu : nil } diff --git a/Modules/Account/Sources/Account/Account.swift b/Modules/Account/Sources/Account/Account.swift index ba5b877d2..d0e7093fc 100644 --- a/Modules/Account/Sources/Account/Account.swift +++ b/Modules/Account/Sources/Account/Account.swift @@ -87,6 +87,7 @@ public enum FetchType { case articleIDs(Set) case search(String) case searchWithArticleIDs(String, Set) + case smartFeedCriteria(SmartFeedCriteria) } @MainActor public final class Account: ProgressInfoReporter, DisplayNameProvider, UnreadCountProvider, Container, Hashable { @@ -800,6 +801,8 @@ public enum FetchType { return _fetchArticlesMatching(searchString: searchString) case .searchWithArticleIDs(let searchString, let articleIDs): return _fetchArticlesMatchingWithArticleIDs(searchString: searchString, articleIDs: articleIDs) + case .smartFeedCriteria(let criteria): + return _fetchArticlesMatchingCriteria(criteria) } } @@ -825,9 +828,15 @@ public enum FetchType { return await _fetchArticlesMatchingAsync(searchString: searchString) case .searchWithArticleIDs(let searchString, let articleIDs): return await _fetchArticlesMatchingWithArticleIDsAsync(searchString: searchString, articleIDs: articleIDs) + case .smartFeedCriteria(let criteria): + return await _fetchArticlesMatchingCriteriaAsync(criteria) } } + public func fetchUnreadCountMatchingCriteriaAsync(_ criteria: SmartFeedCriteria) async -> Int { + await database.fetchUnreadCountMatchingCriteriaAsync(criteria: criteria, feedIDs: flattenedFeedsIDs) + } + public func fetchUnreadCountForStarredArticlesAsync() async -> Int { await database.fetchUnreadCountForStarredArticlesAsync(feedIDs: flattenedFeedsIDs) } @@ -1288,6 +1297,14 @@ private extension Account { await database.fetchArticlesMatchingAsync(searchString: searchString, feedIDs: flattenedFeedsIDs) } + func _fetchArticlesMatchingCriteria(_ criteria: SmartFeedCriteria) -> Set
{ + database.fetchArticlesMatchingCriteria(criteria: criteria, feedIDs: flattenedFeedsIDs) + } + + func _fetchArticlesMatchingCriteriaAsync(_ criteria: SmartFeedCriteria) async -> Set
{ + await database.fetchArticlesMatchingCriteriaAsync(criteria: criteria, feedIDs: flattenedFeedsIDs) + } + func _fetchArticlesMatchingWithArticleIDs(searchString: String, articleIDs: Set) -> Set
{ database.fetchArticlesMatchingWithArticleIDs(searchString: searchString, articleIDs: articleIDs) } diff --git a/Modules/ArticlesDatabase/Package.swift b/Modules/ArticlesDatabase/Package.swift index bd31ac17a..84bd358ca 100644 --- a/Modules/ArticlesDatabase/Package.swift +++ b/Modules/ArticlesDatabase/Package.swift @@ -29,6 +29,14 @@ let package = Package( .enableUpcomingFeature("NonisolatedNonsendingByDefault"), .enableUpcomingFeature("InferIsolatedConformances") ] + ), + .testTarget( + name: "ArticlesDatabaseTests", + dependencies: ["ArticlesDatabase"], + swiftSettings: [ + .enableUpcomingFeature("NonisolatedNonsendingByDefault"), + .enableUpcomingFeature("InferIsolatedConformances") + ] ) ] ) diff --git a/Modules/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift b/Modules/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift index 597dd7ebb..4c85bd149 100644 --- a/Modules/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift +++ b/Modules/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesDatabase.swift @@ -158,6 +158,11 @@ public struct ArticleCounts: Sendable { return articlesTable.fetchArticlesMatching(searchString, feedIDs) } + public func fetchArticlesMatchingCriteria(criteria: SmartFeedCriteria, feedIDs: Set) -> Set
{ + Self.logger.debug("ArticlesDatabase: \(#function, privacy: .public) \(self.accountID, privacy: .public)") + return articlesTable.fetchArticlesMatchingCriteria(criteria, feedIDs) + } + public func fetchArticlesMatchingWithArticleIDs(searchString: String, articleIDs: Set) -> Set
{ Self.logger.debug("ArticlesDatabase: \(#function, privacy: .public) \(self.accountID, privacy: .public)") return articlesTable.fetchArticlesMatchingWithArticleIDs(searchString, articleIDs) @@ -231,6 +236,14 @@ public struct ArticleCounts: Sendable { } } + public func fetchArticlesMatchingCriteriaAsync(criteria: SmartFeedCriteria, feedIDs: Set) async -> Set
{ + await withCheckedContinuation { continuation in + articlesTable.fetchArticlesMatchingCriteriaAsync(criteria, feedIDs) { articles in + continuation.resume(returning: articles) + } + } + } + public func fetchArticlesMatchingWithArticleIDsAsync(searchString: String, articleIDs: Set) async -> Set
{ await withCheckedContinuation { continuation in _fetchArticlesMatchingWithArticleIDsAsync(searchString: searchString, articleIDs: articleIDs) { articles in @@ -289,6 +302,14 @@ public struct ArticleCounts: Sendable { } } + public func fetchUnreadCountMatchingCriteriaAsync(criteria: SmartFeedCriteria, feedIDs: Set) async -> Int { + await withCheckedContinuation { continuation in + articlesTable.fetchUnreadCountMatchingCriteriaAsync(criteria, feedIDs) { unreadCount in + continuation.resume(returning: unreadCount) + } + } + } + public func fetchTodayArticlesCountAsync(feedIDs: Set) async -> Int { await withCheckedContinuation { continuation in articlesTable.fetchArticlesCountSince(feedIDs, todayCutoffDate()) { count in diff --git a/Modules/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift b/Modules/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift index 425801834..1d79c3cc4 100644 --- a/Modules/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift +++ b/Modules/ArticlesDatabase/Sources/ArticlesDatabase/ArticlesTable.swift @@ -108,6 +108,29 @@ final class ArticlesTable: DatabaseTable, Sendable { fetchArticlesCount { self.fetchStarredArticlesCount(feedIDs, $0) } } + // MARK: - Fetching Custom Smart Feed (Criteria) Articles + + func fetchArticlesMatchingCriteria(_ criteria: SmartFeedCriteria, _ feedIDs: Set) -> Set
{ + fetchArticles { self.fetchArticlesMatchingCriteria(criteria, feedIDs, $0) } + } + + func fetchArticlesMatchingCriteriaAsync(_ criteria: SmartFeedCriteria, _ feedIDs: Set, _ completion: @escaping ArticleSetResultBlock) { + fetchArticlesAsync({ self.fetchArticlesMatchingCriteria(criteria, feedIDs, $0) }, completion) + } + + func fetchUnreadCountMatchingCriteriaAsync(_ criteria: SmartFeedCriteria, _ feedIDs: Set, _ completion: @escaping SingleUnreadCountCompletionBlock) { + if feedIDs.isEmpty { + completion(0) + return + } + queue.runInDatabase { database in + let count = self.fetchUnreadCountMatchingCriteria(criteria, feedIDs, database) + DispatchQueue.main.async { + completion(count) + } + } + } + // MARK: - Fetching Counts Async func fetchArticleCountsAsync(_ feedIDs: Set, _ completion: @escaping @Sendable (ArticleCounts) -> Void) { @@ -803,6 +826,31 @@ nonisolated private extension ArticlesTable { return fetchArticlesWithWhereClause(database, whereClause: "articles.feedID = ?", parameters: [feedID as AnyObject]) } + func fetchArticlesMatchingCriteria(_ criteria: SmartFeedCriteria, _ feedIDs: Set, _ database: FMDatabase) -> Set
{ + // select * from articles natural join statuses where feedID in (…) and () + if feedIDs.isEmpty { + return Set
() + } + let (criteriaClause, criteriaParameters) = criteria.whereClauseAndParameters(forAccountID: accountID) + let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))! + let whereClause = "feedID in \(placeholders) and (\(criteriaClause))" + var parameters = feedIDs.map { $0 as AnyObject } + parameters += criteriaParameters.map { $0 as AnyObject } + return fetchArticlesWithWhereClause(database, whereClause: whereClause, parameters: parameters) + } + + func fetchUnreadCountMatchingCriteria(_ criteria: SmartFeedCriteria, _ feedIDs: Set, _ database: FMDatabase) -> Int { + if feedIDs.isEmpty { + return 0 + } + let (criteriaClause, criteriaParameters) = criteria.whereClauseAndParameters(forAccountID: accountID) + let placeholders = NSString.rs_SQLValueList(withPlaceholders: UInt(feedIDs.count))! + let whereClause = "feedID in \(placeholders) and (\(criteriaClause)) and read=0" + var parameters = feedIDs.map { $0 as AnyObject } + parameters += criteriaParameters.map { $0 as AnyObject } + return fetchArticleCountsWithWhereClause(database, whereClause: whereClause, parameters: parameters) + } + func fetchArticles(articleIDs: Set, _ database: FMDatabase) -> Set
{ if articleIDs.isEmpty { return Set
() diff --git a/Modules/ArticlesDatabase/Sources/ArticlesDatabase/SmartFeedCriteria.swift b/Modules/ArticlesDatabase/Sources/ArticlesDatabase/SmartFeedCriteria.swift new file mode 100644 index 000000000..01424c713 --- /dev/null +++ b/Modules/ArticlesDatabase/Sources/ArticlesDatabase/SmartFeedCriteria.swift @@ -0,0 +1,77 @@ +// +// SmartFeedCriteria.swift +// ArticlesDatabase +// +// Defines the rules that back a user-created smart feed. +// + +import Foundation + +/// The set of rules for a user-created smart feed. +/// +/// A smart feed matches an article when its conditions are satisfied according to +/// `matchType` (`all` = every condition, `any` = at least one). Lives in the +/// ArticlesDatabase module so both `FetchType` (in the Account module) and the SQL +/// builder here can reference it without a dependency cycle. +public struct SmartFeedCriteria: Codable, Sendable, Hashable { + + public enum MatchType: String, Codable, Sendable, Hashable { + case all + case any + } + + public enum Condition: Codable, Sendable, Hashable { + /// `true` == "is read", `false` == "is unread". + case read(Bool) + /// `true` == "is starred", `false` == "is not starred". + case starred(Bool) + /// A specific feed. `isNegated` == "is not". Feeds are account-scoped, so the + /// `accountID` is stored alongside the `feedID` (which is only unique within an account). + case feed(accountID: String, feedID: String, isNegated: Bool) + } + + public var matchType: MatchType + public var conditions: [Condition] + + public init(matchType: MatchType, conditions: [Condition]) { + self.matchType = matchType + self.conditions = conditions + } + + /// A SQL WHERE fragment (and its bound parameters) evaluating these conditions + /// against the `articles natural join statuses` query for a single account. + /// + /// A feed condition that references a *different* account is reduced to a constant — + /// false for "feed is", true for "feed is not" — because a feedID from another account + /// can never match a row in this account's database. This keeps a cross-account smart + /// feed correct when each account is queried in turn. + public func whereClauseAndParameters(forAccountID accountID: String) -> (clause: String, parameters: [String]) { + var fragments = [String]() + var parameters = [String]() + + for condition in conditions { + switch condition { + case .read(let isRead): + fragments.append("read=\(isRead ? 1 : 0)") + case .starred(let isStarred): + fragments.append("starred=\(isStarred ? 1 : 0)") + case .feed(let conditionAccountID, let feedID, let isNegated): + if conditionAccountID == accountID { + fragments.append(isNegated ? "feedID<>?" : "feedID=?") + parameters.append(feedID) + } else { + // Feed belongs to another account: "is" can never match here; "is not" always matches. + fragments.append(isNegated ? "1" : "0") + } + } + } + + if fragments.isEmpty { + // No conditions: match-all matches everything, match-any matches nothing. + return (matchType == .all ? "1" : "0", []) + } + + let separator = matchType == .all ? " and " : " or " + return (fragments.joined(separator: separator), parameters) + } +} diff --git a/Modules/ArticlesDatabase/Tests/ArticlesDatabaseTests/SmartFeedCriteriaTests.swift b/Modules/ArticlesDatabase/Tests/ArticlesDatabaseTests/SmartFeedCriteriaTests.swift new file mode 100644 index 000000000..01df59e8f --- /dev/null +++ b/Modules/ArticlesDatabase/Tests/ArticlesDatabaseTests/SmartFeedCriteriaTests.swift @@ -0,0 +1,130 @@ +// +// SmartFeedCriteriaTests.swift +// ArticlesDatabaseTests +// +// Verifies the SQL WHERE-clause building for custom smart feeds, including the +// cross-account feed-scoping rules. +// + +import XCTest +@testable import ArticlesDatabase + +final class SmartFeedCriteriaTests: XCTestCase { + + private let accountID = "account-1" + private let otherAccountID = "account-2" + + // MARK: - Single conditions + + func testUnread() { + let criteria = SmartFeedCriteria(matchType: .all, conditions: [.read(false)]) + let (clause, parameters) = criteria.whereClauseAndParameters(forAccountID: accountID) + XCTAssertEqual(clause, "read=0") + XCTAssertTrue(parameters.isEmpty) + } + + func testRead() { + let criteria = SmartFeedCriteria(matchType: .all, conditions: [.read(true)]) + XCTAssertEqual(criteria.whereClauseAndParameters(forAccountID: accountID).clause, "read=1") + } + + func testStarred() { + let starred = SmartFeedCriteria(matchType: .all, conditions: [.starred(true)]) + XCTAssertEqual(starred.whereClauseAndParameters(forAccountID: accountID).clause, "starred=1") + + let notStarred = SmartFeedCriteria(matchType: .all, conditions: [.starred(false)]) + XCTAssertEqual(notStarred.whereClauseAndParameters(forAccountID: accountID).clause, "starred=0") + } + + // MARK: - Feed conditions (same account) + + func testFeedIsSameAccount() { + let criteria = SmartFeedCriteria(matchType: .all, conditions: [.feed(accountID: accountID, feedID: "feed-1", isNegated: false)]) + let (clause, parameters) = criteria.whereClauseAndParameters(forAccountID: accountID) + XCTAssertEqual(clause, "feedID=?") + XCTAssertEqual(parameters, ["feed-1"]) + } + + func testFeedIsNotSameAccount() { + let criteria = SmartFeedCriteria(matchType: .all, conditions: [.feed(accountID: accountID, feedID: "feed-1", isNegated: true)]) + let (clause, parameters) = criteria.whereClauseAndParameters(forAccountID: accountID) + XCTAssertEqual(clause, "feedID<>?") + XCTAssertEqual(parameters, ["feed-1"]) + } + + // MARK: - Feed conditions (different account) reduce to constants + + func testFeedIsOtherAccountIsFalse() { + // "feed is X" where X lives in another account can never match here. + let criteria = SmartFeedCriteria(matchType: .all, conditions: [.feed(accountID: otherAccountID, feedID: "feed-1", isNegated: false)]) + let (clause, parameters) = criteria.whereClauseAndParameters(forAccountID: accountID) + XCTAssertEqual(clause, "0") + XCTAssertTrue(parameters.isEmpty) + } + + func testFeedIsNotOtherAccountIsTrue() { + // "feed is not X" where X lives in another account is always true here. + let criteria = SmartFeedCriteria(matchType: .all, conditions: [.feed(accountID: otherAccountID, feedID: "feed-1", isNegated: true)]) + let (clause, parameters) = criteria.whereClauseAndParameters(forAccountID: accountID) + XCTAssertEqual(clause, "1") + XCTAssertTrue(parameters.isEmpty) + } + + // MARK: - Combining conditions + + func testMatchAllJoinsWithAnd() { + let criteria = SmartFeedCriteria(matchType: .all, conditions: [.read(false), .starred(true)]) + XCTAssertEqual(criteria.whereClauseAndParameters(forAccountID: accountID).clause, "read=0 and starred=1") + } + + func testMatchAnyJoinsWithOr() { + let criteria = SmartFeedCriteria(matchType: .any, conditions: [.read(false), .starred(true)]) + XCTAssertEqual(criteria.whereClauseAndParameters(forAccountID: accountID).clause, "read=0 or starred=1") + } + + /// The motivating example: all unread except specific feeds. + func testUnreadExcludingFeeds() { + let criteria = SmartFeedCriteria(matchType: .all, conditions: [ + .read(false), + .feed(accountID: accountID, feedID: "noisy-1", isNegated: true), + .feed(accountID: accountID, feedID: "noisy-2", isNegated: true) + ]) + let (clause, parameters) = criteria.whereClauseAndParameters(forAccountID: accountID) + XCTAssertEqual(clause, "read=0 and feedID<>? and feedID<>?") + XCTAssertEqual(parameters, ["noisy-1", "noisy-2"]) + } + + /// Parameter order must follow condition order so placeholders bind correctly. + func testParameterOrderMatchesConditionOrder() { + let criteria = SmartFeedCriteria(matchType: .all, conditions: [ + .feed(accountID: accountID, feedID: "first", isNegated: false), + .feed(accountID: accountID, feedID: "second", isNegated: true) + ]) + XCTAssertEqual(criteria.whereClauseAndParameters(forAccountID: accountID).parameters, ["first", "second"]) + } + + // MARK: - Empty conditions + + func testEmptyMatchAllIsTrue() { + let criteria = SmartFeedCriteria(matchType: .all, conditions: []) + XCTAssertEqual(criteria.whereClauseAndParameters(forAccountID: accountID).clause, "1") + } + + func testEmptyMatchAnyIsFalse() { + let criteria = SmartFeedCriteria(matchType: .any, conditions: []) + XCTAssertEqual(criteria.whereClauseAndParameters(forAccountID: accountID).clause, "0") + } + + // MARK: - Round-trip Codable + + func testCodableRoundTrip() throws { + let criteria = SmartFeedCriteria(matchType: .any, conditions: [ + .read(false), + .starred(true), + .feed(accountID: accountID, feedID: "feed-1", isNegated: true) + ]) + let data = try JSONEncoder().encode(criteria) + let decoded = try JSONDecoder().decode(SmartFeedCriteria.self, from: data) + XCTAssertEqual(criteria, decoded) + } +} diff --git a/Shared/Assets.swift b/Shared/Assets.swift index a17c3d4a6..9a13b82b6 100644 --- a/Shared/Assets.swift +++ b/Shared/Assets.swift @@ -94,6 +94,7 @@ struct Assets { static let mainFolder = IconImage(folder, isSymbol: true, isBackgroundSuppressed: true, preferredColor: Assets.Colors.primaryAccent) static let todayFeed = IconImage(RSImage(symbol: "sun.max.fill")!, isSymbol: true, isBackgroundSuppressed: true, preferredColor: NSColor.orange) static let unreadFeed = IconImage(RSImage(symbol: "largecircle.fill.circle")!, isSymbol: true, isBackgroundSuppressed: true, preferredColor: Assets.Colors.primaryAccent) + static let customSmartFeed = IconImage(RSImage(symbol: "line.3.horizontal.decrease.circle")!, isSymbol: true, isBackgroundSuppressed: true, preferredColor: Assets.Colors.primaryAccent) #else // iOS static var accountLocalPadImage: RSImage { RSImage(named: "accountLocalPad")! } @@ -123,6 +124,7 @@ struct Assets { static let mainFolder = IconImage(folder, isSymbol: true, isBackgroundSuppressed: true, preferredColor: Assets.Colors.secondaryAccent) static let todayFeed = IconImage(RSImage(symbol: "sun.max.fill")!, isSymbol: true, isBackgroundSuppressed: true, preferredColor: UIColor.systemOrange) static let unreadFeed = IconImage(RSImage(symbol: "largecircle.fill.circle")!, isSymbol: true, isBackgroundSuppressed: true, preferredColor: Assets.Colors.secondaryAccent) + static let customSmartFeed = IconImage(RSImage(symbol: "line.3.horizontal.decrease.circle")!, isSymbol: true, isBackgroundSuppressed: true, preferredColor: Assets.Colors.secondaryAccent) static var timelineStar: RSImage { let image = RSImage(symbol: "star.fill")! return image.withTintColor(Assets.Colors.star, renderingMode: .alwaysOriginal) diff --git a/Shared/SmartFeeds/CustomSmartFeedDelegate.swift b/Shared/SmartFeeds/CustomSmartFeedDelegate.swift new file mode 100644 index 000000000..3cd0ace9d --- /dev/null +++ b/Shared/SmartFeeds/CustomSmartFeedDelegate.swift @@ -0,0 +1,44 @@ +// +// CustomSmartFeedDelegate.swift +// NetNewsWire +// +// Backs a user-created smart feed, fetching articles that match its criteria. +// + +import Foundation +import RSCore +import Articles +import ArticlesDatabase +import Account +import Images + +@MainActor final class CustomSmartFeedDelegate: SmartFeedDelegate { + + let userSmartFeed: UserSmartFeed + + init(userSmartFeed: UserSmartFeed) { + self.userSmartFeed = userSmartFeed + } + + var sidebarItemID: SidebarItemIdentifier? { + // A stable per-feed id (the UUID) — not a type name — so identity survives + // relaunches, state restoration, and mutation of the smart-feeds array. + SidebarItemIdentifier.smartFeed(userSmartFeed.id) + } + + var nameForDisplay: String { + userSmartFeed.name + } + + var fetchType: FetchType { + .smartFeedCriteria(userSmartFeed.criteria) + } + + var smallIcon: IconImage? { + Assets.Images.customSmartFeed + } + + func fetchUnreadCount(account: Account) async -> Int { + await account.fetchUnreadCountMatchingCriteriaAsync(userSmartFeed.criteria) + } +} diff --git a/Shared/SmartFeeds/SmartFeedEditor/SmartFeedEditorModel.swift b/Shared/SmartFeeds/SmartFeedEditor/SmartFeedEditorModel.swift new file mode 100644 index 000000000..09f255176 --- /dev/null +++ b/Shared/SmartFeeds/SmartFeedEditor/SmartFeedEditorModel.swift @@ -0,0 +1,141 @@ +// +// SmartFeedEditorModel.swift +// NetNewsWire +// +// Backs the shared SwiftUI editor for creating and editing custom smart feeds. +// + +import Foundation +import Account +import ArticlesDatabase + +@MainActor final class SmartFeedEditorModel: ObservableObject { + + /// Which attribute a condition row matches on. + enum ConditionField: String, CaseIterable, Identifiable { + case status + case starred + case feed + var id: String { rawValue } + } + + /// An editable, UI-friendly representation of a single `SmartFeedCriteria.Condition`. + struct EditableCondition: Identifiable { + var id = UUID() + var field: ConditionField = .status + var statusIsRead = false // false == "is Unread" + var starredIsStarred = true // true == "is Starred" + var feedIsNegated = false // false == "is" + var feedChoiceID: String? // matches FeedChoice.id + } + + /// A selectable feed, across all active accounts. + struct FeedChoice: Identifiable, Hashable { + let accountID: String + let feedID: String + let name: String + var id: String { "\(accountID)|\(feedID)" } + } + + let existingID: String? + let feedChoices: [FeedChoice] + + @Published var name: String + @Published var matchType: SmartFeedCriteria.MatchType + @Published var conditions: [EditableCondition] + + init(userSmartFeed: UserSmartFeed?) { + self.feedChoices = Self.allFeedChoices() + + if let userSmartFeed { + existingID = userSmartFeed.id + name = userSmartFeed.name + matchType = userSmartFeed.criteria.matchType + conditions = userSmartFeed.criteria.conditions.map { Self.editableCondition(from: $0) } + } else { + existingID = nil + name = "" + matchType = .all + conditions = [EditableCondition()] + } + + if conditions.isEmpty { + conditions = [EditableCondition()] + } + } + + var isEditingExisting: Bool { + existingID != nil + } + + var isValid: Bool { + guard !name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return false + } + guard !conditions.isEmpty else { + return false + } + // A feed condition with no feed chosen isn't usable. + return conditions.allSatisfy { condition in + condition.field != .feed || condition.feedChoiceID != nil + } + } + + func addCondition() { + conditions.append(EditableCondition()) + } + + func removeCondition(_ condition: EditableCondition) { + conditions.removeAll { $0.id == condition.id } + } + + func save() { + let trimmedName = name.trimmingCharacters(in: .whitespacesAndNewlines) + let criteria = SmartFeedCriteria(matchType: matchType, conditions: builtConditions()) + + if let existingID { + SmartFeedsController.shared.updateUserSmartFeed(UserSmartFeed(id: existingID, name: trimmedName, criteria: criteria)) + } else { + SmartFeedsController.shared.addUserSmartFeed(UserSmartFeed(name: trimmedName, criteria: criteria)) + } + } + + // MARK: - Private + + private func builtConditions() -> [SmartFeedCriteria.Condition] { + conditions.compactMap { condition in + switch condition.field { + case .status: + return .read(condition.statusIsRead) + case .starred: + return .starred(condition.starredIsStarred) + case .feed: + guard let choice = feedChoices.first(where: { $0.id == condition.feedChoiceID }) else { + return nil + } + return .feed(accountID: choice.accountID, feedID: choice.feedID, isNegated: condition.feedIsNegated) + } + } + } + + private static func editableCondition(from condition: SmartFeedCriteria.Condition) -> EditableCondition { + switch condition { + case .read(let isRead): + return EditableCondition(field: .status, statusIsRead: isRead) + case .starred(let isStarred): + return EditableCondition(field: .starred, starredIsStarred: isStarred) + case .feed(let accountID, let feedID, let isNegated): + return EditableCondition(field: .feed, feedIsNegated: isNegated, feedChoiceID: "\(accountID)|\(feedID)") + } + } + + private static func allFeedChoices() -> [FeedChoice] { + var choices = [FeedChoice]() + for account in AccountManager.shared.activeAccounts { + for feed in account.flattenedFeeds() { + choices.append(FeedChoice(accountID: account.accountID, feedID: feed.feedID, name: feed.nameForDisplay)) + } + } + return choices.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + } +} diff --git a/Shared/SmartFeeds/SmartFeedEditor/SmartFeedEditorView.swift b/Shared/SmartFeeds/SmartFeedEditor/SmartFeedEditorView.swift new file mode 100644 index 000000000..30a18cb57 --- /dev/null +++ b/Shared/SmartFeeds/SmartFeedEditor/SmartFeedEditorView.swift @@ -0,0 +1,175 @@ +// +// SmartFeedEditorView.swift +// NetNewsWire +// +// Shared SwiftUI editor for creating and editing custom smart feeds. +// Hosted in a UIHostingController on iOS and an NSHostingController on Mac. +// + +import SwiftUI +import ArticlesDatabase + +struct SmartFeedEditorView: View { + + @StateObject private var model: SmartFeedEditorModel + private let onDismiss: () -> Void + + init(userSmartFeed: UserSmartFeed?, onDismiss: @escaping () -> Void) { + _model = StateObject(wrappedValue: SmartFeedEditorModel(userSmartFeed: userSmartFeed)) + self.onDismiss = onDismiss + } + + var body: some View { + VStack(spacing: 0) { + Form { + Section { + TextField(SmartFeedEditorView.namePrompt, text: $model.name) + } + + Section(header: Text(SmartFeedEditorView.conditionsHeader)) { + Picker(selection: $model.matchType) { + Text(SmartFeedEditorView.matchAll).tag(SmartFeedCriteria.MatchType.all) + Text(SmartFeedEditorView.matchAny).tag(SmartFeedCriteria.MatchType.any) + } label: { + Text(SmartFeedEditorView.matchLabel) + } + + ForEach($model.conditions) { $condition in + ConditionRow(condition: $condition, feedChoices: model.feedChoices) { + model.removeCondition(condition) + } + } + + Button { + model.addCondition() + } label: { + Label(SmartFeedEditorView.addCondition, systemImage: "plus.circle") + } + } + } + + Divider() + + HStack { + Button(SmartFeedEditorView.cancel, role: .cancel) { + onDismiss() + } + .keyboardShortcut(.cancelAction) + + Spacer() + + Button(model.isEditingExisting ? SmartFeedEditorView.save : SmartFeedEditorView.add) { + model.save() + onDismiss() + } + .keyboardShortcut(.defaultAction) + .disabled(!model.isValid) + } + .padding() + } + .frame(minWidth: 460, minHeight: 380) + } +} + +private struct ConditionRow: View { + + @Binding var condition: SmartFeedEditorModel.EditableCondition + let feedChoices: [SmartFeedEditorModel.FeedChoice] + let onRemove: () -> Void + + var body: some View { + HStack { + Picker(selection: $condition.field) { + Text(SmartFeedEditorView.fieldStatus).tag(SmartFeedEditorModel.ConditionField.status) + Text(SmartFeedEditorView.fieldStarred).tag(SmartFeedEditorModel.ConditionField.starred) + Text(SmartFeedEditorView.fieldFeed).tag(SmartFeedEditorModel.ConditionField.feed) + } label: { + EmptyView() + } + .labelsHidden() + .fixedSize() + + valueControls + + Spacer() + + Button(role: .destructive) { + onRemove() + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + .accessibilityLabel(Text(SmartFeedEditorView.removeCondition)) + } + } + + @ViewBuilder + private var valueControls: some View { + switch condition.field { + case .status: + Picker(selection: $condition.statusIsRead) { + Text(SmartFeedEditorView.statusUnread).tag(false) + Text(SmartFeedEditorView.statusRead).tag(true) + } label: { + EmptyView() + } + .labelsHidden() + .fixedSize() + + case .starred: + Picker(selection: $condition.starredIsStarred) { + Text(SmartFeedEditorView.starredYes).tag(true) + Text(SmartFeedEditorView.starredNo).tag(false) + } label: { + EmptyView() + } + .labelsHidden() + .fixedSize() + + case .feed: + Picker(selection: $condition.feedIsNegated) { + Text(SmartFeedEditorView.feedIs).tag(false) + Text(SmartFeedEditorView.feedIsNot).tag(true) + } label: { + EmptyView() + } + .labelsHidden() + .fixedSize() + + Picker(selection: $condition.feedChoiceID) { + Text(SmartFeedEditorView.feedSelect).tag(String?.none) + ForEach(feedChoices) { choice in + Text(choice.name).tag(Optional(choice.id)) + } + } label: { + EmptyView() + } + .labelsHidden() + } + } +} + +// MARK: - Localized strings + +extension SmartFeedEditorView { + static let namePrompt = NSLocalizedString("Name", comment: "Smart feed name field") + static let conditionsHeader = NSLocalizedString("Conditions", comment: "Smart feed conditions section header") + static let matchLabel = NSLocalizedString("Match", comment: "Smart feed match label") + static let matchAll = NSLocalizedString("all", comment: "Match all conditions") + static let matchAny = NSLocalizedString("any", comment: "Match any condition") + static let addCondition = NSLocalizedString("Add Condition", comment: "Add a smart feed condition") + static let removeCondition = NSLocalizedString("Remove Condition", comment: "Remove a smart feed condition") + static let cancel = NSLocalizedString("Cancel", comment: "Cancel") + static let save = NSLocalizedString("Save", comment: "Save") + static let add = NSLocalizedString("Add", comment: "Add") + static let fieldStatus = NSLocalizedString("Status", comment: "Condition field: read status") + static let fieldStarred = NSLocalizedString("Starred", comment: "Condition field: starred status") + static let fieldFeed = NSLocalizedString("Feed", comment: "Condition field: feed") + static let statusUnread = NSLocalizedString("is Unread", comment: "Status condition value") + static let statusRead = NSLocalizedString("is Read", comment: "Status condition value") + static let starredYes = NSLocalizedString("is Starred", comment: "Starred condition value") + static let starredNo = NSLocalizedString("is Not Starred", comment: "Starred condition value") + static let feedIs = NSLocalizedString("is", comment: "Feed condition operator") + static let feedIsNot = NSLocalizedString("is not", comment: "Feed condition operator") + static let feedSelect = NSLocalizedString("Select…", comment: "Feed condition: no feed chosen") +} diff --git a/Shared/SmartFeeds/SmartFeedsController.swift b/Shared/SmartFeeds/SmartFeedsController.swift index da9902ee3..ae3e4917e 100644 --- a/Shared/SmartFeeds/SmartFeedsController.swift +++ b/Shared/SmartFeeds/SmartFeedsController.swift @@ -21,8 +21,15 @@ import Account let unreadFeed = UnreadFeed() let starredFeed = SmartFeed(delegate: StarredFeedDelegate()) + /// The user-created smart feeds, in display order. + private(set) var userSmartFeeds = [UserSmartFeed]() + + private let userSmartFeedsFile = UserSmartFeedsFile() + private var customSmartFeedsByID = [String: SmartFeed]() + private init() { - self.smartFeeds = [todayFeed, unreadFeed, starredFeed] + userSmartFeeds = userSmartFeedsFile.load() + rebuildSmartFeeds() } func find(by identifier: SidebarItemIdentifier) -> PseudoFeed? { @@ -36,11 +43,61 @@ import Account case String(describing: StarredFeedDelegate.self): return starredFeed default: - return nil + return customSmartFeedsByID[stringIdentifer] } default: return nil } } + // MARK: - Custom Smart Feeds + + /// The `UserSmartFeed` definition for a sidebar identifier, or `nil` if it isn't a + /// user-created smart feed (i.e. it's one of the three built-ins, or not a smart feed). + func userSmartFeed(for identifier: SidebarItemIdentifier?) -> UserSmartFeed? { + guard let identifier, case .smartFeed(let id) = identifier else { + return nil + } + return userSmartFeeds.first { $0.id == id } + } + + func addUserSmartFeed(_ userSmartFeed: UserSmartFeed) { + userSmartFeeds.append(userSmartFeed) + persistAndRebuild() + } + + func updateUserSmartFeed(_ userSmartFeed: UserSmartFeed) { + guard let index = userSmartFeeds.firstIndex(where: { $0.id == userSmartFeed.id }) else { + return + } + userSmartFeeds[index] = userSmartFeed + persistAndRebuild() + } + + func removeUserSmartFeed(_ userSmartFeed: UserSmartFeed) { + userSmartFeeds.removeAll { $0.id == userSmartFeed.id } + persistAndRebuild() + } + + // MARK: - Private + + private func rebuildSmartFeeds() { + var wrappers = [SmartFeed]() + var byID = [String: SmartFeed]() + for userSmartFeed in userSmartFeeds { + let feed = SmartFeed(delegate: CustomSmartFeedDelegate(userSmartFeed: userSmartFeed)) + wrappers.append(feed) + byID[userSmartFeed.id] = feed + } + customSmartFeedsByID = byID + smartFeeds = [todayFeed, unreadFeed, starredFeed] + wrappers + } + + private func persistAndRebuild() { + userSmartFeedsFile.save(userSmartFeeds) + rebuildSmartFeeds() + // Both the Mac sidebar and the iOS sidebar rebuild their trees from + // `smartFeeds` when this notification fires. + NotificationCenter.default.post(name: .ChildrenDidChange, object: self) + } } diff --git a/Shared/SmartFeeds/UserSmartFeed.swift b/Shared/SmartFeeds/UserSmartFeed.swift new file mode 100644 index 000000000..e2abc7624 --- /dev/null +++ b/Shared/SmartFeeds/UserSmartFeed.swift @@ -0,0 +1,22 @@ +// +// UserSmartFeed.swift +// NetNewsWire +// +// A user-created smart feed: a name plus the criteria that define it. +// + +import Foundation +import ArticlesDatabase + +struct UserSmartFeed: Codable, Identifiable, Hashable { + + let id: String + var name: String + var criteria: SmartFeedCriteria + + init(id: String = UUID().uuidString, name: String, criteria: SmartFeedCriteria) { + self.id = id + self.name = name + self.criteria = criteria + } +} diff --git a/Shared/SmartFeeds/UserSmartFeedsFile.swift b/Shared/SmartFeeds/UserSmartFeedsFile.swift new file mode 100644 index 000000000..34baf0762 --- /dev/null +++ b/Shared/SmartFeeds/UserSmartFeedsFile.swift @@ -0,0 +1,37 @@ +// +// UserSmartFeedsFile.swift +// NetNewsWire +// +// Reads and writes the user's custom smart-feed definitions to disk. +// + +import Foundation +import os.log +import RSCore + +@MainActor final class UserSmartFeedsFile { + + private static let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "UserSmartFeedsFile") + private let fileURL = AppConfig.dataFolder.appendingPathComponent("SmartFeeds.json") + + func load() -> [UserSmartFeed] { + guard let data = try? Data(contentsOf: fileURL) else { + return [] + } + do { + return try JSONDecoder().decode([UserSmartFeed].self, from: data) + } catch { + Self.logger.error("Error reading custom smart feeds: \(error.localizedDescription, privacy: .public)") + return [] + } + } + + func save(_ userSmartFeeds: [UserSmartFeed]) { + do { + let data = try JSONEncoder().encode(userSmartFeeds) + try data.write(to: fileURL) + } catch { + Self.logger.error("Error writing custom smart feeds: \(error.localizedDescription, privacy: .public)") + } + } +} diff --git a/iOS/MainFeed/MainFeedCollectionViewController.swift b/iOS/MainFeed/MainFeedCollectionViewController.swift index 0fbbbca1a..124982033 100644 --- a/iOS/MainFeed/MainFeedCollectionViewController.swift +++ b/iOS/MainFeed/MainFeedCollectionViewController.swift @@ -196,7 +196,30 @@ final class MainFeedCollectionViewController: UICollectionViewController, Undoab guard let self else { return nil } - if indexPath.section == 0 { return UISwipeActionsConfiguration(actions: []) } + if indexPath.section == 0 { + // Smart Feeds section: only user-created smart feeds can be deleted/edited. + guard self.userSmartFeed(at: indexPath) != nil else { + return UISwipeActionsConfiguration(actions: []) + } + let deleteTitle = NSLocalizedString("Delete", comment: "Delete button") + let deleteAction = UIContextualAction(style: .destructive, title: nil) { [weak self] _, _, completion in + self?.delete(indexPath: indexPath) + completion(true) + } + deleteAction.image = UIImage(systemName: "trash") + deleteAction.accessibilityLabel = deleteTitle + deleteAction.backgroundColor = UIColor.systemRed + + let editTitle = NSLocalizedString("Edit", comment: "Edit") + let editAction = UIContextualAction(style: .normal, title: nil) { [weak self] _, _, completion in + self?.editSmartFeed(indexPath: indexPath) + completion(true) + } + editAction.image = UIImage(systemName: "pencil") + editAction.accessibilityLabel = editTitle + editAction.backgroundColor = UIColor.systemGray + return UISwipeActionsConfiguration(actions: [deleteAction, editAction]) + } var actions = [UIContextualAction]() // Set up the delete action @@ -810,6 +833,12 @@ final class MainFeedCollectionViewController: UICollectionViewController, Undoab menuItems.append(addFolderAction) + let addSmartFeedActionTitle = NSLocalizedString("Add Smart Feed", comment: "Add Smart Feed") + let addSmartFeedAction = UIAction(title: addSmartFeedActionTitle, image: Assets.Images.smartFeed) { _ in + self.coordinator.showSmartFeedEditor() + } + menuItems.append(addSmartFeedAction) + let contextMenu = UIMenu(title: "", image: nil, identifier: nil, options: [], children: menuItems.reversed()) self.addNewItemButton.menu = contextMenu @@ -854,6 +883,12 @@ final class MainFeedCollectionViewController: UICollectionViewController, Undoab alertController.addAction(addFolderAction) } + let addSmartFeedActionTitle = NSLocalizedString("Add Smart Feed", comment: "Add Smart Feed") + let addSmartFeedAction = UIAlertAction(title: addSmartFeedActionTitle, style: .default) { _ in + self.coordinator.showSmartFeedEditor() + } + alertController.addAction(addSmartFeedAction) + alertController.addAction(cancelAction) alertController.popoverPresentationController?.barButtonItem = sender @@ -1036,15 +1071,53 @@ extension MainFeedCollectionViewController { } func makePseudoFeedContextMenu(indexPath: IndexPath) -> UIContextMenuConfiguration? { - guard let markAllAction = self.markAllAsReadAction(indexPath: indexPath) else { + var actions = [UIMenuElement]() + if let markAllAction = self.markAllAsReadAction(indexPath: indexPath) { + actions.append(markAllAction) + } + if self.userSmartFeed(at: indexPath) != nil { + actions.append(self.editSmartFeedAction(indexPath: indexPath)) + actions.append(self.deleteSmartFeedAction(indexPath: indexPath)) + } + 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) }) } + func editSmartFeedAction(indexPath: IndexPath) -> UIAction { + let title = NSLocalizedString("Edit Smart Feed", comment: "Command") + return UIAction(title: title, image: Assets.Images.edit) { [weak self] _ in + self?.editSmartFeed(indexPath: indexPath) + } + } + + func deleteSmartFeedAction(indexPath: IndexPath) -> UIAction { + let title = NSLocalizedString("Delete Smart Feed", comment: "Command") + return UIAction(title: title, image: Assets.Images.trash, attributes: .destructive) { [weak self] _ in + self?.delete(indexPath: indexPath) + } + } + + /// The user-created smart feed at the given index path, or `nil` if the row is a + /// built-in smart feed (Today/All Unread/Starred) or not a smart feed at all. + func userSmartFeed(at indexPath: IndexPath) -> UserSmartFeed? { + guard let sidebarItem = dataSource.itemIdentifier(for: indexPath)?.node.representedObject as? SidebarItem else { + return nil + } + return SmartFeedsController.shared.userSmartFeed(for: sidebarItem.sidebarItemID) + } + + func editSmartFeed(indexPath: IndexPath) { + guard let userSmartFeed = userSmartFeed(at: indexPath) else { + return + } + coordinator.showSmartFeedEditor(userSmartFeed: userSmartFeed) + } + func homePageAction(indexPath: IndexPath) -> UIAction? { guard let feed = dataSource.itemIdentifier(for: indexPath)?.node.representedObject as? Feed, let homePageURL = feed.homePageURL, @@ -1324,7 +1397,11 @@ extension MainFeedCollectionViewController { let title: String let message: String - if sidebarItem is Folder { + if userSmartFeed(at: indexPath) != nil { + title = NSLocalizedString("Delete Smart Feed", comment: "Command") + let localizedInformativeText = NSLocalizedString("Are you sure you want to delete the “%@” smart feed?", comment: "Smart feed delete text") + message = NSString.localizedStringWithFormat(localizedInformativeText as NSString, sidebarItem.nameForDisplay) as String + } else if sidebarItem is Folder { title = NSLocalizedString("Delete Folder", comment: "Command") let localizedInformativeText = NSLocalizedString("Are you sure you want to delete the “%@” folder?", comment: "Folder delete text") message = NSString.localizedStringWithFormat(localizedInformativeText as NSString, sidebarItem.nameForDisplay) as String @@ -1350,6 +1427,15 @@ extension MainFeedCollectionViewController { } func performDelete(indexPath: IndexPath) { + // Custom smart feeds are pseudo-feeds, which DeleteCommand rejects, so remove them directly. + if let userSmartFeed = userSmartFeed(at: indexPath) { + if indexPath == coordinator.currentFeedIndexPath { + coordinator.selectSidebarItem(indexPath: nil) + } + SmartFeedsController.shared.removeUserSmartFeed(userSmartFeed) + return + } + guard let undoManager = undoManager, let deleteNode = dataSource.itemIdentifier(for: indexPath)?.node, let deleteCommand = DeleteCommand(nodesToDelete: [deleteNode], undoManager: undoManager, errorHandler: ErrorHandler.present(self)) else { diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index 395e6d987..b3ef6767b 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -1426,6 +1426,16 @@ struct SidebarItemNode: Hashable, Sendable { mainFeedCollectionViewController.present(addNavViewController, animated: true) } + /// Presents the shared editor to create a new custom smart feed, or edit an existing one when `userSmartFeed` is non-nil. + func showSmartFeedEditor(userSmartFeed: UserSmartFeed? = nil) { + let editorView = SmartFeedEditorView(userSmartFeed: userSmartFeed) { [weak self] in + self?.mainFeedCollectionViewController.dismiss(animated: true) + } + let hostingController = UIHostingController(rootView: editorView) + hostingController.modalPresentationStyle = .formSheet + mainFeedCollectionViewController.present(hostingController, animated: true) + } + func showFullScreenImage(image: UIImage, imageTitle: String?, transitioningDelegate: UIViewControllerTransitioningDelegate) { let imageVC = UIStoryboard.main.instantiateController(ofType: ImageViewController.self) imageVC.image = image diff --git a/xcconfig/common/NetNewsWire_version.xcconfig b/xcconfig/common/NetNewsWire_version.xcconfig index c1dca3f63..e81ab94f5 100644 --- a/xcconfig/common/NetNewsWire_version.xcconfig +++ b/xcconfig/common/NetNewsWire_version.xcconfig @@ -1 +1 @@ -CURRENT_PROJECT_VERSION = 7108 +CURRENT_PROJECT_VERSION = 7116