NetNewsWire/iOS/MasterFeed/MasterFeedViewController.swift
2023-02-05 09:45:34 +08:00

1369 lines
46 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//
// MasterViewController.swift
// NetNewsWire
//
// Created by Maurice Parker on 4/8/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import Account
import Articles
import RSCore
import RSTree
import SafariServices
class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
@IBOutlet weak var filterButton: UIBarButtonItem!
private var refreshProgressView: RefreshProgressView?
@IBOutlet weak var addNewItemButton: UIBarButtonItem! {
didSet {
if #available(iOS 14, *) {
addNewItemButton.primaryAction = nil
} else {
addNewItemButton.action = #selector(MasterFeedViewController.add(_:))
}
}
}
var undoableCommands = [UndoableCommand]()
weak var coordinator: SceneCoordinator!
private let keyboardManager = KeyboardManager(type: .sidebar)
override var keyCommands: [UIKeyCommand]? {
// If the first responder is the WKWebView (PreloadedWebView) we don't want to supply any keyboard
// commands that the system is looking for by going up the responder chain. They will interfere with
// the WKWebViews built in hardware keyboard shortcuts, specifically the up and down arrow keys.
guard let current = UIResponder.currentFirstResponder, !(current is PreloadedWebView) else { return nil }
return keyboardManager.keyCommands
}
override var canBecomeFirstResponder: Bool {
return true
}
override func viewDidLoad() {
super.viewDidLoad()
if traitCollection.userInterfaceIdiom == .phone {
navigationController?.navigationBar.prefersLargeTitles = true
}
// If you don't have an empty table header, UIKit tries to help out by putting one in for you
// that makes a gap between the first section header and the navigation bar
var frame = CGRect.zero
frame.size.height = .leastNormalMagnitude
tableView.tableHeaderView = UIView(frame: frame)
tableView.register(MasterFeedTableViewSectionHeader.self, forHeaderFooterViewReuseIdentifier: "SectionHeader")
tableView.dragDelegate = self
tableView.dropDelegate = self
tableView.dragInteractionEnabled = true
resetEstimatedRowHeight()
tableView.separatorStyle = .none
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidChange(_:)), name: .UnreadCountDidChange, object: nil)
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(contentSizeCategoryDidChange), name: UIContentSizeCategory.didChangeNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(willEnterForeground(_:)), name: UIApplication.willEnterForegroundNotification, object: nil)
NotificationCenter.default.addObserver(self, selector: #selector(configureContextMenu(_:)), name: .ActiveExtensionPointsDidChange, object: nil)
refreshControl = UIRefreshControl()
refreshControl!.addTarget(self, action: #selector(refreshAccounts(_:)), for: .valueChanged)
configureToolbar()
becomeFirstResponder()
}
override func viewWillAppear(_ animated: Bool) {
updateUI()
super.viewWillAppear(animated)
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
if (isBeingPresented || isMovingToParent) {
// Only show the Twitter alert the first time
// the view is presented.
presentTwitterDeprecationAlertIfRequired()
}
}
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
super.traitCollectionDidChange(previousTraitCollection)
if traitCollection.preferredContentSizeCategory != previousTraitCollection?.preferredContentSizeCategory {
IconImageCache.shared.emptyCache()
reloadAllVisibleCells()
}
}
// MARK: Notifications
@objc func unreadCountDidChange(_ note: Notification) {
updateUI()
guard let unreadCountProvider = note.object as? UnreadCountProvider else {
return
}
if let account = unreadCountProvider as? Account {
if let node = coordinator.rootNode.childNodeRepresentingObject(account) {
let sectionIndex = coordinator.rootNode.indexOfChild(node)!
if let headerView = tableView.headerView(forSection: sectionIndex) as? MasterFeedTableViewSectionHeader {
headerView.unreadCount = account.unreadCount
}
}
return
}
var node: Node? = nil
if let coordinator = unreadCountProvider as? SceneCoordinator, let feed = coordinator.timelineFeed {
node = coordinator.rootNode.descendantNodeRepresentingObject(feed as AnyObject)
} else {
node = coordinator.rootNode.descendantNodeRepresentingObject(unreadCountProvider as AnyObject)
}
guard let unreadCountNode = node, let indexPath = coordinator.indexPathFor(unreadCountNode) else { return }
if let cell = tableView.cellForRow(at: indexPath) as? MasterFeedTableViewCell {
cell.unreadCount = unreadCountProvider.unreadCount
}
}
@objc func faviconDidBecomeAvailable(_ note: Notification) {
applyToAvailableCells(configureIcon)
}
@objc func webFeedIconDidBecomeAvailable(_ note: Notification) {
guard let webFeed = note.userInfo?[UserInfoKey.webFeed] as? WebFeed else {
return
}
applyToCellsForRepresentedObject(webFeed, configureIcon(_:_:))
}
@objc func webFeedSettingDidChange(_ note: Notification) {
guard let webFeed = note.object as? WebFeed, let key = note.userInfo?[WebFeed.WebFeedSettingUserInfoKey] as? String else {
return
}
if key == WebFeed.WebFeedSettingKey.homePageURL || key == WebFeed.WebFeedSettingKey.faviconURL {
configureCellsForRepresentedObject(webFeed)
}
}
@objc func contentSizeCategoryDidChange(_ note: Notification) {
resetEstimatedRowHeight()
tableView.reloadData()
}
@objc func willEnterForeground(_ note: Notification) {
updateUI()
}
// MARK: Table View
override func numberOfSections(in tableView: UITableView) -> Int {
coordinator.numberOfSections()
}
override func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
coordinator.numberOfRows(in: section)
}
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! MasterFeedTableViewCell
configure(cell, indexPath)
return cell
}
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
if coordinator.nodeFor(indexPath)?.representedObject is PseudoFeed {
return false
} else {
return true
}
}
override func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
guard let nameProvider = coordinator.rootNode.childAtIndex(section)?.representedObject as? DisplayNameProvider else {
return 44
}
let headerView = MasterFeedTableViewSectionHeader()
headerView.name = nameProvider.nameForDisplay
let size = headerView.sizeThatFits(CGSize(width: tableView.bounds.width, height: 0.0))
return size.height
}
override func tableView(_ tableView: UITableView, viewForHeaderInSection section: Int) -> UIView? {
guard let nameProvider = coordinator.rootNode.childAtIndex(section)?.representedObject as? DisplayNameProvider else {
return nil
}
let headerView = tableView.dequeueReusableHeaderFooterView(withIdentifier: "SectionHeader") as! MasterFeedTableViewSectionHeader
headerView.delegate = self
headerView.name = nameProvider.nameForDisplay
guard let sectionNode = coordinator.rootNode.childAtIndex(section) else {
return headerView
}
if let account = sectionNode.representedObject as? Account {
headerView.unreadCount = account.unreadCount
} else {
headerView.unreadCount = 0
}
headerView.tag = section
headerView.disclosureExpanded = coordinator.isExpanded(sectionNode)
if section == tableView.numberOfSections - 1 {
headerView.isLastSection = true
} else {
headerView.isLastSection = false
}
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 {
headerView.addInteraction(UIContextMenuInteraction(delegate: self))
}
return headerView
}
override func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
return CGFloat.leastNormalMagnitude
}
override func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
return UIView(frame: CGRect.zero)
}
override func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
var actions = [UIContextualAction]()
// Set up the delete action
let deleteTitle = NSLocalizedString("Delete", comment: "Delete")
let deleteAction = UIContextualAction(style: .normal, title: deleteTitle) { [weak self] (action, view, completion) in
self?.delete(indexPath: indexPath)
completion(true)
}
deleteAction.backgroundColor = UIColor.systemRed
actions.append(deleteAction)
// Set up the rename action
let renameTitle = NSLocalizedString("Rename", comment: "Rename")
let renameAction = UIContextualAction(style: .normal, title: renameTitle) { [weak self] (action, view, completion) in
self?.rename(indexPath: indexPath)
completion(true)
}
renameAction.backgroundColor = UIColor.systemOrange
actions.append(renameAction)
if let webFeed = coordinator.nodeFor(indexPath)?.representedObject as? WebFeed {
let moreTitle = NSLocalizedString("More", comment: "More")
let moreAction = UIContextualAction(style: .normal, title: moreTitle) { [weak self] (action, view, completion) in
if let self = self {
let alert = UIAlertController(title: webFeed.nameForDisplay, message: nil, preferredStyle: .actionSheet)
if let popoverController = alert.popoverPresentationController {
popoverController.sourceView = view
popoverController.sourceRect = CGRect(x: view.frame.size.width/2, y: view.frame.size.height/2, width: 1, height: 1)
}
if let action = self.getInfoAlertAction(indexPath: indexPath, completion: completion) {
alert.addAction(action)
}
if let action = self.homePageAlertAction(indexPath: indexPath, completion: completion) {
alert.addAction(action)
}
if let action = self.copyFeedPageAlertAction(indexPath: indexPath, completion: completion) {
alert.addAction(action)
}
if let action = self.copyHomePageAlertAction(indexPath: indexPath, completion: completion) {
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)
})
self.present(alert, animated: true)
}
}
moreAction.backgroundColor = UIColor.systemGray
actions.append(moreAction)
}
return UISwipeActionsConfiguration(actions: actions)
}
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let feed = coordinator.nodeFor(indexPath)?.representedObject as? Feed else {
return nil
}
if feed is WebFeed {
return makeWebFeedContextMenu(indexPath: indexPath, includeDeleteRename: true)
} else if feed is Folder {
return makeFolderContextMenu(indexPath: indexPath)
} else if feed is PseudoFeed {
return makePseudoFeedContextMenu(indexPath: indexPath)
} else {
return nil
}
}
override func tableView(_ tableView: UITableView, previewForHighlightingContextMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
guard let identifier = configuration.identifier as? MasterFeedRowIdentifier,
let cell = tableView.cellForRow(at: identifier.indexPath) else {
return nil
}
return UITargetedPreview(view: cell, parameters: CroppingPreviewParameters(view: cell))
}
override func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
becomeFirstResponder()
coordinator.selectFeed(indexPath: indexPath, animations: [.navigation, .select, .scroll])
}
override func tableView(_ tableView: UITableView, targetIndexPathForMoveFromRowAt sourceIndexPath: IndexPath, toProposedIndexPath proposedDestinationIndexPath: IndexPath) -> IndexPath {
// Adjust the index path so that it will never be in the smart feeds area
let destIndexPath: IndexPath = {
if proposedDestinationIndexPath.section == 0 {
return IndexPath(row: 0, section: 1)
}
return coordinator.cappedIndexPath(proposedDestinationIndexPath)
}()
guard let draggedNode = coordinator.nodeFor(sourceIndexPath) else {
assertionFailure("This should never happen")
return sourceIndexPath
}
// If there is no destination node, we are dragging onto an empty Account
guard let destNode = coordinator.nodeFor(destIndexPath),
let destParentNode = destNode.parent else {
return proposedDestinationIndexPath
}
// If this is a folder, let the users drop on it
if destNode.representedObject is Folder {
return proposedDestinationIndexPath
}
// If we are dragging around in the same container, just return the original source
if destParentNode.childNodes.contains(draggedNode) {
return sourceIndexPath
}
// Suggest to the user the best place to drop the feed
// Revisit if the tree controller can ever be sorted in some other way.
let nodes = destParentNode.childNodes + [draggedNode]
var sortedNodes = nodes.sortedAlphabeticallyWithFoldersAtEnd()
let index = sortedNodes.firstIndex(of: draggedNode)!
sortedNodes.remove(at: index)
if index == 0 {
if destParentNode.representedObject is Account {
return IndexPath(row: 0, section: destIndexPath.section)
} else {
if let candidateIndexPath = coordinator.indexPathFor(sortedNodes[index]) {
let movementAdjustment = sourceIndexPath < destIndexPath ? 1 : 0
return IndexPath(row: candidateIndexPath.row - movementAdjustment, section: candidateIndexPath.section)
} else {
return sourceIndexPath
}
}
} else {
if index >= sortedNodes.count {
if let lastSortedIndexPath = coordinator.indexPathFor(sortedNodes[sortedNodes.count - 1]) {
let movementAdjustment = sourceIndexPath > destIndexPath ? 1 : 0
return IndexPath(row: lastSortedIndexPath.row + movementAdjustment, section: lastSortedIndexPath.section)
} else {
return sourceIndexPath
}
} else {
let movementAdjustment = sourceIndexPath < destIndexPath ? 1 : 0
return coordinator.indexPathFor(sortedNodes[index - movementAdjustment]) ?? sourceIndexPath
}
}
}
// MARK: Actions
@IBAction func settings(_ sender: UIBarButtonItem) {
coordinator.showSettings()
}
@IBAction func toggleFilter(_ sender: Any) {
coordinator.toggleReadFeedsFilter()
}
@IBAction func add(_ sender: UIBarButtonItem) {
if #available(iOS 14, *) {
} else {
let title = NSLocalizedString("Add Item", comment: "Add Item")
let alertController = UIAlertController(title: title, message: nil, preferredStyle: .actionSheet)
let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel")
let cancelAction = UIAlertAction(title: cancelTitle, style: .cancel)
let addWebFeedActionTitle = NSLocalizedString("Add Web Feed", comment: "Add Web Feed")
let addWebFeedAction = UIAlertAction(title: addWebFeedActionTitle, style: .default) { _ in
self.coordinator.showAddWebFeed()
}
let addRedditFeedActionTitle = NSLocalizedString("Add Reddit Feed", comment: "Add Reddit Feed")
let addRedditFeedAction = UIAlertAction(title: addRedditFeedActionTitle, style: .default) { _ in
self.coordinator.showAddRedditFeed()
}
let addWebFolderdActionTitle = NSLocalizedString("Add Folder", comment: "Add Folder")
let addWebFolderAction = UIAlertAction(title: addWebFolderdActionTitle, style: .default) { _ in
self.coordinator.showAddFolder()
}
alertController.addAction(addWebFeedAction)
if AccountManager.shared.activeAccounts.contains(where: { $0.type == .onMyMac || $0.type == .cloudKit }) {
if ExtensionPointManager.shared.isRedditEnabled {
alertController.addAction(addRedditFeedAction)
}
}
alertController.addAction(addWebFolderAction)
alertController.addAction(cancelAction)
alertController.popoverPresentationController?.barButtonItem = sender
present(alertController, animated: true)
}
}
@objc func toggleSectionHeader(_ sender: UITapGestureRecognizer) {
guard let headerView = sender.view as? MasterFeedTableViewSectionHeader else {
return
}
toggle(headerView)
}
@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) {
appDelegate.manualRefresh(errorHandler: ErrorHandler.present(self))
}
}
// MARK: Keyboard shortcuts
@objc func selectNextUp(_ sender: Any?) {
coordinator.selectPrevFeed()
}
@objc func selectNextDown(_ sender: Any?) {
coordinator.selectNextFeed()
}
@objc func navigateToTimeline(_ sender: Any?) {
coordinator.navigateToTimeline()
}
@objc func openInBrowser(_ sender: Any?) {
coordinator.showBrowserForCurrentFeed()
}
@objc override func delete(_ sender: Any?) {
if let indexPath = coordinator.currentFeedIndexPath {
delete(indexPath: indexPath)
}
}
override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
if action == #selector(UIResponder.delete(_:)) {
return isFirstResponder
}
return super.canPerformAction(action, withSender: sender)
}
@objc func expandSelectedRows(_ sender: Any?) {
if let indexPath = coordinator.currentFeedIndexPath, let node = coordinator.nodeFor(indexPath) {
coordinator.expand(node)
}
}
@objc func collapseSelectedRows(_ sender: Any?) {
if let indexPath = coordinator.currentFeedIndexPath, let node = coordinator.nodeFor(indexPath) {
coordinator.collapse(node)
}
}
@objc func expandAll(_ sender: Any?) {
coordinator.expandAllSectionsAndFolders()
}
@objc func collapseAllExceptForGroupItems(_ sender: Any?) {
coordinator.collapseAllFolders()
}
@objc func markAllAsRead(_ sender: Any) {
guard let indexPath = tableView.indexPathForSelectedRow, let contentView = tableView.cellForRow(at: indexPath)?.contentView else {
return
}
let title = NSLocalizedString("Mark All as Read", comment: "Mark All as Read")
MarkAsReadAlertController.confirm(self, coordinator: coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in
self?.coordinator.markAllAsReadInTimeline()
}
}
@objc func showFeedInspector(_ sender: Any?) {
coordinator.showFeedInspector()
}
// MARK: API
func restoreSelectionIfNecessary(adjustScroll: Bool) {
if let indexPath = coordinator.masterFeedIndexPathForCurrentTimeline() {
if adjustScroll {
tableView.selectRowAndScrollIfNotVisible(at: indexPath, animations: [])
} else {
tableView.selectRow(at: indexPath, animated: false, scrollPosition: .none)
}
}
}
func updateFeedSelection(animations: Animations) {
if let indexPath = coordinator.currentFeedIndexPath {
tableView.selectRowAndScrollIfNotVisible(at: indexPath, animations: animations)
} else {
if let indexPath = tableView.indexPathForSelectedRow {
if animations.contains(.select) {
tableView.deselectRow(at: indexPath, animated: true)
} else {
tableView.deselectRow(at: indexPath, animated: false)
}
}
}
}
func reloadFeeds(initialLoad: Bool, changes: ShadowTableChanges, completion: (() -> Void)? = nil) {
updateUI()
guard !initialLoad else {
tableView.reloadData()
completion?()
return
}
tableView.performBatchUpdates {
if let deletes = changes.deletes, !deletes.isEmpty {
tableView.deleteSections(IndexSet(deletes), with: .middle)
}
if let inserts = changes.inserts, !inserts.isEmpty {
tableView.insertSections(IndexSet(inserts), with: .middle)
}
if let moves = changes.moves, !moves.isEmpty {
for move in moves {
tableView.moveSection(move.from, toSection: move.to)
}
}
if let rowChanges = changes.rowChanges {
for rowChange in rowChanges {
if let deletes = rowChange.deleteIndexPaths, !deletes.isEmpty {
tableView.deleteRows(at: deletes, with: .middle)
}
if let inserts = rowChange.insertIndexPaths, !inserts.isEmpty {
tableView.insertRows(at: inserts, with: .middle)
}
if let moves = rowChange.moveIndexPaths, !moves.isEmpty {
for move in moves {
tableView.moveRow(at: move.0, to: move.1)
}
}
}
}
}
if let rowChanges = changes.rowChanges {
for rowChange in rowChanges {
if let reloads = rowChange.reloadIndexPaths, !reloads.isEmpty {
tableView.reloadRows(at: reloads, with: .none)
}
}
}
completion?()
}
func updateUI() {
if coordinator.isReadFeedsFiltered {
setFilterButtonToActive()
} else {
setFilterButtonToInactive()
}
refreshProgressView?.update()
addNewItemButton?.isEnabled = !AccountManager.shared.activeAccounts.isEmpty
configureContextMenu()
}
@objc
func configureContextMenu(_: Any? = nil) {
if #available(iOS 14.0, *) {
/*
Context Menu Order:
1. Add Web Feed
2. Add Reddit Feed
3. Add Folder
*/
var menuItems: [UIAction] = []
let addWebFeedActionTitle = NSLocalizedString("Add Web Feed", comment: "Add Web Feed")
let addWebFeedAction = UIAction(title: addWebFeedActionTitle, image: AppAssets.plus) { _ in
self.coordinator.showAddWebFeed()
}
menuItems.append(addWebFeedAction)
if AccountManager.shared.activeAccounts.contains(where: { $0.type == .onMyMac || $0.type == .cloudKit }) {
if ExtensionPointManager.shared.isRedditEnabled {
let addRedditFeedActionTitle = NSLocalizedString("Add Reddit Feed", comment: "Add Reddit Feed")
let addRedditFeedAction = UIAction(title: addRedditFeedActionTitle, image: AppAssets.contextMenuReddit.tinted(color: .label)) { _ in
self.coordinator.showAddRedditFeed()
}
menuItems.append(addRedditFeedAction)
}
}
let addWebFolderActionTitle = NSLocalizedString("Add Folder", comment: "Add Folder")
let addWebFolderAction = UIAction(title: addWebFolderActionTitle, image: AppAssets.folderOutlinePlus) { _ in
self.coordinator.showAddFolder()
}
menuItems.append(addWebFolderAction)
let contextMenu = UIMenu(title: NSLocalizedString("Add Item", comment: "Add Item"), image: nil, identifier: nil, options: [], children: menuItems.reversed())
self.addNewItemButton.menu = contextMenu
}
}
func focus() {
becomeFirstResponder()
}
func openInAppBrowser() {
if let indexPath = coordinator.currentFeedIndexPath,
let url = coordinator.homePageURLForFeed(indexPath) {
let vc = SFSafariViewController(url: url)
present(vc, animated: true)
}
}
private func presentTwitterDeprecationAlertIfRequired() {
if AppDefaults.shared.twitterDeprecationAlertShown { return }
let expiryDate = Date(timeIntervalSince1970: 1691539200).timeIntervalSince1970 // August 9th 2023, 00:00 UTC
let currentDate = Date().timeIntervalSince1970
if currentDate > expiryDate {
return // If after August 9th, don't show
}
var twitterIsActive: Bool = false
AccountManager.shared.accounts.forEach({ account in
account.flattenedWebFeeds().forEach({ webfeed in
guard let components = URLComponents(string: webfeed.url),
let host = components.host else {
return
}
if host == "twitter.com" {
twitterIsActive = true
return
}
})
})
if twitterIsActive {
showTwitterDeprecationAlert()
}
AppDefaults.shared.twitterDeprecationAlertShown = true
}
private func showTwitterDeprecationAlert() {
let alert = UIAlertController(title: NSLocalizedString("Twitter Integration Removed", comment: "Twitter Integration Removed"),
message: NSLocalizedString("On February 1, 2023, Twitter announced the end of free access to the Twitter API, effective February 9.\n\nSince Twitter does not provide RSS feeds, weve had to use the Twitter API. Without free access to that API, we cant read feeds from Twitter.\n\nWeve left your Twitter feeds intact. If you have any starred items from those feeds, they will remain as long as you dont delete those feeds.\n\nYou can still read whatever you have already downloaded. However, those feeds will no longer update.", comment: "Twitter deprecation message"),
preferredStyle: .alert)
alert.addAction(UIAlertAction(title: "OK", style: .cancel))
present(alert, animated: true)
}
}
// MARK: UIContextMenuInteractionDelegate
extension MasterFeedViewController: UIContextMenuInteractionDelegate {
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, configurationForMenuAtLocation location: CGPoint) -> UIContextMenuConfiguration? {
guard let sectionIndex = interaction.view?.tag,
let sectionNode = coordinator.rootNode.childAtIndex(sectionIndex),
let account = sectionNode.representedObject as? Account
else {
return nil
}
return UIContextMenuConfiguration(identifier: sectionIndex as NSCopying, previewProvider: nil) { suggestedActions in
var menuElements = [UIMenuElement]()
menuElements.append(UIMenu(title: "", options: .displayInline, children: [self.getAccountInfoAction(account: account)]))
if let markAllAction = self.markAllAsReadAction(account: account, contentView: interaction.view) {
menuElements.append(UIMenu(title: "", options: .displayInline, children: [markAllAction]))
}
menuElements.append(UIMenu(title: "", options: .displayInline, children: [self.deactivateAccountAction(account: account)]))
return UIMenu(title: "", children: menuElements)
}
}
func contextMenuInteraction(_ interaction: UIContextMenuInteraction, previewForHighlightingMenuWithConfiguration configuration: UIContextMenuConfiguration) -> UITargetedPreview? {
guard let sectionIndex = configuration.identifier as? Int,
let cell = tableView.headerView(forSection: sectionIndex) else {
return nil
}
return UITargetedPreview(view: cell, parameters: CroppingPreviewParameters(view: cell))
}
}
// MARK: MasterFeedTableViewSectionHeaderDelegate
extension MasterFeedViewController: MasterFeedTableViewSectionHeaderDelegate {
func masterFeedTableViewSectionHeaderDisclosureDidToggle(_ sender: MasterFeedTableViewSectionHeader) {
toggle(sender)
}
}
// MARK: MasterTableViewCellDelegate
extension MasterFeedViewController: MasterFeedTableViewCellDelegate {
func masterFeedTableViewCellDisclosureDidToggle(_ sender: MasterFeedTableViewCell, expanding: Bool) {
if expanding {
expand(sender)
} else {
collapse(sender)
}
}
}
// MARK: Private
private extension MasterFeedViewController {
func configureToolbar() {
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 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 resetEstimatedRowHeight() {
let titleLabel = NonIntrinsicLabel()
titleLabel.text = "But I must explain"
let unreadCountView = MasterFeedUnreadCountView()
unreadCountView.unreadCount = 10
let layout = MasterFeedTableViewCellLayout(cellWidth: tableView.bounds.size.width, insets: tableView.safeAreaInsets, label: titleLabel, unreadCountView: unreadCountView, showingEditingControl: false, indent: false, shouldShowDisclosure: false)
tableView.estimatedRowHeight = layout.height
}
func configure(_ cell: MasterFeedTableViewCell, _ indexPath: IndexPath) {
guard let node = coordinator.nodeFor(indexPath) else { return }
cell.delegate = self
if node.representedObject is Folder {
cell.indentationLevel = 0
} else {
cell.indentationLevel = 1
}
if let containerID = (node.representedObject as? Container)?.containerID {
cell.setDisclosure(isExpanded: coordinator.isExpanded(containerID), animated: false)
cell.isDisclosureAvailable = true
} else {
cell.isDisclosureAvailable = false
}
if let feed = node.representedObject as? Feed {
cell.name = feed.nameForDisplay
cell.unreadCount = feed.unreadCount
}
configureIcon(cell, indexPath)
let rowsInSection = tableView.numberOfRows(inSection: indexPath.section)
if indexPath.row == rowsInSection - 1 {
cell.isSeparatorShown = false
} else {
cell.isSeparatorShown = true
}
}
func configureIcon(_ cell: MasterFeedTableViewCell, _ indexPath: IndexPath) {
guard let node = coordinator.nodeFor(indexPath), let feed = node.representedObject as? Feed, let feedID = feed.feedID else {
return
}
cell.iconImage = IconImageCache.shared.imageFor(feedID)
}
func nameFor(_ node: Node) -> String {
if let displayNameProvider = node.representedObject as? DisplayNameProvider {
return displayNameProvider.nameForDisplay
}
return ""
}
func configureCellsForRepresentedObject(_ representedObject: AnyObject) {
applyToCellsForRepresentedObject(representedObject, configure)
}
func applyToCellsForRepresentedObject(_ representedObject: AnyObject, _ completion: (MasterFeedTableViewCell, IndexPath) -> Void) {
applyToAvailableCells { (cell, indexPath) in
if let node = coordinator.nodeFor(indexPath),
let representedFeed = representedObject as? Feed,
let candidate = node.representedObject as? Feed,
representedFeed.feedID == candidate.feedID {
completion(cell, indexPath)
}
}
}
func applyToAvailableCells(_ completion: (MasterFeedTableViewCell, IndexPath) -> Void) {
tableView.visibleCells.forEach { cell in
guard let indexPath = tableView.indexPath(for: cell) else {
return
}
completion(cell as! MasterFeedTableViewCell, indexPath)
}
}
private func reloadAllVisibleCells(completion: (() -> Void)? = nil) {
guard let indexPaths = tableView.indexPathsForVisibleRows else { return }
tableView.reloadRows(at: indexPaths, with: .none)
restoreSelectionIfNecessary(adjustScroll: false)
}
private func accountForNode(_ node: Node) -> Account? {
if let account = node.representedObject as? Account {
return account
}
if let folder = node.representedObject as? Folder {
return folder.account
}
if let feed = node.representedObject as? WebFeed {
return feed.account
}
return nil
}
func toggle(_ headerView: MasterFeedTableViewSectionHeader) {
guard let sectionNode = coordinator.rootNode.childAtIndex(headerView.tag) else {
return
}
if coordinator.isExpanded(sectionNode) {
headerView.disclosureExpanded = false
coordinator.collapse(sectionNode)
} else {
headerView.disclosureExpanded = true
coordinator.expand(sectionNode)
}
}
func expand(_ cell: MasterFeedTableViewCell) {
guard let indexPath = tableView.indexPath(for: cell), let node = coordinator.nodeFor(indexPath) else {
return
}
coordinator.expand(node)
}
func collapse(_ cell: MasterFeedTableViewCell) {
guard let indexPath = tableView.indexPath(for: cell), let node = coordinator.nodeFor(indexPath) else {
return
}
coordinator.collapse(node)
}
func makeWebFeedContextMenu(indexPath: IndexPath, includeDeleteRename: Bool) -> UIContextMenuConfiguration {
return UIContextMenuConfiguration(identifier: MasterFeedRowIdentifier(indexPath: indexPath), previewProvider: nil, actionProvider: { [ weak self] suggestedActions in
guard let self = self else { return nil }
var menuElements = [UIMenuElement]()
if let inspectorAction = self.getInfoAction(indexPath: indexPath) {
menuElements.append(UIMenu(title: "", options: .displayInline, children: [inspectorAction]))
}
if let homePageAction = self.homePageAction(indexPath: indexPath) {
menuElements.append(UIMenu(title: "", options: .displayInline, children: [homePageAction]))
}
var pageActions = [UIAction]()
if let copyFeedPageAction = self.copyFeedPageAction(indexPath: indexPath) {
pageActions.append(copyFeedPageAction)
}
if let copyHomePageAction = self.copyHomePageAction(indexPath: indexPath) {
pageActions.append(copyHomePageAction)
}
if !pageActions.isEmpty {
menuElements.append(UIMenu(title: "", options: .displayInline, children: pageActions))
}
if let markAllAction = self.markAllAsReadAction(indexPath: indexPath) {
menuElements.append(UIMenu(title: "", options: .displayInline, children: [markAllAction]))
}
if includeDeleteRename {
menuElements.append(UIMenu(title: "",
options: .displayInline,
children: [
self.renameAction(indexPath: indexPath),
self.deleteAction(indexPath: indexPath)
]))
}
return UIMenu(title: "", children: menuElements)
})
}
func makeFolderContextMenu(indexPath: IndexPath) -> UIContextMenuConfiguration {
return UIContextMenuConfiguration(identifier: MasterFeedRowIdentifier(indexPath: indexPath), previewProvider: nil, actionProvider: { [weak self] suggestedActions in
guard let self = self else { return nil }
var menuElements = [UIMenuElement]()
if let markAllAction = self.markAllAsReadAction(indexPath: indexPath) {
menuElements.append(UIMenu(title: "", options: .displayInline, children: [markAllAction]))
}
menuElements.append(UIMenu(title: "",
options: .displayInline,
children: [
self.renameAction(indexPath: indexPath),
self.deleteAction(indexPath: indexPath)
]))
return UIMenu(title: "", children: menuElements)
})
}
func makePseudoFeedContextMenu(indexPath: IndexPath) -> UIContextMenuConfiguration? {
guard let markAllAction = self.markAllAsReadAction(indexPath: indexPath) else {
return nil
}
return UIContextMenuConfiguration(identifier: MasterFeedRowIdentifier(indexPath: indexPath), previewProvider: nil, actionProvider: { suggestedActions in
return UIMenu(title: "", children: [markAllAction])
})
}
func homePageAction(indexPath: IndexPath) -> UIAction? {
guard coordinator.homePageURLForFeed(indexPath) != nil else {
return nil
}
let title = NSLocalizedString("Open Home Page", comment: "Open Home Page")
let action = UIAction(title: title, image: AppAssets.safariImage) { [weak self] action in
self?.coordinator.showBrowserForFeed(indexPath)
}
return action
}
func homePageAlertAction(indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard coordinator.homePageURLForFeed(indexPath) != nil else {
return nil
}
let title = NSLocalizedString("Open Home Page", comment: "Open Home Page")
let action = UIAlertAction(title: title, style: .default) { [weak self] action in
self?.coordinator.showBrowserForFeed(indexPath)
completion(true)
}
return action
}
func copyFeedPageAction(indexPath: IndexPath) -> UIAction? {
guard let webFeed = coordinator.nodeFor(indexPath)?.representedObject as? WebFeed,
let url = URL(string: webFeed.url) else {
return nil
}
let title = NSLocalizedString("Copy Feed URL", comment: "Copy Feed URL")
let action = UIAction(title: title, image: AppAssets.copyImage) { action in
UIPasteboard.general.url = url
}
return action
}
func copyFeedPageAlertAction(indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard let webFeed = coordinator.nodeFor(indexPath)?.representedObject as? WebFeed,
let url = URL(string: webFeed.url) else {
return nil
}
let title = NSLocalizedString("Copy Feed URL", comment: "Copy Feed URL")
let action = UIAlertAction(title: title, style: .default) { action in
UIPasteboard.general.url = url
completion(true)
}
return action
}
func copyHomePageAction(indexPath: IndexPath) -> UIAction? {
guard let webFeed = coordinator.nodeFor(indexPath)?.representedObject as? WebFeed,
let homePageURL = webFeed.homePageURL,
let url = URL(string: homePageURL) else {
return nil
}
let title = NSLocalizedString("Copy Home Page URL", comment: "Copy Home Page URL")
let action = UIAction(title: title, image: AppAssets.copyImage) { action in
UIPasteboard.general.url = url
}
return action
}
func copyHomePageAlertAction(indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard let webFeed = coordinator.nodeFor(indexPath)?.representedObject as? WebFeed,
let homePageURL = webFeed.homePageURL,
let url = URL(string: homePageURL) else {
return nil
}
let title = NSLocalizedString("Copy Home Page URL", comment: "Copy Home Page URL")
let action = UIAlertAction(title: title, style: .default) { action in
UIPasteboard.general.url = url
completion(true)
}
return action
}
func markAllAsReadAlertAction(indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard let webFeed = coordinator.nodeFor(indexPath)?.representedObject as? WebFeed,
webFeed.unreadCount > 0,
let articles = try? webFeed.fetchArticles(), let contentView = self.tableView.cellForRow(at: indexPath)?.contentView else {
return nil
}
let localizedMenuText = NSLocalizedString("Mark All as Read in “%@”", comment: "Command")
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
MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView, 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")
let action = UIAction(title: title, image: AppAssets.trashImage, attributes: .destructive) { [weak self] action in
self?.delete(indexPath: indexPath)
}
return action
}
func renameAction(indexPath: IndexPath) -> UIAction {
let title = NSLocalizedString("Rename", comment: "Rename")
let action = UIAction(title: title, image: AppAssets.editImage) { [weak self] action in
self?.rename(indexPath: indexPath)
}
return action
}
func getInfoAction(indexPath: IndexPath) -> UIAction? {
guard let webFeed = coordinator.nodeFor(indexPath)?.representedObject as? WebFeed else {
return nil
}
let title = NSLocalizedString("Get Info", comment: "Get Info")
let action = UIAction(title: title, image: AppAssets.infoImage) { [weak self] action in
self?.coordinator.showFeedInspector(for: webFeed)
}
return action
}
func getAccountInfoAction(account: Account) -> UIAction {
let title = NSLocalizedString("Get Info", comment: "Get Info")
let action = UIAction(title: title, image: AppAssets.infoImage) { [weak self] action in
self?.coordinator.showAccountInspector(for: account)
}
return action
}
func deactivateAccountAction(account: Account) -> UIAction {
let title = NSLocalizedString("Deactivate", comment: "Deactivate")
let action = UIAction(title: title, image: AppAssets.deactivateImage) { action in
account.isActive = false
}
return action
}
func getInfoAlertAction(indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard let webFeed = coordinator.nodeFor(indexPath)?.representedObject as? WebFeed else {
return nil
}
let title = NSLocalizedString("Get Info", comment: "Get Info")
let action = UIAlertAction(title: title, style: .default) { [weak self] action in
self?.coordinator.showFeedInspector(for: webFeed)
completion(true)
}
return action
}
func markAllAsReadAction(indexPath: IndexPath) -> UIAction? {
guard let feed = coordinator.nodeFor(indexPath)?.representedObject as? Feed,
let contentView = self.tableView.cellForRow(at: indexPath)?.contentView,
feed.unreadCount > 0 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 action = UIAction(title: title, image: AppAssets.markAllAsReadImage) { [weak self] action in
MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in
if let articles = try? feed.fetchUnreadArticles() {
self?.coordinator.markAllAsRead(Array(articles))
}
}
}
return action
}
func markAllAsReadAction(account: Account, contentView: UIView?) -> UIAction? {
guard account.unreadCount > 0, let contentView = contentView else {
return nil
}
let localizedMenuText = NSLocalizedString("Mark All as Read in “%@”", comment: "Command")
let title = NSString.localizedStringWithFormat(localizedMenuText as NSString, account.nameForDisplay) as String
let action = UIAction(title: title, image: AppAssets.markAllAsReadImage) { [weak self] action in
MarkAsReadAlertController.confirm(self, coordinator: self?.coordinator, confirmTitle: title, sourceType: contentView) { [weak self] in
// If you don't have this delay the screen flashes when it executes this code
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
if let articles = try? account.fetchArticles(.unread()) {
self?.coordinator.markAllAsRead(Array(articles))
}
}
}
}
return action
}
func rename(indexPath: IndexPath) {
guard let feed = coordinator.nodeFor(indexPath)?.representedObject as? Feed else { return }
let formatString = NSLocalizedString("Rename “%@”", comment: "Rename feed")
let title = NSString.localizedStringWithFormat(formatString as NSString, feed.nameForDisplay) as String
let alertController = UIAlertController(title: title, message: nil, preferredStyle: .alert)
let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel")
alertController.addAction(UIAlertAction(title: cancelTitle, style: .cancel))
let renameTitle = NSLocalizedString("Rename", comment: "Rename")
let renameAction = UIAlertAction(title: renameTitle, style: .default) { [weak self] action in
guard let name = alertController.textFields?[0].text, !name.isEmpty else {
return
}
if let webFeed = feed as? WebFeed {
webFeed.rename(to: name) { result in
switch result {
case .success:
break
case .failure(let error):
self?.presentError(error)
}
}
} else if let folder = feed as? Folder {
folder.rename(to: name) { result in
switch result {
case .success:
break
case .failure(let error):
self?.presentError(error)
}
}
}
}
alertController.addAction(renameAction)
alertController.preferredAction = renameAction
alertController.addTextField() { textField in
textField.text = feed.nameForDisplay
textField.placeholder = NSLocalizedString("Name", comment: "Name")
}
self.present(alertController, animated: true) {
}
}
func delete(indexPath: IndexPath) {
guard let feed = coordinator.nodeFor(indexPath)?.representedObject as? Feed else { return }
let title: String
let message: String
if feed is Folder {
title = NSLocalizedString("Delete Folder", comment: "Delete folder")
let localizedInformativeText = NSLocalizedString("Are you sure you want to delete the “%@” folder?", comment: "Folder delete text")
message = NSString.localizedStringWithFormat(localizedInformativeText as NSString, feed.nameForDisplay) as String
} else {
title = NSLocalizedString("Delete Feed", comment: "Delete feed")
let localizedInformativeText = NSLocalizedString("Are you sure you want to delete the “%@” feed?", comment: "Feed delete text")
message = NSString.localizedStringWithFormat(localizedInformativeText as NSString, feed.nameForDisplay) as String
}
let alertController = UIAlertController(title: title, message: message, preferredStyle: .alert)
let cancelTitle = NSLocalizedString("Cancel", comment: "Cancel")
alertController.addAction(UIAlertAction(title: cancelTitle, style: .cancel))
let deleteTitle = NSLocalizedString("Delete", comment: "Delete")
let deleteAction = UIAlertAction(title: deleteTitle, style: .destructive) { [weak self] action in
self?.performDelete(indexPath: indexPath)
}
alertController.addAction(deleteAction)
alertController.preferredAction = deleteAction
self.present(alertController, animated: true)
}
func performDelete(indexPath: IndexPath) {
guard let undoManager = undoManager,
let deleteNode = coordinator.nodeFor(indexPath),
let deleteCommand = DeleteCommand(nodesToDelete: [deleteNode], undoManager: undoManager, errorHandler: ErrorHandler.present(self)) else {
return
}
if let folder = deleteNode.representedObject as? Folder {
ActivityManager.cleanUp(folder)
} else if let feed = deleteNode.representedObject as? WebFeed {
ActivityManager.cleanUp(feed)
}
if indexPath == coordinator.currentFeedIndexPath {
coordinator.selectFeed(indexPath: nil)
}
pushUndoableCommand(deleteCommand)
deleteCommand.perform()
}
}
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);
}
}