feat(chess): focused polling, promotion fix, and game end celebration

- Add focused game mode to ChessPollingDelegate: when viewing a game,
  only that game is polled instead of all active games
- Fix pawn promotion by parsing promotion suffix in publishMove
  (e.g., "e8q" -> "e8" + QUEEN)
- Add game end overlay with victory/defeat/draw celebration
- Remove all debug println statements for production readiness

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2026-02-10 11:24:17 +02:00
parent 3c89c442bc
commit 8d098cf86d
42 changed files with 7397 additions and 4118 deletions
File diff suppressed because it is too large Load Diff
@@ -20,133 +20,141 @@
*/
package com.vitorpamplona.amethyst.desktop.chess
import com.vitorpamplona.amethyst.commons.chess.ChessConfig
import com.vitorpamplona.amethyst.commons.chess.ChessEventBroadcaster
import com.vitorpamplona.amethyst.commons.chess.ChessEventPublisher
import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetchHelper
import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetcher
import com.vitorpamplona.amethyst.commons.chess.IUserMetadataProvider
import com.vitorpamplona.amethyst.commons.chess.RelayFetchProgress
import com.vitorpamplona.amethyst.commons.chess.RelayGameSummary
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder
import com.vitorpamplona.amethyst.commons.data.UserMetadataCache
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd
import com.vitorpamplona.quartz.nip64Chess.ChessGameEvents
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents
import com.vitorpamplona.quartz.nip64Chess.JesterProtocol
import com.vitorpamplona.quartz.nip64Chess.toJesterEvent
/**
* Desktop implementation of ChessEventPublisher.
* Desktop implementation of ChessEventPublisher using Jester protocol.
* Wraps account.signer.sign() + relayManager.broadcastToAll() for event publishing.
*
* Jester Protocol:
* - All chess events use kind 30
* - publishStart creates a start event (content.kind=0)
* - publishMove creates a move event (content.kind=1) with full history
* - publishGameEnd creates a move event with result in content
*/
class DesktopChessPublisher(
private val account: AccountState.LoggedIn,
private val relayManager: DesktopRelayConnectionManager,
) : ChessEventPublisher {
override suspend fun publishChallenge(
gameId: String,
private val broadcaster = ChessEventBroadcaster(relayManager.client)
/**
* Broadcast an event to the dedicated chess relays with reliable delivery.
* Uses ChessEventBroadcaster to ensure relay connections before sending.
*/
private suspend fun broadcastToChessRelays(event: com.vitorpamplona.quartz.nip01Core.core.Event): Boolean {
println("[DesktopChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster")
val result = broadcaster.broadcast(event)
println("[DesktopChessPublisher] Broadcast result: ${result.message}")
return result.success
}
/**
* Publish a game start event (challenge).
* Returns the startEventId (event ID) if successful.
*/
override suspend fun publishStart(
playerColor: Color,
opponentPubkey: String?,
timeControl: String?,
): Boolean =
): String? =
try {
val template =
LiveChessGameChallengeEvent.build(
gameId = gameId,
playerColor = playerColor,
opponentPubkey = opponentPubkey,
timeControl = timeControl,
)
if (opponentPubkey != null) {
JesterEvent.buildPrivateStart(
opponentPubkey = opponentPubkey,
playerColor = playerColor,
)
} else {
JesterEvent.buildStart(
playerColor = playerColor,
)
}
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
true
val success = broadcastToChessRelays(signedEvent)
if (success) signedEvent.id else null
} catch (e: Exception) {
false
println("[DesktopChessPublisher] publishStart failed: ${e.message}")
null
}
override suspend fun publishAccept(
gameId: String,
challengeEventId: String,
challengerPubkey: String,
): Boolean =
/**
* Publish a move event.
* Returns the move event ID if successful.
*/
override suspend fun publishMove(move: ChessMoveEvent): String? =
try {
val template =
LiveChessGameAcceptEvent.build(
gameId = gameId,
challengeEventId = challengeEventId,
challengerPubkey = challengerPubkey,
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
true
} catch (e: Exception) {
false
}
override suspend fun publishMove(move: ChessMoveEvent): Boolean =
try {
val template =
LiveChessMoveEvent.build(
gameId = move.gameId,
moveNumber = move.moveNumber,
san = move.san,
JesterEvent.buildMove(
startEventId = move.startEventId,
headEventId = move.headEventId,
move = move.san,
fen = move.fen,
history = move.history,
opponentPubkey = move.opponentPubkey,
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
true
val success = broadcastToChessRelays(signedEvent)
if (success) signedEvent.id else null
} catch (e: Exception) {
false
println("[DesktopChessPublisher] publishMove failed: ${e.message}")
null
}
/**
* Publish a game end event (includes result in content).
*/
override suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean =
try {
val template =
LiveChessGameEndEvent.build(
gameId = gameEnd.gameId,
JesterEvent.buildEndMove(
startEventId = gameEnd.startEventId,
headEventId = gameEnd.headEventId,
move = gameEnd.lastMove,
fen = gameEnd.fen,
history = gameEnd.history,
opponentPubkey = gameEnd.opponentPubkey,
result = gameEnd.result,
termination = gameEnd.termination,
winnerPubkey = gameEnd.winnerPubkey,
opponentPubkey = gameEnd.opponentPubkey,
pgn = gameEnd.pgn ?: "",
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
true
broadcastToChessRelays(signedEvent)
} catch (e: Exception) {
println("[DesktopChessPublisher] publishGameEnd failed: ${e.message}")
false
}
override suspend fun publishDrawOffer(
gameId: String,
opponentPubkey: String,
message: String?,
): Boolean =
try {
val template =
LiveChessDrawOfferEvent.build(
gameId = gameId,
opponentPubkey = opponentPubkey,
message = message ?: "",
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
true
} catch (e: Exception) {
false
}
override fun getWriteRelayCount(): Int = relayManager.connectedRelays.value.size
override fun getWriteRelayCount(): Int = ChessConfig.CHESS_RELAYS.size
}
/**
* Desktop implementation of ChessRelayFetcher.
* Uses ChessRelayFetchHelper for one-shot relay queries.
* Desktop implementation of ChessRelayFetcher using Jester protocol.
*
* Hybrid approach (like Android's LocalCache pattern):
* 1. Query DesktopChessEventCache first (populated by subscription)
* 2. Also do relay queries and merge results for completeness
*
* This solves the "missing games" issue where pure one-shot relay queries
* would miss events due to timing or slow relays.
*/
class DesktopRelayFetcher(
private val relayManager: DesktopRelayConnectionManager,
@@ -154,266 +162,223 @@ class DesktopRelayFetcher(
) : ChessRelayFetcher {
private val fetchHelper = ChessRelayFetchHelper(relayManager.client)
override suspend fun fetchGameEvents(gameId: String): ChessGameEvents {
val filters = ChessFilterBuilder.gameEventsFilter(gameId)
val relayFilters = relayManager.connectedRelays.value.associateWith { listOf(filters) }
/**
* Get the normalized chess relay URLs.
* Always uses the 3 dedicated chess relays from ChessConfig.
*/
private fun chessRelayUrls(): List<NormalizedRelayUrl> = ChessConfig.CHESS_RELAYS.map { NormalizedRelayUrl(it) }
val events = fetchHelper.fetchEvents(relayFilters)
override fun getRelayUrls(): List<String> {
println("[DesktopRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays")
return ChessConfig.CHESS_RELAYS
}
val challengeEvent =
events
.filterIsInstance<LiveChessGameChallengeEvent>()
.firstOrNull { it.gameId() == gameId }
?: events
.filter { it.kind == LiveChessGameChallengeEvent.KIND }
.mapNotNull { event ->
LiveChessGameChallengeEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
).takeIf { it.gameId() == gameId }
}.firstOrNull()
/**
* Fetch game events - queries cache first, supplements with relay query.
* Similar to Android's LocalCache.addressables pattern.
*
* Uses TWO filters:
* 1. Fetch start event by ID (start events don't have #e tag to themselves)
* 2. Fetch moves that reference the start event via #e tag
*/
override suspend fun fetchGameEvents(startEventId: String): JesterGameEvents {
println("[DesktopRelayFetcher] fetchGameEvents: querying cache for startEventId=$startEventId")
val acceptEvent =
events
.filterIsInstance<LiveChessGameAcceptEvent>()
.firstOrNull { it.gameId() == gameId }
?: events
.filter { it.kind == LiveChessGameAcceptEvent.KIND }
.mapNotNull { event ->
LiveChessGameAcceptEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
).takeIf { it.gameId() == gameId }
}.firstOrNull()
// Query cache first (populated by subscription)
val cachedEvents = DesktopChessEventCache.getGameEvents(startEventId)
println("[DesktopRelayFetcher] fetchGameEvents cache: start=${cachedEvents.startEvent != null}, moves=${cachedEvents.moves.size}")
val moveEvents =
events
.filterIsInstance<LiveChessMoveEvent>()
.filter { it.gameId() == gameId }
.ifEmpty {
events
.filter { it.kind == LiveChessMoveEvent.KIND }
.mapNotNull { event ->
LiveChessMoveEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
).takeIf { it.gameId() == gameId }
}
}
// Also do relay query to catch any new events and populate cache
// Filter 1: Fetch the start event by its ID
val startEventFilter =
Filter(
ids = listOf(startEventId),
kinds = listOf(JesterProtocol.KIND),
)
// Filter 2: Fetch moves that reference the start event
val movesFilter = ChessFilterBuilder.gameEventsFilter(startEventId)
val relayFilters = chessRelayUrls().associateWith { listOf(startEventFilter, movesFilter) }
val relayEvents = fetchHelper.fetchEvents(relayFilters)
val endEvent =
events
.filterIsInstance<LiveChessGameEndEvent>()
.firstOrNull { it.gameId() == gameId }
?: events
.filter { it.kind == LiveChessGameEndEvent.KIND }
.mapNotNull { event ->
LiveChessGameEndEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
).takeIf { it.gameId() == gameId }
}.firstOrNull()
// Add relay events to cache
relayEvents.forEach { DesktopChessEventCache.add(it) }
val drawOffers =
events
.filterIsInstance<LiveChessDrawOfferEvent>()
.filter { it.gameId() == gameId }
.ifEmpty {
events
.filter { it.kind == LiveChessDrawOfferEvent.KIND }
.mapNotNull { event ->
LiveChessDrawOfferEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
).takeIf { it.gameId() == gameId }
}
}
// Convert to JesterEvents and merge
val relayJesterEvents =
relayEvents
.filter { it.kind == JesterProtocol.KIND }
.mapNotNull { it.toJesterEvent() }
return ChessGameEvents(
challenge = challengeEvent,
accept = acceptEvent,
moves = moveEvents,
end = endEvent,
drawOffers = drawOffers,
val relayStartEvent =
relayJesterEvents
.firstOrNull { it.isStartEvent() && it.id == startEventId }
val relayMoves =
relayJesterEvents
.filter { it.isMoveEvent() && it.startEventId() == startEventId }
// Merge: prefer cache start event, merge all moves
val allMoves = (cachedEvents.moves + relayMoves).distinctBy { it.id }
println("[DesktopRelayFetcher] fetchGameEvents: found start=${cachedEvents.startEvent ?: relayStartEvent != null}, moves=${allMoves.size}")
return JesterGameEvents(
startEvent = cachedEvents.startEvent ?: relayStartEvent,
moves = allMoves,
)
}
override suspend fun fetchChallenges(): List<LiveChessGameChallengeEvent> {
val filters = ChessFilterBuilder.challengesFilter(userPubkey)
val relayFilters = relayManager.connectedRelays.value.associateWith { listOf(filters) }
/**
* Fetch start events (challenges) - queries cache first.
*/
override suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)?): List<JesterEvent> {
val relays = chessRelayUrls()
println("[DesktopRelayFetcher] fetchChallenges: querying cache first, then ${relays.size} chess relays")
val events = fetchHelper.fetchEvents(relayFilters)
return events
.filterIsInstance<LiveChessGameChallengeEvent>()
.ifEmpty {
events
.filter { it.kind == LiveChessGameChallengeEvent.KIND }
.map { event ->
LiveChessGameChallengeEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
}
// Query cache for start events that are:
// 1. Open challenges (no specific opponent)
// 2. Directed at us
// 3. Created by us
val cachedStartEvents =
DesktopChessEventCache.queryStartEvents { event ->
event.opponentPubkey() == null ||
event.opponentPubkey() == userPubkey ||
event.pubKey == userPubkey
}
println("[DesktopRelayFetcher] fetchChallenges: found ${cachedStartEvents.size} in cache")
// Also do relay query to catch any new challenges
val filter = ChessFilterBuilder.challengesFilter(userPubkey)
val relayFilters = relays.associateWith { listOf(filter) }
val relayEvents = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress)
// Add to cache
relayEvents.forEach { DesktopChessEventCache.add(it) }
val relayStartEvents =
relayEvents
.filter { it.kind == JesterProtocol.KIND }
.mapNotNull { it.toJesterEvent() }
.filter { it.isStartEvent() }
// Merge and deduplicate
val allStartEvents = (cachedStartEvents + relayStartEvents).distinctBy { it.id }
println("[DesktopRelayFetcher] fetchChallenges: total ${allStartEvents.size} after merge")
return allStartEvents
}
/**
* Fetch user game IDs (startEventIds) - queries cache first.
*/
override suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)?): Set<String> {
val relays = chessRelayUrls()
println("[DesktopRelayFetcher] fetchUserGameIds: querying cache first, then ${relays.size} chess relays")
val gameIds = mutableSetOf<String>()
// Query cache for start events created by user or directed at user
DesktopChessEventCache
.queryStartEvents { event ->
event.pubKey == userPubkey || event.opponentPubkey() == userPubkey
}.forEach { gameIds.add(it.id) }
// Query cache for moves by user or tagged with user
DesktopChessEventCache
.queryMoveEvents { event ->
event.pubKey == userPubkey || event.opponentPubkey() == userPubkey
}.forEach { event ->
event.startEventId()?.let { gameIds.add(it) }
}
println("[DesktopRelayFetcher] fetchUserGameIds: found ${gameIds.size} in cache")
// Also do relay query to catch any new game IDs
val userFilter = ChessFilterBuilder.userGamesFilter(userPubkey)
val taggedFilter = ChessFilterBuilder.userTaggedFilter(userPubkey)
val relayFilters = relays.associateWith { listOf(userFilter, taggedFilter) }
val relayEvents = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress)
// Add to cache
relayEvents.forEach { DesktopChessEventCache.add(it) }
// Extract game IDs from relay events
relayEvents
.filter { it.kind == JesterProtocol.KIND }
.mapNotNull { it.toJesterEvent() }
.forEach { event ->
if (event.isStartEvent()) {
gameIds.add(event.id)
} else if (event.isMoveEvent()) {
event.startEventId()?.let { gameIds.add(it) }
}
}
println("[DesktopRelayFetcher] fetchUserGameIds: total ${gameIds.size} after merge")
return gameIds
}
/**
* Fetch recent games for spectating.
*/
override suspend fun fetchRecentGames(): List<RelayGameSummary> {
val filters = ChessFilterBuilder.recentGamesFilter()
val relayFilters = relayManager.connectedRelays.value.associateWith { listOf(filters) }
val filter = ChessFilterBuilder.recentGamesFilter()
val relayFilters = chessRelayUrls().associateWith { listOf(filter) }
val events = fetchHelper.fetchEvents(relayFilters)
// Group by game ID and create summaries
val gameIds =
// Cache events so they're available when watching
events.forEach { DesktopChessEventCache.add(it) }
// Convert to JesterEvents
val jesterEvents =
events
.filter { it.kind == LiveChessMoveEvent.KIND }
.mapNotNull { event ->
val move =
if (event is LiveChessMoveEvent) {
event
} else {
LiveChessMoveEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
}
move.gameId()
}.distinct()
.filter { it.kind == JesterProtocol.KIND }
.mapNotNull { it.toJesterEvent() }
return gameIds.mapNotNull { gameId ->
// Find challenge and accept for this game
val challenge =
events
.filter { it.kind == LiveChessGameChallengeEvent.KIND }
.mapNotNull { event ->
val e =
if (event is LiveChessGameChallengeEvent) {
event
} else {
LiveChessGameChallengeEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
}
e.takeIf { it.gameId() == gameId }
}.firstOrNull() ?: return@mapNotNull null
// Group by game (startEventId for moves, id for start events)
val startEventsById =
jesterEvents
.filter { it.isStartEvent() }
.associateBy { it.id }
val accept =
events
.filter { it.kind == LiveChessGameAcceptEvent.KIND }
.mapNotNull { event ->
val e =
if (event is LiveChessGameAcceptEvent) {
event
} else {
LiveChessGameAcceptEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
}
e.takeIf { it.gameId() == gameId }
}.firstOrNull()
val movesByGameId =
jesterEvents
.filter { it.isMoveEvent() }
.groupBy { it.startEventId() ?: "" }
.filterKeys { it.isNotEmpty() }
val challengerColor = challenge.playerColor() ?: Color.WHITE
val whitePubkey =
// Get all game IDs (from start events and moves)
val allGameIds = startEventsById.keys + movesByGameId.keys
return allGameIds.mapNotNull { startEventId ->
val startEvent = startEventsById[startEventId] ?: return@mapNotNull null
val moves = movesByGameId[startEventId] ?: emptyList()
val challengerColor = startEvent.playerColor() ?: Color.WHITE
// Determine players from start event and moves
val challengerPubkey = startEvent.pubKey
val opponentFromMoves = moves.firstOrNull { it.pubKey != challengerPubkey }?.pubKey
val opponentPubkey = startEvent.opponentPubkey() ?: opponentFromMoves ?: return@mapNotNull null
val (whitePubkey, blackPubkey) =
if (challengerColor == Color.WHITE) {
challenge.pubKey
challengerPubkey to opponentPubkey
} else {
accept?.pubKey ?: return@mapNotNull null
}
val blackPubkey =
if (challengerColor == Color.BLACK) {
challenge.pubKey
} else {
accept?.pubKey ?: return@mapNotNull null
opponentPubkey to challengerPubkey
}
val moves =
events
.filter { it.kind == LiveChessMoveEvent.KIND }
.mapNotNull { event ->
val m =
if (event is LiveChessMoveEvent) {
event
} else {
LiveChessMoveEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
}
m.takeIf { it.gameId() == gameId }
}
val endEvent =
events
.filter { it.kind == LiveChessGameEndEvent.KIND }
.mapNotNull { event ->
val e =
if (event is LiveChessGameEndEvent) {
event
} else {
LiveChessGameEndEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
}
e.takeIf { it.gameId() == gameId }
}.firstOrNull()
val lastMove = moves.maxByOrNull { it.createdAt }
val lastMove = moves.maxByOrNull { it.history().size }
val hasEnded = lastMove?.result() != null
RelayGameSummary(
gameId = gameId,
startEventId = startEventId,
whitePubkey = whitePubkey,
blackPubkey = blackPubkey,
moveCount = moves.size,
lastMoveTime = lastMove?.createdAt ?: challenge.createdAt,
isActive = endEvent == null,
moveCount = lastMove?.history()?.size ?: 0,
lastMoveTime = lastMove?.createdAt ?: startEvent.createdAt,
isActive = !hasEnded,
)
}
}
@@ -0,0 +1,150 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.chess
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip64Chess.JesterEvent
import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents
import com.vitorpamplona.quartz.nip64Chess.JesterProtocol
import com.vitorpamplona.quartz.nip64Chess.toJesterEvent
import java.util.concurrent.ConcurrentHashMap
/**
* Local event cache for Desktop chess events using Jester protocol.
*
* Mimics Android's LocalCache.addressables behavior:
* - Stores events by ID (deduplication)
* - Queryable by kind with custom filters
* - Thread-safe via ConcurrentHashMap
* - Populated from subscription callbacks
* - Persists across fetcher queries (unlike one-shot relay queries)
*
* Jester Protocol Notes:
* - All chess events use kind 30
* - Start events: content.kind=0, e-tag references START_POSITION_HASH
* - Move events: content.kind=1, e-tags=[startEventId, headEventId]
*/
object DesktopChessEventCache {
// Store all Jester events by ID for deduplication
@PublishedApi
internal val events = ConcurrentHashMap<String, JesterEvent>()
// Index start events by event ID (for quick game lookup)
private val startEvents = ConcurrentHashMap<String, JesterEvent>()
// Index move events by startEventId (game ID) for quick game reconstruction
private val movesByGame = ConcurrentHashMap<String, MutableSet<String>>()
/**
* Add an event to the cache.
* Returns true if the event was new, false if it was a duplicate.
*/
fun add(event: Event): Boolean {
if (event.kind != JesterProtocol.KIND) return false
val jesterEvent = event.toJesterEvent() ?: return false
return addJesterEvent(jesterEvent)
}
/**
* Add a JesterEvent directly.
*/
fun addJesterEvent(event: JesterEvent): Boolean {
val isNew = events.putIfAbsent(event.id, event) == null
if (isNew) {
if (event.isStartEvent()) {
startEvents[event.id] = event
} else if (event.isMoveEvent()) {
val startId = event.startEventId()
if (startId != null) {
movesByGame.computeIfAbsent(startId) { ConcurrentHashMap.newKeySet() }.add(event.id)
}
}
}
return isNew
}
/**
* Get an event by ID.
*/
fun get(id: String): JesterEvent? = events[id]
/**
* Get all start events (challenges).
*/
fun getStartEvents(filter: (JesterEvent) -> Boolean = { true }): List<JesterEvent> = startEvents.values.filter { it.isStartEvent() && filter(it) }
/**
* Get all move events for a specific game.
*/
fun getMovesForGame(startEventId: String): List<JesterEvent> {
val moveIds = movesByGame[startEventId] ?: return emptyList()
return moveIds.mapNotNull { events[it] }
}
/**
* Get all events for a specific game (start + moves).
*/
fun getGameEvents(startEventId: String): JesterGameEvents {
val startEvent = startEvents[startEventId]
val moves = getMovesForGame(startEventId)
return JesterGameEvents(startEvent, moves)
}
/**
* Query events with custom filter.
*/
fun query(filter: (JesterEvent) -> Boolean): List<JesterEvent> = events.values.filter(filter)
/**
* Query start events with custom filter.
*/
fun queryStartEvents(filter: (JesterEvent) -> Boolean = { true }): List<JesterEvent> = startEvents.values.filter { it.isStartEvent() && filter(it) }
/**
* Query move events with custom filter.
*/
fun queryMoveEvents(filter: (JesterEvent) -> Boolean = { true }): List<JesterEvent> = events.values.filter { it.isMoveEvent() && filter(it) }
/**
* Get all game IDs (startEventIds) in the cache.
*/
fun getAllGameIds(): Set<String> = startEvents.keys.toSet()
/**
* Get count of events.
*/
fun size(): Int = events.size
/**
* Get count of start events.
*/
fun startEventCount(): Int = startEvents.size
/**
* Clear all cached events.
*/
fun clear() {
events.clear()
startEvents.clear()
movesByGame.clear()
}
}
@@ -1,959 +0,0 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.chess
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDelegate
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionController
import com.vitorpamplona.amethyst.commons.data.UserMetadataCache
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip64Chess.ChessEngine
import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator
import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.GameResult
import com.vitorpamplona.quartz.nip64Chess.GameTermination
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.nip64Chess.PieceType
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* Desktop ViewModel for managing chess game state and event publishing.
* Adapts the Android ChessViewModel patterns for Desktop's relay architecture.
*/
class DesktopChessViewModel(
private val account: AccountState.LoggedIn,
private val relayManager: DesktopRelayConnectionManager,
private val scope: CoroutineScope,
) {
companion object {
const val CHALLENGE_EXPIRY_SECONDS = 24 * 60 * 60L
const val MAX_RETRIES = 3
const val RETRY_DELAY_MS = 1000L
}
// Active games being played
private val _activeGames = MutableStateFlow<Map<String, LiveChessGameState>>(emptyMap())
val activeGames: StateFlow<Map<String, LiveChessGameState>> = _activeGames.asStateFlow()
// Pending challenges
private val _challenges = MutableStateFlow<List<LiveChessGameChallengeEvent>>(emptyList())
val challenges: StateFlow<List<LiveChessGameChallengeEvent>> = _challenges.asStateFlow()
// Badge count
private val _badgeCount = MutableStateFlow(0)
val badgeCount: StateFlow<Int> = _badgeCount.asStateFlow()
// Currently selected game
private val _selectedGameId = MutableStateFlow<String?>(null)
val selectedGameId: StateFlow<String?> = _selectedGameId.asStateFlow()
// Error state
private val _error = MutableStateFlow<String?>(null)
val error: StateFlow<String?> = _error.asStateFlow()
// Loading state
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
// Refresh key - incrementing this triggers re-subscription
private val _refreshKey = MutableStateFlow(0)
val refreshKey: StateFlow<Int> = _refreshKey.asStateFlow()
// Completed games history
private val _completedGames = MutableStateFlow<List<CompletedGame>>(emptyList())
val completedGames: StateFlow<List<CompletedGame>> = _completedGames.asStateFlow()
// Shared user metadata cache
val userMetadataCache = UserMetadataCache()
// Pending moves waiting for game to be created (gameId -> list of (san, fen, moveNumber))
private val pendingMoves = mutableMapOf<String, MutableList<Triple<String, String, Int?>>>()
// Challenge events indexed by gameId for lookup during accept processing
private val challengesByGameId = mutableMapOf<String, LiveChessGameChallengeEvent>()
// Pending accept events waiting for challenge to arrive (gameId -> accept event)
private val pendingAccepts = mutableMapOf<String, LiveChessGameAcceptEvent>()
// Track event IDs we've already processed to avoid duplicates
// Uses ConcurrentHashMap for thread-safe check-and-add
private val processedEventIds =
java.util.concurrent.ConcurrentHashMap
.newKeySet<String>()
// Track games currently being created to prevent race conditions
private val gamesBeingCreated = mutableSetOf<String>()
// Subscription controller for dynamic filter updates
private var subscriptionController: ChessSubscriptionController? = null
// Polling delegate for periodic refresh (fallback when relay events are missed)
private val pollingDelegate =
ChessPollingDelegate(
config = ChessPollingDefaults.desktop,
scope = scope,
onRefreshGames = { gameIds -> refreshGamesFromRelay(gameIds) },
onRefreshChallenges = { /* Subscriptions handle challenges */ },
onCleanup = { cleanupExpiredChallenges() },
)
init {
// Start polling for move updates
pollingDelegate.start()
}
/**
* Set the subscription controller for dynamic filter updates.
* Should be called after creating the ViewModel.
*/
fun setSubscriptionController(controller: ChessSubscriptionController) {
subscriptionController = controller
}
/**
* Notify subscription controller and polling delegate of active game changes.
* Call this whenever _activeGames is modified.
*/
private fun notifyActiveGamesChanged() {
val gameIds = _activeGames.value.keys
subscriptionController?.updateActiveGames(
activeGameIds = gameIds,
spectatingGameIds = emptySet(),
)
pollingDelegate.setActiveGameIds(gameIds)
}
/**
* Process incoming chess event from relay subscription
*/
fun handleIncomingEvent(event: Event) {
// Skip already processed events (atomic check-and-add)
if (!processedEventIds.add(event.id)) return
// Check by kind since events may not be cast to specific types
when (event.kind) {
MetadataEvent.KIND -> {
handleMetadataEvent(event)
return
}
LiveChessGameChallengeEvent.KIND -> {
if (event is LiveChessGameChallengeEvent) {
handleNewChallenge(event)
} else {
// Manually parse if not already the right type
val challenge =
LiveChessGameChallengeEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
handleNewChallenge(challenge)
}
}
LiveChessGameAcceptEvent.KIND -> {
if (event is LiveChessGameAcceptEvent) {
handleGameAccepted(event)
} else {
val accept =
LiveChessGameAcceptEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
handleGameAccepted(accept)
}
}
LiveChessMoveEvent.KIND -> {
if (event is LiveChessMoveEvent) {
handleIncomingMove(event)
} else {
val move =
LiveChessMoveEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
handleIncomingMove(move)
}
}
LiveChessGameEndEvent.KIND -> {
if (event is LiveChessGameEndEvent) {
handleGameEnded(event)
} else {
val end =
LiveChessGameEndEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
handleGameEnded(end)
}
}
LiveChessDrawOfferEvent.KIND -> {
if (event is LiveChessDrawOfferEvent) {
handleDrawOffer(event)
} else {
val drawOffer =
LiveChessDrawOfferEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
handleDrawOffer(drawOffer)
}
}
}
}
private fun handleMetadataEvent(event: Event) {
try {
val metadataEvent =
MetadataEvent(
event.id,
event.pubKey,
event.createdAt,
event.tags,
event.content,
event.sig,
)
val metadata = metadataEvent.contactMetaData()
if (metadata != null) {
userMetadataCache.put(event.pubKey, metadata)
}
} catch (e: Exception) {
// Ignore parse errors
}
}
private fun handleIncomingMove(event: LiveChessMoveEvent) {
println("[Chess] Received move event: gameId=${event.gameId()}, san=${event.san()}, moveNum=${event.moveNumber()}, from=${event.pubKey.take(8)}")
val gameId = event.gameId() ?: return
val san = event.san() ?: return
val fen = event.fen() ?: return
val moveNumber = event.moveNumber()
val gameState = _activeGames.value[gameId]
if (gameState == null) {
// Game doesn't exist yet - buffer the move for later (including our own for FEN sync)
println("[Chess] Game $gameId not found, buffering move $san (fen for sync)")
val moveList = pendingMoves.getOrPut(gameId) { mutableListOf() }
moveList.add(Triple(san, fen, moveNumber))
return
}
// Don't process our own moves for active games (already applied locally)
if (event.pubKey == account.pubKeyHex) {
println("[Chess] Skipping own move (already applied locally)")
return
}
if (event.pubKey != gameState.opponentPubkey) {
println("[Chess] Move not from opponent (expected ${gameState.opponentPubkey.take(8)}, got ${event.pubKey.take(8)})")
return
}
println("[Chess] Applying opponent move: $san (move #$moveNumber)")
gameState.applyOpponentMove(san, fen, moveNumber)
updateBadgeCount()
}
private fun handleGameAccepted(event: LiveChessGameAcceptEvent) {
val gameId = event.gameId() ?: return
// Skip if game already exists
if (_activeGames.value.containsKey(gameId)) return
val challengerPubkey = event.challengerPubkey() ?: return
val accepterPubkey = event.pubKey
// Check if we have the challenge event for color info
val challengeEvent = challengesByGameId[gameId]
if (challengeEvent == null) {
// Challenge hasn't arrived yet - buffer this accept for later
if (challengerPubkey == account.pubKeyHex || accepterPubkey == account.pubKeyHex) {
pendingAccepts[gameId] = event
}
return
}
// Handle if user is the challenger or accepter
if (challengerPubkey == account.pubKeyHex) {
startGameFromAcceptance(event, isChallenger = true)
} else if (accepterPubkey == account.pubKeyHex) {
startGameFromAcceptance(event, isChallenger = false)
}
}
private fun handleGameEnded(event: LiveChessGameEndEvent) {
val gameId = event.gameId() ?: return
// Store game in history before removing (skip if already in completed list)
val alreadyCompleted = _completedGames.value.any { it.gameId == gameId }
val gameState = _activeGames.value[gameId]
if (gameState != null && !alreadyCompleted) {
val completedGame =
CompletedGame(
gameId = gameId,
opponentPubkey = gameState.opponentPubkey,
playerColor = gameState.playerColor,
result = event.result() ?: "?",
termination = event.termination() ?: "unknown",
winnerPubkey = event.winnerPubkey(),
completedAt = event.createdAt,
moveCount = gameState.moveHistory.value.size,
)
_completedGames.value = listOf(completedGame) + _completedGames.value
}
// Remove from active games
if (_activeGames.value.containsKey(gameId)) {
_activeGames.value = _activeGames.value - gameId
notifyActiveGamesChanged()
}
// Clean up challenge references
_challenges.value = _challenges.value.filter { it.gameId() != gameId }
challengesByGameId.remove(gameId)
pendingAccepts.remove(gameId)
pendingMoves.remove(gameId)
updateBadgeCount()
}
private fun handleDrawOffer(event: LiveChessDrawOfferEvent) {
// Only process draw offers from opponent
if (event.pubKey == account.pubKeyHex) return
val gameId = event.gameId() ?: return
val gameState = _activeGames.value[gameId] ?: return
// Verify this is from our opponent in this game
if (event.pubKey != gameState.opponentPubkey) return
// Pass to game state to track
gameState.receiveDrawOffer(event.pubKey)
updateBadgeCount()
}
private fun handleNewChallenge(event: LiveChessGameChallengeEvent) {
val gameId = event.gameId() ?: return
val opponentPubkey = event.opponentPubkey()
val isUserChallenge = event.pubKey == account.pubKeyHex
val isOpenChallenge = opponentPubkey == null
val isDirectedAtUser = opponentPubkey == account.pubKeyHex
// Request metadata for challenger
userMetadataCache.request(event.pubKey)
opponentPubkey?.let { userMetadataCache.request(it) }
// Always store in lookup map for accept event processing (even if game exists)
challengesByGameId[gameId] = event
// Skip if game already exists (challenge was already accepted)
if (_activeGames.value.containsKey(gameId)) return
// Check if there's a pending accept waiting for this challenge
val pendingAccept = pendingAccepts.remove(gameId)
if (pendingAccept != null) {
// Now we can properly process the accept
val isChallenger = event.pubKey == account.pubKeyHex
val isAccepter = pendingAccept.pubKey == account.pubKeyHex
if (isChallenger || isAccepter) {
startGameFromAcceptance(pendingAccept, isChallenger = isChallenger)
return // Don't add to challenges list since game is starting
}
}
// Show challenges that are:
// - Created by user (their outgoing challenges)
// - Open challenges (anyone can accept)
// - Directed at user (incoming challenges)
if (isUserChallenge || isOpenChallenge || isDirectedAtUser) {
val currentChallenges = _challenges.value.toMutableList()
// Deduplicate by both event ID and game ID to prevent duplicates from relay broadcasts
val isDuplicateEvent = currentChallenges.any { it.id == event.id }
val isDuplicateGame = currentChallenges.any { it.gameId() == gameId }
if (!isDuplicateEvent && !isDuplicateGame) {
currentChallenges.add(event)
_challenges.value = currentChallenges
updateBadgeCount()
}
}
}
/**
* Create a new chess challenge
*/
fun createChallenge(
opponentPubkey: String? = null,
playerColor: Color = Color.WHITE,
timeControl: String? = null,
) {
val gameId = generateGameId()
scope.launch(Dispatchers.IO) {
val success =
retryWithBackoff {
val template =
LiveChessGameChallengeEvent.build(
gameId = gameId,
playerColor = playerColor,
opponentPubkey = opponentPubkey,
timeControl = timeControl,
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
}
if (success) {
_error.value = null
} else {
_error.value = "Failed to create challenge after $MAX_RETRIES attempts"
}
}
}
/**
* Accept a chess challenge
*/
fun acceptChallenge(challengeEvent: LiveChessGameChallengeEvent) {
val gameId = challengeEvent.gameId() ?: return
val challengerPubkey = challengeEvent.pubKey
val challengerColor = challengeEvent.playerColor() ?: Color.WHITE
val playerColor = challengerColor.opposite()
// Store in lookup map for consistent access
challengesByGameId[gameId] = challengeEvent
// Mark as being created to prevent duplicate from relay echo
if (!gamesBeingCreated.add(gameId)) return // Already being created
scope.launch(Dispatchers.IO) {
val success =
retryWithBackoff {
val template =
LiveChessGameAcceptEvent.build(
gameId = gameId,
challengeEventId = challengeEvent.id,
challengerPubkey = challengerPubkey,
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
}
if (success) {
// Check if game was already created by relay echo while we were broadcasting
if (_activeGames.value.containsKey(gameId)) {
gamesBeingCreated.remove(gameId)
_selectedGameId.value = gameId
_challenges.value = _challenges.value.filter { it.id != challengeEvent.id }
_error.value = null
return@launch
}
val engine = ChessEngine()
engine.reset()
val gameState =
LiveChessGameState(
gameId = gameId,
playerPubkey = account.pubKeyHex,
opponentPubkey = challengerPubkey,
playerColor = playerColor,
engine = engine,
)
_activeGames.value = _activeGames.value + (gameId to gameState)
notifyActiveGamesChanged()
gamesBeingCreated.remove(gameId)
_selectedGameId.value = gameId
_challenges.value = _challenges.value.filter { it.id != challengeEvent.id }
_error.value = null
} else {
gamesBeingCreated.remove(gameId)
_error.value = "Failed to accept challenge"
}
}
}
private fun startGameFromAcceptance(
acceptEvent: LiveChessGameAcceptEvent,
isChallenger: Boolean,
) {
scope.launch(Dispatchers.IO) {
val gameId = acceptEvent.gameId() ?: return@launch
val challengerPubkey = acceptEvent.challengerPubkey() ?: return@launch
val accepterPubkey = acceptEvent.pubKey
println("[Chess] startGameFromAcceptance: gameId=$gameId, isChallenger=$isChallenger")
println("[Chess] Pending moves in buffer: ${pendingMoves.keys}")
// Skip if game already exists or is being created
if (_activeGames.value.containsKey(gameId)) {
println("[Chess] Game $gameId already exists, skipping")
return@launch
}
if (!gamesBeingCreated.add(gameId)) {
println("[Chess] Game $gameId already being created, skipping")
return@launch // Returns false if already present
}
val opponentPubkey: String
val playerColor: Color
// Use challengesByGameId for reliable lookup (not affected by UI filtering)
val challengeEvent = challengesByGameId[gameId]
if (isChallenger) {
// User is challenger, opponent is accepter
opponentPubkey = accepterPubkey
playerColor = challengeEvent?.playerColor() ?: Color.WHITE
} else {
// User is accepter, opponent is challenger
opponentPubkey = challengerPubkey
// Accepter gets opposite color of challenger
val challengerColor = challengeEvent?.playerColor() ?: Color.WHITE
playerColor = challengerColor.opposite()
}
val engine = ChessEngine()
engine.reset()
val gameState =
LiveChessGameState(
gameId = gameId,
playerPubkey = account.pubKeyHex,
opponentPubkey = opponentPubkey,
playerColor = playerColor,
engine = engine,
)
println("[Chess] Adding game $gameId to active games, opponent=$opponentPubkey")
_activeGames.value = _activeGames.value + (gameId to gameState)
notifyActiveGamesChanged()
_challenges.value = _challenges.value.filter { it.gameId() != gameId }
gamesBeingCreated.remove(gameId)
// Apply any pending moves that arrived before the game was created
println("[Chess] About to apply pending moves for $gameId, buffer has: ${pendingMoves[gameId]?.size ?: 0} moves")
applyPendingMoves(gameId, gameState)
}
}
private fun applyPendingMoves(
gameId: String,
gameState: LiveChessGameState,
) {
val moves = pendingMoves.remove(gameId)
if (moves == null) {
println("[Chess] No pending moves for game $gameId")
return
}
println("[Chess] Processing ${moves.size} pending moves for game $gameId")
// Sort by move number to find the latest move
val sortedMoves = moves.sortedBy { it.third ?: Int.MAX_VALUE }
if (sortedMoves.isEmpty()) return
// Find the move with highest move number - its FEN has the current board state
val latestMove = sortedMoves.maxByOrNull { it.third ?: 0 }
if (latestMove != null) {
val (san, fen, moveNumber) = latestMove
println("[Chess] Syncing to latest position from move #$moveNumber: $san")
println("[Chess] FEN: $fen")
// Use forceResync to set the board to the correct position
gameState.forceResync(fen)
// Mark all received move numbers as processed to avoid duplicates
sortedMoves.forEach { (_, _, num) ->
if (num != null) {
gameState.markMovesAsReceived(setOf(num))
}
}
println("[Chess] Board synced to position after ${sortedMoves.size} moves")
}
updateBadgeCount()
}
/**
* Make and publish a move
*/
fun publishMove(
gameId: String,
from: String,
to: String,
) {
val gameState = _activeGames.value[gameId] ?: return
// Parse promotion from 'to' if present (e.g., "e8q" -> square="e8", promotion=QUEEN)
val (targetSquare, promotion) = parsePromotionFromTarget(to)
val moveResult = gameState.makeMove(from, targetSquare, promotion)
if (moveResult != null) {
publishMoveEvent(gameId, moveResult)
}
}
/**
* Parse promotion piece from target square string.
* e.g., "e8q" -> ("e8", QUEEN), "e4" -> ("e4", null)
*/
private fun parsePromotionFromTarget(to: String): Pair<String, PieceType?> {
if (to.length == 3) {
val square = to.substring(0, 2)
val promotion =
when (to[2].lowercaseChar()) {
'q' -> PieceType.QUEEN
'r' -> PieceType.ROOK
'b' -> PieceType.BISHOP
'n' -> PieceType.KNIGHT
else -> null
}
return square to promotion
}
return to to null
}
private fun publishMoveEvent(
gameId: String,
moveEvent: ChessMoveEvent,
) {
scope.launch(Dispatchers.IO) {
val success =
retryWithBackoff {
val template =
LiveChessMoveEvent.build(
gameId = moveEvent.gameId,
moveNumber = moveEvent.moveNumber,
san = moveEvent.san,
fen = moveEvent.fen,
opponentPubkey = moveEvent.opponentPubkey,
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
}
if (!success) {
_error.value = "Failed to publish move"
}
}
}
/**
* Resign from a game
*/
fun resign(gameId: String) {
val gameState = _activeGames.value[gameId] ?: return
scope.launch(Dispatchers.IO) {
val endData = gameState.resign()
val success =
retryWithBackoff {
val template =
LiveChessGameEndEvent.build(
gameId = endData.gameId,
result = endData.result,
termination = endData.termination,
winnerPubkey = endData.winnerPubkey,
opponentPubkey = endData.opponentPubkey,
pgn = endData.pgn ?: "",
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
}
if (success) {
// Store in history (skip if already completed)
val alreadyCompleted = _completedGames.value.any { it.gameId == gameId }
if (!alreadyCompleted) {
val completedGame =
CompletedGame(
gameId = gameId,
opponentPubkey = gameState.opponentPubkey,
playerColor = gameState.playerColor,
result = endData.result.notation,
termination = endData.termination.name.lowercase(),
winnerPubkey = endData.winnerPubkey,
completedAt = TimeUtils.now(),
moveCount = gameState.moveHistory.value.size,
)
_completedGames.value = listOf(completedGame) + _completedGames.value
}
_activeGames.value = _activeGames.value - gameId
notifyActiveGamesChanged()
_error.value = null
} else {
_error.value = "Failed to resign"
}
}
}
/**
* Offer draw - sends a draw offer event that opponent can accept or decline
*/
fun offerDraw(gameId: String) {
val gameState = _activeGames.value[gameId] ?: return
scope.launch(Dispatchers.IO) {
val drawOffer = gameState.offerDraw()
val success =
retryWithBackoff {
val template =
LiveChessDrawOfferEvent.build(
gameId = drawOffer.gameId,
opponentPubkey = drawOffer.opponentPubkey,
message = drawOffer.message ?: "",
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
}
if (!success) {
_error.value = "Failed to offer draw"
}
}
}
/**
* Accept opponent's draw offer
*/
fun acceptDraw(gameId: String) {
val gameState = _activeGames.value[gameId] ?: return
scope.launch(Dispatchers.IO) {
val endData = gameState.acceptDraw()
if (endData == null) {
_error.value = "No draw offer to accept"
return@launch
}
val success =
retryWithBackoff {
val template =
LiveChessGameEndEvent.build(
gameId = endData.gameId,
result = endData.result,
termination = endData.termination,
winnerPubkey = endData.winnerPubkey,
opponentPubkey = endData.opponentPubkey,
pgn = endData.pgn ?: "",
)
val signedEvent = account.signer.sign(template)
relayManager.broadcastToAll(signedEvent)
}
if (success) {
// Store in history and remove from active (skip if already completed)
val alreadyCompleted = _completedGames.value.any { it.gameId == gameId }
if (!alreadyCompleted) {
val completedGame =
CompletedGame(
gameId = gameId,
opponentPubkey = gameState.opponentPubkey,
playerColor = gameState.playerColor,
result = GameResult.DRAW.notation,
termination = GameTermination.DRAW_AGREEMENT.name.lowercase(),
winnerPubkey = null,
completedAt = TimeUtils.now(),
moveCount = gameState.moveHistory.value.size,
)
_completedGames.value = listOf(completedGame) + _completedGames.value
}
_activeGames.value = _activeGames.value - gameId
notifyActiveGamesChanged()
_error.value = null
} else {
_error.value = "Failed to accept draw"
}
}
}
/**
* Decline opponent's draw offer
*/
fun declineDraw(gameId: String) {
val gameState = _activeGames.value[gameId] ?: return
gameState.declineDraw()
}
fun selectGame(gameId: String?) {
_selectedGameId.value = gameId
}
fun getGameState(gameId: String): LiveChessGameState? = _activeGames.value[gameId]
fun clearError() {
_error.value = null
}
/**
* Trigger a refresh of chess data from relays.
* Preserves existing game state (including local moves) while refreshing challenges.
*/
fun refresh() {
_isLoading.value = true
// Clear challenges - they'll be rebuilt from relay events
_challenges.value = emptyList()
// Clear event tracking caches to allow re-processing
processedEventIds.clear()
challengesByGameId.clear()
pendingAccepts.clear()
pendingMoves.clear()
gamesBeingCreated.clear()
// DON'T clear _activeGames - preserve existing game state with local moves
// Incoming events will be deduplicated by LiveChessGameState.receivedMoveNumbers
_refreshKey.value++
}
/**
* Called when EOSE is received from relay, indicating initial load complete
*/
fun onLoadComplete() {
_isLoading.value = false
}
/**
* Refresh game states - polling callback.
* Triggers a subscription refresh to catch any missed events.
* The fixed filter (with opponent authors) will fetch opponent moves.
*/
private suspend fun refreshGamesFromRelay(gameIds: Set<String>) {
if (gameIds.isEmpty()) return
// Trigger subscription controller refresh to re-fetch with current filters
// The filter now includes opponent pubkeys as authors, so it will catch their moves
subscriptionController?.forceRefresh()
}
/**
* Clean up expired challenges (older than 24 hours)
*/
private fun cleanupExpiredChallenges() {
val now = TimeUtils.now()
_challenges.value =
_challenges.value.filter { challenge ->
(now - challenge.createdAt) < CHALLENGE_EXPIRY_SECONDS
}
}
private fun updateBadgeCount() {
val incomingChallenges = _challenges.value.count { it.opponentPubkey() == account.pubKeyHex }
val yourTurnGames = _activeGames.value.values.count { it.isPlayerTurn() }
_badgeCount.value = incomingChallenges + yourTurnGames
}
private suspend fun retryWithBackoff(operation: suspend () -> Unit): Boolean {
var attempt = 0
while (attempt < MAX_RETRIES) {
try {
operation()
return true
} catch (e: Exception) {
attempt++
if (attempt < MAX_RETRIES) {
delay(RETRY_DELAY_MS * attempt)
}
}
}
return false
}
private fun generateGameId(): String = ChessGameNameGenerator.generateGameId(TimeUtils.now())
}
/**
* Represents a completed chess game for history display
*/
data class CompletedGame(
val gameId: String,
val opponentPubkey: String,
val playerColor: Color,
val result: String,
val termination: String,
val winnerPubkey: String?,
val completedAt: Long,
val moveCount: Int,
) {
fun didWin(playerPubkey: String): Boolean? =
when {
result == "1/2-1/2" -> null // Draw
winnerPubkey == playerPubkey -> true
winnerPubkey != null -> false
else -> null
}
fun resultText(playerPubkey: String): String =
when (didWin(playerPubkey)) {
true -> "Won"
false -> "Lost"
null -> "Draw"
}
}
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus
import com.vitorpamplona.amethyst.commons.chess.ChessChallenge
import com.vitorpamplona.amethyst.commons.chess.ChessLobbyLogic
import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults
import com.vitorpamplona.amethyst.commons.chess.ChessSyncStatus
import com.vitorpamplona.amethyst.commons.chess.CompletedGame
import com.vitorpamplona.amethyst.commons.chess.PublicGame
import com.vitorpamplona.amethyst.commons.data.UserMetadataCache
@@ -31,7 +32,9 @@ import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip64Chess.Color
import com.vitorpamplona.quartz.nip64Chess.JesterProtocol
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState
import com.vitorpamplona.quartz.nip64Chess.toJesterEvent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.StateFlow
@@ -80,6 +83,9 @@ class DesktopChessViewModelNew(
val broadcastStatus: StateFlow<ChessBroadcastStatus> = logic.state.broadcastStatus
val error: StateFlow<String?> = logic.state.error
val selectedGameId: StateFlow<String?> = logic.state.selectedGameId
val isRefreshing: StateFlow<Boolean> = logic.state.isRefreshing
val syncStatus: StateFlow<ChessSyncStatus> = logic.state.syncStatus
val stateVersion: StateFlow<Long> = logic.state.stateVersion
/** Badge count (incoming challenges + your turn games) - computed property */
val badgeCount: Int get() = logic.state.badgeCount
@@ -98,11 +104,33 @@ class DesktopChessViewModelNew(
fun forceRefresh() = logic.forceRefresh()
/**
* Ensure a game ID is being polled for updates.
* Call this when viewing a game.
*/
fun ensureGamePolling(gameId: String) = logic.ensureGamePolling(gameId)
/**
* Set focused game mode - only poll this specific game.
* Call this when viewing a game to avoid refreshing unrelated games.
*/
fun setFocusedGame(gameId: String) = logic.setFocusedGame(gameId)
/**
* Clear focused game mode - return to lobby mode (poll all games).
* Call this when returning to the lobby view.
*/
fun clearFocusedGame() = logic.clearFocusedGame()
// ============================================
// Incoming event routing (from relay subscriptions)
// ============================================
fun handleIncomingEvent(event: Event) = logic.handleIncomingEvent(event)
fun handleIncomingEvent(event: Event) {
if (event.kind != JesterProtocol.KIND) return
val jesterEvent = event.toJesterEvent() ?: return
logic.handleIncomingEvent(jesterEvent)
}
// ============================================
// Challenge operations
@@ -116,6 +144,8 @@ class DesktopChessViewModelNew(
fun acceptChallenge(challenge: ChessChallenge) = logic.acceptChallenge(challenge)
fun openOwnChallenge(challenge: ChessChallenge) = logic.openOwnChallenge(challenge)
// ============================================
// Game operations
// ============================================
@@ -130,12 +160,6 @@ class DesktopChessViewModelNew(
fun resign(gameId: String) = logic.resign(gameId)
fun offerDraw(gameId: String) = logic.offerDraw(gameId)
fun acceptDraw(gameId: String) = logic.acceptDraw(gameId)
fun declineDraw(gameId: String) = logic.declineDraw(gameId)
fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId)
// ============================================
@@ -156,6 +180,9 @@ class DesktopChessViewModelNew(
fun getGameState(gameId: String): LiveChessGameState? = logic.state.getGameState(gameId)
/** Check if a game was accepted (prevents loading as spectator during race) */
fun wasAccepted(gameId: String): Boolean = logic.state.wasAccepted(gameId)
/** Helper for derived challenge lists */
fun incomingChallenges(): List<ChessChallenge> = logic.state.incomingChallenges()
@@ -0,0 +1,220 @@
/**
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.desktop.subscriptions
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionController
import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionState
import com.vitorpamplona.amethyst.desktop.chess.DesktopChessEventCache
import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent
import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/** Chess event kinds for cache filtering */
private val CHESS_EVENT_KINDS =
setOf(
LiveChessGameChallengeEvent.KIND,
LiveChessGameAcceptEvent.KIND,
LiveChessMoveEvent.KIND,
LiveChessGameEndEvent.KIND,
LiveChessDrawOfferEvent.KIND,
)
/**
* Desktop implementation of [ChessSubscriptionController].
* Uses the shared [ChessFilterBuilder] for consistent filter construction
* with dynamic updates when active games change.
*/
class DesktopChessSubscriptionController(
private val relayManager: RelayConnectionManager,
private val onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
private val onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
) : ChessSubscriptionController {
private val _currentState = MutableStateFlow<ChessSubscriptionState?>(null)
override val currentState: StateFlow<ChessSubscriptionState?> = _currentState.asStateFlow()
// EOSE tracking per relay for efficient re-subscription
private val relayEoseTimes = mutableMapOf<NormalizedRelayUrl, Long>()
// Current subscription ID
private var currentSubId: String? = null
override fun subscribe(state: ChessSubscriptionState) {
// Unsubscribe previous if exists
unsubscribe()
if (state.relays.isEmpty()) return
_currentState.value = state
currentSubId = state.subscriptionId()
// Build filters using shared logic
val relayBasedFilters =
ChessFilterBuilder.buildAllFilters(state) { relay ->
relayEoseTimes[relay]
}
// Group filters by relay
val filtersByRelay = relayBasedFilters.groupByRelay()
// Subscribe on each relay with its specific filters
val allFilters = filtersByRelay.values.flatten().distinct()
relayManager.subscribe(
subId = currentSubId!!,
filters = allFilters,
relays = state.relays,
listener =
object : IRequestListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// Add chess events to local cache for persistence
if (event.kind in CHESS_EVENT_KINDS) {
val isNew = DesktopChessEventCache.add(event)
if (isNew) {
println("[ChessSubscription] Cached new event: kind=${event.kind}, id=${event.id.take(8)}")
}
}
onEvent(event, isLive, relay, forFilters)
}
override fun onEose(
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
// Track EOSE time for this relay for efficient re-subscription
relayEoseTimes[relay] = TimeUtils.now()
onEose(relay, forFilters)
}
},
)
}
override fun unsubscribe() {
currentSubId?.let { relayManager.unsubscribe(it) }
currentSubId = null
_currentState.value = null
}
override fun updateActiveGames(
activeGameIds: Set<String>,
spectatingGameIds: Set<String>,
) {
val current = _currentState.value ?: return
// Create new state with updated game IDs
val newState =
current.copy(
activeGameIds = activeGameIds,
spectatingGameIds = spectatingGameIds,
)
// Only re-subscribe if game IDs actually changed
if (newState.subscriptionId() != current.subscriptionId()) {
subscribe(newState)
}
}
override fun forceRefresh() {
// Clear EOSE cache to re-fetch full history
relayEoseTimes.clear()
_currentState.value?.let { subscribe(it) }
}
}
/**
* Creates a subscription config for chess events with support for active game filtering.
* Uses the shared [ChessFilterBuilder] for consistent filter construction.
*
* @param relays Set of relays to subscribe to
* @param userPubkey User's public key for filtering personal events
* @param activeGameIds Set of game IDs the user is actively playing (for game-specific filters)
* @param opponentPubkeys Set of opponent pubkeys for active games (for move filtering)
* @param onEvent Callback for incoming events
* @param onEose Callback for EOSE (End of Stored Events)
*/
fun createChessSubscriptionWithGames(
relays: Set<NormalizedRelayUrl>,
userPubkey: String,
activeGameIds: Set<String> = emptySet(),
opponentPubkeys: Set<String> = emptySet(),
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? {
if (relays.isEmpty()) return null
val state =
ChessSubscriptionState(
userPubkey = userPubkey,
relays = relays,
activeGameIds = activeGameIds,
opponentPubkeys = opponentPubkeys,
)
val relayBasedFilters =
ChessFilterBuilder.buildAllFilters(state) { null }
val filters =
relayBasedFilters
.groupByRelay()
.values
.flatten()
.distinct()
return SubscriptionConfig(
subId = state.subscriptionId(),
filters = filters,
relays = relays,
onEvent = onEvent,
onEose = onEose,
)
}
/**
* Legacy function for backward compatibility.
* @deprecated Use createChessSubscriptionWithGames instead
*/
@Deprecated(
"Use createChessSubscriptionWithGames for active game support",
ReplaceWith("createChessSubscriptionWithGames(relays, userPubkey, emptySet(), emptySet(), onEvent, onEose)"),
)
fun createChessSubscription(
relays: Set<NormalizedRelayUrl>,
userPubkey: String,
onEvent: (Event, Boolean, NormalizedRelayUrl, List<Filter>?) -> Unit,
onEose: (NormalizedRelayUrl, List<Filter>?) -> Unit = { _, _ -> },
): SubscriptionConfig? = createChessSubscriptionWithGames(relays, userPubkey, emptySet(), emptySet(), onEvent, onEose)
@@ -37,8 +37,10 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.PersonAdd
import androidx.compose.material.icons.filled.PersonRemove
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
@@ -46,7 +48,9 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
@@ -60,6 +64,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction
import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastBanner
import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus
import com.vitorpamplona.amethyst.commons.state.EventCollectionState
import com.vitorpamplona.amethyst.commons.state.FollowState
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
@@ -76,6 +82,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createUserPostsSubscript
import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull
import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import kotlinx.coroutines.Dispatchers
@@ -111,6 +118,13 @@ fun UserProfileScreen(
var followersCount by remember { mutableStateOf(0) }
var followingCount by remember { mutableStateOf(0) }
// Profile editing state (only for own profile)
val isOwnProfile = account != null && pubKeyHex == account.pubKeyHex
var showEditDialog by remember { mutableStateOf(false) }
var editingDisplayName by remember { mutableStateOf("") }
var broadcastStatus by remember { mutableStateOf<ProfileBroadcastStatus>(ProfileBroadcastStatus.Idle) }
var latestMetadataEvent by remember { mutableStateOf<MetadataEvent?>(null) }
val scope = rememberCoroutineScope()
// User's posts
@@ -192,6 +206,14 @@ fun UserProfileScreen(
displayName = extractJsonField(content, "display_name") ?: extractJsonField(content, "name")
about = extractJsonField(content, "about")
picture = extractJsonField(content, "picture")
// Store MetadataEvent for editing (only for own profile)
if (isOwnProfile && event is MetadataEvent) {
val current = latestMetadataEvent
if (current == null || event.createdAt > current.createdAt) {
latestMetadataEvent = event
}
}
} catch (e: Exception) {
// Ignore parse errors
}
@@ -281,6 +303,19 @@ fun UserProfileScreen(
}
Column(modifier = Modifier.fillMaxSize()) {
// Broadcast banner for profile updates
ProfileBroadcastBanner(
status = broadcastStatus,
onTap = {
// Clear banner on tap (could add retry logic for failed)
if (broadcastStatus is ProfileBroadcastStatus.Success ||
broadcastStatus is ProfileBroadcastStatus.Failed
) {
broadcastStatus = ProfileBroadcastStatus.Idle
}
},
)
// Header with back button
Row(
modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp),
@@ -298,6 +333,25 @@ fun UserProfileScreen(
)
}
// Edit button for own profile
if (isOwnProfile && account?.isReadOnly == false) {
OutlinedButton(
onClick = {
editingDisplayName = displayName ?: ""
showEditDialog = true
},
) {
Icon(
Icons.Default.Edit,
contentDescription = "Edit profile",
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.width(8.dp))
Text("Edit Profile")
}
}
// Follow/Unfollow button for other profiles
if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) {
Column(horizontalAlignment = Alignment.End) {
Button(
@@ -586,6 +640,52 @@ fun UserProfileScreen(
}
}
}
// Edit Profile Dialog
if (showEditDialog && account != null) {
AlertDialog(
onDismissRequest = { showEditDialog = false },
title = { Text("Edit Profile") },
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = editingDisplayName,
onValueChange = { editingDisplayName = it },
label = { Text("Display Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
}
},
confirmButton = {
Button(
onClick = {
showEditDialog = false
scope.launch {
updateProfileDisplayName(
newDisplayName = editingDisplayName,
account = account,
relayManager = relayManager,
latestMetadataEvent = latestMetadataEvent,
currentDisplayName = displayName,
currentAbout = about,
currentPicture = picture,
onStatusUpdate = { broadcastStatus = it },
onSuccess = { displayName = editingDisplayName },
)
}
},
) {
Text("Save")
}
},
dismissButton = {
TextButton(onClick = { showEditDialog = false }) {
Text("Cancel")
}
},
)
}
}
/**
@@ -648,3 +748,62 @@ private suspend fun unfollowUser(
throw IllegalStateException("Cannot unfollow: No contact list available")
}
}
/**
* Updates the user's profile display name by creating and broadcasting a new MetadataEvent.
*/
private suspend fun updateProfileDisplayName(
newDisplayName: String,
account: AccountState.LoggedIn,
relayManager: DesktopRelayConnectionManager,
latestMetadataEvent: MetadataEvent?,
currentDisplayName: String?,
currentAbout: String?,
currentPicture: String?,
onStatusUpdate: (ProfileBroadcastStatus) -> Unit,
onSuccess: () -> Unit,
) = withContext(Dispatchers.IO) {
val connectedRelays = relayManager.connectedRelays.value
if (connectedRelays.isEmpty()) {
onStatusUpdate(ProfileBroadcastStatus.Failed("display name", "No connected relays"))
return@withContext
}
val totalRelays = connectedRelays.size
onStatusUpdate(ProfileBroadcastStatus.Broadcasting("display name", 0, totalRelays))
try {
// Create the new MetadataEvent
val template =
if (latestMetadataEvent != null) {
MetadataEvent.updateFromPast(
latest = latestMetadataEvent,
displayName = newDisplayName,
)
} else {
MetadataEvent.createNew(
name = currentDisplayName,
displayName = newDisplayName,
picture = currentPicture,
about = currentAbout,
)
}
// Sign the event
val signedEvent = account.signer.sign(template)
// Broadcast to all relays
relayManager.broadcastToAll(signedEvent)
// Update progress (simplified - just show success after broadcast)
// In a full implementation, you'd track OK responses from each relay
onStatusUpdate(ProfileBroadcastStatus.Success("display name", totalRelays))
onSuccess()
// Auto-hide banner after delay
delay(3000)
onStatusUpdate(ProfileBroadcastStatus.Idle)
} catch (e: Exception) {
onStatusUpdate(ProfileBroadcastStatus.Failed("display name", e.message ?: "Unknown error"))
}
}