diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index dcd4b20516..a3751c3b1b 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -403,6 +403,10 @@ Commands/MarkCommandValidationStatus.swift, ExtensionPoints/SendToMarsEditCommand.swift, ExtensionPoints/SendToMicroBlogCommand.swift, + Resources/DetailKeyboardShortcuts.plist, + Resources/GlobalKeyboardShortcuts.plist, + Resources/SidebarKeyboardShortcuts.plist, + Resources/TimelineKeyboardShortcuts.plist, ShareExtension/SafariExt.js, ShareExtension/ShareDefaultContainer.swift, SmartFeeds/SmartFeedPasteboardWriter.swift, diff --git a/Shared/Localizable.xcstrings b/Shared/Localizable.xcstrings index fd4c64c1de..8af6573224 100644 --- a/Shared/Localizable.xcstrings +++ b/Shared/Localizable.xcstrings @@ -276,7 +276,7 @@ "comment" : "Article content records header" }, "Article Search" : { - "comment" : "Article Search" + "comment" : "Command" }, "Article Theme" : { "comment" : "Article Theme" @@ -388,6 +388,12 @@ "Collapse" : { "comment" : "Collapse" }, + "Collapse All" : { + "comment" : "Command" + }, + "Collapse Selected Row" : { + "comment" : "Command" + }, "Collapsed" : { "comment" : "Collapsed" }, @@ -446,7 +452,7 @@ } }, "Current Activity" : { - "comment" : "Current Activity" + "comment" : "Command\nCurrent Activity" }, "Current Activity Help" : { "comment" : "Help link" @@ -604,12 +610,21 @@ "Expand" : { "comment" : "Expand" }, + "Expand All" : { + "comment" : "Command" + }, + "Expand Selected Row" : { + "comment" : "Command" + }, "Expanded" : { "comment" : "Expanded" }, "Export OPML" : { "comment" : "Export OPML" }, + "Export Subscriptions…" : { + "comment" : "Command" + }, "Export to:" : { "comment" : "Export OPML" }, @@ -651,8 +666,11 @@ "Filter Read Feeds" : { "comment" : "Filter Read Feeds" }, + "Find" : { + "comment" : "Command" + }, "Find in Article" : { - "comment" : "Find in Article" + "comment" : "Command\nFind in Article" }, "Find out more" : { "comment" : "No FreshRSS Button" @@ -698,20 +716,26 @@ "Get Info" : { "comment" : "Get Info" }, - "Go To All Unread" : { - "comment" : "Go To All Unread" + "Go" : { + "comment" : "Command" }, "Go to Feed" : { "comment" : "Go to Feed" }, - "Go To Settings" : { - "comment" : "Go To Settings" + "Go to Next Feed" : { + "comment" : "Command" + }, + "Go to Previous Feed" : { + "comment" : "Command" }, - "Go To Starred" : { - "comment" : "Go To Starred" + "Go to Previous Unread" : { + "comment" : "Command" + }, + "Go To Settings" : { + "comment" : "Command" }, - "Go To Today" : { - "comment" : "Go To Today" + "Group by Feed" : { + "comment" : "Command" }, "Help" : { "comment" : "Help" @@ -763,6 +787,9 @@ "Import Failed" : { "comment" : "Import Failed" }, + "Import Subscriptions…" : { + "comment" : "Command" + }, "Import Theme" : { "comment" : "Import Theme" }, @@ -850,6 +877,9 @@ "Mark All as Read in “%@”" : { "comment" : "Command" }, + "Mark All as Read in Timeline and Go To Next Unread" : { + "comment" : "Command" + }, "Mark Article Unread" : { "comment" : "Mark Article Unread" }, @@ -871,6 +901,9 @@ "Mark Below as Read" : { "comment" : "Command" }, + "Mark Older as Read" : { + "comment" : "Command" + }, "Mark Read" : { "comment" : "command" }, @@ -880,6 +913,9 @@ "Mark Unread" : { "comment" : "command" }, + "Mark Unread and Go To Next Unread" : { + "comment" : "Command" + }, "Mark Unstarred" : { "comment" : "command" }, @@ -914,6 +950,15 @@ } } }, + "Navigate to Detail" : { + "comment" : "Command" + }, + "Navigate to Feeds" : { + "comment" : "Command" + }, + "Navigate to Timeline" : { + "comment" : "Command" + }, "NetNewsWire 7 is dedicated to everyone working to save democracy in the United States and around the world." : { "comment" : "A dedication to those working to save democracy.", "isCommentAutoGenerated" : true @@ -1016,11 +1061,11 @@ "Open Home Page" : { "comment" : "Command" }, - "Open in Browser" : { + "Open In App Browser" : { "comment" : "Command" }, - "Open In Browser" : { - "comment" : "Open In Browser" + "Open in Browser" : { + "comment" : "Command" }, "Open in Browser in Background" : { "comment" : "Open in Browser in Background menu item title" @@ -1028,6 +1073,9 @@ "Open in Browser in Foreground" : { "comment" : "Open in Browser in Foreground menu item title" }, + "Open in NetNewsWire" : { + "comment" : "Command" + }, "Open Settings" : { "comment" : "Open Settings button" }, @@ -1121,6 +1169,12 @@ "Scanning iCloud storage" : { "comment" : "Scan status text while fetching" }, + "Scroll or Go to Next Unread" : { + "comment" : "Command" + }, + "Scroll Up" : { + "comment" : "Command" + }, "Search" : { "comment" : "Search" }, @@ -1143,10 +1197,10 @@ "comment" : "Command" }, "Select Next Down" : { - "comment" : "Select Next Down" + "comment" : "Command" }, "Select Next Up" : { - "comment" : "Select Next Up" + "comment" : "Command" }, "Selected - Filter Read Articles" : { "comment" : "Selected - Filter Read Articles" @@ -1219,7 +1273,7 @@ "comment" : "Command" }, "Show Reader View" : { - "comment" : "Show Reader View" + "comment" : "Command\nShow Reader View" }, "Show Website" : { "comment" : "Show Website" @@ -1275,6 +1329,9 @@ "Smart Feeds" : { "comment" : "Smart Feeds group title" }, + "Sort Articles By" : { + "comment" : "Command" + }, "Star" : { "comment" : "Star" }, @@ -1467,24 +1524,6 @@ "Today" : { "comment" : "Today pseudo-feed title" }, - "Toggle Read Articles Filter" : { - "comment" : "Toggle Read Articles Filter" - }, - "Toggle Read Feeds Filter" : { - "comment" : "Toggle Read Feeds Filter" - }, - "Toggle Read Status" : { - "comment" : "Toggle Read Status" - }, - "Toggle Reader View" : { - "comment" : "Toggle Reader View" - }, - "Toggle Sidebar" : { - "comment" : "Toggle Sidebar" - }, - "Toggle Starred Status" : { - "comment" : "Toggle Starred Status" - }, "Total" : { "comment" : "Total row label" }, @@ -1582,6 +1621,9 @@ "Vacuuming may make databases faster." : { "comment" : "Vacuum explanation" }, + "View" : { + "comment" : "Command" + }, "Waiting for access to Feedly" : { "comment" : "Alert title when adding a Feedly account and waiting for authorization from the user.", "localizations" : { diff --git a/iOS/AppCommands.swift b/iOS/AppCommands.swift new file mode 100644 index 0000000000..226d1cc998 --- /dev/null +++ b/iOS/AppCommands.swift @@ -0,0 +1,368 @@ +// +// AppCommands.swift +// NetNewsWire-iOS +// +// Copyright © 2026 Ranchero Software. All rights reserved. +// + +import UIKit +import RSCore + +@MainActor enum AppCommands { + + /// The pane a set of key commands belongs to. Global commands are attached to + /// the split view so they work no matter which pane has focus. + enum KeyCommandScope { + case global + case sidebar + case timeline + case detail + } + + static func keyCommands(for scope: KeyCommandScope) -> [UIKeyCommand] { + + // Single-key shortcuts would otherwise swallow keystrokes meant for the + // search field, so no pane supplies commands while a text field has focus. + guard !UIResponder.isFirstResponderTextField else { + return [] + } + + switch scope { + case .global: + return globalKeyCommands() + case .sidebar: + return sidebarKeyCommands() + case .timeline: + return timelineKeyCommands() + case .detail: + return detailKeyCommands() + } + } + + // The four tables below mirror the Mac app's Shared/Resources/*KeyboardShortcuts.plist, + // which MainWindowKeyboardHandler and the sidebar, timeline and detail keyboard + // delegates still read. iOS no longer reads them, so a shortcut that should hold on + // both platforms has to be changed in both places. + + private static func globalKeyCommands() -> [UIKeyCommand] { + [ + keyCommand(title: NSLocalizedString("Scroll or Go to Next Unread", comment: "Command"), action: #selector(AppCommandResponder.scrollOrGoToNextUnread(_:)), input: "\u{0020}"), + keyCommand(title: NSLocalizedString("Scroll Up", comment: "Command"), action: #selector(AppCommandResponder.scrollUp(_:)), input: "\u{0020}", modifiers: [.shift]), + keyCommand(title: NSLocalizedString("Go to Previous Unread", comment: "Command"), action: #selector(AppCommandResponder.goToPreviousUnread(_:)), input: "-"), + keyCommand(action: #selector(AppCommandResponder.nextUnread(_:)), input: "+"), + keyCommand(action: #selector(AppCommandResponder.nextUnread(_:)), input: "+", modifiers: [.shift]), + keyCommand(title: NSLocalizedString("Next Unread", comment: "Command"), action: #selector(AppCommandResponder.nextUnread(_:)), input: "n"), + keyCommand(title: NSLocalizedString("Mark Read", comment: "Command"), action: #selector(AppCommandResponder.toggleRead(_:)), input: "r"), + keyCommand(title: NSLocalizedString("Mark Unread", comment: "Command"), action: #selector(AppCommandResponder.toggleRead(_:)), input: "u"), + keyCommand(title: NSLocalizedString("Mark All as Read", comment: "Command"), action: #selector(AppCommandResponder.markAllAsRead(_:)), input: "k"), + keyCommand(title: NSLocalizedString("Mark Unread and Go To Next Unread", comment: "Command"), action: #selector(AppCommandResponder.markUnreadAndGoToNextUnread(_:)), input: "m"), + keyCommand(title: NSLocalizedString("Mark All as Read in Timeline and Go To Next Unread", comment: "Command"), action: #selector(AppCommandResponder.markAllAsReadAndGoToNextUnread(_:)), input: "l"), + keyCommand(title: NSLocalizedString("Mark Older as Read", comment: "Command"), action: #selector(AppCommandResponder.markOlderArticlesAsRead(_:)), input: "o"), + keyCommand(title: openInBrowserTitle, action: #selector(AppCommandResponder.openInBrowser(_:)), input: "b"), + keyCommand(title: NSLocalizedString("Open In App Browser", comment: "Command"), action: #selector(AppCommandResponder.openInAppBrowser(_:)), input: "\r"), + keyCommand(action: #selector(AppCommandResponder.openInBrowserUsingOppositeOfSettings(_:)), input: "\r", modifiers: [.shift]), + keyCommand(title: openInBrowserInvertedTitle, action: #selector(AppCommandResponder.openInBrowserUsingOppositeOfSettings(_:)), input: "b", modifiers: [.shift]), + keyCommand(title: NSLocalizedString("Go to Previous Feed", comment: "Command"), action: #selector(AppCommandResponder.goToPreviousSubscription(_:)), input: "a"), + keyCommand(title: NSLocalizedString("Go to Next Feed", comment: "Command"), action: #selector(AppCommandResponder.goToNextSubscription(_:)), input: "z"), + keyCommand(action: #selector(AppCommandResponder.toggleStarred(_:)), input: "s"), + keyCommand(title: NSLocalizedString("Go To Settings", comment: "Command"), action: #selector(AppCommandResponder.goToSettings(_:)), input: ",", modifiers: [.command]) + ] + } + + private static func sidebarKeyCommands() -> [UIKeyCommand] { + [ + keyCommand(title: NSLocalizedString("Select Next Up", comment: "Command"), action: #selector(AppCommandResponder.selectNextUp(_:)), input: UIKeyCommand.inputUpArrow), + keyCommand(title: NSLocalizedString("Select Next Down", comment: "Command"), action: #selector(AppCommandResponder.selectNextDown(_:)), input: UIKeyCommand.inputDownArrow), + keyCommand(action: #selector(AppCommandResponder.navigateToTimeline(_:)), input: "\t"), + keyCommand(title: NSLocalizedString("Navigate to Timeline", comment: "Command"), action: #selector(AppCommandResponder.navigateToTimeline(_:)), input: UIKeyCommand.inputRightArrow), + keyCommand(title: NSLocalizedString("Collapse Selected Row", comment: "Command"), action: #selector(AppCommandResponder.collapseSelectedRows(_:)), input: ","), + keyCommand(title: NSLocalizedString("Collapse Selected Row", comment: "Command"), action: #selector(AppCommandResponder.collapseSelectedRows(_:)), input: UIKeyCommand.inputLeftArrow, modifiers: [.command]), + keyCommand(title: NSLocalizedString("Expand Selected Row", comment: "Command"), action: #selector(AppCommandResponder.expandSelectedRows(_:)), input: "."), + keyCommand(title: NSLocalizedString("Expand Selected Row", comment: "Command"), action: #selector(AppCommandResponder.expandSelectedRows(_:)), input: UIKeyCommand.inputRightArrow, modifiers: [.command]), + keyCommand(title: NSLocalizedString("Collapse All", comment: "Command"), action: #selector(AppCommandResponder.collapseAllExceptForGroupItems(_:)), input: ";"), + keyCommand(action: #selector(AppCommandResponder.collapseAllExceptForGroupItems(_:)), input: UIKeyCommand.inputLeftArrow, modifiers: [.command, .alternate]), + keyCommand(title: NSLocalizedString("Expand All", comment: "Command"), action: #selector(AppCommandResponder.expandAll(_:)), input: "'"), + keyCommand(action: #selector(AppCommandResponder.expandAll(_:)), input: UIKeyCommand.inputRightArrow, modifiers: [.command, .alternate]), + keyCommand(title: NSLocalizedString("Delete", comment: "Command"), action: #selector(AppCommandResponder.delete(_:)), input: "\u{8}") + ] + } + + private static func timelineKeyCommands() -> [UIKeyCommand] { + [ + keyCommand(title: NSLocalizedString("Select Next Up", comment: "Command"), action: #selector(AppCommandResponder.selectNextUp(_:)), input: UIKeyCommand.inputUpArrow), + keyCommand(title: NSLocalizedString("Select Next Down", comment: "Command"), action: #selector(AppCommandResponder.selectNextDown(_:)), input: UIKeyCommand.inputDownArrow), + keyCommand(title: NSLocalizedString("Navigate to Feeds", comment: "Command"), action: #selector(AppCommandResponder.navigateToSidebar(_:)), input: UIKeyCommand.inputLeftArrow), + keyCommand(title: NSLocalizedString("Navigate to Detail", comment: "Command"), action: #selector(AppCommandResponder.navigateToDetail(_:)), input: UIKeyCommand.inputRightArrow) + ] + } + + private static func detailKeyCommands() -> [UIKeyCommand] { + [ + keyCommand(title: NSLocalizedString("Navigate to Timeline", comment: "Command"), action: #selector(AppCommandResponder.navigateToTimeline(_:)), input: UIKeyCommand.inputLeftArrow) + ] + } + + static func buildMenus(with builder: UIMenuBuilder) { + // NetNewsWire has no rich-text editing, and Format’s Italic (⌘I) + // conflicts with Get Feed Info. The macOS app has no Format menu either. + builder.remove(menu: .format) + + fileMenu(builder) + findMenu(builder) + let hasViewMenu = builder.menu(for: .view) != nil + viewMenu(builder, hasViewMenu: hasViewMenu) + goMenu(builder, hasViewMenu: hasViewMenu) + articleMenu(builder) + windowMenu(builder) + } +} + +extension AppCommands { + + fileprivate static let newItemsMenuIdentifier = UIMenu.Identifier(rawValue: "com.ranchero.NetNewsWire.newItems") + fileprivate static let findMenuIdentifier = UIMenu.Identifier(rawValue: "com.ranchero.NetNewsWire.find") + fileprivate static let sortByMenuIdentifier = UIMenu.Identifier(rawValue: "com.ranchero.NetNewsWire.sortBy") + fileprivate static let viewMenuIdentifier = UIMenu.Identifier(rawValue: "com.ranchero.NetNewsWire.view") + fileprivate static let goMenuIdentifier = UIMenu.Identifier(rawValue: "com.ranchero.NetNewsWire.go") + fileprivate static let articleMenuIdentifier = UIMenu.Identifier(rawValue: "com.ranchero.NetNewsWire.article") + + fileprivate static func keyCommand(title: String? = nil, action: Selector, input: String, modifiers: UIKeyModifierFlags = []) -> UIKeyCommand { + let command: UIKeyCommand + if let title { + command = UIKeyCommand(title: title, action: action, input: input, modifierFlags: modifiers) + } else { + command = UIKeyCommand(input: input, modifierFlags: modifiers, action: action) + } + command.wantsPriorityOverSystemBehavior = true + return command + } + + /// Titles for the paired open-in-browser commands. The preference decides + /// which one opens where, so the titles have to follow it. + private static var openInBrowserTitle: String { + AppDefaults.shared.useSystemBrowser ? + NSLocalizedString("Open in Browser", comment: "Command") : + NSLocalizedString("Open in NetNewsWire", comment: "Command") + } + + private static var openInBrowserInvertedTitle: String { + AppDefaults.shared.useSystemBrowser ? + NSLocalizedString("Open in NetNewsWire", comment: "Command") : + NSLocalizedString("Open in Browser", comment: "Command") + } + + fileprivate static func menuCommand(title: String, action: Selector, input: String? = nil, modifiers: UIKeyModifierFlags = []) -> UIMenuElement { + guard let input else { + return UICommand(title: title, action: action) + } + let command = UIKeyCommand(title: title, action: action, input: input, modifierFlags: modifiers) + + // Several of these shortcuts (⌘F, ⌥⌘F, ⌘I) are ones UIKit also binds to + // system text behavior, so the menu item has to win. + command.wantsPriorityOverSystemBehavior = true + return command + } + + // MARK: - File + + fileprivate static func fileMenu(_ builder: UIMenuBuilder) { + + // Each group is inserted at the start of the File menu, so the groups appear + // in the reverse of the order they're added here: New, Refresh, Import/Export. + // That matches the Mac app's File menu. + let importExportGroup = UIMenu(title: "", options: .displayInline, children: [ + menuCommand(title: NSLocalizedString("Import Subscriptions…", comment: "Command"), action: #selector(AppCommandResponder.importOPML(_:))), + menuCommand(title: NSLocalizedString("Export Subscriptions…", comment: "Command"), action: #selector(AppCommandResponder.exportOPML(_:)), input: "e", modifiers: [.command, .alternate]) + ]) + builder.insertChild(importExportGroup, atStartOfMenu: .file) + + let refreshGroup = UIMenu(title: "", options: .displayInline, children: [ + menuCommand(title: NSLocalizedString("Refresh", comment: "Command"), action: #selector(AppCommandResponder.refresh(_:)), input: "r", modifiers: [.command]) + ]) + builder.insertChild(refreshGroup, atStartOfMenu: .file) + + let newItemsGroup = UIMenu(title: "", image: nil, identifier: newItemsMenuIdentifier, options: .displayInline, children: [ + menuCommand(title: NSLocalizedString("New Feed", comment: "Command"), action: #selector(AppCommandResponder.addNewFeed(_:)), input: "n", modifiers: [.command]), + menuCommand(title: NSLocalizedString("New Folder", comment: "Command"), action: #selector(AppCommandResponder.addNewFolder(_:)), input: "n", modifiers: [.command, .shift]) + ]) + builder.insertChild(newItemsGroup, atStartOfMenu: .file) + } + + // MARK: - Find + + fileprivate static func findMenu(_ builder: UIMenuBuilder) { + let findElements = [ + menuCommand(title: NSLocalizedString("Article Search", comment: "Command"), action: #selector(AppCommandResponder.articleSearch(_:)), input: "f", modifiers: [.command, .alternate]), + menuCommand(title: NSLocalizedString("Find in Article", comment: "Command"), action: #selector(AppCommandResponder.beginFind(_:)), input: "f", modifiers: [.command]) + ] + + // Replace the system Find menu: its Find (⌘F) and Find & Replace (⌥⌘F) + // conflict with Find in Article and Article Search. + let findMenu = UIMenu(title: NSLocalizedString("Find", comment: "Command"), identifier: findMenuIdentifier, children: findElements) + if builder.menu(for: .find) != nil { + builder.replace(menu: .find, with: findMenu) + } else { + builder.insertChild(findMenu, atEndOfMenu: .edit) + } + } + + // MARK: - View + + fileprivate static func viewMenu(_ builder: UIMenuBuilder, hasViewMenu: Bool) { + let sortByMenu = UIMenu(title: NSLocalizedString("Sort Articles By", comment: "Command"), identifier: sortByMenuIdentifier, children: [ + menuCommand(title: NSLocalizedString("Newest Article on Top", comment: "Command"), action: #selector(AppCommandResponder.sortByNewestArticleOnTop(_:))), + menuCommand(title: NSLocalizedString("Oldest Article on Top", comment: "Command"), action: #selector(AppCommandResponder.sortByOldestArticleOnTop(_:))) + ]) + let groupByFeedCommand = menuCommand(title: NSLocalizedString("Group by Feed", comment: "Command"), action: #selector(AppCommandResponder.groupByFeedToggled(_:))) + let topGroup = UIMenu(title: "", options: .displayInline, children: [sortByMenu, groupByFeedCommand]) + + let cleanUpGroup = UIMenu(title: "", options: .displayInline, children: [ + menuCommand(title: NSLocalizedString("Clean Up", comment: "Command"), action: #selector(AppCommandResponder.cleanUp(_:)), input: "'", modifiers: [.command]), + menuCommand(title: NSLocalizedString("Hide Read Articles", comment: "Command"), action: #selector(AppCommandResponder.toggleReadArticlesFilter(_:)), input: "h", modifiers: [.command, .shift]), + menuCommand(title: NSLocalizedString("Hide Read Feeds", comment: "Command"), action: #selector(AppCommandResponder.toggleReadFeedsFilter(_:)), input: "f", modifiers: [.command, .shift]) + ]) + + // No Toggle Sidebar item: the system View menu already provides + // Show Sidebar (⌃⌘S) bound to toggleSidebar:. + if hasViewMenu { + builder.insertChild(cleanUpGroup, atStartOfMenu: .view) + builder.insertChild(topGroup, atStartOfMenu: .view) + } else { + let viewMenu = UIMenu(title: NSLocalizedString("View", comment: "Command"), identifier: viewMenuIdentifier, children: [topGroup, cleanUpGroup]) + builder.insertSibling(viewMenu, afterMenu: .edit) + } + } + + // MARK: - Go + + fileprivate static func goMenu(_ builder: UIMenuBuilder, hasViewMenu: Bool) { + let nextUnreadCommand = menuCommand(title: NSLocalizedString("Next Unread", comment: "Command"), action: #selector(AppCommandResponder.nextUnread(_:)), input: "/", modifiers: [.command]) + let smartFeedsGroup = UIMenu(title: "", options: .displayInline, children: [ + menuCommand(title: NSLocalizedString("Today", comment: "Command"), action: #selector(AppCommandResponder.goToToday(_:)), input: "1", modifiers: [.command]), + menuCommand(title: NSLocalizedString("All Unread", comment: "Command"), action: #selector(AppCommandResponder.goToAllUnread(_:)), input: "2", modifiers: [.command]), + menuCommand(title: NSLocalizedString("Starred", comment: "Command"), action: #selector(AppCommandResponder.goToStarred(_:)), input: "3", modifiers: [.command]) + ]) + + let goMenu = UIMenu(title: NSLocalizedString("Go", comment: "Command"), identifier: goMenuIdentifier, children: [nextUnreadCommand, smartFeedsGroup]) + + if hasViewMenu { + builder.insertSibling(goMenu, afterMenu: .view) + } else { + // Anchor to the custom View menu inserted above. Anchoring to .edit + // would place Go ahead of View, since each insert lands immediately + // after its anchor. + builder.insertSibling(goMenu, afterMenu: viewMenuIdentifier) + } + } + + // MARK: - Article + + fileprivate static func articleMenu(_ builder: UIMenuBuilder) { + let markGroup = UIMenu(title: "", options: .displayInline, children: [ + menuCommand(title: NSLocalizedString("Mark as Read", comment: "Command"), action: #selector(AppCommandResponder.toggleRead(_:)), input: "u", modifiers: [.command, .shift]), + menuCommand(title: NSLocalizedString("Mark All as Read", comment: "Command"), action: #selector(AppCommandResponder.markAllAsRead(_:)), input: "k", modifiers: [.command]), + menuCommand(title: NSLocalizedString("Mark Above as Read", comment: "Command"), action: #selector(AppCommandResponder.markAboveAsRead(_:)), input: "k", modifiers: [.command, .control]), + menuCommand(title: NSLocalizedString("Mark Below as Read", comment: "Command"), action: #selector(AppCommandResponder.markBelowAsRead(_:)), input: "k", modifiers: [.command, .shift]) + ]) + + let starredGroup = UIMenu(title: "", options: .displayInline, children: [ + menuCommand(title: NSLocalizedString("Mark as Starred", comment: "Command"), action: #selector(AppCommandResponder.toggleStarred(_:)), input: "l", modifiers: [.command, .shift]) + ]) + + let readerGroup = UIMenu(title: "", options: .displayInline, children: [ + menuCommand(title: NSLocalizedString("Show Reader View", comment: "Command"), action: #selector(AppCommandResponder.toggleReaderView(_:)), input: "r", modifiers: [.command, .shift]), + menuCommand(title: openInBrowserTitle, action: #selector(AppCommandResponder.openArticleInBrowser(_:)), input: UIKeyCommand.inputRightArrow, modifiers: [.command]), + menuCommand(title: openInBrowserInvertedTitle, action: #selector(AppCommandResponder.openInBrowserUsingOppositeOfSettings(_:)), input: UIKeyCommand.inputRightArrow, modifiers: [.command, .shift]), + menuCommand(title: NSLocalizedString("Get Feed Info", comment: "Command"), action: #selector(AppCommandResponder.showFeedInspector(_:)), input: "i", modifiers: [.command]) + ]) + + let articleMenu = UIMenu(title: NSLocalizedString("Article", comment: "Command"), identifier: articleMenuIdentifier, children: [markGroup, starredGroup, readerGroup]) + builder.insertSibling(articleMenu, afterMenu: goMenuIdentifier) + } + + // MARK: - Window + + fileprivate static func windowMenu(_ builder: UIMenuBuilder) { + guard builder.menu(for: .window) != nil else { + return + } + let currentActivityGroup = UIMenu(title: "", options: .displayInline, children: [ + menuCommand(title: NSLocalizedString("Current Activity", comment: "Command"), action: #selector(AppCommandResponder.showCurrentActivity(_:)), input: "7", modifiers: [.command]) + ]) + builder.insertChild(currentActivityGroup, atStartOfMenu: .window) + } +} + +/// The responder-chain actions bound to the key commands and menu items above. +/// +/// No single object conforms to this protocol; its methods are implemented +/// piecemeal across the responder chain (the split view, feeds, timeline, and +/// article view controllers). Declaring them here lets the tables above use +/// `#selector`, so a misspelled or renamed action is a compile error rather than +/// a silently dead shortcut. +@objc private protocol AppCommandResponder { + + // Global + func scrollOrGoToNextUnread(_ sender: Any?) + func scrollUp(_ sender: Any?) + func goToPreviousUnread(_ sender: Any?) + func nextUnread(_ sender: Any?) + func toggleRead(_ sender: Any?) + func markAllAsRead(_ sender: Any?) + func markUnreadAndGoToNextUnread(_ sender: Any?) + func markAllAsReadAndGoToNextUnread(_ sender: Any?) + func markOlderArticlesAsRead(_ sender: Any?) + func markAboveAsRead(_ sender: Any?) + func markBelowAsRead(_ sender: Any?) + func openInBrowser(_ sender: Any?) + func openInAppBrowser(_ sender: Any?) + func openInBrowserUsingOppositeOfSettings(_ sender: Any?) + func goToPreviousSubscription(_ sender: Any?) + func goToNextSubscription(_ sender: Any?) + func toggleStarred(_ sender: Any?) + func goToSettings(_ sender: Any?) + + // Sidebar / Timeline / Detail + func selectNextUp(_ sender: Any?) + func selectNextDown(_ sender: Any?) + func navigateToTimeline(_ sender: Any?) + func navigateToSidebar(_ sender: Any?) + func navigateToDetail(_ sender: Any?) + func collapseSelectedRows(_ sender: Any?) + func expandSelectedRows(_ sender: Any?) + func collapseAllExceptForGroupItems(_ sender: Any?) + func expandAll(_ sender: Any?) + func delete(_ sender: Any?) + + // File + func importOPML(_ sender: Any?) + func exportOPML(_ sender: Any?) + func refresh(_ sender: Any?) + func addNewFeed(_ sender: Any?) + func addNewFolder(_ sender: Any?) + + // Find + func articleSearch(_ sender: Any?) + func beginFind(_ sender: Any?) + + // View + func sortByNewestArticleOnTop(_ sender: Any?) + func sortByOldestArticleOnTop(_ sender: Any?) + func groupByFeedToggled(_ sender: Any?) + func cleanUp(_ sender: Any?) + func toggleReadArticlesFilter(_ sender: Any?) + func toggleReadFeedsFilter(_ sender: Any?) + + // Go + func goToToday(_ sender: Any?) + func goToAllUnread(_ sender: Any?) + func goToStarred(_ sender: Any?) + + // Article + func openArticleInBrowser(_ sender: Any?) + func toggleReaderView(_ sender: Any?) + func showFeedInspector(_ sender: Any?) + + // Window + func showCurrentActivity(_ sender: Any?) +} diff --git a/iOS/AppDelegate.swift b/iOS/AppDelegate.swift index fa916f45b2..76007877dd 100644 --- a/iOS/AppDelegate.swift +++ b/iOS/AppDelegate.swift @@ -155,6 +155,16 @@ import Images updateBadge() } + // MARK: - Main Menu + + override func buildMenu(with builder: UIMenuBuilder) { + super.buildMenu(with: builder) + guard builder.system == .main else { + return + } + AppCommands.buildMenus(with: builder) + } + // MARK: - API func manualRefresh(errorHandler: @escaping @Sendable (Error) -> Void) { diff --git a/iOS/Article/ArticleViewController.swift b/iOS/Article/ArticleViewController.swift index 5820ed40c2..fffb3417d7 100644 --- a/iOS/Article/ArticleViewController.swift +++ b/iOS/Article/ArticleViewController.swift @@ -113,9 +113,8 @@ final class ArticleViewController: UIViewController { var restoreState: State? - private let keyboardManager = KeyboardManager(type: .detail) override var keyCommands: [UIKeyCommand]? { - return keyboardManager.keyCommands + AppCommands.keyCommands(for: .detail) } override func viewDidLoad() { @@ -372,7 +371,7 @@ final class ArticleViewController: UIViewController { currentWebViewController?.showActivityDialog(popOverBarButtonItem: actionBarButtonItem) } - @objc func toggleReaderView(_ sender: Any?) { + func toggleReaderView() { currentWebViewController?.toggleArticleExtractor() } diff --git a/iOS/Article/WebViewController.swift b/iOS/Article/WebViewController.swift index 0462c3f463..c0efae1b82 100644 --- a/iOS/Article/WebViewController.swift +++ b/iOS/Article/WebViewController.swift @@ -352,12 +352,10 @@ final class WebViewController: UIViewController { } func openInAppBrowser() { - guard let url = article?.preferredURL else { return } - if AppDefaults.shared.useSystemBrowser { - UIApplication.shared.open(url, options: [:]) - } else { - openURLInSafariViewController(url) + guard let url = article?.preferredURL else { + return } + openURLInSafariViewController(url) } } diff --git a/iOS/KeyboardManager.swift b/iOS/KeyboardManager.swift deleted file mode 100644 index 349deed5da..0000000000 --- a/iOS/KeyboardManager.swift +++ /dev/null @@ -1,222 +0,0 @@ -// -// KeyboardManager.swift -// NetNewsWire-iOS -// -// Created by Maurice Parker on 9/4/19. -// Copyright © 2019 Ranchero Software. All rights reserved. -// - -import UIKit - -enum KeyboardType: String, Sendable { - case global = "GlobalKeyboardShortcuts" - case sidebar = "SidebarKeyboardShortcuts" - case timeline = "TimelineKeyboardShortcuts" - case detail = "DetailKeyboardShortcuts" -} - -@MainActor final class KeyboardManager { - - private(set) var _keyCommands: [UIKeyCommand] - var keyCommands: [UIKeyCommand] { - guard !UIResponder.isFirstResponderTextField else { return [UIKeyCommand]() } - return _keyCommands - } - - init(type: KeyboardType) { - _keyCommands = KeyboardManager.globalAuxilaryKeyCommands() - - switch type { - case .sidebar: - _keyCommands.append(contentsOf: KeyboardManager.hardcodeFeedKeyCommands()) - case .timeline, .detail: - _keyCommands.append(contentsOf: KeyboardManager.hardcodeArticleKeyCommands()) - default: - break - } - - let globalFile = Bundle.main.path(forResource: KeyboardType.global.rawValue, ofType: "plist")! - let globalEntries = NSArray(contentsOfFile: globalFile)! as! [[String: Any]] - let globalCommands = globalEntries.compactMap { KeyboardManager.createKeyCommand(keyEntry: $0) } - _keyCommands.append(contentsOf: globalCommands) - - let specificFile = Bundle.main.path(forResource: type.rawValue, ofType: "plist")! - let specificEntries = NSArray(contentsOfFile: specificFile)! as! [[String: Any]] - _keyCommands.append(contentsOf: specificEntries.compactMap { KeyboardManager.createKeyCommand(keyEntry: $0) }) - } - - static func createKeyCommand(title: String, action: String, input: String, modifiers: UIKeyModifierFlags) -> UIKeyCommand { - let selector = NSSelectorFromString(action) - let keyCommand = UIKeyCommand(title: title, image: nil, action: selector, input: input, modifierFlags: modifiers, propertyList: nil, alternates: [], discoverabilityTitle: nil, attributes: [], state: .on) - if #available(iOS 15.0, *) { - keyCommand.wantsPriorityOverSystemBehavior = true - } - return keyCommand - } -} - -private extension KeyboardManager { - - static func createKeyCommand(keyEntry: [String: Any]) -> UIKeyCommand? { - guard let input = createKeyCommandInput(keyEntry: keyEntry) else { return nil } - let modifiers = createKeyModifierFlags(keyEntry: keyEntry) - let action = keyEntry["action"] as! String - - if let title = keyEntry["title"] as? String { - return KeyboardManager.createKeyCommand(title: title, action: action, input: input, modifiers: modifiers) - } else { - let keyCommand = UIKeyCommand(input: input, modifierFlags: modifiers, action: NSSelectorFromString(action)) - if #available(iOS 15.0, *) { - keyCommand.wantsPriorityOverSystemBehavior = true - } - return keyCommand - } - } - - static func createKeyCommandInput(keyEntry: [String: Any]) -> String? { - guard let key = keyEntry["key"] as? String else { - return nil - } - - switch key { - case "[space]": - return "\u{0020}" - case "[uparrow]": - return UIKeyCommand.inputUpArrow - case "[downarrow]": - return UIKeyCommand.inputDownArrow - case "[leftarrow]": - return UIKeyCommand.inputLeftArrow - case "[rightarrow]": - return UIKeyCommand.inputRightArrow - case "[return]": - return "\r" - case "[enter]": - return nil - case "[delete]": - return "\u{8}" - case "[deletefunction]": - return nil - case "[tab]": - return "\t" - default: - return key - } - } - - static func createKeyModifierFlags(keyEntry: [String: Any]) -> UIKeyModifierFlags { - var flags = UIKeyModifierFlags() - - if keyEntry["shiftModifier"] as? Bool ?? false { - flags.insert(.shift) - } - - if keyEntry["optionModifier"] as? Bool ?? false { - flags.insert(.alternate) - } - - if keyEntry["commandModifier"] as? Bool ?? false { - flags.insert(.command) - } - - if keyEntry["controlModifier"] as? Bool ?? false { - flags.insert(.control) - } - - return flags - } - - static func globalAuxilaryKeyCommands() -> [UIKeyCommand] { - var keys = [UIKeyCommand]() - - let addNewFeedTitle = NSLocalizedString("New Feed", comment: "Command") - keys.append(KeyboardManager.createKeyCommand(title: addNewFeedTitle, action: "addNewFeed:", input: "n", modifiers: [.command])) - - let addNewFolderTitle = NSLocalizedString("New Folder", comment: "Command") - keys.append(KeyboardManager.createKeyCommand(title: addNewFolderTitle, action: "addNewFolder:", input: "n", modifiers: [.command, .shift])) - - let refreshTitle = NSLocalizedString("Refresh", comment: "Refresh") - keys.append(KeyboardManager.createKeyCommand(title: refreshTitle, action: "refresh:", input: "r", modifiers: [.command])) - - let nextUnreadTitle = NSLocalizedString("Next Unread", comment: "Next Unread") - keys.append(KeyboardManager.createKeyCommand(title: nextUnreadTitle, action: "nextUnread:", input: "/", modifiers: [.command])) - - let goToTodayTitle = NSLocalizedString("Go To Today", comment: "Go To Today") - keys.append(KeyboardManager.createKeyCommand(title: goToTodayTitle, action: "goToToday:", input: "1", modifiers: [.command])) - - let goToAllUnreadTitle = NSLocalizedString("Go To All Unread", comment: "Go To All Unread") - keys.append(KeyboardManager.createKeyCommand(title: goToAllUnreadTitle, action: "goToAllUnread:", input: "2", modifiers: [.command])) - - let goToStarredTitle = NSLocalizedString("Go To Starred", comment: "Go To Starred") - keys.append(KeyboardManager.createKeyCommand(title: goToStarredTitle, action: "goToStarred:", input: "3", modifiers: [.command])) - - let gotoSettings = NSLocalizedString("Go To Settings", comment: "Go To Settings") - keys.append(KeyboardManager.createKeyCommand(title: gotoSettings, action: "goToSettings:", input: ",", modifiers: [.command])) - - let articleSearchTitle = NSLocalizedString("Article Search", comment: "Article Search") - keys.append(KeyboardManager.createKeyCommand(title: articleSearchTitle, action: "articleSearch:", input: "f", modifiers: [.command, .alternate])) - - let markAllAsReadTitle = NSLocalizedString("Mark All as Read", comment: "Command") - keys.append(KeyboardManager.createKeyCommand(title: markAllAsReadTitle, action: "markAllAsRead:", input: "k", modifiers: [.command])) - - let cleanUp = NSLocalizedString("Clean Up", comment: "Clean Up button") - keys.append(KeyboardManager.createKeyCommand(title: cleanUp, action: "cleanUp:", input: "'", modifiers: [.command])) - - let toggleReadFeedsFilter = NSLocalizedString("Toggle Read Feeds Filter", comment: "Toggle Read Feeds Filter") - keys.append(KeyboardManager.createKeyCommand(title: toggleReadFeedsFilter, action: "toggleReadFeedsFilter:", input: "f", modifiers: [.command, .shift])) - - let toggleReadArticlesFilter = NSLocalizedString("Toggle Read Articles Filter", comment: "Toggle Read Articles Filter") - keys.append(KeyboardManager.createKeyCommand(title: toggleReadArticlesFilter, action: "toggleReadArticlesFilter:", input: "h", modifiers: [.command, .shift])) - - let toggleReaderView = NSLocalizedString("Toggle Reader View", comment: "Toggle Reader View") - keys.append(KeyboardManager.createKeyCommand(title: toggleReaderView, action: "toggleReaderView:", input: "r", modifiers: [.command, .shift])) - - return keys - } - - static func hardcodeFeedKeyCommands() -> [UIKeyCommand] { - var keys = [UIKeyCommand]() - - let nextUpTitle = NSLocalizedString("Select Next Up", comment: "Select Next Up") - keys.append(KeyboardManager.createKeyCommand(title: nextUpTitle, action: "selectNextUp:", input: UIKeyCommand.inputUpArrow, modifiers: [])) - - let nextDownTitle = NSLocalizedString("Select Next Down", comment: "Select Next Down") - keys.append(KeyboardManager.createKeyCommand(title: nextDownTitle, action: "selectNextDown:", input: UIKeyCommand.inputDownArrow, modifiers: [])) - - let getFeedInfo = NSLocalizedString("Get Feed Info", comment: "Get Feed Info") - keys.append(KeyboardManager.createKeyCommand(title: getFeedInfo, action: "showFeedInspector:", input: "i", modifiers: .command)) - - return keys - } - - static func hardcodeArticleKeyCommands() -> [UIKeyCommand] { - var keys = [UIKeyCommand]() - - let openInBrowserTitle = NSLocalizedString("Open In Browser", comment: "Open In Browser") - keys.append(KeyboardManager.createKeyCommand(title: openInBrowserTitle, action: "openInBrowser:", input: UIKeyCommand.inputRightArrow, modifiers: [.command])) - - let toggleReadTitle = NSLocalizedString("Toggle Read Status", comment: "Toggle Read Status") - keys.append(KeyboardManager.createKeyCommand(title: toggleReadTitle, action: "toggleRead:", input: "u", modifiers: [.command, .shift])) - - let markAboveAsReadTitle = NSLocalizedString("Mark Above as Read", comment: "Command") - keys.append(KeyboardManager.createKeyCommand(title: markAboveAsReadTitle, action: "markAboveAsRead:", input: "k", modifiers: [.command, .control])) - - let markBelowAsReadTitle = NSLocalizedString("Mark Below as Read", comment: "Command") - keys.append(KeyboardManager.createKeyCommand(title: markBelowAsReadTitle, action: "markBelowAsRead:", input: "k", modifiers: [.command, .shift])) - - let toggleStarredTitle = NSLocalizedString("Toggle Starred Status", comment: "Toggle Starred Status") - keys.append(KeyboardManager.createKeyCommand(title: toggleStarredTitle, action: "toggleStarred:", input: "l", modifiers: [.command, .shift])) - - let findInArticleTitle = NSLocalizedString("Find in Article", comment: "Find in Article") - keys.append(KeyboardManager.createKeyCommand(title: findInArticleTitle, action: "beginFind:", input: "f", modifiers: [.command])) - - let getFeedInfo = NSLocalizedString("Get Feed Info", comment: "Get Feed Info") - keys.append(KeyboardManager.createKeyCommand(title: getFeedInfo, action: "showFeedInspector:", input: "i", modifiers: .command)) - - let toggleSidebar = NSLocalizedString("Toggle Sidebar", comment: "Toggle Sidebar") - keys.append(KeyboardManager.createKeyCommand(title: toggleSidebar, action: "toggleSidebar:", input: "s", modifiers: [.command, .control])) - - return keys - } - -} diff --git a/iOS/MainFeed/MainFeedCollectionViewController.swift b/iOS/MainFeed/MainFeedCollectionViewController.swift index 48e69d3f50..69a496a2ff 100644 --- a/iOS/MainFeed/MainFeedCollectionViewController.swift +++ b/iOS/MainFeed/MainFeedCollectionViewController.swift @@ -35,7 +35,6 @@ final class MainFeedCollectionViewController: UICollectionViewController, Undoab private static let logger = Logger(subsystem: Logger.nnwSubsystem, category: "MainFeedCollectionViewController") - private let keyboardManager = KeyboardManager(type: .sidebar) override var keyCommands: [UIKeyCommand]? { // If the first responder is the WKWebView (PreloadedWebView) we don’t want to supply any keyboard @@ -45,7 +44,7 @@ final class MainFeedCollectionViewController: UICollectionViewController, Undoab return nil } - return keyboardManager.keyCommands + return AppCommands.keyCommands(for: .sidebar) } override var canBecomeFirstResponder: Bool { @@ -639,10 +638,6 @@ final class MainFeedCollectionViewController: UICollectionViewController, Undoab coordinator.navigateToTimeline() } - @objc func openInBrowser(_ sender: Any?) { - coordinator.showBrowserForCurrentFeed() - } - @objc func selectNextDown(_ sender: Any?) { coordinator.selectNextFeed() } diff --git a/iOS/MainTimeline/MainTimelineViewController.swift b/iOS/MainTimeline/MainTimelineViewController.swift index 61e74b0fe8..092eaa5405 100644 --- a/iOS/MainTimeline/MainTimelineViewController.swift +++ b/iOS/MainTimeline/MainTimelineViewController.swift @@ -139,9 +139,11 @@ final class MainTimelineViewController: UIViewController, UndoableCommandRunner // If the first responder is the WKWebView (PreloadedWebView) we don’t want to supply any keyboard // commands that the system is looking for by going up the responder chain. They will interfere with // the WKWebViews built in hardware keyboard shortcuts, specifically the up and down arrow keys. - guard let current = UIResponder.currentFirstResponder, !(current is PreloadedWebView) else { return nil } + guard let current = UIResponder.currentFirstResponder, !(current is PreloadedWebView) else { + return nil + } - return keyboardManager.keyCommands + return AppCommands.keyCommands(for: .timeline) } override var canBecomeFirstResponder: Bool { true @@ -162,7 +164,6 @@ final class MainTimelineViewController: UIViewController, UndoableCommandRunner // MARK: Private Constants private let searchController = UISearchController(searchResultsController: nil) - private let keyboardManager = KeyboardManager(type: .timeline) private static let logger = Logger(subsystem: Logger.nnwSubsystem, category: "MainTimelineViewController") // MARK: Constants @@ -472,16 +473,6 @@ final class MainTimelineViewController: UIViewController, UndoableCommandRunner // MARK: - IBActions - @objc func openInBrowser(_ sender: Any?) { - assert(coordinator != nil) - coordinator?.showBrowserForCurrentArticle() - } - - @objc func openInAppBrowser(_ sender: Any?) { - assert(coordinator != nil) - coordinator?.showInAppBrowser() - } - @IBAction func toggleFilter(_ sender: Any) { assert(coordinator != nil) coordinator?.toggleReadArticlesFilter() diff --git a/iOS/OPMLImportExportController.swift b/iOS/OPMLImportExportController.swift new file mode 100644 index 0000000000..89de778ec4 --- /dev/null +++ b/iOS/OPMLImportExportController.swift @@ -0,0 +1,180 @@ +// +// OPMLImportExportController.swift +// NetNewsWire-iOS +// +// Copyright © 2026 Ranchero Software. All rights reserved. +// + +import UIKit +import UniformTypeIdentifiers +import Account + +/// Drives the account-picker then document-picker flow for OPML import and export. +/// +/// Both the Settings screen and the File menu need this flow. They differ only in +/// how the account-picker popover is anchored, so the anchor is the one parameter. +@MainActor final class OPMLImportExportController: NSObject { + + private weak var presentingViewController: UIViewController? + private weak var opmlAccount: Account? + + init(presentingViewController: UIViewController) { + self.presentingViewController = presentingViewController + } + + /// - Parameter sourceRect: where to anchor the account-picker popover, in the + /// presenting view controller's view coordinates. Pass `nil` when there is no + /// source view — a menu item or keyboard shortcut — to centre it. + func importOPML(sourceRect: CGRect? = nil) { + switch AccountManager.shared.activeAccounts.count { + case 0: + presentingViewController?.presentError(title: "Error", message: NSLocalizedString("You must have at least one active account.", comment: "Missing active account")) + case 1: + opmlAccount = AccountManager.shared.activeAccounts.first + importOPMLDocumentPicker() + default: + importOPMLAccountPicker(sourceRect: sourceRect) + } + } + + func exportOPML(sourceRect: CGRect? = nil) { + if AccountManager.shared.accounts.count == 1 { + opmlAccount = AccountManager.shared.accounts.first + exportOPMLDocumentPicker() + } else { + exportOPMLAccountPicker(sourceRect: sourceRect) + } + } + +} + +extension OPMLImportExportController: UIDocumentPickerDelegate { + + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + for url in urls { + opmlAccount?.importOPML(url) { [weak self] result in + switch result { + case .success: + break + case .failure: + let title = NSLocalizedString("Import Failed", comment: "Import Failed") + let message = NSLocalizedString("We were unable to process the selected file. Please ensure that it is a properly formatted OPML file.", comment: "Import Failed Message") + self?.presentingViewController?.presentError(title: title, message: message) + } + } + } + } + +} + +private extension OPMLImportExportController { + + func popoverSourceRect(_ sourceRect: CGRect?, in view: UIView) -> CGRect { + if let sourceRect { + return sourceRect + } + return CGRect(x: view.bounds.midX, y: view.bounds.midY, width: 0, height: 0) + } + + func importOPMLAccountPicker(sourceRect: CGRect?) { + guard let presentingViewController else { + return + } + + let title = NSLocalizedString("Choose an account to receive the imported feeds and folders", comment: "Import Account") + let alert = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) + + if let popoverController = alert.popoverPresentationController { + popoverController.sourceView = presentingViewController.view + popoverController.sourceRect = popoverSourceRect(sourceRect, in: presentingViewController.view) + } + + for account in AccountManager.shared.sortedActiveAccounts { + let action = UIAlertAction(title: account.nameForDisplay, style: .default) { [weak self] _ in + self?.opmlAccount = account + self?.importOPMLDocumentPicker() + } + alert.addAction(action) + } + + let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel button") + alert.addAction(UIAlertAction(title: cancelTitle, style: .cancel)) + + presentingViewController.present(alert, animated: true) + } + + func importOPMLDocumentPicker() { + var contentTypes: [UTType] = [] + + // Create UTType for .opml files by extension, without requiring conformance. + // This ensures files ending in .opml can be selected no matter how OPML is registered. + // + if let opmlByExtension = UTType(filenameExtension: "opml") { + contentTypes.append(opmlByExtension) + } + + // Also try the registered org.opml.opml UTI if it exists + if let registeredOPML = UTType("org.opml.opml") { + contentTypes.append(registeredOPML) + } + + // Include XML as a fallback + contentTypes.append(.xml) + + let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: contentTypes, asCopy: true) + documentPicker.delegate = self + documentPicker.modalPresentationStyle = .formSheet + presentingViewController?.present(documentPicker, animated: true) + } + + func exportOPMLAccountPicker(sourceRect: CGRect?) { + guard let presentingViewController else { + return + } + + let title = NSLocalizedString("Choose an account with the subscriptions to export", comment: "Export Account") + let alert = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) + + if let popoverController = alert.popoverPresentationController { + popoverController.sourceView = presentingViewController.view + popoverController.sourceRect = popoverSourceRect(sourceRect, in: presentingViewController.view) + } + + for account in AccountManager.shared.sortedAccounts { + let action = UIAlertAction(title: account.nameForDisplay, style: .default) { [weak self] _ in + self?.opmlAccount = account + self?.exportOPMLDocumentPicker() + } + alert.addAction(action) + } + + let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel button") + alert.addAction(UIAlertAction(title: cancelTitle, style: .cancel)) + + presentingViewController.present(alert, animated: true) + } + + func exportOPMLDocumentPicker() { + guard let account = opmlAccount else { + return + } + + let accountName = account.nameForDisplay.replacingOccurrences(of: " ", with: "").trimmingCharacters(in: .whitespaces) + let filename = "Subscriptions-\(accountName).opml" + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(filename) + do { + try account.logActivity(kind: .exportOPML, detail: filename) { + let opmlString = OPMLExporter.OPMLString(with: account, title: filename) + try opmlString.write(to: tempFile, atomically: true, encoding: String.Encoding.utf8) + } + } catch { + presentingViewController?.presentError(title: "OPML Export Error", message: error.localizedDescription) + return + } + + let docPicker = UIDocumentPickerViewController(forExporting: [tempFile]) + docPicker.modalPresentationStyle = .formSheet + presentingViewController?.present(docPicker, animated: true) + } + +} diff --git a/iOS/RootSplitViewController.swift b/iOS/RootSplitViewController.swift index 52a425ebd3..2984288ace 100644 --- a/iOS/RootSplitViewController.swift +++ b/iOS/RootSplitViewController.swift @@ -8,11 +8,16 @@ import UIKit import Account +import Articles final class RootSplitViewController: UISplitViewController { var coordinator: SceneCoordinator! + override var keyCommands: [UIKeyCommand]? { + AppCommands.keyCommands(for: .global) + } + override var prefersStatusBarHidden: Bool { return coordinator.prefersStatusBarHidden } @@ -76,10 +81,6 @@ final class RootSplitViewController: UISplitViewController { coordinator.selectNextUnread() } - @objc func markRead(_ sender: Any?) { - coordinator.markAsReadForCurrentArticle() - } - @objc func markUnreadAndGoToNextUnread(_ sender: Any?) { coordinator.markAsUnreadForCurrentArticle() coordinator.selectNextUnread() @@ -102,13 +103,7 @@ final class RootSplitViewController: UISplitViewController { } } - // Anchor to the Mark All as Read button (keyboard shortcut has no source view). - // - if let markAllAsReadButton = (viewController(for: .supplementary) as? MainTimelineViewController)?.markAllAsReadButton { - MarkAsReadAlertController.confirm(self, coordinator: coordinator, confirmTitle: title, sourceType: markAllAsReadButton, completion: completion) - } else { - MarkAsReadAlertController.confirm(self, coordinator: coordinator, confirmTitle: title, sourceType: view as UIView, completion: completion) - } + confirmMarkAllAsRead(title: title, completion: completion) } @objc func markAboveAsRead(_ sender: Any?) { @@ -119,10 +114,6 @@ final class RootSplitViewController: UISplitViewController { coordinator.markBelowAsRead() } - @objc func markUnread(_ sender: Any?) { - coordinator.markAsUnreadForCurrentArticle() - } - @objc func goToPreviousSubscription(_ sender: Any?) { coordinator.selectPrevFeed() } @@ -132,6 +123,13 @@ final class RootSplitViewController: UISplitViewController { } @objc func openInBrowser(_ sender: Any?) { + coordinator.showBrowserForCurrentArticleOrFeed() + } + + /// The Article menu's Open in Browser is about the article, so it has its own action + /// and stays disabled with nothing selected. The B shortcut above is the one that + /// falls back to the feed. + @objc func openArticleInBrowser(_ sender: Any?) { coordinator.showBrowserForCurrentArticle() } @@ -157,10 +155,12 @@ final class RootSplitViewController: UISplitViewController { @objc func toggleReadFeedsFilter(_ sender: Any?) { coordinator.toggleReadFeedsFilter() + UIMenuSystem.main.setNeedsRebuild() } @objc func toggleReadArticlesFilter(_ sender: Any?) { coordinator.toggleReadArticlesFilter() + UIMenuSystem.main.setNeedsRebuild() } @objc func refresh(_ sender: Any?) { @@ -194,4 +194,162 @@ final class RootSplitViewController: UISplitViewController { @objc func toggleStarred(_ sender: Any?) { coordinator.toggleStarredForCurrentArticle() } + + @objc func markAllAsRead(_ sender: Any?) { + let title = NSLocalizedString("Mark All as Read", comment: "Command") + + // Capture the articles before the confirmation alert, so switching folders + // while it's up can't mark the wrong folder. + // + let articlesToMark = coordinator.articles + + confirmMarkAllAsRead(title: title) { [weak self] in + self?.coordinator.markAsReadAndShowSidebar(articlesToMark) + } + } + + @objc func markOlderArticlesAsRead(_ sender: Any?) { + if coordinator.sortDirection == .orderedDescending { + coordinator.markBelowAsRead() + } else { + coordinator.markAboveAsRead() + } + } + + @objc func openInBrowserUsingOppositeOfSettings(_ sender: Any?) { + coordinator.openInBrowserUsingOppositeOfSettings() + } + + @objc func sortByNewestArticleOnTop(_ sender: Any?) { + AppDefaults.shared.timelineSortDirection = .orderedDescending + UIMenuSystem.main.setNeedsRebuild() + } + + @objc func sortByOldestArticleOnTop(_ sender: Any?) { + AppDefaults.shared.timelineSortDirection = .orderedAscending + UIMenuSystem.main.setNeedsRebuild() + } + + @objc func groupByFeedToggled(_ sender: Any?) { + AppDefaults.shared.timelineGroupByFeed.toggle() + UIMenuSystem.main.setNeedsRebuild() + } + + @objc func showFeedInspector(_ sender: Any?) { + coordinator.showFeedInspector() + } + + @objc func showCurrentActivity(_ sender: Any?) { + coordinator.showCurrentActivity() + } + + @objc func beginFind(_ sender: Any?) { + coordinator.beginFindInArticle() + } + + @objc func importOPML(_ sender: Any?) { + coordinator.importOPML() + } + + @objc func exportOPML(_ sender: Any?) { + coordinator.exportOPML() + } + + // MARK: - Mark All as Read Confirmation + + /// Anchors the confirmation to the Mark All as Read button when the timeline is + /// showing one. Menu items and keyboard shortcuts have no source view, so they + /// fall back to the split view. + /// + private func confirmMarkAllAsRead(title: String, completion: @escaping () -> Void) { + if let markAllAsReadButton = (viewController(for: .supplementary) as? MainTimelineViewController)?.markAllAsReadButton { + MarkAsReadAlertController.confirm(self, coordinator: coordinator, confirmTitle: title, sourceType: markAllAsReadButton, completion: completion) + } else { + MarkAsReadAlertController.confirm(self, coordinator: coordinator, confirmTitle: title, sourceType: view as UIView, completion: completion) + } + } + + // MARK: - Menu Validation + + /// The open-in-browser commands work on the current article, or, with none selected, + /// on the selected feed's home page. + private var hasSomethingToOpen: Bool { + coordinator.currentArticle != nil || coordinator.currentFeedHomePageURL != nil + } + + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + switch action { + case #selector(toggleRead(_:)), #selector(toggleStarred(_:)), + #selector(markUnreadAndGoToNextUnread(_:)), #selector(toggleReaderView(_:)), #selector(beginFind(_:)): + return coordinator.currentArticle != nil + case #selector(openArticleInBrowser(_:)): + return coordinator.currentArticle != nil && coordinator.canOpenBrowser() + case #selector(openInBrowserUsingOppositeOfSettings(_:)): + return coordinator.currentArticle != nil && coordinator.canOpenBrowser(invertBrowserPreference: true) + case #selector(openInBrowser(_:)): + return hasSomethingToOpen && coordinator.canOpenBrowser() + case #selector(openInAppBrowser(_:)): + return hasSomethingToOpen && !coordinator.isShowingInAppBrowser + case #selector(markAboveAsRead(_:)): + guard let currentArticle = coordinator.currentArticle else { + return false + } + return coordinator.canMarkAboveAsRead(for: currentArticle) + case #selector(markBelowAsRead(_:)): + guard let currentArticle = coordinator.currentArticle else { + return false + } + return coordinator.canMarkBelowAsRead(for: currentArticle) + case #selector(markOlderArticlesAsRead(_:)): + guard let currentArticle = coordinator.currentArticle else { + return false + } + if coordinator.sortDirection == .orderedDescending { + return coordinator.canMarkBelowAsRead(for: currentArticle) + } else { + return coordinator.canMarkAboveAsRead(for: currentArticle) + } + case #selector(nextUnread(_:)): + return coordinator.isNextUnreadAvailable + case #selector(scrollOrGoToNextUnread(_:)): + return coordinator.currentArticle != nil || coordinator.isNextUnreadAvailable + case #selector(markAllAsRead(_:)): + return coordinator.isTimelineUnreadAvailable + case #selector(markAllAsReadAndGoToNextUnread(_:)): + // Stays enabled with a fully-read timeline: the action falls back to + // going to the next unread article elsewhere. + return coordinator.isTimelineUnreadAvailable || coordinator.isNextUnreadAvailable + case #selector(showFeedInspector(_:)): + return coordinator.timelineFeed is Feed || coordinator.currentArticle?.feed != nil + default: + return super.canPerformAction(action, withSender: sender) + } + } + + override func validate(_ command: UICommand) { + super.validate(command) + + switch command.action { + case #selector(toggleRead(_:)): + command.title = coordinator.currentArticle?.status.read ?? false ? + NSLocalizedString("Mark as Unread", comment: "Command") : + NSLocalizedString("Mark as Read", comment: "Command") + case #selector(toggleStarred(_:)): + command.title = coordinator.currentArticle?.status.starred ?? false ? + NSLocalizedString("Mark as Unstarred", comment: "Command") : + NSLocalizedString("Mark as Starred", comment: "Command") + case #selector(groupByFeedToggled(_:)): + command.state = AppDefaults.shared.timelineGroupByFeed ? .on : .off + case #selector(sortByNewestArticleOnTop(_:)): + command.state = AppDefaults.shared.timelineSortDirection == .orderedDescending ? .on : .off + case #selector(sortByOldestArticleOnTop(_:)): + command.state = AppDefaults.shared.timelineSortDirection == .orderedAscending ? .on : .off + case #selector(toggleReadArticlesFilter(_:)): + command.state = coordinator.isReadArticlesFiltered ? .on : .off + case #selector(toggleReadFeedsFilter(_:)): + command.state = coordinator.isReadFeedsFiltered ? .on : .off + default: + break + } + } } diff --git a/iOS/SceneCoordinator.swift b/iOS/SceneCoordinator.swift index 261975ac65..679abfbcc9 100644 --- a/iOS/SceneCoordinator.swift +++ b/iOS/SceneCoordinator.swift @@ -164,6 +164,7 @@ struct SidebarItemNode: Hashable, Sendable { } private var exceptionArticleFetcher: ArticleFetcher? + private lazy var opmlController = OPMLImportExportController(presentingViewController: rootSplitViewController) private(set) var timelineFeed: SidebarItem? { didSet { mainTimelineViewController?.updateNavigationBarTitle(timelineFeed?.nameForDisplay ?? "") @@ -1472,7 +1473,7 @@ struct SidebarItemNode: Hashable, Sendable { guard currentArticle != nil else { return } - articleViewController?.toggleReaderView(nil) + articleViewController?.toggleReaderView() } func toggleRead(_ article: Article) { @@ -1614,6 +1615,14 @@ struct SidebarItemNode: Hashable, Sendable { rootSplitViewController.present(feedInspectorNavController, animated: true) } + func importOPML() { + opmlController.importOPML() + } + + func exportOPML() { + opmlController.exportOPML() + } + func showAddFeed(initialFeed: String? = nil, initialFeedName: String? = nil) { // The sheet appears over the current screen, so the feed and article selection stay as they are. @@ -1662,9 +1671,46 @@ struct SidebarItemNode: Hashable, Sendable { return url } - func showBrowserForCurrentFeed() { - if let ip = currentFeedIndexPath, let url = homePageURLForFeed(ip) { + /// The pref says where links open; the inverted commands flip it. + private func useSystemBrowser(inverted: Bool) -> Bool { + AppDefaults.shared.useSystemBrowser != inverted + } + + /// True while the in-app browser is on screen. A second one can't open on top of it. + var isShowingInAppBrowser: Bool { + var presented = rootSplitViewController.presentedViewController + while let viewController = presented { + if viewController is SFSafariViewController { + return true + } + presented = viewController.presentedViewController + } + return false + } + + /// Whether an open-in-browser command has somewhere to go. The commands that would + /// open the in-app browser are unavailable while it's already showing; the ones that + /// hand off to the system browser always are. + func canOpenBrowser(invertBrowserPreference: Bool = false) -> Bool { + useSystemBrowser(inverted: invertBrowserPreference) || !isShowingInAppBrowser + } + + /// The home page of the feed selected in the sidebar, if it has one. + var currentFeedHomePageURL: URL? { + guard let indexPath = currentFeedIndexPath else { + return nil + } + return homePageURLForFeed(indexPath) + } + + func showBrowserForCurrentFeed(invertBrowserPreference: Bool = false) { + guard let url = currentFeedHomePageURL else { + return + } + if useSystemBrowser(inverted: invertBrowserPreference) { UIApplication.shared.open(url, options: [:]) + } else { + mainFeedCollectionViewController.openInAppBrowser() } } @@ -1675,11 +1721,25 @@ struct SidebarItemNode: Hashable, Sendable { UIApplication.shared.open(url, options: [:]) } - func showBrowserForCurrentArticle() { + func showBrowserForCurrentArticle(invertBrowserPreference: Bool = false) { guard let url = currentArticle?.preferredURL else { return } - UIApplication.shared.open(url, options: [:]) + if useSystemBrowser(inverted: invertBrowserPreference) { + UIApplication.shared.open(url, options: [:]) + } else { + articleViewController?.openInAppBrowser() + } + } + + /// With no article selected, the open-in-browser shortcuts fall back to the selected + /// feed's home page, the way they do on the Mac when the sidebar has focus. + func showBrowserForCurrentArticleOrFeed() { + if currentArticle != nil { + showBrowserForCurrentArticle() + } else { + showBrowserForCurrentFeed() + } } func showInAppBrowser() { @@ -1698,6 +1758,18 @@ struct SidebarItemNode: Hashable, Sendable { activityManager.invalidateBrowsing() } + func openInBrowserUsingOppositeOfSettings() { + if currentArticle != nil { + showBrowserForCurrentArticle(invertBrowserPreference: true) + } else { + showBrowserForCurrentFeed(invertBrowserPreference: true) + } + } + + func beginFindInArticle() { + articleViewController?.beginFind() + } + func navigateToFeeds() { revealColumn(.primary) { [weak self] in self?.mainFeedCollectionViewController?.focus() diff --git a/iOS/Settings/SettingsViewController.swift b/iOS/Settings/SettingsViewController.swift index ed5182c506..fa5e936b68 100644 --- a/iOS/Settings/SettingsViewController.swift +++ b/iOS/Settings/SettingsViewController.swift @@ -10,7 +10,6 @@ import UIKit import CoreServices import SafariServices import SwiftUI -import UniformTypeIdentifiers import RSCore import Account import ActivityLog @@ -65,7 +64,7 @@ final class SettingsViewController: UITableViewController { case about = 4 } - private weak var opmlAccount: Account? + private lazy var opmlController = OPMLImportExportController(presentingViewController: self) @IBOutlet var timelineSortOrderSwitch: UISwitch! @IBOutlet var groupByFeedSwitch: UISwitch! @@ -250,16 +249,10 @@ final class SettingsViewController: UITableViewController { switch FeedsRow(rawValue: indexPath.row) { case .importSubscriptions: tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - if let sourceView = tableView.cellForRow(at: indexPath) { - let sourceRect = tableView.rectForRow(at: indexPath) - importOPML(sourceView: sourceView, sourceRect: sourceRect) - } + opmlController.importOPML(sourceRect: tableView.rectForRow(at: indexPath)) case .exportSubscriptions: tableView.selectRow(at: nil, animated: true, scrollPosition: .none) - if let sourceView = tableView.cellForRow(at: indexPath) { - let sourceRect = tableView.rectForRow(at: indexPath) - exportOPML(sourceView: sourceView, sourceRect: sourceRect) - } + opmlController.exportOPML(sourceRect: tableView.rectForRow(at: indexPath)) case .addNetNewsWireNewsFeed: addFeed() tableView.selectRow(at: nil, animated: true, scrollPosition: .none) @@ -408,6 +401,7 @@ final class SettingsViewController: UITableViewController { } else { AppDefaults.shared.useSystemBrowser = true } + UIMenuSystem.main.setNeedsRebuild() } @IBAction func switchJavaScriptPreference(_ sender: Any) { @@ -442,27 +436,6 @@ final class SettingsViewController: UITableViewController { } -// MARK: - OPML Document Picker - -extension SettingsViewController: UIDocumentPickerDelegate { - - func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { - for url in urls { - opmlAccount?.importOPML(url) { result in - switch result { - case .success: - break - case .failure: - let title = NSLocalizedString("Import Failed", comment: "Import Failed") - let message = NSLocalizedString("We were unable to process the selected file. Please ensure that it is a properly formatted OPML file.", comment: "Import Failed Message") - self.presentError(title: title, message: message) - } - } - } - } - -} - // MARK: - Private private extension SettingsViewController { @@ -525,117 +498,6 @@ private extension SettingsViewController { presentingParentController?.present(hostingController, animated: true) } - func importOPML(sourceView: UIView, sourceRect: CGRect) { - switch AccountManager.shared.activeAccounts.count { - case 0: - presentError(title: "Error", message: NSLocalizedString("You must have at least one active account.", comment: "Missing active account")) - case 1: - opmlAccount = AccountManager.shared.activeAccounts.first - importOPMLDocumentPicker() - default: - importOPMLAccountPicker(sourceView: sourceView, sourceRect: sourceRect) - } - } - - func importOPMLAccountPicker(sourceView: UIView, sourceRect: CGRect) { - let title = NSLocalizedString("Choose an account to receive the imported feeds and folders", comment: "Import Account") - let alert = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) - - if let popoverController = alert.popoverPresentationController { - popoverController.sourceView = view - popoverController.sourceRect = sourceRect - } - - for account in AccountManager.shared.sortedActiveAccounts { - let action = UIAlertAction(title: account.nameForDisplay, style: .default) { [weak self] _ in - self?.opmlAccount = account - self?.importOPMLDocumentPicker() - } - alert.addAction(action) - } - - let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel button") - alert.addAction(UIAlertAction(title: cancelTitle, style: .cancel)) - - self.present(alert, animated: true) - } - - func importOPMLDocumentPicker() { - var contentTypes: [UTType] = [] - - // Create UTType for .opml files by extension, without requiring conformance. - // This ensures files ending in .opml can be selected no matter how OPML is registered. - // - if let opmlByExtension = UTType(filenameExtension: "opml") { - contentTypes.append(opmlByExtension) - } - - // Also try the registered org.opml.opml UTI if it exists - if let registeredOPML = UTType("org.opml.opml") { - contentTypes.append(registeredOPML) - } - - // Include XML as a fallback - contentTypes.append(.xml) - - let documentPicker = UIDocumentPickerViewController(forOpeningContentTypes: contentTypes, asCopy: true) - documentPicker.delegate = self - documentPicker.modalPresentationStyle = .formSheet - self.present(documentPicker, animated: true) - } - - func exportOPML(sourceView: UIView, sourceRect: CGRect) { - if AccountManager.shared.accounts.count == 1 { - opmlAccount = AccountManager.shared.accounts.first! - exportOPMLDocumentPicker() - } else { - exportOPMLAccountPicker(sourceView: sourceView, sourceRect: sourceRect) - } - } - - func exportOPMLAccountPicker(sourceView: UIView, sourceRect: CGRect) { - let title = NSLocalizedString("Choose an account with the subscriptions to export", comment: "Export Account") - let alert = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet) - - if let popoverController = alert.popoverPresentationController { - popoverController.sourceView = view - popoverController.sourceRect = sourceRect - } - - for account in AccountManager.shared.sortedAccounts { - let action = UIAlertAction(title: account.nameForDisplay, style: .default) { [weak self] _ in - self?.opmlAccount = account - self?.exportOPMLDocumentPicker() - } - alert.addAction(action) - } - - let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel button") - alert.addAction(UIAlertAction(title: cancelTitle, style: .cancel)) - - self.present(alert, animated: true) - } - - func exportOPMLDocumentPicker() { - guard let account = opmlAccount else { return } - - let accountName = account.nameForDisplay.replacingOccurrences(of: " ", with: "").trimmingCharacters(in: .whitespaces) - let filename = "Subscriptions-\(accountName).opml" - let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent(filename) - do { - try account.logActivity(kind: .exportOPML, detail: filename) { - let opmlString = OPMLExporter.OPMLString(with: account, title: filename) - try opmlString.write(to: tempFile, atomically: true, encoding: String.Encoding.utf8) - } - } catch { - self.presentError(title: "OPML Export Error", message: error.localizedDescription) - } - - let docPicker = UIDocumentPickerViewController(forExporting: [tempFile]) - docPicker.modalPresentationStyle = .formSheet - self.present(docPicker, animated: true) - } - func openURL(_ urlString: String) { guard let url = URL(string: urlString) else { return