From 574d1f9134b50dbbdf3ffa92e5da2c2b7c6961ef Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sat, 6 May 2023 20:32:48 -0400 Subject: [PATCH] Initial notifications collection view implementatioan --- .../Utilities/NotificationGroup.swift | 2 +- Tusker.xcodeproj/project.pbxproj | 4 + ...otificationsCollectionViewController.swift | 333 ++++++++++++++++++ .../NotificationsPageViewController.swift | 2 +- 4 files changed, 339 insertions(+), 2 deletions(-) create mode 100644 Tusker/Screens/Notifications/NotificationsCollectionViewController.swift diff --git a/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift b/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift index 795ca380..bb9250b5 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift @@ -8,7 +8,7 @@ import Foundation -public struct NotificationGroup: Identifiable, Hashable { +public struct NotificationGroup: Identifiable, Hashable, Sendable { public private(set) var notifications: [Notification] public let id: String public let kind: Notification.Kind diff --git a/Tusker.xcodeproj/project.pbxproj b/Tusker.xcodeproj/project.pbxproj index 80c40e6c..41ca4131 100644 --- a/Tusker.xcodeproj/project.pbxproj +++ b/Tusker.xcodeproj/project.pbxproj @@ -120,6 +120,7 @@ D646C95A213B5D0500269FB5 /* LargeImageInteractionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */; }; D646DCAC2A06C8840059ECEB /* ProfileFieldValueView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCAB2A06C8840059ECEB /* ProfileFieldValueView.swift */; }; D646DCAE2A06C8C90059ECEB /* ProfileFieldVerificationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCAD2A06C8C90059ECEB /* ProfileFieldVerificationView.swift */; }; + D646DCD22A06F2510059ECEB /* NotificationsCollectionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D646DCD12A06F2510059ECEB /* NotificationsCollectionViewController.swift */; }; D647D92824257BEB0005044F /* AttachmentPreviewViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */; }; D64AAE9126C80DC600FC57FB /* ToastView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9026C80DC600FC57FB /* ToastView.swift */; }; D64AAE9526C88C5000FC57FB /* ToastableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */; }; @@ -515,6 +516,7 @@ D646C959213B5D0500269FB5 /* LargeImageInteractionController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargeImageInteractionController.swift; sourceTree = ""; }; D646DCAB2A06C8840059ECEB /* ProfileFieldValueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldValueView.swift; sourceTree = ""; }; D646DCAD2A06C8C90059ECEB /* ProfileFieldVerificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldVerificationView.swift; sourceTree = ""; }; + D646DCD12A06F2510059ECEB /* NotificationsCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsCollectionViewController.swift; sourceTree = ""; }; D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewViewController.swift; sourceTree = ""; }; D64AAE9026C80DC600FC57FB /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = ""; }; D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastableViewController.swift; sourceTree = ""; }; @@ -1069,6 +1071,7 @@ children = ( D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */, D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */, + D646DCD12A06F2510059ECEB /* NotificationsCollectionViewController.swift */, ); path = Notifications; sourceTree = ""; @@ -2030,6 +2033,7 @@ D6674AEA23341F7600E8DF94 /* AppShortcutItems.swift in Sources */, D6DD8FFF2984D327002AD3FD /* BookmarksViewController.swift in Sources */, D646C956213B365700269FB5 /* LargeImageExpandAnimationController.swift in Sources */, + D646DCD22A06F2510059ECEB /* NotificationsCollectionViewController.swift in Sources */, D667E5F82135C3040057A976 /* Mastodon+Equatable.swift in Sources */, D6B4A4FF2506B81A000C81C1 /* AccountDisplayNameLabel.swift in Sources */, D63D8DF42850FE7A008D95E1 /* ViewTags.swift in Sources */, diff --git a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift new file mode 100644 index 00000000..c0fab646 --- /dev/null +++ b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift @@ -0,0 +1,333 @@ +// +// 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 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) + 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) + 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) { + } +} diff --git a/Tusker/Screens/Notifications/NotificationsPageViewController.swift b/Tusker/Screens/Notifications/NotificationsPageViewController.swift index b263c4bd..1c870288 100644 --- a/Tusker/Screens/Notifications/NotificationsPageViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsPageViewController.swift @@ -21,7 +21,7 @@ class NotificationsPageViewController: SegmentedPageViewController