mirror of
https://github.com/Ranchero-Software/NetNewsWire
synced 2025-08-12 06:26:36 +00:00
Merge branch 'ios-candidate'
This commit is contained in:
@@ -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
24
iOS/AccountMigrator.swift
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")!
|
||||
}()
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
60
iOS/Article/ArticleIconSchemeHandler.swift
Normal file
60
iOS/Article/ArticleIconSchemeHandler.swift
Normal 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))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
49
iOS/Article/OpenInSafariActivity.swift
Normal file
49
iOS/Article/OpenInSafariActivity.swift
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
690
iOS/Article/WebViewController.swift
Normal file
690
iOS/Article/WebViewController.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
150
iOS/Article/WebViewProvider.swift
Normal file
150
iOS/Article/WebViewProvider.swift
Normal 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
|
||||
}
|
||||
}
|
||||
25
iOS/Article/WrapperScriptMessageHandler.swift
Normal file
25
iOS/Article/WrapperScriptMessageHandler.swift
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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"/>
|
||||
|
||||
94
iOS/CommonExtension/ExtensionContainers.swift
Normal file
94
iOS/CommonExtension/ExtensionContainers.swift
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
107
iOS/CommonExtension/ExtensionContainersFile.swift
Normal file
107
iOS/CommonExtension/ExtensionContainersFile.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
24
iOS/CommonExtension/ExtensionFeedAddRequest.swift
Normal file
24
iOS/CommonExtension/ExtensionFeedAddRequest.swift
Normal 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
|
||||
|
||||
}
|
||||
160
iOS/CommonExtension/ExtensionFeedAddRequestFile.swift
Normal file
160
iOS/CommonExtension/ExtensionFeedAddRequestFile.swift
Normal 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 }
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
62
iOS/MasterTimeline/MarkAsReadAlertController.swift
Normal file
62
iOS/MasterTimeline/MarkAsReadAlertController.swift
Normal 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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"symbols" : [
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"filename" : "markAllAsRead.svg"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"version" : 1,
|
||||
"author" : "xcode"
|
||||
}
|
||||
}
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}.}
|
||||
@@ -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("");
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
49
iOS/ShareExtension/ShareDefaultContainer.swift
Normal file
49
iOS/ShareExtension/ShareDefaultContainer.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
38
iOS/TitleActivityItemSource.swift
Normal file
38
iOS/TitleActivityItemSource.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
27
iOS/UIKit Extensions/Animations.swift
Normal file
27
iOS/UIKit Extensions/Animations.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
37
iOS/UIKit Extensions/UITableView-Extensions.swift
Normal file
37
iOS/UIKit Extensions/UITableView-Extensions.swift
Normal 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
|
||||
}
|
||||
}
|
||||
58
iOS/UIKit Extensions/UIViewController-Extensions.swift
Normal file
58
iOS/UIKit Extensions/UIViewController-Extensions.swift
Normal 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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user