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:
parent
7a62a528b5
commit
c52e674ece
@ -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 }
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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 = ""
|
||||
|
Loading…
x
Reference in New Issue
Block a user