Reapply filters on when they change

This commit is contained in:
Shadowfacts 2022-12-04 10:54:02 -05:00
parent fabe339215
commit 6501343f24
3 changed files with 153 additions and 49 deletions

View File

@ -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<AnyCancellable>()
// 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)

View File

@ -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<Section, Item> {
@ -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)

View File

@ -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)