Files
NetNewsWire/Mac/MainWindow/Sidebar/SidebarViewController+ContextualMenus.swift
2023-07-05 20:46:25 -07:00

479 lines
17 KiB
Swift

//
// SidebarViewController+ContextualMenus.swift
// NetNewsWire
//
// Created by Brent Simmons on 1/28/18.
// Copyright © 2018 Ranchero Software. All rights reserved.
//
import AppKit
import Articles
import Account
import RSCore
import UserNotifications
extension Notification.Name {
public static let DidUpdateFeedPreferencesFromContextMenu = Notification.Name(rawValue: "DidUpdateFeedPreferencesFromContextMenu")
}
extension SidebarViewController {
func menu(for objects: [Any]?) -> NSMenu? {
guard let objects = objects, objects.count > 0 else {
return menuForNoSelection()
}
if objects.count > 1 {
return menuForMultipleObjects(objects)
}
let object = objects.first!
switch object {
case is Feed:
return menuForFeed(object as! Feed)
case is Folder:
return menuForFolder(object as! Folder)
case is PseudoFeed:
return menuForSmartFeed(object as! PseudoFeed)
default:
return nil
}
}
}
// MARK: Contextual Menu Actions
extension SidebarViewController {
@objc func openHomePageFromContextualMenu(_ sender: Any?) {
guard let menuItem = sender as? NSMenuItem, let urlString = menuItem.representedObject as? String else {
return
}
Browser.open(urlString, inBackground: false)
}
@objc func copyURLFromContextualMenu(_ sender: Any?) {
guard let menuItem = sender as? NSMenuItem, let urlString = menuItem.representedObject as? String else {
return
}
URLPasteboardWriter.write(urlString: urlString, to: NSPasteboard.general)
}
@objc func markObjectsReadFromContextualMenu(_ sender: Any?) {
guard let menuItem = sender as? NSMenuItem, let objects = menuItem.representedObject as? [Any] else {
return
}
var markableArticles = unreadArticles(for: objects)
if let directlyMarkedAsUnreadArticles = delegate?.directlyMarkedAsUnreadArticles {
markableArticles = markableArticles.subtracting(directlyMarkedAsUnreadArticles)
}
guard let undoManager = undoManager,
let markReadCommand = MarkStatusCommand(initialArticles: markableArticles,
markingRead: true,
directlyMarked: false,
undoManager: undoManager) else {
return
}
runCommand(markReadCommand)
}
@objc func markObjectsReadOlderThanOneDayFromContextualMenu(_ sender: Any?) {
return markObjectsReadBetweenDatesFromContextualMenu(before: Calendar.current.date(byAdding: .day, value: -1, to: Date()), after: nil, sender: sender)
}
@objc func markObjectsReadOlderThanTwoDaysFromContextualMenu(_ sender: Any?) {
return markObjectsReadBetweenDatesFromContextualMenu(before: Calendar.current.date(byAdding: .day, value: -2, to: Date()), after: nil, sender: sender)
}
@objc func markObjectsReadOlderThanThreeDaysFromContextualMenu(_ sender: Any?) {
return markObjectsReadBetweenDatesFromContextualMenu(before: Calendar.current.date(byAdding: .day, value: -3, to: Date()), after: nil, sender: sender)
}
@objc func markObjectsReadOlderThanOneWeekFromContextualMenu(_ sender: Any?) {
return markObjectsReadBetweenDatesFromContextualMenu(before: Calendar.current.date(byAdding: .weekOfYear, value: -1, to: Date()), after: nil, sender: sender)
}
@objc func markObjectsReadOlderThanTwoWeeksFromContextualMenu(_ sender: Any?) {
return markObjectsReadBetweenDatesFromContextualMenu(before: Calendar.current.date(byAdding: .weekOfYear, value: -2, to: Date()), after: nil, sender: sender)
}
@objc func markObjectsReadOlderThanOneMonthFromContextualMenu(_ sender: Any?) {
return markObjectsReadBetweenDatesFromContextualMenu(before: Calendar.current.date(byAdding: .month, value: -1, to: Date()), after: nil, sender: sender)
}
@objc func markObjectsReadOlderThanOneYearFromContextualMenu(_ sender: Any?) {
return markObjectsReadBetweenDatesFromContextualMenu(before: Calendar.current.date(byAdding: .year, value: -1, to: Date()), after: nil, sender: sender)
}
func markObjectsReadBetweenDatesFromContextualMenu(before: Date?, after: Date?, sender: Any?) {
guard let menuItem = sender as? NSMenuItem, let objects = menuItem.representedObject as? [Any] else {
return
}
var markableArticles = unreadArticlesBetween(for: objects, before: before, after: after)
if let directlyMarkedAsUnreadArticles = delegate?.directlyMarkedAsUnreadArticles {
markableArticles = markableArticles.subtracting(directlyMarkedAsUnreadArticles)
}
guard let undoManager = undoManager,
let markReadCommand = MarkStatusCommand(initialArticles: markableArticles,
markingRead: true,
directlyMarked: false,
undoManager: undoManager) else {
return
}
runCommand(markReadCommand)
}
@objc func deleteFromContextualMenu(_ sender: Any?) {
guard let menuItem = sender as? NSMenuItem, let objects = menuItem.representedObject as? [AnyObject] else {
return
}
let nodes = objects.compactMap { treeController.nodeInTreeRepresentingObject($0) }
let alert = SidebarDeleteItemsAlert.build(nodes)
alert.beginSheetModal(for: view.window!) { [weak self] result in
if result == NSApplication.ModalResponse.alertFirstButtonReturn {
self?.deleteNodes(nodes)
}
}
}
@objc func renameFromContextualMenu(_ sender: Any?) {
guard let window = view.window, let menuItem = sender as? NSMenuItem, let object = menuItem.representedObject as? DisplayNameProvider, object is Feed || object is Folder else {
return
}
renameWindowController = RenameWindowController(originalTitle: object.nameForDisplay, representedObject: object, delegate: self)
guard let renameSheet = renameWindowController?.window else {
return
}
window.beginSheet(renameSheet)
}
@objc func toggleNotificationsFromContextMenu(_ sender: Any?) {
guard let item = sender as? NSMenuItem,
let feed = item.representedObject as? Feed else {
return
}
UNUserNotificationCenter.current().getNotificationSettings { (settings) in
if settings.authorizationStatus == .denied {
self.showNotificationsNotEnabledAlert()
} else if settings.authorizationStatus == .authorized {
DispatchQueue.main.async {
if feed.isNotifyAboutNewArticles == nil { feed.isNotifyAboutNewArticles = false }
feed.isNotifyAboutNewArticles?.toggle()
NotificationCenter.default.post(Notification(name: .DidUpdateFeedPreferencesFromContextMenu))
}
} else {
UNUserNotificationCenter.current().requestAuthorization(options: [.badge, .sound, .alert]) { (granted, error) in
if granted {
DispatchQueue.main.async {
if feed.isNotifyAboutNewArticles == nil { feed.isNotifyAboutNewArticles = false }
feed.isNotifyAboutNewArticles?.toggle()
NotificationCenter.default.post(Notification(name: .DidUpdateFeedPreferencesFromContextMenu))
NSApplication.shared.registerForRemoteNotifications()
}
} else {
self.showNotificationsNotEnabledAlert()
}
}
}
}
}
@objc func toggleArticleExtractorFromContextMenu(_ sender: Any?) {
guard let item = sender as? NSMenuItem,
let feed = item.representedObject as? Feed else {
return
}
if feed.isArticleExtractorAlwaysOn == nil { feed.isArticleExtractorAlwaysOn = false }
feed.isArticleExtractorAlwaysOn?.toggle()
NotificationCenter.default.post(Notification(name: .DidUpdateFeedPreferencesFromContextMenu))
}
func showNotificationsNotEnabledAlert() {
DispatchQueue.main.async {
let alert = NSAlert()
alert.messageText = NSLocalizedString("alert.title.notifications-not-enabled", comment: "Notifications are not enabled.")
alert.informativeText = NSLocalizedString("alert.message.enable-notifications-in-settings", comment: "You can enable NetNewsWire notifications in System Settings.")
alert.addButton(withTitle: NSLocalizedString("button.title.open-settings", comment: "Open Settings"))
alert.addButton(withTitle: NSLocalizedString("button.title.dismiss", comment: "Dismiss"))
let userChoice = alert.runModal()
if userChoice == .alertFirstButtonReturn {
let config = NSWorkspace.OpenConfiguration()
config.activates = true
// If System Preferences is already open, and no delay is provided here, then it appears in the foreground and immediately disappears.
DispatchQueue.main.asyncAfter(wallDeadline: .now() + 0.2, execute: {
NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.notifications")!, configuration: config)
})
}
}
}
}
extension SidebarViewController: RenameWindowControllerDelegate {
func renameWindowController(_ windowController: RenameWindowController, didRenameObject object: Any, withNewName name: String) {
if let feed = object as? Feed {
feed.rename(to: name) { result in
switch result {
case .success:
break
case .failure(let error):
NSApplication.shared.presentError(error)
}
}
} else if let folder = object as? Folder {
folder.rename(to: name) { result in
switch result {
case .success:
break
case .failure(let error):
NSApplication.shared.presentError(error)
}
}
}
}
}
// MARK: Build Contextual Menus
private extension SidebarViewController {
func menuForNoSelection() -> NSMenu {
let menu = NSMenu(title: "")
menu.addItem(withTitle: NSLocalizedString("button.title.new-feed", comment: "New Feed"), action: #selector(AppDelegate.showAddFeedWindow(_:)), keyEquivalent: "")
menu.addItem(withTitle: NSLocalizedString("button.title.new-folder", comment: "New Folder"), action: #selector(AppDelegate.showAddFolderWindow(_:)), keyEquivalent: "")
return menu
}
func menuForFeed(_ feed: Feed) -> NSMenu? {
let menu = NSMenu(title: "")
if feed.unreadCount > 0 {
menu.addItem(markAllReadMenuItem([feed]))
let catchUpMenuItem = NSMenuItem(title: NSLocalizedString("button.title.mark-as-read-older-than", comment: "Mark as Read Older Than"), action: nil, keyEquivalent: "")
let catchUpSubMenu = catchUpSubMenu([feed])
menu.addItem(catchUpMenuItem)
menu.setSubmenu(catchUpSubMenu, for: catchUpMenuItem)
menu.addItem(NSMenuItem.separator())
}
if let homePageURL = feed.homePageURL, let _ = URL(string: homePageURL) {
let item = menuItem(NSLocalizedString("button.title.open-home-page", comment: "Open Home Page"), #selector(openHomePageFromContextualMenu(_:)), homePageURL.decodedURLString ?? homePageURL)
menu.addItem(item)
menu.addItem(NSMenuItem.separator())
}
let copyFeedURLItem = menuItem(NSLocalizedString("button.title.copy-feed-url", comment: "Copy Feed URL"), #selector(copyURLFromContextualMenu(_:)), feed.url.decodedURLString ?? feed.url)
menu.addItem(copyFeedURLItem)
if let homePageURL = feed.homePageURL {
let item = menuItem(NSLocalizedString("button.title.copy-home-page-url", comment: "Copy Home Page URL"), #selector(copyURLFromContextualMenu(_:)), homePageURL.decodedURLString ?? homePageURL)
menu.addItem(item)
}
menu.addItem(NSMenuItem.separator())
let notificationText = feed.notificationDisplayName.capitalized
let notificationMenuItem = menuItem(notificationText, #selector(toggleNotificationsFromContextMenu(_:)), feed)
if feed.isNotifyAboutNewArticles == nil || feed.isNotifyAboutNewArticles! == false {
notificationMenuItem.state = .off
} else {
notificationMenuItem.state = .on
}
menu.addItem(notificationMenuItem)
let articleExtractorText = NSLocalizedString("button.title.always-use-reader-view", comment: "Always Use Reader View")
let articleExtractorMenuItem = menuItem(articleExtractorText, #selector(toggleArticleExtractorFromContextMenu(_:)), feed)
if feed.isArticleExtractorAlwaysOn == nil || feed.isArticleExtractorAlwaysOn! == false {
articleExtractorMenuItem.state = .off
} else {
articleExtractorMenuItem.state = .on
}
menu.addItem(articleExtractorMenuItem)
menu.addItem(NSMenuItem.separator())
menu.addItem(renameMenuItem(feed))
menu.addItem(deleteMenuItem([feed]))
return menu
}
func menuForFolder(_ folder: Folder) -> NSMenu? {
let menu = NSMenu(title: "")
if folder.unreadCount > 0 {
menu.addItem(markAllReadMenuItem([folder]))
let catchUpMenuItem = NSMenuItem(title: NSLocalizedString("button.title.mark-as-read-older-than", comment: "Mark as Read Older Than"), action: nil, keyEquivalent: "")
let catchUpSubMenu = catchUpSubMenu([folder])
menu.addItem(catchUpMenuItem)
menu.setSubmenu(catchUpSubMenu, for: catchUpMenuItem)
menu.addItem(NSMenuItem.separator())
}
menu.addItem(renameMenuItem(folder))
menu.addItem(deleteMenuItem([folder]))
return menu.numberOfItems > 0 ? menu : nil
}
func menuForSmartFeed(_ smartFeed: PseudoFeed) -> NSMenu? {
let menu = NSMenu(title: "")
if smartFeed.unreadCount > 0 {
menu.addItem(markAllReadMenuItem([smartFeed]))
// Doesn't make sense to mark articles newer than a day with catch up with first option being older than a day
if let maybeSmartFeed = smartFeed as? SmartFeed {
if maybeSmartFeed.delegate is TodayFeedDelegate {
return menu
}
}
let catchUpMenuItem = NSMenuItem(title: NSLocalizedString("button.title.mark-as-read-older-than", comment: "Mark as Read Older Than"), action: nil, keyEquivalent: "")
let catchUpSubMenu = catchUpSubMenu([smartFeed])
menu.addItem(catchUpMenuItem)
menu.setSubmenu(catchUpSubMenu, for: catchUpMenuItem)
}
return menu.numberOfItems > 0 ? menu : nil
}
func menuForMultipleObjects(_ objects: [Any]) -> NSMenu? {
let menu = NSMenu(title: "")
if anyObjectInArrayHasNonZeroUnreadCount(objects) {
menu.addItem(markAllReadMenuItem(objects))
let catchUpMenuItem = NSMenuItem(title: NSLocalizedString("button.title.mark-as-read-older-than", comment: "Mark as Read Older Than"), action: nil, keyEquivalent: "")
let catchUpSubMenu = catchUpSubMenu(objects)
menu.addItem(catchUpMenuItem)
menu.setSubmenu(catchUpSubMenu, for: catchUpMenuItem)
}
if allObjectsAreFeedsAndOrFolders(objects) {
menu.addSeparatorIfNeeded()
menu.addItem(deleteMenuItem(objects))
}
return menu.numberOfItems > 0 ? menu : nil
}
func markAllReadMenuItem(_ objects: [Any]) -> NSMenuItem {
return menuItem(NSLocalizedString("button.title.mark-all-as-read.titlecase", comment: "Mark All as Read"), #selector(markObjectsReadFromContextualMenu(_:)), objects)
}
func catchUpSubMenu(_ objects: [Any]) -> NSMenu {
let menu = NSMenu(title: NSLocalizedString("menu.title.catch-up-to-articles", comment: "Catch up to articles older than..."))
menu.addItem(menuItem(NSLocalizedString("button.title.1-day", comment: "1 day"), #selector(markObjectsReadOlderThanOneDayFromContextualMenu(_:)), objects))
menu.addItem(menuItem(NSLocalizedString("button.title.2-days", comment: "2 days"), #selector(markObjectsReadOlderThanTwoDaysFromContextualMenu(_:)), objects))
menu.addItem(menuItem(NSLocalizedString("button.title.3-days", comment: "3 days"), #selector(markObjectsReadOlderThanThreeDaysFromContextualMenu(_:)), objects))
menu.addItem(menuItem(NSLocalizedString("button.title.1-week", comment: "1 week"), #selector(markObjectsReadOlderThanOneWeekFromContextualMenu(_:)), objects))
menu.addItem(menuItem(NSLocalizedString("button.title.2-weeks", comment: "2 weeks"), #selector(markObjectsReadOlderThanTwoWeeksFromContextualMenu(_:)), objects))
menu.addItem(menuItem(NSLocalizedString("button.title.1-month", comment: "1 month"), #selector(markObjectsReadOlderThanOneMonthFromContextualMenu(_:)), objects))
menu.addItem(menuItem(NSLocalizedString("button.title.1-year", comment: "1 year"), #selector(markObjectsReadOlderThanOneYearFromContextualMenu(_:)), objects))
return menu
}
func deleteMenuItem(_ objects: [Any]) -> NSMenuItem {
return menuItem(NSLocalizedString("button.title.delete", comment: "Delete"), #selector(deleteFromContextualMenu(_:)), objects)
}
func renameMenuItem(_ object: Any) -> NSMenuItem {
return menuItem(NSLocalizedString("button.title.rename", comment: "Rename"), #selector(renameFromContextualMenu(_:)), object)
}
func anyObjectInArrayHasNonZeroUnreadCount(_ objects: [Any]) -> Bool {
for object in objects {
if let unreadCountProvider = object as? UnreadCountProvider {
if unreadCountProvider.unreadCount > 0 {
return true
}
}
}
return false
}
func allObjectsAreFeedsAndOrFolders(_ objects: [Any]) -> Bool {
for object in objects {
if !objectIsFeedOrFolder(object) {
return false
}
}
return true
}
func objectIsFeedOrFolder(_ object: Any) -> Bool {
return object is Feed || object is Folder
}
func menuItem(_ title: String, _ action: Selector, _ representedObject: Any) -> NSMenuItem {
let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
item.representedObject = representedObject
item.target = self
return item
}
func unreadArticles(for objects: [Any]) -> Set<Article> {
var articles = Set<Article>()
for object in objects {
if let articleFetcher = object as? ArticleFetcher {
if let unreadArticles = try? articleFetcher.fetchUnreadArticles() {
articles.formUnion(unreadArticles)
}
}
}
return articles
}
func unreadArticlesBetween(for objects: [Any], before: Date?, after: Date?) -> Set<Article> {
var articles = Set<Article>()
for object in objects {
if let articleFetcher = object as? ArticleFetcher {
if let unreadArticles = try? articleFetcher.fetchUnreadArticlesBetween(before: before, after: after) {
articles.formUnion(unreadArticles)
}
}
}
return articles
}
}