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:
+88
@@ -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()
|
||||
|
||||
+19
-1
@@ -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(),
|
||||
|
||||
+953
@@ -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))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+318
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
+168
@@ -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
|
||||
}
|
||||
}
|
||||
+44
@@ -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"
|
||||
}
|
||||
+170
@@ -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)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
+60
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user