From 323b160b7f32fe1ea6d3f1244b324f36bdd32276 Mon Sep 17 00:00:00 2001 From: Maurice Parker Date: Mon, 25 Nov 2019 19:43:43 -0600 Subject: [PATCH] Add context menu for Fullscreen mode actions. Issue #1344 --- NetNewsWire.xcodeproj/project.pbxproj | 14 +- Shared/Data/ArticleUtilities.swift | 44 ++++++ iOS/AppAssets.swift | 20 +++ iOS/Article/ArticleViewController.swift | 144 ++++++++++++++++-- .../ContextMenuPreviewViewController.swift | 50 ++++++ iOS/Base.lproj/Main.storyboard | 87 +++++++++++ .../Contents.json | 3 +- .../articleExtractorOn.imageset/Contents.json | 3 +- 8 files changed, 344 insertions(+), 21 deletions(-) create mode 100644 iOS/Article/ContextMenuPreviewViewController.swift diff --git a/NetNewsWire.xcodeproj/project.pbxproj b/NetNewsWire.xcodeproj/project.pbxproj index 27c355f46..a9acae6f3 100644 --- a/NetNewsWire.xcodeproj/project.pbxproj +++ b/NetNewsWire.xcodeproj/project.pbxproj @@ -151,6 +151,7 @@ 51B62E68233186730085F949 /* IconView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51B62E67233186730085F949 /* IconView.swift */; }; 51BB7C272335A8E5008E8144 /* ArticleActivityItemSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51BB7C262335A8E5008E8144 /* ArticleActivityItemSource.swift */; }; 51BB7C312335ACDE008E8144 /* page.html in Resources */ = {isa = PBXBuildFile; fileRef = 51BB7C302335ACDE008E8144 /* page.html */; }; + 51C266EA238C334800F53014 /* ContextMenuPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51C266E9238C334800F53014 /* ContextMenuPreviewViewController.swift */; }; 51C451A9226377C200C03939 /* ArticlesDatabase.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8407167F2262A61100344432 /* ArticlesDatabase.framework */; }; 51C451AA226377C200C03939 /* ArticlesDatabase.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 8407167F2262A61100344432 /* ArticlesDatabase.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 51C451B9226377C900C03939 /* Articles.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 840716732262A60F00344432 /* Articles.framework */; }; @@ -1321,6 +1322,7 @@ 51B62E67233186730085F949 /* IconView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconView.swift; sourceTree = ""; }; 51BB7C262335A8E5008E8144 /* ArticleActivityItemSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleActivityItemSource.swift; sourceTree = ""; }; 51BB7C302335ACDE008E8144 /* page.html */ = {isa = PBXFileReference; lastKnownFileType = text.html; path = page.html; sourceTree = ""; }; + 51C266E9238C334800F53014 /* ContextMenuPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuPreviewViewController.swift; sourceTree = ""; }; 51C4524E226506F400C03939 /* UIStoryboard-Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIStoryboard-Extensions.swift"; sourceTree = ""; }; 51C45250226506F400C03939 /* String-Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String-Extensions.swift"; sourceTree = ""; }; 51C45254226507D200C03939 /* AppAssets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppAssets.swift; sourceTree = ""; }; @@ -1948,6 +1950,7 @@ 51C4527E2265092C00C03939 /* ArticleViewController.swift */, 517630222336657E00E15FFF /* ArticleViewControllerWebViewProvider.swift */, 51102164233A7D6C0007A5F7 /* ArticleExtractorButton.swift */, + 51C266E9238C334800F53014 /* ContextMenuPreviewViewController.swift */, 5142192923522B5500E07E2C /* ImageViewController.swift */, 514219362352510100E07E2C /* ImageScrollView.swift */, 518651D9235621840078E021 /* ImageTransition.swift */, @@ -2963,7 +2966,7 @@ }; 513C5CE5232571C2003D4054 = { CreatedOnToolsVersion = 11.0; - DevelopmentTeam = 8EQFQ9RY84; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; }; 518B2ED12351B3DD00400001 = { @@ -2973,7 +2976,7 @@ TestTargetID = 840D617B2029031C009BC708; }; 6581C73220CED60000F4AD34 = { - DevelopmentTeam = 8EQFQ9RY84; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; }; 65ED3FA2235DEF6C0081F399 = { @@ -2986,7 +2989,7 @@ }; 840D617B2029031C009BC708 = { CreatedOnToolsVersion = 9.3; - DevelopmentTeam = 8EQFQ9RY84; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.BackgroundModes = { @@ -2996,7 +2999,7 @@ }; 849C645F1ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = 8EQFQ9RY84; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; SystemCapabilities = { com.apple.HardenedRuntime = { @@ -3006,7 +3009,7 @@ }; 849C64701ED37A5D003D8FC0 = { CreatedOnToolsVersion = 8.2.1; - DevelopmentTeam = 8EQFQ9RY84; + DevelopmentTeam = SHJK2V3AJG; ProvisioningStyle = Automatic; TestTargetID = 849C645F1ED37A5D003D8FC0; }; @@ -4027,6 +4030,7 @@ 51934CCE2310792F006127BE /* ActivityManager.swift in Sources */, 5108F6B72375E612001ABC45 /* CacheCleaner.swift in Sources */, 518651DA235621840078E021 /* ImageTransition.swift in Sources */, + 51C266EA238C334800F53014 /* ContextMenuPreviewViewController.swift in Sources */, 51627A6923861DED007B3B4B /* MasterFeedViewController+Drop.swift in Sources */, 514219372352510100E07E2C /* ImageScrollView.swift in Sources */, 516AE9B32371C372007DEEAA /* MasterFeedTableViewSectionHeaderLayout.swift in Sources */, diff --git a/Shared/Data/ArticleUtilities.swift b/Shared/Data/ArticleUtilities.swift index ffdfdb250..997cb0ded 100644 --- a/Shared/Data/ArticleUtilities.swift +++ b/Shared/Data/ArticleUtilities.swift @@ -92,6 +92,50 @@ extension Article { return FaviconGenerator.favicon(webFeed) } + + func byline() -> String { + guard let authors = authors ?? webFeed?.authors, !authors.isEmpty else { + return "" + } + + // If the author's name is the same as the feed, then we don't want to display it. + // This code assumes that multiple authors would never match the feed name so that + // if there feed owner has an article co-author all authors are given the byline. + if authors.count == 1, let author = authors.first { + if author.name == webFeed?.nameForDisplay { + return "" + } + } + + var byline = "" + var isFirstAuthor = true + + for author in authors { + if !isFirstAuthor { + byline += ", " + } + isFirstAuthor = false + + if let emailAddress = author.emailAddress, emailAddress.contains(" ") { + byline += emailAddress // probably name plus email address + } + else if let name = author.name, let emailAddress = author.emailAddress { + byline += "\(name) <\(emailAddress)>" + } + else if let name = author.name { + byline += name + } + else if let emailAddress = author.emailAddress { + byline += "<\(emailAddress)>" + } + else if let url = author.url { + byline += url + } + } + + return byline + } + } // MARK: Path diff --git a/iOS/AppAssets.swift b/iOS/AppAssets.swift index ed2048aa1..dcc9217a5 100644 --- a/iOS/AppAssets.swift +++ b/iOS/AppAssets.swift @@ -43,6 +43,10 @@ struct AppAssets { return UIImage(named: "articleExtractorOff")! }() + static var articleExtractorOffSmall: UIImage = { + return UIImage(systemName: "doc.plaintext")! + }() + static var articleExtractorOffTinted: UIImage = { let image = UIImage(named: "articleExtractorOff")! return image.maskWithColor(color: AppAssets.primaryAccentColor.cgColor)! @@ -52,6 +56,10 @@ struct AppAssets { return UIImage(named: "articleExtractorOn")! }() + static var articleExtractorOnSmall: UIImage = { + return UIImage(systemName: "doc.plaintext")! + }() + static var articleExtractorOnTinted: UIImage = { let image = UIImage(named: "articleExtractorOn")! return image.maskWithColor(color: AppAssets.primaryAccentColor.cgColor)! @@ -129,6 +137,18 @@ struct AppAssets { return UIImage(systemName: "ellipsis.circle")! }() + static var nextArticleImage: UIImage = { + return UIImage(systemName: "chevron.down")! + }() + + static var nextUnreadArticleImage: UIImage = { + return UIImage(systemName: "chevron.down.circle")! + }() + + static var prevArticleImage: UIImage = { + return UIImage(systemName: "chevron.up")! + }() + static var openInSidebarImage: UIImage = { return UIImage(systemName: "arrow.turn.down.left")! }() diff --git a/iOS/Article/ArticleViewController.swift b/iOS/Article/ArticleViewController.swift index ffbdb48e7..0d4773230 100644 --- a/iOS/Article/ArticleViewController.swift +++ b/iOS/Article/ArticleViewController.swift @@ -47,6 +47,10 @@ class ArticleViewController: UIViewController { }() private var webView: WKWebView! + private lazy var contextMenuInteraction = UIContextMenuInteraction(delegate: self) + private var isFullScreenAvailable: Bool { + return traitCollection.userInterfaceIdiom == .phone && coordinator.isRootSplitCollapsed + } private lazy var transition = ImageTransition(controller: self) private var clickedImageCompletion: (() -> Void)? @@ -93,6 +97,7 @@ class ArticleViewController: UIViewController { webView?.evaluateJavaScript("cancelImageLoad();") webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.imageWasClicked) webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.imageWasShown) + webView.removeInteraction(contextMenuInteraction) webView.removeFromSuperview() ArticleViewControllerWebViewProvider.shared.enqueueWebView(webView) webView = nil @@ -122,6 +127,7 @@ class ArticleViewController: UIViewController { self.webViewContainer.addChildAndPin(webView) webView.navigationDelegate = self webView.uiDelegate = self + self.configureContextMenuInteraction() webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasClicked) webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasShown) @@ -285,19 +291,8 @@ class ArticleViewController: UIViewController { coordinator.toggleStarredForCurrentArticle() } - @IBAction func openBrowser(_ sender: Any) { - coordinator.showBrowserForCurrentArticle() - } - @IBAction func showActivityDialog(_ sender: Any) { - guard let preferredLink = currentArticle?.preferredLink, let url = URL(string: preferredLink) else { - return - } - - let itemSource = ArticleActivityItemSource(url: url, subject: currentArticle!.title) - let activityViewController = UIActivityViewController(activityItems: [itemSource], applicationActivities: nil) - activityViewController.popoverPresentationController?.barButtonItem = actionBarButtonItem - present(activityViewController, animated: true) + showActivityDialog() } // MARK: Keyboard Shortcuts @@ -357,6 +352,39 @@ extension ArticleViewController: InteractiveNavigationControllerTappable { } } +// MARK: UIContextMenuInteractionDelegate + +extension ArticleViewController: UIContextMenuInteractionDelegate { + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? { + + return UIContextMenuConfiguration(identifier: nil, previewProvider: contextMenuPreviewProvider) { [weak self] suggestedActions in + guard let self = self else { return nil } + var actions = [UIAction]() + + if let action = self.prevArticleAction() { + actions.append(action) + } + if let action = self.nextArticleAction() { + actions.append(action) + } + actions.append(self.toggleReadAction()) + actions.append(self.toggleStarredAction()) + if let action = self.nextUnreadArticleAction() { + actions.append(action) + } + actions.append(self.toggleArticleExtractorAction()) + actions.append(self.shareAction()) + + return UIMenu(title: "", children: actions) + } + } + + func contextMenuInteraction(_ interaction: UIContextMenuInteraction, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { + coordinator.showBrowserForCurrentArticle() + } + +} + // MARK: WKNavigationDelegate extension ArticleViewController: WKNavigationDelegate { @@ -492,25 +520,113 @@ private extension ArticleViewController { } } + func showActivityDialog() { + guard let preferredLink = currentArticle?.preferredLink, let url = URL(string: preferredLink) else { + return + } + + let itemSource = ArticleActivityItemSource(url: url, subject: currentArticle!.title) + let activityViewController = UIActivityViewController(activityItems: [itemSource], applicationActivities: nil) + activityViewController.popoverPresentationController?.barButtonItem = actionBarButtonItem + present(activityViewController, animated: true) + } + func showBars() { - if traitCollection.userInterfaceIdiom == .phone && coordinator.isRootSplitCollapsed { + if isFullScreenAvailable { AppDefaults.articleFullscreenEnabled = false coordinator.showStatusBar() showNavigationViewConstraint.constant = 0 showToolbarViewConstraint.constant = 0 navigationController?.setNavigationBarHidden(false, animated: true) navigationController?.setToolbarHidden(false, animated: true) + configureContextMenuInteraction() } } func hideBars() { - if traitCollection.userInterfaceIdiom == .phone && coordinator.isRootSplitCollapsed { + if isFullScreenAvailable { AppDefaults.articleFullscreenEnabled = true coordinator.hideStatusBar() showNavigationViewConstraint.constant = 44.0 showToolbarViewConstraint.constant = 44.0 navigationController?.setNavigationBarHidden(true, animated: true) navigationController?.setToolbarHidden(true, animated: true) + configureContextMenuInteraction() + } + } + + func configureContextMenuInteraction() { + if isFullScreenAvailable { + if navigationController?.isNavigationBarHidden ?? false { + webView?.addInteraction(contextMenuInteraction) + } else { + webView?.removeInteraction(contextMenuInteraction) + } + } + + } + + func contextMenuPreviewProvider() -> UIViewController { + let previewProvider = UIStoryboard.main.instantiateController(ofType: ContextMenuPreviewViewController.self) + previewProvider.article = currentArticle + return previewProvider + } + + func prevArticleAction() -> UIAction? { + guard coordinator.isPrevArticleAvailable else { return nil } + let title = NSLocalizedString("Previous Article", comment: "Previous Article") + return UIAction(title: title, image: AppAssets.prevArticleImage) { [weak self] action in + self?.coordinator.selectPrevArticle() + } + } + + func nextArticleAction() -> UIAction? { + guard coordinator.isNextArticleAvailable else { return nil } + let title = NSLocalizedString("Next Article", comment: "Next Article") + return UIAction(title: title, image: AppAssets.nextArticleImage) { [weak self] action in + self?.coordinator.selectNextArticle() + } + } + + func toggleReadAction() -> UIAction { + let read = currentArticle?.status.read ?? false + let title = read ? NSLocalizedString("Mark as Unread", comment: "Mark as Unread") : NSLocalizedString("Mark as Read", comment: "Mark as Read") + let readImage = read ? AppAssets.circleClosedImage : AppAssets.circleOpenImage + return UIAction(title: title, image: readImage) { [weak self] action in + self?.coordinator.toggleReadForCurrentArticle() + } + } + + func toggleStarredAction() -> UIAction { + let starred = currentArticle?.status.starred ?? false + let title = starred ? NSLocalizedString("Mark as Unstarred", comment: "Mark as Unstarred") : NSLocalizedString("Mark as Starred", comment: "Mark as Starred") + let starredImage = starred ? AppAssets.starOpenImage : AppAssets.starClosedImage + return UIAction(title: title, image: starredImage) { [weak self] action in + self?.coordinator.toggleStarredForCurrentArticle() + } + } + + func nextUnreadArticleAction() -> UIAction? { + guard coordinator.isAnyUnreadAvailable else { return nil } + let title = NSLocalizedString("Next Unread Article", comment: "Next Unread Article") + return UIAction(title: title, image: AppAssets.nextUnreadArticleImage) { [weak self] action in + self?.coordinator.selectNextArticle() + } + } + + func toggleArticleExtractorAction() -> UIAction { + let extracted = articleExtractorButton.buttonState == .on + let title = extracted ? NSLocalizedString("Show Feed Article", comment: "Show Feed Article") : NSLocalizedString("Show Reader View", comment: "Show Reader View") + let extractorImage = extracted ? AppAssets.articleExtractorOffSmall : AppAssets.articleExtractorOnSmall + return UIAction(title: title, image: extractorImage) { [weak self] action in + self?.coordinator.toggleArticleExtractor() + } + } + + func shareAction() -> UIAction { + let title = NSLocalizedString("Share", comment: "Share") + return UIAction(title: title, image: AppAssets.shareImage) { [weak self] action in + self?.showActivityDialog() } } diff --git a/iOS/Article/ContextMenuPreviewViewController.swift b/iOS/Article/ContextMenuPreviewViewController.swift new file mode 100644 index 000000000..f3b67be0d --- /dev/null +++ b/iOS/Article/ContextMenuPreviewViewController.swift @@ -0,0 +1,50 @@ +// +// ContextMenuPreviewViewController.swift +// NetNewsWire-iOS +// +// Created by Maurice Parker on 11/25/19. +// Copyright © 2019 Ranchero Software. All rights reserved. +// + +import UIKit +import Articles + +class ContextMenuPreviewViewController: UIViewController { + + @IBOutlet weak var blogNameLabel: UILabel! + @IBOutlet weak var blogAuthorLabel: UILabel! + @IBOutlet weak var articleTitleLabel: UILabel! + @IBOutlet weak var dateTimeLabel: UILabel! + + var article: Article! + + override func viewDidLoad() { + super.viewDidLoad() + + blogNameLabel.text = article.webFeed?.nameForDisplay ?? "" + blogAuthorLabel.text = article.byline() + articleTitleLabel.text = article.title ?? "" + + let icon = IconView() + icon.iconImage = article.iconImage() + icon.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(icon) + + NSLayoutConstraint.activate([ + icon.widthAnchor.constraint(equalToConstant: 48), + icon.heightAnchor.constraint(equalToConstant: 48), + icon.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 8), + icon.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -20) + ]) + + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .long + dateFormatter.timeStyle = .medium + dateTimeLabel.text = dateFormatter.string(from: article.logicalDatePublished) + + view.setNeedsLayout() + view.layoutIfNeeded() + preferredContentSize = CGSize(width: view.bounds.width, height: dateTimeLabel.frame.maxY + 8) + } + +} diff --git a/iOS/Base.lproj/Main.storyboard b/iOS/Base.lproj/Main.storyboard index 276aee563..3047563b1 100644 --- a/iOS/Base.lproj/Main.storyboard +++ b/iOS/Base.lproj/Main.storyboard @@ -299,6 +299,93 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iOS/Resources/Assets.xcassets/articleExtractorOff.imageset/Contents.json b/iOS/Resources/Assets.xcassets/articleExtractorOff.imageset/Contents.json index da8081b60..f0fe3801e 100644 --- a/iOS/Resources/Assets.xcassets/articleExtractorOff.imageset/Contents.json +++ b/iOS/Resources/Assets.xcassets/articleExtractorOff.imageset/Contents.json @@ -10,6 +10,7 @@ "author" : "xcode" }, "properties" : { - "template-rendering-intent" : "template" + "template-rendering-intent" : "template", + "preserves-vector-representation" : true } } \ No newline at end of file diff --git a/iOS/Resources/Assets.xcassets/articleExtractorOn.imageset/Contents.json b/iOS/Resources/Assets.xcassets/articleExtractorOn.imageset/Contents.json index 2617bbdfe..3a6b6cd07 100644 --- a/iOS/Resources/Assets.xcassets/articleExtractorOn.imageset/Contents.json +++ b/iOS/Resources/Assets.xcassets/articleExtractorOn.imageset/Contents.json @@ -10,6 +10,7 @@ "author" : "xcode" }, "properties" : { - "template-rendering-intent" : "template" + "template-rendering-intent" : "template", + "preserves-vector-representation" : true } } \ No newline at end of file