feat(community): structured NIP-9A rules editor in new-community flow

Adds an opt-in editor for NIP-9A `kind:34551` community rules alongside the
existing freeform `rules` text on `kind:34550`. Closes #2760.

Editor sections in `NewCommunityScreen` (rendered after relays):

- Allowed event kinds: filter chips for common community kinds (1, 20, 21,
  22, 1111, 30023) plus a custom-kind input. Per-kind limits dialog (long
  press / edit icon) for `max-bytes` and `max-per-author-per-day`.
- Banned users: pubkey list with `deny` policy, picked through the same
  user-suggestion field the moderator picker uses.
- Web-of-trust gate (optional): root npub-or-hex pubkey + depth, repeatable.
- Global max event size (optional): single bytes input.

`NewCommunityModel` carries the four collections/values as Compose state and
exposes a pure-helper `buildRulesPayload(...)` companion so the mapping from
editor drafts to Quartz tag types is unit-testable without an Account.

`Account.sendCommunityRules(...)` mirrors `sendCommunityDefinition(...)`:
builds a `CommunityRulesEvent` via the Quartz builder, signs with the same
key, and broadcasts on the same outbox path. Reuses the community's `dTag`
so the rules event replaces in place across edits.

`NewCommunityModel.publish(...)` only emits the rules event when at least
one structured rule is present (`hasStructuredRules()`), so existing
communities and form runs that don't touch the new section continue to
behave exactly as before.

Migrating existing communities, the `min_rules_created_at` ratchet UI, and
edit-flow preload of structured rules from `kind:34551` are deliberately
out of scope here (see issue #2760).

7 unit tests on the pure helper:
- empty editor produces a null payload (no event published)
- max-event-size only is enough to publish
- per-kind limits round-trip into the tag
- bare kind rule serialises to ["k", "<kind>"] (no empty fields)
- banned pubkey writes a deny rule
- WoT gate carries root pubkey + depth
- pubkey-input parser accepts hex and npub, rejects garbage

Builds clean: `:amethyst:assemblePlayDebug`, `:amethyst:spotlessCheck`,
`:amethyst:testPlayDebugUnitTest --tests "*NewCommunityModelRulesTest*"`.
This commit is contained in:
m
2026-05-09 09:11:24 +10:00
parent 8b8c0a10e9
commit f2bd58ce90
6 changed files with 902 additions and 0 deletions
@@ -230,6 +230,10 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesEvent
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
@@ -1245,6 +1249,41 @@ class Account(
return signedEvent
}
/**
* Publishes a sibling NIP-9A `kind:34551` rules document for a community we just
* (or previously) defined with [sendCommunityDefinition]. The event is signed by
* the community owner and addresses the definition through its `a` tag, sharing
* the same `dTag` so it replaces in place when re-edited.
*/
suspend fun sendCommunityRules(
communityDTag: String,
kindRules: List<KindRuleTag>,
pubkeyRules: List<PubkeyRuleTag> = emptyList(),
wotGates: List<WotTag> = emptyList(),
maxEventSize: Int? = null,
minRulesCreatedAt: Long? = null,
): CommunityRulesEvent? {
if (!isWriteable()) return null
val communityAddress = ATag(CommunityDefinitionEvent.KIND, signer.pubKey, communityDTag, null)
val template =
CommunityRulesEvent.build(
dTag = communityDTag,
communityAddress = communityAddress,
kindRules = kindRules,
pubkeyRules = pubkeyRules,
wotGates = wotGates,
maxEventSize = maxEventSize,
minRulesCreatedAt = minRulesCreatedAt,
)
val signedEvent = signer.sign(template)
cache.justConsumeMyOwnEvent(signedEvent)
client.publish(signedEvent, computeRelayListToBroadcast(signedEvent))
return signedEvent
}
private fun loadCurrentAcceptedBadges(): List<AcceptedBadge> {
val newNote = cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey))
val newEvent = newNote?.event as? ProfileBadgesEvent
@@ -0,0 +1,525 @@
/*
* 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.newCommunity
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.AssistChip
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilterChip
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
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.commons.ui.components.Nip05OrPubkeyLine
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.note.UserPicture
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.SuggestionListDefaultHeightPage
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
/**
* NIP-9A structured-rules editor section embedded in the community form.
*
* The whole section is opt-in: when no chip is selected and no other field is
* filled, [NewCommunityModel.publish] won't emit a `kind:34551` event and the
* community continues to behave like any pre-9A NIP-72 community. The freeform
* `rules: String` text on `kind:34550` is still rendered above this section by
* [CommunityFormScreen].
*/
@Composable
internal fun CommunityRulesEditorSection(
model: NewCommunityModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = stringRes(R.string.new_community_rules_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
AllowedKindsBlock(model = model)
BannedUsersBlock(model = model, accountViewModel = accountViewModel, nav = nav)
WotGateBlock(model = model)
MaxEventSizeField(model = model)
}
}
// --- Allowed kinds -----------------------------------------------------------------------------
private data class KnownKind(
val kind: Int,
val labelRes: Int,
)
private val KNOWN_KINDS =
listOf(
KnownKind(1, R.string.new_community_rules_kind_short_text),
KnownKind(20, R.string.new_community_rules_kind_picture),
KnownKind(21, R.string.new_community_rules_kind_video),
KnownKind(22, R.string.new_community_rules_kind_short_video),
KnownKind(1111, R.string.new_community_rules_kind_comment),
KnownKind(30023, R.string.new_community_rules_kind_long_form),
)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun AllowedKindsBlock(model: NewCommunityModel) {
SectionLabel(R.string.new_community_rules_kinds_section)
Text(
text = stringRes(R.string.new_community_rules_kinds_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val current = model.kindRules.toList()
var editingKind by remember { mutableStateOf<Int?>(null) }
androidx.compose.foundation.layout.FlowRow(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
KNOWN_KINDS.forEach { known ->
val selected = current.any { it.kind == known.kind }
FilterChip(
selected = selected,
onClick = {
if (selected) {
model.removeKindRule(known.kind)
} else {
model.addKindRule(KindRuleDraft(kind = known.kind))
}
},
label = { Text(stringRes(known.labelRes), style = MaterialTheme.typography.labelMedium) },
trailingIcon =
if (selected) {
{
IconButton(onClick = { editingKind = known.kind }) {
Icon(
symbol = MaterialSymbols.Edit,
contentDescription =
stringRes(R.string.new_community_rules_limits_title, known.kind),
)
}
}
} else {
null
},
)
}
// Custom kinds the user has added that aren't in KNOWN_KINDS.
current
.filter { rule -> KNOWN_KINDS.none { it.kind == rule.kind } }
.forEach { rule ->
AssistChip(
onClick = { editingKind = rule.kind },
label = {
Text(
stringRes(R.string.new_community_rules_kind_custom_label) + " " + rule.kind,
style = MaterialTheme.typography.labelMedium,
)
},
trailingIcon = {
IconButton(onClick = { model.removeKindRule(rule.kind) }) {
Icon(
symbol = MaterialSymbols.Close,
contentDescription = stringRes(R.string.remove),
)
}
},
)
}
}
CustomKindAddRow(model = model)
val toEdit = editingKind
if (toEdit != null) {
val rule = current.firstOrNull { it.kind == toEdit }
if (rule != null) {
KindRuleLimitsDialog(
rule = rule,
onDismiss = { editingKind = null },
onSave = { updated ->
model.updateKindRule(toEdit) { updated }
editingKind = null
},
)
} else {
editingKind = null
}
}
}
@Composable
private fun CustomKindAddRow(model: NewCommunityModel) {
var raw by remember { mutableStateOf("") }
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedTextField(
value = raw,
onValueChange = { input -> raw = input.filter { it.isDigit() } },
label = { Text(stringRes(R.string.new_community_rules_kind_custom_label)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
)
TextButton(
onClick = {
val parsed = raw.toIntOrNull()
if (parsed != null && parsed >= 0) {
model.addKindRule(KindRuleDraft(kind = parsed))
raw = ""
}
},
enabled = raw.toIntOrNull()?.let { it >= 0 } == true,
) {
Text(stringRes(R.string.new_community_rules_kind_add))
}
}
}
@Composable
private fun KindRuleLimitsDialog(
rule: KindRuleDraft,
onDismiss: () -> Unit,
onSave: (KindRuleDraft) -> Unit,
) {
var maxBytes by remember(rule) { mutableStateOf(rule.maxBytes?.toString().orEmpty()) }
var maxPerDay by remember(rule) { mutableStateOf(rule.maxPerAuthorPerDay?.toString().orEmpty()) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringRes(R.string.new_community_rules_limits_title, rule.kind)) },
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = maxBytes,
onValueChange = { input -> maxBytes = input.filter { it.isDigit() } },
label = { Text(stringRes(R.string.new_community_rules_limits_max_bytes)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
OutlinedTextField(
value = maxPerDay,
onValueChange = { input -> maxPerDay = input.filter { it.isDigit() } },
label = { Text(stringRes(R.string.new_community_rules_limits_max_per_day)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
Text(
text = stringRes(R.string.new_community_rules_limits_unlimited),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
},
confirmButton = {
TextButton(
onClick = {
onSave(
rule.copy(
maxBytes = maxBytes.toIntOrNull()?.takeIf { it > 0 },
maxPerAuthorPerDay = maxPerDay.toIntOrNull()?.takeIf { it > 0 },
),
)
},
) {
Text(stringRes(R.string.new_community_rules_limits_save))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringRes(R.string.cancel))
}
},
)
}
// --- Banned users ------------------------------------------------------------------------------
@Composable
private fun BannedUsersBlock(
model: NewCommunityModel,
accountViewModel: AccountViewModel,
nav: INav,
) {
SectionLabel(R.string.new_community_rules_banned_section)
Text(
text = stringRes(R.string.new_community_rules_banned_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
var search by remember { mutableStateOf("") }
val userSuggestions =
remember {
UserSuggestionState(accountViewModel.account, accountViewModel.nip05ClientBuilder())
}
DisposableEffect(Unit) {
onDispose { userSuggestions.reset() }
}
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
model.bannedPubkeys.toList().forEach { entry ->
val cachedUser = remember(entry.pubkey) { accountViewModel.account.cache.getUserIfExists(entry.pubkey) }
BannedPubkeyRow(
pubkeyHex = entry.pubkey,
user = cachedUser,
accountViewModel = accountViewModel,
nav = nav,
onRemove = { model.removeBannedPubkey(entry.pubkey) },
)
}
OutlinedTextField(
value = search,
onValueChange = {
search = it
if (it.length > 1) {
userSuggestions.processCurrentWord(it)
} else {
userSuggestions.reset()
}
},
label = { Text(stringRes(R.string.new_community_rules_banned_add)) },
placeholder = { Text(stringRes(R.string.new_community_rules_banned_placeholder)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
if (search.length > 1) {
ShowUserSuggestionList(
userSuggestions = userSuggestions,
onSelect = { user ->
model.addBannedPubkey(BannedPubkeyDraft(pubkey = user.pubkeyHex))
search = ""
userSuggestions.reset()
},
accountViewModel = accountViewModel,
modifier = SuggestionListDefaultHeightPage,
)
}
}
}
@Composable
private fun BannedPubkeyRow(
pubkeyHex: HexKey,
user: User?,
accountViewModel: AccountViewModel,
nav: INav,
onRemove: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
UserPicture(
userHex = pubkeyHex,
size = 36.dp,
accountViewModel = accountViewModel,
nav = nav,
)
Column(
modifier = Modifier.weight(1f).padding(start = 12.dp),
) {
Text(
text = user?.toBestDisplayName() ?: shortenHex(pubkeyHex),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (user != null) Nip05OrPubkeyLine(user)
}
IconButton(onClick = onRemove) {
Icon(symbol = MaterialSymbols.Close, contentDescription = stringRes(R.string.remove))
}
}
}
private fun shortenHex(hex: String): String = if (hex.length <= 16) hex else hex.take(8) + "\u2026" + hex.takeLast(8)
// --- WoT gate ----------------------------------------------------------------------------------
@Composable
private fun WotGateBlock(model: NewCommunityModel) {
SectionLabel(R.string.new_community_rules_wot_section)
Text(
text = stringRes(R.string.new_community_rules_wot_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
model.wotGates.toList().forEach { gate ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = shortenHex(gate.rootPubkey) + " \u00b7 " + gate.depth,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier.weight(1f),
)
IconButton(onClick = { model.removeWotGate(gate) }) {
Icon(symbol = MaterialSymbols.Close, contentDescription = stringRes(R.string.remove))
}
}
}
var rootRaw by remember { mutableStateOf("") }
var depthRaw by remember { mutableStateOf("") }
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
OutlinedTextField(
value = rootRaw,
onValueChange = { rootRaw = it },
label = { Text(stringRes(R.string.new_community_rules_wot_root)) },
singleLine = true,
modifier = Modifier.weight(2f),
)
OutlinedTextField(
value = depthRaw,
onValueChange = { input -> depthRaw = input.filter { it.isDigit() } },
label = { Text(stringRes(R.string.new_community_rules_wot_depth)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.weight(1f),
)
}
TextButton(
onClick = {
val depth = depthRaw.toIntOrNull()?.takeIf { it > 0 } ?: return@TextButton
val hex = parsePubkeyToHex(rootRaw.trim()) ?: return@TextButton
model.addWotGate(WotGateDraft(rootPubkey = hex, depth = depth))
rootRaw = ""
depthRaw = ""
},
enabled =
rootRaw.trim().isNotEmpty() &&
depthRaw.toIntOrNull()?.let { it > 0 } == true &&
parsePubkeyToHex(rootRaw.trim()) != null,
) {
Text(stringRes(R.string.new_community_rules_wot_add))
}
}
}
/** Accepts an `npub1...` bech32 or a raw 64-char hex pubkey. Returns canonical hex or null. */
internal fun parsePubkeyToHex(input: String): HexKey? {
val trimmed = input.trim()
if (trimmed.length == 64 && trimmed.all { it.isDigit() || it in 'a'..'f' || it in 'A'..'F' }) {
return trimmed.lowercase()
}
if (trimmed.startsWith("npub1")) {
val parsed = Nip19Parser.uriToRoute(trimmed)?.entity as? NPub ?: return null
return parsed.hex
}
return null
}
// --- Max event size ---------------------------------------------------------------------------
@Composable
private fun MaxEventSizeField(model: NewCommunityModel) {
SectionLabel(R.string.new_community_rules_max_event_size_section)
Text(
text = stringRes(R.string.new_community_rules_max_event_size_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val current = model.maxEventSize
var raw by remember(current) { mutableStateOf(current?.toString().orEmpty()) }
OutlinedTextField(
value = raw,
onValueChange = { input ->
val cleaned = input.filter { it.isDigit() }
raw = cleaned
model.maxEventSize = cleaned.toIntOrNull()?.takeIf { it > 0 }
},
label = { Text(stringRes(R.string.new_community_rules_limits_max_bytes)) },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = Modifier.fillMaxWidth(),
)
}
// --- Helpers ----------------------------------------------------------------------------------
@Composable
private fun SectionLabel(resourceId: Int) {
Text(
text = stringRes(resourceId),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
)
}
@@ -41,11 +41,15 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.KindRuleTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@@ -58,6 +62,40 @@ data class CommunityRelayEntry(
val marker: String? = null,
)
/**
* Editor-side draft of a NIP-9A `k` rule. Empty/null limit fields mean "no limit";
* they are dropped from the published tag.
*/
@Immutable
data class KindRuleDraft(
val kind: Int,
val maxBytes: Int? = null,
val maxPerAuthorPerDay: Int? = null,
) {
fun toTag(): KindRuleTag = KindRuleTag(kind, maxBytes, maxPerAuthorPerDay)
}
/**
* Editor-side draft of a NIP-9A `p` rule for a banned pubkey. v1 only writes
* `deny` policies; allow-listing is deferred to a follow-up.
*/
@Immutable
data class BannedPubkeyDraft(
val pubkey: HexKey,
val role: String? = null,
) {
fun toTag(): PubkeyRuleTag = PubkeyRuleTag(pubkey, PubkeyRuleTag.Policy.DENY, role)
}
/** Editor-side draft of a NIP-9A `wot` gate. */
@Immutable
data class WotGateDraft(
val rootPubkey: HexKey,
val depth: Int,
) {
fun toTag(): WotTag = WotTag(rootPubkey, depth)
}
@Stable
class NewCommunityModel : ViewModel() {
var account: Account? = null
@@ -87,6 +125,14 @@ class NewCommunityModel : ViewModel() {
val moderators = mutableStateListOf<User>()
val relays = mutableStateListOf<CommunityRelayEntry>()
// NIP-9A structured rules - kept separate from the freeform `rules: String` text
// field above. When all four collections/values are empty, no kind:34551 event is
// published, so existing communities upgrade only when an owner opts in.
val kindRules = mutableStateListOf<KindRuleDraft>()
val bannedPubkeys = mutableStateListOf<BannedPubkeyDraft>()
val wotGates = mutableStateListOf<WotGateDraft>()
var maxEventSize by mutableStateOf<Int?>(null)
fun init(account: Account) {
if (this.account == account) return
this.account = account
@@ -173,6 +219,53 @@ class NewCommunityModel : ViewModel() {
name.isNotBlank() &&
description.isNotBlank()
/** Returns true when the editor has any structured NIP-9A rule worth publishing. */
fun hasStructuredRules(): Boolean =
kindRules.isNotEmpty() ||
bannedPubkeys.isNotEmpty() ||
wotGates.isNotEmpty() ||
maxEventSize != null
fun addKindRule(rule: KindRuleDraft) {
if (kindRules.none { it.kind == rule.kind }) {
kindRules.add(rule)
}
}
fun updateKindRule(
kind: Int,
update: (KindRuleDraft) -> KindRuleDraft,
) {
val index = kindRules.indexOfFirst { it.kind == kind }
if (index >= 0) {
kindRules[index] = update(kindRules[index])
}
}
fun removeKindRule(kind: Int) {
kindRules.removeAll { it.kind == kind }
}
fun addBannedPubkey(entry: BannedPubkeyDraft) {
if (bannedPubkeys.none { it.pubkey == entry.pubkey }) {
bannedPubkeys.add(entry)
}
}
fun removeBannedPubkey(pubkey: HexKey) {
bannedPubkeys.removeAll { it.pubkey == pubkey }
}
fun addWotGate(entry: WotGateDraft) {
if (wotGates.none { it.rootPubkey == entry.rootPubkey && it.depth == entry.depth }) {
wotGates.add(entry)
}
}
fun removeWotGate(entry: WotGateDraft) {
wotGates.removeAll { it.rootPubkey == entry.rootPubkey && it.depth == entry.depth }
}
@OptIn(ExperimentalUuidApi::class)
fun publish(
context: Context,
@@ -240,6 +333,19 @@ class NewCommunityModel : ViewModel() {
return@launch
}
// Sibling NIP-9A rules document. Strictly opt-in: only published when
// the owner has set at least one structured rule. Reuses the same dTag
// so the rules event replaces in place across edits.
if (hasStructuredRules()) {
myAccount.sendCommunityRules(
communityDTag = dTag,
kindRules = kindRules.map { it.toTag() },
pubkeyRules = bannedPubkeys.map { it.toTag() },
wotGates = wotGates.map { it.toTag() },
maxEventSize = maxEventSize,
)
}
// Auto-follow only on the create flow; editing doesn't change the follow set.
if (existingDTag == null) {
val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address())
@@ -311,6 +417,42 @@ class NewCommunityModel : ViewModel() {
multiOrchestrator = null
moderators.clear()
relays.clear()
kindRules.clear()
bannedPubkeys.clear()
wotGates.clear()
maxEventSize = null
selectedServer = defaultServer()
}
companion object {
/**
* Builds the `kindRules`/`pubkeyRules`/`wotGates` lists in the same order
* the publish path uses. Pure helper for unit testing the publish() path
* touches an [Account] which can't be constructed in-process.
*/
fun buildRulesPayload(
kindRules: List<KindRuleDraft>,
bannedPubkeys: List<BannedPubkeyDraft>,
wotGates: List<WotGateDraft>,
maxEventSize: Int?,
): RulesPayload? {
if (kindRules.isEmpty() && bannedPubkeys.isEmpty() && wotGates.isEmpty() && maxEventSize == null) {
return null
}
return RulesPayload(
kindRules = kindRules.map { it.toTag() },
pubkeyRules = bannedPubkeys.map { it.toTag() },
wotGates = wotGates.map { it.toTag() },
maxEventSize = maxEventSize,
)
}
}
@Immutable
data class RulesPayload(
val kindRules: List<KindRuleTag>,
val pubkeyRules: List<PubkeyRuleTag>,
val wotGates: List<WotTag>,
val maxEventSize: Int?,
)
}
@@ -264,6 +264,15 @@ private fun CommunityFormScreen(
accountViewModel = accountViewModel,
nav = nav,
)
HorizontalDivider()
SectionHeader(R.string.new_community_rules_section)
CommunityRulesEditorSection(
model = model,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
+28
View File
@@ -514,6 +514,34 @@
<string name="new_community_relay_marker_requests">Requests</string>
<string name="new_community_relay_marker_approvals">Approvals</string>
<string name="new_community_empty">No communities match this filter.</string>
<string name="new_community_rules_section">Verifiable rules (optional)</string>
<string name="new_community_rules_hint">Publishes a machine-readable rules document (NIP-9A) alongside the community. Clients that support it block posts that don\'t match before sending.</string>
<string name="new_community_rules_kinds_section">Allowed event kinds</string>
<string name="new_community_rules_kinds_hint">Tap to allow a kind. Long-press a kind to set per-event size or daily-post limits.</string>
<string name="new_community_rules_kind_short_text">Short text (1)</string>
<string name="new_community_rules_kind_long_form">Long-form article (30023)</string>
<string name="new_community_rules_kind_picture">Picture post (20)</string>
<string name="new_community_rules_kind_video">Video post (21)</string>
<string name="new_community_rules_kind_short_video">Short video (22)</string>
<string name="new_community_rules_kind_comment">Community comment (1111)</string>
<string name="new_community_rules_kind_custom_label">Custom kind</string>
<string name="new_community_rules_kind_add">Add kind</string>
<string name="new_community_rules_limits_title">Limits for kind %1$d</string>
<string name="new_community_rules_limits_max_bytes">Max event size (bytes)</string>
<string name="new_community_rules_limits_max_per_day">Max posts per author per day</string>
<string name="new_community_rules_limits_unlimited">Leave blank for no limit</string>
<string name="new_community_rules_limits_save">Save limits</string>
<string name="new_community_rules_banned_section">Banned users</string>
<string name="new_community_rules_banned_hint">These pubkeys can\'t post into the community.</string>
<string name="new_community_rules_banned_add">Add a banned user</string>
<string name="new_community_rules_banned_placeholder">Name, npub, or NIP-05</string>
<string name="new_community_rules_wot_section">Web-of-trust gate (optional)</string>
<string name="new_community_rules_wot_hint">Allow only authors reachable from a root pubkey within N hops. Multiple gates pass if any one passes.</string>
<string name="new_community_rules_wot_root">Root npub or hex pubkey</string>
<string name="new_community_rules_wot_depth">Depth</string>
<string name="new_community_rules_wot_add">Add WoT gate</string>
<string name="new_community_rules_max_event_size_section">Global max event size (optional)</string>
<string name="new_community_rules_max_event_size_hint">Bytes \u2014 leave blank for no global cap.</string>
<string name="received_badges">Received</string>
<string name="my_badges">Mine</string>
<string name="awarded_badges">Awarded</string>
@@ -0,0 +1,159 @@
/*
* 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.newCommunity
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Unit tests for the pure-helper portion of [NewCommunityModel] that wires the
* structured-rules editor state into a NIP-9A `kind:34551` payload. The full
* `publish()` path needs a live [com.vitorpamplona.amethyst.model.Account] (and
* thus relays + a signer), so here we exercise the deterministic mapping that
* sits between the editor's drafts and the Quartz tag types.
*/
class NewCommunityModelRulesTest {
private val author = "a".repeat(64)
private val denied = "b".repeat(64)
private val wotRoot = "c".repeat(64)
@Test
fun `payload is null when no structured rules are set`() {
val payload =
NewCommunityModel.buildRulesPayload(
kindRules = emptyList(),
bannedPubkeys = emptyList(),
wotGates = emptyList(),
maxEventSize = null,
)
assertNull(payload)
}
@Test
fun `payload non-null when only maxEventSize is set`() {
val payload =
NewCommunityModel.buildRulesPayload(
kindRules = emptyList(),
bannedPubkeys = emptyList(),
wotGates = emptyList(),
maxEventSize = 4096,
)
assertNotNull(payload)
assertEquals(4096, payload!!.maxEventSize)
assertTrue(payload.kindRules.isEmpty())
}
@Test
fun `kind rule with limits round-trips into a tag`() {
val payload =
NewCommunityModel.buildRulesPayload(
kindRules =
listOf(
KindRuleDraft(kind = 1, maxBytes = 1024, maxPerAuthorPerDay = 5),
),
bannedPubkeys = emptyList(),
wotGates = emptyList(),
maxEventSize = null,
)
assertNotNull(payload)
assertEquals(1, payload!!.kindRules.size)
val tag = payload.kindRules[0]
assertEquals(1, tag.kind)
assertEquals(1024, tag.maxBytes)
assertEquals(5, tag.maxPerAuthorPerDay)
}
@Test
fun `kind rule with no limits drops them from the published tag array`() {
val payload =
NewCommunityModel.buildRulesPayload(
kindRules = listOf(KindRuleDraft(kind = 1111)),
bannedPubkeys = emptyList(),
wotGates = emptyList(),
maxEventSize = null,
)
assertNotNull(payload)
val tagArray = payload!!.kindRules[0].toTagArray()
// arrayOfNotNull strips null limit fields, so a bare kind rule serialises to
// exactly ["k", "<kind>"] - verifying the editor's "leave blank for no limit"
// contract.
assertEquals(2, tagArray.size)
assertEquals("k", tagArray[0])
assertEquals("1111", tagArray[1])
}
@Test
fun `banned pubkey is published as a deny rule`() {
val payload =
NewCommunityModel.buildRulesPayload(
kindRules = emptyList(),
bannedPubkeys = listOf(BannedPubkeyDraft(pubkey = denied)),
wotGates = emptyList(),
maxEventSize = null,
)
assertNotNull(payload)
assertEquals(1, payload!!.pubkeyRules.size)
val rule = payload.pubkeyRules[0]
assertEquals(denied, rule.pubkey)
assertEquals(PubkeyRuleTag.Policy.DENY, rule.policy)
}
@Test
fun `wot gate carries depth and root pubkey`() {
val payload =
NewCommunityModel.buildRulesPayload(
kindRules = emptyList(),
bannedPubkeys = emptyList(),
wotGates = listOf(WotGateDraft(rootPubkey = wotRoot, depth = 3)),
maxEventSize = null,
)
assertNotNull(payload)
assertEquals(1, payload!!.wotGates.size)
val gate = payload.wotGates[0]
assertEquals(wotRoot, gate.rootPubkey)
assertEquals(3, gate.depth)
}
@Test
fun `rules editor and pubkey-input parser accept hex and npub`() {
val hex = "deadbeef".repeat(8)
// Round-trip via NPub.create so we don't have to hard-code a bech32 string.
val npub =
com.vitorpamplona.quartz.nip19Bech32.entities.NPub
.create(hex)
assertEquals(hex, parsePubkeyToHex(hex))
assertEquals(hex, parsePubkeyToHex(npub))
assertNull(parsePubkeyToHex("not a pubkey"))
assertNull(parsePubkeyToHex(""))
assertNull(parsePubkeyToHex("npub1invalid"))
}
}