First chess refactoring

This commit is contained in:
Vitor Pamplona
2026-03-02 18:14:08 -05:00
parent df72a6e094
commit 934b1fd5ed
9 changed files with 232 additions and 165 deletions
@@ -31,7 +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.privateDM.datasource.ChatroomFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler 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.chats.rooms.datasource.ChatroomListFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler 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.discover.datasource.DiscoveryFilterAssembler
import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler
@@ -71,6 +71,7 @@ import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner
import com.vitorpamplona.amethyst.commons.chess.LiveChessGameScreen import com.vitorpamplona.amethyst.commons.chess.LiveChessGameScreen
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessSubscription
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
@@ -128,7 +129,7 @@ fun ChessGameScreen(
// Subscribe to chess events when game screen is visible // Subscribe to chess events when game screen is visible
// This is critical - without it, no new events will arrive from relays // This is critical - without it, no new events will arrive from relays
ChessSubscription(accountViewModel, chessViewModel) ChessSubscription(chessViewModel, accountViewModel)
// Handle initial game loading // Handle initial game loading
// Once game is in maps, gameState above will always have the latest // Once game is in maps, gameState above will always have the latest
@@ -37,7 +37,6 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.CheckCircle import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
@@ -53,7 +52,6 @@ import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
@@ -62,7 +60,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -72,20 +69,23 @@ import androidx.compose.ui.unit.dp
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.chess.ActiveGameCard
import com.vitorpamplona.amethyst.commons.chess.ChallengeCard
import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastBanner import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastBanner
import com.vitorpamplona.amethyst.commons.chess.ChessChallenge import com.vitorpamplona.amethyst.commons.chess.ChessChallenge
import com.vitorpamplona.amethyst.commons.chess.ChessConfig import com.vitorpamplona.amethyst.commons.chess.ChessConfig
import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner
import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog
import com.vitorpamplona.amethyst.commons.chess.PublicGame import com.vitorpamplona.amethyst.commons.chess.PublicGame
import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox
import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessSubscription
import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor
/** /**
@@ -106,47 +106,14 @@ fun ChessLobbyScreen(
factory = ChessViewModelFactory(accountViewModel.account), factory = ChessViewModelFactory(accountViewModel.account),
) )
val activeGames by chessViewModel.activeGames.collectAsState()
val spectatingGames by chessViewModel.spectatingGames.collectAsState()
val publicGames by chessViewModel.publicGames.collectAsState()
val challenges by chessViewModel.challenges.collectAsState()
val error by chessViewModel.error.collectAsState()
val broadcastStatus by chessViewModel.broadcastStatus.collectAsState()
val syncStatus by chessViewModel.syncStatus.collectAsState()
val selectedGameId by chessViewModel.selectedGameId.collectAsState()
val userPubkey = accountViewModel.account.userProfile().pubkeyHex
var showNewGameDialog by remember { mutableStateOf(false) }
var showRelaySettings by remember { mutableStateOf(false) }
var isRefreshing by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope()
// Get relay information for settings display
val inboxRelays by accountViewModel.account.notificationRelays.flow
.collectAsState()
val outboxRelays by accountViewModel.account.outboxRelays.flow
.collectAsState()
val globalRelays by accountViewModel.account.defaultGlobalRelays.flow
.collectAsState()
// Subscribe to chess events when screen is visible // Subscribe to chess events when screen is visible
ChessSubscription(accountViewModel, chessViewModel) ChessSubscription(chessViewModel, accountViewModel)
// Clear any stale game selection on mount // Clear any stale game selection on mount
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
chessViewModel.selectGame(null) 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)
}
}
}
// Set lobby mode (poll all games) when screen is visible // Set lobby mode (poll all games) when screen is visible
// Don't stop polling in onDispose - ViewModel lifecycle handles that // Don't stop polling in onDispose - ViewModel lifecycle handles that
// This prevents the race where lobby's onDispose fires AFTER game screen enters // This prevents the race where lobby's onDispose fires AFTER game screen enters
@@ -161,6 +128,42 @@ fun ChessLobbyScreen(
} }
} }
ChessLobbyScreen(chessViewModel, accountViewModel, nav)
}
/**
* Chess lobby screen showing challenges and active games
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ChessLobbyScreen(
chessViewModel: ChessViewModelNew,
accountViewModel: AccountViewModel,
nav: INav,
) {
val activeGames by chessViewModel.activeGames.collectAsState()
val spectatingGames by chessViewModel.spectatingGames.collectAsState()
val publicGames by chessViewModel.publicGames.collectAsState()
val challenges by chessViewModel.challenges.collectAsState()
val error by chessViewModel.error.collectAsState()
val broadcastStatus by chessViewModel.broadcastStatus.collectAsState()
val syncStatus by chessViewModel.syncStatus.collectAsState()
val selectedGameId by chessViewModel.selectedGameId.collectAsState()
val userPubkey = accountViewModel.account.userProfile().pubkeyHex
var showNewGameDialog by remember { mutableStateOf(false) }
var showRelaySettings by remember { mutableStateOf(false) }
// 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)
}
}
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -187,25 +190,14 @@ fun ChessLobbyScreen(
NewChessGameButton { showNewGameDialog = true } NewChessGameButton { showNewGameDialog = true }
}, },
) { paddingValues -> ) { paddingValues ->
PullToRefreshBox( RefresheableBox(
isRefreshing = isRefreshing, onRefresh = { chessViewModel.forceRefresh() },
onRefresh = {
scope.launch {
isRefreshing = true
chessViewModel.forceRefresh()
delay(500) // Brief delay for visual feedback
isRefreshing = false
}
},
modifier =
Modifier
.fillMaxSize()
.padding(paddingValues),
) { ) {
Column( Column(
modifier = modifier =
Modifier Modifier
.fillMaxSize() .fillMaxSize()
.padding(paddingValues)
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) { ) {
// Sync status banner (shows relay progress during loading) // Sync status banner (shows relay progress during loading)
@@ -285,11 +277,9 @@ fun ChessLobbyScreen(
sheetState = rememberModalBottomSheetState(), sheetState = rememberModalBottomSheetState(),
) { ) {
ChessRelaySettingsSheet( ChessRelaySettingsSheet(
inboxRelays = inboxRelays.map { it.displayUrl() },
outboxRelays = outboxRelays.map { it.displayUrl() },
globalRelays = globalRelays.map { it.displayUrl() },
challengeCount = challenges.size, challengeCount = challenges.size,
publicGameCount = publicGames.size, publicGameCount = publicGames.size,
accountViewModel = accountViewModel,
) )
} }
} }
@@ -373,7 +363,7 @@ fun ChessLobbyContent(
remember(state.opponentPubkey) { remember(state.opponentPubkey) {
accountViewModel.checkGetOrCreateUser(state.opponentPubkey)?.toBestDisplayName() ?: state.opponentPubkey.take(8) accountViewModel.checkGetOrCreateUser(state.opponentPubkey)?.toBestDisplayName() ?: state.opponentPubkey.take(8)
} }
com.vitorpamplona.amethyst.commons.chess.ActiveGameCard( ActiveGameCard(
gameId = gameId, gameId = gameId,
opponentName = displayName, opponentName = displayName,
isYourTurn = state.isPlayerTurn(), isYourTurn = state.isPlayerTurn(),
@@ -452,7 +442,7 @@ fun ChessLobbyContent(
?: accountViewModel.checkGetOrCreateUser(challenge.challengerPubkey)?.toBestDisplayName() ?: accountViewModel.checkGetOrCreateUser(challenge.challengerPubkey)?.toBestDisplayName()
?: challenge.challengerPubkey.take(8) ?: challenge.challengerPubkey.take(8)
} }
com.vitorpamplona.amethyst.commons.chess.ChallengeCard( ChallengeCard(
challengerName = displayName, challengerName = displayName,
challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE,
isIncoming = true, isIncoming = true,
@@ -526,13 +516,19 @@ fun ChessLobbyContent(
*/ */
@Composable @Composable
private fun ChessRelaySettingsSheet( private fun ChessRelaySettingsSheet(
inboxRelays: List<String>,
outboxRelays: List<String>,
globalRelays: List<String>,
challengeCount: Int, challengeCount: Int,
publicGameCount: Int, publicGameCount: Int,
connectedRelayCount: Int = 0, connectedRelayCount: Int = 0,
accountViewModel: AccountViewModel,
) { ) {
// Get relay information for settings display
val inboxRelays by accountViewModel.account.notificationRelays.flow
.collectAsState()
val outboxRelays by accountViewModel.account.outboxRelays.flow
.collectAsState()
val globalRelays by accountViewModel.account.defaultGlobalRelays.flow
.collectAsState()
Column( Column(
modifier = modifier =
Modifier Modifier
@@ -601,10 +597,10 @@ private fun ChessRelaySettingsSheet(
// Show chess relays and their connection status // Show chess relays and their connection status
ChessConfig.CHESS_RELAY_NAMES.forEach { relay -> ChessConfig.CHESS_RELAY_NAMES.forEach { relay ->
val isConnected = val isConnected =
inboxRelays.any { it.contains(relay) } || inboxRelays.contains(relay) ||
outboxRelays.any { it.contains(relay) } || outboxRelays.contains(relay) ||
globalRelays.any { it.contains(relay) } globalRelays.contains(relay)
ChessRelayRow(relayUrl = "wss://$relay/", isConnected = isConnected) ChessRelayRow(relay = relay, isConnected = isConnected)
} }
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
@@ -714,7 +710,7 @@ private fun ChessRelaySettingsSheet(
@Composable @Composable
private fun RelayRow( private fun RelayRow(
relayUrl: String, relayUrl: NormalizedRelayUrl,
isPreferred: Boolean = false, isPreferred: Boolean = false,
) { ) {
Row( Row(
@@ -737,7 +733,7 @@ private fun RelayRow(
modifier = Modifier.size(12.dp), modifier = Modifier.size(12.dp),
) )
Text( Text(
text = relayUrl.removePrefix("wss://").removePrefix("ws://"), text = relayUrl.displayUrl(),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontWeight = if (isPreferred) FontWeight.Medium else FontWeight.Normal, fontWeight = if (isPreferred) FontWeight.Medium else FontWeight.Normal,
) )
@@ -753,7 +749,7 @@ private fun RelayRow(
@Composable @Composable
private fun ChessRelayRow( private fun ChessRelayRow(
relayUrl: String, relay: NormalizedRelayUrl,
isConnected: Boolean, isConnected: Boolean,
) { ) {
Row( Row(
@@ -781,7 +777,7 @@ private fun ChessRelayRow(
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
) )
Text( Text(
text = relayUrl.removePrefix("wss://").removePrefix("ws://").removeSuffix("/"), text = relay.displayUrl(),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium, fontWeight = FontWeight.Medium,
) )
@@ -20,6 +20,7 @@
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess
import androidx.compose.runtime.Stable
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus
@@ -46,16 +47,13 @@ import kotlinx.coroutines.flow.StateFlow
* - Platform adapter creation * - Platform adapter creation
* - State exposure to Compose UI * - State exposure to Compose UI
*/ */
@Stable
class ChessViewModelNew( class ChessViewModelNew(
private val account: Account, private val account: Account,
) : ViewModel() { ) : ViewModel() {
// Instance ID for debugging ViewModel sharing // Instance ID for debugging ViewModel sharing
val instanceId = System.identityHashCode(this) val instanceId = System.identityHashCode(this)
init {
println("[ChessViewModelNew] Created instance $instanceId for ${account.userProfile().pubkeyHex.take(8)}")
}
// Platform adapters // Platform adapters
private val publisher = AndroidChessPublisher(account) private val publisher = AndroidChessPublisher(account)
private val fetcher = AndroidRelayFetcher(account) private val fetcher = AndroidRelayFetcher(account)
@@ -0,0 +1,54 @@
/*
* 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.datasource
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/**
* 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(),
val opponentPubkeys: Set<String> = emptySet(),
)
/**
* 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 = key.userPubkey
}
@@ -0,0 +1,42 @@
/*
* 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.datasource
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
/**
* 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() }
}
@@ -18,23 +18,16 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * 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. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionState
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription
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.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelNew
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
/** /**
* Subscribe to chess events when the Chess screen is active. * Subscribe to chess events when the Chess screen is active.
@@ -44,8 +37,8 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
*/ */
@Composable @Composable
fun ChessSubscription( fun ChessSubscription(
accountViewModel: AccountViewModel,
chessViewModel: ChessViewModelNew, chessViewModel: ChessViewModelNew,
accountViewModel: AccountViewModel,
) { ) {
// Get active game IDs from the view model for game-specific subscriptions // Get active game IDs from the view model for game-specific subscriptions
val activeGames by chessViewModel.activeGames.collectAsStateWithLifecycle() val activeGames by chessViewModel.activeGames.collectAsStateWithLifecycle()
@@ -85,80 +78,3 @@ fun ChessSubscription(
} }
} }
} }
/**
* 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(),
val opponentPubkeys: 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 using the shared [ChessFilterBuilder].
*
* Following jesterui's approach:
* 1. Fetch ALL challenges from ALL connected relays (no author/tag restriction)
* 2. Filter client-side based on user's preferences (Global/Follows)
* 3. Game-specific subscriptions for active games
*
* This ensures we see:
* - Open challenges from anyone
* - Challenges directed at us
* - Public games to spectate
*/
fun filterChessEvents(
key: ChessQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// Convert Android ChessQueryState to shared ChessSubscriptionState
val state =
ChessSubscriptionState(
userPubkey = key.userPubkey,
relays = key.inboxRelays + key.globalRelays,
activeGameIds = key.activeGameIds,
opponentPubkeys = key.opponentPubkeys,
)
// Use shared filter builder for consistent behavior with Desktop
return ChessFilterBuilder.buildAllFilters(state) { relay ->
since?.get(relay)?.time
}
}
@@ -0,0 +1,58 @@
/*
* 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.datasource
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionState
import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
/**
* Create relay filters for chess events using the shared [ChessFilterBuilder].
*
* Following jesterui's approach:
* 1. Fetch ALL challenges from ALL connected relays (no author/tag restriction)
* 2. Filter client-side based on user's preferences (Global/Follows)
* 3. Game-specific subscriptions for active games
*
* This ensures we see:
* - Open challenges from anyone
* - Challenges directed at us
* - Public games to spectate
*/
fun filterChessEvents(
key: ChessQueryState,
since: SincePerRelayMap?,
): List<RelayBasedFilter> {
// Convert Android ChessQueryState to shared ChessSubscriptionState
val state =
ChessSubscriptionState(
userPubkey = key.userPubkey,
relays = key.inboxRelays + key.globalRelays,
activeGameIds = key.activeGameIds,
opponentPubkeys = key.opponentPubkeys,
)
// Use shared filter builder for consistent behavior with Desktop
return ChessFilterBuilder.buildAllFilters(state) { relay ->
since?.get(relay)?.time
}
}
@@ -20,6 +20,8 @@
*/ */
package com.vitorpamplona.amethyst.commons.chess package com.vitorpamplona.amethyst.commons.chess
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl
/** /**
* Global chess configuration shared across Android and Desktop. * Global chess configuration shared across Android and Desktop.
* *
@@ -43,9 +45,9 @@ object ChessConfig {
*/ */
val CHESS_RELAY_NAMES = val CHESS_RELAY_NAMES =
listOf( listOf(
"relay.damus.io", "relay.damus.io".normalizeRelayUrl(),
"nos.lol", "nos.lol".normalizeRelayUrl(),
"relay.primal.net", "relay.primal.net".normalizeRelayUrl(),
) )
/** /**