Initial notifications collection view implementatioan

This commit is contained in:
Shadowfacts 2023-05-06 20:32:48 -04:00
parent 25e82d828f
commit 574d1f9134
4 changed files with 339 additions and 2 deletions

View File

@ -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

View File

@ -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 = "<group>"; };
D646DCAB2A06C8840059ECEB /* ProfileFieldValueView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldValueView.swift; sourceTree = "<group>"; };
D646DCAD2A06C8C90059ECEB /* ProfileFieldVerificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileFieldVerificationView.swift; sourceTree = "<group>"; };
D646DCD12A06F2510059ECEB /* NotificationsCollectionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsCollectionViewController.swift; sourceTree = "<group>"; };
D647D92724257BEB0005044F /* AttachmentPreviewViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AttachmentPreviewViewController.swift; sourceTree = "<group>"; };
D64AAE9026C80DC600FC57FB /* ToastView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastView.swift; sourceTree = "<group>"; };
D64AAE9426C88C5000FC57FB /* ToastableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToastableViewController.swift; sourceTree = "<group>"; };
@ -1069,6 +1071,7 @@
children = (
D6BC9DB0232C61BC002CA326 /* NotificationsPageViewController.swift */,
D65234E02561AA68001AF9CF /* NotificationsTableViewController.swift */,
D646DCD12A06F2510059ECEB /* NotificationsCollectionViewController.swift */,
);
path = Notifications;
sourceTree = "<group>";
@ -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 */,

View File

@ -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<TimelineItem>!
let confirmLoadMore = PassthroughSubject<Void, Never>()
private(set) var collectionView: UICollectionView!
private(set) var dataSource: UICollectionViewDiffableDataSource<Section, Item>!
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<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, NotificationGroup> { [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<UICollectionViewListCell, ()> { 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) {
}
}

View File

@ -21,7 +21,7 @@ class NotificationsPageViewController: SegmentedPageViewController<Notifications
self.mastodonController = mastodonController
super.init(pages: [.all, .mentions]) { page in
let vc = NotificationsTableViewController(allowedTypes: page.allowedTypes, mastodonController: mastodonController)
let vc = NotificationsCollectionViewController(allowedTypes: page.allowedTypes, mastodonController: mastodonController)
vc.title = page.title
vc.userActivity = page.userActivity(accountID: mastodonController.accountInfo!.id)
return vc