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
This commit is contained in:
Claude
2026-03-27 13:40:29 +00:00
parent 511fcb4c2a
commit 438d7c9122
2 changed files with 142 additions and 8 deletions
@@ -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<String>,
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<String>,
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(
@@ -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<List<BlockedIp>>(emptyList())
val blockedIps: StateFlow<List<BlockedIp>> = _blockedIps
private val _bannedPubkeyUsers = MutableStateFlow<Map<String, User?>>(emptyMap())
val bannedPubkeyUsers: StateFlow<Map<String, User?>> = _bannedPubkeyUsers
private val _allowedPubkeyUsers = MutableStateFlow<Map<String, User?>>(emptyMap())
val allowedPubkeyUsers: StateFlow<Map<String, User?>> = _allowedPubkeyUsers
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _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<String>): Map<String, User?> = pubkeys.associateWith { LocalCache.getUserIfExists(it) }
fun loadBannedEvents() {
viewModelScope.launch {
val response = retriever.execute(client, Nip86Request.listBannedEvents())