Begin refactor of Timeline to use Combine fully

This commit is contained in:
Maurice Parker 2020-07-24 21:05:30 -05:00
parent c5d040fa97
commit 953c22f605
6 changed files with 303 additions and 626 deletions

View File

@ -25,15 +25,16 @@ final class SceneModel: ObservableObject {
@Published var accountErrorMessage = "" @Published var accountErrorMessage = ""
var selectedArticles: [Article] { var selectedArticles: [Article] {
timelineModel.selectedArticles return [Article]()
// timelineModel.selectedArticles
} }
private var refreshProgressModel: RefreshProgressModel? = nil private var refreshProgressModel: RefreshProgressModel? = nil
private var articleIconSchemeHandler: ArticleIconSchemeHandler? = nil private var articleIconSchemeHandler: ArticleIconSchemeHandler? = nil
private(set) var webViewProvider: WebViewProvider? = nil private(set) var webViewProvider: WebViewProvider? = nil
private(set) var sidebarModel = SidebarModel() private(set) lazy var sidebarModel = SidebarModel(delegate: self)
private(set) var timelineModel = TimelineModel() private(set) lazy var timelineModel = TimelineModel(delegate: self)
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
@ -41,10 +42,6 @@ final class SceneModel: ObservableObject {
/// Prepares the SceneModel to be used in the views /// Prepares the SceneModel to be used in the views
func startup() { func startup() {
sidebarModel.delegate = self
timelineModel.delegate = self
timelineModel.startup()
self.articleIconSchemeHandler = ArticleIconSchemeHandler(sceneModel: self) self.articleIconSchemeHandler = ArticleIconSchemeHandler(sceneModel: self)
self.webViewProvider = WebViewProvider(articleIconSchemeHandler: self.articleIconSchemeHandler!) self.webViewProvider = WebViewProvider(articleIconSchemeHandler: self.articleIconSchemeHandler!)
@ -56,7 +53,7 @@ final class SceneModel: ObservableObject {
/// Goes to the next unread item found in Sidebar and Timeline order, top to bottom /// Goes to the next unread item found in Sidebar and Timeline order, top to bottom
func goToNextUnread() { func goToNextUnread() {
if !timelineModel.goToNextUnread() { if !timelineModel.goToNextUnread() {
timelineModel.isSelectNextUnread = true // timelineModel.isSelectNextUnread = true
sidebarModel.selectNextUnread.send() sidebarModel.selectNextUnread.send()
} }
} }
@ -65,22 +62,22 @@ final class SceneModel: ObservableObject {
/// Marks all the articles in the Timeline as read /// Marks all the articles in the Timeline as read
func markAllAsRead() { func markAllAsRead() {
timelineModel.markAllAsRead() // timelineModel.markAllAsRead()
} }
/// Toggles the read status for the selected articles /// Toggles the read status for the selected articles
func toggleReadStatusForSelectedArticles() { func toggleReadStatusForSelectedArticles() {
timelineModel.toggleReadStatusForSelectedArticles() // timelineModel.toggleReadStatusForSelectedArticles()
} }
/// Toggles the star status for the selected articles /// Toggles the star status for the selected articles
func toggleStarredStatusForSelectedArticles() { func toggleStarredStatusForSelectedArticles() {
timelineModel.toggleStarredStatusForSelectedArticles() // timelineModel.toggleStarredStatusForSelectedArticles()
} }
/// Opens the selected article in an external browser /// Opens the selected article in an external browser
func openSelectedArticleInBrowser() { func openSelectedArticleInBrowser() {
timelineModel.openSelectedArticleInBrowser() // timelineModel.openSelectedArticleInBrowser()
} }
/// Retrieves the article before the given article in the Timeline /// Retrieves the article before the given article in the Timeline
@ -130,20 +127,20 @@ private extension SceneModel {
// MARK: Subscriptions // MARK: Subscriptions
func subscribeToToolbarChangeEvents() { func subscribeToToolbarChangeEvents() {
NotificationCenter.default.publisher(for: .UnreadCountDidChange) // NotificationCenter.default.publisher(for: .UnreadCountDidChange)
.compactMap { $0.object as? AccountManager } // .compactMap { $0.object as? AccountManager }
.sink { [weak self] accountManager in // .sink { [weak self] accountManager in
self?.updateNextUnreadButtonState(accountManager: accountManager) // self?.updateNextUnreadButtonState(accountManager: accountManager)
}.store(in: &cancellables) // }.store(in: &cancellables)
//
let blankNotification = Notification(name: .StatusesDidChange) // let blankNotification = Notification(name: .StatusesDidChange)
let statusesDidChangePublisher = NotificationCenter.default.publisher(for: .StatusesDidChange).prepend(blankNotification) // let statusesDidChangePublisher = NotificationCenter.default.publisher(for: .StatusesDidChange).prepend(blankNotification)
let combinedPublisher = timelineModel.$articles.combineLatest(timelineModel.$selectedArticles, statusesDidChangePublisher) // let combinedPublisher = timelineModel.$articles.combineLatest(timelineModel.$selectedArticles, statusesDidChangePublisher)
//
combinedPublisher.sink { [weak self] (articles, selectedArticles, _) in // combinedPublisher.sink { [weak self] (articles, selectedArticles, _) in
self?.updateMarkAllAsReadButtonsState(articles: articles) // self?.updateMarkAllAsReadButtonsState(articles: articles)
self?.updateArticleButtonsState(selectedArticles: selectedArticles) // self?.updateArticleButtonsState(selectedArticles: selectedArticles)
}.store(in: &cancellables) // }.store(in: &cancellables)
} }
// MARK: Button State Updates // MARK: Button State Updates

View File

@ -37,7 +37,8 @@ class SidebarModel: ObservableObject, UndoableCommandRunner {
var undoManager: UndoManager? var undoManager: UndoManager?
var undoableCommands = [UndoableCommand]() var undoableCommands = [UndoableCommand]()
init() { init(delegate: SidebarModelDelegate) {
self.delegate = delegate
subscribeToSelectedFeedChanges() subscribeToSelectedFeedChanges()
subscribeToRebuildSidebarItemsEvents() subscribeToRebuildSidebarItemsEvents()
subscribeToNextUnread() subscribeToNextUnread()

View File

@ -15,95 +15,97 @@ struct TimelineContextMenu: View {
@ViewBuilder var body: some View { @ViewBuilder var body: some View {
if timelineModel.canMarkIndicatedArticlesAsRead(timelineItem) { Button("Coming back soon...", action: {})
Button {
timelineModel.markIndicatedArticlesAsRead(timelineItem)
} label: {
Text("Mark as Read")
#if os(iOS)
AppAssets.readOpenImage
#endif
}
}
if timelineModel.canMarkIndicatedArticlesAsUnread(timelineItem) { // if timelineModel.canMarkIndicatedArticlesAsRead(timelineItem) {
Button { // Button {
timelineModel.markIndicatedArticlesAsUnread(timelineItem) // timelineModel.markIndicatedArticlesAsRead(timelineItem)
} label: { // } label: {
Text("Mark as Unread") // Text("Mark as Read")
#if os(iOS) // #if os(iOS)
AppAssets.readClosedImage // AppAssets.readOpenImage
#endif // #endif
} // }
} // }
//
if timelineModel.canMarkIndicatedArticlesAsStarred(timelineItem) { // if timelineModel.canMarkIndicatedArticlesAsUnread(timelineItem) {
Button { // Button {
timelineModel.markIndicatedArticlesAsStarred(timelineItem) // timelineModel.markIndicatedArticlesAsUnread(timelineItem)
} label: { // } label: {
Text("Mark as Starred") // Text("Mark as Unread")
#if os(iOS) // #if os(iOS)
AppAssets.starClosedImage // AppAssets.readClosedImage
#endif // #endif
} // }
} // }
//
if timelineModel.canMarkIndicatedArticlesAsUnstarred(timelineItem) { // if timelineModel.canMarkIndicatedArticlesAsStarred(timelineItem) {
Button { // Button {
timelineModel.markIndicatedArticlesAsUnstarred(timelineItem) // timelineModel.markIndicatedArticlesAsStarred(timelineItem)
} label: { // } label: {
Text("Mark as Unstarred") // Text("Mark as Starred")
#if os(iOS) // #if os(iOS)
AppAssets.starOpenImage // AppAssets.starClosedImage
#endif // #endif
} // }
} // }
//
if timelineModel.canMarkAboveAsRead(timelineItem) { // if timelineModel.canMarkIndicatedArticlesAsUnstarred(timelineItem) {
Button { // Button {
timelineModel.markAboveAsRead(timelineItem) // timelineModel.markIndicatedArticlesAsUnstarred(timelineItem)
} label: { // } label: {
Text("Mark Above as Read") // Text("Mark as Unstarred")
#if os(iOS) // #if os(iOS)
AppAssets.markAboveAsReadImage // AppAssets.starOpenImage
#endif // #endif
} // }
} // }
//
if timelineModel.canMarkBelowAsRead(timelineItem) { // if timelineModel.canMarkAboveAsRead(timelineItem) {
Button { // Button {
timelineModel.markBelowAsRead(timelineItem) // timelineModel.markAboveAsRead(timelineItem)
} label: { // } label: {
Text("Mark Below As Read") // Text("Mark Above as Read")
#if os(iOS) // #if os(iOS)
AppAssets.markBelowAsReadImage // AppAssets.markAboveAsReadImage
#endif // #endif
} // }
} // }
//
if timelineModel.canMarkAllAsReadInWebFeed(timelineItem) { // if timelineModel.canMarkBelowAsRead(timelineItem) {
Divider() // Button {
Button { // timelineModel.markBelowAsRead(timelineItem)
timelineModel.markAllAsReadInWebFeed(timelineItem) // } label: {
} label: { // Text("Mark Below As Read")
Text("Mark All as Read in “\(timelineItem.article.webFeed?.nameForDisplay ?? "")") // #if os(iOS)
#if os(iOS) // AppAssets.markBelowAsReadImage
AppAssets.markAllAsReadImage // #endif
#endif // }
} // }
} //
// if timelineModel.canMarkAllAsReadInWebFeed(timelineItem) {
if timelineModel.canOpenIndicatedArticleInBrowser(timelineItem) { // Divider()
Divider() // Button {
Button { // timelineModel.markAllAsReadInWebFeed(timelineItem)
timelineModel.openIndicatedArticleInBrowser(timelineItem) // } label: {
} label: { // Text("Mark All as Read in \(timelineItem.article.webFeed?.nameForDisplay ?? "")")
Text("Open in Browser") // #if os(iOS)
#if os(iOS) // AppAssets.markAllAsReadImage
AppAssets.openInBrowserImage // #endif
#endif // }
} // }
} //
// if timelineModel.canOpenIndicatedArticleInBrowser(timelineItem) {
// Divider()
// Button {
// timelineModel.openIndicatedArticleInBrowser(timelineItem)
// } label: {
// Text("Open in Browser")
// #if os(iOS)
// AppAssets.openInBrowserImage
// #endif
// }
// }
} }
} }

View File

@ -28,345 +28,175 @@ class TimelineModel: ObservableObject, UndoableCommandRunner {
@Published var nameForDisplay = "" @Published var nameForDisplay = ""
@Published var selectedArticleIDs = Set<String>() // Don't use directly. Use selectedArticles @Published var selectedArticleIDs = Set<String>() // Don't use directly. Use selectedArticles
@Published var selectedArticleID: String? = nil // Don't use directly. Use selectedArticles @Published var selectedArticleID: String? = nil // Don't use directly. Use selectedArticles
@Published var selectedArticles = [Article]()
@Published var selectedTimelineItems = [TimelineItem]()
@Published var readFilterEnabledTable = [FeedIdentifier: Bool]()
@Published var isReadFiltered: Bool? = nil @Published var isReadFiltered: Bool? = nil
@Published var articles = [Article]() { var timelineItemsPublisher: AnyPublisher<[TimelineItem], Never>?
didSet { var selectedTimelineItemsPublisher: AnyPublisher<[TimelineItem], Never>?
articleDictionaryNeedsUpdate = true
}
}
@Published var timelineItems = [TimelineItem]() { var readFilterEnabledTable = [FeedIdentifier: Bool]()
didSet {
timelineItemDictionaryNeedsUpdate = true
}
}
// I don't like this flag and feel like it is a hack. Maybe there is a better way to do this using Combine.
var isSelectNextUnread = false
var undoManager: UndoManager? var undoManager: UndoManager?
var undoableCommands = [UndoableCommand]() var undoableCommands = [UndoableCommand]()
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
private var feeds = [Feed]() private var sortDirectionSubject = PassthroughSubject<Bool, Never>()
private var fetchSerialNumber = 0 private var groupByFeedSubject = PassthroughSubject<Bool, Never>()
private let fetchRequestQueue = FetchRequestQueue()
private var exceptionArticleFetcher: ArticleFetcher?
static let fetchAndMergeArticlesQueue = CoalescingQueue(name: "Fetch and Merge Articles", interval: 0.5, maxInterval: 2.0) init(delegate: TimelineModelDelegate) {
self.delegate = delegate
private var articleDictionaryNeedsUpdate = true // subscribeToArticleStatusChanges()
private var _idToArticleDictionary = [String: Article]()
private var idToArticleDictionary: [String: Article] {
if articleDictionaryNeedsUpdate {
rebuildArticleDictionaries()
}
return _idToArticleDictionary
}
private var timelineItemDictionaryNeedsUpdate = true
private var _idToTimelineItemDictionary = [String: Int]()
private var idToTimelineItemDictionary: [String: Int] {
if timelineItemDictionaryNeedsUpdate {
rebuildTimelineItemDictionaries()
}
return _idToTimelineItemDictionary
}
private var sortDirection = AppDefaults.shared.timelineSortDirection {
didSet {
if sortDirection != oldValue {
sortParametersDidChange()
}
}
}
private var groupByFeed = AppDefaults.shared.timelineGroupByFeed {
didSet {
if groupByFeed != oldValue {
sortParametersDidChange()
}
}
}
func startup() {
subscribeToArticleStatusChanges()
subscribeToUserDefaultsChanges() subscribeToUserDefaultsChanges()
subscribeToSelectedFeedChanges() subscribeToArticleFetchChanges()
subscribeToSelectedArticleSelectionChanges() subscribeToSelectedArticleSelectionChanges()
subscribeToAccountDidDownloadArticles() // subscribeToAccountDidDownloadArticles()
} }
// MARK: Subscriptions // MARK: Subscriptions
func subscribeToArticleStatusChanges() { // func subscribeToArticleStatusChanges() {
NotificationCenter.default.publisher(for: .StatusesDidChange).sink { [weak self] note in // NotificationCenter.default.publisher(for: .StatusesDidChange).sink { [weak self] note in
guard let self = self, let articleIDs = note.userInfo?[Account.UserInfoKey.articleIDs] as? Set<String> else { // guard let self = self, let articleIDs = note.userInfo?[Account.UserInfoKey.articleIDs] as? Set<String> else {
return // return
} // }
articleIDs.forEach { articleID in // articleIDs.forEach { articleID in
if let timelineItemIndex = self.idToTimelineItemDictionary[articleID] { // if let timelineItemIndex = self.idToTimelineItemDictionary[articleID] {
self.timelineItems[timelineItemIndex].updateStatus() // self.timelineItems[timelineItemIndex].updateStatus()
} // }
} // }
}.store(in: &cancellables) // }.store(in: &cancellables)
} // }
func subscribeToAccountDidDownloadArticles() { // func subscribeToAccountDidDownloadArticles() {
NotificationCenter.default.publisher(for: .AccountDidDownloadArticles).sink { [weak self] note in // NotificationCenter.default.publisher(for: .AccountDidDownloadArticles).sink { [weak self] note in
guard let self = self, let feeds = note.userInfo?[Account.UserInfoKey.webFeeds] as? Set<WebFeed> else { // guard let self = self, let feeds = note.userInfo?[Account.UserInfoKey.webFeeds] as? Set<WebFeed> else {
return // return
} // }
if self.anySelectedFeedIntersection(with: feeds) || self.anySelectedFeedIsPseudoFeed() { // if self.anySelectedFeedIntersection(with: feeds) || self.anySelectedFeedIsPseudoFeed() {
self.queueFetchAndMergeArticles() // self.queueFetchAndMergeArticles()
} // }
}.store(in: &cancellables) // }.store(in: &cancellables)
} // }
func subscribeToUserDefaultsChanges() { func subscribeToUserDefaultsChanges() {
NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification).sink { [weak self] _ in let kickStartNote = Notification(name: Notification.Name("Kick Start"))
self?.sortDirection = AppDefaults.shared.timelineSortDirection NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification)
self?.groupByFeed = AppDefaults.shared.timelineGroupByFeed .prepend(kickStartNote)
.sink { [weak self] _ in
self?.sortDirectionSubject.send(AppDefaults.shared.timelineSortDirection)
self?.groupByFeedSubject.send(AppDefaults.shared.timelineGroupByFeed)
}.store(in: &cancellables) }.store(in: &cancellables)
} }
func subscribeToSelectedFeedChanges() { func subscribeToArticleFetchChanges() {
guard let selectedFeedsPublisher = delegate?.selectedFeedsPublisher else { return } guard let selectedFeedsPublisher = delegate?.selectedFeedsPublisher else { return }
selectedFeedsPublisher.sink { [weak self] feeds in let sortDirectionPublisher = sortDirectionSubject.removeDuplicates()
guard let self = self else { return } let groupByPublisher = groupByFeedSubject.removeDuplicates()
self.feeds = feeds
self.fetchArticles() timelineItemsPublisher = selectedFeedsPublisher
}.store(in: &cancellables) .map { [weak self] feeds -> Set<Article> in
return self?.fetchArticles(feeds: feeds) ?? Set<Article>()
}
.combineLatest($isReadFiltered, sortDirectionPublisher, groupByPublisher)
.compactMap { [weak self] articles, filtered, sortDirection, groupBy -> [TimelineItem] in
let sortedArticles = Array(articles).sortedByDate(sortDirection ? .orderedDescending : .orderedAscending, groupByFeed: groupBy)
return self?.buildTimelineItems(articles: sortedArticles) ?? [TimelineItem]()
}
.eraseToAnyPublisher()
} }
func subscribeToSelectedArticleSelectionChanges() { func subscribeToSelectedArticleSelectionChanges() {
$selectedArticleIDs.map { [weak self] articleIDs in // $selectedArticleIDs.map { [weak self] articleIDs in
return articleIDs.compactMap { self?.idToArticleDictionary[$0] } // return articleIDs.compactMap { self?.idToArticleDictionary[$0] }
} // }
.assign(to: &$selectedArticles) // .assign(to: &$selectedArticles)
//
$selectedArticleID.compactMap { [weak self] articleID in // $selectedArticleID.compactMap { [weak self] articleID in
if let articleID = articleID, let article = self?.idToArticleDictionary[articleID] { // if let articleID = articleID, let article = self?.idToArticleDictionary[articleID] {
return [article] // return [article]
} else { // } else {
return nil // return nil
} // }
} // }
.assign(to: &$selectedArticles) // .assign(to: &$selectedArticles)
//
// Assign the selected timeline items // // Assign the selected timeline items
$selectedArticles.compactMap { [weak self] selectedArticles in // $selectedArticles.compactMap { [weak self] selectedArticles in
return selectedArticles.compactMap { // return selectedArticles.compactMap {
if let index = self?.idToTimelineItemDictionary[$0.articleID] { // if let index = self?.idToTimelineItemDictionary[$0.articleID] {
return self?.timelineItems[index] // return self?.timelineItems[index]
} // }
return nil // return nil
} // }
}.assign(to: &$selectedTimelineItems) // }.assign(to: &$selectedTimelineItems)
//
// Automatically mark a selected record as read // // Automatically mark a selected record as read
$selectedArticles // $selectedArticles
.filter { $0.count == 1 } // .filter { $0.count == 1 }
.compactMap { $0.first } // .compactMap { $0.first }
.filter { !$0.status.read } // .filter { !$0.status.read }
.sink { markArticles(Set([$0]), statusKey: .read, flag: true) } // .sink { markArticles(Set([$0]), statusKey: .read, flag: true) }
.store(in: &cancellables) // .store(in: &cancellables)
} }
// MARK: API // MARK: API
func toggleReadFilter() { func toggleReadFilter() {
guard let filter = isReadFiltered, let feedID = feeds.first?.feedID else { return } // guard let filter = isReadFiltered, let feedID = feeds.first?.feedID else { return }
readFilterEnabledTable[feedID] = !filter // readFilterEnabledTable[feedID] = !filter
isReadFiltered = !filter // isReadFiltered = !filter
self.fetchArticles() // self.fetchArticles()
} }
func toggleReadStatusForSelectedArticles() { func toggleReadStatusForSelectedArticles() {
guard !selectedArticles.isEmpty else { // guard !selectedArticles.isEmpty else {
return // return
} // }
if selectedArticles.anyArticleIsUnread() { // if selectedArticles.anyArticleIsUnread() {
markSelectedArticlesAsRead() // markSelectedArticlesAsRead()
} else { // } else {
markSelectedArticlesAsUnread() // markSelectedArticlesAsUnread()
} // }
}
func canMarkIndicatedArticlesAsRead(_ timelineItem: TimelineItem) -> Bool {
let articles = indicatedTimelineItems(timelineItem).map { $0.article }
return articles.anyArticleIsUnread()
}
func markIndicatedArticlesAsRead(_ timelineItem: TimelineItem) {
let articles = indicatedTimelineItems(timelineItem).map { $0.article }
markArticlesWithUndo(articles, statusKey: .read, flag: true)
}
func markSelectedArticlesAsRead() {
markArticlesWithUndo(selectedArticles, statusKey: .read, flag: true)
}
func canMarkIndicatedArticlesAsUnread(_ timelineItem: TimelineItem) -> Bool {
let articles = indicatedTimelineItems(timelineItem).map { $0.article }
return articles.anyArticleIsReadAndCanMarkUnread()
}
func markIndicatedArticlesAsUnread(_ timelineItem: TimelineItem) {
let articles = indicatedTimelineItems(timelineItem).map { $0.article }
markArticlesWithUndo(articles, statusKey: .read, flag: false)
}
func markSelectedArticlesAsUnread() {
markArticlesWithUndo(selectedArticles, statusKey: .read, flag: false)
}
func canMarkAboveAsRead(_ timelineItem: TimelineItem) -> Bool {
let timelineItem = indicatedAboveTimelineItem(timelineItem)
return articles.articlesAbove(position: timelineItem.index).canMarkAllAsRead()
}
func markAboveAsRead(_ timelineItem: TimelineItem) {
let timelineItem = indicatedAboveTimelineItem(timelineItem)
let articlesToMark = articles.articlesAbove(position: timelineItem.index)
guard !articlesToMark.isEmpty else { return }
markArticlesWithUndo(articlesToMark, statusKey: .read, flag: true)
}
func canMarkBelowAsRead(_ timelineItem: TimelineItem) -> Bool {
let timelineItem = indicatedBelowTimelineItem(timelineItem)
return articles.articlesBelow(position: timelineItem.index).canMarkAllAsRead()
}
func markBelowAsRead(_ timelineItem: TimelineItem) {
let timelineItem = indicatedBelowTimelineItem(timelineItem)
let articlesToMark = articles.articlesBelow(position: timelineItem.index)
guard !articlesToMark.isEmpty else { return }
markArticlesWithUndo(articlesToMark, statusKey: .read, flag: true)
}
func canMarkAllAsReadInWebFeed(_ timelineItem: TimelineItem) -> Bool {
return timelineItem.article.webFeed?.unreadCount ?? 0 > 0
}
func markAllAsReadInWebFeed(_ timelineItem: TimelineItem) {
guard let articlesSet = try? timelineItem.article.webFeed?.fetchArticles() else { return }
let articlesToMark = Array(articlesSet)
markArticlesWithUndo(articlesToMark, statusKey: .read, flag: true)
}
func canMarkAllAsRead() -> Bool {
return articles.canMarkAllAsRead()
}
func markAllAsRead() {
markArticlesWithUndo(articles, statusKey: .read, flag: true)
}
func toggleStarredStatusForSelectedArticles() {
guard !selectedArticles.isEmpty else {
return
}
if selectedArticles.anyArticleIsUnstarred() {
markSelectedArticlesAsStarred()
} else {
markSelectedArticlesAsUnstarred()
}
}
func canMarkIndicatedArticlesAsStarred(_ timelineItem: TimelineItem) -> Bool {
let articles = indicatedTimelineItems(timelineItem).map { $0.article }
return articles.anyArticleIsUnstarred()
}
func markIndicatedArticlesAsStarred(_ timelineItem: TimelineItem) {
let articles = indicatedTimelineItems(timelineItem).map { $0.article }
markArticlesWithUndo(articles, statusKey: .starred, flag: true)
}
func markSelectedArticlesAsStarred() {
markArticlesWithUndo(selectedArticles, statusKey: .starred, flag: true)
}
func canMarkIndicatedArticlesAsUnstarred(_ timelineItem: TimelineItem) -> Bool {
let articles = indicatedTimelineItems(timelineItem).map { $0.article }
return articles.anyArticleIsStarred()
}
func markIndicatedArticlesAsUnstarred(_ timelineItem: TimelineItem) {
let articles = indicatedTimelineItems(timelineItem).map { $0.article }
markArticlesWithUndo(articles, statusKey: .starred, flag: false)
}
func markSelectedArticlesAsUnstarred() {
markArticlesWithUndo(selectedArticles, statusKey: .starred, flag: false)
}
func canOpenIndicatedArticleInBrowser(_ timelineItem: TimelineItem) -> Bool {
guard indicatedTimelineItems(timelineItem).count == 1 else { return false }
return timelineItem.article.preferredLink != nil
}
func openIndicatedArticleInBrowser(_ timelineItem: TimelineItem) {
openIndicatedArticleInBrowser(timelineItem.article)
}
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)
} }
@discardableResult @discardableResult
func goToNextUnread() -> Bool { func goToNextUnread() -> Bool {
var startIndex: Int // var startIndex: Int
if let firstArticle = selectedArticles.first, let index = timelineItems.firstIndex(where: { $0.article == firstArticle }) { // if let firstArticle = selectedArticles.first, let index = timelineItems.firstIndex(where: { $0.article == firstArticle }) {
startIndex = index // startIndex = index
} else { // } else {
startIndex = 0 // startIndex = 0
} // }
//
for i in startIndex..<timelineItems.count { // for i in startIndex..<timelineItems.count {
if !timelineItems[i].article.status.read { // if !timelineItems[i].article.status.read {
select(timelineItems[i].article.articleID) // select(timelineItems[i].article.articleID)
return true // return true
} // }
} // }
//
return false return false
} }
func articleFor(_ articleID: String) -> Article? { func articleFor(_ articleID: String) -> Article? {
return idToArticleDictionary[articleID] return nil
// return idToArticleDictionary[articleID]
} }
func findPrevArticle(_ article: Article) -> Article? { func findPrevArticle(_ article: Article) -> Article? {
guard let index = articles.firstIndex(of: article), index > 0 else {
return nil return nil
} // guard let index = articles.firstIndex(of: article), index > 0 else {
return articles[index - 1] // return nil
// }
// return articles[index - 1]
} }
func findNextArticle(_ article: Article) -> Article? { func findNextArticle(_ article: Article) -> Article? {
guard let index = articles.firstIndex(of: article), index + 1 != articles.count else {
return nil return nil
} // guard let index = articles.firstIndex(of: article), index + 1 != articles.count else {
return articles[index + 1] // return nil
// }
// return articles[index + 1]
} }
func selectArticle(_ article: Article) { func selectArticle(_ article: Article) {
@ -379,30 +209,6 @@ class TimelineModel: ObservableObject, UndoableCommandRunner {
private extension TimelineModel { private extension TimelineModel {
func indicatedTimelineItems(_ timelineItem: TimelineItem) -> [TimelineItem] {
if selectedTimelineItems.contains(where: { $0.id == timelineItem.id }) {
return selectedTimelineItems
} else {
return [timelineItem]
}
}
func indicatedAboveTimelineItem(_ timelineItem: TimelineItem) -> TimelineItem {
if selectedTimelineItems.contains(where: { $0.id == timelineItem.id }) {
return selectedTimelineItems.first!
} else {
return timelineItem
}
}
func indicatedBelowTimelineItem(_ timelineItem: TimelineItem) -> TimelineItem {
if selectedTimelineItems.contains(where: { $0.id == timelineItem.id }) {
return selectedTimelineItems.last!
} else {
return timelineItem
}
}
func markArticlesWithUndo(_ articles: [Article], statusKey: ArticleStatus.Key, flag: Bool) { func markArticlesWithUndo(_ articles: [Article], statusKey: ArticleStatus.Key, flag: Bool) {
if let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articles, statusKey: statusKey, flag: flag, undoManager: undoManager) { if let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articles, statusKey: statusKey, flag: flag, undoManager: undoManager) {
runCommand(markReadCommand) runCommand(markReadCommand)
@ -411,227 +217,92 @@ private extension TimelineModel {
} }
} }
func select(_ articleID: String) {
selectedArticleIDs = Set([articleID])
selectedArticleID = articleID
}
// MARK: Timeline Management // MARK: Timeline Management
func resetReadFilter() { // func resetReadFilter() {
guard feeds.count == 1, let timelineFeed = feeds.first else { // guard feeds.count == 1, let timelineFeed = feeds.first else {
isReadFiltered = nil // isReadFiltered = nil
return // return
} // }
//
guard timelineFeed.defaultReadFilterType != .alwaysRead else { // guard timelineFeed.defaultReadFilterType != .alwaysRead else {
isReadFiltered = nil // isReadFiltered = nil
return // return
} // }
//
if let feedID = timelineFeed.feedID, let readFilterEnabled = readFilterEnabledTable[feedID] { // if let feedID = timelineFeed.feedID, let readFilterEnabled = readFilterEnabledTable[feedID] {
isReadFiltered = readFilterEnabled // isReadFiltered = readFilterEnabled
} else { // } else {
isReadFiltered = timelineFeed.defaultReadFilterType == .read // isReadFiltered = timelineFeed.defaultReadFilterType == .read
} // }
} // }
func sortParametersDidChange() { func sortParametersDidChange() {
performBlockAndRestoreSelection { // performBlockAndRestoreSelection {
articles = articles.sortedByDate(sortDirection ? .orderedDescending : .orderedAscending, groupByFeed: groupByFeed) // articles = articles.sortedByDate(sortDirection ? .orderedDescending : .orderedAscending, groupByFeed: groupByFeed)
rebuildTimelineItems() // rebuildTimelineItems()
} // }
} }
func performBlockAndRestoreSelection(_ block: (() -> Void)) { func performBlockAndRestoreSelection(_ block: (() -> Void)) {
let savedArticleIDs = selectedArticleIDs // let savedArticleIDs = selectedArticleIDs
let savedArticleID = selectedArticleID // let savedArticleID = selectedArticleID
block() block()
selectedArticleIDs = savedArticleIDs // selectedArticleIDs = savedArticleIDs
selectedArticleID = savedArticleID // selectedArticleID = savedArticleID
}
func rebuildArticleDictionaries() {
var idDictionary = [String: Article]()
articles.forEach { article in
idDictionary[article.articleID] = article
}
_idToArticleDictionary = idDictionary
articleDictionaryNeedsUpdate = false
}
func rebuildTimelineItemDictionaries() {
var idDictionary = [String: Int]()
for (index, timelineItem) in timelineItems.enumerated() {
idDictionary[timelineItem.article.articleID] = index
}
_idToTimelineItemDictionary = idDictionary
timelineItemDictionaryNeedsUpdate = false
} }
// MARK: Article Fetching // MARK: Article Fetching
func fetchArticles() { func fetchArticles(feeds: [Feed]) -> Set<Article> {
replaceArticles(with: Set<Article>()) if feeds.isEmpty {
guard !feeds.isEmpty else {
nameForDisplay = ""
return
}
if feeds.count == 1 {
nameForDisplay = feeds.first!.nameForDisplay
} else {
nameForDisplay = NSLocalizedString("Multiple", comment: "Multiple Feeds")
}
resetReadFilter()
#if os(macOS)
fetchAndReplaceArticlesSync()
#else
fetchAndReplaceArticlesAsync()
#endif
}
func fetchAndReplaceArticlesSync() {
// To be called when the user has made a change of selection in the sidebar.
// It blocks the main thread, so that theres no async delay,
// so that the entire display refreshes at once.
// Its a better user experience this way.
var fetchers = feeds as [ArticleFetcher]
if let fetcher = exceptionArticleFetcher {
fetchers.append(fetcher)
exceptionArticleFetcher = nil
}
let fetchedArticles = fetchUnsortedArticlesSync(for: fetchers)
replaceArticles(with: fetchedArticles)
}
func fetchAndReplaceArticlesAsync() {
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 fetchUnsortedArticlesSync(for representedObjects: [Any]) -> Set<Article> {
cancelPendingAsyncFetches()
let articleFetchers = representedObjects.compactMap{ $0 as? ArticleFetcher }
if articleFetchers.isEmpty {
return Set<Article>() return Set<Article>()
} }
var fetchedArticles = Set<Article>() var fetchedArticles = Set<Article>()
for articleFetcher in articleFetchers { for feed in feeds {
if isReadFiltered ?? true { if isReadFiltered ?? true {
if let articles = try? articleFetcher.fetchUnreadArticles() { if let articles = try? feed.fetchUnreadArticles() {
fetchedArticles.formUnion(articles) fetchedArticles.formUnion(articles)
} }
} else { } else {
if let articles = try? articleFetcher.fetchArticles() { if let articles = try? feed.fetchArticles() {
fetchedArticles.formUnion(articles) fetchedArticles.formUnion(articles)
} }
} }
} }
return fetchedArticles return fetchedArticles
} }
func fetchUnsortedArticlesAsync(for representedObjects: [Any], completion: @escaping ArticleSetBlock) { func buildTimelineItems(articles: [Article]) -> [TimelineItem] {
// 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()
let filtered = isReadFiltered ?? false
let fetchOperation = FetchRequestOperation(id: fetchSerialNumber, readFilter: filtered, representedObjects: representedObjects) { [weak self] (articles, operation) in
precondition(Thread.isMainThread)
guard !operation.isCanceled, let strongSelf = self, operation.id == strongSelf.fetchSerialNumber else {
return
}
completion(articles)
}
fetchRequestQueue.add(fetchOperation)
}
func replaceArticles(with unsortedArticles: Set<Article>) {
articles = Array(unsortedArticles).sortedByDate(sortDirection ? .orderedDescending : .orderedAscending, groupByFeed: groupByFeed)
rebuildTimelineItems()
selectedArticleIDs = Set<String>()
selectedArticleID = nil
if isSelectNextUnread {
goToNextUnread()
isSelectNextUnread = false
}
// TODO: Update unread counts and other item done in didSet on AppKit
}
func rebuildTimelineItems() {
var items = [TimelineItem]() var items = [TimelineItem]()
for (index, article) in articles.enumerated() { for (index, article) in articles.enumerated() {
items.append(TimelineItem(index: index, article: article)) items.append(TimelineItem(index: index, article: article))
} }
timelineItems = items return items
} }
func queueFetchAndMergeArticles() { // func anySelectedFeedIsPseudoFeed() -> Bool {
TimelineModel.fetchAndMergeArticlesQueue.add(self, #selector(fetchAndMergeArticles)) // return feeds.contains(where: { $0 is PseudoFeed})
} // }
//
@objc func fetchAndMergeArticles() { // func anySelectedFeedIntersection(with webFeeds: Set<WebFeed>) -> Bool {
// for feed in feeds {
fetchUnsortedArticlesAsync(for: feeds) { [weak self] (unsortedArticles) in // if let selectedWebFeed = feed as? WebFeed {
// Merge articles by articleID. For any unique articleID in current articles, add to unsortedArticles. // for webFeed in webFeeds {
guard let strongSelf = self else { // if selectedWebFeed.webFeedID == webFeed.webFeedID || selectedWebFeed.url == webFeed.url {
return // return true
} // }
let unsortedArticleIDs = unsortedArticles.articleIDs() // }
var updatedArticles = unsortedArticles // } else if let folder = feed as? Folder {
for article in strongSelf.articles { // for webFeed in webFeeds {
if !unsortedArticleIDs.contains(article.articleID) { // if folder.hasWebFeed(with: webFeed.webFeedID) || folder.hasWebFeed(withURL: webFeed.url) {
updatedArticles.insert(article) // return true
} // }
} // }
strongSelf.performBlockAndRestoreSelection { // }
strongSelf.replaceArticles(with: updatedArticles) // }
} // return false
} // }
}
func anySelectedFeedIsPseudoFeed() -> Bool {
return feeds.contains(where: { $0 is PseudoFeed})
}
func anySelectedFeedIntersection(with webFeeds: Set<WebFeed>) -> Bool {
for feed in feeds {
if let selectedWebFeed = feed as? WebFeed {
for webFeed in webFeeds {
if selectedWebFeed.webFeedID == webFeed.webFeedID || selectedWebFeed.url == webFeed.url {
return true
}
}
} else if let folder = feed as? Folder {
for webFeed in webFeeds {
if folder.hasWebFeed(with: webFeed.webFeedID) || folder.hasWebFeed(withURL: webFeed.url) {
return true
}
}
}
}
return false
}
} }

View File

@ -11,6 +11,7 @@ import SwiftUI
struct TimelineView: View { struct TimelineView: View {
@EnvironmentObject private var timelineModel: TimelineModel @EnvironmentObject private var timelineModel: TimelineModel
@State private var timelineItems = [TimelineItem]()
@State private var timelineItemFrames = [String: CGRect]() @State private var timelineItemFrames = [String: CGRect]()
@ViewBuilder var body: some View { @ViewBuilder var body: some View {
@ -37,7 +38,7 @@ struct TimelineView: View {
.help(timelineModel.isReadFiltered ?? false ? "Show Read Articles" : "Filter Read Articles") .help(timelineModel.isReadFiltered ?? false ? "Show Read Articles" : "Filter Read Articles")
} }
ScrollViewReader { scrollViewProxy in ScrollViewReader { scrollViewProxy in
List(timelineModel.timelineItems, selection: $timelineModel.selectedArticleIDs) { timelineItem in List(timelineItems, selection: $timelineModel.selectedArticleIDs) { timelineItem in
let selected = timelineModel.selectedArticleIDs.contains(timelineItem.article.articleID) let selected = timelineModel.selectedArticleIDs.contains(timelineItem.article.articleID)
TimelineItemView(selected: selected, width: geometryReaderProxy.size.width, timelineItem: timelineItem) TimelineItemView(selected: selected, width: geometryReaderProxy.size.width, timelineItem: timelineItem)
.background(TimelineItemFramePreferenceView(timelineItem: timelineItem)) .background(TimelineItemFramePreferenceView(timelineItem: timelineItem))
@ -61,6 +62,11 @@ struct TimelineView: View {
} }
} }
} }
.onReceive(timelineModel.timelineItemsPublisher!) { items in
withAnimation {
timelineItems = items
}
}
.navigationTitle(Text(verbatim: timelineModel.nameForDisplay)) .navigationTitle(Text(verbatim: timelineModel.nameForDisplay))
#else #else
ScrollViewReader { scrollViewProxy in ScrollViewReader { scrollViewProxy in

View File

@ -80,9 +80,9 @@ class WebViewController: NSViewController {
statusBarView.heightAnchor.constraint(equalToConstant: 20) statusBarView.heightAnchor.constraint(equalToConstant: 20)
]) ])
selectedArticlesCancellable = sceneModel?.timelineModel.$selectedArticles.sink { [weak self] articles in // selectedArticlesCancellable = sceneModel?.timelineModel.$selectedArticles.sink { [weak self] articles in
self?.articles = articles // self?.articles = articles
} // }
} }
// MARK: Notifications // MARK: Notifications