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:
+467
-585
File diff suppressed because it is too large
Load Diff
+271
-306
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.desktop.chess
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip64Chess.JesterEvent
|
||||
import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents
|
||||
import com.vitorpamplona.quartz.nip64Chess.JesterProtocol
|
||||
import com.vitorpamplona.quartz.nip64Chess.toJesterEvent
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Local event cache for Desktop chess events using Jester protocol.
|
||||
*
|
||||
* Mimics Android's LocalCache.addressables behavior:
|
||||
* - Stores events by ID (deduplication)
|
||||
* - Queryable by kind with custom filters
|
||||
* - Thread-safe via ConcurrentHashMap
|
||||
* - Populated from subscription callbacks
|
||||
* - Persists across fetcher queries (unlike one-shot relay queries)
|
||||
*
|
||||
* Jester Protocol Notes:
|
||||
* - All chess events use kind 30
|
||||
* - Start events: content.kind=0, e-tag references START_POSITION_HASH
|
||||
* - Move events: content.kind=1, e-tags=[startEventId, headEventId]
|
||||
*/
|
||||
object DesktopChessEventCache {
|
||||
// Store all Jester events by ID for deduplication
|
||||
@PublishedApi
|
||||
internal val events = ConcurrentHashMap<String, JesterEvent>()
|
||||
|
||||
// Index start events by event ID (for quick game lookup)
|
||||
private val startEvents = ConcurrentHashMap<String, JesterEvent>()
|
||||
|
||||
// Index move events by startEventId (game ID) for quick game reconstruction
|
||||
private val movesByGame = ConcurrentHashMap<String, MutableSet<String>>()
|
||||
|
||||
/**
|
||||
* Add an event to the cache.
|
||||
* Returns true if the event was new, false if it was a duplicate.
|
||||
*/
|
||||
fun add(event: Event): Boolean {
|
||||
if (event.kind != JesterProtocol.KIND) return false
|
||||
|
||||
val jesterEvent = event.toJesterEvent() ?: return false
|
||||
return addJesterEvent(jesterEvent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a JesterEvent directly.
|
||||
*/
|
||||
fun addJesterEvent(event: JesterEvent): Boolean {
|
||||
val isNew = events.putIfAbsent(event.id, event) == null
|
||||
if (isNew) {
|
||||
if (event.isStartEvent()) {
|
||||
startEvents[event.id] = event
|
||||
} else if (event.isMoveEvent()) {
|
||||
val startId = event.startEventId()
|
||||
if (startId != null) {
|
||||
movesByGame.computeIfAbsent(startId) { ConcurrentHashMap.newKeySet() }.add(event.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return isNew
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an event by ID.
|
||||
*/
|
||||
fun get(id: String): JesterEvent? = events[id]
|
||||
|
||||
/**
|
||||
* Get all start events (challenges).
|
||||
*/
|
||||
fun getStartEvents(filter: (JesterEvent) -> Boolean = { true }): List<JesterEvent> = startEvents.values.filter { it.isStartEvent() && filter(it) }
|
||||
|
||||
/**
|
||||
* Get all move events for a specific game.
|
||||
*/
|
||||
fun getMovesForGame(startEventId: String): List<JesterEvent> {
|
||||
val moveIds = movesByGame[startEventId] ?: return emptyList()
|
||||
return moveIds.mapNotNull { events[it] }
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all events for a specific game (start + moves).
|
||||
*/
|
||||
fun getGameEvents(startEventId: String): JesterGameEvents {
|
||||
val startEvent = startEvents[startEventId]
|
||||
val moves = getMovesForGame(startEventId)
|
||||
return JesterGameEvents(startEvent, moves)
|
||||
}
|
||||
|
||||
/**
|
||||
* Query events with custom filter.
|
||||
*/
|
||||
fun query(filter: (JesterEvent) -> Boolean): List<JesterEvent> = events.values.filter(filter)
|
||||
|
||||
/**
|
||||
* Query start events with custom filter.
|
||||
*/
|
||||
fun queryStartEvents(filter: (JesterEvent) -> Boolean = { true }): List<JesterEvent> = startEvents.values.filter { it.isStartEvent() && filter(it) }
|
||||
|
||||
/**
|
||||
* Query move events with custom filter.
|
||||
*/
|
||||
fun queryMoveEvents(filter: (JesterEvent) -> Boolean = { true }): List<JesterEvent> = events.values.filter { it.isMoveEvent() && filter(it) }
|
||||
|
||||
/**
|
||||
* Get all game IDs (startEventIds) in the cache.
|
||||
*/
|
||||
fun getAllGameIds(): Set<String> = startEvents.keys.toSet()
|
||||
|
||||
/**
|
||||
* Get count of events.
|
||||
*/
|
||||
fun size(): Int = events.size
|
||||
|
||||
/**
|
||||
* Get count of start events.
|
||||
*/
|
||||
fun startEventCount(): Int = startEvents.size
|
||||
|
||||
/**
|
||||
* Clear all cached events.
|
||||
*/
|
||||
fun clear() {
|
||||
events.clear()
|
||||
startEvents.clear()
|
||||
movesByGame.clear()
|
||||
}
|
||||
}
|
||||
-959
@@ -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"
|
||||
}
|
||||
}
|
||||
+34
-7
@@ -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()
|
||||
|
||||
|
||||
+220
@@ -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)
|
||||
+159
@@ -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"))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user