Add filter awareness to feed loading

Filters used to be applied only at the display stage.

There is remaining work here:
- Hide filters still will not hide until we update Filter to use API V2.
- Filter work is now duplicated and should be made more efficient.
- It may be important to hold on to statuses even if they are hidden, so that they can be shown if the filter changes.

Contributes to #1354  [BUG] Mastodon iOS App Ignores "Hide completely" Filter action Setting
This commit is contained in:
shannon 2024-11-25 10:33:04 -05:00
parent 7a62a528b5
commit c52e674ece
11 changed files with 228 additions and 63 deletions

View File

@ -29,6 +29,7 @@ extension HomeTimelineViewModel {
}
extension HomeTimelineViewModel.LoadOldestState {
@MainActor
class Initial: HomeTimelineViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
guard let viewModel = viewModel else { return false }

View File

@ -94,7 +94,7 @@ final class HomeTimelineViewModel: NSObject {
init(context: AppContext, authenticationBox: MastodonAuthenticationBox) {
self.context = context
self.authenticationBox = authenticationBox
self.dataController = FeedDataController(context: context, authenticationBox: authenticationBox)
self.dataController = FeedDataController(context: context, authenticationBox: authenticationBox, kind: .home(timeline: timelineContext))
super.init()
self.dataController.records = (try? PersistenceManager.shared.cachedTimeline(.homeTimeline(authenticationBox)).map {
MastodonFeed.fromStatus($0, kind: .home)

View File

@ -30,6 +30,7 @@ extension NotificationTimelineViewModel {
}
extension NotificationTimelineViewModel.LoadOldestState {
@MainActor
class Initial: NotificationTimelineViewModel.LoadOldestState {
override func isValidNextState(_ stateClass: AnyClass) -> Bool {
guard let viewModel = viewModel else { return false }
@ -43,6 +44,7 @@ extension NotificationTimelineViewModel.LoadOldestState {
stateClass == Fail.self || stateClass == Idle.self || stateClass == NoMore.self
}
@MainActor
override func didEnter(from previousState: GKState?) {
super.didEnter(from: previousState)
@ -72,7 +74,7 @@ extension NotificationTimelineViewModel.LoadOldestState {
let _maxID: Mastodon.Entity.Notification.ID? = lastFeedRecord.notification?.id
guard let maxID = _maxID else {
await self.enter(state: Fail.self)
self.enter(state: Fail.self)
return
}
@ -87,13 +89,13 @@ extension NotificationTimelineViewModel.LoadOldestState {
let notifications = response.value
// enter no more state when no new statuses
if notifications.isEmpty || (notifications.count == 1 && notifications[0].id == maxID) {
await self.enter(state: NoMore.self)
self.enter(state: NoMore.self)
} else {
await self.enter(state: Idle.self)
self.enter(state: Idle.self)
}
} catch {
await self.enter(state: Fail.self)
self.enter(state: Fail.self)
}
} // end Task
}

View File

@ -55,7 +55,7 @@ final class NotificationTimelineViewModel {
self.context = context
self.authenticationBox = authenticationBox
self.scope = scope
self.dataController = FeedDataController(context: context, authenticationBox: authenticationBox)
self.dataController = FeedDataController(context: context, authenticationBox: authenticationBox, kind: scope.feedKind)
self.notificationPolicy = notificationPolicy
switch scope {
@ -124,6 +124,17 @@ extension NotificationTimelineViewModel {
return "Notifications from \(account.displayName)"
}
}
var feedKind: MastodonFeed.Kind {
switch self {
case .everything:
return .notificationAll
case .mentions:
return .notificationMentions
case .fromAccount(let account):
return .notificationAccount(account.id)
}
}
}
}

View File

@ -23,6 +23,7 @@ final class MastodonStatusThreadViewModel {
// input
let context: AppContext
let filterApplication: Mastodon.Entity.Filter.FilterApplication?
@Published private(set) var deletedObjectIDs: Set<MastodonStatus.ID> = Set()
// output
@ -32,8 +33,9 @@ final class MastodonStatusThreadViewModel {
@Published var __descendants: [StatusItem] = []
@Published var descendants: [StatusItem] = []
init(context: AppContext) {
init(context: AppContext, filterApplication: Mastodon.Entity.Filter.FilterApplication?) {
self.context = context
self.filterApplication = filterApplication
Publishers.CombineLatest(
$__ancestors,
@ -84,6 +86,17 @@ extension MastodonStatusThreadViewModel {
) {
var newItems: [StatusItem] = []
for node in nodes {
if let filterApplication {
let filterResult = filterApplication.apply(to: node.status, in: .thread)
switch filterResult {
case .hidden:
continue
default:
break
}
}
let item = StatusItem.thread(.leaf(context: .init(status: node.status)))
newItems.append(item)
}
@ -99,6 +112,17 @@ extension MastodonStatusThreadViewModel {
var newItems: [StatusItem] = []
for node in nodes {
if let filterApplication {
let filterResult = filterApplication.apply(to: node.status, in: .thread)
switch filterResult {
case .hidden:
continue
default:
break
}
}
let context = StatusItem.Thread.Context(status: node.status)
let item = StatusItem.thread(.leaf(context: context))
newItems.append(item)

View File

@ -54,10 +54,11 @@ 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)
self.mastodonStatusThreadViewModel = MastodonStatusThreadViewModel(context: context, filterApplication: filterApplication)
// end init
$root

View File

@ -4,6 +4,7 @@ import Combine
import MastodonSDK
import os.log
@MainActor
final public class FeedDataController {
private let logger = Logger(subsystem: "FeedDataController", category: "Data")
private static let entryNotFoundMessage = "Failed to find suitable record. Depending on the context this might result in errors (data not being updated) or can be discarded (e.g. when there are mixed data sources where an entry might or might not exist)."
@ -12,15 +13,34 @@ final public class FeedDataController {
private let context: AppContext
private let authenticationBox: MastodonAuthenticationBox
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) {
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)
}
.store(in: &subscriptions)
}
public func loadInitial(kind: MastodonFeed.Kind) {
Task {
records = try await load(kind: kind, maxID: nil)
let unfilteredRecords = try await load(kind: kind, maxID: nil)
records = filter(unfilteredRecords, forFeed: kind)
}
}
@ -30,7 +50,23 @@ final public class FeedDataController {
return loadInitial(kind: kind)
}
records += try await load(kind: kind, maxID: lastId)
let unfiltered = try await load(kind: kind, maxID: lastId)
records += filter(unfiltered, forFeed: kind)
}
}
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 }
let filterResult = filterApplication.apply(to: status, in: feedKind.filterContext)
switch filterResult {
case .hidden:
return false
default:
return true
}
}
}
@ -166,7 +202,7 @@ final public class FeedDataController {
}
private extension FeedDataController {
func load(kind: MastodonFeed.Kind, maxID: MastodonStatus.ID?) async throws -> [MastodonFeed] {
func load(kind: MastodonFeed.Kind, maxID: MastodonStatus.ID?) async throws -> [MastodonFeed] {
switch kind {
case .home(let timeline):
await AuthenticationServiceProvider.shared.fetchAccounts()
@ -197,7 +233,10 @@ private extension FeedDataController {
)
}
return response.value.map { .fromStatus(.fromEntity($0), kind: .home) }
return response.value.compactMap { entity in
let status = MastodonStatus.fromEntity(entity)
return .fromStatus(status, kind: .home)
}
case .notificationAll:
return try await getFeeds(with: .everything)
case .notificationMentions:
@ -226,6 +265,15 @@ private extension FeedDataController {
return feeds
}
}
extension MastodonFeed.Kind {
var filterContext: Mastodon.Entity.Filter.Context {
switch self {
case .home(let timeline): // TODO: take timeline into account. See iOS-333.
return .home
case .notificationAccount, .notificationAll, .notificationMentions:
return .notifications
}
}
}

View File

@ -0,0 +1,89 @@
//
// FilterApplication.swift
// MastodonSDK
//
// Created by Shannon Hughes on 11/22/24.
//
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 init?(filters: [Mastodon.Entity.Filter]) {
guard !filters.isEmpty else { return nil }
var wordFilters: [Mastodon.Entity.Filter] = []
var nonWordFilters: [Mastodon.Entity.Filter] = []
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
}
}
}
self.hideWords = hidePhraseWords
self.warnWords = warnPhraseWords
}
public func apply(to status: MastodonStatus, in context: Context) -> Mastodon.Entity.Filter.FilterStatus {
let status = status.reblog ?? status
let defaultFilterResult = Mastodon.Entity.Filter.FilterStatus.notFiltered
guard let content = status.entity.content?.lowercased() else { return defaultFilterResult }
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)
}
}
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
return false
} else if let wordsToWarn = warnWords[context], wordsToWarn.contains(word) {
filterResult = .filtered(word)
return false
} else {
return true
}
}
return filterResult
}
}
}

View File

@ -17,6 +17,36 @@ extension Mastodon.Entity {
/// # 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 typealias ID = String
public let id: ID
@ -25,6 +55,7 @@ extension Mastodon.Entity {
public let expiresAt: Date?
public let irreversible: Bool
public let wholeWord: Bool
public let filterAction: FilterAction?
enum CodingKeys: String, CodingKey {
case id
@ -33,6 +64,7 @@ extension Mastodon.Entity {
case expiresAt = "expires_at"
case irreversible
case wholeWord = "whole_word"
case filterAction = "filter_action"
}
}
}

View File

@ -447,55 +447,18 @@ extension StatusView {
}
private func configureFilter(status: MastodonStatus) {
let status = status.reblog ?? status
guard let content = status.entity.content?.lowercased() else { return }
Publishers.CombineLatest(
viewModel.$activeFilters,
viewModel.$filterContext
)
.receive(on: StatusView.statusFilterWorkingQueue)
.map { filters, filterContext in
var wordFilters: [Mastodon.Entity.Filter] = []
var nonWordFilters: [Mastodon.Entity.Filter] = []
for filter in filters {
guard filter.context.contains(where: { $0 == filterContext }) else { continue }
if filter.wholeWord {
wordFilters.append(filter)
} else {
nonWordFilters.append(filter)
}
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
}
var needsFilter = FilterStatus.notFiltered
for filter in nonWordFilters {
guard content.contains(filter.phrase.lowercased()) else { continue }
needsFilter = .filtered(filter.phrase)
break
}
switch needsFilter {
case .notFiltered:
break
default:
return needsFilter
}
let tokenizer = NLTokenizer(unit: .word)
tokenizer.string = content
let phraseWords = wordFilters.map { $0.phrase.lowercased() }
tokenizer.enumerateTokens(in: content.startIndex..<content.endIndex) { range, _ in
let word = String(content[range])
if phraseWords.contains(word) {
needsFilter = .filtered(word)
return false
} else {
return true
}
}
return needsFilter
}
.receive(on: DispatchQueue.main)
.assign(to: \.isFiltered, on: viewModel)

View File

@ -19,12 +19,6 @@ import MastodonSDK
import MastodonMeta
extension StatusView {
public enum FilterStatus {
case notFiltered
case filtered(String)
case hidden
}
public final class ViewModel: ObservableObject {
var disposeBag = Set<AnyCancellable>()
var observations = Set<NSKeyValueObservation>()
@ -123,7 +117,7 @@ extension StatusView {
// Filter
@Published public var activeFilters: [Mastodon.Entity.Filter] = []
@Published public var filterContext: Mastodon.Entity.Filter.Context?
@Published public var isFiltered: FilterStatus = .notFiltered
@Published public var isFiltered: Mastodon.Entity.Filter.FilterStatus = .notFiltered
@Published public var groupedAccessibilityLabel = ""
@Published public var contentAccessibilityLabel = ""