NetNewsWire/Multiplatform/Shared/Sidebar/SidebarModel.swift

311 lines
10 KiB
Swift

//
// SidebarModel.swift
// NetNewsWire
//
// Created by Maurice Parker on 6/28/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
import Foundation
import Combine
import RSCore
import Account
import Articles
protocol SidebarModelDelegate: class {
func unreadCount(for: Feed) -> Int
}
class SidebarModel: ObservableObject, UndoableCommandRunner {
@Published var selectedFeedIdentifiers = Set<FeedIdentifier>()
@Published var selectedFeedIdentifier: FeedIdentifier? = .none
@Published var isReadFiltered = false
@Published var expandedContainers = SidebarExpandedContainers()
weak var delegate: SidebarModelDelegate?
var sidebarItemsPublisher: AnyPublisher<[SidebarItem], Never>?
var selectedFeedsPublisher: AnyPublisher<[Feed], Never>?
var selectNextUnread = PassthroughSubject<Void, Never>()
var markAllAsReadInFeed = PassthroughSubject<Feed, Never>()
var markAllAsReadInAccount = PassthroughSubject<Account, Never>()
var deleteFromAccount = PassthroughSubject<Feed, Never>()
private var cancellables = Set<AnyCancellable>()
var undoManager: UndoManager?
var undoableCommands = [UndoableCommand]()
init(delegate: SidebarModelDelegate) {
self.delegate = delegate
subscribeToSelectedFeedChanges()
subscribeToRebuildSidebarItemsEvents()
subscribeToNextUnread()
subscribeToMarkAllAsReadInFeed()
subscribeToMarkAllAsReadInAccount()
subscribeToDeleteFromAccount()
}
}
// MARK: Private
private extension SidebarModel {
// MARK: Subscriptions
func subscribeToSelectedFeedChanges() {
let selectedFeedIdentifersPublisher = $selectedFeedIdentifiers
.map { [weak self] feedIDs -> [Feed] in
return feedIDs.compactMap { self?.findFeed($0) }
}
let selectedFeedIdentiferPublisher = $selectedFeedIdentifier
.compactMap { [weak self] feedID -> [Feed]? in
if let feedID = feedID, let feed = self?.findFeed(feedID) {
return [feed]
} else {
return nil
}
}
selectedFeedsPublisher = selectedFeedIdentifersPublisher
.merge(with: selectedFeedIdentiferPublisher)
.removeDuplicates(by: { previousFeeds, currentFeeds in
return previousFeeds.elementsEqual(currentFeeds, by: { $0.feedID == $1.feedID })
})
.share()
.eraseToAnyPublisher()
}
func subscribeToRebuildSidebarItemsEvents() {
guard let selectedFeedsPublisher = selectedFeedsPublisher else { return }
let chidrenDidChangePublisher = NotificationCenter.default.publisher(for: .ChildrenDidChange)
let batchUpdateDidPerformPublisher = NotificationCenter.default.publisher(for: .BatchUpdateDidPerform)
let displayNameDidChangePublisher = NotificationCenter.default.publisher(for: .DisplayNameDidChange)
let accountStateDidChangePublisher = NotificationCenter.default.publisher(for: .AccountStateDidChange)
let userDidAddAccountPublisher = NotificationCenter.default.publisher(for: .UserDidAddAccount)
let userDidDeleteAccountPublisher = NotificationCenter.default.publisher(for: .UserDidDeleteAccount)
let unreadCountDidInitializePublisher = NotificationCenter.default.publisher(for: .UnreadCountDidInitialize)
let unreadCountDidChangePublisher = NotificationCenter.default.publisher(for: .UnreadCountDidChange)
let sidebarRebuildPublishers = chidrenDidChangePublisher.merge(with: batchUpdateDidPerformPublisher,
displayNameDidChangePublisher,
accountStateDidChangePublisher,
userDidAddAccountPublisher,
userDidDeleteAccountPublisher,
unreadCountDidInitializePublisher,
unreadCountDidChangePublisher)
let kickStarter = Notification(name: Notification.Name(rawValue: "Kick Starter"))
sidebarItemsPublisher = sidebarRebuildPublishers
.prepend(kickStarter)
.debounce(for: .milliseconds(500), scheduler: RunLoop.main)
.combineLatest($isReadFiltered, selectedFeedsPublisher)
.compactMap { [weak self] _, readFilter, selectedFeeds in
self?.rebuildSidebarItems(isReadFiltered: readFilter, selectedFeeds: selectedFeeds)
}
.share()
.eraseToAnyPublisher()
}
func subscribeToNextUnread() {
guard let sidebarItemsPublisher = sidebarItemsPublisher, let selectedFeedsPublisher = selectedFeedsPublisher else { return }
selectNextUnread
.withLatestFrom(sidebarItemsPublisher, selectedFeedsPublisher)
.compactMap { [weak self] (sidebarItems, selectedFeeds) in
return self?.nextUnread(sidebarItems: sidebarItems, selectedFeeds: selectedFeeds)
}
.sink { [weak self] nextFeedID in
self?.select(nextFeedID)
}
.store(in: &cancellables)
}
func subscribeToMarkAllAsReadInFeed() {
guard let selectedFeedsPublisher = selectedFeedsPublisher else { return }
markAllAsReadInFeed
.withLatestFrom(selectedFeedsPublisher, resultSelector: { givenFeed, selectedFeeds -> [Feed] in
if selectedFeeds.contains(where: { $0.feedID == givenFeed.feedID }) {
return selectedFeeds
} else {
return [givenFeed]
}
})
.map { feeds in
var articles = [Article]()
for feed in feeds {
articles.append(contentsOf: (try? feed.fetchUnreadArticles()) ?? Set<Article>())
}
return articles
}
.sink { [weak self] allArticles in
self?.markAllAsRead(allArticles)
}
.store(in: &cancellables)
}
func subscribeToMarkAllAsReadInAccount() {
markAllAsReadInAccount
.map { account in
var articles = [Article]()
for feed in account.flattenedWebFeeds() {
articles.append(contentsOf: (try? feed.fetchUnreadArticles()) ?? Set<Article>())
}
return articles
}
.sink { [weak self] articles in
self?.markAllAsRead(articles)
}
.store(in: &cancellables)
}
func subscribeToDeleteFromAccount() {
guard let selectedFeedsPublisher = selectedFeedsPublisher else { return }
deleteFromAccount
.withLatestFrom(selectedFeedsPublisher, resultSelector: { givenFeed, selectedFeeds -> [Feed] in
if selectedFeeds.contains(where: { $0.feedID == givenFeed.feedID }) {
return selectedFeeds
} else {
return [givenFeed]
}
})
.sink { feeds in
for feed in feeds {
if let webFeed = feed as? WebFeed {
webFeed.account?.removeWebFeed(webFeed)
}
if let folder = feed as? Folder {
folder.account?.removeFolder(folder) { _ in }
}
}
}
.store(in: &cancellables)
}
/// Marks provided artices as read.
/// - Parameter articles: An array of `Article`s.
/// - Warning: An `UndoManager` is created here as the `Environment`'s undo manager appears to be `nil`.
func markAllAsRead(_ articles: [Article]) {
guard let undoManager = undoManager ?? UndoManager(),
let markAsReadCommand = MarkStatusCommand(initialArticles: articles, markingRead: true, undoManager: undoManager) else {
return
}
runCommand(markAsReadCommand)
}
// MARK: Sidebar Building
func sort(_ folders: Set<Folder>) -> [Folder] {
return folders.sorted(by: { $0.nameForDisplay.localizedStandardCompare($1.nameForDisplay) == .orderedAscending })
}
func sort(_ feeds: Set<WebFeed>) -> [Feed] {
return feeds.sorted(by: { $0.nameForDisplay.localizedStandardCompare($1.nameForDisplay) == .orderedAscending })
}
func rebuildSidebarItems(isReadFiltered: Bool, selectedFeeds: [Feed]) -> [SidebarItem] {
var items = [SidebarItem]()
guard let delegate = delegate else { return items }
var smartFeedControllerItem = SidebarItem(SmartFeedsController.shared)
for feed in SmartFeedsController.shared.smartFeeds {
// It looks like SwiftUI loses its mind when the last element in a section is removed. Don't filter
// the smartfeeds yet or we crash about everytime because Starred is almost always filtered
// if !isReadFiltered || feed.unreadCount > 0 {
smartFeedControllerItem.addChild(SidebarItem(feed, unreadCount: delegate.unreadCount(for: feed)))
// }
}
items.append(smartFeedControllerItem)
let selectedFeedIDs = Set(selectedFeeds.map { $0.feedID })
for account in AccountManager.shared.sortedActiveAccounts {
var accountItem = SidebarItem(account)
for webFeed in sort(account.topLevelWebFeeds) {
if !isReadFiltered || !(webFeed.unreadCount < 1 && !selectedFeedIDs.contains(webFeed.feedID)) {
accountItem.addChild(SidebarItem(webFeed, unreadCount: delegate.unreadCount(for: webFeed)))
}
}
for folder in sort(account.folders ?? Set<Folder>()) {
if !isReadFiltered || !(folder.unreadCount < 1 && !selectedFeedIDs.contains(folder.feedID)) {
var folderItem = SidebarItem(folder, unreadCount: delegate.unreadCount(for: folder))
for webFeed in sort(folder.topLevelWebFeeds) {
if !isReadFiltered || !(webFeed.unreadCount < 1 && !selectedFeedIDs.contains(webFeed.feedID)) {
folderItem.addChild(SidebarItem(webFeed, unreadCount: delegate.unreadCount(for: webFeed)))
}
}
accountItem.addChild(folderItem)
}
}
items.append(accountItem)
}
return items
}
// MARK:
func findFeed(_ feedID: FeedIdentifier) -> Feed? {
switch feedID {
case .smartFeed:
return SmartFeedsController.shared.find(by: feedID)
default:
return AccountManager.shared.existingFeed(with: feedID)
}
}
func nextUnread(sidebarItems: [SidebarItem], selectedFeeds: [Feed]) -> FeedIdentifier? {
guard let startFeed = selectedFeeds.first ?? sidebarItems.first?.children.first?.feed else { return nil }
if let feedID = nextUnread(sidebarItems: sidebarItems, startingAt: startFeed) {
return feedID
} else {
if let firstFeed = sidebarItems.first?.children.first?.feed {
return nextUnread(sidebarItems: sidebarItems, startingAt: firstFeed)
}
}
return nil
}
@discardableResult
func nextUnread(sidebarItems: [SidebarItem], startingAt: Feed) -> FeedIdentifier? {
var foundStartFeed = false
var nextSidebarItem: SidebarItem? = nil
for section in sidebarItems {
if nextSidebarItem == nil {
section.visit { sidebarItem in
if !foundStartFeed && sidebarItem.feed?.feedID == startingAt.feedID {
foundStartFeed = true
}
if foundStartFeed && sidebarItem.unreadCount > 0 {
nextSidebarItem = sidebarItem
return true
}
return false
}
}
}
return nextSidebarItem?.feed?.feedID
}
func select(_ feedID: FeedIdentifier) {
selectedFeedIdentifiers = Set([feedID])
selectedFeedIdentifier = feedID
}
}