Tusker/Tusker/Screens/Notifications/NotificationsCollectionView...

818 lines
36 KiB
Swift

//
// NotificationsCollectionViewController.swift
// Tusker
//
// Created by Shadowfacts on 5/6/23.
// Copyright © 2023 Shadowfacts. All rights reserved.
//
import UIKit
import Pachyderm
import Combine
#if canImport(Sentry)
import Sentry
#endif
import OSLog
private let logger = Logger(subsystem: Bundle.main.bundleIdentifier!, category: "NotificationsCVC")
class NotificationsCollectionViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController {
private let mastodonController: MastodonController
private let filterer: Filterer
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>!
var reconfigureVisibleItemsOnEndDecelerating: Bool = false
private var newer: RequestRange?
private var older: RequestRange?
var updatesNotificationsMarker: Bool = false
private var newestDisplayedNotification: Item?
init(allowedTypes: [Pachyderm.Notification.Kind], mastodonController: MastodonController) {
self.allowedTypes = allowedTypes
self.mastodonController = mastodonController
self.filterer = Filterer(mastodonController: mastodonController, context: .notifications, htmlConverter: TimelineStatusCollectionViewCell.htmlConverter)
super.init(nibName: nil, bundle: nil)
self.controller = TimelineLikeController(delegate: self, dataSource: self, ownerType: String(describing: self))
addKeyCommand(MenuController.refreshCommand(discoverabilityTitle: "Refresh Notifications"))
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override func viewDidLoad() {
super.viewDidLoad()
var config = UICollectionLayoutListConfiguration(appearance: .plain)
config.backgroundColor = .appBackground
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 if case .group(_, _, .some(let filterState)) = item,
self.filterer.isKnownHide(state: filterState) {
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)
section.readableContentInset(in: environment)
return section
}
collectionView = UICollectionView(frame: .zero, collectionViewLayout: layout)
collectionView.delegate = self
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()
#if !targetEnvironment(macCatalyst)
collectionView.refreshControl = UIRefreshControl()
collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged)
#endif
filterer.filtersChanged = { [unowned self] actionsChanged in
self.reapplyFilters(actionsChanged: actionsChanged)
}
NotificationCenter.default.addObserver(self, selector: #selector(handleStatusDeleted), name: .statusDeleted, object: mastodonController)
}
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
let statusCell = UICollectionView.CellRegistration<TimelineStatusCollectionViewCell, (NotificationGroup, CollapseState, Filterer.Result, NSAttributedString?)> { [unowned self] cell, indexPath, itemIdentifier in
cell.delegate = self
let statusID = itemIdentifier.0.notifications.first!.status!.id
let statusState = itemIdentifier.1
cell.updateUI(statusID: statusID, state: statusState, filterResult: itemIdentifier.2, precomputedContent: itemIdentifier.3)
}
let actionGroupCell = UICollectionView.CellRegistration<ActionNotificationGroupCollectionViewCell, NotificationGroup> { [unowned self] cell, indexPath, itemIdentifier in
cell.delegate = self
cell.updateUI(group: itemIdentifier)
}
let followCell = UICollectionView.CellRegistration<FollowNotificationGroupCollectionViewCell, NotificationGroup> { [unowned self] cell, indexPath, itemIdentifier in
cell.delegate = self
cell.updateUI(group: itemIdentifier)
}
let followRequestCell = UICollectionView.CellRegistration<FollowRequestNotificationCollectionViewCell, Pachyderm.Notification> { [unowned self] cell, indexPath, itemIdentifier in
cell.delegate = self
cell.updateUI(notification: itemIdentifier)
}
let pollCell = UICollectionView.CellRegistration<PollFinishedNotificationCollectionViewCell, Pachyderm.Notification> { [unowned self] cell, indexPath, itemIdentifier in
cell.delegate = self
cell.updateUI(notification: itemIdentifier)
}
let updateCell = UICollectionView.CellRegistration<StatusUpdatedNotificationCollectionViewCell, Pachyderm.Notification> { [unowned self] cell, indexPath, itemIdentifier in
cell.delegate = self
cell.updateUI(notification: itemIdentifier)
}
let unknownCell = UICollectionView.CellRegistration<UICollectionViewListCell, ()> { cell, indexPath, itemIdentifier in
var config = cell.defaultContentConfiguration()
config.text = "Unknown Notification"
cell.contentConfiguration = config
}
let zeroHeightCell = UICollectionView.CellRegistration<ZeroHeightCollectionViewCell, Void> { _, _, _ in
}
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
switch itemIdentifier {
case .group(let group, let collapseState, let filterState):
switch group.kind {
case .status, .mention:
let (result, precomputedContent) = self.filterer.resolve(state: filterState!) {
let id = group.notifications.first!.status!.id
return (self.mastodonController.persistentContainer.status(for: id)!, false)
}
switch result {
case .allow, .warn(_):
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (group, collapseState!, result, precomputedContent))
case .hide:
return collectionView.dequeueConfiguredReusableCell(using: zeroHeightCell, for: indexPath, item: ())
}
case .favourite, .reblog, .emojiReaction:
return collectionView.dequeueConfiguredReusableCell(using: actionGroupCell, for: indexPath, item: group)
case .follow:
return collectionView.dequeueConfiguredReusableCell(using: followCell, for: indexPath, item: group)
case .followRequest:
return collectionView.dequeueConfiguredReusableCell(using: followRequestCell, for: indexPath, item: group.notifications.first!)
case .poll:
return collectionView.dequeueConfiguredReusableCell(using: pollCell, for: indexPath, item: group.notifications.first!)
case .update:
return collectionView.dequeueConfiguredReusableCell(using: updateCell, for: indexPath, item: group.notifications.first!)
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()
}
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
updateNotificationsMarkerIfNecessary()
}
@objc func 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 filterResult(state: FilterState, statusID: String) -> (Filterer.Result, NSAttributedString?) {
let status = {
let status = self.mastodonController.persistentContainer.status(for: statusID)!
// if the status is a reblog of another one, filter based on that one
if let reblogged = status.reblog {
return (reblogged, true)
} else {
return (status, false)
}
}
return filterer.resolve(state: state, status: status)
}
private func reapplyFilters(actionsChanged: Bool) {
let visible = collectionView.indexPathsForVisibleItems
let items = visible
.compactMap { dataSource.itemIdentifier(for: $0) }
.filter {
if case .group(_, _, .some(_)) = $0 {
return true
} else {
return false
}
}
guard !items.isEmpty else {
return
}
var snapshot = dataSource.snapshot()
if actionsChanged {
snapshot.reloadItems(items)
} else {
snapshot.reconfigureItems(items)
}
dataSource.apply(snapshot)
}
@objc private func handleStatusDeleted(_ notification: Foundation.Notification) {
guard let userInfo = notification.userInfo,
let statusIDs = userInfo["statusIDs"] as? [String] else {
return
}
var snapshot = dataSource.snapshot()
guard snapshot.sectionIdentifiers.contains(.notifications) else {
return
}
let items = snapshot.itemIdentifiers(inSection: .notifications)
let toDelete = statusIDs.flatMap { id in
items.lazy.filter { $0.group?.notifications.first?.status?.id == id }
}
if !toDelete.isEmpty {
snapshot.deleteItems(toDelete)
dataSource.apply(snapshot, animatingDifferences: true)
}
}
private nonisolated func dismissNotificationsInGroup(at indexPath: IndexPath) async {
let item = await MainActor.run {
dataSource.itemIdentifier(for: indexPath)
}
guard case .group(let group, let collapseState, let filterState) = item 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 = await MainActor.run {
dataSource.snapshot()
}
if dismissFailedIndices.isEmpty {
snapshot.deleteItems([.group(group, collapseState, filterState)])
} else if !dismissFailedIndices.isEmpty && dismissFailedIndices.count == notifications.count {
let dismissFailed = dismissFailedIndices.sorted().map { notifications[$0] }
snapshot.insertItems([.group(NotificationGroup(notifications: dismissFailed, kind: group.kind)!, collapseState, filterState)], afterItem: .group(group, collapseState, filterState))
snapshot.deleteItems([.group(group, collapseState, filterState)])
}
await apply(snapshot, animatingDifferences: true)
}
private func updateNotificationsMarkerIfNecessary() {
guard updatesNotificationsMarker,
case let .group(group, _, _) = newestDisplayedNotification,
let notification = group.notifications.first else {
return
}
logger.debug("Updating notifications marker with \(notification.id)")
Task {
let req = TimelineMarkers.update(timeline: .notifications, lastReadID: notification.id)
do {
_ = try await mastodonController.run(req)
} catch {
logger.error("Failed to update notifications marker: \(String(describing: error))")
}
}
}
}
extension NotificationsCollectionViewController {
enum Section: TimelineLikeCollectionViewSection {
case notifications
case footer
static var entries: Self { .notifications }
}
enum Item: TimelineLikeCollectionViewItem {
case group(NotificationGroup, CollapseState?, FilterState?)
case loadingIndicator
case confirmLoadMore
static func fromTimelineItem(_ item: NotificationGroup) -> Self {
switch item.kind {
case .mention, .status:
return .group(item, .unknown, .unknown)
default:
return .group(item, nil, nil)
}
}
static func ==(lhs: Item, rhs: Item) -> Bool {
switch (lhs, rhs) {
case (.group(let a, _, _), .group(let b, _, _)):
return a == b
case (.loadingIndicator, .loadingIndicator):
return true
case (.confirmLoadMore, .confirmLoadMore):
return true
default:
return false
}
}
func hash(into hasher: inout Hasher) {
switch self {
case .group(let group, _, _):
hasher.combine(0)
hasher.combine(group)
case .loadingIndicator:
hasher.combine(1)
case .confirmLoadMore:
hasher.combine(1)
}
}
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
}
}
var hidesSeparators: Bool {
switch self {
case .loadingIndicator, .confirmLoadMore:
return true
default:
return false
}
}
}
}
// MARK: TimelineLikeCollectionViewController
extension NotificationsCollectionViewController {
enum Error: TimelineLikeCollectionViewError {
case noNewer
case noOlder
case allCaughtUp
}
}
extension NotificationsCollectionViewController: TimelineLikeControllerDataSource {
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)
types.remove(.unknown)
allowedTypes.forEach { types.remove($0) }
if !mastodonController.instanceFeatures.statusEditNotifications {
types.remove(.update)
}
if !mastodonController.instanceFeatures.statusNotifications {
types.remove(.status)
}
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 || notif.kind == .status) {
#if canImport(Sentry)
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)
#endif
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 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)
}
}
// MARK: TimelineLikeControllerDelegate
extension NotificationsCollectionViewController {
func handlePrependItems(_ timelineItems: [NotificationGroup]) async {
let topItem = dataSource.snapshot().itemIdentifiers(inSection: .notifications).first
// we always replace all, because new items are merged with existing ones
await handleReplaceAllItems(timelineItems)
// preserve the scroll position
// todo: this won't work for cmd+r when not at top
if let topID = topItem?.group?.notifications.first?.id {
// the exact item may have changed, due to merging
let newTopGroup = timelineItems.first {
$0.notifications.contains {
$0.id == topID
}
}!
if let newTopIndexPath = dataSource.indexPath(for: .group(newTopGroup, nil, nil)) {
collectionView.scrollToItem(at: newTopIndexPath, at: .top, animated: false)
}
}
}
func handleAppendItems(_ timelineItems: [NotificationGroup]) async {
await handleReplaceAllItems(timelineItems)
}
}
extension NotificationsCollectionViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) {
guard case .notifications = dataSource.sectionIdentifier(for: indexPath.section) else {
return
}
if updatesNotificationsMarker {
let shouldUpdateNewestDisplayedNotification: Bool
if let newestDisplayedNotification,
let currentNewestIndexPath = dataSource.indexPath(for: newestDisplayedNotification) {
shouldUpdateNewestDisplayedNotification = indexPath < currentNewestIndexPath
} else {
shouldUpdateNewestDisplayedNotification = true
}
if shouldUpdateNewestDisplayedNotification,
let item = dataSource.itemIdentifier(for: indexPath) {
newestDisplayedNotification = item
}
}
let itemsInSection = collectionView.numberOfItems(inSection: indexPath.section)
if indexPath.row == itemsInSection - 1 {
Task {
// Because of grouping, all cells from the first load may fit on screen,
// in which case, we try to load older while still in the loadingInitial state.
// So, wait for that to finish before trying to load more.
await controller.finishPendingOperation()
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) {
guard let item = dataSource.itemIdentifier(for: indexPath),
case .group(let group, let collapseState, let filterState) = item else {
return
}
switch group.kind {
case .mention, .status, .poll, .update:
if let filterState,
filterState.isWarning == true {
filterer.setResult(.allow, for: filterState)
collectionView.deselectItem(at: indexPath, animated: true)
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([item])
dataSource.apply(snapshot, animatingDifferences: true)
} else {
let statusID = group.notifications.first!.status!.id
let state = collapseState?.copy() ?? .unknown
selected(status: statusID, state: state)
}
case .favourite, .reblog, .emojiReaction(_, _):
let type = StatusActionAccountListViewController.ActionType(group.kind)!
let statusID = group.notifications.first!.status!.id
let accountIDs = group.notifications.map(\.account.id).uniques()
let vc = StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: mastodonController)
show(vc)
case .follow:
let accountIDs = group.notifications.map(\.account.id).uniques()
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, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? {
guard case .group(let group, let collapseState, _) = 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 = collapseState?.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, .emojiReaction(_, _):
return UIContextMenuConfiguration(previewProvider: {
let type = StatusActionAccountListViewController.ActionType(group.kind)!
let statusID = group.notifications.first!.status!.id
let accountIDs = group.notifications.map(\.account.id).uniques()
return StatusActionAccountListViewController(actionType: type, statusID: statusID, statusState: .unknown, accountIDs: accountIDs, mastodonController: self.mastodonController)
})
case .follow:
let accountIDs = group.notifications.map(\.account.id).uniques()
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) {
MenuPreviewHelper.willPerformPreviewAction(animator: animator, presenter: self)
}
func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) {
if reconfigureVisibleItemsOnEndDecelerating {
reconfigureVisibleItemsOnEndDecelerating = false
reconfigureVisibleCells()
}
}
#if os(visionOS)
func collectionView(_ collectionView: UICollectionView, shouldHighlightItemAt indexPath: IndexPath) -> Bool {
return self.collectionView(collectionView, shouldSelectItemAt: indexPath)
}
#endif
}
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, .emojiReaction(_, _):
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 }
}
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) {
if let indexPath = collectionView.indexPath(for: cell),
let item = dataSource.itemIdentifier(for: indexPath),
case .group(_, _, .some(let filterState)) = item {
filterer.setResult(.allow, for: filterState)
var snapshot = dataSource.snapshot()
snapshot.reconfigureItems([item])
dataSource.apply(snapshot, animatingDifferences: true)
}
}
}
extension NotificationsCollectionViewController: TabBarScrollableViewController {
func tabBarScrollToTop() {
collectionView.scrollToTop()
}
}
extension NotificationsCollectionViewController: StatusBarTappableViewController {
func handleStatusBarTapped(xPosition: CGFloat) -> StatusBarTapActionResult {
collectionView.scrollToTop()
return .stop
}
}
extension NotificationsCollectionViewController: BackgroundableViewController {
func sceneDidEnterBackground() {
updateNotificationsMarkerIfNecessary()
}
}