feat: replace hex pubkeys with user picture + name in chess cards

In chess note cards (NoteCompose), player hex keys were displayed as
raw strings. Now uses LoadUser + ClickableUserPicture + UsernameDisplay
to show proper profile pictures and display names for:
- Challenge cards (incoming/outgoing)
- Game end cards (both players)
- PGN metadata in game viewers (white/black players)

Added playerContent composable slot to PGNMetadata and ChessGameViewer
so callers can inject platform-specific user rendering.

https://claude.ai/code/session_0171mKrVEfQnNRabmT7Kv4gf
This commit is contained in:
Claude
2026-04-08 20:53:56 +00:00
parent b2a9215788
commit b861a71c2c
3 changed files with 161 additions and 35 deletions
@@ -37,6 +37,7 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
@@ -46,10 +47,15 @@ import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.chess.ChessChallenge import com.vitorpamplona.amethyst.commons.chess.ChessChallenge
import com.vitorpamplona.amethyst.commons.chess.ChessGameViewer import com.vitorpamplona.amethyst.commons.chess.ChessGameViewer
import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.User
import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.SensitivityWarning
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelFactory import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelFactory
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelNew import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelNew
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -79,7 +85,22 @@ fun RenderChessGame(
val event = (note.event as? ChessGameEvent) ?: return val event = (note.event as? ChessGameEvent) ?: return
SensitivityWarning(note = note, accountViewModel = accountViewModel) { SensitivityWarning(note = note, accountViewModel = accountViewModel) {
ChessGameViewer(pgnContent = event.pgn()) ChessGameViewer(
pgnContent = event.pgn(),
playerContent = { playerHex ->
LoadUser(playerHex, accountViewModel) { user ->
if (user != null) {
UserChip(user = user, accountViewModel = accountViewModel, nav = nav)
} else {
Text(
text = playerHex.take(8) + "...",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
}
}
},
)
} }
} }
@@ -120,20 +141,6 @@ fun RenderLiveChessChallenge(
else -> MaterialTheme.colorScheme.outline // Gray for sent else -> MaterialTheme.colorScheme.outline // Gray for sent
} }
val icon =
when {
isOpenChallenge -> "🔓"
isIncomingChallenge -> "💌"
else -> ""
}
val title =
when {
isOpenChallenge -> "Open Challenge"
isIncomingChallenge -> "Challenge from ${note.author?.toBestDisplayName()}"
else -> "Challenge sent to ${event.opponentPubkey()?.take(8)}"
}
Card( Card(
modifier = modifier =
Modifier Modifier
@@ -152,12 +159,49 @@ fun RenderLiveChessChallenge(
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) { ) {
Text( Row(
text = "$icon $title", modifier = Modifier.weight(1f),
style = MaterialTheme.typography.titleMedium, verticalAlignment = Alignment.CenterVertically,
fontWeight = FontWeight.Bold, horizontalArrangement = Arrangement.spacedBy(6.dp),
) ) {
when {
isOpenChallenge -> {
Text(
text = "🔓 Open Challenge",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
}
isIncomingChallenge -> {
Text(
text = "💌 Challenge from",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
note.author?.let { author ->
UserChip(user = author, accountViewModel = accountViewModel, nav = nav)
}
}
else -> {
Text(
text = "⏳ Challenge sent to",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
event.opponentPubkey()?.let { opponentHex ->
LoadUser(opponentHex, accountViewModel) { user ->
if (user != null) {
UserChip(user = user, accountViewModel = accountViewModel, nav = nav)
}
}
}
}
}
}
Text( Text(
text = text =
@@ -249,6 +293,31 @@ fun RenderLiveChessGameEnd(
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
// Players row
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
note.author?.let { author ->
UserChip(user = author, accountViewModel = accountViewModel, nav = nav)
}
Text(
text = "vs",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
event.opponentPubkey()?.let { opponentHex ->
LoadUser(opponentHex, accountViewModel) { user ->
if (user != null) {
UserChip(user = user, accountViewModel = accountViewModel, nav = nav)
}
}
}
}
Text( Text(
text = "Result: ${event.result()}", text = "Result: ${event.result()}",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -265,8 +334,49 @@ fun RenderLiveChessGameEnd(
// Show PGN if available // Show PGN if available
event.pgn().takeIf { it.isNotBlank() }?.let { pgn -> event.pgn().takeIf { it.isNotBlank() }?.let { pgn ->
ChessGameViewer(pgnContent = pgn) ChessGameViewer(
pgnContent = pgn,
playerContent = { playerHex ->
LoadUser(playerHex, accountViewModel) { user ->
if (user != null) {
UserChip(user = user, accountViewModel = accountViewModel, nav = nav)
} else {
Text(
text = playerHex.take(8) + "...",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
}
}
},
)
} }
} }
} }
} }
/**
* Compact user display with small picture and name, shown inline.
*/
@Composable
private fun UserChip(
user: User,
accountViewModel: AccountViewModel,
nav: INav,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
ClickableUserPicture(
baseUser = user,
size = 24.dp,
accountViewModel = accountViewModel,
onClick = { nav.nav(routeFor(it)) },
)
UsernameDisplay(
baseUser = user,
accountViewModel = accountViewModel,
)
}
}
@@ -51,11 +51,14 @@ import com.vitorpamplona.quartz.nip64Chess.PGNParser
* *
* @param pgnContent PGN format string * @param pgnContent PGN format string
* @param modifier Modifier for the viewer * @param modifier Modifier for the viewer
* @param playerContent Optional composable to render player names with avatar + display name.
* When provided, PGN player names (which may be hex pubkeys) are rendered using this composable.
*/ */
@Composable @Composable
fun ChessGameViewer( fun ChessGameViewer(
pgnContent: String, pgnContent: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
playerContent: (@Composable (String) -> Unit)? = null,
) { ) {
val gameResult = val gameResult =
remember(pgnContent) { remember(pgnContent) {
@@ -64,7 +67,7 @@ fun ChessGameViewer(
gameResult.fold( gameResult.fold(
onSuccess = { game -> onSuccess = { game ->
ChessGameDisplay(game, modifier) ChessGameDisplay(game, modifier, playerContent)
}, },
onFailure = { error -> onFailure = { error ->
ChessGameError( ChessGameError(
@@ -83,6 +86,7 @@ fun ChessGameViewer(
private fun ChessGameDisplay( private fun ChessGameDisplay(
game: com.vitorpamplona.quartz.nip64Chess.ChessGame, game: com.vitorpamplona.quartz.nip64Chess.ChessGame,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
playerContent: (@Composable (String) -> Unit)? = null,
) { ) {
var currentMoveIndex by remember { mutableStateOf(0) } var currentMoveIndex by remember { mutableStateOf(0) }
@@ -96,7 +100,7 @@ private fun ChessGameDisplay(
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
// Game metadata header // Game metadata header
PGNMetadata(game = game) PGNMetadata(game = game, playerContent = playerContent)
// Chess board // Chess board
ChessBoard( ChessBoard(
@@ -39,11 +39,15 @@ import com.vitorpamplona.quartz.nip64Chess.ChessGame
* *
* @param game The chess game with metadata to display * @param game The chess game with metadata to display
* @param modifier Modifier for the metadata display * @param modifier Modifier for the metadata display
* @param playerContent Optional composable to render player names (e.g. with avatar + display name).
* Receives the raw player name string from PGN (which may be a hex pubkey in Nostr context).
* When null, falls back to plain text display.
*/ */
@Composable @Composable
fun PGNMetadata( fun PGNMetadata(
game: ChessGame, game: ChessGame,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
playerContent: (@Composable (String) -> Unit)? = null,
) { ) {
Column( Column(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
@@ -76,12 +80,16 @@ fun PGNMetadata(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
game.white?.let { white -> game.white?.let { white ->
Text( if (playerContent != null) {
text = white, playerContent(white)
style = MaterialTheme.typography.bodyMedium, } else {
fontWeight = FontWeight.Medium, Text(
color = MaterialTheme.colorScheme.onSurface, text = white,
) style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
}
} }
Text( Text(
@@ -91,12 +99,16 @@ fun PGNMetadata(
) )
game.black?.let { black -> game.black?.let { black ->
Text( if (playerContent != null) {
text = black, playerContent(black)
style = MaterialTheme.typography.bodyMedium, } else {
fontWeight = FontWeight.Medium, Text(
color = MaterialTheme.colorScheme.onSurface, text = black,
) style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.onSurface,
)
}
} }
} }
} }