mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
+88
-20
@@ -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
-6
@@ -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) {
|
||||
|
||||
+84
-8
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
+66
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user