feat(community): opt-in NIP-9A feed filter for community moderation
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=<owner+moderators>`, `#a=<addressTag>`. `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.
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -151,6 +151,12 @@ class AccountSettings(
|
||||
var stripLocationOnUpload: Boolean = true,
|
||||
val useLocalBlossomCache: MutableStateFlow<Boolean> = MutableStateFlow(true),
|
||||
val localBlossomCacheProfilePicturesOnly: MutableStateFlow<Boolean> = 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<Boolean> = MutableStateFlow(false),
|
||||
val defaultHomeFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.AllFollows),
|
||||
val defaultStoriesFollowList: MutableStateFlow<TopFilter> = MutableStateFlow(TopFilter.Global),
|
||||
val defaultNotificationFollowList: MutableStateFlow<TopFilter> = 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)
|
||||
|
||||
+38
-7
@@ -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<Note>() {
|
||||
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<Note>): Set<Note> = 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<Note> {
|
||||
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<Note>): Set<Note> = innerApplyFilter(newItems)
|
||||
|
||||
private fun innerApplyFilter(collection: Collection<Note>): Set<Note> {
|
||||
if (communityDefEvent == null) return emptySet()
|
||||
|
||||
val validator = rulesValidator()
|
||||
return collection
|
||||
.mapNotNull {
|
||||
filterMap(it)
|
||||
filterMap(it, validator)
|
||||
}.flatten()
|
||||
.toSet()
|
||||
}
|
||||
|
||||
private fun filterMap(note: Note): List<Note>? {
|
||||
private fun filterMap(
|
||||
note: Note,
|
||||
validator: CommunityRulesValidator?,
|
||||
): List<Note>? {
|
||||
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<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
+26
-6
@@ -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<Note>() {
|
||||
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<Note> {
|
||||
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<Note>): Set<Note> {
|
||||
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<Note>? {
|
||||
private fun filterMap(
|
||||
note: Note,
|
||||
validator: CommunityRulesValidator?,
|
||||
): List<Note>? {
|
||||
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<Note>): List<Note> = items.sortedWith(DefaultFeedOrder)
|
||||
}
|
||||
|
||||
+7
-1
@@ -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,
|
||||
|
||||
+84
@@ -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
|
||||
+1
@@ -35,6 +35,7 @@ class CommunityFilterAssembler(
|
||||
val group =
|
||||
listOf(
|
||||
CommunityFeedFilterSubAssembler(client, ::allKeys),
|
||||
CommunityRulesFilterSubAssembler(client, ::allKeys),
|
||||
)
|
||||
|
||||
override fun invalidateKeys() = invalidateFilters()
|
||||
|
||||
+80
@@ -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<CommunityQueryState>,
|
||||
) : SingleSubEoseManager<CommunityQueryState>(client, allKeys) {
|
||||
override fun updateFilter(
|
||||
keys: List<CommunityQueryState>,
|
||||
since: SincePerRelayMap?,
|
||||
): List<RelayBasedFilter> {
|
||||
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
|
||||
}
|
||||
+13
@@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1195,6 +1195,8 @@
|
||||
<string name="show_sensitive_content_explainer">Shows a warning message when the author of the post marked it as sensitive</string>
|
||||
<string name="max_hashtag_limit_title">Max hashtags per post</string>
|
||||
<string name="max_hashtag_limit_explainer">Hides posts with more hashtags than this limit. Set to 0 to disable</string>
|
||||
<string name="hide_community_rules_violations_title">Hide posts that violate community rules</string>
|
||||
<string name="hide_community_rules_violations_explainer">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.</string>
|
||||
|
||||
<string name="new_reaction_symbol">New Reaction Symbol</string>
|
||||
<string name="no_reaction_type_setup_long_press_to_change">No reaction types pre-selected for this user. Long press on the heart button to change</string>
|
||||
|
||||
+153
@@ -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<Int> = listOf(CommentEvent.KIND),
|
||||
kindMaxBytes: Int? = null,
|
||||
deny: List<String> = emptyList(),
|
||||
globalMaxBytes: Int? = null,
|
||||
): CommunityRulesEvent {
|
||||
val tags =
|
||||
buildList<Array<String>> {
|
||||
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))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user