Convert rest of notifications screen to collection view
This commit is contained in:
parent
73ca312f43
commit
8321b1f432
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue