diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index 559934407..981a03b24 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -142,7 +142,24 @@ class AppModules( // Custom fetcher that considers tor settings and avoids forwarding. val nip05Fetcher = OkHttpNip05Fetcher(roleBasedHttpClientBuilder::okHttpClientForNip05) - val nip05Client = Nip05Client(nip05Fetcher) + val namecoinElectrumxClient = + com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient( + socketFactory = { roleBasedHttpClientBuilder.socketFactoryForNip05() }, + ) + val namecoinResolver = + com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver( + electrumxClient = namecoinElectrumxClient, + serverListProvider = { + if (roleBasedHttpClientBuilder.shouldUseTorForNIP05("https://electrumx.example.com")) { + com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient.TOR_SERVERS + } else { + com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient.DEFAULT_SERVERS + } + }, + ) + val namecoinNameService = + com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService.init(namecoinElectrumxClient) + val nip05Client = Nip05Client(nip05Fetcher, namecoinResolver) // Application-wide block height request cache val otsBlockHeightCache by lazy { OtsBlockHeightCache() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index 5da610d01..ea26f2b6f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -169,13 +169,13 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.JesterEvent -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.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.accept.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip64Chess/ChessAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip64Chess/ChessAction.kt index 143f6ce8b..82a966095 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip64Chess/ChessAction.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip64Chess/ChessAction.kt @@ -24,10 +24,10 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip64Chess.Color import com.vitorpamplona.quartz.nip64Chess.GameResult import com.vitorpamplona.quartz.nip64Chess.GameTermination -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.accept.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent /** * Action class for creating and signing live chess events diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/ProxiedSocketFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/ProxiedSocketFactory.kt new file mode 100644 index 000000000..53a52361f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/ProxiedSocketFactory.kt @@ -0,0 +1,70 @@ +/* + * 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.model.privacyOptions + +import java.net.InetAddress +import java.net.Proxy +import java.net.Socket +import javax.net.SocketFactory + +/** + * A [SocketFactory] that creates sockets routed through a SOCKS proxy. + * + * Used to ensure raw TCP connections (e.g. ElectrumX for Namecoin) + * respect the user's Tor/proxy settings, preventing IP leaks. + */ +class ProxiedSocketFactory( + private val proxy: Proxy, +) : SocketFactory() { + override fun createSocket(): Socket = Socket(proxy) + + override fun createSocket( + host: String, + port: Int, + ): Socket = Socket(proxy).apply { connect(java.net.InetSocketAddress(host, port)) } + + override fun createSocket( + host: String, + port: Int, + localHost: InetAddress, + localPort: Int, + ): Socket = + Socket(proxy).apply { + bind(java.net.InetSocketAddress(localHost, localPort)) + connect(java.net.InetSocketAddress(host, port)) + } + + override fun createSocket( + host: InetAddress, + port: Int, + ): Socket = Socket(proxy).apply { connect(java.net.InetSocketAddress(host, port)) } + + override fun createSocket( + address: InetAddress, + port: Int, + localAddress: InetAddress, + localPort: Int, + ): Socket = + Socket(proxy).apply { + bind(java.net.InetSocketAddress(localAddress, localPort)) + connect(java.net.InetSocketAddress(address, port)) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt index aacb3e262..daea244bb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt @@ -25,6 +25,9 @@ import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.amethyst.ui.tor.TorType import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import okhttp3.OkHttpClient +import java.net.InetSocketAddress +import java.net.Proxy +import javax.net.SocketFactory class RoleBasedHttpClientBuilder( val okHttpClient: DualHttpClientManager, @@ -141,4 +144,24 @@ class RoleBasedHttpClientBuilder( override fun okHttpClientForPreview(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForPreviewUrl(url)) override fun okHttpClientForPushRegistration(url: String): OkHttpClient = okHttpClient.getHttpClient(shouldUseTorForTrustedRelays()) + + /** + * Returns a [SocketFactory] that routes through the user's Tor proxy + * when NIP-05 verification traffic should use Tor. + * + * Used by [ElectrumxClient] so that Namecoin lookups respect the + * same proxy/Tor settings as HTTP-based NIP-05 verification, + * preventing IP leaks through direct socket connections. + */ + fun socketFactoryForNip05(): SocketFactory { + // ElectrumX servers are always external, so we use a dummy + // non-localhost, non-onion URL to query the Tor policy. + val useTor = shouldUseTorForNIP05("https://electrumx.example.com") + if (!useTor) return SocketFactory.getDefault() + + val proxy = okHttpClient.getCurrentProxy() ?: return SocketFactory.getDefault() + val proxyAddr = proxy.address() as? InetSocketAddress ?: return SocketFactory.getDefault() + + return ProxiedSocketFactory(Proxy(Proxy.Type.SOCKS, proxyAddr)) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavFilter.kt index de820177d..597005256 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavFilter.kt @@ -26,9 +26,9 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.combine diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt new file mode 100644 index 000000000..6f58bfcaa --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/namecoin/NamecoinNameService.kt @@ -0,0 +1,165 @@ +/* + * 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.service.namecoin + +import com.vitorpamplona.quartz.nip05.namecoin.ElectrumxClient +import com.vitorpamplona.quartz.nip05.namecoin.ElectrumxServer +import com.vitorpamplona.quartz.nip05.namecoin.NamecoinLookupCache +import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver +import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNostrResult +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +/** + * Application-level singleton for Namecoin name resolution. + * + * Thread-safe, lifecycle-aware, and designed for integration + * into Amethyst's existing `ServiceManager` infrastructure. + */ +class NamecoinNameService( + electrumxClient: ElectrumxClient = ElectrumxClient(), +) { + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + private val resolver = NamecoinNameResolver(electrumxClient) + private val cache = NamecoinLookupCache() + + // Custom server list (user-configurable) + private var customServers: List = emptyList() + + companion object { + @Volatile + private var instance: NamecoinNameService? = null + + fun getInstance(): NamecoinNameService = + instance ?: throw IllegalStateException( + "NamecoinNameService not initialized. Call init() first.", + ) + + fun init(electrumxClient: ElectrumxClient): NamecoinNameService = + synchronized(this) { + instance?.let { return it } + NamecoinNameService(electrumxClient).also { instance = it } + } + } + + // ── Public API ───────────────────────────────────────────────────── + + /** + * Resolve a Namecoin identifier to a Nostr pubkey. + * + * Returns cached results when available. This is the primary method + * that the search bar and NIP-05 verifier should call. + * + * @param identifier e.g. "alice@example.bit", "id/bob", "example.bit" + * @return [NamecoinNostrResult] or null + */ + suspend fun resolve(identifier: String): NamecoinNostrResult? { + // Check cache first + val cached = cache.get(identifier) + if (cached != null) return cached.result + + // Perform lookup + val result = resolver.resolve(identifier) + cache.put(identifier, result) + return result + } + + /** + * Verify that a Namecoin name maps to the expected pubkey. + * + * This is the Namecoin equivalent of NIP-05 verification: + * given a pubkey from a kind-0 event and a `nip05` value + * ending in `.bit`, check that the Namecoin blockchain + * confirms the mapping. + * + * @param nip05Address The nip05 field value, e.g. "alice@example.bit" + * @param expectedPubkeyHex The pubkey from the kind-0 event + * @return true if the Namecoin name resolves to the expected pubkey + */ + suspend fun verifyNip05( + nip05Address: String, + expectedPubkeyHex: String, + ): Boolean { + if (!NamecoinNameResolver.isNamecoinIdentifier(nip05Address)) return false + val result = resolve(nip05Address) ?: return false + return result.pubkey.equals(expectedPubkeyHex, ignoreCase = true) + } + + /** + * Perform a lookup and emit results via a StateFlow. + * + * Useful for composable UIs that observe resolution state. + */ + fun resolveLive(identifier: String): StateFlow { + val state = MutableStateFlow(NamecoinResolveState.Loading) + scope.launch { + try { + val result = resolve(identifier) + state.value = + if (result != null) { + NamecoinResolveState.Resolved(result) + } else { + NamecoinResolveState.NotFound + } + } catch (e: Exception) { + state.value = NamecoinResolveState.Error(e.message ?: "Unknown error") + } + } + return state + } + + /** + * Configure custom ElectrumX servers. + * + * Users who run their own ElectrumX instance can add it here + * for better privacy and reliability. + */ + fun setCustomServers(servers: List) { + customServers = servers + } + + /** + * Clear the resolution cache. + */ + suspend fun clearCache() = cache.clear() +} + +/** + * Observable state for a Namecoin resolution in progress. + */ +sealed class NamecoinResolveState { + data object Loading : NamecoinResolveState() + + data class Resolved( + val result: NamecoinNostrResult, + ) : NamecoinResolveState() + + data object NotFound : NamecoinResolveState() + + data class Error( + val message: String, + ) : NamecoinResolveState() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 9a95f5a55..4fd832695 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -217,9 +217,9 @@ import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt index ab22901ef..4c1a9da8f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt @@ -52,9 +52,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelFactory import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelNew import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt index 18c639ec4..5a532819b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt @@ -34,10 +34,10 @@ import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.Color -import com.vitorpamplona.quartz.nip64Chess.JesterEvent -import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents -import com.vitorpamplona.quartz.nip64Chess.JesterProtocol -import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent /** * Android implementation of ChessEventPublisher using Jester protocol. diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt index 7c7fef8b7..2e30e791a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -33,9 +33,9 @@ import com.vitorpamplona.amethyst.commons.chess.PublicGame import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip64Chess.Color -import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState -import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent import kotlinx.coroutines.flow.StateFlow /** diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt index 26749dd38..883704540 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent /** * Feed filter for NIP-64 Chess Game events (Kind 64) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index fcf5941dc..864c5b194 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -39,9 +39,9 @@ import com.vitorpamplona.quartz.nip18Reposts.RepostEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip64Chess/FilterHomePostsByChess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip64Chess/FilterHomePostsByChess.kt index 3999badfc..dbde0f4c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip64Chess/FilterHomePostsByChess.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip64Chess/FilterHomePostsByChess.kt @@ -24,9 +24,9 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessTopNavPerRelayFil import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent val ChessPostsKinds = listOf( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt index fb9ca719e..7d80db5b1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt @@ -39,9 +39,9 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent -import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index 375f0dd12..19872675d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -221,13 +221,13 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.JesterEvent -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.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.accept.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt index 8004ccdde..e712185e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/search/SearchBarViewModel.kt @@ -32,8 +32,11 @@ import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.namecoin.NamecoinNameService import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchQueryState import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver import com.vitorpamplona.quartz.nip10Notes.content.findHashtags import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.FlowPreview @@ -43,7 +46,9 @@ import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update @@ -69,12 +74,41 @@ class SearchBarViewModel( val searchDataSourceState = SearchQueryState(MutableStateFlow(searchValue), account) + /** + * Resolves Namecoin identifiers (.bit / d/ / id/) via ElectrumX and + * returns the matching [User] from LocalCache, or null. + */ + private val namecoinResolvedUser = + searchValueFlow + .debounce(400) + .distinctUntilChanged() + .filter { NamecoinNameResolver.isNamecoinIdentifier(it) } + .map { term -> + try { + val result = NamecoinNameService.getInstance().resolve(term) + if (result != null) { + LocalCache.getOrCreateUser(result.pubkey) + } else { + null + } + } catch (_: Exception) { + null + } + }.flowOn(Dispatchers.IO) + .stateIn(viewModelScope, WhileSubscribed(5000), null) + val searchResultsUsers = combine( searchValueFlow.debounce(100), invalidations.debounce(100), - ) { term, version -> - LocalCache.findUsersStartingWith(term, account) + namecoinResolvedUser, + ) { term, version, namecoinUser -> + val localResults = LocalCache.findUsersStartingWith(term, account) + if (namecoinUser != null && localResults.none { it.pubkeyHex == namecoinUser.pubkeyHex }) { + listOf(namecoinUser) + localResults + } else { + localResults + } }.flowOn(Dispatchers.IO) .stateIn(viewModelScope, WhileSubscribed(5000), emptyList()) diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 912d90571..4850ce01d 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -1398,11 +1398,15 @@ Prostor schůzky Profil Seznam ztlumených + NNS + NIP + Nostr Connect Stav DVM Požadavek na obsah DVM Odpověď obsahu DVM Požadavek uživatele DVM Odpověď uživatele DVM + OTS Platba na Seznamy lidí Obrázky @@ -1434,7 +1438,9 @@ Důvěryhodní poskytovatelé Video (odpověď) Krátká videa (odpověď) + Video Krátká videa Hlasová zpráva Hlasová odpověď + Wiki diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index ec219b17a..d1fa799fd 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -1321,6 +1321,7 @@ anz der Bedingungen ist erforderlich %1$s entfernen Outbox-Relays + Apps App-Empfehlungen Benutzereinstellungen Audio-Header @@ -1373,6 +1374,7 @@ anz der Bedingungen ist erforderlich Blob-Header Medizinische Daten Follow-Pakete + Reposts (16) Geohash-Follows Geschenkverpackungen Git-Issue @@ -1381,6 +1383,7 @@ anz der Bedingungen ist erforderlich Git-Antwort Zap-Ziele Gefolgte Hashtags + Highlights HTTP-Authentifizierung Index-Relay-Liste Abenteuer-Prolog @@ -1389,26 +1392,35 @@ anz der Bedingungen ist erforderlich Benannte Lesezeichen Live-Chats Live-Streams + Zap NWC-Anfrage NWC-Antwort + Privaten Zaps Zap-Anfrage + Blogs Besprechungsraum Raumpräsenz Besprechungsbereich Profil Stummschaltliste + NNS + NIP + Nostr Connect DVM-Status DVM-Inhaltsanfrage DVM-Inhaltsantwort DVM-Benutzeranfrage DVM-Benutzerantwort + OTS Zahlen an Personenlisten Bilder + Angepinnt Zap-Umfrage Umfrage Umfrageantwort NIP-04-DMs + Private Relais Proxy-Relays Öffentliche Nachricht Reaktionen @@ -1418,17 +1430,22 @@ anz der Bedingungen ist erforderlich Relay-Monitor-Ankündigung Relay-Set Meldungen + Reposts Benutzer löschen Siegel Such-Relays Benutzerstatus Notizen Bearbeitungen + Torrents Torrent-Kommentare Vertrauenswürdige Relays Vertrauenswürdige Anbieter Video (Antwort) Shorts (Antwort) + Video + Shorts Sprachnachricht Sprachantwort + Wiki diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 0853836c7..f58008471 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -796,6 +796,7 @@ Som ligado. Clique para silenciar Voltar %d segundos Avançar %d segundos + Picture-in-Picture Pesquise registros locais e remotos O endereço Nostr foi verificado Endereço Nostr falhou na verificação @@ -1315,6 +1316,7 @@ Remover %1$s Relays de Saída + Aplicativos Recomendações de Apps Configurações do Usuário Cabeçalho de Áudio @@ -1367,6 +1369,7 @@ Cabeçalhos Blob Dados Médicos Pacotes de Seguir + Repostagens (16) Seguindo Geohash Embrulhos de presente Issue do Git @@ -1384,20 +1387,26 @@ Favoritos nomeados Chats ao vivo Transmissões ao vivo + Zaps Solicitação NWC Resposta NWC Zaps privados Solicitação de Zap + Blogs Sala de reunião Presença na sala Espaço de reunião Perfil Lista de silenciados + NNS + CNPJ + Nostr Connect Status do DVM Solicitação de conteúdo DVM Resposta de conteúdo DVM Solicitação de usuário DVM Resposta de usuário DVM + OTS Pagar para Listas de pessoas Imagens @@ -1416,18 +1425,22 @@ Anúncio de Monitor de Relay Conjunto de relays Denúncias + Repostagens Excluir usuário Selos Relays de busca Status do usuário Notas Edições + Torrents Comentários de torrent Relays confiáveis Provedores confiáveis Vídeo (resposta) Shorts (resposta) Vídeo + Shorts Mensagem de voz Resposta de voz + Wiki diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index 69cc53cbd..ec57be293 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -1368,6 +1368,7 @@ Blob-huvuden Medicinska data Följ-paket + Återinlägg (16) Geohash-följer Presentinslagningar Git-ärende @@ -1385,6 +1386,7 @@ Namngivna bokmärken Livechattar Livesändningar + Zaps NWC-förfrågan NWC-svar Privata zaps @@ -1395,11 +1397,15 @@ Mötesutrymme Profil Tystnadslista + NNS + NIP + Nostr Connect DVM-status DVM-innehållsförfrågan DVM-innehållssvar DVM-användarförfrågan DVM-användarsvar + OTS Betala till Personlistor Bilder @@ -1418,6 +1424,7 @@ Relay övervakningsmeddelande Reläuppsättning Rapporter + Återinlägg Radera användare Sigill Sökreläer @@ -1430,6 +1437,9 @@ Betrodda leverantörer Video (svar) Shorts (svar) + Video + Shorts Röstmeddelande Röstsvar + Wiki diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt index c15195dbf..8a4575f3f 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt @@ -21,10 +21,10 @@ package com.vitorpamplona.amethyst.commons.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 com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt index f0557e2ab..0b4212e3e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.chess import com.vitorpamplona.quartz.nip64Chess.ChessEngine import com.vitorpamplona.quartz.nip64Chess.ChessStateReconstructor import com.vitorpamplona.quartz.nip64Chess.Color -import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState import com.vitorpamplona.quartz.nip64Chess.ReconstructedGameState import com.vitorpamplona.quartz.nip64Chess.ReconstructionResult diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt index 56ae30b3d..3a4043f93 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -23,8 +23,8 @@ package com.vitorpamplona.amethyst.commons.chess import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.Color -import com.vitorpamplona.quartz.nip64Chess.JesterEvent -import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents import com.vitorpamplona.quartz.nip64Chess.PieceType import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.CoroutineScope diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt index 90ff1070e..4057a696e 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.amethyst.commons.chess.subscription import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol import com.vitorpamplona.quartz.utils.TimeUtils /** diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt index ac6e7d896..59c37d4df 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt @@ -27,7 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol import kotlinx.coroutines.delay /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt index a78c3b68e..86b7e9d13 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/DesktopPreferences.kt @@ -35,6 +35,8 @@ object DesktopPreferences { private const val KEY_FEED_MODE = "feed_mode" private const val KEY_LAST_SCREEN = "last_screen" + private const val KEY_DECK_COLUMNS = "deck_columns" + private const val KEY_LAYOUT_MODE = "layout_mode" var feedMode: FeedMode get() { @@ -54,4 +56,16 @@ object DesktopPreferences { set(value) { prefs.put(KEY_LAST_SCREEN, value) } + + var deckColumns: String + get() = prefs.get(KEY_DECK_COLUMNS, "") + set(value) { + prefs.put(KEY_DECK_COLUMNS, value) + } + + var layoutMode: String + get() = prefs.get(KEY_LAYOUT_MODE, "SINGLE_PANE") + set(value) { + prefs.put(KEY_LAYOUT_MODE, value) + } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 5614e4e4b..fa3abb3e2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -25,32 +25,20 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Article -import androidx.compose.material.icons.filled.Email -import androidx.compose.material.icons.filled.Extension -import androidx.compose.material.icons.filled.Home -import androidx.compose.material.icons.filled.Notifications -import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material.icons.filled.Search -import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.NavigationRail -import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.SnackbarHost @@ -82,23 +70,20 @@ import androidx.compose.ui.window.rememberWindowState import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache -import com.vitorpamplona.amethyst.desktop.chess.ChessScreen import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator -import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen import com.vitorpamplona.amethyst.desktop.ui.ComposeNoteDialog -import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.LoginScreen -import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen -import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen -import com.vitorpamplona.amethyst.desktop.ui.SearchScreen -import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen -import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback -import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker +import com.vitorpamplona.amethyst.desktop.ui.deck.AddColumnDialog +import com.vitorpamplona.amethyst.desktop.ui.deck.DeckColumnType +import com.vitorpamplona.amethyst.desktop.ui.deck.DeckLayout +import com.vitorpamplona.amethyst.desktop.ui.deck.DeckSidebar +import com.vitorpamplona.amethyst.desktop.ui.deck.DeckState +import com.vitorpamplona.amethyst.desktop.ui.deck.SinglePaneLayout import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect @@ -110,8 +95,13 @@ import kotlinx.coroutines.launch private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") +enum class LayoutMode { + SINGLE_PANE, + DECK, +} + /** - * Desktop navigation state - extends AppScreen with dynamic destinations. + * Desktop navigation state — used for in-column navigation (drill-down). */ sealed class DesktopScreen { object Feed : DesktopScreen() @@ -151,7 +141,18 @@ fun main() = ) var showComposeDialog by remember { mutableStateOf(false) } var replyToNote by remember { mutableStateOf(null) } - var currentScreen by remember { mutableStateOf(DesktopScreen.Feed) } + val deckScope = rememberCoroutineScope() + val deckState = remember { DeckState(deckScope).also { it.load() } } + var showAddColumnDialog by remember { mutableStateOf(false) } + var layoutMode by remember { + mutableStateOf( + try { + LayoutMode.valueOf(DesktopPreferences.layoutMode) + } catch (e: Exception) { + LayoutMode.SINGLE_PANE + }, + ) + } Window( onCloseRequest = ::exitApplication, @@ -179,7 +180,13 @@ fun main() = } else { KeyShortcut(Key.Comma, ctrl = true) }, - onClick = { currentScreen = DesktopScreen.Settings }, + onClick = { + if (deckState.hasColumnOfType(DeckColumnType.Settings)) { + deckState.focusExistingColumn(DeckColumnType.Settings) + } else { + deckState.addColumn(DeckColumnType.Settings) + } + }, ) Separator() Item( @@ -216,9 +223,121 @@ fun main() = ) } Menu("View") { - Item("Feed", onClick = { }) - Item("Messages", onClick = { }) - Item("Notifications", onClick = { }) + Item( + if (layoutMode == LayoutMode.DECK) "\u2713 Deck Layout" else "Deck Layout", + shortcut = + if (isMacOS) { + KeyShortcut(Key.D, meta = true, shift = true) + } else { + KeyShortcut(Key.D, ctrl = true, shift = true) + }, + onClick = { + layoutMode = + if (layoutMode == LayoutMode.DECK) LayoutMode.SINGLE_PANE else LayoutMode.DECK + DesktopPreferences.layoutMode = layoutMode.name + }, + ) + if (layoutMode == LayoutMode.DECK) { + Separator() + Item( + "Add Column", + shortcut = + if (isMacOS) { + KeyShortcut(Key.T, meta = true) + } else { + KeyShortcut(Key.T, ctrl = true) + }, + onClick = { showAddColumnDialog = true }, + ) + Item( + "Close Column", + shortcut = + if (isMacOS) { + KeyShortcut(Key.W, meta = true) + } else { + KeyShortcut(Key.W, ctrl = true) + }, + onClick = { + val cols = deckState.columns.value + val idx = deckState.focusedColumnIndex.value + if (cols.size > 1 && idx in cols.indices) { + deckState.removeColumn(cols[idx].id) + } + }, + ) + Item( + "Move Column Left", + shortcut = + if (isMacOS) { + KeyShortcut(Key.DirectionLeft, meta = true, shift = true) + } else { + KeyShortcut(Key.DirectionLeft, ctrl = true, shift = true) + }, + onClick = { + val idx = deckState.focusedColumnIndex.value + if (idx > 0) { + deckState.moveColumn(idx, idx - 1) + deckState.focusColumn(idx - 1) + } + }, + ) + Item( + "Move Column Right", + shortcut = + if (isMacOS) { + KeyShortcut(Key.DirectionRight, meta = true, shift = true) + } else { + KeyShortcut(Key.DirectionRight, ctrl = true, shift = true) + }, + onClick = { + val idx = deckState.focusedColumnIndex.value + val size = deckState.columns.value.size + if (idx < size - 1) { + deckState.moveColumn(idx, idx + 1) + deckState.focusColumn(idx + 1) + } + }, + ) + Separator() + // Focus column by index (Cmd/Ctrl+1..9) + val columnKeys = + listOf( + Key.One, + Key.Two, + Key.Three, + Key.Four, + Key.Five, + Key.Six, + Key.Seven, + Key.Eight, + Key.Nine, + ) + val columnCount = deckState.columns.value.size + columnKeys.take(columnCount).forEachIndexed { i, key -> + Item( + "Column ${i + 1}", + shortcut = + if (isMacOS) { + KeyShortcut(key, meta = true) + } else { + KeyShortcut(key, ctrl = true) + }, + onClick = { deckState.focusColumn(i) }, + ) + } + Separator() + Menu("Add Column...") { + Item("Home Feed", onClick = { deckState.addColumn(DeckColumnType.HomeFeed) }) + Item("Notifications", onClick = { deckState.addColumn(DeckColumnType.Notifications) }) + Item("Messages", onClick = { deckState.addColumn(DeckColumnType.Messages) }) + Item("Search", onClick = { deckState.addColumn(DeckColumnType.Search) }) + Item("Reads", onClick = { deckState.addColumn(DeckColumnType.Reads) }) + Item("Bookmarks", onClick = { deckState.addColumn(DeckColumnType.Bookmarks) }) + Item("Global Feed", onClick = { deckState.addColumn(DeckColumnType.GlobalFeed) }) + Item("Profile", onClick = { deckState.addColumn(DeckColumnType.MyProfile) }) + Item("Chess", onClick = { deckState.addColumn(DeckColumnType.Chess) }) + } + } } Menu("Help") { Item("About Amethyst", onClick = { }) @@ -227,9 +346,10 @@ fun main() = } App( - currentScreen = currentScreen, - onScreenChange = { currentScreen = it }, + layoutMode = layoutMode, + deckState = deckState, showComposeDialog = showComposeDialog, + showAddColumnDialog = showAddColumnDialog, onShowComposeDialog = { showComposeDialog = true }, onShowReplyDialog = { event -> replyToNote = event @@ -239,6 +359,8 @@ fun main() = showComposeDialog = false replyToNote = null }, + onDismissAddColumnDialog = { showAddColumnDialog = false }, + onShowAddColumnDialog = { showAddColumnDialog = true }, replyToNote = replyToNote, ) } @@ -246,12 +368,15 @@ fun main() = @Composable fun App( - currentScreen: DesktopScreen, - onScreenChange: (DesktopScreen) -> Unit, + layoutMode: LayoutMode, + deckState: DeckState, showComposeDialog: Boolean, + showAddColumnDialog: Boolean, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, onDismissComposeDialog: () -> Unit, + onDismissAddColumnDialog: () -> Unit, + onShowAddColumnDialog: () -> Unit, replyToNote: com.vitorpamplona.quartz.nip01Core.core.Event?, ) { val relayManager = remember { DesktopRelayConnectionManager() } @@ -301,7 +426,7 @@ fun App( is AccountState.LoggedOut -> { LoginScreen( accountManager = accountManager, - onLoginSuccess = { onScreenChange(DesktopScreen.Feed) }, + onLoginSuccess = { }, ) } @@ -315,16 +440,18 @@ fun App( } MainContent( - currentScreen = currentScreen, - onScreenChange = onScreenChange, + layoutMode = layoutMode, + deckState = deckState, relayManager = relayManager, localCache = localCache, accountManager = accountManager, account = account, nwcConnection = nwcConnection, subscriptionsCoordinator = subscriptionsCoordinator, + appScope = scope, onShowComposeDialog = onShowComposeDialog, onShowReplyDialog = onShowReplyDialog, + onShowAddColumnDialog = onShowAddColumnDialog, ) // Compose dialog @@ -336,6 +463,17 @@ fun App( replyTo = replyToNote, ) } + + // Add column dialog + if (showAddColumnDialog) { + AddColumnDialog( + onDismiss = onDismissAddColumnDialog, + onAdd = { type -> + deckState.addColumn(type) + onDismissAddColumnDialog() + }, + ) + } } } } @@ -344,16 +482,18 @@ fun App( @Composable fun MainContent( - currentScreen: DesktopScreen, - onScreenChange: (DesktopScreen) -> Unit, + layoutMode: LayoutMode, + deckState: DeckState, relayManager: DesktopRelayConnectionManager, localCache: DesktopLocalCache, accountManager: AccountManager, account: AccountState.LoggedIn, nwcConnection: Nip47WalletConnect.Nip47URINorm?, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + appScope: CoroutineScope, onShowComposeDialog: () -> Unit, onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onShowAddColumnDialog: () -> Unit, ) { val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() @@ -465,234 +605,51 @@ fun MainContent( Box(Modifier.fillMaxSize()) { Row(Modifier.fillMaxSize()) { - // Sidebar Navigation - NavigationRail( - modifier = Modifier.width(80.dp).fillMaxHeight(), - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ) { - Spacer(Modifier.height(16.dp)) + when (layoutMode) { + LayoutMode.SINGLE_PANE -> { + SinglePaneLayout( + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + modifier = Modifier.weight(1f), + ) + } - NavigationRailItem( - icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, - label = { Text("Feed") }, - selected = currentScreen == DesktopScreen.Feed, - onClick = { onScreenChange(DesktopScreen.Feed) }, - ) + LayoutMode.DECK -> { + DeckSidebar( + onAddColumn = onShowAddColumnDialog, + onOpenSettings = { + if (deckState.hasColumnOfType(DeckColumnType.Settings)) { + deckState.focusExistingColumn(DeckColumnType.Settings) + } else { + deckState.addColumn(DeckColumnType.Settings) + } + }, + ) - NavigationRailItem( - icon = { Icon(Icons.AutoMirrored.Filled.Article, contentDescription = "Reads") }, - label = { Text("Reads") }, - selected = currentScreen == DesktopScreen.Reads, - onClick = { onScreenChange(DesktopScreen.Reads) }, - ) + VerticalDivider() - NavigationRailItem( - icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, - label = { Text("Search") }, - selected = currentScreen == DesktopScreen.Search, - onClick = { onScreenChange(DesktopScreen.Search) }, - ) - - NavigationRailItem( - icon = { Icon(com.vitorpamplona.amethyst.commons.icons.Bookmark, contentDescription = "Bookmarks") }, - label = { Text("Bookmarks") }, - selected = currentScreen == DesktopScreen.Bookmarks, - onClick = { onScreenChange(DesktopScreen.Bookmarks) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, - label = { Text("DMs") }, - selected = currentScreen == DesktopScreen.Messages, - onClick = { onScreenChange(DesktopScreen.Messages) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, - label = { Text("Alerts") }, - selected = currentScreen == DesktopScreen.Notifications, - onClick = { onScreenChange(DesktopScreen.Notifications) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Extension, contentDescription = "Chess") }, - label = { Text("Chess") }, - selected = currentScreen == DesktopScreen.Chess, - onClick = { onScreenChange(DesktopScreen.Chess) }, - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, - label = { Text("Profile") }, - selected = currentScreen == DesktopScreen.MyProfile || currentScreen is DesktopScreen.UserProfile, - onClick = { onScreenChange(DesktopScreen.MyProfile) }, - ) - - Spacer(Modifier.weight(1f)) - - HorizontalDivider(Modifier.padding(horizontal = 16.dp)) - - NavigationRailItem( - icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, - label = { Text("Settings") }, - selected = currentScreen == DesktopScreen.Settings, - onClick = { onScreenChange(DesktopScreen.Settings) }, - ) - - Spacer(Modifier.height(16.dp)) - } - - VerticalDivider() - - // Main Content - Box( - modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp), - ) { - when (currentScreen) { - DesktopScreen.Feed -> { - FeedScreen( - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onCompose = onShowComposeDialog, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToThread = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - onZapFeedback = onZapFeedback, - ) - } - - DesktopScreen.Reads -> { - ReadsScreen( - relayManager = relayManager, - localCache = localCache, - account = account, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToArticle = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - ) - } - - DesktopScreen.Search -> { - SearchScreen( - localCache = localCache, - relayManager = relayManager, - subscriptionsCoordinator = subscriptionsCoordinator, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToThread = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - ) - } - - DesktopScreen.Bookmarks -> { - BookmarksScreen( - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToThread = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - onZapFeedback = onZapFeedback, - ) - } - - DesktopScreen.Messages -> { - DesktopMessagesScreen( - account = iAccount, - cacheProvider = localCache, - relayManager = relayManager, - localCache = localCache, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - ) - } - - DesktopScreen.Notifications -> { - NotificationsScreen(relayManager, account, subscriptionsCoordinator) - } - - DesktopScreen.Chess -> { - ChessScreen( - relayManager = relayManager, - account = account, - onBack = { onScreenChange(DesktopScreen.Feed) }, - ) - } - - DesktopScreen.MyProfile -> { - UserProfileScreen( - pubKeyHex = account.pubKeyHex, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onBack = { onScreenChange(DesktopScreen.Feed) }, - onCompose = onShowComposeDialog, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onZapFeedback = onZapFeedback, - ) - } - - is DesktopScreen.UserProfile -> { - UserProfileScreen( - pubKeyHex = currentScreen.pubKeyHex, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onBack = { onScreenChange(DesktopScreen.Feed) }, - onCompose = onShowComposeDialog, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onZapFeedback = onZapFeedback, - ) - } - - is DesktopScreen.Thread -> { - ThreadScreen( - noteId = currentScreen.noteId, - relayManager = relayManager, - localCache = localCache, - account = account, - nwcConnection = nwcConnection, - subscriptionsCoordinator = subscriptionsCoordinator, - onBack = { onScreenChange(DesktopScreen.Feed) }, - onNavigateToProfile = { pubKeyHex -> - onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) - }, - onNavigateToThread = { noteId -> - onScreenChange(DesktopScreen.Thread(noteId)) - }, - onZapFeedback = onZapFeedback, - onReply = onShowReplyDialog, - ) - } - - DesktopScreen.Settings -> { - RelaySettingsScreen(relayManager, account, accountManager) - } + DeckLayout( + deckState = deckState, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + modifier = Modifier.weight(1f), + ) } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt index 48f0d794f..432fd7c47 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -39,6 +39,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.ArrowBack import androidx.compose.material.icons.filled.Refresh @@ -90,6 +92,7 @@ fun ChessScreen( relayManager: DesktopRelayConnectionManager, account: AccountState.LoggedIn, onBack: () -> Unit = {}, + compactMode: Boolean = false, ) { val scope = rememberCoroutineScope() val viewModel = @@ -274,6 +277,7 @@ fun ChessScreen( }, onResign = { viewModel.resign(gameState.startEventId) }, isSpectatorOverride = isSpectating, + compactMode = compactMode, ) } } else { @@ -593,6 +597,7 @@ private fun DesktopChessGameLayout( onMoveMade: (from: String, to: String, san: String) -> Unit, onResign: () -> Unit, isSpectatorOverride: Boolean? = null, + compactMode: Boolean = false, ) { // Collect state flows to trigger recomposition on changes val currentPosition by gameState.currentPosition.collectAsState() @@ -604,12 +609,13 @@ private fun DesktopChessGameLayout( val opponentPubkey = gameState.opponentPubkey val isSpectator = isSpectatorOverride ?: gameState.isSpectator + var showInfoPanel by remember { mutableStateOf(!compactMode) } + BoxWithConstraints( modifier = Modifier.fillMaxSize().padding(16.dp), ) { // Calculate board size based on available space - // Leave room for the info panel (300dp + 24dp spacing) - val infoPanelWidth = 300.dp + 24.dp + val infoPanelWidth = if (showInfoPanel) 300.dp + 24.dp else 0.dp val availableWidth = maxWidth - infoPanelWidth val availableHeight = maxHeight // Board should fit within available space, maintaining square aspect ratio @@ -617,11 +623,11 @@ private fun DesktopChessGameLayout( Row( modifier = Modifier.fillMaxSize(), - horizontalArrangement = Arrangement.spacedBy(24.dp), + horizontalArrangement = Arrangement.spacedBy(if (showInfoPanel) 24.dp else 0.dp), ) { - // Left side: Chess board + // Left side: Chess board + toggle Box( - modifier = Modifier.fillMaxHeight(), + modifier = Modifier.weight(1f).fillMaxHeight(), contentAlignment = Alignment.Center, ) { InteractiveChessBoard( @@ -633,188 +639,206 @@ private fun DesktopChessGameLayout( positionVersion = moveHistory.size, onMoveMade = onMoveMade, ) + + // Toggle button anchored to top-end of board area + IconButton( + onClick = { showInfoPanel = !showInfoPanel }, + modifier = Modifier.align(Alignment.TopEnd), + ) { + Icon( + if (showInfoPanel) { + Icons.AutoMirrored.Filled.KeyboardArrowRight + } else { + Icons.AutoMirrored.Filled.KeyboardArrowLeft + }, + contentDescription = if (showInfoPanel) "Hide info panel" else "Show info panel", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } - // Right side: Game info, moves, controls - Column( - modifier = - Modifier - .width(300.dp) - .fillMaxHeight() - .verticalScroll(rememberScrollState()), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // Extract human-readable game name - val gameName = - remember(startEventId) { - ChessGameNameGenerator.extractDisplayName(startEventId) - } - - // Game info card - Card( - modifier = Modifier.fillMaxWidth(), + // Right side: Game info, moves, controls (collapsible) + if (showInfoPanel) { + Column( + modifier = + Modifier + .width(300.dp) + .fillMaxHeight() + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - // Show readable game name if available - if (gameName != null) { - Text( - gameName, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary, - ) - } else { - Text( - "Game Info", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) + // Extract human-readable game name + val gameName = + remember(startEventId) { + ChessGameNameGenerator.extractDisplayName(startEventId) } - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + // Game info card + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { - UserAvatar( - userHex = opponentPubkey, - pictureUrl = opponentPicture, - size = 48.dp, - ) - Column { - if (isSpectator) { - Text( - "Spectating", - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.tertiary, - ) - } else { - Text( - "vs $opponentName", - style = MaterialTheme.typography.bodyLarge, - ) - Text( - "You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // Show readable game name if available + if (gameName != null) { + Text( + gameName, + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } else { + Text( + "Game Info", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + UserAvatar( + userHex = opponentPubkey, + pictureUrl = opponentPicture, + size = 48.dp, + ) + Column { + if (isSpectator) { + Text( + "Spectating", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + ) + } else { + Text( + "vs $opponentName", + style = MaterialTheme.typography.bodyLarge, + ) + Text( + "You play ${if (playerColor == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Use currentPosition to derive turn (triggers recomposition on move) + val currentTurn = currentPosition.activeColor + + if (isSpectator) { + Text( + "${if (currentTurn == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}'s turn", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + val isYourTurn = currentTurn == playerColor + Text( + if (isYourTurn) "Your turn" else "Opponent's turn", + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal, + color = + if (isYourTurn) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + ) + } + } + } + + // Move history card + if (moveHistory.isNotEmpty()) { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Move History", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + // Format moves as numbered pairs + val moveText = + moveHistory + .chunked(2) + .mapIndexed { index, pair -> + "${index + 1}. ${pair.joinToString(" ")}" + }.joinToString("\n") + + Text( + moveText, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + // Game controls card (only for participants, not spectators) + if (!isSpectator) { + Card( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "Actions", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + OutlinedButton( + onClick = onResign, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Resign") } } } - - // Use currentPosition to derive turn (triggers recomposition on move) - val currentTurn = currentPosition.activeColor - - if (isSpectator) { - Text( - "${if (currentTurn == com.vitorpamplona.quartz.nip64Chess.Color.WHITE) "White" else "Black"}'s turn", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - val isYourTurn = currentTurn == playerColor - Text( - if (isYourTurn) "Your turn" else "Opponent's turn", - style = MaterialTheme.typography.bodyMedium, - fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal, - color = - if (isYourTurn) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - }, - ) - } - } - } - - // Move history card - if (moveHistory.isNotEmpty()) { - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + } else { + // Spectator info card + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), + ), ) { - Text( - "Move History", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - - // Format moves as numbered pairs - val moveText = - moveHistory - .chunked(2) - .mapIndexed { index, pair -> - "${index + 1}. ${pair.joinToString(" ")}" - }.joinToString("\n") - - Text( - moveText, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - - // Game controls card (only for participants, not spectators) - if (!isSpectator) { - Card( - modifier = Modifier.fillMaxWidth(), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - "Actions", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - - OutlinedButton( - onClick = onResign, - modifier = Modifier.fillMaxWidth(), + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - Text("Resign") + Icon(Icons.Default.Visibility, contentDescription = null) + Text( + "Watching game - spectator mode", + style = MaterialTheme.typography.bodyMedium, + ) } } } - } else { - // Spectator info card - Card( - modifier = Modifier.fillMaxWidth(), - colors = - CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), - ), - ) { - Row( - modifier = Modifier.padding(16.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Icon(Icons.Default.Visibility, contentDescription = null) - Text( - "Watching game - spectator mode", - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } - // Game ID (small footer) - Text( - "Game: ${startEventId.take(16)}...", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + // Game ID (small footer) + Text( + "Game: ${startEventId.take(16)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt index f80a3f6a6..d20a2247b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt @@ -37,10 +37,10 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd import com.vitorpamplona.quartz.nip64Chess.ChessMoveEvent import com.vitorpamplona.quartz.nip64Chess.Color -import com.vitorpamplona.quartz.nip64Chess.JesterEvent -import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents -import com.vitorpamplona.quartz.nip64Chess.JesterProtocol -import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent /** * Desktop implementation of ChessEventPublisher using Jester protocol. diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessEventCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessEventCache.kt index c779afebd..0e9fd9a02 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessEventCache.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessEventCache.kt @@ -21,10 +21,10 @@ 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 com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent import java.util.concurrent.ConcurrentHashMap /** diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt index 895aed70a..5958c3aa5 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt @@ -32,9 +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.jester.JesterProtocol import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState -import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.toJesterEvent import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.StateFlow diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt index 81d9bbde2..fa133d8a2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt @@ -30,11 +30,11 @@ 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.nip64Chess.draw.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.accept.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index b4268710f..e79aa3cfa 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -149,6 +151,7 @@ fun FeedScreen( account: AccountState.LoggedIn? = null, nwcConnection: com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm? = null, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, + initialFeedMode: FeedMode? = null, onCompose: () -> Unit = {}, onNavigateToProfile: (String) -> Unit = {}, onNavigateToThread: (String) -> Unit = {}, @@ -168,7 +171,7 @@ fun FeedScreen( } val events by eventState.items.collectAsState() var replyToEvent by remember { mutableStateOf(null) } - var feedMode by remember { mutableStateOf(DesktopPreferences.feedMode) } + var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) } var followedUsers by remember { mutableStateOf>(emptySet()) } var zapsByEvent by remember { mutableStateOf>>(emptyMap()) } // Track reaction event IDs per target event to deduplicate @@ -475,16 +478,17 @@ fun FeedScreen( ) } + @OptIn(ExperimentalLayoutApi::class) Column(modifier = Modifier.fillMaxSize()) { - // Header with compose button - Row( + // Header with compose button — wraps on narrow columns + FlowRow( modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Column { - Row( - verticalAlignment = Alignment.CenterVertically, + FlowRow( + verticalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt index c1096316e..11121055b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ReadsScreen.kt @@ -23,6 +23,8 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize @@ -259,16 +261,17 @@ fun ReadsScreen( } } + @OptIn(ExperimentalLayoutApi::class) Column(modifier = Modifier.fillMaxSize()) { - // Header - Row( + // Header — wraps on narrow columns + FlowRow( modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Column { - Row( - verticalAlignment = Alignment.CenterVertically, + FlowRow( + verticalArrangement = Arrangement.Center, horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt index c787c3471..cc4cee93b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/SearchScreen.kt @@ -78,6 +78,7 @@ fun SearchScreen( localCache: DesktopLocalCache, relayManager: DesktopRelayConnectionManager, subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator? = null, + initialQuery: String = "", onNavigateToProfile: (String) -> Unit, onNavigateToThread: (String) -> Unit, onNavigateToHashtag: (String) -> Unit = {}, @@ -86,6 +87,13 @@ fun SearchScreen( val scope = rememberCoroutineScope() val searchState = remember { SearchBarState(localCache, scope) } val focusRequester = remember { FocusRequester() } + + // Pre-fill initial query (e.g., hashtag column) + LaunchedEffect(initialQuery) { + if (initialQuery.isNotBlank()) { + searchState.updateSearchText(initialQuery) + } + } val relayStatuses by relayManager.relayStatuses.collectAsState() // Collect state from SearchBarState diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt new file mode 100644 index 000000000..f7ef606dd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/AddColumnDialog.kt @@ -0,0 +1,163 @@ +/* + * 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.ui.deck + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +private val COLUMN_OPTIONS = + listOf( + DeckColumnType.HomeFeed, + DeckColumnType.Notifications, + DeckColumnType.Messages, + DeckColumnType.Search, + DeckColumnType.Reads, + DeckColumnType.Bookmarks, + DeckColumnType.GlobalFeed, + DeckColumnType.MyProfile, + DeckColumnType.Chess, + ) + +@Composable +fun AddColumnDialog( + onDismiss: () -> Unit, + onAdd: (DeckColumnType) -> Unit, +) { + var hashtagInput by remember { mutableStateOf("") } + var showHashtagInput by remember { mutableStateOf(false) } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Add Column") }, + text = { + Column { + if (showHashtagInput) { + Text( + "Enter hashtag:", + style = MaterialTheme.typography.bodyMedium, + ) + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = hashtagInput, + onValueChange = { hashtagInput = it.removePrefix("#") }, + label = { Text("Hashtag") }, + placeholder = { Text("bitcoin") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } else { + COLUMN_OPTIONS.forEach { type -> + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { onAdd(type) } + .padding(vertical = 10.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + Icon( + imageVector = type.icon(), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(12.dp)) + Text( + type.title(), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { showHashtagInput = true } + .padding(vertical = 10.dp, horizontal = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = DeckColumnType.Hashtag("").icon(), + contentDescription = null, + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(12.dp)) + Text( + "Hashtag...", + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + }, + confirmButton = { + if (showHashtagInput) { + Button( + onClick = { + if (hashtagInput.isNotBlank()) { + onAdd(DeckColumnType.Hashtag(hashtagInput.trim())) + } + }, + enabled = hashtagInput.isNotBlank(), + ) { + Text("Add") + } + } + }, + dismissButton = { + TextButton(onClick = { + if (showHashtagInput) { + showHashtagInput = false + } else { + onDismiss() + } + }) { + Text(if (showHashtagInput) "Back" else "Cancel") + } + }, + ) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt new file mode 100644 index 000000000..3deb62b25 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/ColumnHeader.kt @@ -0,0 +1,138 @@ +/* + * 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.ui.deck + +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Public +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material.icons.filled.Tag +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +@Composable +fun ColumnHeader( + column: DeckColumn, + canClose: Boolean, + hasBackStack: Boolean, + onBack: () -> Unit, + onClose: () -> Unit, + onDoubleClick: () -> Unit = {}, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .height(40.dp) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { onDoubleClick() }, + ) + }.padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (hasBackStack) { + IconButton(onClick = onBack, modifier = Modifier.size(28.dp)) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(4.dp)) + } + + Icon( + imageVector = column.type.icon(), + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.width(8.dp)) + + Text( + text = column.type.title(), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + if (canClose) { + IconButton(onClick = onClose, modifier = Modifier.size(28.dp)) { + Icon( + Icons.Default.Close, + contentDescription = "Close column", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +fun DeckColumnType.icon(): ImageVector = + when (this) { + DeckColumnType.HomeFeed -> Icons.Default.Home + DeckColumnType.Notifications -> Icons.Default.Notifications + DeckColumnType.Messages -> Icons.Default.Email + DeckColumnType.Search -> Icons.Default.Search + DeckColumnType.Reads -> Icons.AutoMirrored.Filled.Article + DeckColumnType.Bookmarks -> com.vitorpamplona.amethyst.commons.icons.Bookmark + DeckColumnType.GlobalFeed -> Icons.Default.Public + DeckColumnType.MyProfile -> Icons.Default.Person + DeckColumnType.Chess -> Icons.Default.Extension + DeckColumnType.Settings -> Icons.Default.Settings + is DeckColumnType.Profile -> Icons.Default.Person + is DeckColumnType.Thread -> Icons.AutoMirrored.Filled.Article + is DeckColumnType.Hashtag -> Icons.Default.Tag + } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt new file mode 100644 index 000000000..ce90b7ef6 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnContainer.kt @@ -0,0 +1,390 @@ +/* + * 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.ui.deck + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.HorizontalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.DesktopScreen +import com.vitorpamplona.amethyst.desktop.RelaySettingsScreen +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.chess.ChessScreen +import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.subscriptions.FeedMode +import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen +import com.vitorpamplona.amethyst.desktop.ui.FeedScreen +import com.vitorpamplona.amethyst.desktop.ui.NotificationsScreen +import com.vitorpamplona.amethyst.desktop.ui.ReadsScreen +import com.vitorpamplona.amethyst.desktop.ui.SearchScreen +import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen +import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen +import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen +import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +class ColumnNavigationState { + private val _stack = MutableStateFlow>(emptyList()) + val stack: kotlinx.coroutines.flow.StateFlow> = _stack.asStateFlow() + + fun push(screen: DesktopScreen) { + _stack.value = _stack.value + screen + } + + fun pop(): Boolean { + if (_stack.value.isEmpty()) return false + _stack.value = _stack.value.dropLast(1) + return true + } + + fun clear() { + _stack.value = emptyList() + } +} + +@Composable +fun DeckColumnContainer( + column: DeckColumn, + canClose: Boolean, + onClose: () -> Unit, + onDoubleClickHeader: () -> Unit = {}, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + accountManager: AccountManager, + account: AccountState.LoggedIn, + nwcConnection: Nip47URINorm?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + appScope: CoroutineScope, + onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, + modifier: Modifier = Modifier, +) { + val navState = remember(column.id) { ColumnNavigationState() } + val navStack by navState.stack.collectAsState() + val currentOverlay = navStack.lastOrNull() + + Column( + modifier = + modifier + .width(column.width.dp) + .fillMaxHeight(), + ) { + ColumnHeader( + column = column, + canClose = canClose, + hasBackStack = navStack.isNotEmpty(), + onBack = { navState.pop() }, + onClose = onClose, + onDoubleClick = onDoubleClickHeader, + ) + + HorizontalDivider() + + Box( + modifier = Modifier.fillMaxSize().padding(12.dp), + ) { + if (currentOverlay != null) { + OverlayContent( + screen = currentOverlay, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onBack = { navState.pop() }, + ) + } else { + RootContent( + columnType = column.type, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + ) + } + } + } +} + +@Composable +internal fun RootContent( + columnType: DeckColumnType, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + accountManager: AccountManager, + account: AccountState.LoggedIn, + nwcConnection: Nip47URINorm?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + appScope: CoroutineScope, + onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, + onNavigateToProfile: (String) -> Unit, + onNavigateToThread: (String) -> Unit, +) { + val scope = rememberCoroutineScope() + + when (columnType) { + DeckColumnType.HomeFeed -> { + FeedScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + initialFeedMode = FeedMode.FOLLOWING, + onCompose = onShowComposeDialog, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + ) + } + + DeckColumnType.Notifications -> { + NotificationsScreen(relayManager, account, subscriptionsCoordinator) + } + + DeckColumnType.Messages -> { + val dmSendTracker = + remember(relayManager) { + DmSendTracker(relayManager.client) + } + val iAccount = + remember(account, localCache, relayManager, dmSendTracker) { + DesktopIAccount(account, localCache, relayManager, dmSendTracker, appScope) + } + DesktopMessagesScreen( + account = iAccount, + cacheProvider = localCache, + relayManager = relayManager, + localCache = localCache, + onNavigateToProfile = onNavigateToProfile, + ) + } + + DeckColumnType.Search -> { + SearchScreen( + localCache = localCache, + relayManager = relayManager, + subscriptionsCoordinator = subscriptionsCoordinator, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + ) + } + + DeckColumnType.Reads -> { + ReadsScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + onNavigateToProfile = onNavigateToProfile, + onNavigateToArticle = onNavigateToThread, + ) + } + + DeckColumnType.Bookmarks -> { + BookmarksScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + ) + } + + DeckColumnType.GlobalFeed -> { + FeedScreen( + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + initialFeedMode = FeedMode.GLOBAL, + onCompose = onShowComposeDialog, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + ) + } + + DeckColumnType.MyProfile -> { + UserProfileScreen( + pubKeyHex = account.pubKeyHex, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = {}, + onCompose = onShowComposeDialog, + onNavigateToProfile = onNavigateToProfile, + onZapFeedback = onZapFeedback, + ) + } + + DeckColumnType.Chess -> { + ChessScreen( + relayManager = relayManager, + account = account, + onBack = {}, + compactMode = true, + ) + } + + DeckColumnType.Settings -> { + RelaySettingsScreen(relayManager, account, accountManager) + } + + is DeckColumnType.Profile -> { + UserProfileScreen( + pubKeyHex = columnType.pubKeyHex, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = {}, + onCompose = onShowComposeDialog, + onNavigateToProfile = onNavigateToProfile, + onZapFeedback = onZapFeedback, + ) + } + + is DeckColumnType.Thread -> { + ThreadScreen( + noteId = columnType.noteId, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = {}, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + onReply = onShowReplyDialog, + ) + } + + is DeckColumnType.Hashtag -> { + SearchScreen( + localCache = localCache, + relayManager = relayManager, + subscriptionsCoordinator = subscriptionsCoordinator, + initialQuery = "#${columnType.tag}", + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + ) + } + } +} + +@Composable +internal fun OverlayContent( + screen: DesktopScreen, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + account: AccountState.LoggedIn, + nwcConnection: Nip47URINorm?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, + onNavigateToProfile: (String) -> Unit, + onNavigateToThread: (String) -> Unit, + onBack: () -> Unit, +) { + when (screen) { + is DesktopScreen.UserProfile -> { + UserProfileScreen( + pubKeyHex = screen.pubKeyHex, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = onBack, + onCompose = onShowComposeDialog, + onNavigateToProfile = onNavigateToProfile, + onZapFeedback = onZapFeedback, + ) + } + + is DesktopScreen.Thread -> { + ThreadScreen( + noteId = screen.noteId, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onBack = onBack, + onNavigateToProfile = onNavigateToProfile, + onNavigateToThread = onNavigateToThread, + onZapFeedback = onZapFeedback, + onReply = onShowReplyDialog, + ) + } + + else -> { + androidx.compose.material3.Text( + "Unsupported screen type", + style = androidx.compose.material3.MaterialTheme.typography.bodyMedium, + color = androidx.compose.material3.MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt new file mode 100644 index 000000000..2b1642244 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckColumnType.kt @@ -0,0 +1,97 @@ +/* + * 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.ui.deck + +import java.util.UUID + +sealed class DeckColumnType { + object HomeFeed : DeckColumnType() + + object Notifications : DeckColumnType() + + object Messages : DeckColumnType() + + object Search : DeckColumnType() + + object Reads : DeckColumnType() + + object Bookmarks : DeckColumnType() + + object GlobalFeed : DeckColumnType() + + object MyProfile : DeckColumnType() + + object Chess : DeckColumnType() + + object Settings : DeckColumnType() + + data class Profile( + val pubKeyHex: String, + ) : DeckColumnType() + + data class Thread( + val noteId: String, + ) : DeckColumnType() + + data class Hashtag( + val tag: String, + ) : DeckColumnType() + + fun title(): String = + when (this) { + HomeFeed -> "Home" + Notifications -> "Notifications" + Messages -> "Messages" + Search -> "Search" + Reads -> "Reads" + Bookmarks -> "Bookmarks" + GlobalFeed -> "Global" + MyProfile -> "Profile" + Chess -> "Chess" + Settings -> "Settings" + is Profile -> "Profile" + is Thread -> "Thread" + is Hashtag -> "#$tag" + } + + fun typeKey(): String = + when (this) { + HomeFeed -> "home" + Notifications -> "notifications" + Messages -> "messages" + Search -> "search" + Reads -> "reads" + Bookmarks -> "bookmarks" + GlobalFeed -> "global" + MyProfile -> "my_profile" + Chess -> "chess" + Settings -> "settings" + is Profile -> "profile" + is Thread -> "thread" + is Hashtag -> "hashtag" + } +} + +data class DeckColumn( + val id: String = UUID.randomUUID().toString(), + val type: DeckColumnType, + val width: Float = 400f, +) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt new file mode 100644 index 000000000..1df6bf135 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckLayout.kt @@ -0,0 +1,144 @@ +/* + * 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.ui.deck + +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerIcon +import androidx.compose.ui.input.pointer.pointerHoverIcon +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm +import kotlinx.coroutines.CoroutineScope +import java.awt.Cursor + +@Composable +fun DeckLayout( + deckState: DeckState, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + accountManager: AccountManager, + account: AccountState.LoggedIn, + nwcConnection: Nip47URINorm?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + appScope: CoroutineScope, + onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, + modifier: Modifier = Modifier, +) { + val columns by deckState.columns.collectAsState() + val density = LocalDensity.current + + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val availableWidthDp = with(density) { constraints.maxWidth.toDp().value } + deckState.setAvailableWidth(availableWidthDp) + + // Auto-fit columns on first composition or when available width changes significantly + LaunchedEffect(availableWidthDp, columns.size) { + val dividers = (columns.size - 1) * DeckState.DIVIDER_WIDTH + val totalColumnWidth = columns.sumOf { it.width.toDouble() }.toFloat() + val diff = kotlin.math.abs(totalColumnWidth + dividers - availableWidthDp) + if (diff > 20f && columns.isNotEmpty()) { + deckState.fitColumnsToWidth(availableWidthDp) + } + } + + Row( + modifier = + Modifier + .fillMaxSize() + .horizontalScroll(rememberScrollState()), + ) { + columns.forEachIndexed { index, column -> + if (index > 0) { + DraggableDivider( + onDrag = { delta -> + val leftCol = columns[index - 1] + val rightCol = columns[index] + deckState.resizePair(leftCol.id, rightCol.id, delta, availableWidthDp) + }, + ) + } + + DeckColumnContainer( + column = column, + canClose = columns.size > 1, + onClose = { deckState.removeColumn(column.id) }, + onDoubleClickHeader = { deckState.expandColumn(column.id, availableWidthDp) }, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + ) + } + } + } +} + +@Composable +private fun DraggableDivider(onDrag: (Float) -> Unit) { + Box( + modifier = + Modifier + .width(12.dp) + .fillMaxHeight() + .pointerHoverIcon(PointerIcon(Cursor(Cursor.E_RESIZE_CURSOR))) + .pointerInput(Unit) { + detectDragGestures { change, dragAmount -> + change.consume() + onDrag(dragAmount.x / density) + } + }, + contentAlignment = androidx.compose.ui.Alignment.Center, + ) { + VerticalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + ) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt new file mode 100644 index 000000000..912042a1b --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckSidebar.kt @@ -0,0 +1,89 @@ +/* + * 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.ui.deck + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +@Composable +fun DeckSidebar( + onAddColumn: () -> Unit, + onOpenSettings: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .width(48.dp) + .fillMaxHeight() + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Top, + ) { + Text( + "A", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + fontSize = 20.sp, + ) + + Spacer(Modifier.size(16.dp)) + + IconButton(onClick = onAddColumn) { + Icon( + Icons.Default.Add, + contentDescription = "Add Column", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.weight(1f)) + + IconButton(onClick = onOpenSettings) { + Icon( + Icons.Default.Settings, + contentDescription = "Settings", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt new file mode 100644 index 000000000..bdfb40298 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/DeckState.kt @@ -0,0 +1,295 @@ +/* + * 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.ui.deck + +import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper +import com.fasterxml.jackson.module.kotlin.readValue +import com.vitorpamplona.amethyst.desktop.DesktopPreferences +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class DeckState( + private val saveScope: CoroutineScope, +) { + private val _columns = MutableStateFlow(DEFAULT_COLUMNS) + val columns: StateFlow> = _columns.asStateFlow() + + private val _focusedColumnIndex = MutableStateFlow(0) + val focusedColumnIndex: StateFlow = _focusedColumnIndex.asStateFlow() + + @Volatile + private var lastKnownWidth: Float = 0f + + private var saveJob: Job? = null + + fun setAvailableWidth(width: Float) { + lastKnownWidth = width + } + + fun addColumn( + type: DeckColumnType, + afterIndex: Int? = null, + ) { + val col = DeckColumn(type = type) + _columns.update { current -> + if (afterIndex != null && afterIndex < current.size) { + current.toMutableList().apply { add(afterIndex + 1, col) } + } else { + current + col + } + } + // Auto-fit all columns to available width when known + val width = lastKnownWidth + if (width > 0f) { + fitColumnsToWidth(width) + } else { + scheduleSave() + } + } + + fun hasColumnOfType(type: DeckColumnType): Boolean = _columns.value.any { it.type == type } + + fun focusExistingColumn(type: DeckColumnType) { + val idx = _columns.value.indexOfFirst { it.type == type } + if (idx >= 0) focusColumn(idx) + } + + fun removeColumn(id: String) { + _columns.update { current -> + if (current.size <= 1) return + val removed = current.find { it.id == id } ?: return + val remaining = current.filter { it.id != id } + val extra = removed.width / remaining.size + val result = + remaining.map { + it.copy(width = (it.width + extra).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) + } + // Fix gaps: if total is less than available, redistribute remainder + val width = lastKnownWidth + if (width > 0f) { + val dividers = (result.size - 1) * DIVIDER_WIDTH + val totalUsed = result.sumOf { it.width.toDouble() }.toFloat() + val deficit = width - dividers - totalUsed + if (deficit > 1f) { + val perColumn = deficit / result.size + return@update result.map { + it.copy(width = (it.width + perColumn).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) + } + } + } + result + } + _focusedColumnIndex.update { idx -> + idx.coerceAtMost(_columns.value.size - 1) + } + scheduleSave() + } + + fun moveColumn( + fromIndex: Int, + toIndex: Int, + ) { + _columns.update { current -> + if (fromIndex !in current.indices || toIndex !in current.indices) return + current.toMutableList().apply { + val item = removeAt(fromIndex) + add(toIndex, item) + } + } + scheduleSave() + } + + fun updateColumnWidth( + id: String, + width: Float, + ) { + _columns.update { current -> + current.map { + if (it.id == id) it.copy(width = width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) else it + } + } + scheduleSave() + } + + fun expandColumn( + id: String, + availableWidth: Float, + ) { + _columns.update { cols -> + if (cols.find { it.id == id } == null) return + val othersMin = (cols.size - 1) * MIN_COLUMN_WIDTH + val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH + val maxForTarget = (availableWidth - othersMin - dividerWidth).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + cols.map { + if (it.id == id) { + it.copy(width = maxForTarget) + } else { + it.copy(width = MIN_COLUMN_WIDTH) + } + } + } + scheduleSave() + } + + fun resizePair( + leftId: String, + rightId: String, + delta: Float, + availableWidth: Float, + ) { + _columns.update { cols -> + val left = cols.find { it.id == leftId } ?: return + val right = cols.find { it.id == rightId } ?: return + val otherWidth = cols.filter { it.id != leftId && it.id != rightId }.sumOf { it.width.toDouble() }.toFloat() + val dividerWidth = (cols.size - 1) * DIVIDER_WIDTH + val maxPairWidth = availableWidth - otherWidth - dividerWidth + var newLeft = (left.width + delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + var newRight = (right.width - delta).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + if (newLeft + newRight > maxPairWidth) { + if (delta > 0) { + newLeft = (maxPairWidth - newRight).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + } else { + newRight = (maxPairWidth - newLeft).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + } + } + cols.map { + when (it.id) { + leftId -> it.copy(width = newLeft) + rightId -> it.copy(width = newRight) + else -> it + } + } + } + scheduleSave() + } + + fun fitColumnsToWidth(availableWidth: Float) { + _columns.update { cols -> + if (cols.isEmpty()) return + val dividers = (cols.size - 1) * DIVIDER_WIDTH + val usable = availableWidth - dividers + val perColumn = (usable / cols.size).coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH) + cols.map { it.copy(width = perColumn) } + } + scheduleSave() + } + + fun focusColumn(index: Int) { + if (index in _columns.value.indices) { + _focusedColumnIndex.value = index + } + } + + private fun scheduleSave() { + saveJob?.cancel() + saveJob = + saveScope.launch { + delay(SAVE_DEBOUNCE_MS) + save() + } + } + + fun save() { + try { + val data = + _columns.value.map { col -> + mapOf( + "id" to col.id, + "type" to col.type.typeKey(), + "width" to col.width, + "param" to + when (col.type) { + is DeckColumnType.Profile -> col.type.pubKeyHex + is DeckColumnType.Thread -> col.type.noteId + is DeckColumnType.Hashtag -> col.type.tag + else -> null + }, + ) + } + DesktopPreferences.deckColumns = mapper.writeValueAsString(data) + } catch (e: Exception) { + println("DeckState: failed to save columns: ${e.message}") + } + } + + fun load() { + try { + val json = DesktopPreferences.deckColumns + if (json.isBlank()) return + val data: List> = mapper.readValue(json) + val loaded = + data.mapNotNull { entry -> + val type = parseColumnType(entry) ?: return@mapNotNull null + val id = entry["id"] as? String ?: return@mapNotNull null + val width = (entry["width"] as? Number)?.toFloat() ?: 400f + DeckColumn(id = id, type = type, width = width.coerceIn(MIN_COLUMN_WIDTH, MAX_COLUMN_WIDTH)) + } + if (loaded.isNotEmpty()) { + _columns.value = loaded + } + } catch (e: Exception) { + println("DeckState: failed to load columns: ${e.message}") + } + } + + companion object { + const val MIN_COLUMN_WIDTH = 300f + const val MAX_COLUMN_WIDTH = 800f + const val DIVIDER_WIDTH = 12f + private const val SAVE_DEBOUNCE_MS = 500L + + val DEFAULT_COLUMNS = + listOf( + DeckColumn(id = "default-home", type = DeckColumnType.HomeFeed), + DeckColumn(id = "default-notifications", type = DeckColumnType.Notifications), + DeckColumn(id = "default-messages", type = DeckColumnType.Messages), + ) + + private val mapper = jacksonObjectMapper() + + private fun parseColumnType(entry: Map): DeckColumnType? { + val typeKey = entry["type"] as? String ?: return null + val param = entry["param"] as? String + return when (typeKey) { + "home" -> DeckColumnType.HomeFeed + "notifications" -> DeckColumnType.Notifications + "messages" -> DeckColumnType.Messages + "search" -> DeckColumnType.Search + "reads" -> DeckColumnType.Reads + "bookmarks" -> DeckColumnType.Bookmarks + "global" -> DeckColumnType.GlobalFeed + "my_profile" -> DeckColumnType.MyProfile + "chess" -> DeckColumnType.Chess + "settings" -> DeckColumnType.Settings + "profile" -> param?.let { DeckColumnType.Profile(it) } + "thread" -> param?.let { DeckColumnType.Thread(it) } + "hashtag" -> param?.let { DeckColumnType.Hashtag(it) } + else -> null + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt new file mode 100644 index 000000000..dd8276699 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/deck/SinglePaneLayout.kt @@ -0,0 +1,233 @@ +/* + * 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.ui.deck + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Article +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Extension +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.DesktopScreen +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator +import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect.Nip47URINorm +import kotlinx.coroutines.CoroutineScope + +private data class NavItem( + val type: DeckColumnType, + val icon: ImageVector, + val label: String, +) + +private val navItems = + listOf( + NavItem(DeckColumnType.HomeFeed, Icons.Default.Home, "Home"), + NavItem(DeckColumnType.Reads, Icons.AutoMirrored.Filled.Article, "Reads"), + NavItem(DeckColumnType.Search, Icons.Default.Search, "Search"), + NavItem(DeckColumnType.Bookmarks, Icons.Default.Bookmark, "Bookmarks"), + NavItem(DeckColumnType.Messages, Icons.Default.Email, "Messages"), + NavItem(DeckColumnType.Notifications, Icons.Default.Notifications, "Notifications"), + NavItem(DeckColumnType.MyProfile, Icons.Default.Person, "Profile"), + NavItem(DeckColumnType.Chess, Icons.Default.Extension, "Chess"), + NavItem(DeckColumnType.Settings, Icons.Default.Settings, "Settings"), + ) + +@Composable +fun SinglePaneLayout( + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + accountManager: AccountManager, + account: AccountState.LoggedIn, + nwcConnection: Nip47URINorm?, + subscriptionsCoordinator: DesktopRelaySubscriptionsCoordinator, + appScope: CoroutineScope, + onShowComposeDialog: () -> Unit, + onShowReplyDialog: (com.vitorpamplona.quartz.nip01Core.core.Event) -> Unit, + onZapFeedback: (ZapFeedback) -> Unit, + modifier: Modifier = Modifier, +) { + var currentColumnType by remember { mutableStateOf(DeckColumnType.HomeFeed) } + val navState = remember { ColumnNavigationState() } + val navStack by navState.stack.collectAsState() + val currentOverlay = navStack.lastOrNull() + + Row(modifier = modifier.fillMaxSize()) { + NavigationRail( + modifier = Modifier.width(80.dp).fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ) { + navItems.forEach { item -> + NavigationRailItem( + selected = currentColumnType == item.type && navStack.isEmpty(), + onClick = { + currentColumnType = item.type + navState.clear() + }, + icon = { + Icon( + item.icon, + contentDescription = item.label, + modifier = Modifier.size(22.dp), + ) + }, + label = { + Text( + item.label, + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + ) + } + } + + VerticalDivider() + + Column(modifier = Modifier.weight(1f).fillMaxHeight()) { + // Show header with back button when navigated into overlay + if (navStack.isNotEmpty()) { + SinglePaneHeader( + title = + when (currentOverlay) { + is DesktopScreen.UserProfile -> "Profile" + is DesktopScreen.Thread -> "Thread" + else -> currentColumnType.title() + }, + onBack = { navState.pop() }, + ) + HorizontalDivider() + } + + Box( + modifier = Modifier.fillMaxSize().padding(12.dp), + ) { + if (currentOverlay != null) { + OverlayContent( + screen = currentOverlay, + relayManager = relayManager, + localCache = localCache, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + onBack = { navState.pop() }, + ) + } else { + RootContent( + columnType = currentColumnType, + relayManager = relayManager, + localCache = localCache, + accountManager = accountManager, + account = account, + nwcConnection = nwcConnection, + subscriptionsCoordinator = subscriptionsCoordinator, + appScope = appScope, + onShowComposeDialog = onShowComposeDialog, + onShowReplyDialog = onShowReplyDialog, + onZapFeedback = onZapFeedback, + onNavigateToProfile = { navState.push(DesktopScreen.UserProfile(it)) }, + onNavigateToThread = { navState.push(DesktopScreen.Thread(it)) }, + ) + } + } + } + } +} + +@Composable +private fun SinglePaneHeader( + title: String, + onBack: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = + modifier + .fillMaxWidth() + .height(40.dp) + .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)) + .padding(horizontal = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + IconButton(onClick = onBack, modifier = Modifier.size(28.dp)) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Spacer(Modifier.width(8.dp)) + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/docs/namecoin-nip05-design.md b/docs/namecoin-nip05-design.md new file mode 100644 index 000000000..557e88ff9 --- /dev/null +++ b/docs/namecoin-nip05-design.md @@ -0,0 +1,309 @@ +# Namecoin NIP-05 Resolution — Design Document + +## Overview + +This patch adds Namecoin blockchain-based NIP-05 identity verification to Amethyst. Users can set their `nip05` field to a `.bit` domain (e.g. `alice@example.bit`) or a direct Namecoin name (`d/example`, `id/alice`), and Amethyst will resolve it via the Namecoin blockchain instead of HTTP. + +This is censorship-resistant identity verification: no web server to seize, no DNS to hijack, no TLS certificate to revoke. The name-to-pubkey mapping lives in Namecoin UTXOs. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Amethyst App │ +├─────────────────────────────────────────────────────────────┤ +│ Nip05Client │ +│ ├── HTTP path (existing) ── Nip05Fetcher │ +│ └── Namecoin path (new) ── NamecoinNameResolver │ +│ │ │ +│ NamecoinNameService (singleton) │ │ +│ └── NamecoinLookupCache │ │ +│ │ │ +│ RoleBasedHttpClientBuilder │ │ +│ └── socketFactoryForNip05() ───┤ (Tor-aware sockets) │ +│ │ │ +│ ProxiedSocketFactory │ │ +│ └── SOCKS5 proxy routing ───┘ │ +├────────────────────────────────────┼────────────────────────┤ +│ Quartz Library │ +├────────────────────────────────────┼────────────────────────┤ +│ NamecoinNameResolver │ │ +│ ├── parseIdentifier() │ │ +│ ├── extractFromDomainValue() │ (d/ namespace) │ +│ ├── extractFromIdentityValue() │ (id/ namespace) │ +│ └── serverListProvider() │ (Tor/clearnet routing) │ +│ │ │ +│ ElectrumxClient │ │ +│ ├── buildNameIndexScript() │ │ +│ ├── electrumScriptHash() │ │ +│ ├── parseNameScript() │ │ +│ └── socketFactory() ───┤ (injected, proxy-aware)│ +│ ▼ │ +│ ┌───────────────────────────┐ │ +│ │ ElectrumX Server │ │ +│ │ (clearnet or .onion) │ │ +│ └───────────────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Layer Separation + +- **`quartz/` (library)** — Protocol-level logic. No Android dependencies. + - `ElectrumxClient` — TCP/TLS connection to ElectrumX, JSON-RPC, script parsing. Accepts an injected `SocketFactory` lambda for proxy/Tor support. + - `NamecoinNameResolver` — Identifier parsing, value extraction, NIP-05 mapping. Accepts a `serverListProvider` lambda for dynamic server selection. + - `NamecoinLookupCache` — LRU cache with TTL + - `NamecoinNameResolverTest` — Unit tests for parsing and value extraction + +- **`amethyst/` (app)** — Android integration and Tor-aware wiring. + - `NamecoinNameService` — Application singleton, initialized with a proxy-aware `ElectrumxClient` + - `ProxiedSocketFactory` — `SocketFactory` implementation that routes through a SOCKS5 proxy (Tor) + - `RoleBasedHttpClientBuilder.socketFactoryForNip05()` — Returns a proxy-aware or default `SocketFactory` based on current Tor settings + +## Tor & Proxy Integration + +The ElectrumX connection respects the user's Tor settings to prevent IP leaks: + +### Problem + +The original `ElectrumxClient` used raw `java.net.Socket` / `SSLSocket` directly, bypassing OkHttp entirely. This meant Namecoin lookups would leak the user's real IP even when they had configured Tor for NIP-05 verification traffic. + +### Solution + +1. **`ElectrumxClient`** accepts a `socketFactory: () -> SocketFactory` lambda (evaluated at each connection, not captured at construction) +2. **`ProxiedSocketFactory`** creates sockets routed through a `java.net.Proxy` (SOCKS5) +3. **`RoleBasedHttpClientBuilder.socketFactoryForNip05()`** checks the user's NIP-05 Tor settings and returns either `SocketFactory.getDefault()` or a `ProxiedSocketFactory` with the active Tor SOCKS proxy +4. SSL is layered on top of the (possibly proxied) base socket via `SSLSocketFactory.createSocket(socket, host, port, autoClose)`, preserving the proxy tunnel + +### Server Selection + +When Tor is enabled for NIP-05 traffic, the server list switches to prioritize onion routing: + +| Setting | Primary server | Fallback | +|---|---|---| +| **Tor off** | `electrumx.testls.space:50002` | `ulrichard.ch:50006`, `nmc2.lelux.fi:50006` | +| **Tor on** | `.onion:50002` (see below) | `electrumx.testls.space:50002` (via Tor) | + +The `serverListProvider` lambda in `NamecoinNameResolver` is evaluated at resolution time, so toggling Tor settings takes effect immediately without restarting the app. + +### Dynamic Evaluation + +Both the socket factory and server list are provided as lambdas, not captured values. This means: +- Toggling Tor on/off in settings takes effect on the next Namecoin lookup +- The proxy port is read from `DualHttpClientManager`'s live `StateFlow` +- No app restart or singleton reconstruction needed + +## ElectrumX Protocol — How Name Resolution Works + +Namecoin names are stored as UTXOs with `NAME_UPDATE` scripts. The ElectrumX server indexes these by a canonical "name index script hash", allowing lookup via standard Electrum protocol methods. + +### Resolution Steps + +``` +1. Build canonical name index script + OP_NAME_UPDATE(0x53) + push(name_bytes) + push(empty) + OP_2DROP(0x6d) + OP_DROP(0x75) + OP_RETURN(0x6a) + +2. Compute Electrum-style scripthash + SHA-256(script) → reverse bytes → hex encode + +3. Query transaction history + → blockchain.scripthash.get_history(scripthash) + ← [{tx_hash, height}, ...] + +4. Fetch latest transaction (last entry = most recent name update) + → blockchain.transaction.get(tx_hash, verbose=true) + ← {vout: [{scriptPubKey: {hex: "53..."}}]} + +5. Parse NAME_UPDATE script from transaction output + Script: OP_NAME_UPDATE OP_2DROP OP_DROP + Extract: name string + JSON value + +6. Extract Nostr pubkey from the JSON value +``` + +### Why Not `blockchain.name.get_value_proof`? + +The Electrum-NMC fork of ElectrumX advertises a `blockchain.name.get_value_proof` method (protocol v1.4.3), but in practice this method expects a scripthash parameter, not a name string. The scripthash-based approach described above works with both the Namecoin ElectrumX fork and stock ElectrumX pointed at a Namecoin node, as long as the server has a name index. + +## Namecoin Value Formats + +### Domain namespace (`d/`) + +Namecoin `d/` names store domain configuration as JSON. Two Nostr formats are supported: + +**Simple form** — single pubkey for the root domain: +```json +{ + "nostr": "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9" +} +``` + +**Extended form** — multiple users with relay hints (mirrors NIP-05 JSON structure): +```json +{ + "nostr": { + "names": { + "_": "aaaa...0001", + "alice": "bbbb...0002" + }, + "relays": { + "bbbb...0002": ["wss://relay.example.com"] + } + } +} +``` + +### Identity namespace (`id/`) + +Namecoin `id/` names store personal identity data: + +```json +{ + "nostr": "cccc...0003" +} +``` + +Or with relay hints: +```json +{ + "nostr": { + "pubkey": "dddd...0004", + "relays": ["wss://relay.example.com"] + } +} +``` + +## Identifier Formats + +| User input | Namecoin name | Local part | Namespace | +|---|---|---|---| +| `alice@example.bit` | `d/example` | `alice` | DOMAIN | +| `_@example.bit` | `d/example` | `_` | DOMAIN | +| `example.bit` | `d/example` | `_` | DOMAIN | +| `d/example` | `d/example` | `_` | DOMAIN | +| `id/alice` | `id/alice` | `_` | IDENTITY | + +## NIP-05 Integration + +The integration is minimal and non-invasive: + +1. **`Nip05Client`** gains an optional `namecoinResolver` parameter +2. On `verify()` and `get()`, if the identifier matches `.bit` / `d/` / `id/`, it routes to `NamecoinNameResolver` instead of the HTTP fetcher +3. Non-Namecoin identifiers are completely unaffected +4. **`AppModules`** wires up the resolver with Tor-aware socket factory and dynamic server selection + +## Search Integration + +The search bar resolves Namecoin identifiers in real-time via `SearchBarViewModel`: + +1. A `namecoinResolvedUser` flow watches the search input with a 400ms debounce +2. If the input matches any Namecoin format (`d/*`, `id/*`, `*.bit`, `*@*.bit`), it resolves via `NamecoinNameService` → `ElectrumxClient` → blockchain +3. The resolved pubkey is used to get/create a `User` in `LocalCache` +4. The Namecoin-resolved user is prepended to the standard local search results (deduplicated) + +This means typing `alice@example.bit`, `example.bit`, `d/example`, or `id/alice` into the search bar will query the Namecoin blockchain and show the resolved user profile at the top of results. + +## Default ElectrumX Servers + +### Clearnet (Tor off) +| Server | Port | TLS | Notes | +|---|---|---|---| +| `electrumx.testls.space` | 50002 | Yes (self-signed) | Primary | +| `ulrichard.ch` | 50006 | Yes | Fallback | +| `nmc2.lelux.fi` | 50006 | Yes | Fallback | + +### Tor (Tor on for NIP-05) +| Server | Port | TLS | Notes | +|---|---|---|---| +| `i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion` | 50002 | Yes (self-signed) | Primary — onion service for `electrumx.testls.space` | +| `electrumx.testls.space` | 50002 | Yes (self-signed) | Fallback (routed through Tor SOCKS proxy) | + +The `trustAllCerts` flag is set for servers with self-signed certificates. Users can configure custom servers via `NamecoinNameService.setCustomServers()`. + +## Caching + +- **LRU cache** with configurable max entries (default 500) and TTL (default 1 hour) +- Cache key is the normalized (lowercased, trimmed) identifier +- Both positive and negative results are cached +- Cache is invalidated on TTL expiry; manual `invalidate()` and `clear()` are available + +## Security Considerations + +- **Tor integration**: ElectrumX connections are routed through the user's Tor SOCKS proxy when NIP-05 Tor settings are enabled. This prevents IP leaks to ElectrumX servers. The onion server is preferred when Tor is active, providing end-to-end onion routing. +- **Self-signed certificates**: The primary ElectrumX server uses a self-signed TLS cert. The `trustAllCerts` option accepts any certificate for that server. This is acceptable because the Namecoin blockchain itself provides the trust anchor — we verify names against on-chain data, not the transport layer. A MITM could return stale data but cannot forge name registrations. +- **Name expiry**: Namecoin names expire after ~36,000 blocks (~250 days) if not renewed. The current implementation does not check expiry. Future work should compare the name's `height` + `expiresIn` against the current block height. +- **Server trust**: The client trusts that the ElectrumX server returns accurate transaction data. For higher assurance, SPV proof verification could be added in the future. +- **Dynamic proxy evaluation**: Socket factory and server list are evaluated per-request (via lambdas), ensuring Tor setting changes take effect immediately without stale socket reuse. + +## Files Changed + +### New files (quartz/) +- `quartz/.../nip05/namecoin/ElectrumxClient.kt` — ElectrumX TCP/TLS client with injected `SocketFactory` for proxy support +- `quartz/.../nip05/namecoin/NamecoinNameResolver.kt` — Identifier parsing, value extraction, dynamic server selection +- `quartz/.../nip05/namecoin/NamecoinLookupCache.kt` — LRU cache with TTL +- `quartz/src/jvmTest/.../NamecoinNameResolverTest.kt` — Unit tests + +### New files (amethyst/) +- `amethyst/.../service/namecoin/NamecoinNameService.kt` — App singleton, initialized with proxy-aware ElectrumxClient +- `amethyst/.../model/privacyOptions/ProxiedSocketFactory.kt` — `SocketFactory` that routes through SOCKS5 proxy (Tor) + +### Modified files +- `amethyst/.../AppModules.kt` — Wires up resolver with Tor-aware socket factory and server list provider +- `amethyst/.../model/privacyOptions/RoleBasedHttpClientBuilder.kt` — Added `socketFactoryForNip05()` for proxy-aware socket creation +- `amethyst/.../ui/screen/loggedIn/search/SearchBarViewModel.kt` — Namecoin search resolution +- `quartz/.../nip05DnsIdentifiers/Nip05Client.kt` — Route `.bit` identifiers to Namecoin resolver + +## Testing + +### Unit tests +```bash +./gradlew :quartz:jvmTest --tests "*NamecoinNameResolverTest*" +``` + +### Manual testing (emulator or device) + +Build and install the debug APK: +```bash +./gradlew assemblePlayDebug +adb install -r amethyst/build/outputs/apk/play/debug/amethyst-play-universal-debug.apk +``` + +**Search bar tests** — open the search bar and enter each of these: + +| Search query | Expected result | What it tests | +|---|---|---| +| `m@testls.bit` | Resolves to Vitor Pamplona's profile | NIP-05 style `user@domain.bit` | +| `testls.bit` | Resolves to Vitor Pamplona's profile (root `_` entry) | Bare domain `.bit` lookup | +| `d/testls` | Resolves to Vitor Pamplona's profile | Direct `d/` namespace | +| `id/someuser` | Resolves if registered on-chain | Direct `id/` namespace | + +**Tor tests** — enable Tor and set "NIP-05 verifications via Tor" to on: +1. Search for `m@testls.bit` — should resolve via onion server +2. Verify no direct clearnet connections to ElectrumX servers (use `tcpdump`) +3. Toggle Tor off — next search should use clearnet servers + +**Verification test** — if a profile has a `.bit` address in its `nip05` field, the NIP-05 badge should verify via the blockchain instead of HTTP. + +**Network verification** — to confirm ElectrumX calls are being made: +```bash +adb root +adb shell tcpdump -i any -nn port 50002 or port 50006 +``` +With Tor off, you should see TCP connections to `162.212.154.52:50002` (electrumx.testls.space). +With Tor on, you should see connections to the local Tor SOCKS port only (no direct ElectrumX connections). + +### Live test data +The name `d/testls` is registered on the Namecoin blockchain (block 551519+, last updated block 814278) with value: +```json +{ + "nostr": { + "names": { + "m": "6cdebccabda1dfa058ab85352a79509b592b2bdfa0370325e28ec1cb4f18667d" + } + } +} +``` + +This means: +- `m@testls.bit` → resolves `m` entry → pubkey `6cdebcca...18667d` +- `testls.bit` → resolves root `_` entry → falls back to first available entry +- `d/testls` → same as `testls.bit` (root lookup) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt new file mode 100644 index 000000000..6e63b785e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/ElectrumxClient.kt @@ -0,0 +1,554 @@ +/* + * 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.quartz.nip05.namecoin + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.int +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.put +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.PrintWriter +import java.net.InetSocketAddress +import java.net.Socket +import java.security.MessageDigest +import java.util.concurrent.atomic.AtomicInteger +import javax.net.SocketFactory +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManager +import javax.net.ssl.X509TrustManager + +/** + * Result of an ElectrumX name_show query. + * + * Maps to the JSON fields returned by Namecoin Core / Electrum-NMC: + * { "name": "d/example", "value": "{...}", "txid": "abc...", "height": 12345, ... } + */ +@Serializable +data class NameShowResult( + val name: String, + val value: String, + val txid: String? = null, + val height: Int? = null, + val expiresIn: Int? = null, +) + +/** + * Represents a single ElectrumX server endpoint. + */ +data class ElectrumxServer( + val host: String, + val port: Int, + val useSsl: Boolean = true, + /** If true, accept any certificate (self-signed, expired, etc.) */ + val trustAllCerts: Boolean = false, +) + +/** + * Lightweight, query-only ElectrumX client for Namecoin name resolution. + * + * Connects over TCP/TLS to a Namecoin ElectrumX server and resolves + * Namecoin names to their current values using the standard Electrum + * protocol (scripthash-based lookups). Works with both the Namecoin + * ElectrumX fork and stock ElectrumX pointed at a Namecoin node, as + * long as the server has a name index. + * + * Resolution strategy: + * 1. Build a canonical name index script for the identifier + * 2. Compute the Electrum-style scripthash (reversed SHA-256) + * 3. Query `blockchain.scripthash.get_history` to find the latest tx + * 4. Fetch the raw transaction and parse the name value from the script + * + * Usage: + * ``` + * val client = ElectrumxClient() + * val result = client.nameShow("d/example", server) + * ``` + */ +class ElectrumxClient( + private val connectTimeoutMs: Long = 10_000L, + private val readTimeoutMs: Long = 15_000L, + private val socketFactory: () -> SocketFactory = { SocketFactory.getDefault() }, +) { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + private val requestId = AtomicInteger(0) + private val mutex = Mutex() + + companion object { + /** Well-known public Namecoin ElectrumX servers (clearnet). */ + val DEFAULT_SERVERS = + listOf( + ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), + ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true), + ElectrumxServer("46.229.238.187", 57002, useSsl = true, trustAllCerts = true), + ) + + /** Tor-preferred server list: onion primary, clearnet fallback. */ + val TOR_SERVERS = + listOf( + ElectrumxServer( + "i665jpwsq46zlsdbnj4axgzd3s56uzey5uhotsnxzsknzbn36jaddsid.onion", + 50002, + useSsl = true, + trustAllCerts = true, + ), + ElectrumxServer("electrumx.testls.space", 50002, useSsl = true, trustAllCerts = true), + ElectrumxServer("nmc2.bitcoins.sk", 57002, useSsl = true, trustAllCerts = true), + ) + + private const val PROTOCOL_VERSION = "1.4" + + /** + * Namecoin names expire this many blocks after their last update. + * From chainparams.cpp: consensus.nNameExpirationDepth = 36000 + * (~250 days at ~10 min/block). + */ + const val NAME_EXPIRE_DEPTH = 36_000 + + // Namecoin script opcodes + private const val OP_NAME_UPDATE: Byte = 0x53 // OP_3 repurposed by Namecoin + private const val OP_2DROP: Byte = 0x6d + private const val OP_DROP: Byte = 0x75 + private const val OP_RETURN: Byte = 0x6a + private const val OP_PUSHDATA1: Byte = 0x4c + private const val OP_PUSHDATA2: Byte = 0x4d + } + + /** + * Perform a name_show lookup against the given ElectrumX server. + * + * Uses the scripthash-based approach: computes the name's canonical + * index script hash, queries transaction history, and parses the + * name value from the latest transaction's output script. + * + * @param identifier Full Namecoin name, e.g. "d/example" or "id/alice" + * @param server ElectrumX server to query + * @return [NameShowResult] on success, null if the name does not exist + * or the server is unreachable + */ + suspend fun nameShow( + identifier: String, + server: ElectrumxServer = DEFAULT_SERVERS.first(), + ): NameShowResult? = + withContext(Dispatchers.IO) { + mutex.withLock { + try { + connectAndQuery(identifier, server) + } catch (e: Exception) { + // Log but don't crash — callers handle null gracefully. + e.printStackTrace() + null + } + } + } + + /** + * Try each server in order until one succeeds. + * + * @param identifier Full Namecoin name, e.g. "d/example" + * @param servers Ordered server list to try; defaults to [DEFAULT_SERVERS] + */ + suspend fun nameShowWithFallback( + identifier: String, + servers: List = DEFAULT_SERVERS, + ): NameShowResult? { + for (server in servers) { + val result = nameShow(identifier, server) + if (result != null) return result + } + return null + } + + // ── internals ────────────────────────────────────────────────────── + + private fun connectAndQuery( + identifier: String, + server: ElectrumxServer, + ): NameShowResult? { + val socket = createSocket(server) + socket.soTimeout = readTimeoutMs.toInt() + val writer = PrintWriter(socket.getOutputStream(), true) + val reader = BufferedReader(InputStreamReader(socket.getInputStream())) + + try { + // 1. Negotiate protocol version + val versionReq = buildRpcRequest("server.version", listOf("AmethystNMC/0.1", PROTOCOL_VERSION)) + writer.println(versionReq) + reader.readLine() // consume version response + + // 2. Compute the canonical name index scripthash + val nameScript = buildNameIndexScript(identifier.toByteArray(Charsets.US_ASCII)) + val scriptHash = electrumScriptHash(nameScript) + + // 3. Get transaction history for this name + val historyReq = buildRpcRequest("blockchain.scripthash.get_history", listOf(scriptHash)) + writer.println(historyReq) + val historyResponse = reader.readLine() ?: return null + val historyEntries = parseHistoryResponse(historyResponse) ?: return null + if (historyEntries.isEmpty()) return null + + // 4. Get the latest transaction (last entry = most recent update) + val latestEntry = historyEntries.last() + val txHash = latestEntry.first + val height = latestEntry.second + + val txReq = buildRpcRequest("blockchain.transaction.get", listOf(txHash, true)) + writer.println(txReq) + val txResponse = reader.readLine() ?: return null + + // 5. Get current block height to check name expiry + val headersReq = buildRpcRequest("blockchain.headers.subscribe", emptyList()) + writer.println(headersReq) + val headersResponse = reader.readLine() + val currentHeight = parseBlockHeight(headersResponse) + + // 6. Check if the name has expired + if (currentHeight != null && height > 0) { + val blocksSinceUpdate = currentHeight - height + if (blocksSinceUpdate >= NAME_EXPIRE_DEPTH) { + return null // Name has expired + } + } + + // 7. Parse the name value from the transaction + val result = parseNameFromTransaction(identifier, txHash, height, txResponse) + // Populate expiresIn if we know the current height + return if (result != null && currentHeight != null && height > 0) { + result.copy(expiresIn = NAME_EXPIRE_DEPTH - (currentHeight - height)) + } else { + result + } + } finally { + runCatching { writer.close() } + runCatching { reader.close() } + runCatching { socket.close() } + } + } + + /** + * Build the canonical script used by ElectrumX to index Namecoin names. + * + * Format: OP_NAME_UPDATE OP_2DROP OP_DROP OP_RETURN + * + * This matches the `build_name_index_script` method in the Namecoin + * ElectrumX fork (electrumx/lib/coins.py). + */ + private fun buildNameIndexScript(nameBytes: ByteArray): ByteArray { + val result = mutableListOf() + result.add(OP_NAME_UPDATE) + result.addAll(pushData(nameBytes).toList()) + result.addAll(pushData(byteArrayOf()).toList()) // empty value + result.add(OP_2DROP) + result.add(OP_DROP) + result.add(OP_RETURN) + return result.toByteArray() + } + + /** + * Bitcoin-style push data encoding. + */ + private fun pushData(data: ByteArray): ByteArray { + val len = data.size + return when { + len < 0x4c -> { + byteArrayOf(len.toByte()) + data + } + + len <= 0xff -> { + byteArrayOf(OP_PUSHDATA1, len.toByte()) + data + } + + else -> { + val lenBytes = byteArrayOf((len and 0xff).toByte(), ((len shr 8) and 0xff).toByte()) + byteArrayOf(OP_PUSHDATA2) + lenBytes + data + } + } + } + + /** + * Electrum protocol scripthash: SHA-256 of the script, byte-reversed, hex-encoded. + */ + private fun electrumScriptHash(script: ByteArray): String { + val digest = MessageDigest.getInstance("SHA-256").digest(script) + return digest.reversedArray().joinToString("") { "%02x".format(it) } + } + + /** + * Parse the block height from a `blockchain.headers.subscribe` response. + * + * Response format: {"result": {"height": 814300, "hex": "..."}, ...} + */ + private fun parseBlockHeight(raw: String?): Int? { + if (raw == null) return null + return try { + val envelope = json.parseToJsonElement(raw).jsonObject + val result = envelope["result"]?.jsonObject ?: return null + result["height"]?.jsonPrimitive?.int + } catch (_: Exception) { + null + } + } + + /** + * Parse the history response into a list of (txHash, height) pairs. + */ + private fun parseHistoryResponse(raw: String): List>? { + val envelope = json.parseToJsonElement(raw).jsonObject + val error = envelope["error"] + if (error != null && error !is kotlinx.serialization.json.JsonNull) return null + + val result = envelope["result"]?.jsonArray ?: return null + return result.mapNotNull { entry -> + val obj = entry.jsonObject + val txHash = obj["tx_hash"]?.jsonPrimitive?.content ?: return@mapNotNull null + val height = obj["height"]?.jsonPrimitive?.int ?: return@mapNotNull null + txHash to height + } + } + + /** + * Parse a Namecoin name and value from a verbose transaction response. + * + * Scans each output for a NAME_UPDATE script (starts with OP_3 = 0x53), + * then extracts the name and value from the script's push data. + */ + private fun parseNameFromTransaction( + identifier: String, + txHash: String, + height: Int, + raw: String, + ): NameShowResult? { + val envelope = json.parseToJsonElement(raw).jsonObject + val error = envelope["error"] + if (error != null && error !is kotlinx.serialization.json.JsonNull) return null + + val result = envelope["result"]?.jsonObject ?: return null + val vouts = result["vout"]?.jsonArray ?: return null + + for (vout in vouts) { + val scriptHex = + vout.jsonObject["scriptPubKey"] + ?.jsonObject + ?.get("hex") + ?.jsonPrimitive + ?.content + ?: continue + + // NAME_UPDATE scripts start with OP_3 (0x53) + if (!scriptHex.startsWith("53")) continue + + val scriptBytes = hexToBytes(scriptHex) + val parsed = parseNameScript(scriptBytes) ?: continue + + // Verify this is the name we're looking for + if (parsed.first == identifier) { + return NameShowResult( + name = parsed.first, + value = parsed.second, + txid = txHash, + height = height, + ) + } + } + + return null + } + + /** + * Parse a NAME_UPDATE script to extract the name and value. + * + * Script format: OP_NAME_UPDATE OP_2DROP OP_DROP + * + * @return Pair of (name, value) as strings, or null if parsing fails + */ + private fun parseNameScript(script: ByteArray): Pair? { + if (script.isEmpty() || script[0] != OP_NAME_UPDATE) return null + + var pos = 1 + + // Read name + val (nameBytes, newPos1) = readPushData(script, pos) ?: return null + pos = newPos1 + + // Read value + val (valueBytes, _) = readPushData(script, pos) ?: return null + + val name = String(nameBytes, Charsets.US_ASCII) + val value = String(valueBytes, Charsets.UTF_8) + return name to value + } + + /** + * Read a push-data encoded byte sequence from the script at the given position. + * + * @return Pair of (data, nextPosition), or null if the script is malformed + */ + private fun readPushData( + script: ByteArray, + pos: Int, + ): Pair? { + if (pos >= script.size) return null + + val opcode = script[pos].toInt() and 0xff + return when { + opcode == 0 -> { + // OP_0 / push empty + byteArrayOf() to (pos + 1) + } + + opcode < 0x4c -> { + // Direct push: opcode is the length + val end = pos + 1 + opcode + if (end > script.size) return null + script.copyOfRange(pos + 1, end) to end + } + + opcode == 0x4c -> { + // OP_PUSHDATA1: next byte is length + if (pos + 2 > script.size) return null + val len = script[pos + 1].toInt() and 0xff + val end = pos + 2 + len + if (end > script.size) return null + script.copyOfRange(pos + 2, end) to end + } + + opcode == 0x4d -> { + // OP_PUSHDATA2: next 2 bytes are length (little-endian) + if (pos + 3 > script.size) return null + val len = + (script[pos + 1].toInt() and 0xff) or + ((script[pos + 2].toInt() and 0xff) shl 8) + val end = pos + 3 + len + if (end > script.size) return null + script.copyOfRange(pos + 3, end) to end + } + + else -> { + null + } + } + } + + private fun hexToBytes(hex: String): ByteArray { + val len = hex.length + val data = ByteArray(len / 2) + for (i in 0 until len step 2) { + data[i / 2] = + ( + (Character.digit(hex[i], 16) shl 4) + + Character.digit(hex[i + 1], 16) + ).toByte() + } + return data + } + + private fun createSocket(server: ElectrumxServer): Socket { + // Create the base socket through the injected factory, which + // may route through a SOCKS proxy (e.g. Tor) if configured. + val baseSocket = + socketFactory().createSocket().apply { + connect(InetSocketAddress(server.host, server.port), connectTimeoutMs.toInt()) + } + + if (!server.useSsl) return baseSocket + + // Upgrade to TLS over the already-connected (possibly proxied) socket. + val sslFactory = + if (server.trustAllCerts) { + trustAllSslFactory() + } else { + SSLSocketFactory.getDefault() as SSLSocketFactory + } + return sslFactory.createSocket(baseSocket, server.host, server.port, true) + } + + /** + * Create an SSLSocketFactory that accepts any certificate. + * Used for servers with self-signed certificates. + */ + private fun trustAllSslFactory(): SSLSocketFactory { + val trustAllCerts = + arrayOf( + object : X509TrustManager { + override fun checkClientTrusted( + chain: Array, + authType: String, + ) {} + + override fun checkServerTrusted( + chain: Array, + authType: String, + ) {} + + override fun getAcceptedIssuers(): Array = arrayOf() + }, + ) + val sslContext = SSLContext.getInstance("TLS") + sslContext.init(null, trustAllCerts, java.security.SecureRandom()) + return sslContext.socketFactory + } + + private fun buildRpcRequest( + method: String, + params: List, + ): String { + val id = requestId.incrementAndGet() + val obj = + buildJsonObject { + put("jsonrpc", "2.0") + put("id", id) + put("method", method) + put( + "params", + json.encodeToJsonElement( + kotlinx.serialization.builtins.ListSerializer( + kotlinx.serialization.json.JsonElement + .serializer(), + ), + params.map { + when (it) { + is Boolean -> JsonPrimitive(it) + is Number -> JsonPrimitive(it) + else -> JsonPrimitive(it.toString()) + } + }, + ), + ) + } + return json.encodeToString(JsonObject.serializer(), obj) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinLookupCache.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinLookupCache.kt new file mode 100644 index 000000000..0c211e375 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinLookupCache.kt @@ -0,0 +1,77 @@ +/* + * 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.quartz.nip05.namecoin + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +data class CachedResult( + val result: NamecoinNostrResult?, + val timestamp: Long = System.currentTimeMillis(), +) + +class NamecoinLookupCache( + private val maxEntries: Int = 500, + private val ttlMs: Long = 3_600_000L, // 1 hour +) { + private val cache = LinkedHashMap(maxEntries, 0.75f, true) + private val mutex = Mutex() + + /** + * Normalised cache key from the user's raw input. + */ + private fun cacheKey(identifier: String): String = identifier.trim().lowercase() + + suspend fun get(identifier: String): CachedResult? = + mutex.withLock { + val key = cacheKey(identifier) + val entry = cache[key] ?: return null + val age = System.currentTimeMillis() - entry.timestamp + if (age > ttlMs) { + cache.remove(key) + return null + } + return entry + } + + suspend fun put( + identifier: String, + result: NamecoinNostrResult?, + ) = mutex.withLock { + val key = cacheKey(identifier) + if (cache.size >= maxEntries) { + // Remove eldest (LRU) entry + val eldest = cache.entries.firstOrNull() + if (eldest != null) cache.remove(eldest.key) + } + cache[key] = CachedResult(result) + } + + suspend fun invalidate(identifier: String) = + mutex.withLock { + cache.remove(cacheKey(identifier)) + } + + suspend fun clear() = + mutex.withLock { + cache.clear() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt new file mode 100644 index 000000000..f0f7549c1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolver.kt @@ -0,0 +1,314 @@ +/* + * 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.quartz.nip05.namecoin + +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject + +/** + * Result of resolving a Namecoin name to Nostr identity data. + */ +data class NamecoinNostrResult( + /** Hex-encoded 32-byte Schnorr public key */ + val pubkey: String, + /** Optional relay URLs where this user can be found */ + val relays: List = emptyList(), + /** The Namecoin name that was resolved (e.g. "d/example") */ + val namecoinName: String, + /** The local-part that was matched (e.g. "alice" or "_") */ + val localPart: String = "_", +) + +/** + * Resolves Namecoin names to Nostr public keys. + * + * This is the primary entry point for Namecoin→Nostr resolution. + * It is designed to be used alongside Amethyst's existing NIP-05 + * verifier: if an identifier ends with `.bit`, it should be routed + * here instead of to the HTTP-based NIP-05 path. + */ +class NamecoinNameResolver( + private val electrumxClient: ElectrumxClient = ElectrumxClient(), + private val lookupTimeoutMs: Long = 20_000L, + private val serverListProvider: () -> List = { ElectrumxClient.DEFAULT_SERVERS }, +) { + private val json = + Json { + ignoreUnknownKeys = true + isLenient = true + } + + companion object { + private val HEX_PUBKEY_REGEX = Regex("^[0-9a-fA-F]{64}$") + + /** + * Check whether an identifier should be routed to Namecoin + * resolution rather than standard NIP-05. + */ + fun isNamecoinIdentifier(identifier: String): Boolean { + val normalized = identifier.trim().lowercase() + return normalized.endsWith(".bit") || + normalized.startsWith("d/") || + normalized.startsWith("id/") + } + } + + /** + * Resolve a user-supplied identifier to a Nostr pubkey via Namecoin. + * + * @param identifier User input, e.g. "alice@example.bit", "id/alice", "example.bit" + * @return [NamecoinNostrResult] on success, null if resolution failed + */ + suspend fun resolve(identifier: String): NamecoinNostrResult? { + val parsed = parseIdentifier(identifier) ?: return null + return withTimeoutOrNull(lookupTimeoutMs) { + performLookup(parsed) + } + } + + // ── Identifier Parsing ───────────────────────────────────────────── + + /** + * Parsed representation of a Namecoin lookup request. + */ + private data class ParsedIdentifier( + /** The Namecoin name to query, e.g. "d/example" or "id/alice" */ + val namecoinName: String, + /** The local-part to look up within the name's value. + * For d/ names: the user part (or "_" for root). + * For id/ names: always "_". */ + val localPart: String, + /** Which namespace: DOMAIN or IDENTITY */ + val namespace: Namespace, + ) + + private enum class Namespace { DOMAIN, IDENTITY } + + /** + * Parse a user-supplied string into a structured lookup request. + * + * Accepted formats: + * "alice@example.bit" → d/example, localPart=alice + * "_@example.bit" → d/example, localPart=_ + * "example.bit" → d/example, localPart=_ + * "d/example" → d/example, localPart=_ + * "id/alice" → id/alice, localPart=_ + */ + private fun parseIdentifier(raw: String): ParsedIdentifier? { + val input = raw.trim() + + // Direct namespace references + if (input.startsWith("d/", ignoreCase = true)) { + return ParsedIdentifier( + namecoinName = input.lowercase(), + localPart = "_", + namespace = Namespace.DOMAIN, + ) + } + if (input.startsWith("id/", ignoreCase = true)) { + return ParsedIdentifier( + namecoinName = input.lowercase(), + localPart = "_", + namespace = Namespace.IDENTITY, + ) + } + + // NIP-05 style: user@domain.bit + if (input.contains("@") && input.endsWith(".bit", ignoreCase = true)) { + val parts = input.split("@", limit = 2) + if (parts.size != 2) return null + val localPart = parts[0].lowercase().ifEmpty { "_" } + val domain = parts[1].removeSuffix(".bit").lowercase() + if (domain.isEmpty()) return null + return ParsedIdentifier( + namecoinName = "d/$domain", + localPart = localPart, + namespace = Namespace.DOMAIN, + ) + } + + // Bare domain: example.bit + if (input.endsWith(".bit", ignoreCase = true)) { + val domain = input.removeSuffix(".bit").lowercase() + if (domain.isEmpty()) return null + return ParsedIdentifier( + namecoinName = "d/$domain", + localPart = "_", + namespace = Namespace.DOMAIN, + ) + } + + return null + } + + // ── Lookup & Value Parsing ───────────────────────────────────────── + + private suspend fun performLookup(parsed: ParsedIdentifier): NamecoinNostrResult? { + val nameResult = electrumxClient.nameShowWithFallback(parsed.namecoinName, serverListProvider()) ?: return null + val valueJson = tryParseJson(nameResult.value) ?: return null + + return when (parsed.namespace) { + Namespace.DOMAIN -> extractFromDomainValue(valueJson, parsed) + Namespace.IDENTITY -> extractFromIdentityValue(valueJson, parsed) + } + } + + /** + * Extract Nostr data from a `d/` domain value. + * + * Supports: + * { "nostr": "hex-pubkey" } → simple form + * { "nostr": { "names": { "alice": "hex" }, ... } } → extended NIP-05-like form + */ + private fun extractFromDomainValue( + value: JsonObject, + parsed: ParsedIdentifier, + ): NamecoinNostrResult? { + val nostrField = value["nostr"] ?: return null + + // Simple form: "nostr": "hex-pubkey" + if (nostrField is JsonPrimitive && nostrField.isString) { + val pubkey = nostrField.content + if (parsed.localPart == "_" && isValidPubkey(pubkey)) { + return NamecoinNostrResult( + pubkey = pubkey.lowercase(), + namecoinName = parsed.namecoinName, + localPart = "_", + ) + } + // Simple form only supports root — if a non-root local-part + // was requested, we can't resolve it. + if (parsed.localPart != "_") return null + } + + // Extended form: "nostr": { "names": {...}, "relays": {...} } + if (nostrField is JsonObject) { + val names = nostrField["names"]?.jsonObject ?: return null + val pubkeyElem = names[parsed.localPart] ?: names["_"] // fall back to root + val pubkey = (pubkeyElem as? JsonPrimitive)?.content ?: return null + if (!isValidPubkey(pubkey)) return null + + val relays = extractRelays(nostrField, pubkey) + return NamecoinNostrResult( + pubkey = pubkey.lowercase(), + relays = relays, + namecoinName = parsed.namecoinName, + localPart = parsed.localPart, + ) + } + + return null + } + + /** + * Extract Nostr data from an `id/` identity value. + * + * The id/ namespace stores general identity data. We look for: + * { "nostr": "hex-pubkey" } + * { "nostr": { "pubkey": "hex", "relays": [...] } } + */ + private fun extractFromIdentityValue( + value: JsonObject, + parsed: ParsedIdentifier, + ): NamecoinNostrResult? { + val nostrField = value["nostr"] ?: return null + + // Simple: "nostr": "hex-pubkey" + if (nostrField is JsonPrimitive && nostrField.isString) { + val pubkey = nostrField.content + if (isValidPubkey(pubkey)) { + return NamecoinNostrResult( + pubkey = pubkey.lowercase(), + namecoinName = parsed.namecoinName, + ) + } + } + + // Object form: "nostr": { "pubkey": "hex", "relays": [...] } + if (nostrField is JsonObject) { + // Try "pubkey" field + val pubkey = (nostrField["pubkey"] as? JsonPrimitive)?.content + if (pubkey != null && isValidPubkey(pubkey)) { + val relays = + try { + nostrField["relays"]?.jsonArray?.mapNotNull { + (it as? JsonPrimitive)?.content + } ?: emptyList() + } catch (_: Exception) { + emptyList() + } + + return NamecoinNostrResult( + pubkey = pubkey.lowercase(), + relays = relays, + namecoinName = parsed.namecoinName, + ) + } + + // Also try NIP-05-like "names" structure for id/ names + val names = nostrField["names"]?.jsonObject + if (names != null) { + val rootPubkey = (names["_"] as? JsonPrimitive)?.content + if (rootPubkey != null && isValidPubkey(rootPubkey)) { + val relays = extractRelays(nostrField, rootPubkey) + return NamecoinNostrResult( + pubkey = rootPubkey.lowercase(), + relays = relays, + namecoinName = parsed.namecoinName, + ) + } + } + } + + return null + } + + // ── Helpers ───────────────────────────────────────────────────────── + + private fun extractRelays( + nostrObj: JsonObject, + pubkey: String, + ): List { + return try { + val relaysMap = nostrObj["relays"]?.jsonObject ?: return emptyList() + val relayArray = + relaysMap[pubkey.lowercase()]?.jsonArray + ?: relaysMap[pubkey]?.jsonArray + ?: return emptyList() + relayArray.mapNotNull { (it as? JsonPrimitive)?.content } + } catch (_: Exception) { + emptyList() + } + } + + private fun tryParseJson(raw: String): JsonObject? = + try { + json.parseToJsonElement(raw).jsonObject + } catch (_: Exception) { + null + } + + private fun isValidPubkey(s: String): Boolean = HEX_PUBKEY_REGEX.matches(s) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt index c84aeed30..438c3f154 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip05DnsIdentifiers/Nip05Client.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip05DnsIdentifiers import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip05.namecoin.NamecoinNameResolver import kotlinx.coroutines.CancellationException data class Nip05KeyInfo( @@ -30,6 +31,7 @@ data class Nip05KeyInfo( class Nip05Client( val fetcher: Nip05Fetcher, + val namecoinResolver: NamecoinNameResolver? = null, ) { val parser = Nip05Parser() @@ -37,6 +39,12 @@ class Nip05Client( nip05: Nip05Id, hexKey: HexKey, ): Boolean { + // Namecoin: route .bit domains to blockchain verification + if (namecoinResolver != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) { + val result = namecoinResolver.resolve(nip05.toValue()) + return result?.pubkey == hexKey + } + val json = fetchNip05Data(nip05) val key = @@ -54,7 +62,15 @@ class Nip05Client( } } - suspend fun get(nip05: Nip05Id) = parser.parseHexKeyAndRelays(nip05, fetchNip05Data(nip05)) + suspend fun get(nip05: Nip05Id): Nip05KeyInfo? { + // Namecoin: route .bit domains to blockchain resolution + if (namecoinResolver != null && NamecoinNameResolver.isNamecoinIdentifier(nip05.toValue())) { + val result = namecoinResolver.resolve(nip05.toValue()) ?: return null + return Nip05KeyInfo(result.pubkey, result.relays) + } + + return parser.parseHexKeyAndRelays(nip05, fetchNip05Data(nip05)) + } suspend fun load(nip05: Nip05Id) = parser.parse(fetchNip05Data(nip05)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt index 65849aa77..cb6542ce9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt @@ -21,6 +21,9 @@ package com.vitorpamplona.quartz.nip64Chess import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.jester.JesterProtocol /** * Deterministic chess state reconstruction from Jester protocol events. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt deleted file mode 100644 index 4381f832c..000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt +++ /dev/null @@ -1,295 +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.quartz.nip64Chess - -import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip31Alts.alt -import com.vitorpamplona.quartz.utils.TimeUtils - -/** - * Live Chess Game Challenge Event (Kind 30064) - * - * Challenge another player to a chess game or create an open challenge. - * This is a parameterized replaceable event. - * - * Tags: - * - d: game_id (unique identifier for this game) - * - p: opponent pubkey (optional, for direct challenges) - * - player_color: "white" or "black" - * - time_control: optional time control (e.g., "10+0", "5+3") - */ -@Immutable -class LiveChessGameChallengeEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 30064 - - fun build( - gameId: String, - playerColor: Color, - opponentPubkey: String? = null, - timeControl: String? = null, - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { - add(arrayOf("d", gameId)) - add(arrayOf("player_color", if (playerColor == Color.WHITE) "white" else "black")) - opponentPubkey?.let { add(arrayOf("p", it)) } - timeControl?.let { add(arrayOf("time_control", it)) } - alt("Chess game challenge") - initializer() - } - } - - fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) - - fun playerColor(): Color? = - tags - .firstOrNull { it.size >= 2 && it[0] == "player_color" } - ?.get(1) - ?.let { if (it == "white") Color.WHITE else Color.BLACK } - - fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) - - fun timeControl(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "time_control" }?.get(1) -} - -/** - * Live Chess Game Accept Event (Kind 30065) - * - * Accept a chess game challenge - * - * Tags: - * - d: game_id (same as challenge) - * - e: challenge event ID - * - p: challenger pubkey - */ -@Immutable -class LiveChessGameAcceptEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 30065 - - fun build( - gameId: String, - challengeEventId: String, - challengerPubkey: String, - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, "", createdAt) { - add(arrayOf("d", gameId)) - add(arrayOf("e", challengeEventId)) - add(arrayOf("p", challengerPubkey)) - alt("Chess game acceptance") - initializer() - } - } - - fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) - - fun challengeEventId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) - - fun challengerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) -} - -/** - * Live Chess Move Event (Kind 30066) - * - * Individual move in a live chess game - * - * Tags: - * - d: game_id - * - move_number: move number (1-based) - * - san: move in Standard Algebraic Notation - * - fen: resulting position in FEN notation - * - p: opponent pubkey - * - * Content: Optional move comment - */ -@Immutable -class LiveChessMoveEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 30066 - - fun build( - gameId: String, - moveNumber: Int, - san: String, - fen: String, - opponentPubkey: String, - comment: String = "", - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, comment, createdAt) { - add(arrayOf("d", "$gameId-$moveNumber")) - add(arrayOf("game_id", gameId)) - add(arrayOf("move_number", moveNumber.toString())) - add(arrayOf("san", san)) - add(arrayOf("fen", fen)) - add(arrayOf("p", opponentPubkey)) - alt("Chess move: $san") - initializer() - } - } - - fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "game_id" }?.get(1) - - fun moveNumber(): Int? = - tags - .firstOrNull { it.size >= 2 && it[0] == "move_number" } - ?.get(1) - ?.toIntOrNull() - - fun san(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "san" }?.get(1) - - fun fen(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "fen" }?.get(1) - - fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) - - fun comment(): String = content -} - -/** - * Live Chess Game End Event (Kind 30067) - * - * Game result and termination - * - * Tags: - * - d: game_id - * - result: "1-0"|"0-1"|"1/2-1/2" - * - termination: reason for game end - * - winner: pubkey of winner (if applicable) - * - p: opponent pubkey - * - * Content: Optional PGN of complete game - */ -@Immutable -class LiveChessGameEndEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 30067 - - fun build( - gameId: String, - result: GameResult, - termination: GameTermination, - winnerPubkey: String? = null, - opponentPubkey: String, - pgn: String = "", - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, pgn, createdAt) { - add(arrayOf("d", gameId)) - add(arrayOf("result", result.notation)) - add(arrayOf("termination", termination.name.lowercase())) - winnerPubkey?.let { add(arrayOf("winner", it)) } - add(arrayOf("p", opponentPubkey)) - alt("Chess game ended: ${result.notation}") - initializer() - } - } - - fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) - - fun result(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "result" }?.get(1) - - fun termination(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "termination" }?.get(1) - - fun winnerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "winner" }?.get(1) - - fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) - - fun pgn(): String = content -} - -/** - * Live Chess Draw Offer Event (Kind 30068) - * - * Offer a draw to opponent. Opponent can accept by sending a game end event - * with DRAW_AGREEMENT termination, or decline/ignore by making their next move. - * - * Tags: - * - d: game_id - * - p: opponent pubkey - * - * Content: Optional message - */ -@Immutable -class LiveChessDrawOfferEvent( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - tags: Array>, - content: String, - sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { - companion object { - const val KIND = 30068 - - fun build( - gameId: String, - opponentPubkey: String, - message: String = "", - createdAt: Long = TimeUtils.now(), - initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, message, createdAt) { - add(arrayOf("d", gameId)) - add(arrayOf("p", opponentPubkey)) - alt("Chess draw offer") - initializer() - } - } - - fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) - - fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) - - fun message(): String = content -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/LiveChessGameAcceptEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/LiveChessGameAcceptEvent.kt new file mode 100644 index 000000000..16872f468 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/LiveChessGameAcceptEvent.kt @@ -0,0 +1,72 @@ +/* + * 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.quartz.nip64Chess.accept + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Live Chess Game Accept Event (Kind 30065) + * + * Accept a chess game challenge + * + * Tags: + * - d: game_id (same as challenge) + * - e: challenge event ID + * - p: challenger pubkey + */ +@Immutable +class LiveChessGameAcceptEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun challengeEventId() = tags.challengeEventId() + + fun challengerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + companion object { + const val KIND = 30065 + const val ALT_DESCRIPTION = "Chess game acceptance" + + fun build( + gameId: String, + challengeEventId: String, + challengerPubkey: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + add(arrayOf("d", gameId)) + challengeEvent(challengeEventId) + add(arrayOf("p", challengerPubkey)) + alt(ALT_DESCRIPTION) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/TagArrayBuilderExt.kt new file mode 100644 index 000000000..4f0e0391c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/TagArrayBuilderExt.kt @@ -0,0 +1,26 @@ +/* + * 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.quartz.nip64Chess.accept + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip64Chess.accept.tags.ChallengeEventTag + +fun TagArrayBuilder.challengeEvent(challengeEventId: String) = add(ChallengeEventTag.assemble(challengeEventId)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/TagArrayExt.kt new file mode 100644 index 000000000..86a529305 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/TagArrayExt.kt @@ -0,0 +1,26 @@ +/* + * 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.quartz.nip64Chess.accept + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip64Chess.accept.tags.ChallengeEventTag + +fun TagArray.challengeEventId() = firstNotNullOfOrNull(ChallengeEventTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/tags/ChallengeEventTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/tags/ChallengeEventTag.kt new file mode 100644 index 000000000..803b0e869 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/accept/tags/ChallengeEventTag.kt @@ -0,0 +1,40 @@ +/* + * 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.quartz.nip64Chess.accept.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ChallengeEventTag { + companion object { + const val TAG_NAME = "e" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + fun assemble(challengeEventId: String) = arrayOf(TAG_NAME, challengeEventId) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/LiveChessGameChallengeEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/LiveChessGameChallengeEvent.kt new file mode 100644 index 000000000..98cc21fa7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/LiveChessGameChallengeEvent.kt @@ -0,0 +1,79 @@ +/* + * 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.quartz.nip64Chess.challenge + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Live Chess Game Challenge Event (Kind 30064) + * + * Challenge another player to a chess game or create an open challenge. + * This is a parameterized replaceable event. + * + * Tags: + * - d: game_id (unique identifier for this game) + * - p: opponent pubkey (optional, for direct challenges) + * - player_color: "white" or "black" + * - time_control: optional time control (e.g., "10+0", "5+3") + */ +@Immutable +class LiveChessGameChallengeEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun playerColor() = tags.playerColor() + + fun timeControl() = tags.timeControl() + + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + companion object { + const val KIND = 30064 + const val ALT_DESCRIPTION = "Chess game challenge" + + fun build( + gameId: String, + playerColor: Color, + opponentPubkey: String? = null, + timeControl: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + add(arrayOf("d", gameId)) + playerColor(playerColor) + opponentPubkey?.let { add(arrayOf("p", it)) } + timeControl?.let { timeControl(it) } + alt(ALT_DESCRIPTION) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/TagArrayBuilderExt.kt new file mode 100644 index 000000000..a110d2405 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/TagArrayBuilderExt.kt @@ -0,0 +1,30 @@ +/* + * 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.quartz.nip64Chess.challenge + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.challenge.tags.PlayerColorTag +import com.vitorpamplona.quartz.nip64Chess.challenge.tags.TimeControlTag + +fun TagArrayBuilder.playerColor(color: Color) = addUnique(PlayerColorTag.assemble(color)) + +fun TagArrayBuilder.timeControl(timeControl: String) = addUnique(TimeControlTag.assemble(timeControl)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/TagArrayExt.kt new file mode 100644 index 000000000..01a247b87 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/TagArrayExt.kt @@ -0,0 +1,29 @@ +/* + * 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.quartz.nip64Chess.challenge + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip64Chess.challenge.tags.PlayerColorTag +import com.vitorpamplona.quartz.nip64Chess.challenge.tags.TimeControlTag + +fun TagArray.playerColor() = firstNotNullOfOrNull(PlayerColorTag::parse) + +fun TagArray.timeControl() = firstNotNullOfOrNull(TimeControlTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/tags/PlayerColorTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/tags/PlayerColorTag.kt new file mode 100644 index 000000000..983ab756d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/tags/PlayerColorTag.kt @@ -0,0 +1,45 @@ +/* + * 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.quartz.nip64Chess.challenge.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.utils.ensure + +class PlayerColorTag { + companion object { + const val TAG_NAME = "player_color" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Color? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return when (tag[1]) { + "white" -> Color.WHITE + "black" -> Color.BLACK + else -> null + } + } + + fun assemble(color: Color) = arrayOf(TAG_NAME, if (color == Color.WHITE) "white" else "black") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/tags/TimeControlTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/tags/TimeControlTag.kt new file mode 100644 index 000000000..84f1c9494 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/challenge/tags/TimeControlTag.kt @@ -0,0 +1,40 @@ +/* + * 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.quartz.nip64Chess.challenge.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class TimeControlTag { + companion object { + const val TAG_NAME = "time_control" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(timeControl: String) = arrayOf(TAG_NAME, timeControl) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/draw/LiveChessDrawOfferEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/draw/LiveChessDrawOfferEvent.kt new file mode 100644 index 000000000..ab7c05076 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/draw/LiveChessDrawOfferEvent.kt @@ -0,0 +1,73 @@ +/* + * 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.quartz.nip64Chess.draw + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Live Chess Draw Offer Event (Kind 30068) + * + * Offer a draw to opponent. Opponent can accept by sending a game end event + * with DRAW_AGREEMENT termination, or decline/ignore by making their next move. + * + * Tags: + * - d: game_id + * - p: opponent pubkey + * + * Content: Optional message + */ +@Immutable +class LiveChessDrawOfferEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + fun message(): String = content + + companion object { + const val KIND = 30068 + const val ALT_DESCRIPTION = "Chess draw offer" + + fun build( + gameId: String, + opponentPubkey: String, + message: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, message, createdAt) { + add(arrayOf("d", gameId)) + add(arrayOf("p", opponentPubkey)) + alt(ALT_DESCRIPTION) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/LiveChessGameEndEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/LiveChessGameEndEvent.kt new file mode 100644 index 000000000..f7f2773ec --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/LiveChessGameEndEvent.kt @@ -0,0 +1,89 @@ +/* + * 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.quartz.nip64Chess.end + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip64Chess.GameResult +import com.vitorpamplona.quartz.nip64Chess.GameTermination +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Live Chess Game End Event (Kind 30067) + * + * Game result and termination + * + * Tags: + * - d: game_id + * - result: "1-0"|"0-1"|"1/2-1/2" + * - termination: reason for game end + * - winner: pubkey of winner (if applicable) + * - p: opponent pubkey + * + * Content: Optional PGN of complete game + */ +@Immutable +class LiveChessGameEndEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun result() = tags.result() + + fun termination() = tags.termination() + + fun winnerPubkey() = tags.winnerPubkey() + + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + fun pgn(): String = content + + companion object { + const val KIND = 30067 + const val ALT_DESCRIPTION = "Chess game ended" + + fun build( + gameId: String, + result: GameResult, + termination: GameTermination, + winnerPubkey: String? = null, + opponentPubkey: String, + pgn: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, pgn, createdAt) { + add(arrayOf("d", gameId)) + result(result) + termination(termination) + winnerPubkey?.let { winner(it) } + add(arrayOf("p", opponentPubkey)) + alt("$ALT_DESCRIPTION: ${result.notation}") + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/TagArrayBuilderExt.kt new file mode 100644 index 000000000..22da9eae7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/TagArrayBuilderExt.kt @@ -0,0 +1,35 @@ +/* + * 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.quartz.nip64Chess.end + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip64Chess.GameResult +import com.vitorpamplona.quartz.nip64Chess.GameTermination +import com.vitorpamplona.quartz.nip64Chess.end.tags.ResultTag +import com.vitorpamplona.quartz.nip64Chess.end.tags.TerminationTag +import com.vitorpamplona.quartz.nip64Chess.end.tags.WinnerTag + +fun TagArrayBuilder.result(result: GameResult) = addUnique(ResultTag.assemble(result)) + +fun TagArrayBuilder.termination(termination: GameTermination) = addUnique(TerminationTag.assemble(termination)) + +fun TagArrayBuilder.winner(winnerPubkey: HexKey) = addUnique(WinnerTag.assemble(winnerPubkey)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/TagArrayExt.kt new file mode 100644 index 000000000..55ea46743 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/TagArrayExt.kt @@ -0,0 +1,32 @@ +/* + * 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.quartz.nip64Chess.end + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip64Chess.end.tags.ResultTag +import com.vitorpamplona.quartz.nip64Chess.end.tags.TerminationTag +import com.vitorpamplona.quartz.nip64Chess.end.tags.WinnerTag + +fun TagArray.result() = firstNotNullOfOrNull(ResultTag::parse) + +fun TagArray.termination() = firstNotNullOfOrNull(TerminationTag::parse) + +fun TagArray.winnerPubkey() = firstNotNullOfOrNull(WinnerTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/ResultTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/ResultTag.kt new file mode 100644 index 000000000..769246b08 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/ResultTag.kt @@ -0,0 +1,41 @@ +/* + * 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.quartz.nip64Chess.end.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip64Chess.GameResult +import com.vitorpamplona.quartz.utils.ensure + +class ResultTag { + companion object { + const val TAG_NAME = "result" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(result: GameResult) = arrayOf(TAG_NAME, result.notation) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/TerminationTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/TerminationTag.kt new file mode 100644 index 000000000..9a22a5292 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/TerminationTag.kt @@ -0,0 +1,41 @@ +/* + * 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.quartz.nip64Chess.end.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip64Chess.GameTermination +import com.vitorpamplona.quartz.utils.ensure + +class TerminationTag { + companion object { + const val TAG_NAME = "termination" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(termination: GameTermination) = arrayOf(TAG_NAME, termination.name.lowercase()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt new file mode 100644 index 000000000..c260a465b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/end/tags/WinnerTag.kt @@ -0,0 +1,41 @@ +/* + * 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.quartz.nip64Chess.end.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class WinnerTag { + companion object { + const val TAG_NAME = "winner" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): HexKey? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(winnerPubkey: HexKey) = arrayOf(TAG_NAME, winnerPubkey) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/game/ChessGameEvent.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEvent.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/game/ChessGameEvent.kt index 664c60b0b..f0d7f00e7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/game/ChessGameEvent.kt @@ -18,7 +18,7 @@ * 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.quartz.nip64Chess +package com.vitorpamplona.quartz.nip64Chess.game import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -50,6 +50,16 @@ class ChessGameEvent( content: String, // PGN database format sig: HexKey, ) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + /** + * Get PGN content from event + */ + fun pgn(): String = content + + /** + * Get alt text for non-supporting clients (NIP-31) + */ + fun altText(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "alt" }?.get(1) + companion object { const val KIND = 64 const val ALT_DESCRIPTION = "Chess Game" @@ -73,14 +83,4 @@ class ChessGameEvent( initializer() } } - - /** - * Get PGN content from event - */ - fun pgn(): String = content - - /** - * Get alt text for non-supporting clients (NIP-31) - */ - fun altText(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "alt" }?.get(1) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterContent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterContent.kt new file mode 100644 index 000000000..0eb536cd3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterContent.kt @@ -0,0 +1,40 @@ +/* + * 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.quartz.nip64Chess.jester + +import kotlinx.serialization.Serializable + +/** + * JSON content structure for Jester events + */ +@Serializable +data class JesterContent( + val version: String = "0", + val kind: Int, + val fen: String = JesterProtocol.FEN_START, + val move: String? = null, + val history: List = emptyList(), + val nonce: String? = null, + // Extended fields for Amethyst (backward compatible - jesterui ignores unknown fields) + val playerColor: String? = null, // "white" or "black" - challenger's color choice + val result: String? = null, // "1-0", "0-1", "1/2-1/2" for game end + val termination: String? = null, // "checkmate", "resignation", "draw_agreement", etc. +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterEvent.kt similarity index 80% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEvents.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterEvent.kt index f650b2194..6b9075837 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEvents.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterEvent.kt @@ -18,7 +18,7 @@ * 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.quartz.nip64Chess +package com.vitorpamplona.quartz.nip64Chess.jester import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.Event @@ -26,65 +26,12 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.GameResult +import com.vitorpamplona.quartz.nip64Chess.GameTermination import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -/** - * Jester Protocol Implementation - * - * Compatible with jesterui (https://github.com/jesterui/jesterui) - * - * Key differences from previous implementation: - * - Single event kind (30) for all chess messages - * - Content is JSON with: version, kind, fen, move, history, nonce - * - Event linking via e-tags: [startId] or [startId, headId] - * - Full move history included in every move event - * - * Content kind values: - * - 0: Game start (challenge) - * - 1: Move - * - 2: Chat (not implemented) - * - * Reference: https://github.com/jesterui/jesterui/blob/devel/FLOW.md - */ -object JesterProtocol { - /** Jester uses kind 30 for all chess events */ - const val KIND = 30 - - /** SHA256 of starting FEN position - used as reference for game discovery */ - const val START_POSITION_HASH = "b1791d7fc9ae3d38966568c257ffb3a02cbf8394cdb4805bc70f64fc3c0b6879" - - /** Standard starting FEN */ - const val FEN_START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" - - /** Content kind for game start */ - const val CONTENT_KIND_START = 0 - - /** Content kind for move */ - const val CONTENT_KIND_MOVE = 1 - - /** Content kind for chat */ - const val CONTENT_KIND_CHAT = 2 -} - -/** - * JSON content structure for Jester events - */ -@Serializable -data class JesterContent( - val version: String = "0", - val kind: Int, - val fen: String = JesterProtocol.FEN_START, - val move: String? = null, - val history: List = emptyList(), - val nonce: String? = null, - // Extended fields for Amethyst (backward compatible - jesterui ignores unknown fields) - val playerColor: String? = null, // "white" or "black" - challenger's color choice - val result: String? = null, // "1-0", "0-1", "1/2-1/2" for game end - val termination: String? = null, // "checkmate", "resignation", "draw_agreement", etc. -) - private val json = Json { ignoreUnknownKeys = true @@ -352,30 +299,3 @@ fun Event.toJesterEvent(): JesterEvent? { if (kind != JesterProtocol.KIND) return null return JesterEvent(id, pubKey, createdAt, tags, content, sig) } - -/** - * Game events container for Jester protocol - */ -data class JesterGameEvents( - val startEvent: JesterEvent?, - val moves: List, -) { - companion object { - fun empty() = JesterGameEvents(null, emptyList()) - } - - /** Get the latest move (with longest history) */ - fun latestMove(): JesterEvent? = moves.maxByOrNull { it.history().size } - - /** Get the current FEN position */ - fun currentFen(): String = latestMove()?.fen() ?: startEvent?.fen() ?: JesterProtocol.FEN_START - - /** Get the complete move history */ - fun fullHistory(): List = latestMove()?.history() ?: emptyList() - - /** Check if game has ended */ - fun isEnded(): Boolean = latestMove()?.result() != null - - /** Get the game result if ended */ - fun result(): String? = latestMove()?.result() -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterGameEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterGameEvents.kt new file mode 100644 index 000000000..039805742 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterGameEvents.kt @@ -0,0 +1,48 @@ +/* + * 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.quartz.nip64Chess.jester + +/** + * Game events container for Jester protocol + */ +data class JesterGameEvents( + val startEvent: JesterEvent?, + val moves: List, +) { + companion object { + fun empty() = JesterGameEvents(null, emptyList()) + } + + /** Get the latest move (with longest history) */ + fun latestMove(): JesterEvent? = moves.maxByOrNull { it.history().size } + + /** Get the current FEN position */ + fun currentFen(): String = latestMove()?.fen() ?: startEvent?.fen() ?: JesterProtocol.FEN_START + + /** Get the complete move history */ + fun fullHistory(): List = latestMove()?.history() ?: emptyList() + + /** Check if game has ended */ + fun isEnded(): Boolean = latestMove()?.result() != null + + /** Get the game result if ended */ + fun result(): String? = latestMove()?.result() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterProtocol.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterProtocol.kt new file mode 100644 index 000000000..0ecf6df8d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/jester/JesterProtocol.kt @@ -0,0 +1,59 @@ +/* + * 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.quartz.nip64Chess.jester + +/** + * Jester Protocol Constants + * + * Compatible with jesterui (https://github.com/jesterui/jesterui) + * + * Key differences from previous implementation: + * - Single event kind (30) for all chess messages + * - Content is JSON with: version, kind, fen, move, history, nonce + * - Event linking via e-tags: [startId] or [startId, headId] + * - Full move history included in every move event + * + * Content kind values: + * - 0: Game start (challenge) + * - 1: Move + * - 2: Chat (not implemented) + * + * Reference: https://github.com/jesterui/jesterui/blob/devel/FLOW.md + */ +object JesterProtocol { + /** Jester uses kind 30 for all chess events */ + const val KIND = 30 + + /** SHA256 of starting FEN position - used as reference for game discovery */ + const val START_POSITION_HASH = "b1791d7fc9ae3d38966568c257ffb3a02cbf8394cdb4805bc70f64fc3c0b6879" + + /** Standard starting FEN */ + const val FEN_START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + + /** Content kind for game start */ + const val CONTENT_KIND_START = 0 + + /** Content kind for move */ + const val CONTENT_KIND_MOVE = 1 + + /** Content kind for chat */ + const val CONTENT_KIND_CHAT = 2 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/LiveChessMoveEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/LiveChessMoveEvent.kt new file mode 100644 index 000000000..869930687 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/LiveChessMoveEvent.kt @@ -0,0 +1,90 @@ +/* + * 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.quartz.nip64Chess.move + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Live Chess Move Event (Kind 30066) + * + * Individual move in a live chess game + * + * Tags: + * - d: game_id + * - move_number: move number (1-based) + * - san: move in Standard Algebraic Notation + * - fen: resulting position in FEN notation + * - p: opponent pubkey + * + * Content: Optional move comment + */ +@Immutable +class LiveChessMoveEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun gameId() = tags.gameId() + + fun moveNumber() = tags.moveNumber() + + fun san() = tags.san() + + fun fen() = tags.fen() + + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + fun comment(): String = content + + companion object { + const val KIND = 30066 + const val ALT_DESCRIPTION = "Chess move" + + fun build( + gameId: String, + moveNumber: Int, + san: String, + fen: String, + opponentPubkey: String, + comment: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, comment, createdAt) { + add(arrayOf("d", "$gameId-$moveNumber")) + gameId(gameId) + moveNumber(moveNumber) + san(san) + fen(fen) + add(arrayOf("p", opponentPubkey)) + alt("$ALT_DESCRIPTION: $san") + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/TagArrayBuilderExt.kt new file mode 100644 index 000000000..0a6a0d33c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/TagArrayBuilderExt.kt @@ -0,0 +1,35 @@ +/* + * 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.quartz.nip64Chess.move + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip64Chess.move.tags.FenTag +import com.vitorpamplona.quartz.nip64Chess.move.tags.GameIdTag +import com.vitorpamplona.quartz.nip64Chess.move.tags.MoveNumberTag +import com.vitorpamplona.quartz.nip64Chess.move.tags.SanTag + +fun TagArrayBuilder.gameId(gameId: String) = addUnique(GameIdTag.assemble(gameId)) + +fun TagArrayBuilder.moveNumber(moveNumber: Int) = addUnique(MoveNumberTag.assemble(moveNumber)) + +fun TagArrayBuilder.san(san: String) = addUnique(SanTag.assemble(san)) + +fun TagArrayBuilder.fen(fen: String) = addUnique(FenTag.assemble(fen)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/TagArrayExt.kt new file mode 100644 index 000000000..9651d9a85 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/TagArrayExt.kt @@ -0,0 +1,35 @@ +/* + * 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.quartz.nip64Chess.move + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip64Chess.move.tags.FenTag +import com.vitorpamplona.quartz.nip64Chess.move.tags.GameIdTag +import com.vitorpamplona.quartz.nip64Chess.move.tags.MoveNumberTag +import com.vitorpamplona.quartz.nip64Chess.move.tags.SanTag + +fun TagArray.gameId() = firstNotNullOfOrNull(GameIdTag::parse) + +fun TagArray.moveNumber() = firstNotNullOfOrNull(MoveNumberTag::parse) + +fun TagArray.san() = firstNotNullOfOrNull(SanTag::parse) + +fun TagArray.fen() = firstNotNullOfOrNull(FenTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/FenTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/FenTag.kt new file mode 100644 index 000000000..bda7f867e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/FenTag.kt @@ -0,0 +1,40 @@ +/* + * 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.quartz.nip64Chess.move.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class FenTag { + companion object { + const val TAG_NAME = "fen" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(fen: String) = arrayOf(TAG_NAME, fen) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/GameIdTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/GameIdTag.kt new file mode 100644 index 000000000..4f16bb095 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/GameIdTag.kt @@ -0,0 +1,40 @@ +/* + * 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.quartz.nip64Chess.move.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class GameIdTag { + companion object { + const val TAG_NAME = "game_id" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(gameId: String) = arrayOf(TAG_NAME, gameId) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/MoveNumberTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/MoveNumberTag.kt new file mode 100644 index 000000000..6978c5b3d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/MoveNumberTag.kt @@ -0,0 +1,40 @@ +/* + * 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.quartz.nip64Chess.move.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class MoveNumberTag { + companion object { + const val TAG_NAME = "move_number" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Int? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(moveNumber: Int) = arrayOf(TAG_NAME, moveNumber.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/SanTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/SanTag.kt new file mode 100644 index 000000000..6d3897676 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/move/tags/SanTag.kt @@ -0,0 +1,40 @@ +/* + * 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.quartz.nip64Chess.move.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class SanTag { + companion object { + const val TAG_NAME = "san" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(san: String) = arrayOf(TAG_NAME, san) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 9cb287dc6..a3ace34f8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -114,13 +114,13 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent -import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent -import com.vitorpamplona.quartz.nip64Chess.JesterEvent -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.game.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.jester.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.draw.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.accept.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.challenge.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.end.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.move.LiveChessMoveEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.RelayDiscoveryEvent import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt new file mode 100644 index 000000000..c9a9a5499 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip05/namecoin/NamecoinNameResolverTest.kt @@ -0,0 +1,281 @@ +/* + * 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.quartz.nip05.namecoin + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class NamecoinNameResolverTest { + // ── isNamecoinIdentifier ─────────────────────────────────────────── + + @Test + fun `recognizes dot-bit domains`() { + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("example.bit")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("alice@example.bit")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("_@example.bit")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("EXAMPLE.BIT")) + } + + @Test + fun `recognizes d-slash names`() { + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("d/example")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("D/Example")) + } + + @Test + fun `recognizes id-slash names`() { + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("id/alice")) + assertTrue(NamecoinNameResolver.isNamecoinIdentifier("ID/Alice")) + } + + @Test + fun `rejects non-namecoin identifiers`() { + assertFalse(NamecoinNameResolver.isNamecoinIdentifier("[email protected]")) + assertFalse(NamecoinNameResolver.isNamecoinIdentifier("npub1abc")) + assertFalse(NamecoinNameResolver.isNamecoinIdentifier("some random text")) + assertFalse(NamecoinNameResolver.isNamecoinIdentifier("")) + } + + // ── Value format: simple pubkey in d/ ────────────────────────────── + + @Test + fun `parses simple nostr field from domain value`() { + val value = """{"nostr":"b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"}""" + val result = extractNostrFromValue(value, "d/example", "_") + assertNotNull(result) + assertEquals("b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9", result!!.pubkey) + } + + // ── Value format: extended NIP-05-like in d/ ─────────────────────── + + @Test + fun `parses extended nostr names from domain value`() { + val value = """{ + "nostr": { + "names": { + "_": "aaaa000000000000000000000000000000000000000000000000000000000001", + "alice": "bbbb000000000000000000000000000000000000000000000000000000000002" + }, + "relays": { + "bbbb000000000000000000000000000000000000000000000000000000000002": [ + "wss://relay.example.com" + ] + } + } + }""" + + // Root lookup + val rootResult = extractNostrFromValue(value, "d/example", "_") + assertNotNull(rootResult) + assertEquals("aaaa000000000000000000000000000000000000000000000000000000000001", rootResult!!.pubkey) + + // Named lookup + val aliceResult = extractNostrFromValue(value, "d/example", "alice") + assertNotNull(aliceResult) + assertEquals("bbbb000000000000000000000000000000000000000000000000000000000002", aliceResult!!.pubkey) + assertEquals(listOf("wss://relay.example.com"), aliceResult.relays) + } + + @Test + fun `falls back to root when named user not found`() { + val value = """{ + "nostr": { + "names": { + "_": "aaaa000000000000000000000000000000000000000000000000000000000001" + } + } + }""" + + val result = extractNostrFromValue(value, "d/example", "nonexistent") + assertNotNull(result) + assertEquals("aaaa000000000000000000000000000000000000000000000000000000000001", result!!.pubkey) + } + + // ── Value format: id/ namespace ──────────────────────────────────── + + @Test + fun `parses simple nostr field from identity value`() { + val value = """{ + "nostr": "cccc000000000000000000000000000000000000000000000000000000000003", + "email": "[email protected]" + }""" + val result = extractNostrFromIdentityValue(value, "id/alice") + assertNotNull(result) + assertEquals("cccc000000000000000000000000000000000000000000000000000000000003", result!!.pubkey) + } + + @Test + fun `parses object nostr field from identity value`() { + val value = """{ + "nostr": { + "pubkey": "dddd000000000000000000000000000000000000000000000000000000000004", + "relays": ["wss://relay.example.com", "wss://relay2.example.com"] + } + }""" + val result = extractNostrFromIdentityValue(value, "id/bob") + assertNotNull(result) + assertEquals("dddd000000000000000000000000000000000000000000000000000000000004", result!!.pubkey) + assertEquals(2, result.relays.size) + } + + // ── Invalid data ─────────────────────────────────────────────────── + + @Test + fun `rejects invalid pubkey lengths`() { + val value = """{"nostr":"tooshort"}""" + val result = extractNostrFromValue(value, "d/bad", "_") + assertNull(result) + } + + @Test + fun `rejects non-hex pubkeys`() { + val value = """{"nostr":"zzzz000000000000000000000000000000000000000000000000000000000000"}""" + val result = extractNostrFromValue(value, "d/bad", "_") + assertNull(result) + } + + @Test + fun `handles missing nostr field`() { + val value = """{"ip":"1.2.3.4","map":{"www":{"ip":"1.2.3.4"}}}""" + val result = extractNostrFromValue(value, "d/example", "_") + assertNull(result) + } + + @Test + fun `handles malformed JSON gracefully`() { + val value = "not json at all" + val result = extractNostrFromValue(value, "d/broken", "_") + assertNull(result) + } + + // ── Test helpers ─────────────────────────────────────────────────── + + /** + * Directly test value parsing without network access. + * Simulates what NamecoinNameResolver does after receiving a name_show result. + */ + private fun extractNostrFromValue( + jsonValue: String, + namecoinName: String, + localPart: String, + ): NamecoinNostrResult? { + val json = + kotlinx.serialization.json.Json { + ignoreUnknownKeys = true + isLenient = true + } + val obj = + try { + json.parseToJsonElement(jsonValue).jsonObject + } catch (_: Exception) { + return null + } + + val nostrField = obj["nostr"] ?: return null + + // Simple form + if (nostrField is kotlinx.serialization.json.JsonPrimitive && nostrField.isString) { + val pubkey = nostrField.content + if (localPart == "_" && pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) { + return NamecoinNostrResult(pubkey = pubkey.lowercase(), namecoinName = namecoinName) + } + return null + } + + // Extended form + if (nostrField is kotlinx.serialization.json.JsonObject) { + val names = nostrField["names"]?.jsonObject ?: return null + val pubkeyElem = names[localPart] ?: names["_"] ?: return null + val pubkey = (pubkeyElem as? kotlinx.serialization.json.JsonPrimitive)?.content ?: return null + if (!pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) return null + + val relays = + try { + val relaysMap = nostrField["relays"]?.jsonObject + relaysMap?.get(pubkey.lowercase())?.jsonArray?.mapNotNull { + (it as? kotlinx.serialization.json.JsonPrimitive)?.content + } ?: emptyList() + } catch (_: Exception) { + emptyList() + } + + return NamecoinNostrResult( + pubkey = pubkey.lowercase(), + relays = relays, + namecoinName = namecoinName, + localPart = localPart, + ) + } + return null + } + + private fun extractNostrFromIdentityValue( + jsonValue: String, + namecoinName: String, + ): NamecoinNostrResult? { + val json = + kotlinx.serialization.json.Json { + ignoreUnknownKeys = true + isLenient = true + } + val obj = + try { + json.parseToJsonElement(jsonValue).jsonObject + } catch (_: Exception) { + return null + } + + val nostrField = obj["nostr"] ?: return null + + if (nostrField is kotlinx.serialization.json.JsonPrimitive && nostrField.isString) { + val pubkey = nostrField.content + if (pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) { + return NamecoinNostrResult(pubkey = pubkey.lowercase(), namecoinName = namecoinName) + } + } + + if (nostrField is kotlinx.serialization.json.JsonObject) { + val pubkey = (nostrField["pubkey"] as? kotlinx.serialization.json.JsonPrimitive)?.content + if (pubkey != null && pubkey.matches(Regex("^[0-9a-fA-F]{64}$"))) { + val relays = + try { + nostrField["relays"]?.jsonArray?.mapNotNull { + (it as? kotlinx.serialization.json.JsonPrimitive)?.content + } ?: emptyList() + } catch (_: Exception) { + emptyList() + } + return NamecoinNostrResult(pubkey = pubkey.lowercase(), relays = relays, namecoinName = namecoinName) + } + } + return null + } + + // Needed for jsonArray and jsonObject extensions + private val kotlinx.serialization.json.JsonElement.jsonObject + get() = this as kotlinx.serialization.json.JsonObject + private val kotlinx.serialization.json.JsonElement.jsonArray + get() = this as kotlinx.serialization.json.JsonArray +}