Refactor Diffable Datasources out of the Sidebar

This commit is contained in:
Maurice Parker 2021-10-20 19:03:02 -05:00
parent fd3a3cf3b3
commit bbc7230e76
13 changed files with 382 additions and 389 deletions

View File

@ -17,6 +17,7 @@ public enum ReadFilterType {
public protocol Feed: FeedIdentifiable, ArticleFetcher, DisplayNameProvider, UnreadCountProvider {
var account: Account? { get }
var defaultReadFilterType: ReadFilterType { get }
}

View File

@ -183,7 +183,6 @@
513C5CE9232571C2003D4054 /* ShareViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 513C5CE8232571C2003D4054 /* ShareViewController.swift */; };
513C5CEC232571C2003D4054 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 513C5CEA232571C2003D4054 /* MainInterface.storyboard */; };
513C5CF0232571C2003D4054 /* NetNewsWire iOS Share Extension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 513C5CE6232571C2003D4054 /* NetNewsWire iOS Share Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
513CCF2524880C1500C55709 /* MasterFeedTableViewIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 513CCF08248808BA00C55709 /* MasterFeedTableViewIdentifier.swift */; };
513F325C2593ECF40003048F /* RSCore in Frameworks */ = {isa = PBXBuildFile; productRef = 513F325B2593ECF40003048F /* RSCore */; };
513F325D2593ECF40003048F /* RSCore in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 513F325B2593ECF40003048F /* RSCore */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; };
513F32712593EE6F0003048F /* Articles in Frameworks */ = {isa = PBXBuildFile; productRef = 513F32702593EE6F0003048F /* Articles */; };
@ -242,7 +241,6 @@
516244E3241E19F000B61C47 /* ColorPaletteTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 516244E2241E19F000B61C47 /* ColorPaletteTableViewController.swift */; };
51627A6723861DA3007B3B4B /* MasterFeedViewController+Drag.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51627A6623861DA3007B3B4B /* MasterFeedViewController+Drag.swift */; };
51627A6923861DED007B3B4B /* MasterFeedViewController+Drop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51627A6823861DED007B3B4B /* MasterFeedViewController+Drop.swift */; };
51627A6B238629D8007B3B4B /* MasterFeedDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51627A6A238629D8007B3B4B /* MasterFeedDataSource.swift */; };
51627A93238A3836007B3B4B /* CroppingPreviewParameters.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51627A92238A3836007B3B4B /* CroppingPreviewParameters.swift */; };
516A093723609A3600EAE89B /* SettingsComboTableViewCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = 516A091D23609A3600EAE89B /* SettingsComboTableViewCell.xib */; };
516A09392360A2AE00EAE89B /* SettingsComboTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 516A09382360A2AE00EAE89B /* SettingsComboTableViewCell.swift */; };
@ -281,6 +279,8 @@
5193CD58245E44A90092735E /* RedditFeedProvider-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5193CD57245E44A90092735E /* RedditFeedProvider-Extensions.swift */; };
5193CD59245E44A90092735E /* RedditFeedProvider-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5193CD57245E44A90092735E /* RedditFeedProvider-Extensions.swift */; };
5193CD5A245E44A90092735E /* RedditFeedProvider-Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5193CD57245E44A90092735E /* RedditFeedProvider-Extensions.swift */; };
5195C1DA2720205F00888867 /* ShadowTableChanges.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5195C1D92720205F00888867 /* ShadowTableChanges.swift */; };
5195C1DC2720BD3000888867 /* MasterFeedRowIdentifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5195C1DB2720BD3000888867 /* MasterFeedRowIdentifier.swift */; };
519B8D332143397200FA689C /* SharingServiceDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519B8D322143397200FA689C /* SharingServiceDelegate.swift */; };
519CA8E525841DB700EB079A /* CrashReporter in Frameworks */ = {isa = PBXBuildFile; productRef = 519CA8E425841DB700EB079A /* CrashReporter */; };
519E743D22C663F900A78E47 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 519E743422C663F900A78E47 /* SceneDelegate.swift */; };
@ -409,8 +409,6 @@
51DC07992552083500A3F79F /* ArticleTextSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51DC07972552083500A3F79F /* ArticleTextSize.swift */; };
51DC079A2552083500A3F79F /* ArticleTextSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51DC07972552083500A3F79F /* ArticleTextSize.swift */; };
51DC07AC255209E200A3F79F /* ArticleTextSize.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51DC07972552083500A3F79F /* ArticleTextSize.swift */; };
51DC37072402153E0095D371 /* UpdateSelectionOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51DC37062402153E0095D371 /* UpdateSelectionOperation.swift */; };
51DC37092402F1470095D371 /* MasterFeedDataSourceOperation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51DC37082402F1470095D371 /* MasterFeedDataSourceOperation.swift */; };
51DC370B2405BC9A0095D371 /* PreloadedWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 51DC370A2405BC9A0095D371 /* PreloadedWebView.swift */; };
51DEE81226FB9233006DAA56 /* Appanoose.nnwtheme in Resources */ = {isa = PBXBuildFile; fileRef = 51DEE81126FB9233006DAA56 /* Appanoose.nnwtheme */; };
51DEE81326FB9233006DAA56 /* Appanoose.nnwtheme in Resources */ = {isa = PBXBuildFile; fileRef = 51DEE81126FB9233006DAA56 /* Appanoose.nnwtheme */; };
@ -1214,7 +1212,6 @@
513C5CE8232571C2003D4054 /* ShareViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareViewController.swift; sourceTree = "<group>"; };
513C5CEB232571C2003D4054 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/MainInterface.storyboard; sourceTree = "<group>"; };
513C5CED232571C2003D4054 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
513CCF08248808BA00C55709 /* MasterFeedTableViewIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterFeedTableViewIdentifier.swift; sourceTree = "<group>"; };
5141E7382373C18B0013FF27 /* WebFeedInspectorViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebFeedInspectorViewController.swift; sourceTree = "<group>"; };
5141E7552374A2890013FF27 /* DetailIconSchemeHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DetailIconSchemeHandler.swift; sourceTree = "<group>"; };
5142192923522B5500E07E2C /* ImageViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageViewController.swift; sourceTree = "<group>"; };
@ -1245,7 +1242,6 @@
516244E2241E19F000B61C47 /* ColorPaletteTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorPaletteTableViewController.swift; sourceTree = "<group>"; };
51627A6623861DA3007B3B4B /* MasterFeedViewController+Drag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MasterFeedViewController+Drag.swift"; sourceTree = "<group>"; };
51627A6823861DED007B3B4B /* MasterFeedViewController+Drop.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MasterFeedViewController+Drop.swift"; sourceTree = "<group>"; };
51627A6A238629D8007B3B4B /* MasterFeedDataSource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MasterFeedDataSource.swift; sourceTree = "<group>"; };
51627A92238A3836007B3B4B /* CroppingPreviewParameters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CroppingPreviewParameters.swift; sourceTree = "<group>"; };
516A091D23609A3600EAE89B /* SettingsComboTableViewCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = SettingsComboTableViewCell.xib; sourceTree = "<group>"; };
516A09382360A2AE00EAE89B /* SettingsComboTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsComboTableViewCell.swift; sourceTree = "<group>"; };
@ -1279,6 +1275,8 @@
51934CCD2310792F006127BE /* ActivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityManager.swift; sourceTree = "<group>"; };
51938DF1231AFC660055A1A0 /* SearchTimelineFeedDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTimelineFeedDelegate.swift; sourceTree = "<group>"; };
5193CD57245E44A90092735E /* RedditFeedProvider-Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "RedditFeedProvider-Extensions.swift"; sourceTree = "<group>"; };
5195C1D92720205F00888867 /* ShadowTableChanges.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShadowTableChanges.swift; sourceTree = "<group>"; };
5195C1DB2720BD3000888867 /* MasterFeedRowIdentifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterFeedRowIdentifier.swift; sourceTree = "<group>"; };
519B8D322143397200FA689C /* SharingServiceDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharingServiceDelegate.swift; sourceTree = "<group>"; };
519E743422C663F900A78E47 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
519ED455244828C3007F8E94 /* AddExtensionPointViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddExtensionPointViewController.swift; sourceTree = "<group>"; };
@ -1343,8 +1341,6 @@
51D6A5BB23199C85001C27D8 /* MasterTimelineDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterTimelineDataSource.swift; sourceTree = "<group>"; };
51D87EE02311D34700E63F03 /* ActivityType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityType.swift; sourceTree = "<group>"; };
51DC07972552083500A3F79F /* ArticleTextSize.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArticleTextSize.swift; sourceTree = "<group>"; };
51DC37062402153E0095D371 /* UpdateSelectionOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateSelectionOperation.swift; sourceTree = "<group>"; };
51DC37082402F1470095D371 /* MasterFeedDataSourceOperation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MasterFeedDataSourceOperation.swift; sourceTree = "<group>"; };
51DC370A2405BC9A0095D371 /* PreloadedWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreloadedWebView.swift; sourceTree = "<group>"; };
51DEE81126FB9233006DAA56 /* Appanoose.nnwtheme */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Appanoose.nnwtheme; sourceTree = "<group>"; };
51DEE81726FBFF84006DAA56 /* Promenade.nnwtheme */ = {isa = PBXFileReference; lastKnownFileType = wrapper; path = Promenade.nnwtheme; sourceTree = "<group>"; };
@ -2073,11 +2069,8 @@
51C45264226508F600C03939 /* MasterFeedViewController.swift */,
51627A6623861DA3007B3B4B /* MasterFeedViewController+Drag.swift */,
51627A6823861DED007B3B4B /* MasterFeedViewController+Drop.swift */,
51627A6A238629D8007B3B4B /* MasterFeedDataSource.swift */,
51DC37082402F1470095D371 /* MasterFeedDataSourceOperation.swift */,
51CE1C0A23622006005548FC /* RefreshProgressView.swift */,
51CE1C0823621EDA005548FC /* RefreshProgressView.xib */,
51DC37062402153E0095D371 /* UpdateSelectionOperation.swift */,
51C45260226508F600C03939 /* Cell */,
);
path = MasterFeed;
@ -2086,9 +2079,9 @@
51C45260226508F600C03939 /* Cell */ = {
isa = PBXGroup;
children = (
5195C1DB2720BD3000888867 /* MasterFeedRowIdentifier.swift */,
51C45262226508F600C03939 /* MasterFeedTableViewCell.swift */,
51C45263226508F600C03939 /* MasterFeedTableViewCellLayout.swift */,
513CCF08248808BA00C55709 /* MasterFeedTableViewIdentifier.swift */,
512E08F722688F7C00BDCFDD /* MasterFeedTableViewSectionHeader.swift */,
516AE9B22371C372007DEEAA /* MasterFeedTableViewSectionHeaderLayout.swift */,
51C45261226508F600C03939 /* MasterFeedUnreadCountView.swift */,
@ -2698,6 +2691,7 @@
840D617E2029031C009BC708 /* AppDelegate.swift */,
519E743422C663F900A78E47 /* SceneDelegate.swift */,
5126EE96226CB48A00C22AFC /* SceneCoordinator.swift */,
5195C1D92720205F00888867 /* ShadowTableChanges.swift */,
514B7C8223205EFB00BAC947 /* RootSplitViewController.swift */,
511D4410231FC02D00FB1562 /* KeyboardManager.swift */,
51C45254226507D200C03939 /* AppAssets.swift */,
@ -4054,6 +4048,7 @@
51C452A422650A2D00C03939 /* ArticleUtilities.swift in Sources */,
51EF0F79227716380050506E /* ColorHash.swift in Sources */,
51F9F3FB23DFB25700A314FD /* Animations.swift in Sources */,
5195C1DA2720205F00888867 /* ShadowTableChanges.swift in Sources */,
5183CCDA226E31A50010922C /* NonIntrinsicImageView.swift in Sources */,
B2B80778239C4C7000F191E0 /* RSImage-AppIcons.swift in Sources */,
518ED21D23D0F26000E0A862 /* UIViewController-Extensions.swift in Sources */,
@ -4146,7 +4141,6 @@
51C452AF2265108300C03939 /* ArticleArray.swift in Sources */,
51C4528E2265099C00C03939 /* SmartFeedsController.swift in Sources */,
51C9DE5823EA2EF4003D5A6D /* WrapperScriptMessageHandler.swift in Sources */,
51627A6B238629D8007B3B4B /* MasterFeedDataSource.swift in Sources */,
51B5C87D23F2346200032075 /* ExtensionContainersFile.swift in Sources */,
51102165233A7D6C0007A5F7 /* ArticleExtractorButton.swift in Sources */,
5141E7392373C18B0013FF27 /* WebFeedInspectorViewController.swift in Sources */,
@ -4178,6 +4172,7 @@
512D554423C804DE0023FFFA /* OpenInSafariActivity.swift in Sources */,
512392C224E33A3C00F11704 /* RedditEnterDetailTableViewController.swift in Sources */,
51C452762265091600C03939 /* MasterTimelineViewController.swift in Sources */,
5195C1DC2720BD3000888867 /* MasterFeedRowIdentifier.swift in Sources */,
5108F6D823763094001ABC45 /* TickMarkSlider.swift in Sources */,
51C452882265093600C03939 /* AddFeedViewController.swift in Sources */,
51B5C8C023F3866C00032075 /* ExtensionFeedAddRequestFile.swift in Sources */,
@ -4196,7 +4191,6 @@
B24E9ADE245AB88400DA5718 /* NSAttributedString+NetNewsWire.swift in Sources */,
C5A6ED5223C9AF4300AB6BE2 /* TitleActivityItemSource.swift in Sources */,
17D7586F2679C21800B17787 /* OnePasswordExtension.m in Sources */,
51DC37092402F1470095D371 /* MasterFeedDataSourceOperation.swift in Sources */,
17071EF126F8137400F5E71D /* ArticleTheme+Notifications.swift in Sources */,
51C4529B22650A1000C03939 /* FaviconDownloader.swift in Sources */,
84DEE56622C32CA4005FC42C /* SmartFeedDelegate.swift in Sources */,
@ -4214,7 +4208,6 @@
FFD43E412340F488009E5CA3 /* MarkAsReadAlertController.swift in Sources */,
51C452A322650A1E00C03939 /* HTMLMetadataDownloader.swift in Sources */,
51C4528D2265095F00C03939 /* AddFolderViewController.swift in Sources */,
51DC37072402153E0095D371 /* UpdateSelectionOperation.swift in Sources */,
51C452782265091600C03939 /* MasterTimelineCellData.swift in Sources */,
5148F4552336DB7000F8CD8B /* MasterTimelineTitleView.swift in Sources */,
515A517C243E90260089E588 /* ExtensionPointManager.swift in Sources */,
@ -4226,7 +4219,6 @@
511D4419231FC02D00FB1562 /* KeyboardManager.swift in Sources */,
51A1699D235E10D700EB091F /* SettingsViewController.swift in Sources */,
51C45293226509C800C03939 /* StarredFeedDelegate.swift in Sources */,
513CCF2524880C1500C55709 /* MasterFeedTableViewIdentifier.swift in Sources */,
51D6A5BC23199C85001C27D8 /* MasterTimelineDataSource.swift in Sources */,
51934CCB230F599B006127BE /* InteractiveNavigationController.swift in Sources */,
769F2ED513DA03EE75B993A8 /* NewsBlurAccountViewController.swift in Sources */,

View File

@ -14,6 +14,8 @@ import Account
final class SmartFeed: PseudoFeed {
var account: Account? = nil
public var defaultReadFilterType: ReadFilterType {
return .none
}

View File

@ -19,6 +19,8 @@ import ArticlesDatabase
// This just shows the global unread count, which appDelegate already has. Easy.
final class UnreadFeed: PseudoFeed {
var account: Account? = nil
public var defaultReadFilterType: ReadFilterType {
return .alwaysRead

View File

@ -0,0 +1,23 @@
//
// MasterFeedRowIdentifier.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 10/20/21.
// Copyright © 2021 Ranchero Software. All rights reserved.
//
import Foundation
class MasterFeedRowIdentifier: NSObject, NSCopying {
var indexPath: IndexPath
init(indexPath: IndexPath) {
self.indexPath = indexPath
}
func copy(with zone: NSZone? = nil) -> Any {
return self
}
}

View File

@ -1,22 +0,0 @@
//
// MasterFeedDataSource.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 8/28/19.
// Copyright © 2019 Ranchero Software. All rights reserved.
//
import UIKit
import RSTree
import Account
class MasterFeedDataSource: UITableViewDiffableDataSource<Int, MasterFeedTableViewIdentifier> {
override func tableView(_ tableView: UITableView, canEditRowAt indexPath: IndexPath) -> Bool {
guard let identifier = itemIdentifier(for: indexPath), identifier.isEditable else {
return false
}
return true
}
}

View File

@ -1,39 +0,0 @@
//
// MasterFeedDataSourceOperation.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 2/23/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import UIKit
import RSCore
import RSTree
class MasterFeedDataSourceOperation: MainThreadOperation {
// MainThreadOperation
public var isCanceled = false
public var id: Int?
public weak var operationDelegate: MainThreadOperationDelegate?
public var name: String? = "MasterFeedDataSourceOperation"
public var completionBlock: MainThreadOperation.MainThreadOperationCompletionBlock?
private var dataSource: UITableViewDiffableDataSource<Int, MasterFeedTableViewIdentifier>
private var snapshot: NSDiffableDataSourceSnapshot<Int, MasterFeedTableViewIdentifier>
private var animating: Bool
init(dataSource: UITableViewDiffableDataSource<Int, MasterFeedTableViewIdentifier>, snapshot: NSDiffableDataSourceSnapshot<Int, MasterFeedTableViewIdentifier>, animating: Bool) {
self.dataSource = dataSource
self.snapshot = snapshot
self.animating = animating
}
func run() {
dataSource.apply(snapshot, animatingDifferences: animating) { [weak self] in
guard let self = self else { return }
self.operationDelegate?.operationDidComplete(self)
}
}
}

View File

@ -13,11 +13,11 @@ import Account
extension MasterFeedViewController: UITableViewDragDelegate {
func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
guard let identifier = dataSource.itemIdentifier(for: indexPath), identifier.isWebFeed, let url = identifier.url else {
guard let node = coordinator.nodeFor(indexPath), let webFeed = node.representedObject as? WebFeed else {
return [UIDragItem]()
}
let data = url.data(using: .utf8)
let data = webFeed.url.data(using: .utf8)
let itemProvider = NSItemProvider()
itemProvider.registerDataRepresentation(forTypeIdentifier: kUTTypeURL as String, visibility: .ownProcess) { completion in
@ -26,7 +26,7 @@ extension MasterFeedViewController: UITableViewDragDelegate {
}
let dragItem = UIDragItem(itemProvider: itemProvider)
dragItem.localObject = identifier
dragItem.localObject = node
return [dragItem]
}

View File

@ -22,24 +22,22 @@ extension MasterFeedViewController: UITableViewDropDelegate {
return UITableViewDropProposal(operation: .forbidden)
}
guard let destIdentifier = dataSource.itemIdentifier(for: destIndexPath) else {
return UITableViewDropProposal(operation: .move, intent: .insertIntoDestinationIndexPath)
}
guard let destAccount = destIdentifier.account, let destCell = tableView.cellForRow(at: destIndexPath) else {
return UITableViewDropProposal(operation: .forbidden)
}
guard let destFeed = coordinator.nodeFor(destIndexPath)?.representedObject as? Feed,
let destAccount = destFeed.account,
let destCell = tableView.cellForRow(at: destIndexPath) else {
return UITableViewDropProposal(operation: .forbidden)
}
// Validate account specific behaviors...
if destAccount.behaviors.contains(.disallowFeedInMultipleFolders),
let sourceFeedID = (session.localDragSession?.items.first?.localObject as? MasterFeedTableViewIdentifier)?.feedID,
let sourceWebFeed = AccountManager.shared.existingFeed(with: sourceFeedID) as? WebFeed,
let sourceNode = session.localDragSession?.items.first?.localObject as? Node,
let sourceWebFeed = sourceNode.representedObject as? WebFeed,
sourceWebFeed.account?.accountID != destAccount.accountID && destAccount.hasWebFeed(withURL: sourceWebFeed.url) {
return UITableViewDropProposal(operation: .forbidden)
}
// Determine the correct drop proposal
if destIdentifier.isFolder {
if destFeed is Folder {
if session.location(in: destCell).y >= 0 {
return UITableViewDropProposal(operation: .move, intent: .insertIntoDestinationIndexPath)
} else {
@ -53,30 +51,29 @@ extension MasterFeedViewController: UITableViewDropDelegate {
func tableView(_ tableView: UITableView, performDropWith dropCoordinator: UITableViewDropCoordinator) {
guard let dragItem = dropCoordinator.items.first?.dragItem,
let sourceIdentifier = dragItem.localObject as? MasterFeedTableViewIdentifier,
let sourceParentContainerID = sourceIdentifier.parentContainerID,
let source = AccountManager.shared.existingContainer(with: sourceParentContainerID),
let destIndexPath = dropCoordinator.destinationIndexPath else {
return
}
let dragNode = dragItem.localObject as? Node,
let source = dragNode.parent?.representedObject as? Container,
let destIndexPath = dropCoordinator.destinationIndexPath else {
return
}
let isFolderDrop: Bool = {
if let propDestIdentifier = dataSource.itemIdentifier(for: destIndexPath), let propCell = tableView.cellForRow(at: destIndexPath) {
return propDestIdentifier.isFolder && dropCoordinator.session.location(in: propCell).y >= 0
if coordinator.nodeFor(destIndexPath)?.representedObject is Folder, let propCell = tableView.cellForRow(at: destIndexPath) {
return dropCoordinator.session.location(in: propCell).y >= 0
}
return false
}()
// Based on the drop we have to determine a node to start looking for a parent container.
let destIdentifier: MasterFeedTableViewIdentifier? = {
let destNode: Node? = {
if isFolderDrop {
return dataSource.itemIdentifier(for: destIndexPath)
return coordinator.nodeFor(destIndexPath)
} else {
if destIndexPath.row == 0 {
return dataSource.itemIdentifier(for: IndexPath(row: 0, section: destIndexPath.section))
return coordinator.nodeFor(IndexPath(row: 0, section: destIndexPath.section))
} else if destIndexPath.row > 0 {
return dataSource.itemIdentifier(for: IndexPath(row: destIndexPath.row - 1, section: destIndexPath.section))
return coordinator.nodeFor(IndexPath(row: destIndexPath.row - 1, section: destIndexPath.section))
} else {
return nil
}
@ -86,25 +83,21 @@ extension MasterFeedViewController: UITableViewDropDelegate {
// Now we start looking for the parent container
let destinationContainer: Container? = {
if let containerID = destIdentifier?.containerID ?? destIdentifier?.parentContainerID {
return AccountManager.shared.existingContainer(with: containerID)
if let container = (destNode?.representedObject as? Container) ?? (destNode?.parent?.representedObject as? Container) {
return container
} else {
// If we got here, we are trying to drop on an empty section header. Go and find the Account for this section
return coordinator.rootNode.childAtIndex(destIndexPath.section)?.representedObject as? Account
}
}()
guard let destination = destinationContainer else { return }
guard case .webFeed(_, let webFeedID) = sourceIdentifier.feedID else { return }
guard let webFeed = source.existingWebFeed(withWebFeedID: webFeedID) else { return }
guard let destination = destinationContainer, let webFeed = dragNode.representedObject as? WebFeed else { return }
if source.account == destination.account {
moveWebFeedInAccount(feed: webFeed, sourceContainer: source, destinationContainer: destination)
} else {
moveWebFeedBetweenAccounts(feed: webFeed, sourceContainer: source, destinationContainer: destination)
}
}
func moveWebFeedInAccount(feed: WebFeed, sourceContainer: Container, destinationContainer: Container) {

View File

@ -27,9 +27,6 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
}
}
private let operationQueue = MainThreadOperationQueue()
lazy var dataSource = makeDataSource()
var undoableCommands = [UndoableCommand]()
weak var coordinator: SceneCoordinator!
@ -63,7 +60,6 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
tableView.tableHeaderView = UIView(frame: frame)
tableView.register(MasterFeedTableViewSectionHeader.self, forHeaderFooterViewReuseIdentifier: "SectionHeader")
tableView.dataSource = dataSource
tableView.dragDelegate = self
tableView.dropDelegate = self
tableView.dragInteractionEnabled = true
@ -83,7 +79,6 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
configureToolbar()
becomeFirstResponder()
}
override func viewWillAppear(_ animated: Bool) {
@ -94,7 +89,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
IconImageCache.shared.emptyCache()
super.traitCollectionDidChange(previousTraitCollection)
reloadAllVisibleCells()
// reloadAllVisibleCells()
}
// MARK: Notifications
@ -123,11 +118,8 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
node = coordinator.rootNode.descendantNodeRepresentingObject(representedObject as AnyObject)
}
guard let unreadCountNode = node else { return }
let identifier = makeIdentifier(unreadCountNode)
if dataSource.indexPath(for: identifier) != nil {
self.reload(identifier)
}
guard let unreadCountNode = node, let indexPath = coordinator.indexPathFor(unreadCountNode) else { return }
tableView.reloadRows(at: [indexPath], with: .middle)
}
@objc func faviconDidBecomeAvailable(_ note: Notification) {
@ -152,7 +144,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
@objc func contentSizeCategoryDidChange(_ note: Notification) {
resetEstimatedRowHeight()
applyChanges(animated: false)
tableView.reloadData()
}
@objc func willEnterForeground(_ note: Notification) {
@ -161,6 +153,20 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
// 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, heightForHeaderInSection section: Int) -> CGFloat {
guard let nameProvider = coordinator.rootNode.childAtIndex(section)?.representedObject as? DisplayNameProvider else {
@ -251,13 +257,13 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
renameAction.backgroundColor = UIColor.systemOrange
actions.append(renameAction)
if let identifier = dataSource.itemIdentifier(for: indexPath), identifier.isWebFeed {
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: identifier.nameForDisplay, message: nil, preferredStyle: .actionSheet)
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)
@ -303,26 +309,25 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
}
override func tableView(_ tableView: UITableView, contextMenuConfigurationForRowAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard let identifier = dataSource.itemIdentifier(for: indexPath) else {
guard let feed = coordinator.nodeFor(indexPath)?.representedObject as? Feed else {
return nil
}
if identifier.isWebFeed {
return makeWebFeedContextMenu(identifier: identifier, indexPath: indexPath, includeDeleteRename: true)
} else if identifier.isFolder {
return makeFolderContextMenu(identifier: identifier, indexPath: indexPath)
} else if identifier.isPsuedoFeed {
return makePseudoFeedContextMenu(identifier: identifier, indexPath: indexPath)
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? MasterFeedTableViewIdentifier,
let indexPath = dataSource.indexPath(for: identifier),
let cell = tableView.cellForRow(at: indexPath) else {
return nil
}
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))
}
@ -342,21 +347,16 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
return coordinator.cappedIndexPath(proposedDestinationIndexPath)
}()
guard let draggedIdentifier = dataSource.itemIdentifier(for: sourceIndexPath),
let draggedFeedID = draggedIdentifier.feedID,
let draggedNode = coordinator.nodeFor(feedID: draggedFeedID) else {
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 destIdentifier = dataSource.itemIdentifier(for: destIndexPath),
let destFeedID = destIdentifier.feedID,
let destNode = coordinator.nodeFor(feedID: destFeedID),
let destParentContainerID = destIdentifier.parentContainerID,
let destParentNode = coordinator.nodeFor(containerID: destParentContainerID) else {
return proposedDestinationIndexPath
}
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 {
@ -381,8 +381,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
if destParentNode.representedObject is Account {
return IndexPath(row: 0, section: destIndexPath.section)
} else {
let identifier = makeIdentifier(sortedNodes[index])
if let candidateIndexPath = dataSource.indexPath(for: identifier) {
if let candidateIndexPath = coordinator.indexPathFor(sortedNodes[index]) {
let movementAdjustment = sourceIndexPath < destIndexPath ? 1 : 0
return IndexPath(row: candidateIndexPath.row - movementAdjustment, section: candidateIndexPath.section)
} else {
@ -393,8 +392,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
} else {
if index >= sortedNodes.count {
let identifier = makeIdentifier(sortedNodes[sortedNodes.count - 1])
if let lastSortedIndexPath = dataSource.indexPath(for: identifier) {
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 {
@ -402,8 +400,7 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
}
} else {
let movementAdjustment = sourceIndexPath < destIndexPath ? 1 : 0
let identifer = makeIdentifier(sortedNodes[index - movementAdjustment])
return dataSource.indexPath(for: identifer) ?? sourceIndexPath
return coordinator.indexPathFor(sortedNodes[index - movementAdjustment]) ?? sourceIndexPath
}
}
@ -515,14 +512,14 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
}
@objc func expandSelectedRows(_ sender: Any?) {
if let indexPath = coordinator.currentFeedIndexPath, let containerID = dataSource.itemIdentifier(for: indexPath)?.containerID {
coordinator.expand(containerID)
if let indexPath = coordinator.currentFeedIndexPath, let node = coordinator.nodeFor(indexPath) {
coordinator.expand(node)
}
}
@objc func collapseSelectedRows(_ sender: Any?) {
if let indexPath = coordinator.currentFeedIndexPath, let containerID = dataSource.itemIdentifier(for: indexPath)?.containerID {
coordinator.collapse(containerID)
if let indexPath = coordinator.currentFeedIndexPath, let node = coordinator.nodeFor(indexPath) {
coordinator.collapse(node)
}
}
@ -562,23 +559,54 @@ class MasterFeedViewController: UITableViewController, UndoableCommandRunner {
}
func updateFeedSelection(animations: Animations) {
operationQueue.add(UpdateSelectionOperation(coordinator: coordinator, dataSource: dataSource, tableView: tableView, animations: animations))
}
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,
// then the table itself is in a weird state. This is because we do unusual things like allowing
// drops on a "folder" that should cause the dropped cell to disappear.
applyChanges(animated: !initialLoad) { [weak self] in
if !initialLoad {
self?.reloadAllVisibleCells(completion: completion)
} else {
completion?()
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
}
for change in changes {
guard !change.isEmpty else { continue }
tableView.performBatchUpdates {
if let deletes = change.deleteIndexPaths, !deletes.isEmpty {
tableView.deleteRows(at: deletes, with: .middle)
}
if let inserts = change.insertIndexPaths, !inserts.isEmpty {
tableView.insertRows(at: inserts, with: .middle)
}
if let moves = change.moveIndexPaths, !moves.isEmpty {
for move in moves {
tableView.moveRow(at: move.0, to: move.1)
}
}
if let reloads = change.reloadIndexPaths, !reloads.isEmpty {
tableView.reloadRows(at: reloads, with: .middle)
}
}
}
completion?()
}
func updateUI() {
if coordinator.isReadFeedsFiltered {
@ -741,65 +769,6 @@ private extension MasterFeedViewController {
filterButton?.accLabelText = NSLocalizedString("Filter Read Feeds", comment: "Filter Read Feeds")
}
func makeIdentifier(_ node: Node) -> MasterFeedTableViewIdentifier {
let unreadCount = coordinator.unreadCountFor(node)
return MasterFeedTableViewIdentifier(node: node, unreadCount: unreadCount)
}
func reload(_ identifier: MasterFeedTableViewIdentifier) {
var snapshot = dataSource.snapshot()
snapshot.reloadItems([identifier])
queueApply(snapshot: snapshot, animatingDifferences: false) { [weak self] in
self?.restoreSelectionIfNecessary(adjustScroll: false)
}
}
func applyChanges(animated: Bool, adjustScroll: Bool = false, completion: (() -> Void)? = nil) {
var snapshot = NSDiffableDataSourceSnapshot<Int, MasterFeedTableViewIdentifier>()
let sectionIdentifiers = Array(0...coordinator.rootNode.childNodes.count - 1)
snapshot.appendSections(sectionIdentifiers)
for sectionIdentifer in sectionIdentifiers {
let identifiers = coordinator.shadowNodesFor(section: sectionIdentifer).map { makeIdentifier($0) }
snapshot.appendItems(identifiers, toSection: sectionIdentifer)
}
queueApply(snapshot: snapshot, animatingDifferences: animated) { [weak self] in
self?.restoreSelectionIfNecessary(adjustScroll: adjustScroll)
completion?()
}
}
func queueApply(snapshot: NSDiffableDataSourceSnapshot<Int, MasterFeedTableViewIdentifier>, animatingDifferences: Bool = true, completion: (() -> Void)? = nil) {
let operation = MasterFeedDataSourceOperation(dataSource: dataSource, snapshot: snapshot, animating: animatingDifferences)
operation.completionBlock = { [weak self] _ in
self?.enableTableViewSelection()
completion?()
}
disableTableViewSelectionIfNecessary()
operationQueue.add(operation)
}
private func disableTableViewSelectionIfNecessary() {
// We only need to disable tableView selection if the feeds are filtered by unread
guard coordinator.isReadFeedsFiltered else { return }
tableView.allowsSelection = false
}
private func enableTableViewSelection() {
tableView.allowsSelection = true
}
func makeDataSource() -> MasterFeedDataSource {
let dataSource = MasterFeedDataSource(tableView: tableView, cellProvider: { [weak self] tableView, indexPath, cellContents in
let cell = tableView.dequeueReusableCell(withIdentifier: "Cell", for: indexPath) as! MasterFeedTableViewCell
self?.configure(cell, cellContents)
return cell
})
dataSource.defaultRowAnimation = .middle
return dataSource
}
func resetEstimatedRowHeight() {
let titleLabel = NonIntrinsicLabel()
titleLabel.text = "But I must explain"
@ -811,27 +780,30 @@ private extension MasterFeedViewController {
tableView.estimatedRowHeight = layout.height
}
func configure(_ cell: MasterFeedTableViewCell, _ identifier: MasterFeedTableViewIdentifier) {
func configure(_ cell: MasterFeedTableViewCell, _ indexPath: IndexPath) {
guard let node = coordinator.nodeFor(indexPath) else { return }
cell.delegate = self
if identifier.isFolder {
if node.representedObject is Folder {
cell.indentationLevel = 0
} else {
cell.indentationLevel = 1
}
if let containerID = identifier.containerID {
if let containerID = (node.representedObject as? Container)?.containerID {
cell.setDisclosure(isExpanded: coordinator.isExpanded(containerID), animated: false)
cell.isDisclosureAvailable = true
} else {
cell.isDisclosureAvailable = false
}
cell.name = identifier.nameForDisplay
cell.unreadCount = identifier.unreadCount
configureIcon(cell, identifier)
guard let indexPath = dataSource.indexPath(for: identifier) else { return }
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
@ -841,8 +813,8 @@ private extension MasterFeedViewController {
}
func configureIcon(_ cell: MasterFeedTableViewCell, _ identifier: MasterFeedTableViewIdentifier) {
guard let feedID = identifier.feedID else {
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)
@ -859,35 +831,29 @@ private extension MasterFeedViewController {
applyToCellsForRepresentedObject(representedObject, configure)
}
func applyToCellsForRepresentedObject(_ representedObject: AnyObject, _ completion: (MasterFeedTableViewCell, MasterFeedTableViewIdentifier) -> Void) {
applyToAvailableCells { (cell, identifier) in
if let representedFeed = representedObject as? Feed, representedFeed.feedID == identifier.feedID {
completion(cell, identifier)
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, MasterFeedTableViewIdentifier) -> Void) {
func applyToAvailableCells(_ completion: (MasterFeedTableViewCell, IndexPath) -> Void) {
tableView.visibleCells.forEach { cell in
guard let indexPath = tableView.indexPath(for: cell), let identifier = dataSource.itemIdentifier(for: indexPath) else {
guard let indexPath = tableView.indexPath(for: cell) else {
return
}
completion(cell as! MasterFeedTableViewCell, identifier)
completion(cell as! MasterFeedTableViewCell, indexPath)
}
}
private func reloadAllVisibleCells(completion: (() -> Void)? = nil) {
let visibleNodes = tableView.indexPathsForVisibleRows!.compactMap { return dataSource.itemIdentifier(for: $0) }
reloadCells(visibleNodes, completion: completion)
}
private func reloadCells(_ identifiers: [MasterFeedTableViewIdentifier], completion: (() -> Void)? = nil) {
var snapshot = dataSource.snapshot()
snapshot.reloadItems(identifiers)
queueApply(snapshot: snapshot, animatingDifferences: false) { [weak self] in
self?.restoreSelectionIfNecessary(adjustScroll: false)
completion?()
}
guard let indexPaths = tableView.indexPathsForVisibleRows else { return }
tableView.reloadRows(at: indexPaths, with: .middle)
}
private func accountForNode(_ node: Node) -> Account? {
@ -918,21 +884,21 @@ private extension MasterFeedViewController {
}
func expand(_ cell: MasterFeedTableViewCell) {
guard let indexPath = tableView.indexPath(for: cell), let containerID = dataSource.itemIdentifier(for: indexPath)?.containerID else {
guard let indexPath = tableView.indexPath(for: cell), let node = coordinator.nodeFor(indexPath) else {
return
}
coordinator.expand(containerID)
coordinator.expand(node)
}
func collapse(_ cell: MasterFeedTableViewCell) {
guard let indexPath = tableView.indexPath(for: cell), let containerID = dataSource.itemIdentifier(for: indexPath)?.containerID else {
guard let indexPath = tableView.indexPath(for: cell), let node = coordinator.nodeFor(indexPath) else {
return
}
coordinator.collapse(containerID)
coordinator.collapse(node)
}
func makeWebFeedContextMenu(identifier: MasterFeedTableViewIdentifier, indexPath: IndexPath, includeDeleteRename: Bool) -> UIContextMenuConfiguration {
return UIContextMenuConfiguration(identifier: identifier as NSCopying, previewProvider: nil, actionProvider: { [ weak self] suggestedActions in
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 }
@ -976,8 +942,8 @@ private extension MasterFeedViewController {
}
func makeFolderContextMenu(identifier: MasterFeedTableViewIdentifier, indexPath: IndexPath) -> UIContextMenuConfiguration {
return UIContextMenuConfiguration(identifier: identifier as NSCopying, previewProvider: nil, actionProvider: { [weak self] suggestedActions in
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 }
@ -999,12 +965,12 @@ private extension MasterFeedViewController {
})
}
func makePseudoFeedContextMenu(identifier: MasterFeedTableViewIdentifier, indexPath: IndexPath) -> UIContextMenuConfiguration? {
func makePseudoFeedContextMenu(indexPath: IndexPath) -> UIContextMenuConfiguration? {
guard let markAllAction = self.markAllAsReadAction(indexPath: indexPath) else {
return nil
}
return UIContextMenuConfiguration(identifier: identifier as NSCopying, previewProvider: nil, actionProvider: { suggestedActions in
return UIContextMenuConfiguration(identifier: MasterFeedRowIdentifier(indexPath: indexPath), previewProvider: nil, actionProvider: { suggestedActions in
return UIMenu(title: "", children: [markAllAction])
})
}
@ -1035,11 +1001,10 @@ private extension MasterFeedViewController {
}
func copyFeedPageAction(indexPath: IndexPath) -> UIAction? {
guard let feedID = dataSource.itemIdentifier(for: indexPath)?.feedID,
let webFeed = AccountManager.shared.existingFeed(with: feedID) as? WebFeed,
let url = URL(string: webFeed.url) else {
return nil
}
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
@ -1049,12 +1014,11 @@ private extension MasterFeedViewController {
}
func copyFeedPageAlertAction(indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard let feedID = dataSource.itemIdentifier(for: indexPath)?.feedID,
let webFeed = AccountManager.shared.existingFeed(with: feedID) as? WebFeed,
let url = URL(string: webFeed.url) else {
return nil
}
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
@ -1064,12 +1028,11 @@ private extension MasterFeedViewController {
}
func copyHomePageAction(indexPath: IndexPath) -> UIAction? {
guard let feedID = dataSource.itemIdentifier(for: indexPath)?.feedID,
let webFeed = AccountManager.shared.existingFeed(with: feedID) as? WebFeed,
let homePageURL = webFeed.homePageURL,
let url = URL(string: homePageURL) else {
return nil
}
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
@ -1079,13 +1042,12 @@ private extension MasterFeedViewController {
}
func copyHomePageAlertAction(indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard let feedID = dataSource.itemIdentifier(for: indexPath)?.feedID,
let webFeed = AccountManager.shared.existingFeed(with: feedID) as? WebFeed,
let homePageURL = webFeed.homePageURL,
let url = URL(string: homePageURL) else {
return nil
}
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
@ -1095,16 +1057,14 @@ private extension MasterFeedViewController {
}
func markAllAsReadAlertAction(indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard let identifier = dataSource.itemIdentifier(for: indexPath),
identifier.unreadCount > 0,
let feedID = identifier.feedID,
let feed = AccountManager.shared.existingFeed(with: feedID) as? WebFeed,
let articles = try? feed.fetchArticles(), let contentView = self.tableView.cellForRow(at: indexPath)?.contentView else {
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, feed.nameForDisplay) as String
let title = NSString.localizedStringWithFormat(localizedMenuText as NSString, webFeed.nameForDisplay) as String
let cancel = {
completion(true)
}
@ -1137,13 +1097,13 @@ private extension MasterFeedViewController {
}
func getInfoAction(indexPath: IndexPath) -> UIAction? {
guard let feedID = dataSource.itemIdentifier(for: indexPath)?.feedID, let feed = AccountManager.shared.existingFeed(with: feedID) as? WebFeed else {
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: feed)
self?.coordinator.showFeedInspector(for: webFeed)
}
return action
}
@ -1165,41 +1125,25 @@ private extension MasterFeedViewController {
}
func getInfoAlertAction(indexPath: IndexPath, completion: @escaping (Bool) -> Void) -> UIAlertAction? {
guard let feedID = dataSource.itemIdentifier(for: indexPath)?.feedID, let feed = AccountManager.shared.existingFeed(with: feedID) as? WebFeed else {
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: feed)
self?.coordinator.showFeedInspector(for: webFeed)
completion(true)
}
return action
}
func markAllAsReadAction(indexPath: IndexPath) -> UIAction? {
guard let identifier = dataSource.itemIdentifier(for: indexPath), identifier.unreadCount > 0 else {
return nil
}
guard let feed = coordinator.nodeFor(indexPath)?.representedObject as? Feed,
let contentView = self.tableView.cellForRow(at: indexPath)?.contentView,
feed.unreadCount > 0 else {
return nil
}
var smartFeed: Feed?
if identifier.isPsuedoFeed {
if SmartFeedsController.shared.todayFeed.feedID == identifier.feedID {
smartFeed = SmartFeedsController.shared.todayFeed
} else if SmartFeedsController.shared.unreadFeed.feedID == identifier.feedID {
smartFeed = SmartFeedsController.shared.unreadFeed
} else if SmartFeedsController.shared.starredFeed.feedID == identifier.feedID {
smartFeed = SmartFeedsController.shared.starredFeed
}
}
guard let feedID = identifier.feedID,
let feed = smartFeed ?? AccountManager.shared.existingFeed(with: feedID),
feed.unreadCount > 0,
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, feed.nameForDisplay) as String
let action = UIAction(title: title, image: AppAssets.markAllAsReadImage) { [weak self] action in
@ -1236,11 +1180,10 @@ private extension MasterFeedViewController {
func rename(indexPath: IndexPath) {
guard let feedID = dataSource.itemIdentifier(for: indexPath)?.feedID, let feed = AccountManager.shared.existingFeed(with: feedID) else { return }
guard let feed = coordinator.nodeFor(indexPath)?.representedObject as? Feed else { return }
let name = dataSource.itemIdentifier(for: indexPath)?.nameForDisplay ?? ""
let formatString = NSLocalizedString("Rename “%@”", comment: "Rename feed")
let title = NSString.localizedStringWithFormat(formatString as NSString, name) as String
let title = NSString.localizedStringWithFormat(formatString as NSString, feed.nameForDisplay) as String
let alertController = UIAlertController(title: title, message: nil, preferredStyle: .alert)
@ -1280,7 +1223,7 @@ private extension MasterFeedViewController {
alertController.preferredAction = renameAction
alertController.addTextField() { textField in
textField.text = name
textField.text = feed.nameForDisplay
textField.placeholder = NSLocalizedString("Name", comment: "Name")
}
@ -1291,7 +1234,7 @@ private extension MasterFeedViewController {
}
func delete(indexPath: IndexPath) {
guard let feedID = dataSource.itemIdentifier(for: indexPath)?.feedID, let feed = AccountManager.shared.existingFeed(with: feedID) else { return }
guard let feed = coordinator.nodeFor(indexPath)?.representedObject as? Feed else { return }
let title: String
let message: String
@ -1312,7 +1255,7 @@ private extension MasterFeedViewController {
let deleteTitle = NSLocalizedString("Delete", comment: "Delete")
let deleteAction = UIAlertAction(title: deleteTitle, style: .destructive) { [weak self] action in
self?.delete(indexPath: indexPath, feedID: feedID)
self?.performDelete(indexPath: indexPath)
}
alertController.addAction(deleteAction)
alertController.preferredAction = deleteAction
@ -1320,9 +1263,9 @@ private extension MasterFeedViewController {
self.present(alertController, animated: true)
}
func delete(indexPath: IndexPath, feedID: FeedIdentifier) {
func performDelete(indexPath: IndexPath) {
guard let undoManager = undoManager,
let deleteNode = coordinator.nodeFor(feedID: feedID),
let deleteNode = coordinator.nodeFor(indexPath),
let deleteCommand = DeleteCommand(nodesToDelete: [deleteNode], undoManager: undoManager, errorHandler: ErrorHandler.present(self)) else {
return
}

View File

@ -688,7 +688,7 @@ private extension MasterTimelineViewController {
func updateTitleUnreadCount() {
if let titleView = navigationItem.titleView as? MasterTimelineTitleView {
titleView.unreadCountView.unreadCount = coordinator.unreadCount
titleView.unreadCountView.unreadCount = coordinator.timelineUnreadCount
}
}

View File

@ -19,6 +19,7 @@ enum PanelMode {
case three
case standard
}
enum SearchScope: Int {
case timeline = 0
case global = 1
@ -30,7 +31,21 @@ enum ShowFeedName {
case feed
}
class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
struct FeedNode: Hashable {
var node: Node
var feedID: FeedIdentifier
init(_ node: Node) {
self.node = node
self.feedID = (node.representedObject as! Feed).feedID!
}
func hash(into hasher: inout Hasher) {
hasher.combine(feedID)
}
}
class SceneCoordinator: NSObject, UndoableCommandRunner {
var undoableCommands = [UndoableCommand]()
var undoManager: UndoManager? {
@ -74,7 +89,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
private var expandedTable = Set<ContainerIdentifier>()
private var readFilterEnabledTable = [FeedIdentifier: Bool]()
private var shadowTable = [[Node]]()
private var shadowTable = [[FeedNode]]()
private(set) var preSearchTimelineFeed: Feed?
private var lastSearchString = ""
@ -285,20 +300,14 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
}
var isTimelineUnreadAvailable: Bool {
return unreadCount > 0
return timelineUnreadCount > 0
}
var isAnyUnreadAvailable: Bool {
return appDelegate.unreadCount > 0
}
var unreadCount: Int = 0 {
didSet {
if unreadCount != oldValue {
postUnreadCountDidChangeNotification()
}
}
}
var timelineUnreadCount: Int = 0
override init() {
treeController = TreeController(delegate: treeControllerDelegate)
@ -307,7 +316,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
for sectionNode in treeController.rootNode.childNodes {
markExpanded(sectionNode)
shadowTable.append([Node]())
shadowTable.append([FeedNode]())
}
NotificationCenter.default.addObserver(self, selector: #selector(unreadCountDidInitialize(_:)), name: .UnreadCountDidInitialize, object: nil)
@ -650,20 +659,6 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
refreshTimeline(resetScroll: false)
}
func shadowNodesFor(section: Int) -> [Node] {
return shadowTable[section]
}
func nodeFor(containerID: ContainerIdentifier) -> Node? {
return treeController.rootNode.descendantNode(where: { node in
if let container = node.representedObject as? Container {
return container.containerID == containerID
} else {
return false
}
})
}
func nodeFor(feedID: FeedIdentifier) -> Node? {
return treeController.rootNode.descendantNode(where: { node in
@ -675,6 +670,30 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
})
}
func numberOfSections() -> Int {
return shadowTable.count
}
func numberOfRows(in section: Int) -> Int {
return shadowTable[section].count
}
func nodeFor(_ indexPath: IndexPath) -> Node? {
guard indexPath.section < shadowTable.count && indexPath.row < shadowTable[indexPath.section].count else {
return nil
}
return shadowTable[indexPath.section][indexPath.row].node
}
func indexPathFor(_ node: Node) -> IndexPath? {
for i in 0..<shadowTable.count {
if let row = shadowTable[i].firstIndex(of: FeedNode(node)) {
return IndexPath(row: row, section: i)
}
}
return nil
}
func articleFor(_ articleID: String) -> Article? {
return idToAticleDictionary[articleID]
}
@ -689,7 +708,7 @@ class SceneCoordinator: NSObject, UndoableCommandRunner, UnreadCountProvider {
func unreadCountFor(_ node: Node) -> Int {
// The coordinator supplies the unread count for the currently selected feed
if node.representedObject === timelineFeed as AnyObject {
return unreadCount
return timelineUnreadCount
}
if let unreadCountProvider = node.representedObject as? UnreadCountProvider {
return unreadCountProvider.unreadCount
@ -1430,7 +1449,7 @@ private extension SceneCoordinator {
count += 1
}
}
unreadCount = count
timelineUnreadCount = count
}
func rebuildArticleDictionaries() {
@ -1483,8 +1502,8 @@ private extension SceneCoordinator {
func addShadowTableToFilterExceptions() {
for section in shadowTable {
for node in section {
if let feed = node.representedObject as? Feed, let feedID = feed.feedID {
for feedNode in section {
if let feed = feedNode.node.representedObject as? Feed, let feedID = feed.feedID {
treeControllerDelegate.addFilterException(feedID)
}
}
@ -1501,51 +1520,81 @@ private extension SceneCoordinator {
func rebuildBackingStores(initialLoad: Bool = false, updateExpandedNodes: (() -> Void)? = nil, completion: (() -> Void)? = nil) {
if !BatchUpdate.shared.isPerforming {
addToFilterExeptionsIfNecessary(timelineFeed)
treeController.rebuild()
treeControllerDelegate.resetFilterExceptions()
updateExpandedNodes?()
rebuildShadowTable()
masterFeedViewController.reloadFeeds(initialLoad: initialLoad, completion: completion)
let changes = rebuildShadowTable()
masterFeedViewController.reloadFeeds(initialLoad: initialLoad, changes: changes, completion: completion)
}
}
func rebuildShadowTable() {
shadowTable = [[Node]]()
func rebuildShadowTable() -> [ShadowTableChanges] {
var newShadowTable = [[FeedNode]]()
for i in 0..<treeController.rootNode.numberOfChildNodes {
var result = [Node]()
var result = [FeedNode]()
let sectionNode = treeController.rootNode.childAtIndex(i)!
if isExpanded(sectionNode) {
for node in sectionNode.childNodes {
result.append(node)
result.append(FeedNode(node))
if isExpanded(node) {
for child in node.childNodes {
result.append(child)
result.append(FeedNode(child))
}
}
}
}
shadowTable.append(result)
newShadowTable.append(result)
}
// If we have a current Feed IndexPath it is no longer valid and needs reset.
if currentFeedIndexPath != nil {
currentFeedIndexPath = indexPathFor(timelineFeed as AnyObject)
}
// Compute the differences in the shadow tables
var changes = [ShadowTableChanges]()
for (index, newSectionNodes) in newShadowTable.enumerated() {
var moves = Set<ShadowTableChanges.Move>()
var inserts = Set<Int>()
var deletes = Set<Int>()
let diff = newSectionNodes.difference(from: shadowTable[index]).inferringMoves()
for change in diff {
switch change {
case .insert(let offset, _, let associated):
if let associated = associated {
moves.insert(ShadowTableChanges.Move(associated, offset))
} else {
inserts.insert(offset)
}
case .remove(let offset, _, let associated):
if let associated = associated {
moves.insert(ShadowTableChanges.Move(offset, associated))
} else {
deletes.insert(offset)
}
}
}
changes.append(ShadowTableChanges(section: index, deletes: deletes, inserts: inserts, moves: moves))
}
shadowTable = newShadowTable
return changes
}
func shadowTableContains(_ feed: Feed) -> Bool {
for section in shadowTable {
for node in section {
if let nodeFeed = node.representedObject as? Feed, nodeFeed.feedID == feed.feedID {
for feedNode in section {
if let nodeFeed = feedNode.node.representedObject as? Feed, nodeFeed.feedID == feed.feedID {
return true
}
}
@ -1559,22 +1608,6 @@ private extension SceneCoordinator {
}
}
func nodeFor(_ indexPath: IndexPath) -> Node? {
guard indexPath.section < shadowTable.count && indexPath.row < shadowTable[indexPath.section].count else {
return nil
}
return shadowTable[indexPath.section][indexPath.row]
}
func indexPathFor(_ node: Node) -> IndexPath? {
for i in 0..<shadowTable.count {
if let row = shadowTable[i].firstIndex(of: node) {
return IndexPath(row: row, section: i)
}
}
return nil
}
func indexPathFor(_ object: AnyObject) -> IndexPath? {
guard let node = treeController.rootNode.descendantNodeRepresentingObject(object) else {
return nil

View File

@ -0,0 +1,65 @@
//
// ShadowTableChanges.swift
// NetNewsWire-iOS
//
// Created by Maurice Parker on 10/20/21.
// Copyright © 2021 Ranchero Software. All rights reserved.
//
import Foundation
public struct ShadowTableChanges {
public struct Move: Hashable {
public var from: Int
public var to: Int
init(_ from: Int, _ to: Int) {
self.from = from
self.to = to
}
}
public var section: Int
public var deletes: Set<Int>?
public var inserts: Set<Int>?
public var moves: Set<Move>?
public var reloads: Set<Int>?
public var isEmpty: Bool {
return (deletes?.isEmpty ?? true) && (inserts?.isEmpty ?? true) && (moves?.isEmpty ?? true) && (reloads?.isEmpty ?? true)
}
public var isOnlyReloads: Bool {
return (deletes?.isEmpty ?? true) && (inserts?.isEmpty ?? true) && (moves?.isEmpty ?? true)
}
public var deleteIndexPaths: [IndexPath]? {
guard let deletes = deletes else { return nil }
return deletes.map { IndexPath(row: $0, section: section) }
}
public var insertIndexPaths: [IndexPath]? {
guard let inserts = inserts else { return nil }
return inserts.map { IndexPath(row: $0, section: section) }
}
public var moveIndexPaths: [(IndexPath, IndexPath)]? {
guard let moves = moves else { return nil }
return moves.map { (IndexPath(row: $0.from, section: section), IndexPath(row: $0.to, section: section)) }
}
public var reloadIndexPaths: [IndexPath]? {
guard let reloads = reloads else { return nil }
return reloads.map { IndexPath(row: $0, section: section) }
}
init(section: Int, deletes: Set<Int>? = nil, inserts: Set<Int>? = nil, moves: Set<Move>? = nil, reloads: Set<Int>? = nil) {
self.section = section
self.deletes = deletes
self.inserts = inserts
self.moves = moves
self.reloads = reloads
}
}