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
@@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilter
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler
@@ -79,6 +80,7 @@ class RelaySubscriptionsCoordinator(
val hashtags = HashtagFilterAssembler(client)
val geohashes = GeoHashFilterAssembler(client)
val followPacks = FollowPackFeedFilterAssembler(client)
val chess = ChessFilterAssembler(client)
// active when sending zaps via NWC
val nwc = NWCPaymentFilterAssembler(client)
@@ -101,6 +103,7 @@ class RelaySubscriptionsCoordinator(
profile,
hashtags,
geohashes,
chess,
nwc,
)
@@ -75,6 +75,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28P
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen
import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen
@@ -138,6 +139,7 @@ fun AppNavigation(
composable<Route.Video> { VideoScreen(accountViewModel, nav) }
composable<Route.Discover> { DiscoverScreen(accountViewModel, nav) }
composable<Route.Notification> { NotificationScreen(accountViewModel, nav) }
composable<Route.Chess> { ChessLobbyScreen(accountViewModel, nav) }
composableFromEnd<Route.Lists> { ListOfPeopleListsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.MyPeopleListView> { PeopleListScreen(it.dTag, accountViewModel, nav) }
@@ -471,6 +471,15 @@ fun ListContent(
route = Route.Drafts,
)
NavigationRow(
title = R.string.route_chess,
icon = R.drawable.ic_chess,
iconReference = 1,
tint = MaterialTheme.colorScheme.onBackground,
nav = nav,
route = Route.Chess,
)
IconRowRelays(
accountViewModel = accountViewModel,
onClick = {
@@ -40,6 +40,8 @@ sealed class Route {
@Serializable object Notification : Route()
@Serializable object Chess : Route()
@Serializable object Search : Route()
@Serializable object SecurityFilters : Route()
@@ -316,6 +318,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? {
dest.hasRoute<Route.Video>() -> entry.toRoute<Route.Video>()
dest.hasRoute<Route.Discover>() -> entry.toRoute<Route.Discover>()
dest.hasRoute<Route.Notification>() -> entry.toRoute<Route.Notification>()
dest.hasRoute<Route.Chess>() -> entry.toRoute<Route.Chess>()
dest.hasRoute<Route.Search>() -> entry.toRoute<Route.Search>()
dest.hasRoute<Route.SecurityFilters>() -> entry.toRoute<Route.SecurityFilters>()
dest.hasRoute<Route.PrivacyOptions>() -> entry.toRoute<Route.PrivacyOptions>()
@@ -144,7 +144,7 @@ class TopNavFilterState(
),
)
val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, chessFollow, muteListFollow)
val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow)
fun mergePeopleLists(
peopleLists: List<AddressableNote>,
@@ -258,7 +258,7 @@ class TopNavFilterState(
checkNotInMainThread()
emit(
listOf(
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, chessFollow),
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow),
myLivePeopleListsFlow,
myLiveKind3FollowsFlow,
listOf(muteListFollow),
@@ -274,7 +274,7 @@ class TopNavFilterState(
checkNotInMainThread()
emit(
listOf(
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, chessFollow),
listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow),
myLivePeopleListsFlow,
listOf(muteListFollow),
).flatten().toImmutableList(),
@@ -413,9 +413,6 @@ val DEFAULT_FEED_KINDS =
WikiNoteEvent.KIND,
NipTextEvent.KIND,
InteractiveStoryPrologueEvent.KIND,
ChessGameEvent.KIND,
LiveChessGameChallengeEvent.KIND,
LiveChessGameEndEvent.KIND,
)
val DEFAULT_COMMUNITY_FEEDS =
@@ -21,17 +21,31 @@
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
@@ -40,6 +54,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.commons.chess.LiveChessGameScreen
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
/**
* Wrapper screen for live chess game
@@ -50,6 +65,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
* @param accountViewModel Account view model
* @param nav Navigation interface
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChessGameScreen(
gameId: String,
@@ -66,47 +82,136 @@ fun ChessGameScreen(
)
val activeGames by chessViewModel.activeGames.collectAsState()
val gameState = activeGames[gameId]
val error by chessViewModel.error.collectAsState()
var isLoading by remember { mutableStateOf(true) }
var loadedGameState by remember { mutableStateOf<LiveChessGameState?>(null) }
var loadAttempted by remember { mutableStateOf(false) }
Scaffold { paddingValues ->
if (gameState == null) {
// Game not found - show error
Column(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "Game Not Found",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
)
// Try to load game from cache if not in activeGames
LaunchedEffect(gameId, activeGames) {
val existingState = activeGames[gameId]
if (existingState != null) {
loadedGameState = existingState
isLoading = false
loadAttempted = true
} else if (!loadAttempted) {
// Try to load from LocalCache
loadAttempted = true
val loaded = chessViewModel.loadGameFromCache(gameId)
loadedGameState = loaded
isLoading = false
}
}
Spacer(modifier = Modifier.height(8.dp))
// Update state when activeGames changes (e.g., after refresh loads new moves)
LaunchedEffect(activeGames[gameId]) {
activeGames[gameId]?.let {
loadedGameState = it
}
}
Text(
text = "This game may have ended or the ID is incorrect.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
// Start polling when screen is visible
DisposableEffect(Unit) {
chessViewModel.startPolling()
onDispose {
// Don't stop polling - let ViewModel manage it
}
}
val gameState = loadedGameState ?: activeGames[gameId]
Scaffold(
topBar = {
TopAppBar(
title = { Text("Chess Game") },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
)
}
},
)
},
) { paddingValues ->
when {
isLoading -> {
// Loading state
Box(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
CircularProgressIndicator()
Spacer(modifier = Modifier.height(16.dp))
Text("Loading game...")
}
}
}
gameState == null -> {
// Game not found - show error with back button
Column(
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text(
text = "Game Not Found",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
)
Spacer(modifier = Modifier.height(8.dp))
// Show specific error if available
Text(
text = error ?: "This game may have ended or is waiting for opponent.",
style = MaterialTheme.typography.bodyMedium,
color = if (error != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Game ID: ${gameId.take(16)}...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = { nav.popBack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
modifier = Modifier.padding(end = 8.dp),
)
Text("Go Back")
}
}
}
else -> {
// Show game with proper padding for status bar
// Use state-observing version for automatic refresh on polling updates
LiveChessGameScreen(
modifier = Modifier.padding(paddingValues),
gameState = gameState,
opponentName = gameState.opponentPubkey.take(8), // TODO: Resolve to display name
onMoveMade = { from, to, san ->
chessViewModel.publishMove(gameId, from, to)
},
onResign = { chessViewModel.resign(gameId) },
onOfferDraw = { chessViewModel.offerDraw(gameId) },
)
}
} else {
// Show game
LiveChessGameScreen(
engine = gameState.engine,
playerColor = gameState.playerColor,
gameId = gameState.gameId,
opponentName = gameState.opponentPubkey.take(8), // TODO: Resolve to display name
onMoveMade = { from, to, san ->
chessViewModel.publishMove(gameId, from, to)
},
onResign = { chessViewModel.resign(gameId) },
onOfferDraw = { chessViewModel.offerDraw(gameId) },
)
}
}
}
@@ -0,0 +1,517 @@
/**
* 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.ui.screen.loggedIn.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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
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.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
/**
* Chess lobby screen showing challenges and active games
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChessLobbyScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val chessViewModel: ChessViewModel =
viewModel(
key = "ChessViewModel-${accountViewModel.account.userProfile().pubkeyHex}",
factory = ChessViewModelFactory(accountViewModel.account),
)
val activeGames by chessViewModel.activeGames.collectAsState()
val challenges by chessViewModel.challenges.collectAsState()
val error by chessViewModel.error.collectAsState()
val selectedGameId by chessViewModel.selectedGameId.collectAsState()
val userPubkey = accountViewModel.account.userProfile().pubkeyHex
var showNewGameDialog by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
// Subscribe to chess events when screen is visible
ChessSubscription(accountViewModel, chessViewModel)
// Clear any stale game selection on mount
LaunchedEffect(Unit) {
chessViewModel.selectGame(null)
}
// Auto-navigate when a game is selected (e.g., after accepting a challenge)
LaunchedEffect(selectedGameId, activeGames) {
selectedGameId?.let { gameId ->
if (activeGames.containsKey(gameId)) {
nav.nav(Route.ChessGame(gameId))
chessViewModel.selectGame(null)
}
}
}
// Start polling when screen is visible
DisposableEffect(Unit) {
chessViewModel.startPolling()
onDispose {
chessViewModel.stopPolling()
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.route_chess)) },
navigationIcon = {
IconButton(onClick = { nav.popBack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Back",
)
}
},
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { showNewGameDialog = true },
) {
Icon(Icons.Default.Add, contentDescription = "New Game")
}
},
) { paddingValues ->
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
scope.launch {
isRefreshing = true
chessViewModel.forceRefresh()
delay(500) // Brief delay for visual feedback
isRefreshing = false
}
},
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) {
Column(
modifier =
Modifier
.fillMaxSize()
.padding(horizontal = 16.dp),
) {
// 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 = { chessViewModel.clearError() }) {
Text("Dismiss")
}
}
}
}
// Main content
ChessLobbyContent(
challenges = challenges,
activeGames = activeGames,
userPubkey = userPubkey,
accountViewModel = accountViewModel,
onAcceptChallenge = { note ->
chessViewModel.acceptChallenge(note)
},
onSelectGame = { gameId ->
nav.nav(Route.ChessGame(gameId))
},
)
}
}
}
// New game dialog
if (showNewGameDialog) {
NewChessGameDialog(
onDismiss = { showNewGameDialog = false },
onCreateGame = { opponentPubkey, color ->
chessViewModel.createChallenge(opponentPubkey, color)
showNewGameDialog = false
},
)
}
}
@Composable
fun ChessLobbyContent(
challenges: List<Note>,
activeGames: Map<String, LiveChessGameState>,
userPubkey: String,
accountViewModel: AccountViewModel,
onAcceptChallenge: (Note) -> Unit,
onSelectGame: (String) -> Unit,
) {
if (activeGames.isEmpty() && challenges.isEmpty()) {
// Empty state
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"No games or challenges",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(8.dp))
Text(
"Create a new game to get started",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
return
}
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,
isYourTurn = state.isPlayerTurn(),
accountViewModel = accountViewModel,
onClick = { onSelectGame(gameId) },
)
}
}
// Incoming challenges
val incomingChallenges =
challenges.filter {
val event = it.event as? LiveChessGameChallengeEvent
event?.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.idHex }) { note ->
ChallengeCard(
note = note,
isIncoming = true,
accountViewModel = accountViewModel,
onAccept = { onAcceptChallenge(note) },
)
}
}
// User's outgoing challenges
val outgoingChallenges =
challenges.filter {
it.author?.pubkeyHex == 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.idHex }) { note ->
OutgoingChallengeCard(
note = note,
accountViewModel = accountViewModel,
)
}
}
// Open challenges from others
val openChallenges =
challenges.filter {
val event = it.event as? LiveChessGameChallengeEvent
event?.opponentPubkey() == null && it.author?.pubkeyHex != 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.idHex }) { note ->
ChallengeCard(
note = note,
isIncoming = false,
accountViewModel = accountViewModel,
onAccept = { onAcceptChallenge(note) },
)
}
}
// Bottom padding for FAB
item {
Spacer(Modifier.height(80.dp))
}
}
}
@Composable
private fun ActiveGameCard(
gameId: String,
opponentPubkey: String,
isYourTurn: Boolean,
accountViewModel: AccountViewModel,
onClick: () -> Unit,
) {
val displayName =
remember(opponentPubkey) {
accountViewModel.checkGetOrCreateUser(opponentPubkey)?.toBestDisplayName() ?: opponentPubkey.take(8)
}
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.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
"vs $displayName",
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 ChallengeCard(
note: Note,
isIncoming: Boolean,
accountViewModel: AccountViewModel,
onAccept: () -> Unit,
) {
val event = note.event as? LiveChessGameChallengeEvent ?: return
val challengerPubkey = note.author?.pubkeyHex ?: return
val playerColor = event.playerColor()
val displayName =
remember(challengerPubkey) {
accountViewModel.checkGetOrCreateUser(challengerPubkey)?.toBestDisplayName() ?: challengerPubkey.take(8)
}
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.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
if (isIncoming) "Challenge from $displayName" else "Open challenge by $displayName",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
)
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 OutgoingChallengeCard(
note: Note,
accountViewModel: AccountViewModel,
) {
val event = note.event as? LiveChessGameChallengeEvent ?: return
val opponentPubkey = event.opponentPubkey()
val playerColor = event.playerColor()
val displayName =
remember(opponentPubkey) {
opponentPubkey?.let {
accountViewModel.checkGetOrCreateUser(it)?.toBestDisplayName() ?: it.take(8)
}
}
// Not clickable - waiting for opponent to accept
Card(
modifier = Modifier.fillMaxWidth(),
border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary),
) {
Row(
modifier = Modifier.padding(16.dp).fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
if (displayName != null) {
"Challenge to $displayName"
} 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,
)
}
}
}
@@ -0,0 +1,270 @@
/**
* 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.ui.screen.loggedIn.chess
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS
import com.vitorpamplona.amethyst.service.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* Default "since" window for challenges (24 hours)
* Challenges older than this are considered expired anyway
*/
private const val CHALLENGE_WINDOW_SECONDS = 24 * 60 * 60L
/**
* Default "since" window for game events when no EOSE cache exists (7 days)
* This prevents loading ancient game history on first connection
*/
private const val GAME_EVENT_WINDOW_SECONDS = 7 * 24 * 60 * 60L
/**
* Subscribe to chess events when the Chess tab is active.
* Respects the Global/Follows filter from the discovery top bar.
*/
@Composable
fun ChessSubscription(
accountViewModel: AccountViewModel,
chessViewModel: ChessViewModel,
) {
// Observe the current filter selection (Global, All Follows, etc.)
val filterSelection by accountViewModel.account.settings.defaultDiscoveryFollowList
.collectAsStateWithLifecycle()
val isGlobal = filterSelection == GLOBAL_FOLLOWS
// Get active game IDs from the view model for game-specific subscriptions
val activeGames by chessViewModel.activeGames.collectAsStateWithLifecycle()
val activeGameIds = activeGames.keys
val state =
remember(accountViewModel.account, isGlobal, activeGameIds) {
ChessQueryState(
userPubkey = accountViewModel.account.userProfile().pubkeyHex,
// Use notification inbox relays - where others send events TO us
inboxRelays = accountViewModel.account.notificationRelays.flow.value,
// Use proxy relays for global, outbox relays for follows
globalRelays =
if (isGlobal) {
accountViewModel.account.defaultGlobalRelays.flow.value
} else {
accountViewModel.account.followOutboxesOrProxy.flow.value
},
isGlobal = isGlobal,
activeGameIds = activeGameIds,
)
}
DisposableEffect(state) {
accountViewModel.dataSources().chess.subscribe(state)
chessViewModel.refreshChallenges()
onDispose {
accountViewModel.dataSources().chess.unsubscribe(state)
}
}
}
/**
* Query state for chess subscription
*/
data class ChessQueryState(
val userPubkey: String,
val inboxRelays: Set<NormalizedRelayUrl>,
val globalRelays: Set<NormalizedRelayUrl>,
val isGlobal: Boolean,
val activeGameIds: Set<String> = emptySet(),
)
/**
* Filter assembler for chess events
*/
class ChessFilterAssembler(
client: INostrClient,
) : ComposeSubscriptionManager<ChessQueryState>() {
val group =
listOf(
ChessFeedFilterSubAssembler(client, ::allKeys),
)
override fun invalidateKeys() = invalidateFilters()
override fun invalidateFilters() = group.forEach { it.invalidateFilters() }
override fun destroy() = group.forEach { it.destroy() }
}
/**
* Sub-assembler that creates the actual relay filters
*/
class ChessFeedFilterSubAssembler(
client: INostrClient,
allKeys: () -> Set<ChessQueryState>,
) : PerUniqueIdEoseManager<ChessQueryState, String>(client, allKeys) {
override fun updateFilter(
key: ChessQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> = filterChessEvents(key, since)
override fun id(key: ChessQueryState): String = "chess-${key.userPubkey}-${key.isGlobal}-${key.activeGameIds.hashCode()}"
}
/**
* Create relay filters for chess events.
*
* Creates filters based on the Global/Follows setting:
* - Global: Fetch all chess events from global relays
* - Follows: Fetch from followed users' outbox relays
*
* Also always fetches challenges directed at the user from inbox relays.
*
* Improvements over basic implementation:
* 1. Default "since" timestamps to avoid loading very old events on first connection
* 2. Separate challenge filter (shorter window) from game events (longer window)
* 3. Game-specific subscriptions for active games using #a tag references
* 4. Grouped filters by relay to reduce subscription overhead
*/
fun filterChessEvents(
key: ChessQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
val filters = mutableListOf<RelayBasedFilter>()
val now = TimeUtils.now()
// Challenge kinds only (for lobby display)
val challengeKinds =
listOf(
LiveChessGameChallengeEvent.KIND,
LiveChessGameAcceptEvent.KIND,
)
// Move/end kinds (for active games)
val gameEventKinds =
listOf(
LiveChessMoveEvent.KIND,
LiveChessGameEndEvent.KIND,
)
// All chess kinds
val allChessKinds = challengeKinds + gameEventKinds
// ========================================
// Filter 1: Personal challenges from inbox relays
// Uses 24h window for challenges (they expire anyway)
// ========================================
val inboxFilter =
Filter(
kinds = allChessKinds,
tags = mapOf("p" to listOf(key.userPubkey)),
limit = 100,
)
for (relay in key.inboxRelays) {
val sinceTime = since?.get(relay)?.time ?: (now - CHALLENGE_WINDOW_SECONDS)
filters.add(
RelayBasedFilter(
relay = relay,
filter = inboxFilter.copy(since = sinceTime),
),
)
}
// ========================================
// Filter 2: General challenges from global/outbox relays
// Uses 24h window to show recent open challenges
// ========================================
val globalChallengeFilter =
Filter(
kinds = challengeKinds,
limit = 50,
)
for (relay in key.globalRelays) {
val sinceTime = since?.get(relay)?.time ?: (now - CHALLENGE_WINDOW_SECONDS)
filters.add(
RelayBasedFilter(
relay = relay,
filter = globalChallengeFilter.copy(since = sinceTime),
),
)
}
// ========================================
// Filter 3: Active game events
// Uses longer window (7 days) or no window for active games
// Follows jesterui pattern: subscribe to game-specific events via #d tag
// ========================================
if (key.activeGameIds.isNotEmpty()) {
// Create filters for each active game's events
// Using #d tag to match the game_id in addressable events
val gameSpecificFilter =
Filter(
kinds = gameEventKinds,
tags = mapOf("d" to key.activeGameIds.toList()),
limit = 200, // More moves per game
)
// Query all relays for active game events (no since - need full history)
val allRelays = key.inboxRelays + key.globalRelays
for (relay in allRelays) {
filters.add(
RelayBasedFilter(
relay = relay,
filter = gameSpecificFilter,
),
)
}
} else {
// No active games - use general game event filter with window
val generalGameFilter =
Filter(
kinds = gameEventKinds,
limit = 100,
)
for (relay in key.globalRelays) {
val sinceTime = since?.get(relay)?.time ?: (now - GAME_EVENT_WINDOW_SECONDS)
filters.add(
RelayBasedFilter(
relay = relay,
filter = generalGameFilter.copy(since = sinceTime),
),
)
}
}
return filters
}
@@ -22,12 +22,16 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDelegate
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.Note
import com.vitorpamplona.amethyst.model.filterIntoSet
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
@@ -35,6 +39,7 @@ import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -47,6 +52,18 @@ import java.util.UUID
class ChessViewModel(
private val account: Account,
) : ViewModel() {
companion object {
// Challenge expiry: 24 hours
const val CHALLENGE_EXPIRY_SECONDS = 24 * 60 * 60L
// Retry configuration
const val MAX_RETRIES = 3
const val RETRY_DELAY_MS = 1000L
// Cleanup interval: check every 5 minutes
const val CLEANUP_INTERVAL_MS = 5 * 60 * 1000L
}
// Active games being played
private val _activeGames = MutableStateFlow<Map<String, LiveChessGameState>>(emptyMap())
val activeGames: StateFlow<Map<String, LiveChessGameState>> = _activeGames.asStateFlow()
@@ -67,9 +84,78 @@ class ChessViewModel(
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
// Pending retry operations
private val _pendingRetries = MutableStateFlow<Map<String, RetryOperation>>(emptyMap())
val pendingRetries: StateFlow<Map<String, RetryOperation>> = _pendingRetries.asStateFlow()
// Polling delegate for periodic refresh
private val pollingDelegate =
ChessPollingDelegate(
config = ChessPollingDefaults.android,
scope = viewModelScope,
onRefreshGames = { gameIds -> refreshGamesFromCache(gameIds) },
onRefreshChallenges = { refreshChallenges() },
onCleanup = {
cleanupExpiredChallenges()
checkAbandonedGames()
},
)
init {
refreshChallenges()
subscribeToChessEvents()
// Start polling - will do initial fetch
startPolling()
}
/**
* Start polling for chess events
*/
fun startPolling() {
pollingDelegate.start()
}
/**
* Stop polling (call when screen is not visible on Android)
*/
fun stopPolling() {
pollingDelegate.stop()
}
/**
* Force immediate refresh
*/
fun forceRefresh() {
pollingDelegate.refreshNow()
}
/**
* Remove expired challenges
*/
private fun cleanupExpiredChallenges() {
val now = TimeUtils.now()
val validChallenges =
_challenges.value.filter { note ->
val event = note.event as? LiveChessGameChallengeEvent
val createdAt = event?.createdAt ?: 0
(now - createdAt) < CHALLENGE_EXPIRY_SECONDS
}
if (validChallenges.size != _challenges.value.size) {
_challenges.value = validChallenges
updateBadgeCount()
}
}
/**
* Check for abandoned games and notify
*/
private fun checkAbandonedGames() {
_activeGames.value.forEach { (gameId, state) ->
if (state.isAbandoned()) {
// Game is abandoned, could auto-claim or notify user
_error.value = "Game $gameId appears abandoned. You can claim victory."
}
}
}
/**
@@ -105,8 +191,9 @@ class ChessViewModel(
val san = event.san() ?: return
val fen = event.fen() ?: return
val moveNumber = event.moveNumber()
gameState.applyOpponentMove(san, fen)
gameState.applyOpponentMove(san, fen, moveNumber)
updateBadgeCount()
}
@@ -129,6 +216,7 @@ class ChessViewModel(
// Remove from active games if present
if (_activeGames.value.containsKey(gameId)) {
_activeGames.value = _activeGames.value - gameId
pollingDelegate.removeGameId(gameId)
updateBadgeCount()
}
}
@@ -140,11 +228,26 @@ class ChessViewModel(
note: Note,
event: LiveChessGameChallengeEvent,
) {
// Add to challenges if directed at us or is open
val gameId = event.gameId() ?: return
// Skip if this game is already active
if (_activeGames.value.containsKey(gameId)) return
// Add to challenges if directed at us, from us, or is open
val opponentPubkey = event.opponentPubkey()
if (opponentPubkey == null || opponentPubkey == account.signer.pubKey) {
val isFromUs = event.pubKey == account.signer.pubKey
val isDirectedAtUs = opponentPubkey == account.signer.pubKey
val isOpenChallenge = opponentPubkey == null
if (isFromUs || isDirectedAtUs || isOpenChallenge) {
val currentChallenges = _challenges.value.toMutableList()
if (currentChallenges.none { it.idHex == note.idHex }) {
// Deduplicate by both event ID and game ID
val isDuplicateEvent = currentChallenges.any { it.idHex == note.idHex }
val isDuplicateGame =
currentChallenges.any {
(it.event as? LiveChessGameChallengeEvent)?.gameId() == gameId
}
if (!isDuplicateEvent && !isDuplicateGame) {
currentChallenges.add(note)
_challenges.value = currentChallenges
updateBadgeCount()
@@ -153,7 +256,7 @@ class ChessViewModel(
}
/**
* Create a new chess challenge (open or directed)
* Create a new chess challenge (open or directed) with retry
*/
fun createChallenge(
opponentPubkey: String? = null,
@@ -161,24 +264,26 @@ class ChessViewModel(
timeControl: String? = null,
) {
val acc = account
val gameId = generateGameId()
viewModelScope.launch(Dispatchers.IO) {
try {
val gameId = generateGameId()
val success =
retryWithBackoff("challenge-$gameId") {
val template =
LiveChessGameChallengeEvent.build(
gameId = gameId,
playerColor = playerColor,
opponentPubkey = opponentPubkey,
timeControl = timeControl,
)
val template =
LiveChessGameChallengeEvent.build(
gameId = gameId,
playerColor = playerColor,
opponentPubkey = opponentPubkey,
timeControl = timeControl,
)
acc.signAndComputeBroadcast(template)
acc.signAndComputeBroadcast(template)
}
if (success) {
_error.value = null
} catch (e: Exception) {
_error.value = "Failed to create challenge: ${e.message}"
} else {
_error.value = "Failed to create challenge after $MAX_RETRIES attempts"
}
}
}
@@ -217,6 +322,7 @@ class ChessViewModel(
)
_activeGames.value = _activeGames.value + (gameId to gameState)
pollingDelegate.addGameId(gameId)
_selectedGameId.value = gameId
_error.value = null
} catch (e: Exception) {
@@ -270,6 +376,7 @@ class ChessViewModel(
)
_activeGames.value = _activeGames.value + (gameId to gameState)
pollingDelegate.addGameId(gameId)
_selectedGameId.value = gameId
}
}
@@ -362,7 +469,7 @@ class ChessViewModel(
}
/**
* Offer/accept draw
* Offer a draw to opponent - sends a draw offer event that opponent can accept or decline
*/
fun offerDraw(gameId: String) {
val acc = account
@@ -370,22 +477,16 @@ class ChessViewModel(
viewModelScope.launch(Dispatchers.IO) {
try {
val endData = gameState.offerDraw()
val drawOffer = gameState.offerDraw()
val template =
LiveChessGameEndEvent.build(
gameId = endData.gameId,
result = endData.result,
termination = endData.termination,
winnerPubkey = endData.winnerPubkey,
opponentPubkey = endData.opponentPubkey,
pgn = endData.pgn ?: "",
LiveChessDrawOfferEvent.build(
gameId = drawOffer.gameId,
opponentPubkey = drawOffer.opponentPubkey,
message = drawOffer.message ?: "",
)
acc.signAndComputeBroadcast(template)
// Remove from active games
_activeGames.value = _activeGames.value - gameId
_error.value = null
} catch (e: Exception) {
_error.value = "Failed to offer draw: ${e.message}"
@@ -408,10 +509,26 @@ class ChessViewModel(
/**
* Refresh challenges from local cache
*/
private fun refreshChallenges() {
fun refreshChallenges() {
viewModelScope.launch(Dispatchers.IO) {
// TODO: Subscribe to challenge events via relay filter
// For now, this is a placeholder
val userPubkey = account.signer.pubKey
val now = TimeUtils.now()
// Query LocalCache for chess challenge events
val challengeNotes =
LocalCache.notes.filterIntoSet { _, note ->
val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false
// Check if challenge is not expired
val createdAt = event.createdAt
if ((now - createdAt) >= CHALLENGE_EXPIRY_SECONDS) return@filterIntoSet false
// Include challenges directed at us, or open challenges (not from us)
val opponentPubkey = event.opponentPubkey()
opponentPubkey == userPubkey || (opponentPubkey == null && event.pubKey != userPubkey) || event.pubKey == userPubkey
}
_challenges.value = challengeNotes.toList()
updateBadgeCount()
}
}
@@ -441,6 +558,76 @@ class ChessViewModel(
_error.value = null
}
/**
* Claim victory for an abandoned game
*/
fun claimAbandonmentVictory(gameId: String) {
val gameState = _activeGames.value[gameId] ?: return
viewModelScope.launch(Dispatchers.IO) {
val endData = gameState.claimAbandonmentVictory() ?: return@launch
val success =
retryWithBackoff("abandon-$gameId") {
val template =
LiveChessGameEndEvent.build(
gameId = endData.gameId,
result = endData.result,
termination = endData.termination,
winnerPubkey = endData.winnerPubkey,
opponentPubkey = endData.opponentPubkey,
pgn = endData.pgn ?: "",
)
account.signAndComputeBroadcast(template)
}
if (success) {
_activeGames.value = _activeGames.value - gameId
_error.value = null
} else {
_error.value = "Failed to claim victory"
}
}
}
/**
* Force resync a game to opponent's position
*/
fun forceResync(
gameId: String,
fen: String,
) {
val gameState = _activeGames.value[gameId] ?: return
gameState.forceResync(fen)
}
/**
* Retry an operation with exponential backoff
*/
private suspend fun retryWithBackoff(
operationId: String,
operation: suspend () -> Unit,
): Boolean {
var attempt = 0
while (attempt < MAX_RETRIES) {
try {
_pendingRetries.value =
_pendingRetries.value + (operationId to RetryOperation(operationId, attempt + 1, MAX_RETRIES))
operation()
_pendingRetries.value = _pendingRetries.value - operationId
return true
} catch (e: Exception) {
attempt++
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
}
}
_pendingRetries.value = _pendingRetries.value - operationId
return false
}
/**
* Generate unique game ID
*/
@@ -449,4 +636,175 @@ class ChessViewModel(
val random = UUID.randomUUID().toString().take(8)
return "chess-$timestamp-$random"
}
/**
* Refresh game state from LocalCache for specific game IDs
* Called periodically by polling delegate
*/
private suspend fun refreshGamesFromCache(gameIds: Set<String>) {
val userPubkey = account.signer.pubKey
for (gameId in gameIds) {
// Find moves for this game
val moveNotes =
LocalCache.notes.filterIntoSet { _, note ->
val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false
event.gameId() == gameId
}
val gameState = _activeGames.value[gameId]
if (gameState != null) {
// Apply any new moves
moveNotes
.mapNotNull { it.event as? LiveChessMoveEvent }
.filter { it.pubKey != userPubkey } // Only opponent moves
.sortedBy { it.moveNumber() }
.forEach { moveEvent ->
val san = moveEvent.san() ?: return@forEach
val fen = moveEvent.fen() ?: return@forEach
val moveNumber = moveEvent.moveNumber()
gameState.applyOpponentMove(san, fen, moveNumber)
}
}
}
updateBadgeCount()
}
/**
* Load or rebuild game state from LocalCache for a specific gameId
* Used when navigating to a game screen
*
* @return GameLoadResult with either the game state or an error reason
*/
fun loadGameFromCache(gameId: String): LiveChessGameState? {
// Check if already loaded
_activeGames.value[gameId]?.let { return it }
val userPubkey = account.signer.pubKey
// Find the challenge event for this game
val challengeNotes =
LocalCache.notes.filterIntoSet { _, note ->
val event = note.event as? LiveChessGameChallengeEvent ?: return@filterIntoSet false
event.gameId() == gameId
}
val challengeNote = challengeNotes.firstOrNull()
// Find accept event for this game
val acceptNotes =
LocalCache.notes.filterIntoSet { _, note ->
val event = note.event as? LiveChessGameAcceptEvent ?: return@filterIntoSet false
event.gameId() == gameId
}
val acceptNote = acceptNotes.firstOrNull()
// Need challenge to understand the game
val challengeEvent = challengeNote?.event as? LiveChessGameChallengeEvent
if (challengeEvent == null) {
_error.value = "Challenge event not found for game $gameId"
return null
}
val acceptEvent = acceptNote?.event as? LiveChessGameAcceptEvent
// Determine if we're a participant
val challengerPubkey = challengeEvent.pubKey
val acceptorPubkey = acceptEvent?.pubKey
val challengerColor = challengeEvent.playerColor() ?: Color.WHITE
val (playerColor, opponentPubkey) =
when {
challengerPubkey == userPubkey && acceptorPubkey != null -> {
// We created the challenge, someone accepted
challengerColor to acceptorPubkey
}
acceptorPubkey == userPubkey -> {
// We accepted someone's challenge
challengerColor.opposite() to challengerPubkey
}
challengerPubkey == userPubkey && acceptorPubkey == null -> {
// We created challenge but no one accepted yet
_error.value = "Waiting for opponent to accept challenge"
return null
}
else -> {
_error.value = "You are not a participant in this game"
return null
}
}
// Build game state
val engine = ChessEngine()
engine.reset()
// Find and apply all moves in order
val moveNotes =
LocalCache.notes.filterIntoSet { _, note ->
val event = note.event as? LiveChessMoveEvent ?: return@filterIntoSet false
event.gameId() == gameId
}
val sortedMoves =
moveNotes
.mapNotNull { it.event as? LiveChessMoveEvent }
.sortedBy { it.moveNumber() ?: Int.MAX_VALUE }
// Track move numbers we've loaded
val loadedMoveNumbers = mutableSetOf<Int>()
for (moveEvent in sortedMoves) {
val san = moveEvent.san() ?: continue
val moveNumber = moveEvent.moveNumber()
try {
engine.makeMove(san)
if (moveNumber != null) {
loadedMoveNumbers.add(moveNumber)
}
} catch (e: Exception) {
_error.value = "Error loading move $san: ${e.message}"
// Continue trying to load other moves
}
}
val gameState =
LiveChessGameState(
gameId = gameId,
playerPubkey = userPubkey,
opponentPubkey = opponentPubkey,
playerColor = playerColor,
engine = engine,
)
// Mark loaded moves as received to prevent re-application during refresh
gameState.markMovesAsReceived(loadedMoveNumbers)
// Add to active games
_activeGames.value = _activeGames.value + (gameId to gameState)
// Update polling delegate with this game
pollingDelegate.addGameId(gameId)
// Clear any error since we loaded successfully
_error.value = null
return gameState
}
/**
* Called when ViewModel is cleared
*/
override fun onCleared() {
super.onCleared()
pollingDelegate.stop()
}
}
/**
* Represents a pending retry operation for UI feedback
*/
data class RetryOperation(
val id: String,
val currentAttempt: Int,
val maxAttempts: Int,
)
@@ -0,0 +1,53 @@
/**
* 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.ui.screen.loggedIn.chess
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
import com.vitorpamplona.amethyst.ui.theme.Size26Modifier
import com.vitorpamplona.amethyst.ui.theme.Size55Modifier
/**
* Simple floating action button for creating new chess game challenges.
* Just triggers onClick - the caller handles the dialog.
*/
@Composable
fun NewChessGameButton(onClick: () -> Unit) {
FloatingActionButton(
onClick = onClick,
modifier = Size55Modifier,
shape = CircleShape,
containerColor = MaterialTheme.colorScheme.primary,
) {
Icon(
imageVector = Icons.Default.Add,
contentDescription = "New Chess Game",
modifier = Size26Modifier,
tint = Color.White,
)
}
}
@@ -123,7 +123,7 @@ fun DiscoverScreen(
accountViewModel: AccountViewModel,
nav: INav,
) {
val tabs by
val feedTabs by
remember(accountViewModel) {
mutableStateOf(
listOf(
@@ -181,7 +181,7 @@ fun DiscoverScreen(
)
}
val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { tabs.size }
val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { feedTabs.size }
WatchAccountForDiscoveryScreen(
discoveryFollowSetsFeedContentState = discoveryFollowSetsFeedContentState,
@@ -204,13 +204,13 @@ fun DiscoverScreen(
DiscoveryFilterAssemblerSubscription(accountViewModel.dataSources().discovery, accountViewModel)
DiscoverPages(pagerState, tabs, accountViewModel, nav)
DiscoverPages(pagerState, feedTabs, accountViewModel, nav)
}
@Composable
private fun DiscoverPages(
pagerState: PagerState,
tabs: ImmutableList<TabItem>,
feedTabs: ImmutableList<TabItem>,
accountViewModel: AccountViewModel,
nav: INav,
) {
@@ -228,7 +228,7 @@ private fun DiscoverPages(
) {
val coroutineScope = rememberCoroutineScope()
tabs.forEachIndexed { index, tab ->
feedTabs.forEachIndexed { index, tab ->
Tab(
selected = pagerState.currentPage == index,
text = { Text(text = stringRes(tab.resource)) },
@@ -241,42 +241,49 @@ private fun DiscoverPages(
bottomBar = {
AppBottomBar(Route.Discover, accountViewModel) { route ->
if (route == Route.Discover) {
tabs[pagerState.currentPage].feedState.sendToTop()
val currentPage = pagerState.currentPage
if (currentPage >= 0 && currentPage < feedTabs.size) {
feedTabs[currentPage].feedState.sendToTop()
}
} else {
nav.newStack(route)
}
}
},
floatingButton = {
if (tabs[pagerState.currentPage].resource == R.string.discover_marketplace) {
val currentPage = pagerState.currentPage
if (currentPage >= 0 && currentPage < feedTabs.size && feedTabs[currentPage].resource == R.string.discover_marketplace) {
NewProductButton(accountViewModel, nav)
}
},
accountViewModel = accountViewModel,
) {
HorizontalPager(state = pagerState, contentPadding = it) { page ->
RefresheableBox(tabs[page].feedState, true) {
if (tabs[page].useGridLayout) {
SaveableGridFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState ->
RenderDiscoverFeed(
feedContentState = tabs[page].feedState,
routeForLastRead = tabs[page].routeForLastRead,
forceEventKind = tabs[page].forceEventKind,
listState = listState,
accountViewModel = accountViewModel,
nav = nav,
)
}
} else {
SaveableFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState ->
RenderDiscoverFeed(
feedContentState = tabs[page].feedState,
routeForLastRead = tabs[page].routeForLastRead,
forceEventKind = tabs[page].forceEventKind,
listState = listState,
accountViewModel = accountViewModel,
nav = nav,
)
if (page >= 0 && page < feedTabs.size) {
val tab = feedTabs[page]
RefresheableBox(tab.feedState, true) {
if (tab.useGridLayout) {
SaveableGridFeedContentState(tab.feedState, scrollStateKey = tab.scrollStateKey) { listState ->
RenderDiscoverFeed(
feedContentState = tab.feedState,
routeForLastRead = tab.routeForLastRead,
forceEventKind = tab.forceEventKind,
listState = listState,
accountViewModel = accountViewModel,
nav = nav,
)
}
} else {
SaveableFeedContentState(tab.feedState, scrollStateKey = tab.scrollStateKey) { listState ->
RenderDiscoverFeed(
feedContentState = tab.feedState,
routeForLastRead = tab.routeForLastRead,
forceEventKind = tab.forceEventKind,
listState = listState,
accountViewModel = accountViewModel,
nav = nav,
)
}
}
}
}
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF9C9C9C"
android:pathData="M19,22H5v-2h14v2M17.16,8.26A8.94,8.94 0,0 0,19 3H15.03A5.24,5.24 0,0 1,14.05 4.89L10.08,4.89A5.24,5.24 0,0 1,9.1 3H5A8.94,8.94 0,0 0,6.84 8.26L6.84,18h10.32v-9.74ZM7.5,6c0.28,0 0.5,0.22 0.5,0.5S7.78,7 7.5,7 7,6.78 7,6.5 7.22,6 7.5,6M9,12 L9,9h2v3H9M13,12v-3h2v3H13Z"/>
</vector>
+1
View File
@@ -1191,6 +1191,7 @@
<string name="route_notifications">Notifications</string>
<string name="route_global">Global</string>
<string name="route_video">Shorts</string>
<string name="route_chess">Chess</string>
<string name="route_security_filters">Security Filters</string>
<string name="new_post">New Post</string>
@@ -20,23 +20,25 @@
*/
package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.quartz.nip64Chess.ChessPiece
import com.vitorpamplona.quartz.nip64Chess.ChessPosition
import com.vitorpamplona.quartz.nip64Chess.PieceType
import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor
@@ -103,13 +105,12 @@ private fun ChessSquare(
),
contentAlignment = Alignment.Center,
) {
// Display piece using Unicode chess symbols
// Display piece using CBurnett ImageVector icons
piece?.let {
Text(
text = it.toUnicode(),
fontSize = (size.value * 0.6).sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
Image(
imageVector = it.toImageVector(),
contentDescription = "${it.color} ${it.type}",
modifier = Modifier.fillMaxSize().padding(2.dp),
)
}
@@ -130,16 +131,16 @@ private fun ChessSquare(
}
/**
* Extension function to convert ChessPiece to Unicode chess symbol
* Extension function to convert ChessPiece to CBurnett ImageVector
*/
private fun com.vitorpamplona.quartz.nip64Chess.ChessPiece.toUnicode(): String {
private fun ChessPiece.toImageVector(): ImageVector {
val white = color == ChessColor.WHITE
return when (type) {
PieceType.KING -> if (white) "" else ""
PieceType.QUEEN -> if (white) "" else ""
PieceType.ROOK -> if (white) "" else ""
PieceType.BISHOP -> if (white) "" else ""
PieceType.KNIGHT -> if (white) "" else ""
PieceType.PAWN -> if (white) "" else ""
PieceType.KING -> if (white) ChessPieceVectors.WhiteKing else ChessPieceVectors.BlackKing
PieceType.QUEEN -> if (white) ChessPieceVectors.WhiteQueen else ChessPieceVectors.BlackQueen
PieceType.ROOK -> if (white) ChessPieceVectors.WhiteRook else ChessPieceVectors.BlackRook
PieceType.BISHOP -> if (white) ChessPieceVectors.WhiteBishop else ChessPieceVectors.BlackBishop
PieceType.KNIGHT -> if (white) ChessPieceVectors.WhiteKnight else ChessPieceVectors.BlackKnight
PieceType.PAWN -> if (white) ChessPieceVectors.WhitePawn else ChessPieceVectors.BlackPawn
}
}
@@ -0,0 +1,257 @@
/**
* 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.commons.chess
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Configuration for chess event polling
*/
data class ChessPollingConfig(
/** Interval for polling active games (ms) */
val activeGamePollInterval: Long = 10_000L,
/** Interval for polling challenges (ms) */
val challengePollInterval: Long = 30_000L,
/** Whether to continue polling in background */
val pollInBackground: Boolean = false,
/** Challenge expiry time (seconds) */
val challengeExpirySeconds: Long = 24 * 60 * 60L,
/** Cleanup interval (ms) */
val cleanupInterval: Long = 5 * 60 * 1000L,
)
/**
* Platform-specific defaults
*/
object ChessPollingDefaults {
/** Android: shorter intervals, no background polling */
val android =
ChessPollingConfig(
activeGamePollInterval = 10_000L,
challengePollInterval = 30_000L,
pollInBackground = false,
)
/** Desktop: can poll in background */
val desktop =
ChessPollingConfig(
activeGamePollInterval = 10_000L,
challengePollInterval = 30_000L,
pollInBackground = true,
)
}
/**
* Delegate for managing chess event polling
*
* Usage:
* ```
* class ChessViewModel(...) : ViewModel() {
* private val pollingDelegate = ChessPollingDelegate(
* config = ChessPollingDefaults.android,
* scope = viewModelScope,
* onRefreshGames = { gameIds -> refreshGamesFromCache(gameIds) },
* onRefreshChallenges = { refreshChallengesFromCache() },
* )
*
* fun startPolling() = pollingDelegate.start()
* fun stopPolling() = pollingDelegate.stop()
* }
* ```
*/
class ChessPollingDelegate(
private val config: ChessPollingConfig,
private val scope: CoroutineScope,
private val onRefreshGames: suspend (Set<String>) -> Unit,
private val onRefreshChallenges: suspend () -> Unit,
private val onCleanup: suspend () -> Unit = {},
) {
private var gamePollingJob: Job? = null
private var challengePollingJob: Job? = null
private var cleanupJob: Job? = null
private val _isPolling = MutableStateFlow(false)
val isPolling: StateFlow<Boolean> = _isPolling.asStateFlow()
private val activeGameIdsFlow = MutableStateFlow<Set<String>>(emptySet())
/**
* Update the set of game IDs to poll for
*/
fun setActiveGameIds(gameIds: Set<String>) {
activeGameIdsFlow.value = gameIds
}
/**
* Add a game ID to poll for
*/
fun addGameId(gameId: String) {
activeGameIdsFlow.value = activeGameIdsFlow.value + gameId
}
/**
* Remove a game ID from polling
*/
fun removeGameId(gameId: String) {
activeGameIdsFlow.value = activeGameIdsFlow.value - gameId
}
/**
* Start polling for chess events
*/
fun start() {
if (_isPolling.value) return
_isPolling.value = true
// Poll for active games
gamePollingJob =
scope.launch {
while (isActive) {
val gameIds = activeGameIdsFlow.value
if (gameIds.isNotEmpty()) {
onRefreshGames(gameIds)
}
delay(config.activeGamePollInterval)
}
}
// Poll for challenges
challengePollingJob =
scope.launch {
// Initial fetch
onRefreshChallenges()
while (isActive) {
delay(config.challengePollInterval)
onRefreshChallenges()
}
}
// Cleanup job
cleanupJob =
scope.launch {
while (isActive) {
delay(config.cleanupInterval)
onCleanup()
}
}
}
/**
* Stop polling
*/
fun stop() {
_isPolling.value = false
gamePollingJob?.cancel()
challengePollingJob?.cancel()
cleanupJob?.cancel()
gamePollingJob = null
challengePollingJob = null
cleanupJob = null
}
/**
* Pause polling (e.g., when app goes to background on Android)
*/
fun pause() {
if (!config.pollInBackground) {
stop()
}
}
/**
* Resume polling (e.g., when app comes to foreground on Android)
*/
fun resume() {
if (!_isPolling.value) {
start()
}
}
/**
* Force an immediate refresh
*/
fun refreshNow() {
scope.launch {
onRefreshChallenges()
val gameIds = activeGameIdsFlow.value
if (gameIds.isNotEmpty()) {
onRefreshGames(gameIds)
}
}
}
}
/**
* Interface for chess event sources that can be refreshed
*/
interface ChessEventSource {
/**
* Fetch/refresh challenges from the event source
*/
suspend fun fetchChallenges(): List<ChessChallengeData>
/**
* Fetch/refresh game state for specific game IDs
*/
suspend fun fetchGameUpdates(gameIds: Set<String>): Map<String, ChessGameUpdate>
}
/**
* Data class representing a chess challenge
*/
data class ChessChallengeData(
val id: String,
val gameId: String,
val challengerPubkey: String,
val opponentPubkey: String?,
val challengerColor: com.vitorpamplona.quartz.nip64Chess.Color,
val createdAt: Long,
val isExpired: Boolean = false,
)
/**
* Data class representing a game update
*/
data class ChessGameUpdate(
val gameId: String,
val moves: List<ChessMoveData>,
val isEnded: Boolean = false,
val endReason: String? = null,
)
/**
* Data class representing a chess move
*/
data class ChessMoveData(
val san: String,
val fen: String,
val moveNumber: Int,
val playerPubkey: String,
val timestamp: Long,
)
@@ -20,16 +20,17 @@
*/
package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -40,12 +41,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
import com.vitorpamplona.quartz.nip64Chess.ChessPiece
import com.vitorpamplona.quartz.nip64Chess.PieceType
import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor
@@ -55,55 +57,76 @@ import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor
* @param engine Chess engine for move validation and legal moves
* @param modifier Modifier for the board
* @param boardSize Total size of the board in dp
* @param flipped If true, renders from black's perspective (rank 1 at top)
* @param playerColor The color the player is playing as (only allows moving these pieces)
* @param onMoveMade Callback when a valid move is made, receives (from, to, san)
* NOTE: This callback should make the actual move - this component only validates
*/
@Composable
fun InteractiveChessBoard(
engine: ChessEngine,
modifier: Modifier = Modifier,
boardSize: Dp = 400.dp,
flipped: Boolean = false,
playerColor: ChessColor? = null, // null = allow both (for local play)
onMoveMade: (from: String, to: String, san: String) -> Unit = { _, _, _ -> },
) {
val position = remember(engine) { engine.getPosition() }
// Track move count to trigger recomposition when board changes
var moveCount by remember { mutableStateOf(0) }
val position = remember(engine, moveCount) { engine.getPosition() }
val sideToMove = remember(engine, moveCount) { engine.getSideToMove() }
var selectedSquare by remember { mutableStateOf<Pair<Int, Int>?>(null) }
var legalMoves by remember { mutableStateOf<List<String>>(emptyList()) }
// Can only interact if it's your turn (or playerColor is null for local play)
val canInteract = playerColor == null || sideToMove == playerColor
val squareSize = boardSize / 8
// Rank iteration order based on perspective
val rankRange = if (flipped) (0..7) else (7 downTo 0)
val fileRange = if (flipped) (7 downTo 0) else (0..7)
Column(modifier = modifier.size(boardSize)) {
// Render ranks 8 down to 1 (from white's perspective)
for (rank in 7 downTo 0) {
for (rank in rankRange) {
Row {
for (file in 0..7) {
for (file in fileRange) {
val piece = position.pieceAt(file, rank)
val isLightSquare = (rank + file) % 2 == 0
val square = fileRankToSquare(file, rank)
val isSelected = selectedSquare == (file to rank)
val isLegalMove = legalMoves.contains(square)
val showCoord = if (flipped) rank == 7 else rank == 0
InteractiveChessSquare(
piece = piece,
isLight = isLightSquare,
size = squareSize,
isSelected = isSelected,
isLegalMove = isLegalMove,
showCoordinate = rank == 0,
showCoordinate = showCoord,
file = file,
rank = rank,
onClick = {
if (!canInteract) return@InteractiveChessSquare
if (selectedSquare != null) {
// Attempt to make move
// Attempt to make move - validate first, don't actually make it
val from = fileRankToSquare(selectedSquare!!.first, selectedSquare!!.second)
val to = square
val result = engine.makeMove(from, to)
if (result.success) {
onMoveMade(from, to, result.san ?: "$from$to")
if (legalMoves.contains(to)) {
// Valid move - callback will make the actual move
selectedSquare = null
legalMoves = emptyList()
// Pass empty san - callback will get it from makeMove result
onMoveMade(from, to, "")
moveCount++ // Trigger recomposition after move is made
} else {
// If move failed, try selecting this square instead
if (piece != null && piece.color == engine.getSideToMove()) {
// Not a legal move target - try selecting this square instead
val validColor = playerColor ?: sideToMove
if (piece != null && piece.color == validColor) {
selectedSquare = file to rank
legalMoves = engine.getLegalMovesFrom(square)
} else {
@@ -112,8 +135,9 @@ fun InteractiveChessBoard(
}
}
} else {
// Select piece
if (piece != null && piece.color == engine.getSideToMove()) {
// Select piece - only allow selecting player's pieces
val validColor = playerColor ?: sideToMove
if (piece != null && piece.color == validColor) {
selectedSquare = file to rank
legalMoves = engine.getLegalMovesFrom(square)
}
@@ -164,13 +188,12 @@ private fun InteractiveChessSquare(
},
contentAlignment = Alignment.Center,
) {
// Display piece using Unicode chess symbols
// Display piece using CBurnett ImageVector icons
piece?.let {
Text(
text = it.toUnicode(),
fontSize = (size.value * 0.6).sp,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurface,
Image(
imageVector = it.toImageVector(),
contentDescription = "${it.color} ${it.type}",
modifier = Modifier.fillMaxSize().padding(2.dp),
)
}
@@ -218,16 +241,16 @@ private fun fileRankToSquare(
): String = "${('a' + file)}${rank + 1}"
/**
* Extension function to convert ChessPiece to Unicode chess symbol
* Extension function to convert ChessPiece to CBurnett ImageVector
*/
private fun com.vitorpamplona.quartz.nip64Chess.ChessPiece.toUnicode(): String {
private fun ChessPiece.toImageVector(): ImageVector {
val white = color == ChessColor.WHITE
return when (type) {
PieceType.KING -> if (white) "" else ""
PieceType.QUEEN -> if (white) "" else ""
PieceType.ROOK -> if (white) "" else ""
PieceType.BISHOP -> if (white) "" else ""
PieceType.KNIGHT -> if (white) "" else ""
PieceType.PAWN -> if (white) "" else ""
PieceType.KING -> if (white) ChessPieceVectors.WhiteKing else ChessPieceVectors.BlackKing
PieceType.QUEEN -> if (white) ChessPieceVectors.WhiteQueen else ChessPieceVectors.BlackQueen
PieceType.ROOK -> if (white) ChessPieceVectors.WhiteRook else ChessPieceVectors.BlackRook
PieceType.BISHOP -> if (white) ChessPieceVectors.WhiteBishop else ChessPieceVectors.BlackBishop
PieceType.KNIGHT -> if (white) ChessPieceVectors.WhiteKnight else ChessPieceVectors.BlackKnight
PieceType.PAWN -> if (white) ChessPieceVectors.WhitePawn else ChessPieceVectors.BlackPawn
}
}
@@ -20,14 +20,21 @@
*/
package com.vitorpamplona.amethyst.commons.chess
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
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.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme
@@ -35,6 +42,7 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
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
@@ -43,9 +51,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
import androidx.compose.ui.window.Dialog
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
/**
* Dialog for creating a new chess game challenge
@@ -156,6 +166,81 @@ fun NewChessGameDialog(
/**
* Complete live chess game UI with board, controls, and game info
*
* This version observes LiveChessGameState flows for automatic UI updates
* when polling refreshes the game state.
*
* @param modifier Modifier for the root layout
* @param gameState Live chess game state (observed for updates)
* @param opponentName Opponent's display name
* @param onMoveMade Callback when player makes a move (from, to, san)
* @param onResign Callback when player resigns
* @param onOfferDraw Callback when player offers draw
*/
@Composable
fun LiveChessGameScreen(
modifier: Modifier = Modifier,
gameState: LiveChessGameState,
opponentName: String,
onMoveMade: (from: String, to: String, san: String) -> Unit,
onResign: () -> Unit,
onOfferDraw: () -> Unit,
) {
// Observe state flows for automatic recomposition on updates
val currentPosition by gameState.currentPosition.collectAsState()
val moveHistory by gameState.moveHistory.collectAsState()
BoxWithConstraints(
modifier = modifier.fillMaxSize(),
) {
// Calculate board size based on available space
// Leave room for header (~100dp), history (~60dp), controls (~60dp), and padding
val availableWidth = maxWidth - 32.dp // Account for horizontal padding
val availableHeight = maxHeight - 250.dp // Account for other UI elements
val boardSize = min(availableWidth, availableHeight).coerceAtLeast(200.dp)
Column(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Game info - use currentPosition.activeColor for turn display
GameInfoHeader(
gameId = gameState.gameId,
opponentName = opponentName,
playerColor = gameState.playerColor,
currentTurn = currentPosition.activeColor,
)
// Interactive chess board - flip when playing black
// Auto-sized to fit available space
InteractiveChessBoard(
engine = gameState.engine,
boardSize = boardSize,
flipped = gameState.playerColor == Color.BLACK,
onMoveMade = onMoveMade,
)
// Move history (scrollable) - observed from state flow
MoveHistoryDisplay(
moves = moveHistory,
)
// Game controls
GameControls(
onResign = onResign,
onOfferDraw = onOfferDraw,
)
}
}
}
/**
* Legacy version that takes engine directly (for backwards compatibility)
*
* @param modifier Modifier for the root layout
* @param engine Chess engine instance
* @param playerColor Color the player is playing as
* @param gameId Unique game identifier
@@ -166,6 +251,7 @@ fun NewChessGameDialog(
*/
@Composable
fun LiveChessGameScreen(
modifier: Modifier = Modifier,
engine: ChessEngine,
playerColor: Color,
gameId: String,
@@ -174,39 +260,51 @@ fun LiveChessGameScreen(
onResign: () -> Unit,
onOfferDraw: () -> Unit,
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
BoxWithConstraints(
modifier = modifier.fillMaxSize(),
) {
// Game info
GameInfoHeader(
gameId = gameId,
opponentName = opponentName,
playerColor = playerColor,
currentTurn = engine.getSideToMove(),
)
// Calculate board size based on available space
// Leave room for header (~100dp), history (~60dp), controls (~60dp), and padding
val availableWidth = maxWidth - 32.dp // Account for horizontal padding
val availableHeight = maxHeight - 250.dp // Account for other UI elements
val boardSize = min(availableWidth, availableHeight).coerceAtLeast(200.dp)
// Interactive chess board
InteractiveChessBoard(
engine = engine,
boardSize = 400.dp,
onMoveMade = onMoveMade,
)
Column(
modifier =
Modifier
.fillMaxSize()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
// Game info
GameInfoHeader(
gameId = gameId,
opponentName = opponentName,
playerColor = playerColor,
currentTurn = engine.getSideToMove(),
)
// Move history
MoveHistoryDisplay(
moves = engine.getMoveHistory(),
)
// Interactive chess board - flip when playing black
// Auto-sized to fit available space
InteractiveChessBoard(
engine = engine,
boardSize = boardSize,
flipped = playerColor == Color.BLACK,
onMoveMade = onMoveMade,
)
// Game controls
GameControls(
onResign = onResign,
onOfferDraw = onOfferDraw,
)
// Move history (scrollable)
MoveHistoryDisplay(
moves = engine.getMoveHistory(),
)
// Game controls
GameControls(
onResign = onResign,
onOfferDraw = onOfferDraw,
)
}
}
}
@@ -263,25 +361,91 @@ private fun GameInfoHeader(
}
/**
* Display move history in SAN notation
* Display move history in SAN notation with move numbers
* Shows in a horizontally scrollable container
*/
@Composable
private fun MoveHistoryDisplay(moves: List<String>) {
if (moves.isNotEmpty()) {
Column(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Moves:",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
)
Column(
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = "Move History",
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 4.dp),
)
Text(
text = moves.joinToString(" "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Box(
modifier =
Modifier
.fillMaxWidth()
.height(48.dp)
.background(
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
RoundedCornerShape(8.dp),
).padding(horizontal = 8.dp, vertical = 4.dp),
) {
if (moves.isEmpty()) {
Text(
text = "No moves yet",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.align(Alignment.CenterStart),
)
} else {
val scrollState = rememberScrollState()
Row(
modifier =
Modifier
.horizontalScroll(scrollState)
.align(Alignment.CenterStart),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
// Group moves into pairs (white, black)
moves.chunked(2).forEachIndexed { index, movePair ->
// Move number
Text(
text = "${index + 1}.",
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
// White's move
Text(
text = movePair[0],
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
modifier =
Modifier
.background(
MaterialTheme.colorScheme.surface,
RoundedCornerShape(4.dp),
).padding(horizontal = 4.dp, vertical = 2.dp),
)
// Black's move (if exists)
if (movePair.size > 1) {
Text(
text = movePair[1],
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
modifier =
Modifier
.background(
MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(4.dp),
).padding(horizontal = 4.dp, vertical = 2.dp),
)
}
Spacer(modifier = Modifier.width(8.dp))
}
}
}
}
}
}
@@ -0,0 +1,144 @@
/**
* 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.commons.data
import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Simple data class for cached user display info
*/
data class UserDisplayInfo(
val pubkey: String,
val displayName: String?,
val pictureUrl: String?,
val nip05: String?,
)
/**
* Lightweight user metadata cache for UI display.
* Can be used by any feature (chess, chat, etc.) that needs user info.
*
* Thread-safe via StateFlow updates.
*/
class UserMetadataCache {
private val _metadata = MutableStateFlow<Map<String, UserMetadata>>(emptyMap())
val metadata: StateFlow<Map<String, UserMetadata>> = _metadata.asStateFlow()
private val _pubkeysNeeded = MutableStateFlow<Set<String>>(emptySet())
val pubkeysNeeded: StateFlow<Set<String>> = _pubkeysNeeded.asStateFlow()
/**
* Add or update metadata for a pubkey
*/
fun put(
pubkey: String,
userMetadata: UserMetadata,
) {
_metadata.value = _metadata.value + (pubkey to userMetadata)
_pubkeysNeeded.value = _pubkeysNeeded.value - pubkey
}
/**
* Get metadata for a pubkey (may be null if not cached)
*/
fun get(pubkey: String): UserMetadata? = _metadata.value[pubkey]
/**
* Check if metadata is cached for a pubkey
*/
fun contains(pubkey: String): Boolean = _metadata.value.containsKey(pubkey)
/**
* Request metadata for a pubkey (adds to needed set if not cached)
*/
fun request(pubkey: String) {
if (!contains(pubkey) && pubkey !in _pubkeysNeeded.value) {
_pubkeysNeeded.value = _pubkeysNeeded.value + pubkey
}
}
/**
* Request metadata for multiple pubkeys
*/
fun requestAll(pubkeys: Collection<String>) {
val newNeeded = pubkeys.filter { !contains(it) && it !in _pubkeysNeeded.value }
if (newNeeded.isNotEmpty()) {
_pubkeysNeeded.value = _pubkeysNeeded.value + newNeeded
}
}
/**
* Get display name for a pubkey, falling back to truncated pubkey
*/
fun getDisplayName(pubkey: String): String {
val meta = get(pubkey)
return meta?.bestName() ?: formatPubkeyShort(pubkey)
}
/**
* Get profile picture URL for a pubkey
*/
fun getPictureUrl(pubkey: String): String? = get(pubkey)?.profilePicture()
/**
* Get display info for a pubkey (for UI binding)
*/
fun getDisplayInfo(pubkey: String): UserDisplayInfo {
val meta = get(pubkey)
return UserDisplayInfo(
pubkey = pubkey,
displayName = meta?.bestName(),
pictureUrl = meta?.profilePicture(),
nip05 = meta?.nip05,
)
}
/**
* Clear metadata that was requested but hasn't been fetched
* (call when pubkeys are no longer needed)
*/
fun clearNeeded(pubkeys: Collection<String>) {
_pubkeysNeeded.value = _pubkeysNeeded.value - pubkeys.toSet()
}
/**
* Clear all cached metadata
*/
fun clear() {
_metadata.value = emptyMap()
_pubkeysNeeded.value = emptySet()
}
companion object {
/**
* Format pubkey for display (npub-style truncation)
*/
fun formatPubkeyShort(pubkey: String): String =
if (pubkey.length > 12) {
"${pubkey.take(8)}...${pubkey.takeLast(4)}"
} else {
pubkey
}
}
}
@@ -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,
+3 -1
View File
@@ -124,7 +124,9 @@ kotlin {
implementation(libs.okhttpCoroutines)
// Chess engine for move validation and legal move generation
implementation("io.github.cvb941:kchesslib:1.0.4")
// NOTE: 1.0.4+ uses Java 21's removeLast() which crashes on Android API < 34
// TODO: Test if 1.0.0 works, or fork library to fix
implementation("io.github.cvb941:kchesslib:1.0.0")
}
}
@@ -112,6 +112,27 @@ data class ChessGameEnd(
val pgn: String? = null,
)
/**
* Draw offer event - offer a draw to opponent
* Kind: 30068
*
* Tags:
* - d: game_id
* - p: opponent pubkey
*
* Content: Optional message
*
* Opponent can:
* - Accept by sending a GameEnd event with DRAW_AGREEMENT
* - Decline implicitly by making their next move
* - Decline explicitly (optional, no event needed)
*/
data class ChessDrawOffer(
val gameId: String,
val opponentPubkey: String,
val message: String? = null,
)
/**
* Game termination reason
*/
@@ -132,4 +153,5 @@ object LiveChessEventKinds {
const val GAME_ACCEPT = 30065
const val CHESS_MOVE = 30066
const val GAME_END = 30067
const val DRAW_OFFER = 30068
}
@@ -247,3 +247,48 @@ class LiveChessGameEndEvent(
fun pgn(): String = content
}
/**
* Live Chess Draw Offer Event (Kind 30068)
*
* Offer a draw to opponent. Opponent can accept by sending a game end event
* with DRAW_AGREEMENT termination, or decline/ignore by making their next move.
*
* Tags:
* - d: game_id
* - p: opponent pubkey
*
* Content: Optional message
*/
@Immutable
class LiveChessDrawOfferEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig) {
companion object {
const val KIND = 30068
fun build(
gameId: String,
opponentPubkey: String,
message: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveChessDrawOfferEvent>.() -> Unit = {},
) = eventTemplate(KIND, message, createdAt) {
add(arrayOf("d", gameId))
add(arrayOf("p", opponentPubkey))
alt("Chess draw offer")
initializer()
}
}
fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1)
fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1)
fun message(): String = content
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.quartz.nip64Chess
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -38,19 +39,57 @@ class LiveChessGameState(
val opponentPubkey: String,
val playerColor: Color,
val engine: ChessEngine,
val createdAt: Long = TimeUtils.now(),
) {
companion object {
// Abandon timeout: 24 hours without a move
const val ABANDON_TIMEOUT_SECONDS = 24 * 60 * 60L
// Inactivity warning: 1 hour without a move
const val INACTIVITY_WARNING_SECONDS = 60 * 60L
}
private val _gameStatus = MutableStateFlow<GameStatus>(GameStatus.InProgress)
val gameStatus: StateFlow<GameStatus> = _gameStatus.asStateFlow()
private val _currentPosition = MutableStateFlow(engine.getPosition())
val currentPosition: StateFlow<ChessPosition> = _currentPosition.asStateFlow()
private val _moveHistory = MutableStateFlow<List<String>>(emptyList())
// Initialize from engine's history so freshly loaded games show all moves
private val _moveHistory = MutableStateFlow(engine.getMoveHistory())
val moveHistory: StateFlow<List<String>> = _moveHistory.asStateFlow()
private val _lastError = MutableStateFlow<String?>(null)
val lastError: StateFlow<String?> = _lastError.asStateFlow()
// Track when last activity occurred (move made or received)
private val _lastActivityAt = MutableStateFlow(createdAt)
val lastActivityAt: StateFlow<Long> = _lastActivityAt.asStateFlow()
// Track received move numbers to detect duplicates and out-of-order
private val receivedMoveNumbers = mutableSetOf<Int>()
// Pending moves waiting for earlier moves (move number -> move data)
private val pendingMoves = mutableMapOf<Int, Pair<String, String>>()
// Desync detection
private val _isDesynced = MutableStateFlow(false)
val isDesynced: StateFlow<Boolean> = _isDesynced.asStateFlow()
// Draw offer tracking - pubkey of who offered the draw (null = no pending offer)
private val _pendingDrawOffer = MutableStateFlow<String?>(null)
val pendingDrawOffer: StateFlow<String?> = _pendingDrawOffer.asStateFlow()
/**
* Check if there's a pending draw offer from opponent
*/
fun hasOpponentDrawOffer(): Boolean = _pendingDrawOffer.value == opponentPubkey
/**
* Check if we have an outgoing draw offer
*/
fun hasOurDrawOffer(): Boolean = _pendingDrawOffer.value == playerPubkey
/**
* Check if it's the player's turn
*/
@@ -80,8 +119,12 @@ class LiveChessGameState(
if (result.success && result.san != null && result.position != null) {
_currentPosition.value = result.position
_moveHistory.value = engine.getMoveHistory()
_lastActivityAt.value = TimeUtils.now()
_lastError.value = null
// Making a move implicitly declines any pending draw offer
_pendingDrawOffer.value = null
// Check for game end conditions
checkGameEnd()
@@ -105,7 +148,22 @@ class LiveChessGameState(
fun applyOpponentMove(
san: String,
fen: String,
moveNumber: Int? = null,
): Boolean {
// Check for duplicate move
if (moveNumber != null && receivedMoveNumbers.contains(moveNumber)) {
// Already processed this move, ignore
return true
}
// Check for out-of-order move
val expectedMoveNumber = _moveHistory.value.size + 1
if (moveNumber != null && moveNumber > expectedMoveNumber) {
// Store for later processing
pendingMoves[moveNumber] = san to fen
return true
}
if (isPlayerTurn()) {
_lastError.value = "Received move but it's not opponent's turn"
return false
@@ -115,22 +173,37 @@ class LiveChessGameState(
val result = engine.makeMove(san)
if (result.success) {
// Mark as received
if (moveNumber != null) {
receivedMoveNumbers.add(moveNumber)
}
// Verify the resulting FEN matches what opponent sent
val currentFen = engine.getFen()
if (currentFen != fen) {
// Positions don't match - possible desync
_lastError.value = "Position mismatch after opponent move"
// Positions don't match - desync detected
_isDesynced.value = true
_lastError.value = "Position mismatch - syncing to opponent's position"
// Load the opponent's FEN to stay in sync
engine.loadFen(fen)
} else {
_isDesynced.value = false
}
_currentPosition.value = engine.getPosition()
_moveHistory.value = engine.getMoveHistory()
_lastActivityAt.value = TimeUtils.now()
_lastError.value = null
// Opponent making a move declines any pending draw offer
_pendingDrawOffer.value = null
// Check for game end conditions
checkGameEnd()
// Process any pending moves that are now valid
processPendingMoves()
return true
} else {
_lastError.value = "Invalid opponent move: $san"
@@ -138,6 +211,79 @@ class LiveChessGameState(
}
}
/**
* Process any pending out-of-order moves
*/
private fun processPendingMoves() {
val nextMoveNumber = _moveHistory.value.size + 1
val pendingMove = pendingMoves.remove(nextMoveNumber)
if (pendingMove != null) {
val (san, fen) = pendingMove
applyOpponentMove(san, fen, nextMoveNumber)
}
}
/**
* Force resync to a specific FEN (manual recovery)
*/
fun forceResync(fen: String) {
engine.loadFen(fen)
_currentPosition.value = engine.getPosition()
_moveHistory.value = engine.getMoveHistory()
_isDesynced.value = false
_lastError.value = null
}
/**
* Mark move numbers as already received.
* Used when loading a game from cache to prevent duplicate move application during refresh.
*
* @param moveNumbers Set of move numbers that have been loaded
*/
fun markMovesAsReceived(moveNumbers: Set<Int>) {
receivedMoveNumbers.addAll(moveNumbers)
}
/**
* Check if game appears abandoned (opponent hasn't moved in a long time)
*/
fun isAbandoned(): Boolean {
if (_gameStatus.value != GameStatus.InProgress) return false
if (isPlayerTurn()) return false // Only check when waiting for opponent
val elapsed = TimeUtils.now() - _lastActivityAt.value
return elapsed > ABANDON_TIMEOUT_SECONDS
}
/**
* Check if opponent is inactive (warning threshold)
*/
fun isOpponentInactive(): Boolean {
if (_gameStatus.value != GameStatus.InProgress) return false
if (isPlayerTurn()) return false
val elapsed = TimeUtils.now() - _lastActivityAt.value
return elapsed > INACTIVITY_WARNING_SECONDS
}
/**
* Claim victory due to abandonment
*/
fun claimAbandonmentVictory(): ChessGameEnd? {
if (!isAbandoned()) return null
_gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(playerColor))
return ChessGameEnd(
gameId = gameId,
result = GameResult.getResultForWinner(playerColor),
termination = GameTermination.ABANDONMENT,
winnerPubkey = playerPubkey,
opponentPubkey = opponentPubkey,
pgn = generatePGN(),
)
}
/**
* Offer resignation (player resigns)
*/
@@ -155,9 +301,37 @@ class LiveChessGameState(
}
/**
* Offer or accept draw
* Offer a draw to opponent.
* Returns the draw offer data to be published to Nostr.
*/
fun offerDraw(): ChessGameEnd {
fun offerDraw(): ChessDrawOffer {
_pendingDrawOffer.value = playerPubkey
return ChessDrawOffer(
gameId = gameId,
opponentPubkey = opponentPubkey,
)
}
/**
* Handle receiving a draw offer from opponent (via Nostr event)
*/
fun receiveDrawOffer(fromPubkey: String): Boolean {
if (fromPubkey != opponentPubkey) return false
_pendingDrawOffer.value = opponentPubkey
return true
}
/**
* Accept opponent's draw offer.
* Returns game end data to be published to Nostr.
*/
fun acceptDraw(): ChessGameEnd? {
if (_pendingDrawOffer.value != opponentPubkey) {
_lastError.value = "No draw offer to accept"
return null
}
_pendingDrawOffer.value = null
_gameStatus.value = GameStatus.Finished(GameResult.DRAW)
return ChessGameEnd(
@@ -170,6 +344,13 @@ class LiveChessGameState(
)
}
/**
* Decline a draw offer (explicit decline, also happens implicitly on move)
*/
fun declineDraw() {
_pendingDrawOffer.value = null
}
/**
* Check if game has ended (checkmate, stalemate, etc.)
*/
@@ -112,6 +112,7 @@ actual class ChessEngine {
val sq = Square.fromValue(square.uppercase())
return board
.legalMoves()
.filterNotNull()
.filter { it.from == sq }
.map { it.to.toString().lowercase() }
}
@@ -173,7 +174,7 @@ actual class ChessEngine {
actual fun getMoveHistory(): List<String> {
// kchesslib stores move history
return board.backup.map { it.move.toString() }
return board.backup.mapNotNull { it?.move?.toString() }
}
/**