From 862e1186ce0eedeac5034906e21add06d20fff06 Mon Sep 17 00:00:00 2001 From: shannon Date: Tue, 26 Nov 2024 10:46:27 -0500 Subject: [PATCH] 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 --- Mastodon/Diffable/Status/StatusSection.swift | 10 +- .../DiscoveryPostsViewModel+Diffable.swift | 3 +- .../HashtagTimelineViewModel+Diffable.swift | 3 +- .../HomeTimelineViewModel+Diffable.swift | 3 +- .../Notification/NotificationSection.swift | 10 +- ...tificationTimelineViewModel+Diffable.swift | 3 +- .../Bookmark/BookmarkViewModel+Diffable.swift | 3 +- .../Favorite/FavoriteViewModel+Diffable.swift | 3 +- .../UserTimelineViewModel+Diffable.swift | 3 +- .../MastodonStatusThreadViewModel.swift | 22 +-- .../Thread/ThreadViewModel+Diffable.swift | 3 +- .../ThreadViewModel+LoadThreadState.swift | 4 +- Mastodon/Scene/Thread/ThreadViewModel.swift | 3 +- .../DataController/FeedDataController.swift | 47 ++--- .../Model/FilterApplication.swift | 117 +++++++----- .../Service/API/APIService+Filter.swift | 4 +- .../Service/StatusFilterService.swift | 48 ++--- .../API/Mastodon+API+Account+Filter.swift | 36 ++-- .../Entity/Mastodon+Entity+Filter.swift | 172 ++++++++++++++---- ...eContentViewModel+UITextViewDelegate.swift | 1 + .../Content/StatusView+Configuration.swift | 14 +- .../View/Content/StatusView+ViewModel.swift | 19 +- 22 files changed, 315 insertions(+), 216 deletions(-) diff --git a/Mastodon/Diffable/Status/StatusSection.swift b/Mastodon/Diffable/Status/StatusSection.swift index ca13fd7d7..9ef709e64 100644 --- a/Mastodon/Diffable/Status/StatusSection.swift +++ b/Mastodon/Diffable/Status/StatusSection.swift @@ -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( diff --git a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift index 977c977cd..e55c86335 100644 --- a/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift +++ b/Mastodon/Scene/Discovery/Posts/DiscoveryPostsViewModel+Diffable.swift @@ -22,8 +22,7 @@ extension DiscoveryPostsViewModel { authenticationBox: authenticationBox, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, - filterContext: .none, - activeFilters: nil + filterContext: nil ) ) diff --git a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift index ca26cf183..d3564e48b 100644 --- a/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HashtagTimeline/HashtagTimelineViewModel+Diffable.swift @@ -23,8 +23,7 @@ extension HashtagTimelineViewModel { authenticationBox: authenticationBox, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, - filterContext: .none, - activeFilters: nil + filterContext: nil ) ) diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift index a05635dbd..5f0b11140 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+Diffable.swift @@ -25,8 +25,7 @@ extension HomeTimelineViewModel { authenticationBox: authenticationBox, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate, - filterContext: .home, - activeFilters: StatusFilterService.shared.$activeFilters + filterContext: .home ) ) diff --git a/Mastodon/Scene/Notification/NotificationSection.swift b/Mastodon/Scene/Notification/NotificationSection.swift index 888991e83..6dbcd1c51 100644 --- a/Mastodon/Scene/Notification/NotificationSection.swift +++ b/Mastodon/Scene/Notification/NotificationSection.swift @@ -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) } } diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift index d516f0bb6..0594c9199 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+Diffable.swift @@ -22,8 +22,7 @@ extension NotificationTimelineViewModel { configuration: NotificationSection.Configuration( authenticationBox: authenticationBox, notificationTableViewCellDelegate: notificationTableViewCellDelegate, - filterContext: .notifications, - activeFilters: StatusFilterService.shared.$activeFilters + filterContext: .notifications ) ) diff --git a/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+Diffable.swift b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+Diffable.swift index fc6622768..097366df4 100644 --- a/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Bookmark/BookmarkViewModel+Diffable.swift @@ -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 diff --git a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift index 9f41715fe..f84929e53 100644 --- a/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Favorite/FavoriteViewModel+Diffable.swift @@ -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 diff --git a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift index f67817d00..43d4fccf3 100644 --- a/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift +++ b/Mastodon/Scene/Profile/Timeline/UserTimelineViewModel+Diffable.swift @@ -22,8 +22,7 @@ extension UserTimelineViewModel { authenticationBox: authenticationBox, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, - filterContext: .none, - activeFilters: nil + filterContext: nil ) ) diff --git a/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift b/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift index 1e1b7c8e2..e4c97b1c4 100644 --- a/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift +++ b/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift @@ -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 = 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 diff --git a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift index a6a08ae90..1c50ef442 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel+Diffable.swift @@ -28,8 +28,7 @@ extension ThreadViewModel { authenticationBox: authenticationBox, statusTableViewCellDelegate: statusTableViewCellDelegate, timelineMiddleLoaderTableViewCellDelegate: nil, - filterContext: .thread, - activeFilters: StatusFilterService.shared.$activeFilters + filterContext: .thread ) ) diff --git a/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift b/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift index b8f82a1a0..9227a08f0 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel+LoadThreadState.swift @@ -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: []) } diff --git a/Mastodon/Scene/Thread/ThreadViewModel.swift b/Mastodon/Scene/Thread/ThreadViewModel.swift index 0b77cbace..54a2698e4 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel.swift @@ -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 diff --git a/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift b/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift index e2a8361fb..93d38cba8 100644 --- a/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift +++ b/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift @@ -16,23 +16,20 @@ final public class FeedDataController { private let kind: MastodonFeed.Kind private var subscriptions = Set() - 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 diff --git a/MastodonSDK/Sources/MastodonCore/Model/FilterApplication.swift b/MastodonSDK/Sources/MastodonCore/Model/FilterApplication.swift index 56a80058a..321c00635 100644 --- a/MastodonSDK/Sources/MastodonCore/Model/FilterApplication.swift +++ b/MastodonSDK/Sources/MastodonCore/Model/FilterApplication.swift @@ -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.. AnyPublisher, 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) } } diff --git a/MastodonSDK/Sources/MastodonCore/Service/StatusFilterService.swift b/MastodonSDK/Sources/MastodonCore/Service/StatusFilterService.swift index 5eacf259c..1e2d53ca8 100644 --- a/MastodonSDK/Sources/MastodonCore/Service/StatusFilterService.swift +++ b/MastodonSDK/Sources/MastodonCore/Service/StatusFilterService.swift @@ -22,7 +22,7 @@ public final class StatusFilterService { public let filterUpdatePublisher = PassthroughSubject() // 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, 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, Error>.success(newResponse) } - .catch { error in - Just(Result, 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) diff --git a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Filter.swift b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Filter.swift index 5bded31ba..1bc0b760a 100644 --- a/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Filter.swift +++ b/MastodonSDK/Sources/MastodonSDK/API/Mastodon+API+Account+Filter.swift @@ -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, 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 } - } diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift index 8006385e4..fd20a36b8 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Filter.swift @@ -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` diff --git a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+UITextViewDelegate.swift b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+UITextViewDelegate.swift index f7315700b..c2098be0d 100644 --- a/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+UITextViewDelegate.swift +++ b/MastodonSDK/Sources/MastodonUI/Scene/ComposeContent/ComposeContentViewModel+UITextViewDelegate.swift @@ -8,6 +8,7 @@ import UIKit // MARK: - UITextViewDelegate +@MainActor extension ComposeContentViewModel: UITextViewDelegate { public func textViewDidBeginEditing(_ textView: UITextView) { diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift index bc8bfb292..cd91f8e56 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+Configuration.swift @@ -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) diff --git a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift index ce1cb829e..ac57f990a 100644 --- a/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift +++ b/MastodonSDK/Sources/MastodonUI/View/Content/StatusView+ViewModel.swift @@ -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 }