feat(community): validate posts against NIP-9A community rules in composer
When the comment composer is replying into a NIP-72 community (`replyingTo.event is CommunityDefinitionEvent`), subscribe to the community's latest `kind:34551` rules document, run `CommunityRulesValidator.validate(...)` on every draft change, render an inline banner above the bottom action row when the draft would be rejected, and disable the post button until the violation is resolved. - `CommunityRulesFilterSubAssembler` is added to the existing `CommunityFilterAssembler.group` so any screen that already mounts `CommunityFilterAssemblerSubscription` (community feed, this composer) also pulls the latest rules event from the community's relays. Filter is `kinds=[34551]`, `authors=<owner+moderators>`, `#a=<addressTag>`, matching the NIP-72 trust model. - `CommentPostViewModel` observes `kind:34551` via `LocalCache.observeEvents` keyed on the reply target, picks the latest `created_at` matching the community address, and re-runs the validator on every draft change. The validator's `postsTodayByKind` and `wot` callbacks are intentionally null for this PR (per-day quota lookups and NIP-02 follow-graph traversal are deferred to follow-ups; the validator skips those checks cleanly). - Draft size is conservatively estimated from `content.toByteArray(UTF-8)` — tags add bytes, so this can under-count on the boundary, but relays still enforce the real cap. Good enough for a pre-send preview. - `CommunityRulesViolationBanner` renders the first `CommunityRulesValidator.Violation` with a localized message; new strings cover all 7 sealed-violation variants. - `CommentPostViewModelTest` covers valid drafts, kind-not-allowed, oversize, denied-author, the under-size boundary, and multibyte UTF-8 size accounting. Compose state — not StateFlow — backs `validationResult` and `communityRules` so `canPost()` and the banner recompose without an explicit `collectAsState` site at the top bar (`isActive` reads `validationResult` directly). Refs nostr-protocol/nips#2331 Closes #2759
This commit is contained in:
+108
-1
@@ -71,6 +71,7 @@ import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
|
||||
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||
import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash
|
||||
@@ -96,6 +97,8 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser
|
||||
import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId
|
||||
import com.vitorpamplona.quartz.nip73ExternalIds.scope
|
||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
|
||||
@@ -113,6 +116,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -151,6 +155,17 @@ open class CommentPostViewModel :
|
||||
|
||||
var notifying by mutableStateOf<List<User>?>(null)
|
||||
|
||||
// NIP-9A: latest community rules document for the community we're posting into.
|
||||
// Null when the reply target is not a community, or no rules have been observed yet.
|
||||
var communityRules: CommunityRulesEvent? by mutableStateOf(null)
|
||||
private set
|
||||
private var rulesObserverJob: Job? = null
|
||||
|
||||
// NIP-9A: latest validation outcome for the current draft.
|
||||
// Null = no violation (or rules not yet known); non-null = first violation found.
|
||||
var validationResult: CommunityRulesValidator.Violation? by mutableStateOf(null)
|
||||
private set
|
||||
|
||||
override val message = TextFieldState()
|
||||
|
||||
val urlPreviews = PreviewState()
|
||||
@@ -243,6 +258,90 @@ open class CommentPostViewModel :
|
||||
open fun reply(post: Note) {
|
||||
this.replyingTo = post
|
||||
this.externalIdentity = (post.event as? CommentEvent)?.scope()
|
||||
observeCommunityRules(post)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribes to the latest [CommunityRulesEvent] when [target] is a NIP-72
|
||||
* community, and clears the rules state otherwise. Re-evaluates the current
|
||||
* draft against any rules that arrive (NIP-9A composer-side validation).
|
||||
*/
|
||||
private fun observeCommunityRules(target: Note) {
|
||||
rulesObserverJob?.cancel()
|
||||
rulesObserverJob = null
|
||||
communityRules = null
|
||||
validationResult = null
|
||||
|
||||
val targetEvent = target.event as? CommunityDefinitionEvent ?: return
|
||||
val communityAddress = targetEvent.addressTag()
|
||||
val authors = targetEvent.moderatorKeys()
|
||||
|
||||
val filter =
|
||||
Filter(
|
||||
kinds = listOf(CommunityRulesEvent.KIND),
|
||||
authors = authors.sorted(),
|
||||
tags = mapOf("a" to listOf(communityAddress)),
|
||||
limit = 5,
|
||||
)
|
||||
|
||||
rulesObserverJob =
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
LocalCache.observeEvents<CommunityRulesEvent>(filter).collectLatest { events ->
|
||||
val latest =
|
||||
events
|
||||
.filter { it.communityAddress() == communityAddress }
|
||||
.maxByOrNull { it.createdAt }
|
||||
communityRules = latest
|
||||
revalidateDraft()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the NIP-9A validator against the current draft and updates
|
||||
* [validationResult]. Web-of-trust gates and per-day quota checks are deferred
|
||||
* to a follow-up (see NIP-9A track), so [postsTodayByKind] and `wot` are null
|
||||
* here; the validator skips those checks cleanly.
|
||||
*/
|
||||
private fun revalidateDraft() {
|
||||
val rules = communityRules
|
||||
if (rules == null) {
|
||||
validationResult = null
|
||||
return
|
||||
}
|
||||
|
||||
if (!::account.isInitialized) return
|
||||
|
||||
validationResult =
|
||||
validateDraft(
|
||||
rules = rules,
|
||||
author = account.signer.pubKey,
|
||||
draftContent = message.text.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Pure helper that runs the NIP-9A validator for a `kind:1111` reply with
|
||||
* [draftContent]. Sizes are estimated from the content bytes; tags add bytes,
|
||||
* so this can under-count on the boundary, but relays still enforce the real
|
||||
* cap. Good enough for a pre-send preview.
|
||||
*
|
||||
* Web-of-trust gates and per-day quota lookups are deferred to a follow-up;
|
||||
* see the NIP-9A track in the issue tracker.
|
||||
*/
|
||||
internal fun validateDraft(
|
||||
rules: CommunityRulesEvent,
|
||||
author: String,
|
||||
draftContent: String,
|
||||
): CommunityRulesValidator.Violation? =
|
||||
CommunityRulesValidator(rules).validate(
|
||||
author = author,
|
||||
kind = CommentEvent.KIND,
|
||||
sizeBytes = draftContent.toByteArray(Charsets.UTF_8).size,
|
||||
postsTodayByKind = null,
|
||||
wot = null,
|
||||
)
|
||||
}
|
||||
|
||||
open fun quote(quote: Note) {
|
||||
@@ -316,6 +415,7 @@ open class CommentPostViewModel :
|
||||
replyingTo = LocalCache.getOrCreateNote(it)
|
||||
}
|
||||
}
|
||||
replyingTo?.let { observeCommunityRules(it) }
|
||||
|
||||
wantsToAddGeoHash = draftEvent.hasGeohashes()
|
||||
|
||||
@@ -588,6 +688,11 @@ open class CommentPostViewModel :
|
||||
|
||||
message.setTextAndPlaceCursorAtEnd("")
|
||||
|
||||
rulesObserverJob?.cancel()
|
||||
rulesObserverJob = null
|
||||
communityRules = null
|
||||
validationResult = null
|
||||
|
||||
replyingTo = null
|
||||
externalIdentity = null
|
||||
|
||||
@@ -630,6 +735,7 @@ open class CommentPostViewModel :
|
||||
|
||||
override fun onMessageChanged() {
|
||||
urlPreviews.update(message.text.toString())
|
||||
revalidateDraft()
|
||||
|
||||
if (message.selection.collapsed) {
|
||||
val lastWord = message.currentWord()
|
||||
@@ -707,7 +813,8 @@ open class CommentPostViewModel :
|
||||
!mediaUploadTracker.isUploading &&
|
||||
!wantsInvoice &&
|
||||
(!wantsZapraiser || zapRaiserAmount.value != null) &&
|
||||
multiOrchestrator == null
|
||||
multiOrchestrator == null &&
|
||||
validationResult == null
|
||||
|
||||
fun insertAtCursor(newElement: String) {
|
||||
message.insertUrlAtCursor(newElement)
|
||||
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
* 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.note.nip22Comments
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator
|
||||
|
||||
/**
|
||||
* Inline banner shown above the composer's send button when the current draft
|
||||
* violates the latest NIP-9A `kind:34551` rules document for the community
|
||||
* being posted into. The send button is disabled in lockstep — see
|
||||
* `CommentPostViewModel.canPost`.
|
||||
*/
|
||||
@Composable
|
||||
fun CommunityRulesViolationBanner(violation: CommunityRulesValidator.Violation) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 10.dp, vertical = 6.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(MaterialTheme.colorScheme.errorContainer)
|
||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Warning,
|
||||
contentDescription = stringRes(R.string.community_rules_violation_icon),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
Spacer(Modifier.width(10.dp))
|
||||
Text(
|
||||
text = describeViolation(violation),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun describeViolation(violation: CommunityRulesValidator.Violation): String =
|
||||
when (violation) {
|
||||
is CommunityRulesValidator.Violation.AuthorDenied -> {
|
||||
stringRes(R.string.community_rules_violation_author_denied)
|
||||
}
|
||||
|
||||
is CommunityRulesValidator.Violation.KindNotAllowed -> {
|
||||
stringRes(R.string.community_rules_violation_kind_not_allowed, violation.kind)
|
||||
}
|
||||
|
||||
is CommunityRulesValidator.Violation.KindSizeExceeded -> {
|
||||
stringRes(
|
||||
R.string.community_rules_violation_kind_size_exceeded,
|
||||
violation.sizeBytes,
|
||||
violation.maxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
is CommunityRulesValidator.Violation.MaxSizeExceeded -> {
|
||||
stringRes(
|
||||
R.string.community_rules_violation_max_size_exceeded,
|
||||
violation.sizeBytes,
|
||||
violation.maxBytes,
|
||||
)
|
||||
}
|
||||
|
||||
is CommunityRulesValidator.Violation.QuotaExceeded -> {
|
||||
stringRes(
|
||||
R.string.community_rules_violation_quota_exceeded,
|
||||
violation.postsToday,
|
||||
violation.maxPerDay,
|
||||
)
|
||||
}
|
||||
|
||||
is CommunityRulesValidator.Violation.WotGateFailed -> {
|
||||
stringRes(R.string.community_rules_violation_wot_gate_failed)
|
||||
}
|
||||
|
||||
is CommunityRulesValidator.Violation.StaleRules -> {
|
||||
stringRes(R.string.community_rules_violation_stale_rules)
|
||||
}
|
||||
}
|
||||
+15
@@ -52,6 +52,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.model.AddressableNote
|
||||
import com.vitorpamplona.amethyst.ui.actions.StrippingFailureDialog
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromFiles
|
||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery
|
||||
@@ -85,6 +86,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo
|
||||
import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton
|
||||
import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssemblerSubscription
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size10dp
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
|
||||
@@ -148,6 +150,16 @@ fun GenericCommentPostScreen(
|
||||
) {
|
||||
WatchAndLoadMyEmojiList(accountViewModel)
|
||||
|
||||
// NIP-9A: when replying into a NIP-72 community, mount the community feed
|
||||
// subscription so the latest kind:34551 rules document is fetched and
|
||||
// observed by the ViewModel for composer-side validation. No-op for
|
||||
// hashtag/geohash composers (replyingTo is null or not addressable there).
|
||||
(postViewModel.replyingTo as? AddressableNote)?.let { addressable ->
|
||||
if (addressable.event is com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent) {
|
||||
CommunityFilterAssemblerSubscription(addressable, accountViewModel.dataSources().community)
|
||||
}
|
||||
}
|
||||
|
||||
StrippingFailureDialog(postViewModel.strippingFailureConfirmation)
|
||||
|
||||
BackHandler {
|
||||
@@ -411,6 +423,9 @@ private fun GenericCommentPostBody(
|
||||
)
|
||||
}
|
||||
|
||||
// NIP-9A: surface the first community-rules violation found in the current draft.
|
||||
postViewModel.validationResult?.let { CommunityRulesViolationBanner(it) }
|
||||
|
||||
BottomRowActions(postViewModel)
|
||||
}
|
||||
}
|
||||
|
||||
+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
|
||||
}
|
||||
@@ -2772,4 +2772,13 @@
|
||||
<string name="emoji_public_explainer">Public emojis are visible to everyone and appear in your reaction menu and \":\" autocomplete picker when this pack is in your emoji list.</string>
|
||||
<string name="emoji_private_explainer">Private emojis are stored encrypted on relays and visible only to you. They appear in your reaction menu and \":\" autocomplete just like public ones.</string>
|
||||
<string name="gif">Gif</string>
|
||||
<!-- NIP-9A community rules composer-side validation. -->
|
||||
<string name="community_rules_violation_icon">Community rule violation</string>
|
||||
<string name="community_rules_violation_author_denied">You are on this community\'s deny-list. Posts will not be accepted.</string>
|
||||
<string name="community_rules_violation_kind_not_allowed">This community does not accept events of kind %1$d.</string>
|
||||
<string name="community_rules_violation_kind_size_exceeded">Draft is %1$d bytes; this community caps this kind at %2$d bytes.</string>
|
||||
<string name="community_rules_violation_max_size_exceeded">Draft is %1$d bytes; this community caps events at %2$d bytes.</string>
|
||||
<string name="community_rules_violation_quota_exceeded">You have already posted %1$d events of this kind today (limit %2$d).</string>
|
||||
<string name="community_rules_violation_wot_gate_failed">You don\'t pass this community\'s web-of-trust requirements.</string>
|
||||
<string name="community_rules_violation_stale_rules">The latest community rules document was rejected as stale.</string>
|
||||
</resources>
|
||||
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
/*
|
||||
* 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.note.nip22Comments
|
||||
|
||||
import com.vitorpamplona.quartz.nip22Comments.CommentEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent
|
||||
import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Unit tests for the pure NIP-9A draft-validation helper exposed on
|
||||
* [CommentPostViewModel]'s companion. The ViewModel itself is too coupled to
|
||||
* Compose state, the relay subscription stack, and signers to construct in a
|
||||
* unit test, but the helper is the only place where draft size, kind, and
|
||||
* author flow into the validator — so testing it covers what matters.
|
||||
*/
|
||||
class CommentPostViewModelTest {
|
||||
private val community = "a".repeat(64)
|
||||
private val author = "b".repeat(64)
|
||||
private val denied = "c".repeat(64)
|
||||
|
||||
private fun rules(
|
||||
allowedKind: Int = CommentEvent.KIND,
|
||||
maxBytes: Int? = null,
|
||||
deny: List<String> = emptyList(),
|
||||
): CommunityRulesEvent {
|
||||
val tags =
|
||||
buildList<Array<String>> {
|
||||
add(arrayOf("d", "rules-1"))
|
||||
add(arrayOf("a", "34550:$community:my-community"))
|
||||
if (maxBytes != null) {
|
||||
add(arrayOf("k", allowedKind.toString(), maxBytes.toString()))
|
||||
} else {
|
||||
add(arrayOf("k", allowedKind.toString()))
|
||||
}
|
||||
deny.forEach { add(arrayOf("p", it, "deny")) }
|
||||
}.toTypedArray()
|
||||
return CommunityRulesEvent(
|
||||
id = "0".repeat(64),
|
||||
pubKey = community,
|
||||
createdAt = 100L,
|
||||
tags = tags,
|
||||
content = "",
|
||||
sig = "0".repeat(128),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `valid draft passes without violation`() {
|
||||
val violation =
|
||||
CommentPostViewModel.validateDraft(
|
||||
rules = rules(),
|
||||
author = author,
|
||||
draftContent = "hello world",
|
||||
)
|
||||
assertNull(violation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `kind not allowed yields KindNotAllowed`() {
|
||||
val violation =
|
||||
CommentPostViewModel.validateDraft(
|
||||
rules = rules(allowedKind = 30023), // long-form, not kind:1111
|
||||
author = author,
|
||||
draftContent = "hello",
|
||||
)
|
||||
assertTrue(violation is CommunityRulesValidator.Violation.KindNotAllowed)
|
||||
assertEquals(CommentEvent.KIND, (violation as CommunityRulesValidator.Violation.KindNotAllowed).kind)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `oversize draft yields KindSizeExceeded`() {
|
||||
val draft = "x".repeat(120)
|
||||
val violation =
|
||||
CommentPostViewModel.validateDraft(
|
||||
rules = rules(maxBytes = 100),
|
||||
author = author,
|
||||
draftContent = draft,
|
||||
)
|
||||
assertTrue(violation is CommunityRulesValidator.Violation.KindSizeExceeded)
|
||||
val ks = violation as CommunityRulesValidator.Violation.KindSizeExceeded
|
||||
assertEquals(120, ks.sizeBytes)
|
||||
assertEquals(100, ks.maxBytes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `denied author yields AuthorDenied regardless of content`() {
|
||||
val violation =
|
||||
CommentPostViewModel.validateDraft(
|
||||
rules = rules(deny = listOf(denied)),
|
||||
author = denied,
|
||||
draftContent = "hello",
|
||||
)
|
||||
assertTrue(violation is CommunityRulesValidator.Violation.AuthorDenied)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `under-size boundary passes`() {
|
||||
val draft = "x".repeat(100) // exactly the cap
|
||||
val violation =
|
||||
CommentPostViewModel.validateDraft(
|
||||
rules = rules(maxBytes = 100),
|
||||
author = author,
|
||||
draftContent = draft,
|
||||
)
|
||||
assertNull(violation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `multibyte chars count toward size cap by bytes not chars`() {
|
||||
// "🚀" is 4 UTF-8 bytes, so 30 of them = 120 bytes > cap of 100.
|
||||
val draft = "🚀".repeat(30)
|
||||
val violation =
|
||||
CommentPostViewModel.validateDraft(
|
||||
rules = rules(maxBytes = 100),
|
||||
author = author,
|
||||
draftContent = draft,
|
||||
)
|
||||
assertTrue(violation is CommunityRulesValidator.Violation.KindSizeExceeded)
|
||||
assertEquals(120, (violation as CommunityRulesValidator.Violation.KindSizeExceeded).sizeBytes)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user