diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift index 5dd578f40..fae432104 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel+LoadOldestState.swift @@ -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 } diff --git a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift index ee34f134a..f0a915b2c 100644 --- a/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift +++ b/Mastodon/Scene/HomeTimeline/HomeTimelineViewModel.swift @@ -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) diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift index 7210a220c..fcb35093a 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel+LoadOldestState.swift @@ -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 } diff --git a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift index 0e1edbb07..c557828d9 100644 --- a/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift +++ b/Mastodon/Scene/Notification/NotificationTimeline/NotificationTimelineViewModel.swift @@ -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) + } + } } } diff --git a/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift b/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift index 2e096da9f..1e1b7c8e2 100644 --- a/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift +++ b/Mastodon/Scene/Thread/MastodonStatusThreadViewModel.swift @@ -23,6 +23,7 @@ final class MastodonStatusThreadViewModel { // input let context: AppContext + let filterApplication: Mastodon.Entity.Filter.FilterApplication? @Published private(set) var deletedObjectIDs: Set = 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) diff --git a/Mastodon/Scene/Thread/ThreadViewModel.swift b/Mastodon/Scene/Thread/ThreadViewModel.swift index 6e1e6de46..0b77cbace 100644 --- a/Mastodon/Scene/Thread/ThreadViewModel.swift +++ b/Mastodon/Scene/Thread/ThreadViewModel.swift @@ -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 diff --git a/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift b/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift index 9c7d4b588..e2a8361fb 100644 --- a/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift +++ b/MastodonSDK/Sources/MastodonCore/DataController/FeedDataController.swift @@ -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() + 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 + } + } +} diff --git a/MastodonSDK/Sources/MastodonCore/Model/FilterApplication.swift b/MastodonSDK/Sources/MastodonCore/Model/FilterApplication.swift new file mode 100644 index 000000000..56a80058a --- /dev/null +++ b/MastodonSDK/Sources/MastodonCore/Model/FilterApplication.swift @@ -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..