Content Filter and improvements

This commit is contained in:
Lumaa 2024-07-05 21:06:15 +02:00
parent d8096f5986
commit 4608d0b1a5
11 changed files with 374 additions and 78 deletions

View File

@ -30,6 +30,7 @@ Join the Matrix Space to get support, with full security: [Join #threadedapp:mat
- [EmojiText](https://github.com/divadretlaw/EmojiText)
- [KeychainSwift](https://github.com/evgenyneu/keychain-swift)
- [RevenueCat](https://www.revenuecat.com/)
- A derivative of [ProboscisKit](https://github.com/lumaa-dev/ProboscisKit)
## To-do list

View File

@ -11,6 +11,7 @@
B9029FC42B8125CE00AA9B68 /* HuggingFace.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9029FC32B8125CE00AA9B68 /* HuggingFace.swift */; };
B915C4422B6F908C00042DDB /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B915C4412B6F908C00042DDB /* ProfileView.swift */; };
B93126EA2C29C63100BF16E9 /* ContentFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93126E92C29C63000BF16E9 /* ContentFilter.swift */; };
B93126F02C2AEB8300BF16E9 /* FilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93126EF2C2AEB8300BF16E9 /* FilterView.swift */; };
B934EA242BAB5E7F001F4345 /* RestrictedView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B934EA232BAB5E7F001F4345 /* RestrictedView.swift */; };
B93757112B7FB8D400652F91 /* AltClients.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93757102B7FB8D400652F91 /* AltClients.swift */; };
B93ADFCB2B7625CD00FF9172 /* DiscoveryView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B93ADFCA2B7625CD00FF9172 /* DiscoveryView.swift */; };
@ -191,6 +192,7 @@
B9029FC32B8125CE00AA9B68 /* HuggingFace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFace.swift; sourceTree = "<group>"; };
B915C4412B6F908C00042DDB /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = "<group>"; };
B93126E92C29C63000BF16E9 /* ContentFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentFilter.swift; sourceTree = "<group>"; };
B93126EF2C2AEB8300BF16E9 /* FilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FilterView.swift; sourceTree = "<group>"; };
B934EA232BAB5E7F001F4345 /* RestrictedView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RestrictedView.swift; sourceTree = "<group>"; };
B93757102B7FB8D400652F91 /* AltClients.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AltClients.swift; sourceTree = "<group>"; };
B93ADFCA2B7625CD00FF9172 /* DiscoveryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryView.swift; sourceTree = "<group>"; };
@ -569,6 +571,7 @@
B97798882B853E6600DC869F /* UpdateView.swift */,
B9B469B12B9A6E8300AD5585 /* PrivacyView.swift */,
B934EA232BAB5E7F001F4345 /* RestrictedView.swift */,
B93126EF2C2AEB8300BF16E9 /* FilterView.swift */,
);
path = Settings;
sourceTree = "<group>";
@ -834,6 +837,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
B93126F02C2AEB8300BF16E9 /* FilterView.swift in Sources */,
B9FB94922B2E35D000D81C07 /* SettingsView.swift in Sources */,
B98BC7472B46CE6300595441 /* PostDetailsView.swift in Sources */,
B9CC45B82B40A2D6001E4FA5 /* AboutView.swift in Sources */,

View File

@ -6,6 +6,7 @@ import SwiftData
/// A content filter designed for posts and its author
protocol PostFilter {
var type: ContentFilter.FilterContentType { get }
var categoryName: String { get }
var content: [String] { get set }
var post: Status? { get set }
@ -62,8 +63,8 @@ extension PostFilter {
class ContentFilter {
static let defaultFilter: ContentFilter.WordFilter = .init(categoryName: "Default", words: [])
@Model
class WordFilter: PostFilter {
let type: ContentFilter.FilterContentType = .words
let categoryName: String
var content: [String]
var post: Status?
@ -74,6 +75,11 @@ class ContentFilter {
self.content = rearranged
}
init(model: ModelFilter) {
self.categoryName = model.name
self.content = model.sensitive
}
func setContent(_ content: [String]) {
let rearranged: [String] = content.compactMap({ $0.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) }).uniqued()
self.content = rearranged
@ -118,6 +124,7 @@ class ContentFilter {
}
class URLFilter: PostFilter {
var type: ContentFilter.FilterContentType = .url
let categoryName: String
var content: [String]
var post: Status?
@ -128,6 +135,11 @@ class ContentFilter {
self.content = rearranged
}
init(model: ModelFilter) {
self.categoryName = model.name
self.content = model.sensitive
}
func setContent(_ content: [String]) {
let rearranged: [String] = content.compactMap({ $0.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) }).uniqued()
self.content = rearranged
@ -176,63 +188,9 @@ class ContentFilter {
}
}
class InstanceFilter: PostFilter {
let categoryName: String
var content: [String]
var post: Status?
init(categoryName: String, urls: [URL]) {
self.categoryName = categoryName
let rearranged: [String] = urls.compactMap({ $0.host() ?? "https://example.com" }).uniqued()
self.content = rearranged
}
func setContent(_ content: [String]) {
let rearranged: [String] = content.compactMap({ $0.lowercased().trimmingCharacters(in: .whitespacesAndNewlines) }).uniqued()
self.content = rearranged
}
func setContent(_ content: [URL]) {
let rearranged: [String] = content.compactMap({ $0.host() ?? "https://example.com" }).uniqued()
self.content = rearranged
}
func filter(_ post: Status, type: ContentFilter.FilterType = .remove) -> Bool {
self.setPost(post)
if type == .censor {
let sensitives: [String] = self.willFilter(post.content)
for word in sensitives {
post.content.asMarkdown = self.post!.content.asMarkdown.replacingOccurrences(of: word, with: "***")
post.content.asSafeMarkdownAttributedString = self.post!.content.asSafeMarkdownAttributedString.replacing(word, with: "")
}
return !sensitives.isEmpty
} else {
let includesSensitive: Bool = self.willFilter(post.content)
return includesSensitive
}
}
func filter(_ post: Status, type: ContentFilter.FilterType = .remove, manualEdit: @escaping (String) -> Void = {_ in}) -> Bool {
self.setPost(post)
if type == .censor {
let sensitives: [String] = self.willFilter(post.content)
for word in sensitives {
manualEdit(word)
}
return !sensitives.isEmpty
} else {
let includesSensitive: Bool = self.willFilter(post.content)
return includesSensitive
}
}
private func setPost(_ post: Status?) {
if let p = post {
self.post = p
}
}
enum FilterContentType: String, Codable {
case words = "words"
case url = "url"
}
enum FilterType: String, CaseIterable {
@ -260,6 +218,25 @@ class ContentFilter {
}
}
@Model
class ModelFilter {
let name: String
let sensitive: [String]
let type: ContentFilter.FilterContentType
init(name: String, sensitive: [String], type: ContentFilter.FilterContentType = .words) {
self.name = name
self.sensitive = sensitive
self.type = type
}
init(postFilter: PostFilter) {
self.name = postFilter.categoryName
self.sensitive = postFilter.content
self.type = postFilter.type
}
}
extension NSAttributedString {
func replacing(_ placeholder: String, with valueString: String) -> NSAttributedString {
if let range = self.string.range(of: placeholder) {

View File

@ -8,6 +8,8 @@ class FetchTimeline {
public var statusesState: LoadingState = .loading
private var timeline: TimelineFilter = .home
public private(set) var filtering: Bool = false
public private(set) var lastFilter: PostFilter? = nil
init(client: Client) {
self.client = client
@ -31,7 +33,7 @@ class FetchTimeline {
self.statusesState = .loading
let lastStatus = self.datasource.last!
let newStatuses: [Status] = await fetchNewestStatuses(lastStatusId: lastStatus.id)
let newStatuses: [Status] = await fetchNewestStatuses(lastStatusId: lastStatus.id, filter: lastFilter)
self.datasource.append(contentsOf: newStatuses)
self.statusesState = .loaded
@ -44,6 +46,7 @@ class FetchTimeline {
do {
var statuses: [Status] = try await client!.get(endpoint: timeline.endpoint(sinceId: nil, maxId: lastStatusId, minId: nil, offset: 0))
statuses = applyContentFilter(statuses, filter: filter)
self.lastFilter = filter
if lastStatusId == nil {
self.datasource = statuses
}
@ -66,28 +69,52 @@ class FetchTimeline {
return await self.fetchNewestStatuses(lastStatusId: nil, filter: filter)
}
func applyContentFilter(_ statuses: [Status], filter: PostFilter? = nil) -> [Status] {
private func applyContentFilter(_ statuses: [Status], filter: PostFilter? = nil) -> [Status] {
guard let postFilter = filter else { return statuses }
var filteredStatuses: [Status] = statuses
let contentFilter: any PostFilter = postFilter
let filterType: ContentFilter.FilterType = UserDefaults.standard.bool(forKey: "censorsFilter") ? .censor : .remove
for post in statuses {
let i = statuses.firstIndex(of: post) ?? -1
_ = contentFilter.filter(post, type: .censor) { sensitive in
let isFiltered = contentFilter.filter(post, type: filterType) { sensitive in
post.content.asRawText = post.content.asRawText.replacingOccurrences(of: sensitive, with: "***")
post.content.asMarkdown = post.content.asMarkdown.replacingOccurrences(of: sensitive, with: "***")
post.content.asSafeMarkdownAttributedString = post.content.asSafeMarkdownAttributedString.replacing(sensitive, with: "***")
filteredStatuses[i] = post
print("Edited \(post.account.acct)'s post")
print("Censored \(post.account.acct)'s post")
}
if isFiltered && filterType == .remove {
filteredStatuses.remove(at: i)
print("Removed \(post.account.acct)'s post")
}
}
return filteredStatuses
}
func toggleContentFilter(filter: PostFilter) async -> [Status] {
if self.filtering {
self.lastFilter = nil
self.filtering = false
self.datasource = []
self.statusesState = .loading
return await self.fetchNewestStatuses(lastStatusId: nil, filter: nil)
} else {
self.lastFilter = filter
self.filtering = true
return await self.useContentFilter(filter)
}
}
func getStatuses() -> [Status] {
return datasource
}

View File

@ -105,6 +105,7 @@ public enum SheetDestination: Identifiable {
case safari(url: URL)
case shareImage(image: UIImage, status: Status)
case update
case filter
public var id: String {
switch self {
@ -125,6 +126,8 @@ public enum SheetDestination: Identifiable {
return "shareImage"
case .update:
return "update"
case .filter:
return "contentfilter"
}
}
@ -147,6 +150,8 @@ public enum SheetDestination: Identifiable {
return false
case .update:
return false
case .filter:
return false
}
}
}
@ -158,6 +163,7 @@ public enum RouterDestination: Hashable {
case about
case privacy
case restricted
case filter
case account(acc: Account)
case post(status: Status)
@ -166,7 +172,7 @@ public enum RouterDestination: Hashable {
}
extension RouterDestination {
static let allSettings: [RouterDestination] = [.settings, .support, .about, .appearence, .privacy, .restricted]
static let allSettings: [RouterDestination] = [.settings, .support, .about, .appearence, .privacy, .restricted, .filter]
}
extension View {
@ -193,6 +199,8 @@ extension View {
RestrictedView()
case .timeline(let timeline):
PostsView(filter: timeline)
case .filter:
FilterView()
}
}
}

View File

@ -1984,6 +1984,102 @@
}
}
},
"settings.content-filter.allow" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Enable Content Filter"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Activer le Filtre de Contenu"
}
}
}
},
"settings.content-filter.auto" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Auto start Content Filter"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Démarrage auto du Filtre de Contenu"
}
}
}
},
"settings.content-filter.new-word" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sensitive Word"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Mot sensible"
}
}
}
},
"settings.content-filter.type" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Filter type"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Type de filtre"
}
}
}
},
"settings.content-filter.words" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sensitive Words"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Mots sensibles"
}
}
}
},
"settings.content-filter.words.footer" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Sensitive Words are the words that will be filtered by the Content Filter, the Content Filter will detect your words even if theyre in the middle of a word like “annoy” will filter “annoying”."
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Les mots sensibles sont des mots qui sont filtrés par le Filtre de Contenu, le Filtre de Contenu détectera tous les mots même si ils sont au milieu dun mot comme “sacre” filtrera “sacre-bleu”."
}
}
}
},
"settings.done" : {
"localizations" : {
"en" : {
@ -2048,6 +2144,22 @@
}
}
},
"settings.privacy.filter" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Content Filter"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Filtre de Contenu"
}
}
}
},
"settings.privacy.restricted" : {
"localizations" : {
"en" : {
@ -2544,6 +2656,38 @@
}
}
},
"status.content-filter.censor" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Censor"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Censurer"
}
}
}
},
"status.content-filter.remove" : {
"localizations" : {
"en" : {
"stringUnit" : {
"state" : "translated",
"value" : "Remove"
}
},
"fr" : {
"stringUnit" : {
"state" : "translated",
"value" : "Supprimer"
}
}
}
},
"status.cross-post.alts" : {
"localizations" : {
"en" : {
@ -3698,4 +3842,4 @@
}
},
"version" : "1.0"
}
}

View File

@ -43,7 +43,7 @@ public extension View {
@ViewBuilder
func modelData() -> some View {
self
.modelContainer(for: [LoggedAccount.self, ContentFilter.WordFilter.self])
.modelContainer(for: [LoggedAccount.self, ModelFilter.self])
}
}

View File

@ -1,10 +1,106 @@
//Made by Lumaa
import SwiftUI
import SwiftData
struct FilterView: View {
@Environment(\.modelContext) private var modelContext: ModelContext
@AppStorage("allowFilter") private var allowFilter: Bool = false
@AppStorage("autoOnFilter") private var enableAutoFilter: Bool = false
@AppStorage("censorsFilter") private var censorsFilter: Bool = false
@State private var censorType: ContentFilter.FilterType = .censor
@Query private var filters: [ModelFilter]
@State private var wordsFilter: ContentFilter.WordFilter = ContentFilter.defaultFilter
@State private var newWord: String = ""
@FocusState private var filterState: Bool
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
List {
Toggle(isOn: $allowFilter.animation(.spring)) {
Text("settings.content-filter.allow")
}
.tint(Color.green)
.listRowThreaded()
if allowFilter {
Toggle(isOn: $enableAutoFilter.animation(.spring)) {
Text("settings.content-filter.auto")
}
.tint(Color.green)
.listRowThreaded()
Picker(selection: $censorType) {
ForEach(ContentFilter.FilterType.allCases, id: \.self) { type in
type.label
.id(type)
}
} label: {
Text("settings.content-filter.type")
}
.pickerStyle(.menu)
.listRowThreaded()
.onAppear {
censorType = censorsFilter ? .censor : .remove
}
.onChange(of: censorType) { _, newValue in
censorsFilter = newValue == .censor
}
Section(header: Text("settings.content-filter.words"), footer: Text("settings.content-filter.words.footer")) {
TextField("settings.content-filter.new-word", text: $newWord)
.keyboardType(.asciiCapable)
.submitLabel(.done)
.focused($filterState)
.onSubmit {
filterState.toggle()
let sensitive = newWord.lowercased().trimmingCharacters(in: .whitespacesAndNewlines)
if !sensitive.isEmpty {
wordsFilter.content.append(sensitive)
}
newWord = ""
saveData()
}
ForEach(wordsFilter.content, id: \.self) { word in
Text(word)
.lineLimit(1)
.multilineTextAlignment(.leading)
}
.onDelete { i in
wordsFilter.content.remove(atOffsets: i)
saveData()
}
}
.onAppear {
self.wordsFilter = filters.compactMap({ ContentFilter.WordFilter(model: $0) }).first ?? ContentFilter.defaultFilter
filterState = true
}
.listRowThreaded()
}
}
.navigationTitle(Text("settings.privacy.filter"))
.navigationBarTitleDisplayMode(.inline)
.listThreaded()
}
private func saveData() {
let pack: ModelFilter = ModelFilter(postFilter: wordsFilter)
do {
if let firstFilter = filters.first {
modelContext.delete(firstFilter)
}
modelContext.insert(pack)
try modelContext.save()
} catch {
print("Couldn't save properly: \(error)")
}
}
}

View File

@ -19,6 +19,13 @@ struct PrivacyView: View {
}
.listRowThreaded()
Button {
navigator.navigate(to: .filter)
} label: {
Label("settings.privacy.filter", systemImage: "line.3.horizontal.decrease.circle")
}
.listRowThreaded()
Spacer()
.frame(height: 30)
.listRowThreaded()

View File

@ -1,6 +1,7 @@
//Made by Lumaa
import SwiftUI
import SwiftData
struct TimelineView: View {
@Environment(AccountManager.self) private var accountManager: AccountManager
@ -13,6 +14,9 @@ struct TimelineView: View {
@State private var statuses: [Status]?
@State private var lastSeen: Int?
@Query private var filters: [ModelFilter]
@State private var wordsFilter: ContentFilter.WordFilter = ContentFilter.defaultFilter
@State var filter: TimelineFilter = .home
@State var showHero: Bool = true
@State var timelineModel: FetchTimeline // home timeline by default
@ -64,6 +68,7 @@ struct TimelineView: View {
.disabled(t == filter)
}
}
.padding(.horizontal, 7.5)
ScrollView(.horizontal) {
HStack {
@ -80,6 +85,7 @@ struct TimelineView: View {
.disabled(t == filter)
}
}
.padding(.horizontal, 7.5)
}
.padding(.vertical)
.scrollIndicators(.hidden)
@ -98,7 +104,7 @@ struct TimelineView: View {
}
.refreshable {
if let client = accountManager.getClient() {
statuses = []
statuses = nil
Task {
loadingStatuses = true
@ -116,17 +122,21 @@ struct TimelineView: View {
}
}
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
statuses = []
Task {
loadingStatuses = true
statuses = await self.timelineModel.useContentFilter(ContentFilter.WordFilter(categoryName: "Test", words: ["is"]))
loadingStatuses = false
if UserDefaults.standard.bool(forKey: "allowFilter") {
ToolbarItem(placement: .primaryAction) {
Button {
statuses = nil
Task {
loadingStatuses = true
statuses = await self.timelineModel.toggleContentFilter(filter: wordsFilter)
loadingStatuses = false
}
} label: {
Image(systemName: self.timelineModel.filtering ? "line.3.horizontal.decrease.circle.fill" :"line.3.horizontal.decrease.circle")
.symbolEffect(.pulse.wholeSymbol, isActive: self.timelineModel.filtering)
}
} label: {
Image(systemName: "line.3.horizontal.decrease.circle")
.tint(Color(uiColor: UIColor.label))
}
}
}
@ -163,12 +173,14 @@ struct TimelineView: View {
}
} label: {
Text(t.localizedTitle())
.padding(.horizontal)
.frame(width: 20)
.lineLimit(1)
}
.buttonStyle(LargeButton(filled: t == filter, height: 7.5))
.disabled(t == filter)
}
}
.padding(.horizontal, 7.5)
ScrollView(.horizontal) {
HStack {
@ -185,6 +197,7 @@ struct TimelineView: View {
.disabled(t == filter)
}
}
.padding(.horizontal, 7.5)
}
.padding(.vertical)
.scrollIndicators(.hidden)
@ -209,9 +222,17 @@ struct TimelineView: View {
Color.appBackground
.ignoresSafeArea()
.onAppear {
if UserDefaults.standard.bool(forKey: "allowFilter") {
self.wordsFilter = filters.compactMap({ ContentFilter.WordFilter(model: $0) }).first ?? ContentFilter.defaultFilter
}
if let client = accountManager.getClient() {
Task {
statuses = await timelineModel.fetch(client: client)
if UserDefaults.standard.bool(forKey: "autoOnFilter") {
statuses = await self.timelineModel.useContentFilter(wordsFilter)
}
}
}
}
@ -244,6 +265,10 @@ struct TimelineView: View {
loadingStatuses = true
statuses = await timelineModel.fetch(client: client)
lastSeen = nil
if timelineModel.filtering {
statuses = await self.timelineModel.useContentFilter(wordsFilter)
}
loadingStatuses = false
}
}

View File

@ -11,3 +11,10 @@ struct ThreadedWatch_Watch_AppApp: App {
}
}
}
extension View {
func modelData() -> some View {
self
.modelContainer(for: LoggedAccount.self)
}
}