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:
@@ -230,6 +230,10 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov
|
|||||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
|
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
|
||||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
|
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.poll.PollEvent
|
||||||
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
|
||||||
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
|
import com.vitorpamplona.quartz.nip90Dvms.contentDiscoveryRequest.NIP90ContentDiscoveryRequestEvent
|
||||||
@@ -1245,6 +1249,41 @@ class Account(
|
|||||||
return signedEvent
|
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> {
|
private fun loadCurrentAcceptedBadges(): List<AcceptedBadge> {
|
||||||
val newNote = cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey))
|
val newNote = cache.getAddressableNoteIfExists(ProfileBadgesEvent.createAddress(signer.pubKey))
|
||||||
val newEvent = newNote?.event as? ProfileBadgesEvent
|
val newEvent = newNote?.event as? ProfileBadgesEvent
|
||||||
|
|||||||
+525
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+142
@@ -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.mediaServers.ServerName
|
||||||
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
|
||||||
import com.vitorpamplona.amethyst.ui.stringRes
|
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.relay.normalizer.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
|
||||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
|
||||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
|
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
|
||||||
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
|
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.collections.immutable.ImmutableList
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -58,6 +62,40 @@ data class CommunityRelayEntry(
|
|||||||
val marker: String? = null,
|
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
|
@Stable
|
||||||
class NewCommunityModel : ViewModel() {
|
class NewCommunityModel : ViewModel() {
|
||||||
var account: Account? = null
|
var account: Account? = null
|
||||||
@@ -87,6 +125,14 @@ class NewCommunityModel : ViewModel() {
|
|||||||
val moderators = mutableStateListOf<User>()
|
val moderators = mutableStateListOf<User>()
|
||||||
val relays = mutableStateListOf<CommunityRelayEntry>()
|
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) {
|
fun init(account: Account) {
|
||||||
if (this.account == account) return
|
if (this.account == account) return
|
||||||
this.account = account
|
this.account = account
|
||||||
@@ -173,6 +219,53 @@ class NewCommunityModel : ViewModel() {
|
|||||||
name.isNotBlank() &&
|
name.isNotBlank() &&
|
||||||
description.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)
|
@OptIn(ExperimentalUuidApi::class)
|
||||||
fun publish(
|
fun publish(
|
||||||
context: Context,
|
context: Context,
|
||||||
@@ -240,6 +333,19 @@ class NewCommunityModel : ViewModel() {
|
|||||||
return@launch
|
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.
|
// Auto-follow only on the create flow; editing doesn't change the follow set.
|
||||||
if (existingDTag == null) {
|
if (existingDTag == null) {
|
||||||
val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address())
|
val communityNote = myAccount.cache.getOrCreateAddressableNote(definition.address())
|
||||||
@@ -311,6 +417,42 @@ class NewCommunityModel : ViewModel() {
|
|||||||
multiOrchestrator = null
|
multiOrchestrator = null
|
||||||
moderators.clear()
|
moderators.clear()
|
||||||
relays.clear()
|
relays.clear()
|
||||||
|
kindRules.clear()
|
||||||
|
bannedPubkeys.clear()
|
||||||
|
wotGates.clear()
|
||||||
|
maxEventSize = null
|
||||||
selectedServer = defaultServer()
|
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?,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -264,6 +264,15 @@ private fun CommunityFormScreen(
|
|||||||
accountViewModel = accountViewModel,
|
accountViewModel = accountViewModel,
|
||||||
nav = nav,
|
nav = nav,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
HorizontalDivider()
|
||||||
|
|
||||||
|
SectionHeader(R.string.new_community_rules_section)
|
||||||
|
CommunityRulesEditorSection(
|
||||||
|
model = model,
|
||||||
|
accountViewModel = accountViewModel,
|
||||||
|
nav = nav,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -514,6 +514,34 @@
|
|||||||
<string name="new_community_relay_marker_requests">Requests</string>
|
<string name="new_community_relay_marker_requests">Requests</string>
|
||||||
<string name="new_community_relay_marker_approvals">Approvals</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_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="received_badges">Received</string>
|
||||||
<string name="my_badges">Mine</string>
|
<string name="my_badges">Mine</string>
|
||||||
<string name="awarded_badges">Awarded</string>
|
<string name="awarded_badges">Awarded</string>
|
||||||
|
|||||||
+159
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user