diff --git a/Multiplatform/Shared/SceneModel.swift b/Multiplatform/Shared/SceneModel.swift index 6b3ec16d9..acd957a03 100644 --- a/Multiplatform/Shared/SceneModel.swift +++ b/Multiplatform/Shared/SceneModel.swift @@ -25,15 +25,16 @@ final class SceneModel: ObservableObject { @Published var accountErrorMessage = "" var selectedArticles: [Article] { - timelineModel.selectedArticles + return [Article]() +// timelineModel.selectedArticles } private var refreshProgressModel: RefreshProgressModel? = nil private var articleIconSchemeHandler: ArticleIconSchemeHandler? = nil private(set) var webViewProvider: WebViewProvider? = nil - private(set) var sidebarModel = SidebarModel() - private(set) var timelineModel = TimelineModel() + private(set) lazy var sidebarModel = SidebarModel(delegate: self) + private(set) lazy var timelineModel = TimelineModel(delegate: self) private var cancellables = Set() @@ -41,10 +42,6 @@ final class SceneModel: ObservableObject { /// Prepares the SceneModel to be used in the views func startup() { - sidebarModel.delegate = self - timelineModel.delegate = self - timelineModel.startup() - self.articleIconSchemeHandler = ArticleIconSchemeHandler(sceneModel: self) 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 func goToNextUnread() { if !timelineModel.goToNextUnread() { - timelineModel.isSelectNextUnread = true +// timelineModel.isSelectNextUnread = true sidebarModel.selectNextUnread.send() } } @@ -65,22 +62,22 @@ final class SceneModel: ObservableObject { /// Marks all the articles in the Timeline as read func markAllAsRead() { - timelineModel.markAllAsRead() +// timelineModel.markAllAsRead() } /// Toggles the read status for the selected articles func toggleReadStatusForSelectedArticles() { - timelineModel.toggleReadStatusForSelectedArticles() +// timelineModel.toggleReadStatusForSelectedArticles() } /// Toggles the star status for the selected articles func toggleStarredStatusForSelectedArticles() { - timelineModel.toggleStarredStatusForSelectedArticles() +// timelineModel.toggleStarredStatusForSelectedArticles() } /// Opens the selected article in an external browser func openSelectedArticleInBrowser() { - timelineModel.openSelectedArticleInBrowser() +// timelineModel.openSelectedArticleInBrowser() } /// Retrieves the article before the given article in the Timeline @@ -130,20 +127,20 @@ private extension SceneModel { // MARK: Subscriptions func subscribeToToolbarChangeEvents() { - NotificationCenter.default.publisher(for: .UnreadCountDidChange) - .compactMap { $0.object as? AccountManager } - .sink { [weak self] accountManager in - self?.updateNextUnreadButtonState(accountManager: accountManager) - }.store(in: &cancellables) - - let blankNotification = Notification(name: .StatusesDidChange) - let statusesDidChangePublisher = NotificationCenter.default.publisher(for: .StatusesDidChange).prepend(blankNotification) - let combinedPublisher = timelineModel.$articles.combineLatest(timelineModel.$selectedArticles, statusesDidChangePublisher) - - combinedPublisher.sink { [weak self] (articles, selectedArticles, _) in - self?.updateMarkAllAsReadButtonsState(articles: articles) - self?.updateArticleButtonsState(selectedArticles: selectedArticles) - }.store(in: &cancellables) +// NotificationCenter.default.publisher(for: .UnreadCountDidChange) +// .compactMap { $0.object as? AccountManager } +// .sink { [weak self] accountManager in +// self?.updateNextUnreadButtonState(accountManager: accountManager) +// }.store(in: &cancellables) +// +// let blankNotification = Notification(name: .StatusesDidChange) +// let statusesDidChangePublisher = NotificationCenter.default.publisher(for: .StatusesDidChange).prepend(blankNotification) +// let combinedPublisher = timelineModel.$articles.combineLatest(timelineModel.$selectedArticles, statusesDidChangePublisher) +// +// combinedPublisher.sink { [weak self] (articles, selectedArticles, _) in +// self?.updateMarkAllAsReadButtonsState(articles: articles) +// self?.updateArticleButtonsState(selectedArticles: selectedArticles) +// }.store(in: &cancellables) } // MARK: Button State Updates diff --git a/Multiplatform/Shared/Sidebar/SidebarModel.swift b/Multiplatform/Shared/Sidebar/SidebarModel.swift index af064a888..1de2fa8c7 100644 --- a/Multiplatform/Shared/Sidebar/SidebarModel.swift +++ b/Multiplatform/Shared/Sidebar/SidebarModel.swift @@ -37,7 +37,8 @@ class SidebarModel: ObservableObject, UndoableCommandRunner { var undoManager: UndoManager? var undoableCommands = [UndoableCommand]() - init() { + init(delegate: SidebarModelDelegate) { + self.delegate = delegate subscribeToSelectedFeedChanges() subscribeToRebuildSidebarItemsEvents() subscribeToNextUnread() diff --git a/Multiplatform/Shared/Timeline/TimelineContextMenu.swift b/Multiplatform/Shared/Timeline/TimelineContextMenu.swift index 94370765d..939300232 100644 --- a/Multiplatform/Shared/Timeline/TimelineContextMenu.swift +++ b/Multiplatform/Shared/Timeline/TimelineContextMenu.swift @@ -15,95 +15,97 @@ struct TimelineContextMenu: View { @ViewBuilder var body: some View { - if timelineModel.canMarkIndicatedArticlesAsRead(timelineItem) { - Button { - timelineModel.markIndicatedArticlesAsRead(timelineItem) - } label: { - Text("Mark as Read") - #if os(iOS) - AppAssets.readOpenImage - #endif - } - } + Button("Coming back soon...", action: {}) - if timelineModel.canMarkIndicatedArticlesAsUnread(timelineItem) { - Button { - timelineModel.markIndicatedArticlesAsUnread(timelineItem) - } label: { - Text("Mark as Unread") - #if os(iOS) - AppAssets.readClosedImage - #endif - } - } - - if timelineModel.canMarkIndicatedArticlesAsStarred(timelineItem) { - Button { - timelineModel.markIndicatedArticlesAsStarred(timelineItem) - } label: { - Text("Mark as Starred") - #if os(iOS) - AppAssets.starClosedImage - #endif - } - } - - if timelineModel.canMarkIndicatedArticlesAsUnstarred(timelineItem) { - Button { - timelineModel.markIndicatedArticlesAsUnstarred(timelineItem) - } label: { - Text("Mark as Unstarred") - #if os(iOS) - AppAssets.starOpenImage - #endif - } - } - - if timelineModel.canMarkAboveAsRead(timelineItem) { - Button { - timelineModel.markAboveAsRead(timelineItem) - } label: { - Text("Mark Above as Read") - #if os(iOS) - AppAssets.markAboveAsReadImage - #endif - } - } - - if timelineModel.canMarkBelowAsRead(timelineItem) { - Button { - timelineModel.markBelowAsRead(timelineItem) - } label: { - Text("Mark Below As Read") - #if os(iOS) - AppAssets.markBelowAsReadImage - #endif - } - } - - if timelineModel.canMarkAllAsReadInWebFeed(timelineItem) { - Divider() - Button { - timelineModel.markAllAsReadInWebFeed(timelineItem) - } label: { - Text("Mark All as Read in “\(timelineItem.article.webFeed?.nameForDisplay ?? "")”") - #if os(iOS) - AppAssets.markAllAsReadImage - #endif - } - } - - if timelineModel.canOpenIndicatedArticleInBrowser(timelineItem) { - Divider() - Button { - timelineModel.openIndicatedArticleInBrowser(timelineItem) - } label: { - Text("Open in Browser") - #if os(iOS) - AppAssets.openInBrowserImage - #endif - } - } +// if timelineModel.canMarkIndicatedArticlesAsRead(timelineItem) { +// Button { +// timelineModel.markIndicatedArticlesAsRead(timelineItem) +// } label: { +// Text("Mark as Read") +// #if os(iOS) +// AppAssets.readOpenImage +// #endif +// } +// } +// +// if timelineModel.canMarkIndicatedArticlesAsUnread(timelineItem) { +// Button { +// timelineModel.markIndicatedArticlesAsUnread(timelineItem) +// } label: { +// Text("Mark as Unread") +// #if os(iOS) +// AppAssets.readClosedImage +// #endif +// } +// } +// +// if timelineModel.canMarkIndicatedArticlesAsStarred(timelineItem) { +// Button { +// timelineModel.markIndicatedArticlesAsStarred(timelineItem) +// } label: { +// Text("Mark as Starred") +// #if os(iOS) +// AppAssets.starClosedImage +// #endif +// } +// } +// +// if timelineModel.canMarkIndicatedArticlesAsUnstarred(timelineItem) { +// Button { +// timelineModel.markIndicatedArticlesAsUnstarred(timelineItem) +// } label: { +// Text("Mark as Unstarred") +// #if os(iOS) +// AppAssets.starOpenImage +// #endif +// } +// } +// +// if timelineModel.canMarkAboveAsRead(timelineItem) { +// Button { +// timelineModel.markAboveAsRead(timelineItem) +// } label: { +// Text("Mark Above as Read") +// #if os(iOS) +// AppAssets.markAboveAsReadImage +// #endif +// } +// } +// +// if timelineModel.canMarkBelowAsRead(timelineItem) { +// Button { +// timelineModel.markBelowAsRead(timelineItem) +// } label: { +// Text("Mark Below As Read") +// #if os(iOS) +// AppAssets.markBelowAsReadImage +// #endif +// } +// } +// +// if timelineModel.canMarkAllAsReadInWebFeed(timelineItem) { +// Divider() +// Button { +// timelineModel.markAllAsReadInWebFeed(timelineItem) +// } label: { +// Text("Mark All as Read in “\(timelineItem.article.webFeed?.nameForDisplay ?? "")”") +// #if os(iOS) +// AppAssets.markAllAsReadImage +// #endif +// } +// } +// +// if timelineModel.canOpenIndicatedArticleInBrowser(timelineItem) { +// Divider() +// Button { +// timelineModel.openIndicatedArticleInBrowser(timelineItem) +// } label: { +// Text("Open in Browser") +// #if os(iOS) +// AppAssets.openInBrowserImage +// #endif +// } +// } } } diff --git a/Multiplatform/Shared/Timeline/TimelineModel.swift b/Multiplatform/Shared/Timeline/TimelineModel.swift index 66085b1a1..8fe6304b0 100644 --- a/Multiplatform/Shared/Timeline/TimelineModel.swift +++ b/Multiplatform/Shared/Timeline/TimelineModel.swift @@ -28,345 +28,175 @@ class TimelineModel: ObservableObject, UndoableCommandRunner { @Published var nameForDisplay = "" @Published var selectedArticleIDs = Set() // 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 articles = [Article]() { - didSet { - articleDictionaryNeedsUpdate = true - } - } - - @Published var timelineItems = [TimelineItem]() { - didSet { - timelineItemDictionaryNeedsUpdate = true - } - } + var timelineItemsPublisher: AnyPublisher<[TimelineItem], Never>? + var selectedTimelineItemsPublisher: AnyPublisher<[TimelineItem], Never>? + + var readFilterEnabledTable = [FeedIdentifier: Bool]() - // 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 undoableCommands = [UndoableCommand]() private var cancellables = Set() - private var feeds = [Feed]() - private var fetchSerialNumber = 0 - private let fetchRequestQueue = FetchRequestQueue() - private var exceptionArticleFetcher: ArticleFetcher? - - static let fetchAndMergeArticlesQueue = CoalescingQueue(name: "Fetch and Merge Articles", interval: 0.5, maxInterval: 2.0) - - private var articleDictionaryNeedsUpdate = true - 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 sortDirectionSubject = PassthroughSubject() + private var groupByFeedSubject = PassthroughSubject() - private var groupByFeed = AppDefaults.shared.timelineGroupByFeed { - didSet { - if groupByFeed != oldValue { - sortParametersDidChange() - } - } - } - - func startup() { - subscribeToArticleStatusChanges() + init(delegate: TimelineModelDelegate) { + self.delegate = delegate +// subscribeToArticleStatusChanges() subscribeToUserDefaultsChanges() - subscribeToSelectedFeedChanges() + subscribeToArticleFetchChanges() subscribeToSelectedArticleSelectionChanges() - subscribeToAccountDidDownloadArticles() +// subscribeToAccountDidDownloadArticles() } // MARK: Subscriptions - func subscribeToArticleStatusChanges() { - NotificationCenter.default.publisher(for: .StatusesDidChange).sink { [weak self] note in - guard let self = self, let articleIDs = note.userInfo?[Account.UserInfoKey.articleIDs] as? Set else { - return - } - articleIDs.forEach { articleID in - if let timelineItemIndex = self.idToTimelineItemDictionary[articleID] { - self.timelineItems[timelineItemIndex].updateStatus() - } - } - }.store(in: &cancellables) - } +// func subscribeToArticleStatusChanges() { +// NotificationCenter.default.publisher(for: .StatusesDidChange).sink { [weak self] note in +// guard let self = self, let articleIDs = note.userInfo?[Account.UserInfoKey.articleIDs] as? Set else { +// return +// } +// articleIDs.forEach { articleID in +// if let timelineItemIndex = self.idToTimelineItemDictionary[articleID] { +// self.timelineItems[timelineItemIndex].updateStatus() +// } +// } +// }.store(in: &cancellables) +// } - func subscribeToAccountDidDownloadArticles() { - NotificationCenter.default.publisher(for: .AccountDidDownloadArticles).sink { [weak self] note in - guard let self = self, let feeds = note.userInfo?[Account.UserInfoKey.webFeeds] as? Set else { - return - } - if self.anySelectedFeedIntersection(with: feeds) || self.anySelectedFeedIsPseudoFeed() { - self.queueFetchAndMergeArticles() - } - }.store(in: &cancellables) - } +// func subscribeToAccountDidDownloadArticles() { +// NotificationCenter.default.publisher(for: .AccountDidDownloadArticles).sink { [weak self] note in +// guard let self = self, let feeds = note.userInfo?[Account.UserInfoKey.webFeeds] as? Set else { +// return +// } +// if self.anySelectedFeedIntersection(with: feeds) || self.anySelectedFeedIsPseudoFeed() { +// self.queueFetchAndMergeArticles() +// } +// }.store(in: &cancellables) +// } func subscribeToUserDefaultsChanges() { - NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification).sink { [weak self] _ in - self?.sortDirection = AppDefaults.shared.timelineSortDirection - self?.groupByFeed = AppDefaults.shared.timelineGroupByFeed + let kickStartNote = Notification(name: Notification.Name("Kick Start")) + NotificationCenter.default.publisher(for: UserDefaults.didChangeNotification) + .prepend(kickStartNote) + .sink { [weak self] _ in + self?.sortDirectionSubject.send(AppDefaults.shared.timelineSortDirection) + self?.groupByFeedSubject.send(AppDefaults.shared.timelineGroupByFeed) }.store(in: &cancellables) } - func subscribeToSelectedFeedChanges() { + func subscribeToArticleFetchChanges() { guard let selectedFeedsPublisher = delegate?.selectedFeedsPublisher else { return } - selectedFeedsPublisher.sink { [weak self] feeds in - guard let self = self else { return } - self.feeds = feeds - self.fetchArticles() - }.store(in: &cancellables) + let sortDirectionPublisher = sortDirectionSubject.removeDuplicates() + let groupByPublisher = groupByFeedSubject.removeDuplicates() + + timelineItemsPublisher = selectedFeedsPublisher + .map { [weak self] feeds -> Set
in + return self?.fetchArticles(feeds: feeds) ?? Set
() + } + .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() { - $selectedArticleIDs.map { [weak self] articleIDs in - return articleIDs.compactMap { self?.idToArticleDictionary[$0] } - } - .assign(to: &$selectedArticles) - - $selectedArticleID.compactMap { [weak self] articleID in - if let articleID = articleID, let article = self?.idToArticleDictionary[articleID] { - return [article] - } else { - return nil - } - } - .assign(to: &$selectedArticles) - - // Assign the selected timeline items - $selectedArticles.compactMap { [weak self] selectedArticles in - return selectedArticles.compactMap { - if let index = self?.idToTimelineItemDictionary[$0.articleID] { - return self?.timelineItems[index] - } - return nil - } - }.assign(to: &$selectedTimelineItems) - - // Automatically mark a selected record as read - $selectedArticles - .filter { $0.count == 1 } - .compactMap { $0.first } - .filter { !$0.status.read } - .sink { markArticles(Set([$0]), statusKey: .read, flag: true) } - .store(in: &cancellables) +// $selectedArticleIDs.map { [weak self] articleIDs in +// return articleIDs.compactMap { self?.idToArticleDictionary[$0] } +// } +// .assign(to: &$selectedArticles) +// +// $selectedArticleID.compactMap { [weak self] articleID in +// if let articleID = articleID, let article = self?.idToArticleDictionary[articleID] { +// return [article] +// } else { +// return nil +// } +// } +// .assign(to: &$selectedArticles) +// +// // Assign the selected timeline items +// $selectedArticles.compactMap { [weak self] selectedArticles in +// return selectedArticles.compactMap { +// if let index = self?.idToTimelineItemDictionary[$0.articleID] { +// return self?.timelineItems[index] +// } +// return nil +// } +// }.assign(to: &$selectedTimelineItems) +// +// // Automatically mark a selected record as read +// $selectedArticles +// .filter { $0.count == 1 } +// .compactMap { $0.first } +// .filter { !$0.status.read } +// .sink { markArticles(Set([$0]), statusKey: .read, flag: true) } +// .store(in: &cancellables) } // MARK: API func toggleReadFilter() { - guard let filter = isReadFiltered, let feedID = feeds.first?.feedID else { return } - readFilterEnabledTable[feedID] = !filter - isReadFiltered = !filter - self.fetchArticles() +// guard let filter = isReadFiltered, let feedID = feeds.first?.feedID else { return } +// readFilterEnabledTable[feedID] = !filter +// isReadFiltered = !filter +// self.fetchArticles() } func toggleReadStatusForSelectedArticles() { - guard !selectedArticles.isEmpty else { - return - } - if selectedArticles.anyArticleIsUnread() { - markSelectedArticlesAsRead() - } else { - 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) +// guard !selectedArticles.isEmpty else { +// return +// } +// if selectedArticles.anyArticleIsUnread() { +// markSelectedArticlesAsRead() +// } else { +// markSelectedArticlesAsUnread() +// } } @discardableResult func goToNextUnread() -> Bool { - var startIndex: Int - if let firstArticle = selectedArticles.first, let index = timelineItems.firstIndex(where: { $0.article == firstArticle }) { - startIndex = index - } else { - startIndex = 0 - } - - for i in startIndex.. Article? { - return idToArticleDictionary[articleID] + return nil +// return idToArticleDictionary[articleID] } func findPrevArticle(_ article: Article) -> Article? { - guard let index = articles.firstIndex(of: article), index > 0 else { - return nil - } - return articles[index - 1] + return nil +// 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] + return nil +// guard let index = articles.firstIndex(of: article), index + 1 != articles.count else { +// return nil +// } +// return articles[index + 1] } func selectArticle(_ article: Article) { @@ -379,30 +209,6 @@ class TimelineModel: ObservableObject, UndoableCommandRunner { 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) { if let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articles, statusKey: statusKey, flag: flag, undoManager: undoManager) { runCommand(markReadCommand) @@ -411,227 +217,92 @@ private extension TimelineModel { } } - func select(_ articleID: String) { - selectedArticleIDs = Set([articleID]) - selectedArticleID = articleID - } - // 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 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() - } +// performBlockAndRestoreSelection { +// articles = articles.sortedByDate(sortDirection ? .orderedDescending : .orderedAscending, groupByFeed: groupByFeed) +// rebuildTimelineItems() +// } } func performBlockAndRestoreSelection(_ block: (() -> Void)) { - let savedArticleIDs = selectedArticleIDs - let savedArticleID = selectedArticleID +// let savedArticleIDs = selectedArticleIDs +// let savedArticleID = selectedArticleID block() - selectedArticleIDs = savedArticleIDs - 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 +// selectedArticleIDs = savedArticleIDs +// selectedArticleID = savedArticleID } // MARK: Article Fetching - func fetchArticles() { - replaceArticles(with: Set
()) - - 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 there’s no async delay, - // so that the entire display refreshes at once. - // It’s 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
{ - cancelPendingAsyncFetches() - let articleFetchers = representedObjects.compactMap{ $0 as? ArticleFetcher } - if articleFetchers.isEmpty { + func fetchArticles(feeds: [Feed]) -> Set
{ + if feeds.isEmpty { return Set
() } var fetchedArticles = Set
() - for articleFetcher in articleFetchers { + for feed in feeds { if isReadFiltered ?? true { - if let articles = try? articleFetcher.fetchUnreadArticles() { + if let articles = try? feed.fetchUnreadArticles() { fetchedArticles.formUnion(articles) } } else { - if let articles = try? articleFetcher.fetchArticles() { + if let articles = try? feed.fetchArticles() { fetchedArticles.formUnion(articles) } } } - return fetchedArticles - } - - func fetchUnsortedArticlesAsync(for representedObjects: [Any], completion: @escaping ArticleSetBlock) { - // The callback will *not* be called if the fetch is no longer relevant — that is, - // if it’s been superseded by a newer fetch, or the timeline was emptied, etc., it won’t 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) - } + return fetchedArticles + } - func replaceArticles(with unsortedArticles: Set
) { - articles = Array(unsortedArticles).sortedByDate(sortDirection ? .orderedDescending : .orderedAscending, groupByFeed: groupByFeed) - rebuildTimelineItems() - - selectedArticleIDs = Set() - selectedArticleID = nil - - if isSelectNextUnread { - goToNextUnread() - isSelectNextUnread = false - } - - // TODO: Update unread counts and other item done in didSet on AppKit - } - - func rebuildTimelineItems() { + func buildTimelineItems(articles: [Article]) -> [TimelineItem] { var items = [TimelineItem]() for (index, article) in articles.enumerated() { items.append(TimelineItem(index: index, article: article)) } - timelineItems = items + return items } - func queueFetchAndMergeArticles() { - TimelineModel.fetchAndMergeArticlesQueue.add(self, #selector(fetchAndMergeArticles)) - } - - @objc func fetchAndMergeArticles() { - - fetchUnsortedArticlesAsync(for: feeds) { [weak self] (unsortedArticles) in - // Merge articles by articleID. For any unique articleID in current articles, add to unsortedArticles. - guard let strongSelf = self else { - return - } - let unsortedArticleIDs = unsortedArticles.articleIDs() - var updatedArticles = unsortedArticles - for article in strongSelf.articles { - if !unsortedArticleIDs.contains(article.articleID) { - updatedArticles.insert(article) - } - } - strongSelf.performBlockAndRestoreSelection { - strongSelf.replaceArticles(with: updatedArticles) - } - } - } - - func anySelectedFeedIsPseudoFeed() -> Bool { - return feeds.contains(where: { $0 is PseudoFeed}) - } - - func anySelectedFeedIntersection(with webFeeds: Set) -> 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 - } +// func anySelectedFeedIsPseudoFeed() -> Bool { +// return feeds.contains(where: { $0 is PseudoFeed}) +// } +// +// func anySelectedFeedIntersection(with webFeeds: Set) -> 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 +// } } diff --git a/Multiplatform/Shared/Timeline/TimelineView.swift b/Multiplatform/Shared/Timeline/TimelineView.swift index 9f242ac1b..dd24a2141 100644 --- a/Multiplatform/Shared/Timeline/TimelineView.swift +++ b/Multiplatform/Shared/Timeline/TimelineView.swift @@ -11,6 +11,7 @@ import SwiftUI struct TimelineView: View { @EnvironmentObject private var timelineModel: TimelineModel + @State private var timelineItems = [TimelineItem]() @State private var timelineItemFrames = [String: CGRect]() @ViewBuilder var body: some View { @@ -37,7 +38,7 @@ struct TimelineView: View { .help(timelineModel.isReadFiltered ?? false ? "Show Read Articles" : "Filter Read Articles") } 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) TimelineItemView(selected: selected, width: geometryReaderProxy.size.width, 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)) #else ScrollViewReader { scrollViewProxy in diff --git a/Multiplatform/macOS/Article/WebViewController.swift b/Multiplatform/macOS/Article/WebViewController.swift index 7fefe51f8..eaf2cd04f 100644 --- a/Multiplatform/macOS/Article/WebViewController.swift +++ b/Multiplatform/macOS/Article/WebViewController.swift @@ -80,9 +80,9 @@ class WebViewController: NSViewController { statusBarView.heightAnchor.constraint(equalToConstant: 20) ]) - selectedArticlesCancellable = sceneModel?.timelineModel.$selectedArticles.sink { [weak self] articles in - self?.articles = articles - } +// selectedArticlesCancellable = sceneModel?.timelineModel.$selectedArticles.sink { [weak self] articles in +// self?.articles = articles +// } } // MARK: Notifications