Reapply filters on when they change
This commit is contained in:
parent
fabe339215
commit
6501343f24
|
@ -10,14 +10,15 @@ import Foundation
|
||||||
import Pachyderm
|
import Pachyderm
|
||||||
import Combine
|
import Combine
|
||||||
|
|
||||||
|
/// An opaque object that serves as the cache for the filtered-ness of a particular status.
|
||||||
class FilterState {
|
class FilterState {
|
||||||
static var unknown: FilterState { FilterState(state: .unknown) }
|
static var unknown: FilterState { FilterState(state: .unknown) }
|
||||||
|
|
||||||
private var state: State
|
fileprivate var state: State
|
||||||
|
|
||||||
var isWarning: Bool {
|
var isWarning: Bool {
|
||||||
switch state {
|
switch state {
|
||||||
case .known(.warn(_)):
|
case .known(.warn(_), _):
|
||||||
return true
|
return true
|
||||||
default:
|
default:
|
||||||
return false
|
return false
|
||||||
|
@ -28,26 +29,9 @@ class FilterState {
|
||||||
self.state = state
|
self.state = state
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use a closure for the status in case the result is cached and we don't need to look it up
|
fileprivate enum State {
|
||||||
@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 {
|
|
||||||
case unknown
|
case unknown
|
||||||
case known(Filterer.Result)
|
case known(Filterer.Result, generation: Int)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -56,24 +40,33 @@ class Filterer {
|
||||||
let mastodonController: MastodonController
|
let mastodonController: MastodonController
|
||||||
let context: FilterV1.Context
|
let context: FilterV1.Context
|
||||||
|
|
||||||
|
var filtersChanged: ((Bool) -> Void)?
|
||||||
|
|
||||||
private let htmlConverter = HTMLConverter()
|
private let htmlConverter = HTMLConverter()
|
||||||
private var needsSetupFilters = true
|
private var hasSetup = false
|
||||||
private var matchers = [(NSRegularExpression, Result)]()
|
private var matchers = [(NSRegularExpression, Result)]()
|
||||||
private var filtersChanged: AnyCancellable?
|
private var allFiltersObserver: AnyCancellable?
|
||||||
private var filterObservers = Set<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) {
|
init(mastodonController: MastodonController, context: FilterV1.Context) {
|
||||||
self.mastodonController = mastodonController
|
self.mastodonController = mastodonController
|
||||||
self.context = context
|
self.context = context
|
||||||
|
|
||||||
filtersChanged = mastodonController.$filters
|
allFiltersObserver = mastodonController.$filters
|
||||||
.sink { [unowned self] _ in
|
.sink { [unowned self] in
|
||||||
self.needsSetupFilters = true
|
if self.hasSetup {
|
||||||
|
self.setupFilters(filters: $0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func setupFilters(filters: [FilterMO]) {
|
private func setupFilters(filters: [FilterMO]) {
|
||||||
print("setting up filters")
|
let oldMatchers = matchers
|
||||||
|
|
||||||
matchers = []
|
matchers = []
|
||||||
filterObservers = []
|
filterObservers = []
|
||||||
for filter in filters where filter.contexts.contains(context) {
|
for filter in filters where filter.contexts.contains(context) {
|
||||||
|
@ -84,15 +77,62 @@ class Filterer {
|
||||||
|
|
||||||
filter.objectWillChange
|
filter.objectWillChange
|
||||||
.sink { [unowned self] _ in
|
.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)
|
.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 {
|
// Use a closure for the status in case the result is cached and we don't need to look it up
|
||||||
if needsSetupFilters {
|
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)
|
setupFilters(filters: mastodonController.filters)
|
||||||
}
|
}
|
||||||
if matchers.isEmpty {
|
if matchers.isEmpty {
|
||||||
|
@ -108,7 +148,7 @@ class Filterer {
|
||||||
return .allow
|
return .allow
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Result {
|
enum Result: Equatable {
|
||||||
case allow
|
case allow
|
||||||
case hide
|
case hide
|
||||||
case warn(String)
|
case warn(String)
|
||||||
|
|
|
@ -108,6 +108,10 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.store(in: &cancellables)
|
.store(in: &cancellables)
|
||||||
|
|
||||||
|
filterer.filtersChanged = { [unowned self] actionsChanged in
|
||||||
|
self.reapplyFilters(actionsChanged: actionsChanged)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
private func createDataSource() -> UICollectionViewDiffableDataSource<Section, Item> {
|
||||||
|
@ -142,12 +146,7 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
return cell
|
return cell
|
||||||
}
|
}
|
||||||
case .status(id: let id, collapseState: let collapseState, filterState: let filterState, pinned: let pinned):
|
case .status(id: let id, collapseState: let collapseState, filterState: let filterState, pinned: let pinned):
|
||||||
let status = {
|
let result = filterResult(state: filterState, statusID: id)
|
||||||
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)
|
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, collapseState, result, pinned))
|
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, collapseState, result, pinned))
|
||||||
case .loadingIndicator:
|
case .loadingIndicator:
|
||||||
return loadingIndicatorCell(for: indexPath)
|
return loadingIndicatorCell(for: indexPath)
|
||||||
|
@ -246,6 +245,38 @@ class ProfileStatusesViewController: UIViewController, TimelineLikeCollectionVie
|
||||||
await apply(snapshot, animatingDifferences: true)
|
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() {
|
@objc func refresh() {
|
||||||
guard case .loaded = state else {
|
guard case .loaded = state else {
|
||||||
#if !targetEnvironment(macCatalyst)
|
#if !targetEnvironment(macCatalyst)
|
||||||
|
@ -458,7 +489,7 @@ extension ProfileStatusesViewController: UICollectionViewDelegate {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if filterState.isWarning {
|
if filterState.isWarning {
|
||||||
filterState.setResult(.allow)
|
filterer.setResult(.allow, for: filterState)
|
||||||
collectionView.deselectItem(at: indexPath, animated: true)
|
collectionView.deselectItem(at: indexPath, animated: true)
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
snapshot.reconfigureItems([item])
|
snapshot.reconfigureItems([item])
|
||||||
|
@ -504,7 +535,7 @@ extension ProfileStatusesViewController: StatusCollectionViewCellDelegate {
|
||||||
if let indexPath = collectionView.indexPath(for: cell),
|
if let indexPath = collectionView.indexPath(for: cell),
|
||||||
let item = dataSource.itemIdentifier(for: indexPath),
|
let item = dataSource.itemIdentifier(for: indexPath),
|
||||||
case .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item {
|
case .status(id: _, collapseState: _, filterState: let filterState, pinned: _) = item {
|
||||||
filterState.setResult(.allow)
|
filterer.setResult(.allow, for: filterState)
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
snapshot.reconfigureItems([item])
|
snapshot.reconfigureItems([item])
|
||||||
dataSource.apply(snapshot, animatingDifferences: true)
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
|
|
@ -69,7 +69,7 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
config.topSeparatorVisibility = .hidden
|
config.topSeparatorVisibility = .hidden
|
||||||
config.bottomSeparatorVisibility = .hidden
|
config.bottomSeparatorVisibility = .hidden
|
||||||
} else if case .status(id: let id, collapseState: _, filterState: let filterState) = item,
|
} 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.topSeparatorVisibility = .hidden
|
||||||
config.bottomSeparatorVisibility = .hidden
|
config.bottomSeparatorVisibility = .hidden
|
||||||
} else {
|
} 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)
|
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
|
return UICollectionViewDiffableDataSource(collectionView: collectionView) { [unowned self] collectionView, indexPath, itemIdentifier in
|
||||||
switch itemIdentifier {
|
switch itemIdentifier {
|
||||||
case .status(id: let id, collapseState: let state, filterState: let filterState):
|
case .status(id: let id, collapseState: let state, filterState: let filterState):
|
||||||
let status = {
|
let result = filterResult(state: filterState, statusID: id)
|
||||||
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)
|
|
||||||
switch result {
|
switch result {
|
||||||
case .allow, .warn(_):
|
case .allow, .warn(_):
|
||||||
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, result))
|
return collectionView.dequeueConfiguredReusableCell(using: statusCell, for: indexPath, item: (id, state, result))
|
||||||
|
@ -330,6 +329,40 @@ class TimelineViewController: UIViewController, TimelineLikeCollectionViewContro
|
||||||
isShowingTimelineDescription = false
|
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) {
|
@objc private func sceneWillEnterForeground(_ notification: Foundation.Notification) {
|
||||||
guard let scene = notification.object as? UIScene,
|
guard let scene = notification.object as? UIScene,
|
||||||
// view.window is nil when the VC is not on screen
|
// view.window is nil when the VC is not on screen
|
||||||
|
@ -790,7 +823,7 @@ extension TimelineViewController: UICollectionViewDelegate {
|
||||||
removeTimelineDescriptionCell()
|
removeTimelineDescriptionCell()
|
||||||
case .status(id: let id, collapseState: let collapseState, filterState: let filterState):
|
case .status(id: let id, collapseState: let collapseState, filterState: let filterState):
|
||||||
if filterState.isWarning {
|
if filterState.isWarning {
|
||||||
filterState.setResult(.allow)
|
filterer.setResult(.allow, for: filterState)
|
||||||
collectionView.deselectItem(at: indexPath, animated: true)
|
collectionView.deselectItem(at: indexPath, animated: true)
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
snapshot.reconfigureItems([item])
|
snapshot.reconfigureItems([item])
|
||||||
|
@ -853,7 +886,7 @@ extension TimelineViewController: StatusCollectionViewCellDelegate {
|
||||||
if let indexPath = collectionView.indexPath(for: cell),
|
if let indexPath = collectionView.indexPath(for: cell),
|
||||||
let item = dataSource.itemIdentifier(for: indexPath),
|
let item = dataSource.itemIdentifier(for: indexPath),
|
||||||
case .status(id: _, collapseState: _, filterState: let filterState) = item {
|
case .status(id: _, collapseState: _, filterState: let filterState) = item {
|
||||||
filterState.setResult(.allow)
|
filterer.setResult(.allow, for: filterState)
|
||||||
var snapshot = dataSource.snapshot()
|
var snapshot = dataSource.snapshot()
|
||||||
snapshot.reconfigureItems([item])
|
snapshot.reconfigureItems([item])
|
||||||
dataSource.apply(snapshot, animatingDifferences: true)
|
dataSource.apply(snapshot, animatingDifferences: true)
|
||||||
|
|
Loading…
Reference in New Issue