From 2004caa53c602b6da444165e0a9abe4d955915e1 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 23 Mar 2026 15:27:20 +0100 Subject: [PATCH 01/31] fix(chess): board interactive on mobile at game start The board was non-interactive for participants during pending challenges on Android because canMakeMoves factored in isPendingChallenge. Desktop passed isSpectator directly without the pending check, so it worked. Remove isPendingChallenge from the board interactivity gate to match Desktop behavior. The header still shows "Challenge Pending" status, and this also fixes a secondary issue where the isPendingChallenge flag could get stuck when replaceGameState preserved the old state via its open-challenge workaround. Co-Authored-By: Claude Opus 4.6 --- .../vitorpamplona/amethyst/commons/chess/LiveChessGame.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt index c04a06f36..3781f0162 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt @@ -210,8 +210,9 @@ fun LiveChessGameScreen( // Use override if provided, otherwise fall back to gameState flag val isSpectator = isSpectatorOverride ?: gameState.isSpectator - // Pending challenges and spectators cannot make moves - val canMakeMoves = !isSpectator && !gameState.isPendingChallenge + // Spectators cannot make moves; pending challenges still allow board interaction + // (matches Desktop behavior where board is always interactive for participants) + val canMakeMoves = !isSpectator // Track if game end overlay was dismissed var gameEndDismissed by remember { mutableStateOf(false) } From 638bdb7b2d3b2a3ea49049c9d9f068eac0bbe44e Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 23 Mar 2026 15:33:28 +0100 Subject: [PATCH 02/31] fix(chess): finished games transition from active to completed Wire onGameEndDismiss callback so the "Continue" button on game end overlay moves the game to the completed list. Add auto-detection of finished games during polling refresh so games transition even if the user navigates away without clicking the button. Discovered games that are already finished now go straight to completed instead of appearing in the active list. Co-Authored-By: Claude Opus 4.6 --- .../screen/loggedIn/chess/ChessGameScreen.kt | 1 + .../loggedIn/chess/ChessViewModelNew.kt | 2 + .../amethyst/commons/chess/ChessLobbyLogic.kt | 46 +++++++++++++++++-- .../amethyst/commons/chess/ChessLobbyState.kt | 34 ++++++++++++++ .../desktop/chess/DesktopChessViewModelNew.kt | 2 + 5 files changed, 80 insertions(+), 5 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt index 99f957911..ef46c576c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt @@ -319,6 +319,7 @@ fun ChessGameScreen( }, onResign = { chessViewModel.resign(gameId) }, isSpectatorOverride = isSpectating, + onGameEndDismiss = { chessViewModel.dismissGame(gameId) }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt index a5ab59bba..e6b569e8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -166,6 +166,8 @@ class ChessViewModelNew( fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId) + fun dismissGame(gameId: String) = logic.dismissGame(gameId) + // ============================================ // Spectator operations // ============================================ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index 87e7bd9ed..6c5df00c5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.commons.chess import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.GameResult +import com.vitorpamplona.quartz.nip64Chess.GameStatus import com.vitorpamplona.quartz.nip64Chess.PieceType import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents @@ -236,9 +238,9 @@ class ChessLobbyLogic( if (result != null) { val gameResult = when (result) { - "1-0" -> com.vitorpamplona.quartz.nip64Chess.GameResult.WHITE_WINS - "0-1" -> com.vitorpamplona.quartz.nip64Chess.GameResult.BLACK_WINS - "1/2-1/2" -> com.vitorpamplona.quartz.nip64Chess.GameResult.DRAW + "1-0" -> GameResult.WHITE_WINS + "0-1" -> GameResult.BLACK_WINS + "1/2-1/2" -> GameResult.DRAW else -> null } if (gameResult != null) { @@ -583,7 +585,14 @@ class ChessLobbyLogic( when (result) { is LoadGameResult.Success -> { - state.replaceGameState(startEventId, result.liveState) + // Check status FIRST to avoid briefly emitting a finished game through activeGames + val gameStatus = result.liveState.gameStatus.value + if (gameStatus is GameStatus.Finished) { + state.moveToCompleted(startEventId, gameStatus.result.notation, null) + pollingDelegate.removeGameId(startEventId) + } else { + state.replaceGameState(startEventId, result.liveState) + } } is LoadGameResult.Error -> { @@ -747,13 +756,26 @@ class ChessLobbyLogic( val newGameIds = discoveredGameIds - currentActiveIds - currentSpectatingIds + // Also check completed games to avoid re-adding them + val completedGameIds = + state.completedGames.value + .map { it.gameId } + .toSet() + for (startEventId in newGameIds) { + if (startEventId in completedGameIds) continue + val events = fetcher.fetchGameEvents(startEventId) val result = ChessGameLoader.loadGame(events, userPubkey) when (result) { is LoadGameResult.Success -> { - if (!result.liveState.isSpectator) { + // If the discovered game is already finished, send it straight to completed + val gameStatus = result.liveState.gameStatus.value + if (gameStatus is GameStatus.Finished) { + // Use the direct helper to avoid the addActiveGame → moveToCompleted flicker + state.addCompletedGameDirectly(startEventId, result.liveState, gameStatus.result.notation, null) + } else if (!result.liveState.isSpectator) { state.addActiveGame(startEventId, result.liveState) pollingDelegate.addGameId(startEventId) } else { @@ -815,6 +837,20 @@ class ChessLobbyLogic( return null } + /** + * Dismiss a finished game from the active/spectating list and move it to completed. + * Called when the user clicks "Continue" on the game end overlay, or automatically + * when a finished game is detected during polling refresh. + */ + fun dismissGame(gameId: String) { + val gameState = state.getGameState(gameId) ?: return + val status = gameState.gameStatus.value + if (status is GameStatus.Finished) { + state.moveToCompleted(gameId, status.result.notation, null) + pollingDelegate.removeGameId(gameId) + } + } + fun clearError() { state.setError(null) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt index 5ebcd39f0..1f5fbaf30 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt @@ -453,6 +453,40 @@ class ChessLobbyState( } } + /** + * Build and record a CompletedGame directly from a LiveChessGameState without requiring the + * game to be inserted into activeGames first. Use this when a newly discovered game is already + * finished so we avoid the intermediate addActiveGame → moveToCompleted flicker. + */ + fun addCompletedGameDirectly( + gameId: String, + liveState: LiveChessGameState, + result: String, + termination: String?, + ) { + val whitePubkey = + if (liveState.playerColor == Color.WHITE) liveState.playerPubkey else liveState.opponentPubkey + val blackPubkey = + if (liveState.playerColor == Color.BLACK) liveState.playerPubkey else liveState.opponentPubkey + + val completed = + CompletedGame( + gameId = gameId, + whitePubkey = whitePubkey, + whiteDisplayName = null, + blackPubkey = blackPubkey, + blackDisplayName = null, + result = result, + termination = termination, + moveCount = liveState.moveHistory.value.size, + completedAt = TimeUtils.now(), + ) + + _completedGames.update { current -> + if (current.any { it.gameId == gameId }) current else listOf(completed) + current + } + } + fun addSpectatingGame( gameId: String, state: LiveChessGameState, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt index b329a8c27..fb23322e7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt @@ -162,6 +162,8 @@ class DesktopChessViewModelNew( fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId) + fun dismissGame(gameId: String) = logic.dismissGame(gameId) + // ============================================ // Spectator operations // ============================================ From 8c69105fdb58e186a5cb9777e829926357bd9d0a Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 23 Mar 2026 15:41:38 +0100 Subject: [PATCH 03/31] fix(chess): exclude own games from spectator list Skip adding games to spectatingGames when the user is a participant (playerPubkey or opponentPubkey matches userPubkey) or when the game is already finished. Co-Authored-By: Claude Sonnet 4.6 --- .../amethyst/commons/chess/ChessLobbyState.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt index 1f5fbaf30..d33577f7a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.chess import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.GameStatus import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -497,6 +498,14 @@ class ChessLobbyState( _activeGames.update { it + (gameId to state) } return } + // Don't add own games to spectating list + if (state.playerPubkey == userPubkey || state.opponentPubkey == userPubkey) { + return + } + // Don't add finished games to spectating list + if (state.gameStatus.value is GameStatus.Finished) { + return + } _spectatingGames.update { it + (gameId to state) } } From 006a2229fe337b2884091624b2bd420e6ce3fa2b Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 23 Mar 2026 16:10:41 +0100 Subject: [PATCH 04/31] feat(chess): avatar vs avatar display on board and game list Add ChessPlayerChip.kt with three shared composables: - ChessPlayerChip: single player avatar + name with active border - ChessPlayerVsHeader: mirrored White vs Black header above the board - OverlappingAvatars: compact overlapping circular avatars for list cards Wire avatars into: - LiveChessGameScreen: vs header with active player green border - DesktopChessGameLayout: vs header above the chess board - Android & Desktop lobby cards: overlapping avatars in ActiveGameCard, ChallengeCard, and OutgoingChallengeCard avatar slots Uses existing UserAvatar (coil3 AsyncImage + Robohash fallback) from commons for KMP compatibility. Co-Authored-By: Claude Opus 4.6 --- .../screen/loggedIn/chess/ChessGameScreen.kt | 42 ++- .../screen/loggedIn/chess/ChessLobbyScreen.kt | 64 ++++- .../amethyst/commons/chess/ChessPlayerChip.kt | 248 ++++++++++++++++++ .../amethyst/commons/chess/LiveChessGame.kt | 20 ++ .../amethyst/desktop/chess/ChessScreen.kt | 91 +++++-- 5 files changed, 432 insertions(+), 33 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPlayerChip.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt index ef46c576c..6fe10f787 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt @@ -295,12 +295,42 @@ fun ChessGameScreen( } else -> { - // Resolve opponent display name + // Resolve opponent display name and avatar + val opponentUser = + remember(gameState.opponentPubkey) { + accountViewModel.checkGetOrCreateUser(gameState.opponentPubkey) + } val opponentDisplayName = remember(gameState.opponentPubkey) { - accountViewModel.checkGetOrCreateUser(gameState.opponentPubkey)?.toBestDisplayName() - ?: gameState.opponentPubkey.take(8) + opponentUser?.toBestDisplayName() ?: gameState.opponentPubkey.take(8) } + val opponentAvatarUrl = + remember(gameState.opponentPubkey) { + opponentUser?.profilePicture() + } + + // Resolve player display name and avatar + val playerUser = + remember(gameState.playerPubkey) { + accountViewModel.checkGetOrCreateUser(gameState.playerPubkey) + } + val playerDisplayName = + remember(gameState.playerPubkey) { + playerUser?.toBestDisplayName() ?: gameState.playerPubkey.take(8) + } + val playerAvatarUrl = + remember(gameState.playerPubkey) { + playerUser?.profilePicture() + } + + // Determine white/black pubkeys, names and avatars based on player color + val isPlayerWhite = gameState.playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE + val whitePubkey = if (isPlayerWhite) gameState.playerPubkey else gameState.opponentPubkey + val blackPubkey = if (isPlayerWhite) gameState.opponentPubkey else gameState.playerPubkey + val whiteDisplayName = if (isPlayerWhite) playerDisplayName else opponentDisplayName + val blackDisplayName = if (isPlayerWhite) opponentDisplayName else playerDisplayName + val whiteAvatarUrl = if (isPlayerWhite) playerAvatarUrl else opponentAvatarUrl + val blackAvatarUrl = if (isPlayerWhite) opponentAvatarUrl else playerAvatarUrl // Determine spectator status: // 1. If game was accepted locally, user is definitely NOT a spectator @@ -320,6 +350,12 @@ fun ChessGameScreen( onResign = { chessViewModel.resign(gameId) }, isSpectatorOverride = isSpectating, onGameEndDismiss = { chessViewModel.dismissGame(gameId) }, + whiteName = whiteDisplayName, + whiteHex = whitePubkey, + whiteAvatarUrl = whiteAvatarUrl, + blackName = blackDisplayName, + blackHex = blackPubkey, + blackAvatarUrl = blackAvatarUrl, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt index 96a2502eb..42f56380f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt @@ -69,6 +69,7 @@ import com.vitorpamplona.amethyst.commons.chess.ChessChallenge import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog import com.vitorpamplona.amethyst.commons.chess.OutgoingChallengeCard +import com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars import com.vitorpamplona.amethyst.commons.chess.PublicGameCard import com.vitorpamplona.amethyst.commons.chess.SpectatingGameCard import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox @@ -359,15 +360,28 @@ fun ChessLobbyContent( } items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) -> - val displayName = + val opponent = remember(state.opponentPubkey) { - accountViewModel.checkGetOrCreateUser(state.opponentPubkey)?.toBestDisplayName() ?: state.opponentPubkey.take(8) + accountViewModel.checkGetOrCreateUser(state.opponentPubkey) + } + val displayName = opponent?.toBestDisplayName() ?: state.opponentPubkey.take(8) + val playerUser = + remember(state.playerPubkey) { + accountViewModel.checkGetOrCreateUser(state.playerPubkey) } ActiveGameCard( gameId = gameId, opponentName = displayName, isYourTurn = state.isPlayerTurn(), onClick = { onSelectGame(gameId) }, + avatar = { + OverlappingAvatars( + avatar1Hex = state.playerPubkey, + avatar2Hex = state.opponentPubkey, + avatar1Url = playerUser?.profilePicture(), + avatar2Url = opponent?.profilePicture(), + ) + }, ) } } @@ -386,14 +400,32 @@ fun ChessLobbyContent( } items(outgoingChallenges, key = { "outgoing-${it.eventId}" }) { challenge -> + val opponentUser = + remember(challenge.opponentPubkey) { + challenge.opponentPubkey?.let { accountViewModel.checkGetOrCreateUser(it) } + } val opponentName = - challenge.opponentPubkey?.let { pubkey -> - accountViewModel.checkGetOrCreateUser(pubkey)?.toBestDisplayName() ?: pubkey.take(8) + opponentUser?.toBestDisplayName() + ?: challenge.opponentPubkey?.take(8) + val currentUser = + remember(userPubkey) { + accountViewModel.checkGetOrCreateUser(userPubkey) } OutgoingChallengeCard( opponentName = opponentName, userPlaysWhite = challenge.challengerColor == ChessColor.WHITE, onClick = { onOpenOwnChallenge(challenge) }, + avatar = + challenge.opponentPubkey?.let { opPubkey -> + { + OverlappingAvatars( + avatar1Hex = userPubkey, + avatar2Hex = opPubkey, + avatar1Url = currentUser?.profilePicture(), + avatar2Url = opponentUser?.profilePicture(), + ) + } + }, ) } } @@ -442,11 +474,23 @@ fun ChessLobbyContent( ?: accountViewModel.checkGetOrCreateUser(challenge.challengerPubkey)?.toBestDisplayName() ?: challenge.challengerPubkey.take(8) } + val currentUser = + remember(userPubkey) { + accountViewModel.checkGetOrCreateUser(userPubkey) + } ChallengeCard( challengerName = displayName, challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, isIncoming = true, onAccept = { onAcceptChallenge(challenge) }, + avatar = { + OverlappingAvatars( + avatar1Hex = challenge.challengerPubkey, + avatar2Hex = userPubkey, + avatar1Url = challenge.challengerAvatarUrl, + avatar2Url = currentUser?.profilePicture(), + ) + }, ) } } @@ -471,11 +515,23 @@ fun ChessLobbyContent( ?: accountViewModel.checkGetOrCreateUser(challenge.challengerPubkey)?.toBestDisplayName() ?: challenge.challengerPubkey.take(8) } + val currentUser = + remember(userPubkey) { + accountViewModel.checkGetOrCreateUser(userPubkey) + } ChallengeCard( challengerName = displayName, challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, isIncoming = false, onAccept = { onAcceptChallenge(challenge) }, + avatar = { + OverlappingAvatars( + avatar1Hex = challenge.challengerPubkey, + avatar2Hex = userPubkey, + avatar1Url = challenge.challengerAvatarUrl, + avatar2Url = currentUser?.profilePicture(), + ) + }, ) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPlayerChip.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPlayerChip.kt new file mode 100644 index 000000000..a64db869b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPlayerChip.kt @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import androidx.compose.ui.graphics.Color as ComposeColor + +private val ActiveBorderColor = ComposeColor(0xFF4CAF50) +private val AvatarOuterSize = 36.dp +private val AvatarInnerSize = 32.dp // 4dp inset for border + +/** + * Single player chip showing avatar and name. + * + * @param displayName Player's display name + * @param userHex Player's public key hex (for Robohash fallback) + * @param avatarUrl Optional URL for the player's profile picture + * @param isActive Whether this player's turn is active (shows green border) + * @param mirrored If true, reverses the layout so name appears left of avatar (for right-side player) + * @param modifier Additional modifiers + */ +@Composable +fun ChessPlayerChip( + displayName: String, + userHex: String, + avatarUrl: String?, + isActive: Boolean = false, + mirrored: Boolean = false, + modifier: Modifier = Modifier, +) { + val avatar = + @Composable { + Box( + modifier = + if (isActive) { + Modifier + .size(AvatarOuterSize) + .border(2.dp, ActiveBorderColor, CircleShape) + .clip(CircleShape) + } else { + Modifier + .size(AvatarOuterSize) + .clip(CircleShape) + }, + ) { + UserAvatar( + userHex = userHex, + pictureUrl = avatarUrl, + size = AvatarInnerSize, + modifier = Modifier.align(Alignment.Center), + ) + } + } + + val name = + @Composable { + Text( + text = displayName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isActive) FontWeight.Bold else FontWeight.Normal, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Row( + modifier = modifier.alpha(if (isActive) 1f else 0.6f), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + if (mirrored) { + name() + avatar() + } else { + avatar() + name() + } + } +} + +/** + * Full "White vs Black" header for the game board. + * + * Layout: + * ``` + * [WhiteAvatar] WhiteName vs BlackName [BlackAvatar] + * White Black + * ``` + * + * Active player (based on isWhiteTurn) gets a green avatar border; + * inactive player is slightly dimmed. + * + * @param whiteName Display name for the white player + * @param whiteHex Public key hex for white player (Robohash fallback) + * @param whiteAvatarUrl Optional avatar URL for white player + * @param blackName Display name for the black player + * @param blackHex Public key hex for black player (Robohash fallback) + * @param blackAvatarUrl Optional avatar URL for black player + * @param isWhiteTurn Whether it is currently white's turn + * @param modifier Additional modifiers + */ +@Composable +fun ChessPlayerVsHeader( + whiteName: String, + whiteHex: String, + whiteAvatarUrl: String?, + blackName: String, + blackHex: String, + blackAvatarUrl: String?, + isWhiteTurn: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center, + ) { + // White player (left side) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f), + ) { + ChessPlayerChip( + displayName = whiteName, + userHex = whiteHex, + avatarUrl = whiteAvatarUrl, + isActive = isWhiteTurn, + ) + Text( + text = "White", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + text = "vs", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // Black player (right side) — mirrored so avatar is on the right + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.weight(1f), + ) { + ChessPlayerChip( + displayName = blackName, + userHex = blackHex, + avatarUrl = blackAvatarUrl, + isActive = !isWhiteTurn, + mirrored = true, + ) + Text( + text = "Black", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * Two overlapping circular avatars for compact game list cards. + * + * The second avatar is offset to overlap the first by 8dp. + * + * @param avatar1Hex Public key hex for first player (Robohash fallback) + * @param avatar2Hex Public key hex for second player (Robohash fallback) + * @param avatar1Url Optional avatar URL for first player + * @param avatar2Url Optional avatar URL for second player + * @param size Diameter of each avatar circle + * @param modifier Additional modifiers + */ +@Composable +fun OverlappingAvatars( + avatar1Hex: String, + avatar2Hex: String, + avatar1Url: String?, + avatar2Url: String?, + size: Dp = 28.dp, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier.size(width = size + size - 8.dp, height = size), + ) { + UserAvatar( + userHex = avatar1Hex, + pictureUrl = avatar1Url, + size = size, + ) + + Box( + modifier = Modifier.offset(x = size - 8.dp), + ) { + // Thin border to visually separate overlapping avatars + Box( + modifier = + Modifier + .size(size) + .background(MaterialTheme.colorScheme.surface, CircleShape), + ) + UserAvatar( + userHex = avatar2Hex, + pictureUrl = avatar2Url, + size = size, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt index 3781f0162..5553faa40 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt @@ -201,6 +201,12 @@ fun LiveChessGameScreen( onResign: () -> Unit, isSpectatorOverride: Boolean? = null, onGameEndDismiss: (() -> Unit)? = null, + whiteName: String = "White", + whiteHex: String = "", + whiteAvatarUrl: String? = null, + blackName: String = "Black", + blackHex: String = "", + blackAvatarUrl: String? = null, ) { // Observe state flows for automatic recomposition on updates val currentPosition by gameState.currentPosition.collectAsState() @@ -236,6 +242,20 @@ fun LiveChessGameScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(8.dp), ) { + // Player vs Player avatar header + if (whiteHex.isNotEmpty() || blackHex.isNotEmpty()) { + ChessPlayerVsHeader( + whiteName = whiteName, + whiteHex = whiteHex, + whiteAvatarUrl = whiteAvatarUrl, + blackName = blackName, + blackHex = blackHex, + blackAvatarUrl = blackAvatarUrl, + isWhiteTurn = currentPosition.activeColor == Color.WHITE, + modifier = Modifier.fillMaxWidth(), + ) + } + // Game info - use currentPosition.activeColor for turn display GameInfoHeader( gameId = gameState.startEventId, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index 2668756b8..0ec8464fe 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -268,6 +268,10 @@ fun ChessScreen( val wasAccepted = viewModel.wasAccepted(selectedGameId!!) val isSpectating = !wasAccepted && spectatingGames.containsKey(selectedGameId) + val isPlayerWhite = gameState.playerColor == ChessColor.WHITE + val whitePubkey = if (isPlayerWhite) gameState.playerPubkey else gameState.opponentPubkey + val blackPubkey = if (isPlayerWhite) gameState.opponentPubkey else gameState.playerPubkey + DesktopChessGameLayout( gameState = gameState, opponentName = viewModel.userMetadataCache.getDisplayName(gameState.opponentPubkey), @@ -278,6 +282,12 @@ fun ChessScreen( onResign = { viewModel.resign(gameState.startEventId) }, isSpectatorOverride = isSpectating, compactMode = compactMode, + whiteName = viewModel.userMetadataCache.getDisplayName(whitePubkey), + whiteHex = whitePubkey, + whiteAvatarUrl = viewModel.userMetadataCache.getPictureUrl(whitePubkey), + blackName = viewModel.userMetadataCache.getDisplayName(blackPubkey), + blackHex = blackPubkey, + blackAvatarUrl = viewModel.userMetadataCache.getPictureUrl(blackPubkey), ) } } else { @@ -398,10 +408,11 @@ private fun ChessLobby( isYourTurn = state.isPlayerTurn(), onClick = { onSelectGame(gameId) }, avatar = { - UserAvatar( - userHex = state.opponentPubkey, - pictureUrl = metadataCache.getPictureUrl(state.opponentPubkey), - size = 40.dp, + com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars( + avatar1Hex = state.playerPubkey, + avatar2Hex = state.opponentPubkey, + avatar1Url = metadataCache.getPictureUrl(state.playerPubkey), + avatar2Url = metadataCache.getPictureUrl(state.opponentPubkey), ) }, ) @@ -429,10 +440,11 @@ private fun ChessLobby( avatar = challenge.opponentPubkey?.let { pubkey -> { - UserAvatar( - userHex = pubkey, - pictureUrl = metadataCache.getPictureUrl(pubkey), - size = 40.dp, + com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars( + avatar1Hex = userPubkey, + avatar2Hex = pubkey, + avatar1Url = metadataCache.getPictureUrl(userPubkey), + avatar2Url = metadataCache.getPictureUrl(pubkey), ) } }, @@ -481,10 +493,11 @@ private fun ChessLobby( isIncoming = true, onAccept = { onAcceptChallenge(challenge) }, avatar = { - UserAvatar( - userHex = challenge.challengerPubkey, - pictureUrl = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey), - size = 40.dp, + com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars( + avatar1Hex = challenge.challengerPubkey, + avatar2Hex = userPubkey, + avatar1Url = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey), + avatar2Url = metadataCache.getPictureUrl(userPubkey), ) }, ) @@ -511,10 +524,11 @@ private fun ChessLobby( isIncoming = false, onAccept = { onAcceptChallenge(challenge) }, avatar = { - UserAvatar( - userHex = challenge.challengerPubkey, - pictureUrl = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey), - size = 40.dp, + com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars( + avatar1Hex = challenge.challengerPubkey, + avatar2Hex = userPubkey, + avatar1Url = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey), + avatar2Url = metadataCache.getPictureUrl(userPubkey), ) }, ) @@ -601,6 +615,12 @@ private fun DesktopChessGameLayout( onResign: () -> Unit, isSpectatorOverride: Boolean? = null, compactMode: Boolean = false, + whiteName: String = "White", + whiteHex: String = "", + whiteAvatarUrl: String? = null, + blackName: String = "Black", + blackHex: String = "", + blackAvatarUrl: String? = null, ) { // Collect state flows to trigger recomposition on changes val currentPosition by gameState.currentPosition.collectAsState() @@ -628,20 +648,39 @@ private fun DesktopChessGameLayout( modifier = Modifier.fillMaxSize(), horizontalArrangement = Arrangement.spacedBy(if (showInfoPanel) 24.dp else 0.dp), ) { - // Left side: Chess board + toggle + // Left side: Chess board + toggle + vs header Box( modifier = Modifier.weight(1f).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, - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Player vs Player header above board + if (whiteHex.isNotEmpty() || blackHex.isNotEmpty()) { + com.vitorpamplona.amethyst.commons.chess.ChessPlayerVsHeader( + whiteName = whiteName, + whiteHex = whiteHex, + whiteAvatarUrl = whiteAvatarUrl, + blackName = blackName, + blackHex = blackHex, + blackAvatarUrl = blackAvatarUrl, + isWhiteTurn = currentPosition.activeColor == ChessColor.WHITE, + modifier = Modifier.fillMaxWidth(), + ) + } + + 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, + ) + } // Toggle button anchored to top-end of board area IconButton( From 012e97494e8de4a97f444036d0b876fdc3f8977f Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 23 Mar 2026 16:22:02 +0100 Subject: [PATCH 05/31] feat(chess): add rank numbers (1-8) to board coordinates Co-Authored-By: Claude Sonnet 4.6 --- .../amethyst/commons/chess/ChessBoard.kt | 21 +++++++++++++++++++ .../commons/chess/InteractiveChessBoard.kt | 17 +++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt index dc0cec5bb..66191aff2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt @@ -79,6 +79,12 @@ fun ChessBoard( } else { null }, + rankLabel = + if (showCoordinates && file == 0) { + (rank + 1).toString() + } else { + null + }, ) } } @@ -95,6 +101,7 @@ private fun ChessSquare( isLight: Boolean, size: Dp, coordinate: String?, + rankLabel: String? = null, ) { Box( modifier = @@ -114,6 +121,20 @@ private fun ChessSquare( ) } + // Show rank label (1-8) on left file + rankLabel?.let { + Text( + text = it, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = if (isLight) Color(0xFFB58863) else Color(0xFFF0D9B5), + modifier = + Modifier + .align(Alignment.TopStart) + .padding(2.dp), + ) + } + // Show file coordinate (a-h) on bottom rank coordinate?.let { Text( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt index c91613b26..d103bb747 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt @@ -117,6 +117,7 @@ fun InteractiveChessBoard( val isLegalMove = legalMoves.contains(square) val showCoord = if (flipped) rank == 7 else rank == 0 + val showRankLabel = if (flipped) file == 7 else file == 0 InteractiveChessSquare( piece = piece, @@ -125,6 +126,7 @@ fun InteractiveChessBoard( isSelected = isSelected, isLegalMove = isLegalMove, showCoordinate = showCoord, + showRankLabel = showRankLabel, file = file, rank = rank, onClick = { @@ -231,6 +233,7 @@ private fun InteractiveChessSquare( isSelected: Boolean, isLegalMove: Boolean, showCoordinate: Boolean, + showRankLabel: Boolean, file: Int, rank: Int, onClick: () -> Unit, @@ -286,6 +289,20 @@ private fun InteractiveChessSquare( ) } + // Show rank label (1-8) on left file + if (showRankLabel) { + Text( + text = (rank + 1).toString(), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = if (isLight) Color(0xFFB58863) else Color(0xFFF0D9B5), + modifier = + Modifier + .align(Alignment.TopStart) + .padding(2.dp), + ) + } + // Show file coordinate (a-h) on bottom rank if (showCoordinate) { Text( From ed9d942ce086315a7654a05f330355d3c3d063f6 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 23 Mar 2026 16:24:28 +0100 Subject: [PATCH 06/31] feat(chess): finished games section below active games in lobby Adds a "Finished Games" section to the Android chess lobby screen, showing up to 10 completed games with opponent avatars, result (Won/Lost/Draw), and move count. The section only appears when completedGames is non-empty, matching the Desktop implementation. Co-Authored-By: Claude Sonnet 4.6 --- .../screen/loggedIn/chess/ChessLobbyScreen.kt | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt index 42f56380f..333d9c6e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt @@ -67,6 +67,7 @@ import com.vitorpamplona.amethyst.commons.chess.ActiveGameCard import com.vitorpamplona.amethyst.commons.chess.ChallengeCard import com.vitorpamplona.amethyst.commons.chess.ChessChallenge import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner +import com.vitorpamplona.amethyst.commons.chess.CompletedGameCard import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog import com.vitorpamplona.amethyst.commons.chess.OutgoingChallengeCard import com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars @@ -295,13 +296,15 @@ fun ChessLobbyContent( val spectatingGames by chessViewModel.spectatingGames.collectAsState() val publicGames by chessViewModel.publicGames.collectAsState() val challenges by chessViewModel.challenges.collectAsState() + val completedGames by chessViewModel.completedGames.collectAsState() val userPubkey = accountViewModel.account.userProfile().pubkeyHex val hasContent = activeGames.isNotEmpty() || spectatingGames.isNotEmpty() || publicGames.isNotEmpty() || - challenges.isNotEmpty() + challenges.isNotEmpty() || + completedGames.isNotEmpty() if (!hasContent) { // Empty state - use LazyColumn so pull-to-refresh works @@ -560,6 +563,54 @@ fun ChessLobbyContent( } } + // Finished games section + if (completedGames.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Finished 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 -> + val opponentPubkey = + if (game.whitePubkey == userPubkey) game.blackPubkey else game.whitePubkey + val opponentUser = + remember(opponentPubkey) { + accountViewModel.checkGetOrCreateUser(opponentPubkey) + } + val opponentName = + opponentUser?.toBestDisplayName() + ?: game.blackDisplayName + ?: game.whiteDisplayName + ?: opponentPubkey.take(8) + CompletedGameCard( + opponentName = opponentName, + result = game.result, + didUserWin = game.didUserWin(userPubkey), + isDraw = game.isDraw, + moveCount = game.moveCount, + avatar = { + OverlappingAvatars( + avatar1Hex = userPubkey, + avatar2Hex = opponentPubkey, + avatar1Url = + remember(userPubkey) { + accountViewModel.checkGetOrCreateUser(userPubkey) + }?.profilePicture(), + avatar2Url = opponentUser?.profilePicture(), + ) + }, + ) + } + } + // Bottom padding for FAB item { Spacer(Modifier.height(80.dp)) From 292c6de08aded48a9c80775aa1ccf4c2cf522a7c Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 23 Mar 2026 16:28:44 +0100 Subject: [PATCH 07/31] feat(chess): exit spectator mode with Leave Game button Adds ability to leave a spectated game on both Android and Desktop: - Added stopSpectating() to ChessLobbyLogic (removes from state + stops polling) - Both ViewModels now delegate to logic.stopSpectating() instead of bypassing it - LiveChessGameScreen accepts onLeaveSpectating callback shown in spectator info area - Android: Leave Game button pops back stack and stops spectating - Desktop: Leave Game button clears selectedGameId (returns to lobby) and stops spectating Co-Authored-By: Claude Sonnet 4.6 --- .../screen/loggedIn/chess/ChessGameScreen.kt | 9 ++++ .../loggedIn/chess/ChessViewModelNew.kt | 2 +- .../amethyst/commons/chess/ChessLobbyLogic.kt | 5 +++ .../amethyst/commons/chess/LiveChessGame.kt | 44 ++++++++++++------- .../amethyst/desktop/chess/ChessScreen.kt | 38 ++++++++++++---- .../desktop/chess/DesktopChessViewModelNew.kt | 2 +- 6 files changed, 74 insertions(+), 26 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt index 6fe10f787..702fc5fab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt @@ -350,6 +350,15 @@ fun ChessGameScreen( onResign = { chessViewModel.resign(gameId) }, isSpectatorOverride = isSpectating, onGameEndDismiss = { chessViewModel.dismissGame(gameId) }, + onLeaveSpectating = + if (isSpectating) { + { + chessViewModel.stopSpectating(gameId) + nav.popBack() + } + } else { + null + }, whiteName = whiteDisplayName, whiteHex = whitePubkey, whiteAvatarUrl = whiteAvatarUrl, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt index e6b569e8c..ef1466285 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -176,7 +176,7 @@ class ChessViewModelNew( fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId) - fun stopSpectating(gameId: String) = logic.state.removeSpectatingGame(gameId) + fun stopSpectating(gameId: String) = logic.stopSpectating(gameId) // ============================================ // Utility diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index 6c5df00c5..5005d6edc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -505,6 +505,11 @@ class ChessLobbyLogic( // Spectator mode // ======================================== + fun stopSpectating(gameId: String) { + state.removeSpectatingGame(gameId) + pollingDelegate.removeGameId(gameId) + } + fun loadGameAsSpectator(startEventId: String) { scope.launch(Dispatchers.Default) { state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt index 5553faa40..7ffedeb42 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt @@ -201,6 +201,7 @@ fun LiveChessGameScreen( onResign: () -> Unit, isSpectatorOverride: Boolean? = null, onGameEndDismiss: (() -> Unit)? = null, + onLeaveSpectating: (() -> Unit)? = null, whiteName: String = "White", whiteHex: String = "", whiteAvatarUrl: String? = null, @@ -299,7 +300,7 @@ fun LiveChessGameScreen( } isSpectator -> { - SpectatorInfo() + SpectatorInfo(onLeaveSpectating = onLeaveSpectating) } else -> { @@ -547,22 +548,33 @@ private fun GameInfoHeader( * Info banner shown when spectating a game */ @Composable -private fun SpectatorInfo() { - Box( - modifier = - Modifier - .fillMaxWidth() - .background( - MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), - RoundedCornerShape(8.dp), - ).padding(12.dp), - contentAlignment = Alignment.Center, +private fun SpectatorInfo(onLeaveSpectating: (() -> Unit)? = null) { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Text( - text = "Watching game - spectator mode", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onTertiaryContainer, - ) + Box( + modifier = + Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), + RoundedCornerShape(8.dp), + ).padding(12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Watching game - spectator mode", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + onLeaveSpectating?.let { onLeave -> + OutlinedButton(onClick = onLeave) { + Text("Leave Game") + } + } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index 0ec8464fe..f80ba8562 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -281,6 +281,15 @@ fun ChessScreen( }, onResign = { viewModel.resign(gameState.startEventId) }, isSpectatorOverride = isSpectating, + onLeaveSpectating = + if (isSpectating) { + { + viewModel.stopSpectating(gameState.startEventId) + viewModel.selectGame(null) + } + } else { + null + }, compactMode = compactMode, whiteName = viewModel.userMetadataCache.getDisplayName(whitePubkey), whiteHex = whitePubkey, @@ -614,6 +623,7 @@ private fun DesktopChessGameLayout( onMoveMade: (from: String, to: String, san: String) -> Unit, onResign: () -> Unit, isSpectatorOverride: Boolean? = null, + onLeaveSpectating: (() -> Unit)? = null, compactMode: Boolean = false, whiteName: String = "White", whiteHex: String = "", @@ -860,16 +870,28 @@ private fun DesktopChessGameLayout( containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), ), ) { - Row( + Column( modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - Icon(Icons.Default.Visibility, contentDescription = null) - Text( - "Watching game - spectator mode", - style = MaterialTheme.typography.bodyMedium, - ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon(Icons.Default.Visibility, contentDescription = null) + Text( + "Watching game - spectator mode", + style = MaterialTheme.typography.bodyMedium, + ) + } + onLeaveSpectating?.let { onLeave -> + OutlinedButton( + onClick = onLeave, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Leave Game") + } + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt index fb23322e7..a09245178 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt @@ -172,7 +172,7 @@ class DesktopChessViewModelNew( fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId) - fun stopSpectating(gameId: String) = logic.state.removeSpectatingGame(gameId) + fun stopSpectating(gameId: String) = logic.stopSpectating(gameId) // ============================================ // Utility From 478c4cc35641b116ab08e2162416f569ccc47e12 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 23 Mar 2026 16:32:56 +0100 Subject: [PATCH 08/31] feat(chess): notifications for challenge accepted and opponent moves Add Android push notifications for chess events: - LiveChessGameAcceptEvent (kind 30065): notifies when opponent accepts challenge - LiveChessMoveEvent (kind 30066): notifies when opponent makes a move - New Chess notification channel alongside existing DM and Zap channels - Chess kinds added to NotificationFeedFilter for in-app notification feed Co-Authored-By: Claude Opus 4.6 --- .../EventNotificationConsumer.kt | 71 +++++++++++++++++++ .../notifications/NotificationUtils.kt | 52 ++++++++++++++ .../dal/NotificationFeedFilter.kt | 4 ++ amethyst/src/main/res/values/strings.xml | 7 ++ 4 files changed, 134 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 2ccd1a069..55f0849b9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendChessNotification import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification import com.vitorpamplona.amethyst.ui.note.showAmount @@ -47,6 +48,8 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import java.math.BigDecimal @@ -108,6 +111,8 @@ class EventNotificationConsumer( is LnZapEvent -> notify(innerEvent, account) is ChatMessageEvent -> notify(innerEvent, account) is ChatMessageEncryptedFileHeaderEvent -> notify(innerEvent, account) + is LiveChessGameAcceptEvent -> notify(innerEvent, account) + is LiveChessMoveEvent -> notify(innerEvent, account) } } } @@ -481,6 +486,72 @@ class EventNotificationConsumer( } } + private suspend fun notify( + event: LiveChessGameAcceptEvent, + account: Account, + ) { + Log.d(TAG, "New Chess Challenge Accept to Notify") + if ( + event.createdAt > TimeUtils.fifteenMinutesAgo() && + event.pubKey != account.signer.pubKey + ) { + val author = LocalCache.getOrCreateUser(event.pubKey) + val user = author.toBestDisplayName() + val userPicture = author.profilePicture() + val title = stringRes(applicationContext, R.string.app_notification_chess_channel_name) + val content = stringRes(applicationContext, R.string.app_notification_chess_challenge_accepted, user) + val noteUri = + "notifications$ACCOUNT_QUERY_PARAM" + + account.signer.pubKey + .hexToByteArray() + .toNpub() + + notificationManager() + .sendChessNotification( + event.id, + content, + title, + event.createdAt, + userPicture, + noteUri, + applicationContext, + ) + } + } + + private suspend fun notify( + event: LiveChessMoveEvent, + account: Account, + ) { + Log.d(TAG, "New Chess Move to Notify") + if ( + event.createdAt > TimeUtils.fifteenMinutesAgo() && + event.pubKey != account.signer.pubKey + ) { + val author = LocalCache.getOrCreateUser(event.pubKey) + val user = author.toBestDisplayName() + val userPicture = author.profilePicture() + val title = stringRes(applicationContext, R.string.app_notification_chess_channel_name) + val content = stringRes(applicationContext, R.string.app_notification_chess_your_turn, user) + val noteUri = + "notifications$ACCOUNT_QUERY_PARAM" + + account.signer.pubKey + .hexToByteArray() + .toNpub() + + notificationManager() + .sendChessNotification( + event.id, + content, + title, + event.createdAt, + userPicture, + noteUri, + applicationContext, + ) + } + } + fun notificationManager(): NotificationManager = ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 9c16a2e64..a418f223e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -45,8 +45,10 @@ import kotlinx.coroutines.withContext object NotificationUtils { private var dmChannel: NotificationChannel? = null private var zapChannel: NotificationChannel? = null + private var chessChannel: NotificationChannel? = null private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION" private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION" + private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION" const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION" const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION" const val KEY_REPLY_TEXT = "key_reply_text" @@ -56,6 +58,7 @@ object NotificationUtils { private const val DM_SUMMARY_ID = 0x10000 private const val ZAP_SUMMARY_ID = 0x20000 + private const val CHESS_SUMMARY_ID = 0x30000 fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel { if (dmChannel != null) return dmChannel!! @@ -99,6 +102,55 @@ object NotificationUtils { return zapChannel!! } + fun getOrCreateChessChannel(applicationContext: Context): NotificationChannel { + if (chessChannel != null) return chessChannel!! + + chessChannel = + NotificationChannel( + stringRes(applicationContext, R.string.app_notification_chess_channel_id), + stringRes(applicationContext, R.string.app_notification_chess_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = + stringRes(applicationContext, R.string.app_notification_chess_channel_description) + } + + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(chessChannel!!) + + return chessChannel!! + } + + suspend fun NotificationManager.sendChessNotification( + id: String, + messageBody: String, + messageTitle: String, + time: Long, + pictureUrl: String?, + uri: String, + applicationContext: Context, + ) { + getOrCreateChessChannel(applicationContext) + val channelId = stringRes(applicationContext, R.string.app_notification_chess_channel_id) + + sendNotification( + id = id, + messageBody = messageBody, + messageTitle = messageTitle, + time = time, + pictureUrl = pictureUrl, + uri = uri, + channelId = channelId, + notificationGroupKey = CHESS_GROUP_KEY, + category = NotificationCompat.CATEGORY_SOCIAL, + summaryId = CHESS_SUMMARY_ID, + summaryText = stringRes(applicationContext, R.string.app_notification_chess_summary), + applicationContext = applicationContext, + ) + } + suspend fun NotificationManager.sendZapNotification( id: String, messageBody: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index 2e2f89eba..f03728166 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -58,6 +58,8 @@ import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEven import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent @@ -83,6 +85,8 @@ class NotificationFeedFilter( CalendarRSVPEvent.KIND, ClassifiedsEvent.KIND, LiveActivitiesEvent.KIND, + LiveChessGameAcceptEvent.KIND, + LiveChessMoveEvent.KIND, LongTextNoteEvent.KIND, NipTextEvent.KIND, VideoVerticalEvent.KIND, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index bf05100bf..cc337d2e0 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -738,6 +738,13 @@ New messages New zaps + ChessID + Chess + Notifies you about chess game events + %1$s accepted your chess challenge + %1$s moved — your turn + Chess updates + Notify: Join Conversation From 08d5f2ebb55e8631912c9d7d997eac4cb258464f Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 23 Mar 2026 17:01:19 +0100 Subject: [PATCH 09/31] fix(chess): resolve TimeUtils compilation error in ChessLobbyState Co-Authored-By: Claude Opus 4.6 --- .../vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt index d33577f7a..894673718 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt @@ -480,7 +480,9 @@ class ChessLobbyState( result = result, termination = termination, moveCount = liveState.moveHistory.value.size, - completedAt = TimeUtils.now(), + completedAt = + com.vitorpamplona.quartz.utils.TimeUtils + .now(), ) _completedGames.update { current -> From c86daa695009aad5180632f23a18344b86ed9696 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 23 Mar 2026 18:01:29 +0100 Subject: [PATCH 10/31] fix(chess): filter own games from live list, dismissable errors, clickable avatars and completed games - Filter user's own games from public "Live Games" list in lobby - Fix broken addSpectatingGame filter (playerPubkey is always viewerPubkey) - Add removeGame() to clean up stale entries when "Game not found" - Make player avatars clickable to open profile on game screen - Make completed game cards clickable to view final board state Co-Authored-By: Claude Opus 4.6 --- .../ui/screen/loggedIn/chess/ChessGameScreen.kt | 7 ++++++- .../ui/screen/loggedIn/chess/ChessLobbyScreen.kt | 1 + .../ui/screen/loggedIn/chess/ChessViewModelNew.kt | 2 ++ .../amethyst/commons/chess/ChessLobbyCards.kt | 3 +++ .../amethyst/commons/chess/ChessLobbyLogic.kt | 10 ++++++++++ .../amethyst/commons/chess/ChessLobbyState.kt | 7 +++++-- .../amethyst/commons/chess/ChessPlayerChip.kt | 11 ++++++++++- .../amethyst/commons/chess/LiveChessGame.kt | 3 +++ .../amethyst/desktop/chess/ChessScreen.kt | 5 +++++ .../desktop/chess/DesktopChessViewModelNew.kt | 2 ++ 10 files changed, 47 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt index 702fc5fab..46231e950 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt @@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner import com.vitorpamplona.amethyst.commons.chess.LiveChessGameScreen import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource.ChessSubscription import com.vitorpamplona.amethyst.ui.stringRes @@ -283,7 +284,10 @@ fun ChessGameScreen( Spacer(modifier = Modifier.height(24.dp)) - Button(onClick = { nav.popBack() }) { + Button(onClick = { + chessViewModel.removeGame(gameId) + nav.popBack() + }) { Icon( imageVector = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringRes(R.string.back), @@ -365,6 +369,7 @@ fun ChessGameScreen( blackName = blackDisplayName, blackHex = blackPubkey, blackAvatarUrl = blackAvatarUrl, + onPlayerClick = { pubkeyHex -> nav.nav(Route.Profile(pubkeyHex)) }, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt index 333d9c6e1..5a8f0b416 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt @@ -596,6 +596,7 @@ fun ChessLobbyContent( didUserWin = game.didUserWin(userPubkey), isDraw = game.isDraw, moveCount = game.moveCount, + onClick = { onSelectGame(game.gameId) }, avatar = { OverlappingAvatars( avatar1Hex = userPubkey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt index ef1466285..97711b9e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -178,6 +178,8 @@ class ChessViewModelNew( fun stopSpectating(gameId: String) = logic.stopSpectating(gameId) + fun removeGame(gameId: String) = logic.removeGame(gameId) + // ============================================ // Utility // ============================================ diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt index e9f23f8f7..ad8124bcf 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt @@ -288,6 +288,7 @@ fun CompletedGameCard( didUserWin: Boolean, isDraw: Boolean, moveCount: Int, + onClick: (() -> Unit)? = null, modifier: Modifier = Modifier, avatar: @Composable (() -> Unit)? = null, ) { @@ -309,6 +310,8 @@ fun CompletedGameCard( } Card( + onClick = onClick ?: {}, + enabled = onClick != null, modifier = modifier.fillMaxWidth(), ) { Row( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index 5005d6edc..8bb9c3c92 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -856,6 +856,16 @@ class ChessLobbyLogic( } } + /** + * Remove a game from all lobby lists (active, spectating) and stop polling. + * Used when a game fails to load ("Game not found") so the user can dismiss the stale entry. + */ + fun removeGame(gameId: String) { + state.removeActiveGame(gameId) + state.removeSpectatingGame(gameId) + pollingDelegate.removeGameId(gameId) + } + fun clearError() { state.setError(null) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt index 894673718..9604fff1d 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt @@ -297,7 +297,8 @@ class ChessLobbyState( } fun updatePublicGames(games: List) { - _publicGames.value = games + // Filter out games where the user is a participant + _publicGames.value = games.filter { it.whitePubkey != userPubkey && it.blackPubkey != userPubkey } } fun addActiveGame( @@ -501,7 +502,9 @@ class ChessLobbyState( return } // Don't add own games to spectating list - if (state.playerPubkey == userPubkey || state.opponentPubkey == userPubkey) { + // Note: LiveChessGameState.playerPubkey is always viewerPubkey regardless of role, + // so we check isSpectator instead to determine if the user is actually a participant + if (!state.isSpectator) { return } // Don't add finished games to spectating list diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPlayerChip.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPlayerChip.kt index a64db869b..a363f3e70 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPlayerChip.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPlayerChip.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.chess import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -64,6 +65,7 @@ fun ChessPlayerChip( avatarUrl: String?, isActive: Boolean = false, mirrored: Boolean = false, + onClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { val avatar = @@ -102,7 +104,10 @@ fun ChessPlayerChip( } Row( - modifier = modifier.alpha(if (isActive) 1f else 0.6f), + modifier = + modifier + .alpha(if (isActive) 1f else 0.6f) + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp), ) { @@ -146,6 +151,8 @@ fun ChessPlayerVsHeader( blackHex: String, blackAvatarUrl: String?, isWhiteTurn: Boolean, + onWhiteClick: (() -> Unit)? = null, + onBlackClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { Row( @@ -163,6 +170,7 @@ fun ChessPlayerVsHeader( userHex = whiteHex, avatarUrl = whiteAvatarUrl, isActive = isWhiteTurn, + onClick = onWhiteClick, ) Text( text = "White", @@ -188,6 +196,7 @@ fun ChessPlayerVsHeader( avatarUrl = blackAvatarUrl, isActive = !isWhiteTurn, mirrored = true, + onClick = onBlackClick, ) Text( text = "Black", diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt index 7ffedeb42..09282773e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt @@ -208,6 +208,7 @@ fun LiveChessGameScreen( blackName: String = "Black", blackHex: String = "", blackAvatarUrl: String? = null, + onPlayerClick: ((pubkeyHex: String) -> Unit)? = null, ) { // Observe state flows for automatic recomposition on updates val currentPosition by gameState.currentPosition.collectAsState() @@ -253,6 +254,8 @@ fun LiveChessGameScreen( blackHex = blackHex, blackAvatarUrl = blackAvatarUrl, isWhiteTurn = currentPosition.activeColor == Color.WHITE, + onWhiteClick = onPlayerClick?.let { { it(whiteHex) } }, + onBlackClick = onPlayerClick?.let { { it(blackHex) } }, modifier = Modifier.fillMaxWidth(), ) } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index f80ba8562..3d83bdf29 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -252,8 +252,12 @@ fun ChessScreen( // Main content if (selectedGameId != null) { // Set focused game mode - only poll this game, not others + // If game isn't in active/spectating maps (e.g. completed game), load from relays LaunchedEffect(selectedGameId) { viewModel.setFocusedGame(selectedGameId!!) + if (viewModel.getGameState(selectedGameId!!) == null) { + viewModel.loadGame(selectedGameId!!) + } } // Use stateVersion to ensure recomposition when game state changes @@ -591,6 +595,7 @@ private fun ChessLobby( didUserWin = game.didUserWin(userPubkey), isDraw = game.isDraw, moveCount = game.moveCount, + onClick = { onSelectGame(game.gameId) }, avatar = { UserAvatar( userHex = opponentPubkey, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt index a09245178..b698514f3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt @@ -174,6 +174,8 @@ class DesktopChessViewModelNew( fun stopSpectating(gameId: String) = logic.stopSpectating(gameId) + fun removeGame(gameId: String) = logic.removeGame(gameId) + // ============================================ // Utility // ============================================ From a6205a29c40291738652f5a6ba9d8f0b26960ce1 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 23 Mar 2026 18:08:57 +0100 Subject: [PATCH 11/31] fix(chess): add chess kinds to notification relay subscription Chess event kinds 30065 (accept) and 30066 (move) were missing from the notification relay subscription filters, so they were never fetched from relays for the in-app notification feed. Also bypass the follow-list filter for chess events since opponents may not be followed. Co-Authored-By: Claude Opus 4.6 --- .../nip01Notifications/FilterNotificationsToPubkey.kt | 4 ++++ .../loggedIn/notifications/dal/NotificationFeedFilter.kt | 5 ++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 0d22198a7..b00996b98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -47,6 +47,8 @@ import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent @@ -85,6 +87,8 @@ val NotificationsPerKeyKinds2 = CalendarRSVPEvent.KIND, InteractiveStoryPrologueEvent.KIND, InteractiveStorySceneEvent.KIND, + LiveChessGameAcceptEvent.KIND, + LiveChessMoveEvent.KIND, ) val NotificationsPerKeyKinds3 = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index f03728166..ce26d0bf7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -189,9 +189,12 @@ class NotificationFeedFilter( } } + // Chess events bypass the follow filter — opponents may not be followed + val isChessEvent = noteEvent is LiveChessGameAcceptEvent || noteEvent is LiveChessMoveEvent + return noteEvent?.kind in NOTIFICATION_KINDS && (noteEvent is LnZapEvent || notifAuthor != loggedInUserHex) && - (filterParams.isGlobal(it.relays) || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && + (isChessEvent || filterParams.isGlobal(it.relays) || notifAuthor == null || filterParams.isAuthorInFollows(notifAuthor)) && noteEvent?.isTaggedUser(loggedInUserHex) ?: false && (filterParams.isHiddenList || notifAuthor == null || !account.isHidden(notifAuthor)) && (noteEvent !is PrivateDmEvent || !account.isDecryptedContentHidden(noteEvent)) && From 6c8021f2196e74ecec96c9a79efc2a7c359d4462 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 23 Mar 2026 20:37:57 +0100 Subject: [PATCH 12/31] code review fixes: Deduplicate chess notify() into shared notifyChessEvent() helper using BaseChessEvent Remove addCompletedGameDirectly, extend moveToCompleted with optional liveState param Hoist currentUser lookup to top of ChessLobbyContent, remove 4 duplicate remembers Add missing imports in desktop ChessScreen, replace all FQN usages with short names Remove redundant distinctBy in completed games UI (state already prevents duplicates) --- .../EventNotificationConsumer.kt | 71 ++++++--------- .../screen/loggedIn/chess/ChessLobbyScreen.kt | 29 ++----- .../amethyst/commons/chess/ChessLobbyLogic.kt | 3 +- .../amethyst/commons/chess/ChessLobbyState.kt | 87 ++++--------------- .../amethyst/desktop/chess/ChessScreen.kt | 35 +++++--- 5 files changed, 76 insertions(+), 149 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index 55f0849b9..e1252c1c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -48,6 +48,7 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent import com.vitorpamplona.quartz.nip64Chess.challenge.accept.LiveChessGameAcceptEvent import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.utils.Log @@ -107,12 +108,29 @@ class EventNotificationConsumer( Log.d(TAG, "Unwrapped consume ${innerEvent.javaClass.simpleName}") when (innerEvent) { - is PrivateDmEvent -> notify(innerEvent, account) - is LnZapEvent -> notify(innerEvent, account) - is ChatMessageEvent -> notify(innerEvent, account) - is ChatMessageEncryptedFileHeaderEvent -> notify(innerEvent, account) - is LiveChessGameAcceptEvent -> notify(innerEvent, account) - is LiveChessMoveEvent -> notify(innerEvent, account) + is PrivateDmEvent -> { + notify(innerEvent, account) + } + + is LnZapEvent -> { + notify(innerEvent, account) + } + + is ChatMessageEvent -> { + notify(innerEvent, account) + } + + is ChatMessageEncryptedFileHeaderEvent -> { + notify(innerEvent, account) + } + + is LiveChessGameAcceptEvent -> { + notifyChessEvent(innerEvent, account, R.string.app_notification_chess_challenge_accepted) + } + + is LiveChessMoveEvent -> { + notifyChessEvent(innerEvent, account, R.string.app_notification_chess_your_turn) + } } } } @@ -486,11 +504,11 @@ class EventNotificationConsumer( } } - private suspend fun notify( - event: LiveChessGameAcceptEvent, + private suspend fun notifyChessEvent( + event: BaseChessEvent, account: Account, + contentStringRes: Int, ) { - Log.d(TAG, "New Chess Challenge Accept to Notify") if ( event.createdAt > TimeUtils.fifteenMinutesAgo() && event.pubKey != account.signer.pubKey @@ -499,40 +517,7 @@ class EventNotificationConsumer( val user = author.toBestDisplayName() val userPicture = author.profilePicture() val title = stringRes(applicationContext, R.string.app_notification_chess_channel_name) - val content = stringRes(applicationContext, R.string.app_notification_chess_challenge_accepted, user) - val noteUri = - "notifications$ACCOUNT_QUERY_PARAM" + - account.signer.pubKey - .hexToByteArray() - .toNpub() - - notificationManager() - .sendChessNotification( - event.id, - content, - title, - event.createdAt, - userPicture, - noteUri, - applicationContext, - ) - } - } - - private suspend fun notify( - event: LiveChessMoveEvent, - account: Account, - ) { - Log.d(TAG, "New Chess Move to Notify") - if ( - event.createdAt > TimeUtils.fifteenMinutesAgo() && - event.pubKey != account.signer.pubKey - ) { - val author = LocalCache.getOrCreateUser(event.pubKey) - val user = author.toBestDisplayName() - val userPicture = author.profilePicture() - val title = stringRes(applicationContext, R.string.app_notification_chess_channel_name) - val content = stringRes(applicationContext, R.string.app_notification_chess_your_turn, user) + val content = stringRes(applicationContext, contentStringRes, user) val noteUri = "notifications$ACCOUNT_QUERY_PARAM" + account.signer.pubKey diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt index 5a8f0b416..40f04a28a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt @@ -298,6 +298,10 @@ fun ChessLobbyContent( val challenges by chessViewModel.challenges.collectAsState() val completedGames by chessViewModel.completedGames.collectAsState() val userPubkey = accountViewModel.account.userProfile().pubkeyHex + val currentUser = + remember(userPubkey) { + accountViewModel.checkGetOrCreateUser(userPubkey) + } val hasContent = activeGames.isNotEmpty() || @@ -368,10 +372,6 @@ fun ChessLobbyContent( accountViewModel.checkGetOrCreateUser(state.opponentPubkey) } val displayName = opponent?.toBestDisplayName() ?: state.opponentPubkey.take(8) - val playerUser = - remember(state.playerPubkey) { - accountViewModel.checkGetOrCreateUser(state.playerPubkey) - } ActiveGameCard( gameId = gameId, opponentName = displayName, @@ -381,7 +381,7 @@ fun ChessLobbyContent( OverlappingAvatars( avatar1Hex = state.playerPubkey, avatar2Hex = state.opponentPubkey, - avatar1Url = playerUser?.profilePicture(), + avatar1Url = currentUser?.profilePicture(), avatar2Url = opponent?.profilePicture(), ) }, @@ -410,10 +410,6 @@ fun ChessLobbyContent( val opponentName = opponentUser?.toBestDisplayName() ?: challenge.opponentPubkey?.take(8) - val currentUser = - remember(userPubkey) { - accountViewModel.checkGetOrCreateUser(userPubkey) - } OutgoingChallengeCard( opponentName = opponentName, userPlaysWhite = challenge.challengerColor == ChessColor.WHITE, @@ -477,10 +473,6 @@ fun ChessLobbyContent( ?: accountViewModel.checkGetOrCreateUser(challenge.challengerPubkey)?.toBestDisplayName() ?: challenge.challengerPubkey.take(8) } - val currentUser = - remember(userPubkey) { - accountViewModel.checkGetOrCreateUser(userPubkey) - } ChallengeCard( challengerName = displayName, challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, @@ -518,10 +510,6 @@ fun ChessLobbyContent( ?: accountViewModel.checkGetOrCreateUser(challenge.challengerPubkey)?.toBestDisplayName() ?: challenge.challengerPubkey.take(8) } - val currentUser = - remember(userPubkey) { - accountViewModel.checkGetOrCreateUser(userPubkey) - } ChallengeCard( challengerName = displayName, challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, @@ -576,7 +564,7 @@ fun ChessLobbyContent( } items( - completedGames.distinctBy { it.gameId }.take(10), + completedGames.take(10), key = { "completed-${it.gameId}-${it.completedAt}" }, ) { game -> val opponentPubkey = @@ -601,10 +589,7 @@ fun ChessLobbyContent( OverlappingAvatars( avatar1Hex = userPubkey, avatar2Hex = opponentPubkey, - avatar1Url = - remember(userPubkey) { - accountViewModel.checkGetOrCreateUser(userPubkey) - }?.profilePicture(), + avatar1Url = currentUser?.profilePicture(), avatar2Url = opponentUser?.profilePicture(), ) }, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index 8bb9c3c92..1530bb3dc 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -778,8 +778,7 @@ class ChessLobbyLogic( // If the discovered game is already finished, send it straight to completed val gameStatus = result.liveState.gameStatus.value if (gameStatus is GameStatus.Finished) { - // Use the direct helper to avoid the addActiveGame → moveToCompleted flicker - state.addCompletedGameDirectly(startEventId, result.liveState, gameStatus.result.notation, null) + state.moveToCompleted(startEventId, gameStatus.result.notation, null, liveState = result.liveState) } else if (!result.liveState.isSpectator) { state.addActiveGame(startEventId, result.liveState) pollingDelegate.addGameId(startEventId) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt index 9604fff1d..73814c801 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt @@ -394,93 +394,35 @@ class ChessLobbyState( } /** - * Move a game from active/spectating to completed. - * Display names are optional; UI can look them up later if needed. + * Move a game to completed. Looks up the game from active/spectating maps, + * or uses the provided [liveState] if the game isn't in either map yet + * (e.g. a newly discovered game that is already finished). */ fun moveToCompleted( gameId: String, result: String, termination: String?, + liveState: LiveChessGameState? = null, whiteDisplayName: String? = null, blackDisplayName: String? = null, ) { - val existingState = _activeGames.value[gameId] ?: _spectatingGames.value[gameId] - existingState?.let { gameState -> - // Derive white/black pubkeys from player color - val whitePubkey = - if (gameState.playerColor == Color.WHITE) { - gameState.playerPubkey - } else { - gameState.opponentPubkey - } - val blackPubkey = - if (gameState.playerColor == Color.BLACK) { - gameState.playerPubkey - } else { - gameState.opponentPubkey - } + val gameState = _activeGames.value[gameId] ?: _spectatingGames.value[gameId] ?: liveState ?: return - val completed = - CompletedGame( - gameId = gameId, - whitePubkey = whitePubkey, - whiteDisplayName = whiteDisplayName, - blackPubkey = blackPubkey, - blackDisplayName = blackDisplayName, - result = result, - termination = termination, - moveCount = gameState.moveHistory.value.size, - completedAt = - com.vitorpamplona.quartz.utils.TimeUtils - .now(), - ) - - _completedGames.update { current -> - // Avoid duplicates - if (current.any { it.gameId == gameId }) { - current - } else { - listOf(completed) + current - } - } - - // Remove from active/spectating - _activeGames.update { it - gameId } - _spectatingGames.update { it - gameId } - - // Clear selection if this game was selected - if (_selectedGameId.value == gameId) { - _selectedGameId.value = null - } - } - } - - /** - * Build and record a CompletedGame directly from a LiveChessGameState without requiring the - * game to be inserted into activeGames first. Use this when a newly discovered game is already - * finished so we avoid the intermediate addActiveGame → moveToCompleted flicker. - */ - fun addCompletedGameDirectly( - gameId: String, - liveState: LiveChessGameState, - result: String, - termination: String?, - ) { val whitePubkey = - if (liveState.playerColor == Color.WHITE) liveState.playerPubkey else liveState.opponentPubkey + if (gameState.playerColor == Color.WHITE) gameState.playerPubkey else gameState.opponentPubkey val blackPubkey = - if (liveState.playerColor == Color.BLACK) liveState.playerPubkey else liveState.opponentPubkey + if (gameState.playerColor == Color.BLACK) gameState.playerPubkey else gameState.opponentPubkey val completed = CompletedGame( gameId = gameId, whitePubkey = whitePubkey, - whiteDisplayName = null, + whiteDisplayName = whiteDisplayName, blackPubkey = blackPubkey, - blackDisplayName = null, + blackDisplayName = blackDisplayName, result = result, termination = termination, - moveCount = liveState.moveHistory.value.size, + moveCount = gameState.moveHistory.value.size, completedAt = com.vitorpamplona.quartz.utils.TimeUtils .now(), @@ -489,6 +431,15 @@ class ChessLobbyState( _completedGames.update { current -> if (current.any { it.gameId == gameId }) current else listOf(completed) + current } + + // Remove from active/spectating + _activeGames.update { it - gameId } + _spectatingGames.update { it - gameId } + + // Clear selection if this game was selected + if (_selectedGameId.value == gameId) { + _selectedGameId.value = null + } } fun addSpectatingGame( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index 3d83bdf29..b218acbc0 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -43,7 +43,6 @@ import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight 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 @@ -67,14 +66,22 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.min +import com.vitorpamplona.amethyst.commons.chess.ActiveGameCard +import com.vitorpamplona.amethyst.commons.chess.ChallengeCard import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastBanner import com.vitorpamplona.amethyst.commons.chess.ChessChallenge import com.vitorpamplona.amethyst.commons.chess.ChessConfig +import com.vitorpamplona.amethyst.commons.chess.ChessPlayerVsHeader import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner import com.vitorpamplona.amethyst.commons.chess.CompletedGame +import com.vitorpamplona.amethyst.commons.chess.CompletedGameCard import com.vitorpamplona.amethyst.commons.chess.InteractiveChessBoard import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog +import com.vitorpamplona.amethyst.commons.chess.OutgoingChallengeCard +import com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars import com.vitorpamplona.amethyst.commons.chess.PublicGame +import com.vitorpamplona.amethyst.commons.chess.PublicGameCard +import com.vitorpamplona.amethyst.commons.chess.SpectatingGameCard import com.vitorpamplona.amethyst.commons.data.UserMetadataCache import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar import com.vitorpamplona.amethyst.desktop.account.AccountState @@ -415,13 +422,13 @@ private fun ChessLobby( } items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) -> - com.vitorpamplona.amethyst.commons.chess.ActiveGameCard( + ActiveGameCard( gameId = gameId, opponentName = metadataCache.getDisplayName(state.opponentPubkey), isYourTurn = state.isPlayerTurn(), onClick = { onSelectGame(gameId) }, avatar = { - com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars( + OverlappingAvatars( avatar1Hex = state.playerPubkey, avatar2Hex = state.opponentPubkey, avatar1Url = metadataCache.getPictureUrl(state.playerPubkey), @@ -446,14 +453,14 @@ private fun ChessLobby( } items(outgoingChallenges, key = { "outgoing-${it.eventId}" }) { challenge -> - com.vitorpamplona.amethyst.commons.chess.OutgoingChallengeCard( + OutgoingChallengeCard( opponentName = challenge.opponentPubkey?.let { metadataCache.getDisplayName(it) }, userPlaysWhite = challenge.challengerColor == ChessColor.WHITE, onClick = { onOpenOwnChallenge(challenge) }, avatar = challenge.opponentPubkey?.let { pubkey -> { - com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars( + OverlappingAvatars( avatar1Hex = userPubkey, avatar2Hex = pubkey, avatar1Url = metadataCache.getPictureUrl(userPubkey), @@ -479,7 +486,7 @@ private fun ChessLobby( items(spectatingGames.entries.toList(), key = { "spectating-${it.key}" }) { (gameId, state) -> val moveCount by state.moveHistory.collectAsState() - com.vitorpamplona.amethyst.commons.chess.SpectatingGameCard( + SpectatingGameCard( moveCount = moveCount.size, onClick = { onSelectGame(gameId) }, ) @@ -500,13 +507,13 @@ private fun ChessLobby( } items(incomingChallenges, key = { "incoming-${it.eventId}" }) { challenge -> - com.vitorpamplona.amethyst.commons.chess.ChallengeCard( + ChallengeCard( challengerName = challenge.challengerDisplayName ?: metadataCache.getDisplayName(challenge.challengerPubkey), challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, isIncoming = true, onAccept = { onAcceptChallenge(challenge) }, avatar = { - com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars( + OverlappingAvatars( avatar1Hex = challenge.challengerPubkey, avatar2Hex = userPubkey, avatar1Url = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey), @@ -531,13 +538,13 @@ private fun ChessLobby( } items(openChallenges, key = { "open-${it.eventId}" }) { challenge -> - com.vitorpamplona.amethyst.commons.chess.ChallengeCard( + ChallengeCard( challengerName = challenge.challengerDisplayName ?: metadataCache.getDisplayName(challenge.challengerPubkey), challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, isIncoming = false, onAccept = { onAcceptChallenge(challenge) }, avatar = { - com.vitorpamplona.amethyst.commons.chess.OverlappingAvatars( + OverlappingAvatars( avatar1Hex = challenge.challengerPubkey, avatar2Hex = userPubkey, avatar1Url = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey), @@ -561,7 +568,7 @@ private fun ChessLobby( } items(publicGames, key = { "public-${it.gameId}" }) { game -> - com.vitorpamplona.amethyst.commons.chess.PublicGameCard( + PublicGameCard( whiteName = game.whiteDisplayName ?: metadataCache.getDisplayName(game.whitePubkey), blackName = game.blackDisplayName ?: metadataCache.getDisplayName(game.blackPubkey), moveCount = game.moveCount, @@ -583,13 +590,13 @@ private fun ChessLobby( } items( - completedGames.distinctBy { it.gameId }.take(10), + completedGames.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( + CompletedGameCard( opponentName = game.blackDisplayName ?: game.whiteDisplayName ?: metadataCache.getDisplayName(opponentPubkey), result = game.result, didUserWin = game.didUserWin(userPubkey), @@ -674,7 +681,7 @@ private fun DesktopChessGameLayout( ) { // Player vs Player header above board if (whiteHex.isNotEmpty() || blackHex.isNotEmpty()) { - com.vitorpamplona.amethyst.commons.chess.ChessPlayerVsHeader( + ChessPlayerVsHeader( whiteName = whiteName, whiteHex = whiteHex, whiteAvatarUrl = whiteAvatarUrl, From 944fbcd0003def8ebe9b7ce355f57d4247afd8f5 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 24 Mar 2026 14:18:01 +0100 Subject: [PATCH 13/31] add extensive [chessdebug] logging across all chess layers Uses Log.d("chessdebug", ...) via quartz's multiplatform Log utility (android.util.Log on Android, println on Desktop) for proper logcat integration. Layers instrumented: - [Reconstructor] state reconstruction, move replay, desync detection - [Collector/CollectorMgr] event ingestion, dedup, routing - [GameLoader] game loading, live state conversion - [Lobby] challenges, moves, resign, spectating, polling refresh - [Polling] polling cycles, focused mode - [Broadcaster] relay connections, event send/confirm - [LiveGame] move validation, opponent moves, head event tracking - [AndroidVM] incoming event parsing from subscriptions Filter with: adb logcat | grep chessdebug --- .../loggedIn/chess/ChessViewModelNew.kt | 9 ++- .../commons/chess/ChessEventCollector.kt | 26 +++++-- .../commons/chess/ChessEventPolling.kt | 8 ++- .../amethyst/commons/chess/ChessGameLoader.kt | 19 ++++-- .../amethyst/commons/chess/ChessLobbyLogic.kt | 68 +++++++++++++++++-- .../commons/chess/ChessEventBroadcaster.kt | 8 +++ .../nip64Chess/ChessStateReconstructor.kt | 26 ++++++- .../quartz/nip64Chess/LiveChessGameState.kt | 13 ++++ 8 files changed, 159 insertions(+), 18 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt index 97711b9e3..00ec7b067 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip64Chess.Color import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.StateFlow /** @@ -94,6 +95,7 @@ class ChessViewModelNew( // ============================================ init { + Log.d("chessdebug", "[AndroidVM] init: instanceId=$instanceId, userPubkey=${account.userProfile().pubkeyHex.take(8)}") logic.startPolling() } @@ -132,7 +134,12 @@ class ChessViewModelNew( fun handleIncomingEvent(event: Event) { if (event.kind != JesterProtocol.KIND) return - val jesterEvent = event.toJesterEvent() ?: return + val jesterEvent = + event.toJesterEvent() ?: run { + Log.d("chessdebug", "[AndroidVM] handleIncomingEvent: failed to parse kind ${event.kind} event ${event.id.take(8)} as JesterEvent") + return + } + Log.d("chessdebug", "[AndroidVM] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${jesterEvent.isStartEvent()}, isMove=${jesterEvent.isMoveEvent()}") logic.handleIncomingEvent(jesterEvent) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt index 8a4575f3f..297557d66 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt @@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -88,14 +89,20 @@ class ChessEventCollector( * @return true if the event was added, false if already exists or invalid */ fun addEvent(event: JesterEvent): Boolean { - if (processedEventIds.contains(event.id)) return false + if (processedEventIds.contains(event.id)) { + Log.d("chessdebug", "[Collector] DEDUP: event ${event.id.take(8)} already processed for game ${startEventId.take(8)}") + return false + } // Check if this event belongs to our game val eventStartId = event.startEventId() val isStartEvent = event.isStartEvent() && event.id == startEventId val isMoveEvent = event.isMoveEvent() && eventStartId == startEventId - if (!isStartEvent && !isMoveEvent) return false + if (!isStartEvent && !isMoveEvent) { + Log.d("chessdebug", "[Collector] REJECTED: event ${event.id.take(8)} not for game ${startEventId.take(8)} (isStart=$isStartEvent, isMove=$isMoveEvent, eventStartId=${eventStartId?.take(8)})") + return false + } return if (isStartEvent) { addStartEvent(event) @@ -129,6 +136,7 @@ class ChessEventCollector( if (_startEvent.compareAndSet(null, event)) { processedEventIds.add(event.id) incrementEventCount() + Log.d("chessdebug", "[Collector] START event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, createdAt=${event.createdAt}") return true } return false @@ -148,6 +156,7 @@ class ChessEventCollector( if (moves.putIfAbsent(event.id, event) == null) { processedEventIds.add(event.id) incrementEventCount() + Log.d("chessdebug", "[Collector] MOVE event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, move=${event.move()}, historySize=${event.history().size}, fen=${event.fen()?.take(30)}, result=${event.result()}, totalMoves=${moves.size}") return true } return false @@ -254,13 +263,22 @@ class ChessEventCollectorManager { fun addEvent(event: JesterEvent): Boolean { // For start events, create collector with event ID if (event.isStartEvent()) { + Log.d("chessdebug", "[CollectorMgr] Routing START event ${event.id.take(8)} from ${event.pubKey.take(8)}") val collector = getOrCreate(event.id) return collector.addEvent(event) } // For move events, find the collector by startEventId - val startId = event.startEventId() ?: return false - val collector = collectors[startId] ?: return false + val startId = + event.startEventId() ?: run { + Log.d("chessdebug", "[CollectorMgr] REJECTED: move event ${event.id.take(8)} has no startEventId") + return false + } + val collector = + collectors[startId] ?: run { + Log.d("chessdebug", "[CollectorMgr] REJECTED: no collector for game ${startId.take(8)} (active games: ${collectors.keys.map { it.take(8) }})") + return false + } return collector.addEvent(event) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt index f73db65d9..91a46df5c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.chess +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay @@ -167,8 +168,10 @@ class ChessPollingDelegate( */ fun start() { if (_isPolling.value) { + Log.d("chessdebug", "[Polling] start: already polling, skipping") return } + Log.d("chessdebug", "[Polling] start: gameInterval=${config.activeGamePollInterval}ms, challengeInterval=${config.challengePollInterval}ms") _isPolling.value = true // Poll for active games @@ -177,10 +180,11 @@ class ChessPollingDelegate( while (isActive) { val gameIds = getEffectiveGameIds() if (gameIds.isNotEmpty()) { + Log.d("chessdebug", "[Polling] polling ${gameIds.size} games: ${gameIds.map { it.take(8) }}, focused=${_focusedGameId.value?.take(8)}") try { onRefreshGames(gameIds) - } catch (_: Exception) { - // Error during refresh - continue polling + } catch (e: Exception) { + Log.d("chessdebug", "[Polling] ERROR during game refresh: ${e.message}") } } delay(config.activeGamePollInterval) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt index ea6631791..40dd68cc1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip64Chess.ReconstructedGameState import com.vitorpamplona.quartz.nip64Chess.ReconstructionResult import com.vitorpamplona.quartz.nip64Chess.ViewerRole import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents +import com.vitorpamplona.quartz.utils.Log /** * Converts a ReconstructedGameState (from ChessStateReconstructor) into a @@ -63,7 +64,10 @@ object ChessGameLoader { result: ReconstructionResult, viewerPubkey: String, ): LiveChessGameState? { - if (result !is ReconstructionResult.Success) return null + if (result !is ReconstructionResult.Success) { + Log.d("chessdebug", "[GameLoader] toLiveGameState: reconstruction was not successful") + return null + } val state = result.state val engine = result.engine @@ -75,6 +79,8 @@ object ChessGameLoader { ViewerRole.SPECTATOR -> viewerPubkey to (state.blackPubkey ?: "") } + Log.d("chessdebug", "[GameLoader] toLiveGameState: game=${state.startEventId.take(8)}, role=${state.viewerRole}, color=${state.playerColor}, isSpectator=${state.viewerRole == ViewerRole.SPECTATOR}, isPending=${state.isPendingChallenge}, moves=${state.moveHistory.size}") + return LiveChessGameState( startEventId = state.startEventId, playerPubkey = playerPubkey, @@ -91,9 +97,9 @@ object ChessGameLoader { // Mark finished if the game has ended if (state.isFinished() && state.gameStatus is com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished) { - gameState.markAsFinished( - (state.gameStatus as com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished).result, - ) + val gameResult = (state.gameStatus as com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished).result + Log.d("chessdebug", "[GameLoader] Marking game ${state.startEventId.take(8)} as finished: $gameResult") + gameState.markAsFinished(gameResult) } // Handle pending draw offer @@ -115,19 +121,23 @@ object ChessGameLoader { events: JesterGameEvents, viewerPubkey: String, ): LoadGameResult { + Log.d("chessdebug", "[GameLoader] loadGame: startEvent=${events.startEvent?.id?.take(8)}, moves=${events.moves.size}, viewer=${viewerPubkey.take(8)}") val result = ChessStateReconstructor.reconstruct(events, viewerPubkey) return when (result) { is ReconstructionResult.Success -> { val liveState = toLiveGameState(result, viewerPubkey) if (liveState != null) { + Log.d("chessdebug", "[GameLoader] loadGame SUCCESS: game=${result.state.startEventId.take(8)}, status=${result.state.gameStatus}") LoadGameResult.Success(liveState, result.state) } else { + Log.d("chessdebug", "[GameLoader] loadGame FAILED: could not convert to live state") LoadGameResult.Error("Failed to convert reconstructed state to live state") } } is ReconstructionResult.Error -> { + Log.d("chessdebug", "[GameLoader] loadGame ERROR: ${result.message}") LoadGameResult.Error(result.message) } } @@ -149,6 +159,7 @@ object ChessGameLoader { playerColor: Color, isPendingChallenge: Boolean = false, ): LiveChessGameState { + Log.d("chessdebug", "[GameLoader] createNewGame: game=${startEventId.take(8)}, player=${playerPubkey.take(8)}, opponent=${opponentPubkey.take(8)}, color=$playerColor, isPending=$isPendingChallenge") val engine = ChessEngine() engine.reset() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index 1530bb3dc..d839285d1 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip64Chess.GameStatus import com.vitorpamplona.quartz.nip64Chess.PieceType import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -187,6 +188,7 @@ class ChessLobbyLogic( * Called by platform subscription callbacks for real-time updates. */ fun handleIncomingEvent(event: JesterEvent) { + Log.d("chessdebug", "[Lobby] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${event.isStartEvent()}, isMove=${event.isMoveEvent()}, createdAt=${event.createdAt}") when { event.isStartEvent() -> handleStartEvent(event) event.isMoveEvent() -> handleMoveEvent(event) @@ -197,6 +199,8 @@ class ChessLobbyLogic( val startEventId = event.id val challengerColor = event.playerColor() ?: Color.WHITE + Log.d("chessdebug", "[Lobby] handleStartEvent: game=${startEventId.take(8)}, challenger=${event.pubKey.take(8)}, color=$challengerColor, opponent=${event.opponentPubkey()?.take(8)}") + val challenge = ChessChallenge( eventId = event.id, @@ -212,12 +216,26 @@ class ChessLobbyLogic( } private fun handleMoveEvent(event: JesterEvent) { - val startEventId = event.startEventId() ?: return - val san = event.move() ?: return - val fen = event.fen() ?: return + val startEventId = + event.startEventId() ?: run { + Log.d("chessdebug", "[Lobby] handleMoveEvent: REJECTED - no startEventId for event ${event.id.take(8)}") + return + } + val san = + event.move() ?: run { + Log.d("chessdebug", "[Lobby] handleMoveEvent: REJECTED - no move in event ${event.id.take(8)}") + return + } + val fen = + event.fen() ?: run { + Log.d("chessdebug", "[Lobby] handleMoveEvent: REJECTED - no FEN in event ${event.id.take(8)}") + return + } val history = event.history() val moveNumber = history.size + Log.d("chessdebug", "[Lobby] handleMoveEvent: game=${startEventId.take(8)}, move=$san, moveNumber=$moveNumber, from=${event.pubKey.take(8)}, result=${event.result()}") + // Check if this is our game (we're either the author or tagged as opponent) val opponentFromTag = event.opponentPubkey() val isOurGame = event.pubKey == userPubkey || opponentFromTag == userPubkey @@ -227,11 +245,15 @@ class ChessLobbyLogic( // If this is our game but we haven't loaded it yet, load it now // This happens when someone accepts our challenge (makes first move) if (gameState == null && isOurGame) { + Log.d("chessdebug", "[Lobby] handleMoveEvent: game ${startEventId.take(8)} not loaded but is ours - calling handleGameAccepted") handleGameAccepted(startEventId) return // handleGameAccepted will load the game and poll for events } - if (gameState == null) return + if (gameState == null) { + Log.d("chessdebug", "[Lobby] handleMoveEvent: game ${startEventId.take(8)} not loaded and not ours - ignoring") + return + } // Check for game end val result = event.result() @@ -244,6 +266,7 @@ class ChessLobbyLogic( else -> null } if (gameResult != null) { + Log.d("chessdebug", "[Lobby] handleMoveEvent: game ${startEventId.take(8)} ENDED: $result, termination=${event.termination()}") gameState.markAsFinished(gameResult) state.moveToCompleted(startEventId, result, event.termination()) pollingDelegate.removeGameId(startEventId) @@ -253,9 +276,12 @@ class ChessLobbyLogic( // Only apply opponent moves optimistically if (event.pubKey != userPubkey) { + Log.d("chessdebug", "[Lobby] handleMoveEvent: applying opponent move $san (move #$moveNumber) to game ${startEventId.take(8)}") gameState.applyOpponentMove(san, fen, moveNumber) // Update head event ID for move linking gameState.updateHeadEventId(event.id) + } else { + Log.d("chessdebug", "[Lobby] handleMoveEvent: skipping own move $san for game ${startEventId.take(8)}") } } @@ -268,6 +294,7 @@ class ChessLobbyLogic( playerColor: Color = Color.WHITE, timeControl: String? = null, // Not supported in Jester, kept for API compatibility ) { + Log.d("chessdebug", "[Lobby] createChallenge: opponent=${opponentPubkey?.take(8)}, color=$playerColor") scope.launch(Dispatchers.Default) { state.setBroadcastStatus( ChessBroadcastStatus.Broadcasting( @@ -280,6 +307,7 @@ class ChessLobbyLogic( val startEventId = retryWithBackoffResult { publisher.publishStart(playerColor, opponentPubkey) } if (startEventId != null) { + Log.d("chessdebug", "[Lobby] createChallenge SUCCESS: startEventId=${startEventId.take(8)}") // Add challenge to local state - shows in "Your Challenges" section val challenge = ChessChallenge( @@ -302,6 +330,7 @@ class ChessLobbyLogic( state.setBroadcastStatus(ChessBroadcastStatus.Idle) state.setError(null) } else { + Log.d("chessdebug", "[Lobby] createChallenge FAILED: publish returned null") state.setBroadcastStatus( ChessBroadcastStatus.Failed("Challenge", "Failed to publish"), ) @@ -317,6 +346,7 @@ class ChessLobbyLogic( * In Jester protocol, acceptance is implicit - we just track the game locally. */ fun acceptChallenge(challenge: ChessChallenge) { + Log.d("chessdebug", "[Lobby] acceptChallenge: game=${challenge.gameId.take(8)}, challenger=${challenge.challengerPubkey.take(8)}, color=${challenge.challengerColor.opposite()}") // Mark as accepted SYNCHRONOUSLY before launching coroutine // This prevents race where navigation happens before coroutine runs, // which would cause loadGame() to incorrectly mark as spectator @@ -378,14 +408,17 @@ class ChessLobbyLogic( * When we detect our challenge was accepted (opponent made first move), load game from relays. */ fun handleGameAccepted(startEventId: String) { + Log.d("chessdebug", "[Lobby] handleGameAccepted: game ${startEventId.take(8)} - fetching from relays") scope.launch(Dispatchers.Default) { state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) val events = fetcher.fetchGameEvents(startEventId) + Log.d("chessdebug", "[Lobby] handleGameAccepted: fetched ${events.moves.size} moves for game ${startEventId.take(8)}") val result = ChessGameLoader.loadGame(events, userPubkey) when (result) { is LoadGameResult.Success -> { + Log.d("chessdebug", "[Lobby] handleGameAccepted SUCCESS: game ${startEventId.take(8)}, role=${result.reconstructedState.viewerRole}") state.addActiveGame(startEventId, result.liveState) pollingDelegate.addGameId(startEventId) state.setBroadcastStatus(ChessBroadcastStatus.Idle) @@ -393,6 +426,7 @@ class ChessLobbyLogic( } is LoadGameResult.Error -> { + Log.d("chessdebug", "[Lobby] handleGameAccepted FAILED: game ${startEventId.take(8)}, error=${result.message}") state.setError("Failed to load game: ${result.message}") state.setBroadcastStatus(ChessBroadcastStatus.Idle) } @@ -409,9 +443,15 @@ class ChessLobbyLogic( from: String, to: String, ) { - val gameState = state.getGameState(startEventId) ?: return + Log.d("chessdebug", "[Lobby] publishMove: game=${startEventId.take(8)}, from=$from, to=$to") + val gameState = + state.getGameState(startEventId) ?: run { + Log.d("chessdebug", "[Lobby] publishMove: REJECTED - no game state for ${startEventId.take(8)}") + return + } if (state.isSpectating(startEventId)) { + Log.d("chessdebug", "[Lobby] publishMove: REJECTED - spectating game ${startEventId.take(8)}") state.setError("Cannot move while spectating") return } @@ -419,7 +459,13 @@ class ChessLobbyLogic( // Parse promotion suffix from `to` if present (e.g., "e8q" -> "e8" + QUEEN) val (actualTo, promotion) = parsePromotionFromTo(to) - val moveResult = gameState.makeMove(from, actualTo, promotion) ?: return + val moveResult = + gameState.makeMove(from, actualTo, promotion) ?: run { + Log.d("chessdebug", "[Lobby] publishMove: REJECTED - makeMove returned null for $from->$actualTo in game ${startEventId.take(8)}") + return + } + + Log.d("chessdebug", "[Lobby] publishMove: move validated: san=${moveResult.san}, fen=${moveResult.fen.take(30)}, history=${moveResult.history.size} moves, headEvent=${moveResult.headEventId.take(8)}") scope.launch(Dispatchers.Default) { state.setBroadcastStatus( @@ -433,6 +479,7 @@ class ChessLobbyLogic( val newEventId = retryWithBackoffResult { publisher.publishMove(moveResult) } if (newEventId != null) { + Log.d("chessdebug", "[Lobby] publishMove SUCCESS: newEventId=${newEventId.take(8)} for game ${startEventId.take(8)}") // Update head event ID for next move linking gameState.updateHeadEventId(newEventId) @@ -451,6 +498,7 @@ class ChessLobbyLogic( ) state.setError(null) } else { + Log.d("chessdebug", "[Lobby] publishMove FAILED: reverting move ${moveResult.san} for game ${startEventId.take(8)}") // Revert the move since publishing failed gameState.undoLastMove() @@ -463,6 +511,7 @@ class ChessLobbyLogic( } fun resign(startEventId: String) { + Log.d("chessdebug", "[Lobby] resign: game=${startEventId.take(8)}") val gameState = state.getGameState(startEventId) ?: return if (state.isSpectating(startEventId)) { @@ -511,6 +560,7 @@ class ChessLobbyLogic( } fun loadGameAsSpectator(startEventId: String) { + Log.d("chessdebug", "[Lobby] loadGameAsSpectator: game=${startEventId.take(8)}") scope.launch(Dispatchers.Default) { state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) @@ -536,9 +586,11 @@ class ChessLobbyLogic( } fun loadGame(startEventId: String) { + Log.d("chessdebug", "[Lobby] loadGame: game=${startEventId.take(8)}") scope.launch(Dispatchers.Default) { // Don't load if game already exists or was accepted (acceptChallenge will handle it) if (state.getGameState(startEventId) != null || state.wasAccepted(startEventId)) { + Log.d("chessdebug", "[Lobby] loadGame: skipping - already exists or was accepted for ${startEventId.take(8)}") return@launch } @@ -584,6 +636,7 @@ class ChessLobbyLogic( } private suspend fun refreshGame(startEventId: String) { + Log.d("chessdebug", "[Lobby] refreshGame: fetching game ${startEventId.take(8)} from relays") val events = fetcher.fetchGameEvents(startEventId) val result = ChessGameLoader.loadGame(events, userPubkey) @@ -593,14 +646,17 @@ class ChessLobbyLogic( // Check status FIRST to avoid briefly emitting a finished game through activeGames val gameStatus = result.liveState.gameStatus.value if (gameStatus is GameStatus.Finished) { + Log.d("chessdebug", "[Lobby] refreshGame: game ${startEventId.take(8)} is FINISHED (${(gameStatus as GameStatus.Finished).result}), moving to completed") state.moveToCompleted(startEventId, gameStatus.result.notation, null) pollingDelegate.removeGameId(startEventId) } else { + Log.d("chessdebug", "[Lobby] refreshGame: game ${startEventId.take(8)} updated, moves=${result.liveState.moveHistory.value.size}, isPlayerTurn=${result.liveState.isPlayerTurn()}") state.replaceGameState(startEventId, result.liveState) } } is LoadGameResult.Error -> { + Log.d("chessdebug", "[Lobby] refreshGame: ERROR for game ${startEventId.take(8)}: ${result.message}") // Don't overwrite error for periodic refresh failures } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt index 59c37d4df..2a95a8445 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.delay /** @@ -67,11 +68,15 @@ class ChessEventBroadcaster( val targetRelays = ChessConfig.CHESS_RELAYS.map { NormalizedRelayUrl(it) }.toSet() val subId = newSubId() + Log.d("chessdebug", "[Broadcaster] broadcast: event=${event.id.take(8)}, kind=${event.kind}, targetRelays=${targetRelays.map { it.url }}") + // Step 1: Check which relays are already connected val initialConnected = client.connectedRelaysFlow().value val alreadyConnected = targetRelays.intersect(initialConnected) val needsConnection = targetRelays - alreadyConnected + Log.d("chessdebug", "[Broadcaster] connected=${alreadyConnected.map { it.url }}, needsConnection=${needsConnection.map { it.url }}") + // Step 2: If some relays need connection, open a subscription to trigger it if (needsConnection.isNotEmpty()) { // Use a valid filter with recent since timestamp to trigger connection @@ -110,8 +115,11 @@ class ChessEventBroadcaster( } // Step 3: Send the event and wait for OK responses + Log.d("chessdebug", "[Broadcaster] sending event ${event.id.take(8)} and waiting for OK (timeout=${timeoutSeconds}s)") val success = client.sendAndWaitForResponse(event, targetRelays, timeoutSeconds) + Log.d("chessdebug", "[Broadcaster] broadcast result: success=$success for event ${event.id.take(8)}") + // Note: sendAndWaitForResponse only returns aggregate success (any relay accepted) // We don't have per-relay results, so relayResults is empty return BroadcastResult( diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt index 4dd69fbd4..297ca80d6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip64Chess import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents +import com.vitorpamplona.quartz.utils.Log /** * Deterministic chess state reconstruction from Jester protocol events. @@ -59,16 +60,23 @@ object ChessStateReconstructor { events: JesterGameEvents, viewerPubkey: String, ): ReconstructionResult { + Log.d("chessdebug", "[Reconstructor] reconstruct() called: viewerPubkey=${viewerPubkey.take(8)}, startEvent=${events.startEvent?.id?.take(8)}, moveCount=${events.moves.size}") + // Step 1: Get start event (required for player info) val startEvent = events.startEvent - ?: return ReconstructionResult.Error("No start event found") + ?: run { + Log.d("chessdebug", "[Reconstructor] ERROR: No start event found") + return ReconstructionResult.Error("No start event found") + } val startEventId = startEvent.id val challengerPubkey = startEvent.pubKey val challengerColor = startEvent.playerColor() ?: Color.WHITE val challengedPubkey = startEvent.opponentPubkey() + Log.d("chessdebug", "[Reconstructor] startEventId=${startEventId.take(8)}, challenger=${challengerPubkey.take(8)}, challengerColor=$challengerColor, challengedPubkey=${challengedPubkey?.take(8)}") + // In Jester, we determine opponent from the p-tag or from move events // For open challenges, we need to find who made the first move val opponentFromMoves = @@ -77,6 +85,7 @@ object ChessStateReconstructor { ?.pubKey val actualOpponent = challengedPubkey ?: opponentFromMoves + Log.d("chessdebug", "[Reconstructor] opponentFromMoves=${opponentFromMoves?.take(8)}, actualOpponent=${actualOpponent?.take(8)}") // Determine players based on challenger's color choice val (whitePubkey, blackPubkey) = @@ -108,6 +117,8 @@ object ChessStateReconstructor { ViewerRole.SPECTATOR -> blackPubkey ?: "" // For spectators, "opponent" is black } + Log.d("chessdebug", "[Reconstructor] white=${whitePubkey?.take(8)}, black=${blackPubkey?.take(8)}, viewerRole=$viewerRole, playerColor=$playerColor") + // Check if game is pending (no moves yet AND viewer is the challenger) // If viewer accepted someone else's challenge, the game is NOT pending val isChallenger = viewerPubkey == startEvent.pubKey @@ -118,6 +129,11 @@ object ChessStateReconstructor { val latestMove = events.latestMove() val history = latestMove?.history() ?: emptyList() + Log.d("chessdebug", "[Reconstructor] latestMove=${latestMove?.id?.take(8)}, historySize=${history.size}, isPendingChallenge=$isPendingChallenge") + if (history.isNotEmpty()) { + Log.d("chessdebug", "[Reconstructor] history: ${history.joinToString(" ")}") + } + // Track applied moves val appliedMoveNumbers = mutableSetOf() var isDesynced = false @@ -131,8 +147,10 @@ object ChessStateReconstructor { } else { // Move failed - game might be desynced isDesynced = true + Log.d("chessdebug", "[Reconstructor] DESYNC: move #$moveNumber '$san' failed: ${result.error}") // Try to recover by loading the FEN from latest move if available latestMove?.fen()?.let { fen -> + Log.d("chessdebug", "[Reconstructor] Recovering with FEN: $fen") engine.loadFen(fen) } break @@ -144,6 +162,7 @@ object ChessStateReconstructor { val currentFen = engine.getFen() if (!fenPositionsMatch(currentFen, expectedFen)) { isDesynced = true + Log.d("chessdebug", "[Reconstructor] FEN MISMATCH: current=$currentFen, expected=$expectedFen") engine.loadFen(expectedFen) } } @@ -154,17 +173,20 @@ object ChessStateReconstructor { when { gameResult != null -> { val result = parseGameResult(gameResult) + Log.d("chessdebug", "[Reconstructor] Game finished from result tag: $gameResult -> $result") GameStatus.Finished(result) } engine.isCheckmate() -> { val winner = engine.getSideToMove().opposite() + Log.d("chessdebug", "[Reconstructor] Checkmate detected, winner=$winner") GameStatus.Finished( if (winner == Color.WHITE) GameResult.WHITE_WINS else GameResult.BLACK_WINS, ) } engine.isStalemate() -> { + Log.d("chessdebug", "[Reconstructor] Stalemate detected") GameStatus.Finished(GameResult.DRAW) } @@ -197,6 +219,8 @@ object ChessStateReconstructor { timeControl = null, // Not supported in Jester ) + Log.d("chessdebug", "[Reconstructor] Result: status=$gameStatus, appliedMoves=${appliedMoveNumbers.size}, isDesynced=$isDesynced, headEvent=${headEventId.take(8)}") + return ReconstructionResult.Success(state, engine) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt index 7e0daed77..7a49956ab 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip64Chess +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -125,12 +126,15 @@ class LiveChessGameState( to: String, promotion: PieceType? = null, ): ChessMoveEvent? { + Log.d("chessdebug", "[LiveGame] makeMove: game=${startEventId.take(8)}, from=$from, to=$to, promotion=$promotion, isPlayerTurn=${isPlayerTurn()}, status=${_gameStatus.value}") if (!isPlayerTurn()) { + Log.d("chessdebug", "[LiveGame] makeMove REJECTED: not player's turn (sideToMove=${engine.getSideToMove()}, playerColor=$playerColor)") _lastError.value = "Not your turn" return null } if (_gameStatus.value != GameStatus.InProgress) { + Log.d("chessdebug", "[LiveGame] makeMove REJECTED: game not in progress (${_gameStatus.value})") _lastError.value = "Game is not in progress" return null } @@ -171,6 +175,7 @@ class LiveChessGameState( * @param newHeadEventId The ID of the newly published move event */ fun updateHeadEventId(newHeadEventId: String) { + Log.d("chessdebug", "[LiveGame] updateHeadEventId: game=${startEventId.take(8)}, old=${_headEventId.value.take(8)}, new=${newHeadEventId.take(8)}") _headEventId.value = newHeadEventId } @@ -204,8 +209,10 @@ class LiveChessGameState( fen: String, moveNumber: Int? = null, ): Boolean { + Log.d("chessdebug", "[LiveGame] applyOpponentMove: game=${startEventId.take(8)}, san=$san, moveNumber=$moveNumber, expectedMove=${_moveHistory.value.size + 1}") // Check for duplicate move if (moveNumber != null && receivedMoveNumbers.contains(moveNumber)) { + Log.d("chessdebug", "[LiveGame] applyOpponentMove: DUPLICATE move #$moveNumber, ignoring") // Already processed this move, ignore return true } @@ -213,12 +220,14 @@ class LiveChessGameState( // Check for out-of-order move val expectedMoveNumber = _moveHistory.value.size + 1 if (moveNumber != null && moveNumber > expectedMoveNumber) { + Log.d("chessdebug", "[LiveGame] applyOpponentMove: OUT-OF-ORDER move #$moveNumber (expected #$expectedMoveNumber), queuing") // Store for later processing pendingMoves[moveNumber] = san to fen return true } if (isPlayerTurn()) { + Log.d("chessdebug", "[LiveGame] applyOpponentMove: REJECTED - it's player's turn, not opponent's") _lastError.value = "Received move but it's not opponent's turn" return false } @@ -237,11 +246,13 @@ class LiveChessGameState( if (currentFen != fen) { // Positions don't match - desync detected _isDesynced.value = true + Log.d("chessdebug", "[LiveGame] applyOpponentMove: FEN MISMATCH after $san - current=$currentFen, expected=$fen") _lastError.value = "Position mismatch - syncing to opponent's position" // Load the opponent's FEN to stay in sync engine.loadFen(fen) } else { _isDesynced.value = false + Log.d("chessdebug", "[LiveGame] applyOpponentMove: SUCCESS - $san applied, totalMoves=${_moveHistory.value.size + 1}") } _currentPosition.value = engine.getPosition() @@ -260,6 +271,7 @@ class LiveChessGameState( return true } else { + Log.d("chessdebug", "[LiveGame] applyOpponentMove: INVALID move $san - ${result.error}") _lastError.value = "Invalid opponent move: $san" return false } @@ -512,6 +524,7 @@ class LiveChessGameState( * Used when loading a game from cache that already has an end event. */ fun markAsFinished(result: GameResult) { + Log.d("chessdebug", "[LiveGame] markAsFinished: game=${startEventId.take(8)}, result=$result") _gameStatus.value = GameStatus.Finished(result) } From 5bdc684f21eeb57bfb6b7d08c6220a785edb8feb Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 24 Mar 2026 16:41:41 +0100 Subject: [PATCH 14/31] fix: add event-level dedup and handleGameAccepted guard in chess lobby - Dedup incoming events by ID (bounded LRU set of 500) to prevent redundant processing when multiple relays deliver the same event - Filter non-chess kind 30 events early (isStart=false, isMove=false) - Guard handleGameAccepted with recentlyLoadedGames check to prevent N concurrent relay fetches when N move events arrive for same game --- .../datasource/ChessFeedFilterSubAssembler.kt | 34 ++++++++++++++- .../chess/datasource/ChessFilterAssembler.kt | 12 ++++-- .../chess/datasource/ChessSubscription.kt | 13 +++++- .../amethyst/commons/chess/ChessLobbyLogic.kt | 43 +++++++++++++++++++ 4 files changed, 96 insertions(+), 6 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt index 3a1cccd1d..702c7dcbd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt @@ -22,9 +22,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.utils.TimeUtils /** * Query state for chess subscription @@ -39,16 +44,43 @@ data class ChessQueryState( ) /** - * Sub-assembler that creates the actual relay filters + * Sub-assembler that creates the actual relay filters. + * Intercepts incoming chess events from relays and forwards them to the ViewModel. */ class ChessFeedFilterSubAssembler( client: INostrClient, allKeys: () -> Set, ) : PerUniqueIdEoseManager(client, allKeys) { + var onChessEvent: ((Event) -> Unit)? = null + override fun updateFilter( key: ChessQueryState, since: SincePerRelayMap?, ): List = filterChessEvents(key, since) override fun id(key: ChessQueryState): String = key.userPubkey + + override fun newSub(key: ChessQueryState): Subscription = + requestNewSubscription( + object : IRequestListener { + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (isLive) { + newEose(key, relay, TimeUtils.now(), forFilters) + } + onChessEvent?.invoke(event) + } + }, + ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFilterAssembler.kt index 04a4fc5b9..a083cbf0d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFilterAssembler.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient /** @@ -29,10 +30,13 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient class ChessFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { - val group = - listOf( - ChessFeedFilterSubAssembler(client, ::allKeys), - ) + private val subAssembler = ChessFeedFilterSubAssembler(client, ::allKeys) + + val group = listOf(subAssembler) + + fun setOnChessEvent(callback: ((Event) -> Unit)?) { + subAssembler.onChessEvent = callback + } override fun invalidateKeys() = invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt index 11e955ef2..acbe21fc5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt @@ -66,8 +66,19 @@ fun ChessSubscription( ) } + // Wire incoming chess events from relay subscriptions to the ViewModel + val chessAssembler = accountViewModel.dataSources().chess + DisposableEffect(chessAssembler, chessViewModel) { + chessAssembler.setOnChessEvent { event -> + chessViewModel.handleIncomingEvent(event) + } + onDispose { + chessAssembler.setOnChessEvent(null) + } + } + // Register subscription with Amethyst's subscription system - KeyDataSourceSubscription(state, accountViewModel.dataSources().chess) + KeyDataSourceSubscription(state, chessAssembler) // Trigger ViewModel refresh when subscription state changes // This fetches challenges from LocalCache after events arrive diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index d839285d1..4ed8ed9d4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -128,6 +128,15 @@ class ChessLobbyLogic( ) { val state = ChessLobbyState(userPubkey, scope) + // Track when games were last loaded to prevent duplicate fetches + // (e.g., discoverUserGames loads a game, then polling immediately re-fetches it) + private val recentlyLoadedGames = java.util.concurrent.ConcurrentHashMap() + + // Dedup incoming events (same event delivered by multiple relays) + // Bounded LRU: evict oldest when exceeding capacity + private val seenEventIds = java.util.Collections.synchronizedSet(LinkedHashSet()) + private val seenEventIdsMax = 500 + private val pollingDelegate = ChessPollingDelegate( config = pollingConfig, @@ -188,6 +197,20 @@ class ChessLobbyLogic( * Called by platform subscription callbacks for real-time updates. */ fun handleIncomingEvent(event: JesterEvent) { + // Skip non-chess events (kind 30 but not start or move) + if (!event.isStartEvent() && !event.isMoveEvent()) return + + // Dedup: skip if we already processed this event ID (multiple relays deliver same event) + synchronized(seenEventIds) { + if (!seenEventIds.add(event.id)) return + if (seenEventIds.size > seenEventIdsMax) { + seenEventIds.iterator().let { + it.next() + it.remove() + } + } + } + Log.d("chessdebug", "[Lobby] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${event.isStartEvent()}, isMove=${event.isMoveEvent()}, createdAt=${event.createdAt}") when { event.isStartEvent() -> handleStartEvent(event) @@ -408,6 +431,15 @@ class ChessLobbyLogic( * When we detect our challenge was accepted (opponent made first move), load game from relays. */ fun handleGameAccepted(startEventId: String) { + // Skip if already loaded or in-flight (multiple move events from same game trigger this) + val lastLoaded = recentlyLoadedGames[startEventId] + if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) { + Log.d("chessdebug", "[Lobby] handleGameAccepted: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago") + return + } + // Mark immediately to prevent concurrent launches + recentlyLoadedGames[startEventId] = TimeUtils.now() + Log.d("chessdebug", "[Lobby] handleGameAccepted: game ${startEventId.take(8)} - fetching from relays") scope.launch(Dispatchers.Default) { state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) @@ -418,6 +450,7 @@ class ChessLobbyLogic( when (result) { is LoadGameResult.Success -> { + recentlyLoadedGames[startEventId] = TimeUtils.now() Log.d("chessdebug", "[Lobby] handleGameAccepted SUCCESS: game ${startEventId.take(8)}, role=${result.reconstructedState.viewerRole}") state.addActiveGame(startEventId, result.liveState) pollingDelegate.addGameId(startEventId) @@ -569,6 +602,7 @@ class ChessLobbyLogic( when (result) { is LoadGameResult.Success -> { + recentlyLoadedGames[startEventId] = TimeUtils.now() state.addSpectatingGame(startEventId, result.liveState) pollingDelegate.addGameId(startEventId) state.setBroadcastStatus(ChessBroadcastStatus.Idle) @@ -601,6 +635,7 @@ class ChessLobbyLogic( when (result) { is LoadGameResult.Success -> { + recentlyLoadedGames[startEventId] = TimeUtils.now() if (result.liveState.isSpectator) { state.addSpectatingGame(startEventId, result.liveState) } else { @@ -636,6 +671,13 @@ class ChessLobbyLogic( } private suspend fun refreshGame(startEventId: String) { + // Skip if this game was just loaded (prevents duplicate fetch after discoverUserGames) + val lastLoaded = recentlyLoadedGames[startEventId] + if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) { + Log.d("chessdebug", "[Lobby] refreshGame: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago") + return + } + Log.d("chessdebug", "[Lobby] refreshGame: fetching game ${startEventId.take(8)} from relays") val events = fetcher.fetchGameEvents(startEventId) @@ -831,6 +873,7 @@ class ChessLobbyLogic( when (result) { is LoadGameResult.Success -> { + recentlyLoadedGames[startEventId] = TimeUtils.now() // If the discovered game is already finished, send it straight to completed val gameStatus = result.liveState.gameStatus.value if (gameStatus is GameStatus.Finished) { From 1f34a79676a00d4a4ee138c4a16c866d7ef20cf9 Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 24 Mar 2026 19:41:20 +0100 Subject: [PATCH 15/31] fix: prevent double-fetch and stop re-polling finished chess games - Mark recentlyLoadedGames timestamp eagerly in refreshGame (before async fetch) to prevent concurrent fetches for the same game - Clear focused game in removeGameId when the removed game matches, so finished games stop being re-polled Co-Authored-By: Claude Opus 4.6 --- .../vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt | 4 ++++ .../vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt | 2 ++ 2 files changed, 6 insertions(+) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt index 91a46df5c..277ddf542 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt @@ -161,6 +161,10 @@ class ChessPollingDelegate( */ fun removeGameId(gameId: String) { activeGameIdsFlow.value = activeGameIdsFlow.value - gameId + // If this was the focused game, clear focus to stop re-polling a finished game + if (_focusedGameId.value == gameId) { + _focusedGameId.value = null + } } /** diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index 4ed8ed9d4..49e218047 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -677,6 +677,8 @@ class ChessLobbyLogic( Log.d("chessdebug", "[Lobby] refreshGame: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago") return } + // Mark immediately to prevent concurrent fetches for the same game + recentlyLoadedGames[startEventId] = TimeUtils.now() Log.d("chessdebug", "[Lobby] refreshGame: fetching game ${startEventId.take(8)} from relays") val events = fetcher.fetchGameEvents(startEventId) From 86fd92e505e77422f16cfe2506e76c92f2a6259d Mon Sep 17 00:00:00 2001 From: davotoula Date: Tue, 24 Mar 2026 21:26:50 +0100 Subject: [PATCH 16/31] feat(chess): add dismiss/clear-all for finished games in lobby Persist dismissed game IDs locally (SharedPreferences on Android, java.util.prefs on Desktop) so completed games can be permanently hidden from the chess lobby. Adds per-game dismiss button and "Clear all" action when 2+ finished games exist. Co-Authored-By: Claude Opus 4.6 --- .../amethyst/ui/note/types/Chess.kt | 4 +- .../screen/loggedIn/chess/ChessGameScreen.kt | 1 + .../screen/loggedIn/chess/ChessLobbyScreen.kt | 26 ++++++--- .../loggedIn/chess/ChessViewModelFactory.kt | 3 +- .../loggedIn/chess/ChessViewModelNew.kt | 8 +++ .../loggedIn/home/NewChessGameButton.kt | 4 +- .../chess/ChessDismissedGamesStorage.kt | 53 +++++++++++++++++++ .../chess/ChessDismissedGamesStorage.kt | 38 +++++++++++++ .../amethyst/commons/chess/ChessLobbyCards.kt | 14 +++++ .../amethyst/commons/chess/ChessLobbyLogic.kt | 20 +++++++ .../amethyst/commons/chess/ChessLobbyState.kt | 10 ++++ .../chess/ChessDismissedGamesStorage.kt | 51 ++++++++++++++++++ .../amethyst/desktop/chess/ChessScreen.kt | 28 +++++++--- .../desktop/chess/DesktopChessViewModelNew.kt | 7 +++ 14 files changed, 251 insertions(+), 16 deletions(-) create mode 100644 commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt index 066de85c4..f340dec51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.note.types +import androidx.activity.compose.LocalActivity import androidx.compose.foundation.border import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -97,11 +98,12 @@ fun RenderLiveChessChallenge( ) { val event = (note.event as? LiveChessGameChallengeEvent) ?: return val gameId = event.gameId() + val activity = LocalActivity.current as androidx.fragment.app.FragmentActivity val chessViewModel: ChessViewModelNew = viewModel( key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", - factory = ChessViewModelFactory(accountViewModel.account), + factory = ChessViewModelFactory(accountViewModel.account, activity.application), ) val isOpenChallenge = event.opponentPubkey() == null diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt index 46231e950..dd71450ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt @@ -102,6 +102,7 @@ fun ChessGameScreen( factory = ChessViewModelFactory( accountViewModel.account, + activity.application, ), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt index 40f04a28a..f664a51af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt @@ -45,6 +45,7 @@ import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -96,7 +97,7 @@ fun ChessLobbyScreen( viewModel( viewModelStoreOwner = activity, key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", - factory = ChessViewModelFactory(accountViewModel.account), + factory = ChessViewModelFactory(accountViewModel.account, activity.application), ) // Subscribe to chess events when screen is visible @@ -555,12 +556,22 @@ fun ChessLobbyContent( if (completedGames.isNotEmpty()) { item { Spacer(Modifier.height(16.dp)) - Text( - "Finished Games", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - modifier = Modifier.padding(vertical = 8.dp), - ) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Finished Games", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + if (completedGames.size >= 2) { + TextButton(onClick = { chessViewModel.dismissAllCompletedGames() }) { + Text("Clear all") + } + } + } } items( @@ -585,6 +596,7 @@ fun ChessLobbyContent( isDraw = game.isDraw, moveCount = game.moveCount, onClick = { onSelectGame(game.gameId) }, + onDismiss = { chessViewModel.dismissCompletedGame(game.gameId) }, avatar = { OverlappingAvatars( avatar1Hex = userPubkey, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt index b4c33a68d..ea1e19164 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt @@ -30,11 +30,12 @@ import com.vitorpamplona.amethyst.model.Account */ class ChessViewModelFactory( private val account: Account, + private val application: android.app.Application, ) : ViewModelProvider.Factory { @Suppress("UNCHECKED_CAST") override fun create(modelClass: Class): T { if (modelClass.isAssignableFrom(ChessViewModelNew::class.java)) { - return ChessViewModelNew(account) as T + return ChessViewModelNew(account, application) as T } throw IllegalArgumentException("Unknown ViewModel class") } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt index 00ec7b067..8bce957e3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -25,6 +25,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus import com.vitorpamplona.amethyst.commons.chess.ChessChallenge +import com.vitorpamplona.amethyst.commons.chess.ChessDismissedGamesStorage import com.vitorpamplona.amethyst.commons.chess.ChessLobbyLogic import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults import com.vitorpamplona.amethyst.commons.chess.ChessSyncStatus @@ -51,6 +52,7 @@ import kotlinx.coroutines.flow.StateFlow @Stable class ChessViewModelNew( private val account: Account, + application: android.app.Application, ) : ViewModel() { // Instance ID for debugging ViewModel sharing val instanceId = System.identityHashCode(this) @@ -59,6 +61,7 @@ class ChessViewModelNew( private val publisher = AndroidChessPublisher(account) private val fetcher = AndroidRelayFetcher(account) private val metadataProvider = AndroidMetadataProvider() + private val dismissedStorage = ChessDismissedGamesStorage.create(application) // Shared business logic (creates its own ChessLobbyState internally) private val logic = @@ -69,6 +72,7 @@ class ChessViewModelNew( metadataProvider = metadataProvider, scope = viewModelScope, pollingConfig = ChessPollingDefaults.android, + dismissedStorage = dismissedStorage, ) // ============================================ @@ -105,6 +109,10 @@ class ChessViewModelNew( fun forceRefresh() = logic.forceRefresh() + fun dismissCompletedGame(gameId: String) = logic.dismissCompletedGame(gameId) + + fun dismissAllCompletedGames() = logic.dismissAllCompletedGames() + /** * Ensure a game ID is being polled for updates. * Call this when entering a game screen. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt index 0a5e34b9e..26bc07716 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home +import androidx.activity.compose.LocalActivity import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material3.FloatingActionButton @@ -47,11 +48,12 @@ fun NewChessGameButton( nav: INav, ) { var showDialog by remember { mutableStateOf(false) } + val activity = LocalActivity.current as androidx.fragment.app.FragmentActivity val chessViewModel: ChessViewModelNew = viewModel( key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", - factory = ChessViewModelFactory(accountViewModel.account), + factory = ChessViewModelFactory(accountViewModel.account, activity.application), ) FloatingActionButton( diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt new file mode 100644 index 000000000..b4e599a36 --- /dev/null +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import android.content.Context +import android.content.SharedPreferences + +actual class ChessDismissedGamesStorage private actual constructor() { + private var prefs: SharedPreferences? = null + + actual companion object { + private const val PREFS_NAME = "chess_dismissed_games" + + private fun prefsKey(userPubkey: String) = "dismissed_$userPubkey" + + actual fun create(context: Any?): ChessDismissedGamesStorage { + val storage = ChessDismissedGamesStorage() + val ctx = + context as? Context + ?: throw IllegalArgumentException("Android context required") + storage.prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + return storage + } + } + + // getStringSet returns a live reference to the internal set — must copy defensively + actual fun load(userPubkey: String): Set = prefs?.getStringSet(prefsKey(userPubkey), null)?.toHashSet() ?: emptySet() + + actual fun save( + userPubkey: String, + ids: Set, + ) { + prefs?.edit()?.putStringSet(prefsKey(userPubkey), ids)?.apply() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt new file mode 100644 index 000000000..1567a00df --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +/** + * Persists dismissed chess game IDs locally per user. + * Uses expect/actual for platform-specific storage. + */ +expect class ChessDismissedGamesStorage private constructor() { + companion object { + fun create(context: Any? = null): ChessDismissedGamesStorage + } + + fun load(userPubkey: String): Set + + fun save( + userPubkey: String, + ids: Set, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt index ad8124bcf..afcae46a8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt @@ -27,8 +27,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material3.Button import androidx.compose.material3.Card +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 @@ -289,6 +293,7 @@ fun CompletedGameCard( isDraw: Boolean, moveCount: Int, onClick: (() -> Unit)? = null, + onDismiss: (() -> Unit)? = null, modifier: Modifier = Modifier, avatar: @Composable (() -> Unit)? = null, ) { @@ -340,6 +345,15 @@ fun CompletedGameCard( color = resultColor, fontWeight = FontWeight.Bold, ) + if (onDismiss != null) { + IconButton(onClick = onDismiss) { + Icon( + Icons.Default.Close, + contentDescription = "Dismiss", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index 49e218047..621ec955f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -125,9 +125,15 @@ class ChessLobbyLogic( private val metadataProvider: IUserMetadataProvider, private val scope: CoroutineScope, pollingConfig: ChessPollingConfig = ChessPollingDefaults.android, + private val dismissedStorage: ChessDismissedGamesStorage? = null, ) { val state = ChessLobbyState(userPubkey, scope) + private val dismissedGameIds: MutableSet = + java.util.Collections.synchronizedSet( + dismissedStorage?.load(userPubkey)?.toMutableSet() ?: mutableSetOf(), + ) + // Track when games were last loaded to prevent duplicate fetches // (e.g., discoverUserGames loads a game, then polling immediately re-fetches it) private val recentlyLoadedGames = java.util.concurrent.ConcurrentHashMap() @@ -869,6 +875,7 @@ class ChessLobbyLogic( for (startEventId in newGameIds) { if (startEventId in completedGameIds) continue + if (startEventId in dismissedGameIds) continue val events = fetcher.fetchGameEvents(startEventId) val result = ChessGameLoader.loadGame(events, userPubkey) @@ -942,6 +949,19 @@ class ChessLobbyLogic( return null } + fun dismissCompletedGame(gameId: String) { + state.removeCompletedGame(gameId) + dismissedGameIds.add(gameId) + dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds)) + } + + fun dismissAllCompletedGames() { + val allIds = state.completedGames.value.map { it.gameId } + state.clearCompletedGames() + dismissedGameIds.addAll(allIds) + dismissedStorage?.save(userPubkey, HashSet(dismissedGameIds)) + } + /** * Dismiss a finished game from the active/spectating list and move it to completed. * Called when the user clicks "Continue" on the game end overlay, or automatically diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt index 73814c801..f60b777d3 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt @@ -442,6 +442,16 @@ class ChessLobbyState( } } + fun removeCompletedGame(gameId: String) { + _completedGames.update { current -> + current.filter { it.gameId != gameId } + } + } + + fun clearCompletedGames() { + _completedGames.value = emptyList() + } + fun addSpectatingGame( gameId: String, state: LiveChessGameState, diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt new file mode 100644 index 000000000..490420da7 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessDismissedGamesStorage.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess + +import java.util.prefs.Preferences + +actual class ChessDismissedGamesStorage private actual constructor() { + private val prefs: Preferences = Preferences.userNodeForPackage(ChessDismissedGamesStorage::class.java) + + actual companion object { + private const val NODE_PREFIX = "chess_dismissed_" + private const val DELIMITER = "," + + actual fun create(context: Any?): ChessDismissedGamesStorage = ChessDismissedGamesStorage() + } + + actual fun load(userPubkey: String): Set { + val raw = prefs.get("$NODE_PREFIX$userPubkey", "") + if (raw.isEmpty()) return emptySet() + return raw.split(DELIMITER).toSet() + } + + actual fun save( + userPubkey: String, + ids: Set, + ) { + if (ids.isEmpty()) { + prefs.remove("$NODE_PREFIX$userPubkey") + } else { + prefs.put("$NODE_PREFIX$userPubkey", ids.joinToString(DELIMITER)) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index b218acbc0..4ed4f1d3f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -53,6 +53,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton 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 @@ -339,6 +340,8 @@ fun ChessScreen( onOpenOwnChallenge = { viewModel.openOwnChallenge(it) }, onWatchGame = { viewModel.loadGameAsSpectator(it) }, onSelectGame = { viewModel.selectGame(it) }, + onDismissGame = { viewModel.dismissCompletedGame(it) }, + onDismissAllGames = { viewModel.dismissAllCompletedGames() }, listState = listState, ) } @@ -372,6 +375,8 @@ private fun ChessLobby( onOpenOwnChallenge: (ChessChallenge) -> Unit, onWatchGame: (String) -> Unit, onSelectGame: (String) -> Unit, + onDismissGame: (String) -> Unit, + onDismissAllGames: () -> Unit, listState: LazyListState = rememberLazyListState(), ) { val hasContent = @@ -581,12 +586,22 @@ private fun ChessLobby( 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), - ) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Recent Games", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + if (completedGames.size >= 2) { + TextButton(onClick = onDismissAllGames) { + Text("Clear all") + } + } + } } items( @@ -603,6 +618,7 @@ private fun ChessLobby( isDraw = game.isDraw, moveCount = game.moveCount, onClick = { onSelectGame(game.gameId) }, + onDismiss = { onDismissGame(game.gameId) }, avatar = { UserAvatar( userHex = opponentPubkey, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt index b698514f3..c77c18650 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt @@ -22,6 +22,7 @@ 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.ChessDismissedGamesStorage import com.vitorpamplona.amethyst.commons.chess.ChessLobbyLogic import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults import com.vitorpamplona.amethyst.commons.chess.ChessSyncStatus @@ -59,6 +60,7 @@ class DesktopChessViewModelNew( private val publisher = DesktopChessPublisher(account, relayManager) private val fetcher = DesktopRelayFetcher(relayManager, account.pubKeyHex) private val metadataProvider = DesktopMetadataProvider(userMetadataCache) + private val dismissedStorage = ChessDismissedGamesStorage.create() // Shared business logic (creates its own ChessLobbyState internally) private val logic = @@ -69,6 +71,7 @@ class DesktopChessViewModelNew( metadataProvider = metadataProvider, scope = scope, pollingConfig = ChessPollingDefaults.desktop, + dismissedStorage = dismissedStorage, ) // ============================================ @@ -164,6 +167,10 @@ class DesktopChessViewModelNew( fun dismissGame(gameId: String) = logic.dismissGame(gameId) + fun dismissCompletedGame(gameId: String) = logic.dismissCompletedGame(gameId) + + fun dismissAllCompletedGames() = logic.dismissAllCompletedGames() + // ============================================ // Spectator operations // ============================================ From 40ce80786ef7b1b575868dc8f96469c7a31085a8 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 30 Mar 2026 14:50:42 +0200 Subject: [PATCH 17/31] update logging to new style --- .../loggedIn/chess/ChessViewModelNew.kt | 6 +- .../datasource/ChessFeedFilterSubAssembler.kt | 4 +- .../commons/chess/ChessEventCollector.kt | 14 ++-- .../commons/chess/ChessEventPolling.kt | 8 +-- .../amethyst/commons/chess/ChessGameLoader.kt | 16 ++--- .../amethyst/commons/chess/ChessLobbyLogic.kt | 72 +++++++++---------- .../commons/chess/ChessEventBroadcaster.kt | 8 +-- 7 files changed, 64 insertions(+), 64 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt index 8bce957e3..fce1c2a2c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -99,7 +99,7 @@ class ChessViewModelNew( // ============================================ init { - Log.d("chessdebug", "[AndroidVM] init: instanceId=$instanceId, userPubkey=${account.userProfile().pubkeyHex.take(8)}") + Log.d("chessdebug") { "[AndroidVM] init: instanceId=$instanceId, userPubkey=${account.userProfile().pubkeyHex.take(8)}" } logic.startPolling() } @@ -144,10 +144,10 @@ class ChessViewModelNew( if (event.kind != JesterProtocol.KIND) return val jesterEvent = event.toJesterEvent() ?: run { - Log.d("chessdebug", "[AndroidVM] handleIncomingEvent: failed to parse kind ${event.kind} event ${event.id.take(8)} as JesterEvent") + Log.d("chessdebug") { "[AndroidVM] handleIncomingEvent: failed to parse kind ${event.kind} event ${event.id.take(8)} as JesterEvent" } return } - Log.d("chessdebug", "[AndroidVM] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${jesterEvent.isStartEvent()}, isMove=${jesterEvent.isMoveEvent()}") + Log.d("chessdebug") { "[AndroidVM] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${jesterEvent.isStartEvent()}, isMove=${jesterEvent.isMoveEvent()}" } logic.handleIncomingEvent(jesterEvent) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt index 702c7dcbd..01616b65a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl @@ -62,7 +62,7 @@ class ChessFeedFilterSubAssembler( override fun newSub(key: ChessQueryState): Subscription = requestNewSubscription( - object : IRequestListener { + object : SubscriptionListener { override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt index 297557d66..9c9b0e676 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt @@ -90,7 +90,7 @@ class ChessEventCollector( */ fun addEvent(event: JesterEvent): Boolean { if (processedEventIds.contains(event.id)) { - Log.d("chessdebug", "[Collector] DEDUP: event ${event.id.take(8)} already processed for game ${startEventId.take(8)}") + Log.d("chessdebug") { "[Collector] DEDUP: event ${event.id.take(8)} already processed for game ${startEventId.take(8)}" } return false } @@ -100,7 +100,7 @@ class ChessEventCollector( val isMoveEvent = event.isMoveEvent() && eventStartId == startEventId if (!isStartEvent && !isMoveEvent) { - Log.d("chessdebug", "[Collector] REJECTED: event ${event.id.take(8)} not for game ${startEventId.take(8)} (isStart=$isStartEvent, isMove=$isMoveEvent, eventStartId=${eventStartId?.take(8)})") + Log.d("chessdebug") { "[Collector] REJECTED: event ${event.id.take(8)} not for game ${startEventId.take(8)} (isStart=$isStartEvent, isMove=$isMoveEvent, eventStartId=${eventStartId?.take(8)})" } return false } @@ -136,7 +136,7 @@ class ChessEventCollector( if (_startEvent.compareAndSet(null, event)) { processedEventIds.add(event.id) incrementEventCount() - Log.d("chessdebug", "[Collector] START event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, createdAt=${event.createdAt}") + Log.d("chessdebug") { "[Collector] START event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, createdAt=${event.createdAt}" } return true } return false @@ -156,7 +156,7 @@ class ChessEventCollector( if (moves.putIfAbsent(event.id, event) == null) { processedEventIds.add(event.id) incrementEventCount() - Log.d("chessdebug", "[Collector] MOVE event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, move=${event.move()}, historySize=${event.history().size}, fen=${event.fen()?.take(30)}, result=${event.result()}, totalMoves=${moves.size}") + Log.d("chessdebug") { "[Collector] MOVE event added: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, move=${event.move()}, historySize=${event.history().size}, fen=${event.fen()?.take(30)}, result=${event.result()}, totalMoves=${moves.size}" } return true } return false @@ -263,7 +263,7 @@ class ChessEventCollectorManager { fun addEvent(event: JesterEvent): Boolean { // For start events, create collector with event ID if (event.isStartEvent()) { - Log.d("chessdebug", "[CollectorMgr] Routing START event ${event.id.take(8)} from ${event.pubKey.take(8)}") + Log.d("chessdebug") { "[CollectorMgr] Routing START event ${event.id.take(8)} from ${event.pubKey.take(8)}" } val collector = getOrCreate(event.id) return collector.addEvent(event) } @@ -271,12 +271,12 @@ class ChessEventCollectorManager { // For move events, find the collector by startEventId val startId = event.startEventId() ?: run { - Log.d("chessdebug", "[CollectorMgr] REJECTED: move event ${event.id.take(8)} has no startEventId") + Log.d("chessdebug") { "[CollectorMgr] REJECTED: move event ${event.id.take(8)} has no startEventId" } return false } val collector = collectors[startId] ?: run { - Log.d("chessdebug", "[CollectorMgr] REJECTED: no collector for game ${startId.take(8)} (active games: ${collectors.keys.map { it.take(8) }})") + Log.d("chessdebug") { "[CollectorMgr] REJECTED: no collector for game ${startId.take(8)} (active games: ${collectors.keys.map { it.take(8) }})" } return false } return collector.addEvent(event) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt index 277ddf542..4cc76c5ee 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt @@ -172,10 +172,10 @@ class ChessPollingDelegate( */ fun start() { if (_isPolling.value) { - Log.d("chessdebug", "[Polling] start: already polling, skipping") + Log.d("chessdebug") { "[Polling] start: already polling, skipping" } return } - Log.d("chessdebug", "[Polling] start: gameInterval=${config.activeGamePollInterval}ms, challengeInterval=${config.challengePollInterval}ms") + Log.d("chessdebug") { "[Polling] start: gameInterval=${config.activeGamePollInterval}ms, challengeInterval=${config.challengePollInterval}ms" } _isPolling.value = true // Poll for active games @@ -184,11 +184,11 @@ class ChessPollingDelegate( while (isActive) { val gameIds = getEffectiveGameIds() if (gameIds.isNotEmpty()) { - Log.d("chessdebug", "[Polling] polling ${gameIds.size} games: ${gameIds.map { it.take(8) }}, focused=${_focusedGameId.value?.take(8)}") + Log.d("chessdebug") { "[Polling] polling ${gameIds.size} games: ${gameIds.map { it.take(8) }}, focused=${_focusedGameId.value?.take(8)}" } try { onRefreshGames(gameIds) } catch (e: Exception) { - Log.d("chessdebug", "[Polling] ERROR during game refresh: ${e.message}") + Log.d("chessdebug") { "[Polling] ERROR during game refresh: ${e.message}" } } } delay(config.activeGamePollInterval) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt index 40dd68cc1..56671e660 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt @@ -65,7 +65,7 @@ object ChessGameLoader { viewerPubkey: String, ): LiveChessGameState? { if (result !is ReconstructionResult.Success) { - Log.d("chessdebug", "[GameLoader] toLiveGameState: reconstruction was not successful") + Log.d("chessdebug") { "[GameLoader] toLiveGameState: reconstruction was not successful" } return null } @@ -79,7 +79,7 @@ object ChessGameLoader { ViewerRole.SPECTATOR -> viewerPubkey to (state.blackPubkey ?: "") } - Log.d("chessdebug", "[GameLoader] toLiveGameState: game=${state.startEventId.take(8)}, role=${state.viewerRole}, color=${state.playerColor}, isSpectator=${state.viewerRole == ViewerRole.SPECTATOR}, isPending=${state.isPendingChallenge}, moves=${state.moveHistory.size}") + Log.d("chessdebug") { "[GameLoader] toLiveGameState: game=${state.startEventId.take(8)}, role=${state.viewerRole}, color=${state.playerColor}, isSpectator=${state.viewerRole == ViewerRole.SPECTATOR}, isPending=${state.isPendingChallenge}, moves=${state.moveHistory.size}" } return LiveChessGameState( startEventId = state.startEventId, @@ -98,7 +98,7 @@ object ChessGameLoader { // Mark finished if the game has ended if (state.isFinished() && state.gameStatus is com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished) { val gameResult = (state.gameStatus as com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished).result - Log.d("chessdebug", "[GameLoader] Marking game ${state.startEventId.take(8)} as finished: $gameResult") + Log.d("chessdebug") { "[GameLoader] Marking game ${state.startEventId.take(8)} as finished: $gameResult" } gameState.markAsFinished(gameResult) } @@ -121,23 +121,23 @@ object ChessGameLoader { events: JesterGameEvents, viewerPubkey: String, ): LoadGameResult { - Log.d("chessdebug", "[GameLoader] loadGame: startEvent=${events.startEvent?.id?.take(8)}, moves=${events.moves.size}, viewer=${viewerPubkey.take(8)}") + Log.d("chessdebug") { "[GameLoader] loadGame: startEvent=${events.startEvent?.id?.take(8)}, moves=${events.moves.size}, viewer=${viewerPubkey.take(8)}" } val result = ChessStateReconstructor.reconstruct(events, viewerPubkey) return when (result) { is ReconstructionResult.Success -> { val liveState = toLiveGameState(result, viewerPubkey) if (liveState != null) { - Log.d("chessdebug", "[GameLoader] loadGame SUCCESS: game=${result.state.startEventId.take(8)}, status=${result.state.gameStatus}") + Log.d("chessdebug") { "[GameLoader] loadGame SUCCESS: game=${result.state.startEventId.take(8)}, status=${result.state.gameStatus}" } LoadGameResult.Success(liveState, result.state) } else { - Log.d("chessdebug", "[GameLoader] loadGame FAILED: could not convert to live state") + Log.d("chessdebug") { "[GameLoader] loadGame FAILED: could not convert to live state" } LoadGameResult.Error("Failed to convert reconstructed state to live state") } } is ReconstructionResult.Error -> { - Log.d("chessdebug", "[GameLoader] loadGame ERROR: ${result.message}") + Log.d("chessdebug") { "[GameLoader] loadGame ERROR: ${result.message}" } LoadGameResult.Error(result.message) } } @@ -159,7 +159,7 @@ object ChessGameLoader { playerColor: Color, isPendingChallenge: Boolean = false, ): LiveChessGameState { - Log.d("chessdebug", "[GameLoader] createNewGame: game=${startEventId.take(8)}, player=${playerPubkey.take(8)}, opponent=${opponentPubkey.take(8)}, color=$playerColor, isPending=$isPendingChallenge") + Log.d("chessdebug") { "[GameLoader] createNewGame: game=${startEventId.take(8)}, player=${playerPubkey.take(8)}, opponent=${opponentPubkey.take(8)}, color=$playerColor, isPending=$isPendingChallenge" } val engine = ChessEngine() engine.reset() diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index 621ec955f..798ee2f34 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -217,7 +217,7 @@ class ChessLobbyLogic( } } - Log.d("chessdebug", "[Lobby] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${event.isStartEvent()}, isMove=${event.isMoveEvent()}, createdAt=${event.createdAt}") + Log.d("chessdebug") { "[Lobby] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${event.isStartEvent()}, isMove=${event.isMoveEvent()}, createdAt=${event.createdAt}" } when { event.isStartEvent() -> handleStartEvent(event) event.isMoveEvent() -> handleMoveEvent(event) @@ -228,7 +228,7 @@ class ChessLobbyLogic( val startEventId = event.id val challengerColor = event.playerColor() ?: Color.WHITE - Log.d("chessdebug", "[Lobby] handleStartEvent: game=${startEventId.take(8)}, challenger=${event.pubKey.take(8)}, color=$challengerColor, opponent=${event.opponentPubkey()?.take(8)}") + Log.d("chessdebug") { "[Lobby] handleStartEvent: game=${startEventId.take(8)}, challenger=${event.pubKey.take(8)}, color=$challengerColor, opponent=${event.opponentPubkey()?.take(8)}" } val challenge = ChessChallenge( @@ -247,23 +247,23 @@ class ChessLobbyLogic( private fun handleMoveEvent(event: JesterEvent) { val startEventId = event.startEventId() ?: run { - Log.d("chessdebug", "[Lobby] handleMoveEvent: REJECTED - no startEventId for event ${event.id.take(8)}") + Log.d("chessdebug") { "[Lobby] handleMoveEvent: REJECTED - no startEventId for event ${event.id.take(8)}" } return } val san = event.move() ?: run { - Log.d("chessdebug", "[Lobby] handleMoveEvent: REJECTED - no move in event ${event.id.take(8)}") + Log.d("chessdebug") { "[Lobby] handleMoveEvent: REJECTED - no move in event ${event.id.take(8)}" } return } val fen = event.fen() ?: run { - Log.d("chessdebug", "[Lobby] handleMoveEvent: REJECTED - no FEN in event ${event.id.take(8)}") + Log.d("chessdebug") { "[Lobby] handleMoveEvent: REJECTED - no FEN in event ${event.id.take(8)}" } return } val history = event.history() val moveNumber = history.size - Log.d("chessdebug", "[Lobby] handleMoveEvent: game=${startEventId.take(8)}, move=$san, moveNumber=$moveNumber, from=${event.pubKey.take(8)}, result=${event.result()}") + Log.d("chessdebug") { "[Lobby] handleMoveEvent: game=${startEventId.take(8)}, move=$san, moveNumber=$moveNumber, from=${event.pubKey.take(8)}, result=${event.result()}" } // Check if this is our game (we're either the author or tagged as opponent) val opponentFromTag = event.opponentPubkey() @@ -274,13 +274,13 @@ class ChessLobbyLogic( // If this is our game but we haven't loaded it yet, load it now // This happens when someone accepts our challenge (makes first move) if (gameState == null && isOurGame) { - Log.d("chessdebug", "[Lobby] handleMoveEvent: game ${startEventId.take(8)} not loaded but is ours - calling handleGameAccepted") + Log.d("chessdebug") { "[Lobby] handleMoveEvent: game ${startEventId.take(8)} not loaded but is ours - calling handleGameAccepted" } handleGameAccepted(startEventId) return // handleGameAccepted will load the game and poll for events } if (gameState == null) { - Log.d("chessdebug", "[Lobby] handleMoveEvent: game ${startEventId.take(8)} not loaded and not ours - ignoring") + Log.d("chessdebug") { "[Lobby] handleMoveEvent: game ${startEventId.take(8)} not loaded and not ours - ignoring" } return } @@ -295,7 +295,7 @@ class ChessLobbyLogic( else -> null } if (gameResult != null) { - Log.d("chessdebug", "[Lobby] handleMoveEvent: game ${startEventId.take(8)} ENDED: $result, termination=${event.termination()}") + Log.d("chessdebug") { "[Lobby] handleMoveEvent: game ${startEventId.take(8)} ENDED: $result, termination=${event.termination()}" } gameState.markAsFinished(gameResult) state.moveToCompleted(startEventId, result, event.termination()) pollingDelegate.removeGameId(startEventId) @@ -305,12 +305,12 @@ class ChessLobbyLogic( // Only apply opponent moves optimistically if (event.pubKey != userPubkey) { - Log.d("chessdebug", "[Lobby] handleMoveEvent: applying opponent move $san (move #$moveNumber) to game ${startEventId.take(8)}") + Log.d("chessdebug") { "[Lobby] handleMoveEvent: applying opponent move $san (move #$moveNumber) to game ${startEventId.take(8)}" } gameState.applyOpponentMove(san, fen, moveNumber) // Update head event ID for move linking gameState.updateHeadEventId(event.id) } else { - Log.d("chessdebug", "[Lobby] handleMoveEvent: skipping own move $san for game ${startEventId.take(8)}") + Log.d("chessdebug") { "[Lobby] handleMoveEvent: skipping own move $san for game ${startEventId.take(8)}" } } } @@ -323,7 +323,7 @@ class ChessLobbyLogic( playerColor: Color = Color.WHITE, timeControl: String? = null, // Not supported in Jester, kept for API compatibility ) { - Log.d("chessdebug", "[Lobby] createChallenge: opponent=${opponentPubkey?.take(8)}, color=$playerColor") + Log.d("chessdebug") { "[Lobby] createChallenge: opponent=${opponentPubkey?.take(8)}, color=$playerColor" } scope.launch(Dispatchers.Default) { state.setBroadcastStatus( ChessBroadcastStatus.Broadcasting( @@ -336,7 +336,7 @@ class ChessLobbyLogic( val startEventId = retryWithBackoffResult { publisher.publishStart(playerColor, opponentPubkey) } if (startEventId != null) { - Log.d("chessdebug", "[Lobby] createChallenge SUCCESS: startEventId=${startEventId.take(8)}") + Log.d("chessdebug") { "[Lobby] createChallenge SUCCESS: startEventId=${startEventId.take(8)}" } // Add challenge to local state - shows in "Your Challenges" section val challenge = ChessChallenge( @@ -359,7 +359,7 @@ class ChessLobbyLogic( state.setBroadcastStatus(ChessBroadcastStatus.Idle) state.setError(null) } else { - Log.d("chessdebug", "[Lobby] createChallenge FAILED: publish returned null") + Log.d("chessdebug") { "[Lobby] createChallenge FAILED: publish returned null" } state.setBroadcastStatus( ChessBroadcastStatus.Failed("Challenge", "Failed to publish"), ) @@ -375,7 +375,7 @@ class ChessLobbyLogic( * In Jester protocol, acceptance is implicit - we just track the game locally. */ fun acceptChallenge(challenge: ChessChallenge) { - Log.d("chessdebug", "[Lobby] acceptChallenge: game=${challenge.gameId.take(8)}, challenger=${challenge.challengerPubkey.take(8)}, color=${challenge.challengerColor.opposite()}") + Log.d("chessdebug") { "[Lobby] acceptChallenge: game=${challenge.gameId.take(8)}, challenger=${challenge.challengerPubkey.take(8)}, color=${challenge.challengerColor.opposite()}" } // Mark as accepted SYNCHRONOUSLY before launching coroutine // This prevents race where navigation happens before coroutine runs, // which would cause loadGame() to incorrectly mark as spectator @@ -440,24 +440,24 @@ class ChessLobbyLogic( // Skip if already loaded or in-flight (multiple move events from same game trigger this) val lastLoaded = recentlyLoadedGames[startEventId] if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) { - Log.d("chessdebug", "[Lobby] handleGameAccepted: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago") + Log.d("chessdebug") { "[Lobby] handleGameAccepted: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" } return } // Mark immediately to prevent concurrent launches recentlyLoadedGames[startEventId] = TimeUtils.now() - Log.d("chessdebug", "[Lobby] handleGameAccepted: game ${startEventId.take(8)} - fetching from relays") + Log.d("chessdebug") { "[Lobby] handleGameAccepted: game ${startEventId.take(8)} - fetching from relays" } scope.launch(Dispatchers.Default) { state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) val events = fetcher.fetchGameEvents(startEventId) - Log.d("chessdebug", "[Lobby] handleGameAccepted: fetched ${events.moves.size} moves for game ${startEventId.take(8)}") + Log.d("chessdebug") { "[Lobby] handleGameAccepted: fetched ${events.moves.size} moves for game ${startEventId.take(8)}" } val result = ChessGameLoader.loadGame(events, userPubkey) when (result) { is LoadGameResult.Success -> { recentlyLoadedGames[startEventId] = TimeUtils.now() - Log.d("chessdebug", "[Lobby] handleGameAccepted SUCCESS: game ${startEventId.take(8)}, role=${result.reconstructedState.viewerRole}") + Log.d("chessdebug") { "[Lobby] handleGameAccepted SUCCESS: game ${startEventId.take(8)}, role=${result.reconstructedState.viewerRole}" } state.addActiveGame(startEventId, result.liveState) pollingDelegate.addGameId(startEventId) state.setBroadcastStatus(ChessBroadcastStatus.Idle) @@ -465,7 +465,7 @@ class ChessLobbyLogic( } is LoadGameResult.Error -> { - Log.d("chessdebug", "[Lobby] handleGameAccepted FAILED: game ${startEventId.take(8)}, error=${result.message}") + Log.d("chessdebug") { "[Lobby] handleGameAccepted FAILED: game ${startEventId.take(8)}, error=${result.message}" } state.setError("Failed to load game: ${result.message}") state.setBroadcastStatus(ChessBroadcastStatus.Idle) } @@ -482,15 +482,15 @@ class ChessLobbyLogic( from: String, to: String, ) { - Log.d("chessdebug", "[Lobby] publishMove: game=${startEventId.take(8)}, from=$from, to=$to") + Log.d("chessdebug") { "[Lobby] publishMove: game=${startEventId.take(8)}, from=$from, to=$to" } val gameState = state.getGameState(startEventId) ?: run { - Log.d("chessdebug", "[Lobby] publishMove: REJECTED - no game state for ${startEventId.take(8)}") + Log.d("chessdebug") { "[Lobby] publishMove: REJECTED - no game state for ${startEventId.take(8)}" } return } if (state.isSpectating(startEventId)) { - Log.d("chessdebug", "[Lobby] publishMove: REJECTED - spectating game ${startEventId.take(8)}") + Log.d("chessdebug") { "[Lobby] publishMove: REJECTED - spectating game ${startEventId.take(8)}" } state.setError("Cannot move while spectating") return } @@ -500,11 +500,11 @@ class ChessLobbyLogic( val moveResult = gameState.makeMove(from, actualTo, promotion) ?: run { - Log.d("chessdebug", "[Lobby] publishMove: REJECTED - makeMove returned null for $from->$actualTo in game ${startEventId.take(8)}") + Log.d("chessdebug") { "[Lobby] publishMove: REJECTED - makeMove returned null for $from->$actualTo in game ${startEventId.take(8)}" } return } - Log.d("chessdebug", "[Lobby] publishMove: move validated: san=${moveResult.san}, fen=${moveResult.fen.take(30)}, history=${moveResult.history.size} moves, headEvent=${moveResult.headEventId.take(8)}") + Log.d("chessdebug") { "[Lobby] publishMove: move validated: san=${moveResult.san}, fen=${moveResult.fen.take(30)}, history=${moveResult.history.size} moves, headEvent=${moveResult.headEventId.take(8)}" } scope.launch(Dispatchers.Default) { state.setBroadcastStatus( @@ -518,7 +518,7 @@ class ChessLobbyLogic( val newEventId = retryWithBackoffResult { publisher.publishMove(moveResult) } if (newEventId != null) { - Log.d("chessdebug", "[Lobby] publishMove SUCCESS: newEventId=${newEventId.take(8)} for game ${startEventId.take(8)}") + Log.d("chessdebug") { "[Lobby] publishMove SUCCESS: newEventId=${newEventId.take(8)} for game ${startEventId.take(8)}" } // Update head event ID for next move linking gameState.updateHeadEventId(newEventId) @@ -537,7 +537,7 @@ class ChessLobbyLogic( ) state.setError(null) } else { - Log.d("chessdebug", "[Lobby] publishMove FAILED: reverting move ${moveResult.san} for game ${startEventId.take(8)}") + Log.d("chessdebug") { "[Lobby] publishMove FAILED: reverting move ${moveResult.san} for game ${startEventId.take(8)}" } // Revert the move since publishing failed gameState.undoLastMove() @@ -550,7 +550,7 @@ class ChessLobbyLogic( } fun resign(startEventId: String) { - Log.d("chessdebug", "[Lobby] resign: game=${startEventId.take(8)}") + Log.d("chessdebug") { "[Lobby] resign: game=${startEventId.take(8)}" } val gameState = state.getGameState(startEventId) ?: return if (state.isSpectating(startEventId)) { @@ -599,7 +599,7 @@ class ChessLobbyLogic( } fun loadGameAsSpectator(startEventId: String) { - Log.d("chessdebug", "[Lobby] loadGameAsSpectator: game=${startEventId.take(8)}") + Log.d("chessdebug") { "[Lobby] loadGameAsSpectator: game=${startEventId.take(8)}" } scope.launch(Dispatchers.Default) { state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) @@ -626,11 +626,11 @@ class ChessLobbyLogic( } fun loadGame(startEventId: String) { - Log.d("chessdebug", "[Lobby] loadGame: game=${startEventId.take(8)}") + Log.d("chessdebug") { "[Lobby] loadGame: game=${startEventId.take(8)}" } scope.launch(Dispatchers.Default) { // Don't load if game already exists or was accepted (acceptChallenge will handle it) if (state.getGameState(startEventId) != null || state.wasAccepted(startEventId)) { - Log.d("chessdebug", "[Lobby] loadGame: skipping - already exists or was accepted for ${startEventId.take(8)}") + Log.d("chessdebug") { "[Lobby] loadGame: skipping - already exists or was accepted for ${startEventId.take(8)}" } return@launch } @@ -680,13 +680,13 @@ class ChessLobbyLogic( // Skip if this game was just loaded (prevents duplicate fetch after discoverUserGames) val lastLoaded = recentlyLoadedGames[startEventId] if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) { - Log.d("chessdebug", "[Lobby] refreshGame: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago") + Log.d("chessdebug") { "[Lobby] refreshGame: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago" } return } // Mark immediately to prevent concurrent fetches for the same game recentlyLoadedGames[startEventId] = TimeUtils.now() - Log.d("chessdebug", "[Lobby] refreshGame: fetching game ${startEventId.take(8)} from relays") + Log.d("chessdebug") { "[Lobby] refreshGame: fetching game ${startEventId.take(8)} from relays" } val events = fetcher.fetchGameEvents(startEventId) val result = ChessGameLoader.loadGame(events, userPubkey) @@ -696,17 +696,17 @@ class ChessLobbyLogic( // Check status FIRST to avoid briefly emitting a finished game through activeGames val gameStatus = result.liveState.gameStatus.value if (gameStatus is GameStatus.Finished) { - Log.d("chessdebug", "[Lobby] refreshGame: game ${startEventId.take(8)} is FINISHED (${(gameStatus as GameStatus.Finished).result}), moving to completed") + Log.d("chessdebug") { "[Lobby] refreshGame: game ${startEventId.take(8)} is FINISHED (${(gameStatus as GameStatus.Finished).result}), moving to completed" } state.moveToCompleted(startEventId, gameStatus.result.notation, null) pollingDelegate.removeGameId(startEventId) } else { - Log.d("chessdebug", "[Lobby] refreshGame: game ${startEventId.take(8)} updated, moves=${result.liveState.moveHistory.value.size}, isPlayerTurn=${result.liveState.isPlayerTurn()}") + Log.d("chessdebug") { "[Lobby] refreshGame: game ${startEventId.take(8)} updated, moves=${result.liveState.moveHistory.value.size}, isPlayerTurn=${result.liveState.isPlayerTurn()}" } state.replaceGameState(startEventId, result.liveState) } } is LoadGameResult.Error -> { - Log.d("chessdebug", "[Lobby] refreshGame: ERROR for game ${startEventId.take(8)}: ${result.message}") + Log.d("chessdebug") { "[Lobby] refreshGame: ERROR for game ${startEventId.take(8)}: ${result.message}" } // Don't overwrite error for periodic refresh failures } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt index 9f9a48e8c..1d68c03ef 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt @@ -68,14 +68,14 @@ class ChessEventBroadcaster( val targetRelays = ChessConfig.CHESS_RELAYS.map { NormalizedRelayUrl(it) }.toSet() val subId = newSubId() - Log.d("chessdebug", "[Broadcaster] broadcast: event=${event.id.take(8)}, kind=${event.kind}, targetRelays=${targetRelays.map { it.url }}") + Log.d("chessdebug") { "[Broadcaster] broadcast: event=${event.id.take(8)}, kind=${event.kind}, targetRelays=${targetRelays.map { it.url }}" } // Step 1: Check which relays are already connected val initialConnected = client.connectedRelaysFlow().value val alreadyConnected = targetRelays.intersect(initialConnected) val needsConnection = targetRelays - alreadyConnected - Log.d("chessdebug", "[Broadcaster] connected=${alreadyConnected.map { it.url }}, needsConnection=${needsConnection.map { it.url }}") + Log.d("chessdebug") { "[Broadcaster] connected=${alreadyConnected.map { it.url }}, needsConnection=${needsConnection.map { it.url }}" } // Step 2: If some relays need connection, open a subscription to trigger it if (needsConnection.isNotEmpty()) { @@ -115,10 +115,10 @@ class ChessEventBroadcaster( } // Step 3: Send the event and wait for OK responses - Log.d("chessdebug", "[Broadcaster] sending event ${event.id.take(8)} and waiting for OK (timeout=${timeoutSeconds}s)") + Log.d("chessdebug") { "[Broadcaster] sending event ${event.id.take(8)} and waiting for OK (timeout=${timeoutSeconds}s)" } val success = client.publishAndConfirm(event, targetRelays, timeoutSeconds) - Log.d("chessdebug", "[Broadcaster] broadcast result: success=$success for event ${event.id.take(8)}") + Log.d("chessdebug") { "[Broadcaster] broadcast result: success=$success for event ${event.id.take(8)}" } // Note: publishAndConfirm only returns aggregate success (any relay accepted) // We don't have per-relay results, so relayResults is empty From 43914d655c5cdc665eee2e9386b5aac7f8c97d51 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 30 Mar 2026 15:52:02 +0200 Subject: [PATCH 18/31] chore: add .worktrees/ to .gitignore Co-Authored-By: Claude Opus 4.6 --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index bece7072e..8751fd092 100644 --- a/.gitignore +++ b/.gitignore @@ -163,3 +163,6 @@ TASKS.md desktopApp/src/jvmMain/appResources/linux/ desktopApp/src/jvmMain/appResources/macos/ desktopApp/src/jvmMain/appResources/windows/ + +# Git worktrees +.worktrees/ From f76fc23840b38690e2c24c0267f33d4e2956e0b0 Mon Sep 17 00:00:00 2001 From: davotoula Date: Mon, 30 Mar 2026 16:42:38 +0200 Subject: [PATCH 19/31] update logging to new style --- .../loggedIn/chess/AndroidChessAdapter.kt | 39 ++++++++++--------- .../amethyst/desktop/chess/ChessScreen.kt | 3 +- .../desktop/chess/DesktopChessAdapter.kt | 31 ++++++++------- 3 files changed, 38 insertions(+), 35 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt index 5a532819b..3a49b5e1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent +import com.vitorpamplona.quartz.utils.Log /** * Android implementation of ChessEventPublisher using Jester protocol. @@ -59,9 +60,9 @@ class AndroidChessPublisher( * Uses ChessEventBroadcaster to ensure relay connections before sending. */ private suspend fun broadcastToChessRelays(event: com.vitorpamplona.quartz.nip01Core.core.Event): Boolean { - println("[AndroidChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster") + Log.d("chessdebug") { "[AndroidChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster" } val result = broadcaster.broadcast(event) - println("[AndroidChessPublisher] Broadcast result: ${result.message}") + Log.d("chessdebug") { "[AndroidChessPublisher] Broadcast result: ${result.message}" } return result.success } @@ -92,7 +93,7 @@ class AndroidChessPublisher( account.cache.justConsumeMyOwnEvent(signedEvent) if (success) signedEvent.id else null } catch (e: Exception) { - println("[AndroidChessPublisher] publishStart failed: ${e.message}") + Log.d("chessdebug") { "[AndroidChessPublisher] publishStart failed: ${e.message}" } null } @@ -118,7 +119,7 @@ class AndroidChessPublisher( account.cache.justConsumeMyOwnEvent(signedEvent) if (success) signedEvent.id else null } catch (e: Exception) { - println("[AndroidChessPublisher] publishMove failed: ${e.message}") + Log.d("chessdebug") { "[AndroidChessPublisher] publishMove failed: ${e.message}" } null } @@ -145,7 +146,7 @@ class AndroidChessPublisher( account.cache.justConsumeMyOwnEvent(signedEvent) success } catch (e: Exception) { - println("[AndroidChessPublisher] publishGameEnd failed: ${e.message}") + Log.d("chessdebug") { "[AndroidChessPublisher] publishGameEnd failed: ${e.message}" } false } @@ -180,7 +181,7 @@ class AndroidRelayFetcher( } override fun getRelayUrls(): List { - println("[AndroidRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays") + Log.d("chessdebug") { "[AndroidRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays" } return ChessConfig.CHESS_RELAYS } @@ -193,7 +194,7 @@ class AndroidRelayFetcher( * 2. Fetch moves that reference the start event via #e tag */ override suspend fun fetchGameEvents(startEventId: String): JesterGameEvents { - println("[AndroidRelayFetcher] fetchGameEvents: fetching from relays for startEventId=$startEventId") + Log.d("chessdebug") { "[AndroidRelayFetcher] fetchGameEvents: fetching from relays for startEventId=$startEventId" } // Filter 1: Fetch the start event by its ID val startEventFilter = @@ -223,7 +224,7 @@ class AndroidRelayFetcher( } } - println("[AndroidRelayFetcher] fetchGameEvents: found start=${startEvent != null}, moves=${moves.size}") + Log.d("chessdebug") { "[AndroidRelayFetcher] fetchGameEvents: found start=${startEvent != null}, moves=${moves.size}" } return JesterGameEvents( startEvent = startEvent, @@ -237,30 +238,30 @@ class AndroidRelayFetcher( */ override suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)?): List { val relays = chessRelayUrls() - println("[AndroidRelayFetcher] fetchChallenges: fetching from ${relays.size} relays: $relays") + Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: fetching from ${relays.size} relays: $relays" } if (relays.isEmpty()) { - println("[AndroidRelayFetcher] fetchChallenges: WARNING - no connected relays!") + Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: WARNING - no connected relays!" } return emptyList() } val filter = ChessFilterBuilder.challengesFilter(userPubkey) - println("[AndroidRelayFetcher] fetchChallenges: filter kinds=${filter.kinds}, tags=${filter.tags}, since=${filter.since}, limit=${filter.limit}") - println("[AndroidRelayFetcher] fetchChallenges: filter JSON=${filter.toJson()}") + Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: filter kinds=${filter.kinds}, tags=${filter.tags}, since=${filter.since}, limit=${filter.limit}" } + Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: filter JSON=${filter.toJson()}" } val relayFilters = relays.associateWith { listOf(filter) } val events = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress) - println("[AndroidRelayFetcher] fetchChallenges: received ${events.size} raw events") + Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: received ${events.size} raw events" } // Debug: If no events, also try without #e filter to see if relays have ANY kind 30 events if (events.isEmpty()) { - println("[AndroidRelayFetcher] DEBUG: No events with #e filter, trying without tag filter...") + Log.d("chessdebug") { "[AndroidRelayFetcher] DEBUG: No events with #e filter, trying without tag filter..." } val debugFilter = ChessFilterBuilder.recentGamesFilter() val debugFilters = relays.associateWith { listOf(debugFilter) } val debugEvents = fetchHelper.fetchEvents(debugFilters, timeoutMs = 10_000) - println("[AndroidRelayFetcher] DEBUG: recentGamesFilter (no #e) returned ${debugEvents.size} events") + Log.d("chessdebug") { "[AndroidRelayFetcher] DEBUG: recentGamesFilter (no #e) returned ${debugEvents.size} events" } debugEvents.take(5).forEach { e -> - println("[AndroidRelayFetcher] DEBUG: kind=${e.kind}, id=${e.id.take(8)}, tags=${e.tags.take(3).map { it.toList() }}") + Log.d("chessdebug") { "[AndroidRelayFetcher] DEBUG: kind=${e.kind}, id=${e.id.take(8)}, tags=${e.tags.take(3).map { it.toList() }}" } } } @@ -286,7 +287,7 @@ class AndroidRelayFetcher( } } - println("[AndroidRelayFetcher] fetchChallenges: found ${challenges.size} challenges from ${events.size} events") + Log.d("chessdebug") { "[AndroidRelayFetcher] fetchChallenges: found ${challenges.size} challenges from ${events.size} events" } return challenges } @@ -356,7 +357,7 @@ class AndroidRelayFetcher( * Uses one-shot fetch for reliability. */ override suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)?): Set { - println("[AndroidRelayFetcher] fetchUserGameIds: fetching from relays") + Log.d("chessdebug") { "[AndroidRelayFetcher] fetchUserGameIds: fetching from relays" } // Fetch events authored by user AND events tagging user val authoredFilter = ChessFilterBuilder.userGamesFilter(userPubkey) @@ -386,7 +387,7 @@ class AndroidRelayFetcher( } } - println("[AndroidRelayFetcher] fetchUserGameIds: found ${gameIds.size} game IDs from ${events.size} events") + Log.d("chessdebug") { "[AndroidRelayFetcher] fetchUserGameIds: found ${gameIds.size} game IDs from ${events.size} events" } return gameIds } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index 4ed4f1d3f..b144fd9c7 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -91,6 +91,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createChessSubscriptionW import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataListSubscription import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator +import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor /** @@ -119,7 +120,7 @@ fun ChessScreen( ChessConfig.CHESS_RELAYS.forEach { relayUrl -> relayManager.addRelay(relayUrl) } - println("[ChessScreen] Added ${ChessConfig.CHESS_RELAYS.size} chess relays to relay manager") + Log.d("chessdebug") { "[ChessScreen] Added ${ChessConfig.CHESS_RELAYS.size} chess relays to relay manager" } } // Derive stable keys to avoid recomposition from LiveChessGameState identity changes diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt index d20a2247b..3f2c93595 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent +import com.vitorpamplona.quartz.utils.Log /** * Desktop implementation of ChessEventPublisher using Jester protocol. @@ -63,9 +64,9 @@ class DesktopChessPublisher( * 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") + Log.d("chessdebug") { "[DesktopChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster" } val result = broadcaster.broadcast(event) - println("[DesktopChessPublisher] Broadcast result: ${result.message}") + Log.d("chessdebug") { "[DesktopChessPublisher] Broadcast result: ${result.message}" } return result.success } @@ -93,7 +94,7 @@ class DesktopChessPublisher( val success = broadcastToChessRelays(signedEvent) if (success) signedEvent.id else null } catch (e: Exception) { - println("[DesktopChessPublisher] publishStart failed: ${e.message}") + Log.d("chessdebug") { "[DesktopChessPublisher] publishStart failed: ${e.message}" } null } @@ -116,7 +117,7 @@ class DesktopChessPublisher( val success = broadcastToChessRelays(signedEvent) if (success) signedEvent.id else null } catch (e: Exception) { - println("[DesktopChessPublisher] publishMove failed: ${e.message}") + Log.d("chessdebug") { "[DesktopChessPublisher] publishMove failed: ${e.message}" } null } @@ -139,7 +140,7 @@ class DesktopChessPublisher( val signedEvent = account.signer.sign(template) broadcastToChessRelays(signedEvent) } catch (e: Exception) { - println("[DesktopChessPublisher] publishGameEnd failed: ${e.message}") + Log.d("chessdebug") { "[DesktopChessPublisher] publishGameEnd failed: ${e.message}" } false } @@ -169,7 +170,7 @@ class DesktopRelayFetcher( private fun chessRelayUrls(): List = ChessConfig.CHESS_RELAYS.map { NormalizedRelayUrl(it) } override fun getRelayUrls(): List { - println("[DesktopRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays") + Log.d("chessdebug") { "[DesktopRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays" } return ChessConfig.CHESS_RELAYS } @@ -182,11 +183,11 @@ class DesktopRelayFetcher( * 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") + Log.d("chessdebug") { "[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}") + Log.d("chessdebug") { "[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 @@ -220,7 +221,7 @@ class DesktopRelayFetcher( // 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}") + Log.d("chessdebug") { "[DesktopRelayFetcher] fetchGameEvents: found start=${cachedEvents.startEvent ?: relayStartEvent != null}, moves=${allMoves.size}" } return JesterGameEvents( startEvent = cachedEvents.startEvent ?: relayStartEvent, @@ -233,7 +234,7 @@ class DesktopRelayFetcher( */ override suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)?): List { val relays = chessRelayUrls() - println("[DesktopRelayFetcher] fetchChallenges: querying cache first, then ${relays.size} chess relays") + Log.d("chessdebug") { "[DesktopRelayFetcher] fetchChallenges: querying cache first, then ${relays.size} chess relays" } // Query cache for start events that are: // 1. Open challenges (no specific opponent) @@ -246,7 +247,7 @@ class DesktopRelayFetcher( event.pubKey == userPubkey } - println("[DesktopRelayFetcher] fetchChallenges: found ${cachedStartEvents.size} in cache") + Log.d("chessdebug") { "[DesktopRelayFetcher] fetchChallenges: found ${cachedStartEvents.size} in cache" } // Also do relay query to catch any new challenges val filter = ChessFilterBuilder.challengesFilter(userPubkey) @@ -264,7 +265,7 @@ class DesktopRelayFetcher( // Merge and deduplicate val allStartEvents = (cachedStartEvents + relayStartEvents).distinctBy { it.id } - println("[DesktopRelayFetcher] fetchChallenges: total ${allStartEvents.size} after merge") + Log.d("chessdebug") { "[DesktopRelayFetcher] fetchChallenges: total ${allStartEvents.size} after merge" } return allStartEvents } @@ -273,7 +274,7 @@ class DesktopRelayFetcher( */ override suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)?): Set { val relays = chessRelayUrls() - println("[DesktopRelayFetcher] fetchUserGameIds: querying cache first, then ${relays.size} chess relays") + Log.d("chessdebug") { "[DesktopRelayFetcher] fetchUserGameIds: querying cache first, then ${relays.size} chess relays" } val gameIds = mutableSetOf() @@ -291,7 +292,7 @@ class DesktopRelayFetcher( event.startEventId()?.let { gameIds.add(it) } } - println("[DesktopRelayFetcher] fetchUserGameIds: found ${gameIds.size} in cache") + Log.d("chessdebug") { "[DesktopRelayFetcher] fetchUserGameIds: found ${gameIds.size} in cache" } // Also do relay query to catch any new game IDs val userFilter = ChessFilterBuilder.userGamesFilter(userPubkey) @@ -314,7 +315,7 @@ class DesktopRelayFetcher( } } - println("[DesktopRelayFetcher] fetchUserGameIds: total ${gameIds.size} after merge") + Log.d("chessdebug") { "[DesktopRelayFetcher] fetchUserGameIds: total ${gameIds.size} after merge" } return gameIds } From a7f60082f027d151a49d8005165088dfd367de03 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 30 Mar 2026 21:51:03 +0000 Subject: [PATCH 20/31] feat: add push notifications for reactions Add a new notification channel for reactions (kind 7 events) so users get notified when someone reacts to their posts. Follows the same pattern as zap notifications with deduplication, time-window filtering, and grouped summaries. https://claude.ai/code/session_01J9rAA4oeFEfNcYD6GoJqqt --- .../EventNotificationConsumer.kt | 75 +++++++++++++++++++ .../notifications/NotificationUtils.kt | 52 +++++++++++++ amethyst/src/main/res/values/strings.xml | 7 ++ 3 files changed, 134 insertions(+) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index d1696c702..42eb17aa8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -30,6 +30,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendDMNotification +import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendReactionNotification import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.sendZapNotification import com.vitorpamplona.amethyst.ui.note.showAmount import com.vitorpamplona.amethyst.ui.stringRes @@ -43,6 +44,7 @@ import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEven import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent @@ -106,6 +108,7 @@ class EventNotificationConsumer( when (innerEvent) { is PrivateDmEvent -> notify(innerEvent, account) is LnZapEvent -> notify(innerEvent, account) + is ReactionEvent -> notify(innerEvent, account) is ChatMessageEvent -> notify(innerEvent, account) is ChatMessageEncryptedFileHeaderEvent -> notify(innerEvent, account) } @@ -481,6 +484,78 @@ class EventNotificationConsumer( } } + private suspend fun notify( + event: ReactionEvent, + account: Account, + ) { + Log.d(TAG, "New Reaction to Notify") + + // old event being re-broadcast + if (event.createdAt < TimeUtils.fifteenMinutesAgo()) return + + // don't notify for own reactions + if (event.pubKey == account.signer.pubKey) return + + // only notify if the reaction is for the current user + if (!event.isTaggedUser(account.signer.pubKey)) return + + val reactedPostId = event.originalPost().firstOrNull() ?: return + val reactedNote = LocalCache.checkGetOrCreateNote(reactedPostId) + + val author = LocalCache.getOrCreateUser(event.pubKey) + val user = author.toBestDisplayName() + val userPicture = author.profilePicture() + + val reactionContent = event.content + val reactionSymbol = + when { + reactionContent == ReactionEvent.LIKE || reactionContent.isBlank() -> "\uD83E\uDD19" + reactionContent == ReactionEvent.DISLIKE -> "\uD83D\uDC4E" + else -> reactionContent + } + + val title = "$reactionSymbol $user" + + val reactedContent = + reactedNote + ?.event + ?.content + ?.split("\n") + ?.firstOrNull() ?: "" + + val content = + if (reactedContent.isNotBlank()) { + stringRes( + applicationContext, + R.string.app_notification_reactions_channel_message_for, + reactedContent, + ) + } else { + stringRes( + applicationContext, + R.string.app_notification_reactions_channel_message, + user, + ) + } + + val noteUri = + "notifications$ACCOUNT_QUERY_PARAM" + + account.signer.pubKey + .hexToByteArray() + .toNpub() + + notificationManager() + .sendReactionNotification( + event.id, + content, + title, + event.createdAt, + userPicture, + noteUri, + applicationContext, + ) + } + fun notificationManager(): NotificationManager = ContextCompat.getSystemService(applicationContext, NotificationManager::class.java) as NotificationManager diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 9c16a2e64..0ba91c622 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -45,8 +45,10 @@ import kotlinx.coroutines.withContext object NotificationUtils { private var dmChannel: NotificationChannel? = null private var zapChannel: NotificationChannel? = null + private var reactionChannel: NotificationChannel? = null private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION" private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION" + private const val REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION" const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION" const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION" const val KEY_REPLY_TEXT = "key_reply_text" @@ -56,6 +58,7 @@ object NotificationUtils { private const val DM_SUMMARY_ID = 0x10000 private const val ZAP_SUMMARY_ID = 0x20000 + private const val REACTION_SUMMARY_ID = 0x30000 fun getOrCreateDMChannel(applicationContext: Context): NotificationChannel { if (dmChannel != null) return dmChannel!! @@ -99,6 +102,55 @@ object NotificationUtils { return zapChannel!! } + fun getOrCreateReactionChannel(applicationContext: Context): NotificationChannel { + if (reactionChannel != null) return reactionChannel!! + + reactionChannel = + NotificationChannel( + stringRes(applicationContext, R.string.app_notification_reactions_channel_id), + stringRes(applicationContext, R.string.app_notification_reactions_channel_name), + NotificationManager.IMPORTANCE_DEFAULT, + ).apply { + description = + stringRes(applicationContext, R.string.app_notification_reactions_channel_description) + } + + val notificationManager: NotificationManager = + applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + notificationManager.createNotificationChannel(reactionChannel!!) + + return reactionChannel!! + } + + suspend fun NotificationManager.sendReactionNotification( + id: String, + messageBody: String, + messageTitle: String, + time: Long, + pictureUrl: String?, + uri: String, + applicationContext: Context, + ) { + getOrCreateReactionChannel(applicationContext) + val channelId = stringRes(applicationContext, R.string.app_notification_reactions_channel_id) + + sendNotification( + id = id, + messageBody = messageBody, + messageTitle = messageTitle, + time = time, + pictureUrl = pictureUrl, + uri = uri, + channelId = channelId, + notificationGroupKey = REACTION_GROUP_KEY, + category = NotificationCompat.CATEGORY_SOCIAL, + summaryId = REACTION_SUMMARY_ID, + summaryText = stringRes(applicationContext, R.string.app_notification_reactions_summary), + applicationContext = applicationContext, + ) + } + suspend fun NotificationManager.sendZapNotification( id: String, messageBody: String, diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index c11be6263..12d9e1bad 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -751,6 +751,13 @@ New messages New zaps + ReactionsID + Reactions + Notifies you when somebody reacts to your post + %1$s reacted to your post + for %1$s + New reactions + Notify: Join Conversation From c5edcab0519c6c176650587f336edd01978134c9 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Mon, 30 Mar 2026 22:03:51 -0400 Subject: [PATCH 21/31] Fixes the loading of old labeled bookmarks --- .../account/metadata/FilterBookmarksAndReportsFromKey.kt | 2 ++ .../labeledBookmarkList/LabeledBookmarkListEvent.kt | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt index eedf9a4af..970d910e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBookmarksAndReportsFromKey.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent @@ -33,6 +34,7 @@ val ReportsAndBookmarksFromKeyKinds = listOf( ReportEvent.KIND, BookmarkListEvent.KIND, + LabeledBookmarkListEvent.KIND, PinListEvent.KIND, RequestToVanishEvent.KIND, ) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt index 6d4e03c82..04c3b7c01 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/labeledBookmarkList/LabeledBookmarkListEvent.kt @@ -92,7 +92,10 @@ class LabeledBookmarkListEvent( const val ALT = "A labeled list of bookmarks" @OptIn(ExperimentalUuidApi::class) - fun createBookmarkAddress(pubKey: HexKey) = Address(KIND, pubKey, Uuid.random().toString()) + fun createAddress( + pubKey: HexKey, + dTag: String, + ) = Address(KIND, pubKey, dTag) suspend fun create( title: String = "", From 96c40921388c35760f294cadd255e66beb4f1518 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 03:45:24 +0000 Subject: [PATCH 22/31] fix: ensure secondary sort by id ascending when sorting by createdAt descending When events share the same createdAt timestamp, the tiebreaker sort by id must be ascending to produce a stable, deterministic order. Fixed several locations that either had no secondary sort, used descending id sort, or used .reversed() which incorrectly flipped both sort directions. https://claude.ai/code/session_01RvuyPf1x9wLf2DCgsSXLGz --- .../DiscoverMarketplaceFeedFilter.kt | 2 +- .../loggedIn/notifications/CardFeedContentState.kt | 12 ++++-------- .../screen/loggedIn/notifications/OpenPollsState.kt | 2 +- .../amethyst/commons/search/SearchResultFilter.kt | 2 +- .../amethyst/commons/search/SearchResultSorter.kt | 6 +++--- .../commons/ui/notifications/CardFeedState.kt | 2 +- .../vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt | 2 +- .../amethyst/desktop/ui/UserProfileScreen.kt | 4 ++-- 8 files changed, 14 insertions(+), 18 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt index ebf796682..c83f7480d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt @@ -75,5 +75,5 @@ open class DiscoverMarketplaceFeedFilter( } } - override fun sort(items: Set): List = items.sortedWith(compareBy({ it.createdAt() }, { it.idHex })).reversed() + override fun sort(items: Set): List = items.sortedWith(compareByDescending { it.createdAt() }.thenBy { it.idHex }) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index ad7de41b2..5d8f6d682 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -262,8 +262,7 @@ class CardFeedContentState( val sortedList = singleList .get(string) - ?.sortedWith(compareBy({ it.createdAt() }, { it.idHex })) - ?.reversed() + ?.sortedWith(compareByDescending { it.createdAt() }.thenBy { it.idHex }) sortedList?.chunked(30)?.map { chunk -> MultiSetCard( @@ -293,8 +292,7 @@ class CardFeedContentState( ZapUserSetCard( user.key, zaps - .sortedWith(compareBy({ it.createdAt() }, { it.idHex() })) - .reversed() + .sortedWith(compareByDescending { it.createdAt() }.thenBy { it.idHex() }) .toImmutableList(), ) } @@ -318,8 +316,7 @@ class CardFeedContentState( } return (multiCards + textNoteCards + userZaps) - .sortedWith(compareBy({ it.createdAt() }, { it.id() })) - .reversed() + .sortedWith(compareByDescending { it.createdAt() }.thenBy { it.id() }) } private fun updateFeed(notes: ImmutableList) { @@ -383,8 +380,7 @@ class CardFeedContentState( val updatedCards = (oldNotesState.feed.value.list + newCards) .distinctBy { it.id() } - .sortedWith(compareBy({ it.createdAt() }, { it.id() })) - .reversed() + .sortedWith(compareByDescending { it.createdAt() }.thenBy { it.id() }) .take(localFilter.limit()) .toImmutableList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsState.kt index 83b626a13..ede972e7c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/OpenPollsState.kt @@ -80,6 +80,6 @@ class OpenPollsState( false } } - }.sortedByDescending { it.createdAt() } + }.sortedWith(compareByDescending { it.createdAt() }.thenBy { it.idHex }) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultFilter.kt index 6cff2d8cf..c0a73021e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultFilter.kt @@ -53,7 +53,7 @@ object SearchResultFilter { } // Sort by createdAt descending - return result.sortedByDescending { it.createdAt } + return result.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }) } fun isReply(event: Event): Boolean = event.kind == 1 && event.tags.any { it.size >= 2 && it[0] == "e" } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt index bc1ea8177..04e92ebec 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/search/SearchResultSorter.kt @@ -33,16 +33,16 @@ object SearchResultSorter { ): List = when (order) { SearchSortOrder.NEWEST -> { - events.sortedByDescending { it.createdAt } + events.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }) } SearchSortOrder.OLDEST -> { - events.sortedBy { it.createdAt } + events.sortedWith(compareBy { it.createdAt }.thenBy { it.id }) } SearchSortOrder.RELEVANCE -> { if (searchText.isBlank()) { - events.sortedByDescending { it.createdAt } + events.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }) } else { events.sortedByDescending { scoreEvent(it, searchText) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt index b2424cd7f..cc46f38a2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/notifications/CardFeedState.kt @@ -75,4 +75,4 @@ sealed class CardFeedState { */ val DefaultCardComparator: Comparator = compareByDescending { it.createdAt() } - .thenByDescending { it.id() } + .thenBy { it.id() } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index 42f3ad82a..7ff4b2b51 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -196,7 +196,7 @@ fun ReadsScreen( remember { EventCollectionState( getId = { it.id }, - sortComparator = compareByDescending { it.publishedAt() ?: it.createdAt }, + sortComparator = compareByDescending { it.publishedAt() ?: it.createdAt }.thenBy { it.id }, maxSize = 100, scope = scope, ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index 914f6fcbd..1b61d4772 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -829,7 +829,7 @@ fun UserProfileScreen( } } else { items( - articleEvents.sortedByDescending { it.publishedAt() ?: it.createdAt }, + articleEvents.sortedWith(compareByDescending { it.publishedAt() ?: it.createdAt }.thenBy { it.id }), key = { "art-${it.id}" }, ) { article -> LongFormCard( @@ -871,7 +871,7 @@ fun UserProfileScreen( } } else { items( - highlightEvents.sortedByDescending { it.createdAt }, + highlightEvents.sortedWith(compareByDescending { it.createdAt }.thenBy { it.id }), key = { "hl-${it.id}" }, ) { highlight -> PublishedHighlightCard( From 3a850ba26c64758687db71fb59cc2758986bc4b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 04:01:05 +0000 Subject: [PATCH 23/31] fix: place cursor at beginning of text field when quoting a note When a user hits Quote, the new post screen opens with the nostr URI pre-filled but the cursor was at the end (after the URI). This moves the cursor to the beginning so the user can immediately start typing their commentary. https://claude.ai/code/session_01RPseKC9GJ8hs3GGBR85ezw --- .../amethyst/ui/note/nip22Comments/CommentPostViewModel.kt | 3 ++- .../ui/screen/loggedIn/home/ShortNotePostViewModel.kt | 3 ++- .../amethyst/commons/compose/TextFieldStateExtensions.kt | 7 +++++++ 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt index a488bde7a..ca54a47cb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/CommentPostViewModel.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.compose.currentWord import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord +import com.vitorpamplona.amethyst.commons.compose.setTextAndPlaceCursorAtBeginning import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -244,7 +245,7 @@ open class CommentPostViewModel : } open fun quote(quote: Note) { - message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quote.toNEvent()}") + message.setTextAndPlaceCursorAtBeginning(message.text.toString() + "\nnostr:${quote.toNEvent()}") quote.author?.let { quotedUser -> if (quotedUser.pubkeyHex != accountViewModel.userProfile().pubkeyHex) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index d096a1917..71e8bacab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.compose.currentWord import com.vitorpamplona.amethyst.commons.compose.insertUrlAtCursor import com.vitorpamplona.amethyst.commons.compose.replaceCurrentWord +import com.vitorpamplona.amethyst.commons.compose.setTextAndPlaceCursorAtBeginning import com.vitorpamplona.amethyst.commons.model.nip30CustomEmojis.EmojiPackState.EmojiMedia import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -369,7 +370,7 @@ open class ShortNotePostViewModel : multiOrchestrator = null quote?.let { quotedNote -> - message.setTextAndPlaceCursorAtEnd(message.text.toString() + "\nnostr:${quotedNote.toNEvent()}") + message.setTextAndPlaceCursorAtBeginning(message.text.toString() + "\nnostr:${quotedNote.toNEvent()}") quotedNote.author?.let { quotedUser -> if (quotedUser.pubkeyHex != user.pubkeyHex) { diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt index 6b74453af..c669c7fbd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldStateExtensions.kt @@ -25,6 +25,13 @@ import androidx.compose.ui.text.TextRange import kotlin.math.max import kotlin.math.min +fun TextFieldState.setTextAndPlaceCursorAtBeginning(text: String) { + edit { + replace(0, length, text) + selection = TextRange(0, 0) + } +} + fun TextFieldState.insertUrlAtCursor(url: String) { edit { var toInsert = url.trim() From be1ad9cdb814d7ec101475f76cc11650bb1eed29 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 04:06:02 +0000 Subject: [PATCH 24/31] refactor: remove ~94 redirect consume functions from LocalCache Replace typed consume() overloads that simply forwarded to consumeRegularEvent() or consumeBaseReplaceable() with direct calls in the when dispatch block. This removes ~600 lines of boilerplate without changing behavior. https://claude.ai/code/session_017dM6dt1on3g3yhWSi69Pwq --- .../amethyst/model/LocalCache.kt | 803 +++--------------- .../notifications/donations/ZapTheDevsCard.kt | 2 +- 2 files changed, 101 insertions(+), 704 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index c138c431f..5d7e305fa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -580,84 +580,6 @@ object LocalCache : ILocalCache, ICacheProvider { return false } - fun consume( - event: ExternalIdentitiesEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: ContactListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: BookmarkListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: TextNoteEvent, - relay: NormalizedRelayUrl? = null, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: PublicMessageEvent, - relay: NormalizedRelayUrl? = null, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: TorrentEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: InteractiveStoryPrologueEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: InteractiveStorySceneEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: InteractiveStoryReadingStateEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: AttestationEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: AttestationRequestEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: AttestorRecommendationEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: AttestorProficiencyEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - fun consumeRegularEvent( event: Event, relay: NormalizedRelayUrl?, @@ -694,135 +616,6 @@ object LocalCache : ILocalCache, ICacheProvider { } } - fun consume( - event: PictureEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: VoiceEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: VoiceReplyEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - @Suppress("DEPRECATION") - fun consume( - event: TorrentCommentEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: NIP90ContentDiscoveryResponseEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: NIP90ContentDiscoveryRequestEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: NIP90StatusEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: NIP90UserDiscoveryResponseEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: RequestToVanishEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: NIP90UserDiscoveryRequestEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: GoalEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: GitPatchEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: GitIssueEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - @Suppress("DEPRECATION") - fun consume( - event: GitReplyEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - // Chess events (NIP-64 live chess) - fun consume( - event: ChessGameEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: JesterEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: LiveChessGameChallengeEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: LiveChessGameAcceptEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: LiveChessMoveEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: LiveChessGameEndEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: LiveChessDrawOfferEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - fun consume( event: NipTextEvent, relay: NormalizedRelayUrl?, @@ -1103,12 +896,6 @@ object LocalCache : ILocalCache, ICacheProvider { } } - fun consume( - event: ZapPollEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - private fun consume( event: LiveActivitiesEvent, relay: NormalizedRelayUrl?, @@ -1150,234 +937,6 @@ object LocalCache : ILocalCache, ICacheProvider { return false } - fun consume( - event: LabeledBookmarkListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: MuteListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: CommunityListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: GitRepositoryEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: RootSiteEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: NamedSiteEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: ChannelListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: BlossomServersEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: FileServersEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: PeopleListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: EphemeralChatListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: FollowListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: AdvertisedRelayListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: ChatMessageRelayListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: PrivateOutboxRelayListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: HashtagListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: GeohashListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: SearchRelayListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: BlockedRelayListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: TrustedRelayListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: RelayFeedsListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: TrustProviderListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: ProxyRelayListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: IndexerRelayListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: BroadcastRelayListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: CommunityDefinitionEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: EmojiPackSelectionEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: EmojiPackEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: ClassifiedsEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: PinListEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: RelaySetEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: AudioTrackEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: VideoVerticalEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: VideoHorizontalEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: VideoNormalEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - private fun consume( - event: VideoShortEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - private fun consume( - event: RelayDiscoveryEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: RelayMonitorEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - fun consume( event: StatusEvent, relay: NormalizedRelayUrl?, @@ -1438,66 +997,6 @@ object LocalCache : ILocalCache, ICacheProvider { return false } - fun consume( - event: BadgeDefinitionEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: BadgeProfilesEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: BadgeAwardEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - private fun consume( - event: NNSEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: AppDefinitionEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: CalendarEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: WebBookmarkEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: CalendarDateSlotEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: CalendarTimeSlotEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - private fun consume( - event: CalendarRSVPEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - private fun consumeBaseReplaceable( event: Event, relay: NormalizedRelayUrl?, @@ -1543,24 +1042,6 @@ object LocalCache : ILocalCache, ICacheProvider { } } - fun consume( - event: AppRecommendationEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: AppSpecificDataEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeBaseReplaceable(event, relay, wasVerified) - - fun consume( - event: PrivateDmEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - fun consume( event: DeletionEvent, relay: NormalizedRelayUrl?, @@ -1993,12 +1474,6 @@ object LocalCache : ILocalCache, ICacheProvider { return new } - fun consume( - event: CommentEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - @Suppress("UNUSED_PARAMETER") fun consume( event: ChannelHideMessageEvent, @@ -2080,36 +1555,6 @@ object LocalCache : ILocalCache, ICacheProvider { return false } - fun consume( - event: AudioHeaderEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: FileHeaderEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: ProfileGalleryEntryEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: FileStorageHeaderEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: FhirResourceEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - fun consume( event: TextNoteModificationEvent, relay: NormalizedRelayUrl?, @@ -2145,30 +1590,6 @@ object LocalCache : ILocalCache, ICacheProvider { return false } - fun consume( - event: HighlightEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: CodeSnippetEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: ChatEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: PollEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - fun consume( event: PollResponseEvent, relay: NormalizedRelayUrl?, @@ -2242,30 +1663,6 @@ object LocalCache : ILocalCache, ICacheProvider { return false } - private fun consume( - event: ChatMessageEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - private fun consume( - event: ChatMessageEncryptedFileHeaderEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: SealedRumorEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - - fun consume( - event: GiftWrapEvent, - relay: NormalizedRelayUrl?, - wasVerified: Boolean, - ) = consumeRegularEvent(event, relay, wasVerified) - fun consume( event: LnZapPaymentRequestEvent, relay: NormalizedRelayUrl?, @@ -3141,134 +2538,134 @@ object LocalCache : ILocalCache, ICacheProvider { ): Boolean = try { when (event) { - is AdvertisedRelayListEvent -> consume(event, relay, wasVerified) - is AppDefinitionEvent -> consume(event, relay, wasVerified) - is AppRecommendationEvent -> consume(event, relay, wasVerified) - is AppSpecificDataEvent -> consume(event, relay, wasVerified) - is AttestationEvent -> consume(event, relay, wasVerified) - is AttestationRequestEvent -> consume(event, relay, wasVerified) - is AttestorRecommendationEvent -> consume(event, relay, wasVerified) - is AttestorProficiencyEvent -> consume(event, relay, wasVerified) - is AudioHeaderEvent -> consume(event, relay, wasVerified) - is AudioTrackEvent -> consume(event, relay, wasVerified) - is BadgeAwardEvent -> consume(event, relay, wasVerified) - is BadgeDefinitionEvent -> consume(event, relay, wasVerified) - is BadgeProfilesEvent -> consume(event, relay, wasVerified) - is BlockedRelayListEvent -> consume(event, relay, wasVerified) - is BlossomServersEvent -> consume(event, relay, wasVerified) - is BroadcastRelayListEvent -> consume(event, relay, wasVerified) - is BookmarkListEvent -> consume(event, relay, wasVerified) - is CalendarEvent -> consume(event, relay, wasVerified) - is CalendarDateSlotEvent -> consume(event, relay, wasVerified) - is CalendarTimeSlotEvent -> consume(event, relay, wasVerified) - is CalendarRSVPEvent -> consume(event, relay, wasVerified) + is AdvertisedRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is AppDefinitionEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is AppRecommendationEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is AppSpecificDataEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is AttestationEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is AttestationRequestEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is AttestorRecommendationEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is AttestorProficiencyEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is AudioHeaderEvent -> consumeRegularEvent(event, relay, wasVerified) + is AudioTrackEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is BadgeAwardEvent -> consumeRegularEvent(event, relay, wasVerified) + is BadgeDefinitionEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is BadgeProfilesEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is BlockedRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is BlossomServersEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is BroadcastRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is BookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is CalendarEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is CalendarDateSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is CalendarTimeSlotEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is CalendarRSVPEvent -> consumeBaseReplaceable(event, relay, wasVerified) is ChannelCreateEvent -> consume(event, relay, wasVerified) - is ChannelListEvent -> consume(event, relay, wasVerified) + is ChannelListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is ChannelHideMessageEvent -> consume(event, relay, wasVerified) is ChannelMessageEvent -> consume(event, relay, wasVerified) is ChannelMetadataEvent -> consume(event, relay, wasVerified) is ChannelMuteUserEvent -> consume(event, relay, wasVerified) - is ChatMessageEncryptedFileHeaderEvent -> consume(event, relay, wasVerified) - is ChatMessageEvent -> consume(event, relay, wasVerified) - is ChatMessageRelayListEvent -> consume(event, relay, wasVerified) - is ClassifiedsEvent -> consume(event, relay, wasVerified) - is CommentEvent -> consume(event, relay, wasVerified) - is CommunityDefinitionEvent -> consume(event, relay, wasVerified) - is CommunityListEvent -> consume(event, relay, wasVerified) + is ChatMessageEncryptedFileHeaderEvent -> consumeRegularEvent(event, relay, wasVerified) + is ChatMessageEvent -> consumeRegularEvent(event, relay, wasVerified) + is ChatMessageRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is ClassifiedsEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is CommentEvent -> consumeRegularEvent(event, relay, wasVerified) + is CommunityDefinitionEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is CommunityListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is CommunityPostApprovalEvent -> consume(event, relay, wasVerified) - is ContactListEvent -> consume(event, relay, wasVerified) + is ContactListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is DeletionEvent -> consume(event, relay, wasVerified) is DraftWrapEvent -> consume(event, relay, wasVerified) - is EmojiPackEvent -> consume(event, relay, wasVerified) - is EmojiPackSelectionEvent -> consume(event, relay, wasVerified) + is EmojiPackEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is EmojiPackSelectionEvent -> consumeBaseReplaceable(event, relay, wasVerified) is EphemeralChatEvent -> consume(event, relay, wasVerified) - is EphemeralChatListEvent -> consume(event, relay, wasVerified) - is ExternalIdentitiesEvent -> consume(event, relay, wasVerified) + is EphemeralChatListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is ExternalIdentitiesEvent -> consumeBaseReplaceable(event, relay, wasVerified) is GenericRepostEvent -> consume(event, relay, wasVerified) - is FhirResourceEvent -> consume(event, relay, wasVerified) - is FileHeaderEvent -> consume(event, relay, wasVerified) - is ProfileGalleryEntryEvent -> consume(event, relay, wasVerified) - is FileServersEvent -> consume(event, relay, wasVerified) + is FhirResourceEvent -> consumeRegularEvent(event, relay, wasVerified) + is FileHeaderEvent -> consumeRegularEvent(event, relay, wasVerified) + is ProfileGalleryEntryEvent -> consumeRegularEvent(event, relay, wasVerified) + is FileServersEvent -> consumeBaseReplaceable(event, relay, wasVerified) is FileStorageEvent -> consume(event, relay, wasVerified) - is FileStorageHeaderEvent -> consume(event, relay, wasVerified) - is FollowListEvent -> consume(event, relay, wasVerified) - is GeohashListEvent -> consume(event, relay, wasVerified) - is GoalEvent -> consume(event, relay, wasVerified) - is GiftWrapEvent -> consume(event, relay, wasVerified) - is GitIssueEvent -> consume(event, relay, wasVerified) - is GitReplyEvent -> consume(event, relay, wasVerified) - is GitPatchEvent -> consume(event, relay, wasVerified) - is GitRepositoryEvent -> consume(event, relay, wasVerified) - is RootSiteEvent -> consume(event, relay, wasVerified) - is NamedSiteEvent -> consume(event, relay, wasVerified) - is ChessGameEvent -> consume(event, relay, wasVerified) - is RelayFeedsListEvent -> consume(event, relay, wasVerified) - is JesterEvent -> consume(event, relay, wasVerified) - is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified) - is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified) - is LiveChessMoveEvent -> consume(event, relay, wasVerified) - is LiveChessGameEndEvent -> consume(event, relay, wasVerified) - is LiveChessDrawOfferEvent -> consume(event, relay, wasVerified) - is HashtagListEvent -> consume(event, relay, wasVerified) - is HighlightEvent -> consume(event, relay, wasVerified) - is IndexerRelayListEvent -> consume(event, relay, wasVerified) - is InteractiveStoryPrologueEvent -> consume(event, relay, wasVerified) - is InteractiveStorySceneEvent -> consume(event, relay, wasVerified) - is InteractiveStoryReadingStateEvent -> consume(event, relay, wasVerified) - is LabeledBookmarkListEvent -> consume(event, relay, wasVerified) + is FileStorageHeaderEvent -> consumeRegularEvent(event, relay, wasVerified) + is FollowListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is GeohashListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is GoalEvent -> consumeRegularEvent(event, relay, wasVerified) + is GiftWrapEvent -> consumeRegularEvent(event, relay, wasVerified) + is GitIssueEvent -> consumeRegularEvent(event, relay, wasVerified) + is GitReplyEvent -> consumeRegularEvent(event, relay, wasVerified) + is GitPatchEvent -> consumeRegularEvent(event, relay, wasVerified) + is GitRepositoryEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is RootSiteEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is NamedSiteEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is ChessGameEvent -> consumeRegularEvent(event, relay, wasVerified) + is RelayFeedsListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is JesterEvent -> consumeRegularEvent(event, relay, wasVerified) + is LiveChessGameChallengeEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is LiveChessGameAcceptEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is LiveChessMoveEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is LiveChessGameEndEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is LiveChessDrawOfferEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is HashtagListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is HighlightEvent -> consumeRegularEvent(event, relay, wasVerified) + is IndexerRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is InteractiveStoryPrologueEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is InteractiveStorySceneEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is InteractiveStoryReadingStateEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is LabeledBookmarkListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is LiveActivitiesEvent -> consume(event, relay, wasVerified) is LiveActivitiesChatMessageEvent -> consume(event, relay, wasVerified) is LnZapEvent -> consume(event, relay, wasVerified) is LnZapRequestEvent -> consume(event, relay, wasVerified) - is NIP90StatusEvent -> consume(event, relay, wasVerified) - is NIP90ContentDiscoveryResponseEvent -> consume(event, relay, wasVerified) - is NIP90ContentDiscoveryRequestEvent -> consume(event, relay, wasVerified) - is NIP90UserDiscoveryResponseEvent -> consume(event, relay, wasVerified) - is NIP90UserDiscoveryRequestEvent -> consume(event, relay, wasVerified) + is NIP90StatusEvent -> consumeRegularEvent(event, relay, wasVerified) + is NIP90ContentDiscoveryResponseEvent -> consumeRegularEvent(event, relay, wasVerified) + is NIP90ContentDiscoveryRequestEvent -> consumeRegularEvent(event, relay, wasVerified) + is NIP90UserDiscoveryResponseEvent -> consumeRegularEvent(event, relay, wasVerified) + is NIP90UserDiscoveryRequestEvent -> consumeRegularEvent(event, relay, wasVerified) is LnZapPaymentRequestEvent -> consume(event, relay, wasVerified) is LnZapPaymentResponseEvent -> consume(event, relay, wasVerified) is LongTextNoteEvent -> consume(event, relay, wasVerified) is MetadataEvent -> consume(event, relay, wasVerified) - is MuteListEvent -> consume(event, relay, wasVerified) - is NNSEvent -> consume(event, relay, wasVerified) + is MuteListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is NNSEvent -> consumeBaseReplaceable(event, relay, wasVerified) is NipTextEvent -> consume(event, relay, wasVerified) is OtsEvent -> consume(event, relay, wasVerified) - is PictureEvent -> consume(event, relay, wasVerified) - is PrivateDmEvent -> consume(event, relay, wasVerified) - is PrivateOutboxRelayListEvent -> consume(event, relay, wasVerified) - is ProxyRelayListEvent -> consume(event, relay, wasVerified) - is PinListEvent -> consume(event, relay, wasVerified) - is PublicMessageEvent -> consume(event, relay, wasVerified) - is PeopleListEvent -> consume(event, relay, wasVerified) - is RequestToVanishEvent -> consume(event, relay, wasVerified) - is CodeSnippetEvent -> consume(event, relay, wasVerified) - is ZapPollEvent -> consume(event, relay, wasVerified) - is ChatEvent -> consume(event, relay, wasVerified) - is PollEvent -> consume(event, relay, wasVerified) + is PictureEvent -> consumeRegularEvent(event, relay, wasVerified) + is PrivateDmEvent -> consumeRegularEvent(event, relay, wasVerified) + is PrivateOutboxRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is ProxyRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is PinListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is PublicMessageEvent -> consumeRegularEvent(event, relay, wasVerified) + is PeopleListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is RequestToVanishEvent -> consumeRegularEvent(event, relay, wasVerified) + is CodeSnippetEvent -> consumeRegularEvent(event, relay, wasVerified) + is ZapPollEvent -> consumeRegularEvent(event, relay, wasVerified) + is ChatEvent -> consumeRegularEvent(event, relay, wasVerified) + is PollEvent -> consumeRegularEvent(event, relay, wasVerified) is PollResponseEvent -> consume(event, relay, wasVerified) - is RelayDiscoveryEvent -> consume(event, relay, wasVerified) - is RelayMonitorEvent -> consume(event, relay, wasVerified) + is RelayDiscoveryEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is RelayMonitorEvent -> consumeBaseReplaceable(event, relay, wasVerified) is ReactionEvent -> consume(event, relay, wasVerified) is ContactCardEvent -> consume(event, relay, wasVerified) - is RelaySetEvent -> consume(event, relay, wasVerified) + is RelaySetEvent -> consumeBaseReplaceable(event, relay, wasVerified) is ReportEvent -> consume(event, relay, wasVerified) is RepostEvent -> consume(event, relay, wasVerified) - is SealedRumorEvent -> consume(event, relay, wasVerified) - is SearchRelayListEvent -> consume(event, relay, wasVerified) + is SealedRumorEvent -> consumeRegularEvent(event, relay, wasVerified) + is SearchRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) is StatusEvent -> consume(event, relay, wasVerified) - is TextNoteEvent -> consume(event, relay, wasVerified) + is TextNoteEvent -> consumeRegularEvent(event, relay, wasVerified) is TextNoteModificationEvent -> consume(event, relay, wasVerified) - is TorrentEvent -> consume(event, relay, wasVerified) - is TorrentCommentEvent -> consume(event, relay, wasVerified) - is TrustedRelayListEvent -> consume(event, relay, wasVerified) - is TrustProviderListEvent -> consume(event, relay, wasVerified) - is VideoHorizontalEvent -> consume(event, relay, wasVerified) - is VideoNormalEvent -> consume(event, relay, wasVerified) - is VideoVerticalEvent -> consume(event, relay, wasVerified) - is VideoShortEvent -> consume(event, relay, wasVerified) - is VoiceEvent -> consume(event, relay, wasVerified) - is VoiceReplyEvent -> consume(event, relay, wasVerified) - is WebBookmarkEvent -> consume(event, relay, wasVerified) + is TorrentEvent -> consumeRegularEvent(event, relay, wasVerified) + is TorrentCommentEvent -> consumeRegularEvent(event, relay, wasVerified) + is TrustedRelayListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is TrustProviderListEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is VideoHorizontalEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is VideoNormalEvent -> consumeRegularEvent(event, relay, wasVerified) + is VideoVerticalEvent -> consumeBaseReplaceable(event, relay, wasVerified) + is VideoShortEvent -> consumeRegularEvent(event, relay, wasVerified) + is VoiceEvent -> consumeRegularEvent(event, relay, wasVerified) + is VoiceReplyEvent -> consumeRegularEvent(event, relay, wasVerified) + is WebBookmarkEvent -> consumeBaseReplaceable(event, relay, wasVerified) is WikiNoteEvent -> consume(event, relay, wasVerified) is PaymentTargetsEvent -> consume(event, relay, wasVerified) else -> Log.w("Event Not Supported") { "From ${relay?.url}: ${event.toJson()}" }.let { false } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/donations/ZapTheDevsCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/donations/ZapTheDevsCard.kt index 3de2439f2..d2a6da29f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/donations/ZapTheDevsCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/donations/ZapTheDevsCard.kt @@ -95,7 +95,7 @@ fun ZapTheDevsCardPreview() { sig = "e036ecce534e22efd47634c56328af62576ab3a36c565f7c8c5fbea67f48cd46d4041ecfc0ca01dafa0ebe8a0b119d125527a28f88aa30356b80c26dd0953aed", ) - LocalCache.consume(releaseNotes, null, true) + LocalCache.consumeRegularEvent(releaseNotes, null, true) } val accountViewModel = mockAccountViewModel() From abeccb23a2db6fd9df1ce176581fdc003f0b8ad0 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 04:23:50 +0000 Subject: [PATCH 25/31] feat: support npub and nprofile with nostr: prefix in edit field visual transformation The visual transformation for message edit fields now processes nostr:npub1..., nostr:nprofile1..., and @nprofile1... mentions in addition to the existing @npub1... pattern. Both UrlUserTagOutputTransformation (new API) and UrlUserTagTransformation (old API) are updated to resolve these to user display names. https://claude.ai/code/session_012ijrLmft5PjcQ64zDwXJWj --- .../actions/UrlUserTagOutputTransformation.kt | 18 +++--- .../ui/actions/UrlUserTagTransformation.kt | 62 ++++++++++++++++--- 2 files changed, 64 insertions(+), 16 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt index 40bcb20fc..3b6149d98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagOutputTransformation.kt @@ -26,8 +26,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.style.TextDecoration import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull import kotlin.coroutines.cancellation.CancellationException class UrlUserTagOutputTransformation( @@ -36,15 +35,20 @@ class UrlUserTagOutputTransformation( override fun TextFieldBuffer.transformOutput() { val text = asCharSequence().toString() - // Find all @npub mentions using regex and replace in reverse order + // Find all user mentions using regex and replace in reverse order // so that earlier indices remain valid after replacements. - val npubRegex = Regex("@npub1[a-z0-9]{58}") - val matches = npubRegex.findAll(text).toList().reversed() + // Matches: @npub1..., nostr:npub1..., @nprofile1..., nostr:nprofile1... + val mentionRegex = Regex("(?:@|nostr:)(?:npub1[a-z0-9]{58}|nprofile1[a-z0-9]+)") + val matches = mentionRegex.findAll(text).toList().reversed() for (match in matches) { try { - val key = decodePublicKey(match.value.removePrefix("@")) - val user = LocalCache.getOrCreateUser(key.toHexKey()) + val bech32 = + match.value + .removePrefix("@") + .removePrefix("nostr:") + val hex = decodePublicKeyAsHexOrNull(bech32) ?: continue + val user = LocalCache.getOrCreateUser(hex) val displayName = "@${user.toBestDisplayName()}" replace(match.range.first, match.range.last + 1, displayName) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt index 24f6fc752..2bd2817af 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/UrlUserTagTransformation.kt @@ -31,8 +31,10 @@ import androidx.compose.ui.text.input.TransformedText import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextDecoration import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip19Bech32.decodePublicKey +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.entities.NProfile +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import kotlin.coroutines.cancellation.CancellationException data class RangesChanges( @@ -60,20 +62,19 @@ fun buildAnnotatedStringWithUrlHighlighting( text.text.split('\n').joinToString("\n") { paragraph: String -> paragraph.split(' ').joinToString(" ") { word: String -> try { - if (word.startsWith("@npub") && word.length >= 64) { - val keyB32 = word.substring(0, 64) - val restOfWord = word.substring(64) + val mention = parseUserMention(word) + if (mention != null) { + val (keyPortion, restOfWord, hexKey) = mention val startIndex = builderBefore.toString().length builderBefore.append( - "$keyB32$restOfWord ", + "$keyPortion$restOfWord ", ) // accounts for the \n at the end of each paragraph - val endIndex = startIndex + keyB32.length + val endIndex = startIndex + keyPortion.length - val key = decodePublicKey(keyB32.removePrefix("@")) - val user = LocalCache.getOrCreateUser(key.toHexKey()) + val user = LocalCache.getOrCreateUser(hexKey) val newWord = "@${user.toBestDisplayName()}" val startNew = builderAfter.toString().length @@ -181,3 +182,46 @@ fun buildAnnotatedStringWithUrlHighlighting( numberOffsetTranslator, ) } + +private data class UserMention( + val keyPortion: String, + val restOfWord: String, + val hexKey: String, +) + +private fun parseUserMention(word: String): UserMention? { + var key = word + val prefix: String + + if (key.startsWith("nostr:", true)) { + prefix = key.substring(0, 6) + key = key.substring(6) + } else if (key.startsWith("@")) { + prefix = "@" + key = key.substring(1) + } else { + return null + } + + if (key.startsWith("npub1", true) && key.length >= 63) { + val keyB32 = key.substring(0, 63) + val restOfWord = key.substring(63) + val hex = decodePublicKeyAsHexOrNull(keyB32) ?: return null + return UserMention("$prefix$keyB32", restOfWord, hex) + } else if (key.startsWith("nprofile1", true)) { + val parsed = Nip19Parser.uriToRoute(key) ?: return null + val entity = parsed.entity + if (entity !is NProfile && entity !is NPub) return null + val hex = + when (entity) { + is NProfile -> entity.hex + is NPub -> entity.hex + else -> return null + } + val bech32Len = parsed.nip19raw.length + val restOfWord = key.substring(bech32Len) + return UserMention("$prefix${parsed.nip19raw}", restOfWord, hex) + } + + return null +} From acd0a13abf54c57a42644f95caaf9b75a669675e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 31 Mar 2026 12:07:09 +0000 Subject: [PATCH 26/31] fix: catch exceptions from Tor library to prevent app crashes Wrap all Tor library interactions in try-catch blocks so that failures in the Tor service (JNI, control connection, bind/unbind) are logged and gracefully degrade to TorServiceStatus.Off instead of crashing the entire app. https://claude.ai/code/session_019JDZMTvVdJyc9z2WmoAimv --- .../relayClient/RelayProxyClientConnector.kt | 18 +++++--- .../amethyst/ui/tor/TorManager.kt | 5 +++ .../amethyst/ui/tor/TorService.kt | 44 ++++++++++++------- 3 files changed, 46 insertions(+), 21 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt index ae6fc1831..220987e65 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/RelayProxyClientConnector.kt @@ -79,16 +79,24 @@ class RelayProxyClientConnector( client.disconnect() } if (it.torStatus is TorServiceStatus.Active) { - it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_DORMANT) - Log.d("ManageRelayServices", "Pausing Tor Activity") + try { + it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_DORMANT) + Log.d("ManageRelayServices", "Pausing Tor Activity") + } catch (e: Exception) { + Log.e("ManageRelayServices") { "Failed to signal Tor dormant: ${e.message}" } + } } } else if (it.connectivity is ConnectivityStatus.Active && !client.isActive()) { Log.d("ManageRelayServices", "Connectivity On: Resuming Relay Services") if (it.torStatus is TorServiceStatus.Active) { - it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_ACTIVE) - it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_NEWNYM) - Log.d("ManageRelayServices", "Resuming Tor Activity with new nym") + try { + it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_ACTIVE) + it.torStatus.torControlConnection?.signal(TorControlCommands.SIGNAL_NEWNYM) + Log.d("ManageRelayServices", "Resuming Tor Activity with new nym") + } catch (e: Exception) { + Log.e("ManageRelayServices") { "Failed to signal Tor active: ${e.message}" } + } } // only calls this if the client is not active. Otherwise goes to the else below diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt index 3781515ff..e156287cd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorManager.kt @@ -22,11 +22,13 @@ package com.vitorpamplona.amethyst.ui.tor import android.content.Context import com.vitorpamplona.amethyst.model.preferences.TorSharedPreferences +import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flowOn @@ -71,6 +73,9 @@ class TorManager( } } } + }.catch { e -> + Log.e("TorManager") { "Tor service error: ${e.message}" } + emit(TorServiceStatus.Off) }.flowOn(Dispatchers.IO) .stateIn( scope, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt index e25508d0e..013339885 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorService.kt @@ -54,18 +54,23 @@ class TorService( service: IBinder, ) { launch(Dispatchers.IO) { - // moved torService to a local variable, since we only need it once - val torService = (service as LocalBinder).service + try { + // moved torService to a local variable, since we only need it once + val torService = (service as LocalBinder).service - while (torService.socksPort < 0) { - delay(SOCKS_PORT_POLL_INTERVAL_MS) + while (torService.socksPort < 0) { + delay(SOCKS_PORT_POLL_INTERVAL_MS) + } + + val active = TorServiceStatus.Active(torService.socksPort) + active.torControlConnection = torService.torControlConnection + + trySend(active) + Log.d("TorService") { "Tor Service Connected ${torService.socksPort}" } + } catch (e: Exception) { + Log.e("TorService") { "Tor service connection failed: ${e.message}" } + trySend(TorServiceStatus.Off) } - - val active = TorServiceStatus.Active(torService.socksPort) - active.torControlConnection = torService.torControlConnection - - trySend(active) - Log.d("TorService") { "Tor Service Connected ${torService.socksPort}" } } } @@ -75,11 +80,16 @@ class TorService( } } - context.bindService( - currentIntent, - serviceConnection, - BIND_AUTO_CREATE, - ) + try { + context.bindService( + currentIntent, + serviceConnection, + BIND_AUTO_CREATE, + ) + } catch (e: Exception) { + Log.e("TorService") { "Failed to bind Tor Service: ${e.message}" } + trySend(TorServiceStatus.Off) + } awaitClose { Log.d("TorService", "Stopping Tor Service") @@ -88,8 +98,10 @@ class TorService( } catch (e: Exception) { Log.d("TorService") { "Failed to unbind Tor Service: ${e.message}" } } - launch { + try { context.stopService(currentIntent) + } catch (e: Exception) { + Log.d("TorService") { "Failed to stop Tor Service: ${e.message}" } } trySend(TorServiceStatus.Off) } From 26798e2b57fdfd90d265c546af5a372f406f903e Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 31 Mar 2026 08:59:38 -0400 Subject: [PATCH 27/31] Fixes assertion --- .../vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt | 2 +- .../quartz/nipBEBle/protocol/BleChunkAssemblerTest.kt | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index 798ee2f34..8735c8ec0 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -696,7 +696,7 @@ class ChessLobbyLogic( // Check status FIRST to avoid briefly emitting a finished game through activeGames val gameStatus = result.liveState.gameStatus.value if (gameStatus is GameStatus.Finished) { - Log.d("chessdebug") { "[Lobby] refreshGame: game ${startEventId.take(8)} is FINISHED (${(gameStatus as GameStatus.Finished).result}), moving to completed" } + Log.d("chessdebug") { "[Lobby] refreshGame: game ${startEventId.take(8)} is FINISHED (${gameStatus.result}), moving to completed" } state.moveToCompleted(startEventId, gameStatus.result.notation, null) pollingDelegate.removeGameId(startEventId) } else { diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/protocol/BleChunkAssemblerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/protocol/BleChunkAssemblerTest.kt index 523ff36ec..9b8f760df 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/protocol/BleChunkAssemblerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nipBEBle/protocol/BleChunkAssemblerTest.kt @@ -24,6 +24,7 @@ import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull import kotlin.test.assertNull +import kotlin.test.assertTrue class BleChunkAssemblerTest { @Test @@ -43,7 +44,7 @@ class BleChunkAssemblerTest { val assembler = BleChunkAssembler() val message = """["EVENT",{"content":"hello world from nostr ble mesh networking"}]""" val chunks = BleMessageChunker.splitIntoChunks(message, chunkSize = 10) - assert(chunks.size > 1) + assertTrue(chunks.size > 1) for (i in 0 until chunks.size - 1) { val result = assembler.addChunk(chunks[i]) From d5f27c79943b8d364ebaa3a4fd6deb81ba507812 Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 31 Mar 2026 13:01:19 +0000 Subject: [PATCH 28/31] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-pl-rPL/strings.xml | 1 + amethyst/src/main/res/values-zh-rCN/strings.xml | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index e3bca072b..0ef2ee2d2 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1622,6 +1622,7 @@ Test połączenia Testowanie serwerów… Podłączony + Test nieudany Wyniki testu Diagnostyka Ostatni test diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index ac2075f8a..389d7b3bd 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -655,6 +655,11 @@ 标记为已读 新信息 新打闪 + 国际象棋 + 提醒您有关国际象棋游戏的事件 + %1$s 接受了您的国际象棋挑战 + %1$s 已移动 —— 轮到您了 + 国际象棋更新 通知: 加入对话 用户或群组 ID From bc8b73e458eac9aa524a70692091331df4cf0e1e Mon Sep 17 00:00:00 2001 From: Crowdin Bot Date: Tue, 31 Mar 2026 13:01:46 +0000 Subject: [PATCH 29/31] New Crowdin translations by GitHub Action --- amethyst/src/main/res/values-pl-rPL/strings.xml | 1 + amethyst/src/main/res/values-zh-rCN/strings.xml | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index e3bca072b..0ef2ee2d2 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -1622,6 +1622,7 @@ Test połączenia Testowanie serwerów… Podłączony + Test nieudany Wyniki testu Diagnostyka Ostatni test diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index ac2075f8a..389d7b3bd 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -655,6 +655,11 @@ 标记为已读 新信息 新打闪 + 国际象棋 + 提醒您有关国际象棋游戏的事件 + %1$s 接受了您的国际象棋挑战 + %1$s 已移动 —— 轮到您了 + 国际象棋更新 通知: 加入对话 用户或群组 ID From 7ec535765397253df9f5a106bcb14f3b50904d76 Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 31 Mar 2026 09:04:42 -0400 Subject: [PATCH 30/31] Lint --- .../service/notifications/EventNotificationConsumer.kt | 4 ++-- .../service/notifications/NotificationUtils.kt | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt index dc71af1ac..970f5e80d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/EventNotificationConsumer.kt @@ -125,7 +125,7 @@ class EventNotificationConsumer( is ChatMessageEncryptedFileHeaderEvent -> { notify(innerEvent, account) } - + is ReactionEvent -> { notify(innerEvent, account) } @@ -581,7 +581,7 @@ class EventNotificationConsumer( applicationContext, ) } - + private suspend fun notifyChessEvent( event: BaseChessEvent, account: Account, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 80ddfffaf..6de0f84e7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -119,15 +119,15 @@ object NotificationUtils { description = stringRes(applicationContext, R.string.app_notification_reactions_channel_description) } - + val notificationManager: NotificationManager = applicationContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - + notificationManager.createNotificationChannel(reactionChannel!!) return reactionChannel!! } - + fun getOrCreateChessChannel(applicationContext: Context): NotificationChannel { if (chessChannel != null) return chessChannel!! @@ -148,7 +148,7 @@ object NotificationUtils { return chessChannel!! } - + suspend fun NotificationManager.sendChessNotification( id: String, messageBody: String, @@ -176,7 +176,7 @@ object NotificationUtils { applicationContext = applicationContext, ) } - + suspend fun NotificationManager.sendChessNotification( id: String, messageBody: String, From bbeb2a66ee4fcfc7bae7ca12ab65c167d7c9df8f Mon Sep 17 00:00:00 2001 From: Vitor Pamplona Date: Tue, 31 Mar 2026 09:06:12 -0400 Subject: [PATCH 31/31] Fixes bad merge --- .../amethyst/service/notifications/NotificationUtils.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt index 6de0f84e7..ed5e76ac9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationUtils.kt @@ -46,13 +46,13 @@ object NotificationUtils { private var dmChannel: NotificationChannel? = null private var zapChannel: NotificationChannel? = null private var reactionChannel: NotificationChannel? = null + private var chessChannel: NotificationChannel? = null + private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION" private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION" private const val REACTION_GROUP_KEY = "com.vitorpamplona.amethyst.REACTION_NOTIFICATION" - private var chessChannel: NotificationChannel? = null - private const val DM_GROUP_KEY = "com.vitorpamplona.amethyst.DM_NOTIFICATION" - private const val ZAP_GROUP_KEY = "com.vitorpamplona.amethyst.ZAP_NOTIFICATION" private const val CHESS_GROUP_KEY = "com.vitorpamplona.amethyst.CHESS_NOTIFICATION" + const val REPLY_ACTION = "com.vitorpamplona.amethyst.REPLY_ACTION" const val MARK_READ_ACTION = "com.vitorpamplona.amethyst.MARK_READ_ACTION" const val KEY_REPLY_TEXT = "key_reply_text" @@ -149,7 +149,7 @@ object NotificationUtils { return chessChannel!! } - suspend fun NotificationManager.sendChessNotification( + suspend fun NotificationManager.sendReactionNotification( id: String, messageBody: String, messageTitle: String,