252 lines
8.3 KiB
Swift
252 lines
8.3 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)?
|
|
|
|
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) {
|
|
self.mastodonController = mastodonController
|
|
self.context = context
|
|
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)
|
|
}
|
|
}
|