From 438d7c9122afe6f307630a7a5ebcb3afeb119b04 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 13:40:29 +0000 Subject: [PATCH 1/2] 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()) From 9e871c571929a556098e5564c9e58539db15a22a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Mar 2026 14:08:31 +0000 Subject: [PATCH 2/2] refactor: use flow.map for pubkey-to-user resolution, filter invalid pubkeys Replace MutableStateFlow caches with Flow.map derived from bannedPubkeys and allowedPubkeys. Use LocalCache.checkGetOrCreateUser to safely resolve pubkeys, filtering out invalid ones (null). Remove raw pubkey display from PubkeyUserCard since invalid pubkeys are already excluded. https://claude.ai/code/session_018x2PcJX6VGyJmVuphkbK54 --- .../relays/nip86/RelayManagementScreen.kt | 111 ++++++++---------- .../relays/nip86/RelayManagementViewModel.kt | 33 ++++-- 2 files changed, 67 insertions(+), 77 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 a661f048f..e9f3f5b6d 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 @@ -338,10 +338,8 @@ private fun PubkeysTab( 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() + val bannedPubkeyUsers by viewModel.bannedPubkeyUsers.collectAsStateWithLifecycle(emptyList()) + val allowedPubkeyUsers by viewModel.allowedPubkeyUsers.collectAsStateWithLifecycle(emptyList()) var showBanDialog by remember { mutableStateOf(false) } var showAllowDialog by remember { mutableStateOf(false) } @@ -358,17 +356,14 @@ private fun PubkeysTab( ) } - if (bannedPubkeys.isEmpty()) { + if (bannedPubkeyUsers.isEmpty()) { item { EmptyListMessage(stringResource(R.string.relay_management_no_banned_pubkeys)) } } else { - items(bannedPubkeys, key = { it.pubkey }) { entry -> - val user = bannedPubkeyUsers[entry.pubkey] + items(bannedPubkeyUsers, key = { it.user.pubkeyHex }) { entry -> PubkeyUserCard( - pubkey = entry.pubkey, - user = user, - reason = entry.reason, + entry = entry, showRemove = supportedMethods.contains(Nip86Method.UNBAN_PUBKEY), - onRemove = { viewModel.unbanPubkey(entry.pubkey) }, + onRemove = { viewModel.unbanPubkey(entry.user.pubkeyHex) }, accountViewModel = accountViewModel, ) } @@ -385,17 +380,14 @@ private fun PubkeysTab( ) } - if (allowedPubkeys.isEmpty()) { + if (allowedPubkeyUsers.isEmpty()) { item { EmptyListMessage(stringResource(R.string.relay_management_no_allowed_pubkeys)) } } else { - items(allowedPubkeys, key = { it.pubkey }) { entry -> - val user = allowedPubkeyUsers[entry.pubkey] + items(allowedPubkeyUsers, key = { it.user.pubkeyHex }) { entry -> PubkeyUserCard( - pubkey = entry.pubkey, - user = user, - reason = entry.reason, + entry = entry, showRemove = supportedMethods.contains(Nip86Method.UNALLOW_PUBKEY), - onRemove = { viewModel.unallowPubkey(entry.pubkey) }, + onRemove = { viewModel.unallowPubkey(entry.user.pubkeyHex) }, accountViewModel = accountViewModel, ) } @@ -430,59 +422,48 @@ private fun PubkeysTab( @Composable private fun PubkeyUserCard( - pubkey: String, - user: User?, - reason: String?, + entry: PubkeyUser, 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, - ) - } + SlimListItem( + modifier = Modifier.fillMaxWidth(), + leadingContent = { + ClickableUserPicture(entry.user, Size55dp, accountViewModel = accountViewModel, onClick = null) + }, + headlineContent = { + UsernameDisplay(entry.user, accountViewModel = accountViewModel) + }, + supportingContent = { + Column { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + PubkeyNip05Row(entry.user, accountViewModel) } - }, - trailingContent = { - if (showRemove) { - IconButton(onClick = onRemove) { - Icon( - Icons.Default.Close, - contentDescription = stringResource(R.string.relay_management_remove), - tint = MaterialTheme.colorScheme.error, - ) - } + entry.reason?.let { + Text( + it, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) } - }, - ) - } else { - HexEntryCard( - hex = pubkey, - reason = reason, - showRemove = showRemove, - onRemove = onRemove, - ) - } + } + }, + trailingContent = { + if (showRemove) { + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Close, + contentDescription = stringResource(R.string.relay_management_remove), + tint = MaterialTheme.colorScheme.error, + ) + } + } + }, + ) } @Composable 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 7494c9991..1b1effd97 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 @@ -34,10 +34,17 @@ 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.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +class PubkeyUser( + val user: User, + val reason: String?, +) + class RelayManagementViewModel( relayUrl: NormalizedRelayUrl, signer: NostrSigner, @@ -66,11 +73,19 @@ class RelayManagementViewModel( private val _blockedIps = MutableStateFlow>(emptyList()) val blockedIps: StateFlow> = _blockedIps - private val _bannedPubkeyUsers = MutableStateFlow>(emptyMap()) - val bannedPubkeyUsers: StateFlow> = _bannedPubkeyUsers + val bannedPubkeyUsers: Flow> = + _bannedPubkeys.map { list -> + list.mapNotNull { entry -> + LocalCache.checkGetOrCreateUser(entry.pubkey)?.let { PubkeyUser(it, entry.reason) } + } + } - private val _allowedPubkeyUsers = MutableStateFlow>(emptyMap()) - val allowedPubkeyUsers: StateFlow> = _allowedPubkeyUsers + val allowedPubkeyUsers: Flow> = + _allowedPubkeys.map { list -> + list.mapNotNull { entry -> + LocalCache.checkGetOrCreateUser(entry.pubkey)?.let { PubkeyUser(it, entry.reason) } + } + } private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading @@ -98,9 +113,7 @@ class RelayManagementViewModel( if (response.error != null) { _error.value = response.error } else { - val pubkeys = client.parseBannedPubkeys(response) ?: emptyList() - _bannedPubkeys.value = pubkeys - _bannedPubkeyUsers.value = resolvePubkeysToUsers(pubkeys.map { it.pubkey }) + _bannedPubkeys.value = client.parseBannedPubkeys(response) ?: emptyList() } } } @@ -111,15 +124,11 @@ class RelayManagementViewModel( if (response.error != null) { _error.value = response.error } else { - val pubkeys = client.parseAllowedPubkeys(response) ?: emptyList() - _allowedPubkeys.value = pubkeys - _allowedPubkeyUsers.value = resolvePubkeysToUsers(pubkeys.map { it.pubkey }) + _allowedPubkeys.value = client.parseAllowedPubkeys(response) ?: emptyList() } } } - private fun resolvePubkeysToUsers(pubkeys: List): Map = pubkeys.associateWith { LocalCache.getUserIfExists(it) } - fun loadBannedEvents() { viewModelScope.launch { val response = retriever.execute(client, Nip86Request.listBannedEvents())