From dc83172aeae62c9bb6630a23a0d4ff7b9309b28a Mon Sep 17 00:00:00 2001 From: Shadowfacts Date: Sun, 14 May 2023 19:10:56 -0400 Subject: [PATCH] Support filtering on Notifications screen --- ...otificationsCollectionViewController.swift | 123 ++++++++++++++---- .../Status/StatusCollectionViewCell.swift | 6 - 2 files changed, 101 insertions(+), 28 deletions(-) diff --git a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift index f5344716..963aa701 100644 --- a/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift +++ b/Tusker/Screens/Notifications/NotificationsCollectionViewController.swift @@ -14,6 +14,7 @@ import Sentry class NotificationsCollectionViewController: UIViewController, TimelineLikeCollectionViewController, CollectionViewController { weak var mastodonController: MastodonController! + private let filterer: Filterer private let allowedTypes: [Pachyderm.Notification.Kind] private let groupTypes = [Pachyderm.Notification.Kind.favourite, .reblog, .follow] @@ -31,6 +32,11 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle self.allowedTypes = allowedTypes self.mastodonController = mastodonController + self.filterer = Filterer(mastodonController: mastodonController, context: .notifications) + self.filterer.htmlConverter.font = TimelineStatusCollectionViewCell.contentFont + self.filterer.htmlConverter.monospaceFont = TimelineStatusCollectionViewCell.monospaceFont + self.filterer.htmlConverter.paragraphStyle = TimelineStatusCollectionViewCell.contentParagraphStyle + super.init(nibName: nil, bundle: nil) self.controller = TimelineLikeController(delegate: self, ownerType: String(describing: self)) @@ -73,6 +79,10 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle 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 @@ -106,14 +116,18 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle collectionView.refreshControl = UIRefreshControl() collectionView.refreshControl!.addTarget(self, action: #selector(refresh), for: .valueChanged) #endif + + filterer.filtersChanged = { [unowned self] actionsChanged in + self.reapplyFilters(actionsChanged: actionsChanged) + } } private func createDataSource() -> UICollectionViewDiffableDataSource { - let statusCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in + let statusCell = UICollectionView.CellRegistration { [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: .allow, precomputedContent: nil) + cell.updateUI(statusID: statusID, state: statusState, filterResult: itemIdentifier.2, precomputedContent: itemIdentifier.3) } let actionGroupCell = UICollectionView.CellRegistration { [unowned self] cell, indexPath, itemIdentifier in cell.delegate = self @@ -140,12 +154,23 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle config.text = "Unknown Notification" cell.contentConfiguration = config } + let zeroHeightCell = UICollectionView.CellRegistration { _, _, _ in + } return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in switch itemIdentifier { - case .group(let group, let collapseState): + case .group(let group, let collapseState, let filterState): switch group.kind { case .status, .mention: - return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (group, collapseState!)) + 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: return collectionView.dequeueConfiguredReusableCell(using: actionGroupCell, for: indexPath, item: group) case .follow: @@ -192,8 +217,44 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle } } + 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) + } + private func dismissNotificationsInGroup(at indexPath: IndexPath) async { - guard case .group(let group, let collapseState) = dataSource.itemIdentifier(for: indexPath) else { + guard case .group(let group, let collapseState, let filterState) = dataSource.itemIdentifier(for: indexPath) else { return } let notifications = group.notifications @@ -216,11 +277,11 @@ class NotificationsCollectionViewController: UIViewController, TimelineLikeColle } var snapshot = dataSource.snapshot() if dismissFailedIndices.isEmpty { - snapshot.deleteItems([.group(group, collapseState)]) + 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)!, collapseState)], afterItem: .group(group, collapseState)) - snapshot.deleteItems([.group(group, collapseState)]) + snapshot.insertItems([.group(NotificationGroup(notifications: dismissFailed)!, collapseState, filterState)], afterItem: .group(group, collapseState, filterState)) + snapshot.deleteItems([.group(group, collapseState, filterState)]) } await apply(snapshot, animatingDifferences: true) } @@ -235,22 +296,22 @@ extension NotificationsCollectionViewController { static var entries: Self { .notifications } } enum Item: TimelineLikeCollectionViewItem { - case group(NotificationGroup, CollapseState?) + 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) + return .group(item, .unknown, .unknown) default: - return .group(item, nil) + return .group(item, nil, nil) } } static func ==(lhs: Item, rhs: Item) -> Bool { switch (lhs, rhs) { - case (.group(let a, _), .group(let b, _)): + case (.group(let a, _, _), .group(let b, _, _)): return a == b case (.loadingIndicator, .loadingIndicator): return true @@ -263,7 +324,7 @@ extension NotificationsCollectionViewController { func hash(into hasher: inout Hasher) { switch self { - case .group(let group, _): + case .group(let group, _, _): hasher.combine(0) hasher.combine(group) case .loadingIndicator: @@ -274,7 +335,7 @@ extension NotificationsCollectionViewController { } var group: NotificationGroup? { - if case .group(let group, _) = self { + if case .group(let group, _, _) = self { return group } else { return nil @@ -283,7 +344,7 @@ extension NotificationsCollectionViewController { var isSelectable: Bool { switch self { - case .group(_, _): + case .group(_, _, _): return true default: return false @@ -399,7 +460,7 @@ extension NotificationsCollectionViewController { $0.id == topID } }! - if let newTopIndexPath = dataSource.indexPath(for: .group(newTopGroup, nil)) { + if let newTopIndexPath = dataSource.indexPath(for: .group(newTopGroup, nil, nil)) { collectionView.scrollToItem(at: newTopIndexPath, at: .top, animated: false) } } @@ -459,14 +520,24 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate { } func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - guard case .group(let group, let collapseState) = dataSource.itemIdentifier(for: indexPath) else { + 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: - let statusID = group.notifications.first!.status!.id - let state = collapseState?.copy() ?? .unknown - selected(status: statusID, state: state) + 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: let type = group.kind == .favourite ? StatusActionAccountListViewController.ActionType.favorite : .reblog let statusID = group.notifications.first!.status!.id @@ -493,7 +564,7 @@ extension NotificationsCollectionViewController: UICollectionViewDelegate { } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - guard case .group(let group, let collapseState) = dataSource.itemIdentifier(for: indexPath), + guard case .group(let group, let collapseState, _) = dataSource.itemIdentifier(for: indexPath), let cell = collectionView.cellForItem(at: indexPath) else { return nil } @@ -566,7 +637,7 @@ 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 { + guard case .group(let group, _, _) = dataSource.itemIdentifier(for: indexPath) else { return [] } switch group.kind { @@ -616,5 +687,13 @@ extension NotificationsCollectionViewController: StatusCollectionViewCellDelegat } 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) + } } } diff --git a/Tusker/Views/Status/StatusCollectionViewCell.swift b/Tusker/Views/Status/StatusCollectionViewCell.swift index 96bb3fa0..77982eb0 100644 --- a/Tusker/Views/Status/StatusCollectionViewCell.swift +++ b/Tusker/Views/Status/StatusCollectionViewCell.swift @@ -119,12 +119,6 @@ extension StatusCollectionViewCell { favoriteButton.isEnabled = mastodonController.loggedIn let didResolve = statusState.resolveFor(status: status, height: self.estimateContentHeight) -// let didResolve = statusState.resolveFor(status: status) { -//// // layout so that we can take the content height into consideration when deciding whether to collapse -//// layoutIfNeeded() -//// return contentContainer.visibleSubviewHeight -// return contentContainer.estimateVisibleSubviewHeight(effectiveWidth: ) -// } if didResolve { if statusState.collapsible! && showStatusAutomatically { statusState.collapsed = false