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:
m
2026-05-09 09:26:29 +10:00
parent 8b8c0a10e9
commit ca1f76f4bc
12 changed files with 428 additions and 15 deletions
@@ -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)
@@ -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)
}
@@ -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,
@@ -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)
}
@@ -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,
@@ -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
@@ -35,6 +35,7 @@ class CommunityFilterAssembler(
val group =
listOf(
CommunityFeedFilterSubAssembler(client, ::allKeys),
CommunityRulesFilterSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
@@ -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
}
@@ -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) },
)
}
}
}
+2
View File
@@ -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>
@@ -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))
}
}