Merge pull request #1954 from vitorpamplona/claude/nip86-relay-management-KuTru

Add NIP-86 relay management UI and client implementation
This commit is contained in:
Vitor Pamplona
2026-03-27 08:20:33 -04:00
committed by GitHub
11 changed files with 1871 additions and 1 deletions
@@ -0,0 +1,88 @@
/*
* 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.model.nip86RelayManagement
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Request
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.coroutines.executeAsync
class Nip86Retriever(
val okHttpClient: (NormalizedRelayUrl) -> OkHttpClient,
) {
companion object {
val CONTENT_TYPE = "application/nostr+json+rpc".toMediaType()
}
suspend fun execute(
client: Nip86Client,
request: Nip86Request,
): Nip86Response {
val jsonBody = client.serializeRequest(request)
val bodyBytes = jsonBody.encodeToByteArray()
val authToken = client.buildAuthHeader(bodyBytes)
val httpRequest =
Request
.Builder()
.url(client.httpUrl)
.header("Content-Type", "application/nostr+json+rpc")
.header("Authorization", authToken)
.post(bodyBytes.toRequestBody(CONTENT_TYPE))
.build()
val httpClient = okHttpClient(client.relayUrl)
return withContext(Dispatchers.IO) {
try {
httpClient.newCall(httpRequest).executeAsync().use { response ->
val body = response.body.string()
if (response.code == 401) {
Nip86Response(error = "Unauthorized: relay rejected authentication")
} else if (!response.isSuccessful) {
Nip86Response(error = "HTTP ${response.code}: $body")
} else {
try {
client.parseResponse(body)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Nip86Retriever", "Failed to parse response: $body", e)
Nip86Response(error = "Failed to parse response: ${e.message}")
}
}
}
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.e("Nip86Retriever", "Failed to reach relay ${client.relayUrl.url}", e)
Nip86Response(error = "Failed to reach relay: ${e.message}")
}
}
}
}
@@ -108,6 +108,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.eventsync.EventSyncScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86.RelayManagementScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen
@@ -228,6 +229,7 @@ fun BuildNavigation(
composableFromEndArgs<Route.RelayFeed> { RelayFeedScreen(it, accountViewModel, nav) }
composableFromEndArgs<Route.ChessGame> { ChessGameScreen(it.gameId, accountViewModel, nav) }
composableFromEndArgs<Route.RelayInfo> { RelayInformationScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.RelayManagement> { RelayManagementScreen(it.url, accountViewModel, nav) }
composableFromEndArgs<Route.Community> { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEndArgs<Route.FollowPack> { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
@@ -220,6 +220,10 @@ sealed class Route {
val url: String,
) : Route()
@Serializable data class RelayManagement(
val url: String,
) : Route()
@Serializable data class RelayFeed(
val url: String,
) : Route()
@@ -62,6 +62,7 @@ import androidx.compose.material.icons.filled.Language
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material.icons.filled.Payment
import androidx.compose.material.icons.filled.PrivacyTip
import androidx.compose.material.icons.filled.Shield
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Tag
import androidx.compose.material.icons.filled.Topic
@@ -254,6 +255,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent
import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent
import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent
@@ -307,7 +309,23 @@ fun RelayInformationScreen(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
actions = {},
actions = {
val relayInfo by loadRelayInfo(relay)
if (Nip86Client.supportsNip86(relayInfo.supported_nips)) {
OutlinedButton(
onClick = { nav.nav(Route.RelayManagement(relay.url)) },
shape = ButtonBorder,
) {
Icon(
Icons.Default.Shield,
contentDescription = stringRes(R.string.relay_management_button),
modifier = Height25Modifier,
)
Spacer(modifier = StdHorzSpacer)
Text(stringRes(R.string.relay_management_button))
}
}
},
title = {
Text(
relay.displayUrl(),
@@ -0,0 +1,953 @@
/*
* 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.relays.nip86
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Block
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScrollableTabRow
import androidx.compose.material3.Snackbar
import androidx.compose.material3.Tab
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
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.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.model.nip86RelayManagement.Nip86Retriever
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton
import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Method
@Composable
fun RelayManagementScreen(
relayUrl: String,
accountViewModel: AccountViewModel,
nav: INav,
) {
RelayUrlNormalizer.normalizeOrNull(relayUrl)?.let {
RelayManagementScreen(
relay = it,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RelayManagementScreen(
relay: NormalizedRelayUrl,
accountViewModel: AccountViewModel,
nav: INav,
) {
val retriever =
remember {
Nip86Retriever(Amethyst.instance.torEvaluatorFlow::okHttpClientForRelay)
}
val viewModel =
remember(relay) {
RelayManagementViewModel(
relayUrl = relay,
signer = accountViewModel.account.signer,
retriever = retriever,
)
}
LaunchedEffect(relay) {
viewModel.loadSupportedMethods()
}
val supportedMethods by viewModel.supportedMethods.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
val error by viewModel.error.collectAsState()
LaunchedEffect(supportedMethods) {
if (supportedMethods.isNotEmpty()) {
viewModel.loadAllLists()
}
}
Scaffold(
modifier = Modifier.fillMaxSize(),
topBar = {
TopAppBar(
actions = {},
title = {
Text(
stringResource(R.string.relay_management_title, relay.displayUrl()),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
},
navigationIcon = {
Row {
Spacer(modifier = StdHorzSpacer)
BackButton(onPress = nav::popBack)
}
},
)
},
) { pad ->
if (isLoading && supportedMethods.isEmpty()) {
Column(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text(stringResource(R.string.relay_management_loading))
}
} else if (supportedMethods.isEmpty() && error != null) {
Column(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
Icons.Default.Block,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.error,
)
Spacer(modifier = Modifier.height(16.dp))
Text(
stringResource(R.string.relay_management_error),
style = MaterialTheme.typography.titleMedium,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
error ?: "",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
)
}
} else {
RelayManagementContent(pad, viewModel, supportedMethods, error)
}
}
}
@Composable
private fun RelayManagementContent(
pad: PaddingValues,
viewModel: RelayManagementViewModel,
supportedMethods: List<String>,
error: String?,
) {
val tabs =
remember(supportedMethods) {
buildList {
if (supportedMethods.any { it in listOf(Nip86Method.BAN_PUBKEY, Nip86Method.LIST_BANNED_PUBKEYS, Nip86Method.ALLOW_PUBKEY, Nip86Method.LIST_ALLOWED_PUBKEYS) }) {
add(ManagementTab.PUBKEYS)
}
if (supportedMethods.any {
it in listOf(Nip86Method.BAN_EVENT, Nip86Method.LIST_BANNED_EVENTS, Nip86Method.ALLOW_EVENT, Nip86Method.LIST_EVENTS_NEEDING_MODERATION)
}
) {
add(ManagementTab.EVENTS)
}
if (supportedMethods.any { it in listOf(Nip86Method.ALLOW_KIND, Nip86Method.DISALLOW_KIND, Nip86Method.LIST_ALLOWED_KINDS) }) {
add(ManagementTab.KINDS)
}
if (supportedMethods.any { it in listOf(Nip86Method.BLOCK_IP, Nip86Method.UNBLOCK_IP, Nip86Method.LIST_BLOCKED_IPS) }) {
add(ManagementTab.IPS)
}
if (supportedMethods.any { it in listOf(Nip86Method.CHANGE_RELAY_NAME, Nip86Method.CHANGE_RELAY_DESCRIPTION, Nip86Method.CHANGE_RELAY_ICON) }) {
add(ManagementTab.SETTINGS)
}
}
}
var selectedTab by remember { mutableIntStateOf(0) }
Column(
modifier =
Modifier
.padding(pad)
.consumeWindowInsets(pad)
.fillMaxSize(),
) {
if (error != null) {
Snackbar(
modifier = Modifier.padding(8.dp),
action = {
TextButton(onClick = { viewModel.clearError() }) {
Text(stringResource(R.string.relay_management_dismiss))
}
},
) {
Text(error)
}
}
if (tabs.isNotEmpty()) {
ScrollableTabRow(
selectedTabIndex = selectedTab.coerceAtMost(tabs.size - 1),
edgePadding = 8.dp,
) {
tabs.forEachIndexed { index, tab ->
Tab(
selected = selectedTab == index,
onClick = { selectedTab = index },
text = { Text(stringResource(tab.titleRes)) },
)
}
}
when (tabs.getOrNull(selectedTab)) {
ManagementTab.PUBKEYS -> {
PubkeysTab(viewModel, supportedMethods)
}
ManagementTab.EVENTS -> {
EventsTab(viewModel, supportedMethods)
}
ManagementTab.KINDS -> {
KindsTab(viewModel, supportedMethods)
}
ManagementTab.IPS -> {
IpsTab(viewModel, supportedMethods)
}
ManagementTab.SETTINGS -> {
SettingsTab(viewModel, supportedMethods)
}
null -> {}
}
} else {
Column(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
stringResource(R.string.relay_management_no_methods),
style = MaterialTheme.typography.bodyLarge,
)
}
}
}
}
private enum class ManagementTab(
val titleRes: Int,
) {
PUBKEYS(R.string.relay_management_tab_pubkeys),
EVENTS(R.string.relay_management_tab_events),
KINDS(R.string.relay_management_tab_kinds),
IPS(R.string.relay_management_tab_ips),
SETTINGS(R.string.relay_management_tab_settings),
}
// Pubkeys Tab
@Composable
private fun PubkeysTab(
viewModel: RelayManagementViewModel,
supportedMethods: List<String>,
) {
val bannedPubkeys by viewModel.bannedPubkeys.collectAsState()
val allowedPubkeys by viewModel.allowedPubkeys.collectAsState()
var showBanDialog by remember { mutableStateOf(false) }
var showAllowDialog by remember { mutableStateOf(false) }
LazyColumn(
contentPadding = PaddingValues(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (supportedMethods.contains(Nip86Method.LIST_BANNED_PUBKEYS)) {
item {
SectionHeaderWithAdd(
stringResource(R.string.relay_management_banned_pubkeys),
showAdd = supportedMethods.contains(Nip86Method.BAN_PUBKEY),
onAdd = { showBanDialog = true },
)
}
if (bannedPubkeys.isEmpty()) {
item { EmptyListMessage(stringResource(R.string.relay_management_no_banned_pubkeys)) }
} else {
items(bannedPubkeys, key = { it.pubkey }) { entry ->
HexEntryCard(
hex = entry.pubkey,
reason = entry.reason,
showRemove = supportedMethods.contains(Nip86Method.UNBAN_PUBKEY),
onRemove = { viewModel.unbanPubkey(entry.pubkey) },
)
}
}
}
if (supportedMethods.contains(Nip86Method.LIST_ALLOWED_PUBKEYS)) {
item { Spacer(modifier = Modifier.height(8.dp)) }
item {
SectionHeaderWithAdd(
stringResource(R.string.relay_management_allowed_pubkeys),
showAdd = supportedMethods.contains(Nip86Method.ALLOW_PUBKEY),
onAdd = { showAllowDialog = true },
)
}
if (allowedPubkeys.isEmpty()) {
item { EmptyListMessage(stringResource(R.string.relay_management_no_allowed_pubkeys)) }
} else {
items(allowedPubkeys, key = { it.pubkey }) { entry ->
HexEntryCard(
hex = entry.pubkey,
reason = entry.reason,
showRemove = supportedMethods.contains(Nip86Method.UNALLOW_PUBKEY),
onRemove = { viewModel.unallowPubkey(entry.pubkey) },
)
}
}
}
}
if (showBanDialog) {
HexInputDialog(
title = stringResource(R.string.relay_management_ban_pubkey),
label = stringResource(R.string.relay_management_pubkey_hex),
onConfirm = { hex, reason ->
viewModel.banPubkey(hex, reason.ifBlank { null })
showBanDialog = false
},
onDismiss = { showBanDialog = false },
)
}
if (showAllowDialog) {
HexInputDialog(
title = stringResource(R.string.relay_management_allow_pubkey),
label = stringResource(R.string.relay_management_pubkey_hex),
onConfirm = { hex, reason ->
viewModel.allowPubkey(hex, reason.ifBlank { null })
showAllowDialog = false
},
onDismiss = { showAllowDialog = false },
)
}
}
// Events Tab
@Composable
private fun EventsTab(
viewModel: RelayManagementViewModel,
supportedMethods: List<String>,
) {
val bannedEvents by viewModel.bannedEvents.collectAsState()
val eventsNeedingModeration by viewModel.eventsNeedingModeration.collectAsState()
var showBanDialog by remember { mutableStateOf(false) }
LazyColumn(
contentPadding = PaddingValues(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (supportedMethods.contains(Nip86Method.LIST_EVENTS_NEEDING_MODERATION)) {
item {
SectionHeaderWithAdd(
stringResource(R.string.relay_management_moderation_queue),
showAdd = false,
onAdd = {},
)
}
if (eventsNeedingModeration.isEmpty()) {
item { EmptyListMessage(stringResource(R.string.relay_management_no_moderation_events)) }
} else {
items(eventsNeedingModeration, key = { it.id }) { entry ->
ModerationEventCard(
eventId = entry.id,
reason = entry.reason,
canAllow = supportedMethods.contains(Nip86Method.ALLOW_EVENT),
canBan = supportedMethods.contains(Nip86Method.BAN_EVENT),
onAllow = { viewModel.allowEvent(entry.id) },
onBan = { viewModel.banEvent(entry.id) },
)
}
}
}
if (supportedMethods.contains(Nip86Method.LIST_BANNED_EVENTS)) {
item { Spacer(modifier = Modifier.height(8.dp)) }
item {
SectionHeaderWithAdd(
stringResource(R.string.relay_management_banned_events),
showAdd = supportedMethods.contains(Nip86Method.BAN_EVENT),
onAdd = { showBanDialog = true },
)
}
if (bannedEvents.isEmpty()) {
item { EmptyListMessage(stringResource(R.string.relay_management_no_banned_events)) }
} else {
items(bannedEvents, key = { it.id }) { entry ->
HexEntryCard(
hex = entry.id,
reason = entry.reason,
showRemove = false,
onRemove = {},
)
}
}
}
}
if (showBanDialog) {
HexInputDialog(
title = stringResource(R.string.relay_management_ban_event),
label = stringResource(R.string.relay_management_event_id_hex),
onConfirm = { hex, reason ->
viewModel.banEvent(hex, reason.ifBlank { null })
showBanDialog = false
},
onDismiss = { showBanDialog = false },
)
}
}
// Kinds Tab
@Composable
private fun KindsTab(
viewModel: RelayManagementViewModel,
supportedMethods: List<String>,
) {
val allowedKinds by viewModel.allowedKinds.collectAsState()
var showAddDialog by remember { mutableStateOf(false) }
LazyColumn(
contentPadding = PaddingValues(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
SectionHeaderWithAdd(
stringResource(R.string.relay_management_allowed_kinds),
showAdd = supportedMethods.contains(Nip86Method.ALLOW_KIND),
onAdd = { showAddDialog = true },
)
}
if (allowedKinds.isEmpty()) {
item { EmptyListMessage(stringResource(R.string.relay_management_no_allowed_kinds)) }
} else {
items(allowedKinds, key = { it }) { kind ->
KindEntryCard(
kind = kind,
showRemove = supportedMethods.contains(Nip86Method.DISALLOW_KIND),
onRemove = { viewModel.disallowKind(kind) },
)
}
}
}
if (showAddDialog) {
KindInputDialog(
onConfirm = { kind ->
viewModel.allowKind(kind)
showAddDialog = false
},
onDismiss = { showAddDialog = false },
)
}
}
// IPs Tab
@Composable
private fun IpsTab(
viewModel: RelayManagementViewModel,
supportedMethods: List<String>,
) {
val blockedIps by viewModel.blockedIps.collectAsState()
var showBlockDialog by remember { mutableStateOf(false) }
LazyColumn(
contentPadding = PaddingValues(10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
item {
SectionHeaderWithAdd(
stringResource(R.string.relay_management_blocked_ips),
showAdd = supportedMethods.contains(Nip86Method.BLOCK_IP),
onAdd = { showBlockDialog = true },
)
}
if (blockedIps.isEmpty()) {
item { EmptyListMessage(stringResource(R.string.relay_management_no_blocked_ips)) }
} else {
items(blockedIps, key = { it.ip }) { entry ->
IpEntryCard(
ip = entry.ip,
reason = entry.reason,
showRemove = supportedMethods.contains(Nip86Method.UNBLOCK_IP),
onRemove = { viewModel.unblockIp(entry.ip) },
)
}
}
}
if (showBlockDialog) {
HexInputDialog(
title = stringResource(R.string.relay_management_block_ip),
label = stringResource(R.string.relay_management_ip_address),
onConfirm = { ip, reason ->
viewModel.blockIp(ip, reason.ifBlank { null })
showBlockDialog = false
},
onDismiss = { showBlockDialog = false },
)
}
}
// Settings Tab
@Composable
private fun SettingsTab(
viewModel: RelayManagementViewModel,
supportedMethods: List<String>,
) {
var relayName by remember { mutableStateOf("") }
var relayDescription by remember { mutableStateOf("") }
var relayIcon by remember { mutableStateOf("") }
LazyColumn(
contentPadding = PaddingValues(10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
if (supportedMethods.contains(Nip86Method.CHANGE_RELAY_NAME)) {
item {
SettingsField(
label = stringResource(R.string.relay_management_relay_name),
value = relayName,
onValueChange = { relayName = it },
onApply = { viewModel.changeRelayName(relayName) },
)
}
}
if (supportedMethods.contains(Nip86Method.CHANGE_RELAY_DESCRIPTION)) {
item {
SettingsField(
label = stringResource(R.string.relay_management_relay_description),
value = relayDescription,
onValueChange = { relayDescription = it },
onApply = { viewModel.changeRelayDescription(relayDescription) },
)
}
}
if (supportedMethods.contains(Nip86Method.CHANGE_RELAY_ICON)) {
item {
SettingsField(
label = stringResource(R.string.relay_management_relay_icon_url),
value = relayIcon,
onValueChange = { relayIcon = it },
onApply = { viewModel.changeRelayIcon(relayIcon) },
)
}
}
}
}
// Reusable components
@Composable
private fun SectionHeaderWithAdd(
title: String,
showAdd: Boolean,
onAdd: () -> Unit,
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
title,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary,
)
if (showAdd) {
IconButton(onClick = onAdd) {
Icon(
Icons.Default.Add,
contentDescription = stringResource(R.string.relay_management_add),
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
HorizontalDivider()
}
@Composable
private fun EmptyListMessage(message: String) {
Text(
message,
modifier = Modifier.padding(vertical = 8.dp),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@Composable
private fun HexEntryCard(
hex: String,
reason: String?,
showRemove: Boolean,
onRemove: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.padding(12.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
hex,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
reason?.let {
Text(
it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (showRemove) {
IconButton(onClick = onRemove) {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.relay_management_remove),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
@Composable
private fun ModerationEventCard(
eventId: String,
reason: String?,
canAllow: Boolean,
canBan: Boolean,
onAllow: () -> Unit,
onBan: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Column(modifier = Modifier.padding(12.dp)) {
Text(
eventId,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
reason?.let {
Text(
it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
if (canAllow) {
IconButton(onClick = onAllow) {
Icon(
Icons.Default.CheckCircle,
contentDescription = stringResource(R.string.relay_management_allow),
tint = MaterialTheme.colorScheme.primary,
)
}
}
if (canBan) {
IconButton(onClick = onBan) {
Icon(
Icons.Default.Block,
contentDescription = stringResource(R.string.relay_management_ban),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
}
@Composable
private fun KindEntryCard(
kind: Int,
showRemove: Boolean,
onRemove: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.padding(12.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
"Kind $kind",
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
)
if (showRemove) {
IconButton(onClick = onRemove) {
Icon(
Icons.Default.Delete,
contentDescription = stringResource(R.string.relay_management_remove),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
@Composable
private fun IpEntryCard(
ip: String,
reason: String?,
showRemove: Boolean,
onRemove: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) {
Row(
modifier = Modifier.padding(12.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
ip,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace,
)
reason?.let {
Text(
it,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
if (showRemove) {
IconButton(onClick = onRemove) {
Icon(
Icons.Default.Close,
contentDescription = stringResource(R.string.relay_management_remove),
tint = MaterialTheme.colorScheme.error,
)
}
}
}
}
}
@Composable
private fun SettingsField(
label: String,
value: String,
onValueChange: (String) -> Unit,
onApply: () -> Unit,
) {
Column {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(modifier = Modifier.height(4.dp))
TextButton(
onClick = onApply,
modifier = Modifier.align(Alignment.End),
enabled = value.isNotBlank(),
) {
Text(stringResource(R.string.relay_management_apply))
}
}
}
@Composable
private fun HexInputDialog(
title: String,
label: String,
onConfirm: (String, String) -> Unit,
onDismiss: () -> Unit,
) {
var hexValue by remember { mutableStateOf("") }
var reasonValue by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column {
OutlinedTextField(
value = hexValue,
onValueChange = { hexValue = it },
label = { Text(label) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedTextField(
value = reasonValue,
onValueChange = { reasonValue = it },
label = { Text(stringResource(R.string.relay_management_reason_optional)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(hexValue.trim(), reasonValue.trim()) },
enabled = hexValue.isNotBlank(),
) {
Text(stringResource(R.string.relay_management_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.relay_management_cancel))
}
},
)
}
@Composable
private fun KindInputDialog(
onConfirm: (Int) -> Unit,
onDismiss: () -> Unit,
) {
var kindValue by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.relay_management_allow_kind)) },
text = {
OutlinedTextField(
value = kindValue,
onValueChange = { kindValue = it.filter { c -> c.isDigit() } },
label = { Text(stringResource(R.string.relay_management_kind_number)) },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
},
confirmButton = {
TextButton(
onClick = {
kindValue.toIntOrNull()?.let { onConfirm(it) }
},
enabled = kindValue.toIntOrNull() != null,
) {
Text(stringResource(R.string.relay_management_confirm))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.relay_management_cancel))
}
},
)
}
@@ -0,0 +1,318 @@
/*
* 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.relays.nip86
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.model.nip86RelayManagement.Nip86Retriever
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip86RelayManagement.Nip86Client
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.Nip86Request
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class RelayManagementViewModel(
relayUrl: NormalizedRelayUrl,
signer: NostrSigner,
private val retriever: Nip86Retriever,
) : ViewModel() {
val client = Nip86Client(relayUrl, signer)
private val _supportedMethods = MutableStateFlow<List<String>>(emptyList())
val supportedMethods: StateFlow<List<String>> = _supportedMethods
private val _bannedPubkeys = MutableStateFlow<List<BannedPubkey>>(emptyList())
val bannedPubkeys: StateFlow<List<BannedPubkey>> = _bannedPubkeys
private val _allowedPubkeys = MutableStateFlow<List<AllowedPubkey>>(emptyList())
val allowedPubkeys: StateFlow<List<AllowedPubkey>> = _allowedPubkeys
private val _bannedEvents = MutableStateFlow<List<BannedEvent>>(emptyList())
val bannedEvents: StateFlow<List<BannedEvent>> = _bannedEvents
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 _blockedIps = MutableStateFlow<List<BlockedIp>>(emptyList())
val blockedIps: StateFlow<List<BlockedIp>> = _blockedIps
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error
fun loadSupportedMethods() {
viewModelScope.launch {
_isLoading.value = true
_error.value = null
val response = retriever.execute(client, Nip86Request.supportedMethods())
if (response.error != null) {
_error.value = response.error
} else {
_supportedMethods.value = client.parseSupportedMethods(response) ?: emptyList()
}
_isLoading.value = false
}
}
fun loadBannedPubkeys() {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.listBannedPubkeys())
if (response.error != null) {
_error.value = response.error
} else {
_bannedPubkeys.value = client.parseBannedPubkeys(response) ?: emptyList()
}
}
}
fun loadAllowedPubkeys() {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.listAllowedPubkeys())
if (response.error != null) {
_error.value = response.error
} else {
_allowedPubkeys.value = client.parseAllowedPubkeys(response) ?: emptyList()
}
}
}
fun loadBannedEvents() {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.listBannedEvents())
if (response.error != null) {
_error.value = response.error
} else {
_bannedEvents.value = client.parseBannedEvents(response) ?: emptyList()
}
}
}
fun loadEventsNeedingModeration() {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.listEventsNeedingModeration())
if (response.error != null) {
_error.value = response.error
} else {
_eventsNeedingModeration.value = client.parseEventsNeedingModeration(response) ?: emptyList()
}
}
}
fun loadAllowedKinds() {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.listAllowedKinds())
if (response.error != null) {
_error.value = response.error
} else {
_allowedKinds.value = client.parseAllowedKinds(response) ?: emptyList()
}
}
}
fun loadBlockedIps() {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.listBlockedIps())
if (response.error != null) {
_error.value = response.error
} else {
_blockedIps.value = client.parseBlockedIps(response) ?: emptyList()
}
}
}
fun banPubkey(
pubkey: String,
reason: String? = null,
) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.banPubkey(pubkey, reason))
if (response.error != null) {
_error.value = response.error
} else {
loadBannedPubkeys()
}
}
}
fun unbanPubkey(pubkey: String) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.unbanPubkey(pubkey))
if (response.error != null) {
_error.value = response.error
} else {
loadBannedPubkeys()
}
}
}
fun allowPubkey(
pubkey: String,
reason: String? = null,
) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.allowPubkey(pubkey, reason))
if (response.error != null) {
_error.value = response.error
} else {
loadAllowedPubkeys()
}
}
}
fun unallowPubkey(pubkey: String) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.unallowPubkey(pubkey))
if (response.error != null) {
_error.value = response.error
} else {
loadAllowedPubkeys()
}
}
}
fun banEvent(
eventId: String,
reason: String? = null,
) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.banEvent(eventId, reason))
if (response.error != null) {
_error.value = response.error
} else {
loadBannedEvents()
}
}
}
fun allowEvent(
eventId: String,
reason: String? = null,
) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.allowEvent(eventId, reason))
if (response.error != null) {
_error.value = response.error
} else {
loadEventsNeedingModeration()
}
}
}
fun changeRelayName(newName: String) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.changeRelayName(newName))
if (response.error != null) {
_error.value = response.error
}
}
}
fun changeRelayDescription(newDescription: String) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.changeRelayDescription(newDescription))
if (response.error != null) {
_error.value = response.error
}
}
}
fun changeRelayIcon(newIconUrl: String) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.changeRelayIcon(newIconUrl))
if (response.error != null) {
_error.value = response.error
}
}
}
fun allowKind(kind: Int) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.allowKind(kind))
if (response.error != null) {
_error.value = response.error
} else {
loadAllowedKinds()
}
}
}
fun disallowKind(kind: Int) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.disallowKind(kind))
if (response.error != null) {
_error.value = response.error
} else {
loadAllowedKinds()
}
}
}
fun blockIp(
ip: String,
reason: String? = null,
) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.blockIp(ip, reason))
if (response.error != null) {
_error.value = response.error
} else {
loadBlockedIps()
}
}
}
fun unblockIp(ip: String) {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.unblockIp(ip))
if (response.error != null) {
_error.value = response.error
} else {
loadBlockedIps()
}
}
}
fun clearError() {
_error.value = null
}
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()
}
}
+45
View File
@@ -1974,4 +1974,49 @@
<string name="web_bookmark_delete">Delete</string>
<string name="web_bookmark_delete_confirm">Delete this web bookmark?</string>
<string name="web_bookmark_open_url">Open URL</string>
<!-- NIP-86 Relay Management -->
<string name="relay_management_title">Manage %1$s</string>
<string name="relay_management_loading">Loading relay management capabilities…</string>
<string name="relay_management_error">Unable to connect to relay management</string>
<string name="relay_management_no_methods">No management methods available</string>
<string name="relay_management_dismiss">Dismiss</string>
<string name="relay_management_tab_pubkeys">Pubkeys</string>
<string name="relay_management_tab_events">Events</string>
<string name="relay_management_tab_kinds">Kinds</string>
<string name="relay_management_tab_ips">IPs</string>
<string name="relay_management_tab_settings">Settings</string>
<string name="relay_management_banned_pubkeys">Banned Pubkeys</string>
<string name="relay_management_no_banned_pubkeys">No banned pubkeys</string>
<string name="relay_management_allowed_pubkeys">Allowed Pubkeys</string>
<string name="relay_management_no_allowed_pubkeys">No allowed pubkeys</string>
<string name="relay_management_moderation_queue">Moderation Queue</string>
<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_kinds">Allowed Kinds</string>
<string name="relay_management_no_allowed_kinds">No allowed 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>
<string name="relay_management_remove">Remove</string>
<string name="relay_management_allow">Allow</string>
<string name="relay_management_ban">Ban</string>
<string name="relay_management_ban_pubkey">Ban Pubkey</string>
<string name="relay_management_allow_pubkey">Allow Pubkey</string>
<string name="relay_management_ban_event">Ban Event</string>
<string name="relay_management_allow_kind">Allow Kind</string>
<string name="relay_management_block_ip">Block IP</string>
<string name="relay_management_pubkey_hex">Pubkey (hex)</string>
<string name="relay_management_event_id_hex">Event ID (hex)</string>
<string name="relay_management_kind_number">Kind number</string>
<string name="relay_management_ip_address">IP address</string>
<string name="relay_management_reason_optional">Reason (optional)</string>
<string name="relay_management_confirm">Confirm</string>
<string name="relay_management_cancel">Cancel</string>
<string name="relay_management_apply">Apply</string>
<string name="relay_management_relay_name">Relay Name</string>
<string name="relay_management_relay_description">Relay Description</string>
<string name="relay_management_relay_icon_url">Relay Icon URL</string>
<string name="relay_management_button">Manage Relay</string>
</resources>
@@ -0,0 +1,168 @@
/*
* 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.quartz.nip86RelayManagement
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
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.Nip86Request
import com.vitorpamplona.quartz.nip86RelayManagement.rpc.Nip86Response
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.int
import kotlinx.serialization.json.jsonPrimitive
class Nip86Client(
val relayUrl: NormalizedRelayUrl,
val signer: NostrSigner,
) {
val httpUrl: String = relayUrl.toHttp()
suspend fun buildAuthHeader(payload: ByteArray): String {
val template =
HTTPAuthorizationEvent.build(
url = httpUrl,
method = "POST",
file = payload,
)
val signedEvent = signer.sign<HTTPAuthorizationEvent>(template)
return signedEvent.toAuthToken()
}
fun serializeRequest(request: Nip86Request): String = JsonMapper.toJson(request)
fun parseResponse(json: String): Nip86Response = JsonMapper.fromJson<Nip86Response>(json)
fun parseSupportedMethods(response: Nip86Response): List<String>? {
val result = response.result ?: return null
return (result as? JsonArray)?.map { it.jsonPrimitive.content }
}
fun parseBooleanResult(response: Nip86Response): Boolean? {
val result = response.result ?: return null
return (result as? JsonPrimitive)?.boolean
}
fun parseBannedPubkeys(response: Nip86Response): List<BannedPubkey>? {
val result = response.result ?: return null
return JsonMapper.fromJson<List<BannedPubkey>>(result.toString())
}
fun parseAllowedPubkeys(response: Nip86Response): List<AllowedPubkey>? {
val result = response.result ?: return null
return JsonMapper.fromJson<List<AllowedPubkey>>(result.toString())
}
fun parseBannedEvents(response: Nip86Response): List<BannedEvent>? {
val result = response.result ?: return null
return JsonMapper.fromJson<List<BannedEvent>>(result.toString())
}
fun parseEventsNeedingModeration(response: Nip86Response): List<EventNeedingModeration>? {
val result = response.result ?: return null
return JsonMapper.fromJson<List<EventNeedingModeration>>(result.toString())
}
fun parseAllowedKinds(response: Nip86Response): List<Int>? {
val result = response.result ?: return null
return (result as? JsonArray)?.map { it.jsonPrimitive.int }
}
fun parseBlockedIps(response: Nip86Response): List<BlockedIp>? {
val result = response.result ?: return null
return JsonMapper.fromJson<List<BlockedIp>>(result.toString())
}
// Convenience methods that build requests
fun supportedMethodsRequest() = Nip86Request.supportedMethods()
fun banPubkeyRequest(
pubkey: String,
reason: String? = null,
) = Nip86Request.banPubkey(pubkey, reason)
fun unbanPubkeyRequest(
pubkey: String,
reason: String? = null,
) = Nip86Request.unbanPubkey(pubkey, reason)
fun listBannedPubkeysRequest() = Nip86Request.listBannedPubkeys()
fun allowPubkeyRequest(
pubkey: String,
reason: String? = null,
) = Nip86Request.allowPubkey(pubkey, reason)
fun unallowPubkeyRequest(
pubkey: String,
reason: String? = null,
) = Nip86Request.unallowPubkey(pubkey, reason)
fun listAllowedPubkeysRequest() = Nip86Request.listAllowedPubkeys()
fun listEventsNeedingModerationRequest() = Nip86Request.listEventsNeedingModeration()
fun allowEventRequest(
eventId: String,
reason: String? = null,
) = Nip86Request.allowEvent(eventId, reason)
fun banEventRequest(
eventId: String,
reason: String? = null,
) = Nip86Request.banEvent(eventId, reason)
fun listBannedEventsRequest() = Nip86Request.listBannedEvents()
fun changeRelayNameRequest(newName: String) = Nip86Request.changeRelayName(newName)
fun changeRelayDescriptionRequest(newDescription: String) = Nip86Request.changeRelayDescription(newDescription)
fun changeRelayIconRequest(newIconUrl: String) = Nip86Request.changeRelayIcon(newIconUrl)
fun allowKindRequest(kind: Int) = Nip86Request.allowKind(kind)
fun disallowKindRequest(kind: Int) = Nip86Request.disallowKind(kind)
fun listAllowedKindsRequest() = Nip86Request.listAllowedKinds()
fun blockIpRequest(
ip: String,
reason: String? = null,
) = Nip86Request.blockIp(ip, reason)
fun unblockIpRequest(ip: String) = Nip86Request.unblockIp(ip)
fun listBlockedIpsRequest() = Nip86Request.listBlockedIps()
companion object {
fun supportsNip86(supportedNips: List<String>?): Boolean = supportedNips?.any { it == "86" } == true
}
}
@@ -0,0 +1,44 @@
/*
* 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.quartz.nip86RelayManagement.rpc
object Nip86Method {
const val SUPPORTED_METHODS = "supportedmethods"
const val BAN_PUBKEY = "banpubkey"
const val UNBAN_PUBKEY = "unbanpubkey"
const val LIST_BANNED_PUBKEYS = "listbannedpubkeys"
const val ALLOW_PUBKEY = "allowpubkey"
const val UNALLOW_PUBKEY = "unallowpubkey"
const val LIST_ALLOWED_PUBKEYS = "listallowedpubkeys"
const val LIST_EVENTS_NEEDING_MODERATION = "listeventsneedingmoderation"
const val ALLOW_EVENT = "allowevent"
const val BAN_EVENT = "banevent"
const val LIST_BANNED_EVENTS = "listbannedevents"
const val CHANGE_RELAY_NAME = "changerelayname"
const val CHANGE_RELAY_DESCRIPTION = "changerelaydescription"
const val CHANGE_RELAY_ICON = "changerelayicon"
const val ALLOW_KIND = "allowkind"
const val DISALLOW_KIND = "disallowkind"
const val LIST_ALLOWED_KINDS = "listallowedkinds"
const val BLOCK_IP = "blockip"
const val UNBLOCK_IP = "unblockip"
const val LIST_BLOCKED_IPS = "listblockedips"
}
@@ -0,0 +1,170 @@
/*
* 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.quartz.nip86RelayManagement.rpc
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
@Serializable
class Nip86Request(
val method: String,
val params: JsonArray = JsonArray(emptyList()),
) {
companion object {
fun supportedMethods() =
Nip86Request(
method = Nip86Method.SUPPORTED_METHODS,
)
fun banPubkey(
pubkey: String,
reason: String? = null,
) = Nip86Request(
method = Nip86Method.BAN_PUBKEY,
params = buildParams(pubkey, reason),
)
fun unbanPubkey(
pubkey: String,
reason: String? = null,
) = Nip86Request(
method = Nip86Method.UNBAN_PUBKEY,
params = buildParams(pubkey, reason),
)
fun listBannedPubkeys() =
Nip86Request(
method = Nip86Method.LIST_BANNED_PUBKEYS,
)
fun allowPubkey(
pubkey: String,
reason: String? = null,
) = Nip86Request(
method = Nip86Method.ALLOW_PUBKEY,
params = buildParams(pubkey, reason),
)
fun unallowPubkey(
pubkey: String,
reason: String? = null,
) = Nip86Request(
method = Nip86Method.UNALLOW_PUBKEY,
params = buildParams(pubkey, reason),
)
fun listAllowedPubkeys() =
Nip86Request(
method = Nip86Method.LIST_ALLOWED_PUBKEYS,
)
fun listEventsNeedingModeration() =
Nip86Request(
method = Nip86Method.LIST_EVENTS_NEEDING_MODERATION,
)
fun allowEvent(
eventId: String,
reason: String? = null,
) = Nip86Request(
method = Nip86Method.ALLOW_EVENT,
params = buildParams(eventId, reason),
)
fun banEvent(
eventId: String,
reason: String? = null,
) = Nip86Request(
method = Nip86Method.BAN_EVENT,
params = buildParams(eventId, reason),
)
fun listBannedEvents() =
Nip86Request(
method = Nip86Method.LIST_BANNED_EVENTS,
)
fun changeRelayName(newName: String) =
Nip86Request(
method = Nip86Method.CHANGE_RELAY_NAME,
params = buildJsonArray { add(JsonPrimitive(newName)) },
)
fun changeRelayDescription(newDescription: String) =
Nip86Request(
method = Nip86Method.CHANGE_RELAY_DESCRIPTION,
params = buildJsonArray { add(JsonPrimitive(newDescription)) },
)
fun changeRelayIcon(newIconUrl: String) =
Nip86Request(
method = Nip86Method.CHANGE_RELAY_ICON,
params = buildJsonArray { add(JsonPrimitive(newIconUrl)) },
)
fun allowKind(kind: Int) =
Nip86Request(
method = Nip86Method.ALLOW_KIND,
params = buildJsonArray { add(JsonPrimitive(kind)) },
)
fun disallowKind(kind: Int) =
Nip86Request(
method = Nip86Method.DISALLOW_KIND,
params = buildJsonArray { add(JsonPrimitive(kind)) },
)
fun listAllowedKinds() =
Nip86Request(
method = Nip86Method.LIST_ALLOWED_KINDS,
)
fun blockIp(
ip: String,
reason: String? = null,
) = Nip86Request(
method = Nip86Method.BLOCK_IP,
params = buildParams(ip, reason),
)
fun unblockIp(ip: String) =
Nip86Request(
method = Nip86Method.UNBLOCK_IP,
params = buildJsonArray { add(JsonPrimitive(ip)) },
)
fun listBlockedIps() =
Nip86Request(
method = Nip86Method.LIST_BLOCKED_IPS,
)
private fun buildParams(
primary: String,
reason: String? = null,
): JsonArray =
buildJsonArray {
add(JsonPrimitive(primary))
reason?.let { add(JsonPrimitive(it)) }
}
}
}
@@ -0,0 +1,60 @@
/*
* 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.quartz.nip86RelayManagement.rpc
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
class Nip86Response(
val result: JsonElement? = null,
val error: String? = null,
)
@Serializable
class BannedPubkey(
val pubkey: String,
val reason: String? = null,
)
@Serializable
class AllowedPubkey(
val pubkey: String,
val reason: String? = null,
)
@Serializable
class BannedEvent(
val id: String,
val reason: String? = null,
)
@Serializable
class EventNeedingModeration(
val id: String,
val reason: String? = null,
)
@Serializable
class BlockedIp(
val ip: String,
val reason: String? = null,
)