// // ItemsViewController.swift // Reader // // Created by Shadowfacts on 1/9/22. // import UIKit import CoreData import SafariServices protocol ItemsViewControllerDelegate: AnyObject { func showReadItem(_ item: Item) } class ItemsViewController: UIViewController { weak var delegate: ItemsViewControllerDelegate? let fervorController: FervorController let type: ItemListType private let fetchRequest: NSFetchRequest private var collectionView: UICollectionView! private var dataSource: UICollectionViewDiffableDataSource! private var batchUpdates: [() -> Void] = [] init(type: ItemListType, fervorController: FervorController) { self.fervorController = fervorController self.type = type self.fetchRequest = type.idFetchRequest super.init(nibName: nil, bundle: nil) self.title = type.title switch type { case .all: self.userActivity = .readAll(account: fervorController.account!) case .unread: self.userActivity = .readUnread(account: fervorController.account!) case .group(let group): self.userActivity = .readGroup(group, account: fervorController.account!) case .feed(let feed): self.userActivity = .readFeed(feed, account: fervorController.account!) } } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() if UIDevice.current.userInterfaceIdiom != .mac { view.backgroundColor = .appBackground } var configuration = UICollectionLayoutListConfiguration(appearance: .plain) configuration.backgroundColor = .clear let layout = UICollectionViewCompositionalLayout.list(using: configuration) collectionView = UICollectionView(frame: view.bounds, collectionViewLayout: layout) collectionView.delegate = self collectionView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(collectionView) dataSource = createDataSource() NSLayoutConstraint.activate([ collectionView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor), collectionView.topAnchor.constraint(equalTo: view.topAnchor), collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) fetchRequest.sortDescriptors = [NSSortDescriptor(key: "published", ascending: false)] do { let ids = try fervorController.persistentContainer.viewContext.fetch(fetchRequest) var snapshot = NSDiffableDataSourceSnapshot() snapshot.appendSections([.items]) snapshot.appendItems(ids, toSection: .items) dataSource.apply(snapshot, animatingDifferences: false) NotificationCenter.default.addObserver(self, selector: #selector(managedObjectsDidChange), name: .NSManagedObjectContextObjectsDidChange, object: fervorController.persistentContainer.viewContext) } catch { let alert = UIAlertController(title: "Error fetching items", message: error.localizedDescription, preferredStyle: .alert) alert.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil)) present(alert, animated: true) } } private func createDataSource() -> UICollectionViewDiffableDataSource { let itemCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in cell.delegate = self cell.scrollView = self.collectionView let item = self.fervorController.persistentContainer.viewContext.object(with: itemIdentifier) as! Item cell.updateUI(item: item) } return UICollectionViewDiffableDataSource(collectionView: collectionView) { collectionView, indexPath, itemIdentifier in return collectionView.dequeueConfiguredReusableCell(using: itemCell, for: indexPath, item: itemIdentifier) } } @objc private func managedObjectsDidChange(_ notification: Notification) { let inserted = notification.userInfo?[NSInsertedObjectsKey] as? NSSet let updated = notification.userInfo?[NSUpdatedObjectsKey] as? NSSet let deleted = notification.userInfo?[NSDeletedObjectsKey] as? NSSet // managed objectss from the notification are tied to the thread it was delivered on // so get the published dates and evaluate the predicate here let insertedItems = inserted?.compactMap { $0 as? Item }.filter { fetchRequest.predicate?.evaluate(with: $0) ?? true }.map { ($0, $0.published) } var snapshot = self.dataSource.snapshot() if let updated = updated { let knownUpdated = updated.compactMap { ($0 as? Item)?.objectID }.filter { snapshot.itemIdentifiers.contains($0) } snapshot.reconfigureItems(knownUpdated) } if let deleted = deleted { let knownDeleted = deleted.compactMap { ($0 as? Item)?.objectID }.filter { snapshot.itemIdentifiers.contains($0) } snapshot.deleteItems(knownDeleted) } if let insertedItems = insertedItems { self.fervorController.persistentContainer.performBackgroundTask { ctx in // for newly inserted items, the ctx doesn't have the published date so we check the data we got from the notification func publishedForItem(_ id: NSManagedObjectID) -> Date? { if let (_, pub) = insertedItems.first(where: { $0.0.objectID == id }) { return pub } else { return (ctx.object(with: id) as! Item).published } } // todo: this feels inefficient for (inserted, insertedPublished) in insertedItems { // todo: uhh, what does sql do if the published date is nil? guard let insertedPublished = insertedPublished else { snapshot.insertItems([inserted.objectID], beforeItem: snapshot.itemIdentifiers.first!) continue } var index = 0 while index < snapshot.itemIdentifiers.count, let pub = publishedForItem(snapshot.itemIdentifiers[index]), insertedPublished < pub { index += 1 } // index is the index of the first item which the inserted one was published after // (i.e., the item that should appear immediately after inserted in the list) if index == snapshot.itemIdentifiers.count { snapshot.appendItems([inserted.objectID], toSection: .items) } else { snapshot.insertItems([inserted.objectID], beforeItem: snapshot.itemIdentifiers[index]) } } DispatchQueue.main.async { self.dataSource.apply(snapshot, animatingDifferences: false) } } } else { DispatchQueue.main.async { self.dataSource.apply(snapshot, animatingDifferences: false) } } } } extension ItemsViewController { enum Section: Hashable { case items } } extension ItemsViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard let id = dataSource.itemIdentifier(for: indexPath) else { return nil } let item = fervorController.persistentContainer.viewContext.object(with: id) as! Item return UIContextMenuConfiguration(identifier: nil, previewProvider: { ReadViewController(item: item, fervorController: self.fervorController) }, actionProvider: { _ in var children: [UIMenuElement] = [] if let url = item.url { children.append(UIAction(title: "Open in Safari", image: UIImage(systemName: "safari"), handler: { [weak self] _ in let vc = SFSafariViewController(url: url) vc.preferredControlTintColor = .appTintColor self?.present(vc, animated: true) })) #if targetEnvironment(macCatalyst) self.activityItemsConfiguration = UIActivityItemsConfiguration(objects: [url as NSURL]) children.append(UICommand(title: "Share…", action: Selector(("unused")), propertyList: UICommandTagShare)) #else children.append(UIAction(title: "Share", image: UIImage(systemName: "square.and.arrow.up"), handler: { [weak self] _ in self?.present(UIActivityViewController(activityItems: [url], applicationActivities: nil), animated: true) })) #endif } if item.read { children.append(UIAction(title: "Mark as Unread", image: UIImage(systemName: "checkmark.circle"), handler: { [unowned self] _ in Task { await self.fervorController.markItem(item, read: false) } })) } else { children.append(UIAction(title: "Mark as Read", image: UIImage(systemName: "checkmark.circle.fill"), handler: { [unowned self] _ in Task { await self.fervorController.markItem(item, read: true) } })) } return UIMenu(children: children) }) } func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { guard let vc = animator.previewViewController else { return } animator.preferredCommitStyle = .pop animator.addCompletion { self.show(vc, sender: nil) } } } extension ItemsViewController: ItemCollectionViewCellDelegate { func itemCellSelected(cell: ItemCollectionViewCell, item: Item) { cell.setRead(true, animated: true) if let delegate = delegate { delegate.showReadItem(item) } else { show(ReadViewController(item: item, fervorController: fervorController), sender: nil) } } }