// // 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() private var filterObservers = Set() 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) } }