From b861a71c2cb8afe00625a617acd0323603886f50 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Apr 2026 20:53:56 +0000 Subject: [PATCH] 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 --- .../amethyst/ui/note/types/Chess.kt | 152 +++++++++++++++--- .../amethyst/commons/chess/ChessGameViewer.kt | 8 +- .../amethyst/commons/chess/PGNMetadata.kt | 36 +++-- 3 files changed, 161 insertions(+), 35 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt index f340dec51..275ffad3e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt @@ -37,6 +37,7 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color 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.ChessGameViewer import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.navigation.navs.INav 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.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelFactory import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelNew import com.vitorpamplona.amethyst.ui.stringRes @@ -79,7 +85,22 @@ fun RenderChessGame( val event = (note.event as? ChessGameEvent) ?: return 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 } - 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( modifier = Modifier @@ -152,12 +159,49 @@ fun RenderLiveChessChallenge( Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = "$icon $title", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically, + 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 = @@ -249,6 +293,31 @@ fun RenderLiveChessGameEnd( style = MaterialTheme.typography.titleMedium, 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 = "Result: ${event.result()}", style = MaterialTheme.typography.bodyMedium, @@ -265,8 +334,49 @@ fun RenderLiveChessGameEnd( // Show PGN if available 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, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameViewer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameViewer.kt index 1fcc1ab3c..41f891250 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameViewer.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameViewer.kt @@ -51,11 +51,14 @@ import com.vitorpamplona.quartz.nip64Chess.PGNParser * * @param pgnContent PGN format string * @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 fun ChessGameViewer( pgnContent: String, modifier: Modifier = Modifier, + playerContent: (@Composable (String) -> Unit)? = null, ) { val gameResult = remember(pgnContent) { @@ -64,7 +67,7 @@ fun ChessGameViewer( gameResult.fold( onSuccess = { game -> - ChessGameDisplay(game, modifier) + ChessGameDisplay(game, modifier, playerContent) }, onFailure = { error -> ChessGameError( @@ -83,6 +86,7 @@ fun ChessGameViewer( private fun ChessGameDisplay( game: com.vitorpamplona.quartz.nip64Chess.ChessGame, modifier: Modifier = Modifier, + playerContent: (@Composable (String) -> Unit)? = null, ) { var currentMoveIndex by remember { mutableStateOf(0) } @@ -96,7 +100,7 @@ private fun ChessGameDisplay( verticalArrangement = Arrangement.spacedBy(12.dp), ) { // Game metadata header - PGNMetadata(game = game) + PGNMetadata(game = game, playerContent = playerContent) // Chess board ChessBoard( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/PGNMetadata.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/PGNMetadata.kt index 16441b52d..e204bfe2b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/PGNMetadata.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/PGNMetadata.kt @@ -39,11 +39,15 @@ import com.vitorpamplona.quartz.nip64Chess.ChessGame * * @param game The chess game with metadata to 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 fun PGNMetadata( game: ChessGame, modifier: Modifier = Modifier, + playerContent: (@Composable (String) -> Unit)? = null, ) { Column( modifier = modifier.fillMaxWidth(), @@ -76,12 +80,16 @@ fun PGNMetadata( verticalAlignment = Alignment.CenterVertically, ) { game.white?.let { white -> - Text( - text = white, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) + if (playerContent != null) { + playerContent(white) + } else { + Text( + text = white, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + } } Text( @@ -91,12 +99,16 @@ fun PGNMetadata( ) game.black?.let { black -> - Text( - text = black, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, - ) + if (playerContent != null) { + playerContent(black) + } else { + Text( + text = black, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + } } } }