From ca1f76f4bc75ae42546b2613dfbb07471a496b02 Mon Sep 17 00:00:00 2001 From: m Date: Sat, 9 May 2026 09:26:29 +1000 Subject: [PATCH] feat(community): opt-in NIP-9A feed filter for community moderation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Hide posts that violate community rules" toggle in Security & Filters that drops events from community feeds when their author/kind/size fails the latest cached `kind:34551` (NIP-9A) rules document for that community. Default OFF preserves pre-9A behaviour. Closes #2761. When the toggle is on, both `CommunityFeedFilter` (approval-only feed) and `CommunityModerationFeedFilter` (un-approved feed) construct a per-feed `CommunityRulesValidator` from the latest `kind:34551` for the community and drop candidates whose `validate(...)` returns a violation. When no rules event is cached for the community, the validator is null and the filter behaves exactly as before — no false positives on pre-9A communities. `CommunityRulesFilterSubAssembler` (new) joins the existing `CommunityFilterAssembler.group`, reusing the `CommunityQueryState` keyspace so any screen mounting the community feed subscription also pulls the rules document. Filter: `kinds=[34551]`, `authors=`, `#a=`. `CommunityRulesLookup.kt` extracts the cache scan + violation check into pure helpers (`latestCommunityRules`, `violatesCommunityRules`) so the filter wiring is unit-testable without LocalCache. The `Account.settings.hideCommunityRulesViolations` flag follows the existing `useLocalBlossomCache` plumbing pattern: persisted in `LocalPreferences`, mutated through a `change*` setter on `AccountSettings`, read into the feed view models at construction time. Out of scope (deferred to follow-up issues): web-of-trust gates, per-day quota enforcement (`postsTodayByKind`), stale-rules ratchet UI, "Send anyway" override on validation. The validator skips wot/quota cleanly when their callbacks are null, so the feed filter is a strict subset of NIP-9A's checks for v1. 7 unit tests on `violatesCommunityRules`: - comment passes when its kind is whitelisted - text note fails when only comments are whitelisted - denied author overrides any kind allow-list - oversize per-kind, under-size per-kind, global max-event-size - note without an event is treated as passing Builds clean: `:amethyst:assemblePlayDebug`, `:amethyst:spotlessCheck`, `:amethyst:testPlayDebugUnitTest --tests "*CommunityRulesLookupTest*"`. Note: this PR also lands a copy of `CommunityRulesFilterSubAssembler` identical to the one in PR #2798 (composer-side validation). When either PR merges first, the other rebases to drop the duplicate; the file is the same in both. --- .../amethyst/LocalPreferences.kt | 4 + .../amethyst/model/AccountSettings.kt | 13 ++ .../communities/dal/CommunityFeedFilter.kt | 45 +++++- .../communities/dal/CommunityFeedViewModel.kt | 8 +- .../dal/CommunityModerationFeedFilter.kt | 32 +++- .../dal/CommunityModerationFeedViewModel.kt | 8 +- .../communities/dal/CommunityRulesLookup.kt | 84 ++++++++++ .../datasource/CommunityFilterAssembler.kt | 1 + .../CommunityRulesFilterSubAssembler.kt | 80 +++++++++ .../settings/SecurityFiltersScreen.kt | 13 ++ amethyst/src/main/res/values/strings.xml | 2 + .../dal/CommunityRulesLookupTest.kt | 153 ++++++++++++++++++ 12 files changed, 428 insertions(+), 15 deletions(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityRulesLookup.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityRulesFilterSubAssembler.kt create mode 100644 amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityRulesLookupTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index 8d2e92bad..eb3d70ec0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -97,6 +97,7 @@ private object PrefKeys { const val STRIP_LOCATION_ON_UPLOAD = "stripLocationOnUpload" const val USE_LOCAL_BLOSSOM_CACHE = "useLocalBlossomCache" const val LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY = "localBlossomCacheProfilePicturesOnly" + const val HIDE_COMMUNITY_RULES_VIOLATIONS = "hideCommunityRulesViolations" const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList" const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList" const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList" @@ -350,6 +351,7 @@ object LocalPreferences { putBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, settings.stripLocationOnUpload) putBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, settings.useLocalBlossomCache.value) putBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, settings.localBlossomCacheProfilePicturesOnly.value) + putBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, settings.hideCommunityRulesViolations.value) putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value)) putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value)) @@ -519,6 +521,7 @@ object LocalPreferences { val stripLocationOnUpload = getBoolean(PrefKeys.STRIP_LOCATION_ON_UPLOAD, true) val useLocalBlossomCache = getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, true) val localBlossomCacheProfilePicturesOnly = getBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, false) + val hideCommunityRulesViolations = getBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, false) val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false) val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false) val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false) @@ -628,6 +631,7 @@ object LocalPreferences { stripLocationOnUpload = stripLocationOnUpload, useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache), localBlossomCacheProfilePicturesOnly = MutableStateFlow(localBlossomCacheProfilePicturesOnly), + hideCommunityRulesViolations = MutableStateFlow(hideCommunityRulesViolations), defaultHomeFollowList = MutableStateFlow(followListPrefs.home), defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories), defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index bf0a9e6ae..daa4ae66e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -151,6 +151,12 @@ class AccountSettings( var stripLocationOnUpload: Boolean = true, val useLocalBlossomCache: MutableStateFlow = MutableStateFlow(true), val localBlossomCacheProfilePicturesOnly: MutableStateFlow = MutableStateFlow(false), + /** + * NIP-9A opt-in: when true, community feeds drop events whose latest cached + * `kind:34551` rules document fails [com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator]. + * Default false preserves pre-9A behaviour. + */ + val hideCommunityRulesViolations: MutableStateFlow = MutableStateFlow(false), val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), val defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), @@ -412,6 +418,13 @@ class AccountSettings( } } + fun changeHideCommunityRulesViolations(enabled: Boolean) { + if (hideCommunityRulesViolations.value != enabled) { + hideCommunityRulesViolations.tryEmit(enabled) + saveAccountSettings() + } + } + fun changeLocalBlossomCacheProfilePicturesOnly(enabled: Boolean) { if (localBlossomCacheProfilePicturesOnly.value != enabled) { localBlossomCacheProfilePicturesOnly.tryEmit(enabled) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedFilter.kt index 3caf3c8d5..d945aedf0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedFilter.kt @@ -31,40 +31,66 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.isForCommunity +import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator class CommunityFeedFilter( val communityDefNote: AddressableNote, val account: Account, + /** + * When true, candidate events are additionally checked against the latest + * NIP-9A `kind:34551` rules document for this community and dropped on any + * violation. No-op when no rules document is cached. Default false preserves + * pre-9A behaviour for any caller that constructs the filter directly. + */ + val hideRulesViolations: Boolean = false, ) : AdditiveFeedFilter() { val communityDefEvent = communityDefNote.event as? CommunityDefinitionEvent val moderators = communityDefEvent?.moderatorKeys()?.toSet() ?: emptySet() override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + communityDefNote.idHex + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + /** + * Memoised per-feed validator. Recomputed lazily on each `feed()` / + * `applyFilter()` call; the latest cached rules event for the community + * may change between calls when the rules sub-assembler delivers an update. + * Null when [hideRulesViolations] is off, when no rules are cached, or when + * the community definition itself isn't available. + */ + private fun rulesValidator(): CommunityRulesValidator? { + if (!hideRulesViolations) return null + val def = communityDefEvent ?: return null + return latestCommunityRules(def)?.let { CommunityRulesValidator(it) } + } + override fun feed(): List { if (communityDefEvent == null) return emptyList() + val validator = rulesValidator() val result = LocalCache.notes.mapFlattenIntoSet { _, it -> - filterMap(it) + filterMap(it, validator) } return sort(result) } - override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) - private fun innerApplyFilter(collection: Collection): Set { if (communityDefEvent == null) return emptySet() + val validator = rulesValidator() return collection .mapNotNull { - filterMap(it) + filterMap(it, validator) }.flatten() .toSet() } - private fun filterMap(note: Note): List? { + private fun filterMap( + note: Note, + validator: CommunityRulesValidator?, + ): List? { val noteEvent = note.event ?: return null return if ( @@ -75,10 +101,10 @@ class CommunityFeedFilter( ) { if (noteEvent is CommunityPostApprovalEvent) { note.replyTo?.filter { - it.isNewThread() + it.isNewThread() && passesRules(it, validator) } } else { - if (note.isNewThread()) { + if (note.isNewThread() && passesRules(note, validator)) { listOf(note) } else { null @@ -89,5 +115,10 @@ class CommunityFeedFilter( } } + private fun passesRules( + candidate: Note, + validator: CommunityRulesValidator?, + ): Boolean = validator == null || !violatesCommunityRules(validator, candidate) + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedViewModel.kt index c998e811f..e866946c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityFeedViewModel.kt @@ -29,7 +29,13 @@ import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel class CommunityFeedViewModel( val note: AddressableNote, val account: Account, -) : AndroidFeedViewModel(CommunityFeedFilter(note, account)) { +) : AndroidFeedViewModel( + CommunityFeedFilter( + note, + account, + hideRulesViolations = account.settings.hideCommunityRulesViolations.value, + ), + ) { class Factory( val note: AddressableNote, val account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityModerationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityModerationFeedFilter.kt index 393cf53b9..2da71cdd5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityModerationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityModerationFeedFilter.kt @@ -33,24 +33,35 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.isForCommunity +import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator class CommunityModerationFeedFilter( val communityDefNote: AddressableNote, val account: Account, + /** See [CommunityFeedFilter.hideRulesViolations]. */ + val hideRulesViolations: Boolean = false, ) : AdditiveFeedFilter() { - val approvedFilter = CommunityFeedFilter(communityDefNote, account) + val approvedFilter = CommunityFeedFilter(communityDefNote, account, hideRulesViolations) val communityDefEvent = communityDefNote.event as? CommunityDefinitionEvent val moderators = communityDefEvent?.moderatorKeys()?.toSet() ?: emptySet() override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + communityDefNote.idHex + /** See [CommunityFeedFilter.rulesValidator]. */ + private fun rulesValidator(): CommunityRulesValidator? { + if (!hideRulesViolations) return null + val def = communityDefEvent ?: return null + return latestCommunityRules(def)?.let { CommunityRulesValidator(it) } + } + override fun feed(): List { if (communityDefEvent == null) return emptyList() + val validator = rulesValidator() val result = LocalCache.notes.mapFlattenIntoSet { _, it -> - filterMap(it) + filterMap(it, validator) } return sort(result) @@ -83,9 +94,10 @@ class CommunityModerationFeedFilter( private fun innerApplyFilter(collection: Collection): Set { if (communityDefEvent == null) return emptySet() + val validator = rulesValidator() return collection .mapNotNull { - filterMap(it) + filterMap(it, validator) }.flatten() .toSet() } @@ -98,7 +110,10 @@ class CommunityModerationFeedFilter( approvalEvent.isForCommunity(this.communityDefNote.idHex) } - private fun filterMap(note: Note): List? { + private fun filterMap( + note: Note, + validator: CommunityRulesValidator?, + ): List? { val noteEvent = note.event ?: return null return if ( @@ -108,10 +123,10 @@ class CommunityModerationFeedFilter( ) { if (noteEvent is CommunityPostApprovalEvent) { note.replyTo?.filter { - it.isNewThread() && !wasApprovedByCommunity(it) + it.isNewThread() && !wasApprovedByCommunity(it) && passesRules(it, validator) } } else { - if (note.isNewThread() && !wasApprovedByCommunity(note)) { + if (note.isNewThread() && !wasApprovedByCommunity(note) && passesRules(note, validator)) { listOf(note) } else { null @@ -122,5 +137,10 @@ class CommunityModerationFeedFilter( } } + private fun passesRules( + candidate: Note, + validator: CommunityRulesValidator?, + ): Boolean = validator == null || !violatesCommunityRules(validator, candidate) + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityModerationFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityModerationFeedViewModel.kt index 029d1ad13..802756172 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityModerationFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityModerationFeedViewModel.kt @@ -29,7 +29,13 @@ import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel class CommunityModerationFeedViewModel( val note: AddressableNote, val account: Account, -) : AndroidFeedViewModel(CommunityModerationFeedFilter(note, account)) { +) : AndroidFeedViewModel( + CommunityModerationFeedFilter( + note, + account, + hideRulesViolations = account.settings.hideCommunityRulesViolations.value, + ), + ) { class Factory( val note: AddressableNote, val account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityRulesLookup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityRulesLookup.kt new file mode 100644 index 000000000..95f701596 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityRulesLookup.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.dal + +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.filter +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent +import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator + +/** + * Returns the most recent [CommunityRulesEvent] whose `a` tag points back at + * [community], signed by the community owner or a declared moderator. + * + * NIP-9A explicitly delegates rules authoring to whichever pubkeys appear in the + * community's `kind:34550` definition (`p` moderator tags + the owner). Picking + * the highest-`createdAt` event across that set matches the validator contract: + * latest rules win. + * + * Returns null when the cache hasn't (yet) seen any rules document for this + * community \u2014 in which case the feed filter behaves like there are no rules, + * matching pre-9A behaviour. + */ +fun latestCommunityRules(community: CommunityDefinitionEvent): CommunityRulesEvent? { + val signers = community.moderatorKeys().toSet() + if (signers.isEmpty()) return null + + val communityAddress = community.addressTag() + + return LocalCache.addressables + .filter(CommunityRulesEvent.KIND) { _, note -> + val event = note.event as? CommunityRulesEvent ?: return@filter false + event.pubKey in signers && event.communityAddress() == communityAddress + }.asSequence() + .mapNotNull { it.event as? CommunityRulesEvent } + .maxByOrNull { it.createdAt } +} + +/** + * Returns true when [note] would violate [rules] under [CommunityRulesValidator]. + * + * `wot` and per-kind quota inputs are deliberately null-passed here \u2014 those + * checks are deferred to follow-up issues and the validator skips them cleanly + * when the callbacks aren't supplied. + */ +fun violatesCommunityRules( + validator: CommunityRulesValidator, + note: Note, +): Boolean { + val event = note.event ?: return false + return validator.validate( + author = event.pubKey, + kind = event.kind, + sizeBytes = event.sizeInBytes(), + ) != null +} + +/** + * Approximate event size in bytes used by [CommunityRulesValidator]. Matches the + * validator's `max_event_size` and per-kind `max-bytes` semantics: total UTF-8 + * size of the content (not the wire-encoded JSON, which the validator can't be + * specific about across implementations). + */ +private fun Event.sizeInBytes(): Int = content.encodeToByteArray().size diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt index fdb60d00d..2c3c7c2d9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityFilterAssembler.kt @@ -35,6 +35,7 @@ class CommunityFilterAssembler( val group = listOf( CommunityFeedFilterSubAssembler(client, ::allKeys), + CommunityRulesFilterSubAssembler(client, ::allKeys), ) override fun invalidateKeys() = invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityRulesFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityRulesFilterSubAssembler.kt new file mode 100644 index 000000000..96d93030d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/datasource/CommunityRulesFilterSubAssembler.kt @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource + +import com.vitorpamplona.amethyst.commons.relayClient.eoseManagers.SingleSubEoseManager +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent + +/** + * Subscribes to the latest NIP-9A [CommunityRulesEvent] (kind:34551) for each + * tracked community. + * + * Reuses the existing [CommunityQueryState] keyspace so any screen that already + * subscribes to the community feed (via [CommunityFilterAssemblerSubscription]) + * also pulls the rules document. Only events signed by the community owner or a + * declared moderator are accepted, matching the trust model of NIP-72 approvals. + */ +class CommunityRulesFilterSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : SingleSubEoseManager(client, allKeys) { + override fun updateFilter( + keys: List, + since: SincePerRelayMap?, + ): List { + if (keys.isEmpty()) return emptyList() + + return keys.flatMap { key -> + val commEvent = key.community.event + if (commEvent !is CommunityDefinitionEvent) return@flatMap emptyList() + + val signers = commEvent.moderatorKeys() + val communityRelays = + ( + commEvent.relayUrls().ifEmpty { + LocalCache.relayHints.hintsForAddress(commEvent.addressTag()) + } + key.community.relayUrls() + ).toSet() + + communityRelays.map { relay -> + RelayBasedFilter( + relay = relay, + filter = + Filter( + kinds = listOf(CommunityRulesEvent.KIND), + authors = signers.sorted(), + tags = mapOf("a" to listOf(commEvent.addressTag())), + limit = 5, + since = since?.get(relay)?.time, + ), + ) + } + } + } + + override fun distinct(key: CommunityQueryState) = key.community.idHex +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt index 2dda65234..ce9c37f36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/SecurityFiltersScreen.kt @@ -436,6 +436,19 @@ private fun HeaderOptions(accountViewModel: AccountViewModel) { modifier = Modifier.padding(start = 8.dp), ) } + + SettingsRow( + R.string.hide_community_rules_violations_title, + R.string.hide_community_rules_violations_explainer, + ) { + val hideViolations by accountViewModel.account.settings.hideCommunityRulesViolations + .collectAsStateWithLifecycle() + + Switch( + checked = hideViolations, + onCheckedChange = { accountViewModel.account.settings.changeHideCommunityRulesViolations(it) }, + ) + } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e295639e2..0366a39a5 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1195,6 +1195,8 @@ Shows a warning message when the author of the post marked it as sensitive Max hashtags per post Hides posts with more hashtags than this limit. Set to 0 to disable + Hide posts that violate community rules + Drops posts from community feeds when the community publishes a NIP-9A rules document and an event would fail it. No effect when a community has no structured rules. New Reaction Symbol No reaction types pre-selected for this user. Long press on the heart button to change diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityRulesLookupTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityRulesLookupTest.kt new file mode 100644 index 000000000..7c9fda877 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/dal/CommunityRulesLookupTest.kt @@ -0,0 +1,153 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.dal + +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent +import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Unit tests for the pure-helper [violatesCommunityRules] entry point used by + * [CommunityFeedFilter] and [CommunityModerationFeedFilter] when the user has + * enabled "Hide posts that violate community rules". + * + * Builds synthetic [Note]s + [CommunityRulesEvent]s without going through + * LocalCache, the relay client, or any signer. The validator itself is covered + * by Quartz tests; here we cover the wiring between an editor-created rules + * document and amethyst's [Note] type. + */ +class CommunityRulesLookupTest { + private val communityOwner = "a".repeat(64) + private val author = "b".repeat(64) + private val denied = "c".repeat(64) + + private fun rules( + allowedKinds: List = listOf(CommentEvent.KIND), + kindMaxBytes: Int? = null, + deny: List = emptyList(), + globalMaxBytes: Int? = null, + ): CommunityRulesEvent { + val tags = + buildList> { + add(arrayOf("d", "rules-1")) + add(arrayOf("a", "34550:$communityOwner:my-community")) + allowedKinds.forEach { kind -> + if (kindMaxBytes != null) { + add(arrayOf("k", kind.toString(), kindMaxBytes.toString())) + } else { + add(arrayOf("k", kind.toString())) + } + } + deny.forEach { add(arrayOf("p", it, "deny")) } + globalMaxBytes?.let { add(arrayOf("max_event_size", it.toString())) } + }.toTypedArray() + return CommunityRulesEvent( + id = "0".repeat(64), + pubKey = communityOwner, + createdAt = 100L, + tags = tags, + content = "", + sig = "0".repeat(128), + ) + } + + private fun textNote( + content: String, + pubKey: String = author, + ): Note { + val n = Note("d".repeat(64)) + n.event = + TextNoteEvent( + id = "1".repeat(64), + pubKey = pubKey, + createdAt = 200L, + tags = emptyArray(), + content = content, + sig = "0".repeat(128), + ) + return n + } + + private fun commentNote( + content: String, + pubKey: String = author, + ): Note { + val n = Note("e".repeat(64)) + n.event = + CommentEvent( + id = "2".repeat(64), + pubKey = pubKey, + createdAt = 200L, + tags = emptyArray(), + content = content, + sig = "0".repeat(128), + ) + return n + } + + @Test + fun `comment passes when its kind is whitelisted`() { + val v = CommunityRulesValidator(rules(allowedKinds = listOf(CommentEvent.KIND))) + assertFalse(violatesCommunityRules(v, commentNote("hello"))) + } + + @Test + fun `text note fails when only comments are whitelisted`() { + val v = CommunityRulesValidator(rules(allowedKinds = listOf(CommentEvent.KIND))) + assertTrue(violatesCommunityRules(v, textNote("hello"))) + } + + @Test + fun `denied author is rejected even if kind allowed`() { + val v = CommunityRulesValidator(rules(allowedKinds = listOf(CommentEvent.KIND), deny = listOf(denied))) + assertTrue(violatesCommunityRules(v, commentNote("hello", pubKey = denied))) + } + + @Test + fun `oversize comment fails per-kind size cap`() { + val v = CommunityRulesValidator(rules(allowedKinds = listOf(CommentEvent.KIND), kindMaxBytes = 5)) + assertTrue(violatesCommunityRules(v, commentNote("longer than five bytes"))) + } + + @Test + fun `under-size comment passes per-kind size cap`() { + val v = CommunityRulesValidator(rules(allowedKinds = listOf(CommentEvent.KIND), kindMaxBytes = 100)) + assertFalse(violatesCommunityRules(v, commentNote("hi"))) + } + + @Test + fun `global max event size also rejects oversize`() { + val v = CommunityRulesValidator(rules(allowedKinds = listOf(CommentEvent.KIND), globalMaxBytes = 4)) + assertTrue(violatesCommunityRules(v, commentNote("five!"))) + } + + @Test + fun `note without an event is treated as passing - filter has nothing to evaluate`() { + val v = CommunityRulesValidator(rules()) + val empty = Note("f".repeat(64)) + assertFalse(violatesCommunityRules(v, empty)) + } +}