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.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
|
||||
|
||||
+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.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?,
|
||||
)
|
||||
}
|
||||
|
||||
+9
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+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