Merge branch 'main' into bsc-662-catch-up

This commit is contained in:
Bryan Culver
2022-11-21 23:07:29 -05:00
113 changed files with 2473 additions and 1485 deletions

View File

@@ -0,0 +1,55 @@
//
// AboutNetNewsWireView.swift
// NetNewsWire
//
// Created by Stuart Breckenridge on 03/10/2022.
// Copyright © 2022 Ranchero Software. All rights reserved.
//
import SwiftUI
@available(macOS 12, *)
struct AboutNetNewsWireView: View {
var body: some View {
HStack {
Spacer()
VStack(spacing: 8) {
Spacer()
Image("About")
.resizable()
.frame(width: 75, height: 75)
Text("NetNewsWire")
.font(.headline)
Text("\(Bundle.main.versionNumber) (\(Bundle.main.buildNumber))")
.foregroundColor(.secondary)
.font(.callout)
Text("By Brent Simmons and the NetNewsWire team.")
.font(.subheadline)
Text("[netnewswire.com](https://netnewswire.com)")
.font(.callout)
Spacer()
Text(verbatim: "Copyright © Brent Simmons 2002 - \(Calendar.current.component(.year, from: .now))")
.font(.callout)
.foregroundColor(.secondary)
.padding(.bottom)
}
Spacer()
}
.multilineTextAlignment(.center)
.frame(width: 400, height: 400)
}
}
@available(macOS 12, *)
struct AboutNetNewsWireView_Previews: PreviewProvider {
static var previews: some View {
AboutNetNewsWireView()
}
}

View File

@@ -0,0 +1,131 @@
//
// AboutWindowController.swift
// NetNewsWire
//
// Created by Stuart Breckenridge on 03/10/2022.
// Copyright © 2022 Ranchero Software. All rights reserved.
//
import AppKit
import SwiftUI
import RSCore
extension NSToolbarItem.Identifier {
static let aboutGroup = NSToolbarItem.Identifier("about.toolbar.group")
}
extension NSUserInterfaceItemIdentifier {
static let aboutNetNewsWire = NSUserInterfaceItemIdentifier("about.netnewswire")
}
// MARK: - AboutWindowController
@available(macOS 12, *)
class AboutWindowController: NSWindowController, NSToolbarDelegate {
var hostingController: AboutHostingController
override init(window: NSWindow?) {
self.hostingController = AboutHostingController(rootView: AnyView(AboutNetNewsWireView()))
super.init(window: window)
let window = NSWindow(contentViewController: hostingController)
window.identifier = .aboutNetNewsWire
window.standardWindowButton(.zoomButton)?.isEnabled = false
window.titleVisibility = .hidden
self.window = window
self.hostingController.configureToolbar()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func windowDidLoad() {
super.windowDidLoad()
}
}
// MARK: - AboutHostingController
@available(macOS 12, *)
class AboutHostingController: NSHostingController<AnyView>, NSToolbarDelegate {
private lazy var segmentedControl: NSSegmentedControl = {
let control = NSSegmentedControl(labels: ["About", "Credits"],
trackingMode: .selectOne,
target: self,
action: #selector(segmentedControlSelectionChanged(_:)))
control.segmentCount = 2
control.setSelected(true, forSegment: 0)
return control
}()
override init(rootView: AnyView) {
super.init(rootView: rootView)
}
@MainActor required dynamic init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
public func configureToolbar() {
let toolbar = NSToolbar(identifier: NSToolbar.Identifier("netnewswire.about.toolbar"))
toolbar.delegate = self
toolbar.autosavesConfiguration = false
toolbar.allowsUserCustomization = false
view.window?.toolbar = toolbar
view.window?.toolbarStyle = .unified
toolbar.insertItem(withItemIdentifier: .flexibleSpace, at: 0)
toolbar.insertItem(withItemIdentifier: .flexibleSpace, at: 2)
}
// MARK: NSToolbarDelegate
func toolbar(_ toolbar: NSToolbar, itemForItemIdentifier itemIdentifier: NSToolbarItem.Identifier, willBeInsertedIntoToolbar flag: Bool) -> NSToolbarItem? {
switch itemIdentifier {
case .aboutGroup:
let toolbarItem = NSToolbarItem(itemIdentifier: .aboutGroup)
toolbarItem.view = segmentedControl
toolbarItem.autovalidates = true
return toolbarItem
default:
return nil
}
}
func toolbarAllowedItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return [.aboutGroup]
}
func toolbarWillAddItem(_ notification: Notification) {
//
}
func toolbarDidRemoveItem(_ notification: Notification) {
//
}
func toolbarDefaultItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return [.aboutGroup]
}
func toolbarSelectableItemIdentifiers(_ toolbar: NSToolbar) -> [NSToolbarItem.Identifier] {
return []
}
// MARK: - Target/Action
@objc
func segmentedControlSelectionChanged(_ sender: NSSegmentedControl) {
if sender.selectedSegment == 0 {
rootView = AnyView(AboutNetNewsWireView())
} else {
rootView = AnyView(CreditsNetNewsWireView())
}
}
}

View File

@@ -0,0 +1,80 @@
//
// CreditsNetNewsWireView.swift
// NetNewsWire
//
// Created by Stuart Breckenridge on 03/10/2022.
// Copyright © 2022 Ranchero Software. All rights reserved.
//
import SwiftUI
@available(macOS 12, *)
struct CreditsNetNewsWireView: View, LoadableAboutData {
var body: some View {
ScrollView(.vertical, showsIndicators: false) {
Spacer()
.frame(height: 12)
Section("Primary Contributors") {
GroupBox {
ForEach(0..<about.PrimaryContributors.count, id: \.self) { i in
contributorView(about.PrimaryContributors[i])
.padding(.vertical, 2)
.listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12))
}
}
}
Section("Additional Contributors") {
GroupBox {
ForEach(0..<about.AdditionalContributors.count, id: \.self) { i in
contributorView(about.AdditionalContributors[i])
.padding(.vertical, 2)
.listRowInsets(EdgeInsets(top: 0, leading: 12, bottom: 0, trailing: 12))
}
}
}
Section("Thanks") {
GroupBox {
Text(about.ThanksMarkdown)
.multilineTextAlignment(.center)
.font(.callout)
.padding(.vertical, 2)
}
}
Spacer()
.frame(height: 12)
}
.padding(.horizontal)
.frame(width: 400, height: 400)
}
func contributorView(_ appCredit: AboutData.Contributor) -> some View {
HStack {
Text(appCredit.name)
Spacer()
if let role = appCredit.role {
Text(role)
.foregroundColor(.secondary)
}
Image(systemName: "info.circle")
.foregroundColor(.secondary)
}
.onTapGesture {
guard let url = appCredit.url else { return }
if let _ = URL(string: url) {
Browser.open(url, inBackground: false)
}
}
}
}
@available(macOS 12, *)
struct CreditsNetNewsWireView_Previews: PreviewProvider {
static var previews: some View {
CreditsNetNewsWireView()
}
}

View File

@@ -30,11 +30,10 @@ final class DetailContainerView: NSView, NSTextFinderBarContainer {
contentView.translatesAutoresizingMaskIntoConstraints = false
addSubview(contentView, positioned: .below, relativeTo: detailStatusBarView)
// Constrain the content view to fill the available space on all sides except the top, which we'll constrain to the find bar
var constraints = constraintsToMakeSubViewFullSize(contentView).filter { $0.firstAttribute != .top }
// Constrain the content view to fill the available space on all sides
var constraints = constraintsToMakeSubViewFullSize(contentView)
constraints.append(findBarContainerView.topAnchor.constraint(equalTo: self.safeAreaLayoutGuide.topAnchor))
constraints.append(findBarContainerView.bottomAnchor.constraint(equalTo: contentView.topAnchor))
NSLayoutConstraint.activate(constraints)
contentViewConstraints = constraints
}

View File

@@ -117,7 +117,7 @@ private extension NSUserInterfaceItemIdentifier {
private extension DetailWebView {
static let menuItemIdentifiersToHide: [NSUserInterfaceItemIdentifier] = [.DetailMenuItemIdentifierReload, .DetailMenuItemIdentifierOpenLink]
static let menuItemIdentifiersToHide: [NSUserInterfaceItemIdentifier] = [.DetailMenuItemIdentifierReload]
static let menuItemIdentifierMatchStrings = ["newwindow", "download"]
func shouldHideMenuItem(_ menuItem: NSMenuItem) -> Bool {

View File

@@ -204,7 +204,15 @@ class MainWindowController : NSWindowController, NSUserInterfaceValidations {
public func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool {
if item.action == #selector(copyArticleURL(_:)) {
return canCopyArticleURL()
let canCopyArticleURL = canCopyArticleURL()
if let item = item as? NSMenuItem {
let format = NSLocalizedString("Copy Article URL", comment: "Copy Article URL");
item.title = String.localizedStringWithFormat(format, selectedArticles?.count ?? 0)
}
return canCopyArticleURL
}
if item.action == #selector(copyExternalURL(_:)) {
@@ -321,21 +329,21 @@ class MainWindowController : NSWindowController, NSUserInterfaceValidations {
}
@IBAction func copyArticleURL(_ sender: Any?) {
if let link = oneSelectedArticle?.preferredURL?.absoluteString {
URLPasteboardWriter.write(urlString: link, to: .general)
if let currentLinks {
URLPasteboardWriter.write(urlStrings: currentLinks, alertingIn: window)
}
}
@IBAction func copyExternalURL(_ sender: Any?) {
if let link = oneSelectedArticle?.externalLink {
URLPasteboardWriter.write(urlString: link, to: .general)
if let links = selectedArticles?.compactMap({ $0.externalLink }) {
URLPasteboardWriter.write(urlStrings: links, to: .general)
}
}
@IBAction func openArticleInBrowser(_ sender: Any?) {
if let link = currentLink {
Browser.open(link, invertPreference: NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false)
}
guard let selectedArticles else { return }
let urlStrings = selectedArticles.compactMap { $0.preferredLink }
Browser.open(urlStrings, fromWindow: window, invertPreference: NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false)
}
@IBAction func openInBrowser(_ sender: Any?) {
@@ -529,16 +537,17 @@ class MainWindowController : NSWindowController, NSUserInterfaceValidations {
assertionFailure("Expected toolbarShowShareMenu to be called only by the Share item in the toolbar.")
return
}
guard let view = shareToolbarItem.view else {
// TODO: handle menu form representation
return
}
let sortedArticles = selectedArticles.sortedByDate(.orderedAscending)
let items = sortedArticles.map { ArticlePasteboardWriter(article: $0) }
let sharingServicePicker = NSSharingServicePicker(items: items)
sharingServicePicker.delegate = sharingServicePickerDelegate
sharingServicePicker.show(relativeTo: view.bounds, of: view, preferredEdge: .minY)
if let view = shareToolbarItem.view, view.window != nil {
sharingServicePicker.show(relativeTo: view.bounds, of: view, preferredEdge: .minY)
} else if let view = window?.contentView {
sharingServicePicker.show(relativeTo: CGRect(x: view.frame.width / 2.0, y: view.frame.height - 4, width: 1, height: 1), of: view, preferredEdge: .minY)
}
}
@IBAction func moveFocusToSearchField(_ sender: Any?) {
@@ -628,6 +637,10 @@ extension MainWindowController: NSWindowDelegate {
extension MainWindowController: SidebarDelegate {
var directlyMarkedAsUnreadArticles: Set<Article>? {
return timelineContainerViewController?.currentTimelineViewController?.directlyMarkedAsUnreadArticles
}
func sidebarSelectionDidChange(_: SidebarViewController, selectedObjects: [AnyObject]?) {
// Dont update the timeline if it already has those objects.
let representedObjectsAreTheSame = timelineContainerViewController?.regularTimelineViewControllerHasRepresentedObjects(selectedObjects) ?? false
@@ -666,6 +679,9 @@ extension MainWindowController: TimelineContainerViewControllerDelegate {
articleExtractor = nil
isShowingExtractedArticle = false
makeToolbarValidate()
if #available(macOS 13.0, *) { } else {
updateShareToolbarItemMenu()
}
let detailState: DetailState
if let articles = articles {
@@ -894,11 +910,23 @@ extension MainWindowController: NSToolbarDelegate {
button.action = #selector(toggleArticleExtractor(_:))
button.rightClickAction = #selector(showArticleExtractorMenu(_:))
toolbarItem.view = button
toolbarItem.menuFormRepresentation = NSMenuItem(title: description, action: #selector(toggleArticleExtractor(_:)), keyEquivalent: "")
return toolbarItem
case .share:
let title = NSLocalizedString("Share", comment: "Share")
return buildToolbarButton(.share, title, AppAssets.shareImage, "toolbarShowShareMenu:")
let image = AppAssets.shareImage
if #available(macOS 13.0, *) {
// `item.view` is required for properly positioning the sharing picker.
return buildToolbarButton(.share, title, image, "toolbarShowShareMenu:", usesCustomButtonView: true)
} else {
let item = NSMenuToolbarItem(itemIdentifier: .share)
item.image = image
item.toolTip = title
item.label = title
item.showsIndicator = false
return item
}
case .openInBrowser:
let title = NSLocalizedString("Open in Browser", comment: "Open in Browser")
@@ -1043,7 +1071,11 @@ private extension MainWindowController {
}
var currentLink: String? {
return oneSelectedArticle?.preferredLink
return selectedArticles?.first { $0.preferredLink != nil }?.preferredLink
}
var currentLinks: [String?]? {
return selectedArticles?.map { $0.preferredLink }
}
// MARK: - State Restoration
@@ -1081,7 +1113,11 @@ private extension MainWindowController {
// MARK: - Command Validation
func canCopyArticleURL() -> Bool {
return currentLink != nil
if let currentLinks, currentLinks.count != 0 {
return true
}
return false
}
func canCopyExternalURL() -> Bool {
@@ -1130,16 +1166,13 @@ private extension MainWindowController {
if let toolbarItem = item as? NSToolbarItem {
toolbarItem.toolTip = commandName
toolbarItem.image = markingRead ? AppAssets.readClosedImage : AppAssets.readOpenImage
}
if let menuItem = item as? NSMenuItem {
menuItem.title = commandName
}
if let toolbarItem = item as? NSToolbarItem, let button = toolbarItem.view as? NSButton {
button.image = markingRead ? AppAssets.readClosedImage : AppAssets.readOpenImage
}
return result
}
@@ -1220,16 +1253,13 @@ private extension MainWindowController {
if let toolbarItem = item as? NSToolbarItem {
toolbarItem.toolTip = commandName
toolbarItem.image = starring ? AppAssets.starOpenImage : AppAssets.starClosedImage
}
if let menuItem = item as? NSMenuItem {
menuItem.title = commandName
}
if let toolbarItem = item as? NSToolbarItem, let button = toolbarItem.view as? NSButton {
button.image = starring ? AppAssets.starOpenImage : AppAssets.starClosedImage
}
return result
}
@@ -1252,24 +1282,24 @@ private extension MainWindowController {
guard let isReadFiltered = timelineContainerViewController?.isReadFiltered else {
(item as? NSMenuItem)?.title = hideCommand
if let toolbarItem = item as? NSToolbarItem, let button = toolbarItem.view as? NSButton {
if let toolbarItem = item as? NSToolbarItem {
toolbarItem.toolTip = hideCommand
button.image = AppAssets.filterInactive
toolbarItem.image = AppAssets.filterInactive
}
return false
}
if isReadFiltered {
(item as? NSMenuItem)?.title = showCommand
if let toolbarItem = item as? NSToolbarItem, let button = toolbarItem.view as? NSButton {
if let toolbarItem = item as? NSToolbarItem {
toolbarItem.toolTip = showCommand
button.image = AppAssets.filterActive
toolbarItem.image = AppAssets.filterActive
}
} else {
(item as? NSMenuItem)?.title = hideCommand
if let toolbarItem = item as? NSToolbarItem, let button = toolbarItem.view as? NSButton {
if let toolbarItem = item as? NSToolbarItem {
toolbarItem.toolTip = hideCommand
button.image = AppAssets.filterInactive
toolbarItem.image = AppAssets.filterInactive
}
}
@@ -1386,19 +1416,26 @@ private extension MainWindowController {
}
}
func buildToolbarButton(_ itemIdentifier: NSToolbarItem.Identifier, _ title: String, _ image: NSImage, _ selector: String) -> NSToolbarItem {
func buildToolbarButton(_ itemIdentifier: NSToolbarItem.Identifier, _ title: String, _ image: NSImage, _ selector: String, usesCustomButtonView: Bool = false) -> NSToolbarItem {
let toolbarItem = RSToolbarItem(itemIdentifier: itemIdentifier)
toolbarItem.autovalidates = true
let button = NSButton()
button.bezelStyle = .texturedRounded
button.image = image
button.imageScaling = .scaleProportionallyDown
button.action = Selector((selector))
toolbarItem.view = button
toolbarItem.toolTip = title
toolbarItem.label = title
if usesCustomButtonView {
let button = NSButton()
button.bezelStyle = .texturedRounded
button.image = image
button.imageScaling = .scaleProportionallyDown
button.action = Selector((selector))
toolbarItem.view = button
toolbarItem.menuFormRepresentation = NSMenuItem(title: title, action: Selector((selector)), keyEquivalent: "")
} else {
toolbarItem.image = image
toolbarItem.isBordered = true
toolbarItem.action = Selector((selector))
}
return toolbarItem
}
@@ -1434,7 +1471,7 @@ private extension MainWindowController {
let defaultThemeItem = NSMenuItem()
defaultThemeItem.title = ArticleTheme.defaultTheme.name
defaultThemeItem.action = #selector(selectArticleTheme(_:))
defaultThemeItem.state = defaultThemeItem.title == ArticleThemesManager.shared.currentThemeName ? .on : .off
defaultThemeItem.state = defaultThemeItem.title == ArticleThemesManager.shared.currentTheme.name ? .on : .off
articleThemeMenu.addItem(defaultThemeItem)
articleThemeMenu.addItem(NSMenuItem.separator())
@@ -1443,7 +1480,7 @@ private extension MainWindowController {
let themeItem = NSMenuItem()
themeItem.title = themeName
themeItem.action = #selector(selectArticleTheme(_:))
themeItem.state = themeItem.title == ArticleThemesManager.shared.currentThemeName ? .on : .off
themeItem.state = themeItem.title == ArticleThemesManager.shared.currentTheme.name ? .on : .off
articleThemeMenu.addItem(themeItem)
}
@@ -1451,5 +1488,17 @@ private extension MainWindowController {
articleThemePopUpButton?.menu = articleThemeMenu
}
func updateShareToolbarItemMenu() {
guard let shareToolbarItem = shareToolbarItem as? NSMenuToolbarItem else {
return
}
if let shareMenu = shareMenu {
shareToolbarItem.isEnabled = true
shareToolbarItem.menu = shareMenu
} else {
shareToolbarItem.isEnabled = false
}
}
}

View File

@@ -31,8 +31,8 @@ final class SidebarStatusBarView: NSView {
}
override func awakeFromNib() {
progressIndicator.isHidden = true
progressIndicator.usesThreadedAnimation = true
progressLabel.isHidden = true
let progressLabelFontSize = progressLabel.font?.pointSize ?? 13.0
@@ -43,20 +43,18 @@ final class SidebarStatusBarView: NSView {
}
@objc func updateUI() {
guard let progress = progress else {
stopProgressIfNeeded()
return
}
updateProgressIndicator(progress)
updateProgressLabel(progress)
progressLabel.stringValue = progress.label
}
// MARK: Notifications
@objc dynamic func progressDidChange(_ notification: Notification) {
progress = AccountManager.shared.combinedRefreshProgress
}
}
@@ -68,10 +66,10 @@ private extension SidebarStatusBarView {
static let animationDuration = 0.2
func stopProgressIfNeeded() {
if !isAnimatingProgress {
return
}
isAnimatingProgress = false
self.progressIndicator.stopAnimation(self)
progressIndicator.isHidden = true
@@ -88,10 +86,10 @@ private extension SidebarStatusBarView {
}
func startProgressIfNeeded() {
if isAnimatingProgress {
return
}
isAnimatingProgress = true
progressIndicator.isHidden = false
progressLabel.isHidden = false
@@ -108,12 +106,16 @@ private extension SidebarStatusBarView {
}
func updateProgressIndicator(_ progress: CombinedRefreshProgress) {
if progress.isComplete {
stopProgressIfNeeded()
return
}
if progressIndicator.isIndeterminate != progress.isIndeterminate {
stopProgressIfNeeded()
progressIndicator.isIndeterminate = progress.isIndeterminate
}
startProgressIfNeeded()
let maxValue = Double(progress.numberOfTasks)
@@ -127,16 +129,4 @@ private extension SidebarStatusBarView {
}
}
func updateProgressLabel(_ progress: CombinedRefreshProgress) {
if progress.isComplete {
progressLabel.stringValue = ""
return
}
let formatString = NSLocalizedString("%@ of %@", comment: "Status bar progress")
let s = NSString(format: formatString as NSString, NSNumber(value: progress.numberCompleted), NSNumber(value: progress.numberOfTasks))
progressLabel.stringValue = s as String
}
}

View File

@@ -69,8 +69,16 @@ extension SidebarViewController {
return
}
let articles = unreadArticles(for: objects)
guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: Array(articles), markingRead: true, undoManager: undoManager) else {
var markableArticles = unreadArticles(for: objects)
if let directlyMarkedAsUnreadArticles = delegate?.directlyMarkedAsUnreadArticles {
markableArticles = markableArticles.subtracting(directlyMarkedAsUnreadArticles)
}
guard let undoManager = undoManager,
let markReadCommand = MarkStatusCommand(initialArticles: markableArticles,
markingRead: true,
directlyMarked: false,
undoManager: undoManager) else {
return
}
runCommand(markReadCommand)

View File

@@ -17,6 +17,7 @@ extension Notification.Name {
}
protocol SidebarDelegate: AnyObject {
var directlyMarkedAsUnreadArticles: Set<Article>? { get }
func sidebarSelectionDidChange(_: SidebarViewController, selectedObjects: [AnyObject]?)
func unreadCount(for: AnyObject) -> Int
func sidebarInvalidatedRestorationState(_: SidebarViewController)
@@ -256,7 +257,11 @@ protocol SidebarDelegate: AnyObject {
return
}
if AppDefaults.shared.feedDoubleClickMarkAsRead, let articles = try? singleSelectedWebFeed?.fetchUnreadArticles() {
if let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: Array(articles), markingRead: true, undoManager: undoManager) {
if let undoManager = undoManager,
let markReadCommand = MarkStatusCommand(initialArticles: Array(articles),
markingRead: true,
directlyMarked: false,
undoManager: undoManager) {
runCommand(markReadCommand)
}
}

View File

@@ -39,12 +39,12 @@ extension TimelineViewController {
@objc func markArticlesReadFromContextualMenu(_ sender: Any?) {
guard let articles = articles(from: sender) else { return }
markArticles(articles, read: true)
markArticles(articles, read: true, directlyMarked: true)
}
@objc func markArticlesUnreadFromContextualMenu(_ sender: Any?) {
guard let articles = articles(from: sender) else { return }
markArticles(articles, read: false)
markArticles(articles, read: false, directlyMarked: true)
}
@objc func markAboveArticlesReadFromContextualMenu(_ sender: Any?) {
@@ -59,14 +59,14 @@ extension TimelineViewController {
@objc func markArticlesStarredFromContextualMenu(_ sender: Any?) {
guard let articles = articles(from: sender) else { return }
markArticles(articles, starred: true)
markArticles(articles, starred: true, directlyMarked: true)
}
@objc func markArticlesUnstarredFromContextualMenu(_ sender: Any?) {
guard let articles = articles(from: sender) else {
return
}
markArticles(articles, starred: false)
markArticles(articles, starred: false, directlyMarked: true)
}
@objc func selectFeedInSidebarFromContextualMenu(_ sender: Any?) {
@@ -81,7 +81,11 @@ extension TimelineViewController {
return
}
guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: feedArticles, markingRead: true, undoManager: undoManager) else {
guard let undoManager = undoManager,
let markReadCommand = MarkStatusCommand(initialArticles: feedArticles,
markingRead: true,
directlyMarked: false,
undoManager: undoManager) else {
return
}
@@ -89,18 +93,19 @@ extension TimelineViewController {
}
@objc func openInBrowserFromContextualMenu(_ sender: Any?) {
guard let menuItem = sender as? NSMenuItem, let urlString = menuItem.representedObject as? String else {
guard let menuItem = sender as? NSMenuItem, let urlStrings = menuItem.representedObject as? [String] else {
return
}
Browser.open(urlString, inBackground: false)
Browser.open(urlStrings, fromWindow: self.view.window, invertPreference: NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false)
}
@objc func copyURLFromContextualMenu(_ sender: Any?) {
guard let menuItem = sender as? NSMenuItem, let urlString = menuItem.representedObject as? String else {
guard let menuItem = sender as? NSMenuItem, let urlStrings = menuItem.representedObject as? [String?] else {
return
}
URLPasteboardWriter.write(urlString: urlString, to: .general)
URLPasteboardWriter.write(urlStrings: urlStrings, alertingIn: self.view.window)
}
@objc func performShareServiceFromContextualMenu(_ sender: Any?) {
@@ -114,16 +119,21 @@ extension TimelineViewController {
private extension TimelineViewController {
func markArticles(_ articles: [Article], read: Bool) {
markArticles(articles, statusKey: .read, flag: read)
func markArticles(_ articles: [Article], read: Bool, directlyMarked: Bool) {
markArticles(articles, statusKey: .read, flag: read, directlyMarked: directlyMarked)
}
func markArticles(_ articles: [Article], starred: Bool) {
markArticles(articles, statusKey: .starred, flag: starred)
func markArticles(_ articles: [Article], starred: Bool, directlyMarked: Bool) {
markArticles(articles, statusKey: .starred, flag: starred, directlyMarked: directlyMarked)
}
func markArticles(_ articles: [Article], statusKey: ArticleStatus.Key, flag: Bool) {
guard let undoManager = undoManager, let markStatusCommand = MarkStatusCommand(initialArticles: articles, statusKey: statusKey, flag: flag, undoManager: undoManager) else {
func markArticles(_ articles: [Article], statusKey: ArticleStatus.Key, flag: Bool, directlyMarked: Bool) {
guard let undoManager = undoManager,
let markStatusCommand = MarkStatusCommand(initialArticles: articles,
statusKey: statusKey,
flag: flag,
directlyMarked: directlyMarked,
undoManager: undoManager) else {
return
}
@@ -176,14 +186,19 @@ private extension TimelineViewController {
menu.addItem(markAllMenuItem)
}
}
if articles.count == 1, let link = articles.first!.preferredLink {
let links = articles.map { $0.preferredLink }
let compactLinks = links.compactMap { $0 }
if compactLinks.count > 0 {
menu.addSeparatorIfNeeded()
menu.addItem(openInBrowserMenuItem(link))
menu.addItem(openInBrowserMenuItem(compactLinks))
menu.addItem(openInBrowserReversedMenuItem(compactLinks))
menu.addSeparatorIfNeeded()
menu.addItem(copyArticleURLMenuItem(link))
if let externalLink = articles.first?.externalLink, externalLink != link {
menu.addItem(copyArticleURLsMenuItem(links))
if let externalLink = articles.first?.externalLink, externalLink != links.first {
menu.addItem(copyExternalURLMenuItem(externalLink))
}
}
@@ -274,13 +289,21 @@ private extension TimelineViewController {
return menuItem(menuText, #selector(markAllInFeedAsRead(_:)), articles)
}
func openInBrowserMenuItem(_ urlString: String) -> NSMenuItem {
func openInBrowserMenuItem(_ urlStrings: [String]) -> NSMenuItem {
return menuItem(NSLocalizedString("Open in Browser", comment: "Command"), #selector(openInBrowserFromContextualMenu(_:)), urlStrings)
}
return menuItem(NSLocalizedString("Open in Browser", comment: "Command"), #selector(openInBrowserFromContextualMenu(_:)), urlString)
func openInBrowserReversedMenuItem(_ urlStrings: [String]) -> NSMenuItem {
let item = menuItem(Browser.titleForOpenInBrowserInverted, #selector(openInBrowserFromContextualMenu(_:)), urlStrings)
item.keyEquivalentModifierMask = .shift
item.isAlternate = true
return item;
}
func copyArticleURLMenuItem(_ urlString: String) -> NSMenuItem {
return menuItem(NSLocalizedString("Copy Article URL", comment: "Command"), #selector(copyURLFromContextualMenu(_:)), urlString)
func copyArticleURLsMenuItem(_ urlStrings: [String?]) -> NSMenuItem {
let format = NSLocalizedString("Copy Article URL", comment: "Command")
let title = String.localizedStringWithFormat(format, urlStrings.count)
return menuItem(title, #selector(copyURLFromContextualMenu(_:)), urlStrings)
}
func copyExternalURLMenuItem(_ urlString: String) -> NSMenuItem {

View File

@@ -65,7 +65,6 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
if !representedObjectArraysAreEqual(oldValue, representedObjects) {
unreadCount = 0
selectionDidChange(nil)
if showsSearchResults {
fetchAndReplaceArticlesAsync()
} else {
@@ -75,6 +74,7 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
}
updateUnreadCount()
}
selectionDidChange(nil)
}
}
}
@@ -123,10 +123,13 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
showFeedNames = .feed
}
directlyMarkedAsUnreadArticles = Set<Article>()
articleRowMap = [String: [Int]]()
tableView.reloadData()
}
}
var directlyMarkedAsUnreadArticles = Set<Article>()
var unreadCount: Int = 0 {
didSet {
@@ -219,6 +222,8 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
NotificationCenter.default.addObserver(self, selector: #selector(accountsDidChange(_:)), name: .UserDidDeleteAccount, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(containerChildrenDidChange(_:)), name: .ChildrenDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(markStatusCommandDidDirectMarking(_:)), name: .MarkStatusCommandDidDirectMarking, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(markStatusCommandDidUndoDirectMarking(_:)), name: .MarkStatusCommandDidUndoDirectMarking, object: nil)
didRegisterForNotifications = true
}
}
@@ -230,7 +235,13 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
// MARK: - API
func markAllAsRead(completion: (() -> Void)? = nil) {
guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articles, markingRead: true, undoManager: undoManager, completion: completion) else {
let markableArticles = Set(articles).subtracting(directlyMarkedAsUnreadArticles)
guard let undoManager = undoManager,
let markReadCommand = MarkStatusCommand(initialArticles: markableArticles,
markingRead: true,
directlyMarked: false,
undoManager: undoManager,
completion: completion) else {
return
}
runCommand(markReadCommand)
@@ -315,9 +326,8 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
// MARK: - Actions
@objc func openArticleInBrowser(_ sender: Any?) {
if let link = oneSelectedArticle?.preferredLink {
Browser.open(link, invertPreference: NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false)
}
let urlStrings = selectedArticles.compactMap { $0.preferredLink }
Browser.open(urlStrings, fromWindow: self.view.window, invertPreference: NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false)
}
@IBAction func toggleStatusOfSelectedArticles(_ sender: Any?) {
@@ -337,14 +347,22 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
}
@IBAction func markSelectedArticlesAsRead(_ sender: Any?) {
guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: selectedArticles, markingRead: true, undoManager: undoManager) else {
guard let undoManager = undoManager,
let markReadCommand = MarkStatusCommand(initialArticles: selectedArticles,
markingRead: true,
directlyMarked: true,
undoManager: undoManager) else {
return
}
runCommand(markReadCommand)
}
@IBAction func markSelectedArticlesAsUnread(_ sender: Any?) {
guard let undoManager = undoManager, let markUnreadCommand = MarkStatusCommand(initialArticles: selectedArticles, markingRead: false, undoManager: undoManager) else {
guard let undoManager = undoManager,
let markUnreadCommand = MarkStatusCommand(initialArticles: selectedArticles,
markingRead: false,
directlyMarked: true,
undoManager: undoManager) else {
return
}
runCommand(markUnreadCommand)
@@ -412,7 +430,11 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
return
}
guard let undoManager = undoManager, let markStarredCommand = MarkStatusCommand(initialArticles: selectedArticles, markingRead: markingRead, undoManager: undoManager) else {
guard let undoManager = undoManager,
let markStarredCommand = MarkStatusCommand(initialArticles: selectedArticles,
markingRead: markingRead,
directlyMarked: true,
undoManager: undoManager) else {
return
}
@@ -435,7 +457,11 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
return
}
guard let undoManager = undoManager, let markStarredCommand = MarkStatusCommand(initialArticles: selectedArticles, markingStarred: starring, undoManager: undoManager) else {
guard let undoManager = undoManager,
let markStarredCommand = MarkStatusCommand(initialArticles: selectedArticles,
markingStarred: starring,
directlyMarked: true,
undoManager: undoManager) else {
return
}
runCommand(markStarredCommand)
@@ -502,7 +528,12 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
return
}
guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articlesToMark, markingRead: true, undoManager: undoManager) else {
let markableArticles = Set(articlesToMark).subtracting(directlyMarkedAsUnreadArticles)
guard let undoManager = undoManager,
let markReadCommand = MarkStatusCommand(initialArticles: markableArticles,
markingRead: true,
directlyMarked: false,
undoManager: undoManager) else {
return
}
runCommand(markReadCommand)
@@ -510,9 +541,16 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
func markAboveArticlesRead(_ selectedArticles: [Article]) {
guard let first = selectedArticles.first else { return }
let articlesToMark = articles.articlesAbove(article: first)
guard !articlesToMark.isEmpty else { return }
guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articlesToMark, markingRead: true, undoManager: undoManager) else {
let markableArticles = Set(articlesToMark).subtracting(directlyMarkedAsUnreadArticles)
guard let undoManager = undoManager,
let markReadCommand = MarkStatusCommand(initialArticles: markableArticles,
markingRead: true,
directlyMarked: false,
undoManager: undoManager) else {
return
}
runCommand(markReadCommand)
@@ -520,9 +558,16 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
func markBelowArticlesRead(_ selectedArticles: [Article]) {
guard let last = selectedArticles.last else { return }
let articlesToMark = articles.articlesBelow(article: last)
guard !articlesToMark.isEmpty else { return }
guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articlesToMark, markingRead: true, undoManager: undoManager) else {
let markableArticles = Set(articlesToMark).subtracting(directlyMarkedAsUnreadArticles)
guard let undoManager = undoManager,
let markReadCommand = MarkStatusCommand(initialArticles: markableArticles,
markingRead: true,
directlyMarked: false,
undoManager: undoManager) else {
return
}
runCommand(markReadCommand)
@@ -666,6 +711,28 @@ final class TimelineViewController: NSViewController, UndoableCommandRunner, Unr
self.groupByFeed = AppDefaults.shared.timelineGroupByFeed
}
@objc func markStatusCommandDidDirectMarking(_ note: Notification) {
guard let userInfo = note.userInfo,
let articles = userInfo[Account.UserInfoKey.articles] as? Set<Article>,
let statusKey = userInfo[Account.UserInfoKey.statusKey] as? ArticleStatus.Key,
let flag = userInfo[Account.UserInfoKey.statusFlag] as? Bool else { return }
if statusKey == .read && flag == false {
directlyMarkedAsUnreadArticles.formUnion(articles)
}
}
@objc func markStatusCommandDidUndoDirectMarking(_ note: Notification) {
guard let userInfo = note.userInfo,
let articles = userInfo[Account.UserInfoKey.articles] as? Set<Article>,
let statusKey = userInfo[Account.UserInfoKey.statusKey] as? ArticleStatus.Key,
let flag = userInfo[Account.UserInfoKey.statusFlag] as? Bool else { return }
if statusKey == .read && flag == false {
directlyMarkedAsUnreadArticles.subtract(articles)
}
}
// MARK: - Reloading Data
private func cellForRowView(_ rowView: NSView) -> NSView? {
@@ -779,8 +846,7 @@ extension TimelineViewController: NSUserInterfaceValidations {
item.title = Browser.titleForOpenInBrowserInverted
}
let currentLink = oneSelectedArticle?.preferredLink
return currentLink != nil
return selectedArticles.first { $0.preferredLink != nil } != nil
}
if item.action == #selector(copy(_:)) {
@@ -901,14 +967,22 @@ extension TimelineViewController: NSTableViewDelegate {
}
private func toggleArticleRead(_ article: Article) {
guard let undoManager = undoManager, let markUnreadCommand = MarkStatusCommand(initialArticles: [article], markingRead: !article.status.read, undoManager: undoManager) else {
guard let undoManager = undoManager,
let markUnreadCommand = MarkStatusCommand(initialArticles: [article],
markingRead: !article.status.read,
directlyMarked: true,
undoManager: undoManager) else {
return
}
self.runCommand(markUnreadCommand)
}
private func toggleArticleStarred(_ article: Article) {
guard let undoManager = undoManager, let markUnreadCommand = MarkStatusCommand(initialArticles: [article], markingStarred: !article.status.starred, undoManager: undoManager) else {
guard let undoManager = undoManager,
let markUnreadCommand = MarkStatusCommand(initialArticles: [article],
markingStarred: !article.status.starred,
directlyMarked: true,
undoManager: undoManager) else {
return
}
self.runCommand(markUnreadCommand)

View File

@@ -0,0 +1,36 @@
//
// URLPasteboardWriter+NetNewsWire.swift
// NetNewsWire
//
// Created by Nate Weaver on 2022-10-10.
// Copyright © 2022 Ranchero Software. All rights reserved.
//
import RSCore
extension URLPasteboardWriter {
/// Copy URL strings, alerting the user the first time the array of URL strings contains `nil`.
/// - Parameters:
/// - urlStrings: The URL strings to copy.
/// - pasteboard: The pastebaord to copy to.
/// - window: The window to use as a sheet parent for the alert. If `nil`, will run the alert modally.
static func write(urlStrings: [String?], to pasteboard: NSPasteboard = .general, alertingIn window: NSWindow?) {
URLPasteboardWriter.write(urlStrings: urlStrings.compactMap { $0 }, to: pasteboard)
if urlStrings.contains(nil), !AppDefaults.shared.hasSeenNotAllArticlesHaveURLsAlert {
let alert = NSAlert()
alert.messageText = NSLocalizedString("Some articles dont have links, so they weren't copied.", comment: "\"Some articles have no links\" copy alert message text")
alert.informativeText = NSLocalizedString("You won't see this message again.", comment: "You won't see this message again")
if let window {
alert.beginSheetModal(for: window)
} else {
alert.runModal() // this should never happen
}
AppDefaults.shared.hasSeenNotAllArticlesHaveURLsAlert = true
}
}
}