Merge branch 'vitorpamplona:main' into kmp-completeness

This commit is contained in:
KotlinGeekDev
2026-03-04 14:40:12 +01:00
committed by GitHub
81 changed files with 5211 additions and 912 deletions
@@ -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() }
@@ -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
@@ -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
@@ -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))
}
}
@@ -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))
}
}
@@ -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
@@ -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<ElectrumxServer> = 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<NamecoinResolveState> {
val state = MutableStateFlow<NamecoinResolveState>(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<ElectrumxServer>) {
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()
}
@@ -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
@@ -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
/**
@@ -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.
@@ -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
/**
@@ -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)
@@ -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
@@ -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(
@@ -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
@@ -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
@@ -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())
@@ -1398,11 +1398,15 @@
<string name="kind_meeting_space">Prostor schůzky</string>
<string name="kind_profile">Profil</string>
<string name="kind_mute_list">Seznam ztlumených</string>
<string name="kind_nns">NNS</string>
<string name="kind_nip">NIP</string>
<string name="kind_nostr_connect">Nostr Connect</string>
<string name="kind_dvm_status">Stav DVM</string>
<string name="kind_dvm_content_req">Požadavek na obsah DVM</string>
<string name="kind_dvm_content_resp">Odpověď obsahu DVM</string>
<string name="kind_dvm_user_req">Požadavek uživatele DVM</string>
<string name="kind_dvm_user_resp">Odpověď uživatele DVM</string>
<string name="kind_ots">OTS</string>
<string name="kind_pay_to">Platba na</string>
<string name="kind_people_lists">Seznamy lidí</string>
<string name="kind_pictures">Obrázky</string>
@@ -1434,7 +1438,9 @@
<string name="kind_trusted_providers">Důvěryhodní poskytovatelé</string>
<string name="kind_video_repl">Video (odpověď)</string>
<string name="kind_shorts_repl">Krátká videa (odpověď)</string>
<string name="kind_video">Video</string>
<string name="kind_shorts">Krátká videa</string>
<string name="kind_voice_msg">Hlasová zpráva</string>
<string name="kind_voice_reply">Hlasová odpověď</string>
<string name="kind_wiki">Wiki</string>
</resources>
@@ -1321,6 +1321,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="remove_language">%1$s entfernen</string>
<!-- Kind display names for relay subscription filter chips -->
<string name="kind_outbox_relays">Outbox-Relays</string>
<string name="kind_apps">Apps</string>
<string name="kind_app_recommendations">App-Empfehlungen</string>
<string name="kind_user_settings">Benutzereinstellungen</string>
<string name="kind_audio_header">Audio-Header</string>
@@ -1373,6 +1374,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="kind_blob_headers">Blob-Header</string>
<string name="kind_medical_data">Medizinische Daten</string>
<string name="kind_follow_packs">Follow-Pakete</string>
<string name="kind_reposts_16">Reposts (16)</string>
<string name="kind_geohash_follows">Geohash-Follows</string>
<string name="kind_gift_wraps">Geschenkverpackungen</string>
<string name="kind_git_issue">Git-Issue</string>
@@ -1381,6 +1383,7 @@ anz der Bedingungen ist erforderlich</string>
<string name="kind_git_reply">Git-Antwort</string>
<string name="kind_zap_goals">Zap-Ziele</string>
<string name="kind_hashtag_follows">Gefolgte Hashtags</string>
<string name="kind_highlights">Highlights</string>
<string name="kind_http_auth">HTTP-Authentifizierung</string>
<string name="kind_index_relay_list">Index-Relay-Liste</string>
<string name="kind_adventure_prologue">Abenteuer-Prolog</string>
@@ -1389,26 +1392,35 @@ anz der Bedingungen ist erforderlich</string>
<string name="kind_named_bookmarks">Benannte Lesezeichen</string>
<string name="kind_live_chats">Live-Chats</string>
<string name="kind_live_streams">Live-Streams</string>
<string name="kind_zaps">Zap</string>
<string name="kind_nwc_request">NWC-Anfrage</string>
<string name="kind_nwc_response">NWC-Antwort</string>
<string name="kind_private_zaps">Privaten Zaps</string>
<string name="kind_zap_req">Zap-Anfrage</string>
<string name="kind_blogs">Blogs</string>
<string name="kind_meeting_room">Besprechungsraum</string>
<string name="kind_room_presence">Raumpräsenz</string>
<string name="kind_meeting_space">Besprechungsbereich</string>
<string name="kind_profile">Profil</string>
<string name="kind_mute_list">Stummschaltliste</string>
<string name="kind_nns">NNS</string>
<string name="kind_nip">NIP</string>
<string name="kind_nostr_connect">Nostr Connect</string>
<string name="kind_dvm_status">DVM-Status</string>
<string name="kind_dvm_content_req">DVM-Inhaltsanfrage</string>
<string name="kind_dvm_content_resp">DVM-Inhaltsantwort</string>
<string name="kind_dvm_user_req">DVM-Benutzeranfrage</string>
<string name="kind_dvm_user_resp">DVM-Benutzerantwort</string>
<string name="kind_ots">OTS</string>
<string name="kind_pay_to">Zahlen an</string>
<string name="kind_people_lists">Personenlisten</string>
<string name="kind_pictures">Bilder</string>
<string name="kind_pins">Angepinnt</string>
<string name="kind_zap_poll">Zap-Umfrage</string>
<string name="kind_poll">Umfrage</string>
<string name="kind_poll_response">Umfrageantwort</string>
<string name="kind_nip04_dms">NIP-04-DMs</string>
<string name="kind_private_relays">Private Relais</string>
<string name="kind_proxy_relays">Proxy-Relays</string>
<string name="kind_public_message">Öffentliche Nachricht</string>
<string name="kind_reactions">Reaktionen</string>
@@ -1418,17 +1430,22 @@ anz der Bedingungen ist erforderlich</string>
<string name="kind_relay_monitor">Relay-Monitor-Ankündigung</string>
<string name="kind_relay_set">Relay-Set</string>
<string name="kind_reports">Meldungen</string>
<string name="kind_reposts">Reposts</string>
<string name="kind_user_delete">Benutzer löschen</string>
<string name="kind_seals">Siegel</string>
<string name="kind_search_relays">Such-Relays</string>
<string name="kind_user_status">Benutzerstatus</string>
<string name="kind_notes">Notizen</string>
<string name="kind_edits">Bearbeitungen</string>
<string name="kind_torrents">Torrents</string>
<string name="kind_torrent_comments">Torrent-Kommentare</string>
<string name="kind_trusted_relays">Vertrauenswürdige Relays</string>
<string name="kind_trusted_providers">Vertrauenswürdige Anbieter</string>
<string name="kind_video_repl">Video (Antwort)</string>
<string name="kind_shorts_repl">Shorts (Antwort)</string>
<string name="kind_video">Video</string>
<string name="kind_shorts">Shorts</string>
<string name="kind_voice_msg">Sprachnachricht</string>
<string name="kind_voice_reply">Sprachantwort</string>
<string name="kind_wiki">Wiki</string>
</resources>
@@ -796,6 +796,7 @@
<string name="mute_button">Som ligado. Clique para silenciar</string>
<string name="skip_back">Voltar %d segundos</string>
<string name="skip_forward">Avançar %d segundos</string>
<string name="picture_in_picture">Picture-in-Picture</string>
<string name="search_button">Pesquise registros locais e remotos</string>
<string name="nip05_verified">O endereço Nostr foi verificado</string>
<string name="nip05_failed">Endereço Nostr falhou na verificação</string>
@@ -1315,6 +1316,7 @@
<string name="remove_language">Remover %1$s</string>
<!-- Kind display names for relay subscription filter chips -->
<string name="kind_outbox_relays">Relays de Saída</string>
<string name="kind_apps">Aplicativos</string>
<string name="kind_app_recommendations">Recomendações de Apps</string>
<string name="kind_user_settings">Configurações do Usuário</string>
<string name="kind_audio_header">Cabeçalho de Áudio</string>
@@ -1367,6 +1369,7 @@
<string name="kind_blob_headers">Cabeçalhos Blob</string>
<string name="kind_medical_data">Dados Médicos</string>
<string name="kind_follow_packs">Pacotes de Seguir</string>
<string name="kind_reposts_16">Repostagens (16)</string>
<string name="kind_geohash_follows">Seguindo Geohash</string>
<string name="kind_gift_wraps">Embrulhos de presente</string>
<string name="kind_git_issue">Issue do Git</string>
@@ -1384,20 +1387,26 @@
<string name="kind_named_bookmarks">Favoritos nomeados</string>
<string name="kind_live_chats">Chats ao vivo</string>
<string name="kind_live_streams">Transmissões ao vivo</string>
<string name="kind_zaps">Zaps</string>
<string name="kind_nwc_request">Solicitação NWC</string>
<string name="kind_nwc_response">Resposta NWC</string>
<string name="kind_private_zaps">Zaps privados</string>
<string name="kind_zap_req">Solicitação de Zap</string>
<string name="kind_blogs">Blogs</string>
<string name="kind_meeting_room">Sala de reunião</string>
<string name="kind_room_presence">Presença na sala</string>
<string name="kind_meeting_space">Espaço de reunião</string>
<string name="kind_profile">Perfil</string>
<string name="kind_mute_list">Lista de silenciados</string>
<string name="kind_nns">NNS</string>
<string name="kind_nip">CNPJ</string>
<string name="kind_nostr_connect">Nostr Connect</string>
<string name="kind_dvm_status">Status do DVM</string>
<string name="kind_dvm_content_req">Solicitação de conteúdo DVM</string>
<string name="kind_dvm_content_resp">Resposta de conteúdo DVM</string>
<string name="kind_dvm_user_req">Solicitação de usuário DVM</string>
<string name="kind_dvm_user_resp">Resposta de usuário DVM</string>
<string name="kind_ots">OTS</string>
<string name="kind_pay_to">Pagar para</string>
<string name="kind_people_lists">Listas de pessoas</string>
<string name="kind_pictures">Imagens</string>
@@ -1416,18 +1425,22 @@
<string name="kind_relay_monitor">Anúncio de Monitor de Relay</string>
<string name="kind_relay_set">Conjunto de relays</string>
<string name="kind_reports">Denúncias</string>
<string name="kind_reposts">Repostagens</string>
<string name="kind_user_delete">Excluir usuário</string>
<string name="kind_seals">Selos</string>
<string name="kind_search_relays">Relays de busca</string>
<string name="kind_user_status">Status do usuário</string>
<string name="kind_notes">Notas</string>
<string name="kind_edits">Edições</string>
<string name="kind_torrents">Torrents</string>
<string name="kind_torrent_comments">Comentários de torrent</string>
<string name="kind_trusted_relays">Relays confiáveis</string>
<string name="kind_trusted_providers">Provedores confiáveis</string>
<string name="kind_video_repl">Vídeo (resposta)</string>
<string name="kind_shorts_repl">Shorts (resposta)</string>
<string name="kind_video">Vídeo</string>
<string name="kind_shorts">Shorts</string>
<string name="kind_voice_msg">Mensagem de voz</string>
<string name="kind_voice_reply">Resposta de voz</string>
<string name="kind_wiki">Wiki</string>
</resources>
@@ -1368,6 +1368,7 @@
<string name="kind_blob_headers">Blob-huvuden</string>
<string name="kind_medical_data">Medicinska data</string>
<string name="kind_follow_packs">Följ-paket</string>
<string name="kind_reposts_16">Återinlägg (16)</string>
<string name="kind_geohash_follows">Geohash-följer</string>
<string name="kind_gift_wraps">Presentinslagningar</string>
<string name="kind_git_issue">Git-ärende</string>
@@ -1385,6 +1386,7 @@
<string name="kind_named_bookmarks">Namngivna bokmärken</string>
<string name="kind_live_chats">Livechattar</string>
<string name="kind_live_streams">Livesändningar</string>
<string name="kind_zaps">Zaps</string>
<string name="kind_nwc_request">NWC-förfrågan</string>
<string name="kind_nwc_response">NWC-svar</string>
<string name="kind_private_zaps">Privata zaps</string>
@@ -1395,11 +1397,15 @@
<string name="kind_meeting_space">Mötesutrymme</string>
<string name="kind_profile">Profil</string>
<string name="kind_mute_list">Tystnadslista</string>
<string name="kind_nns">NNS</string>
<string name="kind_nip">NIP</string>
<string name="kind_nostr_connect">Nostr Connect</string>
<string name="kind_dvm_status">DVM-status</string>
<string name="kind_dvm_content_req">DVM-innehållsförfrågan</string>
<string name="kind_dvm_content_resp">DVM-innehållssvar</string>
<string name="kind_dvm_user_req">DVM-användarförfrågan</string>
<string name="kind_dvm_user_resp">DVM-användarsvar</string>
<string name="kind_ots">OTS</string>
<string name="kind_pay_to">Betala till</string>
<string name="kind_people_lists">Personlistor</string>
<string name="kind_pictures">Bilder</string>
@@ -1418,6 +1424,7 @@
<string name="kind_relay_monitor">Relay övervakningsmeddelande</string>
<string name="kind_relay_set">Reläuppsättning</string>
<string name="kind_reports">Rapporter</string>
<string name="kind_reposts">Återinlägg</string>
<string name="kind_user_delete">Radera användare</string>
<string name="kind_seals">Sigill</string>
<string name="kind_search_relays">Sökreläer</string>
@@ -1430,6 +1437,9 @@
<string name="kind_trusted_providers">Betrodda leverantörer</string>
<string name="kind_video_repl">Video (svar)</string>
<string name="kind_shorts_repl">Shorts (svar)</string>
<string name="kind_video">Video</string>
<string name="kind_shorts">Shorts</string>
<string name="kind_voice_msg">Röstmeddelande</string>
<string name="kind_voice_reply">Röstsvar</string>
<string name="kind_wiki">Wiki</string>
</resources>
@@ -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
@@ -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
@@ -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
@@ -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
/**
@@ -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
/**
@@ -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)
}
}
@@ -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<com.vitorpamplona.quartz.nip01Core.core.Event?>(null) }
var currentScreen by remember { mutableStateOf<DesktopScreen>(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),
)
}
}
}
@@ -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,
)
}
}
}
}
@@ -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.
@@ -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
/**
@@ -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
@@ -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
@@ -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<Event?>(null) }
var feedMode by remember { mutableStateOf(DesktopPreferences.feedMode) }
var feedMode by remember { mutableStateOf(initialFeedMode ?: DesktopPreferences.feedMode) }
var followedUsers by remember { mutableStateOf<Set<String>>(emptySet()) }
var zapsByEvent by remember { mutableStateOf<Map<String, List<ZapReceipt>>>(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(
@@ -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(
@@ -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
@@ -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")
}
},
)
}
@@ -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
}
@@ -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<List<DesktopScreen>>(emptyList())
val stack: kotlinx.coroutines.flow.StateFlow<List<DesktopScreen>> = _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,
)
}
}
}
@@ -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,
)
@@ -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,
)
}
}
@@ -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,
)
}
}
}
@@ -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<List<DeckColumn>> = _columns.asStateFlow()
private val _focusedColumnIndex = MutableStateFlow(0)
val focusedColumnIndex: StateFlow<Int> = _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<Map<String, Any?>> = 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<String, Any?>): 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
}
}
}
}
@@ -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>(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,
)
}
}
+309
View File
@@ -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 <push(name)> <push(value_json)> OP_2DROP OP_DROP <address_script>
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)
@@ -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<ElectrumxServer> = 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<String>())
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 <push(name)> <push(empty)> 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<Byte>()
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<Pair<String, Int>>? {
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 <push(name)> <push(value)> OP_2DROP OP_DROP <address_script>
*
* @return Pair of (name, value) as strings, or null if parsing fails
*/
private fun parseNameScript(script: ByteArray): Pair<String, String>? {
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<ByteArray, Int>? {
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<TrustManager>(
object : X509TrustManager {
override fun checkClientTrusted(
chain: Array<java.security.cert.X509Certificate>,
authType: String,
) {}
override fun checkServerTrusted(
chain: Array<java.security.cert.X509Certificate>,
authType: String,
) {}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
},
)
val sslContext = SSLContext.getInstance("TLS")
sslContext.init(null, trustAllCerts, java.security.SecureRandom())
return sslContext.socketFactory
}
private fun buildRpcRequest(
method: String,
params: List<Any>,
): 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)
}
}
@@ -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<String, CachedResult>(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()
}
}
@@ -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<String> = 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 NamecoinNostr 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<ElectrumxServer> = { 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<String> {
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)
}
@@ -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))
@@ -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.
@@ -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<Array<String>>,
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<LiveChessGameChallengeEvent>.() -> 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<Array<String>>,
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<LiveChessGameAcceptEvent>.() -> 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<Array<String>>,
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<LiveChessMoveEvent>.() -> 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<Array<String>>,
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<LiveChessGameEndEvent>.() -> 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<Array<String>>,
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<LiveChessDrawOfferEvent>.() -> 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
}
@@ -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<Array<String>>,
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<LiveChessGameAcceptEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
add(arrayOf("d", gameId))
challengeEvent(challengeEventId)
add(arrayOf("p", challengerPubkey))
alt(ALT_DESCRIPTION)
initializer()
}
}
}
@@ -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<LiveChessGameAcceptEvent>.challengeEvent(challengeEventId: String) = add(ChallengeEventTag.assemble(challengeEventId))
@@ -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)
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64
fun parse(tag: Array<String>): 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)
}
}
@@ -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<Array<String>>,
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<LiveChessGameChallengeEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
add(arrayOf("d", gameId))
playerColor(playerColor)
opponentPubkey?.let { add(arrayOf("p", it)) }
timeControl?.let { timeControl(it) }
alt(ALT_DESCRIPTION)
initializer()
}
}
}
@@ -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<LiveChessGameChallengeEvent>.playerColor(color: Color) = addUnique(PlayerColorTag.assemble(color))
fun TagArrayBuilder<LiveChessGameChallengeEvent>.timeControl(timeControl: String) = addUnique(TimeControlTag.assemble(timeControl))
@@ -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)
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): 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")
}
}
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): 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)
}
}
@@ -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<Array<String>>,
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<LiveChessDrawOfferEvent>.() -> Unit = {},
) = eventTemplate(KIND, message, createdAt) {
add(arrayOf("d", gameId))
add(arrayOf("p", opponentPubkey))
alt(ALT_DESCRIPTION)
initializer()
}
}
}
@@ -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<Array<String>>,
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<LiveChessGameEndEvent>.() -> 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()
}
}
}
@@ -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<LiveChessGameEndEvent>.result(result: GameResult) = addUnique(ResultTag.assemble(result))
fun TagArrayBuilder<LiveChessGameEndEvent>.termination(termination: GameTermination) = addUnique(TerminationTag.assemble(termination))
fun TagArrayBuilder<LiveChessGameEndEvent>.winner(winnerPubkey: HexKey) = addUnique(WinnerTag.assemble(winnerPubkey))
@@ -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)
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): 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)
}
}
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): 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())
}
}
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): 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)
}
}
@@ -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)
}
@@ -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<String> = 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.
)
@@ -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<String> = 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<JesterEvent>,
) {
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<String> = 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()
}
@@ -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<JesterEvent>,
) {
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<String> = 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()
}
@@ -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
}
@@ -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<Array<String>>,
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<LiveChessMoveEvent>.() -> 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()
}
}
}
@@ -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<LiveChessMoveEvent>.gameId(gameId: String) = addUnique(GameIdTag.assemble(gameId))
fun TagArrayBuilder<LiveChessMoveEvent>.moveNumber(moveNumber: Int) = addUnique(MoveNumberTag.assemble(moveNumber))
fun TagArrayBuilder<LiveChessMoveEvent>.san(san: String) = addUnique(SanTag.assemble(san))
fun TagArrayBuilder<LiveChessMoveEvent>.fen(fen: String) = addUnique(FenTag.assemble(fen))
@@ -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)
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): 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)
}
}
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): 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)
}
}
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): 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())
}
}
@@ -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<String>) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty()
fun parse(tag: Array<String>): 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)
}
}
@@ -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
@@ -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
}