forked from shadowfacts/Tusker
Initial notifications collection view implementatioan
This commit is contained in:
parent
25e82d828f
commit
574d1f9134
|
@ -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
|
||||
|
|
|
@ -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 */,
|
||||
|
|
|
@ -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) {
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue