From 2f8e62fda59a199a7e6c0dc769139c1d407a81fc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 26 Mar 2026 04:32:43 +0000 Subject: [PATCH] feat: implement NIP-86 Relay Management API Add quartz protocol package for NIP-86 JSON-RPC relay management with NIP-98 HTTP authorization, and a management UI accessible from the relay information screen when the relay advertises NIP-86 support. Quartz (nip86RelayManagement/): - Nip86Method: all 21 method constants from the spec - Nip86Request: serializable JSON-RPC request with factory methods - Nip86Response: response model with typed result parsing helpers - Nip86Client: builds NIP-98 auth headers, serializes requests, parses responses for each result type (pubkeys, events, kinds, IPs) Amethyst: - Nip86Retriever: OkHttp-based HTTP executor for NIP-86 calls - RelayManagementViewModel: state management for all NIP-86 operations - RelayManagementScreen: tabbed UI (Pubkeys, Events, Kinds, IPs, Settings) with add/remove dialogs, moderation queue, and relay settings fields - Route.RelayManagement added to navigation - "Manage Relay" button shown in RelayInformationScreen top bar when relay's supported_nips includes "86" https://claude.ai/code/session_01QckCAm1T8pJqqmURBiNtaQ --- .../nip86RelayManagement/Nip86Retriever.kt | 88 ++ .../amethyst/ui/navigation/AppNavigation.kt | 2 + .../amethyst/ui/navigation/routes/Routes.kt | 4 + .../loggedIn/relays/RelayInformationScreen.kt | 20 +- .../relays/nip86/RelayManagementScreen.kt | 953 ++++++++++++++++++ .../relays/nip86/RelayManagementViewModel.kt | 318 ++++++ amethyst/src/main/res/values/strings.xml | 45 + .../nip86RelayManagement/Nip86Client.kt | 168 +++ .../nip86RelayManagement/rpc/Nip86Method.kt | 44 + .../nip86RelayManagement/rpc/Nip86Request.kt | 170 ++++ .../nip86RelayManagement/rpc/Nip86Response.kt | 60 ++ 11 files changed, 1871 insertions(+), 1 deletion(-) create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip86RelayManagement/Nip86Retriever.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt create mode 100644 amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86Client.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Method.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Request.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Response.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip86RelayManagement/Nip86Retriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip86RelayManagement/Nip86Retriever.kt new file mode 100644 index 000000000..2ccbd9453 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip86RelayManagement/Nip86Retriever.kt @@ -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}") + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 926bce501..461c2ab8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -112,6 +112,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 @@ -226,6 +227,7 @@ fun AppNavigation( composableFromEndArgs { RelayFeedScreen(it, accountViewModel, nav) } composableFromEndArgs { ChessGameScreen(it.gameId, accountViewModel, nav) } composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } + composableFromEndArgs { RelayManagementScreen(it.url, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index f22434cb0..e18134c22 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -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() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 2276ba5f9..90c1d912f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -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(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt new file mode 100644 index 000000000..898372cc7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementScreen.kt @@ -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, + 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, +) { + 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, +) { + 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, +) { + 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, +) { + 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, +) { + 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)) + } + }, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt new file mode 100644 index 000000000..3182d025d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip86/RelayManagementViewModel.kt @@ -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>(emptyList()) + val supportedMethods: StateFlow> = _supportedMethods + + private val _bannedPubkeys = MutableStateFlow>(emptyList()) + val bannedPubkeys: StateFlow> = _bannedPubkeys + + private val _allowedPubkeys = MutableStateFlow>(emptyList()) + val allowedPubkeys: StateFlow> = _allowedPubkeys + + private val _bannedEvents = MutableStateFlow>(emptyList()) + val bannedEvents: StateFlow> = _bannedEvents + + private val _eventsNeedingModeration = MutableStateFlow>(emptyList()) + val eventsNeedingModeration: StateFlow> = _eventsNeedingModeration + + private val _allowedKinds = MutableStateFlow>(emptyList()) + val allowedKinds: StateFlow> = _allowedKinds + + private val _blockedIps = MutableStateFlow>(emptyList()) + val blockedIps: StateFlow> = _blockedIps + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading + + private val _error = MutableStateFlow(null) + val error: StateFlow = _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() + } +} diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 03022e303..3ac992398 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -1955,4 +1955,49 @@ Delete Delete this web bookmark? Open URL + + + Manage %1$s + Loading relay management capabilities… + Unable to connect to relay management + No management methods available + Dismiss + Pubkeys + Events + Kinds + IPs + Settings + Banned Pubkeys + No banned pubkeys + Allowed Pubkeys + No allowed pubkeys + Moderation Queue + No events needing moderation + Banned Events + No banned events + Allowed Kinds + No allowed kinds + Blocked IPs + No blocked IPs + Add + Remove + Allow + Ban + Ban Pubkey + Allow Pubkey + Ban Event + Allow Kind + Block IP + Pubkey (hex) + Event ID (hex) + Kind number + IP address + Reason (optional) + Confirm + Cancel + Apply + Relay Name + Relay Description + Relay Icon URL + Manage Relay diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86Client.kt new file mode 100644 index 000000000..bc3410627 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/Nip86Client.kt @@ -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(template) + return signedEvent.toAuthToken() + } + + fun serializeRequest(request: Nip86Request): String = JsonMapper.toJson(request) + + fun parseResponse(json: String): Nip86Response = JsonMapper.fromJson(json) + + fun parseSupportedMethods(response: Nip86Response): List? { + 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? { + val result = response.result ?: return null + return JsonMapper.fromJson>(result.toString()) + } + + fun parseAllowedPubkeys(response: Nip86Response): List? { + val result = response.result ?: return null + return JsonMapper.fromJson>(result.toString()) + } + + fun parseBannedEvents(response: Nip86Response): List? { + val result = response.result ?: return null + return JsonMapper.fromJson>(result.toString()) + } + + fun parseEventsNeedingModeration(response: Nip86Response): List? { + val result = response.result ?: return null + return JsonMapper.fromJson>(result.toString()) + } + + fun parseAllowedKinds(response: Nip86Response): List? { + val result = response.result ?: return null + return (result as? JsonArray)?.map { it.jsonPrimitive.int } + } + + fun parseBlockedIps(response: Nip86Response): List? { + val result = response.result ?: return null + return JsonMapper.fromJson>(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?): Boolean = supportedNips?.any { it == "86" } == true + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Method.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Method.kt new file mode 100644 index 000000000..a52f48be7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Method.kt @@ -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" +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Request.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Request.kt new file mode 100644 index 000000000..1260c0ad5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Request.kt @@ -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)) } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Response.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Response.kt new file mode 100644 index 000000000..8f34cb275 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip86RelayManagement/rpc/Nip86Response.kt @@ -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, +)