refactor impl
This commit is contained in:
@@ -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,
|
||||
|
||||
+903
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+813
@@ -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"
|
||||
}
|
||||
}
|
||||
+33
@@ -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,
|
||||
)
|
||||
|
||||
+97
@@ -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).
|
||||
*
|
||||
|
||||
+21
@@ -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,
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user