Merge branch 'vitorpamplona:main' into kmp-completeness
This commit is contained in:
@@ -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
|
||||
|
||||
+70
@@ -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))
|
||||
}
|
||||
}
|
||||
+23
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+165
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+4
-4
@@ -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.
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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)
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+3
-3
@@ -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(
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+7
-7
@@ -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
|
||||
|
||||
+36
-2
@@ -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>
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+14
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+196
-172
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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.
|
||||
|
||||
+4
-4
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
+163
@@ -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")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
+138
@@ -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
|
||||
}
|
||||
+390
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+97
@@ -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,
|
||||
)
|
||||
+144
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+89
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+295
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+233
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
+554
@@ -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)
|
||||
}
|
||||
}
|
||||
+77
@@ -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()
|
||||
}
|
||||
}
|
||||
+314
@@ -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 Namecoin→Nostr resolution.
|
||||
* It is designed to be used alongside Amethyst's existing NIP-05
|
||||
* verifier: if an identifier ends with `.bit`, it should be routed
|
||||
* here instead of to the HTTP-based NIP-05 path.
|
||||
*/
|
||||
class NamecoinNameResolver(
|
||||
private val electrumxClient: ElectrumxClient = ElectrumxClient(),
|
||||
private val lookupTimeoutMs: Long = 20_000L,
|
||||
private val serverListProvider: () -> List<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)
|
||||
}
|
||||
+17
-1
@@ -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))
|
||||
|
||||
|
||||
+3
@@ -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.
|
||||
|
||||
-295
@@ -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
|
||||
}
|
||||
+72
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -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))
|
||||
+26
@@ -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)
|
||||
+40
@@ -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)
|
||||
}
|
||||
}
|
||||
+79
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+30
@@ -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))
|
||||
+29
@@ -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)
|
||||
+45
@@ -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")
|
||||
}
|
||||
}
|
||||
+40
@@ -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)
|
||||
}
|
||||
}
|
||||
+73
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+89
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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)
|
||||
+41
@@ -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)
|
||||
}
|
||||
}
|
||||
+41
@@ -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())
|
||||
}
|
||||
}
|
||||
+41
@@ -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)
|
||||
}
|
||||
}
|
||||
+11
-11
@@ -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)
|
||||
}
|
||||
+40
@@ -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.
|
||||
)
|
||||
+4
-84
@@ -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()
|
||||
}
|
||||
+48
@@ -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()
|
||||
}
|
||||
+59
@@ -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
|
||||
}
|
||||
+90
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -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)
|
||||
}
|
||||
}
|
||||
+40
@@ -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)
|
||||
}
|
||||
}
|
||||
+40
@@ -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
|
||||
|
||||
+281
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user