Merge branch 'ios-candidate'

This commit is contained in:
Maurice Parker
2020-02-21 17:02:14 -08:00
232 changed files with 7116 additions and 4736 deletions

View File

@@ -31,10 +31,11 @@ class FeedbinAccountViewController: UITableViewController {
if let account = account, let credentials = try? account.retrieveCredentials(type: .basic) {
actionButton.setTitle(NSLocalizedString("Update Credentials", comment: "Update Credentials"), for: .normal)
actionButton.isEnabled = true
emailTextField.text = credentials.username
passwordTextField.text = credentials.secret
} else {
actionButton.setTitle(NSLocalizedString("Add Account", comment: "Update Credentials"), for: .normal)
actionButton.setTitle(NSLocalizedString("Add Account", comment: "Add Account"), for: .normal)
}
NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: emailTextField)

24
iOS/AccountMigrator.swift Normal file
View File

@@ -0,0 +1,24 @@
//
// AccountMigrator.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 2/9/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
struct AccountMigrator {
static func migrate() {
let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as! String
let containerAccountsURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)
let containerAccountsFolder = containerAccountsURL!.appendingPathComponent("Accounts")
let documentAccountURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let documentAccountsFolder = documentAccountURL.appendingPathComponent("Accounts")
try? FileManager.default.moveItem(at: containerAccountsFolder, to: documentAccountsFolder)
}
}

View File

@@ -68,13 +68,13 @@
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Folder" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="RtT-rR-5LA">
<rect key="frame" x="18.999999999999996" y="11.666666666666664" width="48.666666666666657" height="21"/>
<rect key="frame" x="19.999999999999996" y="11.666666666666664" width="48.666666666666657" height="21"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Label" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="htg-Nn-3xi">
<rect key="frame" x="282" y="11.666666666666664" width="42" height="21"/>
<rect key="frame" x="281" y="11.666666666666664" width="42" height="21"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
@@ -83,8 +83,8 @@
<constraints>
<constraint firstItem="htg-Nn-3xi" firstAttribute="centerY" secondItem="ZbC-Z6-dtq" secondAttribute="centerY" id="Z4n-Wi-s5H"/>
<constraint firstItem="htg-Nn-3xi" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="RtT-rR-5LA" secondAttribute="trailing" constant="8" symbolic="YES" id="grG-sv-OgE"/>
<constraint firstItem="htg-Nn-3xi" firstAttribute="trailing" secondItem="ZbC-Z6-dtq" secondAttribute="trailingMargin" constant="-4" id="izx-09-DWe"/>
<constraint firstItem="RtT-rR-5LA" firstAttribute="leading" secondItem="ZbC-Z6-dtq" secondAttribute="leadingMargin" constant="4" id="q2d-LR-YGh"/>
<constraint firstAttribute="trailing" secondItem="htg-Nn-3xi" secondAttribute="trailing" constant="20" symbolic="YES" id="izx-09-DWe"/>
<constraint firstItem="RtT-rR-5LA" firstAttribute="leading" secondItem="ZbC-Z6-dtq" secondAttribute="leading" constant="20" symbolic="YES" id="q2d-LR-YGh"/>
<constraint firstItem="RtT-rR-5LA" firstAttribute="centerY" secondItem="ZbC-Z6-dtq" secondAttribute="centerY" id="wHc-B0-lLE"/>
</constraints>
</tableViewCellContentView>

View File

@@ -20,7 +20,22 @@ class AddFolderViewController: UITableViewController, AddContainerViewController
return accounts.count > 1
}
private var accounts: [Account]!
private var accounts: [Account]! {
didSet {
if let predefinedAccount = accounts.first(where: { $0.accountID == AppDefaults.addFolderAccountID }) {
selectedAccount = predefinedAccount
} else {
selectedAccount = accounts[0]
}
}
}
private var selectedAccount: Account! {
didSet {
guard selectedAccount != oldValue else { return }
accountLabel.text = selectedAccount.flatMap { ($0 as DisplayNameProvider).nameForDisplay }
}
}
weak var delegate: AddContainerViewControllerChildDelegate?
@@ -32,13 +47,11 @@ class AddFolderViewController: UITableViewController, AddContainerViewController
nameTextField.delegate = self
accountLabel.text = (accounts[0] as DisplayNameProvider).nameForDisplay
if shouldDisplayPicker {
accountPickerView.dataSource = self
accountPickerView.delegate = self
if let index = accounts.firstIndex(where: { $0.accountID == AppDefaults.addFolderAccountID }) {
if let index = accounts.firstIndex(of: selectedAccount) {
accountPickerView.selectRow(index, inComponent: 0, animated: false)
}
@@ -52,21 +65,26 @@ class AddFolderViewController: UITableViewController, AddContainerViewController
NotificationCenter.default.addObserver(self, selector: #selector(textDidChange(_:)), name: UITextField.textDidChangeNotification, object: nameTextField)
}
private func didSelect(_ account: Account) {
AppDefaults.addFolderAccountID = account.accountID
selectedAccount = account
}
func cancel() {
delegate?.processingDidEnd()
}
func add() {
let account = accounts[accountPickerView.selectedRow(inComponent: 0)]
if let folderName = nameTextField.text {
account.addFolder(folderName) { result in
switch result {
case .success:
self.delegate?.processingDidEnd()
case .failure(let error):
self.presentError(error)
}
guard let folderName = nameTextField.text else {
return
}
selectedAccount.addFolder(folderName) { result in
switch result {
case .success:
self.delegate?.processingDidEnd()
case .failure(let error):
self.presentError(error)
}
}
}
@@ -100,8 +118,7 @@ extension AddFolderViewController: UIPickerViewDataSource, UIPickerViewDelegate
}
func pickerView(_ pickerView: UIPickerView, didSelectRow row: Int, inComponent component: Int) {
accountLabel.text = (accounts[row] as DisplayNameProvider).nameForDisplay
AppDefaults.addFolderAccountID = accounts[row].accountID
didSelect(accounts[row])
}
}

View File

@@ -14,13 +14,13 @@
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Folder" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="xCU-fd-wms">
<rect key="frame" x="16" y="12.5" width="49" height="21"/>
<rect key="frame" x="20" y="12.5" width="49" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Detail" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="jCz-VR-Elr">
<rect key="frame" x="287" y="12.5" width="44" height="21"/>
<rect key="frame" x="283" y="12.5" width="44" height="21"/>
<fontDescription key="fontDescription" type="system" pointSize="17"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
@@ -30,8 +30,8 @@
<constraints>
<constraint firstItem="xCU-fd-wms" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="centerY" id="KCG-KB-QVx"/>
<constraint firstItem="jCz-VR-Elr" firstAttribute="centerY" secondItem="iN0-l3-epB" secondAttribute="centerY" id="ZEI-9G-SpU"/>
<constraint firstAttribute="trailing" secondItem="jCz-VR-Elr" secondAttribute="trailing" constant="16" id="rCo-rg-mRd"/>
<constraint firstItem="xCU-fd-wms" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="16" id="rZa-T7-Dy4"/>
<constraint firstAttribute="trailing" secondItem="jCz-VR-Elr" secondAttribute="trailing" constant="20" symbolic="YES" id="rCo-rg-mRd"/>
<constraint firstItem="xCU-fd-wms" firstAttribute="leading" secondItem="iN0-l3-epB" secondAttribute="leading" constant="20" symbolic="YES" id="rZa-T7-Dy4"/>
<constraint firstItem="jCz-VR-Elr" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="xCU-fd-wms" secondAttribute="trailing" constant="8" id="yWW-mq-p2A"/>
</constraints>
<nil key="simulatedTopBarMetrics"/>

View File

@@ -30,9 +30,9 @@ class AddWebFeedViewController: UITableViewController, AddContainerViewControlle
super.viewDidLoad()
if initialFeed == nil, let urlString = UIPasteboard.general.string as NSString? {
if urlString.rs_stringMayBeURL() {
initialFeed = urlString.rs_normalizedURL()
if initialFeed == nil, let urlString = UIPasteboard.general.string {
if urlString.mayBeURL {
initialFeed = urlString.normalizedURL
}
}
@@ -73,7 +73,7 @@ class AddWebFeedViewController: UITableViewController, AddContainerViewControlle
func add() {
let urlString = urlTextField.text ?? ""
let normalizedURLString = (urlString as NSString).rs_normalizedURL()
let normalizedURLString = urlString.normalizedURL
guard !normalizedURLString.isEmpty, let url = URL(string: normalizedURLString) else {
delegate?.processingDidCancel()
@@ -118,7 +118,7 @@ class AddWebFeedViewController: UITableViewController, AddContainerViewControlle
}
@objc func textDidChange(_ note: Notification) {
delegate?.readyToAdd(state: urlTextField.text?.rs_stringMayBeURL() ?? false)
delegate?.readyToAdd(state: urlTextField.text?.mayBeURL ?? false)
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {

View File

@@ -49,7 +49,7 @@ struct AppAssets {
static var articleExtractorOffTinted: UIImage = {
let image = UIImage(named: "articleExtractorOff")!
return image.maskWithColor(color: AppAssets.primaryAccentColor.cgColor)!
return image.tinted(color: AppAssets.primaryAccentColor)!
}()
static var articleExtractorOn: UIImage = {
@@ -62,7 +62,7 @@ struct AppAssets {
static var articleExtractorOnTinted: UIImage = {
let image = UIImage(named: "articleExtractorOn")!
return image.maskWithColor(color: AppAssets.primaryAccentColor.cgColor)!
return image.tinted(color: AppAssets.primaryAccentColor)!
}()
static var iconBackgroundColor: UIColor = {
@@ -113,15 +113,15 @@ struct AppAssets {
UIImage(systemName: "info.circle")!
}()
static var markAllInFeedAsReadImage: UIImage = {
return UIImage(systemName: "asterisk.circle")!
static var markAllAsReadImage: UIImage = {
return UIImage(named: "markAllAsRead")!
}()
static var markOlderAsReadDownImage: UIImage = {
static var markBelowAsReadImage: UIImage = {
return UIImage(systemName: "arrowtriangle.down.circle")!
}()
static var markOlderAsReadUpImage: UIImage = {
static var markAboveAsReadImage: UIImage = {
return UIImage(systemName: "arrowtriangle.up.circle")!
}()

View File

@@ -20,11 +20,13 @@ struct AppDefaults {
static let lastImageCacheFlushDate = "lastImageCacheFlushDate"
static let firstRunDate = "firstRunDate"
static let timelineGroupByFeed = "timelineGroupByFeed"
static let refreshClearsReadArticles = "refreshClearsReadArticles"
static let timelineNumberOfLines = "timelineNumberOfLines"
static let timelineIconSize = "timelineIconSize"
static let timelineSortDirection = "timelineSortDirection"
static let articleFullscreenAvailable = "articleFullscreenAvailable"
static let articleFullscreenEnabled = "articleFullscreenEnabled"
static let displayUndoAvailableTip = "displayUndoAvailableTip"
static let confirmMarkAllAsRead = "confirmMarkAllAsRead"
static let lastRefresh = "lastRefresh"
static let addWebFeedAccountID = "addWebFeedAccountID"
static let addWebFeedFolderName = "addWebFeedFolderName"
@@ -84,6 +86,15 @@ struct AppDefaults {
}
}
static var refreshClearsReadArticles: Bool {
get {
return bool(for: Key.refreshClearsReadArticles)
}
set {
setBool(for: Key.refreshClearsReadArticles, newValue)
}
}
static var timelineSortDirection: ComparisonResult {
get {
return sortDirection(for: Key.timelineSortDirection)
@@ -93,6 +104,15 @@ struct AppDefaults {
}
}
static var articleFullscreenAvailable: Bool {
get {
return bool(for: Key.articleFullscreenAvailable)
}
set {
setBool(for: Key.articleFullscreenAvailable, newValue)
}
}
static var articleFullscreenEnabled: Bool {
get {
return bool(for: Key.articleFullscreenEnabled)
@@ -102,12 +122,12 @@ struct AppDefaults {
}
}
static var displayUndoAvailableTip: Bool {
static var confirmMarkAllAsRead: Bool {
get {
return bool(for: Key.displayUndoAvailableTip)
return bool(for: Key.confirmMarkAllAsRead)
}
set {
setBool(for: Key.displayUndoAvailableTip, newValue)
setBool(for: Key.confirmMarkAllAsRead, newValue)
}
}
@@ -140,13 +160,14 @@ struct AppDefaults {
}
static func registerDefaults() {
let defaults: [String : Any] = [Key.lastImageCacheFlushDate: Date(),
Key.timelineGroupByFeed: false,
let defaults: [String : Any] = [Key.timelineGroupByFeed: false,
Key.refreshClearsReadArticles: false,
Key.timelineNumberOfLines: 2,
Key.timelineIconSize: IconSize.medium.rawValue,
Key.timelineSortDirection: ComparisonResult.orderedDescending.rawValue,
Key.articleFullscreenAvailable: false,
Key.articleFullscreenEnabled: false,
Key.displayUndoAvailableTip: true]
Key.confirmMarkAllAsRead: true]
AppDefaults.shared.register(defaults: defaults)
}

View File

@@ -41,6 +41,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
var imageDownloader: ImageDownloader!
var authorAvatarDownloader: AuthorAvatarDownloader!
var webFeedIconDownloader: WebFeedIconDownloader!
var extensionContainersFile: ExtensionContainersFile!
var extensionFeedAddRequestFile: ExtensionFeedAddRequestFile!
var unreadCount = 0 {
didSet {
@@ -58,9 +60,12 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
super.init()
appDelegate = self
// Force lazy initialization of the web view provider so that it can warm up the queue of prepared web views
let _ = ArticleViewControllerWebViewProvider.shared
AccountManager.shared = AccountManager()
AccountMigrator.migrate()
let documentAccountURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
let documentAccountsFolder = documentAccountURL.appendingPathComponent("Accounts").absoluteString
let documentAccountsFolderPath = String(documentAccountsFolder.suffix(from: documentAccountsFolder.index(documentAccountsFolder.startIndex, offsetBy: 7)))
AccountManager.shared = AccountManager(accountsFolder: documentAccountsFolderPath)
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(accountRefreshDidFinish(_:)), name: .AccountRefreshDidFinish, object: nil)
@@ -99,6 +104,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
UNUserNotificationCenter.current().delegate = self
userNotificationManager = UserNotificationManager()
extensionContainersFile = ExtensionContainersFile()
extensionFeedAddRequestFile = ExtensionFeedAddRequestFile()
syncTimer = ArticleStatusSyncTimer()
#if DEBUG
@@ -126,8 +134,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
}
// MARK: - API
func resumeDatabaseProcessingIfNecessary() {
if AccountManager.shared.isSuspended {
AccountManager.shared.resumeAll()
os_log("Application processing resumed.", log: self.log, type: .info)
}
}
func prepareAccountsForBackground() {
extensionFeedAddRequestFile.suspend()
syncTimer?.invalidate()
scheduleBackgroundFeedRefresh()
syncArticleStatus()
@@ -135,10 +150,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD
}
func prepareAccountsForForeground() {
if AccountManager.shared.isSuspended {
AccountManager.shared.resumeAll()
os_log("Application processing resumed.", log: self.log, type: .info)
}
extensionFeedAddRequestFile.resume()
if let lastRefresh = AppDefaults.lastRefresh {
if Date() > lastRefresh.addingTimeInterval(15 * 60) {
@@ -296,7 +308,8 @@ private extension AppDelegate {
guard UIApplication.shared.applicationState == .background else { return }
AccountManager.shared.suspendNetworkAll()
AccountManager.shared.suspendDatabaseAll()
CoalescingQueue.standard.performCallsImmediately()
for scene in UIApplication.shared.connectedScenes {
if let sceneDelegate = scene.delegate as? SceneDelegate {
@@ -304,7 +317,6 @@ private extension AppDelegate {
}
}
AccountManager.shared.suspendDatabaseAll()
os_log("Application processing suspended.", log: self.log, type: .info)
}

View File

@@ -17,40 +17,58 @@ enum ArticleExtractorButtonState {
class ArticleExtractorButton: UIButton {
private var animatedLayer: CALayer?
var buttonState: ArticleExtractorButtonState = .off {
didSet {
if buttonState != oldValue {
switch buttonState {
case .error:
stripSublayer()
stripAnimatedSublayer()
setImage(AppAssets.articleExtractorError, for: .normal)
case .animated:
setImage(nil, for: .normal)
setNeedsLayout()
case .on:
stripSublayer()
stripAnimatedSublayer()
setImage(AppAssets.articleExtractorOn, for: .normal)
case .off:
stripSublayer()
stripAnimatedSublayer()
setImage(AppAssets.articleExtractorOff, for: .normal)
}
}
}
}
override var accessibilityLabel: String? {
get {
switch buttonState {
case .error:
return NSLocalizedString("Error - Reader View", comment: "Error - Reader View")
case .animated:
return NSLocalizedString("Processing - Reader View", comment: "Processing - Reader View")
case .on:
return NSLocalizedString("Selected - Reader View", comment: "Selected - Reader View")
case .off:
return NSLocalizedString("Reader View", comment: "Reader View")
}
}
set {
super.accessibilityLabel = newValue
}
}
override func layoutSubviews() {
super.layoutSubviews()
guard case .animated = buttonState else {
return
}
stripSublayer()
stripAnimatedSublayer()
addAnimatedSublayer(to: layer)
}
private func stripSublayer() {
if layer.sublayers?.count ?? 0 > 1 {
layer.sublayers?.last?.removeFromSuperlayer()
}
private func stripAnimatedSublayer() {
animatedLayer?.removeFromSuperlayer()
}
private func addAnimatedSublayer(to hostedLayer: CALayer) {
@@ -58,12 +76,12 @@ class ArticleExtractorButton: UIButton {
let image2 = AppAssets.articleExtractorOnTinted.cgImage!
let images = [image1, image2, image1]
let imageLayer = CALayer()
animatedLayer = CALayer()
let imageSize = AppAssets.articleExtractorOff.size
imageLayer.bounds = CGRect(x: 0, y: 0, width: imageSize.width, height: imageSize.height)
imageLayer.position = CGPoint(x: bounds.midX, y: bounds.midY)
animatedLayer!.bounds = CGRect(x: 0, y: 0, width: imageSize.width, height: imageSize.height)
animatedLayer!.position = CGPoint(x: bounds.midX, y: bounds.midY)
hostedLayer.addSublayer(imageLayer)
hostedLayer.addSublayer(animatedLayer!)
let animation = CAKeyframeAnimation(keyPath: "contents")
animation.calculationMode = CAAnimationCalculationMode.linear
@@ -72,7 +90,7 @@ class ArticleExtractorButton: UIButton {
animation.values = images as [Any]
animation.repeatCount = HUGE
imageLayer.add(animation, forKey: "contents")
animatedLayer!.add(animation, forKey: "contents")
}
}

View File

@@ -0,0 +1,60 @@
//
// ArticleIconSchemeHandler.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 1/27/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import WebKit
import Articles
class ArticleIconSchemeHandler: NSObject, WKURLSchemeHandler {
weak var coordinator: SceneCoordinator?
init(coordinator: SceneCoordinator) {
self.coordinator = coordinator
}
func webView(_ webView: WKWebView, start urlSchemeTask: WKURLSchemeTask) {
guard let url = urlSchemeTask.request.url, let coordinator = coordinator else {
urlSchemeTask.didFailWithError(URLError(.fileDoesNotExist))
return
}
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
return
}
let articleID = components.path
guard let iconImage = coordinator.articleFor(articleID)?.iconImage() else {
urlSchemeTask.didFailWithError(URLError(.fileDoesNotExist))
return
}
let iconView = IconView(frame: CGRect(x: 0, y: 0, width: 48, height: 48))
iconView.iconImage = iconImage
let renderedImage = iconView.asImage()
guard let data = renderedImage.dataRepresentation() else {
urlSchemeTask.didFailWithError(URLError(.fileDoesNotExist))
return
}
let headerFields = ["Cache-Control": "no-cache"]
if let response = HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: headerFields) {
urlSchemeTask.didReceive(response)
urlSchemeTask.didReceive(data)
urlSchemeTask.didFinish()
}
}
func webView(_ webView: WKWebView, stop urlSchemeTask: WKURLSchemeTask) {
urlSchemeTask.didFailWithError(URLError(.unknown))
}
}

View File

@@ -12,20 +12,12 @@ import Account
import Articles
import SafariServices
enum ArticleViewState: Equatable {
case noSelection
case multipleSelection
case loading
case article(Article)
case extracted(Article, ExtractedArticle)
}
class ArticleViewController: UIViewController {
private struct MessageName {
static let imageWasClicked = "imageWasClicked"
static let imageWasShown = "imageWasShown"
}
typealias State = (extractedArticle: ExtractedArticle?,
isShowingExtractedArticle: Bool,
articleExtractorButtonState: ArticleExtractorButtonState,
windowScrollY: Int)
@IBOutlet private weak var nextUnreadBarButtonItem: UIBarButtonItem!
@IBOutlet private weak var prevArticleBarButtonItem: UIBarButtonItem!
@@ -33,11 +25,12 @@ class ArticleViewController: UIViewController {
@IBOutlet private weak var readBarButtonItem: UIBarButtonItem!
@IBOutlet private weak var starBarButtonItem: UIBarButtonItem!
@IBOutlet private weak var actionBarButtonItem: UIBarButtonItem!
@IBOutlet private weak var webViewContainer: UIView!
@IBOutlet private weak var showNavigationView: UIView!
@IBOutlet private weak var showToolbarView: UIView!
@IBOutlet private weak var showNavigationViewConstraint: NSLayoutConstraint!
@IBOutlet private weak var showToolbarViewConstraint: NSLayoutConstraint!
private var pageViewController: UIPageViewController!
private var currentWebViewController: WebViewController? {
return pageViewController?.viewControllers?.first as? WebViewController
}
private var articleExtractorButton: ArticleExtractorButton = {
let button = ArticleExtractorButton(type: .system)
@@ -46,114 +39,87 @@ class ArticleViewController: UIViewController {
return button
}()
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)?
weak var coordinator: SceneCoordinator!
var state: ArticleViewState = .noSelection {
var article: Article? {
didSet {
if state != oldValue {
updateUI()
reloadHTML()
if let controller = currentWebViewController, controller.article != article {
controller.article = article
DispatchQueue.main.async {
// You have to set the view controller to clear out the UIPageViewController child controller cache.
// You also have to do it in an async call or you will get a strange assertion error.
self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil)
}
}
updateUI()
}
}
var restoreOffset = 0
var currentArticle: Article? {
switch state {
case .article(let article):
return article
case .extracted(let article, _):
return article
default:
return nil
}
var currentState: State? {
guard let controller = currentWebViewController else { return nil}
return State(extractedArticle: controller.extractedArticle,
isShowingExtractedArticle: controller.isShowingExtractedArticle,
articleExtractorButtonState: controller.articleExtractorButtonState,
windowScrollY: controller.windowScrollY)
}
var articleExtractorButtonState: ArticleExtractorButtonState {
get {
return articleExtractorButton.buttonState
}
set {
articleExtractorButton.buttonState = newValue
}
}
var restoreState: State?
private let keyboardManager = KeyboardManager(type: .detail)
override var keyCommands: [UIKeyCommand]? {
return keyboardManager.keyCommands
}
deinit {
if webView != nil {
webView?.evaluateJavaScript("cancelImageLoad();")
webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.imageWasClicked)
webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.imageWasShown)
webView.removeFromSuperview()
ArticleViewControllerWebViewProvider.shared.enqueueWebView(webView)
webView = nil
}
}
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(statusesDidChange(_:)), name: .StatusesDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange(_:)), name: UIContentSizeCategory.didChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
let fullScreenTapZone = UIView()
NSLayoutConstraint.activate([
fullScreenTapZone.widthAnchor.constraint(equalToConstant: 150),
fullScreenTapZone.heightAnchor.constraint(equalToConstant: 44)
])
fullScreenTapZone.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapNavigationBar)))
navigationItem.titleView = fullScreenTapZone
articleExtractorButton.addTarget(self, action: #selector(toggleArticleExtractor(_:)), for: .touchUpInside)
toolbarItems?.insert(UIBarButtonItem(customView: articleExtractorButton), at: 6)
showNavigationView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:))))
showToolbarView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:))))
pageViewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal, options: [:])
pageViewController.delegate = self
pageViewController.dataSource = self
pageViewController.view.translatesAutoresizingMaskIntoConstraints = false
ArticleViewControllerWebViewProvider.shared.dequeueWebView() { webView in
self.webView = webView
self.webViewContainer.addChildAndPin(webView)
webView.translatesAutoresizingMaskIntoConstraints = false
self.webViewContainer.addSubview(webView)
NSLayoutConstraint.activate([
self.webViewContainer.leadingAnchor.constraint(equalTo: webView.leadingAnchor),
self.webViewContainer.trailingAnchor.constraint(equalTo: webView.trailingAnchor),
self.webViewContainer.topAnchor.constraint(equalTo: webView.topAnchor),
self.webViewContainer.bottomAnchor.constraint(equalTo: webView.bottomAnchor)
])
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)
// Even though page.html should be loaded into this webview, we have to do it again
// to work around this bug: http://www.openradar.me/22855188
let url = Bundle.main.url(forResource: "page", withExtension: "html")!
webView.loadFileURL(url, allowingReadAccessTo: url.deletingLastPathComponent())
view.addSubview(pageViewController.view)
addChild(pageViewController!)
NSLayoutConstraint.activate([
view.leadingAnchor.constraint(equalTo: pageViewController.view.leadingAnchor),
view.trailingAnchor.constraint(equalTo: pageViewController.view.trailingAnchor),
view.topAnchor.constraint(equalTo: pageViewController.view.topAnchor),
view.bottomAnchor.constraint(equalTo: pageViewController.view.bottomAnchor)
])
let controller = createWebViewController(article)
if let state = restoreState {
controller.extractedArticle = state.extractedArticle
controller.isShowingExtractedArticle = state.isShowingExtractedArticle
controller.articleExtractorButtonState = state.articleExtractorButtonState
controller.windowScrollY = state.windowScrollY
}
articleExtractorButton.buttonState = controller.articleExtractorButtonState
pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil)
updateUI()
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
if AppDefaults.articleFullscreenEnabled {
hideBars()
currentWebViewController?.hideBars()
}
}
@@ -169,7 +135,7 @@ class ArticleViewController: UIViewController {
func updateUI() {
guard let article = currentArticle else {
guard let article = article else {
articleExtractorButton.isEnabled = false
nextUnreadBarButtonItem.isEnabled = false
prevArticleBarButtonItem.isEnabled = false
@@ -189,46 +155,23 @@ class ArticleViewController: UIViewController {
starBarButtonItem.isEnabled = true
actionBarButtonItem.isEnabled = true
let readImage = article.status.read ? AppAssets.circleOpenImage : AppAssets.circleClosedImage
readBarButtonItem.image = readImage
let starImage = article.status.starred ? AppAssets.starClosedImage : AppAssets.starOpenImage
starBarButtonItem.image = starImage
}
func reloadHTML() {
let style = ArticleStylesManager.shared.currentStyle
let rendering: ArticleRenderer.Rendering
switch state {
case .noSelection:
rendering = ArticleRenderer.noSelectionHTML(style: style)
case .multipleSelection:
rendering = ArticleRenderer.multipleSelectionHTML(style: style)
case .loading:
rendering = ArticleRenderer.loadingHTML(style: style)
case .article(let article):
rendering = ArticleRenderer.articleHTML(article: article, style: style, useImageIcon: true)
case .extracted(let article, let extractedArticle):
rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, style: style, useImageIcon: true)
if article.status.read {
readBarButtonItem.image = AppAssets.circleOpenImage
readBarButtonItem.isEnabled = article.isAvailableToMarkUnread
readBarButtonItem.accLabelText = NSLocalizedString("Mark Article Unread", comment: "Mark Article Unread")
} else {
readBarButtonItem.image = AppAssets.circleClosedImage
readBarButtonItem.isEnabled = true
readBarButtonItem.accLabelText = NSLocalizedString("Selected - Mark Article Unread", comment: "Selected - Mark Article Unread")
}
let templateData = TemplateData(style: rendering.style, body: rendering.html)
let encoder = JSONEncoder()
var render = "error();"
if let data = try? encoder.encode(templateData) {
let json = String(data: data, encoding: .utf8)!
render = "render(\(json), \(restoreOffset));"
if article.status.starred {
starBarButtonItem.image = AppAssets.starClosedImage
starBarButtonItem.accLabelText = NSLocalizedString("Selected - Star Article", comment: "Selected - Star Article")
} else {
starBarButtonItem.image = AppAssets.starOpenImage
starBarButtonItem.accLabelText = NSLocalizedString("Star Article", comment: "Star Article")
}
restoreOffset = 0
ArticleViewControllerWebViewProvider.shared.articleIconSchemeHandler.currentArticle = currentArticle
webView?.scrollView.setZoomScale(1.0, animated: false)
webView?.evaluateJavaScript(render)
}
@@ -242,45 +185,42 @@ class ArticleViewController: UIViewController {
guard let articleIDs = note.userInfo?[Account.UserInfoKey.articleIDs] as? Set<String> else {
return
}
guard let currentArticle = currentArticle else {
guard let article = article else {
return
}
if articleIDs.contains(currentArticle.articleID) {
if articleIDs.contains(article.articleID) {
updateUI()
}
}
@objc func webFeedIconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func avatarDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func faviconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func contentSizeCategoryDidChange(_ note: Notification) {
reloadHTML()
coordinator.webViewProvider.flushQueue()
coordinator.webViewProvider.replenishQueueIfNeeded()
if let controller = currentWebViewController {
controller.fullReload()
self.pageViewController.setViewControllers([controller], direction: .forward, animated: false, completion: nil)
}
}
@objc func willEnterForeground(_ note: Notification) {
// The toolbar will come back on you if you don't hide it again
if AppDefaults.articleFullscreenEnabled {
hideBars()
currentWebViewController?.hideBars()
}
}
// MARK: Actions
@objc func didTapNavigationBar() {
currentWebViewController?.hideBars()
}
@objc func showBars(_ sender: Any) {
showBars()
currentWebViewController?.showBars()
}
@IBAction func toggleArticleExtractor(_ sender: Any) {
coordinator.toggleArticleExtractor()
currentWebViewController?.toggleArticleExtractor()
}
@IBAction func nextUnread(_ sender: Any) {
@@ -304,7 +244,7 @@ class ArticleViewController: UIViewController {
}
@IBAction func showActivityDialog(_ sender: Any) {
showActivityDialog()
currentWebViewController?.showActivityDialog(popOverBarButtonItem: actionBarButtonItem)
}
// MARK: Keyboard Shortcuts
@@ -315,339 +255,89 @@ class ArticleViewController: UIViewController {
// MARK: API
func focus() {
webView.becomeFirstResponder()
currentWebViewController?.focus()
}
func finalScrollPosition() -> CGFloat {
return webView.scrollView.contentSize.height - webView.scrollView.bounds.size.height + webView.scrollView.contentInset.bottom
}
func canScrollDown() -> Bool {
return webView.scrollView.contentOffset.y < finalScrollPosition()
return currentWebViewController?.canScrollDown() ?? false
}
func scrollPageDown() {
let scrollToY: CGFloat = {
let fullScroll = webView.scrollView.contentOffset.y + webView.scrollView.bounds.size.height
let final = finalScrollPosition()
return fullScroll < final ? fullScroll : final
}()
let convertedPoint = self.view.convert(CGPoint(x: 0, y: 0), to: webView.scrollView)
let scrollToPoint = CGPoint(x: convertedPoint.x, y: scrollToY)
webView.scrollView.setContentOffset(scrollToPoint, animated: true)
}
func hideClickedImage() {
webView?.evaluateJavaScript("hideClickedImage();")
}
func showClickedImage(completion: @escaping () -> Void) {
clickedImageCompletion = completion
webView?.evaluateJavaScript("showClickedImage();")
currentWebViewController?.scrollPageDown()
}
func fullReload() {
if let offset = webView?.scrollView.contentOffset.y {
restoreOffset = Int(offset)
webView?.reload()
currentWebViewController?.fullReload()
}
func stopArticleExtractorIfProcessing() {
currentWebViewController?.stopArticleExtractorIfProcessing()
}
}
// MARK: WebViewControllerDelegate
extension ArticleViewController: WebViewControllerDelegate {
func webViewController(_ webViewController: WebViewController, articleExtractorButtonStateDidUpdate buttonState: ArticleExtractorButtonState) {
if webViewController === currentWebViewController {
articleExtractorButton.buttonState = buttonState
}
}
}
// MARK: InteractiveNavigationControllerTappable
// MARK: UIPageViewControllerDataSource
extension ArticleViewController: InteractiveNavigationControllerTappable {
func didTapNavigationBar() {
hideBars()
extension ArticleViewController: UIPageViewControllerDataSource {
func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? {
guard let webViewController = viewController as? WebViewController,
let currentArticle = webViewController.article,
let article = coordinator.findPrevArticle(currentArticle) else {
return nil
}
return createWebViewController(article)
}
}
// 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()
func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? {
guard let webViewController = viewController as? WebViewController,
let currentArticle = webViewController.article,
let article = coordinator.findNextArticle(currentArticle) else {
return nil
}
return createWebViewController(article)
}
}
// MARK: WKNavigationDelegate
// MARK: UIPageViewControllerDelegate
extension ArticleViewController: WKNavigationDelegate {
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
extension ArticleViewController: UIPageViewControllerDelegate {
func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) {
guard finished, completed else { return }
guard let article = currentWebViewController?.article else { return }
if navigationAction.navigationType == .linkActivated {
guard let url = navigationAction.request.url else {
decisionHandler(.allow)
return
}
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
if components?.scheme == "http" || components?.scheme == "https" {
let vc = SFSafariViewController(url: url)
present(vc, animated: true)
decisionHandler(.cancel)
} else {
decisionHandler(.allow)
}
} else {
decisionHandler(.allow)
}
coordinator.selectArticle(article, animations: [.select, .scroll, .navigation])
articleExtractorButton.buttonState = currentWebViewController?.articleExtractorButtonState ?? .off
}
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
self.updateUI()
self.reloadHTML()
previousViewControllers.compactMap({ $0 as? WebViewController }).forEach({ $0.stopWebViewActivity() })
}
}
// MARK: WKUIDelegate
extension ArticleViewController: WKUIDelegate {
func webView(_ webView: WKWebView, contextMenuForElement elementInfo: WKContextMenuElementInfo, willCommitWithAnimator animator: UIContextMenuInteractionCommitAnimating) {
// We need to have at least an unimplemented WKUIDelegate assigned to the WKWebView. This makes the
// link preview launch Safari when the link preview is tapped. In theory, you shoud be able to get
// the link from the elementInfo above and transition to SFSafariViewController instead of launching
// Safari. As the time of this writing, the link in elementInfo is always nil. ¯\_()_/¯
}
}
// MARK: WKScriptMessageHandler
extension ArticleViewController: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
switch message.name {
case MessageName.imageWasShown:
clickedImageCompletion?()
case MessageName.imageWasClicked:
imageWasClicked(body: message.body as? String)
default:
return
}
}
}
class WrapperScriptMessageHandler: NSObject, WKScriptMessageHandler {
// We need to wrap a message handler to prevent a circlular reference
private weak var handler: WKScriptMessageHandler?
init(_ handler: WKScriptMessageHandler) {
self.handler = handler
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
handler?.userContentController(userContentController, didReceive: message)
}
}
// MARK: UIViewControllerTransitioningDelegate
extension ArticleViewController: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition.presenting = true
return transition
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition.presenting = false
return transition
}
}
// MARK: JSON
private struct TemplateData: Codable {
let style: String
let body: String
}
private struct ImageClickMessage: Codable {
let x: Float
let y: Float
let width: Float
let height: Float
let imageURL: String
}
// MARK: Private
private extension ArticleViewController {
func reloadArticleImage() {
webView?.evaluateJavaScript("reloadArticleImage()")
}
func imageWasClicked(body: String?) {
guard let body = body,
let data = body.data(using: .utf8),
let clickMessage = try? JSONDecoder().decode(ImageClickMessage.self, from: data),
let range = clickMessage.imageURL.range(of: ";base64,")
else { return }
let base64Image = String(clickMessage.imageURL.suffix(from: range.upperBound))
if let imageData = Data(base64Encoded: base64Image), let image = UIImage(data: imageData) {
let y = CGFloat(clickMessage.y) + webView.safeAreaInsets.top
let rect = CGRect(x: CGFloat(clickMessage.x), y: y, width: CGFloat(clickMessage.width), height: CGFloat(clickMessage.height))
transition.originFrame = webView.convert(rect, to: nil)
if navigationController?.navigationBar.isHidden ?? false {
transition.maskFrame = webView.convert(webView.frame, to: nil)
} else {
transition.maskFrame = webView.convert(webView.safeAreaLayoutGuide.layoutFrame, to: nil)
}
transition.originImage = image
coordinator.showFullScreenImage(image: image, transitioningDelegate: self)
}
}
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 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 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.selectNextUnread()
}
}
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.articleExtractorOffSF : AppAssets.articleExtractorOnSF
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()
}
func createWebViewController(_ article: Article?) -> WebViewController {
let controller = WebViewController()
controller.coordinator = coordinator
controller.delegate = self
controller.article = article
return controller
}
}

View File

@@ -1,97 +0,0 @@
//
// ArticleViewControllerWebViewProvider.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 9/21/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import Foundation
import WebKit
/// WKWebView has an awful behavior of a flash to white on first load when in dark mode.
/// Keep a queue of WebViews where we've already done a trivial load so that by the time we need them in the UI, they're past the flash-to-shite part of their lifecycle.
class ArticleViewControllerWebViewProvider: NSObject, WKNavigationDelegate {
static let shared = ArticleViewControllerWebViewProvider()
let articleIconSchemeHandler = ArticleIconSchemeHandler()
private let minimumQueueDepth = 3
private let maximumQueueDepth = 6
private var queue: [WKWebView] = []
private var waitingForFirstLoad = true
private var waitingCompletionHandler: ((WKWebView) -> ())?
func dequeueWebView(completion: @escaping (WKWebView) -> ()) {
if waitingForFirstLoad {
waitingCompletionHandler = completion
} else {
completeRequest(completion: completion)
}
}
func enqueueWebView(_ webView: WKWebView) {
guard queue.count < maximumQueueDepth else {
return
}
webView.navigationDelegate = self
queue.insert(webView, at: 0)
webView.loadHTMLString(ArticleRenderer.page.html, baseURL: ArticleRenderer.page.baseURL)
}
// MARK: WKNavigationDelegate
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
if waitingForFirstLoad {
waitingForFirstLoad = false
if let completion = waitingCompletionHandler {
completeRequest(completion: completion)
waitingCompletionHandler = nil
}
}
}
// MARK: Private
private override init() {
super.init()
replenishQueueIfNeeded()
}
private func replenishQueueIfNeeded() {
while queue.count < minimumQueueDepth {
let preferences = WKPreferences()
preferences.javaScriptCanOpenWindowsAutomatically = false
preferences.javaScriptEnabled = true
let configuration = WKWebViewConfiguration()
configuration.preferences = preferences
configuration.setValue(true, forKey: "allowUniversalAccessFromFileURLs")
configuration.allowsInlineMediaPlayback = true
configuration.mediaTypesRequiringUserActionForPlayback = .video
configuration.setURLSchemeHandler(articleIconSchemeHandler, forURLScheme: ArticleRenderer.imageIconScheme)
let webView = WKWebView(frame: .zero, configuration: configuration)
enqueueWebView(webView)
}
}
private func completeRequest(completion: @escaping (WKWebView) -> ()) {
if let webView = queue.popLast() {
webView.navigationDelegate = nil
replenishQueueIfNeeded()
completion(webView)
return
}
assertionFailure("Creating WKWebView in \(#function); queue has run dry.")
let webView = WKWebView(frame: .zero)
completion(webView)
}
}

View File

@@ -42,9 +42,24 @@ class ContextMenuPreviewViewController: UIViewController {
dateFormatter.timeStyle = .medium
dateTimeLabel.text = dateFormatter.string(from: article.logicalDatePublished)
// When in landscape the context menu preview will force this controller into a tiny
// view space. If it is documented anywhere what that is, I haven't found it. This
// set of magic numbers is what I worked out by testing a variety of phones.
let width: CGFloat
let heightPadding: CGFloat
if view.bounds.width > view.bounds.height {
width = 260
heightPadding = 16
view.widthAnchor.constraint(equalToConstant: width).isActive = true
} else {
width = view.bounds.width
heightPadding = 8
}
view.setNeedsLayout()
view.layoutIfNeeded()
preferredContentSize = CGSize(width: view.bounds.width, height: dateTimeLabel.frame.maxY + 8)
preferredContentSize = CGSize(width: width, height: dateTimeLabel.frame.maxY + heightPadding)
}
}

View File

@@ -10,15 +10,15 @@ import UIKit
class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning {
private weak var articleController: ArticleViewController?
private weak var webViewController: WebViewController?
private let duration = 0.4
var presenting = true
var originFrame: CGRect!
var maskFrame: CGRect!
var originImage: UIImage!
init(controller: ArticleViewController) {
self.articleController = controller
init(controller: WebViewController) {
self.webViewController = controller
}
func transitionDuration(using transitionContext: UIViewControllerContextTransitioning?) -> TimeInterval {
@@ -44,7 +44,7 @@ class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning {
transitionContext.containerView.backgroundColor = AppAssets.fullScreenBackgroundColor
transitionContext.containerView.addSubview(imageView)
articleController?.hideClickedImage()
webViewController?.hideClickedImage()
UIView.animate(
withDuration: duration,
@@ -93,11 +93,16 @@ class ImageTransition: NSObject, UIViewControllerAnimatedTransitioning {
animations: {
imageView.frame = self.originFrame
}, completion: { _ in
self.articleController?.showClickedImage() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
imageView.removeFromSuperview()
transitionContext.completeTransition(true)
if let controller = self.webViewController {
controller.showClickedImage() {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
imageView.removeFromSuperview()
transitionContext.completeTransition(true)
}
}
} else {
imageView.removeFromSuperview()
transitionContext.completeTransition(true)
}
})
}

View File

@@ -12,8 +12,10 @@ class ImageViewController: UIViewController {
@IBOutlet weak var shareButton: UIButton!
@IBOutlet weak var imageScrollView: ImageScrollView!
@IBOutlet weak var titleLabel: UILabel!
var image: UIImage!
var imageTitle: String?
var zoomedFrame: CGRect {
return imageScrollView.zoomedFrame
}
@@ -26,6 +28,8 @@ class ImageViewController: UIViewController {
imageScrollView.imageContentMode = .aspectFit
imageScrollView.initialOffset = .center
imageScrollView.display(image: image)
titleLabel.text = imageTitle ?? ""
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {

View File

@@ -0,0 +1,49 @@
//
// OpenInSafariActivity.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 1/9/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
class OpenInSafariActivity: UIActivity {
private var activityItems: [Any]?
override var activityTitle: String? {
return NSLocalizedString("Open in Safari", comment: "Open in Safari")
}
override var activityImage: UIImage? {
return UIImage(systemName: "safari", withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .regular))
}
override var activityType: UIActivity.ActivityType? {
return UIActivity.ActivityType(rawValue: "com.rancharo.NetNewsWire-Evergreen.safari")
}
override class var activityCategory: UIActivity.Category {
return .action
}
override func canPerform(withActivityItems activityItems: [Any]) -> Bool {
return true
}
override func prepare(withActivityItems activityItems: [Any]) {
self.activityItems = activityItems
}
override func perform() {
guard let url = activityItems?.firstElementPassingTest({ $0 is URL }) as? URL else {
activityDidFinish(false)
return
}
UIApplication.shared.open(url, options: [:], completionHandler: nil)
activityDidFinish(true)
}
}

View File

@@ -0,0 +1,690 @@
//
// WebViewController.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 12/28/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import WebKit
import RSCore
import Account
import Articles
import SafariServices
protocol WebViewControllerDelegate: class {
func webViewController(_: WebViewController, articleExtractorButtonStateDidUpdate: ArticleExtractorButtonState)
}
class WebViewController: UIViewController {
private struct MessageName {
static let imageWasClicked = "imageWasClicked"
static let imageWasShown = "imageWasShown"
}
private var topShowBarsView: UIView!
private var bottomShowBarsView: UIView!
private var topShowBarsViewConstraint: NSLayoutConstraint!
private var bottomShowBarsViewConstraint: NSLayoutConstraint!
private var webView: WKWebView? {
return view.subviews[0] as? WKWebView
}
private lazy var contextMenuInteraction = UIContextMenuInteraction(delegate: self)
private var isFullScreenAvailable: Bool {
return AppDefaults.articleFullscreenAvailable && traitCollection.userInterfaceIdiom == .phone && coordinator.isRootSplitCollapsed
}
private lazy var transition = ImageTransition(controller: self)
private var clickedImageCompletion: (() -> Void)?
private var articleExtractor: ArticleExtractor? = nil
var extractedArticle: ExtractedArticle? {
didSet {
windowScrollY = 0
}
}
var isShowingExtractedArticle = false
var articleExtractorButtonState: ArticleExtractorButtonState = .off {
didSet {
delegate?.webViewController(self, articleExtractorButtonStateDidUpdate: articleExtractorButtonState)
}
}
weak var coordinator: SceneCoordinator!
weak var delegate: WebViewControllerDelegate?
var article: Article? {
didSet {
stopArticleExtractor()
if article?.webFeed?.isArticleExtractorAlwaysOn ?? false {
startArticleExtractor()
}
if article != oldValue {
windowScrollY = 0
loadWebView()
}
}
}
let scrollPositionQueue = CoalescingQueue(name: "Article Scroll Position", interval: 0.3, maxInterval: 1.0)
var windowScrollY = 0
deinit {
recycleWebView(webView)
}
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
// Configure the tap zones
configureTopShowBarsView()
configureBottomShowBarsView()
loadWebView()
}
// MARK: Notifications
@objc func webFeedIconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func avatarDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
@objc func faviconDidBecomeAvailable(_ note: Notification) {
reloadArticleImage()
}
// MARK: Actions
@objc func showBars(_ sender: Any) {
showBars()
}
// MARK: API
func focus() {
webView?.becomeFirstResponder()
}
func canScrollDown() -> Bool {
guard let webView = webView else { return false }
return webView.scrollView.contentOffset.y < finalScrollPosition()
}
func scrollPageDown() {
guard let webView = webView else { return }
let scrollToY: CGFloat = {
let fullScroll = webView.scrollView.contentOffset.y + webView.scrollView.layoutMarginsGuide.layoutFrame.height
let final = finalScrollPosition()
return fullScroll < final ? fullScroll : final
}()
let convertedPoint = self.view.convert(CGPoint(x: 0, y: 0), to: webView.scrollView)
let scrollToPoint = CGPoint(x: convertedPoint.x, y: scrollToY)
webView.scrollView.setContentOffset(scrollToPoint, animated: true)
}
func hideClickedImage() {
webView?.evaluateJavaScript("hideClickedImage();")
}
func showClickedImage(completion: @escaping () -> Void) {
clickedImageCompletion = completion
webView?.evaluateJavaScript("showClickedImage();")
}
func fullReload() {
self.loadWebView()
}
func showBars() {
AppDefaults.articleFullscreenEnabled = false
coordinator.showStatusBar()
topShowBarsViewConstraint?.constant = 0
bottomShowBarsViewConstraint?.constant = 0
navigationController?.setNavigationBarHidden(false, animated: true)
navigationController?.setToolbarHidden(false, animated: true)
configureContextMenuInteraction()
}
func hideBars() {
if isFullScreenAvailable {
AppDefaults.articleFullscreenEnabled = true
coordinator.hideStatusBar()
topShowBarsViewConstraint?.constant = -44.0
bottomShowBarsViewConstraint?.constant = 44.0
navigationController?.setNavigationBarHidden(true, animated: true)
navigationController?.setToolbarHidden(true, animated: true)
configureContextMenuInteraction()
}
}
func toggleArticleExtractor() {
guard let article = article else {
return
}
guard articleExtractor?.state != .processing else {
stopArticleExtractor()
loadWebView()
return
}
guard !isShowingExtractedArticle else {
isShowingExtractedArticle = false
loadWebView()
articleExtractorButtonState = .off
return
}
if let articleExtractor = articleExtractor {
if article.preferredLink == articleExtractor.articleLink {
isShowingExtractedArticle = true
loadWebView()
articleExtractorButtonState = .on
}
} else {
startArticleExtractor()
}
}
func stopArticleExtractorIfProcessing() {
if articleExtractor?.state == .processing {
stopArticleExtractor()
}
}
func stopWebViewActivity() {
if let webView = webView {
stopMediaPlayback(webView)
cancelImageLoad(webView)
}
}
func showActivityDialog(popOverBarButtonItem: UIBarButtonItem? = nil) {
guard let preferredLink = article?.preferredLink, let url = URL(string: preferredLink) else {
return
}
let activityViewController = UIActivityViewController(url: url, title: article?.title, applicationActivities: [OpenInSafariActivity()])
activityViewController.popoverPresentationController?.barButtonItem = popOverBarButtonItem
present(activityViewController, animated: true)
}
}
// MARK: ArticleExtractorDelegate
extension WebViewController: ArticleExtractorDelegate {
func articleExtractionDidFail(with: Error) {
stopArticleExtractor()
articleExtractorButtonState = .error
loadWebView()
}
func articleExtractionDidComplete(extractedArticle: ExtractedArticle) {
if articleExtractor?.state != .cancelled {
self.extractedArticle = extractedArticle
isShowingExtractedArticle = true
loadWebView()
articleExtractorButtonState = .on
}
}
}
// MARK: UIContextMenuInteractionDelegate
extension WebViewController: 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)
}
if let action = self.toggleReadAction() {
actions.append(action)
}
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 WebViewController: WKNavigationDelegate {
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
if navigationAction.navigationType == .linkActivated {
guard let url = navigationAction.request.url else {
decisionHandler(.allow)
return
}
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
if components?.scheme == "http" || components?.scheme == "https" {
decisionHandler(.cancel)
// If the resource cannot be opened with an installed app, present the web view.
UIApplication.shared.open(url, options: [.universalLinksOnly: true]) { didOpen in
assert(Thread.isMainThread)
guard didOpen == false else {
return
}
let vc = SFSafariViewController(url: url)
self.present(vc, animated: true)
}
} else {
decisionHandler(.allow)
}
} else {
decisionHandler(.allow)
}
}
}
// MARK: WKUIDelegate
extension WebViewController: WKUIDelegate {
func webView(_ webView: WKWebView, contextMenuForElement elementInfo: WKContextMenuElementInfo, willCommitWithAnimator animator: UIContextMenuInteractionCommitAnimating) {
// We need to have at least an unimplemented WKUIDelegate assigned to the WKWebView. This makes the
// link preview launch Safari when the link preview is tapped. In theory, you shoud be able to get
// the link from the elementInfo above and transition to SFSafariViewController instead of launching
// Safari. As the time of this writing, the link in elementInfo is always nil. ¯\_()_/¯
}
}
// MARK: WKScriptMessageHandler
extension WebViewController: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
switch message.name {
case MessageName.imageWasShown:
clickedImageCompletion?()
case MessageName.imageWasClicked:
imageWasClicked(body: message.body as? String)
default:
return
}
}
}
// MARK: UIViewControllerTransitioningDelegate
extension WebViewController: UIViewControllerTransitioningDelegate {
func animationController(forPresented presented: UIViewController, presenting: UIViewController, source: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition.presenting = true
return transition
}
func animationController(forDismissed dismissed: UIViewController) -> UIViewControllerAnimatedTransitioning? {
transition.presenting = false
return transition
}
}
// MARK:
extension WebViewController: UIScrollViewDelegate {
func scrollViewDidScroll(_ scrollView: UIScrollView) {
scrollPositionQueue.add(self, #selector(scrollPositionDidChange))
}
@objc func scrollPositionDidChange() {
webView?.evaluateJavaScript("window.scrollY") { (scrollY, _) in
self.windowScrollY = scrollY as? Int ?? 0
}
}
}
// MARK: JSON
private struct TemplateData: Codable {
let style: String
let body: String
let title: String
let baseURL: String
}
private struct ImageClickMessage: Codable {
let x: Float
let y: Float
let width: Float
let height: Float
let imageTitle: String?
let imageURL: String
}
// MARK: Private
private extension WebViewController {
func loadWebView() {
guard isViewLoaded else { return }
coordinator.webViewProvider.dequeueWebView() { webView in
let webViewToRecycle = self.webView
self.renderPage(webViewToRecycle)
// Add the webview
webView.translatesAutoresizingMaskIntoConstraints = false
self.view.insertSubview(webView, at: 0)
NSLayoutConstraint.activate([
self.view.leadingAnchor.constraint(equalTo: webView.leadingAnchor),
self.view.trailingAnchor.constraint(equalTo: webView.trailingAnchor),
self.view.topAnchor.constraint(equalTo: webView.topAnchor),
self.view.bottomAnchor.constraint(equalTo: webView.bottomAnchor)
])
// UISplitViewController reports the wrong size to WKWebView which can cause horizontal
// rubberbanding on the iPad. This interferes with our UIPageViewController preventing
// us from easily swiping between WKWebViews. This hack fixes that.
webView.scrollView.contentInset = UIEdgeInsets(top: 0, left: -1, bottom: 0, right: 0)
webView.scrollView.setZoomScale(1.0, animated: false)
self.view.setNeedsLayout()
self.view.layoutIfNeeded()
// Configure the webview
webView.navigationDelegate = self
webView.uiDelegate = self
webView.scrollView.delegate = self
self.configureContextMenuInteraction()
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasClicked)
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.imageWasShown)
self.renderPage(webView)
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
self.recycleWebView(webViewToRecycle)
}
}
}
func recycleWebView(_ webView: WKWebView?) {
guard let webView = webView else { return }
webView.removeFromSuperview()
stopMediaPlayback(webView)
cancelImageLoad(webView)
webView.navigationDelegate = nil
webView.uiDelegate = nil
webView.scrollView.delegate = nil
webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.imageWasClicked)
webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.imageWasShown)
webView.interactions.removeAll()
coordinator.webViewProvider.enqueueWebView(webView)
}
func renderPage(_ webView: WKWebView?) {
guard let webView = webView else { return }
let style = ArticleStylesManager.shared.currentStyle
let rendering: ArticleRenderer.Rendering
if let articleExtractor = articleExtractor, articleExtractor.state == .processing {
rendering = ArticleRenderer.loadingHTML(style: style)
} else if let articleExtractor = articleExtractor, articleExtractor.state == .failedToParse, let article = article {
rendering = ArticleRenderer.articleHTML(article: article, style: style)
} else if let article = article, let extractedArticle = extractedArticle {
if isShowingExtractedArticle {
rendering = ArticleRenderer.articleHTML(article: article, extractedArticle: extractedArticle, style: style)
} else {
rendering = ArticleRenderer.articleHTML(article: article, style: style)
}
} else if let article = article {
rendering = ArticleRenderer.articleHTML(article: article, style: style)
} else {
rendering = ArticleRenderer.noSelectionHTML(style: style)
}
let templateData = TemplateData(style: rendering.style, body: rendering.html, title: rendering.title, baseURL: rendering.baseURL)
let encoder = JSONEncoder()
var render = "error();"
if let data = try? encoder.encode(templateData) {
let json = String(data: data, encoding: .utf8)!
render = "render(\(json), \(windowScrollY));"
}
windowScrollY = 0
webView.evaluateJavaScript(render)
}
func finalScrollPosition() -> CGFloat {
guard let webView = webView else { return 0 }
return webView.scrollView.contentSize.height - webView.scrollView.bounds.height + webView.scrollView.safeAreaInsets.bottom
}
func startArticleExtractor() {
if let link = article?.preferredLink, let extractor = ArticleExtractor(link) {
extractor.delegate = self
extractor.process()
articleExtractor = extractor
articleExtractorButtonState = .animated
}
}
func stopArticleExtractor() {
articleExtractor?.cancel()
articleExtractor = nil
isShowingExtractedArticle = false
articleExtractorButtonState = .off
}
func reloadArticleImage() {
guard let article = article else { return }
var components = URLComponents()
components.scheme = ArticleRenderer.imageIconScheme
components.path = article.articleID
if let imageSrc = components.string {
webView?.evaluateJavaScript("reloadArticleImage(\"\(imageSrc)\")")
}
}
func imageWasClicked(body: String?) {
guard let webView = webView,
let body = body,
let data = body.data(using: .utf8),
let clickMessage = try? JSONDecoder().decode(ImageClickMessage.self, from: data),
let range = clickMessage.imageURL.range(of: ";base64,")
else { return }
let base64Image = String(clickMessage.imageURL.suffix(from: range.upperBound))
if let imageData = Data(base64Encoded: base64Image), let image = UIImage(data: imageData) {
let y = CGFloat(clickMessage.y) + webView.safeAreaInsets.top
let rect = CGRect(x: CGFloat(clickMessage.x), y: y, width: CGFloat(clickMessage.width), height: CGFloat(clickMessage.height))
transition.originFrame = webView.convert(rect, to: nil)
if navigationController?.navigationBar.isHidden ?? false {
transition.maskFrame = webView.convert(webView.frame, to: nil)
} else {
transition.maskFrame = webView.convert(webView.safeAreaLayoutGuide.layoutFrame, to: nil)
}
transition.originImage = image
coordinator.showFullScreenImage(image: image, imageTitle: clickMessage.imageTitle, transitioningDelegate: self)
}
}
func stopMediaPlayback(_ webView: WKWebView) {
webView.evaluateJavaScript("stopMediaPlayback();")
}
func cancelImageLoad(_ webView: WKWebView) {
webView.evaluateJavaScript("cancelImageLoad();")
}
func configureTopShowBarsView() {
topShowBarsView = UIView()
topShowBarsView.backgroundColor = .clear
topShowBarsView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(topShowBarsView)
if AppDefaults.articleFullscreenEnabled {
topShowBarsViewConstraint = view.topAnchor.constraint(equalTo: topShowBarsView.bottomAnchor, constant: -44.0)
} else {
topShowBarsViewConstraint = view.topAnchor.constraint(equalTo: topShowBarsView.bottomAnchor, constant: 0.0)
}
NSLayoutConstraint.activate([
topShowBarsViewConstraint,
view.leadingAnchor.constraint(equalTo: topShowBarsView.leadingAnchor),
view.trailingAnchor.constraint(equalTo: topShowBarsView.trailingAnchor),
topShowBarsView.heightAnchor.constraint(equalToConstant: 44.0)
])
topShowBarsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:))))
}
func configureBottomShowBarsView() {
bottomShowBarsView = UIView()
topShowBarsView.backgroundColor = .clear
bottomShowBarsView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(bottomShowBarsView)
if AppDefaults.articleFullscreenEnabled {
bottomShowBarsViewConstraint = view.bottomAnchor.constraint(equalTo: bottomShowBarsView.topAnchor, constant: 44.0)
} else {
bottomShowBarsViewConstraint = view.bottomAnchor.constraint(equalTo: bottomShowBarsView.topAnchor, constant: 0.0)
}
NSLayoutConstraint.activate([
bottomShowBarsViewConstraint,
view.leadingAnchor.constraint(equalTo: bottomShowBarsView.leadingAnchor),
view.trailingAnchor.constraint(equalTo: bottomShowBarsView.trailingAnchor),
bottomShowBarsView.heightAnchor.constraint(equalToConstant: 44.0)
])
bottomShowBarsView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(showBars(_:))))
}
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 = article
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? {
guard let article = article, !article.status.read || article.isAvailableToMarkUnread else { return nil }
let title = article.status.read ? NSLocalizedString("Mark as Unread", comment: "Mark as Unread") : NSLocalizedString("Mark as Read", comment: "Mark as Read")
let readImage = article.status.read ? AppAssets.circleClosedImage : AppAssets.circleOpenImage
return UIAction(title: title, image: readImage) { [weak self] action in
self?.coordinator.toggleReadForCurrentArticle()
}
}
func toggleStarredAction() -> UIAction {
let starred = article?.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.selectNextUnread()
}
}
func toggleArticleExtractorAction() -> UIAction {
let extracted = articleExtractorButtonState == .on
let title = extracted ? NSLocalizedString("Show Feed Article", comment: "Show Feed Article") : NSLocalizedString("Show Reader View", comment: "Show Reader View")
let extractorImage = extracted ? AppAssets.articleExtractorOffSF : AppAssets.articleExtractorOnSF
return UIAction(title: title, image: extractorImage) { [weak self] action in
self?.toggleArticleExtractor()
}
}
func shareAction() -> UIAction {
let title = NSLocalizedString("Share", comment: "Share")
return UIAction(title: title, image: AppAssets.shareImage) { [weak self] action in
self?.showActivityDialog()
}
}
}

View File

@@ -0,0 +1,150 @@
//
// WebViewProvider.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 9/21/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import Foundation
import WebKit
/// WKWebView has an awful behavior of a flash to white on first load when in dark mode.
/// Keep a queue of WebViews where we've already done a trivial load so that by the time we need them in the UI, they're past the flash-to-shite part of their lifecycle.
class WebViewProvider: NSObject, WKNavigationDelegate {
private struct MessageName {
static let domContentLoaded = "domContentLoaded"
}
let articleIconSchemeHandler: ArticleIconSchemeHandler
private let minimumQueueDepth = 3
private let maximumQueueDepth = 6
private var queue = UIView()
private var waitingForFirstLoad = true
private var waitingCompletionHandler: ((WKWebView) -> ())?
init(coordinator: SceneCoordinator, viewController: UIViewController) {
articleIconSchemeHandler = ArticleIconSchemeHandler(coordinator: coordinator)
super.init()
viewController.view.insertSubview(queue, at: 0)
replenishQueueIfNeeded()
NotificationCenter.default.addObserver(self, selector: #selector(didEnterBackground), name: UIApplication.didEnterBackgroundNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground), name: UIApplication.willEnterForegroundNotification, object: nil)
}
@objc func didEnterBackground() {
flushQueue()
}
@objc func willEnterForeground() {
replenishQueueIfNeeded()
}
func flushQueue() {
queue.subviews.forEach { $0.removeFromSuperview() }
waitingForFirstLoad = true
}
func replenishQueueIfNeeded() {
while queue.subviews.count < minimumQueueDepth {
let webView = WKWebView(frame: .zero, configuration: buildConfiguration())
enqueueWebView(webView)
}
}
func dequeueWebView(completion: @escaping (WKWebView) -> ()) {
if waitingForFirstLoad {
waitingCompletionHandler = completion
} else {
completeRequest(completion: completion)
}
}
func enqueueWebView(_ webView: WKWebView) {
guard queue.subviews.count < maximumQueueDepth else {
return
}
webView.configuration.userContentController.add(WrapperScriptMessageHandler(self), name: MessageName.domContentLoaded)
queue.insertSubview(webView, at: 0)
webView.loadFileURL(ArticleRenderer.page.url, allowingReadAccessTo: ArticleRenderer.page.baseURL)
}
// MARK: WKNavigationDelegate
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
if waitingForFirstLoad {
waitingForFirstLoad = false
if let completion = waitingCompletionHandler {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.completeRequest(completion: completion)
self.waitingCompletionHandler = nil
}
}
}
}
}
// MARK: WKScriptMessageHandler
extension WebViewProvider: WKScriptMessageHandler {
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
switch message.name {
case MessageName.domContentLoaded:
if waitingForFirstLoad {
waitingForFirstLoad = false
if let completion = waitingCompletionHandler {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
self.completeRequest(completion: completion)
self.waitingCompletionHandler = nil
}
}
}
default:
return
}
}
}
// MARK: Private
private extension WebViewProvider {
func completeRequest(completion: @escaping (WKWebView) -> ()) {
if let webView = queue.subviews.last as? WKWebView {
webView.removeFromSuperview()
webView.configuration.userContentController.removeScriptMessageHandler(forName: MessageName.domContentLoaded)
replenishQueueIfNeeded()
completion(webView)
return
}
assertionFailure("Creating WKWebView in \(#function); queue has run dry.")
let webView = WKWebView(frame: .zero)
completion(webView)
}
func buildConfiguration() -> WKWebViewConfiguration {
let preferences = WKPreferences()
preferences.javaScriptCanOpenWindowsAutomatically = false
preferences.javaScriptEnabled = true
let configuration = WKWebViewConfiguration()
configuration.preferences = preferences
configuration.setValue(true, forKey: "allowUniversalAccessFromFileURLs")
configuration.allowsInlineMediaPlayback = true
configuration.mediaTypesRequiringUserActionForPlayback = .video
configuration.setURLSchemeHandler(articleIconSchemeHandler, forURLScheme: ArticleRenderer.imageIconScheme)
return configuration
}
}

View File

@@ -0,0 +1,25 @@
//
// WrapperScriptMessageHandler.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 2/4/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import WebKit
class WrapperScriptMessageHandler: NSObject, WKScriptMessageHandler {
// We need to wrap a message handler to prevent a circlular reference
private weak var handler: WKScriptMessageHandler?
init(_ handler: WKScriptMessageHandler) {
self.handler = handler
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
handler?.userContentController(userContentController, didReceive: message)
}
}

View File

@@ -65,11 +65,11 @@
</connections>
</tableView>
<toolbarItems>
<barButtonItem title="Item" image="gear" catalog="system" id="AK3-N5-4ke"/>
<barButtonItem style="plain" systemItem="flexibleSpace" id="Kjl-Sb-QP1"/>
<barButtonItem systemItem="add" id="PVr-3K-nPg"/>
</toolbarItems>
<navigationItem key="navigationItem" title="Feeds" largeTitleDisplayMode="never" id="lE1-xw-gjH">
<barButtonItem key="leftBarButtonItem" title="Item" image="gear" catalog="system" id="AK3-N5-4ke"/>
<barButtonItem key="rightBarButtonItem" image="line.horizontal.3.decrease.circle" catalog="system" id="Khk-Hd-iNS"/>
</navigationItem>
<simulatedToolbarMetrics key="simulatedBottomBarMetrics"/>

View File

@@ -65,11 +65,11 @@
</connections>
</tableView>
<toolbarItems>
<barButtonItem title="Item" image="gear" catalog="system" id="AK3-N5-4ke"/>
<barButtonItem style="plain" systemItem="flexibleSpace" id="Kjl-Sb-QP1"/>
<barButtonItem systemItem="add" id="PVr-3K-nPg"/>
</toolbarItems>
<navigationItem key="navigationItem" title="Feeds" largeTitleDisplayMode="always" id="lE1-xw-gjH">
<barButtonItem key="leftBarButtonItem" title="Item" image="gear" catalog="system" id="AK3-N5-4ke"/>
<barButtonItem key="rightBarButtonItem" image="line.horizontal.3.decrease.circle" catalog="system" id="Khk-Hd-iNS"/>
</navigationItem>
<simulatedToolbarMetrics key="simulatedBottomBarMetrics"/>

View File

@@ -15,39 +15,7 @@
<view key="view" contentMode="scaleToFill" id="svH-Pt-448">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<subviews>
<view contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="DNb-lt-KzC">
<rect key="frame" x="0.0" y="0.0" width="414" height="896"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
</view>
<view opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="iEi-hX-TYy">
<rect key="frame" x="0.0" y="813" width="414" height="100"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="100" id="xX2-AK-xJX"/>
</constraints>
</view>
<view opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="A7j-8T-DqE">
<rect key="frame" x="0.0" y="-12" width="414" height="100"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
<constraints>
<constraint firstAttribute="height" constant="100" id="3HX-Dm-bA6"/>
</constraints>
</view>
</subviews>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<constraints>
<constraint firstItem="VUw-jc-0yf" firstAttribute="bottom" secondItem="iEi-hX-TYy" secondAttribute="top" id="4fZ-pn-fmB"/>
<constraint firstItem="DNb-lt-KzC" firstAttribute="top" secondItem="svH-Pt-448" secondAttribute="top" id="Bfh-RL-m4d"/>
<constraint firstItem="A7j-8T-DqE" firstAttribute="trailing" secondItem="VUw-jc-0yf" secondAttribute="trailing" id="Feu-hj-K01"/>
<constraint firstItem="DNb-lt-KzC" firstAttribute="bottom" secondItem="svH-Pt-448" secondAttribute="bottom" id="FfW-6G-Bcp"/>
<constraint firstItem="VUw-jc-0yf" firstAttribute="trailing" secondItem="iEi-hX-TYy" secondAttribute="trailing" id="Ij6-ri-sBN"/>
<constraint firstItem="iEi-hX-TYy" firstAttribute="leading" secondItem="VUw-jc-0yf" secondAttribute="leading" id="Muc-gr-S7o"/>
<constraint firstItem="DNb-lt-KzC" firstAttribute="trailing" secondItem="VUw-jc-0yf" secondAttribute="trailing" id="QJ5-Ne-ndd"/>
<constraint firstItem="A7j-8T-DqE" firstAttribute="bottom" secondItem="VUw-jc-0yf" secondAttribute="top" id="b2h-zZ-xwi"/>
<constraint firstItem="DNb-lt-KzC" firstAttribute="leading" secondItem="VUw-jc-0yf" secondAttribute="leading" id="ezE-0p-35X"/>
<constraint firstItem="A7j-8T-DqE" firstAttribute="leading" secondItem="VUw-jc-0yf" secondAttribute="leading" id="wny-M6-akA"/>
</constraints>
<viewLayoutGuide key="safeArea" id="VUw-jc-0yf"/>
</view>
<toolbarItems>
@@ -75,7 +43,7 @@
<userDefinedRuntimeAttribute type="string" keyPath="accLabelText" value="Next Unread"/>
</userDefinedRuntimeAttributes>
<connections>
<action selector="nextUnread:" destination="JEX-9P-axG" id="USD-hC-C6z"/>
<action selector="nextUnread:" destination="JEX-9P-axG" id="nI3-pz-tc8"/>
</connections>
</barButtonItem>
<barButtonItem style="plain" systemItem="flexibleSpace" id="vAq-iW-Yyo"/>
@@ -83,7 +51,7 @@
<barButtonItem image="square.and.arrow.up" catalog="system" id="9Ut-5B-JKP">
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="boolean" keyPath="accEnabled" value="YES"/>
<userDefinedRuntimeAttribute type="string" keyPath="accLabelText" value="Action"/>
<userDefinedRuntimeAttribute type="string" keyPath="accLabelText" value="Share"/>
</userDefinedRuntimeAttributes>
<connections>
<action selector="showActivityDialog:" destination="JEX-9P-axG" id="t7U-uT-fs5"/>
@@ -117,15 +85,10 @@
<connections>
<outlet property="actionBarButtonItem" destination="9Ut-5B-JKP" id="9bO-kz-cTz"/>
<outlet property="nextArticleBarButtonItem" destination="2qz-M5-Yhk" id="IQd-jx-qEr"/>
<outlet property="nextUnreadBarButtonItem" destination="2w5-e9-C2V" id="xJr-5y-p1N"/>
<outlet property="nextUnreadBarButtonItem" destination="2w5-e9-C2V" id="Ekf-My-AHN"/>
<outlet property="prevArticleBarButtonItem" destination="v4j-fq-23N" id="Gny-Oh-cQa"/>
<outlet property="readBarButtonItem" destination="hy0-LS-MzE" id="BzM-x9-tuj"/>
<outlet property="showNavigationView" destination="A7j-8T-DqE" id="D59-3C-HmS"/>
<outlet property="showNavigationViewConstraint" destination="b2h-zZ-xwi" id="CaG-8F-5kF"/>
<outlet property="showToolbarView" destination="iEi-hX-TYy" id="zoa-h3-H8b"/>
<outlet property="showToolbarViewConstraint" destination="4fZ-pn-fmB" id="ayD-Mq-kft"/>
<outlet property="starBarButtonItem" destination="wU4-eH-wC9" id="Z8Q-Lt-dKk"/>
<outlet property="webViewContainer" destination="DNb-lt-KzC" id="Fc1-Ae-pWK"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="FJe-Yq-33r" sceneMemberID="firstResponder"/>
@@ -136,7 +99,7 @@
<scene sceneID="fag-XH-avP">
<objects>
<tableViewController storyboardIdentifier="MasterTimelineViewController" useStoryboardIdentifierAsRestorationIdentifier="YES" clearsSelectionOnViewWillAppear="NO" id="Kyk-vK-QRX" customClass="MasterTimelineViewController" customModule="NetNewsWire" customModuleProvider="target" sceneMemberID="viewController">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="mtv-Ik-FoJ">
<tableView key="view" clipsSubviews="YES" contentMode="scaleToFill" alwaysBounceVertical="YES" keyboardDismissMode="onDrag" dataMode="prototypes" style="plain" separatorStyle="default" rowHeight="-1" estimatedRowHeight="-1" sectionHeaderHeight="28" sectionFooterHeight="28" id="mtv-Ik-FoJ">
<rect key="frame" x="0.0" y="0.0" width="414" height="725"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
@@ -156,13 +119,20 @@
</connections>
</tableView>
<toolbarItems>
<barButtonItem title="Mark All as Read" id="fTv-eX-72r">
<barButtonItem title="Item" image="markAllAsRead" id="fTv-eX-72r">
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="accLabelText" value="Mark All as Read"/>
</userDefinedRuntimeAttributes>
<connections>
<action selector="markAllAsRead:" destination="Kyk-vK-QRX" id="4nd-Gg-APm"/>
</connections>
</barButtonItem>
<barButtonItem style="plain" systemItem="flexibleSpace" id="53V-wq-bat"/>
<barButtonItem style="plain" systemItem="flexibleSpace" id="93y-8j-WBh"/>
<barButtonItem title="First Unread" id="2v2-jD-C9k">
<barButtonItem image="chevron.down.circle" catalog="system" id="2v2-jD-C9k">
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="accLabelText" value="First Unread"/>
</userDefinedRuntimeAttributes>
<connections>
<action selector="firstUnread:" destination="Kyk-vK-QRX" id="d5y-x5-Qht"/>
</connections>
@@ -170,6 +140,9 @@
</toolbarItems>
<navigationItem key="navigationItem" title="Timeline" largeTitleDisplayMode="never" id="wcC-1L-ug4">
<barButtonItem key="rightBarButtonItem" image="line.horizontal.3.decrease.circle" catalog="system" id="af2-lj-EcA">
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="accLabelText" value="FIlter Articles"/>
</userDefinedRuntimeAttributes>
<connections>
<action selector="toggleFilter:" destination="Kyk-vK-QRX" id="jxP-b2-V1n"/>
</connections>
@@ -203,7 +176,7 @@
<rect key="frame" x="0.0" y="0.0" width="414" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
</tableViewCellContentView>
<color key="backgroundColor" systemColor="systemBackgroundColor" cocoaTouchSystemColor="whiteColor"/>
<color key="backgroundColor" white="0.0" alpha="0.0" colorSpace="custom" customColorSpace="genericGamma22GrayColorSpace"/>
</tableViewCell>
</prototypes>
<sections/>
@@ -212,9 +185,8 @@
<outlet property="delegate" destination="7bK-jq-Zjz" id="RA6-mI-bju"/>
</connections>
</tableView>
<toolbarItems/>
<navigationItem key="navigationItem" title="Feeds" id="Zdf-7t-Un8">
<barButtonItem key="leftBarButtonItem" title="Settings" image="gear" catalog="system" id="TlU-Pg-ATe">
<toolbarItems>
<barButtonItem title="Settings" image="gear" catalog="system" id="TlU-Pg-ATe">
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="accLabelText" value="Settings"/>
</userDefinedRuntimeAttributes>
@@ -222,16 +194,32 @@
<action selector="settings:" destination="7bK-jq-Zjz" id="Y8a-lz-Im7"/>
</connections>
</barButtonItem>
<barButtonItem key="rightBarButtonItem" image="line.horizontal.3.decrease.circle" catalog="system" id="ZJu-oJ-c1R">
<barButtonItem style="plain" systemItem="flexibleSpace" id="Rbh-Vg-Wo8"/>
<barButtonItem style="plain" systemItem="flexibleSpace" id="Vhj-bc-20A"/>
<barButtonItem systemItem="add" id="YFE-wd-vFC">
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="accLabelText" value="Add Item"/>
</userDefinedRuntimeAttributes>
<connections>
<action selector="toggleFilter:" destination="7bK-jq-Zjz" id="7lh-Bz-nfD"/>
<action selector="add:" destination="7bK-jq-Zjz" id="d1n-0d-2gR"/>
</connections>
</barButtonItem>
</toolbarItems>
<navigationItem key="navigationItem" title="Feeds" id="Zdf-7t-Un8">
<barButtonItem key="rightBarButtonItem" image="line.horizontal.3.decrease.circle" catalog="system" id="9ro-XY-5xU">
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="string" keyPath="accLabelText" value="Feeds Filter"/>
</userDefinedRuntimeAttributes>
<connections>
<action selector="toggleFilter:" destination="7bK-jq-Zjz" id="jmL-ei-avl"/>
</connections>
</barButtonItem>
</navigationItem>
<simulatedNavigationBarMetrics key="simulatedTopBarMetrics" prompted="NO"/>
<simulatedToolbarMetrics key="simulatedBottomBarMetrics"/>
<connections>
<outlet property="filterButton" destination="ZJu-oJ-c1R" id="jiO-wg-qrG"/>
<outlet property="addNewItemButton" destination="YFE-wd-vFC" id="NMJ-uE-zGh"/>
<outlet property="filterButton" destination="9ro-XY-5xU" id="PSL-lE-ITK"/>
</connections>
</tableViewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="Rux-fX-hf1" sceneMemberID="firstResponder"/>
@@ -251,6 +239,20 @@
<viewLayoutGuide key="contentLayoutGuide" id="phv-DN-krZ"/>
<viewLayoutGuide key="frameLayoutGuide" id="NNU-C8-Fsz"/>
</scrollView>
<visualEffectView opaque="NO" contentMode="scaleToFill" translatesAutoresizingMaskIntoConstraints="NO" id="bHh-pW-oTS">
<rect key="frame" x="0.0" y="862" width="414" height="0.0"/>
<view key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" insetsLayoutMarginsFromSafeArea="NO" id="StS-kO-TuW">
<rect key="frame" x="0.0" y="0.0" width="414" height="0.0"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
</view>
<blurEffect style="systemUltraThinMaterial"/>
</visualEffectView>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="center" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="eMj-1g-3xm">
<rect key="frame" x="0.0" y="862" width="414" height="0.0"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<button opaque="NO" contentMode="scaleToFill" contentHorizontalAlignment="center" contentVerticalAlignment="center" buttonType="roundedRect" lineBreakMode="middleTruncation" translatesAutoresizingMaskIntoConstraints="NO" id="RmY-a3-hUg">
<rect key="frame" x="362" y="44" width="44" height="44"/>
<constraints>
@@ -280,10 +282,17 @@
<constraints>
<constraint firstItem="RmY-a3-hUg" firstAttribute="top" secondItem="mbY-02-GFL" secondAttribute="top" id="A0i-Hs-1Ac"/>
<constraint firstAttribute="bottom" secondItem="msG-pz-EKk" secondAttribute="bottom" id="AtA-bA-jDr"/>
<constraint firstItem="eMj-1g-3xm" firstAttribute="trailing" secondItem="mbY-02-GFL" secondAttribute="trailing" id="E7e-Lv-6ZA"/>
<constraint firstItem="bHh-pW-oTS" firstAttribute="bottom" secondItem="eMj-1g-3xm" secondAttribute="bottom" id="P3m-i2-3pJ"/>
<constraint firstAttribute="trailing" secondItem="msG-pz-EKk" secondAttribute="trailing" id="R49-qV-8nm"/>
<constraint firstItem="msG-pz-EKk" firstAttribute="leading" secondItem="w6Q-vH-063" secondAttribute="leading" id="XN1-xN-hYS"/>
<constraint firstItem="eMj-1g-3xm" firstAttribute="leading" secondItem="mbY-02-GFL" secondAttribute="leading" id="Xni-Dn-I3Z"/>
<constraint firstItem="mbY-02-GFL" firstAttribute="trailing" secondItem="RmY-a3-hUg" secondAttribute="trailing" constant="8" id="Zlz-lM-LV8"/>
<constraint firstItem="mbY-02-GFL" firstAttribute="bottom" secondItem="eMj-1g-3xm" secondAttribute="bottom" id="eaS-iG-yMv"/>
<constraint firstItem="bHh-pW-oTS" firstAttribute="leading" secondItem="eMj-1g-3xm" secondAttribute="leading" id="f8r-dq-Irr"/>
<constraint firstItem="bHh-pW-oTS" firstAttribute="top" secondItem="eMj-1g-3xm" secondAttribute="top" id="gTP-i5-FYQ"/>
<constraint firstItem="msG-pz-EKk" firstAttribute="top" secondItem="w6Q-vH-063" secondAttribute="top" id="p1a-s0-wdK"/>
<constraint firstItem="bHh-pW-oTS" firstAttribute="trailing" secondItem="eMj-1g-3xm" secondAttribute="trailing" id="qB9-zk-5JN"/>
<constraint firstItem="cXR-ll-xBx" firstAttribute="leading" secondItem="mbY-02-GFL" secondAttribute="leading" constant="8" id="vJs-LN-Ydd"/>
<constraint firstItem="cXR-ll-xBx" firstAttribute="top" secondItem="mbY-02-GFL" secondAttribute="top" id="xVN-Qt-WYA"/>
</constraints>
@@ -292,6 +301,7 @@
<connections>
<outlet property="imageScrollView" destination="msG-pz-EKk" id="dGi-M6-dcO"/>
<outlet property="shareButton" destination="RmY-a3-hUg" id="Z54-ah-WAI"/>
<outlet property="titleLabel" destination="eMj-1g-3xm" id="6wF-IZ-fNw"/>
</connections>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="ZPN-tH-JAG" userLabel="First Responder" customClass="UIResponder" sceneMemberID="firstResponder"/>
@@ -393,6 +403,7 @@
<image name="circle" catalog="system" width="64" height="60"/>
<image name="gear" catalog="system" width="64" height="58"/>
<image name="line.horizontal.3.decrease.circle" catalog="system" width="64" height="60"/>
<image name="markAllAsRead" width="17" height="26"/>
<image name="multiply.circle.fill" catalog="system" width="64" height="60"/>
<image name="square.and.arrow.up" catalog="system" width="56" height="64"/>
<image name="square.and.arrow.up.fill" catalog="system" width="56" height="64"/>

View File

@@ -0,0 +1,94 @@
//
// ExtensionContainers.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 2/10/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import Account
protocol ExtensionContainer: ContainerIdentifiable, Codable {
var name: String { get }
var accountID: String { get }
}
struct ExtensionContainers: Codable {
enum CodingKeys: String, CodingKey {
case accounts
}
let accounts: [ExtensionAccount]
var flattened: [ExtensionContainer] {
return accounts.reduce([ExtensionContainer](), { (containers, account) in
var result = containers
result.append(account)
result.append(contentsOf: account.folders)
return result
})
}
func findAccount(forName name: String) -> ExtensionAccount? {
return accounts.first(where: { $0.name == name })
}
}
struct ExtensionAccount: ExtensionContainer {
enum CodingKeys: String, CodingKey {
case name
case accountID
case type
case disallowFeedInRootFolder
case containerID
case folders
}
let name: String
let accountID: String
let type: AccountType
let disallowFeedInRootFolder: Bool
let containerID: ContainerIdentifier?
let folders: [ExtensionFolder]
init(account: Account) {
self.name = account.nameForDisplay
self.accountID = account.accountID
self.type = account.type
self.disallowFeedInRootFolder = account.behaviors.contains(.disallowFeedInRootFolder)
self.containerID = account.containerID
self.folders = account.sortedFolders?.map { ExtensionFolder(folder: $0) } ?? [ExtensionFolder]()
}
func findFolder(forName name: String) -> ExtensionFolder? {
return folders.first(where: { $0.name == name })
}
}
struct ExtensionFolder: ExtensionContainer {
enum CodingKeys: String, CodingKey {
case accountName
case accountID
case name
case containerID
}
let accountName: String
let accountID: String
let name: String
let containerID: ContainerIdentifier?
init(folder: Folder) {
self.accountName = folder.account?.nameForDisplay ?? ""
self.accountID = folder.account?.accountID ?? ""
self.name = folder.nameForDisplay
self.containerID = folder.containerID
}
}

View File

@@ -0,0 +1,107 @@
//
// ExtensionContainersFile.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 2/10/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import os.log
import RSCore
import RSParser
import Account
final class ExtensionContainersFile {
private static var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "extensionContainersFile")
private static var filePath: String = {
let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as! String
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)
return containerURL!.appendingPathComponent("extension_containers.plist").path
}()
private var isDirty = false {
didSet {
queueSaveToDiskIfNeeded()
}
}
private let saveQueue = CoalescingQueue(name: "Save Queue", interval: 0.5)
init() {
if !FileManager.default.fileExists(atPath: ExtensionContainersFile.filePath) {
save()
}
NotificationCenter.default.addObserver(self, selector: #selector(markAsDirty), name: .UserDidAddAccount, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(markAsDirty), name: .UserDidDeleteAccount, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(markAsDirty), name: .AccountStateDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(markAsDirty), name: .ChildrenDidChange, object: nil)
}
/// Reads and decodes the shared plist file.
static func read() -> ExtensionContainers? {
let errorPointer: NSErrorPointer = nil
let fileCoordinator = NSFileCoordinator()
let fileURL = URL(fileURLWithPath: ExtensionContainersFile.filePath)
var extensionContainers: ExtensionContainers? = nil
fileCoordinator.coordinate(readingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { readURL in
if let fileData = try? Data(contentsOf: readURL) {
let decoder = PropertyListDecoder()
extensionContainers = try? decoder.decode(ExtensionContainers.self, from: fileData)
}
})
if let error = errorPointer?.pointee {
os_log(.error, log: log, "Read from disk coordination failed: %@.", error.localizedDescription)
}
return extensionContainers
}
}
private extension ExtensionContainersFile {
@objc func markAsDirty() {
isDirty = true
}
func queueSaveToDiskIfNeeded() {
saveQueue.add(self, #selector(saveToDiskIfNeeded))
}
@objc func saveToDiskIfNeeded() {
if isDirty {
isDirty = false
save()
}
}
func save() {
let encoder = PropertyListEncoder()
encoder.outputFormat = .binary
let errorPointer: NSErrorPointer = nil
let fileCoordinator = NSFileCoordinator()
let fileURL = URL(fileURLWithPath: ExtensionContainersFile.filePath)
fileCoordinator.coordinate(writingItemAt: fileURL, options: [], error: errorPointer, byAccessor: { writeURL in
do {
let extensionAccounts = AccountManager.shared.sortedActiveAccounts.map { ExtensionAccount(account: $0) }
let extensionContainers = ExtensionContainers(accounts: extensionAccounts)
let data = try encoder.encode(extensionContainers)
try data.write(to: writeURL)
} catch let error as NSError {
os_log(.error, log: Self.log, "Save to disk failed: %@.", error.localizedDescription)
}
})
if let error = errorPointer?.pointee {
os_log(.error, log: Self.log, "Save to disk coordination failed: %@.", error.localizedDescription)
}
}
}

View File

@@ -0,0 +1,24 @@
//
// ExtensionFeedAddRequest.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 2/10/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import Account
struct ExtensionFeedAddRequest: Codable {
enum CodingKeys: String, CodingKey {
case name
case feedURL
case destinationContainerID
}
let name: String?
let feedURL: URL
let destinationContainerID: ContainerIdentifier
}

View File

@@ -0,0 +1,160 @@
//
// ExtensionFeedAddRequestFile.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 2/11/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import os.log
import Account
final class ExtensionFeedAddRequestFile: NSObject, NSFilePresenter {
private static var log = OSLog(subsystem: Bundle.main.bundleIdentifier!, category: "extensionFeedAddRequestFile")
private static var filePath: String = {
let appGroup = Bundle.main.object(forInfoDictionaryKey: "AppGroup") as! String
let containerURL = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)
return containerURL!.appendingPathComponent("extension_feed_add_request.plist").path
}()
private let operationQueue: OperationQueue
var presentedItemURL: URL? {
return URL(fileURLWithPath: ExtensionFeedAddRequestFile.filePath)
}
var presentedItemOperationQueue: OperationQueue {
return operationQueue
}
override init() {
operationQueue = OperationQueue()
operationQueue.maxConcurrentOperationCount = 1
super.init()
NSFileCoordinator.addFilePresenter(self)
process()
}
func presentedItemDidChange() {
DispatchQueue.main.async {
self.process()
}
}
func resume() {
NSFileCoordinator.addFilePresenter(self)
process()
}
func suspend() {
NSFileCoordinator.removeFilePresenter(self)
}
static func save(_ feedAddRequest: ExtensionFeedAddRequest) {
let decoder = PropertyListDecoder()
let encoder = PropertyListEncoder()
encoder.outputFormat = .binary
let errorPointer: NSErrorPointer = nil
let fileCoordinator = NSFileCoordinator()
let fileURL = URL(fileURLWithPath: ExtensionFeedAddRequestFile.filePath)
fileCoordinator.coordinate(writingItemAt: fileURL, options: [.forMerging], error: errorPointer, byAccessor: { url in
do {
var requests: [ExtensionFeedAddRequest]
if let fileData = try? Data(contentsOf: url),
let decodedRequests = try? decoder.decode([ExtensionFeedAddRequest].self, from: fileData) {
requests = decodedRequests
} else {
requests = [ExtensionFeedAddRequest]()
}
requests.append(feedAddRequest)
let data = try encoder.encode(requests)
try data.write(to: url)
} catch let error as NSError {
os_log(.error, log: Self.log, "Save to disk failed: %@.", error.localizedDescription)
}
})
if let error = errorPointer?.pointee {
os_log(.error, log: Self.log, "Save to disk coordination failed: %@.", error.localizedDescription)
}
}
}
private extension ExtensionFeedAddRequestFile {
func process() {
let decoder = PropertyListDecoder()
let encoder = PropertyListEncoder()
encoder.outputFormat = .binary
let errorPointer: NSErrorPointer = nil
let fileCoordinator = NSFileCoordinator(filePresenter: self)
let fileURL = URL(fileURLWithPath: ExtensionFeedAddRequestFile.filePath)
var requests: [ExtensionFeedAddRequest]? = nil
fileCoordinator.coordinate(writingItemAt: fileURL, options: [.forMerging], error: errorPointer, byAccessor: { url in
do {
if let fileData = try? Data(contentsOf: url),
let decodedRequests = try? decoder.decode([ExtensionFeedAddRequest].self, from: fileData) {
requests = decodedRequests
}
let data = try encoder.encode([ExtensionFeedAddRequest]())
try data.write(to: url)
} catch let error as NSError {
os_log(.error, log: Self.log, "Save to disk failed: %@.", error.localizedDescription)
}
})
if let error = errorPointer?.pointee {
os_log(.error, log: Self.log, "Save to disk coordination failed: %@.", error.localizedDescription)
}
requests?.forEach { processRequest($0) }
}
func processRequest(_ request: ExtensionFeedAddRequest) {
var destinationAccountID: String? = nil
switch request.destinationContainerID {
case .account(let accountID):
destinationAccountID = accountID
case .folder(let accountID, _):
destinationAccountID = accountID
default:
break
}
guard let accountID = destinationAccountID, let account = AccountManager.shared.existingAccount(with: accountID) else {
return
}
var destinationContainer: Container? = nil
if account.containerID == request.destinationContainerID {
destinationContainer = account
} else {
destinationContainer = account.folders?.first(where: { $0.containerID == request.destinationContainerID })
}
guard let container = destinationContainer else { return }
account.createWebFeed(url: request.feedURL.absoluteString, name: request.name, container: container) { _ in }
}
}

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15505" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="15702" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES">
<device id="retina6_1" orientation="portrait" appearance="light"/>
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15509"/>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="15704"/>
<capability name="Named colors" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
@@ -252,27 +252,45 @@
</tableViewSection>
<tableViewSection headerTitle="Home Page" id="dTd-6q-SZd">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="0zc-o6-Sjh">
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" id="0zc-o6-Sjh" customClass="VibrantBasicTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="204.5" width="374" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="0zc-o6-Sjh" id="vJs-XK-ebf">
<rect key="frame" x="0.0" y="0.0" width="374" height="43.5"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="natural" lineBreakMode="characterWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="HUP-Cu-FGT" customClass="InteractiveLabel" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="11" width="334" height="22"/>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="" textAlignment="natural" lineBreakMode="characterWrap" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="HUP-Cu-FGT">
<rect key="frame" x="20" y="11" width="301" height="22"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<imageView clipsSubviews="YES" userInteractionEnabled="NO" contentMode="scaleAspectFit" horizontalHuggingPriority="251" verticalHuggingPriority="251" image="safari" catalog="system" translatesAutoresizingMaskIntoConstraints="NO" id="p82-kn-lfh">
<rect key="frame" x="329" y="10" width="25" height="24"/>
<color key="tintColor" name="primaryAccentColor"/>
<constraints>
<constraint firstAttribute="width" constant="25" id="Suu-bu-Lar"/>
<constraint firstAttribute="height" constant="25" id="gD0-zu-2pr"/>
</constraints>
</imageView>
</subviews>
<constraints>
<constraint firstItem="p82-kn-lfh" firstAttribute="leading" secondItem="HUP-Cu-FGT" secondAttribute="trailing" constant="8" symbolic="YES" id="4Ze-t9-SMR"/>
<constraint firstAttribute="trailing" secondItem="p82-kn-lfh" secondAttribute="trailing" constant="20" symbolic="YES" id="69m-bk-BHO"/>
<constraint firstItem="HUP-Cu-FGT" firstAttribute="leading" secondItem="vJs-XK-ebf" secondAttribute="leadingMargin" id="GrO-sc-ZMe"/>
<constraint firstAttribute="bottomMargin" secondItem="HUP-Cu-FGT" secondAttribute="bottom" id="Jtq-bB-vJN"/>
<constraint firstAttribute="trailingMargin" secondItem="HUP-Cu-FGT" secondAttribute="trailing" id="Nbf-dl-g4K"/>
<constraint firstItem="p82-kn-lfh" firstAttribute="centerY" secondItem="vJs-XK-ebf" secondAttribute="centerY" id="f1u-Mm-Arn"/>
<constraint firstItem="HUP-Cu-FGT" firstAttribute="top" secondItem="vJs-XK-ebf" secondAttribute="topMargin" id="lBd-G7-RdW"/>
</constraints>
</tableViewCellContentView>
<userDefinedRuntimeAttributes>
<userDefinedRuntimeAttribute type="image" keyPath="imageNormal" value="safari" catalog="system"/>
<userDefinedRuntimeAttribute type="image" keyPath="imageSelected" value="safari.fill" catalog="system"/>
</userDefinedRuntimeAttributes>
<connections>
<outlet property="icon" destination="p82-kn-lfh" id="qYr-gp-cbS"/>
<outlet property="label" destination="HUP-Cu-FGT" id="FWP-ba-TIm"/>
</connections>
</tableViewCell>
</cells>
</tableViewSection>
@@ -361,6 +379,8 @@
</scene>
</scenes>
<resources>
<image name="safari" catalog="system" width="64" height="60"/>
<image name="safari.fill" catalog="system" width="64" height="60"/>
<namedColor name="deleteBackgroundColor">
<color red="1" green="0.23100000619888306" blue="0.18799999356269836" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
</namedColor>

View File

@@ -8,6 +8,7 @@
import UIKit
import Account
import SafariServices
class WebFeedInspectorViewController: UITableViewController {
@@ -25,12 +26,18 @@ class WebFeedInspectorViewController: UITableViewController {
if let feedIcon = appDelegate.webFeedIconDownloader.icon(for: webFeed) {
return feedIcon
}
if let favicon = appDelegate.faviconDownloader.favicon(for: webFeed) {
if let favicon = appDelegate.faviconDownloader.faviconAsIcon(for: webFeed) {
return favicon
}
return FaviconGenerator.favicon(webFeed)
}
private let homePageIndexPath = IndexPath(row: 0, section: 1)
private var shouldHideHomePageSection: Bool {
return webFeed.homePageURL == nil
}
override func viewDidLoad() {
tableView.register(InspectorIconHeaderView.self, forHeaderFooterViewReuseIdentifier: "SectionHeader")
@@ -71,23 +78,71 @@ class WebFeedInspectorViewController: UITableViewController {
dismiss(animated: true)
}
/// Returns a new indexPath, taking into consideration any
/// conditions that may require the tableView to be
/// displayed differently than what is setup in the storyboard.
private func shift(_ indexPath: IndexPath) -> IndexPath {
return IndexPath(row: indexPath.row, section: shift(indexPath.section))
}
/// Returns a new section, taking into consideration any
/// conditions that may require the tableView to be
/// displayed differently than what is setup in the storyboard.
private func shift(_ section: Int) -> Int {
if section >= homePageIndexPath.section && shouldHideHomePageSection {
return section + 1
}
return section
}
}
// MARK: Table View
extension WebFeedInspectorViewController {
override func numberOfSections(in tableView: UITableView) -> Int {
let numberOfSections = super.numberOfSections(in: tableView)
return shouldHideHomePageSection ? numberOfSections - 1 : numberOfSections
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return super.tableView(tableView, numberOfRowsInSection: shift(section))
}
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: section)
return section == 0 ? ImageHeaderView.rowHeight : super.tableView(tableView, heightForHeaderInSection: shift(section))
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
super.tableView(tableView, cellForRowAt: shift(indexPath))
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
super.tableView(tableView, titleForHeaderInSection: shift(section))
}
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
if section == 0 {
if shift(section) == 0 {
headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "SectionHeader") as? InspectorIconHeaderView
headerView?.iconView.iconImage = iconImage
return headerView
} else {
return super.tableView(tableView, viewForHeaderInSection: section)
return super.tableView(tableView, viewForHeaderInSection: shift(section))
}
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
if shift(indexPath) == homePageIndexPath,
let homePageUrlString = webFeed.homePageURL,
let homePageUrl = URL(string: homePageUrlString) {
let safari = SFSafariViewController(url: homePageUrl)
safari.modalPresentationStyle = .pageSheet
present(safari, animated: true) {
tableView.deselectRow(at: indexPath, animated: true)
}
}
}

View File

@@ -7,15 +7,24 @@
//
import Intents
import Account
public enum AddWebFeedIntentHandlerError: LocalizedError {
case communicationFailure
public var errorDescription: String? {
switch self {
case .communicationFailure:
return NSLocalizedString("Unable to communicate with NetNewsWire.", comment: "Communication failure")
}
}
}
public class AddWebFeedIntentHandler: NSObject, AddWebFeedIntentHandling {
override init() {
super.init()
DispatchQueue.main.sync {
AccountManager.shared = AccountManager()
}
}
public func resolveUrl(for intent: AddWebFeedIntent, with completion: @escaping (AddWebFeedUrlResolutionResult) -> Void) {
@@ -27,10 +36,13 @@ public class AddWebFeedIntentHandler: NSObject, AddWebFeedIntentHandling {
}
public func provideAccountNameOptions(for intent: AddWebFeedIntent, with completion: @escaping ([String]?, Error?) -> Void) {
DispatchQueue.main.async {
let accountNames = AccountManager.shared.activeAccounts.compactMap { $0.nameForDisplay }
completion(accountNames, nil)
guard let extensionContainers = ExtensionContainersFile.read() else {
completion(nil, AddWebFeedIntentHandlerError.communicationFailure)
return
}
let accountNames = extensionContainers.accounts.map { $0.name }
completion(accountNames, nil)
}
public func resolveAccountName(for intent: AddWebFeedIntent, with completion: @escaping (AddWebFeedAccountNameResolutionResult) -> Void) {
@@ -38,25 +50,32 @@ public class AddWebFeedIntentHandler: NSObject, AddWebFeedIntentHandling {
completion(AddWebFeedAccountNameResolutionResult.notRequired())
return
}
DispatchQueue.main.async {
if AccountManager.shared.findActiveAccount(forDisplayName: accountName) == nil {
completion(.unsupported(forReason: .invalid))
} else {
completion(.success(with: accountName))
}
guard let extensionContainers = ExtensionContainersFile.read() else {
completion(.unsupported(forReason: .communication))
return
}
if extensionContainers.findAccount(forName: accountName) == nil {
completion(.unsupported(forReason: .invalid))
} else {
completion(.success(with: accountName))
}
}
public func provideFolderNameOptions(for intent: AddWebFeedIntent, with completion: @escaping ([String]?, Error?) -> Void) {
DispatchQueue.main.async {
guard let accountName = intent.accountName, let account = AccountManager.shared.findActiveAccount(forDisplayName: accountName) else {
completion([String](), nil)
return
}
let folderNames = account.folders?.map { $0.nameForDisplay }
completion(folderNames, nil)
guard let extensionContainers = ExtensionContainersFile.read() else {
completion(nil, AddWebFeedIntentHandlerError.communicationFailure)
return
}
guard let accountName = intent.accountName, let account = extensionContainers.findAccount(forName: accountName) else {
completion([String](), nil)
return
}
let folderNames = account.folders.map { $0.name }
completion(folderNames, nil)
}
public func resolveFolderName(for intent: AddWebFeedIntent, with completion: @escaping (AddWebFeedFolderNameResolutionResult) -> Void) {
@@ -65,73 +84,60 @@ public class AddWebFeedIntentHandler: NSObject, AddWebFeedIntentHandling {
return
}
DispatchQueue.main.async {
guard let account = AccountManager.shared.findActiveAccount(forDisplayName: accountName) else {
completion(.unsupported(forReason: .invalid))
return
}
if account.findFolder(withDisplayName: folderName) == nil {
completion(.unsupported(forReason: .invalid))
} else {
completion(.success(with: folderName))
}
guard let extensionContainers = ExtensionContainersFile.read() else {
completion(.unsupported(forReason: .communication))
return
}
guard let account = extensionContainers.findAccount(forName: accountName) else {
completion(.unsupported(forReason: .invalid))
return
}
if account.findFolder(forName: folderName) == nil {
completion(.unsupported(forReason: .invalid))
} else {
completion(.success(with: folderName))
}
return
}
public func handle(intent: AddWebFeedIntent, completion: @escaping (AddWebFeedIntentResponse) -> Void) {
guard let url = intent.url else {
guard let url = intent.url, let extensionContainers = ExtensionContainersFile.read() else {
completion(AddWebFeedIntentResponse(code: .failure, userActivity: nil))
return
}
DispatchQueue.main.async {
let account: Account? = {
if let accountName = intent.accountName {
return AccountManager.shared.findActiveAccount(forDisplayName: accountName)
} else {
return AccountManager.shared.sortedActiveAccounts.first
}
}()
guard let validAccount = account else {
completion(AddWebFeedIntentResponse(code: .failure, userActivity: nil))
return
}
let container: Container? = {
if let folderName = intent.folderName {
return validAccount.findFolder(withDisplayName: folderName)
} else {
return validAccount
}
}()
guard let validContainer = container else {
completion(AddWebFeedIntentResponse(code: .failure, userActivity: nil))
return
let account: ExtensionAccount? = {
if let accountName = intent.accountName {
return extensionContainers.findAccount(forName: accountName)
} else {
return extensionContainers.accounts.first
}
}()
validAccount.createWebFeed(url: url.absoluteString, name: nil, container: validContainer) { result in
switch result {
case .success:
AccountManager.shared.suspendNetworkAll()
AccountManager.shared.suspendDatabaseAll()
completion(AddWebFeedIntentResponse(code: .success, userActivity: nil))
case .failure(let error):
switch error {
case AccountError.createErrorNotFound:
completion(AddWebFeedIntentResponse(code: .feedNotFound, userActivity: nil))
case AccountError.createErrorAlreadySubscribed:
completion(AddWebFeedIntentResponse(code: .alreadySubscribed, userActivity: nil))
default:
completion(AddWebFeedIntentResponse(code: .failure, userActivity: nil))
}
}
}
guard let validAccount = account else {
completion(AddWebFeedIntentResponse(code: .failure, userActivity: nil))
return
}
let container: ExtensionContainer? = {
if let folderName = intent.folderName {
return validAccount.findFolder(forName: folderName)
} else {
return validAccount
}
}()
guard let validContainer = container, let containerID = validContainer.containerID else {
completion(AddWebFeedIntentResponse(code: .failure, userActivity: nil))
return
}
let request = ExtensionFeedAddRequest(name: nil, feedURL: url, destinationContainerID: containerID)
ExtensionFeedAddRequestFile.save(request)
completion(AddWebFeedIntentResponse(code: .success, userActivity: nil))
}
}

View File

@@ -9,7 +9,7 @@
<key>INIntentDefinitionNamespace</key>
<string>U6u7RF</string>
<key>INIntentDefinitionSystemVersion</key>
<string>19B88</string>
<string>19D76</string>
<key>INIntentDefinitionToolsBuildVersion</key>
<string>11B53</string>
<key>INIntentDefinitionToolsVersion</key>
@@ -177,6 +177,16 @@
<key>INIntentParameterUnsupportedReasonFormatStringID</key>
<string>JGkCuS</string>
</dict>
<dict>
<key>INIntentParameterUnsupportedReasonCode</key>
<string>communication</string>
<key>INIntentParameterUnsupportedReasonCustom</key>
<true/>
<key>INIntentParameterUnsupportedReasonFormatString</key>
<string>Unable to communicate with NetNewsWire.</string>
<key>INIntentParameterUnsupportedReasonFormatStringID</key>
<string>uSfloN</string>
</dict>
</array>
</dict>
<dict>
@@ -259,6 +269,16 @@
<key>INIntentParameterUnsupportedReasonFormatStringID</key>
<string>ef5kBt</string>
</dict>
<dict>
<key>INIntentParameterUnsupportedReasonCode</key>
<string>communication</string>
<key>INIntentParameterUnsupportedReasonCustom</key>
<true/>
<key>INIntentParameterUnsupportedReasonFormatString</key>
<string>Unable to communicate with NetNewsWire.</string>
<key>INIntentParameterUnsupportedReasonFormatStringID</key>
<string>ExjqcE</string>
</dict>
</array>
</dict>
</array>
@@ -276,30 +296,6 @@
<key>INIntentResponseCodeName</key>
<string>failure</string>
</dict>
<dict>
<key>INIntentResponseCodeConciseFormatString</key>
<string>You are already subscribed to this feed in this account.</string>
<key>INIntentResponseCodeConciseFormatStringID</key>
<string>srME8b</string>
<key>INIntentResponseCodeFormatString</key>
<string>You are already subscribed to this feed in this account.</string>
<key>INIntentResponseCodeFormatStringID</key>
<string>UGGPkp</string>
<key>INIntentResponseCodeName</key>
<string>alreadySubscribed</string>
</dict>
<dict>
<key>INIntentResponseCodeConciseFormatString</key>
<string>No feed was found at the specified URL.</string>
<key>INIntentResponseCodeConciseFormatStringID</key>
<string>8Dh9Yy</string>
<key>INIntentResponseCodeFormatString</key>
<string>No feed was found at the specified URL.</string>
<key>INIntentResponseCodeFormatStringID</key>
<string>drQfaI</string>
<key>INIntentResponseCodeName</key>
<string>feedNotFound</string>
</dict>
</array>
</dict>
<key>INIntentTitle</key>

View File

@@ -168,8 +168,11 @@ private extension KeyboardManager {
let toggleReadTitle = NSLocalizedString("Toggle Read Status", comment: "Toggle Read Status")
keys.append(KeyboardManager.createKeyCommand(title: toggleReadTitle, action: "toggleRead:", input: "u", modifiers: [.command, .shift]))
let markOlderAsReadTitle = NSLocalizedString("Mark Older as Read", comment: "Mark Older as Read")
keys.append(KeyboardManager.createKeyCommand(title: markOlderAsReadTitle, action: "markOlderArticlesAsRead:", input: "k", modifiers: [.command, .shift]))
let markAboveAsReadTitle = NSLocalizedString("Mark Above as Read", comment: "Mark Above as Read")
keys.append(KeyboardManager.createKeyCommand(title: markAboveAsReadTitle, action: "markAboveAsRead:", input: "k", modifiers: [.command, .control]))
let markBelowAsReadTitle = NSLocalizedString("Mark Below as Read", comment: "Mark Below as Read")
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]))

View File

@@ -42,7 +42,6 @@ class MasterFeedTableViewSectionHeader: UITableViewHeaderFooterView {
set {
if titleView.text != newValue {
titleView.text = newValue
setNeedsDisplay()
setNeedsLayout()
}
}
@@ -50,7 +49,7 @@ class MasterFeedTableViewSectionHeader: UITableViewHeaderFooterView {
var disclosureExpanded = false {
didSet {
updateExpandedState()
updateExpandedState(animate: true)
updateUnreadCountView()
}
}
@@ -105,7 +104,10 @@ class MasterFeedTableViewSectionHeader: UITableViewHeaderFooterView {
override func layoutSubviews() {
super.layoutSubviews()
let layout = MasterFeedTableViewSectionHeaderLayout(cellWidth: bounds.size.width, insets: safeAreaInsets, label: titleView, unreadCountView: unreadCountView)
let layout = MasterFeedTableViewSectionHeaderLayout(cellWidth: contentView.bounds.size.width,
insets: contentView.safeAreaInsets,
label: titleView,
unreadCountView: unreadCountView)
layoutWith(layout)
}
@@ -116,19 +118,22 @@ private extension MasterFeedTableViewSectionHeader {
func commonInit() {
addSubviewAtInit(unreadCountView)
addSubviewAtInit(titleView)
updateExpandedState()
addSubviewAtInit(disclosureView)
updateExpandedState(animate: false)
addBackgroundView()
addSubviewAtInit(topSeparatorView)
addSubviewAtInit(bottomSeparatorView)
}
func updateExpandedState() {
func updateExpandedState(animate: Bool) {
if !isLastSection && self.disclosureExpanded {
self.bottomSeparatorView.isHidden = false
}
let duration = animate ? 0.3 : 0.0
UIView.animate(
withDuration: 0.3,
withDuration: duration,
animations: {
if self.disclosureExpanded {
self.disclosureView.transform = CGAffineTransform(rotationAngle: 1.570796)
@@ -155,7 +160,7 @@ private extension MasterFeedTableViewSectionHeader {
}
func addSubviewAtInit(_ view: UIView) {
addSubview(view)
contentView.addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
}

View File

@@ -16,7 +16,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
@IBOutlet weak var filterButton: UIBarButtonItem!
private var refreshProgressView: RefreshProgressView?
private var addNewItemButton: UIBarButtonItem!
@IBOutlet weak var addNewItemButton: UIBarButtonItem!
lazy var dataSource = makeDataSource()
var undoableCommands = [UndoableCommand]()
@@ -58,8 +58,6 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(webFeedIconDidBecomeAvailable(_:)), name: .WebFeedIconDidBecomeAvailable, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(webFeedSettingDidChange(_:)), name: .WebFeedSettingDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(webFeedMetadataDidChange(_:)), name: .WebFeedMetadataDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(userDidAddFeed(_:)), name: .UserDidAddFeed, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
@@ -107,8 +105,8 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
}
var node: Node? = nil
if let coordinator = representedObject as? SceneCoordinator, let fetcher = coordinator.timelineFeed {
node = coordinator.rootNode.descendantNodeRepresentingObject(fetcher as AnyObject)
if let coordinator = representedObject as? SceneCoordinator, let feed = coordinator.timelineFeed {
node = coordinator.rootNode.descendantNodeRepresentingObject(feed as AnyObject)
} else {
node = coordinator.rootNode.descendantNodeRepresentingObject(representedObject as AnyObject)
}
@@ -117,10 +115,9 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
// completing if called to soon after a selectRow where scrolling is necessary. See discloseFeed.
if let node = node,
let indexPath = dataSource.indexPath(for: node),
let cell = tableView.cellForRow(at: indexPath) as? MasterFeedTableViewCell,
let unreadCountProvider = node.representedObject as? UnreadCountProvider {
let cell = tableView.cellForRow(at: indexPath) as? MasterFeedTableViewCell {
if cell.unreadCount != unreadCountProvider.unreadCount {
if cell.unreadCount != coordinator.unreadCountFor(node) {
self.reloadNode(node)
}
@@ -151,13 +148,6 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
reloadAllVisibleCells()
}
@objc func userDidAddFeed(_ notification: Notification) {
guard let webFeed = notification.userInfo?[UserInfoKey.webFeed] as? WebFeed else {
return
}
discloseFeed(webFeed, animated: true)
}
@objc func contentSizeCategoryDidChange(_ note: Notification) {
resetEstimatedRowHeight()
applyChanges(animated: false)
@@ -214,6 +204,11 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
headerView.gestureRecognizers?.removeAll()
let tap = UITapGestureRecognizer(target: self, action:#selector(self.toggleSectionHeader(_:)))
headerView.addGestureRecognizer(tap)
// Without this the swipe gesture registers on the cell below
let gestureRecognizer = UIPanGestureRecognizer(target: nil, action: nil)
gestureRecognizer.delegate = self
headerView.addGestureRecognizer(gestureRecognizer)
headerView.interactions.removeAll()
if section != 0 {
@@ -281,6 +276,10 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
alert.addAction(action)
}
if let action = self.markAllAsReadAlertAction(indexPath: indexPath, completion: completion) {
alert.addAction(action)
}
let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel")
alert.addAction(UIAlertAction(title: cancelTitle, style: .cancel) { _ in
completion(true)
@@ -301,13 +300,17 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
}
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let node = dataSource.itemIdentifier(for: indexPath), !(node.representedObject is PseudoFeed) else {
guard let node = dataSource.itemIdentifier(for: indexPath) else {
return nil
}
if node.representedObject is WebFeed {
return makeFeedContextMenu(node: node, indexPath: indexPath, includeDeleteRename: true)
} else {
} else if node.representedObject is Folder {
return makeFolderContextMenu(node: node, indexPath: indexPath)
} else if node.representedObject is PseudoFeed {
return makePseudoFeedContextMenu(node: node, indexPath: indexPath)
} else {
return nil
}
}
@@ -324,7 +327,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
becomeFirstResponder()
coordinator.selectFeed(indexPath, animated: true)
coordinator.selectFeed(indexPath: indexPath, animations: [.navigation, .select, .scroll])
}
override func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath {
@@ -398,10 +401,10 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
@IBAction func toggleFilter(_ sender: Any) {
if coordinator.isReadFeedsFiltered {
filterButton.image = AppAssets.filterInactiveImage
setFilterButtonToInactive()
coordinator.showAllFeeds()
} else {
filterButton.image = AppAssets.filterActiveImage
setFilterButtonToActive()
coordinator.hideReadFeeds()
}
}
@@ -433,10 +436,15 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
@objc func refreshAccounts(_ sender: Any) {
refreshControl?.endRefreshing()
// This is a hack to make sure that an error dialog doesn't interfere with dismissing the refreshControl.
// If the error dialog appears too closely to the call to endRefreshing, then the refreshControl never disappears.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.present(self))
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.present(self)) {
if AppDefaults.refreshClearsReadArticles {
self.coordinator.refreshTimeline(resetScroll: false)
}
}
}
}
@@ -501,26 +509,35 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
func restoreSelectionIfNecessary(adjustScroll: Bool) {
if let indexPath = coordinator.masterFeedIndexPathForCurrentTimeline() {
if adjustScroll {
tableView.selectRowAndScrollIfNotVisible(at: indexPath, animated: false)
tableView.selectRowAndScrollIfNotVisible(at: indexPath, animations: [])
} else {
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
}
}
}
func updateFeedSelection(animated: Bool) {
func updateFeedSelection(animations: Animations) {
if dataSource.snapshot().numberOfItems > 0 {
if let indexPath = coordinator.currentFeedIndexPath {
if tableView.indexPathForSelectedRow != indexPath {
tableView.selectRowAndScrollIfNotVisible(at: indexPath, animated: animated)
tableView.selectRowAndScrollIfNotVisible(at: indexPath, animations: animations)
}
} else {
tableView.selectRow(at: nil, animated: animated, scrollPosition: .none)
if animations.contains(.select) {
// This nasty bit of duct tape is because there is something, somewhere
// interrupting the deselection animation, which will leave the row selected.
// This seems to get it far enough away the problem that it always works.
DispatchQueue.main.async {
self.tableView.selectRow(at: nil, animated: true, scrollPosition: .none)
}
} else {
self.tableView.selectRow(at: nil, animated: false, scrollPosition: .none)
}
}
}
}
func reloadFeeds(initialLoad: Bool) {
func reloadFeeds(initialLoad: Bool, completion: (() -> Void)? = nil) {
updateUI()
// We have to reload all the visible cells because if we got here by doing a table cell move,
@@ -528,75 +545,13 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
// drops on a "folder" that should cause the dropped cell to disappear.
applyChanges(animated: !initialLoad) { [weak self] in
if !initialLoad {
self?.reloadAllVisibleCells()
self?.reloadAllVisibleCells(completion: completion)
} else {
completion?()
}
}
}
func ensureSectionIsExpanded(_ sectionIndex: Int, completion: (() -> Void)? = nil) {
guard let sectionNode = coordinator.rootNode.childAtIndex(sectionIndex) else {
return
}
if !coordinator.isExpanded(sectionNode) {
coordinator.expand(sectionNode)
self.applyChanges(animated: true) {
completion?()
}
} else {
completion?()
}
}
func discloseFeed(_ webFeed: WebFeed, animated: Bool, completion: (() -> Void)? = nil) {
func discloseFeedInAccount() {
guard let node = coordinator.rootNode.descendantNodeRepresentingObject(webFeed as AnyObject) else {
completion?()
return
}
if let indexPath = dataSource.indexPath(for: node) {
coordinator.selectFeed(indexPath, animated: animated) {
completion?()
}
return
}
// It wasn't already visable, so expand its folder and try again
guard let parent = node.parent else {
completion?()
return
}
coordinator.expand(parent)
reloadNode(parent)
applyChanges(animated: true, adjustScroll: true) { [weak self] in
if let indexPath = self?.dataSource.indexPath(for: node) {
self?.coordinator.selectFeed(indexPath, animated: animated) {
completion?()
}
}
}
}
// If the account for the feed is collapsed, expand it
if let account = webFeed.account,
let accountNode = coordinator.rootNode.childNodeRepresentingObject(account as AnyObject),
!coordinator.isExpanded(accountNode) {
coordinator.expand(accountNode)
applyChanges(animated: false) {
discloseFeedInAccount()
}
} else {
discloseFeedInAccount()
}
}
func focus() {
becomeFirstResponder()
}
@@ -618,7 +573,14 @@ extension MasterFeedViewController: UIContextMenuInteractionDelegate {
return UIContextMenuConfiguration(identifier: sectionIndex as NSCopying, previewProvider: nil) { suggestedActions in
let accountInfoAction = self.getAccountInfoAction(account: account)
let deactivateAction = self.deactivateAccountAction(account: account)
return UIMenu(title: "", children: [accountInfoAction, deactivateAction])
var actions = [accountInfoAction, deactivateAction]
if let markAllAction = self.markAllAsReadAction(account: account) {
actions.insert(markAllAction, at: 1)
}
return UIMenu(title: "", children: actions)
}
}
@@ -657,23 +619,30 @@ private extension MasterFeedViewController {
}
self.refreshProgressView = refreshProgressView
let refreshProgressItemButton = UIBarButtonItem(customView: refreshProgressView)
let spaceItemButton = UIBarButtonItem(barButtonSystemItem: .flexibleSpace, target: nil, action: nil)
addNewItemButton = UIBarButtonItem(barButtonSystemItem: .add, target: self, action: #selector(add(_:)))
setToolbarItems([refreshProgressItemButton, spaceItemButton, addNewItemButton], animated: false)
toolbarItems?.insert(refreshProgressItemButton, at: 2)
}
func updateUI() {
if coordinator.isReadFeedsFiltered {
filterButton.image = AppAssets.filterActiveImage
setFilterButtonToActive()
} else {
filterButton.image = AppAssets.filterInactiveImage
setFilterButtonToInactive()
}
refreshProgressView?.updateRefreshLabel()
addNewItemButton?.isEnabled = !AccountManager.shared.activeAccounts.isEmpty
}
func setFilterButtonToActive() {
filterButton?.image = AppAssets.filterActiveImage
filterButton?.accLabelText = NSLocalizedString("Selected - Filter Read Feeds", comment: "Selected - Filter Read Feeds")
}
func setFilterButtonToInactive() {
filterButton?.image = AppAssets.filterInactiveImage
filterButton?.accLabelText = NSLocalizedString("Filter Read Feeds", comment: "Filter Read Feeds")
}
func reloadNode(_ node: Node) {
var snapshot = dataSource.snapshot()
snapshot.reloadItems([node])
@@ -756,7 +725,7 @@ private extension MasterFeedViewController {
return feedIconImage
}
if let faviconImage = appDelegate.faviconDownloader.favicon(for: webFeed) {
if let faviconImage = appDelegate.faviconDownloader.faviconAsIcon(for: webFeed) {
return faviconImage
}
@@ -797,16 +766,17 @@ private extension MasterFeedViewController {
}
}
private func reloadAllVisibleCells() {
private func reloadAllVisibleCells(completion: (() -> Void)? = nil) {
let visibleNodes = tableView.indexPathsForVisibleRows!.compactMap { return dataSource.itemIdentifier(for: $0) }
reloadCells(visibleNodes)
reloadCells(visibleNodes, completion: completion)
}
private func reloadCells(_ nodes: [Node]) {
private func reloadCells(_ nodes: [Node], completion: (() -> Void)? = nil) {
var snapshot = dataSource.snapshot()
snapshot.reloadItems(nodes)
dataSource.apply(snapshot, animatingDifferences: false) { [weak self] in
self?.restoreSelectionIfNecessary(adjustScroll: false)
completion?()
}
}
@@ -828,9 +798,7 @@ private extension MasterFeedViewController {
return
}
coordinator.expand(node)
applyChanges(animated: true) { [weak self] in
self?.reloadNode(node)
}
applyChanges(animated: true)
}
func collapse(_ cell: MasterFeedTableViewCell) {
@@ -838,9 +806,7 @@ private extension MasterFeedViewController {
return
}
coordinator.collapse(node)
applyChanges(animated: true) { [weak self] in
self?.reloadNode(node)
}
applyChanges(animated: true)
}
func makeFeedContextMenu(node: Node, indexPath: IndexPath, includeDeleteRename: Bool) -> UIContextMenuConfiguration {
@@ -865,10 +831,14 @@ private extension MasterFeedViewController {
if let copyHomePageAction = self.copyHomePageAction(indexPath: indexPath) {
actions.append(copyHomePageAction)
}
if let markAllAction = self.markAllAsReadAction(indexPath: indexPath) {
actions.append(markAllAction)
}
if includeDeleteRename {
actions.append(self.deleteAction(indexPath: indexPath))
actions.append(self.renameAction(indexPath: indexPath))
actions.append(self.deleteAction(indexPath: indexPath))
}
return UIMenu(title: "", children: actions)
@@ -885,12 +855,26 @@ private extension MasterFeedViewController {
var actions = [UIAction]()
actions.append(self.deleteAction(indexPath: indexPath))
actions.append(self.renameAction(indexPath: indexPath))
if let markAllAction = self.markAllAsReadAction(indexPath: indexPath) {
actions.append(markAllAction)
}
return UIMenu(title: "", children: actions)
})
}
func makePseudoFeedContextMenu(node: Node, indexPath: IndexPath) -> UIContextMenuConfiguration? {
guard let markAllAction = self.markAllAsReadAction(indexPath: indexPath) else {
return nil
}
return UIContextMenuConfiguration(identifier: node.uniqueID as NSCopying, previewProvider: nil, actionProvider: { suggestedActions in
return UIMenu(title: "", children: [markAllAction])
})
}
func homePageAction(indexPath: IndexPath) -> UIAction? {
guard coordinator.homePageURLForFeed(indexPath) != nil else {
return nil
@@ -976,6 +960,29 @@ private extension MasterFeedViewController {
return action
}
func markAllAsReadAlertAction(indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard let node = dataSource.itemIdentifier(for: indexPath),
coordinator.unreadCountFor(node) > 0,
let feed = node.representedObject as? WebFeed,
let articles = try? feed.fetchArticles() else {
return nil
}
let localizedMenuText = NSLocalizedString("Mark All as Read in “%@”", comment: "Command")
let title = NSString.localizedStringWithFormat(localizedMenuText as NSString, feed.nameForDisplay) as String
let cancel = {
completion(true)
}
let action = UIAlertAction(title: title, style: .default) { [weak self] action in
MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, cancelCompletion: cancel) { [weak self] in
self?.coordinator.markAllAsRead(Array(articles))
completion(true)
}
}
return action
}
func deleteAction(indexPath: IndexPath) -> UIAction {
let title = NSLocalizedString("Delete", comment: "Delete")
@@ -1033,6 +1040,47 @@ private extension MasterFeedViewController {
}
return action
}
func markAllAsReadAction(indexPath: IndexPath) -> UIAction? {
guard let node = dataSource.itemIdentifier(for: indexPath),
coordinator.unreadCountFor(node) > 0 else {
return nil
}
guard let articleFetcher = node.representedObject as? Feed,
let fetchedArticles = try? articleFetcher.fetchArticles() else {
return nil
}
let articles = Array(fetchedArticles)
return markAllAsReadAction(articles: articles, nameForDisplay: articleFetcher.nameForDisplay)
}
func markAllAsReadAction(account: Account) -> UIAction? {
guard let fetchedArticles = try? account.fetchArticles(FetchType.unread) else {
return nil
}
let articles = Array(fetchedArticles)
return markAllAsReadAction(articles: articles, nameForDisplay: account.nameForDisplay)
}
func markAllAsReadAction(articles: [Article], nameForDisplay: String) -> UIAction? {
guard articles.canMarkAllAsRead() else {
return nil
}
let localizedMenuText = NSLocalizedString("Mark All as Read in “%@”", comment: "Command")
let title = NSString.localizedStringWithFormat(localizedMenuText as NSString, nameForDisplay) as String
let action = UIAction(title: title, image: AppAssets.markAllAsReadImage) { [weak self] action in
MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title) { [weak self] in
self?.coordinator.markAllAsRead(articles)
}
}
return action
}
func rename(indexPath: IndexPath) {
@@ -1058,7 +1106,7 @@ private extension MasterFeedViewController {
feed.rename(to: name) { result in
switch result {
case .success:
self?.reloadNode(node)
break
case .failure(let error):
self?.presentError(error)
}
@@ -1067,7 +1115,7 @@ private extension MasterFeedViewController {
folder.rename(to: name) { result in
switch result {
case .success:
self?.reloadNode(node)
break
case .failure(let error):
self?.presentError(error)
}
@@ -1079,6 +1127,7 @@ private extension MasterFeedViewController {
alertController.addAction(renameAction)
alertController.addTextField() { textField in
textField.text = name
textField.placeholder = NSLocalizedString("Name", comment: "Name")
}
@@ -1106,9 +1155,19 @@ private extension MasterFeedViewController {
deleteCommand.perform()
if indexPath == coordinator.currentFeedIndexPath {
coordinator.selectFeed(nil, animated: false)
coordinator.selectFeed(indexPath: nil)
}
}
}
extension MasterFeedViewController: UIGestureRecognizerDelegate {
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
guard let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer else {
return false
}
let velocity = gestureRecognizer.velocity(in: self.view)
return abs(velocity.x) > abs(velocity.y);
}
}

View File

@@ -13,34 +13,41 @@ class RefreshProgressView: UIView {
@IBOutlet weak var progressView: UIProgressView!
@IBOutlet weak var label: UILabel!
private lazy var progressWidth = progressView.widthAnchor.constraint(equalToConstant: 100.0)
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
private lazy var progressWidthConstraint = progressView.widthAnchor.constraint(equalToConstant: 100.0)
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
func commonInit() {
override func awakeFromNib() {
NotificationCenter.default.addObserver(self, selector: #selector(progressDidChange(_:)), name: .AccountRefreshProgressDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange(_:)), name: UIContentSizeCategory.didChangeNotification, object: nil)
if !AccountManager.shared.combinedRefreshProgress.isComplete {
progressChanged()
} else {
updateRefreshLabel()
}
scheduleUpdateRefreshLabel()
}
override func didMoveToSuperview() {
progressChanged()
}
func updateRefreshLabel() {
if let accountLastArticleFetchEndTime = AccountManager.shared.lastArticleFetchEndTime {
if Date() > accountLastArticleFetchEndTime.addingTimeInterval(1) {
if Date() > accountLastArticleFetchEndTime.addingTimeInterval(60) {
let relativeDateTimeFormatter = RelativeDateTimeFormatter()
relativeDateTimeFormatter.dateTimeStyle = .named
let refreshed = relativeDateTimeFormatter.localizedString(for: accountLastArticleFetchEndTime, relativeTo: Date())
let localizedRefreshText = NSLocalizedString("Updated %@", comment: "Updated")
let refreshText = NSString.localizedStringWithFormat(localizedRefreshText as NSString, refreshed) as String
label.text = refreshText
} else {
label.text = NSLocalizedString("Updated just now", comment: "Updated Just Now")
label.text = NSLocalizedString("Updated Just Now", comment: "Updated Just Now")
}
} else {
label.text = ""
}
@@ -48,25 +55,13 @@ class RefreshProgressView: UIView {
}
@objc func progressDidChange(_ note: Notification) {
let progress = AccountManager.shared.combinedRefreshProgress
if progress.isComplete {
progressView.progress = 1
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.updateRefreshLabel()
self.label.isHidden = false
self.progressView.isHidden = true
self.progressWidth.isActive = false
}
} else {
label.isHidden = true
progressView.isHidden = false
self.progressWidth.isActive = true
let percent = Float(progress.numberCompleted) / Float(progress.numberOfTasks)
progressView.progress = percent
}
progressChanged()
}
@objc func contentSizeCategoryDidChange(_ note: Notification) {
// This hack is probably necessary because custom views in the toolbar don't get
// notifications that the content size changed.
label.font = UIFont.preferredFont(forTextStyle: .footnote)
}
deinit {
@@ -75,3 +70,52 @@ class RefreshProgressView: UIView {
}
// MARK: Private
private extension RefreshProgressView {
func progressChanged() {
// Layout may crash if not in the view hierarchy.
// https://github.com/Ranchero-Software/NetNewsWire/issues/1764
let isInViewHierarchy = self.superview != nil
let progress = AccountManager.shared.combinedRefreshProgress
if progress.isComplete {
if isInViewHierarchy {
progressView.setProgress(1, animated: true)
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
self.updateRefreshLabel()
self.label.isHidden = false
self.progressView.isHidden = true
self.progressWidthConstraint.isActive = false
if isInViewHierarchy {
self.progressView.setProgress(0, animated: true)
}
}
} else {
label.isHidden = true
progressView.isHidden = false
progressWidthConstraint.isActive = true
if isInViewHierarchy {
progressView.setNeedsLayout()
progressView.layoutIfNeeded()
let percent = Float(progress.numberCompleted) / Float(progress.numberOfTasks)
// Don't let the progress bar go backwards unless we need to go back more than 25%
if percent > progressView.progress || progressView.progress - percent > 0.25 {
progressView.setProgress(percent, animated: true)
}
}
}
}
func scheduleUpdateRefreshLabel() {
DispatchQueue.main.asyncAfter(deadline: .now() + 60) { [weak self] in
self?.updateRefreshLabel()
self?.scheduleUpdateRefreshLabel()
}
}
}

View File

@@ -34,6 +34,11 @@ class MasterTimelineTableViewCell: VibrantTableViewCell {
commonInit()
}
override func prepareForReuse() {
unreadIndicatorView.isHidden = true
starView.isHidden = true
}
override var frame: CGRect {
didSet {
setNeedsLayout()
@@ -178,12 +183,25 @@ private extension MasterTimelineTableViewCell {
}
func updateUnreadIndicator() {
showOrHideView(unreadIndicatorView, cellData.read || cellData.starred)
unreadIndicatorView.setNeedsDisplay()
if !unreadIndicatorView.isHidden && cellData.read && !cellData.starred {
UIView.animate(withDuration: 0.66, animations: { self.unreadIndicatorView.alpha = 0 }) { _ in
self.unreadIndicatorView.isHidden = true
self.unreadIndicatorView.alpha = 1
}
} else {
showOrHideView(unreadIndicatorView, cellData.read || cellData.starred)
}
}
func updateStarView() {
showOrHideView(starView, !cellData.starred)
if !starView.isHidden && cellData.read && !cellData.starred {
UIView.animate(withDuration: 0.66, animations: { self.starView.alpha = 0 }) { _ in
self.starView.isHidden = true
self.starView.alpha = 1
}
} else {
showOrHideView(starView, !cellData.starred)
}
}
func updateIconImage() {
@@ -200,6 +218,10 @@ private extension MasterTimelineTableViewCell {
}
}
func updateAccessiblityLabel() {
accessibilityLabel = "\(cellData.feedName), \(cellData.title), \(cellData.summary), \(cellData.dateString)"
}
func makeIconEmpty() {
if iconView.iconImage != nil {
iconView.iconImage = nil
@@ -232,6 +254,7 @@ private extension MasterTimelineTableViewCell {
updateUnreadIndicator()
updateStarView()
updateIconImage()
updateAccessiblityLabel()
}
}

View File

@@ -0,0 +1,62 @@
//
// UndoAvailableAlertController.swift
// NetNewsWire
//
// Created by Phil Viso on 9/29/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import Foundation
import UIKit
struct MarkAsReadAlertController {
static func confirm(_ controller: UIViewController?,
coordinator: SceneCoordinator?,
confirmTitle: String,
cancelCompletion: (() -> Void)? = nil,
completion: @escaping () -> Void) {
guard let controller = controller, let coordinator = coordinator else {
completion()
return
}
if AppDefaults.confirmMarkAllAsRead {
let alertController = MarkAsReadAlertController.alert(coordinator: coordinator, confirmTitle: confirmTitle, cancelCompletion: cancelCompletion) { _ in
completion()
}
controller.present(alertController, animated: true)
} else {
completion()
}
}
private static func alert(coordinator: SceneCoordinator,
confirmTitle: String,
cancelCompletion: (() -> Void)?,
completion: @escaping (UIAlertAction) -> Void) -> UIAlertController {
let title = NSLocalizedString("Mark As Read", comment: "Mark As Read")
let message = NSLocalizedString("You can turn this confirmation off in settings.",
comment: "You can turn this confirmation off in settings.")
let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel")
let settingsTitle = NSLocalizedString("Open Settings", comment: "Open Settings")
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
let cancelAction = UIAlertAction(title: cancelTitle, style: .cancel) { _ in
cancelCompletion?()
}
let settingsAction = UIAlertAction(title: settingsTitle, style: .default) { _ in
coordinator.showSettings(scrollToArticlesSection: true)
}
let markAction = UIAlertAction(title: confirmTitle, style: .default, handler: completion)
alertController.addAction(markAction)
alertController.addAction(settingsAction)
alertController.addAction(cancelAction)
return alertController
}
}

View File

@@ -17,6 +17,8 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
private var iconSize = IconSize.medium
private lazy var feedTapGestureRecognizer = UITapGestureRecognizer(target: self, action:#selector(showFeedInspector(_:)))
private var refreshProgressView: RefreshProgressView?
@IBOutlet weak var filterButton: UIBarButtonItem!
@IBOutlet weak var markAllAsReadButton: UIBarButtonItem!
@IBOutlet weak var firstUnreadButton: UIBarButtonItem!
@@ -26,7 +28,7 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
weak var coordinator: SceneCoordinator!
var undoableCommands = [UndoableCommand]()
let scrollPositionQueue = CoalescingQueue(name: "Scroll Position", interval: 0.3, maxInterval: 1.0)
let scrollPositionQueue = CoalescingQueue(name: "Timeline Scroll Position", interval: 0.3, maxInterval: 1.0)
private let keyboardManager = KeyboardManager(type: .timeline)
override var keyCommands: [UIKeyCommand]? {
@@ -49,6 +51,7 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(contentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(displayNameDidChange), name: .DisplayNameDidChange, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
// Setup the Search Controller
searchController.delegate = self
@@ -72,13 +75,18 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
if let titleView = Bundle.main.loadNibNamed("MasterTimelineTitleView", owner: self, options: nil)?[0] as? MasterTimelineTitleView {
navigationItem.titleView = titleView
}
resetUI(resetScroll: true)
applyChanges(animated: false)
// Restore the scroll position if we have one stored
if let restoreIndexPath = coordinator.timelineMiddleIndexPath {
tableView.scrollToRow(at: restoreIndexPath, at: .middle, animated: false)
refreshControl = UIRefreshControl()
refreshControl!.addTarget(self, action: #selector(refreshAccounts(_:)), for: .valueChanged)
configureToolbar()
resetUI(resetScroll: true)
// Load the table and then scroll to the saved position if available
applyChanges(animated: false) {
if let restoreIndexPath = self.coordinator.timelineMiddleIndexPath {
self.tableView.scrollToRow(at: restoreIndexPath, at: .middle, animated: false)
}
}
}
@@ -104,24 +112,18 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
// MARK: Actions
@IBAction func toggleFilter(_ sender: Any) {
if coordinator.isReadArticlesFiltered {
filterButton.image = AppAssets.filterInactiveImage
setFilterButtonToInactive()
coordinator.showAllArticles()
} else {
filterButton.image = AppAssets.filterActiveImage
setFilterButtonToActive()
coordinator.hideReadArticles()
}
}
@IBAction func markAllAsRead(_ sender: Any) {
if coordinator.displayUndoAvailableTip {
let alertController = UndoAvailableAlertController.alert { [weak self] _ in
self?.coordinator.displayUndoAvailableTip = false
self?.coordinator.markAllAsReadInTimeline()
}
present(alertController, animated: true)
} else {
coordinator.markAllAsReadInTimeline()
let title = NSLocalizedString("Mark All as Read", comment: "Mark All as Read")
MarkAsReadAlertController.confirm(self, coordinator: coordinator, confirmTitle: title) { [weak self] in
self?.coordinator.markAllAsReadInTimeline()
}
}
@@ -129,6 +131,20 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
coordinator.selectFirstUnread()
}
@objc func refreshAccounts(_ sender: Any) {
refreshControl?.endRefreshing()
// This is a hack to make sure that an error dialog doesn't interfere with dismissing the refreshControl.
// If the error dialog appears too closely to the call to endRefreshing, then the refreshControl never disappears.
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
AccountManager.shared.refreshAll(errorHandler: ErrorHandler.present(self)) {
if AppDefaults.refreshClearsReadArticles {
self.coordinator.refreshTimeline(resetScroll: false)
}
}
}
}
// MARK: Keyboard shortcuts
@objc func selectNextUp(_ sender: Any?) {
@@ -156,7 +172,7 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
func restoreSelectionIfNecessary(adjustScroll: Bool) {
if let article = coordinator.currentArticle, let indexPath = dataSource.indexPath(for: article) {
if adjustScroll {
tableView.selectRowAndScrollIfNotVisible(at: indexPath, animated: false)
tableView.selectRowAndScrollIfNotVisible(at: indexPath, animations: [])
} else {
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
}
@@ -171,17 +187,21 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
applyChanges(animated: animated)
}
func updateArticleSelection(animated: Bool) {
func updateArticleSelection(animations: Animations) {
if let article = coordinator.currentArticle, let indexPath = dataSource.indexPath(for: article) {
if tableView.indexPathForSelectedRow != indexPath {
tableView.selectRowAndScrollIfNotVisible(at: indexPath, animated: true)
tableView.selectRowAndScrollIfNotVisible(at: indexPath, animations: animations)
}
} else {
tableView.selectRow(at: nil, animated: animated, scrollPosition: .none)
tableView.selectRow(at: nil, animated: animations.contains(.select), scrollPosition: .none)
}
updateUI()
}
func hideSearch() {
navigationItem.searchController?.isActive = false
}
func showSearchAll() {
navigationItem.searchController?.isActive = true
@@ -197,7 +217,8 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
override func tableView(_ tableView: UITableView, leadingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
guard let article = dataSource.itemIdentifier(for: indexPath) else { return nil }
guard !article.status.read || article.isAvailableToMarkUnread else { return nil }
// Set up the read action
let readTitle = article.status.read ?
NSLocalizedString("Unread", comment: "Unread") :
@@ -242,8 +263,14 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
popoverController.sourceView = view
popoverController.sourceRect = CGRect(x: view.frame.size.width/2, y: view.frame.size.height/2, width: 1, height: 1)
}
alert.addAction(self.markOlderAsReadAlertAction(article, completion: completion))
if let action = self.markAboveAsReadAlertAction(article, completion: completion) {
alert.addAction(action)
}
if let action = self.markBelowAsReadAlertAction(article, completion: completion) {
alert.addAction(action)
}
if let action = self.discloseFeedAlertAction(article, completion: completion) {
alert.addAction(action)
@@ -288,9 +315,19 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
guard let self = self else { return nil }
var actions = [UIAction]()
actions.append(self.toggleArticleReadStatusAction(article))
if let action = self.toggleArticleReadStatusAction(article) {
actions.append(action)
}
actions.append(self.toggleArticleStarStatusAction(article))
actions.append(self.markOlderAsReadAction(article))
if let action = self.markAboveAsReadAction(article) {
actions.append(action)
}
if let action = self.markBelowAsReadAction(article) {
actions.append(action)
}
if let action = self.discloseFeedAction(article) {
actions.append(action)
@@ -326,7 +363,7 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
becomeFirstResponder()
let article = dataSource.itemIdentifier(for: indexPath)
coordinator.selectArticle(article, animated: true)
coordinator.selectArticle(article, animations: [.scroll, .select, .navigation])
}
override func scrollViewDidScroll(_ scrollView: UIScrollView) {
@@ -419,6 +456,10 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
}
}
@objc func willEnterForeground(_ note: Notification) {
updateUI()
}
@objc func scrollPositionDidChange() {
coordinator.timelineMiddleIndexPath = tableView.middleVisibleRow()
}
@@ -450,7 +491,7 @@ class MasterTimelineViewController: UITableViewController, UndoableCommandRunner
let prototypeID = "prototype"
let status = ArticleStatus(articleID: prototypeID, read: false, starred: false, userDeleted: false, dateArrived: Date())
let prototypeArticle = Article(accountID: prototypeID, articleID: prototypeID, webFeedID: prototypeID, uniqueID: prototypeID, title: longTitle, contentHTML: nil, contentText: nil, url: nil, externalURL: nil, summary: nil, imageURL: nil, bannerImageURL: nil, datePublished: nil, dateModified: nil, authors: nil, status: status)
let prototypeArticle = Article(accountID: prototypeID, articleID: prototypeID, webFeedID: prototypeID, uniqueID: prototypeID, title: longTitle, contentHTML: nil, contentText: nil, url: nil, externalURL: nil, summary: nil, imageURL: nil, datePublished: nil, dateModified: nil, authors: nil, status: status)
let prototypeCellData = MasterTimelineCellData(article: prototypeArticle, showFeedName: true, feedName: "Prototype Feed Name", iconImage: nil, showIcon: false, featuredImage: nil, numberOfLines: numberOfTextLines, iconSize: iconSize)
@@ -502,6 +543,22 @@ extension MasterTimelineViewController: UISearchBarDelegate {
private extension MasterTimelineViewController {
func configureToolbar() {
if coordinator.isThreePanelMode {
firstUnreadButton.isHidden = true
return
}
guard let refreshProgressView = Bundle.main.loadNibNamed("RefreshProgressView", owner: self, options: nil)?[0] as? RefreshProgressView else {
return
}
self.refreshProgressView = refreshProgressView
let refreshProgressItemButton = UIBarButtonItem(customView: refreshProgressView)
toolbarItems?.insert(refreshProgressItemButton, at: 2)
}
func resetUI(resetScroll: Bool) {
title = coordinator.timelineFeed?.nameForDisplay ?? "Timeline"
@@ -514,8 +571,10 @@ private extension MasterTimelineViewController {
if coordinator.timelineFeed is WebFeed {
titleView.heightAnchor.constraint(equalToConstant: 44.0).isActive = true
titleView.addGestureRecognizer(feedTapGestureRecognizer)
titleView.accessibilityTraits = .button
} else {
titleView.removeGestureRecognizer(feedTapGestureRecognizer)
titleView.accessibilityTraits.remove(.button)
}
navigationItem.titleView = titleView
@@ -529,9 +588,9 @@ private extension MasterTimelineViewController {
}
if coordinator.isReadArticlesFiltered {
filterButton.image = AppAssets.filterActiveImage
setFilterButtonToActive()
} else {
filterButton.image = AppAssets.filterInactiveImage
setFilterButtonToInactive()
}
tableView.selectRow(at: nil, animated: false, scrollPosition: .top)
@@ -544,10 +603,21 @@ private extension MasterTimelineViewController {
}
func updateUI() {
refreshProgressView?.updateRefreshLabel()
updateTitleUnreadCount()
updateToolbar()
}
func setFilterButtonToActive() {
filterButton?.image = AppAssets.filterActiveImage
filterButton?.accLabelText = NSLocalizedString("Selected - Filter Read Articles", comment: "Selected - Filter Read Articles")
}
func setFilterButtonToInactive() {
filterButton?.image = AppAssets.filterInactiveImage
filterButton?.accLabelText = NSLocalizedString("Filter Read Articles", comment: "Filter Read Articles")
}
func updateToolbar() {
markAllAsReadButton.isEnabled = coordinator.isTimelineUnreadAvailable
firstUnreadButton.isEnabled = coordinator.isTimelineUnreadAvailable
@@ -606,8 +676,9 @@ private extension MasterTimelineViewController {
return nil
}
func toggleArticleReadStatusAction(_ article: Article) -> UIAction {
func toggleArticleReadStatusAction(_ article: Article) -> UIAction? {
guard !article.status.read || article.isAvailableToMarkUnread else { return nil }
let title = article.status.read ?
NSLocalizedString("Mark as Unread", comment: "Mark as Unread") :
NSLocalizedString("Mark as Read", comment: "Mark as Read")
@@ -633,41 +704,93 @@ private extension MasterTimelineViewController {
return action
}
func markOlderAsReadAction(_ article: Article) -> UIAction {
let title = NSLocalizedString("Mark Older as Read", comment: "Mark Older as Read")
let image = coordinator.sortDirection == .orderedDescending ? AppAssets.markOlderAsReadDownImage : AppAssets.markOlderAsReadUpImage
func markAboveAsReadAction(_ article: Article) -> UIAction? {
guard coordinator.canMarkAboveAsRead(for: article) else {
return nil
}
let title = NSLocalizedString("Mark Above as Read", comment: "Mark Above as Read")
let image = AppAssets.markAboveAsReadImage
let action = UIAction(title: title, image: image) { [weak self] action in
self?.coordinator.markAsReadOlderArticlesInTimeline(article)
MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title) { [weak self] in
self?.coordinator.markAboveAsRead(article)
}
}
return action
}
func markOlderAsReadAlertAction(_ article: Article, completion: @escaping (Bool) -> Void) -> UIAlertAction {
let title = NSLocalizedString("Mark Older as Read", comment: "Mark Older as Read")
let action = UIAlertAction(title: title, style: .default) { [weak self] action in
self?.coordinator.markAsReadOlderArticlesInTimeline(article)
func markBelowAsReadAction(_ article: Article) -> UIAction? {
guard coordinator.canMarkBelowAsRead(for: article) else {
return nil
}
let title = NSLocalizedString("Mark Below as Read", comment: "Mark Below as Read")
let image = AppAssets.markBelowAsReadImage
let action = UIAction(title: title, image: image) { [weak self] action in
MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title) { [weak self] in
self?.coordinator.markBelowAsRead(article)
}
}
return action
}
func markAboveAsReadAlertAction(_ article: Article, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard coordinator.canMarkAboveAsRead(for: article) else {
return nil
}
let title = NSLocalizedString("Mark Above as Read", comment: "Mark Above as Read")
let cancel = {
completion(true)
}
let action = UIAlertAction(title: title, style: .default) { [weak self] action in
MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, cancelCompletion: cancel) { [weak self] in
self?.coordinator.markAboveAsRead(article)
completion(true)
}
}
return action
}
func markBelowAsReadAlertAction(_ article: Article, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard coordinator.canMarkBelowAsRead(for: article) else {
return nil
}
let title = NSLocalizedString("Mark Below as Read", comment: "Mark Below as Read")
let cancel = {
completion(true)
}
let action = UIAlertAction(title: title, style: .default) { [weak self] action in
MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, cancelCompletion: cancel) { [weak self] in
self?.coordinator.markBelowAsRead(article)
completion(true)
}
}
return action
}
func discloseFeedAction(_ article: Article) -> UIAction? {
guard let webFeed = article.webFeed else { return nil }
guard let webFeed = article.webFeed,
!coordinator.timelineFeedIsEqualTo(webFeed) else { return nil }
let title = NSLocalizedString("Go to Feed", comment: "Go to Feed")
let action = UIAction(title: title, image: AppAssets.openInSidebarImage) { [weak self] action in
self?.coordinator.discloseFeed(webFeed, animated: true)
self?.coordinator.discloseWebFeed(webFeed, animations: [.scroll, .navigation])
}
return action
}
func discloseFeedAlertAction(_ article: Article, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard let webFeed = article.webFeed else { return nil }
guard let webFeed = article.webFeed,
!coordinator.timelineFeedIsEqualTo(webFeed) else { return nil }
let title = NSLocalizedString("Go to Feed", comment: "Go to Feed")
let action = UIAlertAction(title: title, style: .default) { [weak self] action in
self?.coordinator.discloseFeed(webFeed, animated: true)
self?.coordinator.discloseWebFeed(webFeed, animations: [.scroll, .navigation])
completion(true)
}
return action
@@ -687,8 +810,10 @@ private extension MasterTimelineViewController {
let localizedMenuText = NSLocalizedString("Mark All as Read in “%@”", comment: "Command")
let title = NSString.localizedStringWithFormat(localizedMenuText as NSString, webFeed.nameForDisplay) as String
let action = UIAction(title: title, image: AppAssets.markAllInFeedAsReadImage) { [weak self] action in
self?.coordinator.markAllAsRead(articles)
let action = UIAction(title: title, image: AppAssets.markAllAsReadImage) { [weak self] action in
MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title) { [weak self] in
self?.coordinator.markAllAsRead(articles)
}
}
return action
}
@@ -706,10 +831,15 @@ private extension MasterTimelineViewController {
let localizedMenuText = NSLocalizedString("Mark All as Read in “%@”", comment: "Mark All as Read in Feed")
let title = NSString.localizedStringWithFormat(localizedMenuText as NSString, webFeed.nameForDisplay) as String
let cancel = {
completion(true)
}
let action = UIAlertAction(title: title, style: .default) { [weak self] action in
self?.coordinator.markAllAsRead(articles)
completion(true)
MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, cancelCompletion: cancel) { [weak self] in
self?.coordinator.markAllAsRead(articles)
completion(true)
}
}
return action
}
@@ -738,8 +868,7 @@ private extension MasterTimelineViewController {
}
func shareDialogForTableCell(indexPath: IndexPath, url: URL, title: String?) {
let itemSource = ArticleActivityItemSource(url: url, subject: title)
let activityViewController = UIActivityViewController(activityItems: [itemSource], applicationActivities: nil)
let activityViewController = UIActivityViewController(url: url, title: title, applicationActivities: nil)
guard let cell = tableView.cellForRow(at: indexPath) else { return }
let popoverController = activityViewController.popoverPresentationController

View File

@@ -1,31 +0,0 @@
//
// UndoAvailableAlertController.swift
// NetNewsWire
//
// Created by Phil Viso on 9/29/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import Foundation
import UIKit
struct UndoAvailableAlertController {
static func alert(handler: @escaping (UIAlertAction) -> Void) -> UIAlertController {
let title = NSLocalizedString("Undo Available", comment: "Undo Available")
let message = NSLocalizedString("You can undo this and other actions with a three finger swipe to the left.",
comment: "Mark all articles")
let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel")
let confirmTitle = NSLocalizedString("Got It", comment: "Got It")
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
let cancelAction = UIAlertAction(title: cancelTitle, style: .cancel)
let markAction = UIAlertAction(title: confirmTitle, style: .default, handler: handler)
alertController.addAction(cancelAction)
alertController.addAction(markAction)
return alertController
}
}

View File

@@ -5,7 +5,7 @@
\margl1440\margr1440\vieww8340\viewh9300\viewkind0
\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\li363\fi-364\pardirnatural\partightenfactor0
\f0\b\fs28 \cf2 By Brent Simmons and the NetNewsWire team
\f0\b\fs28 \cf2 By Brent Simmons and the Ranchero Software team
\fs22 \
\pard\tx560\tx1120\tx1680\tx2240\tx2800\tx3360\tx3920\tx4480\tx5040\tx5600\tx6160\tx6720\pardirnatural\partightenfactor0
{\field{\*\fldinst{HYPERLINK "https://ranchero.com/netnewswire/"}}{\fldrslt

View File

@@ -0,0 +1,12 @@
{
"symbols" : [
{
"idiom" : "universal",
"filename" : "markAllAsRead.svg"
}
],
"info" : {
"version" : 1,
"author" : "xcode"
}
}

View File

@@ -0,0 +1,142 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="3300px" height="2200px" viewBox="0 0 3300 2200" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 60.1 (88133) - https://sketch.com -->
<title>Untitled</title>
<desc>Created with Sketch.</desc>
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
<g id="markAllAsRead">
<g id="Notes">
<rect id="artboard" fill="#FFFFFF" fill-rule="nonzero" x="0" y="0" width="3300" height="2200"></rect>
<line x1="263" y1="292" x2="3036" y2="292" id="Path" stroke="#000000" stroke-width="0.5"></line>
<text id="Weight/Scale-Variations" fill="#000000" font-family="Helvetica-Bold, Helvetica" font-size="13" font-weight="bold">
<tspan x="263" y="322">Weight/Scale Variations</tspan>
</text>
<text id="Ultralight" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="533.711" y="322">Ultralight</tspan>
</text>
<text id="Thin" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="843.422" y="322">Thin</tspan>
</text>
<text id="Light" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1138.63" y="322">Light</tspan>
</text>
<text id="Regular" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1426.84" y="322">Regular</tspan>
</text>
<text id="Medium" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1723.06" y="322">Medium</tspan>
</text>
<text id="Semibold" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="2015.77" y="322">Semibold</tspan>
</text>
<text id="Bold" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="2326.48" y="322">Bold</tspan>
</text>
<text id="Heavy" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="2618.19" y="322">Heavy</tspan>
</text>
<text id="Black" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="2917.4" y="322">Black</tspan>
</text>
<line x1="263" y1="1903" x2="3036" y2="1903" id="Path" stroke="#000000" stroke-width="0.5"></line>
<g id="Group" transform="translate(264.000000, 1918.000000)" fill="#000000" fill-rule="nonzero">
<path d="M8.24805,15.830078 C12.5547,15.830078 16.1387,12.25586 16.1387,7.94922 C16.1387,3.6426 12.5449,0.0684 8.23828,0.0684 C3.94141,0.0684 0.36719,3.6426 0.36719,7.94922 C0.36719,12.25586 3.95117,15.830078 8.24805,15.830078 Z M8.24805,14.345703 C4.70312,14.345703 1.87109,11.50391 1.87109,7.94922 C1.87109,4.3945 4.69336,1.5527 8.23828,1.5527 C11.793,1.5527 14.6348,4.3945 14.6445252,7.94922 C14.6543,11.50391 11.8027,14.345703 8.24805,14.345703 Z M8.22852,11.57227 C8.69727,11.57227 8.9707,11.25977 8.9707,10.74219 L8.9707,8.68164 L11.1973,8.68164 C11.6953,8.68164 12.0371,8.42773 12.0371,7.95898 C12.0371,7.48047 11.7148,7.2168 11.1973,7.2168 L8.9707,7.2168 L8.9707,4.9902 C8.9707,4.4727 8.69727,4.1504 8.22852,4.1504 C7.75977,4.1504 7.50586,4.4922 7.50586,4.9902 L7.50586,7.2168 L5.29883,7.2168 C4.78125,7.2168 4.44922,7.48047 4.44922,7.95898 C4.44922,8.42773 4.80078,8.68164 5.29883,8.68164 L7.50586,8.68164 L7.50586,10.74219 C7.50586,11.24023 7.75977,11.57227 8.22852,11.57227 Z" id="Shape"></path>
</g>
<g id="Group" transform="translate(282.506000, 1915.000000)" fill="#000000" fill-rule="nonzero">
<path d="M10.709,20.91016 C16.1582,20.91016 20.6699,16.39844 20.6699,10.94922 C20.6699,5.5098 16.1484,0.9883 10.6992,0.9883 C5.25977,0.9883 0.74805,5.5098 0.74805,10.94922 C0.74805,16.39844 5.26953,20.91016 10.709,20.91016 Z M10.709,19.25 C6.09961,19.25 2.41797,15.55859 2.41797,10.94922 C2.41797,6.3496 6.08984,2.6484 10.6992,2.6484 C15.3086,2.6484 19,6.3496 19.009819,10.94922 C19.0195,15.55859 15.3184,19.25 10.709,19.25 Z M10.6895,15.58789 C11.207,15.58789 11.5195,15.22656 11.5195,14.66016 L11.5195,11.76953 L14.5762,11.76953 C15.123,11.76953 15.5039,11.48633 15.5039,10.96875 C15.5039,10.44141 15.1426,10.13867 14.5762,10.13867 L11.5195,10.13867 L11.5195,7.0723 C11.5195,6.4961 11.207,6.1445 10.6895,6.1445 C10.1719,6.1445 9.8789,6.5156 9.8789,7.0723 L9.8789,10.13867 L6.83203,10.13867 C6.26562,10.13867 5.89453,10.44141 5.89453,10.96875 C5.89453,11.48633 6.28516,11.76953 6.83203,11.76953 L9.8789,11.76953 L9.8789,14.66016 C9.8789,15.20703 10.1719,15.58789 10.6895,15.58789 Z" id="Shape"></path>
</g>
<g id="Group" transform="translate(306.924000, 1913.000000)" fill="#000000" fill-rule="nonzero">
<path d="M12.9707,25.67383 C19.9336,25.67383 25.6953,19.921875 25.6953,12.95898 C25.6953,5.9961 19.9238,0.2441 12.9609,0.2441 C6.00781,0.2441 0.25586,5.9961 0.25586,12.95898 C0.25586,19.921875 6.01758,25.67383 12.9707,25.67383 Z M12.9707,23.85742 C6.93555,23.85742 2.08203,18.99414 2.08203,12.95898 C2.08203,6.9238 6.92578,2.0605 12.9609,2.0605 C19.0059,2.0605 23.8594,6.9238 23.8691148,12.95898 C23.8789,18.99414 19.0156,23.85742 12.9707,23.85742 Z M12.9512,18.93555 C13.5176,18.93555 13.8691,18.54492 13.8691,17.93945 L13.8691,13.86719 L18.1074,13.86719 C18.6934,13.86719 19.1133,13.53516 19.1133,12.97852 C19.1133,12.40234 18.7227,12.06055 18.1074,12.06055 L13.8691,12.06055 L13.8691,7.8125 C13.8691,7.1973 13.5176,6.8066 12.9512,6.8066 C12.3848,6.8066 12.0625,7.2168 12.0625,7.8125 L12.0625,12.06055 L7.83398,12.06055 C7.21875,12.06055 6.80859,12.40234 6.80859,12.97852 C6.80859,13.53516 7.23828,13.86719 7.83398,13.86719 L12.0625,13.86719 L12.0625,17.93945 C12.0625,18.52539 12.3848,18.93555 12.9512,18.93555 Z" id="Shape"></path>
</g>
<text id="Design-Variations" fill="#000000" font-family="Helvetica-Bold, Helvetica" font-size="13" font-weight="bold">
<tspan x="263" y="1953">Design Variations</tspan>
</text>
<text id="Symbols-are-supported-in-up-to-nine-weights-and-three-scales." fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1971">Symbols are supported in up to nine weights and three scales.</tspan>
</text>
<text id="For-optimal-layout-with-text-and-other-symbols,-vertically-align" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1989">For optimal layout with text and other symbols, vertically align</tspan>
</text>
<text id="symbols-with-the-adjacent-text." fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="2007">symbols with the adjacent text.</tspan>
</text>
<rect id="Rectangle" fill="#00AEEF" fill-rule="nonzero" opacity="0.4" x="776" y="1919" width="3" height="14"></rect>
<g id="Group" transform="translate(779.000000, 1918.000000)" fill="#000000" fill-rule="nonzero">
<path d="M10.5273,15 L12.373,15 L7.17773,0.9082 L5.43945,0.9082 L0.244141,15 L2.08984,15 L3.50586,10.9668 L9.11133,10.9668 L10.5273,15 Z M6.2793,3.0469 L6.33789,3.0469 L8.59375,9.47266 L4.02344,9.47266 L6.2793,3.0469 Z" id="Shape"></path>
</g>
<rect id="Rectangle" fill="#00AEEF" fill-rule="nonzero" opacity="0.4" x="791.617" y="1919" width="3" height="14"></rect>
<text id="Margins" fill="#000000" font-family="Helvetica-Bold, Helvetica" font-size="13" font-weight="bold">
<tspan x="776" y="1953">Margins</tspan>
</text>
<text id="Leading-and-trailing-margins-on-the-left-and-right-side-of-each-symbol" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="1971">Leading and trailing margins on the left and right side of each symbol</tspan>
</text>
<text id="can-be-adjusted-by-modifying-the-width-of-the-blue-rectangles." fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="1989">can be adjusted by modifying the width of the blue rectangles.</tspan>
</text>
<text id="Modifications-are-automatically-applied-proportionally-to-all" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="2007">Modifications are automatically applied proportionally to all</tspan>
</text>
<text id="scales-and-weights." fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="776" y="2025">scales and weights.</tspan>
</text>
<g id="Group" transform="translate(1291.000000, 1915.000000)" fill="#000000" fill-rule="nonzero">
<path d="M0.83203,21.11523 L2.375,22.6582 C3.22461,23.48828 4.19141,23.41992 5.06055,22.46289 L15.2754,11.33984 C15.7051,11.63281 16.0957,11.62305 16.5645,11.52539 L17.6094,11.31055 L18.3027,12.00391 L18.2539,12.52148 C18.1855,13.04883 18.3516,13.46875 18.8496,13.9668 L19.6602,14.77734 C20.168,15.28516 20.8223,15.31445 21.3008,14.83594 L24.5527,11.58398 C25.0312,11.10547 25.0117,10.45117 24.5039,9.94336 L23.6836,9.12305 C23.1855,8.625 22.7754,8.44922 22.2383,8.52734 L21.7109,8.58594 L21.0566,7.9219 L21.3398,6.7793 C21.4863,6.2129 21.3398,5.7441 20.7148,5.1387 L18.3027,2.7461 C14.7578,-0.7793 10.2266,-0.6719 7.11133,2.4629 C6.69141,2.8926 6.64258,3.4785 6.91602,3.9082 C7.15039,4.2793 7.62891,4.5039 8.2734,4.3379 C9.7871,3.957 11.3008,4.0742 12.7852,5.0801 L12.1602,6.6621 C11.9258,7.248 11.9453,7.7266 12.1797,8.16602 L1.01758,18.439453 C0.08008,19.30859 -0.02734,20.25586 0.83203,21.11523 Z M8.6738,2.8535 C11.3398,0.8613 14.6504,1.1738 17.0527,3.5859 L19.6797,6.1934 C19.9141,6.4277 19.9434,6.6133 19.8848,6.9062 L19.5039,8.46875 L21.0762,10.04102 L22.043,9.95312 C22.3262,9.92383 22.4141,9.94336 22.6387,10.16797 L23.2637,10.79297 L20.5098,13.53711 L19.8848,12.92188 C19.6602,12.69727 19.6406,12.59961 19.6699,12.31641 L19.7578,11.35938 L18.1953,9.79688 L16.5742,10.10938 C16.291,10.16797 16.1445,10.16797 15.9102,9.92383 L13.7324,7.7461 C13.5078,7.5117 13.4785,7.375 13.6055,7.0527 L14.5527,4.7773 C12.9512,3.2441 10.8418,2.3945 8.8008,3.0488 C8.7129,3.0781 8.6445,3.0586 8.6152,3.0195 C8.5859,2.9707 8.5859,2.9219 8.6738,2.8535 Z M2.10156,20.41211 C1.61328,19.91406 1.78906,19.61133 2.12109,19.30859 L13.0781,9.19141 L14.3086,10.42188 L4.15234,21.34961 C3.84961,21.68164 3.46875,21.7793 3.06836,21.37891 L2.10156,20.41211 Z" id="Shape"></path>
</g>
<text id="Exporting" fill="#000000" font-family="Helvetica-Bold, Helvetica" font-size="13" font-weight="bold">
<tspan x="1289" y="1953">Exporting</tspan>
</text>
<text id="Symbols-should-be-outlined-when-exporting-to-ensure-the" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1289" y="1971">Symbols should be outlined when exporting to ensure the</tspan>
</text>
<text id="design-is-preserved-when-submitting-to-Xcode." fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="1289" y="1989">design is preserved when submitting to Xcode.</tspan>
</text>
<text id="template-version" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="2952" y="1933">Template v.1.0</tspan>
</text>
<text id="Generated-from-circle" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="2911" y="1951">Generated from circle</tspan>
</text>
<text id="Typeset-at-100-points" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="2912" y="1969">Typeset at 100 points</tspan>
</text>
<text id="Small" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="726">Small</tspan>
</text>
<text id="Medium" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1156">Medium</tspan>
</text>
<text id="Large" fill="#000000" font-family="Helvetica" font-size="13" font-weight="normal">
<tspan x="263" y="1586">Large</tspan>
</text>
</g>
<g id="Guides" transform="translate(263.000000, 625.000000)">
<g id="H-reference" transform="translate(76.000000, 0.000000)" fill="#27AAE1" fill-rule="nonzero">
<path d="M54.9316,71 L57.666,71 L30.5664,0.541 L28.0762,0.541 L0.976562,71 L3.66211,71 L12.9395,46.5371 L45.7031,46.5371 L54.9316,71 Z M29.1992,3.9102 L29.4434,3.9102 L44.8242,44.291 L13.8184,44.291 L29.1992,3.9102 Z" id="Shape"></path>
</g>
<line x1="0" y1="71" x2="2773" y2="71" id="Baseline-S" stroke="#27AAE1" stroke-width="0.577"></line>
<line x1="0" y1="0.541" x2="2773" y2="0.541" id="Capline-S" stroke="#27AAE1" stroke-width="0.577"></line>
<g id="H-reference" transform="translate(76.000000, 430.000000)" fill="#27AAE1" fill-rule="nonzero">
<path d="M54.9316,71 L57.666,71 L30.5664,0.541 L28.0762,0.541 L0.976562,71 L3.66211,71 L12.9395,46.5371 L45.7031,46.5371 L54.9316,71 Z M29.1992,3.9102 L29.4434,3.9102 L44.8242,44.291 L13.8184,44.291 L29.1992,3.9102 Z" id="Shape"></path>
</g>
<line x1="0" y1="501" x2="2773" y2="501" id="Baseline-M" stroke="#27AAE1" stroke-width="0.577"></line>
<line x1="0" y1="430.54" x2="2773" y2="430.54" id="Capline-M" stroke="#27AAE1" stroke-width="0.577"></line>
<g id="H-reference" transform="translate(76.000000, 860.000000)" fill="#27AAE1" fill-rule="nonzero">
<path d="M54.9316,71 L57.666,71 L30.5664,0.541 L28.0762,0.541 L0.976562,71 L3.66211,71 L12.9395,46.5371 L45.7031,46.5371 L54.9316,71 Z M29.1992,3.9102 L29.4434,3.9102 L44.8242,44.291 L13.8184,44.291 L29.1992,3.9102 Z" id="Shape"></path>
</g>
<line x1="0" y1="931" x2="2773" y2="931" id="Baseline-L" stroke="#27AAE1" stroke-width="0.577"></line>
<line x1="0" y1="860.54" x2="2773" y2="860.54" id="Capline-L" stroke="#27AAE1" stroke-width="0.577"></line>
<rect id="left-margin" fill="#00AEEF" fill-rule="nonzero" opacity="0.4" x="1128.3" y="405.79" width="8.74023" height="119.336"></rect>
<rect id="right-margin" fill="#00AEEF" fill-rule="nonzero" opacity="0.4" x="1236.65" y="405.79" width="8.74023" height="119.336"></rect>
</g>
<g id="Symbols" transform="translate(1396.000000, 1003.000000)" fill="#000000" fill-rule="nonzero">
<g id="Regular-M" transform="translate(0.300000, 0.000000)">
<path d="M50.50467,149.6094 C77.75077,149.6094 100.30977,127.05079 100.30977,99.8047 C100.30977,72.6074 77.70197,50 50.45587,50 C23.25857,50 0.7,72.6074 0.7,99.8047 C0.7,127.05079 23.30747,149.6094 50.50467,149.6094 Z M50.50467,141.3086 C27.45777,141.3086 9.04957,122.8516 9.04957,99.8047 C9.04957,76.8066 27.40897,58.3008 50.45587,58.3008 C73.50277,58.3008 91.95977,76.8066 92.0088671,99.8047 C92.05777,122.8516 73.55157,141.3086 50.50467,141.3086 Z" id="Shape"></path>
<path d="M50.45587,25 C77.70197,25 100.30977,47.6074 100.30977,74.8047 C100.30977,74.870144 100.30964,74.935561 100.30938,75.0009507 L99.8322178,75.0006156 C97.6995557,51.580586 76.5280991,33.3008 50.4213361,33.3008 C24.3145732,33.3008 3.24806132,51.580586 1.17616722,75.0006156 L0.701,75 L0.7,74.8047 C0.7,47.879373 22.8096545,25.452607 49.6413605,25.0067642 Z" id="Combined-Shape"></path>
<path d="M50.45587,-1.77635684e-14 C77.70197,-1.77635684e-14 100.30977,22.6074 100.30977,49.8047 C100.30977,49.870144 100.30964,49.935561 100.30938,50.0009507 L99.8322178,50.0006156 C97.6995557,26.580586 76.5280991,8.3008 50.4213361,8.3008 C24.3145732,8.3008 3.24806132,26.580586 1.17616722,50.0006156 L0.701,50 L0.7,49.8047 C0.7,22.879373 22.8096545,0.45260699 49.6413605,0.0067642025 Z" id="Combined-Shape"></path>
</g>
</g>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -11,6 +11,7 @@ Lead iOS developer: {\field{\*\fldinst{HYPERLINK "https://github.com/vincode-io"
App icon: {\field{\*\fldinst{HYPERLINK "https://twitter.com/BradEllis"}}{\fldrslt Brad Ellis}}\
\pard\pardeftab720\li366\fi-367\sa60\partightenfactor0
\cf2 Feedly syncing: {\field{\*\fldinst{HYPERLINK "https://twitter.com/kielgillard"}}{\fldrslt Kiel Gillard}}\
Under-the-hood magic and CSS stylin\'92s: {\field{\*\fldinst{HYPERLINK "https://github.com/wevah"}}{\fldrslt Nate Weaver}}\
\pard\pardeftab720\li362\fi-363\sa60\partightenfactor0
\cf2 Newsfoot (JS footnote displayer): {\field{\*\fldinst{HYPERLINK "https://github.com/brehaut/"}}{\fldrslt Andrew Brehaut}}\
\pard\pardeftab720\li355\fi-356\sa60\partightenfactor0

View File

@@ -60,10 +60,10 @@
<string>Grant permission to save images from the article.</string>
<key>NSUserActivityTypes</key>
<array>
<string>Restoration</string>
<string>AddWebFeedIntent</string>
<string>NextUnread</string>
<string>ReadArticle</string>
<string>Restoration</string>
<string>SelectFeed</string>
</array>
<key>UIApplicationSceneManifest</key>
@@ -142,5 +142,27 @@
</dict>
</dict>
</array>
<key>UTImportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.xml</string>
</array>
<key>UTTypeDescription</key>
<string>OPML</string>
<key>UTTypeIconFiles</key>
<array/>
<key>UTTypeIdentifier</key>
<string>org.opml.opml</string>
<key>UTTypeTagSpecification</key>
<dict>
<key>public.filename-extension</key>
<array>
<string>opml</string>
</array>
</dict>
</dict>
</array>
</dict>
</plist>

View File

@@ -8,4 +8,4 @@
\f0\fs22 \cf2 Thanks to Sheila and my family; thanks to my friends in Seattle and around the globe; thanks to my co-workers and friends at {\field{\*\fldinst{HYPERLINK "https://www.omnigroup.com"}}{\fldrslt the Omni Group}}; thanks to the ever-patient and ever-awesome NetNewsWire beta testers. \
\pard\tx0\pardeftab720\li360\fi-361\sa60\partightenfactor0
\cf2 Thanks to {\field{\*\fldinst{HYPERLINK "https://shapeof.com/"}}{\fldrslt Gus Mueller}} for {\field{\*\fldinst{HYPERLINK "https://github.com/ccgus/fmdb"}}{\fldrslt FMDB}} by {\field{\*\fldinst{HYPERLINK "http://flyingmeat.com/"}}{\fldrslt Flying Meat Software}}. Thanks to {\field{\*\fldinst{HYPERLINK "https://github.com"}}{\fldrslt GitHub}} and {\field{\*\fldinst{HYPERLINK "https://slack.com"}}{\fldrslt Slack}} for making open source collaboration easy and fun. Thanks to {\field{\*\fldinst{HYPERLINK "https://benubois.com/"}}{\fldrslt Ben Ubois}} at {\field{\*\fldinst{HYPERLINK "https://feedbin.com/"}}{\fldrslt Feedbin}} for all the extra help with syncing and article rendering.}
\cf2 Thanks to {\field{\*\fldinst{HYPERLINK "https://shapeof.com/"}}{\fldrslt Gus Mueller}} for {\field{\*\fldinst{HYPERLINK "https://github.com/ccgus/fmdb"}}{\fldrslt FMDB}} by {\field{\*\fldinst{HYPERLINK "http://flyingmeat.com/"}}{\fldrslt Flying Meat Software}}. Thanks to {\field{\*\fldinst{HYPERLINK "https://github.com"}}{\fldrslt GitHub}} and {\field{\*\fldinst{HYPERLINK "https://slack.com"}}{\fldrslt Slack}} for making open source collaboration easy and fun. Thanks to {\field{\*\fldinst{HYPERLINK "https://benubois.com/"}}{\fldrslt Ben Ubois}} at {\field{\*\fldinst{HYPERLINK "https://feedbin.com/"}}{\fldrslt Feedbin}} for all the extra help with syncing and article rendering \'97\'a0and for {\field{\*\fldinst{HYPERLINK "https://feedbin.com/blog/2019/03/11/the-future-of-full-content/"}}{\fldrslt hosting the server for the Reader view}}.}

View File

@@ -25,7 +25,6 @@ class ImageViewer {
this.loadingInterval = setInterval(callback, 100);
}
}
cancel() {
clearInterval(this.loadingInterval);
this.hideLoadingIndicator();
@@ -45,6 +44,7 @@ class ImageViewer {
y: rect.y,
width: rect.width,
height: rect.height,
imageTitle: this.img.title,
imageURL: canvas.toDataURL(),
};
@@ -58,7 +58,6 @@ class ImageViewer {
showImage() {
this.img.style.opacity = 1
window.webkit.messageHandlers.imageWasShown.postMessage("");
}
showLoadingIndicator() {
@@ -128,6 +127,7 @@ function showClickedImage() {
if (activeImageViewer) {
activeImageViewer.showImage();
}
window.webkit.messageHandlers.imageWasShown.postMessage("");
}
// Add the playsinline attribute to any HTML5 videos that don"t have it.
@@ -136,6 +136,7 @@ function showClickedImage() {
function inlineVideos() {
document.querySelectorAll("video").forEach(element => {
element.setAttribute("playsinline", true)
element.setAttribute("controls", true)
});
}
@@ -143,3 +144,18 @@ function postRenderProcessing() {
ImageViewer.init();
inlineVideos();
}
function stopMediaPlayback() {
document.querySelectorAll("iframe").forEach(element => {
var iframeSrc = element.src;
element.src = iframeSrc;
});
document.querySelectorAll("video, audio").forEach(element => {
element.pause();
});
}
window.addEventListener('DOMContentLoaded', (event) => {
window.webkit.messageHandlers.domContentLoaded.postMessage("");
});

View File

@@ -1,15 +1,17 @@
<html>
<head>
<meta name="viewport" content="width=device-width">
<style>
<head>
<title></title>
<base href="">
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
:root {
color-scheme: light dark;
}
</style>
<script src="main.js"></script>
<script src="main_ios.js"></script>
</style>
<script src="main.js"></script>
<script src="main_ios.js"></script>
<script src="newsfoot.js" async="async"></script>
</head>
<body>
</body>
</head>
<body>
</body>
</html>

View File

@@ -1,13 +1,17 @@
:root {
font: -apple-system-body;
}
body {
margin-top: 3px;
margin-bottom: 20px;
margin-left: 20px;
margin-right: 20px;
font: -apple-system-body;
word-wrap: break-word;
word-break: break-word;
-webkit-hyphens: auto;
-webkit-text-size-adjust: none;
}
a {
@@ -35,23 +39,27 @@ a:hover {
color-scheme: light dark;
--primary-accent-color: #086AEE;
--secondary-accent-color: #086AEE;
--block-quote-border-color: rgba(8, 106, 238, 0.75);
--header-table-border-color: rgba(0, 0, 0, 0.1);
--header-color: rgba(0, 0, 0, 0.3);
--body-code-color: #666;
--system-message-color: #cbcbcb;
--feedlink-color: rgba(0, 0, 0, 0.6);
--article-title-color: #333;
--table-cell-border-color: lightgray;
}
@media(prefers-color-scheme: dark) {
:root {
--primary-accent-color: #2D80F1;
--secondary-accent-color: #5E9EF4;
--header-table-border-color: rgba(255, 255, 255, 0.1);
--block-quote-border-color: rgba(94, 158, 244, 0.75);
--header-table-border-color: rgba(255, 255, 255, 0.2);
--header-color: #d2d2d2;
--body-code-color: #b2b2b2;
--system-message-color: #5f5f5f;
--article-title-color: #e0e0e0;
--table-cell-border-color: dimgray;
}
}
@@ -90,6 +98,7 @@ body > .systemMessage {
}
.avatar img {
border-radius: 4px;
max-width: none;
}
.feedIcon {
border-radius: 4px;
@@ -111,6 +120,10 @@ body > .systemMessage {
font-weight: bold;
}
.articleDateline a:link, .articleDateline a:visited {
color: var(--article-title-color);
}
.articleBody {
line-height: 1.6em;
}
@@ -123,23 +136,63 @@ pre {
margin: 0;
overflow: auto;
overflow-y: hidden;
line-height: 20px;
line-height: 1.4286em;
border: 1px solid var(--secondary-accent-color);
padding: 5px;
word-wrap: normal;
word-break: normal;
-webkit-hyphens: none;
}
code, pre {
font-family: "SF Mono", Menlo, "Courier New", Courier, monospace;
font-size: 14px;
font-size: .8235rem;
-webkit-hyphens: none;
}
img, figure, video, iframe, div {
.nnw-overflow {
overflow-x: auto;
}
/*
Instead of the last-child bits, border-collapse: collapse
could have been used. However, then the inter-cell borders
overlap the table border, which looks bad.
*/
.nnw-overflow table {
margin-bottom: 1px;
border-spacing: 0;
border: 1px solid var(--secondary-accent-color);
font-size: inherit;
}
.nnw-overflow td, .nnw-overflow th {
-webkit-hyphens: none;
word-break: normal;
border: 1px solid var(--table-cell-border-color);
border-top: none;
border-left: none;
padding: 5px;
}
.nnw-overflow tr td:last-child, .nnw-overflow tr th:last-child {
border-right: none;
}
.nnw-overflow tr:last-child td, .nnw-overflow tr:last-child th {
border-bottom: none;
}
.nnw-overflow td pre {
border: none;
padding: 0;
}
img, figure, iframe, div {
max-width: 100%;
height: auto !important;
margin: 0 auto;
}
video {
width: 100% !important;
height: auto !important;
margin: 0 auto;
}
figcaption {
font-size: 14px;
line-height: 1.3em;
@@ -185,6 +238,24 @@ sub {
width: 100% !important;
}
blockquote {
margin-inline-start: 0;
margin-inline-end: 0;
padding-left: 15px;
border-left: 3px solid var(--block-quote-border-color);
}
/* Feed Specific */
.feedbin--article-wrap {
border-top: 1px solid var(--header-table-border-color);
}
.wp-smiley {
height: 1em;
max-height: 1em;
}
/*Block ads and junk*/
iframe[src*="feedads"],
@@ -220,16 +291,11 @@ img[src*="share-buttons"] {
display: none !important;
}
/* Site specific styles */
.wp-smiley {
height: 1em;
max-height: 1em;
}
/* Newsfoot specific styles. Structural styles come first, theme styles second */
.newsfoot-footnote-container {
position: relative;
display: inline-block;
z-index: 9999;
}
.newsfoot-footnote-popover {
position: absolute;

View File

@@ -53,9 +53,13 @@ class RootSplitViewController: UISplitViewController {
coordinator.markAllAsReadInTimeline()
coordinator.selectNextUnread()
}
@objc func markAboveAsRead(_ sender: Any?) {
coordinator.markAboveAsRead()
}
@objc func markOlderArticlesAsRead(_ sender: Any?) {
coordinator.markAsReadOlderArticlesInTimeline()
@objc func markBelowAsRead(_ sender: Any?) {
coordinator.markBelowAsRead()
}
@objc func markUnread(_ sender: Any?) {

File diff suppressed because it is too large Load Diff

View File

@@ -45,11 +45,13 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
func windowScene(_ windowScene: UIWindowScene, performActionFor shortcutItem: UIApplicationShortcutItem, completionHandler: @escaping (Bool) -> Void) {
appDelegate.resumeDatabaseProcessingIfNecessary()
handleShortcutItem(shortcutItem)
completionHandler(true)
}
func scene(_ scene: UIScene, continue userActivity: NSUserActivity) {
appDelegate.resumeDatabaseProcessingIfNecessary()
coordinator.handle(userActivity)
}
@@ -59,6 +61,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
func sceneWillEnterForeground(_ scene: UIScene) {
appDelegate.resumeDatabaseProcessingIfNecessary()
appDelegate.prepareAccountsForForeground()
self.coordinator.configurePanelMode(for: window!.frame.size)
}
@@ -70,6 +73,7 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
// API
func handle(_ response: UNNotificationResponse) {
appDelegate.resumeDatabaseProcessingIfNecessary()
coordinator.handle(response)
}

View File

@@ -28,7 +28,7 @@ class AboutViewController: UITableViewController {
let buildLabel = NonIntrinsicLabel(frame: CGRect(x: 32.0, y: 0.0, width: 0.0, height: 0.0))
buildLabel.font = UIFont.systemFont(ofSize: 11.0)
buildLabel.textColor = UIColor.gray
buildLabel.text = NSLocalizedString("Copyright © 2002-2019 Brent Simmons", comment: "Copyright")
buildLabel.text = NSLocalizedString("Copyright © 2002-2020 Brent Simmons", comment: "Copyright")
buildLabel.numberOfLines = 0
buildLabel.sizeToFit()
buildLabel.translatesAutoresizingMaskIntoConstraints = false

View File

@@ -8,6 +8,7 @@
import Account
import UIKit
import RSCore
protocol AddAccountDismissDelegate: UIViewController {
func dismiss()
@@ -48,7 +49,7 @@ class AddAccountViewController: UITableViewController, AddAccountDismissDelegate
let addAccount = OAuthAccountAuthorizationOperation(accountType: .feedly)
addAccount.delegate = self
addAccount.presentationAnchor = self.view.window!
OperationQueue.main.addOperation(addAccount)
MainThreadOperationQueue.shared.add(addAccount)
case 3:
let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "FeedWranglerAccountNavigationViewController") as! UINavigationController
navController.modalPresentationStyle = .currentContext

View File

@@ -177,54 +177,132 @@
</constraints>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" textLabel="6C6-JQ-lfQ" style="IBUITableViewCellStyleDefault" id="5wo-fM-0l6" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="5wo-fM-0l6" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="531.5" width="374" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="5wo-fM-0l6" id="XAn-lK-LoN">
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="749" text="Refresh to Clear Read Articles" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="KtJ-tk-DlD">
<rect key="frame" x="20" y="11" width="229" height="22"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<switch opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" contentHorizontalAlignment="center" contentVerticalAlignment="center" translatesAutoresizingMaskIntoConstraints="NO" id="duV-CN-JmH">
<rect key="frame" x="305" y="6.5" width="51" height="31"/>
<color key="onTintColor" name="primaryAccentColor"/>
<connections>
<action selector="switchClearsReadArticles:" destination="a0p-rk-skQ" eventType="valueChanged" id="Nel-mq-9fP"/>
</connections>
</switch>
</subviews>
<constraints>
<constraint firstItem="KtJ-tk-DlD" firstAttribute="top" secondItem="XAn-lK-LoN" secondAttribute="topMargin" id="3mX-9g-2Bp"/>
<constraint firstItem="KtJ-tk-DlD" firstAttribute="leading" secondItem="XAn-lK-LoN" secondAttribute="leadingMargin" id="AOT-A0-ak0"/>
<constraint firstAttribute="trailing" secondItem="duV-CN-JmH" secondAttribute="trailing" constant="20" symbolic="YES" id="Qkh-LF-zez"/>
<constraint firstItem="duV-CN-JmH" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="KtJ-tk-DlD" secondAttribute="trailing" constant="8" id="cCz-fb-lta"/>
<constraint firstItem="duV-CN-JmH" firstAttribute="centerY" secondItem="XAn-lK-LoN" secondAttribute="centerY" id="eui-vJ-Bp8"/>
<constraint firstAttribute="bottomMargin" secondItem="KtJ-tk-DlD" secondAttribute="bottom" id="iyQ-7h-MT3"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" id="8Gj-qz-NMY" customClass="VibrantBasicTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="575.5" width="374" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="8Gj-qz-NMY" id="OTe-tG-sb4">
<rect key="frame" x="0.0" y="0.0" width="343" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Timeline Layout" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontSizeToFit="NO" id="6C6-JQ-lfQ">
<rect key="frame" x="20" y="0.0" width="315" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Timeline Layout" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="YHt-eS-KrX">
<rect key="frame" x="20" y="11.5" width="120.5" height="21"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="YHt-eS-KrX" firstAttribute="centerY" secondItem="OTe-tG-sb4" secondAttribute="centerY" id="HpK-qo-s57"/>
<constraint firstItem="YHt-eS-KrX" firstAttribute="leading" secondItem="OTe-tG-sb4" secondAttribute="leadingMargin" id="STg-aB-F7n"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="label" destination="YHt-eS-KrX" id="zaC-7C-hda"/>
</connections>
</tableViewCell>
</cells>
</tableViewSection>
<tableViewSection headerTitle="Articles" id="TRr-Ew-IvU">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="SXs-NQ-y3U" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="675.5" width="374" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="SXs-NQ-y3U" id="BpI-Hz-KH2">
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="749" text="Confirm Mark All as Read" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="5tY-5k-v2g">
<rect key="frame" x="20" y="11" width="192.5" height="22"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<switch opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" contentHorizontalAlignment="center" contentVerticalAlignment="center" on="YES" translatesAutoresizingMaskIntoConstraints="NO" id="UOo-9z-IuL">
<rect key="frame" x="305" y="6.5" width="51" height="31"/>
<color key="onTintColor" name="primaryAccentColor"/>
<connections>
<action selector="switchConfirmMarkAllAsRead:" destination="a0p-rk-skQ" eventType="valueChanged" id="7wW-hF-2OY"/>
</connections>
</switch>
</subviews>
<constraints>
<constraint firstItem="5tY-5k-v2g" firstAttribute="top" secondItem="BpI-Hz-KH2" secondAttribute="topMargin" id="K3n-tK-0tu"/>
<constraint firstItem="UOo-9z-IuL" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="5tY-5k-v2g" secondAttribute="trailing" constant="8" id="KeP-ft-0GH"/>
<constraint firstItem="UOo-9z-IuL" firstAttribute="centerY" secondItem="BpI-Hz-KH2" secondAttribute="centerY" id="V2L-l3-s1E"/>
<constraint firstAttribute="bottomMargin" secondItem="5tY-5k-v2g" secondAttribute="bottom" id="VeZ-P7-kQW"/>
<constraint firstAttribute="trailing" secondItem="UOo-9z-IuL" secondAttribute="trailing" constant="20" symbolic="YES" id="mNk-x8-oJx"/>
<constraint firstItem="5tY-5k-v2g" firstAttribute="leading" secondItem="BpI-Hz-KH2" secondAttribute="leadingMargin" id="v4X-Nd-cpC"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="none" indentationWidth="10" id="WR6-xo-ty2" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="631.5" width="374" height="44"/>
<rect key="frame" x="20" y="719.5" width="374" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="WR6-xo-ty2" id="zX8-l2-bVH">
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" text="Show Articles in Full Screen" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="79e-5s-vd0">
<rect key="frame" x="20" y="11.5" width="206" height="21"/>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="749" verticalCompressionResistancePriority="751" text="Enable Full Screen Articles" textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="79e-5s-vd0">
<rect key="frame" x="20" y="11" width="203.5" height="15.5"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
<switch opaque="NO" contentMode="scaleToFill" horizontalHuggingPriority="750" verticalHuggingPriority="750" contentHorizontalAlignment="center" contentVerticalAlignment="center" translatesAutoresizingMaskIntoConstraints="NO" id="2Md-2E-7Z4">
<rect key="frame" x="305" y="6.5" width="51" height="31"/>
<rect key="frame" x="305" y="3.5" width="51" height="31"/>
<color key="onTintColor" name="secondaryAccentColor"/>
<connections>
<action selector="switchFullscreenArticles:" destination="a0p-rk-skQ" eventType="valueChanged" id="5fa-Ad-e0j"/>
</connections>
</switch>
<label opaque="NO" userInteractionEnabled="NO" contentMode="left" horizontalHuggingPriority="251" verticalHuggingPriority="251" horizontalCompressionResistancePriority="749" text="Tap the article top bar to enter Full Screen. Tap the top or bottom to exit." textAlignment="natural" lineBreakMode="tailTruncation" numberOfLines="0" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" translatesAutoresizingMaskIntoConstraints="NO" id="a30-nc-ZS4">
<rect key="frame" x="20" y="33" width="266.5" height="0.0"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleFootnote"/>
<nil key="textColor"/>
<nil key="highlightedColor"/>
</label>
</subviews>
<constraints>
<constraint firstItem="2Md-2E-7Z4" firstAttribute="centerY" secondItem="zX8-l2-bVH" secondAttribute="centerY" id="1ae-Z0-Rxf"/>
<constraint firstItem="2Md-2E-7Z4" firstAttribute="centerY" secondItem="79e-5s-vd0" secondAttribute="centerY" id="3KV-rT-Dfb"/>
<constraint firstItem="a30-nc-ZS4" firstAttribute="leading" secondItem="zX8-l2-bVH" secondAttribute="leadingMargin" id="52y-SY-gbp"/>
<constraint firstItem="79e-5s-vd0" firstAttribute="top" secondItem="zX8-l2-bVH" secondAttribute="topMargin" id="9bF-Q1-sYE"/>
<constraint firstItem="2Md-2E-7Z4" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="a30-nc-ZS4" secondAttribute="trailing" constant="8" id="E9l-8S-WBL"/>
<constraint firstAttribute="trailing" secondItem="2Md-2E-7Z4" secondAttribute="trailing" constant="20" symbolic="YES" id="ELH-06-H2j"/>
<constraint firstItem="79e-5s-vd0" firstAttribute="centerY" secondItem="zX8-l2-bVH" secondAttribute="centerY" id="FoL-fO-aDw"/>
<constraint firstItem="a30-nc-ZS4" firstAttribute="bottom" secondItem="zX8-l2-bVH" secondAttribute="bottomMargin" id="b3g-at-rjh"/>
<constraint firstItem="2Md-2E-7Z4" firstAttribute="leading" relation="greaterThanOrEqual" secondItem="79e-5s-vd0" secondAttribute="trailing" constant="8" id="lUn-8D-X20"/>
<constraint firstItem="79e-5s-vd0" firstAttribute="leading" secondItem="zX8-l2-bVH" secondAttribute="leadingMargin" id="tdZ-30-ACC"/>
<constraint firstItem="a30-nc-ZS4" firstAttribute="top" secondItem="79e-5s-vd0" secondAttribute="bottom" constant="6.5" id="wuJ-LG-d6p"/>
</constraints>
</tableViewCellContentView>
</tableViewCell>
@@ -233,7 +311,7 @@
<tableViewSection headerTitle="Help" id="TkH-4v-yhk">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="40W-2p-ne4" style="IBUITableViewCellStyleDefault" id="Om7-lH-RUh" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="731.5" width="374" height="44"/>
<rect key="frame" x="20" y="819.5" width="374" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="Om7-lH-RUh" id="vrJ-nE-HMP">
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
@@ -250,7 +328,7 @@
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="lOk-Dh-GfZ" style="IBUITableViewCellStyleDefault" id="GWZ-jk-qU6" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="775.5" width="374" height="44"/>
<rect key="frame" x="20" y="863.5" width="374" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="GWZ-jk-qU6" id="ZgS-bo-xDl">
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
@@ -267,7 +345,7 @@
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="Pm8-6D-fdE" style="IBUITableViewCellStyleDefault" id="3cU-BG-6kK" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="819.5" width="374" height="44"/>
<rect key="frame" x="20" y="907.5" width="374" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="3cU-BG-6kK" id="Qm0-SY-0vx">
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
@@ -284,7 +362,7 @@
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="TEA-EG-V6d" style="IBUITableViewCellStyleDefault" id="4yc-ig-I61" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="863.5" width="374" height="44"/>
<rect key="frame" x="20" y="951.5" width="374" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="4yc-ig-I61" id="uQl-VP-9p9">
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
@@ -301,7 +379,7 @@
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="Q9a-Pi-uCc" style="IBUITableViewCellStyleDefault" id="mSW-A7-8lf" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="907.5" width="374" height="44"/>
<rect key="frame" x="20" y="995.5" width="374" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="mSW-A7-8lf" id="shF-ro-Zpx">
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
@@ -318,14 +396,14 @@
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="dWz-1o-EpJ" style="IBUITableViewCellStyleDefault" id="2MG-qn-idJ" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="951.5" width="374" height="44"/>
<rect key="frame" x="0.0" y="1039.5" width="374" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="2MG-qn-idJ" id="gP9-ry-keC">
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="Technotes" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" id="dWz-1o-EpJ">
<rect key="frame" x="20" y="0.0" width="334" height="44"/>
<rect key="frame" x="15" y="0.0" width="351" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
@@ -335,14 +413,14 @@
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" textLabel="2o6-8W-nyK" style="IBUITableViewCellStyleDefault" id="he9-Ql-yfa" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="995.5" width="374" height="44"/>
<rect key="frame" x="0.0" y="1083.5" width="374" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="he9-Ql-yfa" id="q6L-C8-H9a">
<rect key="frame" x="0.0" y="0.0" width="374" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="NetNewsWire Slack" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" id="2o6-8W-nyK">
<rect key="frame" x="20" y="0.0" width="334" height="44"/>
<rect key="frame" x="15" y="0.0" width="351" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
@@ -352,14 +430,14 @@
</tableViewCellContentView>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" accessoryType="disclosureIndicator" indentationWidth="10" textLabel="Uwu-af-31r" style="IBUITableViewCellStyleDefault" id="EvG-yE-gDF" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="1039.5" width="374" height="44"/>
<rect key="frame" x="0.0" y="1127.5" width="374" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="EvG-yE-gDF" id="wBN-zJ-6pN">
<rect key="frame" x="0.0" y="0.0" width="343" height="44"/>
<rect key="frame" x="0.0" y="0.0" width="355" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<label opaque="NO" multipleTouchEnabled="YES" contentMode="left" insetsLayoutMarginsFromSafeArea="NO" text="About NetNewsWire" textAlignment="natural" lineBreakMode="tailTruncation" baselineAdjustment="alignBaselines" adjustsFontForContentSizeCategory="YES" adjustsFontSizeToFit="NO" id="Uwu-af-31r">
<rect key="frame" x="20" y="0.0" width="315" height="44"/>
<rect key="frame" x="15" y="0.0" width="332" height="44"/>
<autoresizingMask key="autoresizingMask"/>
<fontDescription key="fontDescription" style="UICTFontTextStyleBody"/>
<nil key="textColor"/>
@@ -384,7 +462,9 @@
</barButtonItem>
</navigationItem>
<connections>
<outlet property="confirmMarkAllAsReadSwitch" destination="UOo-9z-IuL" id="yLZ-Kf-wDt"/>
<outlet property="groupByFeedSwitch" destination="JNi-Wz-RbU" id="TwH-Kd-o6N"/>
<outlet property="refreshClearsReadArticlesSwitch" destination="duV-CN-JmH" id="xTd-jF-Ei1"/>
<outlet property="showFullscreenArticlesSwitch" destination="2Md-2E-7Z4" id="lEN-VP-wEO"/>
<outlet property="timelineSortOrderSwitch" destination="Keq-Np-l9O" id="Zm7-HG-r5h"/>
</connections>
@@ -403,7 +483,7 @@
<sections>
<tableViewSection id="m3P-em-PgI">
<cells>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="55" id="UFl-6I-ucw" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="55" id="UFl-6I-ucw" customClass="SettingsAccountTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="18" width="374" height="55"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="UFl-6I-ucw" id="99i-Ge-guB">
@@ -435,8 +515,12 @@
<constraint firstItem="iTt-HT-Ane" firstAttribute="centerY" secondItem="99i-Ge-guB" secondAttribute="centerY" id="UaS-Yf-Q1x"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="accountImage" destination="tb2-dO-AhR" id="Ucm-F4-aev"/>
<outlet property="accountNameLabel" destination="116-rt-msI" id="nn5-2i-HqG"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="56" id="te1-L9-osf" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="56" id="te1-L9-osf" customClass="SettingsAccountTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="73" width="374" height="56"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="te1-L9-osf" id="DgY-u7-DRO">
@@ -468,8 +552,12 @@
<constraint firstItem="7dy-NH-2zV" firstAttribute="leading" secondItem="DgY-u7-DRO" secondAttribute="leading" constant="20" symbolic="YES" id="H71-Jv-7uw"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="accountImage" destination="wyu-mZ-3zz" id="III-7X-cz1"/>
<outlet property="accountNameLabel" destination="uiN-cA-Nc5" id="tvs-Fo-cvB"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="56" id="zcM-qz-glk" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="56" id="zcM-qz-glk" customClass="SettingsAccountTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="129" width="374" height="56"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="zcM-qz-glk" id="3VG-Ax-7gi">
@@ -501,8 +589,12 @@
<constraint firstItem="cXZ-17-bhe" firstAttribute="centerY" secondItem="3VG-Ax-7gi" secondAttribute="centerY" id="r36-pZ-Siw"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="accountImage" destination="fAO-P0-gtD" id="z7J-sQ-zMJ"/>
<outlet property="accountNameLabel" destination="u2M-c5-ujy" id="TFJ-Yt-NAB"/>
</connections>
</tableViewCell>
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="56" id="sKj-1P-BwI" customClass="VibrantTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<tableViewCell clipsSubviews="YES" contentMode="scaleToFill" preservesSuperviewLayoutMargins="YES" selectionStyle="default" indentationWidth="10" rowHeight="56" id="sKj-1P-BwI" customClass="SettingsAccountTableViewCell" customModule="NetNewsWire" customModuleProvider="target">
<rect key="frame" x="20" y="185" width="374" height="56"/>
<autoresizingMask key="autoresizingMask"/>
<tableViewCellContentView key="contentView" opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" preservesSuperviewLayoutMargins="YES" insetsLayoutMarginsFromSafeArea="NO" tableViewCell="sKj-1P-BwI" id="PdS-21-hdl">
@@ -534,6 +626,10 @@
<constraint firstItem="TmQ-Hs-znP" firstAttribute="centerY" secondItem="PdS-21-hdl" secondAttribute="centerY" id="oQy-rL-HV3"/>
</constraints>
</tableViewCellContentView>
<connections>
<outlet property="accountImage" destination="pIU-f0-h1H" id="4Mm-Ym-81C"/>
<outlet property="accountNameLabel" destination="Dur-Qf-YYi" id="DAF-c9-MJM"/>
</connections>
</tableViewCell>
</cells>
</tableViewSection>

View File

@@ -8,6 +8,7 @@
import UIKit
import Account
import CoreServices
import SafariServices
class SettingsViewController: UITableViewController {
@@ -17,8 +18,11 @@ class SettingsViewController: UITableViewController {
@IBOutlet weak var timelineSortOrderSwitch: UISwitch!
@IBOutlet weak var groupByFeedSwitch: UISwitch!
@IBOutlet weak var refreshClearsReadArticlesSwitch: UISwitch!
@IBOutlet weak var confirmMarkAllAsReadSwitch: UISwitch!
@IBOutlet weak var showFullscreenArticlesSwitch: UISwitch!
var scrollToArticlesSection = false
weak var presentingParentController: UIViewController?
override func viewDidLoad() {
@@ -33,7 +37,7 @@ class SettingsViewController: UITableViewController {
tableView.register(UINib(nibName: "SettingsAccountTableViewCell", bundle: nil), forCellReuseIdentifier: "SettingsAccountTableViewCell")
tableView.register(UINib(nibName: "SettingsTableViewCell", bundle: nil), forCellReuseIdentifier: "SettingsTableViewCell")
}
override func viewWillAppear(_ animated: Bool) {
@@ -51,7 +55,19 @@ class SettingsViewController: UITableViewController {
groupByFeedSwitch.isOn = false
}
if AppDefaults.articleFullscreenEnabled {
if AppDefaults.refreshClearsReadArticles {
refreshClearsReadArticlesSwitch.isOn = true
} else {
refreshClearsReadArticlesSwitch.isOn = false
}
if AppDefaults.confirmMarkAllAsRead {
confirmMarkAllAsReadSwitch.isOn = true
} else {
confirmMarkAllAsReadSwitch.isOn = false
}
if AppDefaults.articleFullscreenAvailable {
showFullscreenArticlesSwitch.isOn = true
} else {
showFullscreenArticlesSwitch.isOn = false
@@ -74,54 +90,38 @@ class SettingsViewController: UITableViewController {
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
self.tableView.selectRow(at: nil, animated: true, scrollPosition: .none)
if scrollToArticlesSection {
tableView.scrollToRow(at: IndexPath(row: 0, section: 4), at: .top, animated: true)
scrollToArticlesSection = false
}
}
// MARK: UITableView
override func numberOfSections(in tableView: UITableView) -> Int {
var sections = super.numberOfSections(in: tableView)
if traitCollection.userInterfaceIdiom != .phone {
sections = sections - 1
}
return sections
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
var adjustedSection = section
if traitCollection.userInterfaceIdiom != .phone && section > 3 {
adjustedSection = adjustedSection + 1
}
switch adjustedSection {
switch section {
case 1:
return AccountManager.shared.accounts.count + 1
case 2:
let defaultNumberOfRows = super.tableView(tableView, numberOfRowsInSection: adjustedSection)
let defaultNumberOfRows = super.tableView(tableView, numberOfRowsInSection: section)
if AccountManager.shared.activeAccounts.isEmpty || AccountManager.shared.anyAccountHasFeedWithURL(appNewsURLString) {
return defaultNumberOfRows - 1
}
return defaultNumberOfRows
case 4:
return traitCollection.userInterfaceIdiom == .phone ? 2 : 1
default:
return super.tableView(tableView, numberOfRowsInSection: adjustedSection)
return super.tableView(tableView, numberOfRowsInSection: section)
}
}
override func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? {
var adjustedSection = section
if traitCollection.userInterfaceIdiom != .phone && adjustedSection > 3 {
adjustedSection = adjustedSection + 1
}
return super.tableView(tableView, titleForHeaderInSection: adjustedSection)
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
var adjustedSection = indexPath.section
if traitCollection.userInterfaceIdiom != .phone && adjustedSection > 3 {
adjustedSection = adjustedSection + 1
}
let cell: UITableViewCell
switch adjustedSection {
switch indexPath.section {
case 1:
let sortedAccounts = AccountManager.shared.sortedAccounts
@@ -138,8 +138,7 @@ class SettingsViewController: UITableViewController {
}
default:
let adjustedIndexPath = IndexPath(row: indexPath.row, section: adjustedSection)
cell = super.tableView(tableView, cellForRowAt: adjustedIndexPath)
cell = super.tableView(tableView, cellForRowAt: indexPath)
}
@@ -147,12 +146,8 @@ class SettingsViewController: UITableViewController {
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
var adjustedSection = indexPath.section
if traitCollection.userInterfaceIdiom != .phone && adjustedSection > 3 {
adjustedSection = adjustedSection + 1
}
switch adjustedSection {
switch indexPath.section {
case 0:
UIApplication.shared.open(URL(string: "\(UIApplication.openSettingsURLString)")!)
tableView.selectRow(at: nil, animated: true, scrollPosition: .none)
@@ -188,7 +183,7 @@ class SettingsViewController: UITableViewController {
}
case 3:
switch indexPath.row {
case 2:
case 3:
let timeline = UIStoryboard.settings.instantiateController(ofType: TimelineCustomizerViewController.self)
self.navigationController?.pushViewController(timeline, animated: true)
default:
@@ -270,11 +265,27 @@ class SettingsViewController: UITableViewController {
}
}
@IBAction func switchClearsReadArticles(_ sender: Any) {
if refreshClearsReadArticlesSwitch.isOn {
AppDefaults.refreshClearsReadArticles = true
} else {
AppDefaults.refreshClearsReadArticles = false
}
}
@IBAction func switchConfirmMarkAllAsRead(_ sender: Any) {
if confirmMarkAllAsReadSwitch.isOn {
AppDefaults.confirmMarkAllAsRead = true
} else {
AppDefaults.confirmMarkAllAsRead = false
}
}
@IBAction func switchFullscreenArticles(_ sender: Any) {
if showFullscreenArticlesSwitch.isOn {
AppDefaults.articleFullscreenEnabled = true
AppDefaults.articleFullscreenAvailable = true
} else {
AppDefaults.articleFullscreenEnabled = false
AppDefaults.articleFullscreenAvailable = false
}
}
@@ -308,9 +319,10 @@ extension SettingsViewController: UIDocumentPickerDelegate {
switch result {
case .success:
break
case .failure(let error):
case .failure:
let title = NSLocalizedString("Import Failed", comment: "Import Failed")
self.presentError(title: title, message: error.localizedDescription)
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)
}
}
}
@@ -372,7 +384,17 @@ private extension SettingsViewController {
}
func importOPMLDocumentPicker() {
let docPicker = UIDocumentPickerViewController(documentTypes: ["public.xml", "org.opml.opml"], in: .import)
let utiArray = UTTypeCreateAllIdentifiersForTag(kUTTagClassFilenameExtension, "opml" as NSString, nil)?.takeRetainedValue() as? [String] ?? [String]()
var opmlUTIs = utiArray
.compactMap({ UTTypeCopyDeclaration($0 as NSString)?.takeUnretainedValue() as? [String: Any] })
.reduce([String]()) { (result, dict) in
return result + dict.values.compactMap({ $0 as? String })
}
opmlUTIs.append("public.xml")
let docPicker = UIDocumentPickerViewController(documentTypes: opmlUTIs, in: .import)
docPicker.delegate = self
docPicker.modalPresentationStyle = .formSheet
self.present(docPicker, animated: true)
@@ -380,6 +402,7 @@ private extension SettingsViewController {
func exportOPML(sourceView: UIView, sourceRect: CGRect) {
if AccountManager.shared.accounts.count == 1 {
opmlAccount = AccountManager.shared.accounts.first!
exportOPMLDocumentPicker()
} else {
exportOPMLAccountPicker(sourceView: sourceView, sourceRect: sourceRect)

View File

@@ -67,7 +67,7 @@ private extension TimelinePreviewTableViewController {
let prototypeID = "prototype"
let status = ArticleStatus(articleID: prototypeID, read: false, starred: false, userDeleted: false, dateArrived: Date())
let prototypeArticle = Article(accountID: prototypeID, articleID: prototypeID, webFeedID: prototypeID, uniqueID: prototypeID, title: longTitle, contentHTML: nil, contentText: nil, url: nil, externalURL: nil, summary: nil, imageURL: nil, bannerImageURL: nil, datePublished: nil, dateModified: nil, authors: nil, status: status)
let prototypeArticle = Article(accountID: prototypeID, articleID: prototypeID, webFeedID: prototypeID, uniqueID: prototypeID, title: longTitle, contentHTML: nil, contentText: nil, url: nil, externalURL: nil, summary: nil, imageURL: nil, datePublished: nil, dateModified: nil, authors: nil, status: status)
let iconImage = IconImage(AppAssets.faviconTemplateImage.withTintColor(AppAssets.secondaryAccentColor))

View File

@@ -0,0 +1,49 @@
//
// ShareDefaultContainer.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 2/11/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
struct ShareDefaultContainer {
static func defaultContainer(containers: ExtensionContainers) -> ExtensionContainer? {
if let accountID = AppDefaults.addWebFeedAccountID, let account = containers.accounts.first(where: { $0.accountID == accountID }) {
if let folderName = AppDefaults.addWebFeedFolderName, let folder = account.folders.first(where: { $0.name == folderName }) {
return folder
} else {
return substituteContainerIfNeeded(account: account)
}
} else if let account = containers.accounts.first {
return substituteContainerIfNeeded(account: account)
} else {
return nil
}
}
static func saveDefaultContainer(_ container: ExtensionContainer) {
AppDefaults.addWebFeedAccountID = container.accountID
if let folder = container as? ExtensionFolder {
AppDefaults.addWebFeedFolderName = folder.name
} else {
AppDefaults.addWebFeedFolderName = nil
}
}
private static func substituteContainerIfNeeded(account: ExtensionAccount) -> ExtensionContainer? {
if !account.disallowFeedInRootFolder {
return account
} else {
if let folder = account.folders.first {
return folder
} else {
return nil
}
}
}
}

View File

@@ -7,30 +7,24 @@
//
import UIKit
import RSCore
import Account
import RSCore
protocol ShareFolderPickerControllerDelegate: class {
func shareFolderPickerDidSelect(_ container: Container)
func shareFolderPickerDidSelect(_ container: ExtensionContainer)
}
class ShareFolderPickerController: UITableViewController {
var selectedContainer: Container?
var containers = [Container]()
var containers: [ExtensionContainer]?
var selectedContainerID: ContainerIdentifier?
weak var delegate: ShareFolderPickerControllerDelegate?
override func viewDidLoad() {
for account in AccountManager.shared.sortedActiveAccounts {
containers.append(account)
if let sortedFolders = account.sortedFolders {
containers.append(contentsOf: sortedFolders)
}
}
tableView.register(UINib(nibName: "ShareFolderPickerAccountCell", bundle: Bundle.main), forCellReuseIdentifier: "AccountCell")
tableView.register(UINib(nibName: "ShareFolderPickerFolderCell", bundle: Bundle.main), forCellReuseIdentifier: "FolderCell")
}
override func numberOfSections(in tableView: UITableView) -> Int {
@@ -38,30 +32,28 @@ class ShareFolderPickerController: UITableViewController {
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return containers.count
return containers?.count ?? 0
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let container = containers[indexPath.row]
let container = containers?[indexPath.row]
let cell: ShareFolderPickerCell = {
if container is Account {
if container is ExtensionAccount {
return tableView.dequeueReusableCell(withIdentifier: "AccountCell", for: indexPath) as! ShareFolderPickerCell
} else {
return tableView.dequeueReusableCell(withIdentifier: "FolderCell", for: indexPath) as! ShareFolderPickerCell
}
}()
if let account = container as? Account {
if let account = container as? ExtensionAccount {
cell.icon.image = AppAssets.image(for: account.type)
} else {
cell.icon.image = AppAssets.masterFolderImage.image
}
if let displayNameProvider = container as? DisplayNameProvider {
cell.label?.text = displayNameProvider.nameForDisplay
}
if let compContainer = selectedContainer, container === compContainer {
cell.label?.text = container?.name ?? ""
if let containerID = container?.containerID, containerID == selectedContainerID {
cell.accessoryType = .checkmark
} else {
cell.accessoryType = .none
@@ -71,9 +63,9 @@ class ShareFolderPickerController: UITableViewController {
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
let container = containers[indexPath.row]
guard let container = containers?[indexPath.row] else { return }
if let account = container as? Account, account.behaviors.contains(.disallowFeedInRootFolder) {
if let account = container as? ExtensionAccount, account.disallowFeedInRootFolder {
tableView.selectRow(at: nil, animated: false, scrollPosition: .none)
} else {
let cell = tableView.cellForRow(at: indexPath)

View File

@@ -8,23 +8,26 @@
import UIKit
import MobileCoreServices
import Social
import Account
import Articles
import Social
import RSCore
import RSTree
class ShareViewController: SLComposeServiceViewController, ShareFolderPickerControllerDelegate {
private var url: URL?
private var container: Container?
private var extensionContainers: ExtensionContainers?
private var flattenedContainers: [ExtensionContainer]!
private var selectedContainer: ExtensionContainer?
private var folderItem: SLComposeSheetConfigurationItem!
override func viewDidLoad() {
AccountManager.shared = AccountManager()
container = AddWebFeedDefaultContainer.defaultContainer
extensionContainers = ExtensionContainersFile.read()
flattenedContainers = extensionContainers?.flattened ?? [ExtensionContainer]()
if let extensionContainers = extensionContainers {
selectedContainer = ShareDefaultContainer.defaultContainer(containers: extensionContainers)
}
title = "NetNewsWire"
placeholder = "Feed Name (Optional)"
@@ -32,14 +35,14 @@ class ShareViewController: SLComposeServiceViewController, ShareFolderPickerCont
button.title = "Add Feed"
button.isEnabled = true
}
// Hack the bottom table rows to be smaller since the controller itself doesn't have enough sense to size itself correctly
if let nav = self.children.first as? UINavigationController, let tableView = nav.children.first?.view.subviews.first as? UITableView {
tableView.rowHeight = 38
}
var provider: NSItemProvider? = nil
// Try to get any HTML that is maybe passed in
for item in self.extensionContext!.inputItems as! [NSExtensionItem] {
for itemProvider in item.attachments! {
@@ -48,7 +51,7 @@ class ShareViewController: SLComposeServiceViewController, ShareFolderPickerCont
}
}
}
if provider != nil {
provider!.loadItem(forTypeIdentifier: kUTTypePropertyList as String, options: nil, completionHandler: { [weak self] (pList, error) in
if error != nil {
@@ -66,7 +69,7 @@ class ShareViewController: SLComposeServiceViewController, ShareFolderPickerCont
})
return
}
// Try to get the URL if it is passed in
for item in self.extensionContext!.inputItems as! [NSExtensionItem] {
for itemProvider in item.attachments! {
@@ -75,7 +78,7 @@ class ShareViewController: SLComposeServiceViewController, ShareFolderPickerCont
}
}
}
if provider != nil {
provider!.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil, completionHandler: { [weak self] (urlCoded, error) in
if error != nil {
@@ -91,44 +94,25 @@ class ShareViewController: SLComposeServiceViewController, ShareFolderPickerCont
}
override func isContentValid() -> Bool {
return url != nil && container != nil
return url != nil && selectedContainer != nil
}
override func didSelectPost() {
var account: Account?
if let containerAccount = container as? Account {
account = containerAccount
} else if let containerFolder = container as? Folder, let containerAccount = containerFolder.account {
account = containerAccount
guard let url = url, let selectedContainer = selectedContainer, let containerID = selectedContainer.containerID else {
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
return
}
if let urlString = url?.absoluteString, account!.hasWebFeed(withURL: urlString) {
presentError(AccountError.createErrorAlreadySubscribed)
return
}
let feedName = contentText.isEmpty ? nil : contentText
account!.createWebFeed(url: url!.absoluteString, name: feedName, container: container!) { result in
switch result {
case .success:
account!.save()
AccountManager.shared.suspendNetworkAll()
AccountManager.shared.suspendDatabaseAll()
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
case .failure(let error):
self.presentError(error) {
self.extensionContext!.cancelRequest(withError: error)
}
}
}
let name = contentText.isEmpty ? nil : contentText
let request = ExtensionFeedAddRequest(name: name, feedURL: url, destinationContainerID: containerID)
ExtensionFeedAddRequestFile.save(request)
self.extensionContext!.completeRequest(returningItems: [], completionHandler: nil)
}
func shareFolderPickerDidSelect(_ container: Container) {
AddWebFeedDefaultContainer.saveDefaultContainer(container)
self.container = container
func shareFolderPickerDidSelect(_ container: ExtensionContainer) {
ShareDefaultContainer.saveDefaultContainer(container)
self.selectedContainer = container
updateFolderItemValue()
self.popConfigurationViewController()
}
@@ -150,7 +134,8 @@ class ShareViewController: SLComposeServiceViewController, ShareFolderPickerCont
folderPickerController.navigationController?.title = NSLocalizedString("Folder", comment: "Folder")
folderPickerController.delegate = self
folderPickerController.selectedContainer = self.container
folderPickerController.containers = self.flattenedContainers
folderPickerController.selectedContainerID = self.selectedContainer?.containerID
self.pushConfigurationViewController(folderPickerController)
@@ -165,12 +150,10 @@ class ShareViewController: SLComposeServiceViewController, ShareFolderPickerCont
private extension ShareViewController {
func updateFolderItemValue() {
if let containerName = (container as? DisplayNameProvider)?.nameForDisplay {
if container is Folder {
self.folderItem.value = "\(container?.account?.nameForDisplay ?? "") / \(containerName)"
} else {
self.folderItem.value = containerName
}
if let account = selectedContainer as? ExtensionAccount {
self.folderItem.value = account.name
} else if let folder = selectedContainer as? ExtensionFolder {
self.folderItem.value = "\(folder.accountName) / \(folder.name)"
}
}

View File

@@ -0,0 +1,38 @@
//
// TitleActivityItemSource.swift
// NetNewsWire-iOS
//
// Created by Martin Hartl on 01/11/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
class TitleActivityItemSource: NSObject, UIActivityItemSource {
private let title: String?
init(title: String?) {
self.title = title
}
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any {
return title as Any
}
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
guard let activityType = activityType,
let title = title else {
return NSNull()
}
switch activityType.rawValue {
case "com.omnigroup.OmniFocus3.iOS.QuickEntry",
"com.culturedcode.ThingsiPhone.ShareExtension":
return title
default:
return NSNull()
}
}
}

View File

@@ -0,0 +1,27 @@
//
// Animations.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 1/27/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
/// Used to select which animations should be performed
public struct Animations: OptionSet {
/// Select and deslections will be animated.
public static let select = Animations(rawValue: 1)
/// Scrolling will be animated
public static let scroll = Animations(rawValue: 2)
/// Pushing and popping navigation view controllers will be animated
public static let navigation = Animations(rawValue: 4)
public let rawValue: Int
public init(rawValue: Int) {
self.rawValue = rawValue
}
}

View File

@@ -8,10 +8,6 @@
import UIKit
protocol InteractiveNavigationControllerTappable {
func didTapNavigationBar()
}
class InteractiveNavigationController: UINavigationController {
private let poppableDelegate = PoppableGestureRecognizerDelegate()
@@ -33,8 +29,6 @@ class InteractiveNavigationController: UINavigationController {
poppableDelegate.originalDelegate = interactivePopGestureRecognizer?.delegate
poppableDelegate.navigationController = self
interactivePopGestureRecognizer?.delegate = poppableDelegate
navigationBar.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(didTapNavigationBar)))
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
@@ -44,13 +38,7 @@ class InteractiveNavigationController: UINavigationController {
}
}
@objc func didTapNavigationBar() {
if let tappable = topViewController as? InteractiveNavigationControllerTappable {
tappable.didTapNavigationBar()
}
}
}
// MARK: Private
@@ -59,10 +47,15 @@ private extension InteractiveNavigationController {
func configure() {
isToolbarHidden = false
let navigationAppearance = UINavigationBarAppearance()
navigationAppearance.titleTextAttributes = [.foregroundColor: UIColor.label]
navigationAppearance.largeTitleTextAttributes = [.foregroundColor: UIColor.label]
navigationBar.standardAppearance = navigationAppearance
let navigationStandardAppearance = UINavigationBarAppearance()
navigationStandardAppearance.titleTextAttributes = [.foregroundColor: UIColor.label]
navigationStandardAppearance.largeTitleTextAttributes = [.foregroundColor: UIColor.label]
navigationBar.standardAppearance = navigationStandardAppearance
let scrollEdgeStandardAppearance = UINavigationBarAppearance()
scrollEdgeStandardAppearance.backgroundColor = .systemBackground
navigationBar.scrollEdgeAppearance = scrollEdgeStandardAppearance
navigationBar.tintColor = AppAssets.primaryAccentColor
let toolbarAppearance = UIToolbarAppearance()

View File

@@ -0,0 +1,18 @@
//
// ShareArticleActivityViewController.swift
// NetNewsWire-iOS
//
// Created by Martin Hartl on 01/11/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
extension UIActivityViewController {
convenience init(url: URL, title: String?, applicationActivities: [UIActivity]?) {
let itemSource = ArticleActivityItemSource(url: url, subject: title)
let titleSource = TitleActivityItemSource(title: title)
self.init(activityItems: [titleSource, itemSource], applicationActivities: applicationActivities)
}
}

View File

@@ -0,0 +1,37 @@
//
// UITableView-Extensions.swift
// RSCoreiOS
//
// Created by Maurice Parker on 9/6/19.
// Copyright © 2019 Ranchero Software, LLC. All rights reserved.
//
import UIKit
extension UITableView {
/**
Selects a row and scrolls it to the middle if it is not visible
*/
public func selectRowAndScrollIfNotVisible(at indexPath: IndexPath, animations: Animations) {
selectRow(at: indexPath, animated: animations.contains(.select), scrollPosition: .none)
if let visibleIndexPaths = indexPathsForRows(in: safeAreaLayoutGuide.layoutFrame) {
if !(visibleIndexPaths.contains(indexPath) && cellCompletelyVisable(indexPath)) {
scrollToRow(at: indexPath, at: .middle, animated: animations.contains(.scroll))
}
}
}
func cellCompletelyVisable(_ indexPath: IndexPath) -> Bool {
let rect = rectForRow(at: indexPath)
return safeAreaLayoutGuide.layoutFrame.contains(rect)
}
public func middleVisibleRow() -> IndexPath? {
if let visibleIndexPaths = indexPathsForRows(in: safeAreaLayoutGuide.layoutFrame), visibleIndexPaths.count > 2 {
return visibleIndexPaths[visibleIndexPaths.count / 2]
}
return nil
}
}

View File

@@ -0,0 +1,58 @@
//
// UIViewController-Extensions.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 1/16/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
import RSCore
import Account
extension UIViewController {
func presentError(_ error: Error, dismiss: (() -> Void)? = nil) {
if let accountError = error as? AccountError, accountError.isCredentialsError {
presentAccountError(accountError, dismiss: dismiss)
} else {
let errorTitle = NSLocalizedString("Error", comment: "Error")
presentError(title: errorTitle, message: error.localizedDescription, dismiss: dismiss)
}
}
}
private extension UIViewController {
func presentAccountError(_ error: AccountError, dismiss: (() -> Void)? = nil) {
let title = NSLocalizedString("Account Error", comment: "Account Error")
let alertController = UIAlertController(title: title, message: error.localizedDescription, preferredStyle: .alert)
if error.acount?.type == .feedbin {
let credentialsTitle = NSLocalizedString("Update Credentials", comment: "Update Credentials")
let credentialsAction = UIAlertAction(title: credentialsTitle, style: .default) { [weak self] _ in
dismiss?()
let navController = UIStoryboard.account.instantiateViewController(withIdentifier: "FeedbinAccountNavigationViewController") as! UINavigationController
navController.modalPresentationStyle = .formSheet
let addViewController = navController.topViewController as! FeedbinAccountViewController
addViewController.account = error.acount
self?.present(navController, animated: true)
}
alertController.addAction(credentialsAction)
}
let dismissTitle = NSLocalizedString("OK", comment: "OK")
let dismissAction = UIAlertAction(title: dismissTitle, style: .default) { _ in
dismiss?()
}
alertController.addAction(dismissAction)
self.present(alertController, animated: true, completion: nil)
}
}

View File

@@ -67,3 +67,35 @@ class VibrantTableViewCell: UITableViewCell {
}
}
class VibrantBasicTableViewCell: VibrantTableViewCell {
@IBOutlet private var label: UILabel!
@IBOutlet private var icon: UIImageView!
@IBInspectable var imageNormal: UIImage?
@IBInspectable var imageSelected: UIImage?
var iconTint: UIColor {
return isHighlighted || isSelected ? labelColor : AppAssets.primaryAccentColor
}
var iconImage: UIImage? {
return isHighlighted || isSelected ? imageSelected : imageNormal
}
override func updateVibrancy(animated: Bool) {
super.updateVibrancy(animated: animated)
updateIconVibrancy(icon, color: iconTint, image: iconImage, animated: animated)
updateLabelVibrancy(label, color: labelColor, animated: animated)
}
private func updateIconVibrancy(_ icon: UIImageView?, color: UIColor, image: UIImage?, animated: Bool) {
guard let icon = icon else { return }
UIView.transition(with: icon, duration: duration(animated: animated), options: .transitionCrossDissolve, animations: {
icon.tintColor = color
icon.image = image
}, completion: nil)
}
}