From 438d7c9122afe6f307630a7a5ebcb3afeb119b04 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 13:40:29 +0000 Subject: [PATCH] feat: map pubkeys to users with profile info in relay management Resolve pubkeys to User objects via LocalCache in RelayManagementViewModel, caching the mapping. Render banned/allowed pubkeys using SlimListItem with user picture, name, and NIP-05 verification, with the remove action button in trailingContent. Falls back to HexEntryCard for unknown pubkeys. https://claude.ai/code/session_018x2PcJX6VGyJmVuphkbK54 --- .../relays/nip86/RelayManagementScreen.kt | 132 +++++++++++++++++- .../relays/nip86/RelayManagementViewModel.kt | 18 ++- 2 files changed, 142 insertions(+), 8 deletions(-) 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 index 898372cc7..a661f048f 100644 --- 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 @@ -47,6 +47,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -67,16 +68,28 @@ 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.AnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.model.nip05DnsIdentifiers.Nip05State +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.nip86RelayManagement.Nip86Retriever +import com.vitorpamplona.amethyst.ui.layouts.listItem.SlimListItem import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture +import com.vitorpamplona.amethyst.ui.note.ObserveAndRenderNIP05VerifiedSymbol +import com.vitorpamplona.amethyst.ui.note.UsernameDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.BackButton +import com.vitorpamplona.amethyst.ui.theme.Font14SP +import com.vitorpamplona.amethyst.ui.theme.NIP05IconSize +import com.vitorpamplona.amethyst.ui.theme.Size55dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.nip05 import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl @@ -197,7 +210,7 @@ fun RelayManagementScreen( ) } } else { - RelayManagementContent(pad, viewModel, supportedMethods, error) + RelayManagementContent(pad, viewModel, supportedMethods, error, accountViewModel) } } } @@ -208,6 +221,7 @@ private fun RelayManagementContent( viewModel: RelayManagementViewModel, supportedMethods: List, error: String?, + accountViewModel: AccountViewModel, ) { val tabs = remember(supportedMethods) { @@ -271,7 +285,7 @@ private fun RelayManagementContent( when (tabs.getOrNull(selectedTab)) { ManagementTab.PUBKEYS -> { - PubkeysTab(viewModel, supportedMethods) + PubkeysTab(viewModel, supportedMethods, accountViewModel) } ManagementTab.EVENTS -> { @@ -322,9 +336,12 @@ private enum class ManagementTab( private fun PubkeysTab( viewModel: RelayManagementViewModel, supportedMethods: List, + accountViewModel: AccountViewModel, ) { val bannedPubkeys by viewModel.bannedPubkeys.collectAsState() val allowedPubkeys by viewModel.allowedPubkeys.collectAsState() + val bannedPubkeyUsers by viewModel.bannedPubkeyUsers.collectAsState() + val allowedPubkeyUsers by viewModel.allowedPubkeyUsers.collectAsState() var showBanDialog by remember { mutableStateOf(false) } var showAllowDialog by remember { mutableStateOf(false) } @@ -345,11 +362,14 @@ private fun PubkeysTab( item { EmptyListMessage(stringResource(R.string.relay_management_no_banned_pubkeys)) } } else { items(bannedPubkeys, key = { it.pubkey }) { entry -> - HexEntryCard( - hex = entry.pubkey, + val user = bannedPubkeyUsers[entry.pubkey] + PubkeyUserCard( + pubkey = entry.pubkey, + user = user, reason = entry.reason, showRemove = supportedMethods.contains(Nip86Method.UNBAN_PUBKEY), onRemove = { viewModel.unbanPubkey(entry.pubkey) }, + accountViewModel = accountViewModel, ) } } @@ -369,11 +389,14 @@ private fun PubkeysTab( item { EmptyListMessage(stringResource(R.string.relay_management_no_allowed_pubkeys)) } } else { items(allowedPubkeys, key = { it.pubkey }) { entry -> - HexEntryCard( - hex = entry.pubkey, + val user = allowedPubkeyUsers[entry.pubkey] + PubkeyUserCard( + pubkey = entry.pubkey, + user = user, reason = entry.reason, showRemove = supportedMethods.contains(Nip86Method.UNALLOW_PUBKEY), onRemove = { viewModel.unallowPubkey(entry.pubkey) }, + accountViewModel = accountViewModel, ) } } @@ -405,6 +428,103 @@ private fun PubkeysTab( } } +@Composable +private fun PubkeyUserCard( + pubkey: String, + user: User?, + reason: String?, + showRemove: Boolean, + onRemove: () -> Unit, + accountViewModel: AccountViewModel, +) { + if (user != null) { + SlimListItem( + modifier = Modifier.fillMaxWidth(), + leadingContent = { + ClickableUserPicture(user, Size55dp, accountViewModel = accountViewModel, onClick = null) + }, + headlineContent = { + UsernameDisplay(user, accountViewModel = accountViewModel) + }, + supportingContent = { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + PubkeyNip05Row(user, accountViewModel) + } + reason?.let { + Text( + it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + }, + trailingContent = { + if (showRemove) { + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.relay_management_remove), + tint = MaterialTheme.colorScheme.error, + ) + } + } + }, + ) + } else { + HexEntryCard( + hex = pubkey, + reason = reason, + showRemove = showRemove, + onRemove = onRemove, + ) + } +} + +@Composable +private fun PubkeyNip05Row( + user: User, + accountViewModel: AccountViewModel, +) { + val nip05StateMetadata by user.nip05State().flow.collectAsStateWithLifecycle() + + when (val nip05State = nip05StateMetadata) { + is Nip05State.Exists -> { + if (nip05State.nip05.name != "_") { + Text( + text = remember(nip05State) { AnnotatedString(nip05State.nip05.name) }, + fontSize = Font14SP, + color = MaterialTheme.colorScheme.nip05, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + ObserveAndRenderNIP05VerifiedSymbol(nip05State, 1, NIP05IconSize, accountViewModel) + + Text( + text = nip05State.nip05.domain, + style = LocalTextStyle.current.copy(color = MaterialTheme.colorScheme.nip05, fontSize = Font14SP), + maxLines = 1, + overflow = TextOverflow.Visible, + ) + } + + else -> { + Text( + text = user.pubkeyDisplayHex(), + fontSize = Font14SP, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + // Events Tab @Composable private fun EventsTab( 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 index 3182d025d..7494c9991 100644 --- 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 @@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip86 import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.nip86RelayManagement.Nip86Retriever import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -64,6 +66,12 @@ class RelayManagementViewModel( private val _blockedIps = MutableStateFlow>(emptyList()) val blockedIps: StateFlow> = _blockedIps + private val _bannedPubkeyUsers = MutableStateFlow>(emptyMap()) + val bannedPubkeyUsers: StateFlow> = _bannedPubkeyUsers + + private val _allowedPubkeyUsers = MutableStateFlow>(emptyMap()) + val allowedPubkeyUsers: StateFlow> = _allowedPubkeyUsers + private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading @@ -90,7 +98,9 @@ class RelayManagementViewModel( if (response.error != null) { _error.value = response.error } else { - _bannedPubkeys.value = client.parseBannedPubkeys(response) ?: emptyList() + val pubkeys = client.parseBannedPubkeys(response) ?: emptyList() + _bannedPubkeys.value = pubkeys + _bannedPubkeyUsers.value = resolvePubkeysToUsers(pubkeys.map { it.pubkey }) } } } @@ -101,11 +111,15 @@ class RelayManagementViewModel( if (response.error != null) { _error.value = response.error } else { - _allowedPubkeys.value = client.parseAllowedPubkeys(response) ?: emptyList() + val pubkeys = client.parseAllowedPubkeys(response) ?: emptyList() + _allowedPubkeys.value = pubkeys + _allowedPubkeyUsers.value = resolvePubkeysToUsers(pubkeys.map { it.pubkey }) } } } + private fun resolvePubkeysToUsers(pubkeys: List): Map = pubkeys.associateWith { LocalCache.getUserIfExists(it) } + fun loadBannedEvents() { viewModelScope.launch { val response = retriever.execute(client, Nip86Request.listBannedEvents())