Convert rest of notifications screen to collection view

This commit is contained in:
Shadowfacts 2023-05-07 14:56:23 -04:00
parent 73ca312f43
commit 8321b1f432
3 changed files with 224 additions and 14 deletions

View File

@ -15,7 +15,7 @@ public struct NotificationGroup: Identifiable, Hashable, Sendable {
public let statusState: CollapseState? public let statusState: CollapseState?
@MainActor @MainActor
init?(notifications: [Notification]) { public init?(notifications: [Notification]) {
guard !notifications.isEmpty else { return nil } guard !notifications.isEmpty else { return nil }
self.notifications = notifications self.notifications = notifications
self.id = notifications.first!.id self.id = notifications.first!.id

View File

@ -225,7 +225,7 @@ class FollowRequestNotificationCollectionViewCell: UICollectionViewCell {
// MARK: - Interaction // MARK: - Interaction
@objc private func rejectButtonPressed() { @objc func rejectButtonPressed() {
acceptButton.isEnabled = false acceptButton.isEnabled = false
rejectButton.isEnabled = false rejectButton.isEnabled = false
@ -251,7 +251,7 @@ class FollowRequestNotificationCollectionViewCell: UICollectionViewCell {
} }
} }
@objc private func acceptButtonPressed() { @objc func acceptButtonPressed() {
acceptButton.isEnabled = false acceptButton.isEnabled = false
rejectButton.isEnabled = false rejectButton.isEnabled = false

View File

@ -35,9 +35,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
self.controller = TimelineLikeController(delegate: self) self.controller = TimelineLikeController(delegate: self)
// todo: title
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Notifications")) addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Notifications"))
// todo: user activity
} }
required init?(coder: NSCoder) { required init?(coder: NSCoder) {
@ -49,8 +47,38 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
var config = UICollectionLayoutListConfiguration(appearance: .plain) var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.backgroundColor = .appBackground config.backgroundColor = .appBackground
// todo: swipe actions config.leadingSwipeActionsConfigurationProvider = { [unowned self] in
// todo: separators (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 layout = UICollectionViewCompositionalLayout { sectionIndex, environment in
let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment) let section = NSCollectionLayoutSection.list(using: config, layoutEnvironment: environment)
if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac { if UIDevice.current.userInterfaceIdiom == .pad || UIDevice.current.userInterfaceIdiom == .mac {
@ -60,8 +88,7 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
} }
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout) collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self collectionView.delegate = self
// todo: drag collectionView.dragDelegate = self
//collectionView.dragDelegate = self
collectionView.allowsFocus = true collectionView.allowsFocus = true
collectionView.translatesAutoresizingMaskIntoConstraints = false collectionView.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(collectionView) view.addSubview(collectionView)
@ -74,6 +101,11 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
registerTimelineLikeCells() registerTimelineLikeCells()
dataSource = createDataSource() dataSource = createDataSource()
#if !targetEnvironment(macCatalyst)
collectionView.refreshControl = UIRefreshControl()
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
#endif
} }
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> { private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
@ -148,7 +180,49 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle
} }
@objc func refresh() { @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 return false
} }
} }
var hidesSeparators: Bool {
switch self {
case .loadingIndicator, .confirmLoadMore:
return true
default:
return false
}
}
} }
} }
@ -330,19 +413,146 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate {
} }
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { 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? { func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
// todo guard case .group(let group) = dataSource.itemIdentifier(for: indexPath),
let cell = collectionView.cellForItem(at: indexPath) else {
return nil 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) { func collectionView(_ collectionView: UICollectionView, willPerformPreviewActionForMenuWith configuration: UIContextMenuConfiguration, animator: UIContextMenuInteractionCommitAnimating) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self) MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
} }
} }
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 { extension NotificationsCollectionViewController: TuskerNavigationDelegate {
var apiController: MastodonController! { mastodonController } var apiController: MastodonController! { mastodonController }
} }