fix(chess): filter own games from live list, dismissable errors, clickable avatars and completed games

- Filter user's own games from public "Live Games" list in lobby
- Fix broken addSpectatingGame filter (playerPubkey is always viewerPubkey)
- Add removeGame() to clean up stale entries when "Game not found"
- Make player avatars clickable to open profile on game screen
- Make completed game cards clickable to view final board state

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
davotoula
2026-03-23 18:01:29 +01:00
parent 08d5f2ebb5
commit c86daa6950
10 changed files with 47 additions and 4 deletions
@@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus
import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner
import com.vitorpamplona.amethyst.commons.chess.LiveChessGameScreen import com.vitorpamplona.amethyst.commons.chess.LiveChessGameScreen
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.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessSubscription
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
@@ -283,7 +284,10 @@ fun ChessGameScreen(
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
Button(onClick = { nav.popBack() }) { Button(onClick = {
chessViewModel.removeGame(gameId)
nav.popBack()
}) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringRes(R.string.back), contentDescription = stringRes(R.string.back),
@@ -365,6 +369,7 @@ fun ChessGameScreen(
blackName = blackDisplayName, blackName = blackDisplayName,
blackHex = blackPubkey, blackHex = blackPubkey,
blackAvatarUrl = blackAvatarUrl, blackAvatarUrl = blackAvatarUrl,
onPlayerClick = { pubkeyHex -> nav.nav(Route.Profile(pubkeyHex)) },
) )
} }
} }
@@ -596,6 +596,7 @@ fun ChessLobbyContent(
didUserWin = game.didUserWin(userPubkey), didUserWin = game.didUserWin(userPubkey),
isDraw = game.isDraw, isDraw = game.isDraw,
moveCount = game.moveCount, moveCount = game.moveCount,
onClick = { onSelectGame(game.gameId) },
avatar = { avatar = {
OverlappingAvatars( OverlappingAvatars(
avatar1Hex = userPubkey, avatar1Hex = userPubkey,
@@ -178,6 +178,8 @@ class ChessViewModelNew(
fun stopSpectating(gameId: String) = logic.stopSpectating(gameId) fun stopSpectating(gameId: String) = logic.stopSpectating(gameId)
fun removeGame(gameId: String) = logic.removeGame(gameId)
// ============================================ // ============================================
// Utility // Utility
// ============================================ // ============================================
@@ -288,6 +288,7 @@ fun CompletedGameCard(
didUserWin: Boolean, didUserWin: Boolean,
isDraw: Boolean, isDraw: Boolean,
moveCount: Int, moveCount: Int,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
avatar: @Composable (() -> Unit)? = null, avatar: @Composable (() -> Unit)? = null,
) { ) {
@@ -309,6 +310,8 @@ fun CompletedGameCard(
} }
Card( Card(
onClick = onClick ?: {},
enabled = onClick != null,
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
) { ) {
Row( Row(
@@ -856,6 +856,16 @@ class ChessLobbyLogic(
} }
} }
/**
* Remove a game from all lobby lists (active, spectating) and stop polling.
* Used when a game fails to load ("Game not found") so the user can dismiss the stale entry.
*/
fun removeGame(gameId: String) {
state.removeActiveGame(gameId)
state.removeSpectatingGame(gameId)
pollingDelegate.removeGameId(gameId)
}
fun clearError() { fun clearError() {
state.setError(null) state.setError(null)
} }
@@ -297,7 +297,8 @@ class ChessLobbyState(
} }
fun updatePublicGames(games: List<PublicGame>) { fun updatePublicGames(games: List<PublicGame>) {
_publicGames.value = games // Filter out games where the user is a participant
_publicGames.value = games.filter { it.whitePubkey != userPubkey && it.blackPubkey != userPubkey }
} }
fun addActiveGame( fun addActiveGame(
@@ -501,7 +502,9 @@ class ChessLobbyState(
return return
} }
// Don't add own games to spectating list // Don't add own games to spectating list
if (state.playerPubkey == userPubkey || state.opponentPubkey == userPubkey) { // Note: LiveChessGameState.playerPubkey is always viewerPubkey regardless of role,
// so we check isSpectator instead to determine if the user is actually a participant
if (!state.isSpectator) {
return return
} }
// Don't add finished games to spectating list // Don't add finished games to spectating list
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -64,6 +65,7 @@ fun ChessPlayerChip(
avatarUrl: String?, avatarUrl: String?,
isActive: Boolean = false, isActive: Boolean = false,
mirrored: Boolean = false, mirrored: Boolean = false,
onClick: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val avatar = val avatar =
@@ -102,7 +104,10 @@ fun ChessPlayerChip(
} }
Row( Row(
modifier = modifier.alpha(if (isActive) 1f else 0.6f), modifier =
modifier
.alpha(if (isActive) 1f else 0.6f)
.then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
@@ -146,6 +151,8 @@ fun ChessPlayerVsHeader(
blackHex: String, blackHex: String,
blackAvatarUrl: String?, blackAvatarUrl: String?,
isWhiteTurn: Boolean, isWhiteTurn: Boolean,
onWhiteClick: (() -> Unit)? = null,
onBlackClick: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
@@ -163,6 +170,7 @@ fun ChessPlayerVsHeader(
userHex = whiteHex, userHex = whiteHex,
avatarUrl = whiteAvatarUrl, avatarUrl = whiteAvatarUrl,
isActive = isWhiteTurn, isActive = isWhiteTurn,
onClick = onWhiteClick,
) )
Text( Text(
text = "White", text = "White",
@@ -188,6 +196,7 @@ fun ChessPlayerVsHeader(
avatarUrl = blackAvatarUrl, avatarUrl = blackAvatarUrl,
isActive = !isWhiteTurn, isActive = !isWhiteTurn,
mirrored = true, mirrored = true,
onClick = onBlackClick,
) )
Text( Text(
text = "Black", text = "Black",
@@ -208,6 +208,7 @@ fun LiveChessGameScreen(
blackName: String = "Black", blackName: String = "Black",
blackHex: String = "", blackHex: String = "",
blackAvatarUrl: String? = null, blackAvatarUrl: String? = null,
onPlayerClick: ((pubkeyHex: String) -> Unit)? = null,
) { ) {
// Observe state flows for automatic recomposition on updates // Observe state flows for automatic recomposition on updates
val currentPosition by gameState.currentPosition.collectAsState() val currentPosition by gameState.currentPosition.collectAsState()
@@ -253,6 +254,8 @@ fun LiveChessGameScreen(
blackHex = blackHex, blackHex = blackHex,
blackAvatarUrl = blackAvatarUrl, blackAvatarUrl = blackAvatarUrl,
isWhiteTurn = currentPosition.activeColor == Color.WHITE, isWhiteTurn = currentPosition.activeColor == Color.WHITE,
onWhiteClick = onPlayerClick?.let { { it(whiteHex) } },
onBlackClick = onPlayerClick?.let { { it(blackHex) } },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} }
@@ -252,8 +252,12 @@ fun ChessScreen(
// Main content // Main content
if (selectedGameId != null) { if (selectedGameId != null) {
// Set focused game mode - only poll this game, not others // Set focused game mode - only poll this game, not others
// If game isn't in active/spectating maps (e.g. completed game), load from relays
LaunchedEffect(selectedGameId) { LaunchedEffect(selectedGameId) {
viewModel.setFocusedGame(selectedGameId!!) viewModel.setFocusedGame(selectedGameId!!)
if (viewModel.getGameState(selectedGameId!!) == null) {
viewModel.loadGame(selectedGameId!!)
}
} }
// Use stateVersion to ensure recomposition when game state changes // Use stateVersion to ensure recomposition when game state changes
@@ -591,6 +595,7 @@ private fun ChessLobby(
didUserWin = game.didUserWin(userPubkey), didUserWin = game.didUserWin(userPubkey),
isDraw = game.isDraw, isDraw = game.isDraw,
moveCount = game.moveCount, moveCount = game.moveCount,
onClick = { onSelectGame(game.gameId) },
avatar = { avatar = {
UserAvatar( UserAvatar(
userHex = opponentPubkey, userHex = opponentPubkey,
@@ -174,6 +174,8 @@ class DesktopChessViewModelNew(
fun stopSpectating(gameId: String) = logic.stopSpectating(gameId) fun stopSpectating(gameId: String) = logic.stopSpectating(gameId)
fun removeGame(gameId: String) = logic.removeGame(gameId)
// ============================================ // ============================================
// Utility // Utility
// ============================================ // ============================================