Tusker/Tusker/Filterer.swift

253 lines
8.4 KiB
Swift

//
// Filterer.swift
// Tusker
//
// Created by Shadowfacts on 12/3/22.
// Copyright © 2022 Shadowfacts. All rights reserved.
//
import Foundation
import Pachyderm
import Combine
/// An opaque object that serves as the cache for the filtered-ness of a particular status.
class FilterState: @unchecked Sendable {
static var unknown: FilterState { FilterState(state: .unknown) }
fileprivate var state: State
var isWarning: Bool {
switch state {
case .known(.warn(_), _):
return true
default:
return false
}
}
private init(state: State) {
self.state = state
}
fileprivate enum State {
case unknown
case known(Filterer.Result, generation: Int)
}
}
@MainActor
class Filterer {
let mastodonController: MastodonController
let context: FilterV1.Context
var filtersChanged: ((Bool) -> Void)?
private var htmlConverter: HTMLConverter
private var hasSetup = false
private var matchers = [(NSRegularExpression, Result)]()
private var cancellables = Set<AnyCancellable>()
private var filterObservers = Set<AnyCancellable>()
private var hideReblogsInTimelines: Bool
private var hideRepliesInTimelines: Bool
// 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, htmlConverter: HTMLConverter) {
self.mastodonController = mastodonController
self.context = context
self.htmlConverter = htmlConverter
self.hideReblogsInTimelines = Preferences.shared.hideReblogsInTimelines
self.hideRepliesInTimelines = Preferences.shared.hideRepliesInTimelines
mastodonController.$filters
.sink { [unowned self] in
if self.hasSetup {
self.setupFilters(filters: $0)
}
}
.store(in: &cancellables)
if context == .home {
Preferences.shared.$hideReblogsInTimelines
.sink { [unowned self] newValue in
if newValue != hideReblogsInTimelines {
self.hideReblogsInTimelines = newValue
self.generation += 1
self.filtersChanged?(true)
}
}
.store(in: &cancellables)
Preferences.shared.$hideRepliesInTimelines
.sink { [unowned self] newValue in
if newValue != hideRepliesInTimelines {
self.hideRepliesInTimelines = newValue
self.generation += 1
self.filtersChanged?(true)
}
}
.store(in: &cancellables)
}
}
private func setupFilters(filters: [FilterMO]) {
let oldMatchers = matchers
matchers = []
filterObservers = []
for filter in filters where (filter.expiresAt == nil || filter.expiresAt! > Date()) && filter.contexts.contains(context) {
guard let matcher = filter.createMatcher() else {
continue
}
matchers.append(matcher)
filter.objectWillChange
.sink { [unowned self] _ in
// wait until after the change happens
DispatchQueue.main.async {
self.setupFilters(filters: self.mastodonController.filters)
}
}
.store(in: &filterObservers)
}
if hasSetup {
var allMatch: Bool = true
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
if new.1 == .hide {
// if the pattern's changed and the action is hide, then the cell type for existing items may change
actionsChanged = true
break
} else {
// continue because we want to know if any actions changed
continue
}
}
}
}
if !allMatch {
generation += 1
filtersChanged?(actionsChanged)
}
} else {
hasSetup = true
}
}
// 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, Bool)) -> (Filterer.Result, NSAttributedString?) {
switch state.state {
case .known(_, generation: let knownGen) where knownGen < generation:
fallthrough
case .unknown:
let (status, isReblog) = status()
let (result, attributedString) = doResolve(status: status, isReblog: isReblog)
state.state = .known(result, generation: generation)
return (result, attributedString)
case .known(let result, _):
return (result, nil)
}
}
func setResult(_ result: Result, for state: FilterState) {
state.state = .known(result, generation: generation)
}
func isKnownHide(state: FilterState) -> Bool {
switch state.state {
case .known(.hide, generation: let gen) where gen >= generation:
return true
default:
return false
}
}
private func doResolve(status: StatusMO, isReblog: Bool) -> (Result, NSAttributedString?) {
if !hasSetup {
setupFilters(filters: mastodonController.filters)
}
if context == .home {
if hideReblogsInTimelines,
isReblog {
return (.hide, nil)
} else if hideRepliesInTimelines,
status.inReplyToID != nil {
return (.hide, nil)
}
}
if matchers.isEmpty {
return (.allow, nil)
}
@Lazy var text = self.htmlConverter.convert(status.content)
for (regex, result) in matchers {
if (!status.spoilerText.isEmpty && regex.numberOfMatches(in: status.spoilerText, range: NSRange(location: 0, length: status.spoilerText.utf16.count)) > 0)
|| regex.numberOfMatches(in: text.string, range: NSRange(location: 0, length: text.length)) > 0 {
return (result, _text.valueIfInitialized)
}
}
return (.allow, _text.valueIfInitialized)
}
enum Result: Equatable {
case allow
case hide
case warn(String)
}
}
private extension FilterMO {
func createMatcher() -> (NSRegularExpression, Filterer.Result)? {
guard keywords.count > 0 else {
return nil
}
// TODO: it would be cool to use the Regex builder stuff for this, but it's iOS 16 only
var pattern = ""
var isFirst = true
for keyword in keywordMOs {
if isFirst {
isFirst = false
} else {
pattern += "|"
}
pattern += "("
if keyword.wholeWord {
pattern += "\\b"
}
pattern += NSRegularExpression.escapedPattern(for: keyword.keyword)
if keyword.wholeWord {
pattern += "\\b"
}
pattern += ")"
}
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.useUnicodeWordBoundaries, .caseInsensitive]) else {
return nil
}
let result: Filterer.Result
switch filterAction {
case .hide:
result = .hide
case .warn:
result = .warn(titleOrKeyword)
}
return (regex, result)
}
}