From cb0de81f1ab04ca883fb3585e368948e9067a11b Mon Sep 17 00:00:00 2001 From: m Date: Sat, 9 May 2026 08:51:28 +1000 Subject: [PATCH] feat(community): validate posts against NIP-9A community rules in composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=`, `#a=`, 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 --- .../nip22Comments/CommentPostViewModel.kt | 109 ++++++++++++- .../CommunityRulesViolationBanner.kt | 119 +++++++++++++++ .../nip22Comments/GenericCommentPostScreen.kt | 15 ++ .../datasource/CommunityFilterAssembler.kt | 1 + .../CommunityRulesFilterSubAssembler.kt | 80 ++++++++++ amethyst/src/main/res/values/strings.xml | 9 ++ .../nip22Comments/CommentPostViewModelTest.kt | 143 ++++++++++++++++++ 7 files changed, 475 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommunityRulesViolationBanner.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/note/nip22Comments/CommentPostViewModelTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index 2cc5c9f72..499ae5b7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -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?>(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(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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommunityRulesViolationBanner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommunityRulesViolationBanner.kt new file mode 100644 index 000000000..b02ba9542 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommunityRulesViolationBanner.kt @@ -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) + } + } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index 08d4cebd8..af96d075b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -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) } } 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/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index f71f65d16..045707dec 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -2772,4 +2772,13 @@ Public emojis are visible to everyone and appear in your reaction menu and \":\" autocomplete picker when this pack is in your emoji list. Private emojis are stored encrypted on relays and visible only to you. They appear in your reaction menu and \":\" autocomplete just like public ones. Gif + + Community rule violation + You are on this community\'s deny-list. Posts will not be accepted. + This community does not accept events of kind %1$d. + Draft is %1$d bytes; this community caps this kind at %2$d bytes. + Draft is %1$d bytes; this community caps events at %2$d bytes. + You have already posted %1$d events of this kind today (limit %2$d). + You don\'t pass this community\'s web-of-trust requirements. + The latest community rules document was rejected as stale. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModelTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModelTest.kt new file mode 100644 index 000000000..611933d96 --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModelTest.kt @@ -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 = emptyList(), + ): CommunityRulesEvent { + val tags = + buildList> { + 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) + } +}