2
2
mirror of https://github.com/mastodon/mastodon-ios synced 2025-04-11 22:58:02 +02:00

Centralize filter management

Instead of publishing a list of filters, the StatusFilterService now publishes a struct that can apply those filters to any status in any context.

Also, we now use V2 of the filters API, which distinguishes between hide and warn.

Fixes  #1354 [BUG] Mastodon iOS App Ignores "Hide completely" Filter action Setting
This commit is contained in:
shannon 2024-11-26 10:46:27 -05:00
parent 311bbc0984
commit 862e1186ce
22 changed files with 315 additions and 216 deletions

View File

@ -28,8 +28,7 @@ extension StatusSection {
let authenticationBox: MastodonAuthenticationBox
weak var statusTableViewCellDelegate: StatusTableViewCellDelegate?
weak var timelineMiddleLoaderTableViewCellDelegate: TimelineMiddleLoaderTableViewCellDelegate?
let filterContext: Mastodon.Entity.Filter.Context?
let activeFilters: Published<[Mastodon.Entity.Filter]>.Publisher?
let filterContext: Mastodon.Entity.FilterContext?
}
static func diffableDataSource(
@ -221,9 +220,6 @@ extension StatusSection {
)
cell.statusView.viewModel.filterContext = configuration.filterContext
configuration.activeFilters?
.assign(to: \.activeFilters, on: cell.statusView.viewModel)
.store(in: &cell.disposeBag)
}
static func configure(
@ -247,11 +243,7 @@ extension StatusSection {
viewModel: viewModel,
delegate: configuration.statusTableViewCellDelegate
)
cell.statusView.viewModel.filterContext = configuration.filterContext
configuration.activeFilters?
.assign(to: \.activeFilters, on: cell.statusView.viewModel)
.store(in: &cell.disposeBag)
}
static func configure(

View File

@ -22,8 +22,7 @@ extension DiscoveryPostsViewModel {
authenticationBox: authenticationBox,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: nil,
filterContext: .none,
activeFilters: nil
filterContext: nil
)
)

View File

@ -23,8 +23,7 @@ extension HashtagTimelineViewModel {
authenticationBox: authenticationBox,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: nil,
filterContext: .none,
activeFilters: nil
filterContext: nil
)
)

View File

@ -25,8 +25,7 @@ extension HomeTimelineViewModel {
authenticationBox: authenticationBox,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate,
filterContext: .home,
activeFilters: StatusFilterService.shared.$activeFilters
filterContext: .home
)
)

View File

@ -27,8 +27,7 @@ extension NotificationSection {
struct Configuration {
let authenticationBox: MastodonAuthenticationBox
weak var notificationTableViewCellDelegate: NotificationTableViewCellDelegate?
let filterContext: Mastodon.Entity.Filter.Context?
let activeFilters: Published<[Mastodon.Entity.Filter]>.Publisher?
let filterContext: Mastodon.Entity.FilterContext?
}
static func diffableDataSource(
@ -109,13 +108,6 @@ extension NotificationSection {
cell.notificationView.statusView.viewModel.filterContext = configuration.filterContext
cell.notificationView.quoteStatusView.viewModel.filterContext = configuration.filterContext
configuration.activeFilters?
.assign(to: \.activeFilters, on: cell.notificationView.statusView.viewModel)
.store(in: &cell.disposeBag)
configuration.activeFilters?
.assign(to: \.activeFilters, on: cell.notificationView.quoteStatusView.viewModel)
.store(in: &cell.disposeBag)
}
}

View File

@ -22,8 +22,7 @@ extension NotificationTimelineViewModel {
configuration: NotificationSection.Configuration(
authenticationBox: authenticationBox,
notificationTableViewCellDelegate: notificationTableViewCellDelegate,
filterContext: .notifications,
activeFilters: StatusFilterService.shared.$activeFilters
filterContext: .notifications
)
)

View File

@ -21,8 +21,7 @@ extension BookmarkViewModel {
authenticationBox: authenticationBox,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: nil,
filterContext: .none,
activeFilters: nil
filterContext: nil
)
)
// set empty section to make update animation top-to-bottom style

View File

@ -21,8 +21,7 @@ extension FavoriteViewModel {
authenticationBox: authenticationBox,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: nil,
filterContext: .none,
activeFilters: nil
filterContext: nil
)
)
// set empty section to make update animation top-to-bottom style

View File

@ -22,8 +22,7 @@ extension UserTimelineViewModel {
authenticationBox: authenticationBox,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: nil,
filterContext: .none,
activeFilters: nil
filterContext: nil
)
)

View File

@ -23,7 +23,7 @@ final class MastodonStatusThreadViewModel {
// input
let context: AppContext
let filterApplication: Mastodon.Entity.Filter.FilterApplication?
let filterContext: Mastodon.Entity.FilterContext?
@Published private(set) var deletedObjectIDs: Set<MastodonStatus.ID> = Set()
// output
@ -33,9 +33,9 @@ final class MastodonStatusThreadViewModel {
@Published var __descendants: [StatusItem] = []
@Published var descendants: [StatusItem] = []
init(context: AppContext, filterApplication: Mastodon.Entity.Filter.FilterApplication?) {
init(context: AppContext, filterContext: Mastodon.Entity.FilterContext?) {
self.context = context
self.filterApplication = filterApplication
self.filterContext = filterContext
Publishers.CombineLatest(
$__ancestors,
@ -83,14 +83,14 @@ extension MastodonStatusThreadViewModel {
func appendAncestor(
nodes: [Node]
) {
) async {
var newItems: [StatusItem] = []
for node in nodes {
if let filterApplication {
let filterResult = filterApplication.apply(to: node.status, in: .thread)
if let filterContext, let filterApplication = await StatusFilterService.shared.activeFilterApplication {
let filterResult = filterApplication.apply(to: node.status, in: filterContext)
switch filterResult {
case .hidden:
case .hide:
continue
default:
break
@ -107,16 +107,16 @@ extension MastodonStatusThreadViewModel {
func appendDescendant(
nodes: [Node]
) {
) async {
var newItems: [StatusItem] = []
for node in nodes {
if let filterApplication {
let filterResult = filterApplication.apply(to: node.status, in: .thread)
if let filterContext, let filterApplication = await StatusFilterService.shared.activeFilterApplication {
let filterResult = filterApplication.apply(to: node.status, in: filterContext)
switch filterResult {
case .hidden:
case .hide:
continue
default:
break

View File

@ -28,8 +28,7 @@ extension ThreadViewModel {
authenticationBox: authenticationBox,
statusTableViewCellDelegate: statusTableViewCellDelegate,
timelineMiddleLoaderTableViewCellDelegate: nil,
filterContext: .thread,
activeFilters: StatusFilterService.shared.$activeFilters
filterContext: .thread
)
)

View File

@ -74,14 +74,14 @@ extension ThreadViewModel.LoadThreadState {
_ = try await APIService.shared.getHistory(forStatusID: threadContext.statusID,
authenticationBox: viewModel.authenticationBox)
viewModel.mastodonStatusThreadViewModel.appendAncestor(
await viewModel.mastodonStatusThreadViewModel.appendAncestor(
nodes: MastodonStatusThreadViewModel.Node.replyToThread(
for: threadContext.replyToID,
from: response.value.ancestors
)
)
viewModel.mastodonStatusThreadViewModel.appendDescendant(
await viewModel.mastodonStatusThreadViewModel.appendDescendant(
nodes: response.value.descendants.map { status in
return .init(status: .fromEntity(status), children: [])
}

View File

@ -54,11 +54,10 @@ class ThreadViewModel {
authenticationBox: MastodonAuthenticationBox,
optionalRoot: StatusItem.Thread?
) {
let filterApplication = Mastodon.Entity.Filter.FilterApplication(filters: StatusFilterService.shared.activeFilters.filter { $0.context.contains(.thread) })
self.context = context
self.authenticationBox = authenticationBox
self.root = optionalRoot
self.mastodonStatusThreadViewModel = MastodonStatusThreadViewModel(context: context, filterApplication: filterApplication)
self.mastodonStatusThreadViewModel = MastodonStatusThreadViewModel(context: context, filterContext: .thread)
// end init
$root

View File

@ -16,23 +16,20 @@ final public class FeedDataController {
private let kind: MastodonFeed.Kind
private var subscriptions = Set<AnyCancellable>()
private var filterApplication: Mastodon.Entity.Filter.FilterApplication? {
didSet {
records = filter(records, forFeed: kind)
}
}
public init(context: AppContext, authenticationBox: MastodonAuthenticationBox, kind: MastodonFeed.Kind) {
self.context = context
self.authenticationBox = authenticationBox
self.kind = kind
self.filterApplication = Mastodon.Entity.Filter.FilterApplication(filters: StatusFilterService.shared.activeFilters)
StatusFilterService.shared.$activeFilters
.sink { [weak self] filters in
guard let self else { return }
self.filterApplication = Mastodon.Entity.Filter.FilterApplication(filters: filters)
StatusFilterService.shared.$activeFilterApplication
.sink { filterApplication in
if let filterApplication {
Task { [weak self] in
guard let self else { return }
self.records = await self.filter(self.records, forFeed: kind, with: filterApplication)
}
}
}
.store(in: &subscriptions)
}
@ -40,7 +37,11 @@ final public class FeedDataController {
public func loadInitial(kind: MastodonFeed.Kind) {
Task {
let unfilteredRecords = try await load(kind: kind, maxID: nil)
records = filter(unfilteredRecords, forFeed: kind)
if let filterApplication = StatusFilterService.shared.activeFilterApplication {
records = await filter(unfilteredRecords, forFeed: kind, with: filterApplication)
} else {
records = unfilteredRecords
}
}
}
@ -51,23 +52,27 @@ final public class FeedDataController {
}
let unfiltered = try await load(kind: kind, maxID: lastId)
records += filter(unfiltered, forFeed: kind)
if let filterApplication = StatusFilterService.shared.activeFilterApplication {
records += await filter(unfiltered, forFeed: kind, with: filterApplication)
} else {
records += unfiltered
}
}
}
private func filter(_ records: [MastodonFeed], forFeed feedKind: MastodonFeed.Kind) -> [MastodonFeed] {
guard let filterApplication else { return records }
return records.filter {
guard let status = $0.status else { return true }
private func filter(_ records: [MastodonFeed], forFeed feedKind: MastodonFeed.Kind, with filterApplication: Mastodon.Entity.FilterApplication) async -> [MastodonFeed] {
let filteredRecords = records.filter { feedRecord in
guard let status = feedRecord.status else { return true }
let filterResult = filterApplication.apply(to: status, in: feedKind.filterContext)
switch filterResult {
case .hidden:
case .hide:
return false
default:
return true
}
}
return filteredRecords
}
@MainActor
@ -268,7 +273,7 @@ private extension FeedDataController {
}
extension MastodonFeed.Kind {
var filterContext: Mastodon.Entity.Filter.Context {
var filterContext: Mastodon.Entity.FilterContext {
switch self {
case .home(let timeline): // TODO: take timeline into account. See iOS-333.
return .home

View File

@ -8,75 +8,94 @@
import MastodonSDK
import NaturalLanguage
public extension Mastodon.Entity.Filter {
struct FilterApplication {
let nonWordFilters: [Mastodon.Entity.Filter]
let hideWords: [Context : [String]]
let warnWords: [Context : [String]]
public extension Mastodon.Entity {
struct FilterApplication: Equatable {
let hideAnyMatch: [FilterContext : [String]]
let warnAnyMatch: [FilterContext : [String]]
let hideWholeWordMatch: [FilterContext : [String]]
let warnWholeWordMatch: [FilterContext : [String]]
public init?(filters: [Mastodon.Entity.Filter]) {
public init?(filters: [Mastodon.Entity.FilterInfo]) {
guard !filters.isEmpty else { return nil }
var wordFilters: [Mastodon.Entity.Filter] = []
var nonWordFilters: [Mastodon.Entity.Filter] = []
var _hideAnyMatch = [FilterContext : [String]]()
var _warnAnyMatch = [FilterContext : [String]]()
var _hideWholeWordMatch = [FilterContext : [String]]()
var _warnWholeWordMatch = [FilterContext : [String]]()
for filter in filters {
if filter.wholeWord {
wordFilters.append(filter)
} else {
nonWordFilters.append(filter)
}
}
self.nonWordFilters = nonWordFilters
var hidePhraseWords = [Context : [String]]()
var warnPhraseWords = [Context : [String]]()
for filter in wordFilters {
if filter.filterAction ?? ._other("DEFAULT") == .hide {
for context in filter.context {
var words = hidePhraseWords[context] ?? [String]()
words.append(filter.phrase.lowercased())
hidePhraseWords[context] = words
}
} else {
for context in filter.context {
var words = warnPhraseWords[context] ?? [String]()
words.append(filter.phrase.lowercased())
warnPhraseWords[context] = words
for context in filter.filterContexts {
let partialWords = filter.matchAll
let wholeWords = filter.matchWholeWordOnly
switch filter.filterAction {
case .hide:
var words = _hideWholeWordMatch[context] ?? []
words.append(contentsOf: wholeWords)
_hideWholeWordMatch[context] = words
words = _hideAnyMatch[context] ?? []
words.append(contentsOf: partialWords)
_hideAnyMatch[context] = words
case .warn, ._other:
var words = _warnWholeWordMatch[context] ?? []
words.append(contentsOf: wholeWords)
_warnWholeWordMatch[context] = words
words = _warnAnyMatch[context] ?? []
words.append(contentsOf: partialWords)
_warnAnyMatch[context] = words
}
}
}
self.hideWords = hidePhraseWords
self.warnWords = warnPhraseWords
warnAnyMatch = _warnAnyMatch
warnWholeWordMatch = _warnWholeWordMatch
hideAnyMatch = _hideAnyMatch
hideWholeWordMatch = _hideWholeWordMatch
}
public func apply(to status: MastodonStatus, in context: Context) -> Mastodon.Entity.Filter.FilterStatus {
public func apply(to status: MastodonStatus, in context: FilterContext) -> Mastodon.Entity.FilterResult {
let status = status.reblog ?? status
let defaultFilterResult = Mastodon.Entity.Filter.FilterStatus.notFiltered
let defaultFilterResult = Mastodon.Entity.FilterResult.notFiltered
guard let content = status.entity.content?.lowercased() else { return defaultFilterResult }
return apply(to: content, in: context)
}
for filter in nonWordFilters {
guard filter.context.contains(context) else { continue }
guard content.contains(filter.phrase.lowercased()) else { continue }
switch filter.filterAction {
case .hide:
return .hidden
default:
return .filtered(filter.phrase)
public func apply(to content: String?, in context: FilterContext) -> Mastodon.Entity.FilterResult {
let defaultFilterResult = Mastodon.Entity.FilterResult.notFiltered
guard let content else { return defaultFilterResult }
if let warnAny = warnAnyMatch[context] {
for partialMatchable in warnAny {
if content.contains(partialMatchable) {
return .warn(partialMatchable)
}
}
}
if let hideAny = hideAnyMatch[context] {
for partialMatchable in hideAny {
if content.contains(partialMatchable) {
return .hide
}
}
}
let warnWholeWord = warnWholeWordMatch[context]
let hideWholeWord = hideWholeWordMatch[context]
var filterResult = defaultFilterResult
let tokenizer = NLTokenizer(unit: .word)
tokenizer.string = content
tokenizer.enumerateTokens(in: content.startIndex..<content.endIndex) { range, _ in
let word = String(content[range])
if let wordsToHide = hideWords[context], wordsToHide.contains(word) {
filterResult = .hidden
if hideWholeWord?.contains(word) ?? false {
filterResult = .hide
return false
} else if let wordsToWarn = warnWords[context], wordsToWarn.contains(word) {
filterResult = .filtered(word)
} else if warnWholeWord?.contains(word) ?? false {
filterResult = .warn(word)
return false
} else {
return true

View File

@ -15,10 +15,10 @@ extension APIService {
func filters(
mastodonAuthenticationBox: MastodonAuthenticationBox
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error> {
) async throws -> [Mastodon.Entity.FilterInfo] {
let authorization = mastodonAuthenticationBox.userAuthorization
let domain = mastodonAuthenticationBox.domain
return Mastodon.API.Account.filters(session: session, domain: domain, authorization: authorization)
return try await Mastodon.API.Account.filters(session: session, domain: domain, authorization: authorization)
}
}

View File

@ -22,7 +22,7 @@ public final class StatusFilterService {
public let filterUpdatePublisher = PassthroughSubject<Void, Never>()
// output
@Published public var activeFilters: [Mastodon.Entity.Filter] = []
@Published public var activeFilterApplication: Mastodon.Entity.FilterApplication? = nil
private init() {
// fetch account filters every 300s
@ -38,39 +38,27 @@ public final class StatusFilterService {
.store(in: &disposeBag)
Publishers.CombineLatest(
AuthenticationServiceProvider.shared.$mastodonAuthenticationBoxes,
AuthenticationServiceProvider.shared.currentActiveUser,
filterUpdatePublisher
)
.flatMap { mastodonAuthenticationBoxes, _ -> AnyPublisher<Result<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error>, Never> in
guard let box = mastodonAuthenticationBoxes.first else {
return Just(Result { throw APIService.APIError.implicit(.authenticationMissing) }).eraseToAnyPublisher()
}
return APIService.shared.filters(mastodonAuthenticationBox: box)
.map { response in
let now = Date()
let newResponse = response.map { filters in
return filters.filter { filter in
if let expiresAt = filter.expiresAt {
// filter out expired rules
return expiresAt > now
} else {
return true
}
}
.sink { authBox, _ in
Task {
guard let box = authBox else {
throw APIService.APIError.implicit(.authenticationMissing)
}
let filters = try await APIService.shared.filters(mastodonAuthenticationBox: box)
let now = Date()
let activeFilters = filters.filter { filter in
if let expiresAt = filter.expiresAt {
// filter out expired rules
return expiresAt > now
} else {
return true
}
return Result<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error>.success(newResponse)
}
.catch { error in
Just(Result<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error>.failure(error))
}
.eraseToAnyPublisher()
}
.sink { result in
switch result {
case .success(let response):
self.activeFilters = response.value
case .failure(_):
break
let newFilterBox = Mastodon.Entity.FilterApplication(filters: activeFilters)
guard self.activeFilterApplication != newFilterBox else { return }
self.activeFilterApplication = newFilterBox
}
}
.store(in: &disposeBag)

View File

@ -11,9 +11,13 @@ import Combine
// MARK: - Account credentials
extension Mastodon.API.Account {
static func filtersEndpointURL(domain: String) -> URL {
static func filtersV1EndpointURL(domain: String) -> URL {
return Mastodon.API.endpointURL(domain: domain).appendingPathComponent("filters")
}
static func filtersV2EndpointURL(domain: String) -> URL {
return Mastodon.API.endpointV2URL(domain: domain).appendingPathComponent("filters")
}
/// View all filters
///
@ -34,19 +38,29 @@ extension Mastodon.API.Account {
session: URLSession,
domain: String,
authorization: Mastodon.API.OAuth.Authorization
) -> AnyPublisher<Mastodon.Response.Content<[Mastodon.Entity.Filter]>, Error> {
let request = Mastodon.API.get(
url: filtersEndpointURL(domain: domain),
) async throws -> [Mastodon.Entity.FilterInfo] {
let v2request = Mastodon.API.get(
url: filtersV2EndpointURL(domain: domain),
query: nil,
authorization: authorization
)
return session.dataTaskPublisher(for: request)
.tryMap { data, response in
let value = try Mastodon.API.decode(type: [Mastodon.Entity.Filter].self, from: data, response: response)
return Mastodon.Response.Content(value: value, response: response)
}
.eraseToAnyPublisher()
do {
let (data, response) = try await session.data(for: v2request)
let value = try Mastodon.API.decode(type: [Mastodon.Entity.FilterV2].self, from: data, response: response)
return value
} catch let error {
guard let apiError = error as? Mastodon.API.Error, apiError.httpResponseStatus == .notFound else { throw error }
}
// fallback to v1
let v1request = Mastodon.API.get(
url: filtersV1EndpointURL(domain: domain),
query: nil,
authorization: authorization
)
let (data, response) = try await session.data(for: v1request)
let value = try Mastodon.API.decode(type: [Mastodon.Entity.FilterV1].self, from: data, response: response)
return value
}
}

View File

@ -8,7 +8,45 @@
import Foundation
extension Mastodon.Entity {
/// Field
public enum FilterResult {
case notFiltered
case warn(String)
case hide
}
public enum FilterAction: RawRepresentable, Codable {
public typealias RawValue = String
case warn
case hide
case _other(String)
public init?(rawValue: String) {
switch rawValue {
case "warn": self = .warn
case "hide": self = .hide
default: self = ._other(rawValue)
}
}
public var rawValue: String {
switch self {
case .warn: return "warn"
case .hide: return "hide"
case ._other(let value): return value
}
}
}
public protocol FilterInfo {
var expiresAt: Date? { get }
var filterContexts: [FilterContext] { get }
var filterAction: FilterAction { get }
var matchAll: [String] { get }
var matchWholeWordOnly: [String] { get }
}
/// Filter
///
/// - Since: 2.4.3
/// - Version: 3.3.0
@ -16,46 +54,16 @@ extension Mastodon.Entity {
/// 2021/1/28
/// # Reference
/// [Document](https://docs.joinmastodon.org/entities/filter/)
public struct Filter: Codable {
public enum FilterStatus {
case notFiltered
case filtered(String)
case hidden
}
public enum FilterAction: RawRepresentable, Codable {
public typealias RawValue = String
case warn
case hide
case _other(String)
public init?(rawValue: String) {
switch rawValue {
case "warn": self = .warn
case "hide": self = .hide
default: self = ._other(rawValue)
}
}
public var rawValue: String {
switch self {
case .warn: return "warn"
case .hide: return "hide"
case ._other(let value): return value
}
}
}
public struct FilterV1: FilterInfo, Codable {
public typealias ID = String
public let id: ID
public let phrase: String
public let context: [Context]
public let context: [FilterContext]
public let expiresAt: Date?
public let irreversible: Bool
public let wholeWord: Bool
public let filterAction: FilterAction?
enum CodingKeys: String, CodingKey {
case id
@ -64,13 +72,105 @@ extension Mastodon.Entity {
case expiresAt = "expires_at"
case irreversible
case wholeWord = "whole_word"
case filterAction = "filter_action"
}
public var filterContexts: [Mastodon.Entity.FilterContext] {
return context
}
public var filterAction: Mastodon.Entity.FilterAction {
return .warn
}
public var matchAll: [String] {
if wholeWord {
return []
} else {
return [phrase.lowercased()]
}
}
public var matchWholeWordOnly: [String] {
if wholeWord {
return [phrase.lowercased()]
} else {
return []
}
}
}
/// Filter
///
/// - Since: 4.0.0
/// - Version: 4.0.0
/// # Last Update
/// 2024/11/25
/// # Reference
/// [Document](https://docs.joinmastodon.org/entities/filter/)
public struct FilterV2: FilterInfo, Codable {
public struct FilterKeyword: Codable {
let id: String
let keyword: String
let wholeWord: Bool
enum CodingKeys: String, CodingKey {
case id
case keyword
case wholeWord = "whole_word"
}
}
public typealias ID = String
public let id: ID
public let title: String
public let context: [FilterContext]
public let expiresAt: Date?
public let filterAction: FilterAction
public let keywords: [FilterKeyword]
// public let statuses // not using this for now
enum CodingKeys: String, CodingKey {
case id
case title
case context
case expiresAt = "expires_at"
case filterAction = "filter_action"
case keywords
}
public var filterContexts: [Mastodon.Entity.FilterContext] {
return context
}
public var matchAll: [String] {
return keywords.compactMap { keyword in
if keyword.wholeWord {
return nil
} else {
return keyword.keyword.lowercased()
}
}
}
public var matchWholeWordOnly: [String] {
return keywords.compactMap { keyword in
if keyword.wholeWord {
return keyword.keyword.lowercased()
} else {
return nil
}
}
}
}
}
extension Mastodon.Entity.Filter {
public enum Context: RawRepresentable, Codable, Hashable {
extension Mastodon.Entity {
public enum FilterContext: RawRepresentable, Codable, Hashable {
case home
case notifications
case `public`

View File

@ -8,6 +8,7 @@
import UIKit
// MARK: - UITextViewDelegate
@MainActor
extension ComposeContentViewModel: UITextViewDelegate {
public func textViewDidBeginEditing(_ textView: UITextView) {

View File

@ -447,18 +447,10 @@ extension StatusView {
}
private func configureFilter(status: MastodonStatus) {
Publishers.CombineLatest(
viewModel.$activeFilters,
viewModel.$filterContext
)
StatusFilterService.shared.$activeFilterApplication
.receive(on: StatusView.statusFilterWorkingQueue)
.map { filters, filterContext in
guard let filterContext else { return .notFiltered }
if let filterApplication = Mastodon.Entity.Filter.FilterApplication(filters: filters) { // TODO: don't c
return filterApplication.apply(to: status, in: filterContext)
} else {
return .notFiltered
}
.map { [weak self] filterBox in
return self?.viewModel.applyFilters(filterBox, to: status.entity) ?? .notFiltered
}
.receive(on: DispatchQueue.main)
.assign(to: \.isFiltered, on: viewModel)

View File

@ -115,9 +115,8 @@ extension StatusView {
@Published public var editedAt: Date? = nil
// Filter
@Published public var activeFilters: [Mastodon.Entity.Filter] = []
@Published public var filterContext: Mastodon.Entity.Filter.Context?
@Published public var isFiltered: Mastodon.Entity.Filter.FilterStatus = .notFiltered
public var filterContext: Mastodon.Entity.FilterContext?
@Published public var isFiltered: Mastodon.Entity.FilterResult = .notFiltered
@Published public var groupedAccessibilityLabel = ""
@Published public var contentAccessibilityLabel = ""
@ -162,10 +161,16 @@ extension StatusView {
isBookmark = false
translation = nil
activeFilters = []
filterContext = nil
}
func applyFilters(_ filterBox: Mastodon.Entity.FilterApplication?, to status: Mastodon.Entity.Status) -> Mastodon.Entity.FilterResult {
guard let filterBox, let filterContext = filterContext else { return .notFiltered }
let status = status.reblog ?? status
guard let content = status.content?.lowercased() else { return .notFiltered }
return filterBox.apply(to: content, in: filterContext)
}
init() {
// isReblogEnabled
Publishers.CombineLatest(
@ -204,6 +209,7 @@ extension StatusView {
}
extension StatusView.ViewModel {
func bind(statusView: StatusView) {
bindHeader(statusView: statusView)
bindAuthor(statusView: statusView)
@ -759,12 +765,11 @@ extension StatusView.ViewModel {
switch isFiltered {
case .notFiltered:
break
case .filtered(let reason):
case .warn(let reason):
self?.isContentSensitive = true
self?.isMediaSensitive = true
self?.spoilerContent = PlaintextMetaContent(string: reason)
case .hidden:
assert(false)
case .hide:
self?.isContentSensitive = true
self?.isMediaSensitive = true
}