// // NotificationsCollectionViewController.swift // Tusker // // Created by Shadowfacts on 5/6/23. // Copyright © 2023 Shadowfacts. All rights reserved. // import UIKit import Pachyderm import Combine import Sentry class NotificationsCollectionViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController { weak var mastodonController: MastodonController! private let allowedTypes: [Pachyderm.Notification.Kind] private let groupTypes = [Pachyderm.Notification.Kind.favourite, .reblog, .follow] private(set) var controller: TimelineLikeController! let confirmLoadMore = PassthroughSubject() private(set) var collectionView: UICollectionView! private(set) var dataSource: UICollectionViewDiffableDataSource! private var newer: RequestRange? private var older: RequestRange? init(allowedTypes: [Pachyderm.Notification.Kind], mastodonController: MastodonController) { self.allowedTypes = allowedTypes self.mastodonController = mastodonController super.init(nibName: nil, bundle: nil) self.controller = TimelineLikeController(delegate: self) // todo: title addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Notifications")) // todo: user activity } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } override func viewDidLoad() { super.viewDidLoad() var config = UICollectionLayoutListConfiguration(appearance: .plain) config.backgroundColor = .appBackground // todo: swipe actions // todo: separators let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { section.contentInsetsReference = .readableContent } return section } collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.delegate = self // todo: drag //collectionView.dragDelegate = self collectionView.allowsFocus = true collectionView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(collectionView) NSLayoutConstraint.activate([ collectionView.leadingAnchor.constraint(equalTo: view.leadingAnchor), collectionView.trailingAnchor.constraint(equalTo: view.trailingAnchor), collectionView.topAnchor.constraint(equalTo: view.topAnchor), collectionView.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) registerTimelineLikeCells() dataSource = createDataSource() } private func createDataSource() -> UICollectionViewDiffableDataSource { let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in cell.delegate = self let statusID = itemIdentifier.notifications.first!.status!.id let statusState = itemIdentifier.statusState! cell.updateUI(statusID: statusID, state: statusState, filterResult: .allow, precomputedContent: nil) } let actionGroupCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in cell.delegate = self cell.updateUI(group: itemIdentifier) } let followCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in cell.delegate = self cell.updateUI(group: itemIdentifier) } let followRequestCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in cell.delegate = self cell.updateUI(notification: itemIdentifier) } let unknownCell = UICollectionView.CellRegistration { cell, indexPath, itemIdentifier in var config = cell.defaultContentConfiguration() config.text = "Unknown Notification" cell.contentConfiguration = config } return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in switch itemIdentifier { case .group(let group): switch group.kind { case .status, .mention: return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: group) case .favourite, .reblog: return collectionView.dequeueConfiguredReusableCell(using: actionGroupCell, for: indexPath, item: group) case .follow: return collectionView.dequeueConfiguredReusableCell(using: followCell, for: indexPath, item: group) case .followRequest: return collectionView.dequeueConfiguredReusableCell(using: followRequestCell, for: indexPath, item: group.notifications.first!) default: return collectionView.dequeueConfiguredReusableCell(using: unknownCell, for: indexPath, item: ()) } case .loadingIndicator: return self.loadingIndicatorCell(for: indexPath) case .confirmLoadMore: return self.confirmLoadMoreCell(for: indexPath) } } } override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) clearSelectionOnAppear(animated: animated) if case .notLoadedInitial = controller.state { Task { await controller.loadInitial() } } } @objc func refresh() { // todo: refresh } } extension NotificationsCollectionViewController { enum Section: TimelineLikeCollectionViewSection { case notifications case footer static var entries: Self { .notifications } } enum Item: TimelineLikeCollectionViewItem { case group(NotificationGroup) case loadingIndicator case confirmLoadMore static func fromTimelineItem(_ item: NotificationGroup) -> Self { return .group(item) } var group: NotificationGroup? { if case .group(let group) = self { return group } else { return nil } } var isSelectable: Bool { switch self { case .group(_): return true default: return false } } } } // MARK: TimelineLikeControllerDelegate extension NotificationsCollectionViewController { typealias TimelineItem = NotificationGroup private static let pageSize = 40 private func request(range: RequestRange) -> Request<[Pachyderm.Notification]> { if mastodonController.instanceFeatures.notificationsAllowedTypes { return Client.getNotifications(allowedTypes: allowedTypes, range: range) } else { var types = Set(Notification.Kind.allCases) types.remove(.unknown) allowedTypes.forEach { types.remove($0) } return Client.getNotifications(excludedTypes: Array(types), range: range) } } private func validateNotifications(_ notifications: [Pachyderm.Notification]) -> [Pachyderm.Notification] { return notifications.compactMap { notif in if notif.status == nil && (notif.kind == .mention || notif.kind == .reblog || notif.kind == .favourite) { let crumb = Breadcrumb(level: .fatal, category: "notifications") crumb.data = [ "id": notif.id, "type": notif.kind.rawValue, "created_at": notif.createdAt.formatted(.iso8601), "account": notif.account.id, ] SentrySDK.addBreadcrumb(crumb) return nil } else { return notif } } } func loadInitial() async throws -> [NotificationGroup] { let request = self.request(range: .count(NotificationsCollectionViewController.pageSize)) let (notifications, _) = try await mastodonController.run(request) if !notifications.isEmpty { self.newer = .after(id: notifications.first!.id, count: NotificationsCollectionViewController.pageSize) self.older = .before(id: notifications.last!.id, count: NotificationsCollectionViewController.pageSize) } let validated = validateNotifications(notifications) await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(notifications: validated) { continuation.resume() } } return NotificationGroup.createGroups(notifications: validated, only: self.groupTypes) } func loadNewer() async throws -> [NotificationGroup] { guard let newer else { throw Error.noNewer } let request = self.request(range: newer) let (notifications, _) = try await mastodonController.run(request) if !notifications.isEmpty { self.newer = .after(id: notifications.first!.id, count: NotificationsCollectionViewController.pageSize) } let validated = validateNotifications(notifications) guard !validated.isEmpty else { throw Error.allCaughtUp } await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(notifications: validated) { continuation.resume() } } let newerGroups = NotificationGroup.createGroups(notifications: validated, only: self.groupTypes) let existingGroups = dataSource.snapshot().itemIdentifiers(inSection: .notifications).compactMap(\.group) return NotificationGroup.mergeGroups(first: newerGroups, second: existingGroups, only: self.groupTypes) } func handlePrependItems(_ timelineItems: [NotificationGroup]) async { // we always replace all, because new items are merged with existing ones await handleReplaceAllItems(timelineItems) } func loadOlder() async throws -> [NotificationGroup] { guard let older else { throw Error.noOlder } let request = self.request(range: older) let (notifications, _) = try await mastodonController.run(request) if !notifications.isEmpty { self.older = .before(id: notifications.last!.id, count: NotificationsCollectionViewController.pageSize) } let validated = validateNotifications(notifications) await withCheckedContinuation { continuation in mastodonController.persistentContainer.addAll(notifications: validated) { continuation.resume() } } let olderGroups = NotificationGroup.createGroups(notifications: validated, only: self.groupTypes) let existingGroups = dataSource.snapshot().itemIdentifiers(inSection: .notifications).compactMap(\.group) return NotificationGroup.mergeGroups(first: existingGroups, second: olderGroups, only: self.groupTypes) } func handleAppendItems(_ timelineItems: [NotificationGroup]) async { await handleReplaceAllItems(timelineItems) } enum Error: TimelineLikeCollectionViewError { case noNewer case noOlder case allCaughtUp } } extension NotificationsCollectionViewController: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { guard case .notifications = dataSource.sectionIdentifier(for: indexPath.section) else { return } let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section) if indexPath.row == itemsInSection - 1 { Task { await controller.loadOlder() } } } func collectionView(_ collectionView: UICollectionView, shouldSelectItemAt indexPath: IndexPath) -> Bool { return dataSource.itemIdentifier(for: indexPath)?.isSelectable ?? false } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { // todo } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? { // todo return nil } func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) } } extension NotificationsCollectionViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } } extension NotificationsCollectionViewController: MenuActionProvider { } extension NotificationsCollectionViewController: StatusCollectionViewCellDelegate { func statusCellNeedsReconfigure(_ cell: StatusCollectionViewCell, animated: Bool, completion: (() -> Void)?) { if let indexPath = collectionView.indexPath(for: cell) { var snapshot = dataSource.snapshot() snapshot.reconfigureItems([dataSource.itemIdentifier(for: indexPath)!]) dataSource.apply(snapshot, animatingDifferences: animated, completion: completion) } } func statusCellShowFiltered(_ cell: StatusCollectionViewCell) { } }