refactor impl

This commit is contained in:
nrobi144
2026-01-28 10:45:56 +02:00
parent c2f035acc0
commit e0d0e33408
34 changed files with 5284 additions and 205 deletions
@@ -36,6 +36,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
@@ -82,6 +83,7 @@ import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
import com.vitorpamplona.amethyst.desktop.chess.ChessScreen
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator
import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen
@@ -120,6 +122,8 @@ sealed class DesktopScreen {
object Notifications : DesktopScreen()
object Chess : DesktopScreen()
object MyProfile : DesktopScreen()
data class UserProfile(
@@ -415,6 +419,13 @@ fun MainContent(
onClick = { onScreenChange(DesktopScreen.Notifications) },
)
NavigationRailItem(
icon = { Icon(Icons.Default.Extension, contentDescription = "Chess") },
label = { Text("Chess") },
selected = currentScreen == DesktopScreen.Chess,
onClick = { onScreenChange(DesktopScreen.Chess) },
)
NavigationRailItem(
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
label = { Text("Profile") },
@@ -514,6 +525,14 @@ fun MainContent(
NotificationsScreen(relayManager, account, subscriptionsCoordinator)
}
DesktopScreen.Chess -> {
ChessScreen(
relayManager = relayManager,
account = account,
onBack = { onScreenChange(DesktopScreen.Feed) },
)
}
DesktopScreen.MyProfile -> {
UserProfileScreen(
pubKeyHex = account.pubKeyHex,
@@ -0,0 +1,903 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.chess
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.chess.InteractiveChessBoard
import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog
import com.vitorpamplona.amethyst.commons.data.UserMetadataCache
import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.subscriptions.createChessSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataListSubscription
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
/**
* Desktop chess screen with challenge list and game view
*/
@Composable
fun ChessScreen(
relayManager: DesktopRelayConnectionManager,
account: AccountState.LoggedIn,
onBack: () -> Unit = {},
) {
val scope = rememberCoroutineScope()
val viewModel =
remember(account.pubKeyHex) {
DesktopChessViewModel(account, relayManager, scope)
}
val relayStatuses by relayManager.relayStatuses.collectAsState()
val refreshKey by viewModel.refreshKey.collectAsState()
val isLoading by viewModel.isLoading.collectAsState()
// Subscribe to chess events from relays (re-subscribes when refreshKey changes)
rememberSubscription(relayStatuses, account, refreshKey, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty()) {
createChessSubscription(
relays = configuredRelays,
userPubkey = account.pubKeyHex,
onEvent = { event, _, _, _ ->
viewModel.handleIncomingEvent(event)
},
onEose = { _, _ ->
viewModel.onLoadComplete()
},
)
} else {
null
}
}
// Subscribe to user metadata for pubkeys that need it
val pubkeysNeeded by viewModel.userMetadataCache.pubkeysNeeded.collectAsState()
rememberSubscription(relayStatuses, pubkeysNeeded, relayManager = relayManager) {
val configuredRelays = relayStatuses.keys
if (configuredRelays.isNotEmpty() && pubkeysNeeded.isNotEmpty()) {
createMetadataListSubscription(
relays = configuredRelays,
pubKeys = pubkeysNeeded.toList(),
onEvent = { event, _, _, _ ->
viewModel.handleIncomingEvent(event)
},
)
} else {
null
}
}
val activeGames by viewModel.activeGames.collectAsState()
val challenges by viewModel.challenges.collectAsState()
val completedGames by viewModel.completedGames.collectAsState()
// Observe metadata changes to trigger recomposition
val userMetadata by viewModel.userMetadataCache.metadata.collectAsState()
val selectedGameId by viewModel.selectedGameId.collectAsState()
val error by viewModel.error.collectAsState()
var showNewGameDialog by remember { mutableStateOf(false) }
Column(modifier = Modifier.fillMaxSize()) {
// Header
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (selectedGameId != null) {
IconButton(onClick = { viewModel.selectGame(null) }) {
Icon(Icons.Default.ArrowBack, "Back to list")
}
Spacer(Modifier.width(8.dp))
}
Text(
if (selectedGameId != null) "Live Game" else "Chess",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground,
)
}
if (selectedGameId == null) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Refresh button
IconButton(
onClick = { viewModel.refresh() },
enabled = !isLoading,
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(24.dp),
strokeWidth = 2.dp,
)
} else {
Icon(Icons.Default.Refresh, "Refresh")
}
}
// New Game button
if (!account.isReadOnly) {
Button(onClick = { showNewGameDialog = true }) {
Icon(Icons.Default.Add, "New Game")
Spacer(Modifier.width(8.dp))
Text("New Game")
}
}
}
}
}
// Error display
error?.let { errorMsg ->
Card(
modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer),
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(errorMsg, color = MaterialTheme.colorScheme.onErrorContainer)
OutlinedButton(onClick = { viewModel.clearError() }) {
Text("Dismiss")
}
}
}
}
// Main content
if (selectedGameId != null) {
val gameState = viewModel.getGameState(selectedGameId!!)
if (gameState != null) {
DesktopChessGameLayout(
gameState = gameState,
opponentName = viewModel.userMetadataCache.getDisplayName(gameState.opponentPubkey),
opponentPicture = viewModel.userMetadataCache.getPictureUrl(gameState.opponentPubkey),
onMoveMade = { from, to, _ ->
viewModel.publishMove(gameState.gameId, from, to)
},
onResign = { viewModel.resign(gameState.gameId) },
onOfferDraw = { viewModel.offerDraw(gameState.gameId) },
onAcceptDraw = { viewModel.acceptDraw(gameState.gameId) },
onDeclineDraw = { viewModel.declineDraw(gameState.gameId) },
)
}
} else {
ChessLobby(
challenges = challenges,
activeGames = activeGames,
completedGames = completedGames,
userPubkey = account.pubKeyHex,
isLoading = isLoading,
metadataCache = viewModel.userMetadataCache,
onAcceptChallenge = { viewModel.acceptChallenge(it) },
onSelectGame = { viewModel.selectGame(it) },
)
}
}
// New game dialog
if (showNewGameDialog) {
NewChessGameDialog(
onDismiss = { showNewGameDialog = false },
onCreateGame = { opponentPubkey, color ->
viewModel.createChallenge(opponentPubkey, color)
showNewGameDialog = false
},
)
}
}
/**
* Chess lobby showing challenges and active games
*/
@Composable
private fun ChessLobby(
challenges: List<LiveChessGameChallengeEvent>,
activeGames: Map<String, com.vitorpamplona.quartz.nip64Chess.LiveChessGameState>,
completedGames: List<CompletedGame>,
isLoading: Boolean,
userPubkey: String,
metadataCache: UserMetadataCache,
onAcceptChallenge: (LiveChessGameChallengeEvent) -> Unit,
onSelectGame: (String) -> Unit,
) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
// Active games section
if (activeGames.isNotEmpty()) {
item {
Text(
"Active Games",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp),
)
}
items(activeGames.entries.toList(), key = { it.key }) { (gameId, state) ->
ActiveGameCard(
gameId = gameId,
opponentPubkey = state.opponentPubkey,
opponentName = metadataCache.getDisplayName(state.opponentPubkey),
opponentPicture = metadataCache.getPictureUrl(state.opponentPubkey),
isYourTurn = state.isPlayerTurn(),
onClick = { onSelectGame(gameId) },
)
}
}
// Incoming challenges
val incomingChallenges = challenges.filter { it.opponentPubkey() == userPubkey }
if (incomingChallenges.isNotEmpty()) {
item {
Spacer(Modifier.height(16.dp))
Text(
"Incoming Challenges",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp),
)
}
items(incomingChallenges, key = { it.id }) { challenge ->
ChallengeCard(
challenge = challenge,
challengerName = metadataCache.getDisplayName(challenge.pubKey),
challengerPicture = metadataCache.getPictureUrl(challenge.pubKey),
isIncoming = true,
onAccept = { onAcceptChallenge(challenge) },
)
}
}
// User's outgoing challenges
val outgoingChallenges = challenges.filter { it.pubKey == userPubkey }
if (outgoingChallenges.isNotEmpty()) {
item {
Spacer(Modifier.height(16.dp))
Text(
"Your Challenges",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp),
)
}
items(outgoingChallenges, key = { it.id }) { challenge ->
val opponentPubkey = challenge.opponentPubkey()
OutgoingChallengeCard(
challenge = challenge,
opponentName = opponentPubkey?.let { metadataCache.getDisplayName(it) },
opponentPicture = opponentPubkey?.let { metadataCache.getPictureUrl(it) },
)
}
}
// Open challenges from others
val openChallenges = challenges.filter { it.opponentPubkey() == null && it.pubKey != userPubkey }
if (openChallenges.isNotEmpty()) {
item {
Spacer(Modifier.height(16.dp))
Text(
"Open Challenges",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp),
)
}
items(openChallenges, key = { it.id }) { challenge ->
ChallengeCard(
challenge = challenge,
challengerName = metadataCache.getDisplayName(challenge.pubKey),
challengerPicture = metadataCache.getPictureUrl(challenge.pubKey),
isIncoming = false,
onAccept = { onAcceptChallenge(challenge) },
)
}
}
// Completed games history
if (completedGames.isNotEmpty()) {
item {
Spacer(Modifier.height(16.dp))
Text(
"Recent Games",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp),
)
}
items(completedGames.take(10), key = { it.gameId }) { game ->
CompletedGameCard(
game = game,
userPubkey = userPubkey,
opponentName = metadataCache.getDisplayName(game.opponentPubkey),
opponentPicture = metadataCache.getPictureUrl(game.opponentPubkey),
)
}
}
// Empty state or loading
if (activeGames.isEmpty() && challenges.isEmpty()) {
item {
Column(
modifier = Modifier.fillMaxWidth().padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
if (isLoading) {
CircularProgressIndicator()
Spacer(Modifier.height(16.dp))
Text(
"Loading games from relays...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Text(
"No games or challenges",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
Text(
"Create a new game or refresh to load from relays",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
}
}
@Composable
private fun ActiveGameCard(
gameId: String,
opponentPubkey: String,
opponentName: String,
opponentPicture: String?,
isYourTurn: Boolean,
onClick: () -> Unit,
) {
Card(
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick),
border =
if (isYourTurn) {
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
} else {
null
},
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
UserAvatar(
userHex = opponentPubkey,
pictureUrl = opponentPicture,
size = 40.dp,
)
Column {
Text(
"vs $opponentName",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
"Game: ${gameId.take(12)}...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
if (isYourTurn) "Your turn" else "Waiting...",
style = MaterialTheme.typography.bodyMedium,
color = if (isYourTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal,
)
}
}
}
@Composable
private fun OutgoingChallengeCard(
challenge: LiveChessGameChallengeEvent,
opponentName: String?,
opponentPicture: String?,
) {
val opponentPubkey = challenge.opponentPubkey()
val playerColor = challenge.playerColor()
Card(
modifier = Modifier.fillMaxWidth(),
border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
if (opponentPubkey != null) {
UserAvatar(
userHex = opponentPubkey,
pictureUrl = opponentPicture,
size = 40.dp,
)
}
Column {
Text(
if (opponentName != null) {
"Challenge to $opponentName"
} else {
"Open challenge (awaiting opponent)"
},
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
"You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
"Waiting...",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@Composable
private fun ChallengeCard(
challenge: LiveChessGameChallengeEvent,
challengerName: String,
challengerPicture: String?,
isIncoming: Boolean,
onAccept: () -> Unit,
) {
val borderColor =
if (isIncoming) {
Color(0xFFFF9800) // Orange for incoming
} else {
Color(0xFF4CAF50) // Green for open
}
Card(
modifier = Modifier.fillMaxWidth(),
border = BorderStroke(2.dp, borderColor),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
UserAvatar(
userHex = challenge.pubKey,
pictureUrl = challengerPicture,
size = 40.dp,
)
Column {
Text(
if (isIncoming) "Challenge from $challengerName" else "Open challenge by $challengerName",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
val playerColor = challenge.playerColor()
Text(
"Challenger plays ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Button(onClick = onAccept) {
Text("Accept")
}
}
}
}
@Composable
private fun CompletedGameCard(
game: CompletedGame,
userPubkey: String,
opponentName: String,
opponentPicture: String?,
) {
val resultColor =
when (game.didWin(userPubkey)) {
true -> Color(0xFF4CAF50) // Green for win
false -> Color(0xFFF44336) // Red for loss
null -> Color(0xFF9E9E9E) // Gray for draw
}
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
UserAvatar(
userHex = game.opponentPubkey,
pictureUrl = opponentPicture,
size = 40.dp,
)
Column {
Text(
"vs $opponentName",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
Text(
"${game.moveCount} moves - ${game.termination}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
Text(
game.resultText(userPubkey),
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Bold,
color = resultColor,
)
}
}
}
/**
* Desktop-optimized chess game layout with board on left, info/controls on right
*/
@Composable
private fun DesktopChessGameLayout(
gameState: com.vitorpamplona.quartz.nip64Chess.LiveChessGameState,
opponentName: String,
opponentPicture: String?,
onMoveMade: (from: String, to: String, san: String) -> Unit,
onResign: () -> Unit,
onOfferDraw: () -> Unit,
onAcceptDraw: () -> Unit,
onDeclineDraw: () -> Unit,
) {
// Collect state flows to trigger recomposition on changes
val currentPosition by gameState.currentPosition.collectAsState()
val moveHistory by gameState.moveHistory.collectAsState()
val pendingDrawOffer by gameState.pendingDrawOffer.collectAsState()
val engine = gameState.engine
val playerColor = gameState.playerColor
val gameId = gameState.gameId
val opponentPubkey = gameState.opponentPubkey
val hasOpponentDrawOffer = pendingDrawOffer == opponentPubkey
val hasOurDrawOffer = pendingDrawOffer == gameState.playerPubkey
Row(
modifier = Modifier.fillMaxSize().padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(24.dp),
) {
// Left side: Chess board
Box(
modifier = Modifier.fillMaxHeight(),
contentAlignment = Alignment.Center,
) {
InteractiveChessBoard(
engine = engine,
boardSize = 520.dp,
flipped = playerColor == com.vitorpamplona.quartz.nip64Chess.Color.BLACK,
playerColor = playerColor,
onMoveMade = onMoveMade,
)
}
// Right side: Game info, moves, controls
Column(
modifier =
Modifier
.width(300.dp)
.fillMaxHeight()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
// Game info card
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Game Info",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
UserAvatar(
userHex = opponentPubkey,
pictureUrl = opponentPicture,
size = 48.dp,
)
Column {
Text(
"vs $opponentName",
style = MaterialTheme.typography.bodyLarge,
)
Text(
"You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Use currentPosition to derive turn (triggers recomposition on move)
val currentTurn = currentPosition.activeColor
val isYourTurn = currentTurn == playerColor
Text(
if (isYourTurn) "Your turn" else "Opponent's turn",
style = MaterialTheme.typography.bodyMedium,
fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal,
color =
if (isYourTurn) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
}
}
// Move history card
if (moveHistory.isNotEmpty()) {
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Move History",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
// Format moves as numbered pairs
val moveText =
moveHistory
.chunked(2)
.mapIndexed { index, pair ->
"${index + 1}. ${pair.joinToString(" ")}"
}.joinToString("\n")
Text(
moveText,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Draw offer notification (if opponent offered)
if (hasOpponentDrawOffer) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = Color(0xFFFF9800).copy(alpha = 0.15f),
),
border = BorderStroke(2.dp, Color(0xFFFF9800)),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Draw Offered",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = Color(0xFFFF9800),
)
Text(
"$opponentName has offered a draw",
style = MaterialTheme.typography.bodyMedium,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = onAcceptDraw,
modifier = Modifier.weight(1f),
) {
Text("Accept")
}
OutlinedButton(
onClick = onDeclineDraw,
modifier = Modifier.weight(1f),
) {
Text("Decline")
}
}
}
}
}
// Our draw offer pending notification
if (hasOurDrawOffer) {
Card(
modifier = Modifier.fillMaxWidth(),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.5f),
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
Text(
"Draw offer sent",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium,
)
Text(
"Waiting for $opponentName to respond...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Game controls card
Card(
modifier = Modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
"Actions",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
OutlinedButton(
onClick = onOfferDraw,
modifier = Modifier.weight(1f),
enabled = !hasOurDrawOffer && !hasOpponentDrawOffer,
) {
Text(if (hasOurDrawOffer) "Offer Sent" else "Offer Draw")
}
OutlinedButton(
onClick = onResign,
modifier = Modifier.weight(1f),
) {
Text("Resign")
}
}
}
}
// Game ID (small footer)
Text(
"Game: ${gameId.take(16)}...",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,813 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.chess
import com.vitorpamplona.amethyst.commons.data.UserMetadataCache
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.GameResult
import com.vitorpamplona.quartz.nip64Chess.GameTermination
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.util.UUID
/**
* Desktop ViewModel for managing chess game state and event publishing.
* Adapts the Android ChessViewModel patterns for Desktop's relay architecture.
*/
class DesktopChessViewModel(
private val account: AccountState.LoggedIn,
private val relayManager: DesktopRelayConnectionManager,
private val scope: CoroutineScope,
) {
companion object {
const val CHALLENGE_EXPIRY_SECONDS = 24 * 60 * 60L
const val MAX_RETRIES = 3
const val RETRY_DELAY_MS = 1000L
}
// Active games being played
private val _activeGames = MutableStateFlow<Map<String, LiveChessGameState>>(emptyMap())
val activeGames: StateFlow<Map<String, LiveChessGameState>> = _activeGames.asStateFlow()
// Pending challenges
private val _challenges = MutableStateFlow<List<LiveChessGameChallengeEvent>>(emptyList())
val challenges: StateFlow<List<LiveChessGameChallengeEvent>> = _challenges.asStateFlow()
// Badge count
private val _badgeCount = MutableStateFlow(0)
val badgeCount: StateFlow<Int> = _badgeCount.asStateFlow()
// Currently selected game
private val _selectedGameId = MutableStateFlow<String?>(null)
val selectedGameId: StateFlow<String?> = _selectedGameId.asStateFlow()
// Error state
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
// Loading state
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
// Refresh key - incrementing this triggers re-subscription
private val _refreshKey = MutableStateFlow(0)
val refreshKey: StateFlow<Int> = _refreshKey.asStateFlow()
// Completed games history
private val _completedGames = MutableStateFlow<List<CompletedGame>>(emptyList())
val completedGames: StateFlow<List<CompletedGame>> = _completedGames.asStateFlow()
// Shared user metadata cache
val userMetadataCache = UserMetadataCache()
// Pending moves waiting for game to be created (gameId -> list of (san, fen, moveNumber))
private val pendingMoves = mutableMapOf<String, MutableList<Triple<String, String, Int?>>>()
// Challenge events indexed by gameId for lookup during accept processing
private val challengesByGameId = mutableMapOf<String, LiveChessGameChallengeEvent>()
// Pending accept events waiting for challenge to arrive (gameId -> accept event)
private val pendingAccepts = mutableMapOf<String, LiveChessGameAcceptEvent>()
// Track event IDs we've already processed to avoid duplicates
private val processedEventIds = mutableSetOf<String>()
// Track games currently being created to prevent race conditions
private val gamesBeingCreated = mutableSetOf<String>()
/**
* Process incoming chess event from relay subscription
*/
fun handleIncomingEvent(event: Event) {
// Skip already processed events
if (processedEventIds.contains(event.id)) return
processedEventIds.add(event.id)
// Check by kind since events may not be cast to specific types
when (event.kind) {
MetadataEvent.KIND -> {
handleMetadataEvent(event)
return
}
LiveChessGameChallengeEvent.KIND -> {
if (event is LiveChessGameChallengeEvent) {
handleNewChallenge(event)
} else {
// Manually parse if not already the right type
val challenge =
LiveChessGameChallengeEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
handleNewChallenge(challenge)
}
}
LiveChessGameAcceptEvent.KIND -> {
if (event is LiveChessGameAcceptEvent) {
handleGameAccepted(event)
} else {
val accept =
LiveChessGameAcceptEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
handleGameAccepted(accept)
}
}
LiveChessMoveEvent.KIND -> {
if (event is LiveChessMoveEvent) {
handleIncomingMove(event)
} else {
val move =
LiveChessMoveEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
handleIncomingMove(move)
}
}
LiveChessGameEndEvent.KIND -> {
if (event is LiveChessGameEndEvent) {
handleGameEnded(event)
} else {
val end =
LiveChessGameEndEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
handleGameEnded(end)
}
}
LiveChessDrawOfferEvent.KIND -> {
if (event is LiveChessDrawOfferEvent) {
handleDrawOffer(event)
} else {
val drawOffer =
LiveChessDrawOfferEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
handleDrawOffer(drawOffer)
}
}
}
}
private fun handleMetadataEvent(event: Event) {
try {
val metadataEvent =
MetadataEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
val metadata = metadataEvent.contactMetaData()
if (metadata != null) {
userMetadataCache.put(event.pubKey, metadata)
}
} catch (e: Exception) {
// Ignore parse errors
}
}
private fun handleIncomingMove(event: LiveChessMoveEvent) {
// Don't process our own moves
if (event.pubKey == account.pubKeyHex) return
val gameId = event.gameId() ?: return
val san = event.san() ?: return
val fen = event.fen() ?: return
val moveNumber = event.moveNumber()
val gameState = _activeGames.value[gameId]
if (gameState == null) {
// Game doesn't exist yet - buffer the move for later
val moveList = pendingMoves.getOrPut(gameId) { mutableListOf() }
moveList.add(Triple(san, fen, moveNumber))
return
}
if (event.pubKey != gameState.opponentPubkey) return
gameState.applyOpponentMove(san, fen, moveNumber)
updateBadgeCount()
}
private fun handleGameAccepted(event: LiveChessGameAcceptEvent) {
val gameId = event.gameId() ?: return
// Skip if game already exists
if (_activeGames.value.containsKey(gameId)) return
val challengerPubkey = event.challengerPubkey() ?: return
val accepterPubkey = event.pubKey
// Check if we have the challenge event for color info
val challengeEvent = challengesByGameId[gameId]
if (challengeEvent == null) {
// Challenge hasn't arrived yet - buffer this accept for later
if (challengerPubkey == account.pubKeyHex || accepterPubkey == account.pubKeyHex) {
pendingAccepts[gameId] = event
}
return
}
// Handle if user is the challenger or accepter
if (challengerPubkey == account.pubKeyHex) {
startGameFromAcceptance(event, isChallenger = true)
} else if (accepterPubkey == account.pubKeyHex) {
startGameFromAcceptance(event, isChallenger = false)
}
}
private fun handleGameEnded(event: LiveChessGameEndEvent) {
val gameId = event.gameId() ?: return
// Store game in history before removing
val gameState = _activeGames.value[gameId]
if (gameState != null) {
val completedGame =
CompletedGame(
gameId = gameId,
opponentPubkey = gameState.opponentPubkey,
playerColor = gameState.playerColor,
result = event.result() ?: "?",
termination = event.termination() ?: "unknown",
winnerPubkey = event.winnerPubkey(),
completedAt = event.createdAt,
moveCount = gameState.moveHistory.value.size,
)
_completedGames.value = listOf(completedGame) + _completedGames.value
}
// Remove from active games
if (_activeGames.value.containsKey(gameId)) {
_activeGames.value = _activeGames.value - gameId
}
// Clean up challenge references
_challenges.value = _challenges.value.filter { it.gameId() != gameId }
challengesByGameId.remove(gameId)
pendingAccepts.remove(gameId)
pendingMoves.remove(gameId)
updateBadgeCount()
}
private fun handleDrawOffer(event: LiveChessDrawOfferEvent) {
// Only process draw offers from opponent
if (event.pubKey == account.pubKeyHex) return
val gameId = event.gameId() ?: return
val gameState = _activeGames.value[gameId] ?: return
// Verify this is from our opponent in this game
if (event.pubKey != gameState.opponentPubkey) return
// Pass to game state to track
gameState.receiveDrawOffer(event.pubKey)
updateBadgeCount()
}
private fun handleNewChallenge(event: LiveChessGameChallengeEvent) {
val gameId = event.gameId() ?: return
val opponentPubkey = event.opponentPubkey()
val isUserChallenge = event.pubKey == account.pubKeyHex
val isOpenChallenge = opponentPubkey == null
val isDirectedAtUser = opponentPubkey == account.pubKeyHex
// Request metadata for challenger
userMetadataCache.request(event.pubKey)
opponentPubkey?.let { userMetadataCache.request(it) }
// Always store in lookup map for accept event processing (even if game exists)
challengesByGameId[gameId] = event
// Skip if game already exists (challenge was already accepted)
if (_activeGames.value.containsKey(gameId)) return
// Check if there's a pending accept waiting for this challenge
val pendingAccept = pendingAccepts.remove(gameId)
if (pendingAccept != null) {
// Now we can properly process the accept
val isChallenger = event.pubKey == account.pubKeyHex
val isAccepter = pendingAccept.pubKey == account.pubKeyHex
if (isChallenger || isAccepter) {
startGameFromAcceptance(pendingAccept, isChallenger = isChallenger)
return // Don't add to challenges list since game is starting
}
}
// Show challenges that are:
// - Created by user (their outgoing challenges)
// - Open challenges (anyone can accept)
// - Directed at user (incoming challenges)
if (isUserChallenge || isOpenChallenge || isDirectedAtUser) {
val currentChallenges = _challenges.value.toMutableList()
// Deduplicate by both event ID and game ID to prevent duplicates from relay broadcasts
val isDuplicateEvent = currentChallenges.any { it.id == event.id }
val isDuplicateGame = currentChallenges.any { it.gameId() == gameId }
if (!isDuplicateEvent && !isDuplicateGame) {
currentChallenges.add(event)
_challenges.value = currentChallenges
updateBadgeCount()
}
}
}
/**
* Create a new chess challenge
*/
fun createChallenge(
opponentPubkey: String? = null,
playerColor: Color = Color.WHITE,
timeControl: String? = null,
) {
val gameId = generateGameId()
scope.launch(Dispatchers.IO) {
val success =
retryWithBackoff {
val template =
LiveChessGameChallengeEvent.build(
gameId = gameId,
playerColor = playerColor,
opponentPubkey = opponentPubkey,
timeControl = timeControl,
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
}
if (success) {
_error.value = null
} else {
_error.value = "Failed to create challenge after $MAX_RETRIES attempts"
}
}
}
/**
* Accept a chess challenge
*/
fun acceptChallenge(challengeEvent: LiveChessGameChallengeEvent) {
val gameId = challengeEvent.gameId() ?: return
val challengerPubkey = challengeEvent.pubKey
val challengerColor = challengeEvent.playerColor() ?: Color.WHITE
val playerColor = challengerColor.opposite()
// Store in lookup map for consistent access
challengesByGameId[gameId] = challengeEvent
// Mark as being created to prevent duplicate from relay echo
if (!gamesBeingCreated.add(gameId)) return // Already being created
scope.launch(Dispatchers.IO) {
val success =
retryWithBackoff {
val template =
LiveChessGameAcceptEvent.build(
gameId = gameId,
challengeEventId = challengeEvent.id,
challengerPubkey = challengerPubkey,
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
}
if (success) {
// Check if game was already created by relay echo while we were broadcasting
if (_activeGames.value.containsKey(gameId)) {
gamesBeingCreated.remove(gameId)
_selectedGameId.value = gameId
_challenges.value = _challenges.value.filter { it.id != challengeEvent.id }
_error.value = null
return@launch
}
val engine = ChessEngine()
engine.reset()
val gameState =
LiveChessGameState(
gameId = gameId,
playerPubkey = account.pubKeyHex,
opponentPubkey = challengerPubkey,
playerColor = playerColor,
engine = engine,
)
_activeGames.value = _activeGames.value + (gameId to gameState)
gamesBeingCreated.remove(gameId)
_selectedGameId.value = gameId
_challenges.value = _challenges.value.filter { it.id != challengeEvent.id }
_error.value = null
} else {
gamesBeingCreated.remove(gameId)
_error.value = "Failed to accept challenge"
}
}
}
private fun startGameFromAcceptance(
acceptEvent: LiveChessGameAcceptEvent,
isChallenger: Boolean,
) {
scope.launch(Dispatchers.IO) {
val gameId = acceptEvent.gameId() ?: return@launch
val challengerPubkey = acceptEvent.challengerPubkey() ?: return@launch
val accepterPubkey = acceptEvent.pubKey
// Skip if game already exists or is being created
if (_activeGames.value.containsKey(gameId)) return@launch
if (!gamesBeingCreated.add(gameId)) return@launch // Returns false if already present
val opponentPubkey: String
val playerColor: Color
// Use challengesByGameId for reliable lookup (not affected by UI filtering)
val challengeEvent = challengesByGameId[gameId]
if (isChallenger) {
// User is challenger, opponent is accepter
opponentPubkey = accepterPubkey
playerColor = challengeEvent?.playerColor() ?: Color.WHITE
} else {
// User is accepter, opponent is challenger
opponentPubkey = challengerPubkey
// Accepter gets opposite color of challenger
val challengerColor = challengeEvent?.playerColor() ?: Color.WHITE
playerColor = challengerColor.opposite()
}
val engine = ChessEngine()
engine.reset()
val gameState =
LiveChessGameState(
gameId = gameId,
playerPubkey = account.pubKeyHex,
opponentPubkey = opponentPubkey,
playerColor = playerColor,
engine = engine,
)
_activeGames.value = _activeGames.value + (gameId to gameState)
_challenges.value = _challenges.value.filter { it.gameId() != gameId }
gamesBeingCreated.remove(gameId)
// Apply any pending moves that arrived before the game was created
applyPendingMoves(gameId, gameState)
}
}
private fun applyPendingMoves(
gameId: String,
gameState: LiveChessGameState,
) {
val moves = pendingMoves.remove(gameId) ?: return
// Sort by move number if available, then apply in order
val sortedMoves = moves.sortedBy { it.third ?: Int.MAX_VALUE }
for ((san, fen, moveNumber) in sortedMoves) {
gameState.applyOpponentMove(san, fen, moveNumber)
}
updateBadgeCount()
}
/**
* Make and publish a move
*/
fun publishMove(
gameId: String,
from: String,
to: String,
) {
val gameState = _activeGames.value[gameId] ?: return
val moveResult = gameState.makeMove(from, to)
if (moveResult != null) {
publishMoveEvent(gameId, moveResult)
}
}
private fun publishMoveEvent(
gameId: String,
moveEvent: ChessMoveEvent,
) {
scope.launch(Dispatchers.IO) {
val success =
retryWithBackoff {
val template =
LiveChessMoveEvent.build(
gameId = moveEvent.gameId,
moveNumber = moveEvent.moveNumber,
san = moveEvent.san,
fen = moveEvent.fen,
opponentPubkey = moveEvent.opponentPubkey,
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
}
if (!success) {
_error.value = "Failed to publish move"
}
}
}
/**
* Resign from a game
*/
fun resign(gameId: String) {
val gameState = _activeGames.value[gameId] ?: return
scope.launch(Dispatchers.IO) {
val endData = gameState.resign()
val success =
retryWithBackoff {
val template =
LiveChessGameEndEvent.build(
gameId = endData.gameId,
result = endData.result,
termination = endData.termination,
winnerPubkey = endData.winnerPubkey,
opponentPubkey = endData.opponentPubkey,
pgn = endData.pgn ?: "",
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
}
if (success) {
// Store in history
val completedGame =
CompletedGame(
gameId = gameId,
opponentPubkey = gameState.opponentPubkey,
playerColor = gameState.playerColor,
result = endData.result.notation,
termination = endData.termination.name.lowercase(),
winnerPubkey = endData.winnerPubkey,
completedAt = TimeUtils.now(),
moveCount = gameState.moveHistory.value.size,
)
_completedGames.value = listOf(completedGame) + _completedGames.value
_activeGames.value = _activeGames.value - gameId
_error.value = null
} else {
_error.value = "Failed to resign"
}
}
}
/**
* Offer draw - sends a draw offer event that opponent can accept or decline
*/
fun offerDraw(gameId: String) {
val gameState = _activeGames.value[gameId] ?: return
scope.launch(Dispatchers.IO) {
val drawOffer = gameState.offerDraw()
val success =
retryWithBackoff {
val template =
LiveChessDrawOfferEvent.build(
gameId = drawOffer.gameId,
opponentPubkey = drawOffer.opponentPubkey,
message = drawOffer.message ?: "",
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
}
if (!success) {
_error.value = "Failed to offer draw"
}
}
}
/**
* Accept opponent's draw offer
*/
fun acceptDraw(gameId: String) {
val gameState = _activeGames.value[gameId] ?: return
scope.launch(Dispatchers.IO) {
val endData = gameState.acceptDraw()
if (endData == null) {
_error.value = "No draw offer to accept"
return@launch
}
val success =
retryWithBackoff {
val template =
LiveChessGameEndEvent.build(
gameId = endData.gameId,
result = endData.result,
termination = endData.termination,
winnerPubkey = endData.winnerPubkey,
opponentPubkey = endData.opponentPubkey,
pgn = endData.pgn ?: "",
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
}
if (success) {
// Store in history and remove from active
val completedGame =
CompletedGame(
gameId = gameId,
opponentPubkey = gameState.opponentPubkey,
playerColor = gameState.playerColor,
result = GameResult.DRAW.notation,
termination = GameTermination.DRAW_AGREEMENT.name.lowercase(),
winnerPubkey = null,
completedAt = TimeUtils.now(),
moveCount = gameState.moveHistory.value.size,
)
_completedGames.value = listOf(completedGame) + _completedGames.value
_activeGames.value = _activeGames.value - gameId
_error.value = null
} else {
_error.value = "Failed to accept draw"
}
}
}
/**
* Decline opponent's draw offer
*/
fun declineDraw(gameId: String) {
val gameState = _activeGames.value[gameId] ?: return
gameState.declineDraw()
}
fun selectGame(gameId: String?) {
_selectedGameId.value = gameId
}
fun getGameState(gameId: String): LiveChessGameState? = _activeGames.value[gameId]
fun clearError() {
_error.value = null
}
/**
* Trigger a refresh of chess data from relays.
* Preserves existing game state (including local moves) while refreshing challenges.
*/
fun refresh() {
_isLoading.value = true
// Clear challenges - they'll be rebuilt from relay events
_challenges.value = emptyList()
// Clear event tracking caches to allow re-processing
processedEventIds.clear()
challengesByGameId.clear()
pendingAccepts.clear()
pendingMoves.clear()
gamesBeingCreated.clear()
// DON'T clear _activeGames - preserve existing game state with local moves
// Incoming events will be deduplicated by LiveChessGameState.receivedMoveNumbers
_refreshKey.value++
}
/**
* Called when EOSE is received from relay, indicating initial load complete
*/
fun onLoadComplete() {
_isLoading.value = false
}
private fun updateBadgeCount() {
val incomingChallenges = _challenges.value.count { it.opponentPubkey() == account.pubKeyHex }
val yourTurnGames = _activeGames.value.values.count { it.isPlayerTurn() }
_badgeCount.value = incomingChallenges + yourTurnGames
}
private suspend fun retryWithBackoff(operation: suspend () -> Unit): Boolean {
var attempt = 0
while (attempt < MAX_RETRIES) {
try {
operation()
return true
} catch (e: Exception) {
attempt++
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
}
}
return false
}
private fun generateGameId(): String {
val timestamp = TimeUtils.now()
val random = UUID.randomUUID().toString().take(8)
return "chess-$timestamp-$random"
}
}
/**
* Represents a completed chess game for history display
*/
data class CompletedGame(
val gameId: String,
val opponentPubkey: String,
val playerColor: Color,
val result: String,
val termination: String,
val winnerPubkey: String?,
val completedAt: Long,
val moveCount: Int,
) {
fun didWin(playerPubkey: String): Boolean? =
when {
result == "1/2-1/2" -> null // Draw
winnerPubkey == playerPubkey -> true
winnerPubkey != null -> false
else -> null
}
fun resultText(playerPubkey: String): String =
when (didWin(playerPubkey)) {
true -> "Won"
false -> "Lost"
null -> "Draw"
}
}
@@ -315,3 +315,36 @@ fun createFollowingLongFormFeedSubscription(
onEose = onEose,
)
}
/**
* Creates a subscription config for chess events (challenges, moves, etc.).
* Subscribes to:
* - Open challenges (kind 30064)
* - Challenges directed at the user
* - Events in games the user is playing
*
* @param userPubkey The user's public key to filter relevant games
* @param limit Maximum number of events to request
*/
fun createChessSubscription(
relays: Set<NormalizedRelayUrl>,
userPubkey: String,
limit: Int = 100,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig =
SubscriptionConfig(
subId = generateSubId("chess-${userPubkey.take(8)}"),
filters =
listOf(
// Open challenges and challenges to user
FilterBuilders.chessOpenChallenges(limit = limit),
// Events where user is tagged (challenges to them, moves in their games)
FilterBuilders.chessEventsForUser(userPubkey, limit = limit),
// User's own events (their challenges, moves)
FilterBuilders.chessEventsByUser(userPubkey, limit = limit),
),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
@@ -200,6 +200,103 @@ object FilterBuilders {
ids = ids,
)
/**
* Creates a filter for chess game challenges (kind 30064).
* Includes open challenges (no opponent) and challenges directed at a specific user.
*
* @param userPubkey The user's public key to filter challenges for
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @return Filter for chess challenges
*/
fun chessChallengesToUser(
userPubkey: String,
limit: Int? = 50,
since: Long? = null,
): Filter =
Filter(
kinds = listOf(30064), // LiveChessGameChallengeEvent.KIND
tags = mapOf("p" to listOf(userPubkey)),
limit = limit,
since = since,
)
/**
* Creates a filter for open chess challenges (kind 30064, no specific opponent).
*
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @return Filter for open chess challenges
*/
fun chessOpenChallenges(
limit: Int? = 50,
since: Long? = null,
): Filter =
Filter(
kinds = listOf(30064), // LiveChessGameChallengeEvent.KIND
limit = limit,
since = since,
)
/**
* Creates a filter for all chess events (challenges, accepts, moves, ends, draw offers).
* Useful for loading the chess lobby.
*
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @return Filter for all chess events
*/
fun chessAllEvents(
limit: Int? = 100,
since: Long? = null,
): Filter =
Filter(
kinds = listOf(30064, 30065, 30066, 30067, 30068),
limit = limit,
since = since,
)
/**
* Creates a filter for chess events involving a specific user.
* Includes challenges to/from user, moves in their games, draw offers, etc.
*
* @param userPubkey The user's public key
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @return Filter for chess events involving the user
*/
fun chessEventsForUser(
userPubkey: String,
limit: Int? = 100,
since: Long? = null,
): Filter =
Filter(
kinds = listOf(30064, 30065, 30066, 30067, 30068),
tags = mapOf("p" to listOf(userPubkey)),
limit = limit,
since = since,
)
/**
* Creates a filter for chess events by the user (their own events).
*
* @param userPubkey The user's public key
* @param limit Maximum number of events to request
* @param since Timestamp for events with publication time ≥ this value
* @return Filter for chess events by the user
*/
fun chessEventsByUser(
userPubkey: String,
limit: Int? = 100,
since: Long? = null,
): Filter =
Filter(
kinds = listOf(30064, 30065, 30066, 30067, 30068),
authors = listOf(userPubkey),
limit = limit,
since = since,
)
/**
* Creates a filter for events tagged with specific p-tags (mentioning users).
*
@@ -71,6 +71,27 @@ fun createBatchMetadataSubscription(
)
}
/**
* Creates a subscription config for multiple user metadata (kind 0).
* Useful for fetching metadata for a batch of users at once.
*/
fun createMetadataListSubscription(
relays: Set<NormalizedRelayUrl>,
pubKeys: List<String>,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (pubKeys.isEmpty()) return null
return SubscriptionConfig(
subId = generateSubId("meta-batch-${pubKeys.hashCode()}"),
filters = listOf(FilterBuilders.userMetadataMultiple(pubKeys)),
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Creates a subscription config for user posts (kind 1).
*/
@@ -584,7 +584,8 @@ fun FeedScreen(
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(events, key = { it.id }) { event ->
// Use distinctBy to prevent duplicate key crashes from events with same ID
items(events.distinctBy { it.id }, key = { it.id }) { event ->
FeedNoteCard(
event = event,
relayManager = relayManager,
@@ -226,7 +226,7 @@ fun NotificationsScreen(
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(notifications, key = { it.event.id }) { notification ->
items(notifications.distinctBy { it.event.id }, key = { it.event.id }) { notification ->
NotificationCard(notification)
}
}
@@ -423,7 +423,7 @@ fun ThreadScreen(
}
// Reply notes with level indicators
items(replyEvents, key = { it.id }) { event ->
items(replyEvents.distinctBy { it.id }, key = { it.id }) { event ->
val level = calculateLevel(event)
Column(
@@ -569,7 +569,7 @@ fun UserProfileScreen(
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
items(events, key = { it.id }) { event ->
items(events.distinctBy { it.id }, key = { it.id }) { event ->
FeedNoteCard(
event = event,
relayManager = relayManager,