NetNewsWire/Multiplatform/Shared/Timeline/TimelineModel.swift

456 lines
14 KiB
Swift
Raw Normal View History

2020-06-30 18:03:33 +02:00
//
// TimelineModel.swift
// NetNewsWire
//
// Created by Maurice Parker on 6/30/20.
// Copyright © 2020 Ranchero Software. All rights reserved.
//
#if os(macOS)
import AppKit
#else
import UIKit
#endif
2020-07-12 01:22:47 +02:00
import Combine
2020-06-30 18:03:33 +02:00
import RSCore
import Account
2020-07-01 19:30:55 +02:00
import Articles
2020-06-30 18:03:33 +02:00
protocol TimelineModelDelegate: class {
var selectedFeeds: Published<[Feed]>.Publisher { get }
2020-06-30 18:03:33 +02:00
func timelineRequestedWebFeedSelection(_: TimelineModel, webFeed: WebFeed)
}
2020-07-12 01:22:47 +02:00
class TimelineModel: ObservableObject, UndoableCommandRunner {
2020-06-30 18:03:33 +02:00
weak var delegate: TimelineModelDelegate?
@Published var nameForDisplay = ""
2020-06-30 18:03:33 +02:00
@Published var timelineItems = [TimelineItem]()
2020-07-12 21:43:52 +02:00
@Published var selectedArticleIDs = Set<String>() // Don't use directly. Use selectedArticles
@Published var selectedArticleID: String? = .none // Don't use directly. Use selectedArticles
2020-07-12 01:22:47 +02:00
@Published var selectedArticles = [Article]()
@Published var readFilterEnabledTable = [FeedIdentifier: Bool]()
@Published var isReadFiltered: Bool? = nil
2020-07-12 17:52:42 +02:00
2020-07-12 01:22:47 +02:00
var undoManager: UndoManager?
var undoableCommands = [UndoableCommand]()
private var selectedFeedsCancellable: AnyCancellable?
2020-07-12 01:22:47 +02:00
private var selectedArticleIDsCancellable: AnyCancellable?
private var selectedArticleIDCancellable: AnyCancellable?
private var selectedArticlesCancellable: AnyCancellable?
2020-07-12 01:22:47 +02:00
private var feeds = [Feed]()
2020-07-01 19:30:55 +02:00
private var fetchSerialNumber = 0
private let fetchRequestQueue = FetchRequestQueue()
private var exceptionArticleFetcher: ArticleFetcher?
private var articles = [Article]() {
didSet {
articleDictionaryNeedsUpdate = true
}
}
2020-07-01 19:30:55 +02:00
private var articleDictionaryNeedsUpdate = true
private var _idToArticleDictionary = [String: Article]()
private var idToArticleDictionary: [String: Article] {
if articleDictionaryNeedsUpdate {
rebuildArticleDictionaries()
}
return _idToArticleDictionary
}
private var sortDirection = AppDefaults.shared.timelineSortDirection {
didSet {
if sortDirection != oldValue {
sortParametersDidChange()
}
}
2020-07-01 19:30:55 +02:00
}
private var groupByFeed = AppDefaults.shared.timelineGroupByFeed {
didSet {
if groupByFeed != oldValue {
sortParametersDidChange()
}
}
2020-07-01 19:30:55 +02:00
}
2020-07-01 18:13:11 +02:00
func startup() {
NotificationCenter.default.addObserver(self, selector: #selector(statusesDidChange(_:)), name: .StatusesDidChange, object: nil)
2020-07-13 02:43:25 +02:00
NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil)
// TODO: This should be rewritten to use Combine correctly
selectedFeedsCancellable = delegate?.selectedFeeds.sink { [weak self] feeds in
guard let self = self else { return }
self.fetchArticles(feeds: feeds)
}
2020-07-12 01:22:47 +02:00
// TODO: This should be rewritten to use Combine correctly
selectedArticleIDsCancellable = $selectedArticleIDs.sink { [weak self] articleIDs in
guard let self = self else { return }
self.selectedArticles = articleIDs.compactMap { self.idToArticleDictionary[$0] }
}
// TODO: This should be rewritten to use Combine correctly
selectedArticleIDCancellable = $selectedArticleID.sink { [weak self] articleID in
guard let self = self else { return }
if let articleID = articleID, let article = self.idToArticleDictionary[articleID] {
self.selectedArticles = [article]
}
}
// TODO: This should be rewritten to use Combine correctly
selectedArticlesCancellable = $selectedArticles.sink { articles in
if articles.count == 1 {
let article = articles.first!
if !article.status.read {
markArticles(Set([article]), statusKey: .read, flag: true)
}
}
}
2020-07-12 21:43:52 +02:00
2020-06-30 18:03:33 +02:00
}
// MARK: API
func toggleReadFilter() {
guard let filter = isReadFiltered, let feedID = feeds.first?.feedID else { return }
readFilterEnabledTable[feedID] = !filter
isReadFiltered = !filter
rebuildTimelineItems()
}
func toggleReadStatusForSelectedArticles() {
guard !selectedArticles.isEmpty else {
return
}
if selectedArticles.anyArticleIsUnread() {
markSelectedArticlesAsRead()
} else {
markSelectedArticlesAsUnread()
}
}
func canMarkIndicatedArticlesAsRead(_ article: Article) -> Bool {
let articles = indicatedArticles(article)
return articles.anyArticleIsUnread()
}
func markIndicatedArticlesAsRead(_ article: Article) {
let articles = indicatedArticles(article)
markArticlesWithUndo(articles, statusKey: .read, flag: true)
}
func markSelectedArticlesAsRead() {
markArticlesWithUndo(selectedArticles, statusKey: .read, flag: true)
}
func canMarkIndicatedArticlesAsUnread(_ article: Article) -> Bool {
let articles = indicatedArticles(article)
return articles.anyArticleIsReadAndCanMarkUnread()
}
func markIndicatedArticlesAsUnread(_ article: Article) {
let articles = indicatedArticles(article)
markArticlesWithUndo(articles, statusKey: .read, flag: false)
}
func markSelectedArticlesAsUnread() {
markArticlesWithUndo(selectedArticles, statusKey: .read, flag: false)
}
func canMarkAboveAsRead(_ article: Article) -> Bool {
let article = indicatedAboveArticle(article)
return articles.articlesAbove(article: article).canMarkAllAsRead()
}
func markAboveAsRead(_ article: Article) {
let article = indicatedAboveArticle(article)
let articlesToMark = articles.articlesAbove(article: article)
guard !articlesToMark.isEmpty else { return }
markArticlesWithUndo(articlesToMark, statusKey: .read, flag: true)
}
func canMarkBelowAsRead(_ article: Article) -> Bool {
let article = indicatedBelowArticle(article)
return articles.articlesBelow(article: article).canMarkAllAsRead()
}
func markBelowAsRead(_ article: Article) {
let article = indicatedBelowArticle(article)
let articlesToMark = articles.articlesBelow(article: article)
guard !articlesToMark.isEmpty else { return }
markArticlesWithUndo(articlesToMark, statusKey: .read, flag: true)
}
func canMarkAllAsReadInFeed(_ feed: Feed) -> Bool {
guard let articlesSet = try? feed.fetchArticles() else {
return false
}
return Array(articlesSet).canMarkAllAsRead()
}
func markAllAsReadInFeed(_ feed: Feed) {
guard let articlesSet = try? feed.fetchArticles() else { return }
let articlesToMark = Array(articlesSet)
markArticlesWithUndo(articlesToMark, statusKey: .read, flag: true)
}
func toggleStarredStatusForSelectedArticles() {
guard !selectedArticles.isEmpty else {
return
}
if selectedArticles.anyArticleIsUnstarred() {
markSelectedArticlesAsStarred()
} else {
markSelectedArticlesAsUnstarred()
}
}
func canMarkIndicatedArticlesAsStarred(_ article: Article) -> Bool {
let articles = indicatedArticles(article)
return articles.anyArticleIsUnstarred()
}
func markIndicatedArticlesAsStarred(_ article: Article) {
let articles = indicatedArticles(article)
markArticlesWithUndo(articles, statusKey: .starred, flag: true)
}
func markSelectedArticlesAsStarred() {
markArticlesWithUndo(selectedArticles, statusKey: .starred, flag: true)
}
func canMarkIndicatedArticlesAsUnstarred(_ article: Article) -> Bool {
let articles = indicatedArticles(article)
return articles.anyArticleIsStarred()
}
func markIndicatedArticlesAsUnstarred(_ article: Article) {
let articles = indicatedArticles(article)
markArticlesWithUndo(articles, statusKey: .starred, flag: false)
}
func markSelectedArticlesAsUnstarred() {
markArticlesWithUndo(selectedArticles, statusKey: .starred, flag: false)
}
func canOpenIndicatedArticleInBrowser(_ article: Article) -> Bool {
guard indicatedArticles(article).count == 1 else { return false }
return article.preferredLink != nil
}
func openIndicatedArticleInBrowser(_ article: Article) {
guard let link = article.preferredLink else { return }
#if os(macOS)
Browser.open(link, invertPreference: NSApp.currentEvent?.modifierFlags.contains(.shift) ?? false)
#else
guard let url = URL(string: link) else { return }
UIApplication.shared.open(url, options: [:])
#endif
}
func openSelectedArticleInBrowser() {
guard let article = selectedArticles.first else { return }
openIndicatedArticleInBrowser(article)
}
func articleFor(_ articleID: String) -> Article? {
return idToArticleDictionary[articleID]
}
func findPrevArticle(_ article: Article) -> Article? {
guard let index = articles.firstIndex(of: article), index > 0 else {
return nil
}
return articles[index - 1]
}
func findNextArticle(_ article: Article) -> Article? {
guard let index = articles.firstIndex(of: article), index + 1 != articles.count else {
return nil
}
return articles[index + 1]
}
func selectArticle(_ article: Article) {
// TODO: Implement me!
}
2020-06-30 18:03:33 +02:00
}
// MARK: Private
2020-07-01 19:30:55 +02:00
2020-06-30 18:03:33 +02:00
private extension TimelineModel {
func indicatedArticles(_ article: Article) -> [Article] {
if selectedArticles.contains(article) {
return selectedArticles
} else {
return [article]
}
}
func indicatedAboveArticle(_ article: Article) -> Article {
if selectedArticles.contains(article) {
return selectedArticles.first!
} else {
return article
}
}
func indicatedBelowArticle(_ article: Article) -> Article {
if selectedArticles.contains(article) {
return selectedArticles.last!
} else {
return article
}
}
func markArticlesWithUndo(_ articles: [Article], statusKey: ArticleStatus.Key, flag: Bool) {
guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articles, statusKey: statusKey, flag: flag, undoManager: undoManager) else {
return
}
runCommand(markReadCommand)
}
// MARK: Notifications
@objc func statusesDidChange(_ note: Notification) {
guard let articleIDs = note.userInfo?[Account.UserInfoKey.articleIDs] as? Set<String> else {
return
}
for i in 0..<timelineItems.count {
if articleIDs.contains(timelineItems[i].article.articleID) {
timelineItems[i].updateStatus()
}
}
}
2020-07-13 02:43:25 +02:00
@objc func userDefaultsDidChange(_ note: Notification) {
sortDirection = AppDefaults.shared.timelineSortDirection
groupByFeed = AppDefaults.shared.timelineGroupByFeed
2020-07-13 02:43:25 +02:00
}
// MARK: Timeline Management
func resetReadFilter() {
guard feeds.count == 1, let timelineFeed = feeds.first else {
isReadFiltered = nil
return
}
guard timelineFeed.defaultReadFilterType != .alwaysRead else {
isReadFiltered = nil
return
}
if let feedID = timelineFeed.feedID, let readFilterEnabled = readFilterEnabledTable[feedID] {
isReadFiltered = readFilterEnabled
} else {
isReadFiltered = timelineFeed.defaultReadFilterType == .read
}
}
func sortParametersDidChange() {
performBlockAndRestoreSelection {
articles = articles.sortedByDate(sortDirection ? .orderedDescending : .orderedAscending, groupByFeed: groupByFeed)
rebuildTimelineItems()
}
}
2020-07-01 19:30:55 +02:00
func performBlockAndRestoreSelection(_ block: (() -> Void)) {
// let savedSelection = selectedArticleIDs()
block()
// restoreSelection(savedSelection)
}
func rebuildArticleDictionaries() {
var idDictionary = [String: Article]()
articles.forEach { article in
idDictionary[article.articleID] = article
}
_idToArticleDictionary = idDictionary
articleDictionaryNeedsUpdate = false
}
2020-07-01 19:30:55 +02:00
// MARK: Article Fetching
func fetchArticles(feeds: [Feed]) {
self.feeds = feeds
if feeds.count == 1 {
nameForDisplay = feeds.first!.nameForDisplay
} else {
nameForDisplay = NSLocalizedString("Multiple", comment: "Multiple Feeds")
}
resetReadFilter()
fetchAndReplaceArticlesAsync()
}
func fetchAndReplaceArticlesAsync() {
2020-07-01 19:30:55 +02:00
var fetchers = feeds as [ArticleFetcher]
if let fetcher = exceptionArticleFetcher {
fetchers.append(fetcher)
exceptionArticleFetcher = nil
}
fetchUnsortedArticlesAsync(for: fetchers) { [weak self] (articles) in
self?.replaceArticles(with: articles)
}
}
func cancelPendingAsyncFetches() {
fetchSerialNumber += 1
fetchRequestQueue.cancelAllRequests()
}
func fetchUnsortedArticlesAsync(for representedObjects: [Any], completion: @escaping ArticleSetBlock) {
// The callback will *not* be called if the fetch is no longer relevant that is,
// if its been superseded by a newer fetch, or the timeline was emptied, etc., it wont get called.
precondition(Thread.isMainThread)
cancelPendingAsyncFetches()
// Right now we are pulling all the records and filtering them in the UI. This is because the async
// change of the timeline times doesn't trigger an animation because it isn't in a withAnimation block.
// Ideally this would be done in the database tier with a query, but if you look, we always pull everything
// from SQLite and filter it programmatically at that level currently. So no big deal.
// We should change this as soon as we figure out how to trigger animations on Lists with async tasks.
let fetchOperation = FetchRequestOperation(id: fetchSerialNumber, readFilter: false, representedObjects: representedObjects) { [weak self] (articles, operation) in
2020-07-01 19:30:55 +02:00
precondition(Thread.isMainThread)
guard !operation.isCanceled, let strongSelf = self, operation.id == strongSelf.fetchSerialNumber else {
return
}
completion(articles)
}
fetchRequestQueue.add(fetchOperation)
}
2020-06-30 18:03:33 +02:00
2020-07-01 19:30:55 +02:00
func replaceArticles(with unsortedArticles: Set<Article>) {
articles = Array(unsortedArticles).sortedByDate(sortDirection ? .orderedDescending : .orderedAscending, groupByFeed: groupByFeed)
rebuildTimelineItems()
selectedArticleIDs = Set<String>()
selectedArticleID = nil
2020-07-01 19:30:55 +02:00
// TODO: Update unread counts and other item done in didSet on AppKit
}
func rebuildTimelineItems() {
let filtered = isReadFiltered ?? false
2020-07-12 21:43:52 +02:00
let selectedArticleIDs = selectedArticles.map { $0.articleID }
timelineItems = articles.compactMap { article in
if filtered && article.status.read && !selectedArticleIDs.contains(article.articleID) {
2020-07-12 21:43:52 +02:00
return nil
} else {
return TimelineItem(article: article)
}
}
}
2020-06-30 18:03:33 +02:00
}