diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index 6154f2693..870271d18 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -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, + pubkeyRules: List = emptyList(), + wotGates: List = 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 { val newNote = cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey)) val newEvent = newNote?.event as? ProfileBadgesEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/CommunityRulesEditor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/CommunityRulesEditor.kt new file mode 100644 index 000000000..78beb093f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/CommunityRulesEditor.kt @@ -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(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, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt index 09c61bed8..afdc9b5d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModel.kt @@ -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() val relays = mutableStateListOf() + // 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() + val bannedPubkeys = mutableStateListOf() + val wotGates = mutableStateListOf() + var maxEventSize by mutableStateOf(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, + bannedPubkeys: List, + wotGates: List, + 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, + val pubkeyRules: List, + val wotGates: List, + val maxEventSize: Int?, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt index f8100fc1a..12edeed58 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityScreen.kt @@ -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, + ) } } } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index e295639e2..5f6964c79 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -514,6 +514,34 @@ Requests Approvals No communities match this filter. + Verifiable rules (optional) + Publishes a machine-readable rules document (NIP-9A) alongside the community. Clients that support it block posts that don\'t match before sending. + Allowed event kinds + Tap to allow a kind. Long-press a kind to set per-event size or daily-post limits. + Short text (1) + Long-form article (30023) + Picture post (20) + Video post (21) + Short video (22) + Community comment (1111) + Custom kind + Add kind + Limits for kind %1$d + Max event size (bytes) + Max posts per author per day + Leave blank for no limit + Save limits + Banned users + These pubkeys can\'t post into the community. + Add a banned user + Name, npub, or NIP-05 + Web-of-trust gate (optional) + Allow only authors reachable from a root pubkey within N hops. Multiple gates pass if any one passes. + Root npub or hex pubkey + Depth + Add WoT gate + Global max event size (optional) + Bytes \u2014 leave blank for no global cap. Received Mine Awarded diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModelRulesTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModelRulesTest.kt new file mode 100644 index 000000000..21bc55e2e --- /dev/null +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/communities/newCommunity/NewCommunityModelRulesTest.kt @@ -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", ""] - 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")) + } +}