Merge branch 'main' into feat/desktop-private-dms
This commit is contained in:
@@ -36,6 +36,7 @@ import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Article
|
||||
import androidx.compose.material.icons.filled.Email
|
||||
import androidx.compose.material.icons.filled.Extension
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
import androidx.compose.material.icons.filled.Person
|
||||
@@ -83,6 +84,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountState
|
||||
import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState
|
||||
import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount
|
||||
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
|
||||
@@ -124,6 +126,8 @@ sealed class DesktopScreen {
|
||||
|
||||
object Notifications : DesktopScreen()
|
||||
|
||||
object Chess : DesktopScreen()
|
||||
|
||||
object MyProfile : DesktopScreen()
|
||||
|
||||
data class UserProfile(
|
||||
@@ -510,6 +514,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") },
|
||||
@@ -617,6 +628,14 @@ fun MainContent(
|
||||
NotificationsScreen(relayManager, account, subscriptionsCoordinator)
|
||||
}
|
||||
|
||||
DesktopScreen.Chess -> {
|
||||
ChessScreen(
|
||||
relayManager = relayManager,
|
||||
account = account,
|
||||
onBack = { onScreenChange(DesktopScreen.Feed) },
|
||||
)
|
||||
}
|
||||
|
||||
DesktopScreen.MyProfile -> {
|
||||
UserProfileScreen(
|
||||
pubKeyHex = account.pubKeyHex,
|
||||
|
||||
+823
@@ -0,0 +1,823 @@
|
||||
/*
|
||||
* 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.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.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.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
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.material.icons.filled.Visibility
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
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.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.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.min
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastBanner
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessChallenge
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessConfig
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner
|
||||
import com.vitorpamplona.amethyst.commons.chess.CompletedGame
|
||||
import com.vitorpamplona.amethyst.commons.chess.InteractiveChessBoard
|
||||
import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog
|
||||
import com.vitorpamplona.amethyst.commons.chess.PublicGame
|
||||
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.createChessSubscriptionWithGames
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataListSubscription
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
|
||||
import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor
|
||||
|
||||
/**
|
||||
* 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) {
|
||||
DesktopChessViewModelNew(account, relayManager, scope)
|
||||
}
|
||||
val relayStatuses by relayManager.relayStatuses.collectAsState()
|
||||
val broadcastStatus by viewModel.broadcastStatus.collectAsState()
|
||||
val activeGames by viewModel.activeGames.collectAsState()
|
||||
// Observe state version to force recomposition when game state changes
|
||||
val stateVersion by viewModel.stateVersion.collectAsState()
|
||||
|
||||
// Ensure chess relays are added to the relay manager for broadcasting
|
||||
LaunchedEffect(Unit) {
|
||||
ChessConfig.CHESS_RELAYS.forEach { relayUrl ->
|
||||
relayManager.addRelay(relayUrl)
|
||||
}
|
||||
println("[ChessScreen] Added ${ChessConfig.CHESS_RELAYS.size} chess relays to relay manager")
|
||||
}
|
||||
|
||||
// Derive stable keys to avoid recomposition from LiveChessGameState identity changes
|
||||
val activeGameIds = remember(activeGames.keys) { activeGames.keys.toSet() }
|
||||
val opponentPubkeys =
|
||||
remember(activeGameIds) {
|
||||
activeGames.values.map { it.opponentPubkey }.toSet()
|
||||
}
|
||||
|
||||
// Subscribe to chess events from dedicated chess relays
|
||||
// Re-subscribes when active games change
|
||||
val chessRelays =
|
||||
remember {
|
||||
ChessConfig.CHESS_RELAYS
|
||||
.map {
|
||||
com.vitorpamplona.quartz.nip01Core.relay.normalizer
|
||||
.NormalizedRelayUrl(it)
|
||||
}.toSet()
|
||||
}
|
||||
rememberSubscription(chessRelays, account, activeGameIds, opponentPubkeys, relayManager = relayManager) {
|
||||
createChessSubscriptionWithGames(
|
||||
relays = chessRelays,
|
||||
userPubkey = account.pubKeyHex,
|
||||
activeGameIds = activeGameIds,
|
||||
opponentPubkeys = opponentPubkeys,
|
||||
onEvent = { event, _, _, _ ->
|
||||
viewModel.handleIncomingEvent(event)
|
||||
},
|
||||
onEose = { _, _ ->
|
||||
// ChessLobbyLogic handles loading state internally
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// 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 challenges by viewModel.challenges.collectAsState()
|
||||
val spectatingGames by viewModel.spectatingGames.collectAsState()
|
||||
val publicGames by viewModel.publicGames.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()
|
||||
val isRefreshing by viewModel.isRefreshing.collectAsState()
|
||||
val syncStatus by viewModel.syncStatus.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.forceRefresh() }) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sync status banner (shown in both lobby and game views)
|
||||
ChessSyncBanner(
|
||||
status = syncStatus,
|
||||
onRetry = { viewModel.forceRefresh() },
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
// Broadcast status banner (shows when publishing moves)
|
||||
ChessBroadcastBanner(
|
||||
status = broadcastStatus,
|
||||
onTap = { viewModel.forceRefresh() },
|
||||
)
|
||||
|
||||
// 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) {
|
||||
// Set focused game mode - only poll this game, not others
|
||||
LaunchedEffect(selectedGameId) {
|
||||
viewModel.setFocusedGame(selectedGameId!!)
|
||||
}
|
||||
|
||||
// Use stateVersion to ensure recomposition when game state changes
|
||||
val gameState =
|
||||
remember(selectedGameId, stateVersion) {
|
||||
viewModel.getGameState(selectedGameId!!)
|
||||
}
|
||||
if (gameState != null) {
|
||||
// Determine spectator status:
|
||||
// 1. If game was accepted locally, user is definitely NOT a spectator
|
||||
// 2. Otherwise, check which map the game is in
|
||||
val wasAccepted = viewModel.wasAccepted(selectedGameId!!)
|
||||
val isSpectating = !wasAccepted && spectatingGames.containsKey(selectedGameId)
|
||||
|
||||
DesktopChessGameLayout(
|
||||
gameState = gameState,
|
||||
opponentName = viewModel.userMetadataCache.getDisplayName(gameState.opponentPubkey),
|
||||
opponentPicture = viewModel.userMetadataCache.getPictureUrl(gameState.opponentPubkey),
|
||||
onMoveMade = { from, to, _ ->
|
||||
viewModel.publishMove(gameState.startEventId, from, to)
|
||||
},
|
||||
onResign = { viewModel.resign(gameState.startEventId) },
|
||||
isSpectatorOverride = isSpectating,
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Clear focused game mode when returning to lobby - poll all games
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.clearFocusedGame()
|
||||
}
|
||||
|
||||
// Track outgoing challenges to scroll to top when a new one is created
|
||||
val outgoingChallengesCount = challenges.count { it.isFrom(account.pubKeyHex) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// Scroll to top when user creates a new challenge
|
||||
LaunchedEffect(outgoingChallengesCount) {
|
||||
if (outgoingChallengesCount > 0) {
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
|
||||
ChessLobby(
|
||||
challenges = challenges,
|
||||
activeGames = activeGames,
|
||||
spectatingGames = spectatingGames,
|
||||
publicGames = publicGames,
|
||||
completedGames = completedGames,
|
||||
userPubkey = account.pubKeyHex,
|
||||
metadataCache = viewModel.userMetadataCache,
|
||||
onAcceptChallenge = { viewModel.acceptChallenge(it) },
|
||||
onOpenOwnChallenge = { viewModel.openOwnChallenge(it) },
|
||||
onWatchGame = { viewModel.loadGameAsSpectator(it) },
|
||||
onSelectGame = { viewModel.selectGame(it) },
|
||||
listState = listState,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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<ChessChallenge>,
|
||||
activeGames: Map<String, com.vitorpamplona.quartz.nip64Chess.LiveChessGameState>,
|
||||
spectatingGames: Map<String, com.vitorpamplona.quartz.nip64Chess.LiveChessGameState>,
|
||||
publicGames: List<PublicGame>,
|
||||
completedGames: List<CompletedGame>,
|
||||
userPubkey: String,
|
||||
metadataCache: UserMetadataCache,
|
||||
onAcceptChallenge: (ChessChallenge) -> Unit,
|
||||
onOpenOwnChallenge: (ChessChallenge) -> Unit,
|
||||
onWatchGame: (String) -> Unit,
|
||||
onSelectGame: (String) -> Unit,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
) {
|
||||
val hasContent =
|
||||
activeGames.isNotEmpty() || spectatingGames.isNotEmpty() ||
|
||||
publicGames.isNotEmpty() || challenges.isNotEmpty() || completedGames.isNotEmpty()
|
||||
|
||||
if (!hasContent) {
|
||||
// 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 or refresh to load from relays",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Active games section (user is participant)
|
||||
if (activeGames.isNotEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
"Your Games",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) ->
|
||||
com.vitorpamplona.amethyst.commons.chess.ActiveGameCard(
|
||||
gameId = gameId,
|
||||
opponentName = metadataCache.getDisplayName(state.opponentPubkey),
|
||||
isYourTurn = state.isPlayerTurn(),
|
||||
onClick = { onSelectGame(gameId) },
|
||||
avatar = {
|
||||
UserAvatar(
|
||||
userHex = state.opponentPubkey,
|
||||
pictureUrl = metadataCache.getPictureUrl(state.opponentPubkey),
|
||||
size = 40.dp,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// User's outgoing challenges (right after active games - user wants to see their new challenges)
|
||||
val outgoingChallenges = challenges.filter { it.isFrom(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 = { "outgoing-${it.eventId}" }) { challenge ->
|
||||
com.vitorpamplona.amethyst.commons.chess.OutgoingChallengeCard(
|
||||
opponentName = challenge.opponentPubkey?.let { metadataCache.getDisplayName(it) },
|
||||
userPlaysWhite = challenge.challengerColor == ChessColor.WHITE,
|
||||
onClick = { onOpenOwnChallenge(challenge) },
|
||||
avatar =
|
||||
challenge.opponentPubkey?.let { pubkey ->
|
||||
{
|
||||
UserAvatar(
|
||||
userHex = pubkey,
|
||||
pictureUrl = metadataCache.getPictureUrl(pubkey),
|
||||
size = 40.dp,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Games user is spectating
|
||||
if (spectatingGames.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Watching",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
items(spectatingGames.entries.toList(), key = { "spectating-${it.key}" }) { (gameId, state) ->
|
||||
val moveCount by state.moveHistory.collectAsState()
|
||||
com.vitorpamplona.amethyst.commons.chess.SpectatingGameCard(
|
||||
moveCount = moveCount.size,
|
||||
onClick = { onSelectGame(gameId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Incoming challenges (directed at user)
|
||||
val incomingChallenges = challenges.filter { it.isDirectedAt(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 = { "incoming-${it.eventId}" }) { challenge ->
|
||||
com.vitorpamplona.amethyst.commons.chess.ChallengeCard(
|
||||
challengerName = challenge.challengerDisplayName ?: metadataCache.getDisplayName(challenge.challengerPubkey),
|
||||
challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE,
|
||||
isIncoming = true,
|
||||
onAccept = { onAcceptChallenge(challenge) },
|
||||
avatar = {
|
||||
UserAvatar(
|
||||
userHex = challenge.challengerPubkey,
|
||||
pictureUrl = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey),
|
||||
size = 40.dp,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Open challenges from others (can join)
|
||||
val openChallenges = challenges.filter { it.isOpen && !it.isFrom(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 = { "open-${it.eventId}" }) { challenge ->
|
||||
com.vitorpamplona.amethyst.commons.chess.ChallengeCard(
|
||||
challengerName = challenge.challengerDisplayName ?: metadataCache.getDisplayName(challenge.challengerPubkey),
|
||||
challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE,
|
||||
isIncoming = false,
|
||||
onAccept = { onAcceptChallenge(challenge) },
|
||||
avatar = {
|
||||
UserAvatar(
|
||||
userHex = challenge.challengerPubkey,
|
||||
pictureUrl = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey),
|
||||
size = 40.dp,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Live games to watch (public games user is not part of)
|
||||
if (publicGames.isNotEmpty()) {
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
Text(
|
||||
"Live Games",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(vertical = 8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
items(publicGames, key = { "public-${it.gameId}" }) { game ->
|
||||
com.vitorpamplona.amethyst.commons.chess.PublicGameCard(
|
||||
whiteName = game.whiteDisplayName ?: metadataCache.getDisplayName(game.whitePubkey),
|
||||
blackName = game.blackDisplayName ?: metadataCache.getDisplayName(game.blackPubkey),
|
||||
moveCount = game.moveCount,
|
||||
onWatch = { onWatchGame(game.gameId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.distinctBy { it.gameId }.take(10),
|
||||
key = { "completed-${it.gameId}-${it.completedAt}" },
|
||||
) { game ->
|
||||
// Derive opponent pubkey based on who the user is
|
||||
val opponentPubkey =
|
||||
if (game.whitePubkey == userPubkey) game.blackPubkey else game.whitePubkey
|
||||
com.vitorpamplona.amethyst.commons.chess.CompletedGameCard(
|
||||
opponentName = game.blackDisplayName ?: game.whiteDisplayName ?: metadataCache.getDisplayName(opponentPubkey),
|
||||
result = game.result,
|
||||
didUserWin = game.didUserWin(userPubkey),
|
||||
isDraw = game.isDraw,
|
||||
moveCount = game.moveCount,
|
||||
avatar = {
|
||||
UserAvatar(
|
||||
userHex = opponentPubkey,
|
||||
pictureUrl = metadataCache.getPictureUrl(opponentPubkey),
|
||||
size = 40.dp,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Bottom padding
|
||||
item {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop-optimized chess game layout with board on left, info/controls on right
|
||||
*
|
||||
* @param isSpectatorOverride If non-null, overrides gameState.isSpectator. Use when spectator
|
||||
* status is determined by which map the game is in (activeGames vs spectatingGames).
|
||||
*/
|
||||
@Composable
|
||||
private fun DesktopChessGameLayout(
|
||||
gameState: com.vitorpamplona.quartz.nip64Chess.LiveChessGameState,
|
||||
opponentName: String,
|
||||
opponentPicture: String?,
|
||||
onMoveMade: (from: String, to: String, san: String) -> Unit,
|
||||
onResign: () -> Unit,
|
||||
isSpectatorOverride: Boolean? = null,
|
||||
) {
|
||||
// Collect state flows to trigger recomposition on changes
|
||||
val currentPosition by gameState.currentPosition.collectAsState()
|
||||
val moveHistory by gameState.moveHistory.collectAsState()
|
||||
|
||||
val engine = gameState.engine
|
||||
val playerColor = gameState.playerColor
|
||||
val startEventId = gameState.startEventId
|
||||
val opponentPubkey = gameState.opponentPubkey
|
||||
val isSpectator = isSpectatorOverride ?: gameState.isSpectator
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier.fillMaxSize().padding(16.dp),
|
||||
) {
|
||||
// Calculate board size based on available space
|
||||
// Leave room for the info panel (300dp + 24dp spacing)
|
||||
val infoPanelWidth = 300.dp + 24.dp
|
||||
val availableWidth = maxWidth - infoPanelWidth
|
||||
val availableHeight = maxHeight
|
||||
// Board should fit within available space, maintaining square aspect ratio
|
||||
val boardSize = min(availableWidth, availableHeight).coerceIn(200.dp, 520.dp)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
// Left side: Chess board
|
||||
Box(
|
||||
modifier = Modifier.fillMaxHeight(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
InteractiveChessBoard(
|
||||
engine = engine,
|
||||
boardSize = boardSize,
|
||||
flipped = if (isSpectator) false else playerColor == com.vitorpamplona.quartz.nip64Chess.Color.BLACK,
|
||||
playerColor = playerColor,
|
||||
isSpectator = isSpectator,
|
||||
positionVersion = moveHistory.size,
|
||||
onMoveMade = onMoveMade,
|
||||
)
|
||||
}
|
||||
|
||||
// Right side: Game info, moves, controls
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.width(300.dp)
|
||||
.fillMaxHeight()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
// Extract human-readable game name
|
||||
val gameName =
|
||||
remember(startEventId) {
|
||||
ChessGameNameGenerator.extractDisplayName(startEventId)
|
||||
}
|
||||
|
||||
// Game info card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
// Show readable game name if available
|
||||
if (gameName != null) {
|
||||
Text(
|
||||
gameName,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
} else {
|
||||
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 {
|
||||
if (isSpectator) {
|
||||
Text(
|
||||
"Spectating",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
)
|
||||
} else {
|
||||
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
|
||||
|
||||
if (isSpectator) {
|
||||
Text(
|
||||
"${if (currentTurn == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}'s turn",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
} else {
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Game controls card (only for participants, not spectators)
|
||||
if (!isSpectator) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
"Actions",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
OutlinedButton(
|
||||
onClick = onResign,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text("Resign")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Spectator info card
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(Icons.Default.Visibility, contentDescription = null)
|
||||
Text(
|
||||
"Watching game - spectator mode",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Game ID (small footer)
|
||||
Text(
|
||||
"Game: ${startEventId.take(16)}...",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+397
@@ -0,0 +1,397 @@
|
||||
/*
|
||||
* 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.chess.ChessConfig
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessEventBroadcaster
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessEventPublisher
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetchHelper
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetcher
|
||||
import com.vitorpamplona.amethyst.commons.chess.IUserMetadataProvider
|
||||
import com.vitorpamplona.amethyst.commons.chess.RelayFetchProgress
|
||||
import com.vitorpamplona.amethyst.commons.chess.RelayGameSummary
|
||||
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder
|
||||
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.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd
|
||||
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.Color
|
||||
import com.vitorpamplona.quartz.nip64Chess.JesterEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents
|
||||
import com.vitorpamplona.quartz.nip64Chess.JesterProtocol
|
||||
import com.vitorpamplona.quartz.nip64Chess.toJesterEvent
|
||||
|
||||
/**
|
||||
* Desktop implementation of ChessEventPublisher using Jester protocol.
|
||||
* Wraps account.signer.sign() + relayManager.broadcastToAll() for event publishing.
|
||||
*
|
||||
* Jester Protocol:
|
||||
* - All chess events use kind 30
|
||||
* - publishStart creates a start event (content.kind=0)
|
||||
* - publishMove creates a move event (content.kind=1) with full history
|
||||
* - publishGameEnd creates a move event with result in content
|
||||
*/
|
||||
class DesktopChessPublisher(
|
||||
private val account: AccountState.LoggedIn,
|
||||
private val relayManager: DesktopRelayConnectionManager,
|
||||
) : ChessEventPublisher {
|
||||
private val broadcaster = ChessEventBroadcaster(relayManager.client)
|
||||
|
||||
/**
|
||||
* Broadcast an event to the dedicated chess relays with reliable delivery.
|
||||
* Uses ChessEventBroadcaster to ensure relay connections before sending.
|
||||
*/
|
||||
private suspend fun broadcastToChessRelays(event: com.vitorpamplona.quartz.nip01Core.core.Event): Boolean {
|
||||
println("[DesktopChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster")
|
||||
val result = broadcaster.broadcast(event)
|
||||
println("[DesktopChessPublisher] Broadcast result: ${result.message}")
|
||||
return result.success
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a game start event (challenge).
|
||||
* Returns the startEventId (event ID) if successful.
|
||||
*/
|
||||
override suspend fun publishStart(
|
||||
playerColor: Color,
|
||||
opponentPubkey: String?,
|
||||
): String? =
|
||||
try {
|
||||
val template =
|
||||
if (opponentPubkey != null) {
|
||||
JesterEvent.buildPrivateStart(
|
||||
opponentPubkey = opponentPubkey,
|
||||
playerColor = playerColor,
|
||||
)
|
||||
} else {
|
||||
JesterEvent.buildStart(
|
||||
playerColor = playerColor,
|
||||
)
|
||||
}
|
||||
val signedEvent = account.signer.sign(template)
|
||||
val success = broadcastToChessRelays(signedEvent)
|
||||
if (success) signedEvent.id else null
|
||||
} catch (e: Exception) {
|
||||
println("[DesktopChessPublisher] publishStart failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a move event.
|
||||
* Returns the move event ID if successful.
|
||||
*/
|
||||
override suspend fun publishMove(move: ChessMoveEvent): String? =
|
||||
try {
|
||||
val template =
|
||||
JesterEvent.buildMove(
|
||||
startEventId = move.startEventId,
|
||||
headEventId = move.headEventId,
|
||||
move = move.san,
|
||||
fen = move.fen,
|
||||
history = move.history,
|
||||
opponentPubkey = move.opponentPubkey,
|
||||
)
|
||||
val signedEvent = account.signer.sign(template)
|
||||
val success = broadcastToChessRelays(signedEvent)
|
||||
if (success) signedEvent.id else null
|
||||
} catch (e: Exception) {
|
||||
println("[DesktopChessPublisher] publishMove failed: ${e.message}")
|
||||
null
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a game end event (includes result in content).
|
||||
*/
|
||||
override suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean =
|
||||
try {
|
||||
val template =
|
||||
JesterEvent.buildEndMove(
|
||||
startEventId = gameEnd.startEventId,
|
||||
headEventId = gameEnd.headEventId,
|
||||
move = gameEnd.lastMove,
|
||||
fen = gameEnd.fen,
|
||||
history = gameEnd.history,
|
||||
opponentPubkey = gameEnd.opponentPubkey,
|
||||
result = gameEnd.result,
|
||||
termination = gameEnd.termination,
|
||||
)
|
||||
val signedEvent = account.signer.sign(template)
|
||||
broadcastToChessRelays(signedEvent)
|
||||
} catch (e: Exception) {
|
||||
println("[DesktopChessPublisher] publishGameEnd failed: ${e.message}")
|
||||
false
|
||||
}
|
||||
|
||||
override fun getWriteRelayCount(): Int = ChessConfig.CHESS_RELAYS.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop implementation of ChessRelayFetcher using Jester protocol.
|
||||
*
|
||||
* Hybrid approach (like Android's LocalCache pattern):
|
||||
* 1. Query DesktopChessEventCache first (populated by subscription)
|
||||
* 2. Also do relay queries and merge results for completeness
|
||||
*
|
||||
* This solves the "missing games" issue where pure one-shot relay queries
|
||||
* would miss events due to timing or slow relays.
|
||||
*/
|
||||
class DesktopRelayFetcher(
|
||||
private val relayManager: DesktopRelayConnectionManager,
|
||||
private val userPubkey: String,
|
||||
) : ChessRelayFetcher {
|
||||
private val fetchHelper = ChessRelayFetchHelper(relayManager.client)
|
||||
|
||||
/**
|
||||
* Get the normalized chess relay URLs.
|
||||
* Always uses the 3 dedicated chess relays from ChessConfig.
|
||||
*/
|
||||
private fun chessRelayUrls(): List<NormalizedRelayUrl> = ChessConfig.CHESS_RELAYS.map { NormalizedRelayUrl(it) }
|
||||
|
||||
override fun getRelayUrls(): List<String> {
|
||||
println("[DesktopRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays")
|
||||
return ChessConfig.CHESS_RELAYS
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch game events - queries cache first, supplements with relay query.
|
||||
* Similar to Android's LocalCache.addressables pattern.
|
||||
*
|
||||
* Uses TWO filters:
|
||||
* 1. Fetch start event by ID (start events don't have #e tag to themselves)
|
||||
* 2. Fetch moves that reference the start event via #e tag
|
||||
*/
|
||||
override suspend fun fetchGameEvents(startEventId: String): JesterGameEvents {
|
||||
println("[DesktopRelayFetcher] fetchGameEvents: querying cache for startEventId=$startEventId")
|
||||
|
||||
// Query cache first (populated by subscription)
|
||||
val cachedEvents = DesktopChessEventCache.getGameEvents(startEventId)
|
||||
println("[DesktopRelayFetcher] fetchGameEvents cache: start=${cachedEvents.startEvent != null}, moves=${cachedEvents.moves.size}")
|
||||
|
||||
// Also do relay query to catch any new events and populate cache
|
||||
// Filter 1: Fetch the start event by its ID
|
||||
val startEventFilter =
|
||||
Filter(
|
||||
ids = listOf(startEventId),
|
||||
kinds = listOf(JesterProtocol.KIND),
|
||||
)
|
||||
// Filter 2: Fetch moves that reference the start event
|
||||
val movesFilter = ChessFilterBuilder.gameEventsFilter(startEventId)
|
||||
val relayFilters = chessRelayUrls().associateWith { listOf(startEventFilter, movesFilter) }
|
||||
val relayEvents = fetchHelper.fetchEvents(relayFilters)
|
||||
|
||||
// Add relay events to cache
|
||||
relayEvents.forEach { DesktopChessEventCache.add(it) }
|
||||
|
||||
// Convert to JesterEvents and merge
|
||||
val relayJesterEvents =
|
||||
relayEvents
|
||||
.filter { it.kind == JesterProtocol.KIND }
|
||||
.mapNotNull { it.toJesterEvent() }
|
||||
|
||||
val relayStartEvent =
|
||||
relayJesterEvents
|
||||
.firstOrNull { it.isStartEvent() && it.id == startEventId }
|
||||
|
||||
val relayMoves =
|
||||
relayJesterEvents
|
||||
.filter { it.isMoveEvent() && it.startEventId() == startEventId }
|
||||
|
||||
// Merge: prefer cache start event, merge all moves
|
||||
val allMoves = (cachedEvents.moves + relayMoves).distinctBy { it.id }
|
||||
|
||||
println("[DesktopRelayFetcher] fetchGameEvents: found start=${cachedEvents.startEvent ?: relayStartEvent != null}, moves=${allMoves.size}")
|
||||
|
||||
return JesterGameEvents(
|
||||
startEvent = cachedEvents.startEvent ?: relayStartEvent,
|
||||
moves = allMoves,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch start events (challenges) - queries cache first.
|
||||
*/
|
||||
override suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)?): List<JesterEvent> {
|
||||
val relays = chessRelayUrls()
|
||||
println("[DesktopRelayFetcher] fetchChallenges: querying cache first, then ${relays.size} chess relays")
|
||||
|
||||
// Query cache for start events that are:
|
||||
// 1. Open challenges (no specific opponent)
|
||||
// 2. Directed at us
|
||||
// 3. Created by us
|
||||
val cachedStartEvents =
|
||||
DesktopChessEventCache.queryStartEvents { event ->
|
||||
event.opponentPubkey() == null ||
|
||||
event.opponentPubkey() == userPubkey ||
|
||||
event.pubKey == userPubkey
|
||||
}
|
||||
|
||||
println("[DesktopRelayFetcher] fetchChallenges: found ${cachedStartEvents.size} in cache")
|
||||
|
||||
// Also do relay query to catch any new challenges
|
||||
val filter = ChessFilterBuilder.challengesFilter(userPubkey)
|
||||
val relayFilters = relays.associateWith { listOf(filter) }
|
||||
val relayEvents = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress)
|
||||
|
||||
// Add to cache
|
||||
relayEvents.forEach { DesktopChessEventCache.add(it) }
|
||||
|
||||
val relayStartEvents =
|
||||
relayEvents
|
||||
.filter { it.kind == JesterProtocol.KIND }
|
||||
.mapNotNull { it.toJesterEvent() }
|
||||
.filter { it.isStartEvent() }
|
||||
|
||||
// Merge and deduplicate
|
||||
val allStartEvents = (cachedStartEvents + relayStartEvents).distinctBy { it.id }
|
||||
println("[DesktopRelayFetcher] fetchChallenges: total ${allStartEvents.size} after merge")
|
||||
return allStartEvents
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch user game IDs (startEventIds) - queries cache first.
|
||||
*/
|
||||
override suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)?): Set<String> {
|
||||
val relays = chessRelayUrls()
|
||||
println("[DesktopRelayFetcher] fetchUserGameIds: querying cache first, then ${relays.size} chess relays")
|
||||
|
||||
val gameIds = mutableSetOf<String>()
|
||||
|
||||
// Query cache for start events created by user or directed at user
|
||||
DesktopChessEventCache
|
||||
.queryStartEvents { event ->
|
||||
event.pubKey == userPubkey || event.opponentPubkey() == userPubkey
|
||||
}.forEach { gameIds.add(it.id) }
|
||||
|
||||
// Query cache for moves by user or tagged with user
|
||||
DesktopChessEventCache
|
||||
.queryMoveEvents { event ->
|
||||
event.pubKey == userPubkey || event.opponentPubkey() == userPubkey
|
||||
}.forEach { event ->
|
||||
event.startEventId()?.let { gameIds.add(it) }
|
||||
}
|
||||
|
||||
println("[DesktopRelayFetcher] fetchUserGameIds: found ${gameIds.size} in cache")
|
||||
|
||||
// Also do relay query to catch any new game IDs
|
||||
val userFilter = ChessFilterBuilder.userGamesFilter(userPubkey)
|
||||
val taggedFilter = ChessFilterBuilder.userTaggedFilter(userPubkey)
|
||||
val relayFilters = relays.associateWith { listOf(userFilter, taggedFilter) }
|
||||
val relayEvents = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress)
|
||||
|
||||
// Add to cache
|
||||
relayEvents.forEach { DesktopChessEventCache.add(it) }
|
||||
|
||||
// Extract game IDs from relay events
|
||||
relayEvents
|
||||
.filter { it.kind == JesterProtocol.KIND }
|
||||
.mapNotNull { it.toJesterEvent() }
|
||||
.forEach { event ->
|
||||
if (event.isStartEvent()) {
|
||||
gameIds.add(event.id)
|
||||
} else if (event.isMoveEvent()) {
|
||||
event.startEventId()?.let { gameIds.add(it) }
|
||||
}
|
||||
}
|
||||
|
||||
println("[DesktopRelayFetcher] fetchUserGameIds: total ${gameIds.size} after merge")
|
||||
return gameIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recent games for spectating.
|
||||
*/
|
||||
override suspend fun fetchRecentGames(): List<RelayGameSummary> {
|
||||
val filter = ChessFilterBuilder.recentGamesFilter()
|
||||
val relayFilters = chessRelayUrls().associateWith { listOf(filter) }
|
||||
|
||||
val events = fetchHelper.fetchEvents(relayFilters)
|
||||
|
||||
// Cache events so they're available when watching
|
||||
events.forEach { DesktopChessEventCache.add(it) }
|
||||
|
||||
// Convert to JesterEvents
|
||||
val jesterEvents =
|
||||
events
|
||||
.filter { it.kind == JesterProtocol.KIND }
|
||||
.mapNotNull { it.toJesterEvent() }
|
||||
|
||||
// Group by game (startEventId for moves, id for start events)
|
||||
val startEventsById =
|
||||
jesterEvents
|
||||
.filter { it.isStartEvent() }
|
||||
.associateBy { it.id }
|
||||
|
||||
val movesByGameId =
|
||||
jesterEvents
|
||||
.filter { it.isMoveEvent() }
|
||||
.groupBy { it.startEventId() ?: "" }
|
||||
.filterKeys { it.isNotEmpty() }
|
||||
|
||||
// Get all game IDs (from start events and moves)
|
||||
val allGameIds = startEventsById.keys + movesByGameId.keys
|
||||
|
||||
return allGameIds.mapNotNull { startEventId ->
|
||||
val startEvent = startEventsById[startEventId] ?: return@mapNotNull null
|
||||
val moves = movesByGameId[startEventId] ?: emptyList()
|
||||
|
||||
val challengerColor = startEvent.playerColor() ?: Color.WHITE
|
||||
|
||||
// Determine players from start event and moves
|
||||
val challengerPubkey = startEvent.pubKey
|
||||
val opponentFromMoves = moves.firstOrNull { it.pubKey != challengerPubkey }?.pubKey
|
||||
val opponentPubkey = startEvent.opponentPubkey() ?: opponentFromMoves ?: return@mapNotNull null
|
||||
|
||||
val (whitePubkey, blackPubkey) =
|
||||
if (challengerColor == Color.WHITE) {
|
||||
challengerPubkey to opponentPubkey
|
||||
} else {
|
||||
opponentPubkey to challengerPubkey
|
||||
}
|
||||
|
||||
val lastMove = moves.maxByOrNull { it.history().size }
|
||||
val hasEnded = lastMove?.result() != null
|
||||
|
||||
RelayGameSummary(
|
||||
startEventId = startEventId,
|
||||
whitePubkey = whitePubkey,
|
||||
blackPubkey = blackPubkey,
|
||||
moveCount = lastMove?.history()?.size ?: 0,
|
||||
lastMoveTime = lastMove?.createdAt ?: startEvent.createdAt,
|
||||
isActive = !hasEnded,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Desktop implementation of IUserMetadataProvider.
|
||||
* Wraps UserMetadataCache for user metadata lookup.
|
||||
*/
|
||||
class DesktopMetadataProvider(
|
||||
private val metadataCache: UserMetadataCache,
|
||||
) : IUserMetadataProvider {
|
||||
override fun getDisplayName(pubkey: String): String = metadataCache.getDisplayName(pubkey)
|
||||
|
||||
override fun getPictureUrl(pubkey: String): String? = metadataCache.getPictureUrl(pubkey)
|
||||
}
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/*
|
||||
* 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.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip64Chess.JesterEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents
|
||||
import com.vitorpamplona.quartz.nip64Chess.JesterProtocol
|
||||
import com.vitorpamplona.quartz.nip64Chess.toJesterEvent
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Local event cache for Desktop chess events using Jester protocol.
|
||||
*
|
||||
* Mimics Android's LocalCache.addressables behavior:
|
||||
* - Stores events by ID (deduplication)
|
||||
* - Queryable by kind with custom filters
|
||||
* - Thread-safe via ConcurrentHashMap
|
||||
* - Populated from subscription callbacks
|
||||
* - Persists across fetcher queries (unlike one-shot relay queries)
|
||||
*
|
||||
* Jester Protocol Notes:
|
||||
* - All chess events use kind 30
|
||||
* - Start events: content.kind=0, e-tag references START_POSITION_HASH
|
||||
* - Move events: content.kind=1, e-tags=[startEventId, headEventId]
|
||||
*/
|
||||
object DesktopChessEventCache {
|
||||
// Store all Jester events by ID for deduplication
|
||||
@PublishedApi
|
||||
internal val events = ConcurrentHashMap<String, JesterEvent>()
|
||||
|
||||
// Index start events by event ID (for quick game lookup)
|
||||
private val startEvents = ConcurrentHashMap<String, JesterEvent>()
|
||||
|
||||
// Index move events by startEventId (game ID) for quick game reconstruction
|
||||
private val movesByGame = ConcurrentHashMap<String, MutableSet<String>>()
|
||||
|
||||
/**
|
||||
* Add an event to the cache.
|
||||
* Returns true if the event was new, false if it was a duplicate.
|
||||
*/
|
||||
fun add(event: Event): Boolean {
|
||||
if (event.kind != JesterProtocol.KIND) return false
|
||||
|
||||
val jesterEvent = event.toJesterEvent() ?: return false
|
||||
return addJesterEvent(jesterEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a JesterEvent directly.
|
||||
*/
|
||||
fun addJesterEvent(event: JesterEvent): Boolean {
|
||||
val isNew = events.putIfAbsent(event.id, event) == null
|
||||
if (isNew) {
|
||||
if (event.isStartEvent()) {
|
||||
startEvents[event.id] = event
|
||||
} else if (event.isMoveEvent()) {
|
||||
val startId = event.startEventId()
|
||||
if (startId != null) {
|
||||
movesByGame.computeIfAbsent(startId) { ConcurrentHashMap.newKeySet() }.add(event.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return isNew
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an event by ID.
|
||||
*/
|
||||
fun get(id: String): JesterEvent? = events[id]
|
||||
|
||||
/**
|
||||
* Get all start events (challenges).
|
||||
*/
|
||||
fun getStartEvents(filter: (JesterEvent) -> Boolean = { true }): List<JesterEvent> = startEvents.values.filter { it.isStartEvent() && filter(it) }
|
||||
|
||||
/**
|
||||
* Get all move events for a specific game.
|
||||
*/
|
||||
fun getMovesForGame(startEventId: String): List<JesterEvent> {
|
||||
val moveIds = movesByGame[startEventId] ?: return emptyList()
|
||||
return moveIds.mapNotNull { events[it] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all events for a specific game (start + moves).
|
||||
*/
|
||||
fun getGameEvents(startEventId: String): JesterGameEvents {
|
||||
val startEvent = startEvents[startEventId]
|
||||
val moves = getMovesForGame(startEventId)
|
||||
return JesterGameEvents(startEvent, moves)
|
||||
}
|
||||
|
||||
/**
|
||||
* Query events with custom filter.
|
||||
*/
|
||||
fun query(filter: (JesterEvent) -> Boolean): List<JesterEvent> = events.values.filter(filter)
|
||||
|
||||
/**
|
||||
* Query start events with custom filter.
|
||||
*/
|
||||
fun queryStartEvents(filter: (JesterEvent) -> Boolean = { true }): List<JesterEvent> = startEvents.values.filter { it.isStartEvent() && filter(it) }
|
||||
|
||||
/**
|
||||
* Query move events with custom filter.
|
||||
*/
|
||||
fun queryMoveEvents(filter: (JesterEvent) -> Boolean = { true }): List<JesterEvent> = events.values.filter { it.isMoveEvent() && filter(it) }
|
||||
|
||||
/**
|
||||
* Get all game IDs (startEventIds) in the cache.
|
||||
*/
|
||||
fun getAllGameIds(): Set<String> = startEvents.keys.toSet()
|
||||
|
||||
/**
|
||||
* Get count of events.
|
||||
*/
|
||||
fun size(): Int = events.size
|
||||
|
||||
/**
|
||||
* Get count of start events.
|
||||
*/
|
||||
fun startEventCount(): Int = startEvents.size
|
||||
|
||||
/**
|
||||
* Clear all cached events.
|
||||
*/
|
||||
fun clear() {
|
||||
events.clear()
|
||||
startEvents.clear()
|
||||
movesByGame.clear()
|
||||
}
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* 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.chess.ChessBroadcastStatus
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessChallenge
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessLobbyLogic
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults
|
||||
import com.vitorpamplona.amethyst.commons.chess.ChessSyncStatus
|
||||
import com.vitorpamplona.amethyst.commons.chess.CompletedGame
|
||||
import com.vitorpamplona.amethyst.commons.chess.PublicGame
|
||||
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.nip64Chess.Color
|
||||
import com.vitorpamplona.quartz.nip64Chess.JesterProtocol
|
||||
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
|
||||
import com.vitorpamplona.quartz.nip64Chess.toJesterEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Slim Desktop ViewModel for chess (~120 lines).
|
||||
*
|
||||
* Delegates all business logic to ChessLobbyLogic.
|
||||
* Only handles Desktop-specific concerns:
|
||||
* - Platform adapter creation
|
||||
* - State exposure to Compose Desktop UI
|
||||
* - UserMetadataCache for profile display
|
||||
*/
|
||||
class DesktopChessViewModelNew(
|
||||
private val account: AccountState.LoggedIn,
|
||||
private val relayManager: DesktopRelayConnectionManager,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
// Desktop-specific metadata cache
|
||||
val userMetadataCache = UserMetadataCache()
|
||||
|
||||
// Platform adapters
|
||||
private val publisher = DesktopChessPublisher(account, relayManager)
|
||||
private val fetcher = DesktopRelayFetcher(relayManager, account.pubKeyHex)
|
||||
private val metadataProvider = DesktopMetadataProvider(userMetadataCache)
|
||||
|
||||
// Shared business logic (creates its own ChessLobbyState internally)
|
||||
private val logic =
|
||||
ChessLobbyLogic(
|
||||
userPubkey = account.pubKeyHex,
|
||||
publisher = publisher,
|
||||
fetcher = fetcher,
|
||||
metadataProvider = metadataProvider,
|
||||
scope = scope,
|
||||
pollingConfig = ChessPollingDefaults.desktop,
|
||||
)
|
||||
|
||||
// ============================================
|
||||
// State exposure (delegated from ChessLobbyLogic.state)
|
||||
// ============================================
|
||||
|
||||
val activeGames: StateFlow<Map<String, LiveChessGameState>> = logic.state.activeGames
|
||||
val spectatingGames: StateFlow<Map<String, LiveChessGameState>> = logic.state.spectatingGames
|
||||
val challenges: StateFlow<List<ChessChallenge>> = logic.state.challenges
|
||||
val publicGames: StateFlow<List<PublicGame>> = logic.state.publicGames
|
||||
val completedGames: StateFlow<List<CompletedGame>> = logic.state.completedGames
|
||||
val broadcastStatus: StateFlow<ChessBroadcastStatus> = logic.state.broadcastStatus
|
||||
val error: StateFlow<String?> = logic.state.error
|
||||
val selectedGameId: StateFlow<String?> = logic.state.selectedGameId
|
||||
val isRefreshing: StateFlow<Boolean> = logic.state.isRefreshing
|
||||
val syncStatus: StateFlow<ChessSyncStatus> = logic.state.syncStatus
|
||||
val stateVersion: StateFlow<Long> = logic.state.stateVersion
|
||||
|
||||
/** Badge count (incoming challenges + your turn games) - computed property */
|
||||
val badgeCount: Int get() = logic.state.badgeCount
|
||||
|
||||
// ============================================
|
||||
// Lifecycle
|
||||
// ============================================
|
||||
|
||||
init {
|
||||
logic.startPolling()
|
||||
}
|
||||
|
||||
fun startPolling() = logic.startPolling()
|
||||
|
||||
fun stopPolling() = logic.stopPolling()
|
||||
|
||||
fun forceRefresh() = logic.forceRefresh()
|
||||
|
||||
/**
|
||||
* Ensure a game ID is being polled for updates.
|
||||
* Call this when viewing a game.
|
||||
*/
|
||||
fun ensureGamePolling(gameId: String) = logic.ensureGamePolling(gameId)
|
||||
|
||||
/**
|
||||
* Set focused game mode - only poll this specific game.
|
||||
* Call this when viewing a game to avoid refreshing unrelated games.
|
||||
*/
|
||||
fun setFocusedGame(gameId: String) = logic.setFocusedGame(gameId)
|
||||
|
||||
/**
|
||||
* Clear focused game mode - return to lobby mode (poll all games).
|
||||
* Call this when returning to the lobby view.
|
||||
*/
|
||||
fun clearFocusedGame() = logic.clearFocusedGame()
|
||||
|
||||
// ============================================
|
||||
// Incoming event routing (from relay subscriptions)
|
||||
// ============================================
|
||||
|
||||
fun handleIncomingEvent(event: Event) {
|
||||
if (event.kind != JesterProtocol.KIND) return
|
||||
val jesterEvent = event.toJesterEvent() ?: return
|
||||
logic.handleIncomingEvent(jesterEvent)
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Challenge operations
|
||||
// ============================================
|
||||
|
||||
fun createChallenge(
|
||||
opponentPubkey: String? = null,
|
||||
playerColor: Color = Color.WHITE,
|
||||
timeControl: String? = null,
|
||||
) = logic.createChallenge(opponentPubkey, playerColor, timeControl)
|
||||
|
||||
fun acceptChallenge(challenge: ChessChallenge) = logic.acceptChallenge(challenge)
|
||||
|
||||
fun openOwnChallenge(challenge: ChessChallenge) = logic.openOwnChallenge(challenge)
|
||||
|
||||
// ============================================
|
||||
// Game operations
|
||||
// ============================================
|
||||
|
||||
fun selectGame(gameId: String?) = logic.selectGame(gameId)
|
||||
|
||||
fun publishMove(
|
||||
gameId: String,
|
||||
from: String,
|
||||
to: String,
|
||||
) = logic.publishMove(gameId, from, to)
|
||||
|
||||
fun resign(gameId: String) = logic.resign(gameId)
|
||||
|
||||
fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId)
|
||||
|
||||
// ============================================
|
||||
// Spectator operations
|
||||
// ============================================
|
||||
|
||||
fun loadGame(gameId: String) = logic.loadGame(gameId)
|
||||
|
||||
fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId)
|
||||
|
||||
fun stopSpectating(gameId: String) = logic.state.removeSpectatingGame(gameId)
|
||||
|
||||
// ============================================
|
||||
// Utility
|
||||
// ============================================
|
||||
|
||||
fun clearError() = logic.clearError()
|
||||
|
||||
fun getGameState(gameId: String): LiveChessGameState? = logic.state.getGameState(gameId)
|
||||
|
||||
/** Check if a game was accepted (prevents loading as spectator during race) */
|
||||
fun wasAccepted(gameId: String): Boolean = logic.state.wasAccepted(gameId)
|
||||
|
||||
/** Helper for derived challenge lists */
|
||||
fun incomingChallenges(): List<ChessChallenge> = logic.state.incomingChallenges()
|
||||
|
||||
fun outgoingChallenges(): List<ChessChallenge> = logic.state.outgoingChallenges()
|
||||
|
||||
fun openChallenges(): List<ChessChallenge> = logic.state.openChallenges()
|
||||
}
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
/*
|
||||
* 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.subscriptions
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder
|
||||
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionController
|
||||
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionState
|
||||
import com.vitorpamplona.amethyst.desktop.chess.DesktopChessEventCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
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.LiveChessMoveEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
/** Chess event kinds for cache filtering */
|
||||
private val CHESS_EVENT_KINDS =
|
||||
setOf(
|
||||
LiveChessGameChallengeEvent.KIND,
|
||||
LiveChessGameAcceptEvent.KIND,
|
||||
LiveChessMoveEvent.KIND,
|
||||
LiveChessGameEndEvent.KIND,
|
||||
LiveChessDrawOfferEvent.KIND,
|
||||
)
|
||||
|
||||
/**
|
||||
* Desktop implementation of [ChessSubscriptionController].
|
||||
* Uses the shared [ChessFilterBuilder] for consistent filter construction
|
||||
* with dynamic updates when active games change.
|
||||
*/
|
||||
class DesktopChessSubscriptionController(
|
||||
private val relayManager: RelayConnectionManager,
|
||||
private val onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||
private val onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||
) : ChessSubscriptionController {
|
||||
private val _currentState = MutableStateFlow<ChessSubscriptionState?>(null)
|
||||
override val currentState: StateFlow<ChessSubscriptionState?> = _currentState.asStateFlow()
|
||||
|
||||
// EOSE tracking per relay for efficient re-subscription
|
||||
private val relayEoseTimes = mutableMapOf<NormalizedRelayUrl, Long>()
|
||||
|
||||
// Current subscription ID
|
||||
private var currentSubId: String? = null
|
||||
|
||||
override fun subscribe(state: ChessSubscriptionState) {
|
||||
// Unsubscribe previous if exists
|
||||
unsubscribe()
|
||||
|
||||
if (state.relays.isEmpty()) return
|
||||
|
||||
_currentState.value = state
|
||||
currentSubId = state.subscriptionId()
|
||||
|
||||
// Build filters using shared logic
|
||||
val relayBasedFilters =
|
||||
ChessFilterBuilder.buildAllFilters(state) { relay ->
|
||||
relayEoseTimes[relay]
|
||||
}
|
||||
|
||||
// Group filters by relay
|
||||
val filtersByRelay = relayBasedFilters.groupByRelay()
|
||||
|
||||
// Subscribe on each relay with its specific filters
|
||||
val allFilters = filtersByRelay.values.flatten().distinct()
|
||||
|
||||
relayManager.subscribe(
|
||||
subId = currentSubId!!,
|
||||
filters = allFilters,
|
||||
relays = state.relays,
|
||||
listener =
|
||||
object : IRequestListener {
|
||||
override fun onEvent(
|
||||
event: Event,
|
||||
isLive: Boolean,
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
// Add chess events to local cache for persistence
|
||||
if (event.kind in CHESS_EVENT_KINDS) {
|
||||
DesktopChessEventCache.add(event)
|
||||
}
|
||||
onEvent(event, isLive, relay, forFilters)
|
||||
}
|
||||
|
||||
override fun onEose(
|
||||
relay: NormalizedRelayUrl,
|
||||
forFilters: List<Filter>?,
|
||||
) {
|
||||
// Track EOSE time for this relay for efficient re-subscription
|
||||
relayEoseTimes[relay] = TimeUtils.now()
|
||||
onEose(relay, forFilters)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun unsubscribe() {
|
||||
currentSubId?.let { relayManager.unsubscribe(it) }
|
||||
currentSubId = null
|
||||
_currentState.value = null
|
||||
}
|
||||
|
||||
override fun updateActiveGames(
|
||||
activeGameIds: Set<String>,
|
||||
spectatingGameIds: Set<String>,
|
||||
) {
|
||||
val current = _currentState.value ?: return
|
||||
|
||||
// Create new state with updated game IDs
|
||||
val newState =
|
||||
current.copy(
|
||||
activeGameIds = activeGameIds,
|
||||
spectatingGameIds = spectatingGameIds,
|
||||
)
|
||||
|
||||
// Only re-subscribe if game IDs actually changed
|
||||
if (newState.subscriptionId() != current.subscriptionId()) {
|
||||
subscribe(newState)
|
||||
}
|
||||
}
|
||||
|
||||
override fun forceRefresh() {
|
||||
// Clear EOSE cache to re-fetch full history
|
||||
relayEoseTimes.clear()
|
||||
_currentState.value?.let { subscribe(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a subscription config for chess events with support for active game filtering.
|
||||
* Uses the shared [ChessFilterBuilder] for consistent filter construction.
|
||||
*
|
||||
* @param relays Set of relays to subscribe to
|
||||
* @param userPubkey User's public key for filtering personal events
|
||||
* @param activeGameIds Set of game IDs the user is actively playing (for game-specific filters)
|
||||
* @param opponentPubkeys Set of opponent pubkeys for active games (for move filtering)
|
||||
* @param onEvent Callback for incoming events
|
||||
* @param onEose Callback for EOSE (End of Stored Events)
|
||||
*/
|
||||
fun createChessSubscriptionWithGames(
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
userPubkey: String,
|
||||
activeGameIds: Set<String> = emptySet(),
|
||||
opponentPubkeys: Set<String> = emptySet(),
|
||||
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||
): SubscriptionConfig? {
|
||||
if (relays.isEmpty()) return null
|
||||
|
||||
val state =
|
||||
ChessSubscriptionState(
|
||||
userPubkey = userPubkey,
|
||||
relays = relays,
|
||||
activeGameIds = activeGameIds,
|
||||
opponentPubkeys = opponentPubkeys,
|
||||
)
|
||||
|
||||
val relayBasedFilters =
|
||||
ChessFilterBuilder.buildAllFilters(state) { null }
|
||||
|
||||
val filters =
|
||||
relayBasedFilters
|
||||
.groupByRelay()
|
||||
.values
|
||||
.flatten()
|
||||
.distinct()
|
||||
|
||||
return SubscriptionConfig(
|
||||
subId = state.subscriptionId(),
|
||||
filters = filters,
|
||||
relays = relays,
|
||||
onEvent = onEvent,
|
||||
onEose = onEose,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy function for backward compatibility.
|
||||
* @deprecated Use createChessSubscriptionWithGames instead
|
||||
*/
|
||||
@Deprecated(
|
||||
"Use createChessSubscriptionWithGames for active game support",
|
||||
ReplaceWith("createChessSubscriptionWithGames(relays, userPubkey, emptySet(), emptySet(), onEvent, onEose)"),
|
||||
)
|
||||
fun createChessSubscription(
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
userPubkey: String,
|
||||
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||
): SubscriptionConfig? = createChessSubscriptionWithGames(relays, userPubkey, emptySet(), emptySet(), onEvent, onEose)
|
||||
+33
@@ -315,3 +315,36 @@ fun createFollowingLongFormFeedSubscription(
|
||||
onEose = onEose,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a subscription config for chess events (challenges, moves, etc.).
|
||||
* Subscribes to:
|
||||
* - Open challenges (kind 30064)
|
||||
* - Challenges directed at the user
|
||||
* - Events in games the user is playing
|
||||
*
|
||||
* @param userPubkey The user's public key to filter relevant games
|
||||
* @param limit Maximum number of events to request
|
||||
*/
|
||||
fun createChessSubscription(
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
userPubkey: String,
|
||||
limit: Int = 100,
|
||||
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||
): SubscriptionConfig =
|
||||
SubscriptionConfig(
|
||||
subId = generateSubId("chess-${userPubkey.take(8)}"),
|
||||
filters =
|
||||
listOf(
|
||||
// Open challenges and challenges to user
|
||||
FilterBuilders.chessOpenChallenges(limit = limit),
|
||||
// Events where user is tagged (challenges to them, moves in their games)
|
||||
FilterBuilders.chessEventsForUser(userPubkey, limit = limit),
|
||||
// User's own events (their challenges, moves)
|
||||
FilterBuilders.chessEventsByUser(userPubkey, limit = limit),
|
||||
),
|
||||
relays = relays,
|
||||
onEvent = onEvent,
|
||||
onEose = onEose,
|
||||
)
|
||||
|
||||
+110
@@ -95,6 +95,19 @@ object FilterBuilders {
|
||||
authors = pubKeyHexList,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a filter for user metadata (kind 0) from multiple authors.
|
||||
* Alias for userMetadataBatch for API compatibility.
|
||||
*
|
||||
* @param pubKeys List of author public keys (hex-encoded, 64 chars each)
|
||||
* @return Filter for user metadata
|
||||
*/
|
||||
fun userMetadataMultiple(pubKeys: List<String>): Filter =
|
||||
Filter(
|
||||
kinds = listOf(0),
|
||||
authors = pubKeys,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a filter for contact list (kind 3) from a specific author.
|
||||
*
|
||||
@@ -200,6 +213,103 @@ object FilterBuilders {
|
||||
ids = ids,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a filter for chess game challenges (kind 30064).
|
||||
* Includes open challenges (no opponent) and challenges directed at a specific user.
|
||||
*
|
||||
* @param userPubkey The user's public key to filter challenges for
|
||||
* @param limit Maximum number of events to request
|
||||
* @param since Timestamp for events with publication time ≥ this value
|
||||
* @return Filter for chess challenges
|
||||
*/
|
||||
fun chessChallengesToUser(
|
||||
userPubkey: String,
|
||||
limit: Int? = 50,
|
||||
since: Long? = null,
|
||||
): Filter =
|
||||
Filter(
|
||||
kinds = listOf(30064), // LiveChessGameChallengeEvent.KIND
|
||||
tags = mapOf("p" to listOf(userPubkey)),
|
||||
limit = limit,
|
||||
since = since,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a filter for open chess challenges (kind 30064, no specific opponent).
|
||||
*
|
||||
* @param limit Maximum number of events to request
|
||||
* @param since Timestamp for events with publication time ≥ this value
|
||||
* @return Filter for open chess challenges
|
||||
*/
|
||||
fun chessOpenChallenges(
|
||||
limit: Int? = 50,
|
||||
since: Long? = null,
|
||||
): Filter =
|
||||
Filter(
|
||||
kinds = listOf(30064), // LiveChessGameChallengeEvent.KIND
|
||||
limit = limit,
|
||||
since = since,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a filter for all chess events (challenges, accepts, moves, ends, draw offers).
|
||||
* Useful for loading the chess lobby.
|
||||
*
|
||||
* @param limit Maximum number of events to request
|
||||
* @param since Timestamp for events with publication time ≥ this value
|
||||
* @return Filter for all chess events
|
||||
*/
|
||||
fun chessAllEvents(
|
||||
limit: Int? = 100,
|
||||
since: Long? = null,
|
||||
): Filter =
|
||||
Filter(
|
||||
kinds = listOf(30064, 30065, 30066, 30067, 30068),
|
||||
limit = limit,
|
||||
since = since,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a filter for chess events involving a specific user.
|
||||
* Includes challenges to/from user, moves in their games, draw offers, etc.
|
||||
*
|
||||
* @param userPubkey The user's public key
|
||||
* @param limit Maximum number of events to request
|
||||
* @param since Timestamp for events with publication time ≥ this value
|
||||
* @return Filter for chess events involving the user
|
||||
*/
|
||||
fun chessEventsForUser(
|
||||
userPubkey: String,
|
||||
limit: Int? = 100,
|
||||
since: Long? = null,
|
||||
): Filter =
|
||||
Filter(
|
||||
kinds = listOf(30064, 30065, 30066, 30067, 30068),
|
||||
tags = mapOf("p" to listOf(userPubkey)),
|
||||
limit = limit,
|
||||
since = since,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a filter for chess events by the user (their own events).
|
||||
*
|
||||
* @param userPubkey The user's public key
|
||||
* @param limit Maximum number of events to request
|
||||
* @param since Timestamp for events with publication time ≥ this value
|
||||
* @return Filter for chess events by the user
|
||||
*/
|
||||
fun chessEventsByUser(
|
||||
userPubkey: String,
|
||||
limit: Int? = 100,
|
||||
since: Long? = null,
|
||||
): Filter =
|
||||
Filter(
|
||||
kinds = listOf(30064, 30065, 30066, 30067, 30068),
|
||||
authors = listOf(userPubkey),
|
||||
limit = limit,
|
||||
since = since,
|
||||
)
|
||||
|
||||
/**
|
||||
* Creates a filter for events tagged with specific p-tags (mentioning users).
|
||||
*
|
||||
|
||||
+21
@@ -71,6 +71,27 @@ fun createBatchMetadataSubscription(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a subscription config for multiple user metadata (kind 0).
|
||||
* Useful for fetching metadata for a batch of users at once.
|
||||
*/
|
||||
fun createMetadataListSubscription(
|
||||
relays: Set<NormalizedRelayUrl>,
|
||||
pubKeys: List<String>,
|
||||
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
|
||||
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
|
||||
): SubscriptionConfig? {
|
||||
if (pubKeys.isEmpty()) return null
|
||||
|
||||
return SubscriptionConfig(
|
||||
subId = generateSubId("meta-batch-${pubKeys.hashCode()}"),
|
||||
filters = listOf(FilterBuilders.userMetadataMultiple(pubKeys)),
|
||||
relays = relays,
|
||||
onEvent = onEvent,
|
||||
onEose = onEose,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a subscription config for user posts (kind 1).
|
||||
*/
|
||||
|
||||
@@ -584,7 +584,8 @@ fun FeedScreen(
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(events, key = { it.id }) { event ->
|
||||
// Use distinctBy to prevent duplicate key crashes from events with same ID
|
||||
items(events.distinctBy { it.id }, key = { it.id }) { event ->
|
||||
FeedNoteCard(
|
||||
event = event,
|
||||
relayManager = relayManager,
|
||||
|
||||
@@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache
|
||||
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||
import com.vitorpamplona.amethyst.desktop.nwc.NwcPaymentHandler
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||
@@ -538,7 +539,8 @@ fun NoteActionsRow(
|
||||
if (!isLiked) {
|
||||
scope.launch {
|
||||
reactToNote(
|
||||
event = event,
|
||||
// TODO: Bring a hint to where the event came from
|
||||
event = EventHintBundle(event, null),
|
||||
reaction = "+",
|
||||
account = account,
|
||||
relayManager = relayManager,
|
||||
@@ -578,7 +580,8 @@ fun NoteActionsRow(
|
||||
if (!isReposted) {
|
||||
scope.launch {
|
||||
repostNote(
|
||||
event = event,
|
||||
// TODO: Bring a hint to where the event came from
|
||||
event = EventHintBundle(event, null),
|
||||
account = account,
|
||||
relayManager = relayManager,
|
||||
)
|
||||
@@ -808,7 +811,7 @@ fun NoteActionsRow(
|
||||
* Creates a reaction event and broadcasts to relays.
|
||||
*/
|
||||
private suspend fun reactToNote(
|
||||
event: Event,
|
||||
event: EventHintBundle<Event>,
|
||||
reaction: String,
|
||||
account: AccountState.LoggedIn,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
@@ -893,7 +896,7 @@ private suspend fun removeBookmark(
|
||||
* Creates a repost event and broadcasts to relays.
|
||||
*/
|
||||
private suspend fun repostNote(
|
||||
event: Event,
|
||||
event: EventHintBundle<Event>,
|
||||
account: AccountState.LoggedIn,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
) {
|
||||
|
||||
+1
-1
@@ -226,7 +226,7 @@ fun NotificationsScreen(
|
||||
LazyColumn(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(notifications, key = { it.event.id }) { notification ->
|
||||
items(notifications.distinctBy { it.event.id }, key = { it.event.id }) { notification ->
|
||||
NotificationCard(notification)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -423,7 +423,7 @@ fun ThreadScreen(
|
||||
}
|
||||
|
||||
// Reply notes with level indicators
|
||||
items(replyEvents, key = { it.id }) { event ->
|
||||
items(replyEvents.distinctBy { it.id }, key = { it.id }) { event ->
|
||||
val level = calculateLevel(event)
|
||||
|
||||
Column(
|
||||
|
||||
+170
-18
@@ -37,8 +37,10 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import androidx.compose.material.icons.filled.PersonRemove
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
@@ -46,7 +48,9 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
@@ -60,6 +64,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction
|
||||
import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastBanner
|
||||
import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus
|
||||
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
|
||||
import com.vitorpamplona.amethyst.commons.state.FollowState
|
||||
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
|
||||
@@ -76,6 +82,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createUserPostsSubscript
|
||||
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
|
||||
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -111,6 +118,13 @@ fun UserProfileScreen(
|
||||
var followersCount by remember { mutableStateOf(0) }
|
||||
var followingCount by remember { mutableStateOf(0) }
|
||||
|
||||
// Profile editing state (only for own profile)
|
||||
val isOwnProfile = account != null && pubKeyHex == account.pubKeyHex
|
||||
var showEditDialog by remember { mutableStateOf(false) }
|
||||
var editingDisplayName by remember { mutableStateOf("") }
|
||||
var broadcastStatus by remember { mutableStateOf<ProfileBroadcastStatus>(ProfileBroadcastStatus.Idle) }
|
||||
var latestMetadataEvent by remember { mutableStateOf<MetadataEvent?>(null) }
|
||||
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// User's posts
|
||||
@@ -187,13 +201,25 @@ fun UserProfileScreen(
|
||||
relays = configuredRelays,
|
||||
pubKeyHex = pubKeyHex,
|
||||
onEvent = { event, _, _, _ ->
|
||||
try {
|
||||
val content = event.content
|
||||
displayName = extractJsonField(content, "display_name") ?: extractJsonField(content, "name")
|
||||
about = extractJsonField(content, "about")
|
||||
picture = extractJsonField(content, "picture")
|
||||
} catch (e: Exception) {
|
||||
// Ignore parse errors
|
||||
if (event is MetadataEvent) {
|
||||
try {
|
||||
val metadata = event.contactMetaData()
|
||||
if (metadata != null) {
|
||||
displayName = metadata.displayName ?: metadata.name
|
||||
about = metadata.about
|
||||
picture = metadata.picture
|
||||
}
|
||||
|
||||
// Store MetadataEvent for editing (only for own profile)
|
||||
if (isOwnProfile) {
|
||||
val current = latestMetadataEvent
|
||||
if (current == null || event.createdAt > current.createdAt) {
|
||||
latestMetadataEvent = event
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignore parse errors
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -281,6 +307,19 @@ fun UserProfileScreen(
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Broadcast banner for profile updates
|
||||
ProfileBroadcastBanner(
|
||||
status = broadcastStatus,
|
||||
onTap = {
|
||||
// Clear banner on tap (could add retry logic for failed)
|
||||
if (broadcastStatus is ProfileBroadcastStatus.Success ||
|
||||
broadcastStatus is ProfileBroadcastStatus.Failed
|
||||
) {
|
||||
broadcastStatus = ProfileBroadcastStatus.Idle
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// Header with back button
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
|
||||
@@ -298,6 +337,25 @@ fun UserProfileScreen(
|
||||
)
|
||||
}
|
||||
|
||||
// Edit button for own profile
|
||||
if (isOwnProfile && account?.isReadOnly == false) {
|
||||
OutlinedButton(
|
||||
onClick = {
|
||||
editingDisplayName = displayName ?: ""
|
||||
showEditDialog = true
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Edit,
|
||||
contentDescription = "Edit profile",
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
Text("Edit Profile")
|
||||
}
|
||||
}
|
||||
|
||||
// Follow/Unfollow button for other profiles
|
||||
if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) {
|
||||
Column(horizontalAlignment = Alignment.End) {
|
||||
Button(
|
||||
@@ -569,7 +627,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,
|
||||
@@ -586,17 +644,52 @@ fun UserProfileScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple JSON field extractor (not production-ready, just for demo).
|
||||
*/
|
||||
private fun extractJsonField(
|
||||
json: String,
|
||||
field: String,
|
||||
): String? {
|
||||
val regex = """"$field"\s*:\s*"([^"]*)"""".toRegex()
|
||||
return regex.find(json)?.groupValues?.get(1)
|
||||
// Edit Profile Dialog
|
||||
if (showEditDialog && account != null) {
|
||||
AlertDialog(
|
||||
onDismissRequest = { showEditDialog = false },
|
||||
title = { Text("Edit Profile") },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
OutlinedTextField(
|
||||
value = editingDisplayName,
|
||||
onValueChange = { editingDisplayName = it },
|
||||
label = { Text("Display Name") },
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
Button(
|
||||
onClick = {
|
||||
showEditDialog = false
|
||||
scope.launch {
|
||||
updateProfileDisplayName(
|
||||
newDisplayName = editingDisplayName,
|
||||
account = account,
|
||||
relayManager = relayManager,
|
||||
latestMetadataEvent = latestMetadataEvent,
|
||||
currentDisplayName = displayName,
|
||||
currentAbout = about,
|
||||
currentPicture = picture,
|
||||
onStatusUpdate = { broadcastStatus = it },
|
||||
onSuccess = { displayName = editingDisplayName },
|
||||
)
|
||||
}
|
||||
},
|
||||
) {
|
||||
Text("Save")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showEditDialog = false }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -648,3 +741,62 @@ private suspend fun unfollowUser(
|
||||
throw IllegalStateException("Cannot unfollow: No contact list available")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the user's profile display name by creating and broadcasting a new MetadataEvent.
|
||||
*/
|
||||
private suspend fun updateProfileDisplayName(
|
||||
newDisplayName: String,
|
||||
account: AccountState.LoggedIn,
|
||||
relayManager: DesktopRelayConnectionManager,
|
||||
latestMetadataEvent: MetadataEvent?,
|
||||
currentDisplayName: String?,
|
||||
currentAbout: String?,
|
||||
currentPicture: String?,
|
||||
onStatusUpdate: (ProfileBroadcastStatus) -> Unit,
|
||||
onSuccess: () -> Unit,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
val connectedRelays = relayManager.connectedRelays.value
|
||||
if (connectedRelays.isEmpty()) {
|
||||
onStatusUpdate(ProfileBroadcastStatus.Failed("display name", "No connected relays"))
|
||||
return@withContext
|
||||
}
|
||||
|
||||
val totalRelays = connectedRelays.size
|
||||
onStatusUpdate(ProfileBroadcastStatus.Broadcasting("display name", 0, totalRelays))
|
||||
|
||||
try {
|
||||
// Create the new MetadataEvent
|
||||
val template =
|
||||
if (latestMetadataEvent != null) {
|
||||
MetadataEvent.updateFromPast(
|
||||
latest = latestMetadataEvent,
|
||||
displayName = newDisplayName,
|
||||
)
|
||||
} else {
|
||||
MetadataEvent.createNew(
|
||||
name = currentDisplayName,
|
||||
displayName = newDisplayName,
|
||||
picture = currentPicture,
|
||||
about = currentAbout,
|
||||
)
|
||||
}
|
||||
|
||||
// Sign the event
|
||||
val signedEvent = account.signer.sign(template)
|
||||
|
||||
// Broadcast to all relays
|
||||
relayManager.broadcastToAll(signedEvent)
|
||||
|
||||
// Update progress (simplified - just show success after broadcast)
|
||||
// In a full implementation, you'd track OK responses from each relay
|
||||
onStatusUpdate(ProfileBroadcastStatus.Success("display name", totalRelays))
|
||||
onSuccess()
|
||||
|
||||
// Auto-hide banner after delay
|
||||
delay(3000)
|
||||
onStatusUpdate(ProfileBroadcastStatus.Idle)
|
||||
} catch (e: Exception) {
|
||||
onStatusUpdate(ProfileBroadcastStatus.Failed("display name", e.message ?: "Unknown error"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user