Merge branch 'main' into feat/desktop-private-dms

This commit is contained in:
Vitor Pamplona
2026-02-24 17:50:48 -05:00
committed by GitHub
298 changed files with 23719 additions and 2908 deletions
@@ -36,6 +36,7 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Article
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Notifications
import androidx.compose.material.icons.filled.Person
@@ -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,
@@ -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,
)
}
}
}
}
@@ -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)
}
@@ -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()
}
}
@@ -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()
}
@@ -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)
@@ -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,
)
@@ -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).
*
@@ -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,
) {
@@ -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(
@@ -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"))
}
}