// // TimelineViewController.swift // NetNewsWire // // Created by Brent Simmons on 7/26/15. // Copyright © 2015 Ranchero Software, LLC. All rights reserved. // import Foundation import RSCore import Articles import Account import os.log protocol TimelineDelegate: AnyObject { func timelineSelectionDidChange(_: TimelineViewController, selectedArticles: [Article]?) func timelineRequestedFeedSelection(_: TimelineViewController, feed: Feed) func timelineInvalidatedRestorationState(_: TimelineViewController) } enum TimelineShowFeedName { case none case byline case feed } final class TimelineViewController: NSViewController, UndoableCommandRunner, UnreadCountProvider { @IBOutlet var tableView: TimelineTableView! private var readFilterEnabledTable = [SidebarItemIdentifier: Bool]() var isReadFiltered: Bool? { guard representedObjects?.count == 1, let timelineFeed = representedObjects?.first as? SidebarItem else { return nil } guard timelineFeed.defaultReadFilterType != .alwaysRead else { return nil } if let feedID = timelineFeed.sidebarItemID, let readFilterEnabled = readFilterEnabledTable[feedID] { return readFilterEnabled } else { return timelineFeed.defaultReadFilterType == .read } } var isCleanUpAvailable: Bool { let isEligibleForCleanUp: Bool? if representedObjects?.count == 1, let timelineFeed = representedObjects?.first as? SidebarItem, timelineFeed.defaultReadFilterType == .alwaysRead { isEligibleForCleanUp = true } else { isEligibleForCleanUp = isReadFiltered } guard isEligibleForCleanUp ?? false else { return false } let readSelectedCount = selectedArticles.filter({ $0.status.read }).count let readArticleCount = articles.count - unreadCount let availableToCleanCount = readArticleCount - readSelectedCount return availableToCleanCount > 0 } var representedObjects: [AnyObject]? { didSet { if !representedObjectArraysAreEqual(oldValue, representedObjects) { unreadCount = 0 selectionDidChange(nil) if showsSearchResults { fetchAndReplaceArticlesAsync() } else { fetchAndReplaceArticlesSync() if articles.count > 0 { tableView.scrollRowToVisible(0) } updateUnreadCount() } } } } weak var delegate: TimelineDelegate? var sharingServiceDelegate: NSSharingServiceDelegate? var showsSearchResults = false var selectedArticles: [Article] { return Array(articles.articlesForIndexes(tableView.selectedRowIndexes)) } var hasAtLeastOneSelectedArticle: Bool { return tableView.selectedRow != -1 } var articles = ArticleArray() { didSet { defer { updateUnreadCount() } if articles == oldValue { return } if articles.representSameArticlesInSameOrder(as: oldValue) { // When the array is the same — same articles, same order — // but some data in some of the articles may have changed. // Just reload visible cells in this case: don’t call reloadData. articleRowMap = [String: [Int]]() reloadVisibleCells() return } if let representedObjects = representedObjects, representedObjects.count == 1 && representedObjects.first is Feed { showFeedNames = { for article in articles { if !article.byline().isEmpty { return .byline } } return .none }() } else { showFeedNames = .feed } articleRowMap = [String: [Int]]() tableView.reloadData() } } var unreadCount: Int = 0 { didSet { if unreadCount != oldValue { postUnreadCountDidChangeNotification() } } } var undoableCommands = [UndoableCommand]() private var fetchSerialNumber = 0 private let fetchRequestQueue = FetchRequestQueue() private var exceptionArticleFetcher: ArticleFetcher? private var articleRowMap = [String: [Int]]() // articleID: rowIndex private var cellAppearance: TimelineCellAppearance! private var cellAppearanceWithIcon: TimelineCellAppearance! private var showFeedNames: TimelineShowFeedName = .none { didSet { if showFeedNames != oldValue { updateShowIcons() updateTableViewRowHeight() reloadVisibleCells() } } } private var showIcons = false private var currentRowHeight: CGFloat = 0.0 private var didRegisterForNotifications = false static let fetchAndMergeArticlesQueue = CoalescingQueue(name: "Fetch and Merge Articles", interval: 0.5, maxInterval: 2.0) private var sortDirection = AppDefaults.shared.timelineSortDirection { didSet { if sortDirection != oldValue { sortParametersDidChange() } } } private var groupByFeed = AppDefaults.shared.timelineGroupByFeed { didSet { if groupByFeed != oldValue { sortParametersDidChange() } } } private var fontSize: FontSize = AppDefaults.shared.timelineFontSize { didSet { if fontSize != oldValue { fontSizeDidChange() } } } private var previouslySelectedArticles: ArticleArray? private var oneSelectedArticle: Article? { return selectedArticles.count == 1 ? selectedArticles.first : nil } private let keyboardDelegate = TimelineKeyboardDelegate() private var timelineShowsSeparatorsObserver: NSKeyValueObservation? convenience init(delegate: TimelineDelegate) { self.init(nibName: "TimelineTableView", bundle: nil) self.delegate = delegate } override func viewDidLoad() { cellAppearance = TimelineCellAppearance(showIcon: false, fontSize: fontSize) cellAppearanceWithIcon = TimelineCellAppearance(showIcon: true, fontSize: fontSize) updateRowHeights() tableView.rowHeight = currentRowHeight tableView.target = self tableView.doubleAction = #selector(openArticleInBrowser(_:)) tableView.setDraggingSourceOperationMask(.copy, forLocal: false) tableView.keyboardDelegate = keyboardDelegate tableView.style = .inset if !didRegisterForNotifications { NotificationCenter.default.addObserver(self, selector: #selector(statusesDidChange(_:)), name: .StatusesDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(feedIconDidBecomeAvailable(_:)), name: .feedIconDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(avatarDidBecomeAvailable(_:)), name: .AvatarDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(faviconDidBecomeAvailable(_:)), name: .FaviconDidBecomeAvailable, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(accountDidDownloadArticles(_:)), name: .AccountDidDownloadArticles, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(accountStateDidChange(_:)), name: .AccountStateDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(accountsDidChange(_:)), name: .UserDidAddAccount, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(accountsDidChange(_:)), name: .UserDidDeleteAccount, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(containerChildrenDidChange(_:)), name: .ChildrenDidChange, object: nil) NotificationCenter.default.addObserver(self, selector: #selector(userDefaultsDidChange(_:)), name: UserDefaults.didChangeNotification, object: nil) didRegisterForNotifications = true } } override func viewDidAppear() { sharingServiceDelegate = SharingServiceDelegate(self.view.window) } // MARK: - API func markAllAsRead(completion: (() -> Void)? = nil) { guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articles, markingRead: true, undoManager: undoManager, completion: completion) else { return } runCommand(markReadCommand) } func canMarkAllAsRead() -> Bool { return articles.canMarkAllAsRead() } func canMarkSelectedArticlesAsRead() -> Bool { return selectedArticles.canMarkAllAsRead() } func representsThisObjectOnly(_ object: AnyObject) -> Bool { guard let representedObjects = representedObjects else { return false } if representedObjects.count != 1 { return false } return representedObjects.first! === object } func cleanUp() { fetchAndReplacePreservingSelection() } func toggleReadFilter() { guard let filter = isReadFiltered, let feedID = (representedObjects?.first as? SidebarItem)?.sidebarItemID else { return } readFilterEnabledTable[feedID] = !filter delegate?.timelineInvalidatedRestorationState(self) fetchAndReplacePreservingSelection() } // MARK: State Restoration func saveState(to state: inout [AnyHashable: Any]) { state[UserInfoKey.readArticlesFilterStateKeys] = readFilterEnabledTable.keys.compactMap { $0.userInfo } state[UserInfoKey.readArticlesFilterStateValues] = readFilterEnabledTable.values.compactMap( { $0 }) if selectedArticles.count == 1 { state[UserInfoKey.articlePath] = selectedArticles.first!.pathUserInfo } } func restoreState(from state: [AnyHashable: Any]) { guard let readArticlesFilterStateKeys = state[UserInfoKey.readArticlesFilterStateKeys] as? [[AnyHashable: AnyHashable]], let readArticlesFilterStateValues = state[UserInfoKey.readArticlesFilterStateValues] as? [Bool] else { return } for i in 0..= tableMaxIndex { tableView.scrollTo(row: tableMaxIndex, extraHeight: 0) } tableView.selectRow(nextRowIndex) let followingRowIndex = nextRowIndex + 1 if followingRowIndex > tableMaxIndex { return } tableView.scrollToRowIfNotVisible(followingRowIndex) } func toggleReadStatusForSelectedArticles() { // If any one of the selected articles is unread, then mark them as read. // If all articles are read, then mark them as unread them. let commandStatus = markReadCommandStatus() let markingRead: Bool switch commandStatus { case .canMark: markingRead = true case .canUnmark: markingRead = false case .canDoNothing: return } guard let undoManager = undoManager, let markStarredCommand = MarkStatusCommand(initialArticles: selectedArticles, markingRead: markingRead, undoManager: undoManager) else { return } runCommand(markStarredCommand) } func toggleStarredStatusForSelectedArticles() { // If any one of the selected articles is not starred, then star them. // If all articles are starred, then unstar them. let commandStatus = markStarredCommandStatus() let starring: Bool switch commandStatus { case .canMark: starring = true case .canUnmark: starring = false case .canDoNothing: return } guard let undoManager = undoManager, let markStarredCommand = MarkStatusCommand(initialArticles: selectedArticles, markingStarred: starring, undoManager: undoManager) else { return } runCommand(markStarredCommand) } func markStarredCommandStatus() -> MarkCommandValidationStatus { return MarkCommandValidationStatus.statusFor(selectedArticles) { $0.anyArticleIsUnstarred() } } func markReadCommandStatus() -> MarkCommandValidationStatus { let articles = selectedArticles if articles.anyArticleIsUnread() { return .canMark } if articles.anyArticleIsReadAndCanMarkUnread() { return .canUnmark } return .canDoNothing } func markOlderArticlesRead() { markOlderArticlesRead(selectedArticles) } func markAboveArticlesRead() { markAboveArticlesRead(selectedArticles) } func markBelowArticlesRead() { markBelowArticlesRead(selectedArticles) } func canMarkAboveArticlesAsRead() -> Bool { guard let first = selectedArticles.first else { return false } return articles.articlesAbove(article: first).canMarkAllAsRead() } func canMarkBelowArticlesAsRead() -> Bool { guard let last = selectedArticles.last else { return false } return articles.articlesBelow(article: last).canMarkAllAsRead() } func markOlderArticlesRead(_ selectedArticles: [Article]) { // Mark articles older than the selectedArticles(s) as read. var cutoffDate: Date? for article in selectedArticles { if cutoffDate == nil { cutoffDate = article.logicalDatePublished } else if cutoffDate! > article.logicalDatePublished { cutoffDate = article.logicalDatePublished } } if cutoffDate == nil { return } let articlesToMark = articles.filter { $0.logicalDatePublished < cutoffDate! } if articlesToMark.isEmpty { return } guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articlesToMark, markingRead: true, undoManager: undoManager) else { return } runCommand(markReadCommand) } func markAboveArticlesRead(_ selectedArticles: [Article]) { guard let first = selectedArticles.first else { return } let articlesToMark = articles.articlesAbove(article: first) guard !articlesToMark.isEmpty else { return } guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articlesToMark, markingRead: true, undoManager: undoManager) else { return } runCommand(markReadCommand) } func markBelowArticlesRead(_ selectedArticles: [Article]) { guard let last = selectedArticles.last else { return } let articlesToMark = articles.articlesBelow(article: last) guard !articlesToMark.isEmpty else { return } guard let undoManager = undoManager, let markReadCommand = MarkStatusCommand(initialArticles: articlesToMark, markingRead: true, undoManager: undoManager) else { return } runCommand(markReadCommand) } // MARK: - Navigation func goToDeepLink(for userInfo: [AnyHashable: Any]) { guard let articleID = userInfo[ArticlePathKey.articleID] as? String else { return } if isReadFiltered ?? false { if let accountName = userInfo[ArticlePathKey.accountName] as? String, let account = AccountManager.shared.existingActiveAccount(forDisplayName: accountName) { exceptionArticleFetcher = SingleArticleFetcher(account: account, articleID: articleID) fetchAndReplaceArticlesSync() } } guard let ix = articles.firstIndex(where: { $0.articleID == articleID }) else { return } NSCursor.setHiddenUntilMouseMoves(true) tableView.selectRow(ix) tableView.scrollTo(row: ix) } func goToNextUnread(wrappingToTop wrapping: Bool = false) { guard let ix = indexOfNextUnreadArticle() else { return } NSCursor.setHiddenUntilMouseMoves(true) tableView.selectRow(ix) tableView.scrollTo(row: ix) } func canGoToNextUnread(wrappingToTop wrapping: Bool = false) -> Bool { guard let _ = indexOfNextUnreadArticle(wrappingToTop: wrapping) else { return false } return true } func indexOfNextUnreadArticle(wrappingToTop wrapping: Bool = false) -> Int? { return articles.rowOfNextUnreadArticle(tableView.selectedRow, wrappingToTop: wrapping) } func focus() { guard let window = tableView.window else { return } window.makeFirstResponderUnlessDescendantIsFirstResponder(tableView) if !hasAtLeastOneSelectedArticle && articles.count > 0 { tableView.selectRowAndScrollToVisible(0) } } // MARK: - Notifications @objc func statusesDidChange(_ note: Notification) { guard let articleIDs = note.userInfo?[Account.UserInfoKey.articleIDs] as? Set else { return } reloadVisibleCells(for: articleIDs) updateUnreadCount() } @objc func feedIconDidBecomeAvailable(_ note: Notification) { guard showIcons, let feed = note.userInfo?[UserInfoKey.feed] as? Feed else { return } let indexesToReload = tableView.indexesOfAvailableRowsPassingTest { (row) -> Bool in guard let article = articles.articleAtRow(row) else { return false } return feed == article.feed } if let indexesToReload = indexesToReload { reloadCells(for: indexesToReload) } } @objc func avatarDidBecomeAvailable(_ note: Notification) { guard showIcons, let avatarURL = note.userInfo?[UserInfoKey.url] as? String else { return } let indexesToReload = tableView.indexesOfAvailableRowsPassingTest { (row) -> Bool in guard let article = articles.articleAtRow(row), let authors = article.authors, !authors.isEmpty else { return false } for author in authors { if author.avatarURL == avatarURL { return true } } return false } if let indexesToReload = indexesToReload { reloadCells(for: indexesToReload) } } @objc func faviconDidBecomeAvailable(_ note: Notification) { if showIcons { queueReloadAvailableCells() } } @objc func accountDidDownloadArticles(_ note: Notification) { guard let feeds = note.userInfo?[Account.UserInfoKey.feeds] as? Set else { return } let shouldFetchAndMergeArticles = representedObjectsContainsAnyFeed(feeds) || representedObjectsContainsAnyPseudoFeed() if shouldFetchAndMergeArticles { queueFetchAndMergeArticles() } } @objc func accountStateDidChange(_ note: Notification) { if representedObjectsContainsAnyPseudoFeed() { fetchAndReplaceArticlesAsync() } } @objc func accountsDidChange(_ note: Notification) { if representedObjectsContainsAnyPseudoFeed() { fetchAndReplaceArticlesAsync() } } @objc func containerChildrenDidChange(_ note: Notification) { if representedObjectsContainsAnyPseudoFeed() || representedObjectsContainAnyFolder() { fetchAndReplaceArticlesAsync() } } @objc func userDefaultsDidChange(_ note: Notification) { self.fontSize = AppDefaults.shared.timelineFontSize self.sortDirection = AppDefaults.shared.timelineSortDirection self.groupByFeed = AppDefaults.shared.timelineGroupByFeed } // MARK: - Reloading Data private func cellForRowView(_ rowView: NSView) -> NSView? { for oneView in rowView.subviews where oneView is TimelineTableCellView { return oneView } return nil } private func reloadVisibleCells() { guard let indexes = tableView.indexesOfAvailableRows() else { return } reloadVisibleCells(for: indexes) } private func reloadVisibleCells(for articles: [Article]) { reloadVisibleCells(for: Set(articles.articleIDs())) } private func reloadVisibleCells(for articles: Set
) { reloadVisibleCells(for: articles.articleIDs()) } private func reloadVisibleCells(for articleIDs: Set) { if articleIDs.isEmpty { return } let indexes = indexesForArticleIDs(articleIDs) reloadVisibleCells(for: indexes) } private func reloadVisibleCells(for indexes: IndexSet) { let indexesToReload = tableView.indexesOfAvailableRowsPassingTest { (row) -> Bool in return indexes.contains(row) } if let indexesToReload = indexesToReload { reloadCells(for: indexesToReload) } } private func reloadCells(for indexes: IndexSet) { if indexes.isEmpty { return } tableView.reloadData(forRowIndexes: indexes, columnIndexes: NSIndexSet(index: 0) as IndexSet) } // MARK: - Cell Configuring private func calculateRowHeight() -> CGFloat { let prototypeID = "prototype" let status = ArticleStatus(articleID: prototypeID, read: false, starred: false, dateArrived: Date()) let prototypeArticle = Article(accountID: prototypeID, articleID: prototypeID, feedID: prototypeID, uniqueID: prototypeID, title: Constants.prototypeText, contentHTML: nil, contentText: nil, url: nil, externalURL: nil, summary: nil, imageURL: nil, datePublished: nil, dateModified: nil, authors: nil, status: status) let prototypeCellData = TimelineCellData(article: prototypeArticle, showFeedName: .feed, feedName: "Prototype Feed Name", byline: nil, iconImage: nil, showIcon: false, featuredImage: nil) let height = TimelineCellLayout.height(for: 100, cellData: prototypeCellData, appearance: cellAppearance) return height } private func updateRowHeights() { currentRowHeight = calculateRowHeight() updateTableViewRowHeight() } @objc func fetchAndMergeArticles() { guard let representedObjects = representedObjects else { return } fetchUnsortedArticlesAsync(for: representedObjects) { [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) } } } } // MARK: - NSMenuDelegate extension TimelineViewController: NSMenuDelegate { public func menuNeedsUpdate(_ menu: NSMenu) { menu.removeAllItems() guard let contextualMenu = contextualMenuForClickedRows() else { return } menu.takeItems(from: contextualMenu) } } // MARK: - NSUserInterfaceValidations extension TimelineViewController: NSUserInterfaceValidations { func validateUserInterfaceItem(_ item: NSValidatedUserInterfaceItem) -> Bool { if item.action == #selector(openArticleInBrowser(_:)) { if let item = item as? NSMenuItem, item.keyEquivalentModifierMask.contains(.shift) { item.title = Browser.titleForOpenInBrowserInverted } let currentLink = oneSelectedArticle?.preferredLink return currentLink != nil } if item.action == #selector(copy(_:)) { return NSPasteboard.general.canCopyAtLeastOneObject(selectedArticles) } return true } } // MARK: - NSTableViewDataSource extension TimelineViewController: NSTableViewDataSource { func numberOfRows(in tableView: NSTableView) -> Int { return articles.count } func tableView(_ tableView: NSTableView, objectValueFor tableColumn: NSTableColumn?, row: Int) -> Any? { return articles.articleAtRow(row) ?? nil } func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> NSPasteboardWriting? { guard let article = articles.articleAtRow(row) else { return nil } return ArticlePasteboardWriter(article: article) } } // MARK: - NSTableViewDelegate extension TimelineViewController: NSTableViewDelegate { private static let rowViewIdentifier = NSUserInterfaceItemIdentifier(rawValue: "timelineRow") func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { if let rowView: TimelineTableRowView = tableView.makeView(withIdentifier: TimelineViewController.rowViewIdentifier, owner: nil) as? TimelineTableRowView { return rowView } let rowView = TimelineTableRowView() rowView.identifier = TimelineViewController.rowViewIdentifier return rowView } private static let timelineCellIdentifier = NSUserInterfaceItemIdentifier(rawValue: "timelineCell") func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { func configure(_ cell: TimelineTableCellView) { cell.cellAppearance = showIcons ? cellAppearanceWithIcon : cellAppearance if let article = articles.articleAtRow(row) { configureTimelineCell(cell, article: article) } else { makeTimelineCellEmpty(cell) } } if let cell = tableView.makeView(withIdentifier: TimelineViewController.timelineCellIdentifier, owner: nil) as? TimelineTableCellView { configure(cell) return cell } let cell = TimelineTableCellView() cell.identifier = TimelineViewController.timelineCellIdentifier configure(cell) return cell } func tableViewSelectionDidChange(_ notification: Notification) { if selectedArticles.isEmpty { selectionDidChange(nil) return } if selectedArticles.count == 1 { let article = selectedArticles.first! if !article.status.read { markArticles(Set([article]), statusKey: .read, flag: true) } } selectionDidChange(selectedArticles) } private func selectionDidChange(_ selectedArticles: ArticleArray?) { guard selectedArticles != previouslySelectedArticles else { return } previouslySelectedArticles = selectedArticles delegate?.timelineSelectionDidChange(self, selectedArticles: selectedArticles) delegate?.timelineInvalidatedRestorationState(self) } private func configureTimelineCell(_ cell: TimelineTableCellView, article: Article) { cell.objectValue = article let iconImage = article.iconImage() cell.cellData = TimelineCellData(article: article, showFeedName: showFeedNames, feedName: article.feed?.nameForDisplay, byline: article.byline(), iconImage: iconImage, showIcon: showIcons, featuredImage: nil) } private func iconFor(_ article: Article) -> IconImage? { if !showIcons { return nil } return IconImageCache.shared.imageForArticle(article) } private func avatarForAuthor(_ author: Author) -> IconImage? { return AuthorAvatarDownloader.shared.image(for: author) } private func makeTimelineCellEmpty(_ cell: TimelineTableCellView) { cell.objectValue = nil cell.cellData = TimelineCellData() } private func toggleArticleRead(_ article: Article) { guard let undoManager = undoManager, let markUnreadCommand = MarkStatusCommand(initialArticles: [article], markingRead: !article.status.read, undoManager: undoManager) else { return } self.runCommand(markUnreadCommand) } private func toggleArticleStarred(_ article: Article) { guard let undoManager = undoManager, let markUnreadCommand = MarkStatusCommand(initialArticles: [article], markingStarred: !article.status.starred, undoManager: undoManager) else { return } self.runCommand(markUnreadCommand) } func tableView(_ tableView: NSTableView, rowActionsForRow row: Int, edge: NSTableView.RowActionEdge) -> [NSTableViewRowAction] { guard let article = articles.articleAtRow(row) else { return [] } switch edge { case .leading: let action = NSTableViewRowAction(style: .regular, title: article.status.read ? "Unread" : "Read") { (_, _) in self.toggleArticleRead(article) tableView.rowActionsVisible = false } action.image = article.status.read ? AppAssets.swipeMarkUnreadImage : AppAssets.swipeMarkReadImage return [action] case .trailing: let action = NSTableViewRowAction(style: .regular, title: article.status.starred ? "Unstar" : "Star") { (_, _) in self.toggleArticleStarred(article) tableView.rowActionsVisible = false } action.backgroundColor = AppAssets.starColor action.image = article.status.starred ? AppAssets.swipeMarkUnstarredImage : AppAssets.swipeMarkStarredImage return [action] @unknown default: os_log(.error, "Unknown table row edge: %ld", edge.rawValue) } return [] } } // MARK: - Private private extension TimelineViewController { func fetchAndReplacePreservingSelection() { if let article = oneSelectedArticle, let account = article.account { exceptionArticleFetcher = SingleArticleFetcher(account: account, articleID: article.articleID) } performBlockAndRestoreSelection { fetchAndReplaceArticlesSync() } } @objc func reloadAvailableCells() { if let indexesToReload = tableView.indexesOfAvailableRows() { reloadCells(for: indexesToReload) } } func updateUnreadCount() { var count = 0 for article in articles { if !article.status.read { count += 1 } } unreadCount = count } func queueReloadAvailableCells() { CoalescingQueue.standard.add(self, #selector(reloadAvailableCells)) } func updateTableViewRowHeight() { tableView.rowHeight = currentRowHeight } func updateShowIcons() { if showFeedNames == .feed { self.showIcons = true return } if showFeedNames == .none { self.showIcons = false return } for article in articles { if let authors = article.authors { for author in authors { if author.avatarURL != nil { self.showIcons = true return } } } } self.showIcons = false } func emptyTheTimeline() { if !articles.isEmpty { articles = [Article]() } } func sortParametersDidChange() { performBlockAndRestoreSelection { let unsortedArticles = Set(articles) replaceArticles(with: unsortedArticles) } } func selectedArticleIDs() -> [String] { return selectedArticles.articleIDs() } func restoreSelection(_ articleIDs: [String]) { selectArticles(articleIDs) if tableView.selectedRow != -1 { tableView.scrollRowToVisible(tableView.selectedRow) } } func performBlockAndRestoreSelection(_ block: (() -> Void)) { let savedSelection = selectedArticleIDs() block() restoreSelection(savedSelection) } func rows(for articleID: String) -> [Int]? { updateArticleRowMapIfNeeded() return articleRowMap[articleID] } func rows(for article: Article) -> [Int]? { return rows(for: article.articleID) } func updateArticleRowMap() { var rowMap = [String: [Int]]() var index = 0 for article in articles { if var indexes = rowMap[article.articleID] { indexes.append(index) rowMap[article.articleID] = indexes } else { rowMap[article.articleID] = [index] } index += 1 } articleRowMap = rowMap } func updateArticleRowMapIfNeeded() { if articleRowMap.isEmpty { updateArticleRowMap() } } func indexesForArticleIDs(_ articleIDs: Set) -> IndexSet { var indexes = IndexSet() for articleID in articleIDs { guard let rowsIndex = rows(for: articleID) else { continue } for rowIndex in rowsIndex { indexes.insert(rowIndex) } } return indexes } // MARK: - Appearance Change private func fontSizeDidChange() { cellAppearance = TimelineCellAppearance(showIcon: false, fontSize: fontSize) cellAppearanceWithIcon = TimelineCellAppearance(showIcon: true, fontSize: fontSize) updateRowHeights() performBlockAndRestoreSelection { tableView.reloadData() } } // MARK: - Fetching Articles 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. cancelPendingAsyncFetches() guard var representedObjects = representedObjects else { emptyTheTimeline() return } if exceptionArticleFetcher != nil { representedObjects.append(exceptionArticleFetcher as AnyObject) exceptionArticleFetcher = nil } let fetchedArticles = fetchUnsortedArticlesSync(for: representedObjects) replaceArticles(with: fetchedArticles) } func fetchAndReplaceArticlesAsync() { // To be called when we need to do an entire fetch, but an async delay is okay. // Example: we have the Today feed selected, and the calendar day just changed. cancelPendingAsyncFetches() guard var representedObjects = representedObjects else { emptyTheTimeline() return } if exceptionArticleFetcher != nil { representedObjects.append(exceptionArticleFetcher as AnyObject) exceptionArticleFetcher = nil } fetchUnsortedArticlesAsync(for: representedObjects) { [weak self] (articles) in self?.replaceArticles(with: articles) } } func cancelPendingAsyncFetches() { fetchSerialNumber += 1 fetchRequestQueue.cancelAllRequests() } func replaceArticles(with unsortedArticles: Set
) { articles = Array(unsortedArticles).sortedByDate(sortDirection, groupByFeed: groupByFeed) } func fetchUnsortedArticlesSync(for representedObjects: [Any]) -> Set
{ cancelPendingAsyncFetches() let fetchers = representedObjects.compactMap { $0 as? ArticleFetcher } if fetchers.isEmpty { return Set
() } var fetchedArticles = Set
() for fetchers in fetchers { if (fetchers as? SidebarItem)?.readFiltered(readFilterEnabledTable: readFilterEnabledTable) ?? true { if let articles = try? fetchers.fetchUnreadArticles() { fetchedArticles.formUnion(articles) } } else { if let articles = try? fetchers.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 fetchers = representedObjects.compactMap { $0 as? ArticleFetcher } let fetchOperation = FetchRequestOperation(id: fetchSerialNumber, readFilterEnabledTable: readFilterEnabledTable, fetchers: fetchers) { [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 selectArticles(_ articleIDs: [String]) { let indexesToSelect = indexesForArticleIDs(Set(articleIDs)) if indexesToSelect.isEmpty { tableView.deselectAll(self) return } tableView.selectRowIndexes(indexesToSelect, byExtendingSelection: false) } func queueFetchAndMergeArticles() { TimelineViewController.fetchAndMergeArticlesQueue.add(self, #selector(fetchAndMergeArticles)) } func representedObjectArraysAreEqual(_ objects1: [AnyObject]?, _ objects2: [AnyObject]?) -> Bool { if objects1 == nil && objects2 == nil { return true } guard let objects1 = objects1, let objects2 = objects2 else { return false } if objects1.count != objects2.count { return false } var ix = 0 for oneObject in objects1 { if oneObject !== objects2[ix] { return false } ix += 1 } return true } func representedObjectsContainsAnyPseudoFeed() -> Bool { return representedObjects?.contains(where: { $0 is PseudoFeed}) ?? false } func representedObjectsContainsTodayFeed() -> Bool { return representedObjects?.contains(where: { $0 === SmartFeedsController.shared.todayFeed }) ?? false } func representedObjectsContainAnyFolder() -> Bool { return representedObjects?.contains(where: { $0 is Folder }) ?? false } func representedObjectsContainsAnyFeed(_ feeds: Set) -> Bool { // Return true if there’s a match or if a folder contains (recursively) one of feeds guard let representedObjects = representedObjects else { return false } for representedObject in representedObjects { if let feed = representedObject as? Feed { for oneFeed in feeds { if feed.feedID == oneFeed.feedID || feed.url == oneFeed.url { return true } } } else if let folder = representedObject as? Folder { for oneFeed in feeds { if folder.hasFeed(with: oneFeed.feedID) || folder.hasFeed(withURL: oneFeed.url) { return true } } } } return false } }