diff --git a/Tusker/Filterer.swift b/Tusker/Filterer.swift index 41e894f2..e715bbdc 100644 --- a/Tusker/Filterer.swift +++ b/Tusker/Filterer.swift @@ -10,14 +10,15 @@ import Foundation import Pachyderm import Combine +/// An opaque object that serves as the cache for the filtered-ness of a particular status. class FilterState { static var unknown: FilterState { FilterState(state: .unknown) } - private var state: State + fileprivate var state: State var isWarning: Bool { switch state { - case .known(.warn(_)): + case .known(.warn(_), _): return true default: return false @@ -28,26 +29,9 @@ class FilterState { self.state = state } - // Use a closure for the status in case the result is cached and we don't need to look it up - @MainActor - func resolveFor(status: () -> StatusMO, resolver: Filterer) -> Filterer.Result { - switch state { - case .unknown: - let result = resolver.resolve(status: status()) - state = .known(result) - return result - case .known(let result): - return result - } - } - - func setResult(_ result: Filterer.Result) { - self.state = .known(result) - } - - private enum State { + fileprivate enum State { case unknown - case known(Filterer.Result) + case known(Filterer.Result, generation: Int) } } @@ -56,24 +40,33 @@ class Filterer { let mastodonController: MastodonController let context: FilterV1.Context + var filtersChanged: ((Bool) -> Void)? + private let htmlConverter = HTMLConverter() - private var needsSetupFilters = true + private var hasSetup = false private var matchers = [(NSRegularExpression, Result)]() - private var filtersChanged: AnyCancellable? + private var allFiltersObserver: AnyCancellable? private var filterObservers = Set() + // the generation is incremented when the matchers change, to indicate that older cached FilterStates + // are no longer valid, without needing to go through and update each of them + private var generation = 0 + init(mastodonController: MastodonController, context: FilterV1.Context) { self.mastodonController = mastodonController self.context = context - filtersChanged = mastodonController.$filters - .sink { [unowned self] _ in - self.needsSetupFilters = true + allFiltersObserver = mastodonController.$filters + .sink { [unowned self] in + if self.hasSetup { + self.setupFilters(filters: $0) + } } } private func setupFilters(filters: [FilterMO]) { - print("setting up filters") + let oldMatchers = matchers + matchers = [] filterObservers = [] for filter in filters where filter.contexts.contains(context) { @@ -84,15 +77,62 @@ class Filterer { filter.objectWillChange .sink { [unowned self] _ in - self.needsSetupFilters = true + // wait until after the change happens + DispatchQueue.main.async { + self.setupFilters(filters: self.mastodonController.filters) + } } .store(in: &filterObservers) } - needsSetupFilters = false + + if hasSetup { + var allMatch: Bool = false + var actionsChanged: Bool = false + if matchers.count != oldMatchers.count { + allMatch = false + actionsChanged = true + } else { + for (old, new) in zip(oldMatchers, matchers) { + if old.1 != new.1 { + allMatch = false + actionsChanged = true + break + } else if old.0.pattern != new.0.pattern { + allMatch = false + // continue because we want to know if any actions changed + continue + } + } + } + if !allMatch { + generation += 1 + filtersChanged?(actionsChanged) + } + } else { + hasSetup = true + } } - func resolve(status: StatusMO) -> Result { - if needsSetupFilters { + // Use a closure for the status in case the result is cached and we don't need to look it up + func resolve(state: FilterState, status: () -> StatusMO) -> Filterer.Result { + switch state.state { + case .known(_, generation: let knownGen) where knownGen < generation: + fallthrough + case .unknown: + let result = doResolve(status: status()) + state.state = .known(result, generation: generation) + return result + case .known(let result, _): + return result + } + } + + func setResult(_ result: Result, for state: FilterState) { + state.state = .known(result, generation: generation) + } + + private func doResolve(status: StatusMO) -> Result { + if !hasSetup { setupFilters(filters: mastodonController.filters) } if matchers.isEmpty { @@ -108,7 +148,7 @@ class Filterer { return .allow } - enum Result { + enum Result: Equatable { case allow case hide case warn(String) diff --git a/Tusker/Screens/Profile/ProfileStatusesViewController.swift b/Tusker/Screens/Profile/ProfileStatusesViewController.swift index b10216b6..1b8c8b8f 100644 --- a/Tusker/Screens/Profile/ProfileStatusesViewController.swift +++ b/Tusker/Screens/Profile/ProfileStatusesViewController.swift @@ -108,6 +108,10 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie } } .store(in: &cancellables) + + filterer.filtersChanged = { [unowned self] actionsChanged in + self.reapplyFilters(actionsChanged: actionsChanged) + } } private func createDataSource() -> UICollectionViewDiffableDataSource { @@ -142,12 +146,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie return cell } case .status(id: let id, collapseState: let collapseState, filterState: let filterState, pinned: let pinned): - let status = { - let status = self.mastodonController.persistentContainer.status(for: id)! - // if the status is a reblog of another one, filter based on that one - return status.reblog ?? status - } - let result = filterState.resolveFor(status: status, resolver: filterer) + let result = filterResult(state: filterState, statusID: id) return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, collapseState, result, pinned)) case .loadingIndicator: return loadingIndicatorCell(for: indexPath) @@ -246,6 +245,38 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie await apply(snapshot, animatingDifferences: true) } + private func filterResult(state: FilterState, statusID: String) -> Filterer.Result { + let status = { + let status = self.mastodonController.persistentContainer.status(for: statusID)! + // if the status is a reblog of another one, filter based on that one + return status.reblog ?? status + } + 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 .status(_, _, _, _) = $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 func refresh() { guard case .loaded = state else { #if !targetEnvironment(macCatalyst) @@ -458,7 +489,7 @@ extension ProfileStatusesViewController: UICollectionViewDelegate { return } if filterState.isWarning { - filterState.setResult(.allow) + filterer.setResult(.allow, for: filterState) collectionView.deselectItem(at: indexPath, animated: true) var snapshot = dataSource.snapshot() snapshot.reconfigureItems([item]) @@ -504,7 +535,7 @@ extension ProfileStatusesViewController: StatusCollectionViewCellDelegate { if let indexPath = collectionView.indexPath(for: cell), let item = dataSource.itemIdentifier(for: indexPath), case .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item { - filterState.setResult(.allow) + filterer.setResult(.allow, for: filterState) var snapshot = dataSource.snapshot() snapshot.reconfigureItems([item]) dataSource.apply(snapshot, animatingDifferences: true) diff --git a/Tusker/Screens/Timeline/TimelineViewController.swift b/Tusker/Screens/Timeline/TimelineViewController.swift index 5993172e..1e2b4aa0 100644 --- a/Tusker/Screens/Timeline/TimelineViewController.swift +++ b/Tusker/Screens/Timeline/TimelineViewController.swift @@ -69,7 +69,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro config.topSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden } else if case .status(id: let id, collapseState: _, filterState: let filterState) = item, - case .hide = filterState.resolveFor(status: { mastodonController.persistentContainer.status(for: id)! }, resolver: filterer) { + case .hide = filterResult(state: filterState, statusID: id) { config.topSeparatorVisibility = .hidden config.bottomSeparatorVisibility = .hidden } else { @@ -108,6 +108,10 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro } } + filterer.filtersChanged = { [unowned self] actionsChanged in + self.reapplyFilters(actionsChanged: actionsChanged) + } + NotificationCenter.default.addObserver(self, selector: #selector(sceneWillEnterForeground), name: UIScene.willEnterForegroundNotification, object: nil) } @@ -144,12 +148,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in switch itemIdentifier { case .status(id: let id, collapseState: let state, filterState: let filterState): - let status = { - let status = self.mastodonController.persistentContainer.status(for: id)! - // if the status is a reblog of another one, filter based on that one - return status.reblog ?? status - } - let result = filterState.resolveFor(status: status, resolver: filterer) + let result = filterResult(state: filterState, statusID: id) switch result { case .allow, .warn(_): return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, result)) @@ -330,6 +329,40 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro isShowingTimelineDescription = false } + private func filterResult(state: FilterState, statusID: String) -> Filterer.Result { + let status = { + let status = self.mastodonController.persistentContainer.status(for: statusID)! + // if the status is a reblog of another one, filter based on that one + return status.reblog ?? status + } + 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 .status(_, _, _) = $0 { + return true + } else { + return false + } + } + guard !items.isEmpty else { + return + } + var snapshot = dataSource.snapshot() + if actionsChanged { + // need to reload not just reconfigure because hidden posts use a separate cell type + snapshot.reloadItems(items) + } else { + // reconfigure when possible to avoid the content offset jumping around + snapshot.reconfigureItems(items) + } + dataSource.apply(snapshot) + } + @objc private func sceneWillEnterForeground(_ notification: Foundation.Notification) { guard let scene = notification.object as? UIScene, // view.window is nil when the VC is not on screen @@ -790,7 +823,7 @@ extension TimelineViewController: UICollectionViewDelegate { removeTimelineDescriptionCell() case .status(id: let id, collapseState: let collapseState, filterState: let filterState): if filterState.isWarning { - filterState.setResult(.allow) + filterer.setResult(.allow, for: filterState) collectionView.deselectItem(at: indexPath, animated: true) var snapshot = dataSource.snapshot() snapshot.reconfigureItems([item]) @@ -853,7 +886,7 @@ extension TimelineViewController: StatusCollectionViewCellDelegate { if let indexPath = collectionView.indexPath(for: cell), let item = dataSource.itemIdentifier(for: indexPath), case .status(id: _, collapseState: _, filterState: let filterState) = item { - filterState.setResult(.allow) + filterer.setResult(.allow, for: filterState) var snapshot = dataSource.snapshot() snapshot.reconfigureItems([item]) dataSource.apply(snapshot, animatingDifferences: true)