From 3181c47fdeecb9112f8ef00e47fcc425d689c1c4 Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 7 May 2023 14:56:23 -0400 Subject: [PATCH] Convert rest of notifications screen to collection view --- .../Utilities/NotificationGroup.swift | 2 +- ...equestNotificationCollectionViewCell.swift | 4 +- ...otificationsCollectionViewController.swift | 232 +++++++++++++++++- 3 files changed, 224 insertions(+), 14 deletions(-) diff --git a/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift b/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift index bb9250b5..98543a73 100644 --- a/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift +++ b/Packages/Pachyderm/Sources/Pachyderm/Utilities/NotificationGroup.swift @@ -15,7 +15,7 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable { public let statusState: CollapseState? @MainActor - init?(notifications: [Notification]) { + public init?(notifications: [Notification]) { guard !notifications.isEmpty else { return nil } self.notifications = notifications self.id = notifications.first!.id diff --git a/Tusker/Screens/Notifications/FollowRequestNotificationCollectionViewCell.swift b/Tusker/Screens/Notifications/FollowRequestNotificationCollectionViewCell.swift index 427493fd..286e5746 100644 --- a/Tusker/Screens/Notifications/FollowRequestNotificationCollectionViewCell.swift +++ b/Tusker/Screens/Notifications/FollowRequestNotificationCollectionViewCell.swift @@ -225,7 +225,7 @@ class FollowRequestNotificationCollectionViewCell: UICollectionViewCell { // MARK: - Interaction - @objc private func rejectButtonPressed() { + @objc func rejectButtonPressed() { acceptButton.isEnabled = false rejectButton.isEnabled = false @@ -251,7 +251,7 @@ class FollowRequestNotificationCollectionViewCell: UICollectionViewCell { } } - @objc private func acceptButtonPressed() { + @objc func acceptButtonPressed() { acceptButton.isEnabled = false rejectButton.isEnabled = false diff --git a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift index 340ad9d3..7a5cd270 100644 --- a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift @@ -35,9 +35,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle self.controller = TimelineLikeController(delegate: self) - // todo: title addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Notifications")) - // todo: user activity } required init?(coder: NSCoder) { @@ -49,8 +47,38 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle var config = UICollectionLayoutListConfiguration(appearance: .plain) config.backgroundColor = .appBackground - // todo: swipe actions - // todo: separators + config.leadingSwipeActionsConfigurationProvider = { [unowned self] in + (collectionView.cellForItem(at: $0) as? TimelineStatusCollectionViewCell)?.leadingSwipeActions() + } + config.trailingSwipeActionsConfigurationProvider = { [unowned self] indexPath in + let dismissAction = UIContextualAction(style: .destructive, title: "Dismiss") { _, _, completion in + Task { + await self.dismissNotificationsInGroup(at: indexPath) + completion(true) + } + } + dismissAction.accessibilityLabel = "Dismiss Notification" + dismissAction.image = UIImage(systemName: "clear.fill") + + let cellConfig = (collectionView.cellForItem(at: indexPath) as? TimelineStatusCollectionViewCell)?.trailingSwipeActions() + let config = UISwipeActionsConfiguration(actions: (cellConfig?.actions ?? []) + [dismissAction]) + config.performsFirstActionWithFullSwipe = cellConfig?.performsFirstActionWithFullSwipe ?? false + return config + } + config.itemSeparatorHandler = { [unowned self] indexPath, sectionSeparatorConfiguration in + guard let item = self.dataSource.itemIdentifier(for: indexPath) else { + return sectionSeparatorConfiguration + } + var config = sectionSeparatorConfiguration + if item.hidesSeparators { + config.topSeparatorVisibility = .hidden + config.bottomSeparatorVisibility = .hidden + } else { + config.topSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets + config.bottomSeparatorInsets = TimelineStatusCollectionViewCell.separatorInsets + } + return config + } let layout = UICollectionViewCompositionalLayout { sectionIndex, environment in let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { @@ -60,8 +88,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle } collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView.delegate = self - // todo: drag - //collectionView.dragDelegate = self + collectionView.dragDelegate = self collectionView.allowsFocus = true collectionView.translatesAutoresizingMaskIntoConstraints = false view.addSubview(collectionView) @@ -74,6 +101,11 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle registerTimelineLikeCells() dataSource = createDataSource() + + #if !targetEnvironment(macCatalyst) + collectionView.refreshControl = UIRefreshControl() + collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged) + #endif } private func createDataSource() -> UICollectionViewDiffableDataSource { @@ -148,7 +180,49 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle } @objc func refresh() { - // todo: refresh + Task { @MainActor in + if case .notLoadedInitial = controller.state { + await controller.loadInitial() + } else { + await controller.loadNewer() + } + #if !targetEnvironment(macCatalyst) + collectionView.refreshControl?.endRefreshing() + #endif + } + } + + private func dismissNotificationsInGroup(at indexPath: IndexPath) async { + guard case .group(let group) = dataSource.itemIdentifier(for: indexPath) else { + return + } + let notifications = group.notifications + let dismissFailedIndices = await withTaskGroup(of: (Int, Bool).self) { group -> [Int] in + for (index, notification) in notifications.enumerated() { + group.addTask { + do { + _ = try await self.mastodonController.run(Notification.dismiss(id: notification.id)) + return (index, true) + } catch { + return (index, false) + } + } + } + return await group.reduce(into: [], { partialResult, value in + if !value.1 { + partialResult.append(value.0) + } + }) + } + var snapshot = dataSource.snapshot() + if dismissFailedIndices.isEmpty { + snapshot.deleteItems([.group(group)]) + } else if !dismissFailedIndices.isEmpty && dismissFailedIndices.count == notifications.count { + let dismissFailed = dismissFailedIndices.sorted().map { notifications[$0] } + snapshot.insertItems([.group(NotificationGroup(notifications: dismissFailed)!)], afterItem: .group(group)) + snapshot.deleteItems([.group(group)]) + } + await apply(snapshot, animatingDifferences: true) } } @@ -185,6 +259,15 @@ extension NotificationsCollectionViewController { return false } } + + var hidesSeparators: Bool { + switch self { + case .loadingIndicator, .confirmLoadMore: + return true + default: + return false + } + } } } @@ -330,12 +413,104 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate { } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - // todo + guard case .group(let group) = dataSource.itemIdentifier(for: indexPath) else { + return + } + switch group.kind { + case .mention, .status, .poll, .update: + let statusID = group.notifications.first!.status!.id + let state = group.statusState?.copy() ?? .unknown + selected(status: statusID, state: state) + case .favourite, .reblog: + let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog + let statusID = group.notifications.first!.status!.id + let accountIDs = group.notifications.map(\.account.id) + let vc = StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: mastodonController) + show(vc) + case .follow: + let accountIDs = group.notifications.map(\.account.id) + switch accountIDs.count { + case 0: + collectionView.deselectItem(at: indexPath, animated: true) + case 1: + selected(account: accountIDs.first!) + default: + let vc = AccountListViewController(accountIDs: accountIDs, mastodonController: mastodonController) + vc.title = NSLocalizedString("Followed By", comment: "followed by accounts list title") + show(vc) + } + case .followRequest: + selected(account: group.notifications.first!.account.id) + case .unknown: + collectionView.deselectItem(at: indexPath, animated: true) + } } - func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemsAt indexPaths: [IndexPath], point: CGPoint) -> UIContextMenuConfiguration? { - // todo - return nil + func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { + guard case .group(let group) = dataSource.itemIdentifier(for: indexPath), + let cell = collectionView.cellForItem(at: indexPath) else { + return nil + } + switch group.kind { + case .mention, .status, .poll, .update: + guard let statusID = group.notifications.first?.status?.id, + let status = mastodonController.persistentContainer.status(for: statusID) else { + return nil + } + let state = group.statusState?.copy() ?? .unknown + return UIContextMenuConfiguration { + ConversationViewController(for: statusID, state: state, mastodonController: self.mastodonController) + } actionProvider: { _ in + UIMenu(children: self.actionsForStatus(status, source: .view(cell), includeStatusButtonActions: group.kind == .poll || group.kind == .update)) + } + case .favourite, .reblog: + return UIContextMenuConfiguration(previewProvider: { + let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog + let statusID = group.notifications.first!.status!.id + let accountIDs = group.notifications.map(\.account.id) + return StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: self.mastodonController) + }) + case .follow: + let accountIDs = group.notifications.map(\.account.id) + return UIContextMenuConfiguration { + if accountIDs.count == 1 { + return ProfileViewController(accountID: accountIDs.first!, mastodonController: self.mastodonController) + } else { + let vc = AccountListViewController(accountIDs: accountIDs, mastodonController: self.mastodonController) + vc.title = NSLocalizedString("Followed By", comment: "followed by accounts list title") + return vc + } + } actionProvider: { _ in + if accountIDs.count == 1 { + return UIMenu(children: self.actionsForProfile(accountID: accountIDs.first!, source: .view(cell))) + } else { + return nil + } + } + case .followRequest: + let accountID = group.notifications.first!.account.id + return UIContextMenuConfiguration { + ProfileViewController(accountID: accountID, mastodonController: self.mastodonController) + } actionProvider: { _ in + let cell = cell as! FollowRequestNotificationCollectionViewCell + let acceptRejectChildren = [ + UIAction(title: "Accept", image: UIImage(systemName: "checkmark.circle"), handler: { _ in cell.acceptButtonPressed() }), + UIAction(title: "Reject", image: UIImage(systemName: "xmark.circle"), handler: { _ in cell.rejectButtonPressed() }), + ] + let acceptRejectMenu: UIMenu + if #available(iOS 16.0, *) { + acceptRejectMenu = UIMenu(options: .displayInline, preferredElementSize: .medium, children: acceptRejectChildren) + } else { + acceptRejectMenu = UIMenu(options: .displayInline, children: acceptRejectChildren) + } + return UIMenu(children: [ + acceptRejectMenu, + UIMenu(options: .displayInline, children: self.actionsForProfile(accountID: accountID, source: .view(cell))), + ]) + } + case .unknown: + return nil + } } func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) { @@ -343,6 +518,41 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate { } } +extension NotificationsCollectionViewController: UICollectionViewDragDelegate { + func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { + guard case .group(let group) = dataSource.itemIdentifier(for: indexPath) else { + return [] + } + switch group.kind { + case .mention, .status: + // not combiend with .poll and .update below, b/c TimelineStatusCollectionViewCell handles checking whether the poll view is tracking + let cell = collectionView.cellForItem(at: indexPath) as! TimelineStatusCollectionViewCell + return cell.dragItemsForBeginning(session: session) + case .poll, .update: + let status = group.notifications.first!.status! + let provider = NSItemProvider(object: URL(status.url!)! as NSURL) + let activity = UserActivityManager.showConversationActivity(mainStatusID: status.id, accountID: mastodonController.accountInfo!.id) + activity.displaysAuxiliaryScene = true + provider.registerObject(activity, visibility: .all) + return [UIDragItem(itemProvider: provider)] + case .favourite, .reblog: + return [] + case .follow, .followRequest: + guard group.notifications.count == 1 else { + return [] + } + let account = group.notifications.first!.account + let provider = NSItemProvider(object: account.url as NSURL) + let activity = UserActivityManager.showProfileActivity(id: account.id, accountID: mastodonController.accountInfo!.id) + activity.displaysAuxiliaryScene = true + provider.registerObject(activity, visibility: .all) + return [UIDragItem(itemProvider: provider)] + case .unknown: + return [] + } + } +} + extension NotificationsCollectionViewController: TuskerNavigationDelegate { var apiController: MastodonController! { mastodonController } }