diff --git a/damus.xcodeproj/project.pbxproj b/damus.xcodeproj/project.pbxproj index afc820ea..a02d931f 100644 --- a/damus.xcodeproj/project.pbxproj +++ b/damus.xcodeproj/project.pbxproj @@ -423,6 +423,7 @@ BAB68BED29543FA3007BA466 /* SelectWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */; }; D2277EEA2A089BD5006C3807 /* Router.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2277EE92A089BD5006C3807 /* Router.swift */; }; D71DC1EC2A9129C3006E207C /* PostViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D71DC1EB2A9129C3006E207C /* PostViewTests.swift */; }; + D723C38E2AB8D83400065664 /* ContentFilters.swift in Sources */ = {isa = PBXBuildFile; fileRef = D723C38D2AB8D83400065664 /* ContentFilters.swift */; }; D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */; }; D7DEEF2F2A8C021E00E0C99F /* NostrEventTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */; }; E4FA1C032A24BB7F00482697 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */; }; @@ -1104,6 +1105,7 @@ BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = ""; }; D2277EE92A089BD5006C3807 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = ""; }; D71DC1EB2A9129C3006E207C /* PostViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostViewTests.swift; sourceTree = ""; }; + D723C38D2AB8D83400065664 /* ContentFilters.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentFilters.swift; sourceTree = ""; }; D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = ""; }; D7DEEF2E2A8C021E00E0C99F /* NostrEventTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrEventTests.swift; sourceTree = ""; }; E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchSettingsView.swift; sourceTree = ""; }; @@ -1287,6 +1289,7 @@ 3A23838D2A297DD200E5AA2E /* ZapButtonModel.swift */, 3A5E47C42A4A6CF400C0D090 /* Trie.swift */, 3A90B1802A4EA3AF00000D94 /* UserSearchCache.swift */, + D723C38D2AB8D83400065664 /* ContentFilters.swift */, ); path = Models; sourceTree = ""; @@ -2861,6 +2864,7 @@ 4C75EFA427FA577B0006080F /* PostView.swift in Sources */, 4C30AC7229A5677A00E2BD5A /* NotificationsView.swift in Sources */, 4C1A9A2129DDD3E100516EAC /* KeySettingsView.swift in Sources */, + D723C38E2AB8D83400065664 /* ContentFilters.swift in Sources */, 4C32B95A2A9AD44700DC3548 /* Verifiable.swift in Sources */, 4C73C5142A4437C10062CAC0 /* ZapUserView.swift in Sources */, 501F8C802A0220E1001AFC1D /* KeychainStorage.swift in Sources */, diff --git a/damus/ContentView.swift b/damus/ContentView.swift index 8782d583..6e57fef7 100644 --- a/damus/ContentView.swift +++ b/damus/ContentView.swift @@ -56,20 +56,6 @@ enum Sheets: Identifiable { } } -enum FilterState : Int { - case posts_and_replies = 1 - case posts = 0 - - func filter(ev: NostrEvent) -> Bool { - switch self { - case .posts: - return ev.known_kind == .boost || !ev.is_reply(.empty) - case .posts_and_replies: - return true - } - } -} - struct ContentView: View { let keypair: Keypair @@ -96,6 +82,11 @@ struct ContentView: View { @StateObject var navigationCoordinator: NavigationCoordinator = NavigationCoordinator() @AppStorage("has_seen_suggested_users") private var hasSeenSuggestedUsers = false let sub_id = UUID().description + var damus_filter: DamusFilter { + get { + return DamusFilter(hide_nsfw_tagged_content: self.damus_state?.settings.hide_nsfw_tagged_content ?? true) + } + } @Environment(\.colorScheme) var colorScheme @@ -114,10 +105,10 @@ struct ContentView: View { // This is needed or else there is a bug when switching from the 3rd or 2nd tab to first. no idea why. mystery - contentTimelineView(filter: FilterState.posts.filter) + contentTimelineView(filter: damus_filter.get_filter(.posts)) .tag(FilterState.posts) .id(FilterState.posts) - contentTimelineView(filter: FilterState.posts_and_replies.filter) + contentTimelineView(filter: damus_filter.get_filter(.posts_and_replies)) .tag(FilterState.posts_and_replies) .id(FilterState.posts_and_replies) } diff --git a/damus/Models/ContentFilters.swift b/damus/Models/ContentFilters.swift new file mode 100644 index 00000000..3aadbd41 --- /dev/null +++ b/damus/Models/ContentFilters.swift @@ -0,0 +1,58 @@ +// +// ContentFilters.swift +// damus +// +// Created by Daniel D’Aquino on 2023-09-18. +// + +import Foundation + +protocol ContentFilter { + /// Function that implements the content filtering logic + /// - Parameter ev: The nostr event to be processed + /// - Returns: Must return `true` to show events, and return `false` to hide/filter events + func filter(ev: NostrEvent) -> Bool +} + +/// Simple filter to determine whether to show posts or all posts and replies. +enum FilterState : Int, ContentFilter { + case posts_and_replies = 1 + case posts = 0 + + func filter(ev: NostrEvent) -> Bool { + switch self { + case .posts: + return ev.known_kind == .boost || !ev.is_reply(.empty) + case .posts_and_replies: + return true + } + } +} + +/// Simple filter to determine whether to show posts with #nsfw tags +struct NSFWTagFilter: ContentFilter { + func filter(ev: NostrEvent) -> Bool { + return ev.referenced_hashtags.first(where: { t in t.hashtag == "nsfw" }) == nil + } +} + +/// Generic filter with various tweakable settings +struct DamusFilter: ContentFilter { + let hide_nsfw_tagged_content: Bool + + func filter(ev: NostrEvent) -> Bool { + if self.hide_nsfw_tagged_content { + return NSFWTagFilter().filter(ev: ev) + } + else { + return true + } + } + + func get_filter(_ filter_state: FilterState) -> ((NostrEvent) -> Bool) { + return { ev in + return filter_state.filter(ev: ev) && self.filter(ev: ev) + } + } + +} diff --git a/damus/Models/UserSettingsStore.swift b/damus/Models/UserSettingsStore.swift index 862c71a5..f9a4d9bf 100644 --- a/damus/Models/UserSettingsStore.swift +++ b/damus/Models/UserSettingsStore.swift @@ -109,6 +109,9 @@ class UserSettingsStore: ObservableObject { @Setting(key: "always_show_images", default_value: false) var always_show_images: Bool + + @Setting(key: "hide_nsfw_tagged_content", default_value: false) + var hide_nsfw_tagged_content: Bool @Setting(key: "zap_vibration", default_value: true) var zap_vibration: Bool diff --git a/damus/Views/ConfigView.swift b/damus/Views/ConfigView.swift index b77308f7..b0d67a4a 100644 --- a/damus/Views/ConfigView.swift +++ b/damus/Views/ConfigView.swift @@ -41,7 +41,7 @@ struct ConfigView: View { } NavigationLink(value: Route.AppearanceSettings(settings: settings)) { - IconLabel(NSLocalizedString("Appearance", comment: "Section header for text and appearance settings"), img_name: "eye", color: .red) + IconLabel(NSLocalizedString("Appearance and filters", comment: "Section header for text, appearance, and content filter settings"), img_name: "eye", color: .red) } NavigationLink(value: Route.SearchSettings(settings: settings)) { diff --git a/damus/Views/SearchHomeView.swift b/damus/Views/SearchHomeView.swift index 9b5a3e34..2ce42532 100644 --- a/damus/Views/SearchHomeView.swift +++ b/damus/Views/SearchHomeView.swift @@ -14,6 +14,11 @@ struct SearchHomeView: View { @StateObject var model: SearchHomeModel @State var search: String = "" @FocusState private var isFocused: Bool + var damus_filter: DamusFilter { + get { + return DamusFilter(hide_nsfw_tagged_content: self.damus_state.settings.hide_nsfw_tagged_content) + } + } let preferredLanguages = Set(Locale.preferredLanguages.map { localeToLanguage($0) }) @@ -50,6 +55,10 @@ struct SearchHomeView: View { damus: damus_state, show_friend_icon: true, filter: { ev in + if !damus_filter.filter(ev: ev) { + return false + } + if damus_state.muted_threads.isMutedThread(ev, keypair: self.damus_state.keypair) { return false } diff --git a/damus/Views/Settings/AppearanceSettingsView.swift b/damus/Views/Settings/AppearanceSettingsView.swift index 899347ac..c1062678 100644 --- a/damus/Views/Settings/AppearanceSettingsView.swift +++ b/damus/Views/Settings/AppearanceSettingsView.swift @@ -85,6 +85,15 @@ struct AppearanceSettingsView: View { clear_kingfisher_cache() } } + + // MARK: - Content filters and moderation + Section( + header: Text(NSLocalizedString("Content filters", comment: "Section title for content filtering/moderation configuration.")), + footer: Text(NSLocalizedString("Notes with the #nsfw tag usually contains adult content or other \"Not safe for work\" content", comment: "Section footer clarifying what #nsfw (not safe for work) tags mean")) + ) { + Toggle(NSLocalizedString("Hide notes with #nsfw tags", comment: "Setting to hide notes with the #nsfw (not safe for work) tags"), isOn: $settings.hide_nsfw_tagged_content) + .toggleStyle(.switch) + } }