feat: NIP-86 roles/claims/event allow list in amy and relay management UI

amy admin gains unban-event, unallow-event, list-allowed-events,
list-disallowed-kinds, create/edit/delete-role, assign/unassign-role and
list/create/delete-claim; allow-event is documented as "allow-list".

Android relay management: the moderation queue's approve button keeps
using allowevent (which now allow-lists and lifts any ban) and refreshes
every event list; banned events can be unbanned, allowed events are
listed with unallow, and disallowed kinds are shown read-only.

NIP-43 members screen: filters 13534/33534 by the relay's NIP-11 self,
shows each member's roles as hue-tinted chips, and asks for the invite
code the join request (28934) now requires.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MGR1u8SyzcUuekub39SBsc
This commit is contained in:
Claude
2026-09-27 17:05:19 +00:00
parent 2465474d0d
commit 7ed0190fc3
8 changed files with 389 additions and 42 deletions
@@ -41,6 +41,7 @@ import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
@@ -59,9 +60,12 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui.RelayRoleChips
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_members_count
import com.vitorpamplona.amethyst.commons.resources.relay_members_empty
import com.vitorpamplona.amethyst.commons.resources.relay_members_invite_code
import com.vitorpamplona.amethyst.commons.resources.relay_members_invite_code_hint
import com.vitorpamplona.amethyst.commons.resources.relay_members_join_sent
import com.vitorpamplona.amethyst.commons.resources.relay_members_leave_sent
import com.vitorpamplona.amethyst.commons.resources.relay_members_loading
@@ -72,9 +76,9 @@ import com.vitorpamplona.amethyst.commons.resources.relay_members_you_are_member
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.note.UserCompose
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.fetchAsFlow
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -83,6 +87,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip43RelayMembers.joinRequest.RelayJoinRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.leaveRequest.RelayLeaveRequestEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.tags.RelayMember
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRoleEvent
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.lastOrNull
import kotlinx.coroutines.launch
@@ -97,24 +104,32 @@ fun RelayMembersScreen(
val normalizedRelayUrl = remember(relayUrl) { RelayUrlNormalizer.normalizeOrNull(relayUrl) }
if (normalizedRelayUrl == null) return
var members by remember { mutableStateOf<List<HexKey>>(emptyList()) }
var members by remember { mutableStateOf<List<RelayMember>>(emptyList()) }
var roles by remember { mutableStateOf<Map<String, RelayRole>>(emptyMap()) }
var isLoading by remember { mutableStateOf(true) }
var isMember by remember { mutableStateOf(false) }
var joinRequestSent by remember { mutableStateOf(false) }
var leaveRequestSent by remember { mutableStateOf(false) }
var inviteCode by remember { mutableStateOf("") }
val scope = rememberCoroutineScope()
LaunchedEffect(normalizedRelayUrl) {
// NIP-43 lists (13534) and roles (33534) MUST be signed by the relay's NIP-11 `self`.
// Filter by it once the doc resolves; until then, take whatever the relay serves.
val relayInfo by loadRelayInfo(normalizedRelayUrl)
val relaySelf = relayInfo.self
LaunchedEffect(normalizedRelayUrl, relaySelf) {
launch(Dispatchers.IO) {
val filter =
Filter(
kinds = listOf(RelayMembershipListEvent.KIND),
limit = 1,
val authors = relaySelf?.let { listOf(it) }
val filters =
listOf(
Filter(kinds = listOf(RelayMembershipListEvent.KIND), authors = authors, limit = 1),
Filter(kinds = listOf(RelayRoleEvent.KIND), authors = authors),
)
val events =
accountViewModel.account.client
.fetchAsFlow(normalizedRelayUrl, filter)
.fetchAsFlow(normalizedRelayUrl, filters)
.lastOrNull()
val membershipEvent =
@@ -122,9 +137,19 @@ fun RelayMembersScreen(
?.mapNotNull { it as? RelayMembershipListEvent }
?.maxByOrNull { it.createdAt }
val memberList = membershipEvent?.members() ?: emptyList()
// Only trust role definitions from whoever signed the member list.
val roleSigner = relaySelf ?: membershipEvent?.pubKey
roles =
events
?.mapNotNull { it as? RelayRoleEvent }
?.filter { it.pubKey == roleSigner }
?.groupBy { it.roleId() }
?.mapValues { (_, versions) -> versions.maxBy { it.createdAt }.role() }
?: emptyMap()
val memberList = membershipEvent?.membersWithRoles() ?: emptyList()
members = memberList
isMember = memberList.contains(accountViewModel.account.signer.pubKey)
isMember = memberList.any { it.pubKey == accountViewModel.account.signer.pubKey }
isLoading = false
}
}
@@ -162,9 +187,12 @@ fun RelayMembersScreen(
isLoading = isLoading,
joinRequestSent = joinRequestSent,
leaveRequestSent = leaveRequestSent,
inviteCode = inviteCode,
onInviteCodeChange = { inviteCode = it },
onJoinRequest = {
val claim = inviteCode.trim()
accountViewModel.launchSigner {
sendJoinRequest(normalizedRelayUrl, accountViewModel)
sendJoinRequest(normalizedRelayUrl, claim, accountViewModel)
joinRequestSent = true
}
},
@@ -215,13 +243,21 @@ fun RelayMembersScreen(
)
LazyColumn(modifier = Modifier.fillMaxSize()) {
items(members, key = { it }) { memberPubKey ->
val user = remember(memberPubKey) { accountViewModel.account.cache.getOrCreateUser(memberPubKey) }
UserCompose(
baseUser = user,
accountViewModel = accountViewModel,
nav = nav,
)
items(members, key = { it.pubKey }) { member ->
val user = remember(member.pubKey) { accountViewModel.account.cache.getOrCreateUser(member.pubKey) }
Column {
UserCompose(
baseUser = user,
accountViewModel = accountViewModel,
nav = nav,
)
// Role ids without a published 33534 definition still show, by id.
val memberRoles = remember(member, roles) { member.roles.map { roles[it] ?: RelayRole(it) } }
RelayRoleChips(
roles = memberRoles,
modifier = Modifier.padding(start = 16.dp, end = 16.dp, bottom = 8.dp),
)
}
}
}
}
@@ -235,9 +271,32 @@ fun MembershipActions(
isLoading: Boolean,
joinRequestSent: Boolean,
leaveRequestSent: Boolean,
inviteCode: String,
onInviteCodeChange: (String) -> Unit,
onJoinRequest: () -> Unit,
onLeaveRequest: () -> Unit,
) {
if (!isLoading && !isMember && !joinRequestSent) {
// NIP-43 join requests (kind 28934) must carry the invite code the relay issued.
Column(
modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, top = 16.dp),
) {
Text(
text = stringRes(Res.string.relay_members_invite_code_hint),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = inviteCode,
onValueChange = onInviteCodeChange,
label = { Text(stringRes(Res.string.relay_members_invite_code)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
}
Row(
modifier =
Modifier
@@ -293,7 +352,7 @@ fun MembershipActions(
fontWeight = FontWeight.Bold,
)
} else {
Button(onClick = onJoinRequest) {
Button(onClick = onJoinRequest, enabled = inviteCode.isNotBlank()) {
Icon(
symbol = MaterialSymbols.PersonAdd,
contentDescription = null,
@@ -316,6 +375,8 @@ private fun MembershipActionsNotMemberPreview() {
isLoading = false,
joinRequestSent = false,
leaveRequestSent = false,
inviteCode = "",
onInviteCodeChange = {},
onJoinRequest = {},
onLeaveRequest = {},
)
@@ -331,6 +392,8 @@ private fun MembershipActionsIsMemberPreview() {
isLoading = false,
joinRequestSent = false,
leaveRequestSent = false,
inviteCode = "",
onInviteCodeChange = {},
onJoinRequest = {},
onLeaveRequest = {},
)
@@ -346,6 +409,8 @@ private fun MembershipActionsJoinSentPreview() {
isLoading = false,
joinRequestSent = true,
leaveRequestSent = false,
inviteCode = "",
onInviteCodeChange = {},
onJoinRequest = {},
onLeaveRequest = {},
)
@@ -361,6 +426,8 @@ private fun MembershipActionsLeaveSentPreview() {
isLoading = false,
joinRequestSent = false,
leaveRequestSent = true,
inviteCode = "",
onInviteCodeChange = {},
onJoinRequest = {},
onLeaveRequest = {},
)
@@ -369,9 +436,10 @@ private fun MembershipActionsLeaveSentPreview() {
suspend fun sendJoinRequest(
relay: NormalizedRelayUrl,
claim: String,
accountViewModel: AccountViewModel,
) {
val template = RelayJoinRequestEvent.build()
val template = RelayJoinRequestEvent.build(claim)
val signedEvent = accountViewModel.account.signer.sign(template)
accountViewModel.account.cache.justConsumeMyOwnEvent(signedEvent)
accountViewModel.account.client.publish(signedEvent, setOf(relay))
@@ -82,8 +82,10 @@ import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_management_add
import com.vitorpamplona.amethyst.commons.resources.relay_management_allow
import com.vitorpamplona.amethyst.commons.resources.relay_management_allow_event
import com.vitorpamplona.amethyst.commons.resources.relay_management_allow_kind
import com.vitorpamplona.amethyst.commons.resources.relay_management_allow_pubkey
import com.vitorpamplona.amethyst.commons.resources.relay_management_allowed_events
import com.vitorpamplona.amethyst.commons.resources.relay_management_allowed_kinds
import com.vitorpamplona.amethyst.commons.resources.relay_management_allowed_pubkeys
import com.vitorpamplona.amethyst.commons.resources.relay_management_apply
@@ -96,6 +98,7 @@ import com.vitorpamplona.amethyst.commons.resources.relay_management_block_ip
import com.vitorpamplona.amethyst.commons.resources.relay_management_blocked_ips
import com.vitorpamplona.amethyst.commons.resources.relay_management_cancel
import com.vitorpamplona.amethyst.commons.resources.relay_management_confirm
import com.vitorpamplona.amethyst.commons.resources.relay_management_disallowed_kinds
import com.vitorpamplona.amethyst.commons.resources.relay_management_dismiss
import com.vitorpamplona.amethyst.commons.resources.relay_management_error
import com.vitorpamplona.amethyst.commons.resources.relay_management_event_id_hex
@@ -103,11 +106,13 @@ import com.vitorpamplona.amethyst.commons.resources.relay_management_ip_address
import com.vitorpamplona.amethyst.commons.resources.relay_management_kind_number
import com.vitorpamplona.amethyst.commons.resources.relay_management_loading
import com.vitorpamplona.amethyst.commons.resources.relay_management_moderation_queue
import com.vitorpamplona.amethyst.commons.resources.relay_management_no_allowed_events
import com.vitorpamplona.amethyst.commons.resources.relay_management_no_allowed_kinds
import com.vitorpamplona.amethyst.commons.resources.relay_management_no_allowed_pubkeys
import com.vitorpamplona.amethyst.commons.resources.relay_management_no_banned_events
import com.vitorpamplona.amethyst.commons.resources.relay_management_no_banned_pubkeys
import com.vitorpamplona.amethyst.commons.resources.relay_management_no_blocked_ips
import com.vitorpamplona.amethyst.commons.resources.relay_management_no_disallowed_kinds
import com.vitorpamplona.amethyst.commons.resources.relay_management_no_methods
import com.vitorpamplona.amethyst.commons.resources.relay_management_no_moderation_events
import com.vitorpamplona.amethyst.commons.resources.relay_management_reason_optional
@@ -288,12 +293,19 @@ private fun RelayManagementContent(
add(ManagementTab.PUBKEYS)
}
if (supportedMethods.any {
it in listOf(Nip86Method.BAN_EVENT, Nip86Method.LIST_BANNED_EVENTS, Nip86Method.ALLOW_EVENT, Nip86Method.LIST_EVENTS_NEEDING_MODERATION)
it in
listOf(
Nip86Method.BAN_EVENT,
Nip86Method.LIST_BANNED_EVENTS,
Nip86Method.ALLOW_EVENT,
Nip86Method.LIST_ALLOWED_EVENTS,
Nip86Method.LIST_EVENTS_NEEDING_MODERATION,
)
}
) {
add(ManagementTab.EVENTS)
}
if (supportedMethods.any { it in listOf(Nip86Method.ALLOW_KIND, Nip86Method.DISALLOW_KIND, Nip86Method.LIST_ALLOWED_KINDS) }) {
if (supportedMethods.any { it in listOf(Nip86Method.ALLOW_KIND, Nip86Method.DISALLOW_KIND, Nip86Method.LIST_ALLOWED_KINDS, Nip86Method.LIST_DISALLOWED_KINDS) }) {
add(ManagementTab.KINDS)
}
if (supportedMethods.any { it in listOf(Nip86Method.BLOCK_IP, Nip86Method.UNBLOCK_IP, Nip86Method.LIST_BLOCKED_IPS) }) {
@@ -571,8 +583,10 @@ private fun EventsTab(
supportedMethods: List<String>,
) {
val bannedEvents by viewModel.bannedEvents.collectAsState()
val allowedEvents by viewModel.allowedEvents.collectAsState()
val eventsNeedingModeration by viewModel.eventsNeedingModeration.collectAsState()
var showBanDialog by remember { mutableStateOf(false) }
var showAllowDialog by remember { mutableStateOf(false) }
LazyColumn(
contentPadding = PaddingValues(10.dp),
@@ -596,6 +610,7 @@ private fun EventsTab(
reason = entry.reason,
canAllow = supportedMethods.contains(Nip86Method.ALLOW_EVENT),
canBan = supportedMethods.contains(Nip86Method.BAN_EVENT),
// Approve: `allowevent` allow-lists the event (and lifts any ban).
onAllow = { viewModel.allowEvent(entry.id) },
onBan = { viewModel.banEvent(entry.id) },
)
@@ -616,16 +631,52 @@ private fun EventsTab(
if (bannedEvents.isEmpty()) {
item { EmptyListMessage(stringRes(Res.string.relay_management_no_banned_events)) }
} else {
items(bannedEvents, key = { it.id }) { entry ->
items(bannedEvents, key = { "banned" + it.id }) { entry ->
HexEntryCard(
hex = entry.id,
reason = entry.reason,
showRemove = false,
onRemove = {},
showRemove = supportedMethods.contains(Nip86Method.UNBAN_EVENT),
onRemove = { viewModel.unbanEvent(entry.id) },
)
}
}
}
if (supportedMethods.contains(Nip86Method.LIST_ALLOWED_EVENTS)) {
item { Spacer(modifier = Modifier.height(8.dp)) }
item {
SectionHeaderWithAdd(
stringRes(Res.string.relay_management_allowed_events),
showAdd = supportedMethods.contains(Nip86Method.ALLOW_EVENT),
onAdd = { showAllowDialog = true },
)
}
if (allowedEvents.isEmpty()) {
item { EmptyListMessage(stringRes(Res.string.relay_management_no_allowed_events)) }
} else {
items(allowedEvents, key = { "allowed" + it.id }) { entry ->
HexEntryCard(
hex = entry.id,
reason = entry.reason,
showRemove = supportedMethods.contains(Nip86Method.UNALLOW_EVENT),
onRemove = { viewModel.unallowEvent(entry.id) },
)
}
}
}
}
if (showAllowDialog) {
HexInputDialog(
title = stringRes(Res.string.relay_management_allow_event),
label = stringRes(Res.string.relay_management_event_id_hex),
onConfirm = { hex, reason ->
viewModel.allowEvent(hex, reason.ifBlank { null })
showAllowDialog = false
},
onDismiss = { showAllowDialog = false },
)
}
if (showBanDialog) {
@@ -648,6 +699,7 @@ private fun KindsTab(
supportedMethods: List<String>,
) {
val allowedKinds by viewModel.allowedKinds.collectAsState()
val disallowedKinds by viewModel.disallowedKinds.collectAsState()
var showAddDialog by remember { mutableStateOf(false) }
LazyColumn(
@@ -665,7 +717,7 @@ private fun KindsTab(
if (allowedKinds.isEmpty()) {
item { EmptyListMessage(stringRes(Res.string.relay_management_no_allowed_kinds)) }
} else {
items(allowedKinds, key = { it }) { kind ->
items(allowedKinds, key = { "allowed$it" }) { kind ->
KindEntryCard(
kind = kind,
showRemove = supportedMethods.contains(Nip86Method.DISALLOW_KIND),
@@ -673,6 +725,30 @@ private fun KindsTab(
)
}
}
// Read-only: NIP-86 has no "undisallow"; `allowkind` would also turn on the allow list.
if (supportedMethods.contains(Nip86Method.LIST_DISALLOWED_KINDS)) {
item { Spacer(modifier = Modifier.height(8.dp)) }
item {
SectionHeaderWithAdd(
stringRes(Res.string.relay_management_disallowed_kinds),
showAdd = false,
onAdd = {},
)
}
if (disallowedKinds.isEmpty()) {
item { EmptyListMessage(stringRes(Res.string.relay_management_no_disallowed_kinds)) }
} else {
items(disallowedKinds, key = { "disallowed$it" }) { kind ->
KindEntryCard(
kind = kind,
showRemove = false,
onRemove = {},
)
}
}
}
}
if (showAddDialog) {
@@ -29,11 +29,13 @@ import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedEvent
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.AllowedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedEvent
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BannedPubkey
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.BlockedIp
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.EventNeedingModeration
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -69,12 +71,18 @@ class RelayManagementViewModel(
private val _bannedEvents = MutableStateFlow<List<BannedEvent>>(emptyList())
val bannedEvents: StateFlow<List<BannedEvent>> = _bannedEvents
private val _allowedEvents = MutableStateFlow<List<AllowedEvent>>(emptyList())
val allowedEvents: StateFlow<List<AllowedEvent>> = _allowedEvents
private val _eventsNeedingModeration = MutableStateFlow<List<EventNeedingModeration>>(emptyList())
val eventsNeedingModeration: StateFlow<List<EventNeedingModeration>> = _eventsNeedingModeration
private val _allowedKinds = MutableStateFlow<List<Int>>(emptyList())
val allowedKinds: StateFlow<List<Int>> = _allowedKinds
private val _disallowedKinds = MutableStateFlow<List<Int>>(emptyList())
val disallowedKinds: StateFlow<List<Int>> = _disallowedKinds
private val _blockedIps = MutableStateFlow<List<BlockedIp>>(emptyList())
val blockedIps: StateFlow<List<BlockedIp>> = _blockedIps
@@ -147,6 +155,17 @@ class RelayManagementViewModel(
}
}
fun loadAllowedEvents() {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.listAllowedEvents())
if (response.error != null) {
_error.value = response.error
} else {
_allowedEvents.value = client.parseAllowedEvents(response)?.distinctBy { it.id } ?: emptyList()
}
}
}
fun loadEventsNeedingModeration() {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.listEventsNeedingModeration())
@@ -169,6 +188,17 @@ class RelayManagementViewModel(
}
}
fun loadDisallowedKinds() {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.listDisallowedKinds())
if (response.error != null) {
_error.value = response.error
} else {
_disallowedKinds.value = client.parseDisallowedKinds(response)?.distinctBy { it } ?: emptyList()
}
}
}
fun loadBlockedIps() {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.listBlockedIps())
@@ -189,7 +219,9 @@ class RelayManagementViewModel(
if (response.error != null) {
_error.value = response.error
} else {
// NIP-86: banning also drops the pubkey from the allow list.
loadBannedPubkeys()
loadIfSupported(Nip86Method.LIST_ALLOWED_PUBKEYS) { loadAllowedPubkeys() }
}
}
}
@@ -214,7 +246,9 @@ class RelayManagementViewModel(
if (response.error != null) {
_error.value = response.error
} else {
// NIP-86: allowing also lifts any ban on the pubkey.
loadAllowedPubkeys()
loadIfSupported(Nip86Method.LIST_BANNED_PUBKEYS) { loadBannedPubkeys() }
}
}
}
@@ -236,6 +270,17 @@ class RelayManagementViewModel(
) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.banEvent(eventId, reason))
if (response.error != null) {
_error.value = response.error
} else {
reloadEventLists()
}
}
}
fun unbanEvent(eventId: String) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.unbanEvent(eventId))
if (response.error != null) {
_error.value = response.error
} else {
@@ -244,6 +289,10 @@ class RelayManagementViewModel(
}
}
/**
* Approves an event: NIP-86 `allowevent` puts it on the relay's event
* allow list and lifts any ban on it (it no longer means "unban").
*/
fun allowEvent(
eventId: String,
reason: String? = null,
@@ -253,11 +302,35 @@ class RelayManagementViewModel(
if (response.error != null) {
_error.value = response.error
} else {
loadEventsNeedingModeration()
reloadEventLists()
}
}
}
fun unallowEvent(eventId: String) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.unallowEvent(eventId))
if (response.error != null) {
_error.value = response.error
} else {
loadAllowedEvents()
}
}
}
private fun reloadEventLists() {
loadIfSupported(Nip86Method.LIST_EVENTS_NEEDING_MODERATION) { loadEventsNeedingModeration() }
loadIfSupported(Nip86Method.LIST_BANNED_EVENTS) { loadBannedEvents() }
loadIfSupported(Nip86Method.LIST_ALLOWED_EVENTS) { loadAllowedEvents() }
}
private inline fun loadIfSupported(
method: String,
load: () -> Unit,
) {
if (_supportedMethods.value.contains(method)) load()
}
fun changeRelayName(newName: String) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.changeRelayName(newName))
@@ -292,6 +365,7 @@ class RelayManagementViewModel(
_error.value = response.error
} else {
loadAllowedKinds()
loadIfSupported(Nip86Method.LIST_DISALLOWED_KINDS) { loadDisallowedKinds() }
}
}
}
@@ -303,6 +377,7 @@ class RelayManagementViewModel(
_error.value = response.error
} else {
loadAllowedKinds()
loadIfSupported(Nip86Method.LIST_DISALLOWED_KINDS) { loadDisallowedKinds() }
}
}
}
@@ -337,12 +412,13 @@ class RelayManagementViewModel(
}
fun loadAllLists() {
val methods = _supportedMethods.value
if (methods.contains("listbannedpubkeys")) loadBannedPubkeys()
if (methods.contains("listallowedpubkeys")) loadAllowedPubkeys()
if (methods.contains("listbannedevents")) loadBannedEvents()
if (methods.contains("listeventsneedingmoderation")) loadEventsNeedingModeration()
if (methods.contains("listallowedkinds")) loadAllowedKinds()
if (methods.contains("listblockedips")) loadBlockedIps()
loadIfSupported(Nip86Method.LIST_BANNED_PUBKEYS) { loadBannedPubkeys() }
loadIfSupported(Nip86Method.LIST_ALLOWED_PUBKEYS) { loadAllowedPubkeys() }
loadIfSupported(Nip86Method.LIST_BANNED_EVENTS) { loadBannedEvents() }
loadIfSupported(Nip86Method.LIST_ALLOWED_EVENTS) { loadAllowedEvents() }
loadIfSupported(Nip86Method.LIST_EVENTS_NEEDING_MODERATION) { loadEventsNeedingModeration() }
loadIfSupported(Nip86Method.LIST_ALLOWED_KINDS) { loadAllowedKinds() }
loadIfSupported(Nip86Method.LIST_DISALLOWED_KINDS) { loadDisallowedKinds() }
loadIfSupported(Nip86Method.LIST_BLOCKED_IPS) { loadBlockedIps() }
}
}
+6 -2
View File
@@ -453,8 +453,12 @@ HTTP endpoint. Reuses quartz's `Nip86Client` and the shared `Nip86Retriever`
| `amy admin RELAY supported-methods` | List the NIP-86 methods the relay implements. |
| `amy admin RELAY ban-pubkey HEX [--reason R]` / `unban-pubkey HEX` / `list-banned-pubkeys` | Pubkey ban list. |
| `amy admin RELAY allow-pubkey HEX [--reason R]` / `unallow-pubkey HEX` / `list-allowed-pubkeys` | Pubkey allow list. |
| `amy admin RELAY ban-event ID [--reason R]` / `allow-event ID` / `list-banned-events` / `list-needing-moderation` | Event moderation. |
| `amy admin RELAY allow-kind N` / `disallow-kind N` / `list-allowed-kinds` | Kind allow list. |
| `amy admin RELAY ban-event ID [--reason R]` / `unban-event ID` / `list-banned-events` | Event ban list. `ban-event` also drops the id from the allow list; `unban-event` does not allow-list it. |
| `amy admin RELAY allow-event ID [--reason R]` / `unallow-event ID` / `list-allowed-events` / `list-needing-moderation` | Event allow list (approve an event: it also lifts any ban) and the moderation queue. |
| `amy admin RELAY create-role ID [--label L] [--description D] [--color HUE] [--order N]` / `edit-role ID …` / `delete-role ID` | NIP-43 member roles (kind 33534); `--color` is a hue 0–360. |
| `amy admin RELAY assign-role HEX ROLE` / `unassign-role HEX ROLE` | Give / take a role. |
| `amy admin RELAY create-claim CODE` / `delete-claim CODE` / `list-claims` | NIP-43 invite codes for kind 28934 join requests. |
| `amy admin RELAY allow-kind N` / `disallow-kind N` / `list-allowed-kinds` / `list-disallowed-kinds` | Kind allow / deny lists. |
| `amy admin RELAY block-ip IP [--reason R]` / `unblock-ip IP` / `list-blocked-ips` | IP block list. |
| `amy admin RELAY change-name S` / `change-description S` / `change-icon URL` | Relay metadata. |
+1 -1
View File
@@ -116,7 +116,7 @@ vs streaming `subscribe`). Stateless verbs run with no account or network.
| `git` | `amy git` | ✅ (events + read) | NIP-34: `init` bootstraps a repo from the local `git` checkout (announce + state, like `ngit init`); repo announce (30617) + state (30618), patches (1617), pull requests (1618/1619), issues (1621), NIP-22 comments (1111), NIP-32 labels (1985), status open/applied/closed/draft (1630-1633), GRASP server list (10317); `issues`/`patches`/`prs`/`thread` reads derive status; `apply` applies a fetched patch to the local tree (`git am`); `browse`/`cat`/`log` read git objects over smart-HTTP v2 (quartz `GitHttpClient`, the same shallow-clone path the Android browser uses). Only git-packfile **push** (writing objects to clone/GRASP servers) and NIP-34 cover notes (1624, no quartz builder yet) are out of scope. Event tag shapes were verified byte-for-byte against the ngit reference implementation and the NIP-34 spec (`clone`/`web` as single multi-value tags, issue `p`-tag for maintainer routing, plain patch/PR `r` tags); the quartz readers stay tolerant of the legacy repeated form. See `quartz/…/nip34Git/GitNip34InteropTest`. |
| `podcast` | `amy podcast` | ✅ | NIP-F4 show metadata (10154) + episode publish (54) + list. |
| `bunker` | `amy bunker[ connect]` + `amy login bunker://`/`--nostrconnect` | ✅ | NIP-46 remote signer + login, both the `bunker://` and `nostrconnect://` flows, each direction, plus `auth_url` challenge handling (client surfaces the URL + keeps waiting). Interop-verified vs real `nak`. |
| `admin` | `amy admin RELAY METHOD` | ✅ | NIP-86 Relay Management over NIP-98 HTTP auth — full method set (ban/allow pubkey + event, kinds, IP block, change name/desc/icon, list-*). Reuses quartz `Nip86Client` + shared `commons` `Nip86Retriever`. Interop-verified against `amy serve`. |
| `admin` | `amy admin RELAY METHOD` | ✅ | NIP-86 Relay Management over NIP-98 HTTP auth — full method set (ban/allow pubkey + event, NIP-43 roles + invite claims, kinds, IP block, change name/desc/icon, list-*). Reuses quartz `Nip86Client` + shared `commons` `Nip86Retriever`. Interop-verified against `amy serve`. |
| `serve` | `amy serve` | ✅ | Embeds **geode** (the standalone Ktor relay on quartz's relay-server code) — in-memory by default, `--db FILE` for SQLite, account is admin so `amy admin` works against it. NIP-86 + NIP-77 included. |
| `wallet` (NIP-60 Cashu) | `amy cashu` | ✅ | See the Cashu row above — full NIP-60/61 wallet + nutzaps. |
| `mcp` / `fs` / `spell` | — | 🆕 (niche) | MCP server, FUSE mount, MuSig2/FROST; some pull new deps. |
@@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.cli.DataDir
import com.vitorpamplona.amethyst.cli.Output
import com.vitorpamplona.amethyst.commons.relayManagement.Nip86Retriever
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import okhttp3.OkHttpClient
@@ -41,10 +42,15 @@ import okhttp3.OkHttpClient
* admin wss://relay ban-pubkey HEX [--reason R] / unban-pubkey HEX
* admin wss://relay allow-pubkey HEX [--reason R] / list-allowed-pubkeys
* admin wss://relay list-banned-pubkeys
* admin wss://relay ban-event ID [--reason R] / allow-event ID / list-banned-events
* admin wss://relay ban-event ID [--reason R] / unban-event ID / list-banned-events
* admin wss://relay allow-event ID [--reason R] / unallow-event ID / list-allowed-events
* admin wss://relay list-needing-moderation
* admin wss://relay create-role ID [--label L] [--description D] [--color HUE] [--order N]
* admin wss://relay edit-role ID [...] / delete-role ID
* admin wss://relay assign-role HEX ROLE / unassign-role HEX ROLE
* admin wss://relay create-claim CODE / delete-claim CODE / list-claims
* admin wss://relay change-name S / change-description S / change-icon URL
* admin wss://relay allow-kind N / disallow-kind N / list-allowed-kinds
* admin wss://relay allow-kind N / disallow-kind N / list-allowed-kinds / list-disallowed-kinds
* admin wss://relay block-ip IP [--reason R] / unblock-ip IP / list-blocked-ips
*/
object AdminCommand {
@@ -58,16 +64,30 @@ object AdminCommand {
| admin RELAY allow-pubkey HEX [--reason R] allow-list a pubkey
| admin RELAY unallow-pubkey HEX [--reason R] remove a pubkey from the allow-list
| admin RELAY list-allowed-pubkeys list allowed pubkeys
| admin RELAY ban-event ID [--reason R] ban an event id
| admin RELAY allow-event ID [--reason R] allow an event id
| admin RELAY ban-event ID [--reason R] ban an event id (drops it from the allow-list)
| admin RELAY unban-event ID [--reason R] lift an event ban (does not allow-list it)
| admin RELAY list-banned-events list banned events
| admin RELAY allow-event ID [--reason R] allow-list an event id (lifts any ban)
| admin RELAY unallow-event ID [--reason R] remove an event id from the allow-list
| admin RELAY list-allowed-events list allow-listed events
| admin RELAY list-needing-moderation list events flagged for moderation
| admin RELAY create-role ID [--label L] [--description D] [--color HUE] [--order N]
| define a NIP-43 member role (hue 0-360)
| admin RELAY edit-role ID [--label L] [--description D] [--color HUE] [--order N]
| replace a role's definition
| admin RELAY delete-role ID delete a role
| admin RELAY assign-role HEX ROLE give a pubkey a role
| admin RELAY unassign-role HEX ROLE take a role from a pubkey
| admin RELAY list-claims list NIP-43 invite codes the relay accepts
| admin RELAY create-claim CODE create a NIP-43 invite code
| admin RELAY delete-claim CODE revoke a NIP-43 invite code
| admin RELAY change-name NAME set the relay's name
| admin RELAY change-description TEXT set the relay's description
| admin RELAY change-icon URL set the relay's icon
| admin RELAY allow-kind N allow an event kind
| admin RELAY disallow-kind N disallow an event kind
| admin RELAY list-allowed-kinds list allowed kinds
| admin RELAY list-disallowed-kinds list disallowed kinds
| admin RELAY block-ip IP [--reason R] block an IP address
| admin RELAY unblock-ip IP unblock an IP address
| admin RELAY list-blocked-ips list blocked IPs
@@ -86,7 +106,12 @@ object AdminCommand {
val method = args.positionalOrNull(1) ?: return Output.error("bad_args", "missing method; e.g. supported-methods")
val relay = RelayUrlNormalizer.normalizeOrNull(relayArg) ?: return Output.invalidRelayUrl(relayArg)
val p2 = args.positionalOrNull(2)
val p3 = args.positionalOrNull(3)
val reason = args.flag("reason")
val label = args.flag("label")
val description = args.flag("description")
val color = args.flag("color")
val order = args.flag("order")
args.rejectUnknown()
fun needArg(name: String): String? =
@@ -95,6 +120,19 @@ object AdminCommand {
null
}
fun needSecondArg(name: String): String? =
p3 ?: run {
Output.error("bad_args", "$method requires a $name argument")
null
}
val colorInt =
color?.let {
it.toIntOrNull()?.takeIf { hue -> RelayRole.isValidHue(hue) }
?: return Output.error("bad_args", "--color must be a hue between 0 and 360")
}
val orderInt = order?.let { it.toIntOrNull() ?: return Output.error("bad_args", "--order must be an integer") }
val request: Nip86Request =
when (method) {
"supported-methods" -> Nip86Request.supportedMethods()
@@ -105,15 +143,27 @@ object AdminCommand {
"unallow-pubkey" -> Nip86Request.unallowPubkey(needArg("pubkey") ?: return 2, reason)
"list-allowed-pubkeys" -> Nip86Request.listAllowedPubkeys()
"ban-event" -> Nip86Request.banEvent(needArg("event-id") ?: return 2, reason)
"allow-event" -> Nip86Request.allowEvent(needArg("event-id") ?: return 2, reason)
"unban-event" -> Nip86Request.unbanEvent(needArg("event-id") ?: return 2, reason)
"list-banned-events" -> Nip86Request.listBannedEvents()
"allow-event" -> Nip86Request.allowEvent(needArg("event-id") ?: return 2, reason)
"unallow-event" -> Nip86Request.unallowEvent(needArg("event-id") ?: return 2, reason)
"list-allowed-events" -> Nip86Request.listAllowedEvents()
"list-needing-moderation" -> Nip86Request.listEventsNeedingModeration()
"create-role" -> Nip86Request.createRole(needArg("role-id") ?: return 2, label, description, colorInt, orderInt)
"edit-role" -> Nip86Request.editRole(needArg("role-id") ?: return 2, label, description, colorInt, orderInt)
"delete-role" -> Nip86Request.deleteRole(needArg("role-id") ?: return 2)
"assign-role" -> Nip86Request.assignRole(needArg("pubkey") ?: return 2, needSecondArg("role-id") ?: return 2)
"unassign-role" -> Nip86Request.unassignRole(needArg("pubkey") ?: return 2, needSecondArg("role-id") ?: return 2)
"list-claims" -> Nip86Request.listClaims()
"create-claim" -> Nip86Request.createClaim(needArg("claim") ?: return 2)
"delete-claim" -> Nip86Request.deleteClaim(needArg("claim") ?: return 2)
"change-name" -> Nip86Request.changeRelayName(needArg("name") ?: return 2)
"change-description" -> Nip86Request.changeRelayDescription(needArg("description") ?: return 2)
"change-icon" -> Nip86Request.changeRelayIcon(needArg("icon-url") ?: return 2)
"allow-kind" -> Nip86Request.allowKind((needArg("kind") ?: return 2).toIntOrNull() ?: return Output.error("bad_args", "kind must be an integer"))
"disallow-kind" -> Nip86Request.disallowKind((needArg("kind") ?: return 2).toIntOrNull() ?: return Output.error("bad_args", "kind must be an integer"))
"list-allowed-kinds" -> Nip86Request.listAllowedKinds()
"list-disallowed-kinds" -> Nip86Request.listDisallowedKinds()
"block-ip" -> Nip86Request.blockIp(needArg("ip") ?: return 2, reason)
"unblock-ip" -> Nip86Request.unblockIp(needArg("ip") ?: return 2)
"list-blocked-ips" -> Nip86Request.listBlockedIps()
@@ -2809,8 +2809,13 @@
<string name="relay_management_no_moderation_events">No events needing moderation</string>
<string name="relay_management_banned_events">Banned Events</string>
<string name="relay_management_no_banned_events">No banned events</string>
<string name="relay_management_allowed_events">Allowed Events</string>
<string name="relay_management_no_allowed_events">No allowed events</string>
<string name="relay_management_allow_event">Allow Event</string>
<string name="relay_management_allowed_kinds">Allowed Kinds</string>
<string name="relay_management_no_allowed_kinds">No allowed kinds</string>
<string name="relay_management_disallowed_kinds">Disallowed Kinds</string>
<string name="relay_management_no_disallowed_kinds">No disallowed kinds</string>
<string name="relay_management_blocked_ips">Blocked IPs</string>
<string name="relay_management_no_blocked_ips">No blocked IPs</string>
<string name="relay_management_add">Add</string>
@@ -2843,6 +2848,8 @@
<string name="relay_members_join_sent">Join request sent</string>
<string name="relay_members_leave_sent">Leave request sent</string>
<string name="relay_members_you_are_member">You are a member</string>
<string name="relay_members_invite_code">Invite code</string>
<string name="relay_members_invite_code_hint">This relay admits members with an invite code from its operator</string>
<string name="relay_membership_list">Relay membership list</string>
<string name="relay_member_added">Member added to relay</string>
<string name="relay_members_added">%1$d members added to relay</string>
@@ -20,18 +20,23 @@
*/
package com.vitorpamplona.amethyst.commons.nip43RelayMembers.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
@@ -52,6 +57,7 @@ import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.quartz.nip43RelayMembers.addMember.RelayAddMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.list.RelayMembershipListEvent
import com.vitorpamplona.quartz.nip43RelayMembers.removeMember.RelayRemoveMemberEvent
import com.vitorpamplona.quartz.nip43RelayMembers.roles.RelayRole
/** NIP-43 kind 13534: the relay's current member list, shown as a count. */
@Composable
@@ -123,6 +129,52 @@ fun RelayLeaveRequestCard() {
)
}
/**
* The NIP-43 roles (kind 33534) a relay assigned to a member, as small labelled
* chips tinted with each role's hue. Roles without a label show their id; roles
* without a color use the theme's secondary container. Sorted by the roles'
* display `order`.
*/
@Composable
fun RelayRoleChips(
roles: List<RelayRole>,
modifier: Modifier = Modifier,
) {
if (roles.isEmpty()) return
val sorted = remember(roles) { roles.sortedWith(compareBy<RelayRole>({ it.order ?: Int.MAX_VALUE }, { it.label ?: it.id })) }
FlowRow(
modifier = modifier,
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
sorted.forEach { RelayRoleChip(it) }
}
}
@Composable
fun RelayRoleChip(role: RelayRole) {
val hue = role.color
val background =
if (hue != null && RelayRole.isValidHue(hue)) {
Color.hsv(hue.toFloat(), 0.45f, 0.85f)
} else {
MaterialTheme.colorScheme.secondaryContainer
}
val content = if (hue != null && RelayRole.isValidHue(hue)) Color.Black else MaterialTheme.colorScheme.onSecondaryContainer
Text(
text = role.label ?: role.id,
style = MaterialTheme.typography.labelSmall,
color = content,
maxLines = 1,
modifier =
Modifier
.clip(RoundedCornerShape(50))
.background(background)
.padding(horizontal = 8.dp, vertical = 2.dp),
)
}
@Composable
private fun RelayMemberEventCard(
icon: MaterialSymbol,
@@ -175,6 +227,20 @@ private fun RelayMembershipListCardPreview() {
}
}
@Preview
@Composable
private fun RelayRoleChipsPreview() {
ThemeComparisonColumn {
RelayRoleChips(
listOf(
RelayRole("28b7e50f", label = "king", color = 37, order = 1),
RelayRole("mod", label = "moderator", color = 200, order = 2),
RelayRole("plain"),
),
)
}
}
@Preview
@Composable
private fun RelayAddMemberCardPreview() {