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:
parent
311bbc0984
commit
862e1186ce
@ -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(
|
||||
|
@ -22,8 +22,7 @@ extension DiscoveryPostsViewModel {
|
||||
authenticationBox: authenticationBox,
|
||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: nil,
|
||||
filterContext: .none,
|
||||
activeFilters: nil
|
||||
filterContext: nil
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -23,8 +23,7 @@ extension HashtagTimelineViewModel {
|
||||
authenticationBox: authenticationBox,
|
||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: nil,
|
||||
filterContext: .none,
|
||||
activeFilters: nil
|
||||
filterContext: nil
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -25,8 +25,7 @@ extension HomeTimelineViewModel {
|
||||
authenticationBox: authenticationBox,
|
||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: timelineMiddleLoaderTableViewCellDelegate,
|
||||
filterContext: .home,
|
||||
activeFilters: StatusFilterService.shared.$activeFilters
|
||||
filterContext: .home
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -22,8 +22,7 @@ extension NotificationTimelineViewModel {
|
||||
configuration: NotificationSection.Configuration(
|
||||
authenticationBox: authenticationBox,
|
||||
notificationTableViewCellDelegate: notificationTableViewCellDelegate,
|
||||
filterContext: .notifications,
|
||||
activeFilters: StatusFilterService.shared.$activeFilters
|
||||
filterContext: .notifications
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -22,8 +22,7 @@ extension UserTimelineViewModel {
|
||||
authenticationBox: authenticationBox,
|
||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: nil,
|
||||
filterContext: .none,
|
||||
activeFilters: nil
|
||||
filterContext: nil
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -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
|
||||
|
@ -28,8 +28,7 @@ extension ThreadViewModel {
|
||||
authenticationBox: authenticationBox,
|
||||
statusTableViewCellDelegate: statusTableViewCellDelegate,
|
||||
timelineMiddleLoaderTableViewCellDelegate: nil,
|
||||
filterContext: .thread,
|
||||
activeFilters: StatusFilterService.shared.$activeFilters
|
||||
filterContext: .thread
|
||||
)
|
||||
)
|
||||
|
||||
|
@ -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: [])
|
||||
}
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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`
|
||||
|
@ -8,6 +8,7 @@
|
||||
import UIKit
|
||||
|
||||
// MARK: - UITextViewDelegate
|
||||
@MainActor
|
||||
extension ComposeContentViewModel: UITextViewDelegate {
|
||||
|
||||
public func textViewDidBeginEditing(_ textView: UITextView) {
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user