diff --git a/README.md b/README.md index 2762617d6..28219bf87 100644 --- a/README.md +++ b/README.md @@ -42,13 +42,13 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) - [x] Events / Relay Subscriptions (NIP-01) - [x] Follow List (NIP-02) - [x] OpenTimestamps Attestations (NIP-03) -- [x] Private Messages (NIP-04) +- [x] Private Messages (NIP-04 -- to be removed) - [x] DNS Address (NIP-05) - [x] Mnemonic seed phrase (NIP-06) - [ ] WebBrowser Signer (NIP-07, Not applicable) - [x] Old-style mentions (NIP-08) - [x] Event Deletion (NIP-09) -- [x] Replies, mentions, Threads, and Notifications (NIP-10) +- [x] Text Notes and Threads (NIP-10) - [x] Relay Information Document (NIP-11) - [x] Generic Tag Queries (NIP-12) - [x] Proof of Work Display (NIP-13) @@ -57,9 +57,9 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) - [ ] Marketplace (NIP-15) - [x] Event Treatment (NIP-16) - [x] Private Direct Messages (NIP-17) -- [x] Image/Video/Url/LnInvoice Previews +- [x] Image/Video/Url/LnInvoice/Cashu Previews - [x] Reposts, Quotes, Generic Reposts (NIP-18) -- [x] Bech Encoding support (NIP-19) +- [x] Bech32 Encoding support (NIP-19) - [x] Command Results (NIP-20) - [x] URI Support (NIP-21) - [x] Long-form Content (NIP-23) (view only) @@ -70,45 +70,59 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) - [x] Public Chats (NIP-28) - [ ] Relay-based Groups (NIP-29) - [x] Custom Emoji (NIP-30) -- [x] Event kind summaries (NIP-31) +- [x] Event alt descriptors (NIP-31) - [ ] Labeling (NIP-32) -- [x] Parameterized Replaceable Events (NIP-33) - [x] Git Stuff (NIP-34) -- [ ] Torrents (NIP-35) +- [x] Torrents (NIP-35) - [x] Sensitive Content (NIP-36) -- [x] Edits (NIP-37/Draft) +- [x] Drafts (NIP-37) - [x] User Status Event (NIP-38) - [x] External Identities (NIP-39) - [x] Expiration Support (NIP-40) - [x] Relay Authentication (NIP-42) +- [ ] Relay Access Metadata and Requests (NIP-43) - [x] Versioned Encrypted Payloads (NIP-44) -- [ ] Event Counts (NIP-45, Will not implement) -- [ ] Nostr Connect (NIP-46) -- [x] Wallet Connect API (NIP-47) +- [x] Event Counts (NIP-45) +- [o] Nostr Connect (NIP-46) +- [o] Wallet Connect API (NIP-47) - [ ] Proxy Tags (NIP-48, Not applicable) -- [x] Private key encryption for import/export (NIP-49) -- [x] Online Relay Search (NIP-50) +- [x] Encryption for import/export (NIP-49) +- [x] Relay Search (NIP-50) - [x] Lists (NIP-51) -- [ ] Calendar Events (NIP-52) +- [o] Calendar Events (NIP-52) - [x] Live Activities & Live Chats (NIP-53) - [x] Wiki (NIP-54) -- [x] Inline Metadata (NIP-55 - Draft) +- [x] Android Signer (NIP-55) - [x] Reporting (NIP-56) - [x] Lightning Tips - [x] Zaps (NIP-57) -- [x] Private Zaps - [x] Zap Splits (NIP-57) +- [x] Private Zaps (NIP-57 / Draft) +- [x] Zapraiser (NIP-57 / Draft) - [x] Badges (NIP-58) - [x] Gift Wraps & Seals (NIP-59) -- [x] Zapraiser (NIP-TBD) -- [ ] Relay List Metadata (NIP-65) -- [x] Polls (NIP-69) +- [ ] Cashu Wallets (NIP-60) +- [ ] Nutzaps (NIP-61) +- [x] Request to Vanish (NIP-62) +- [x] Chess (NIP-64) +- [x] Relay List Metadata (NIP-65) +- [ ] Relay Discovery and Monitoring (NIP-66) +- [x] Picture-first feeds (NIP-68) +- [ ] Peer-to-peer Orders (NIP-69) +- [o] Protected Events (NIP-70) - [x] Video Events (NIP-71) - [x] Moderated Communities (NIP-72) +- [x] External Content IDs (NIP-73) - [ ] Zap Goals (NIP-75) +- [ ] Negentropy Syncing (NIP-77) - [x] Arbitrary Custom App Data (NIP-78) +- [ ] NIP-29 Threads (NIP-7D) - [x] Highlights (NIP-84) -- [x] Notify Request (NIP-88/Draft) +- [x] Trusted Assertions (NIP-85) +- [ ] Relay Management API (NIP-86) +- [ ] ECash Mint Discoverability (NIP-87) +- [x] Polls (NIP-88) +- [x] Relay Notify Request - [x] Recommended Application Handlers (NIP-89) - [x] Data Vending Machine (NIP-90) - [x] Inline Metadata (NIP-92) @@ -117,6 +131,14 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) - [x] HTTP File Storage Integration (NIP-96) - [x] HTTP Auth (NIP-98) - [x] Classifieds (NIP-99) +- [x] Voice Messages (NIP-A0) +- [x] Public Messages (NIP-A4) +- [ ] Web Bookmarks +- [x] Blossom (NIP-B7) +- [ ] Nostr BLE Communications Protocol (NIP-BE) +- [ ] Code Snippets (NIP-C0) +- [ ] NIP-29 Chats (NIP-C7) +- [ ] MLS Protocol (NIP-EE) - [x] Audio Tracks (zapstr.live) (kind:31337) - [x] Push Notifications (Google and Unified Push) - [x] In-Device Automatic Translations @@ -128,13 +150,10 @@ height="70">](https://github.com/vitorpamplona/amethyst/releases) - [x] Markdown Support - [x] Medical Data (NIP-xx/Draft) - [x] Embed events (NIP-xx/Draft) -- [x] Draft Events (NIP-xx/Draft) -- [ ] Event Sets (NIP-xx/Draft) -- [ ] Topical Notes (NIP-xx/Draft) +- [x] Edit Short Notes (NIP-xx/Draft) - [ ] Relationship Status (NIP-xx/Draft) - [ ] Signed Filters (NIP-xx/Draft) - [ ] Key Migration (NIP-xx/Draft) -- [ ] Time-based Sync (NIP-xx/Draft) - [x] Image Capture in the app - [x] Video Capture in the app - [ ] Local Database diff --git a/amethyst/build.gradle b/amethyst/build.gradle index 432929a9f..fe6c881a8 100644 --- a/amethyst/build.gradle +++ b/amethyst/build.gradle @@ -280,7 +280,7 @@ dependencies { // view videos implementation libs.androidx.media3.exoplayer implementation libs.androidx.media3.exoplayer.hls - implementation libs.androidx.media3.ui + implementation libs.androidx.media3.ui.compose.material3 implementation libs.androidx.media3.session // important for proxy / tor @@ -294,6 +294,10 @@ dependencies { implementation libs.coil.svg // enables network for coil implementation libs.coil.okhttp + // loads thumbnails for media3 + // TODO: Replace this to the FrameExtractor in media 3 + // when FrameExtractor accepts custom data sources. + implementation(libs.coil.video) // Permission to upload pictures: implementation libs.accompanist.permissions diff --git a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt index b3dcb47c5..3aebe1c1c 100644 --- a/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt +++ b/amethyst/src/androidTest/java/com/vitorpamplona/amethyst/ImageUploadTesting.kt @@ -121,10 +121,10 @@ class ImageUploadTesting { context = InstrumentationRegistry.getInstrumentation().targetContext, ) - assertEquals("image/png", result.type) - assertEquals(paylod.size.toLong(), result.size) - assertEquals(initialHash, result.sha256) - assertEquals("${server.baseUrl}/$initialHash", result.url?.removeSuffix(".png")) + assertEquals(server.baseUrl, "image/png", result.type) + assertEquals(server.baseUrl, paylod.size.toLong(), result.size) + assertEquals(server.baseUrl, initialHash, result.sha256) + // assertEquals(server.baseUrl, "${server.baseUrl}/$initialHash", result.url?.removeSuffix(".png")) val imageData: ByteArray = ImageDownloader().waitAndGetImage(result.url!!, { client })?.bytes @@ -134,7 +134,7 @@ class ImageUploadTesting { } val downloadedHash = sha256(imageData).toHexKey() - assertEquals(initialHash, downloadedHash) + assertEquals(server.baseUrl, initialHash, downloadedHash) } private suspend fun testNip96(server: ServerName) { @@ -207,8 +207,13 @@ class ImageUploadTesting { runBlocking { DEFAULT_MEDIA_SERVERS.forEach { // skip paid servers and primal server is buggy. - if (!it.name.contains("Paid") && !it.name.contains("Primal")) { - testBase(it) + try { + if (!it.name.contains("Paid") && !it.name.contains("Primal")) { + testBase(it) + } + } catch (e: Exception) { + e.printStackTrace() + fail("Could not upload to: ${it.baseUrl}: ${e.message}") } } } @@ -226,14 +231,6 @@ class ImageUploadTesting { testBase(ServerName("nostrage", "https://nostrage.com", ServerType.NIP96)) } - @Test() - @Ignore("Not Working anymore") - fun testSove() = - runBlocking { - testBase(ServerName("sove", "https://sove.rent", ServerType.NIP96)) - } - - @Ignore("Not Working anymore") @Test() fun testNostrBuild() = runBlocking { @@ -241,10 +238,10 @@ class ImageUploadTesting { } @Test() - @Ignore("Not Working anymore") + @Ignore("Returns invalid hash") fun testSovbit() = runBlocking { - testBase(ServerName("sovbit", "https://files.sovbit.host", ServerType.NIP96)) + testBase(ServerName("sovbit", "https://cdn.sovbit.host", ServerType.Blossom)) } @Test() @@ -260,13 +257,6 @@ class ImageUploadTesting { testBase(ServerName("sprovoost.nl", "https://img.sprovoost.nl/", ServerType.NIP96)) } - @Test() - @Ignore("Not Working anymore") - fun testNostrOnch() = - runBlocking { - testBase(ServerName("nostr.onch.services", "https://nostr.onch.services", ServerType.NIP96)) - } - @Ignore("Changes sha256") fun testPrimalBlossom() = runBlocking { diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 9bbfce57b..7339ba1bb 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -76,7 +76,6 @@ android:launchMode="singleInstance" android:windowSoftInputMode="adjustResize" android:configChanges="orientation|screenSize|screenLayout" - android:taskAffinity=".service.playback.pip.PipVideoActivity" android:theme="@style/Theme.Amethyst"> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt index f89c08a22..8c13563f3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -73,6 +73,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import java.io.File @@ -293,6 +294,13 @@ class AppModules( // registers to receive events pokeyReceiver.register(appContext) + + // initializes diskcache on an IO thread. + applicationIOScope.launch { + // Sets Coil - Tor - OkHttp link + delay(3000) + videoCache + } } fun terminate(appContext: Context) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index b899e8282..517776c00 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -82,14 +82,12 @@ import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListDecryptionCache import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState -import com.vitorpamplona.amethyst.model.nip96FileStorage.FileStorageServerListState import com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineRelayListsState import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineWithIndexRelayListsState import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineWithSearchRelayListsState -import com.vitorpamplona.amethyst.model.serverList.MergedServerListState import com.vitorpamplona.amethyst.model.serverList.TrustedRelayListsState import com.vitorpamplona.amethyst.model.topNavFeeds.FeedDecryptionCaches import com.vitorpamplona.amethyst.model.topNavFeeds.FeedTopNavFilterState @@ -192,6 +190,8 @@ import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent import com.vitorpamplona.quartz.nip71Video.VideoShortEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent import com.vitorpamplona.quartz.nip92IMeta.IMetaTag import com.vitorpamplona.quartz.nip92IMeta.imetas @@ -315,8 +315,6 @@ class Account( val appSpecific = AppSpecificState(signer, cache, scope, settings) val blossomServers = BlossomServerListState(signer, cache, scope, settings) - val fileStorageServers = FileStorageServerListState(signer, cache, scope, settings) - val serverLists = MergedServerListState(fileStorageServers, blossomServers, scope) // Relay settings val homeRelays = AccountHomeRelayState(nip65RelayList, privateStorageRelayList, localRelayList, scope) @@ -527,13 +525,12 @@ class Account( if (!signer.isWriteable()) return null if (note.hasReacted(userProfile(), reaction)) return null - val noteEvent = note.event ?: return null + val eventHint = note.toEventHint() ?: return null // For NIP-17 private groups, we don't support tracked mode (too complex) - if (noteEvent is NIP17Group) return null + if (eventHint.event is NIP17Group) return null - val relayHint = note.relays.firstOrNull()?.url - val event = ReactionAction.reactTo(noteEvent, reaction, signer, relayHint) + val event = ReactionAction.reactTo(eventHint, reaction, signer) val relays = computeRelayListToBroadcast(event) return event to relays @@ -882,6 +879,10 @@ class Account( } } + if (event is PollEvent) { + relayList.addAll(event.relays()) + } + relayList.addAll(computeRelaysForChannels(event)) return relayList @@ -991,6 +992,23 @@ class Account( cache.justConsumeMyOwnEvent(event) } + suspend fun pollRespond( + event: PollEvent, + responses: Set, + ) { + val poll = cache.getOrCreateNote(event.id).toEventHint() + + if (poll != null) { + val template = PollResponseEvent.build(poll, responses) + + val signedEvent = signer.sign(template) + + cache.justConsumeMyOwnEvent(signedEvent) + + client.send(signedEvent, computeRelayListToBroadcast(signedEvent)) + } + } + suspend fun createNip95( byteArray: ByteArray, headerInfo: FileHeader, @@ -1337,19 +1355,15 @@ class Account( } suspend fun createInteractiveStoryReadingState( - root: InteractiveStoryBaseEvent, - rootRelay: NormalizedRelayUrl?, - readingScene: InteractiveStoryBaseEvent, - readingSceneRelay: NormalizedRelayUrl?, + root: EventHintBundle, + readingScene: EventHintBundle, ) { if (!isWriteable()) return val template = InteractiveStoryReadingStateEvent.build( root = root, - rootRelay = rootRelay, currentScene = readingScene, - currentSceneRelay = readingSceneRelay, ) val event = signer.sign(template) @@ -1368,8 +1382,7 @@ class Account( suspend fun updateInteractiveStoryReadingState( readingState: InteractiveStoryReadingStateEvent, - readingScene: InteractiveStoryBaseEvent, - readingSceneRelay: NormalizedRelayUrl?, + readingScene: EventHintBundle, ) { if (!isWriteable()) return @@ -1377,7 +1390,6 @@ class Account( InteractiveStoryReadingStateEvent.update( base = readingState, currentScene = readingScene, - currentSceneRelay = readingSceneRelay, ) val event = signer.sign(template) @@ -1907,8 +1919,6 @@ class Account( suspend fun sendNip65RelayList(relays: List) = sendLiterallyEverywhere(nip65RelayList.saveRelayList(relays)) - suspend fun sendFileServersList(servers: List) = sendMyPublicAndPrivateOutbox(fileStorageServers.saveFileServersList(servers)) - suspend fun sendBlossomServersList(servers: List) = sendMyPublicAndPrivateOutbox(blossomServers.saveBlossomServersList(servers)) fun markAsRead( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index d26cc68f1..df414d60a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -81,7 +81,7 @@ val DefaultNIP65List = val DefaultDMRelayList = listOf(Constants.auth, Constants.oxchat, Constants.nos) -val DefaultSearchRelayList = setOf(Constants.wine, Constants.where, Constants.nostoday, Constants.antiprimal) +val DefaultSearchRelayList = setOf(Constants.wine, Constants.where, Constants.nostoday, Constants.antiprimal, Constants.ditto) val DefaultIndexerRelayList = setOf(Constants.purplepages, Constants.coracle, Constants.userkinds, Constants.yabu, Constants.nostr1) @@ -111,6 +111,9 @@ val KIND3_FOLLOWS = " Main User Follows " // This has spaces to avoid mixing with a potential NIP-51 list with the same name. val AROUND_ME = " Around Me " +// This has spaces to avoid mixing with a potential NIP-51 list with the same name. +val CHESS = " Chess " + @Stable class AccountSettings( val keyPair: KeyPair, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt index 3ac691e91..417b4d7b8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Constants.kt @@ -37,6 +37,7 @@ object Constants { val nostoday = RelayUrlNormalizer.normalize("wss://search.nos.today") val antiprimal = RelayUrlNormalizer.normalize("wss://antiprimal.net") + val ditto = RelayUrlNormalizer.normalize("wss://relay.ditto.pub") val auth = RelayUrlNormalizer.normalize("wss://auth.nostr1.com") val oxchat = RelayUrlNormalizer.normalize("wss://relay.0xchat.com") diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCacheAddressExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCacheAddressExt.kt index df3ae4ed4..717289460 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCacheAddressExt.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LargeSoftCacheAddressExt.kt @@ -67,6 +67,18 @@ fun LargeSoftCache.filter( consumer: CacheCollectors.BiFilter = ACCEPT_ALL_FILTER, ): List = filter(kindStart(kind, pubKey), kindEnd(kind, pubKey), consumer) +fun LargeSoftCache.filter( + kinds: List, + pubKey: HexKey, + consumer: CacheCollectors.BiFilter = ACCEPT_ALL_FILTER, +): Set { + val set = mutableSetOf() + kinds.forEach { + set.addAll(filterIntoSet(kindStart(it, pubKey), kindEnd(it, pubKey), consumer)) + } + return set +} + fun LargeSoftCache.filterIntoSet( kind: Int, consumer: CacheCollectors.BiFilter = ACCEPT_ALL_FILTER, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt index f4f8f743b..75df5628b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/LocalCache.kt @@ -61,7 +61,6 @@ import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProvide import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent -import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.isAddressable @@ -168,6 +167,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.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent @@ -180,6 +186,8 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent @@ -411,18 +419,18 @@ object LocalCache : ILocalCache, ICacheProvider { return null } - override fun checkGetOrCreateNote(key: String): Note? { - if (ATag.isATag(key)) { - return checkGetOrCreateAddressableNote(key) + override fun checkGetOrCreateNote(hexKey: String): Note? { + if (ATag.isATag(hexKey)) { + return checkGetOrCreateAddressableNote(hexKey) } - if (isValidHex(key)) { - val note = getOrCreateNote(key) + if (isValidHex(hexKey)) { + val note = getOrCreateNote(hexKey) val noteEvent = note.event return if (noteEvent is AddressableEvent) { // upgrade to the latest - val newNote = checkGetOrCreateAddressableNote(noteEvent.aTag().toTag()) + val newNote = getOrCreateAddressableNote(noteEvent.address()) - if (newNote != null && newNote.event == null) { + if (newNote.event == null) { val author = note.author ?: getOrCreateUser(noteEvent.pubKey) newNote.loadEvent(noteEvent as Event, author, emptyList()) note.moveAllReferencesTo(newNote) @@ -715,6 +723,49 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) + // Chess events (NIP-64 live chess) + fun consume( + event: ChessGameEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + fun consume( + event: JesterEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + fun consume( + event: LiveChessGameChallengeEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: LiveChessGameAcceptEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: LiveChessMoveEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: LiveChessGameEndEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + + fun consume( + event: LiveChessDrawOfferEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + fun consume( event: NipTextEvent, relay: NormalizedRelayUrl?, @@ -1355,10 +1406,13 @@ object LocalCache : ILocalCache, ICacheProvider { ) = consumeBaseReplaceable(event, relay, wasVerified) private fun consumeBaseReplaceable( - event: BaseAddressableEvent, + event: Event, relay: NormalizedRelayUrl?, wasVerified: Boolean, ): Boolean { + // TODO: Redo the Event sctructure in Quartz to avoid this check + check(event is AddressableEvent) { "Event must be addressable: ${event.kind}" } + val version = getOrCreateNote(event.id) val replaceableNote = getOrCreateAddressableNote(event.address()) val author = getOrCreateUser(event.pubKey) @@ -1520,6 +1574,12 @@ object LocalCache : ILocalCache, ICacheProvider { deleteNote.author?.statusStateOrNull()?.removeStatus(deleteNote) } + if (deletedEvent is PollResponseEvent) { + deletedEvent.poll()?.eventId?.let { + getNoteIfExists(it)?.pollStateOrNull()?.removeResponse(deleteNote) + } + } + if (deletedEvent is TorrentCommentEvent) { deletedEvent.torrentIds()?.let { getNoteIfExists(it)?.removeReply(deleteNote) @@ -1998,6 +2058,32 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) + fun consume( + event: PollEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeRegularEvent(event, relay, wasVerified) + + fun consume( + event: PollResponseEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ): Boolean { + val pollId = event.poll()?.eventId + if (pollId != null) { + val pollNote = getOrCreateNote(pollId) + val responseNote = getOrCreateNote(event.id) + + val new = consumeRegularEvent(event, relay, wasVerified) + if (new) { + pollNote.pollState().addResponse(responseNote) + } + return new + } + + return false + } + fun consume( event: FileStorageEvent, relay: NormalizedRelayUrl?, @@ -2611,6 +2697,12 @@ object LocalCache : ILocalCache, ICacheProvider { note.author?.statusStateOrNull()?.removeStatus(note) } + if (noteEvent is PollResponseEvent) { + noteEvent.poll()?.eventId?.let { + getNoteIfExists(it)?.pollStateOrNull()?.removeResponse(note) + } + } + note.clearFlow() notes.remove(note.idHex) @@ -2677,7 +2769,7 @@ object LocalCache : ILocalCache, ICacheProvider { note?.event?.let { noteEvent -> if (noteEvent is AddressableEvent) { - getAddressableNoteIfExists(noteEvent.aTag().toTag())?.addRelay(relay) + getAddressableNoteIfExists(noteEvent.address())?.addRelay(relay) } } @@ -2966,6 +3058,13 @@ object LocalCache : ILocalCache, ICacheProvider { is GitReplyEvent -> consume(event, relay, wasVerified) is GitPatchEvent -> consume(event, relay, wasVerified) is GitRepositoryEvent -> consume(event, relay, wasVerified) + is ChessGameEvent -> consume(event, relay, wasVerified) + is JesterEvent -> consume(event, relay, wasVerified) + is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified) + is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified) + is LiveChessMoveEvent -> consume(event, relay, wasVerified) + is LiveChessGameEndEvent -> consume(event, relay, wasVerified) + is LiveChessDrawOfferEvent -> consume(event, relay, wasVerified) is HashtagListEvent -> consume(event, relay, wasVerified) is HighlightEvent -> consume(event, relay, wasVerified) is IndexerRelayListEvent -> consume(event, relay, wasVerified) @@ -2998,6 +3097,8 @@ object LocalCache : ILocalCache, ICacheProvider { is PublicMessageEvent -> consume(event, relay, wasVerified) is PeopleListEvent -> consume(event, relay, wasVerified) is PollNoteEvent -> consume(event, relay, wasVerified) + is PollEvent -> consume(event, relay, wasVerified) + is PollResponseEvent -> consume(event, relay, wasVerified) is ReactionEvent -> consume(event, relay, wasVerified) is ContactCardEvent -> consume(event, relay, wasVerified) is RelaySetEvent -> consume(event, relay, wasVerified) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip64Chess/ChessAction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip64Chess/ChessAction.kt new file mode 100644 index 000000000..143f6ce8b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip64Chess/ChessAction.kt @@ -0,0 +1,146 @@ +/* + * 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.nip64Chess + +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 + +/** + * Action class for creating and signing live chess events + */ +class ChessAction { + companion object { + /** + * Create a new chess game challenge + * + * @param gameId Unique identifier for the game + * @param playerColor Color the challenger wants to play + * @param opponentPubkey Optional opponent pubkey (null = open challenge) + * @param timeControl Optional time control (e.g., "10+0") + * @param signer Nostr signer + */ + suspend fun createChallenge( + gameId: String, + playerColor: Color, + opponentPubkey: String? = null, + timeControl: String? = null, + signer: NostrSigner, + ): LiveChessGameChallengeEvent = + signer.sign( + LiveChessGameChallengeEvent.build( + gameId = gameId, + playerColor = playerColor, + opponentPubkey = opponentPubkey, + timeControl = timeControl, + ), + ) + + /** + * Accept a chess game challenge + * + * @param gameId Game identifier from the challenge + * @param challengeEventId Event ID of the challenge + * @param challengerPubkey Pubkey of the challenger + * @param signer Nostr signer + */ + suspend fun acceptChallenge( + gameId: String, + challengeEventId: String, + challengerPubkey: String, + signer: NostrSigner, + ): LiveChessGameAcceptEvent = + signer.sign( + LiveChessGameAcceptEvent.build( + gameId = gameId, + challengeEventId = challengeEventId, + challengerPubkey = challengerPubkey, + ), + ) + + /** + * Publish a move in a live chess game + * + * @param gameId Game identifier + * @param moveNumber Move number (1-based) + * @param san Move in Standard Algebraic Notation + * @param fen Resulting position in FEN notation + * @param opponentPubkey Opponent's pubkey + * @param comment Optional move comment + * @param signer Nostr signer + */ + suspend fun publishMove( + gameId: String, + moveNumber: Int, + san: String, + fen: String, + opponentPubkey: String, + comment: String = "", + signer: NostrSigner, + ): LiveChessMoveEvent = + signer.sign( + LiveChessMoveEvent.build( + gameId = gameId, + moveNumber = moveNumber, + san = san, + fen = fen, + opponentPubkey = opponentPubkey, + comment = comment, + ), + ) + + /** + * End a chess game and publish result + * + * @param gameId Game identifier + * @param result Game result (1-0, 0-1, or 1/2-1/2) + * @param termination How the game ended + * @param winnerPubkey Pubkey of winner (if applicable) + * @param opponentPubkey Opponent's pubkey + * @param pgn Optional PGN of complete game + * @param signer Nostr signer + */ + suspend fun endGame( + gameId: String, + result: GameResult, + termination: GameTermination, + winnerPubkey: String? = null, + opponentPubkey: String, + pgn: String = "", + signer: NostrSigner, + ): LiveChessGameEndEvent = + signer.sign( + LiveChessGameEndEvent.build( + gameId = gameId, + result = result, + termination = termination, + winnerPubkey = winnerPubkey, + opponentPubkey = opponentPubkey, + pgn = pgn, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip96FileStorage/FileStorageServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip96FileStorage/FileStorageServerListState.kt deleted file mode 100644 index 686824996..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip96FileStorage/FileStorageServerListState.kt +++ /dev/null @@ -1,81 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.model.nip96FileStorage - -import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.model.NoteState -import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn - -class FileStorageServerListState( - val signer: NostrSigner, - val cache: LocalCache, - val scope: CoroutineScope, - val settings: AccountSettings, -) { - // Creates a long-term reference for this note so that the GC doesn't collect the note it self - val fileStorageListNote = cache.getOrCreateAddressableNote(getFileServersAddress()) - - fun getFileServersAddress() = FileServersEvent.createAddress(signer.pubKey) - - fun getFileServersListFlow(): StateFlow = fileStorageListNote.flow().metadata.stateFlow - - fun getFileServersList(): FileServersEvent? = fileStorageListNote.event as? FileServersEvent - - fun normalizeServers(note: Note): List { - val event = note.event as? FileServersEvent - return event?.servers() ?: emptyList() - } - - val flow = - getFileServersListFlow() - .map { normalizeServers(it.note) } - .onStart { emit(normalizeServers(fileStorageListNote)) } - .flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - emptyList(), - ) - - suspend fun saveFileServersList(servers: List): FileServersEvent { - val serverList = getFileServersList() - - val template = - if (serverList != null && serverList.tags.isNotEmpty()) { - FileServersEvent.replaceServers(serverList, servers) - } else { - FileServersEvent.build(servers) - } - - return signer.sign(template) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt index 39539fadf..735868c78 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nipB7Blossom/BlossomServerListState.kt @@ -24,10 +24,14 @@ import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.NoteState +import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName +import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.utils.Rfc3986 import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.SharingStarted @@ -57,6 +61,13 @@ class BlossomServerListState( return event?.servers() ?: emptyList() } + fun host(url: String): String = + try { + Rfc3986.host(url).removePrefix("cdn.").removePrefix("blossom.") + } catch (e: Exception) { + url.removePrefix("cdn.").removePrefix("blossom.") + } + val flow = getBlossomServersListFlow() .map { normalizeServers(it.note) } @@ -68,6 +79,21 @@ class BlossomServerListState( emptyList(), ) + fun mergeServerList(blossom: List?): List = blossom?.map { ServerName(host(it), it, ServerType.Blossom) } ?: emptyList() + + val hostNameFlow: StateFlow> = + flow + .map { blossoms -> + mergeServerList(blossoms) + }.onStart { + emit(mergeServerList(flow.value)) + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + DEFAULT_MEDIA_SERVERS, + ) + suspend fun saveBlossomServersList(servers: List): BlossomServersEvent { val serverList = getBlossomServersList() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt index 7e6284ec7..97422d41b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/preferences/TorSharedPreferences.kt @@ -101,7 +101,7 @@ class TorSharedPreferences( videosViaTor = preferences[VIDEOS_VIA_TOR_KEY] ?: false, moneyOperationsViaTor = preferences[MONEY_OPERATIONS_VIA_TOR_KEY] ?: false, nip05VerificationsViaTor = preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] ?: false, - nip96UploadsViaTor = preferences[MEDIA_UPLOADS_VIA_TOR_KEY] ?: false, + mediaUploadsViaTor = preferences[MEDIA_UPLOADS_VIA_TOR_KEY] ?: false, ) } catch (e: Exception) { if (e is CancellationException) throw e @@ -125,7 +125,7 @@ class TorSharedPreferences( preferences[VIDEOS_VIA_TOR_KEY] = torSettings.videosViaTor preferences[MONEY_OPERATIONS_VIA_TOR_KEY] = torSettings.moneyOperationsViaTor preferences[NIP05_VERIFICATIONS_VIA_TOR_KEY] = torSettings.nip05VerificationsViaTor - preferences[MEDIA_UPLOADS_VIA_TOR_KEY] = torSettings.nip96UploadsViaTor + preferences[MEDIA_UPLOADS_VIA_TOR_KEY] = torSettings.mediaUploadsViaTor } } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt index 76d918be7..aacb3e262 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt @@ -122,8 +122,8 @@ class RoleBasedHttpClientBuilder( fun shouldUseTorForUploads(url: String) = when (torSettings.torType.value) { TorType.OFF -> false - TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.nip96UploadsViaTor.value) - TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.nip96UploadsViaTor.value) + TorType.INTERNAL -> checkLocalHostOnionAndThen(url, torSettings.mediaUploadsViaTor.value) + TorType.EXTERNAL -> checkLocalHostOnionAndThen(url, torSettings.mediaUploadsViaTor.value) } override fun proxyPortForVideo(url: String): Int? = okHttpClient.getCurrentProxyPort(shouldUseTorForVideoDownload(url)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedServerListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedServerListState.kt deleted file mode 100644 index 9889f0b18..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/serverList/MergedServerListState.kt +++ /dev/null @@ -1,73 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.model.serverList - -import com.vitorpamplona.amethyst.model.nip96FileStorage.FileStorageServerListState -import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState -import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType -import com.vitorpamplona.quartz.utils.Rfc3986 -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.onStart -import kotlinx.coroutines.flow.stateIn - -class MergedServerListState( - val fileServers: FileStorageServerListState, - val blossomServers: BlossomServerListState, - val scope: CoroutineScope, -) { - fun host(url: String): String = - try { - Rfc3986.host(url) - } catch (e: Exception) { - url - } - - fun mergeServerList( - nip96: List?, - blossom: List?, - ): List { - val nip96servers = nip96?.map { ServerName(host(it), it, ServerType.NIP96) } ?: emptyList() - val blossomServers = blossom?.map { ServerName(host(it), it, ServerType.Blossom) } ?: emptyList() - - val result = (nip96servers + blossomServers).ifEmpty { DEFAULT_MEDIA_SERVERS } - - return result + ServerName("NIP95", "", ServerType.NIP95) - } - - val liveServerList: StateFlow> = - combine(fileServers.flow, blossomServers.flow) { nip96s, blossoms -> - mergeServerList(nip96s, blossoms) - }.onStart { - emit(mergeServerList(fileServers.flow.value, blossomServers.flow.value)) - }.flowOn(Dispatchers.IO) - .stateIn( - scope, - SharingStarted.Eagerly, - DEFAULT_MEDIA_SERVERS, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt index 746776ac9..03bea20ea 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/FeedTopNavFilterState.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.topNavFeeds import com.vitorpamplona.amethyst.model.ALL_FOLLOWS import com.vitorpamplona.amethyst.model.ALL_USER_FOLLOWS import com.vitorpamplona.amethyst.model.AROUND_ME +import com.vitorpamplona.amethyst.model.CHESS import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.LocalCache @@ -32,6 +33,7 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlo import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.AllUserFollowsFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.allUserFollows.Kind3UserFollowsFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.AroundMeFeedFlow +import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.NoteFeedFlow import com.vitorpamplona.amethyst.model.topNavFeeds.unknown.UnknownFeedFlow @@ -84,6 +86,10 @@ class FeedTopNavFilterState( AroundMeFeedFlow(locationFlow, followsRelays, proxyRelays) } + CHESS -> { + ChessFeedFlow(followsRelays, proxyRelays) + } + else -> { val note = LocalCache.checkGetOrCreateAddressableNote(listName) if (note != null) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/ProxySettingsAnchor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessFeedFlow.kt similarity index 63% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/ProxySettingsAnchor.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessFeedFlow.kt index 287589899..2d323ad74 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/okhttp/ProxySettingsAnchor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessFeedFlow.kt @@ -18,26 +18,26 @@ * 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.okhttp +package com.vitorpamplona.amethyst.model.topNavFeeds.chess -import com.vitorpamplona.amethyst.model.torState.TorRelayEvaluation -import com.vitorpamplona.amethyst.model.torState.TorRelaySettings +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -class ProxySettingsAnchor { - val flow: MutableStateFlow> = - MutableStateFlow( - MutableStateFlow( - TorRelayEvaluation( - torSettings = TorRelaySettings(), - trustedRelayList = emptySet(), - dmRelayList = emptySet(), - ), - ), - ) +class ChessFeedFlow( + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedFlowsType { + val default = ChessTopNavFilter(outboxRelays, proxyRelays) - var isDM: (NormalizedRelayUrl) -> Boolean = { it in flow.value.value.dmRelayList } - var useProxy: (NormalizedRelayUrl) -> Boolean = { flow.value.value.useTor(it) } + override fun flow() = MutableStateFlow(default) + + override fun startValue(): ChessTopNavFilter = default + + override suspend fun startValue(collector: FlowCollector) { + collector.emit(startValue()) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavFilter.kt new file mode 100644 index 000000000..de820177d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavFilter.kt @@ -0,0 +1,63 @@ +/* + * 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.topNavFeeds.chess + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.LocalCache +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 kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine + +@Immutable +class ChessTopNavFilter( + val outboxRelays: StateFlow>, + val proxyRelays: StateFlow>, +) : IFeedTopNavFilter { + override fun matchAuthor(pubkey: HexKey): Boolean = true + + override fun match(noteEvent: Event) = + noteEvent is ChessGameEvent || + noteEvent is LiveChessGameChallengeEvent || + noteEvent is LiveChessGameEndEvent + + override fun toPerRelayFlow(cache: LocalCache): Flow = + combine(outboxRelays, proxyRelays) { outboxRelays, proxyRelays -> + if (proxyRelays.isNotEmpty()) { + ChessTopNavPerRelayFilterSet(proxyRelays.associateWith { ChessTopNavPerRelayFilter }) + } else { + ChessTopNavPerRelayFilterSet(outboxRelays.associateWith { ChessTopNavPerRelayFilter }) + } + } + + override fun startValue(cache: LocalCache): ChessTopNavPerRelayFilterSet = + if (proxyRelays.value.isNotEmpty()) { + ChessTopNavPerRelayFilterSet(proxyRelays.value.associateWith { ChessTopNavPerRelayFilter }) + } else { + ChessTopNavPerRelayFilterSet(outboxRelays.value.associateWith { ChessTopNavPerRelayFilter }) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavPerRelayFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavPerRelayFilter.kt new file mode 100644 index 000000000..49da8bb5f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavPerRelayFilter.kt @@ -0,0 +1,27 @@ +/* + * 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.topNavFeeds.chess + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilter + +@Immutable +object ChessTopNavPerRelayFilter : IFeedTopNavPerRelayFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavPerRelayFilterSet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavPerRelayFilterSet.kt new file mode 100644 index 000000000..cfc589883 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/chess/ChessTopNavPerRelayFilterSet.kt @@ -0,0 +1,28 @@ +/* + * 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.topNavFeeds.chess + +import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavPerRelayFilterSet +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class ChessTopNavPerRelayFilterSet( + val set: Map, +) : IFeedTopNavPerRelayFilterSet diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt index e259a3bff..fe33323c9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/ImageLoaderSetup.kt @@ -40,6 +40,7 @@ import coil3.request.Options import coil3.size.Precision import coil3.svg.SvgDecoder import coil3.util.Logger +import coil3.video.VideoFrameDecoder import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.quartz.utils.Log import okhttp3.Call @@ -73,6 +74,7 @@ class ImageLoaderSetup { .components { add(gifFactory) add(svgFactory) + add(VideoFrameDecoder.Factory()) add(Base64Fetcher.Factory) add(BlurHashFetcher.Factory) add(Base64Fetcher.BKeyer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt index 40a36abbf..79119551f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/ControlWhenPlayerIsActive.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State import androidx.compose.ui.platform.LocalView import androidx.media3.common.Player import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia @@ -33,16 +32,16 @@ import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia @Composable fun ControlWhenPlayerIsActive( mediaControllerState: MediaControllerState, - automaticallyStartPlayback: State, + automaticallyStartPlayback: Boolean, isClosestToTheCenterOfTheScreen: MutableState, ) { - val controller = mediaControllerState.controller ?: return + val controller = mediaControllerState.controller LaunchedEffect(key1 = isClosestToTheCenterOfTheScreen.value, key2 = mediaControllerState) { // active means being fully visible if (isClosestToTheCenterOfTheScreen.value) { // should auto start video from settings? - if (!automaticallyStartPlayback.value) { + if (!automaticallyStartPlayback) { if (controller.isPlaying) { // if it is visible, it's playing but it wasn't supposed to start automatically. controller.pause() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt index 02c05ca05..3375b7919 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/GetVideoController.kt @@ -21,225 +21,55 @@ package com.vitorpamplona.amethyst.service.playback.composable import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.platform.LocalContext -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import androidx.lifecycle.compose.LocalLifecycleOwner -import androidx.media3.common.Player +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.flow.onEach @Composable fun GetVideoController( mediaItem: LoadedMediaItem, muted: Boolean = false, + play: Boolean = false, inner: @Composable (mediaControllerState: MediaControllerState) -> Unit, ) { val context = LocalContext.current + val controllerState by remember(mediaItem) { + PlaybackServiceClient + .controllerAsFlow( + videoUri = mediaItem.src.videoUri, + proxyPort = mediaItem.src.proxyPort, + keepPlaying = mediaItem.src.keepPlaying, + context = context, + ).onEach { state -> + Log.d("PlaybackService", "Controller instance: ${state.controller}") - val onlyOnePreparing = remember { AtomicBoolean() } - - val controllerId = remember(mediaItem.src.videoUri) { MediaControllerState() } - - controllerId.composed = true - - val scope = rememberCoroutineScope() - - // Prepares a VideoPlayer from the foreground service. - // - // TODO: Review this code because a new Disposable Effect can run - // before the onDispose of the previous composable and the onDispose - // sometimes affects the new variables, not the old ones. - DisposableEffect(key1 = mediaItem.src.videoUri) { - // If it is not null, the user might have come back from a playing video, like clicking on - // the notification of the video player. - if (controllerId.needsController()) { - // If there is a connection, don't wait. - if (!onlyOnePreparing.getAndSet(true)) { - scope.launch(Dispatchers.IO) { - Log.d("PlaybackService", "Preparing Video ${controllerId.id} ${mediaItem.src.videoUri}") - PlaybackServiceClient.prepareController( - mediaControllerState = controllerId, - videoUri = mediaItem.src.videoUri, - proxyPort = mediaItem.src.proxyPort, - keepPlaying = mediaItem.src.keepPlaying, - context = context, - ) { controllerId -> - scope.launch(Dispatchers.Main) { - // checks if the player is still active after requesting to load - if (!controllerId.isActive()) { - onlyOnePreparing.getAndSet(false) - PlaybackServiceClient.removeController(controllerId) - return@launch - } - - // REQUIRED TO BE RUN IN THE MAIN THREAD - if (!controllerId.isPlaying()) { - if (BackgroundMedia.isPlaying()) { - // There is a video playing, start this one on mute. - controllerId.controller?.volume = 0f - } else { - // There is no other video playing. Use the default mute state to - // decide if sound is on or not. - controllerId.controller?.volume = if (muted) 0f else 1f - } - } - - controllerId.controller?.setMediaItem(mediaItem.item) - controllerId.controller?.prepare() - - // checks if the player is still active after requesting to load - if (!controllerId.isActive()) { - PlaybackServiceClient.removeController(controllerId) - return@launch - } - - controllerId.readyToDisplay.value = true - - onlyOnePreparing.getAndSet(false) - - // checks if the player is still active after requesting to load - if (!controllerId.isActive()) { - PlaybackServiceClient.removeController(controllerId) - return@launch - } - } - } + if (BackgroundMedia.isPlaying()) { + // There is a video playing, start this one on mute. + state.controller.volume = 0f + Log.d("PlaybackService", "OnEach Muted due to BackgroundMedia.isPlaying") + } else { + // There is no other video playing. Use the default mute state to + // decide if sound is on or not. + state.controller.volume = if (muted) 0f else 1f + Log.d("PlaybackService", "OnEach $muted") } - } - } else { - // has been loaded. prepare to play. This happens when the background video switches screens. - controllerId.controller?.let { - scope.launch { - // checks if the player is still active after requesting to load - if (!controllerId.isActive()) { - PlaybackServiceClient.removeController(controllerId) - return@launch - } - if (it.playbackState == Player.STATE_IDLE || it.playbackState == Player.STATE_ENDED) { - Log.d("PlaybackService", "Preparing Existing Video ${mediaItem.src.videoUri} ") - - if (it.isPlaying) { - // There is a video playing, start this one on mute. - it.volume = 0f - } else { - // There is no other video playing. Use the default mute state to - // decide if sound is on or not. - it.volume = if (muted) 0f else 1f - } - - if (mediaItem.item != it.currentMediaItem) { - it.setMediaItem(mediaItem.item) - } - - it.prepare() - - // checks if the player is still active after requesting to load - if (!controllerId.isActive()) { - PlaybackServiceClient.removeController(controllerId) - return@launch - } - } + if (play) { + state.controller.playWhenReady = true } + + state.controller.setMediaItem(mediaItem.item) + state.controller.prepare() } - } + }.collectAsStateWithLifecycle(null) - onDispose { - controllerId.composed = false - if (!controllerId.pictureInPictureActive.value) { - PlaybackServiceClient.removeController(controllerId) - } - } - } - - // User pauses and resumes the app. What to do with videos? - val lifeCycleOwner = LocalLifecycleOwner.current - DisposableEffect(key1 = lifeCycleOwner) { - val observer = - LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - controllerId.composed = true - // if the controller is null, restarts the controller with a new one - // if the controller is not null, just continue playing what the controller was playing - if (controllerId.needsController()) { - if (!onlyOnePreparing.getAndSet(true)) { - scope.launch(Dispatchers.IO) { - Log.d("PlaybackService", "Preparing Video from Resume ${controllerId.id} ${mediaItem.src.videoUri} ") - PlaybackServiceClient.prepareController( - mediaControllerState = controllerId, - videoUri = mediaItem.src.videoUri, - proxyPort = mediaItem.src.proxyPort, - keepPlaying = mediaItem.src.keepPlaying, - context = context, - ) { controllerId -> - scope.launch(Dispatchers.Main) { - // checks if the player is still active after requesting to load - if (!controllerId.isActive()) { - onlyOnePreparing.getAndSet(false) - PlaybackServiceClient.removeController(controllerId) - return@launch - } - - // REQUIRED TO BE RUN IN THE MAIN THREAD - // checks again to make sure no other thread has created a controller. - if (!controllerId.isPlaying()) { - if (BackgroundMedia.isPlaying()) { - // There is a video playing, start this one on mute. - controllerId.controller?.volume = 0f - } else { - // There is no other video playing. Use the default mute state to - // decide if sound is on or not. - controllerId.controller?.volume = if (muted) 0f else 1f - } - } - - controllerId.controller?.setMediaItem(mediaItem.item) - controllerId.controller?.prepare() - - // checks if the player is still active after requesting to load - if (!controllerId.isActive()) { - onlyOnePreparing.getAndSet(false) - PlaybackServiceClient.removeController(controllerId) - return@launch - } - - controllerId.readyToDisplay.value = true - - onlyOnePreparing.getAndSet(false) - - // checks if the player is still active after requesting to load - if (!controllerId.isActive()) { - PlaybackServiceClient.removeController(controllerId) - return@launch - } - } - } - } - } - } - } - if (event == Lifecycle.Event.ON_PAUSE) { - controllerId.composed = false - PlaybackServiceClient.removeController(controllerId) - } - } - - lifeCycleOwner.lifecycle.addObserver(observer) - onDispose { lifeCycleOwner.lifecycle.removeObserver(observer) } - } - - if (controllerId.readyToDisplay.value && controllerId.active.value) { - controllerId.controller?.let { - inner(controllerId) - } + controllerState?.let { + inner(it) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt index a16aeea85..d8f4f68d3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/LoadThumbAndThenVideoView.kt @@ -42,7 +42,7 @@ fun LoadThumbAndThenVideoView( contentScale: ContentScale, nostrUriCallback: String? = null, accountViewModel: AccountViewModel, - onDialog: ((Boolean) -> Unit)? = null, + onDialog: (() -> Unit)? = null, ) { var loadingFinished by remember { mutableStateOf>(Pair(false, null)) } val context = LocalContext.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt index 42b49036f..b1a4f3b51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/MediaControllerState.kt @@ -21,10 +21,8 @@ package com.vitorpamplona.amethyst.service.playback.composable import android.graphics.Rect -import androidx.compose.runtime.MutableState import androidx.compose.runtime.Stable -import androidx.compose.runtime.mutableStateOf -import androidx.media3.session.MediaController +import androidx.media3.common.Player import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -34,25 +32,25 @@ class MediaControllerState( // each composable has an ID. val id: String = Uuid.random().toString(), // This is filled after the controller returns from this class - var controller: MediaController? = null, - // this set's the stage to keep playing on the background or not when the user leaves the screen - val pictureInPictureActive: MutableState = mutableStateOf(false), - // this will be false if the screen leaves before the controller connection comes back from the service. - val active: MutableState = mutableStateOf(true), - // this will be set to own when the controller is ready - val readyToDisplay: MutableState = mutableStateOf(false), - // isCurrentlyBeingRendered - var composed: Boolean = false, + var controller: Player, // visibility onscreen val visibility: VisibilityData = VisibilityData(), ) { - fun isPlaying() = controller?.isPlaying == true + fun isPlaying() = controller.isPlaying - fun currrentMedia() = controller?.currentMediaItem?.mediaId + fun currrentMedia() = controller.currentMediaItem?.mediaId - fun isActive() = active.value == true + fun toggleMute() { + controller.volume = if (controller.volume == 0f) 1f else 0f + } - fun needsController() = controller == null + fun togglePlayPause() { + if (controller.isPlaying) { + controller.pause() + } else { + controller.play() + } + } } @Stable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt index cef1e7ef5..2de1333a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/RenderVideoPlayer.kt @@ -20,28 +20,43 @@ */ package com.vitorpamplona.amethyst.service.playback.composable -import android.content.Context -import android.view.View -import android.widget.FrameLayout import androidx.annotation.OptIn +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.geometry.Size import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.media3.ui.AspectRatioFrameLayout -import androidx.media3.ui.PlayerView -import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderControlButtons +import androidx.media3.ui.compose.ContentFrame +import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW +import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderAnimatedBottomInfo +import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCenterButtons +import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTopButtons import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +private fun getVideoSizeDp(player: Player): Size? { + var videoSize = Size(player.videoSize.width.toFloat(), player.videoSize.height.toFloat()) + + if (videoSize.width == 0f || videoSize.height == 0f) return null + + val par = player.videoSize.pixelWidthHeightRatio + if (par < 1.0) { + videoSize = videoSize.copy(width = videoSize.width * par) + } else if (par > 1.0) { + videoSize = videoSize.copy(height = videoSize.height / par) + } + return videoSize +} + @Composable @OptIn(UnstableApi::class) fun RenderVideoPlayer( @@ -52,74 +67,37 @@ fun RenderVideoPlayer( contentScale: ContentScale, borderModifier: Modifier, videoModifier: Modifier, - onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, - onDialog: ((Boolean) -> Unit)?, + onDialog: (() -> Unit)? = null, + controllerVisible: MutableState = remember { mutableStateOf(false) }, accountViewModel: AccountViewModel, ) { - val controllerVisible = remember(controllerState) { mutableStateOf(false) } - Box(modifier = borderModifier) { - AndroidView( - modifier = videoModifier, - factory = { context: Context -> - PlayerView(context).apply { - player = controllerState.controller - // if we alrady know the size of the frame, this forces the player to stay in the size - layoutParams = - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.WRAP_CONTENT, - FrameLayout.LayoutParams.WRAP_CONTENT, - ) - - setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS) - setBackgroundColor(Color.Transparent.toArgb()) - setShutterBackgroundColor(Color.Transparent.toArgb()) - - controllerAutoShow = false - useController = showControls - thumbData?.thumb?.let { defaultArtwork = it } - hideController() - - resizeMode = - when (contentScale) { - ContentScale.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT - ContentScale.FillWidth -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH - ContentScale.Crop -> AspectRatioFrameLayout.RESIZE_MODE_FILL - ContentScale.FillHeight -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_HEIGHT - ContentScale.Inside -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM - else -> AspectRatioFrameLayout.RESIZE_MODE_FIXED_WIDTH - } - - if (showControls) { - onDialog?.let { innerOnDialog -> - setFullscreenButtonClickListener { - controllerState.controller?.pause() - innerOnDialog(it) - } - } - setControllerVisibilityListener( - PlayerView.ControllerVisibilityListener { visible -> - controllerVisible.value = visible == View.VISIBLE - onControllerVisibilityChanged?.let { callback -> callback(visible == View.VISIBLE) } - }, - ) - } - } - }, + ContentFrame( + player = controllerState.controller, + modifier = + videoModifier.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, // to prevent the ripple from the tap + ) { controllerVisible.value = !controllerVisible.value }, + surfaceType = SURFACE_TYPE_TEXTURE_VIEW, // texture view is better inside lazy layouts. + contentScale = contentScale, ) mediaItem.src.waveformData?.let { Waveform(it, controllerState, Modifier.align(Alignment.Center)) } if (showControls) { - RenderControlButtons( - mediaItem.src, - controllerState, - controllerVisible, - Modifier.align(Alignment.TopEnd), - accountViewModel, + RenderTopButtons( + mediaData = mediaItem.src, + controllerState = controllerState, + controllerVisible = controllerVisible, + onZoomClick = onDialog, + modifier = Modifier.align(Alignment.TopEnd), + accountViewModel = accountViewModel, ) - } else { - controllerState.controller?.volume = 0f + + RenderCenterButtons(controllerState, controllerVisible, Modifier.align(Alignment.Center)) + + RenderAnimatedBottomInfo(controllerState, controllerVisible, Modifier.align(Alignment.BottomCenter)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt index a5658a15a..f7e31c162 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoView.kt @@ -65,8 +65,7 @@ fun VideoView( dimensions: DimensionTag? = null, blurhash: String? = null, nostrUriCallback: String? = null, - onDialog: ((Boolean) -> Unit)? = null, - onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, + onDialog: (() -> Unit)? = null, accountViewModel: AccountViewModel, alwaysShowVideo: Boolean = false, ) { @@ -79,7 +78,7 @@ fun VideoView( Modifier } - VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, onControllerVisibilityChanged, accountViewModel, alwaysShowVideo) + VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel) } @Composable @@ -96,11 +95,10 @@ fun VideoView( dimensions: DimensionTag? = null, blurhash: String? = null, nostrUriCallback: String? = null, - onDialog: ((Boolean) -> Unit)? = null, - onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, - accountViewModel: AccountViewModel, + onDialog: (() -> Unit)? = null, alwaysShowVideo: Boolean = false, showControls: Boolean = true, + accountViewModel: AccountViewModel, ) { val automaticallyStartPlayback = remember { @@ -137,9 +135,8 @@ fun VideoView( artworkUri = artworkUri, authorName = authorName, nostrUriCallback = nostrUriCallback, - automaticallyStartPlayback = automaticallyStartPlayback, - onControllerVisibilityChanged = onControllerVisibilityChanged, - onDialog = onDialog, + automaticallyStartPlayback = automaticallyStartPlayback.value, + onZoom = onDialog, accountViewModel = accountViewModel, showControls = showControls, ) @@ -184,9 +181,8 @@ fun VideoView( artworkUri = artworkUri, authorName = authorName, nostrUriCallback = nostrUriCallback, - automaticallyStartPlayback = automaticallyStartPlayback, - onControllerVisibilityChanged = onControllerVisibilityChanged, - onDialog = onDialog, + automaticallyStartPlayback = automaticallyStartPlayback.value, + onZoom = onDialog, accountViewModel = accountViewModel, showControls = showControls, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt index 6f560f0c7..68e285b64 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/VideoViewInner.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.amethyst.service.playback.composable import androidx.compose.runtime.Composable -import androidx.compose.runtime.State +import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -46,9 +46,9 @@ fun VideoViewInner( artworkUri: String? = null, authorName: String? = null, nostrUriCallback: String? = null, - automaticallyStartPlayback: State, - onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, - onDialog: ((Boolean) -> Unit)? = null, + automaticallyStartPlayback: Boolean, + controllerVisible: MutableState = mutableStateOf(true), + onZoom: (() -> Unit)? = null, accountViewModel: AccountViewModel, ) { // keeps a copy of the value to avoid recompositions here when the DEFAULT value changes @@ -80,8 +80,8 @@ fun VideoViewInner( contentScale = contentScale, borderModifier = borderModifier, videoModifier = videoModifier, - onControllerVisibilityChanged = onControllerVisibilityChanged, - onDialog = onDialog, + controllerVisible = controllerVisible, + onDialog = onZoom, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullScreenButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullScreenButton.kt new file mode 100644 index 000000000..280f86c9f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/FullScreenButton.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.composable.controls + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ZoomOutMap +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize +import com.vitorpamplona.amethyst.ui.theme.Size50Modifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Preview +@Composable +fun FullScreenButtonPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + FullScreenButton( + controllerVisible = remember { mutableStateOf(true) }, + modifier = Modifier, + ) {} + } + } +} + +@Composable +fun FullScreenButton( + controllerVisible: MutableState, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + AnimatedVisibility( + visible = controllerVisible.value, + modifier = modifier, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + Box(modifier = PinBottomIconSize) { + Box( + Modifier + .clip(CircleShape) + .fillMaxSize(0.7f) + .align(Alignment.Center) + .background(MaterialTheme.colorScheme.background), + ) + + IconButton( + onClick = onClick, + modifier = Size50Modifier, + ) { + Icon( + imageVector = Icons.Default.ZoomOutMap, + contentDescription = stringRes(id = R.string.enter_picture_in_picture), + modifier = modifier, + tint = MaterialTheme.colorScheme.onBackground, + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt new file mode 100644 index 000000000..44b0ec8d1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/HorizontalLinearProgressIndicator.kt @@ -0,0 +1,132 @@ +/* + * 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.playback.composable.controls + +import androidx.annotation.OptIn +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.state.rememberProgressStateWithTickCount +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Preview +@Composable +fun HorizontalLinearProgressIndicatorPreview() { + ThemeComparisonColumn { + Column(Modifier.background(BitcoinOrange)) { + HorizontalLinearProgressIndicator( + currentPositionProgress = { 0.00f }, + bufferedPositionProgress = { 0.10f }, + onLayoutWidthChanged = { widthPx -> }, + onSeek = {}, + ) + HorizontalLinearProgressIndicator( + currentPositionProgress = { 0.10f }, + bufferedPositionProgress = { 0.20f }, + onLayoutWidthChanged = { widthPx -> }, + onSeek = {}, + ) + HorizontalLinearProgressIndicator( + currentPositionProgress = { 1.00f }, + bufferedPositionProgress = { 1.00f }, + onLayoutWidthChanged = { widthPx -> }, + onSeek = {}, + ) + } + } +} + +@OptIn(UnstableApi::class) +@Composable +fun HorizontalLinearProgressIndicator( + player: Player, + totalTickCount: Int = 0, +) { + var ticks by remember(totalTickCount) { mutableIntStateOf(totalTickCount) } + val progressState = rememberProgressStateWithTickCount(player, ticks) + HorizontalLinearProgressIndicator( + currentPositionProgress = { progressState.currentPositionProgress }, + bufferedPositionProgress = { progressState.bufferedPositionProgress }, + onLayoutWidthChanged = { widthPx -> if (totalTickCount == 0) ticks = widthPx }, + onSeek = { pos -> player.seekTo((pos * player.duration).toLong()) }, + ) +} + +@Composable +private fun HorizontalLinearProgressIndicator( + currentPositionProgress: () -> Float, + bufferedPositionProgress: () -> Float = currentPositionProgress, + onLayoutWidthChanged: (Int) -> Unit = {}, + onSeek: (Float) -> Unit, + playedColor: Color = Color.White, + bufferedColor: Color = Color.LightGray, + unplayedColor: Color = Color.DarkGray, + scrubberColor: Color = playedColor, + rectHeightDp: Dp = 3.dp, +) { + Canvas( + Modifier + .fillMaxWidth() + .padding(horizontal = rectHeightDp * 2.5f) + .pointerInput(Unit) { + detectTapGestures { offset -> + // Capture the exact position + onSeek(offset.x / this.size.width.toFloat()) + } + }.padding(vertical = rectHeightDp * 2) + .height(rectHeightDp) + .onSizeChanged { (w, _) -> onLayoutWidthChanged(w) }, + ) { + val positionX = (currentPositionProgress() * size.width).coerceAtLeast(0f) + val bufferX = (bufferedPositionProgress() * size.width).coerceAtLeast(0f) + + drawRect(unplayedColor, size = Size(size.width, size.height)) + drawRect(bufferedColor, size = Size(bufferX, size.height)) + drawRect(playedColor, size = Size(positionX, size.height)) + + drawCircle( + color = scrubberColor, + radius = size.height * 1.5f, + center = Offset(x = positionX, y = size.height / 2.0f), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt index 60f95ceaf..8f189888f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/MuteButton.kt @@ -41,20 +41,38 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.playback.composable.controls.HorizontalLinearProgressIndicator import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.Size30Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +@Preview +@Composable +fun MuteButtonPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + MuteButton( + controllerVisible = remember { mutableStateOf(true) }, + startingMuteState = true, + modifier = Modifier, + ) {} + } + } +} + @Composable fun MuteButton( controllerVisible: MutableState, startingMuteState: Boolean, - modifier: Modifier, + modifier: Modifier = Modifier, toggle: (Boolean) -> Unit, ) { val holdOn = @@ -83,7 +101,7 @@ fun MuteButton( Box( Modifier .clip(CircleShape) - .fillMaxSize(0.6f) + .fillMaxSize(0.7f) .align(Alignment.Center) .background(MaterialTheme.colorScheme.background), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PictureInPictureButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PictureInPictureButton.kt index 885f49a1b..f3dc6f4ad 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PictureInPictureButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PictureInPictureButton.kt @@ -20,6 +20,8 @@ */ package com.vitorpamplona.amethyst.service.playback.composable.controls +import android.app.AppOpsManager +import android.content.Context import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -31,23 +33,54 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.ui.note.EnablePiP +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize import com.vitorpamplona.amethyst.ui.theme.Size22Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Preview +@Composable +fun PictureInPictureButtonPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + PictureInPictureButton( + controllerVisible = remember { mutableStateOf(true) }, + modifier = Modifier, + ) {} + } + } +} @Composable fun PictureInPictureButton( controllerVisible: MutableState, - modifier: Modifier, + modifier: Modifier = Modifier, onClick: () -> Unit, ) { + val context = LocalContext.current + val isAllowed = + remember { + val appOps = context.getSystemService(Context.APP_OPS_SERVICE) as AppOpsManager + val mode = + appOps.checkOpNoThrow( + AppOpsManager.OPSTR_PICTURE_IN_PICTURE, + android.os.Process.myUid(), + context.packageName, + ) + mode == AppOpsManager.MODE_ALLOWED + } + AnimatedVisibility( - visible = controllerVisible.value, + visible = isAllowed && controllerVisible.value, modifier = modifier, enter = remember { fadeIn() }, exit = remember { fadeOut() }, @@ -56,7 +89,7 @@ fun PictureInPictureButton( Box( Modifier .clip(CircleShape) - .fillMaxSize(0.6f) + .fillMaxSize(0.7f) .align(Alignment.Center) .background(MaterialTheme.colorScheme.background), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlayPauseButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlayPauseButton.kt new file mode 100644 index 000000000..0160cf2cc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlayPauseButton.kt @@ -0,0 +1,130 @@ +/* + * 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.playback.composable.controls + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.PlayIconSize +import com.vitorpamplona.amethyst.ui.theme.Size50Modifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Preview +@Composable +fun AnimatedPlayButtonPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + AnimatedPlayPauseButton( + controllerVisible = remember { mutableStateOf(true) }, + isPlaying = true, + modifier = Modifier, + ) { } + } + } +} + +@Preview +@Composable +fun AnimatedPauseButtonPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + AnimatedPlayPauseButton( + controllerVisible = remember { mutableStateOf(true) }, + isPlaying = false, + modifier = Modifier, + ) { } + } + } +} + +@Composable +fun AnimatedPlayPauseButton( + controllerVisible: State, + modifier: Modifier = Modifier, + isPlaying: Boolean, + onClick: () -> Unit, +) { + AnimatedVisibility( + visible = controllerVisible.value, + modifier = modifier, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + PlayPauseButton(isPlaying, onClick) + } +} + +@Composable +fun PlayPauseButton( + isPlaying: Boolean, + onClick: () -> Unit, +) { + Box(modifier = PlayIconSize, contentAlignment = Alignment.Center) { + Box( + Modifier + .clip(CircleShape) + .fillMaxSize(0.6f) + .background(MaterialTheme.colorScheme.background), + ) + + IconButton( + onClick = onClick, + modifier = Modifier.size(80.dp), + ) { + if (!isPlaying) { + Icon( + imageVector = Icons.Default.PlayArrow, + modifier = Size50Modifier, + contentDescription = stringRes(R.string.play), + ) + } else { + Icon( + imageVector = Icons.Default.Pause, + modifier = Size50Modifier, + contentDescription = stringRes(R.string.play), + ) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlaybackSpeedPopUpButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlaybackSpeedPopUpButton.kt new file mode 100644 index 000000000..1c3b53f2f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlaybackSpeedPopUpButton.kt @@ -0,0 +1,167 @@ +/* + * 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.playback.composable.controls + +import androidx.annotation.OptIn +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +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.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.state.rememberPlaybackSpeedState +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf + +@Preview +@Composable +fun PlaybackSpeedPopUpButtonPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + PlaybackSpeedPopUpButton( + playbackSpeed = 0.50f, + updatePlaybackSpeed = {}, + modifier = Modifier, + ) + } + } +} + +@Preview +@Composable +fun BottomDialogOfChoicesBodyPreview() { + ThemeComparisonRow { + Box(Modifier.background(BitcoinOrange)) { + BottomDialogOfChoicesBody( + currentSpeed = 0.50f, + choices = persistentListOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f), + onDismissRequest = {}, + onSelectChoice = {}, + ) + } + } +} + +@OptIn(UnstableApi::class) +@Composable +fun PlaybackSpeedPopUpButton( + player: Player, + modifier: Modifier = Modifier, + speedSelection: ImmutableList = persistentListOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f), +) { + val state = rememberPlaybackSpeedState(player) + + if (state.isEnabled) { + PlaybackSpeedPopUpButton( + state.playbackSpeed, + state::updatePlaybackSpeed, + modifier, + speedSelection, + ) + } +} + +@OptIn(UnstableApi::class) +@Composable +fun PlaybackSpeedPopUpButton( + playbackSpeed: Float, + updatePlaybackSpeed: (speed: Float) -> Unit, + modifier: Modifier = Modifier, + speedSelection: ImmutableList = persistentListOf(0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 1.75f, 2.0f), +) { + var openDialog by remember { mutableStateOf(false) } + Box { + Text( + text = "%.1fx".format(playbackSpeed), + modifier = modifier.clickable(onClick = { openDialog = true }), + style = MaterialTheme.typography.labelLarge, + ) + + if (openDialog) { + Popup( + alignment = Alignment.BottomCenter, + onDismissRequest = { openDialog = false }, + properties = PopupProperties(focusable = true), + ) { + BottomDialogOfChoicesBody( + currentSpeed = playbackSpeed, + choices = speedSelection, + onDismissRequest = { openDialog = false }, + onSelectChoice = updatePlaybackSpeed, + ) + } + } + } +} + +@Composable +private fun BottomDialogOfChoicesBody( + currentSpeed: Float, + choices: ImmutableList, + onDismissRequest: () -> Unit, + onSelectChoice: (Float) -> Unit, +) { + val colors = + ButtonDefaults.textButtonColors().copy( + contentColor = MaterialTheme.colorScheme.onBackground, + ) + + Column( + modifier = Modifier.background(MaterialTheme.colorScheme.background), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + choices.forEach { speed -> + TextButton( + colors = colors, + onClick = { + onSelectChoice(speed) + onDismissRequest() + }, + ) { + var fontWeight = FontWeight(400) + if (speed == currentSpeed) { + fontWeight = FontWeight(1000) + } + Text("%.1fx".format(speed), fontWeight = fontWeight) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PositionAndDurationText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PositionAndDurationText.kt new file mode 100644 index 000000000..bafa1a06a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PositionAndDurationText.kt @@ -0,0 +1,82 @@ +/* + * 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.playback.composable.controls + +import androidx.annotation.OptIn +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import androidx.media3.common.Player +import androidx.media3.common.util.UnstableApi +import androidx.media3.common.util.Util +import androidx.media3.ui.compose.state.rememberProgressStateWithTickInterval +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@OptIn(UnstableApi::class) +@Preview +@Composable +fun PositionAndDurationTextPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + PositionAndDurationText( + 133000, + 1000000, + modifier = Modifier, + ) + } + } +} + +@OptIn(UnstableApi::class) +@Composable +fun PositionAndDurationText( + player: Player, + modifier: Modifier = Modifier, +) { + val state = rememberProgressStateWithTickInterval(player, tickIntervalMs = 1000L) + + PositionAndDurationText( + state.currentPositionMs, + state.durationMs, + modifier, + ) +} + +@UnstableApi +@Composable +fun PositionAndDurationText( + positionMs: Long, + durationMs: Long, + modifier: Modifier = Modifier, +) { + val text = "${Util.getStringForTime(positionMs)} / ${Util.getStringForTime(durationMs)}" + + Text( + text = text, + style = MaterialTheme.typography.labelLarge, + modifier = modifier, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderBottomInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderBottomInfo.kt new file mode 100644 index 000000000..905041c3d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderBottomInfo.kt @@ -0,0 +1,78 @@ +/* + * 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.playback.composable.controls + +import androidx.annotation.OptIn +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +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.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi +import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState + +@OptIn(UnstableApi::class) +@Composable +fun RenderAnimatedBottomInfo( + controllerState: MediaControllerState, + controllerVisible: MutableState, + modifier: Modifier, +) { + AnimatedVisibility( + visible = controllerVisible.value, + modifier = modifier, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + // Button panel controls + Column(modifier.fillMaxWidth()) { + HorizontalLinearProgressIndicator(controllerState.controller) + RenderBottomButtonLine(controllerState) + } + } +} + +@OptIn(UnstableApi::class) +@Composable +fun RenderBottomButtonLine(controllerState: MediaControllerState) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, bottom = 5.dp), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + PositionAndDurationText(controllerState.controller) + Spacer(Modifier.weight(1f)) + PlaybackSpeedPopUpButton(controllerState.controller) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt new file mode 100644 index 000000000..45c554f35 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt @@ -0,0 +1,52 @@ +/* + * 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.playback.composable.controls + +import androidx.annotation.OptIn +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.state.rememberPlayPauseButtonState +import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState +import kotlinx.coroutines.delay + +@OptIn(UnstableApi::class) +@Composable +fun RenderCenterButtons( + controllerState: MediaControllerState, + controllerVisible: MutableState, + modifier: Modifier, +) { + val state = rememberPlayPauseButtonState(controllerState.controller) + + AnimatedPlayPauseButton(controllerVisible, modifier, !state.showPlay) { + state.onClick() + } + + if (!state.showPlay) { + LaunchedEffect(state.showPlay) { + delay(2000) + controllerVisible.value = false + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt deleted file mode 100644 index 888a6ddb0..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderControlButtons.kt +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.service.playback.composable.controls - -import androidx.compose.foundation.layout.padding -import androidx.compose.runtime.Composable -import androidx.compose.runtime.MutableState -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo -import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING -import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState -import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData -import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming -import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity -import com.vitorpamplona.amethyst.ui.components.ShareMediaAction -import com.vitorpamplona.amethyst.ui.components.getActivity -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.Size110dp -import com.vitorpamplona.amethyst.ui.theme.Size165dp -import com.vitorpamplona.amethyst.ui.theme.Size55dp - -@Composable -fun RenderControlButtons( - mediaData: MediaItemData, - controllerState: MediaControllerState, - controllerVisible: MutableState, - buttonPositionModifier: Modifier, - accountViewModel: AccountViewModel, -) { - MuteButton( - controllerVisible, - (controllerState.controller?.volume ?: 0f) < 0.001, - buttonPositionModifier, - ) { mute: Boolean -> - // makes the new setting the default for new creations. - DEFAULT_MUTED_SETTING.value = mute - - controllerState.controller?.volume = if (mute) 0f else 1f - } - - val context = LocalContext.current.getActivity() - - PictureInPictureButton( - controllerVisible, - buttonPositionModifier.padding(end = Size55dp), - ) { - PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context) - } - - if (!isLiveStreaming(mediaData.videoUri)) { - AnimatedSaveButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { context -> - accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) - } - - AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size165dp)) { popupExpanded, toggle -> - ShareMediaAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle, content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri)) - } - } else { - AnimatedShareButton(controllerVisible, buttonPositionModifier.padding(end = Size110dp)) { popupExpanded, toggle -> - ShareMediaAction(accountViewModel = accountViewModel, popupExpanded, mediaData.videoUri, mediaData.callbackUri, null, null, null, mediaData.mimeType, toggle, content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri)) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt new file mode 100644 index 000000000..e103ec1e4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -0,0 +1,167 @@ +/* + * 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.playback.composable.controls + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo +import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING +import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState +import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData +import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming +import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity +import com.vitorpamplona.amethyst.ui.components.ShareMediaAction +import com.vitorpamplona.amethyst.ui.components.getActivity +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Preview +@Composable +fun RenderTopButtonsPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + RenderTopButtons( + mediaData = MediaItemData("http://test.mp4"), + controllerVisible = remember { mutableStateOf(true) }, + startingMuteState = false, + onMuteClick = { }, + onPictureInPictureClick = { }, + onZoomClick = { }, + modifier = Modifier, + accountViewModel = mockAccountViewModel(), + ) + } + } +} + +@Composable +fun RenderTopButtons( + mediaData: MediaItemData, + controllerState: MediaControllerState, + controllerVisible: MutableState, + onZoomClick: (() -> Unit)?, + modifier: Modifier, + accountViewModel: AccountViewModel, +) { + val context = LocalContext.current.getActivity() + + RenderTopButtons( + mediaData = mediaData, + controllerVisible = controllerVisible, + startingMuteState = controllerState.controller.volume < 0.001, + onMuteClick = { mute -> + // makes the new setting the default for new creations. + DEFAULT_MUTED_SETTING.value = mute + controllerState.controller.volume = if (mute) 0f else 1f + }, + onPictureInPictureClick = { + controllerState.controller.pause() + PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context) + }, + onZoomClick = + onZoomClick?.let { + { + controllerState.controller.pause() + it() + } + }, + modifier = modifier, + accountViewModel = accountViewModel, + ) +} + +@Composable +fun RenderTopButtons( + mediaData: MediaItemData, + controllerVisible: MutableState, + startingMuteState: Boolean, + onMuteClick: (Boolean) -> Unit, + onPictureInPictureClick: () -> Unit, + onZoomClick: (() -> Unit)?, + modifier: Modifier, + accountViewModel: AccountViewModel, +) { + Row(modifier) { + if (!isLiveStreaming(mediaData.videoUri)) { + AnimatedShareButton(controllerVisible) { popupExpanded, toggle -> + ShareMediaAction( + popupExpanded = popupExpanded, + videoUri = mediaData.videoUri, + postNostrUri = mediaData.callbackUri, + blurhash = null, + dim = null, + hash = null, + mimeType = mediaData.mimeType, + onDismiss = toggle, + content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri), + accountViewModel = accountViewModel, + ) + } + + AnimatedSaveButton(controllerVisible) { context -> + accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) + } + } else { + AnimatedShareButton(controllerVisible) { popupExpanded, toggle -> + ShareMediaAction( + popupExpanded = popupExpanded, + videoUri = mediaData.videoUri, + postNostrUri = mediaData.callbackUri, + blurhash = null, + dim = null, + hash = null, + mimeType = mediaData.mimeType, + onDismiss = toggle, + content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri), + accountViewModel = accountViewModel, + ) + } + } + + if (onZoomClick != null) { + FullScreenButton( + controllerVisible = controllerVisible, + onClick = onZoomClick, + ) + } + + PictureInPictureButton( + controllerVisible = controllerVisible, + onClick = onPictureInPictureClick, + ) + + MuteButton( + controllerVisible = controllerVisible, + startingMuteState = startingMuteState, + toggle = onMuteClick, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt index f31531c04..11e6111fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SaveMediaButton.kt @@ -38,26 +38,43 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.State +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.tooling.preview.Preview import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import kotlinx.coroutines.launch +@Preview +@Composable +fun AnimatedSaveButtonPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + AnimatedSaveButton( + controllerVisible = remember { mutableStateOf(true) }, + modifier = Modifier, + ) {} + } + } +} + @Composable fun AnimatedSaveButton( controllerVisible: State, - modifier: Modifier, + modifier: Modifier = Modifier, onSaveClick: (localContext: Context) -> Unit, ) { AnimatedVisibility( @@ -77,7 +94,7 @@ fun SaveMediaButton(onSaveClick: (localContext: Context) -> Unit) { Box( Modifier .clip(CircleShape) - .fillMaxSize(0.6f) + .fillMaxSize(0.7f) .align(Alignment.Center) .background(MaterialTheme.colorScheme.background), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/ShareButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/ShareButton.kt index e26e6db55..4e96d9f32 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/ShareButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/ShareButton.kt @@ -40,16 +40,32 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size50Modifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Preview +@Composable +fun AnimatedShareButtonPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + AnimatedShareButton( + controllerVisible = remember { mutableStateOf(true) }, + modifier = Modifier, + ) { _, _ -> } + } + } +} @Composable fun AnimatedShareButton( controllerVisible: State, - modifier: Modifier, + modifier: Modifier = Modifier, innerAction: @Composable (MutableState, () -> Unit) -> Unit, ) { AnimatedVisibility( @@ -64,13 +80,13 @@ fun AnimatedShareButton( @Composable fun ShareButton(innerAction: @Composable (MutableState, () -> Unit) -> Unit) { - Box(modifier = PinBottomIconSize) { + Box(modifier = PinBottomIconSize, contentAlignment = Alignment.Center) { Box( - Modifier - .clip(CircleShape) - .fillMaxSize(0.6f) - .align(Alignment.Center) - .background(MaterialTheme.colorScheme.background), + modifier = + Modifier + .clip(CircleShape) + .fillMaxSize(0.7f) + .background(MaterialTheme.colorScheme.background), ) val popupExpanded = remember { mutableStateOf(false) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt index b34df0c0f..1a15d7d1f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/mainVideo/VideoPlayerActiveMutex.kt @@ -30,7 +30,6 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.composed import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned @@ -40,7 +39,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerSta import kotlin.math.abs // This keeps the position of all visible videos in the current screen. -val trackingVideos = mutableListOf() +val trackingVideos = mutableSetOf() /** * This function selects only one Video to be active. The video that is closest to the center of the @@ -52,17 +51,21 @@ fun VideoPlayerActiveMutex( inner: @Composable (Modifier, MutableState) -> Unit, ) { // Is the current video the closest to the center? - val isClosestToTheCenterOfTheScreen = remember(controller) { mutableStateOf(false) } + val isClosestToTheCenterOfTheScreen = remember(controller) { mutableStateOf(false) } // Keep track of all available videos. DisposableEffect(key1 = controller) { trackingVideos.add(controller) - onDispose { trackingVideos.remove(controller) } + onDispose { + trackingVideos.remove(controller) + } } + val view = LocalView.current + val videoModifier = remember(controller) { - Modifier.fillMaxWidth().heightIn(min = 100.dp).onVisiblePositionChanges { bounds, distanceToCenter -> + Modifier.fillMaxWidth().heightIn(min = 100.dp).onVisiblePositionChanges(view) { bounds, distanceToCenter -> controller.visibility.bounds = bounds controller.visibility.distanceToCenter = distanceToCenter @@ -93,15 +96,14 @@ fun VideoPlayerActiveMutex( inner(videoModifier, isClosestToTheCenterOfTheScreen) } -fun Modifier.onVisiblePositionChanges(onVisiblePosition: (Rect, Float?) -> Unit): Modifier = - composed { - val view = LocalView.current - - onGloballyPositioned { coordinates -> - val bounds = coordinates.boundsInWindow() - val boundRect = Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt()) - onVisiblePosition(boundRect, coordinates.getDistanceToVertCenterIfVisible(boundRect, view)) - } +fun Modifier.onVisiblePositionChanges( + view: View, + onVisiblePosition: (Rect, Float?) -> Unit, +): Modifier = + onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + val boundRect = Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt()) + onVisiblePosition(boundRect, coordinates.getDistanceToVertCenterIfVisible(boundRect, view)) } fun LayoutCoordinates.getDistanceToVertCenterIfVisible( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt index b5bc0648f..71d9d3a16 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/wavefront/Waveform.kt @@ -35,7 +35,6 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.media3.common.Player -import androidx.media3.session.MediaController import com.linc.audiowaveform.infiniteLinearGradient import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState import com.vitorpamplona.amethyst.service.playback.composable.WaveformData @@ -56,31 +55,25 @@ fun Waveform( val restartFlow = remember { mutableIntStateOf(0) } - val myController = mediaControllerState.controller - // Keeps the screen on while playing and viewing videos. - if (myController != null) { - DisposableEffect(key1 = myController) { - val listener = - object : Player.Listener { - override fun onIsPlayingChanged(isPlaying: Boolean) { - // doesn't consider the mutex because the screen can turn off if the video - // being played in the mutex is not visible. - if (isPlaying) { - restartFlow.intValue += 1 - } + DisposableEffect(key1 = mediaControllerState.controller) { + val listener = + object : Player.Listener { + override fun onIsPlayingChanged(isPlaying: Boolean) { + // doesn't consider the mutex because the screen can turn off if the video + // being played in the mutex is not visible. + if (isPlaying) { + restartFlow.intValue += 1 } } + } - myController.addListener(listener) - onDispose { myController.removeListener(listener) } - } + mediaControllerState.controller.addListener(listener) + onDispose { mediaControllerState.controller.removeListener(listener) } } LaunchedEffect(key1 = restartFlow.intValue) { - mediaControllerState.controller?.let { - pollCurrentDuration(it).collect { value -> waveformProgress.floatValue = value } - } + pollCurrentDuration(mediaControllerState.controller).collect { value -> waveformProgress.floatValue = value } } LaunchedEffect(Unit) { @@ -90,7 +83,7 @@ fun Waveform( } } -private fun pollCurrentDuration(controller: MediaController) = +private fun pollCurrentDuration(controller: Player) = flow { while (controller.currentPosition <= controller.duration) { emit(controller.currentPosition / controller.duration.toFloat()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt index 477a23010..a316de304 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/BackgroundMedia.kt @@ -20,36 +20,43 @@ */ package com.vitorpamplona.amethyst.service.playback.pip +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState -import com.vitorpamplona.amethyst.service.playback.service.PlaybackServiceClient -import kotlinx.coroutines.flow.MutableStateFlow object BackgroundMedia { // background playing mutex. - val bgInstance = MutableStateFlow(null) + var bgInstance: MediaControllerState? = null - fun hasInstance() = bgInstance.value != null + fun hasInstance() = bgInstance != null - fun isPlaying() = bgInstance.value?.isPlaying() == true + fun isPlaying() = bgInstance?.isPlaying() == true - fun isMutex(controller: MediaControllerState): Boolean = controller.id == bgInstance.value?.id + fun isMutex(controller: MediaControllerState): Boolean = controller.id == bgInstance?.id fun hasBackgroundButNot(mediaControllerState: MediaControllerState): Boolean = hasInstance() && !isMutex(mediaControllerState) fun removeBackgroundControllerAndReleaseIt() { - bgInstance.value?.let { - PlaybackServiceClient.removeController(it) - bgInstance.tryEmit(null) - } + bgInstance = null } fun switchKeepPlaying(mediaControllerState: MediaControllerState) { - bgInstance.tryEmit(mediaControllerState) + bgInstance = mediaControllerState } fun clearBackground(mediaControllerState: MediaControllerState) { - if (bgInstance.value == mediaControllerState) { - bgInstance.tryEmit(null) + if (bgInstance == mediaControllerState) { + bgInstance = null + } + } +} + +@Composable +fun RegisterBackgroundMedia(controllerState: MediaControllerState) { + DisposableEffect(controllerState) { + BackgroundMedia.switchKeepPlaying(controllerState) + onDispose { + BackgroundMedia.clearBackground(controllerState) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PiPFromIntents.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PiPFromIntents.kt new file mode 100644 index 000000000..815338ac8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PiPFromIntents.kt @@ -0,0 +1,81 @@ +/* + * 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.playback.pip + +import android.app.Activity +import android.content.Intent +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext +import androidx.core.util.Consumer +import com.vitorpamplona.amethyst.model.MediaAspectRatioCache +import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING +import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData +import com.vitorpamplona.amethyst.ui.components.getActivity +import kotlinx.coroutines.launch + +@Composable +fun rememberVideoDataFromIntents(): MutableState { + val activity = LocalContext.current.getActivity() + + val videoData = + remember { + mutableStateOf(activity.processIntentForPiP(activity.intent)) + } + + val scope = rememberCoroutineScope() + + DisposableEffect(activity) { + val consumer = + Consumer { intent -> + scope.launch { + videoData.value = activity.processIntentForPiP(intent) + } + } + + activity.addOnNewIntentListener(consumer) + onDispose { + activity.removeOnNewIntentListener(consumer) + } + } + + return videoData +} + +fun Activity.processIntentForPiP(intent: Intent): MediaItemData? { + val videoDataOnCreation = IntentExtras.loadBundle(intent.extras) + val bounds = IntentExtras.loadBounds(intent.extras) + val ratio = videoDataOnCreation?.aspectRatio ?: videoDataOnCreation?.videoUri?.let { MediaAspectRatioCache.get(it) } + val isPlaying = true + val isMuted = DEFAULT_MUTED_SETTING.value + + if (!isInPictureInPictureMode) { + enterPictureInPictureMode(makePipParams(isPlaying, isMuted, ratio, bounds)) + } else { + setPictureInPictureParams(makePipParams(isPlaying, isMuted, ratio, bounds)) + } + + return videoDataOnCreation +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PictureInPicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PictureInPicture.kt index 04aadfe74..ba836e13c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PictureInPicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PictureInPicture.kt @@ -20,20 +20,11 @@ */ package com.vitorpamplona.amethyst.service.playback.pip +import android.app.Activity import android.app.PictureInPictureParams import android.graphics.Rect import android.os.Build import android.util.Rational -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext -import androidx.core.app.PictureInPictureModeChangedInfo -import androidx.core.util.Consumer -import com.vitorpamplona.amethyst.ui.components.getActivity fun makePipParams( ratio: Float?, @@ -49,18 +40,25 @@ fun makePipParams( ratio?.let { setAspectRatio(Rational((it * 1000).toInt(), 1000)) } }.build() -@Composable -fun rememberIsInPipMode(): Boolean { - val activity = LocalContext.current.getActivity() - var pipMode by remember { mutableStateOf(activity.isInPictureInPictureMode) } - DisposableEffect(activity) { - val observer = - Consumer { info -> - pipMode = info.isInPictureInPictureMode +fun Activity.makePipParams( + isPlaying: Boolean, + isMuted: Boolean, + ratio: Float?, + bounds: Rect?, +): PictureInPictureParams = + PictureInPictureParams + .Builder() + .apply { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setAutoEnterEnabled(true) } - activity.addOnPictureInPictureModeChangedListener(observer) - onDispose { activity.removeOnPictureInPictureModeChangedListener(observer) } - } - return pipMode -} + setActions( + listOf( + createPlayPauseAction(this@makePipParams, isPlaying), + createMuteAction(this@makePipParams, isMuted), + ), + ) + bounds?.let { setSourceRectHint(bounds) } + ratio?.let { setAspectRatio(Rational((it * 1000).toInt(), 1000)) } + }.build() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt index 379dd42ff..f69ce32c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoActivity.kt @@ -23,15 +23,18 @@ package com.vitorpamplona.amethyst.service.playback.pip import android.app.ActivityOptions import android.content.Context import android.content.Intent -import android.content.res.Configuration import android.graphics.Rect import android.os.Build import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.annotation.OptIn +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.media3.common.util.UnstableApi -import com.vitorpamplona.amethyst.model.MediaAspectRatioCache +import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING +import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController +import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData class PipVideoActivity : ComponentActivity() { @@ -39,22 +42,22 @@ class PipVideoActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - var videoData = IntentExtras.loadBundle(this.intent.extras) - val bounds = IntentExtras.loadBounds(this.intent.extras) - val ratio = videoData?.aspectRatio ?: videoData?.videoUri?.let { MediaAspectRatioCache.get(it) } - - enterPipMode(ratio, bounds) - setContent { - PipVideo() - } - } + val videoData by rememberVideoDataFromIntents() + videoData?.let { mediaItemData -> + // keeps a copy of the value to avoid recompositions here when the DEFAULT value changes + val muted = remember(mediaItemData) { DEFAULT_MUTED_SETTING.value } - override fun onPictureInPictureModeChanged( - isInPictureInPictureMode: Boolean, - newConfig: Configuration, - ) { - super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig) + GetMediaItem(mediaItemData) { mediaItem -> + GetVideoController(mediaItem, muted, true) { controllerState -> + RegisterBackgroundMedia(controllerState) + RegisterControllerReceiver(controllerState) + WatchControllerForActions(mediaItemData, controllerState) + RenderPipVideo(controllerState, mediaItemData.waveformData) + } + } + } + } } override fun onStop() { @@ -67,11 +70,6 @@ class PipVideoActivity : ComponentActivity() { super.finish() } - override fun onUserLeaveHint() { - super.onUserLeaveHint() - finishAndRemoveTask() - } - companion object { fun callIn( videoData: MediaItemData, @@ -86,13 +84,10 @@ class PipVideoActivity : ComponentActivity() { } else { ActivityOptions.makeBasic() } - options.setLaunchBounds(videoBounds) context.startActivity( - Intent(context, PipVideoActivity::class.java).apply { + Intent(context.applicationContext, PipVideoActivity::class.java).apply { putExtras(IntentExtras.createBundle(videoData, videoBounds)) - addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION) }, options.toBundle(), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt index 14a51f341..7a44dbccb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/PipVideoView.kt @@ -20,109 +20,58 @@ */ package com.vitorpamplona.amethyst.service.playback.pip -import android.content.Context -import android.content.Intent +import android.content.Context.RECEIVER_EXPORTED +import android.os.Build import androidx.annotation.OptIn import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.aspectRatio import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.viewinterop.AndroidView -import androidx.core.util.Consumer import androidx.media3.common.util.UnstableApi -import androidx.media3.ui.AspectRatioFrameLayout -import androidx.media3.ui.PlayerView +import androidx.media3.ui.compose.ContentFrame +import androidx.media3.ui.compose.state.rememberMuteButtonState +import androidx.media3.ui.compose.state.rememberPlayPauseButtonState import com.vitorpamplona.amethyst.model.MediaAspectRatioCache -import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState import com.vitorpamplona.amethyst.service.playback.composable.WaveformData -import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem +import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform +import com.vitorpamplona.amethyst.service.playback.pip.makePipParams import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.theme.VoiceHeightModifier -@Composable -fun PipVideo() { - val activity = LocalContext.current.getActivity() - var videoData by remember(activity) { - mutableStateOf(IntentExtras.loadBundle(activity.intent.extras)) - } - - DisposableEffect(activity) { - val consumer = - Consumer { intent -> - videoData = IntentExtras.loadBundle(intent.extras) - val bounds = IntentExtras.loadBounds(intent.extras) - val ratio = videoData?.aspectRatio ?: videoData?.videoUri?.let { MediaAspectRatioCache.get(it) } - - activity.enterPipMode(ratio, bounds) - } - - activity.addOnNewIntentListener(consumer) - onDispose { activity.removeOnNewIntentListener(consumer) } - } - - videoData?.let { - GetMediaItem(it) { mediaItem -> - GetVideoController(mediaItem, false) { controller -> - PipVideo(controller, it.waveformData) - } - } - } -} - @OptIn(UnstableApi::class) @Composable -fun PipVideo( +fun RenderPipVideo( controller: MediaControllerState, waveformData: WaveformData?, ) { - DisposableEffect(controller) { - BackgroundMedia.switchKeepPlaying(controller) - onDispose { - BackgroundMedia.clearBackground(controller) - } - } - - val ratio = - controller.currrentMedia()?.let { - MediaAspectRatioCache.get(it) - } - val modifier = - if (ratio != null) { - Modifier.aspectRatio(ratio) - } else { - Modifier + remember { + val ratio = + controller.currrentMedia()?.let { + MediaAspectRatioCache.get(it) + } + + if (ratio != null) { + Modifier.aspectRatio(ratio) + } else { + Modifier + } } Box(modifier, contentAlignment = Alignment.Center) { - AndroidView( - modifier = Modifier, - factory = { context: Context -> - PlayerView(context).apply { - clipToOutline = true - player = controller.controller - setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS) - - controllerAutoShow = false - useController = false - - hideController() - - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL - - controller.controller?.playWhenReady = true - } - }, + ContentFrame( + player = controller.controller, + keepContentOnReset = true, + contentScale = ContentScale.Crop, ) Row(VoiceHeightModifier, verticalAlignment = Alignment.CenterVertically) { @@ -130,3 +79,51 @@ fun PipVideo( } } } + +@Composable +fun RegisterControllerReceiver(controllerState: MediaControllerState) { + val context = LocalContext.current + + // Use DisposableEffect to manage the receiver's lifecycle + DisposableEffect(context) { + val receiver = + ActionReceiver( + onMute = controllerState::toggleMute, + onPlayPause = controllerState::togglePlayPause, + ) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + context.registerReceiver(receiver, receiver.filterMute, RECEIVER_EXPORTED) + context.registerReceiver(receiver, receiver.filterPlayPause, RECEIVER_EXPORTED) + } else { + context.registerReceiver(receiver, receiver.filterMute) + context.registerReceiver(receiver, receiver.filterPlayPause) + } + + onDispose { + context.unregisterReceiver(receiver) + } + } +} + +@OptIn(UnstableApi::class) +@Composable +fun WatchControllerForActions( + mediaItemData: MediaItemData, + controllerState: MediaControllerState, +) { + val playPauseState = rememberPlayPauseButtonState(controllerState.controller) + val muteState = rememberMuteButtonState(controllerState.controller) + + val activity = LocalContext.current.getActivity() + LaunchedEffect(activity, playPauseState.showPlay, muteState.showMuted) { + activity.setPictureInPictureParams( + activity.makePipParams( + isPlaying = !playPauseState.showPlay, + isMuted = !muteState.showMuted, + ratio = mediaItemData.aspectRatio, + bounds = null, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/VideoPlayerActions.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/VideoPlayerActions.kt new file mode 100644 index 000000000..5f0a1afa4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/VideoPlayerActions.kt @@ -0,0 +1,95 @@ +/* + * 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.playback.pip + +import android.app.PendingIntent +import android.app.RemoteAction +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.drawable.Icon +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes + +const val ACTION_MUTE = "com.vitorpamplona.amethyst.MUTE" +const val ACTION_PLAY_PAUSE = "com.vitorpamplona.amethyst.PLAY_PAUSE" + +class ActionReceiver( + val onMute: () -> Unit, + val onPlayPause: () -> Unit, +) : BroadcastReceiver() { + val filterMute = IntentFilter(ACTION_MUTE) + val filterPlayPause = IntentFilter(ACTION_PLAY_PAUSE) + + override fun onReceive( + context: Context, + intent: Intent, + ) { + when (intent.action) { + ACTION_MUTE -> onMute() + ACTION_PLAY_PAUSE -> onPlayPause() + } + } +} + +fun createMuteAction( + context: Context, + isMuted: Boolean, +): RemoteAction { + val icon = + if (isMuted) { + Icon.createWithResource(context, androidx.media3.session.R.drawable.media3_icon_volume_up) + } else { + Icon.createWithResource(context, androidx.media3.session.R.drawable.media3_icon_volume_off) + } + val title = if (isMuted) stringRes(context, R.string.muted_button) else stringRes(context, R.string.mute_button) + + val intent = + PendingIntent.getBroadcast( + context, + 0, + Intent(ACTION_MUTE), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + return RemoteAction(icon, title, title, intent) +} + +fun createPlayPauseAction( + context: Context, + isPlaying: Boolean, +): RemoteAction { + val icon = + if (!isPlaying) { + Icon.createWithResource(context, androidx.media3.ui.compose.material3.R.drawable.media3_icon_play) + } else { + Icon.createWithResource(context, androidx.media3.session.R.drawable.media3_icon_pause) + } + val title = if (!isPlaying) stringRes(context, R.string.play) else stringRes(context, R.string.pause) + val intent = + PendingIntent.getBroadcast( + context, + 0, + Intent(ACTION_PLAY_PAUSE), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + return RemoteAction(icon, title, title, intent) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt index a8496a07f..c8d4f3082 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/ExoPlayerPool.kt @@ -45,7 +45,7 @@ class ExoPlayerPool( // Exists to avoid exceptions stopping the coroutine val exceptionHandler = CoroutineExceptionHandler { _, throwable -> - Log.e("BundledInsert", "Caught exception: ${throwable.message}", throwable) + Log.e("PlaybackService", "Caught exception: ${throwable.message}", throwable) } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main + exceptionHandler) @@ -75,15 +75,19 @@ class ExoPlayerPool( suspend fun releasePlayer(player: ExoPlayer) { mutex.withLock { - player.pause() - player.stop() - player.clearMediaItems() - if (playerPool.size < poolSize) { - if (!playerPool.contains(player)) { - playerPool.add(player) + if (!player.isReleased) { + player.pause() + player.stop() + player.clearVideoSurface() + player.clearMediaItems() + + if (playerPool.size < poolSize) { + if (!playerPool.contains(player)) { + playerPool.add(player) + } + } else { + player.release() // Release if pool is full. } - } else { - player.release() // Release if pool is full. } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt index 7cd6c1dfc..48811b130 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/playerPool/MediaSessionPool.kt @@ -36,7 +36,6 @@ import androidx.media3.session.MediaSession import com.google.common.util.concurrent.Futures import com.google.common.util.concurrent.ListenableFuture import com.vitorpamplona.amethyst.ui.MainActivity -import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers @@ -99,10 +98,11 @@ class MediaSessionPool( .Builder(context, exoPlayerPool.acquirePlayer(context)) .apply { setBitmapLoader( - DataSourceBitmapLoader( - DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get(), - OkHttpDataSource.Factory(okHttpClient), - ), + DataSourceBitmapLoader + .Builder(context) + .setExecutorService(DataSourceBitmapLoader.DEFAULT_EXECUTOR_SERVICE.get()) + .setDataSourceFactory(OkHttpDataSource.Factory(okHttpClient)) + .build(), ) setId(id) setCallback(globalCallback) @@ -125,11 +125,9 @@ class MediaSessionPool( session.player.removeListener(listener.playerListener) } - cache.remove(session.id) playingMap.remove(session.id) + cache.remove(session.id) session.release() - exoPlayerPool.releasePlayerAsync(session.player as ExoPlayer) - cleanupUnused() } @@ -144,7 +142,6 @@ class MediaSessionPool( // but not connected yet. // delay(10000) snap.values.forEach { - Log.d("MediaSessionPoll", "Clean Up Unused ${it.session.connectedControllers.size} ${it.session.id}") if (it.session.connectedControllers.isEmpty()) { releaseSession(it.session) counter++ diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt index 401b37bcd..b69eaefae 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackService.kt @@ -117,7 +117,7 @@ class PlaybackService : MediaSessionService() { // if nothing is pl if (playing.isEmpty() && BackgroundMedia.hasInstance()) { - BackgroundMedia.bgInstance.value?.id?.let { id -> + BackgroundMedia.bgInstance?.id?.let { id -> (poolNoProxy?.getSession(id) ?: poolWithProxy?.getSession(id))?.let { super.onUpdateNotification(it, startInForegroundRequired) } @@ -126,7 +126,7 @@ class PlaybackService : MediaSessionService() { } playing.forEachIndexed { idx, it -> - if (it.session.player.isPlaying && it.session.player.volume > 0 && it.session.id == BackgroundMedia.bgInstance.value?.id) { + if (it.session.player.isPlaying && it.session.player.volume > 0 && it.session.id == BackgroundMedia.bgInstance?.id) { super.onUpdateNotification(it.session, startInForegroundRequired) return } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt index 15ff30a61..1b3bd3934 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/service/PlaybackServiceClient.kt @@ -27,91 +27,77 @@ import androidx.media3.session.MediaController import androidx.media3.session.SessionToken import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.callbackFlow import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid object PlaybackServiceClient { val executorService: ExecutorService = Executors.newCachedThreadPool() - fun removeController(mediaControllerState: MediaControllerState) { - mediaControllerState.active.value = false - mediaControllerState.readyToDisplay.value = false - - val myController = mediaControllerState.controller - // release when can - if (myController != null) { - mediaControllerState.controller = null - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.Main) { - // myController.pause() - // myController.stop() - myController.release() - Log.d("PlaybackService", "Releasing Video $mediaControllerState") - } - } - } - - fun prepareController( - mediaControllerState: MediaControllerState, + @OptIn(ExperimentalUuidApi::class) + fun controllerAsFlow( videoUri: String, proxyPort: Int? = 0, keepPlaying: Boolean = true, context: Context, - onReady: (MediaControllerState) -> Unit, - ) { + ) = callbackFlow { val appContext = context.applicationContext + val id = Uuid.random().toString() - mediaControllerState.active.value = true - - try { - val bundle = - Bundle().apply { - // link the id with the client's id to make sure it can return the - // same session on background media. - putString("id", mediaControllerState.id) - putBoolean("keepPlaying", keepPlaying) - proxyPort?.let { - putInt("proxyPort", it) - } + val bundle = + Bundle().apply { + // link the id with the client's id to make sure it can return the + // same session on background media. + putString("id", id) + putBoolean("keepPlaying", keepPlaying) + proxyPort?.let { + putInt("proxyPort", it) } + } - val session = SessionToken(appContext, ComponentName(appContext, PlaybackService::class.java)) + val session = SessionToken(appContext, ComponentName(appContext, PlaybackService::class.java)) - val controllerFuture = - MediaController - .Builder(appContext, session) - .setConnectionHints(bundle) - .buildAsync() + val controllerFuture = + MediaController + .Builder(appContext, session) + .setConnectionHints(bundle) + .buildAsync() - Log.d("PlaybackService", "Preparing Controller ${mediaControllerState.id} $videoUri") + Log.d("PlaybackService", "Preparing Controller $id $videoUri") - controllerFuture.addListener( - { - try { - val controller = controllerFuture.get() - mediaControllerState.controller = controller + controllerFuture.addListener( + { + try { + val controller = controllerFuture.get(5, TimeUnit.SECONDS) - // checks if the player is still active before engaging further - if (mediaControllerState.isActive()) { - onReady(mediaControllerState) - } else { - removeController(mediaControllerState) - } - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e) - } - }, - executorService, - ) - } catch (e: Exception) { - if (e is CancellationException) throw e - Log.e("Playback Client", "Failed to load Playback Client for $videoUri", e) + Log.d("PlaybackService", "Controller Ready $id $videoUri") + + // checks if the player is still active before engaging further + trySend( + MediaControllerState( + id = id, + controller = controller, + ), + ) + } catch (e: Exception) { + Log.e("Playback Client", "Failed to load Playback Client for $id $videoUri", e) + close(e) + } + }, + executorService, + ) + + awaitClose { + Log.d("PlaybackService", "Releasing Controller $id $videoUri") + try { + MediaController.releaseFuture(controllerFuture) + } catch (e: Exception) { + Log.e("Playback Client", "Failed to release Playback Client for $id $videoUri ${e.message}") + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt index 01d9b07a6..8a0335bf0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.amethyst.service.relayClient.searchCommand.SearchFilter import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.datasource.ChatroomFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.datasource.ChannelFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.datasource.CommunityFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.followPacks.feed.datasource.FollowPackFeedFilterAssembler @@ -79,6 +80,7 @@ class RelaySubscriptionsCoordinator( val hashtags = HashtagFilterAssembler(client) val geohashes = GeoHashFilterAssembler(client) val followPacks = FollowPackFeedFilterAssembler(client) + val chess = ChessFilterAssembler(client) // active when sending zaps via NWC val nwc = NWCPaymentFilterAssembler(client) @@ -101,6 +103,7 @@ class RelaySubscriptionsCoordinator( profile, hashtags, geohashes, + chess, nwc, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt index 1ded5bbe5..164291266 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/nip01Notifications/FilterNotificationsToPubkey.kt @@ -46,6 +46,8 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent val SummaryKinds = listOf( @@ -64,6 +66,8 @@ val NotificationsPerKeyKinds = EphemeralChatEvent.KIND, BadgeAwardEvent.KIND, PollNoteEvent.KIND, + PollEvent.KIND, + PollResponseEvent.KIND, PublicMessageEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt index 4298599fe..404722223 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/watchers/FilterRepliesAndReactionsToNotes.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent import com.vitorpamplona.quartz.utils.mapOfSet @@ -49,7 +50,6 @@ val RepliesAndReactionsKinds = GenericRepostEvent.KIND, ReportEvent.KIND, LnZapEvent.KIND, - PollNoteEvent.KIND, OtsEvent.KIND, TextNoteModificationEvent.KIND, CommentEvent.KIND, @@ -62,6 +62,8 @@ val RepliesAndReactionsKinds2 = NIP90StatusEvent.KIND, TorrentCommentEvent.KIND, GitReplyEvent.KIND, + PollResponseEvent.KIND, + PollNoteEvent.KIND, ) fun filterRepliesAndReactionsToNotes( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt index 0e2e2c5b9..25f386b48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/subassemblies/SearchPostsByText.kt @@ -47,6 +47,8 @@ import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent val SearchPostsByTextKinds1 = @@ -83,6 +85,8 @@ val SearchPostsByTextKinds3 = InteractiveStorySceneEvent.KIND, FollowListEvent.KIND, NipTextEvent.KIND, + PollEvent.KIND, + PollResponseEvent.KIND, ) fun searchPostsByText( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt index 6c7a0939c..ada46f1dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/MultiOrchestrator.kt @@ -32,6 +32,7 @@ import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +@Stable class MultiOrchestrator( uris: List, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt index 2945de5dc..3dd0ba8ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/EditPostView.kt @@ -78,7 +78,6 @@ import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.playback.composable.VideoView import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.components.BechLink import com.vitorpamplona.amethyst.ui.components.LoadUrlPreview @@ -264,9 +263,7 @@ fun EditPostView( accountViewModel.account.settings.defaultFileServer, onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> postViewModel.upload(alt, sensitiveContent, mediaQuality, false, server, accountViewModel.toastManager::toast, context) - if (server.type != ServerType.NIP95) { - accountViewModel.account.settings.changeDefaultFileServer(server) - } + accountViewModel.account.settings.changeDefaultFileServer(server) }, onDelete = postViewModel::deleteMediaToUpload, onCancel = { postViewModel.multiOrchestrator = null }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt index 7f812155f..1263816fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMediaView.kt @@ -54,7 +54,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge @@ -110,9 +109,7 @@ fun NewMediaView( onPost = { postViewModel.upload(context, onClose, accountViewModel.toastManager::toast) postViewModel.selectedServer?.let { - if (it.type != ServerType.NIP95) { - account.settings.changeDefaultFileServer(it) - } + account.settings.changeDefaultFileServer(it) } }, ) @@ -148,19 +145,14 @@ fun ImageVideoPost( postViewModel: NewMediaModel, accountViewModel: AccountViewModel, ) { - val nip95description = stringRes(id = R.string.upload_server_relays_nip95) - val fileServers by accountViewModel.account.serverLists.liveServerList + val fileServers by accountViewModel.account.blossomServers.hostNameFlow .collectAsState() val fileServerOptions = remember(fileServers) { fileServers .map { - if (it.type == ServerType.NIP95) { - TitleExplainer(it.name, nip95description) - } else { - TitleExplainer(it.name, it.baseUrl) - } + TitleExplainer(it.name, it.baseUrl) }.toImmutableList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt index b770c5473..ee3810e72 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewMessageTagger.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import com.vitorpamplona.quartz.nip19Bech32.bech32.Bech32 import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes @@ -197,7 +197,7 @@ class NewMessageTagger( val restOfWord = key.substring(63) // Converts to npub val pubkey = - Nip19Parser.uriToRoute(Nip01.pubKeyCreate(keyB32.bechToBytes()).toNpub()) ?: return null + Nip19Parser.uriToRoute(Nip01Crypto.pubKeyCreate(keyB32.bechToBytes()).toNpub()) ?: return null return DirtyKeyInfo(pubkey, restOfWord.ifEmpty { null }) } else if (key.startsWith("npub1", true)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt deleted file mode 100644 index 13d6c10e4..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollClosing.kt +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.components - -import android.annotation.SuppressLint -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import kotlinx.coroutines.CancellationException - -@Composable -fun NewPollClosing(pollViewModel: ShortNotePostViewModel) { - var text by rememberSaveable { mutableStateOf("") } - - pollViewModel.isValidClosedAt.value = true - if (text.isNotEmpty()) { - try { - val int = text.toLong() - if (int < 0) { - pollViewModel.isValidClosedAt.value = false - } else { - pollViewModel.closedAt = int - } - } catch (e: Exception) { - if (e is CancellationException) throw e - pollViewModel.isValidClosedAt.value = false - } - } - - val colorInValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.error, - unfocusedBorderColor = Color.Red, - ) - val colorValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, - ) - - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - OutlinedTextField( - value = text, - onValueChange = { text = it }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.width(150.dp), - colors = if (pollViewModel.isValidClosedAt.value) colorValid else colorInValid, - label = { - Text( - text = stringRes(R.string.poll_closing_time), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - placeholder = { - Text( - text = stringRes(R.string.poll_closing_time_days), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - } -} - -@SuppressLint("ViewModelConstructorInComposable") -@Preview -@Composable -fun NewPollClosingPreview() { - NewPollClosing(ShortNotePostViewModel()) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt deleted file mode 100644 index d90793bf8..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollConsensusThreshold.kt +++ /dev/null @@ -1,110 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.components - -import android.annotation.SuppressLint -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.placeholderText -import kotlinx.coroutines.CancellationException - -@Composable -fun NewPollConsensusThreshold(pollViewModel: ShortNotePostViewModel) { - var text by rememberSaveable { mutableStateOf("") } - - pollViewModel.isValidConsensusThreshold.value = true - if (text.isNotEmpty()) { - try { - val int = text.toInt() - if (int < 0 || int > 100) { - pollViewModel.isValidConsensusThreshold.value = false - } else { - pollViewModel.consensusThreshold = int - } - } catch (e: Exception) { - if (e is CancellationException) throw e - pollViewModel.isValidConsensusThreshold.value = false - } - } - - val colorInValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.error, - unfocusedBorderColor = Color.Red, - ) - val colorValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, - ) - - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - OutlinedTextField( - value = text, - onValueChange = { text = it }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.width(150.dp), - colors = if (pollViewModel.isValidConsensusThreshold.value) colorValid else colorInValid, - label = { - Text( - text = stringRes(R.string.poll_consensus_threshold), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - placeholder = { - Text( - text = stringRes(R.string.poll_consensus_threshold_percent), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - } -} - -@SuppressLint("ViewModelConstructorInComposable") -@Preview -@Composable -fun NewPollConsensusThresholdPreview() { - NewPollConsensusThreshold(ShortNotePostViewModel()) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt deleted file mode 100644 index b88e9aa5d..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollOption.kt +++ /dev/null @@ -1,95 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.actions - -import android.annotation.SuppressLint -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Delete -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.tooling.preview.Preview -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.placeholderText - -@Composable -fun NewPollOption( - pollViewModel: ShortNotePostViewModel, - optionIndex: Int, -) { - Row { - val deleteIcon: @Composable (() -> Unit) = { - IconButton( - onClick = { - pollViewModel.removePollOption(optionIndex) - }, - ) { - Icon( - imageVector = Icons.Default.Delete, - contentDescription = stringRes(R.string.clear), - ) - } - } - - OutlinedTextField( - modifier = Modifier.weight(1F), - value = pollViewModel.pollOptions[optionIndex] ?: "", - onValueChange = { - pollViewModel.updatePollOption(optionIndex, it) - }, - label = { - Text( - text = stringRes(R.string.poll_option_index).format(optionIndex + 1), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - placeholder = { - Text( - text = stringRes(R.string.poll_option_description), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - // colors = if (pollViewModel.pollOptions[optionIndex]?.isNotEmpty() == true) colorValid else - // colorInValid, - trailingIcon = if (optionIndex > 1) deleteIcon else null, - ) - } -} - -@SuppressLint("ViewModelConstructorInComposable") -@Preview -@Composable -fun NewPollOptionPreview() { - NewPollOption(ShortNotePostViewModel(), 0) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt deleted file mode 100644 index d3c5400a7..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollPrimaryDescription.kt +++ /dev/null @@ -1,103 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.components - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.LocalTextStyle -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.ExperimentalComposeUiApi -import androidx.compose.ui.Modifier -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.focus.onFocusChanged -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalSoftwareKeyboardController -import androidx.compose.ui.text.input.KeyboardCapitalization -import androidx.compose.ui.text.style.TextDirection -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.placeholderText - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun NewPollPrimaryDescription(pollViewModel: ShortNotePostViewModel) { - // initialize focus reference to be able to request focus programmatically - val focusRequester = remember { FocusRequester() } - val keyboardController = LocalSoftwareKeyboardController.current - - var isInputValid = true - if (pollViewModel.message.text - .isEmpty() - ) { - isInputValid = false - } - - val colorInValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.error, - unfocusedBorderColor = Color.Red, - ) - val colorValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, - ) - - OutlinedTextField( - value = pollViewModel.message, - onValueChange = { pollViewModel.updateMessage(it) }, - label = { - Text( - text = stringRes(R.string.poll_primary_description), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.Sentences, - ), - modifier = - Modifier.fillMaxWidth().padding(top = 8.dp).focusRequester(focusRequester).onFocusChanged { - if (it.isFocused) { - keyboardController?.show() - } - }, - placeholder = { - Text( - text = stringRes(R.string.poll_primary_description), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - colors = if (isInputValid) colorValid else colorInValid, - visualTransformation = UrlUserTagTransformation(MaterialTheme.colorScheme.primary), - textStyle = LocalTextStyle.current.copy(textDirection = TextDirection.Content), - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt deleted file mode 100644 index e52d53a78..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.components - -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.placeholderText - -@Composable -fun NewPollRecipientsField( - pollViewModel: ShortNotePostViewModel, - account: Account, -) { - // if no recipients, add user's pubkey - if (pollViewModel.zapRecipients.isEmpty()) { - pollViewModel.zapRecipients.add(account.userProfile().pubkeyHex) - } - - // TODO allow add multiple recipients and check input validity - - OutlinedTextField( - modifier = Modifier.fillMaxWidth(), - value = pollViewModel.zapRecipients[0], - onValueChange = { /* TODO */ }, - enabled = false, - label = { - Text( - text = stringRes(R.string.poll_zap_recipients), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - placeholder = { - Text( - text = stringRes(R.string.poll_zap_recipients), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt deleted file mode 100644 index 20c9c8dce..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.actions - -import android.annotation.SuppressLint -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.padding -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel -import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer -import com.vitorpamplona.amethyst.ui.theme.placeholderText - -@Composable -fun NewPollVoteValueRange(pollViewModel: ShortNotePostViewModel) { - val colorInValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.error, - unfocusedBorderColor = Color.Red, - ) - val colorValid = - OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary, - unfocusedBorderColor = MaterialTheme.colorScheme.placeholderText, - ) - - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - OutlinedTextField( - value = pollViewModel.valueMinimum?.toString() ?: "", - onValueChange = { pollViewModel.updateMinZapAmountForPoll(it) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.weight(1f), - colors = if (pollViewModel.isValidValueMinimum.value) colorValid else colorInValid, - label = { - Text( - text = stringRes(R.string.poll_zap_value_min), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - placeholder = { - Text( - text = stringRes(R.string.sats), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - - Spacer(modifier = DoubleHorzSpacer) - - OutlinedTextField( - value = pollViewModel.valueMaximum?.toString() ?: "", - onValueChange = { pollViewModel.updateMaxZapAmountForPoll(it) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), - modifier = Modifier.weight(1f), - colors = if (pollViewModel.isValidValueMaximum.value) colorValid else colorInValid, - label = { - Text( - text = stringRes(R.string.poll_zap_value_max), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - placeholder = { - Text( - text = stringRes(R.string.sats), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - } - - Row( - Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - Text( - text = stringRes(R.string.poll_zap_value_min_max_explainer), - color = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(vertical = 10.dp), - ) - } -} - -@SuppressLint("ViewModelConstructorInComposable") -@Preview -@Composable -fun NewPollVoteValueRangePreview() { - Column( - modifier = Modifier.fillMaxWidth(), - ) { - NewPollVoteValueRange(ShortNotePostViewModel()) - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt index 305559556..ddab82030 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersLIstView.kt @@ -56,11 +56,7 @@ import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.grayText @Composable -fun AllMediaBody( - nip96ServersViewModel: NIP96ServersViewModel, - blossomServersViewModel: BlossomServersViewModel, -) { - val nip96ServersState by nip96ServersViewModel.fileServers.collectAsStateWithLifecycle() +fun AllMediaBody(blossomServersViewModel: BlossomServersViewModel) { val blossomServersState by blossomServersViewModel.fileServers.collectAsStateWithLifecycle() LazyColumn( @@ -89,40 +85,15 @@ fun AllMediaBody( }, ) - item { - SettingsCategory( - R.string.media_servers_nip96_section, - R.string.media_servers_nip96_explainer, - SettingsCategorySpacingModifier, - ) - } - - renderMediaServerList( - mediaServersState = nip96ServersState, - keyType = "nip96", - editLabel = R.string.add_a_nip96_server, - emptyLabel = R.string.no_nip96_server_message, - onAddServer = { server -> - nip96ServersViewModel.addServer(server) - }, - onDeleteServer = { - nip96ServersViewModel.removeServer(serverUrl = it) - }, - ) - DEFAULT_MEDIA_SERVERS.let { item { SettingsCategoryWithButton( - title = R.string.built_in_media_servers_title, + title = R.string.recommended_media_servers, description = R.string.built_in_servers_description, modifier = SettingsCategorySpacingModifier, ) { OutlinedButton( onClick = { - nip96ServersViewModel.addServerList( - it.mapNotNull { s -> if (s.type == ServerType.NIP96) s.baseUrl else null }, - ) - blossomServersViewModel.addServerList( it.mapNotNull { s -> if (s.type == ServerType.Blossom) s.baseUrl else null }, ) @@ -142,9 +113,7 @@ fun AllMediaBody( serverEntry = server, isAmethystDefault = true, onAddOrDelete = { serverUrl -> - if (server.type == ServerType.NIP96) { - nip96ServersViewModel.addServer(serverUrl) - } else if (server.type == ServerType.Blossom) { + if (server.type == ServerType.Blossom) { blossomServersViewModel.addServer(serverUrl) } }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt index 9f32890c3..6924b98d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/AllMediaServersScreen.kt @@ -49,18 +49,15 @@ fun AllMediaServersScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val nip96ServersViewModel: NIP96ServersViewModel = viewModel() val blossomServersViewModel: BlossomServersViewModel = viewModel() - nip96ServersViewModel.init(accountViewModel) blossomServersViewModel.init(accountViewModel) LaunchedEffect(key1 = accountViewModel) { - nip96ServersViewModel.load() blossomServersViewModel.load() } - MediaServersScaffold(nip96ServersViewModel, blossomServersViewModel) { + MediaServersScaffold(blossomServersViewModel) { nav.popBack() } } @@ -68,7 +65,6 @@ fun AllMediaServersScreen( @OptIn(ExperimentalMaterial3Api::class) @Composable fun MediaServersScaffold( - nip96ServersViewModel: NIP96ServersViewModel, blossomServersViewModel: BlossomServersViewModel, onClose: () -> Unit, ) { @@ -77,12 +73,10 @@ fun MediaServersScaffold( SavingTopBar( titleRes = R.string.media_servers, onCancel = { - nip96ServersViewModel.refresh() blossomServersViewModel.refresh() onClose() }, onPost = { - nip96ServersViewModel.saveFileServers() blossomServersViewModel.saveFileServers() onClose() }, @@ -111,7 +105,7 @@ fun MediaServersScaffold( color = MaterialTheme.colorScheme.grayText, ) - AllMediaBody(nip96ServersViewModel, blossomServersViewModel) + AllMediaBody(blossomServersViewModel) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt index efac80079..84142984b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/BlossomServersViewModel.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.actions.mediaServers +import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.model.Account @@ -31,6 +32,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +@Stable class BlossomServersViewModel : ViewModel() { private lateinit var accountViewModel: AccountViewModel private lateinit var account: Account diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.kt index 2726f9487..2239d0b94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.kt @@ -41,11 +41,7 @@ fun FileServerSelectionRow( remember(fileServers) { fileServers .map { - if (it.type == ServerType.NIP95) { - TitleExplainer(it.name, nip95description) - } else { - TitleExplainer(it.name, it.baseUrl) - } + TitleExplainer(it.name, it.baseUrl) }.toImmutableList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt deleted file mode 100644 index e4d8d873d..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/NIP96ServersViewModel.kt +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.actions.mediaServers - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.quartz.utils.Log -import com.vitorpamplona.quartz.utils.Rfc3986 -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch - -class NIP96ServersViewModel : ViewModel() { - private lateinit var accountViewModel: AccountViewModel - private lateinit var account: Account - - private val _fileServers = MutableStateFlow>(emptyList()) - val fileServers = _fileServers.asStateFlow() - private var isModified = false - - fun init(accountViewModel: AccountViewModel) { - this.accountViewModel = accountViewModel - this.account = accountViewModel.account - } - - fun load() { - refresh() - } - - fun refresh() { - isModified = false - _fileServers.update { - val obtainedFileServers = obtainFileServers() ?: emptyList() - obtainedFileServers.mapNotNull { serverUrl -> - try { - ServerName( - Rfc3986.host(serverUrl), - serverUrl, - ServerType.NIP96, - ) - } catch (e: Exception) { - Log.d("MediaServersViewModel", "Invalid URL in NIP-96 server list") - null - } - } - } - } - - fun addServerList(serverList: List) { - serverList.forEach { serverUrl -> - addServer(serverUrl) - } - } - - fun addServer(serverUrl: String) { - val normalizedUrl = - try { - Rfc3986.normalize(serverUrl.trim()) - } catch (e: Exception) { - serverUrl - } - val serverNameReference = - try { - Rfc3986.host(normalizedUrl) - } catch (e: Exception) { - normalizedUrl - } - val serverRef = ServerName(serverNameReference, normalizedUrl, ServerType.NIP96) - if (_fileServers.value.contains(serverRef)) { - return - } else { - _fileServers.update { - it.plus(serverRef) - } - } - isModified = true - } - - fun removeServer( - name: String = "", - serverUrl: String, - ) { - viewModelScope.launch { - val serverName = if (name.isNotBlank()) name else Rfc3986.host(serverUrl) - _fileServers.update { - it.minus( - ServerName(serverName, serverUrl, ServerType.NIP96), - ) - } - isModified = true - } - } - - fun removeAllServers() { - _fileServers.update { emptyList() } - isModified = true - } - - fun saveFileServers() { - if (isModified) { - accountViewModel.launchSigner { - val serverList = _fileServers.value.map { it.baseUrl } - account.sendFileServersList(serverList) - refresh() - } - } - } - - private fun obtainFileServers(): List? = account.fileStorageServers.flow.value -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt index c17e5b62c..e81f65e51 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/ServerName.kt @@ -26,22 +26,24 @@ import kotlinx.serialization.Serializable data class ServerName( val name: String, val baseUrl: String, - val type: ServerType = ServerType.NIP96, + val type: ServerType = ServerType.Blossom, ) enum class ServerType { - NIP96, - NIP95, Blossom, + NIP95, + NIP96, } val DEFAULT_MEDIA_SERVERS: List = listOf( - ServerName("Nostr.Build", "https://nostr.build", ServerType.NIP96), - ServerName("NostrCheck.me (NIP-96)", "https://nostrcheck.me", ServerType.NIP96), - ServerName("Sovbit", "https://files.sovbit.host", ServerType.NIP96), - ServerName("Satellite (Paid)", "https://cdn.satellite.earth", ServerType.Blossom), - ServerName("NostrCheck.me (Blossom)", "https://cdn.nostrcheck.me", ServerType.Blossom), + ServerName("Nostr.Build", "https://blossom.band/", ServerType.Blossom), + ServerName("24242.io", "https://24242.io/", ServerType.Blossom), + ServerName("Azzamo", "https://blossom.azzamo.media", ServerType.Blossom), + ServerName("YakiHonne", "https://blossom.yakihonne.com/", ServerType.Blossom), + ServerName("Primal", "https://blossom.primal.net/", ServerType.Blossom), + ServerName("Sovbit", "https://cdn.sovbit.host", ServerType.Blossom), ServerName("Nostr.Download", "https://nostr.download", ServerType.Blossom), + ServerName("Satellite (Paid)", "https://cdn.satellite.earth", ServerType.Blossom), ServerName("NostrMedia (Paid)", "https://nostrmedia.com", ServerType.Blossom), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt index 9e598bc79..32613496f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordVoiceButton.kt @@ -20,8 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.actions.uploads +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons @@ -49,7 +51,9 @@ fun RecordVoiceButton( var isRecording by remember { mutableStateOf(false) } var elapsedSeconds by remember { mutableIntStateOf(0) } - Column { + Column( + verticalArrangement = Arrangement.Center, + ) { // Floating recording indicator at the top FloatingRecordingIndicator( modifier = Modifier.height(50.dp), @@ -58,7 +62,7 @@ fun RecordVoiceButton( ) RecordAudioBox( - modifier = Modifier, + modifier = Modifier.fillMaxSize(), onRecordTaken = { recording -> isRecording = false elapsedSeconds = 0 @@ -77,12 +81,12 @@ fun RecordVoiceButton( } Box( - modifier = Modifier.size(48.dp), + modifier = Modifier.size(42.dp), contentAlignment = Alignment.Center, ) { // Expanding circles background animation ExpandingCirclesAnimation( - modifier = Modifier.size(48.dp), + modifier = Modifier.size(42.dp), isRecording = recordingState, primaryColor = MaterialTheme.colorScheme.primary, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt index 5d1e2eb44..608424282 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableBox.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -44,7 +45,7 @@ import com.vitorpamplona.amethyst.ui.theme.ripple24dp fun ClickableBox( modifier: Modifier = Modifier, onClick: () -> Unit, - content: @Composable () -> Unit, + content: @Composable BoxScope.() -> Unit, ) { Box( modifier.clickable( @@ -54,9 +55,8 @@ fun ClickableBox( onClick = onClick, ), contentAlignment = Alignment.Center, - ) { - content() - } + content = content, + ) } @OptIn(ExperimentalFoundationApi::class) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt index c39f05996..18b45ffc4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/SensitivityWarning.kt @@ -46,6 +46,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -57,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.ButtonPadding import com.vitorpamplona.amethyst.ui.theme.PaddingHorizontal12Modifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip36SensitiveContent.isSensitiveOrNSFW @@ -111,6 +113,14 @@ fun SensitivityWarning( } } +@Preview +@Composable +fun ContentWarningNotePreview() { + ThemeComparisonColumn { + ContentWarningNote({}) + } +} + @Composable fun ContentWarningNote(onDismiss: () -> Unit) { Column { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt index a5a2dfab9..b74937b6e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentDialog.kt @@ -28,6 +28,8 @@ import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement.spacedBy import androidx.compose.foundation.layout.Box @@ -125,9 +127,7 @@ fun ZoomableImageDialog( } Surface(Modifier.fillMaxSize()) { - Box(Modifier.fillMaxSize(), Alignment.TopCenter) { - DialogContent(allImages, imageUrl, onDismiss, accountViewModel) - } + DialogContent(allImages, imageUrl, onDismiss, accountViewModel) } } } @@ -158,136 +158,143 @@ private fun DialogContent( } } - if (allImages.size > 1) { - SlidingCarousel( - pagerState = pagerState, - ) { index -> - allImages.getOrNull(index)?.let { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - RenderImageOrVideo( - content = it, - roundedCorner = false, - isFiniteHeight = true, - controllerVisible = controllerVisible, - onControllerVisibilityChanged = { controllerVisible.value = it }, - onToggleControllerVisibility = { - controllerVisible.value = !controllerVisible.value - }, - accountViewModel = accountViewModel, - ) + Box( + Modifier + .fillMaxSize() + .clickable( + onClick = { + controllerVisible.value = !controllerVisible.value + }, + ), + Alignment.TopCenter, + ) { + if (allImages.size > 1) { + SlidingCarousel( + pagerState = pagerState, + ) { index -> + allImages.getOrNull(index)?.let { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + RenderImageOrVideo( + content = it, + roundedCorner = false, + isFiniteHeight = true, + controllerVisible = controllerVisible, + accountViewModel = accountViewModel, + ) + } } } - } - } else { - Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - RenderImageOrVideo( - content = imageUrl, - roundedCorner = false, - isFiniteHeight = true, - controllerVisible = controllerVisible, - onControllerVisibilityChanged = { controllerVisible.value = it }, - onToggleControllerVisibility = { controllerVisible.value = !controllerVisible.value }, - accountViewModel = accountViewModel, - ) - } - } - - AnimatedVisibility( - visible = controllerVisible.value, - enter = remember { fadeIn() }, - exit = remember { fadeOut() }, - ) { - Row( - modifier = - Modifier - .padding(horizontal = Size15dp, vertical = Size10dp) - .statusBarsPadding() - .systemBarsPadding() - .fillMaxWidth(), - horizontalArrangement = spacedBy(Size10dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - onClick = onDismiss, - contentPadding = PaddingValues(horizontal = Size5dp), - colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background), - ) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringRes(R.string.back), + } else { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + RenderImageOrVideo( + content = imageUrl, + roundedCorner = false, + isFiniteHeight = true, + controllerVisible = controllerVisible, + accountViewModel = accountViewModel, ) } + } - Spacer(modifier = Modifier.weight(1f)) - - allImages.getOrNull(pagerState.currentPage)?.let { myContent -> - val popupExpanded = remember { mutableStateOf(false) } - + AnimatedVisibility( + visible = controllerVisible.value, + enter = remember { fadeIn() }, + exit = remember { fadeOut() }, + ) { + Row( + modifier = + Modifier + .padding(horizontal = Size15dp, vertical = Size10dp) + .statusBarsPadding() + .systemBarsPadding() + .fillMaxWidth(), + horizontalArrangement = spacedBy(Size10dp), + verticalAlignment = Alignment.CenterVertically, + ) { OutlinedButton( - onClick = { popupExpanded.value = true }, + onClick = onDismiss, contentPadding = PaddingValues(horizontal = Size5dp), colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background), ) { Icon( - imageVector = Icons.Default.Share, - modifier = Size20Modifier, - contentDescription = stringRes(R.string.quick_action_share), + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), ) - - ShareMediaAction(accountViewModel = accountViewModel, popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false }) } - if (myContent !is MediaUrlContent || !isLiveStreaming(myContent.url)) { - val localContext = LocalContext.current + Spacer(modifier = Modifier.weight(1f)) - val scope = rememberCoroutineScope() + allImages.getOrNull(pagerState.currentPage)?.let { myContent -> + if (myContent is MediaUrlImage || myContent is MediaLocalImage) { + val popupExpanded = remember { mutableStateOf(false) } - val writeStoragePermissionState = - rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted -> - if (isGranted) { - scope.launch { - saveMediaToGallery(myContent, localContext, accountViewModel) - } - scope.launch { - Toast - .makeText( - localContext, - stringRes(localContext, R.string.media_download_has_started_toast), - Toast.LENGTH_SHORT, - ).show() - } - } + OutlinedButton( + onClick = { popupExpanded.value = true }, + contentPadding = PaddingValues(horizontal = Size5dp), + colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background), + ) { + Icon( + imageVector = Icons.Default.Share, + modifier = Size20Modifier, + contentDescription = stringRes(R.string.quick_action_share), + ) + + ShareMediaAction(accountViewModel = accountViewModel, popupExpanded = popupExpanded, myContent, onDismiss = { popupExpanded.value = false }) } - OutlinedButton( - onClick = { - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || - writeStoragePermissionState.status.isGranted + if (myContent !is MediaUrlContent || !isLiveStreaming(myContent.url)) { + val localContext = LocalContext.current + + val scope = rememberCoroutineScope() + + val writeStoragePermissionState = + rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted -> + if (isGranted) { + scope.launch { + saveMediaToGallery(myContent, localContext, accountViewModel) + } + scope.launch { + Toast + .makeText( + localContext, + stringRes(localContext, R.string.media_download_has_started_toast), + Toast.LENGTH_SHORT, + ).show() + } + } + } + + OutlinedButton( + onClick = { + if ( + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || + writeStoragePermissionState.status.isGranted + ) { + scope.launch(Dispatchers.IO) { + saveMediaToGallery(myContent, localContext, accountViewModel) + } + scope.launch { + Toast + .makeText( + localContext, + stringRes(localContext, R.string.media_download_has_started_toast), + Toast.LENGTH_SHORT, + ).show() + } + } else { + writeStoragePermissionState.launchPermissionRequest() + } + }, + contentPadding = PaddingValues(horizontal = Size5dp), + colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background), ) { - scope.launch(Dispatchers.IO) { - saveMediaToGallery(myContent, localContext, accountViewModel) - } - scope.launch { - Toast - .makeText( - localContext, - stringRes(localContext, R.string.media_download_has_started_toast), - Toast.LENGTH_SHORT, - ).show() - } - } else { - writeStoragePermissionState.launchPermissionRequest() + Icon( + imageVector = Icons.Default.Download, + modifier = Size20Modifier, + contentDescription = stringRes(R.string.save_to_gallery), + ) } - }, - contentPadding = PaddingValues(horizontal = Size5dp), - colors = ButtonDefaults.outlinedButtonColors().copy(containerColor = MaterialTheme.colorScheme.background), - ) { - Icon( - imageVector = Icons.Default.Download, - modifier = Size20Modifier, - contentDescription = stringRes(R.string.save_to_gallery), - ) + } } } } @@ -347,11 +354,8 @@ private fun RenderImageOrVideo( roundedCorner: Boolean, isFiniteHeight: Boolean, controllerVisible: MutableState, - onControllerVisibilityChanged: ((Boolean) -> Unit)? = null, - onToggleControllerVisibility: (() -> Unit)? = null, accountViewModel: AccountViewModel, ) { - val automaticallyStartPlayback = remember { mutableStateOf(true) } val contentScale = if (isFiniteHeight) { ContentScale.Fit @@ -368,9 +372,7 @@ private fun RenderImageOrVideo( .zoomable( rememberZoomState(), onTap = { - if (onToggleControllerVisibility != null) { - onToggleControllerVisibility() - } + controllerVisible.value = !controllerVisible.value }, ) @@ -412,8 +414,9 @@ private fun RenderImageOrVideo( authorName = content.authorName, borderModifier = borderModifier, contentScale = contentScale, - automaticallyStartPlayback = automaticallyStartPlayback, - onControllerVisibilityChanged = onControllerVisibilityChanged, + nostrUriCallback = content.uri, + automaticallyStartPlayback = true, + controllerVisible = controllerVisible, accountViewModel = accountViewModel, ) } @@ -426,9 +429,7 @@ private fun RenderImageOrVideo( .zoomable( rememberZoomState(), onTap = { - if (onToggleControllerVisibility != null) { - onToggleControllerVisibility() - } + controllerVisible.value = !controllerVisible.value }, ) @@ -471,8 +472,9 @@ private fun RenderImageOrVideo( authorName = content.authorName, borderModifier = borderModifier, contentScale = contentScale, - automaticallyStartPlayback = automaticallyStartPlayback, - onControllerVisibilityChanged = onControllerVisibilityChanged, + nostrUriCallback = content.uri, + automaticallyStartPlayback = true, + controllerVisible = controllerVisible, accountViewModel = accountViewModel, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt index 751a98db3..f8cc1c82a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ZoomableContentView.kt @@ -166,9 +166,7 @@ fun ZoomableContentView( roundedCorner = roundedCorner, contentScale = contentScale, nostrUriCallback = content.uri, - onDialog = { - dialogOpen = true - }, + onDialog = { dialogOpen = true }, accountViewModel = accountViewModel, ) } @@ -701,7 +699,6 @@ fun ShareMediaAction( ) { if (content is MediaUrlContent) { ShareMediaAction( - accountViewModel = accountViewModel, popupExpanded = popupExpanded, videoUri = content.url, postNostrUri = content.uri, @@ -711,10 +708,10 @@ fun ShareMediaAction( mimeType = content.mimeType, onDismiss = onDismiss, content = content, + accountViewModel = accountViewModel, ) } else if (content is MediaPreloadedContent) { ShareMediaAction( - accountViewModel = accountViewModel, popupExpanded = popupExpanded, videoUri = content.localFile?.toUri().toString(), postNostrUri = content.uri, @@ -724,6 +721,7 @@ fun ShareMediaAction( mimeType = content.mimeType, onDismiss = onDismiss, content = content, + accountViewModel = accountViewModel, ) } } @@ -731,7 +729,6 @@ fun ShareMediaAction( @OptIn(ExperimentalPermissionsApi::class) @Composable fun ShareMediaAction( - accountViewModel: AccountViewModel, popupExpanded: MutableState, videoUri: String?, postNostrUri: String?, @@ -741,6 +738,7 @@ fun ShareMediaAction( mimeType: String?, onDismiss: () -> Unit, content: BaseMediaContent? = null, + accountViewModel: AccountViewModel, ) { val scope = accountViewModel.viewModelScope diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingFloatingButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingFloatingButton.kt index 76b1c8cb6..6cdf473fc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingFloatingButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/DisappearingFloatingButton.kt @@ -28,7 +28,6 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.layout -import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -36,19 +35,26 @@ fun DisappearingFloatingButton( scrollBehavior: BottomAppBarScrollBehavior, content: @Composable (BoxScope.() -> Unit), ) { + // We calculate the scale/alpha based on how much the bar is expanded + // 1.0 = fully visible, 0.0 = fully hidden + val progress = (1f - scrollBehavior.state.collapsedFraction).coerceAtLeast(0.001f) + Box( modifier = Modifier .layout { measurable, constraints -> val placeable = measurable.measure(constraints) - val height = placeable.height * (1 - scrollBehavior.state.collapsedFraction) - layout(placeable.width, height.roundToInt().coerceAtLeast(0)) { - placeable.place(0, 0) + // Adjust the height of the layout so the FAB doesn't leave a "hole" + val currentHeight = (placeable.height * progress).toInt() + layout(placeable.width, currentHeight) { + placeable.placeRelative(0, 0) } }.graphicsLayer { - val percentage = 1 - scrollBehavior.state.collapsedFraction - this.scaleX = percentage - this.scaleY = percentage + this.scaleX = progress + this.scaleY = progress + this.alpha = progress + // Clip the content as it shrinks to prevent overflow + clip = true }, content = content, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 78ff36813..d31f7171b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -74,6 +74,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28P import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.metadata.ChannelMetadataScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip53LiveActivities.LiveActivityChannelScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.MessagesScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessGameScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessLobbyScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.communities.CommunityScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.DiscoverScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds.NewProductScreen @@ -137,6 +139,7 @@ fun AppNavigation( composable { VideoScreen(accountViewModel, nav) } composable { DiscoverScreen(accountViewModel, nav) } composable { NotificationScreen(accountViewModel, nav) } + composable { ChessLobbyScreen(accountViewModel, nav) } composableFromEnd { ListOfPeopleListsScreen(accountViewModel, nav) } composableFromEndArgs { PeopleListScreen(it.dTag, accountViewModel, nav) } @@ -174,6 +177,7 @@ fun AppNavigation( composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } + composableFromEndArgs { ChessGameScreen(it.gameId, accountViewModel, nav) } composableFromEndArgs { RelayInformationScreen(it.url, accountViewModel, nav) } composableFromEndArgs { CommunityScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEndArgs { FollowPackFeedScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt index 259fb88b2..dc0e12fcc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/DrawerContent.kt @@ -471,6 +471,15 @@ fun ListContent( route = Route.Drafts, ) + NavigationRow( + title = R.string.route_chess, + icon = R.drawable.ic_chess, + iconReference = 1, + tint = MaterialTheme.colorScheme.onBackground, + nav = nav, + route = Route.Chess, + ) + IconRowRelays( accountViewModel = accountViewModel, onClick = { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt index b85ba7c87..25bdca7df 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/RouteMaker.kt @@ -143,7 +143,7 @@ fun routeForInner( } is AddressableEvent -> { - Route.Note(noteEvent.aTag().toTag()) + Route.Note(noteEvent.addressTag()) } else -> { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index 6cffc431e..0147f5b14 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -40,6 +40,8 @@ sealed class Route { @Serializable object Notification : Route() + @Serializable object Chess : Route() + @Serializable object Search : Route() @Serializable object SecurityFilters : Route() @@ -137,6 +139,10 @@ sealed class Route { val geohash: String, ) : Route() + @Serializable data class ChessGame( + val gameId: String, + ) : Route() + @Serializable data class Community( val kind: Int, val pubKeyHex: HexKey, @@ -312,6 +318,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? { dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt index 4ff83b259..9564d0d1b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/NoteCompose.kt @@ -103,6 +103,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderBadgeAward import com.vitorpamplona.amethyst.ui.note.types.RenderChannelMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderChatMessageEncryptedFile +import com.vitorpamplona.amethyst.ui.note.types.RenderChessGame import com.vitorpamplona.amethyst.ui.note.types.RenderClassifieds import com.vitorpamplona.amethyst.ui.note.types.RenderCommunity import com.vitorpamplona.amethyst.ui.note.types.RenderEmojiPack @@ -114,6 +115,8 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderHighlight import com.vitorpamplona.amethyst.ui.note.types.RenderInteractiveStory import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityChatMessage import com.vitorpamplona.amethyst.ui.note.types.RenderLiveActivityEvent +import com.vitorpamplona.amethyst.ui.note.types.RenderLiveChessChallenge +import com.vitorpamplona.amethyst.ui.note.types.RenderLiveChessGameEnd import com.vitorpamplona.amethyst.ui.note.types.RenderLongFormContent import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90ContentDiscoveryResponse import com.vitorpamplona.amethyst.ui.note.types.RenderNIP90Status @@ -131,6 +134,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment import com.vitorpamplona.amethyst.ui.note.types.RenderVoiceTrack import com.vitorpamplona.amethyst.ui.note.types.RenderWikiContent +import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.publicChannels.nip28PublicChat.RenderPublicChatChannelHeader @@ -209,6 +213,9 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent 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.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent @@ -220,6 +227,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent @@ -911,6 +919,33 @@ private fun RenderNoteRow( ) } + is ChessGameEvent -> { + RenderChessGame( + baseNote, + backgroundColor, + accountViewModel, + nav, + ) + } + + is LiveChessGameChallengeEvent -> { + RenderLiveChessChallenge( + baseNote, + backgroundColor, + accountViewModel, + nav, + ) + } + + is LiveChessGameEndEvent -> { + RenderLiveChessGameEnd( + baseNote, + backgroundColor, + accountViewModel, + nav, + ) + } + is ClassifiedsEvent -> { RenderClassifieds( noteEvent, @@ -967,7 +1002,7 @@ private fun RenderNoteRow( } is PollNoteEvent -> { - RenderPoll( + RenderZapPoll( baseNote, makeItShort, canPreview, @@ -979,6 +1014,18 @@ private fun RenderNoteRow( ) } + is PollEvent -> { + RenderPoll( + baseNote, + makeItShort, + canPreview, + quotesLeft, + backgroundColor, + accountViewModel, + nav, + ) + } + is FileHeaderEvent -> { FileHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt index dbc0ed5ae..99d2cc893 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/TimeAgoFormatter.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.utils.TimeUtils import java.text.SimpleDateFormat import java.util.Locale +import kotlin.math.round private const val YEAR_DATE_FORMAT = "MMM dd, yyyy" private const val MONTH_DATE_FORMAT = "MMM dd" @@ -115,6 +116,46 @@ fun timeAgoNoDot( } } +fun timeAheadNoDot( + time: Long?, + context: Context, +): String { + if (time == null) return " " + if (time == 0L) return " ${stringRes(context, R.string.never)}" + + val timeDifference = time - TimeUtils.now() + + return if (timeDifference > TimeUtils.ONE_YEAR) { + // Dec 12, 2022 + + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) + monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) + } + + yearFormatter.format(time * 1000) + } else if (timeDifference > TimeUtils.ONE_MONTH) { + // Dec 12 + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) + monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) + } + + monthFormatter.format(time * 1000) + } else if (timeDifference > TimeUtils.ONE_DAY) { + // 2 days + round(timeDifference / TimeUtils.ONE_DAY.toFloat()).toInt().toString() + stringRes(context, R.string.d) + } else if (timeDifference > TimeUtils.ONE_HOUR) { + round(timeDifference / TimeUtils.ONE_HOUR.toFloat()).toInt().toString() + stringRes(context, R.string.h) + } else if (timeDifference > TimeUtils.ONE_MINUTE) { + round(timeDifference / TimeUtils.ONE_MINUTE.toFloat()).toInt().toString() + stringRes(context, R.string.m) + } else { + stringRes(context, R.string.now) + } +} + fun dateFormatter( time: Long?, never: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt similarity index 99% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt index cc4fc65fe..a9c5f7a81 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNote.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNote.kt @@ -113,7 +113,7 @@ import kotlin.uuid.Uuid @Preview @Composable -fun PollNotePreview() { +fun ZapZapPollNotePreview() { val event = PollNoteEvent( id = "6ff9bc13d27490f6e3953325260bd996901a143de89886a0608c39e7d0160a72", @@ -171,7 +171,7 @@ fun PollNotePreview() { Column( Modifier.padding(10.dp), ) { - PollNote( + ZapPollNote( baseNote = baseNote, true, remember { mutableStateOf(color) }, @@ -186,7 +186,7 @@ fun PollNotePreview() { @Preview @Composable -fun PollNotePreview2() { +fun ZapZapPollNotePreview2() { val event = PollNoteEvent( id = "3064bf97800a4b04b612fc0fd498936eae75fffbdca5bbd09d19a6dc598530ab", @@ -223,7 +223,7 @@ fun PollNotePreview2() { Column( Modifier.padding(10.dp), ) { - PollNote( + ZapPollNote( baseNote = baseNote, true, remember { mutableStateOf(color) }, @@ -237,7 +237,7 @@ fun PollNotePreview2() { } @Composable -fun PollNote( +fun ZapPollNote( baseNote: Note, canPreview: Boolean, backgroundColor: MutableState, @@ -249,7 +249,7 @@ fun PollNote( pollViewModel.init(accountViewModel.account) pollViewModel.load(baseNote) - PollNote( + ZapPollNote( baseNote = baseNote, pollViewModel = pollViewModel, canPreview = canPreview, @@ -260,7 +260,7 @@ fun PollNote( } @Composable -fun PollNote( +fun ZapPollNote( baseNote: Note, pollViewModel: PollNoteViewModel, canPreview: Boolean, @@ -426,7 +426,7 @@ private fun RenderOptionAfterVote( } @Composable -fun DisplayProgress( +private fun DisplayProgress( poolOption: PollOption, color: Color, modifier: Modifier, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt similarity index 100% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/PollNoteViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapPollNoteViewModel.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/AddLnInvoiceButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/AddLnInvoiceButton.kt index 155f31fb9..c4c6f9f8c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/AddLnInvoiceButton.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/invoice/AddLnInvoiceButton.kt @@ -20,18 +20,16 @@ */ package com.vitorpamplona.amethyst.ui.note.creators.invoice -import androidx.compose.foundation.layout.size import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.CurrencyBitcoin import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier @Composable fun AddLnInvoiceButton( @@ -45,14 +43,14 @@ fun AddLnInvoiceButton( Icon( imageVector = Icons.Default.CurrencyBitcoin, contentDescription = stringRes(id = R.string.add_bitcoin_invoice), - modifier = Modifier.size(20.dp), + modifier = Size20Modifier, tint = MaterialTheme.colorScheme.onBackground, ) } else { Icon( imageVector = Icons.Default.CurrencyBitcoin, contentDescription = stringRes(id = R.string.cancel_bitcoin_invoice), - modifier = Modifier.size(20.dp), + modifier = Size20Modifier, tint = BitcoinOrange, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt new file mode 100644 index 000000000..538735cda --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollDeadlinePicker.kt @@ -0,0 +1,170 @@ +/* + * 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.ui.note.creators.polls + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.DateRange +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TimePicker +import androidx.compose.material3.TimePickerDialog +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberTimePickerState +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.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.note.timeAheadNoDot +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.utils.TimeUtils +import java.time.Instant +import java.time.ZoneId +import java.time.ZoneOffset + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PollDeadlinePicker(model: ShortNotePostViewModel) { + var showDatePicker by remember { mutableStateOf(false) } + var showTimePicker by remember { mutableStateOf(false) } + + // Get current time details + val currentTime = Instant.ofEpochMilli(model.closedAt * 1000).atZone(ZoneId.systemDefault()).toLocalDateTime() + + val datePickerState = + rememberDatePickerState( + initialSelectedDateMillis = model.closedAt * 1000, + yearRange = currentTime.year..2050, + selectableDates = + object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean { + // Only allow today and future dates + return utcTimeMillis >= System.currentTimeMillis() - 86400000 // minus 24h buffer + } + }, + ) + val timePickerState = + rememberTimePickerState( + initialHour = currentTime.hour, + initialMinute = currentTime.minute, + is24Hour = false, // Set to true if you prefer military time + ) + + val context = LocalContext.current + + OutlinedCard( + onClick = { showDatePicker = true }, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(Icons.Default.DateRange, contentDescription = null) + Spacer(Modifier.width(12.dp)) + + if (model.closedAt < TimeUtils.oneMinuteFromNow()) { + Text(stringRes(R.string.poll_closing_date_time) + " " + model.closedAt, style = MaterialTheme.typography.bodyLarge) + } else { + Text( + text = stringRes(R.string.poll_closing_in, timeAheadNoDot(model.closedAt, context)), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + } + + // --- Date Picker Dialog --- + if (showDatePicker) { + DatePickerDialog( + onDismissRequest = { showDatePicker = false }, + confirmButton = { + TextButton(onClick = { + showDatePicker = false + showTimePicker = true + }) { Text(stringResource(R.string.next)) } + }, + ) { + DatePicker(state = datePickerState) + } + } + + // --- Time Picker Dialog --- + if (showTimePicker) { + TimePickerDialog( + title = { + Text(stringResource(R.string.closing_time)) + }, + onDismissRequest = { showTimePicker = false }, + confirmButton = { + TextButton( + onClick = { + val datetimeLocalTimeZone = + datePickerState.selectedDateMillis?.let { localDayAtZeroHourMillis -> + (localDayAtZeroHourMillis / 1000) + + (timePickerState.hour * TimeUtils.ONE_HOUR) + + (timePickerState.minute * TimeUtils.ONE_MINUTE) + } ?: TimeUtils.oneDayAhead() + + // Get the offset from UTC for the current instant in the local time zone + val offset: ZoneOffset = ZoneId.systemDefault().rules.getOffset(Instant.now()) + + model.closedAt = datetimeLocalTimeZone - offset.totalSeconds + + showTimePicker = false + }, + ) { Text(stringResource(R.string.confirm)) } + }, + ) { + TimePicker(state = timePickerState) + } + } +} + +@SuppressLint("ViewModelConstructorInComposable") +@Preview +@Composable +fun PollDeadlinePickerPreview() { + PollDeadlinePicker( + ShortNotePostViewModel(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollOptionsField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollOptionsField.kt new file mode 100644 index 000000000..5a973d304 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/polls/PollOptionsField.kt @@ -0,0 +1,128 @@ +/* + * 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.ui.note.creators.polls + +import android.annotation.SuppressLint +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Delete +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardCapitalization +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag +import com.vitorpamplona.quartz.utils.RandomInstance + +@Composable +fun PollOptionsField(postViewModel: ShortNotePostViewModel) { + val optionsList = postViewModel.pollOptions + Column( + modifier = Modifier.fillMaxWidth(), + ) { + optionsList.forEach { option -> + OutlinedTextField( + modifier = Modifier.fillMaxWidth(), + value = postViewModel.pollOptions[option.key]?.label ?: "", + onValueChange = { + postViewModel.updatePollOption(option.key, it) + }, + label = { + Text( + text = stringRes(R.string.poll_option_index, option.key + 1), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + placeholder = { + Text( + text = stringRes(R.string.poll_option_description), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + ), + trailingIcon = { + IconButton( + onClick = { + postViewModel.removePollOption(option.key) + }, + ) { + Icon( + imageVector = Icons.Default.Delete, + contentDescription = stringRes(R.string.clear), + ) + } + }, + ) + } + + Spacer(Modifier.height(8.dp)) + + PollDeadlinePicker(postViewModel) + + Spacer(Modifier.height(2.dp)) + + Button( + onClick = { + postViewModel.pollOptions[postViewModel.pollOptions.size] = OptionTag(RandomInstance.randomChars(6), "") + }, + border = + BorderStroke( + 1.dp, + MaterialTheme.colorScheme.outlineVariant, + ), + colors = + ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Icon(Icons.Default.Add, contentDescription = stringRes(R.string.add_poll_option_button)) + } + } +} + +@SuppressLint("ViewModelConstructorInComposable") +@Preview +@Composable +fun NewPollClosingPreview() { + PollOptionsField( + ShortNotePostViewModel(), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt index cc6b1fc6b..075f0aaf7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/uploads/ImageVideoDescription.kt @@ -58,7 +58,6 @@ import androidx.compose.ui.unit.sp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery import com.vitorpamplona.amethyst.ui.components.TextSpinner @@ -83,38 +82,12 @@ fun ImageVideoDescription( onCancel: () -> Unit, accountViewModel: AccountViewModel, ) { - ImageVideoDescription(uris, defaultServer, true, onAdd, onDelete, onCancel, accountViewModel) -} - -@Composable -fun ImageVideoDescription( - uris: MultiOrchestrator, - defaultServer: ServerName, - includeNIP95: Boolean, - onAdd: (String, ServerName, Boolean, Int, Boolean) -> Unit, - onDelete: (SelectedMediaProcessing) -> Unit, - onCancel: () -> Unit, - accountViewModel: AccountViewModel, -) { - val nip95description = stringRes(id = R.string.upload_server_relays_nip95) - - val fileServers by accountViewModel.account.serverLists.liveServerList + val fileServers by accountViewModel.account.blossomServers.hostNameFlow .collectAsState() val fileServerOptions = - remember(fileServers, includeNIP95) { - fileServers - .mapNotNull { - if (it.type == ServerType.NIP95) { - if (includeNIP95) { - TitleExplainer(it.name, nip95description) - } else { - null - } - } else { - TitleExplainer(it.name, it.baseUrl) - } - }.toImmutableList() + remember(fileServers) { + fileServers.map { TitleExplainer(it.name, it.baseUrl) }.toImmutableList() } var selectedServer by remember { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/PollField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/PollField.kt deleted file mode 100644 index ac1d91cc3..000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zappolls/PollField.kt +++ /dev/null @@ -1,74 +0,0 @@ -/* - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.amethyst.ui.note.creators.zappolls - -import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.Image -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.ui.actions.NewPollOption -import com.vitorpamplona.amethyst.ui.actions.NewPollVoteValueRange -import com.vitorpamplona.amethyst.ui.painterRes -import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostViewModel -import com.vitorpamplona.amethyst.ui.theme.Size18Modifier -import com.vitorpamplona.amethyst.ui.theme.placeholderText - -@Composable -fun PollField(postViewModel: ShortNotePostViewModel) { - val optionsList = postViewModel.pollOptions - Column( - modifier = Modifier.fillMaxWidth(), - ) { - optionsList.forEach { value -> - NewPollOption(postViewModel, value.key) - } - - NewPollVoteValueRange(postViewModel) - - Button( - onClick = { - // postViewModel.pollOptions[postViewModel.pollOptions.size] = "" - optionsList[optionsList.size] = "" - }, - border = - BorderStroke( - 1.dp, - MaterialTheme.colorScheme.placeholderText, - ), - colors = - ButtonDefaults.outlinedButtonColors( - containerColor = MaterialTheme.colorScheme.placeholderText, - ), - ) { - Image( - painter = painterRes(resourceId = android.R.drawable.ic_input_add, 1), - contentDescription = "Add poll option button", - modifier = Size18Modifier, - ) - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt index bedcfbc70..17c3c7b73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/nip22Comments/GenericCommentPostScreen.kt @@ -49,7 +49,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton @@ -287,9 +286,7 @@ private fun GenericCommentPostBody( accountViewModel.account.settings.defaultFileServer, onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) - if (server.type != ServerType.NIP95) { - accountViewModel.account.settings.changeDefaultFileServer(server) - } + accountViewModel.account.settings.changeDefaultFileServer(server) }, onDelete = postViewModel::deleteMediaToUpload, onCancel = { postViewModel.multiOrchestrator = null }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt new file mode 100644 index 000000000..af7b3d472 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt @@ -0,0 +1,268 @@ +/* + * 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.ui.note.types + +import androidx.compose.foundation.border +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.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.chess.ChessChallenge +import com.vitorpamplona.amethyst.commons.chess.ChessGameViewer +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +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.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor + +/** + * Render NIP-64 Chess Game event (Kind 64) + * + * Displays interactive chess board with PGN game replay functionality. + * Wraps content in sensitivity warning for user-controlled content filtering. + * + * @param note The note containing the ChessGameEvent + * @param backgroundColor Mutable state for background color + * @param accountViewModel Account view model for sensitivity checking + * @param nav Navigation interface + */ +@Composable +fun RenderChessGame( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = (note.event as? ChessGameEvent) ?: return + + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + ChessGameViewer(pgnContent = event.pgn()) + } +} + +/** + * Render Live Chess Challenge event (Kind 30064) + * + * Shows a challenge card with Accept/Decline actions for incoming challenges + * or status for sent challenges + */ +@Composable +fun RenderLiveChessChallenge( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = (note.event as? LiveChessGameChallengeEvent) ?: return + val gameId = event.gameId() ?: return + + val chessViewModel: ChessViewModelNew = + viewModel( + key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", + factory = ChessViewModelFactory(accountViewModel.account), + ) + + val isOpenChallenge = event.opponentPubkey() == null + val isIncomingChallenge = event.opponentPubkey() == accountViewModel.account.userProfile().pubkeyHex + + val borderColor = + when { + isOpenChallenge -> Color(0xFF4CAF50) + + // Green for open + isIncomingChallenge -> Color(0xFFFFA726) + + // Orange for incoming + else -> MaterialTheme.colorScheme.outline // Gray for sent + } + + val icon = + when { + isOpenChallenge -> "🔓" + isIncomingChallenge -> "💌" + else -> "⏳" + } + + val title = + when { + isOpenChallenge -> "Open Challenge" + isIncomingChallenge -> "Challenge from ${note.author?.toBestDisplayName()}" + else -> "Challenge sent to ${event.opponentPubkey()?.take(8)}" + } + + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp) + .border(2.dp, borderColor, MaterialTheme.shapes.medium), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "$icon $title", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Text( + text = + if (event.playerColor()?.name == "WHITE") "White" else "Black", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + event.timeControl()?.let { + Text( + text = "Time: $it", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Action buttons + if (isIncomingChallenge || isOpenChallenge) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton(onClick = { /* TODO: Decline */ }) { + Text("Decline") + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + // Accept challenge and navigate to game + val challengerPubkey = note.author?.pubkeyHex ?: return@Button + + // Create ChessChallenge from Note data + val challenge = + ChessChallenge( + eventId = note.idHex, + gameId = gameId, + challengerPubkey = challengerPubkey, + challengerDisplayName = note.author?.toBestDisplayName(), + challengerAvatarUrl = note.author?.profilePicture(), + opponentPubkey = event.opponentPubkey(), + challengerColor = event.playerColor() ?: ChessColor.WHITE, + createdAt = event.createdAt, + ) + + chessViewModel.acceptChallenge(challenge) + + // Navigate to game + nav.nav(Route.ChessGame(gameId)) + }, + ) { + Text("Accept") + } + } + } + } + } +} + +/** + * Render Live Chess Game End event (Kind 30067) + * + * Shows final game result with PGN viewer + */ +@Composable +fun RenderLiveChessGameEnd( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val event = (note.event as? LiveChessGameEndEvent) ?: return + + SensitivityWarning(note = note, accountViewModel = accountViewModel) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + // Result header + Card( + modifier = Modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + ), + ) { + Column(modifier = Modifier.padding(12.dp)) { + Text( + text = "🏆 Game Ended", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Result: ${event.result()}", + style = MaterialTheme.typography.bodyMedium, + ) + event.termination()?.let { + Text( + text = "By: ${it.replace("_", " ")}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + } + + // Show PGN if available + event.pgn().takeIf { it.isNotBlank() }?.let { pgn -> + ChessGameViewer(pgnContent = pgn) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt index 58d55e338..71e7ba8a8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/InteractiveStory.kt @@ -61,12 +61,13 @@ fun RenderInteractiveStory( accountViewModel: AccountViewModel, nav: INav, ) { - val baseRootEvent = baseNote.event as? InteractiveStoryBaseEvent ?: return + val baseRootEvent = baseNote.toEventHint() ?: return val address = baseNote.address() ?: return // keep updating the root event with new versions val note = observeNote(baseNote, accountViewModel) - val rootEvent = note.value?.note?.event as? InteractiveStoryBaseEvent ?: return + val rootHint = note.value.note.toEventHint() ?: return + val rootEvent = rootHint.event // keep updating the reading state event with new versions val readingStateNote = accountViewModel.getInteractiveStoryReadingState(address.toValue()) @@ -83,11 +84,11 @@ fun RenderInteractiveStory( RenderInteractiveStory( section = it, onSelect = { - val event = it.event as? InteractiveStoryBaseEvent ?: return@RenderInteractiveStory - accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, event) + val eventHint = it.toEventHint() ?: return@RenderInteractiveStory + accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, eventHint) }, onRestart = { - accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, rootEvent) + accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, rootHint) }, makeItShort = makeItShort, canPreview = canPreview, @@ -103,11 +104,11 @@ fun RenderInteractiveStory( RenderInteractiveStory( section = rootEvent, onSelect = { - val event = it.event as? InteractiveStoryBaseEvent ?: return@RenderInteractiveStory - accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, event) + val eventHint = it.toEventHint() ?: return@RenderInteractiveStory + accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, eventHint) }, onRestart = { - accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, rootEvent) + accountViewModel.updateInteractiveStoryReadingState(baseRootEvent, rootHint) }, makeItShort = makeItShort, canPreview = canPreview, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt index b16f0df29..e2bdc9642 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Poll.kt @@ -20,35 +20,85 @@ */ package com.vitorpamplona.amethyst.ui.note.types +import android.annotation.SuppressLint +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.MutableState -import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.key +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.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache +import com.vitorpamplona.amethyst.commons.model.nip88Polls.TallyResults import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.PollNote -import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition +import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor +import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.note.showCount import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.theme.BigPadding +import com.vitorpamplona.amethyst.ui.theme.Size25dp +import com.vitorpamplona.amethyst.ui.theme.SmallishBorder +import com.vitorpamplona.amethyst.ui.theme.SpacedBy10dp +import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp +import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.allGoodColor +import com.vitorpamplona.amethyst.ui.theme.grayText import com.vitorpamplona.amethyst.ui.theme.placeholderText -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.amethyst.ui.theme.subtleBorder import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags -import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser -import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.map +import kotlin.collections.map @Composable fun RenderPoll( @@ -56,81 +106,709 @@ fun RenderPoll( makeItShort: Boolean, canPreview: Boolean, quotesLeft: Int, - unPackReply: Boolean, backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, ) { - val noteEvent = note.event as? PollNoteEvent ?: return - val eventContent = noteEvent.content - - val showReply by - remember(note) { - derivedStateOf { - !makeItShort && unPackReply && (note.replyTo != null || noteEvent.hasAnyTaggedUser()) - } - } - - if (showReply) { - val replyingDirectlyTo = - remember(note) { - val replyingTo = noteEvent.replyingToAddressOrEvent() - if (replyingTo != null) { - val newNote = accountViewModel.getNoteIfExists(replyingTo) - if (newNote != null && LocalCache.getAnyChannel(newNote) == null && newNote.event?.kind != CommunityDefinitionEvent.KIND) { - newNote - } else { - note.replyTo?.lastOrNull { it.event?.kind != CommunityDefinitionEvent.KIND } - } - } else { - note.replyTo?.lastOrNull { it.event?.kind != CommunityDefinitionEvent.KIND } - } - } - if (replyingDirectlyTo != null) { - ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav) - Spacer(modifier = StdVertSpacer) - } - } + val noteEvent = note.event as? PollEvent ?: return if (makeItShort && accountViewModel.isLoggedUser(note.author)) { Text( - text = eventContent, + text = noteEvent.content, color = MaterialTheme.colorScheme.placeholderText, maxLines = 2, overflow = TextOverflow.Ellipsis, ) } else { - val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } - val callbackUri = remember(note) { note.toNostrUri() } - SensitivityWarning( note = note, accountViewModel = accountViewModel, ) { + InnerRenderPoll(noteEvent, note, makeItShort, canPreview, quotesLeft, backgroundColor, accountViewModel, nav) + } + } +} + +@Composable +fun InnerRenderPoll( + event: PollEvent, + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } + val callbackUri = remember(note) { note.toNostrUri() } + + Column( + verticalArrangement = SpacedBy5dp, + ) { + TranslatableRichTextViewer( + content = event.content, + canPreview = canPreview && !makeItShort, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + + RenderPollCard( + event = event, + pollState = note.pollState(), + accountViewModel = accountViewModel, + galleryUser = { user -> + ClickableUserPicture( + user, + Size25dp, + accountViewModel, + onClick = { + nav.nav { routeFor(user) } + }, + ) + }, + ) { code, label -> TranslatableRichTextViewer( - content = eventContent, - canPreview = canPreview && !makeItShort, - quotesLeft = quotesLeft, + content = label, + canPreview = canPreview, + quotesLeft = 1, modifier = Modifier.fillMaxWidth(), tags = tags, backgroundColor = backgroundColor, - id = note.idHex, + id = note.idHex + code, callbackUri = callbackUri, accountViewModel = accountViewModel, nav = nav, ) - - PollNote( - note, - canPreview = canPreview && !makeItShort, - backgroundColor, - accountViewModel, - nav, - ) } - if (noteEvent.hasHashtags()) { - DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) + if (event.hasHashtags()) { + DisplayUncitedHashtags(event, event.content, callbackUri, accountViewModel, nav) + } + } +} + +@Stable +class PollCard( + val options: List, + val type: PollType, + val endsAt: Long?, + val isMyPoll: Boolean = false, + val haveIVotedFlow: Flow, + val haveIVoted: () -> Boolean, +) { + fun hasEnded() = endsAt != null && endsAt < TimeUtils.now() +} + +@Stable +class PollItemCard( + val code: String, + val label: String, + val results: Flow, + val currentResults: () -> TallyResults, +) + +@Composable +fun RenderPollCard( + event: PollEvent, + pollState: PollResponsesCache, + accountViewModel: AccountViewModel, + galleryUser: @Composable RowScope.(user: User) -> Unit, + labelContent: @Composable ColumnScope.(code: String, label: String) -> Unit, +) { + val card = + remember(event) { + PollCard( + options = + event.options().map { option -> + PollItemCard( + code = option.code, + label = option.label, + results = + pollState.tallyFlow( + option.code, + accountViewModel.account.pubKey, + accountViewModel.account.allFollows.flow + .map { it.authors }, + ), + currentResults = { + pollState.currentTally( + option.code, + accountViewModel.account.pubKey, + accountViewModel.account.allFollows.flow.value.authors, + ) + }, + ) + }, + type = event.pollType(), + endsAt = event.endsAt(), + isMyPoll = event.pubKey == accountViewModel.account.pubKey, + haveIVotedFlow = pollState.hasPubKeyVotedFlow(accountViewModel.account.userProfile()), + haveIVoted = { + pollState.hasPubKeyVoted(accountViewModel.account.userProfile()) + }, + ) + } + + RenderPollCard( + card = card, + onRespond = { responses -> + accountViewModel.launchSigner { + accountViewModel.account.pollRespond(event, responses) + } + }, + resultContent = galleryUser, + labelContent = labelContent, + ) +} + +@Composable +fun RenderPollCard( + card: PollCard, + onRespond: (Set) -> Unit, + resultContent: @Composable RowScope.(user: User) -> Unit, + labelContent: @Composable ColumnScope.(code: String, label: String) -> Unit, +) { + Column( + verticalArrangement = SpacedBy5dp, + ) { + if (card.isMyPoll) { + RenderResults(card, resultContent, labelContent) + } else { + val haveIVoted = card.haveIVoted() + if (haveIVoted) { + RenderResults(card, resultContent, labelContent) + } else { + // waits for vote + val haveIVoted by card.haveIVotedFlow.collectAsStateWithLifecycle(haveIVoted) + if (haveIVoted) { + RenderResults(card, resultContent, labelContent) + } else if (card.hasEnded()) { + RenderResults(card, resultContent, labelContent) + } else { + when (card.type) { + PollType.SINGLE_CHOICE -> RenderSingleChoiceOptions(card, labelContent, onRespond) + PollType.MULTI_CHOICE -> RenderMultiChoiceOptions(card, labelContent, onRespond) + } + } + } + } + } +} + +@Composable +private fun ColumnScope.RenderSingleChoiceOptions( + card: PollCard, + labelContent: @Composable (ColumnScope.(String, String) -> Unit), + onRespond: (Set) -> Unit, +) { + card.options.forEach { + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(SmallishBorder) + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.placeholderText, + shape = SmallishBorder, + ).clickable { + onRespond(setOf(it.code)) + }, + ) { + Row( + modifier = BigPadding, + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = + Modifier + .fillMaxWidth(), + ) { + labelContent(it.code, it.label) + } + } + } + } +} + +@Composable +private fun ColumnScope.RenderMultiChoiceOptions( + card: PollCard, + labelContent: @Composable (ColumnScope.(String, String) -> Unit), + onRespond: (Set) -> Unit, +) { + var multichoice by + remember { + mutableStateOf>(emptySet()) + } + + card.options.forEach { option -> + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(SmallishBorder) + .border(1.dp, MaterialTheme.colorScheme.grayText, SmallishBorder) + .clickable { + multichoice += option.code + }, + ) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Checkbox( + checked = option.code in multichoice, + onCheckedChange = { checked -> + if (checked) { + multichoice += option.code + } else { + multichoice -= option.code + } + }, + ) + + Column( + modifier = Modifier.weight(1f), + ) { + labelContent(option.code, option.label) + } + } + } + } + + Button( + onClick = { + onRespond(multichoice) + }, + modifier = Modifier.align(Alignment.End), + enabled = multichoice.isNotEmpty(), + ) { + Text("Submit") + } +} + +@Composable +private fun RenderResults( + card: PollCard, + resultContent: @Composable RowScope.(user: User) -> Unit, + labelContent: @Composable (ColumnScope.(code: String, label: String) -> Unit), +) { + card.options.forEach { pollItem -> + RenderClosedItem(pollItem, resultContent) { + labelContent(pollItem.code, pollItem.label) + } + } +} + +@Composable +private fun RenderClosedItem( + item: PollItemCard, + resultContent: @Composable RowScope.(user: User) -> Unit, + labelContent: @Composable ColumnScope.() -> Unit, +) { + val tally by item.results.collectAsStateWithLifecycle(item.currentResults()) + + RenderClosedItem(tally, resultContent, labelContent) +} + +@Composable +private fun RenderClosedItem( + tally: TallyResults, + resultContent: @Composable RowScope.(user: User) -> Unit, + labelContent: @Composable ColumnScope.() -> Unit, +) { + Box( + modifier = + Modifier + .fillMaxWidth() + .clip(SmallishBorder) + .border( + width = 1.dp, + color = + if (tally.isWinning) { + MaterialTheme.colorScheme.allGoodColor + } else { + MaterialTheme.colorScheme.grayText + }, + shape = SmallishBorder, + ).background( + if (tally.isWinning) { + MaterialTheme.colorScheme.allGoodColor.copy(0.2f) + } else { + MaterialTheme.colorScheme.subtleBorder + }, + ), + ) { + // Animate the progress bar when a vote is cast + val animatedProgress by animateFloatAsState( + targetValue = tally.percent, + animationSpec = tween(durationMillis = 800), + ) + + val progressBarColor = if (tally.isWinning) MaterialTheme.colorScheme.allGoodColor else MaterialTheme.colorScheme.primary + + Box( + modifier = + Modifier + .matchParentSize() + .alpha(0.32f) + .drawWithContent { + // Clip the drawing area to show only the progress amount + clipRect(right = size.width * animatedProgress) { + drawRect(progressBarColor) + } + drawContent() + }, + ) + + Row( + modifier = Modifier.padding(15.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + content = labelContent, + ) + + Spacer(StdHorzSpacer) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy10dp, + ) { + UserGallery(tally, resultContent) + + Text( + text = "${(tally.percent * 100).toInt()}%", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + ) + } + } + } +} + +@Composable +fun UserGallery( + tally: TallyResults, + galleryUser: @Composable RowScope.(user: User) -> Unit, +) { + if (tally.users.isNotEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy((-10).dp), + ) { + tally.users.take(6).forEach { + key(it.pubkeyHex) { + galleryUser(it) + } + } + + if (tally.users.size > 6) { + Box( + contentAlignment = Alignment.Center, + modifier = + Modifier + .size(Size25dp) + .clip(shape = CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer), + ) { + Text( + text = "+" + showCount(tally.users.size - 6), + fontSize = 10.sp, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} + +@Preview +@Composable +fun RenderPollManualPreview() { + val poll = + PollCard( + options = + listOf( + PollItemCard( + code = "1", + label = "Yes", + results = flow {}, + currentResults = { + TallyResults( + percent = 0.9f, + isWinning = true, + ) + }, + ), + PollItemCard( + code = "2", + label = "No", + results = flow {}, + currentResults = { + TallyResults( + percent = 0.1f, + isWinning = false, + ) + }, + ), + ), + type = PollType.SINGLE_CHOICE, + endsAt = null, + isMyPoll = true, + haveIVotedFlow = flow {}, + haveIVoted = { true }, + ) + + ThemeComparisonColumn { + Column(Modifier.padding(10.dp)) { + RenderPollCard(poll, {}, {}) { _, label -> + Text( + text = label, + ) + } + } + } +} + +@SuppressLint("StateFlowValueCalledInComposition") +@Preview +@Composable +fun RenderPollGameResultsPreview() { + val event = + PollEvent( + id = "1fae24bb7e1673afc94da3878d051c0f4fd65599960aeb8a9a90642f8cad6299", + pubKey = "c21b1a6cdb247ccbd938dcb16b15a4fa382d00ffd7b12d5cbbad172a0cd4d170", + createdAt = 1770690664, + content = "If there cpuld only be one: ", + tags = + arrayOf( + arrayOf("option", "dzv1u5do4", "GTA V"), + arrayOf("option", "8mr177irx", "Cyberpunk 2077"), + arrayOf("relay", "wss://relay.nostr.band/"), + arrayOf("relay", "wss://relay.primal.net/"), + arrayOf("relay", "wss://nos.lol/"), + arrayOf("relay", "wss://relay.damus.io/"), + arrayOf("relay", "wss://wot.nostr.party/"), + arrayOf("relay", "wss://relay.mostr.pub/"), + arrayOf("relay", "wss://offchain.pub/"), + arrayOf("polltype", "singlechoice"), + ), + sig = "7263d7e98348474c9f6670a98225e3594bffa813ff9c9084b7045126562dc352744450b5e667b0069e0276a63d3b9e14df3b1e1a3e6d5e958c3a7c74390345f5", + ) + + val note = LocalCache.getOrCreateNote("1fae24bb7e1673afc94da3878d051c0f4fd65599960aeb8a9a90642f8cad6299") + + LocalCache.justConsume(event, null, true) + + val response1 = + PollResponseEvent( + "a503bb8b1bd9062fab4acd003f015791faa04a9f321b91fd6d4649315e2a9307", + pubKey = "592295cf2b09a7f9555f43adb734cbee8a84ee892ed3f9336e6a09b6413a0db9", + createdAt = 1770729213, + tags = + arrayOf( + arrayOf("e", "1fae24bb7e1673afc94da3878d051c0f4fd65599960aeb8a9a90642f8cad6299"), + arrayOf("response", "dzv1u5do4"), + ), + content = "", + sig = "0affc0a4f39bbae0802628282af871bbdab8d0338956ebd51cbb5453eac8f66f56013af35dafbe847762f6cfc6f9746be59260d2c462602501dad94d2fb67b63", + ) + + val response2 = + PollResponseEvent( + "1ad46eabd0044911e2c65ace22d4ad19640a74dc2b3daeea7bc2862fb0dcf5fb", + pubKey = "c21b1a6cdb247ccbd938dcb16b15a4fa382d00ffd7b12d5cbbad172a0cd4d170", + createdAt = 1770729213, + tags = + arrayOf( + arrayOf("e", "1fae24bb7e1673afc94da3878d051c0f4fd65599960aeb8a9a90642f8cad6299"), + arrayOf("response", "8mr177irx"), + ), + content = "", + sig = "3efa83a899e1038fde76b8130f99881ab88c330adcd7d9f2578fa3c3e346eba05d627bd75ddb5780ffd460fcefb07839d0b379ba20db30772fa4d847b1dcfa6f", + ) + + LocalCache.justConsume(response1, null, true) + LocalCache.justConsume(response2, null, true) + + ThemeComparisonColumn { + Column(Modifier.padding(10.dp)) { + RenderPoll( + note, + false, + true, + 2, + remember { mutableStateOf(Color.Transparent) }, + mockAccountViewModel(), + EmptyNav(), + ) + } + } +} + +@SuppressLint("StateFlowValueCalledInComposition") +@Preview +@Composable +fun RenderPollColorResultsPreview() { + val event = + PollEvent( + id = "64d4acac073860d9080a51988bb1e9ffaef4ae8f3f9cf2d7b510e10d16a33b91", + pubKey = "feb88e80a63d6a25b4b56eed4fac67342a97f2206e0a04fad89eb24d55ea303f", + createdAt = 1769822711, + content = "Color?", + tags = + arrayOf( + arrayOf("option", "hrwgtnzue", "Blue"), + arrayOf("option", "1cx1gldth", "Red"), + arrayOf("option", "5e29bzfjf", "Green"), + arrayOf("polltype", "singlechoice"), + arrayOf("endsAt", "1769844311"), + arrayOf("relay", "wss://relay.ditto.pub"), + arrayOf("relay", "wss://relay.primal.net"), + arrayOf("relay", "wss://relay.damus.io"), + arrayOf("I", "iso3166:BR"), + arrayOf("K", "iso3166"), + arrayOf("i", "iso3166:BR"), + arrayOf("k", "iso3166"), + ), + sig = "d65af73c3b652ea06df3dc00b05a6f155cf8936d645ca002d2c1a5d59b45e9b072e5217d3339711ad62f5b105859d8da95eb636681bd12ad04063d19359f48b5", + ) + + val note = LocalCache.getOrCreateNote(event) + + LocalCache.justConsume(event, null, true) + + val response1 = + PollResponseEvent( + id = "b756c6b3112d088355f7e3d288f5ccc0633522d86f770e1bdba67fae1b082719", + pubKey = "3ff64bd7dde76af783a9545e2fb3ce84d921be6c8c04f3dc6c4fa734cf7eb812", + createdAt = 1769841653, + tags = + arrayOf( + arrayOf("e", "64d4acac073860d9080a51988bb1e9ffaef4ae8f3f9cf2d7b510e10d16a33b91"), + arrayOf("response", "hrwgtnzue"), + arrayOf("client", "www.pollstr.site"), + ), + content = "", + sig = "3a8cc549a5736774568e4b9a32e689ad5882fe2d030c4e05ecf78c6b1cfe5b5c9d3383067f525dfbddae600dc18b1266526e6d5d4e1f43e5723ebc7607b0377d", + ) + + val response2 = + PollResponseEvent( + id = "6aecc88b2acc8145c68553040da333d62085a6f75b874b1b7fceab2c8158069e", + pubKey = "c21b1a6cdb247ccbd938dcb16b15a4fa382d00ffd7b12d5cbbad172a0cd4d170", + createdAt = 1769827608, + tags = + arrayOf( + arrayOf("e", "64d4acac073860d9080a51988bb1e9ffaef4ae8f3f9cf2d7b510e10d16a33b91"), + arrayOf("response", "5e29bzfjf"), + ), + content = "", + sig = "1ab865185fa7328d8b64f5c676a370d2dc4896678d6b13786ff142cfe8d7dc635f523740fad25a944de1b01def179548843a893a088f177dd011674a746d02a4", + ) + + val response3 = + PollResponseEvent( + id = "53a5ac0972b08276a0ea34a7dcd1d796b14453f4a76ca4626ed42ad19d67ade9", + pubKey = "feb88e80a63d6a25b4b56eed4fac67342a97f2206e0a04fad89eb24d55ea303f", + createdAt = 1769822723, + tags = + arrayOf( + arrayOf("e", "64d4acac073860d9080a51988bb1e9ffaef4ae8f3f9cf2d7b510e10d16a33b91"), + arrayOf("response", "5e29bzfjf"), + ), + content = "", + sig = "d330c6f10d26674785d7e312929f4b4e01272840c087de45dceb0389ce14368908011b621dad24a9e438c1d757f9eac9940b6b9005721ff995c50258d1a7f6fb", + ) + + val response4 = + PollResponseEvent( + id = "5c6670f736f166dad6f1784ebe72bb851a9d82a9b8bb1355d8d74c2c7f1869fd", + pubKey = "feb88e80a63d6a25b4b56eed4fac67342a97f2206e0a04fad89eb24d55ea303f", + createdAt = 1769822720, + tags = + arrayOf( + arrayOf("e", "64d4acac073860d9080a51988bb1e9ffaef4ae8f3f9cf2d7b510e10d16a33b91"), + arrayOf("response", "hrwgtnzue"), + ), + content = "", + sig = "d2feee481c0e84306fcb0815c7c671ab697948148b18307f864fe6c96a09485c12bf8f843d406b231914d60bd2735d3f3ed0b4e89377794bccd66e2e0acf2eb5", + ) + + LocalCache.justConsume(response1, null, true) + LocalCache.justConsume(response2, null, true) + LocalCache.justConsume(response3, null, true) + LocalCache.justConsume(response4, null, true) + + ThemeComparisonColumn { + Column(Modifier.padding(10.dp)) { + RenderPoll( + note, + false, + true, + 2, + remember { mutableStateOf(Color.Transparent) }, + mockAccountViewModel(), + EmptyNav(), + ) + } + } +} + +@SuppressLint("StateFlowValueCalledInComposition") +@Preview +@Composable +fun RenderPollMultiChoicePreview() { + val event = + PollEvent( + id = "ab1cad8d8f24aaa53b8cf34dd06ddedda5961c57da7a0df91349901fc15b5042", + pubKey = "baeb862f3318390ec5af5c9db64ae5ddb2efc1a97db54c6550656bfa2dcc054b", + createdAt = 1769053977, + content = "Have you seen the simply Nostr/greeny show?", + tags = + arrayOf( + arrayOf("option", "py4d986jp", "Yes"), + arrayOf("option", "z0tipo9as", "No "), + arrayOf("option", "59ya8j3jo", "GFY"), + arrayOf("relay", "wss://nos.lol"), + arrayOf("relay", "wss://student.chadpolytechnic.com"), + arrayOf("relay", "wss://nostr-relay.wlvs.space"), + arrayOf("relay", "wss://nostr.ono.re"), + arrayOf("relay", "wss://nostr.bongbong.com"), + arrayOf("polltype", "multiplechoice"), + arrayOf("client", "www.pollstr.site"), + ), + sig = "aaddf12bc4b7947d8c57dd0fb4378c5f0daab6844cc3205bf1480e2a10e077b1abca67d071530e9fecac517fd6d052e49630a32596d9ecfb862967d0b2b2d899", + ) + + val note = LocalCache.getOrCreateNote(event) + + LocalCache.justConsume(event, null, true) + + ThemeComparisonColumn { + Column(Modifier.padding(10.dp)) { + RenderPoll( + note, + makeItShort = false, + canPreview = true, + quotesLeft = 2, + backgroundColor = remember { mutableStateOf(Color.Transparent) }, + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt index 976916d38..14f5c3f5e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/VoiceTrack.kt @@ -20,10 +20,10 @@ */ package com.vitorpamplona.amethyst.ui.note.types -import android.content.Context -import android.view.View -import android.widget.FrameLayout +import android.graphics.Rect import androidx.annotation.OptIn +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -36,7 +36,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -44,23 +44,34 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.toArgb -import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.compose.ui.viewinterop.AndroidView -import androidx.media3.common.Player import androidx.media3.common.util.UnstableApi -import androidx.media3.ui.PlayerView +import androidx.media3.ui.compose.ContentFrame +import androidx.media3.ui.compose.SURFACE_TYPE_TEXTURE_VIEW +import androidx.media3.ui.compose.state.rememberPlayPauseButtonState import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState import com.vitorpamplona.amethyst.service.playback.composable.WaveformData -import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderControlButtons +import com.vitorpamplona.amethyst.service.playback.composable.controls.AnimatedSaveButton +import com.vitorpamplona.amethyst.service.playback.composable.controls.AnimatedShareButton +import com.vitorpamplona.amethyst.service.playback.composable.controls.MuteButton +import com.vitorpamplona.amethyst.service.playback.composable.controls.PictureInPictureButton import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem +import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemData import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform +import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming +import com.vitorpamplona.amethyst.service.playback.pip.PipVideoActivity +import com.vitorpamplona.amethyst.ui.components.ShareMediaAction +import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -140,6 +151,7 @@ fun RenderAudioWithWaveform( Column(modifier = MaxWidthPaddingTop5dp, horizontalAlignment = Alignment.CenterHorizontally) { Row( + Modifier.fillMaxWidth().height(100.dp), verticalAlignment = Alignment.CenterVertically, ) { GetMediaItem( @@ -189,35 +201,22 @@ fun RenderVoicePlayer( val controllerVisible = remember(controllerState) { mutableStateOf(false) } Box(modifier = borderModifier) { - AndroidView( + ContentFrame( modifier = Modifier .fillMaxWidth() - .height(100.dp), - factory = { context: Context -> - PlayerView(context).apply { - player = controllerState.controller - // if we already know the size of the frame, this forces the player to stay in the size - layoutParams = - FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT, - ) - - setBackgroundColor(Color.Transparent.toArgb()) - setShutterBackgroundColor(Color.Transparent.toArgb()) - - controllerAutoShow = false - useController = true - hideController() - - setControllerVisibilityListener( - PlayerView.ControllerVisibilityListener { visible -> - controllerVisible.value = visible == View.VISIBLE - }, - ) - } - }, + .height(100.dp) + .onGloballyPositioned { coordinates -> + val bounds = coordinates.boundsInWindow() + val boundRect = Rect(bounds.left.toInt(), bounds.top.toInt(), bounds.right.toInt(), bounds.bottom.toInt()) + controllerState.visibility.bounds = boundRect + }.clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, // to prevent the ripple from the tap + ) { controllerVisible.value = !controllerVisible.value }, + player = controllerState.controller, + keepContentOnReset = true, + surfaceType = SURFACE_TYPE_TEXTURE_VIEW, // texture view is better inside lazy layouts. ) Row(VoiceHeightModifier, verticalAlignment = Alignment.CenterVertically) { @@ -225,71 +224,38 @@ fun RenderVoicePlayer( waveform?.let { Waveform(it, controllerState, Modifier) } } - RenderControlButtons( - mediaItem.src, - controllerState, - controllerVisible, - Modifier.align(Alignment.TopEnd), - accountViewModel, + RenderTopButtonsForVoice( + mediaData = mediaItem.src, + controllerState = controllerState, + controllerVisible = controllerVisible, + modifier = Modifier.align(Alignment.TopEnd), + accountViewModel = accountViewModel, ) } } +@OptIn(UnstableApi::class) @Composable fun PlayPauseButton(controllerState: MediaControllerState) { - val controller = controllerState.controller - var isPlaying by remember(controller) { - mutableStateOf(controllerState.isPlaying()) - } + val state = rememberPlayPauseButtonState(controllerState.controller) - if (controller != null) { - val view = LocalView.current - // Keeps the screen on while playing and viewing videos. - DisposableEffect(key1 = controller, key2 = view) { - val listener = - object : Player.Listener { - override fun onIsPlayingChanged(playing: Boolean) { - // doesn't consider the mutex because the screen can turn off if the video - // being played in the mutex is not visible. - if (view.keepScreenOn != playing) { - view.keepScreenOn = playing - } - isPlaying = playing - } - - fun destroy() { - if (view.keepScreenOn) { - view.keepScreenOn = false - } - isPlaying = false - } - } - - controller.addListener(listener) - onDispose { - controller.removeListener(listener) - listener.destroy() + // Play/Pause Button + IconButton( + onClick = { + if (controllerState.controller.isPlaying) { + controllerState.controller.pause() + } else { + controllerState.controller.play() } - } - - // Play/Pause Button - IconButton( - onClick = { - if (controller.isPlaying) { - controller.pause() - } else { - controller.play() - } - }, - modifier = Size75Modifier, - ) { - Icon( - imageVector = if (isPlaying) Icons.Default.PauseCircleOutline else Icons.Default.PlayCircleOutline, - contentDescription = if (isPlaying) stringResource(R.string.pause) else stringResource(R.string.play), - modifier = Size50Modifier, - tint = Color.White, - ) - } + }, + modifier = Size75Modifier, + ) { + Icon( + imageVector = if (state.showPlay) Icons.Default.PlayCircleOutline else Icons.Default.PauseCircleOutline, + contentDescription = if (state.showPlay) stringResource(R.string.play) else stringResource(R.string.pause), + modifier = Size50Modifier, + tint = Color.White, + ) } } @@ -343,3 +309,90 @@ fun RenderAudioFromIMeta( nav = nav, ) } + +@Composable +fun RenderTopButtonsForVoice( + mediaData: MediaItemData, + controllerState: MediaControllerState, + controllerVisible: MutableState, + modifier: Modifier, + accountViewModel: AccountViewModel, +) { + val context = LocalContext.current.getActivity() + + RenderTopButtonsForVoice( + mediaData = mediaData, + controllerVisible = controllerVisible, + startingMuteState = controllerState.controller.volume < 0.001, + onMuteClick = { mute -> + // makes the new setting the default for new creations. + DEFAULT_MUTED_SETTING.value = mute + controllerState.controller.volume = if (mute) 0f else 1f + }, + onPictureInPictureClick = { + PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context) + }, + modifier = modifier, + accountViewModel = accountViewModel, + ) +} + +@Composable +fun RenderTopButtonsForVoice( + mediaData: MediaItemData, + controllerVisible: MutableState, + startingMuteState: Boolean, + onMuteClick: (Boolean) -> Unit, + onPictureInPictureClick: () -> Unit, + modifier: Modifier, + accountViewModel: AccountViewModel, +) { + Row(modifier) { + if (!isLiveStreaming(mediaData.videoUri)) { + AnimatedShareButton(controllerVisible) { popupExpanded, toggle -> + ShareMediaAction( + popupExpanded = popupExpanded, + videoUri = mediaData.videoUri, + postNostrUri = mediaData.callbackUri, + blurhash = null, + dim = null, + hash = null, + mimeType = mediaData.mimeType, + onDismiss = toggle, + content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri), + accountViewModel = accountViewModel, + ) + } + + AnimatedSaveButton(controllerVisible) { context -> + accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) + } + } else { + AnimatedShareButton(controllerVisible) { popupExpanded, toggle -> + ShareMediaAction( + popupExpanded = popupExpanded, + videoUri = mediaData.videoUri, + postNostrUri = mediaData.callbackUri, + blurhash = null, + dim = null, + hash = null, + mimeType = mediaData.mimeType, + onDismiss = toggle, + content = MediaUrlVideo(url = mediaData.videoUri, mimeType = mediaData.mimeType, artworkUri = mediaData.artworkUri, authorName = mediaData.authorName, description = mediaData.title, uri = mediaData.callbackUri), + accountViewModel = accountViewModel, + ) + } + } + + PictureInPictureButton( + controllerVisible = controllerVisible, + onClick = onPictureInPictureClick, + ) + + MuteButton( + controllerVisible = controllerVisible, + startingMuteState = startingMuteState, + toggle = onMuteClick, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt new file mode 100644 index 000000000..1de8238e4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapPoll.kt @@ -0,0 +1,136 @@ +/* + * 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.ui.note.types + +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import com.vitorpamplona.amethyst.commons.model.toImmutableListOfLists +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.components.SensitivityWarning +import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition +import com.vitorpamplona.amethyst.ui.note.ZapPollNote +import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags +import com.vitorpamplona.quartz.nip01Core.tags.people.hasAnyTaggedUser +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent + +@Composable +fun RenderZapPoll( + note: Note, + makeItShort: Boolean, + canPreview: Boolean, + quotesLeft: Int, + unPackReply: Boolean, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val noteEvent = note.event as? PollNoteEvent ?: return + val eventContent = noteEvent.content + + val showReply by + remember(note) { + derivedStateOf { + !makeItShort && unPackReply && (note.replyTo != null || noteEvent.hasAnyTaggedUser()) + } + } + + if (showReply) { + val replyingDirectlyTo = + remember(note) { + val replyingTo = noteEvent.replyingToAddressOrEvent() + if (replyingTo != null) { + val newNote = accountViewModel.getNoteIfExists(replyingTo) + if (newNote != null && LocalCache.getAnyChannel(newNote) == null && newNote.event?.kind != CommunityDefinitionEvent.KIND) { + newNote + } else { + note.replyTo?.lastOrNull { it.event?.kind != CommunityDefinitionEvent.KIND } + } + } else { + note.replyTo?.lastOrNull { it.event?.kind != CommunityDefinitionEvent.KIND } + } + } + if (replyingDirectlyTo != null) { + ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav) + Spacer(modifier = StdVertSpacer) + } + } + + if (makeItShort && accountViewModel.isLoggedUser(note.author)) { + Text( + text = eventContent, + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } else { + val tags = remember(note) { note.event?.tags?.toImmutableListOfLists() ?: EmptyTagList } + val callbackUri = remember(note) { note.toNostrUri() } + + SensitivityWarning( + note = note, + accountViewModel = accountViewModel, + ) { + TranslatableRichTextViewer( + content = eventContent, + canPreview = canPreview && !makeItShort, + quotesLeft = quotesLeft, + modifier = Modifier.fillMaxWidth(), + tags = tags, + backgroundColor = backgroundColor, + id = note.idHex, + callbackUri = callbackUri, + accountViewModel = accountViewModel, + nav = nav, + ) + + ZapPollNote( + note, + canPreview = canPreview && !makeItShort, + backgroundColor, + accountViewModel, + nav, + ) + } + + if (noteEvent.hasHashtags()) { + DisplayUncitedHashtags(noteEvent, eventContent, callbackUri, accountViewModel, nav) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt index 4f8368962..d01366fe1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/TopNavFilterState.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.model.ALL_USER_FOLLOWS import com.vitorpamplona.amethyst.model.AROUND_ME import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.CHESS import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.service.checkNotInMainThread @@ -52,8 +53,12 @@ import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent 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.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf @@ -126,6 +131,20 @@ class TopNavFilterState( unpackList = listOf(MuteListEvent.blockListFor(account.userProfile().pubkeyHex)), ) + val chessFollow = + GlobalFeedDefinition( + code = CHESS, + name = ResourceName(R.string.follow_list_chess), + type = CodeNameType.HARDCODED, + kinds = + listOf( + ChessGameEvent.KIND, // Completed games (Kind 64) + LiveChessGameChallengeEvent.KIND, // Challenges (Kind 30064) + LiveChessGameEndEvent.KIND, // Game endings (Kind 30067) + // Note: LiveChessMoveEvent (Kind 30066) intentionally excluded - too noisy + ), + ) + val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow) fun mergePeopleLists( @@ -386,6 +405,7 @@ val DEFAULT_FEED_KINDS = ClassifiedsEvent.KIND, LongTextNoteEvent.KIND, PollNoteEvent.KIND, + PollEvent.KIND, HighlightEvent.KIND, AudioTrackEvent.KIND, AudioHeaderEvent.KIND, @@ -406,6 +426,7 @@ val DEFAULT_COMMUNITY_FEEDS = HighlightEvent.KIND, AudioHeaderEvent.KIND, AudioTrackEvent.KIND, + PollEvent.KIND, PinListEvent.KIND, WikiNoteEvent.KIND, NipTextEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt index c1c2eca36..647a4f905 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountViewModel.kt @@ -91,6 +91,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker import com.vitorpamplona.quartz.nip01Core.relay.client.auth.EmptyIAuthStatus @@ -1480,21 +1481,17 @@ class AccountViewModel( fun getInteractiveStoryReadingState(dATag: String): AddressableNote = LocalCache.getOrCreateAddressableNote(InteractiveStoryReadingStateEvent.createAddress(account.signer.pubKey, dATag)) fun updateInteractiveStoryReadingState( - root: InteractiveStoryBaseEvent, - readingScene: InteractiveStoryBaseEvent, + rootHint: EventHintBundle, + readingSceneHint: EventHintBundle, ) { launchSigner { - val sceneNoteRelayHint = LocalCache.getOrCreateAddressableNote(readingScene.address()).relayHintUrl() - - val readingState = getInteractiveStoryReadingState(root.addressTag()) + val readingState = getInteractiveStoryReadingState(rootHint.event.addressTag()) val readingStateEvent = readingState.event as? InteractiveStoryReadingStateEvent if (readingStateEvent != null) { - account.updateInteractiveStoryReadingState(readingStateEvent, readingScene, sceneNoteRelayHint) + account.updateInteractiveStoryReadingState(readingStateEvent, readingSceneHint) } else { - val rootNoteRelayHint = LocalCache.getOrCreateAddressableNote(root.address()).relayHintUrl() - - account.createInteractiveStoryReadingState(root, rootNoteRelayHint, readingScene, sceneNoteRelayHint) + account.createInteractiveStoryReadingState(rootHint, readingSceneHint) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index b9f35449b..f2cd40249 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -82,7 +82,6 @@ import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton @@ -284,9 +283,7 @@ fun GroupDMScreenContent( context, onceUploaded = { }, ) - if (server.type != ServerType.NIP95) { - accountViewModel.account.settings.changeDefaultFileServer(server) - } + accountViewModel.account.settings.changeDefaultFileServer(server) }, onDelete = { postViewModel.uploadState?.deleteMediaToUpload(it) }, onCancel = uploading::reset, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt index 6d46f1e0a..1dca30b9f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/upload/ChatFileUploadDialog.kt @@ -28,7 +28,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.NonClickableUserPictures import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -70,9 +69,7 @@ fun RoomChatFileUploadDialog( onceUploaded = onUpload, ) - if (state.selectedServer.type != ServerType.NIP95) { - accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer) - } + accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer) }, onCancel, accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt index 1888c8b5e..2ef6776d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/send/ChannelFileUploadDialog.kt @@ -36,7 +36,6 @@ import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.UserPicture @@ -131,9 +130,7 @@ fun ChannelFileUploadDialog( onceUploaded = onUpload, ) - if (state.selectedServer.type != ServerType.NIP95) { - accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer) - } + accountViewModel.account.settings.changeDefaultFileServer(state.selectedServer) }, onCancel, accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt index 2c28ba396..de7626e47 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/utils/ChatFileUploadDialog.kt @@ -55,7 +55,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.ui.components.TextSpinner @@ -145,18 +144,14 @@ private fun ImageVideoPostChat( fileUploadState: ChatFileUploadState, accountViewModel: AccountViewModel, ) { - val fileServers by accountViewModel.account.serverLists.liveServerList + val fileServers by accountViewModel.account.blossomServers.hostNameFlow .collectAsState() val fileServerOptions = remember(fileServers) { fileServers - .mapNotNull { - if (it.type != ServerType.NIP95) { - TitleExplainer(it.name, it.baseUrl) - } else { - null - } + .map { + TitleExplainer(it.name, it.baseUrl) }.toImmutableList() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt new file mode 100644 index 000000000..18c639ec4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/AndroidChessAdapter.kt @@ -0,0 +1,408 @@ +/* + * 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.ui.screen.loggedIn.chess + +import com.vitorpamplona.amethyst.commons.chess.ChessConfig +import com.vitorpamplona.amethyst.commons.chess.ChessEventBroadcaster +import com.vitorpamplona.amethyst.commons.chess.ChessEventPublisher +import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetchHelper +import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetcher +import com.vitorpamplona.amethyst.commons.chess.IUserMetadataProvider +import com.vitorpamplona.amethyst.commons.chess.RelayFetchProgress +import com.vitorpamplona.amethyst.commons.chess.RelayGameSummary +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder +import com.vitorpamplona.amethyst.model.Account +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 + +/** + * Android implementation of ChessEventPublisher using Jester protocol. + * Wraps account.signAndComputeBroadcast() for event publishing. + * + * Jester Protocol: + * - All chess events use kind 30 + * - publishStart creates a start event (content.kind=0) + * - publishMove creates a move event (content.kind=1) with full history + * - publishGameEnd creates a move event with result in content + */ +class AndroidChessPublisher( + private val account: Account, +) : ChessEventPublisher { + private val broadcaster = ChessEventBroadcaster(account.client) + + /** + * Broadcast an event to the dedicated chess relays with reliable delivery. + * Uses ChessEventBroadcaster to ensure relay connections before sending. + */ + private suspend fun broadcastToChessRelays(event: com.vitorpamplona.quartz.nip01Core.core.Event): Boolean { + println("[AndroidChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster") + val result = broadcaster.broadcast(event) + println("[AndroidChessPublisher] Broadcast result: ${result.message}") + return result.success + } + + /** + * Publish a game start event (challenge). + * Returns the startEventId (event ID) if successful. + */ + override suspend fun publishStart( + playerColor: Color, + opponentPubkey: String?, + ): String? = + try { + val template = + if (opponentPubkey != null) { + JesterEvent.buildPrivateStart( + opponentPubkey = opponentPubkey, + playerColor = playerColor, + ) + } else { + JesterEvent.buildStart( + playerColor = playerColor, + ) + } + val signedEvent = account.signer.sign(template) + // Broadcast to chess relays with reliable delivery + val success = broadcastToChessRelays(signedEvent) + // Also add to local cache + account.cache.justConsumeMyOwnEvent(signedEvent) + if (success) signedEvent.id else null + } catch (e: Exception) { + println("[AndroidChessPublisher] publishStart failed: ${e.message}") + null + } + + /** + * Publish a move event. + * Returns the move event ID if successful. + */ + override suspend fun publishMove(move: ChessMoveEvent): String? = + try { + val template = + JesterEvent.buildMove( + startEventId = move.startEventId, + headEventId = move.headEventId, + move = move.san, + fen = move.fen, + history = move.history, + opponentPubkey = move.opponentPubkey, + ) + val signedEvent = account.signer.sign(template) + // Broadcast to chess relays with reliable delivery + val success = broadcastToChessRelays(signedEvent) + // Also add to local cache + account.cache.justConsumeMyOwnEvent(signedEvent) + if (success) signedEvent.id else null + } catch (e: Exception) { + println("[AndroidChessPublisher] publishMove failed: ${e.message}") + null + } + + /** + * Publish a game end event (includes result in content). + */ + override suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean = + try { + val template = + JesterEvent.buildEndMove( + startEventId = gameEnd.startEventId, + headEventId = gameEnd.headEventId, + move = gameEnd.lastMove, + fen = gameEnd.fen, + history = gameEnd.history, + opponentPubkey = gameEnd.opponentPubkey, + result = gameEnd.result, + termination = gameEnd.termination, + ) + val signedEvent = account.signer.sign(template) + // Broadcast to chess relays with reliable delivery + val success = broadcastToChessRelays(signedEvent) + // Also add to local cache + account.cache.justConsumeMyOwnEvent(signedEvent) + success + } catch (e: Exception) { + println("[AndroidChessPublisher] publishGameEnd failed: ${e.message}") + false + } + + override fun getWriteRelayCount(): Int = ChessConfig.CHESS_RELAYS.size +} + +/** + * Android implementation of ChessRelayFetcher using Jester protocol. + * + * Uses direct one-shot relay fetching for reliability: + * - fetchGameEvents: fetches all events for a specific game + * - fetchChallenges: fetches start events (challenges) + * - fetchUserGameIds: discovers games where user is a participant + * - fetchRecentGames: fetches recent games for spectating + * + * Each fetch opens a temporary subscription, waits for EOSE, and returns results. + */ +class AndroidRelayFetcher( + private val account: Account, +) : ChessRelayFetcher { + private val fetchHelper = ChessRelayFetchHelper(account.client) + private val userPubkey = account.userProfile().pubkeyHex + + /** + * Get the normalized chess relay URLs. + * Always uses the 3 dedicated chess relays from ChessConfig. + */ + private fun chessRelayUrls(): List = + ChessConfig.CHESS_RELAYS.map { + com.vitorpamplona.quartz.nip01Core.relay.normalizer + .NormalizedRelayUrl(it) + } + + override fun getRelayUrls(): List { + println("[AndroidRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays") + return ChessConfig.CHESS_RELAYS + } + + /** + * Fetch game events directly from relays. + * Uses one-shot fetch for reliability. + * + * Uses TWO filters: + * 1. Fetch start event by ID (start events don't have #e tag to themselves) + * 2. Fetch moves that reference the start event via #e tag + */ + override suspend fun fetchGameEvents(startEventId: String): JesterGameEvents { + println("[AndroidRelayFetcher] fetchGameEvents: fetching from relays for startEventId=$startEventId") + + // Filter 1: Fetch the start event by its ID + val startEventFilter = + com.vitorpamplona.quartz.nip01Core.relay.filters.Filter( + ids = listOf(startEventId), + kinds = listOf(JesterProtocol.KIND), + ) + + // Filter 2: Fetch moves that reference the start event + val movesFilter = ChessFilterBuilder.gameEventsFilter(startEventId) + + // Combine both filters + val relayFilters = chessRelayUrls().associateWith { listOf(startEventFilter, movesFilter) } + val events = fetchHelper.fetchEvents(relayFilters) + + var startEvent: JesterEvent? = null + val moves = mutableListOf() + + events.forEach { event -> + if (event.kind != JesterProtocol.KIND) return@forEach + val jesterEvent = event.toJesterEvent() ?: return@forEach + + if (jesterEvent.isStartEvent() && jesterEvent.id == startEventId) { + startEvent = jesterEvent + } else if (jesterEvent.isMoveEvent() && jesterEvent.startEventId() == startEventId) { + moves.add(jesterEvent) + } + } + + println("[AndroidRelayFetcher] fetchGameEvents: found start=${startEvent != null}, moves=${moves.size}") + + return JesterGameEvents( + startEvent = startEvent, + moves = moves, + ) + } + + /** + * Fetch challenges (start events) directly from relays. + * Uses one-shot fetch for reliability. + */ + override suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)?): List { + val relays = chessRelayUrls() + println("[AndroidRelayFetcher] fetchChallenges: fetching from ${relays.size} relays: $relays") + + if (relays.isEmpty()) { + println("[AndroidRelayFetcher] fetchChallenges: WARNING - no connected relays!") + return emptyList() + } + + val filter = ChessFilterBuilder.challengesFilter(userPubkey) + println("[AndroidRelayFetcher] fetchChallenges: filter kinds=${filter.kinds}, tags=${filter.tags}, since=${filter.since}, limit=${filter.limit}") + println("[AndroidRelayFetcher] fetchChallenges: filter JSON=${filter.toJson()}") + + val relayFilters = relays.associateWith { listOf(filter) } + val events = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress) + println("[AndroidRelayFetcher] fetchChallenges: received ${events.size} raw events") + + // Debug: If no events, also try without #e filter to see if relays have ANY kind 30 events + if (events.isEmpty()) { + println("[AndroidRelayFetcher] DEBUG: No events with #e filter, trying without tag filter...") + val debugFilter = ChessFilterBuilder.recentGamesFilter() + val debugFilters = relays.associateWith { listOf(debugFilter) } + val debugEvents = fetchHelper.fetchEvents(debugFilters, timeoutMs = 10_000) + println("[AndroidRelayFetcher] DEBUG: recentGamesFilter (no #e) returned ${debugEvents.size} events") + debugEvents.take(5).forEach { e -> + println("[AndroidRelayFetcher] DEBUG: kind=${e.kind}, id=${e.id.take(8)}, tags=${e.tags.take(3).map { it.toList() }}") + } + } + + val challenges = mutableListOf() + + events.forEach { event -> + if (event.kind != JesterProtocol.KIND) return@forEach + val jesterEvent = event.toJesterEvent() ?: return@forEach + + if (!jesterEvent.isStartEvent()) return@forEach + + // Include challenges that are: + // 1. Open challenges (no specific opponent) + // 2. Directed at us + // 3. Created by us + val isRelevant = + jesterEvent.opponentPubkey() == null || + jesterEvent.opponentPubkey() == userPubkey || + jesterEvent.pubKey == userPubkey + + if (isRelevant) { + challenges.add(jesterEvent) + } + } + + println("[AndroidRelayFetcher] fetchChallenges: found ${challenges.size} challenges from ${events.size} events") + return challenges + } + + /** + * Fetch recent games for spectating. + */ + override suspend fun fetchRecentGames(): List { + val filter = ChessFilterBuilder.recentGamesFilter() + val relayFilters = chessRelayUrls().associateWith { listOf(filter) } + val events = fetchHelper.fetchEvents(relayFilters) + + // Convert to JesterEvents + val jesterEvents = + events + .filter { it.kind == JesterProtocol.KIND } + .mapNotNull { it.toJesterEvent() } + + // Group by game (startEventId for moves, id for start events) + val startEventsById = + jesterEvents + .filter { it.isStartEvent() } + .associateBy { it.id } + + val movesByGameId = + jesterEvents + .filter { it.isMoveEvent() } + .groupBy { it.startEventId() ?: "" } + .filterKeys { it.isNotEmpty() } + + // Get all game IDs (from start events and moves) + val allGameIds = startEventsById.keys + movesByGameId.keys + + return allGameIds.mapNotNull { startEventId -> + val startEvent = startEventsById[startEventId] ?: return@mapNotNull null + val moves = movesByGameId[startEventId] ?: emptyList() + + val challengerColor = startEvent.playerColor() ?: Color.WHITE + + // Determine players from start event and moves + val challengerPubkey = startEvent.pubKey + val opponentFromMoves = moves.firstOrNull { it.pubKey != challengerPubkey }?.pubKey + val opponentPubkey = startEvent.opponentPubkey() ?: opponentFromMoves ?: return@mapNotNull null + + val (whitePubkey, blackPubkey) = + if (challengerColor == Color.WHITE) { + challengerPubkey to opponentPubkey + } else { + opponentPubkey to challengerPubkey + } + + val lastMove = moves.maxByOrNull { it.history().size } + val hasEnded = lastMove?.result() != null + + RelayGameSummary( + startEventId = startEventId, + whitePubkey = whitePubkey, + blackPubkey = blackPubkey, + moveCount = lastMove?.history()?.size ?: 0, + lastMoveTime = lastMove?.createdAt ?: startEvent.createdAt, + isActive = !hasEnded, + ) + } + } + + /** + * Fetch user's game IDs directly from relays. + * Uses one-shot fetch for reliability. + */ + override suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)?): Set { + println("[AndroidRelayFetcher] fetchUserGameIds: fetching from relays") + + // Fetch events authored by user AND events tagging user + val authoredFilter = ChessFilterBuilder.userGamesFilter(userPubkey) + val taggedFilter = ChessFilterBuilder.userTaggedFilter(userPubkey) + + val relayFilters = + chessRelayUrls().associateWith { + listOf(authoredFilter, taggedFilter) + } + + val events = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress) + + val gameIds = mutableSetOf() + + events.forEach { event -> + if (event.kind != JesterProtocol.KIND) return@forEach + val jesterEvent = event.toJesterEvent() ?: return@forEach + + // Check if user is involved (author or tagged) + val isUserInvolved = jesterEvent.pubKey == userPubkey || jesterEvent.opponentPubkey() == userPubkey + if (!isUserInvolved) return@forEach + + if (jesterEvent.isStartEvent()) { + gameIds.add(jesterEvent.id) + } else if (jesterEvent.isMoveEvent()) { + jesterEvent.startEventId()?.let { gameIds.add(it) } + } + } + + println("[AndroidRelayFetcher] fetchUserGameIds: found ${gameIds.size} game IDs from ${events.size} events") + return gameIds + } +} + +/** + * Android implementation of IUserMetadataProvider. + * Wraps LocalCache.getOrCreateUser() for user metadata lookup. + */ +class AndroidMetadataProvider : IUserMetadataProvider { + override fun getDisplayName(pubkey: String): String { + val user = LocalCache.getOrCreateUser(pubkey) + return user.toBestDisplayName() + } + + override fun getPictureUrl(pubkey: String): String? { + val user = LocalCache.getOrCreateUser(pubkey) + return user.profilePicture() + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt new file mode 100644 index 000000000..f5e5c86f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt @@ -0,0 +1,430 @@ +/* + * 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.ui.screen.loggedIn.chess + +import androidx.compose.foundation.layout.Arrangement +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.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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Circle +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +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.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastBanner +import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus +import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner +import com.vitorpamplona.amethyst.commons.chess.LiveChessGameScreen +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator + +/** + * Wrapper screen for live chess game + * + * Connects ChessViewModel to LiveChessGameScreen UI component + * + * @param gameId Unique game identifier + * @param accountViewModel Account view model + * @param nav Navigation interface + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChessGameScreen( + gameId: String, + accountViewModel: AccountViewModel, + nav: INav, +) { + // Scope ViewModel to Activity so it's shared between lobby and game screens + val activity = LocalContext.current as FragmentActivity + val chessViewModel: ChessViewModelNew = + viewModel( + viewModelStoreOwner = activity, + key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", + factory = + ChessViewModelFactory( + accountViewModel.account, + ), + ) + + val activeGames by chessViewModel.activeGames.collectAsState() + val spectatingGames by chessViewModel.spectatingGames.collectAsState() + val error by chessViewModel.error.collectAsState() + val broadcastStatus by chessViewModel.broadcastStatus.collectAsState() + val syncStatus by chessViewModel.syncStatus.collectAsState() + // Observe state version to force recomposition when game state changes internally + val stateVersion by chessViewModel.stateVersion.collectAsState() + var isLoading by remember { mutableStateOf(true) } + var loadAttempted by remember { mutableStateOf(false) } + var showRelaySettings by remember { mutableStateOf(false) } + + // Always read directly from maps for auto-refresh support + // stateVersion changes force recomposition, then we get latest from maps + val gameState = activeGames[gameId] ?: spectatingGames[gameId] + + // Get relay information + val outboxRelays by accountViewModel.account.outboxRelays.flow + .collectAsState() + val writeRelays = + remember(outboxRelays) { + outboxRelays.map { it.toString() } + } + + // Subscribe to chess events when game screen is visible + // This is critical - without it, no new events will arrive from relays + ChessSubscription(accountViewModel, chessViewModel) + + // Handle initial game loading + // Once game is in maps, gameState above will always have the latest + LaunchedEffect(gameId, gameState) { + if (gameState != null) { + // Game found in maps - done loading + isLoading = false + loadAttempted = true + } else if (!loadAttempted) { + // Check if this game was accepted locally (prevents incorrect spectator detection) + val wasAccepted = chessViewModel.wasAccepted(gameId) + + // Brief delay to allow acceptChallenge coroutine to complete + // This handles the race condition where navigation happens before + // the game state is fully propagated to StateFlow collectors + kotlinx.coroutines.delay(150) + + // Check again after delay - game may have been added by acceptChallenge + val stateAfterDelay = chessViewModel.getGameState(gameId) + if (stateAfterDelay != null) { + isLoading = false + loadAttempted = true + } else if (wasAccepted) { + // Game was accepted but not yet in StateFlow - keep waiting, don't fetch from relays + // The state will propagate and trigger this LaunchedEffect again + isLoading = true + } else { + // Game not found locally and not accepted, try to load from relays + loadAttempted = true + chessViewModel.loadGame(gameId) + isLoading = false + } + } + } + + // Set focused game mode when screen is visible (only poll this game) + // ViewModel already starts polling in init, so no need to start here + DisposableEffect(gameId) { + chessViewModel.setFocusedGame(gameId) // Focus on just this game + onDispose { + // Don't clear focus here - let lobby handle it when we navigate back + } + } + + // Extract human-readable game name + val gameName = + remember(gameId) { + ChessGameNameGenerator.extractDisplayName(gameId) ?: "Chess Game" + } + + Scaffold( + topBar = { + Column { + TopAppBar( + title = { Text(gameName) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + actions = { + IconButton(onClick = { showRelaySettings = true }) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Relay Settings", + ) + } + }, + ) + + // Sync status banner + ChessSyncBanner( + status = syncStatus, + onRetry = { chessViewModel.forceRefresh() }, + ) + + // Broadcast status banner (shows when publishing moves) + ChessBroadcastBanner( + status = broadcastStatus, + onTap = { + when (broadcastStatus) { + is ChessBroadcastStatus.Failed -> { + // Could implement retry logic here + } + + is ChessBroadcastStatus.Desynced -> { + chessViewModel.forceRefresh() + } + + else -> { } + } + }, + ) + } + }, + ) { paddingValues -> + when { + isLoading -> { + // Loading state + Box( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + contentAlignment = Alignment.Center, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + CircularProgressIndicator() + Spacer(modifier = Modifier.height(16.dp)) + Text("Loading game...") + } + } + } + + gameState == null -> { + // Game not found - show error with back button + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + text = "Game Not Found", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Show specific error if available + Text( + text = error ?: "This game may have ended or is waiting for opponent.", + style = MaterialTheme.typography.bodyMedium, + color = if (error != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Game ID: ${gameId.take(16)}...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + modifier = Modifier.padding(end = 8.dp), + ) + Text("Go Back") + } + } + } + + else -> { + // Resolve opponent display name + val opponentDisplayName = + remember(gameState.opponentPubkey) { + accountViewModel.checkGetOrCreateUser(gameState.opponentPubkey)?.toBestDisplayName() + ?: gameState.opponentPubkey.take(8) + } + + // Determine spectator status: + // 1. If game was accepted locally, user is definitely NOT a spectator + // 2. Otherwise, check which map the game is in + val wasAccepted = chessViewModel.wasAccepted(gameId) + val isSpectating = !wasAccepted && spectatingGames.containsKey(gameId) + + // Show game with proper padding for status bar + // Use state-observing version for automatic refresh on polling updates + LiveChessGameScreen( + modifier = Modifier.padding(paddingValues), + gameState = gameState, + opponentName = opponentDisplayName, + onMoveMade = { from, to, san -> + chessViewModel.publishMove(gameId, from, to) + }, + onResign = { chessViewModel.resign(gameId) }, + isSpectatorOverride = isSpectating, + ) + } + } + } + + // Relay settings bottom sheet + if (showRelaySettings) { + ModalBottomSheet( + onDismissRequest = { showRelaySettings = false }, + sheetState = rememberModalBottomSheetState(), + ) { + RelaySettingsSheet( + writeRelays = writeRelays, + gameId = gameId, + ) + } + } +} + +/** + * Bottom sheet showing relay settings for the chess game + */ +@Composable +private fun RelaySettingsSheet( + writeRelays: List, + gameId: String, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Text( + text = "Chess Game Relays", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = "Moves are broadcast to these relays:", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + if (writeRelays.isEmpty()) { + Text( + text = "No write relays configured", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(writeRelays) { relay -> + RelayItem(relayUrl = relay, isConnected = true) + } + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + HorizontalDivider() + + Spacer(modifier = Modifier.height(16.dp)) + + Text( + text = "Game ID", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(4.dp)) + + Text( + text = gameId, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(32.dp)) + } +} + +@Composable +private fun RelayItem( + relayUrl: String, + isConnected: Boolean, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = if (isConnected) Icons.Default.CheckCircle else Icons.Default.Circle, + contentDescription = if (isConnected) "Connected" else "Disconnected", + tint = if (isConnected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error, + modifier = Modifier.size(16.dp), + ) + + Text( + text = relayUrl.removePrefix("wss://").removePrefix("ws://"), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.weight(1f), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt new file mode 100644 index 000000000..2062caca8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt @@ -0,0 +1,803 @@ +/* + * 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.ui.screen.loggedIn.chess + +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.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.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +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.ArrowBack +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastBanner +import com.vitorpamplona.amethyst.commons.chess.ChessChallenge +import com.vitorpamplona.amethyst.commons.chess.ChessConfig +import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner +import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog +import com.vitorpamplona.amethyst.commons.chess.PublicGame +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor + +/** + * Chess lobby screen showing challenges and active games + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChessLobbyScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + // Scope ViewModel to Activity so it's shared between lobby and game screens + val activity = LocalContext.current as FragmentActivity + val chessViewModel: ChessViewModelNew = + viewModel( + viewModelStoreOwner = activity, + key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", + factory = ChessViewModelFactory(accountViewModel.account), + ) + + val activeGames by chessViewModel.activeGames.collectAsState() + val spectatingGames by chessViewModel.spectatingGames.collectAsState() + val publicGames by chessViewModel.publicGames.collectAsState() + val challenges by chessViewModel.challenges.collectAsState() + val error by chessViewModel.error.collectAsState() + val broadcastStatus by chessViewModel.broadcastStatus.collectAsState() + val syncStatus by chessViewModel.syncStatus.collectAsState() + val selectedGameId by chessViewModel.selectedGameId.collectAsState() + val userPubkey = accountViewModel.account.userProfile().pubkeyHex + + var showNewGameDialog by remember { mutableStateOf(false) } + var showRelaySettings by remember { mutableStateOf(false) } + var isRefreshing by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + + // Get relay information for settings display + val inboxRelays by accountViewModel.account.notificationRelays.flow + .collectAsState() + val outboxRelays by accountViewModel.account.outboxRelays.flow + .collectAsState() + val globalRelays by accountViewModel.account.defaultGlobalRelays.flow + .collectAsState() + + // Subscribe to chess events when screen is visible + ChessSubscription(accountViewModel, chessViewModel) + + // Clear any stale game selection on mount + LaunchedEffect(Unit) { + chessViewModel.selectGame(null) + } + + // Auto-navigate when a game is selected (e.g., after accepting a challenge) + LaunchedEffect(selectedGameId, activeGames) { + selectedGameId?.let { gameId -> + if (activeGames.containsKey(gameId)) { + nav.nav(Route.ChessGame(gameId)) + chessViewModel.selectGame(null) + } + } + } + + // Set lobby mode (poll all games) when screen is visible + // Don't stop polling in onDispose - ViewModel lifecycle handles that + // This prevents the race where lobby's onDispose fires AFTER game screen enters + DisposableEffect(Unit) { + chessViewModel.clearFocusedGame() // Clear any focused game from game screen + chessViewModel.startPolling() + onDispose { + // Don't stop polling here - it causes a race condition: + // 1. Game screen enters and sets focused mode + // 2. Lobby's onDispose fires and stops ALL polling + // ViewModel.onCleared() will stop polling when user leaves chess entirely + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.route_chess)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = "Back", + ) + } + }, + actions = { + IconButton(onClick = { showRelaySettings = true }) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = "Relay Settings", + ) + } + }, + ) + }, + floatingActionButton = { + FloatingActionButton( + onClick = { showNewGameDialog = true }, + ) { + Icon(Icons.Default.Add, contentDescription = "New Game") + } + }, + ) { paddingValues -> + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { + scope.launch { + isRefreshing = true + chessViewModel.forceRefresh() + delay(500) // Brief delay for visual feedback + isRefreshing = false + } + }, + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(horizontal = 16.dp), + ) { + // Sync status banner (shows relay progress during loading) + ChessSyncBanner( + status = syncStatus, + onRetry = { chessViewModel.forceRefresh() }, + modifier = Modifier.padding(bottom = 8.dp), + ) + + // Broadcast status banner + ChessBroadcastBanner( + status = broadcastStatus, + onTap = { /* no-op in lobby */ }, + modifier = Modifier.padding(bottom = 8.dp), + ) + + // Error display + error?.let { errorMsg -> + Card( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(errorMsg, color = MaterialTheme.colorScheme.onErrorContainer) + OutlinedButton(onClick = { chessViewModel.clearError() }) { + Text("Dismiss") + } + } + } + } + + // Main content + ChessLobbyContent( + challenges = challenges, + activeGames = activeGames, + spectatingGames = spectatingGames, + publicGames = publicGames, + userPubkey = userPubkey, + accountViewModel = accountViewModel, + onAcceptChallenge = { challenge -> + chessViewModel.acceptChallenge(challenge) + }, + onOpenOwnChallenge = { challenge -> + chessViewModel.openOwnChallenge(challenge) + }, + onSelectGame = { gameId -> + nav.nav(Route.ChessGame(gameId)) + }, + onWatchGame = { gameId -> + chessViewModel.loadGameAsSpectator(gameId) + nav.nav(Route.ChessGame(gameId)) + }, + ) + } + } + } + + // New game dialog + if (showNewGameDialog) { + NewChessGameDialog( + onDismiss = { showNewGameDialog = false }, + onCreateGame = { opponentPubkey, color -> + chessViewModel.createChallenge(opponentPubkey, color) + showNewGameDialog = false + }, + ) + } + + // Relay settings bottom sheet + if (showRelaySettings) { + ModalBottomSheet( + onDismissRequest = { showRelaySettings = false }, + sheetState = rememberModalBottomSheetState(), + ) { + ChessRelaySettingsSheet( + inboxRelays = inboxRelays.map { it.toString() }, + outboxRelays = outboxRelays.map { it.toString() }, + globalRelays = globalRelays.map { it.toString() }, + challengeCount = challenges.size, + publicGameCount = publicGames.size, + ) + } + } +} + +@Composable +fun ChessLobbyContent( + challenges: List, + activeGames: Map, + spectatingGames: Map, + publicGames: List, + userPubkey: String, + accountViewModel: AccountViewModel, + onAcceptChallenge: (ChessChallenge) -> Unit, + onOpenOwnChallenge: (ChessChallenge) -> Unit, + onSelectGame: (String) -> Unit, + onWatchGame: (String) -> Unit, +) { + val hasContent = + activeGames.isNotEmpty() || spectatingGames.isNotEmpty() || + publicGames.isNotEmpty() || challenges.isNotEmpty() + + if (!hasContent) { + // Empty state - use LazyColumn so pull-to-refresh works + LazyColumn( + modifier = Modifier.fillMaxSize(), + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + ) { + item { + Text( + "No games or challenges", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Text( + "Create a new game to get started", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Text( + "Pull down to refresh", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } + return + } + + // Track outgoing challenges to scroll to top when a new one is created + val outgoingChallengesCount = challenges.count { it.isFrom(userPubkey) } + val listState = rememberLazyListState() + + // Scroll to top when user creates a new challenge + LaunchedEffect(outgoingChallengesCount) { + if (outgoingChallengesCount > 0) { + listState.animateScrollToItem(0) + } + } + + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Active games section (user is participant) + if (activeGames.isNotEmpty()) { + item { + Text( + "Your Games", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) -> + val displayName = + remember(state.opponentPubkey) { + accountViewModel.checkGetOrCreateUser(state.opponentPubkey)?.toBestDisplayName() ?: state.opponentPubkey.take(8) + } + com.vitorpamplona.amethyst.commons.chess.ActiveGameCard( + gameId = gameId, + opponentName = displayName, + isYourTurn = state.isPlayerTurn(), + onClick = { onSelectGame(gameId) }, + ) + } + } + + // User's outgoing challenges (right after active games - user wants to see their new challenges) + val outgoingChallenges = challenges.filter { it.isFrom(userPubkey) } + if (outgoingChallenges.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Your Challenges", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(outgoingChallenges, key = { "outgoing-${it.eventId}" }) { challenge -> + val opponentName = + challenge.opponentPubkey?.let { pubkey -> + accountViewModel.checkGetOrCreateUser(pubkey)?.toBestDisplayName() ?: pubkey.take(8) + } + com.vitorpamplona.amethyst.commons.chess.OutgoingChallengeCard( + opponentName = opponentName, + userPlaysWhite = challenge.challengerColor == ChessColor.WHITE, + onClick = { onOpenOwnChallenge(challenge) }, + ) + } + } + + // Games user is spectating + if (spectatingGames.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Watching", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(spectatingGames.entries.toList(), key = { "spectating-${it.key}" }) { (gameId, state) -> + val moveCount = + state.moveHistory + .collectAsState() + .value.size + com.vitorpamplona.amethyst.commons.chess.SpectatingGameCard( + moveCount = moveCount, + onClick = { onSelectGame(gameId) }, + ) + } + } + + // Incoming challenges (directed at user) + val incomingChallenges = challenges.filter { it.isDirectedAt(userPubkey) } + if (incomingChallenges.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Incoming Challenges", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(incomingChallenges, key = { "incoming-${it.eventId}" }) { challenge -> + val displayName = + remember(challenge.challengerPubkey, challenge.challengerDisplayName) { + challenge.challengerDisplayName + ?: accountViewModel.checkGetOrCreateUser(challenge.challengerPubkey)?.toBestDisplayName() + ?: challenge.challengerPubkey.take(8) + } + com.vitorpamplona.amethyst.commons.chess.ChallengeCard( + challengerName = displayName, + challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, + isIncoming = true, + onAccept = { onAcceptChallenge(challenge) }, + ) + } + } + + // Open challenges from others (can join) + val openChallenges = challenges.filter { it.isOpen && !it.isFrom(userPubkey) } + if (openChallenges.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Open Challenges", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(openChallenges, key = { "open-${it.eventId}" }) { challenge -> + val displayName = + remember(challenge.challengerPubkey, challenge.challengerDisplayName) { + challenge.challengerDisplayName + ?: accountViewModel.checkGetOrCreateUser(challenge.challengerPubkey)?.toBestDisplayName() + ?: challenge.challengerPubkey.take(8) + } + com.vitorpamplona.amethyst.commons.chess.ChallengeCard( + challengerName = displayName, + challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, + isIncoming = false, + onAccept = { onAcceptChallenge(challenge) }, + ) + } + } + + // Live games to watch (public games user is not part of) + if (publicGames.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Live Games", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(publicGames, key = { "public-${it.gameId}" }) { game -> + val whiteName = game.whiteDisplayName ?: game.whitePubkey.take(8) + val blackName = game.blackDisplayName ?: game.blackPubkey.take(8) + com.vitorpamplona.amethyst.commons.chess.PublicGameCard( + whiteName = whiteName, + blackName = blackName, + moveCount = game.moveCount, + onWatch = { onWatchGame(game.gameId) }, + ) + } + } + + // Bottom padding for FAB + item { + Spacer(Modifier.height(80.dp)) + } + } +} + +/** + * Bottom sheet showing relay settings and debug info for chess subscriptions + */ +@Composable +private fun ChessRelaySettingsSheet( + inboxRelays: List, + outboxRelays: List, + globalRelays: List, + challengeCount: Int, + publicGameCount: Int, + connectedRelayCount: Int = 0, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp) + .verticalScroll(rememberScrollState()), + ) { + Text( + text = "Chess Relay Settings", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(16.dp)) + + // Stats section + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + ) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "$challengeCount", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "Challenges", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "$publicGameCount", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Text( + text = "Live Games", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + // Chess relay info + Text( + text = "Active Chess Relays", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Chess uses 3 dedicated relays for fast, reliable queries", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + + // Show chess relays and their connection status + ChessConfig.CHESS_RELAY_NAMES.forEach { relay -> + val isConnected = + inboxRelays.any { it.contains(relay) } || + outboxRelays.any { it.contains(relay) } || + globalRelays.any { it.contains(relay) } + ChessRelayRow(relayUrl = "wss://$relay/", isConnected = isConnected) + } + + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider() + Spacer(modifier = Modifier.height(16.dp)) + + // Inbox relays (where challenges TO you are fetched) + Text( + text = "Inbox Relays (${inboxRelays.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Personal challenges are fetched from here", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + if (inboxRelays.isEmpty()) { + Text( + text = "No inbox relays configured", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } else { + inboxRelays.take(5).forEach { relay -> + RelayRow(relayUrl = relay) + } + if (inboxRelays.size > 5) { + Text( + text = "+${inboxRelays.size - 5} more", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Global relays (where open challenges are fetched) + Text( + text = "Global Relays (${globalRelays.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Open challenges and public games are fetched from here", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + if (globalRelays.isEmpty()) { + Text( + text = "No global relays configured - open challenges won't load!", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } else { + globalRelays.take(5).forEach { relay -> + RelayRow(relayUrl = relay) + } + if (globalRelays.size > 5) { + Text( + text = "+${globalRelays.size - 5} more", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Outbox relays (where your challenges are published) + Text( + text = "Outbox Relays (${outboxRelays.size})", + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = "Your challenges and moves are published here", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(8.dp)) + if (outboxRelays.isEmpty()) { + Text( + text = "No outbox relays configured", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } else { + outboxRelays.take(5).forEach { relay -> + RelayRow(relayUrl = relay) + } + if (outboxRelays.size > 5) { + Text( + text = "+${outboxRelays.size - 5} more", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + Spacer(modifier = Modifier.height(32.dp)) + } +} + +@Composable +private fun RelayRow( + relayUrl: String, + isPreferred: Boolean = false, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 2.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.CheckCircle, + contentDescription = "Connected", + tint = + if (isPreferred) { + MaterialTheme.colorScheme.tertiary + } else { + MaterialTheme.colorScheme.primary + }, + modifier = Modifier.size(12.dp), + ) + Text( + text = relayUrl.removePrefix("wss://").removePrefix("ws://"), + style = MaterialTheme.typography.bodySmall, + fontWeight = if (isPreferred) FontWeight.Medium else FontWeight.Normal, + ) + if (isPreferred) { + Text( + text = "preferred", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } +} + +@Composable +private fun ChessRelayRow( + relayUrl: String, + isConnected: Boolean, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = + if (isConnected) { + Icons.Default.CheckCircle + } else { + Icons.Default.Close + }, + contentDescription = if (isConnected) "Connected" else "Not connected", + tint = + if (isConnected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, + modifier = Modifier.size(16.dp), + ) + Text( + text = relayUrl.removePrefix("wss://").removePrefix("ws://").removeSuffix("/"), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Spacer(Modifier.weight(1f)) + Text( + text = if (isConnected) "connected" else "not connected", + style = MaterialTheme.typography.labelSmall, + color = + if (isConnected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessSubscription.kt new file mode 100644 index 000000000..9028de195 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessSubscription.kt @@ -0,0 +1,164 @@ +/* + * 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.ui.screen.loggedIn.chess + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionState +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Subscribe to chess events when the Chess screen is active. + * + * Uses Amethyst's subscription system to fetch events into LocalCache, + * then triggers ViewModel refresh to process them. + */ +@Composable +fun ChessSubscription( + accountViewModel: AccountViewModel, + chessViewModel: ChessViewModelNew, +) { + // Get active game IDs from the view model for game-specific subscriptions + val activeGames by chessViewModel.activeGames.collectAsStateWithLifecycle() + val spectatingGames by chessViewModel.spectatingGames.collectAsStateWithLifecycle() + val activeGameIds = activeGames.keys + spectatingGames.keys + + // Extract opponent pubkeys using stable keys (avoid recomposition from LiveChessGameState identity) + val opponentPubkeys = + remember(activeGameIds) { + activeGames.values.map { it.opponentPubkey }.toSet() + } + + val state = + remember(accountViewModel.account, activeGameIds, opponentPubkeys) { + ChessQueryState( + userPubkey = accountViewModel.account.userProfile().pubkeyHex, + // Use notification inbox relays - where others send events TO us + inboxRelays = accountViewModel.account.notificationRelays.flow.value, + // Always use global relays for chess - we want to see ALL games + globalRelays = accountViewModel.account.defaultGlobalRelays.flow.value, + // Always treat as global for chess + isGlobal = true, + activeGameIds = activeGameIds, + opponentPubkeys = opponentPubkeys, + ) + } + + // Register subscription with Amethyst's subscription system + KeyDataSourceSubscription(state, accountViewModel.dataSources().chess) + + // Trigger ViewModel refresh when subscription state changes + // This fetches challenges from LocalCache after events arrive + DisposableEffect(state) { + chessViewModel.forceRefresh() + onDispose { + // Subscription cleanup handled by KeyDataSourceSubscription + } + } +} + +/** + * Query state for chess subscription + */ +data class ChessQueryState( + val userPubkey: String, + val inboxRelays: Set, + val globalRelays: Set, + val isGlobal: Boolean, + val activeGameIds: Set = emptySet(), + val opponentPubkeys: Set = emptySet(), +) + +/** + * Filter assembler for chess events + */ +class ChessFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + ChessFeedFilterSubAssembler(client, ::allKeys), + ) + + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } +} + +/** + * Sub-assembler that creates the actual relay filters + */ +class ChessFeedFilterSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: ChessQueryState, + since: SincePerRelayMap?, + ): List = filterChessEvents(key, since) + + override fun id(key: ChessQueryState): String = "chess-${key.userPubkey}-${key.isGlobal}-${key.activeGameIds.hashCode()}" +} + +/** + * Create relay filters for chess events using the shared [ChessFilterBuilder]. + * + * Following jesterui's approach: + * 1. Fetch ALL challenges from ALL connected relays (no author/tag restriction) + * 2. Filter client-side based on user's preferences (Global/Follows) + * 3. Game-specific subscriptions for active games + * + * This ensures we see: + * - Open challenges from anyone + * - Challenges directed at us + * - Public games to spectate + */ +fun filterChessEvents( + key: ChessQueryState, + since: SincePerRelayMap?, +): List { + // Convert Android ChessQueryState to shared ChessSubscriptionState + val state = + ChessSubscriptionState( + userPubkey = key.userPubkey, + relays = key.inboxRelays + key.globalRelays, + activeGameIds = key.activeGameIds, + opponentPubkeys = key.opponentPubkeys, + ) + + // Use shared filter builder for consistent behavior with Desktop + return ChessFilterBuilder.buildAllFilters(state) { relay -> + since?.get(relay)?.time + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt new file mode 100644 index 000000000..b4c33a68d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account + +/** + * Factory for creating ChessViewModelNew instances. + * Uses the slim ViewModel that delegates to shared ChessLobbyLogic. + */ +class ChessViewModelFactory( + private val account: Account, +) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(ChessViewModelNew::class.java)) { + return ChessViewModelNew(account) as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt new file mode 100644 index 000000000..9c0156997 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -0,0 +1,198 @@ +/* + * 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.ui.screen.loggedIn.chess + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus +import com.vitorpamplona.amethyst.commons.chess.ChessChallenge +import com.vitorpamplona.amethyst.commons.chess.ChessLobbyLogic +import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults +import com.vitorpamplona.amethyst.commons.chess.ChessSyncStatus +import com.vitorpamplona.amethyst.commons.chess.CompletedGame +import com.vitorpamplona.amethyst.commons.chess.PublicGame +import com.vitorpamplona.amethyst.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.LiveChessGameState +import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import kotlinx.coroutines.flow.StateFlow + +/** + * Slim Android ViewModel for chess (~120 lines). + * + * Delegates all business logic to ChessLobbyLogic. + * Only handles Android-specific concerns: + * - ViewModel lifecycle (viewModelScope) + * - Platform adapter creation + * - State exposure to Compose UI + */ +class ChessViewModelNew( + private val account: Account, +) : ViewModel() { + // Instance ID for debugging ViewModel sharing + val instanceId = System.identityHashCode(this) + + init { + println("[ChessViewModelNew] Created instance $instanceId for ${account.userProfile().pubkeyHex.take(8)}") + } + + // Platform adapters + private val publisher = AndroidChessPublisher(account) + private val fetcher = AndroidRelayFetcher(account) + private val metadataProvider = AndroidMetadataProvider() + + // Shared business logic (creates its own ChessLobbyState internally) + private val logic = + ChessLobbyLogic( + userPubkey = account.userProfile().pubkeyHex, + publisher = publisher, + fetcher = fetcher, + metadataProvider = metadataProvider, + scope = viewModelScope, + pollingConfig = ChessPollingDefaults.android, + ) + + // ============================================ + // State exposure (delegated from ChessLobbyLogic.state) + // ============================================ + + val activeGames: StateFlow> = logic.state.activeGames + val spectatingGames: StateFlow> = logic.state.spectatingGames + val challenges: StateFlow> = logic.state.challenges + val publicGames: StateFlow> = logic.state.publicGames + val completedGames: StateFlow> = logic.state.completedGames + val broadcastStatus: StateFlow = logic.state.broadcastStatus + val error: StateFlow = logic.state.error + val selectedGameId: StateFlow = logic.state.selectedGameId + val isRefreshing: StateFlow = logic.state.isRefreshing + val stateVersion: StateFlow = logic.state.stateVersion + val syncStatus: StateFlow = logic.state.syncStatus + + /** Badge count (incoming challenges + your turn games) - computed property */ + val badgeCount: Int get() = logic.state.badgeCount + + // ============================================ + // Lifecycle + // ============================================ + + init { + logic.startPolling() + } + + fun startPolling() = logic.startPolling() + + fun stopPolling() = logic.stopPolling() + + fun forceRefresh() = logic.forceRefresh() + + /** + * Ensure a game ID is being polled for updates. + * Call this when entering a game screen. + */ + fun ensureGamePolling(gameId: String) = logic.ensureGamePolling(gameId) + + /** + * Set focused game mode - only poll this specific game. + * Call this when entering a game screen to avoid refreshing unrelated games. + */ + fun setFocusedGame(gameId: String) = logic.setFocusedGame(gameId) + + /** + * Clear focused game mode - return to lobby mode (poll all games). + * Call this when returning to the lobby screen. + */ + fun clearFocusedGame() = logic.clearFocusedGame() + + override fun onCleared() { + super.onCleared() + logic.stopPolling() + } + + // ============================================ + // Incoming event routing (from relay subscriptions) + // ============================================ + + fun handleIncomingEvent(event: Event) { + if (event.kind != JesterProtocol.KIND) return + val jesterEvent = event.toJesterEvent() ?: return + logic.handleIncomingEvent(jesterEvent) + } + + // ============================================ + // Challenge operations + // ============================================ + + fun createChallenge( + opponentPubkey: String? = null, + playerColor: Color = Color.WHITE, + timeControl: String? = null, + ) = logic.createChallenge(opponentPubkey, playerColor, timeControl) + + fun acceptChallenge(challenge: ChessChallenge) = logic.acceptChallenge(challenge) + + fun openOwnChallenge(challenge: ChessChallenge) = logic.openOwnChallenge(challenge) + + // ============================================ + // Game operations + // ============================================ + + fun selectGame(gameId: String?) = logic.selectGame(gameId) + + fun publishMove( + gameId: String, + from: String, + to: String, + ) = logic.publishMove(gameId, from, to) + + fun resign(gameId: String) = logic.resign(gameId) + + fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId) + + // ============================================ + // Spectator operations + // ============================================ + + fun loadGame(gameId: String) = logic.loadGame(gameId) + + fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId) + + fun stopSpectating(gameId: String) = logic.state.removeSpectatingGame(gameId) + + // ============================================ + // Utility + // ============================================ + + fun clearError() = logic.clearError() + + fun getGameState(gameId: String): LiveChessGameState? = logic.state.getGameState(gameId) + + /** Check if a game was accepted (prevents loading as spectator during race) */ + fun wasAccepted(gameId: String): Boolean = logic.state.wasAccepted(gameId) + + /** Helper for derived challenge lists */ + fun incomingChallenges(): List = logic.state.incomingChallenges() + + fun outgoingChallenges(): List = logic.state.outgoingChallenges() + + fun openChallenges(): List = logic.state.openChallenges() +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/NewChessGameButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/NewChessGameButton.kt new file mode 100644 index 000000000..c0baa2135 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/NewChessGameButton.kt @@ -0,0 +1,53 @@ +/* + * 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.ui.screen.loggedIn.chess + +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +/** + * Simple floating action button for creating new chess game challenges. + * Just triggers onClick - the caller handles the dialog. + */ +@Composable +fun NewChessGameButton(onClick: () -> Unit) { + FloatingActionButton( + onClick = onClick, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "New Chess Game", + modifier = Size26Modifier, + tint = Color.White, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt new file mode 100644 index 000000000..4ffd267a1 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.kt @@ -0,0 +1,88 @@ +/* + * 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.ui.screen.loggedIn.chess.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.filterIntoSet +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.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent + +/** + * Feed filter for NIP-64 Chess Game events (Kind 64) + * + * Filters the local cache to show only chess games, + * respecting user's follow lists and hidden users. + */ +class ChessFeedFilter( + val account: Account, +) : AdditiveFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-chess-feed" + + override fun limit() = 200 + + override fun showHiddenKey(): Boolean = + account.settings.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || + account.settings.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + + override fun feed(): List { + val params = buildFilterParams(account) + + val notes = + LocalCache.notes.filterIntoSet { _, it -> + acceptableEvent(it, params) + } + + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + private fun innerApplyFilter(collection: Collection): Set { + val params = buildFilterParams(account) + + return collection.filterTo(HashSet()) { acceptableEvent(it, params) } + } + + fun acceptableEvent( + note: Note, + params: FilterByListParams, + ): Boolean { + val noteEvent = note.event + + return (noteEvent is ChessGameEvent) && + params.match(noteEvent, note.relays) && + (params.isHiddenList || account.isAcceptable(note)) + } + + fun buildFilterParams(account: Account): FilterByListParams = + FilterByListParams.create( + followLists = account.liveStoriesFollowLists.value, + hiddenUsers = account.hiddenUsers.flow.value, + ) + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index a4ec83354..cb02b626c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt @@ -123,7 +123,7 @@ fun DiscoverScreen( accountViewModel: AccountViewModel, nav: INav, ) { - val tabs by + val feedTabs by remember(accountViewModel) { mutableStateOf( listOf( @@ -181,7 +181,7 @@ fun DiscoverScreen( ) } - val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { tabs.size } + val pagerState = rememberForeverPagerState(key = PagerStateKeys.DISCOVER_SCREEN) { feedTabs.size } WatchAccountForDiscoveryScreen( discoveryFollowSetsFeedContentState = discoveryFollowSetsFeedContentState, @@ -204,13 +204,13 @@ fun DiscoverScreen( DiscoveryFilterAssemblerSubscription(accountViewModel.dataSources().discovery, accountViewModel) - DiscoverPages(pagerState, tabs, accountViewModel, nav) + DiscoverPages(pagerState, feedTabs, accountViewModel, nav) } @Composable private fun DiscoverPages( pagerState: PagerState, - tabs: ImmutableList, + feedTabs: ImmutableList, accountViewModel: AccountViewModel, nav: INav, ) { @@ -228,7 +228,7 @@ private fun DiscoverPages( ) { val coroutineScope = rememberCoroutineScope() - tabs.forEachIndexed { index, tab -> + feedTabs.forEachIndexed { index, tab -> Tab( selected = pagerState.currentPage == index, text = { Text(text = stringRes(tab.resource)) }, @@ -241,42 +241,49 @@ private fun DiscoverPages( bottomBar = { AppBottomBar(Route.Discover, accountViewModel) { route -> if (route == Route.Discover) { - tabs[pagerState.currentPage].feedState.sendToTop() + val currentPage = pagerState.currentPage + if (currentPage >= 0 && currentPage < feedTabs.size) { + feedTabs[currentPage].feedState.sendToTop() + } } else { nav.newStack(route) } } }, floatingButton = { - if (tabs[pagerState.currentPage].resource == R.string.discover_marketplace) { + val currentPage = pagerState.currentPage + if (currentPage >= 0 && currentPage < feedTabs.size && feedTabs[currentPage].resource == R.string.discover_marketplace) { NewProductButton(accountViewModel, nav) } }, accountViewModel = accountViewModel, ) { HorizontalPager(state = pagerState, contentPadding = it) { page -> - RefresheableBox(tabs[page].feedState, true) { - if (tabs[page].useGridLayout) { - SaveableGridFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState -> - RenderDiscoverFeed( - feedContentState = tabs[page].feedState, - routeForLastRead = tabs[page].routeForLastRead, - forceEventKind = tabs[page].forceEventKind, - listState = listState, - accountViewModel = accountViewModel, - nav = nav, - ) - } - } else { - SaveableFeedContentState(tabs[page].feedState, scrollStateKey = tabs[page].scrollStateKey) { listState -> - RenderDiscoverFeed( - feedContentState = tabs[page].feedState, - routeForLastRead = tabs[page].routeForLastRead, - forceEventKind = tabs[page].forceEventKind, - listState = listState, - accountViewModel = accountViewModel, - nav = nav, - ) + if (page >= 0 && page < feedTabs.size) { + val tab = feedTabs[page] + RefresheableBox(tab.feedState, true) { + if (tab.useGridLayout) { + SaveableGridFeedContentState(tab.feedState, scrollStateKey = tab.scrollStateKey) { listState -> + RenderDiscoverFeed( + feedContentState = tab.feedState, + routeForLastRead = tab.routeForLastRead, + forceEventKind = tab.forceEventKind, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } else { + SaveableFeedContentState(tab.feedState, scrollStateKey = tab.scrollStateKey) { listState -> + RenderDiscoverFeed( + feedContentState = tab.feedState, + routeForLastRead = tab.routeForLastRead, + forceEventKind = tab.forceEventKind, + listState = listState, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt index 26a3e1222..cac04a869 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/NewProductScreen.kt @@ -48,7 +48,6 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton @@ -255,12 +254,9 @@ private fun NewProductBody( ImageVideoDescription( uris = it, defaultServer = accountViewModel.account.settings.defaultFileServer, - includeNIP95 = false, onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) - if (server.type != ServerType.NIP95) { - accountViewModel.account.settings.changeDefaultFileServer(server) - } + accountViewModel.account.settings.changeDefaultFileServer(server) }, onDelete = postViewModel::deleteMediaToUpload, onCancel = { postViewModel.multiOrchestrator = null }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt index 3af73498f..913be738d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/geohash/datasource/FilterPostsByGeohash.kt @@ -29,12 +29,12 @@ 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.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip73ExternalIds.location.GeohashId import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent val PostsByGeohashKinds = @@ -42,13 +42,13 @@ val PostsByGeohashKinds = TextNoteEvent.KIND, ChannelMessageEvent.KIND, LongTextNoteEvent.KIND, + PollEvent.KIND, PollNoteEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, AudioTrackEvent.KIND, AudioHeaderEvent.KIND, WikiNoteEvent.KIND, - CommentEvent.KIND, ) fun filterPostsByGeohash( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt index 0e233a335..69bbeed3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/datasource/FilterPostsByHashtags.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip73ExternalIds.topics.HashtagId import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent val PostsByHashtagsKinds = @@ -46,7 +47,7 @@ val PostsByHashtagsKinds = TextNoteEvent.KIND, ChannelMessageEvent.KIND, LongTextNoteEvent.KIND, - PollNoteEvent.KIND, + PollEvent.KIND, LiveActivitiesChatMessageEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, @@ -60,6 +61,7 @@ val PostsByHashtagKinds2 = AudioTrackEvent.KIND, AudioHeaderEvent.KIND, NipTextEvent.KIND, + PollNoteEvent.KIND, ) fun filterPostsByHashtags( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt index 06fca2a82..3b945df3d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeScreen.kt @@ -60,6 +60,7 @@ import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActiviti import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState import com.vitorpamplona.amethyst.model.AROUND_ME +import com.vitorpamplona.amethyst.model.CHESS import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled @@ -234,21 +235,29 @@ fun HomeScreenFloatingButton( accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() - if (list.value == AROUND_ME) { - val location by Amethyst.instance.locationManager.geohashStateFlow - .collectAsStateWithLifecycle() + when (list.value) { + AROUND_ME -> { + val location by Amethyst.instance.locationManager.geohashStateFlow + .collectAsStateWithLifecycle() - when (val myLocation = location) { - is LocationState.LocationResult.Success -> { - NewGeoPostButton(myLocation.geoHash.toString(), accountViewModel, nav) + when (val myLocation = location) { + is LocationState.LocationResult.Success -> { + NewGeoPostButton(myLocation.geoHash.toString(), accountViewModel, nav) + } + + is LocationState.LocationResult.LackPermission -> { } + + is LocationState.LocationResult.Loading -> { } } - - is LocationState.LocationResult.LackPermission -> { } - - is LocationState.LocationResult.Loading -> { } } - } else { - NewNoteButton(nav) + + CHESS -> { + NewChessGameButton(accountViewModel, nav) + } + + else -> { + NewNoteButton(nav) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt new file mode 100644 index 000000000..ca4bbefb7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.kt @@ -0,0 +1,76 @@ +/* + * 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.ui.screen.loggedIn.home + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +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.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +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 + +/** + * Floating action button for creating new chess game challenges + */ +@Composable +fun NewChessGameButton( + accountViewModel: AccountViewModel, + nav: INav, +) { + var showDialog by remember { mutableStateOf(false) } + + val chessViewModel: ChessViewModelNew = + viewModel( + key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", + factory = ChessViewModelFactory(accountViewModel.account), + ) + + FloatingActionButton( + onClick = { showDialog = true }, + ) { + Icon( + imageVector = Icons.Default.Add, + contentDescription = "New Chess Game", + ) + } + + if (showDialog) { + NewChessGameDialog( + onDismiss = { showDialog = false }, + onCreateGame = { opponentPubkey, color -> + chessViewModel.createChallenge( + opponentPubkey = opponentPubkey, + playerColor = color, + ) + showDialog = false + }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt index 63dce9f11..369f06947 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home +import android.annotation.SuppressLint import android.content.Intent import android.net.Uri import android.os.Parcelable @@ -37,6 +38,8 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Poll import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -52,13 +55,13 @@ import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.util.Consumer import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.MAX_VOICE_RECORD_SECONDS import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery @@ -83,25 +86,24 @@ import com.vitorpamplona.amethyst.ui.note.creators.location.AddGeoHashButton import com.vitorpamplona.amethyst.ui.note.creators.location.LocationAsHash import com.vitorpamplona.amethyst.ui.note.creators.messagefield.MessageField import com.vitorpamplona.amethyst.ui.note.creators.notify.Notifying +import com.vitorpamplona.amethyst.ui.note.creators.polls.PollOptionsField import com.vitorpamplona.amethyst.ui.note.creators.previews.DisplayPreviews import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.AddSecretEmojiButton import com.vitorpamplona.amethyst.ui.note.creators.secretEmoji.SecretEmojiRequest import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList -import com.vitorpamplona.amethyst.ui.note.creators.zappolls.PollField import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.AddZapraiserButton import com.vitorpamplona.amethyst.ui.note.creators.zapraiser.ZapRaiserRequest import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapTo import com.vitorpamplona.amethyst.ui.note.creators.zapsplits.ForwardZapToButton -import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp -import com.vitorpamplona.amethyst.ui.theme.Size20Modifier import com.vitorpamplona.amethyst.ui.theme.Size35dp import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.ui.theme.replyModifier import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -289,7 +291,7 @@ private fun NewPostScreenBody( verticalAlignment = CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), ) { - PollField(postViewModel) + PollOptionsField(postViewModel) } } @@ -340,9 +342,7 @@ private fun NewPostScreenBody( accountViewModel.account.settings.defaultFileServer, onAdd = { alt, server, sensitiveContent, mediaQuality, useH265 -> postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context, useH265) - if (server.type != ServerType.NIP95) { - accountViewModel.account.settings.changeDefaultFileServer(server) - } + accountViewModel.account.settings.changeDefaultFileServer(server) }, onDelete = postViewModel::deleteMediaToUpload, onCancel = { postViewModel.multiOrchestrator = null }, @@ -354,7 +354,7 @@ private fun NewPostScreenBody( // Show preview for both uploaded messages (voiceMetadata) and pending recordings (postViewModel.voiceMetadata ?: postViewModel.getVoicePreviewMetadata())?.let { metadata -> val fileServersState = - accountViewModel.account.serverLists.liveServerList + accountViewModel.account.blossomServers.hostNameFlow .collectAsState() val fileServers = fileServersState.value @@ -542,6 +542,17 @@ private fun BottomRowActions(postViewModel: ShortNotePostViewModel) { } } +@SuppressLint("ViewModelConstructorInComposable") +@Preview +@Composable +private fun BottomRowActionsPreview() { + val model = ShortNotePostViewModel() + model.canUsePoll = true + ThemeComparisonColumn { + BottomRowActions(model) + } +} + @Composable private fun AddPollButton( isPollActive: Boolean, @@ -552,17 +563,17 @@ private fun AddPollButton( ) { if (!isPollActive) { Icon( - painter = painterRes(R.drawable.ic_poll, 1), + imageVector = Icons.Outlined.Poll, contentDescription = stringRes(id = R.string.poll), - modifier = Size20Modifier, + modifier = Modifier.height(22.dp), tint = MaterialTheme.colorScheme.onBackground, ) } else { Icon( - painter = painterRes(R.drawable.ic_lists, 1), + imageVector = Icons.Outlined.Poll, contentDescription = stringRes(id = R.string.disable_poll), - modifier = Size20Modifier, - tint = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.height(22.dp), + tint = MaterialTheme.colorScheme.primary, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index 314e58db5..c45eb4b20 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home import android.content.Context import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -49,7 +48,6 @@ import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing @@ -70,15 +68,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.IMetaA import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.experimental.zapPolls.closedAt -import com.vitorpamplona.quartz.experimental.zapPolls.consensusThreshold -import com.vitorpamplona.quartz.experimental.zapPolls.maxAmount -import com.vitorpamplona.quartz.experimental.zapPolls.minAmount -import com.vitorpamplona.quartz.experimental.zapPolls.tags.PollOptionTag import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohash @@ -112,6 +103,8 @@ import com.vitorpamplona.quartz.nip57Zaps.splits.zapSplits import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiser import com.vitorpamplona.quartz.nip57Zaps.zapraiser.zapraiserAmount import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder import com.vitorpamplona.quartz.nip92IMeta.imetas import com.vitorpamplona.quartz.nip94FileMetadata.alt @@ -128,6 +121,8 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.StateFlow @@ -224,17 +219,8 @@ open class ShortNotePostViewModel : // Polls var canUsePoll by mutableStateOf(false) var wantsPoll by mutableStateOf(false) - var zapRecipients = mutableStateListOf() - var pollOptions = newStateMapPollOptions() - var valueMaximum by mutableStateOf(null) - var valueMinimum by mutableStateOf(null) - var consensusThreshold: Int? = null - var closedAt: Long? = null - - var isValidValueMaximum = mutableStateOf(true) - var isValidValueMinimum = mutableStateOf(true) - var isValidConsensusThreshold = mutableStateOf(true) - var isValidClosedAt = mutableStateOf(true) + var pollOptions: SnapshotStateMap = newStateMapPollOptions() + var closedAt by mutableStateOf(TimeUtils.oneDayAhead()) // Invoices var canAddInvoice by mutableStateOf(false) @@ -411,9 +397,13 @@ open class ShortNotePostViewModel : private fun loadFromDraft(draft: Note) { val draftEvent = draft.event ?: return - if (draftEvent !is TextNoteEvent) return + if (draftEvent is TextNoteEvent) { + loadFromDraft(draftEvent) + } - loadFromDraft(draftEvent) + if (draftEvent is PollEvent) { + loadFromDraft(draftEvent) + } } private fun loadFromDraft(draftEvent: TextNoteEvent) { @@ -486,21 +476,70 @@ open class ShortNotePostViewModel : wantsForwardZapTo = true } - val polls = draftEvent.tags.filter { it.size > 1 && it[0] == "poll_option" } + wantsPoll = false + + message = TextFieldValue(draftEvent.content) + + iMetaAttachments.addAll(draftEvent.imetas()) + + urlPreviews.update(message) + } + + private fun loadFromDraft(draftEvent: PollEvent) { + canAddInvoice = accountViewModel.userProfile().lnAddress() != null + canAddZapRaiser = accountViewModel.userProfile().lnAddress() != null + multiOrchestrator = null + + val localForwardZapTo = draftEvent.tags.filter { it.size > 1 && it[0] == "zap" } + forwardZapTo.value = SplitBuilder() + localForwardZapTo.forEach { + val user = LocalCache.getOrCreateUser(it[1]) + val value = it.last().toFloatOrNull() ?: 0f + forwardZapTo.value.addItem(user, value) + } + forwardZapToEditting.value = TextFieldValue("") + wantsForwardZapTo = localForwardZapTo.isNotEmpty() + + wantsToMarkAsSensitive = draftEvent.isSensitive() + + val geohash = draftEvent.getGeoHash() + wantsToAddGeoHash = geohash != null + if (geohash != null) { + wantsExclusiveGeoPost = draftEvent.kind == CommentEvent.KIND + } + + val zapRaiser = draftEvent.zapraiserAmount() + wantsZapRaiser = zapRaiser != null + zapRaiserAmount.value = null + if (zapRaiser != null) { + zapRaiserAmount.value = zapRaiser + } + + eTags = + draftEvent.tags.filter { it.size > 1 && (it[0] == "e" || it[0] == "a") && it.getOrNull(3) != "fork" }.mapNotNull { + val note = LocalCache.checkGetOrCreateNote(it[1]) + note + } + + pTags = + draftEvent.tags.filter { it.size > 1 && it[0] == "p" }.map { + LocalCache.getOrCreateUser(it[1]) + } + + canUsePoll = originalNote == null + + if (forwardZapTo.value.items.isNotEmpty()) { + wantsForwardZapTo = true + } + + val polls = draftEvent.options() wantsPoll = polls.isNotEmpty() - polls.forEach { - pollOptions[it[1].toInt()] = it[2] + polls.forEachIndexed { index, tag -> + pollOptions[index] = tag } - val minMax = draftEvent.tags.filter { it.size > 1 && (it[0] == "value_minimum" || it[0] == "value_maximum") } - minMax.forEach { - if (it[0] == "value_maximum") { - valueMaximum = it[1].toLong() - } else if (it[0] == "value_minimum") { - valueMinimum = it[1].toLong() - } - } + closedAt = draftEvent.endsAt() ?: TimeUtils.oneDayAhead() message = TextFieldValue(draftEvent.content) @@ -523,8 +562,8 @@ open class ShortNotePostViewModel : return } // Update default server if voice message was successfully uploaded - if (voiceSelectedServer != null && voiceSelectedServer?.type != ServerType.NIP95) { - account.settings.changeDefaultFileServer(voiceSelectedServer!!) + voiceSelectedServer?.let { + account.settings.changeDefaultFileServer(it) } } @@ -648,18 +687,16 @@ open class ShortNotePostViewModel : val contentWarningReason = if (wantsToMarkAsSensitive) "" else null return if (wantsPoll) { - val options = pollOptions.map { PollOptionTag(it.key, it.value) } + val options = pollOptions.map { it.value } if (options.isEmpty()) return null val quotes = findNostrUris(tagger.message) + val relays = + accountViewModel.account.nip65RelayList.outboxFlow.value + .toList() - PollNoteEvent.build(tagger.message, options) { - valueMinimum?.let { minAmount(it) } - valueMaximum?.let { maxAmount(it) } - closedAt?.let { closedAt(it) } - consensusThreshold?.let { consensusThreshold(it / 100.0) } - + PollEvent.build(tagger.message, options, closedAt, relays) { pTags(tagger.directMentionsUsers.map { it.toPTag() }) quotes(quotes) hashtags(findHashtags(tagger.message)) @@ -849,12 +886,8 @@ open class ShortNotePostViewModel : pTags = null wantsPoll = false - zapRecipients = mutableStateListOf() pollOptions = newStateMapPollOptions() - valueMaximum = null - valueMinimum = null - consensusThreshold = null - closedAt = null + closedAt = TimeUtils.oneDayAhead() wantsInvoice = false wantsZapRaiser = false @@ -962,7 +995,11 @@ open class ShortNotePostViewModel : draftTag.newVersion() } - private fun newStateMapPollOptions(): SnapshotStateMap = mutableStateMapOf(Pair(0, ""), Pair(1, "")) + private fun newStateMapPollOptions(): SnapshotStateMap = + mutableStateMapOf( + 0 to OptionTag(RandomInstance.randomChars(6), ""), + 1 to OptionTag(RandomInstance.randomChars(6), ""), + ) fun canPost(): Boolean { // Voice messages can be posted without text (with either uploaded or pending recording) @@ -979,9 +1016,8 @@ open class ShortNotePostViewModel : ( !wantsPoll || ( - pollOptions.values.all { it.isNotEmpty() } && - isValidValueMinimum.value && - isValidValueMaximum.value + pollOptions.isNotEmpty() && pollOptions.all { it.value.label.isNotEmpty() } && + closedAt > TimeUtils.oneMinuteFromNow() ) ) && multiOrchestrator == null @@ -1131,28 +1167,6 @@ open class ShortNotePostViewModel : Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") } - fun updateMinZapAmountForPoll(textMin: String) { - valueMinimum = textMin.toLongOrNull()?.takeIf { it > 0 } - checkMinMax() - draftTag.newVersion() - } - - fun updateMaxZapAmountForPoll(textMax: String) { - valueMaximum = textMax.toLongOrNull()?.takeIf { it > 0 } - checkMinMax() - draftTag.newVersion() - } - - fun checkMinMax() { - if ((valueMinimum ?: 0) > (valueMaximum ?: Long.MAX_VALUE)) { - isValidValueMinimum.value = false - isValidValueMaximum.value = false - } else { - isValidValueMinimum.value = true - isValidValueMaximum.value = true - } - } - override fun updateZapPercentage( index: Int, sliderValue: Float, @@ -1178,19 +1192,28 @@ open class ShortNotePostViewModel : draftTag.newVersion() } - fun removePollOption(optionIndex: Int) { - pollOptions.removeOrdered(optionIndex) + fun removePollOption(index: Int) { + pollOptions.removeOrdered(index) draftTag.newVersion() } - private fun MutableMap.removeOrdered(index: Int) { + fun updatePollOption( + index: Int, + label: String, + ) { + val current = pollOptions[index] + pollOptions[index] = OptionTag(current?.code ?: RandomInstance.randomChars(6), label) + draftTag.newVersion() + } + + private fun MutableMap.removeOrdered(index: Int) { val keyList = keys val elementList = values.toMutableList() run stop@{ for (i in index until elementList.size) { val nextIndex = i + 1 if (nextIndex == elementList.size) return@stop - elementList[i] = elementList[nextIndex].also { elementList[nextIndex] = "null" } + elementList[i] = elementList[nextIndex].also { elementList[nextIndex] = OptionTag(RandomInstance.randomChars(6), "") } } } elementList.removeAt(elementList.size - 1) @@ -1199,14 +1222,6 @@ open class ShortNotePostViewModel : this.putAll(newEntries) } - fun updatePollOption( - optionIndex: Int, - text: String, - ) { - pollOptions[optionIndex] = text - draftTag.newVersion() - } - fun toggleMarkAsSensitive() { wantsToMarkAsSensitive = !wantsToMarkAsSensitive draftTag.newVersion() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt index f23dd893b..b9da0ce39 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.material3.Surface 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.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -164,10 +165,10 @@ private fun VoiceReplyScreenBody( Spacer(modifier = Modifier.height(16.dp)) // Server selection - val fileServers = - accountViewModel.account.serverLists.liveServerList + val fileServers by + accountViewModel.account.blossomServers.hostNameFlow .collectAsState() - .value + FileServerSelectionRow( fileServers = fileServers, selectedServer = viewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt index d20e9d3a4..d44630a46 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -33,7 +33,6 @@ import com.vitorpamplona.amethyst.service.uploads.CompressorQuality import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator import com.vitorpamplona.amethyst.service.uploads.UploadingState import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.RecordingResult import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceAnonymizationController import com.vitorpamplona.amethyst.ui.actions.uploads.VoicePreset @@ -274,9 +273,7 @@ class VoiceReplyViewModel : ViewModel() { accountViewModel.account.signAndComputeBroadcast(template) } - if (server.type != ServerType.NIP95) { - accountViewModel.account.settings.changeDefaultFileServer(server) - } + accountViewModel.account.settings.changeDefaultFileServer(server) deleteVoiceLocalFile() voiceAnonymization.deleteDistortedFiles() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt index b4977ddcb..53384e1dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeConversationsFeedFilter.kt @@ -36,6 +36,7 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent class HomeConversationsFeedFilter( @@ -81,6 +82,7 @@ class HomeConversationsFeedFilter( ( event is TextNoteEvent || event is PollNoteEvent || + event is PollResponseEvent || event is ChannelMessageEvent || event is CommentEvent || event is VoiceReplyEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt index ea33290fe..fcf5941dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/dal/HomeNewThreadFeedFilter.kt @@ -39,7 +39,11 @@ 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.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent @@ -54,6 +58,8 @@ class HomeNewThreadFeedFilter( WikiNoteEvent.KIND, ClassifiedsEvent.KIND, LongTextNoteEvent.KIND, + LiveChessGameChallengeEvent.KIND, + LiveChessGameEndEvent.KIND, ) } @@ -111,12 +117,16 @@ class HomeNewThreadFeedFilter( (noteEvent is LongTextNoteEvent && noteEvent.content.isNotEmpty()) || (noteEvent is WikiNoteEvent && noteEvent.content.isNotEmpty()) || noteEvent is PollNoteEvent || + noteEvent is PollEvent || noteEvent is HighlightEvent || noteEvent is InteractiveStoryPrologueEvent || noteEvent is CommentEvent || noteEvent is AudioTrackEvent || noteEvent is VoiceEvent || - noteEvent is AudioHeaderEvent + noteEvent is AudioHeaderEvent || + noteEvent is ChessGameEvent || + noteEvent is LiveChessGameChallengeEvent || + noteEvent is LiveChessGameEndEvent ) && filterParams.match(noteEvent, it.relays) && it.isNewThread() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip64Chess/FilterHomePostsByChess.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip64Chess/FilterHomePostsByChess.kt new file mode 100644 index 000000000..3999badfc --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip64Chess/FilterHomePostsByChess.kt @@ -0,0 +1,58 @@ +/* + * 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.ui.screen.loggedIn.home.datasource.nip64Chess + +import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessTopNavPerRelayFilterSet +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 + +val ChessPostsKinds = + listOf( + ChessGameEvent.KIND, // Completed games (Kind 64) + LiveChessGameChallengeEvent.KIND, // Challenges (Kind 30064) + LiveChessGameEndEvent.KIND, // Game endings (Kind 30067) + ) + +fun filterHomePostsByChess( + relays: ChessTopNavPerRelayFilterSet, + since: SincePerRelayMap?, + newThreadSince: Long?, +): List { + if (relays.set.isEmpty()) return emptyList() + + return relays.set.map { + val since = since?.get(it.key)?.time + val relayUrl = it.key + RelayBasedFilter( + relay = relayUrl, + filter = + Filter( + kinds = ChessPostsKinds, + limit = 50, + since = since ?: newThreadSince, + ), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt index 798530826..fb9ca719e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/FilterHomePostsByAuthors.kt @@ -39,7 +39,12 @@ 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.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent @@ -56,7 +61,11 @@ val HomePostsNewThreadKinds = WikiNoteEvent.KIND, NipTextEvent.KIND, PollNoteEvent.KIND, + PollEvent.KIND, InteractiveStoryPrologueEvent.KIND, + ChessGameEvent.KIND, + LiveChessGameChallengeEvent.KIND, + LiveChessGameEndEvent.KIND, ) val HomePostsConversationKinds = @@ -67,6 +76,7 @@ val HomePostsConversationKinds = EphemeralChatEvent.KIND, VoiceEvent.KIND, VoiceReplyEvent.KIND, + PollResponseEvent.KIND, ) fun filterNewHomePostsByAuthors( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt index d3d7f35ae..87ea8d662 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip65Follows/HomeOutboxEventsEoseManager.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follo import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet +import com.vitorpamplona.amethyst.model.topNavFeeds.chess.ChessTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.global.GlobalTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.hashtag.HashtagTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.allcommunities.AllCommunitiesTopNavPerRelayFilterSet @@ -35,6 +36,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeQuerySt import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByGeohashes import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByGlobal import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip01Core.filterHomePostsByHashtags +import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip64Chess.filterHomePostsByChess import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByAllCommunities import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip72Communities.filterHomePostsByCommunity import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -62,6 +64,7 @@ class HomeOutboxEventsEoseManager( is AllCommunitiesTopNavPerRelayFilterSet -> filterHomePostsByAllCommunities(feedSettings, since, newThreadSince) is AllFollowsTopNavPerRelayFilterSet -> filterHomePostsByAllFollows(feedSettings, since, newThreadSince, repliesSince) is AuthorsTopNavPerRelayFilterSet -> filterHomePostsByAuthors(feedSettings, since, newThreadSince, repliesSince) + is ChessTopNavPerRelayFilterSet -> filterHomePostsByChess(feedSettings, since, newThreadSince) is GlobalTopNavPerRelayFilterSet -> filterHomePostsByGlobal(feedSettings, since, newThreadSince, repliesSince) is HashtagTopNavPerRelayFilterSet -> filterHomePostsByHashtags(feedSettings, since, newThreadSince) is LocationTopNavPerRelayFilterSet -> filterHomePostsByGeohashes(feedSettings, since, newThreadSince) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt index 7c4c6365b..4c7f5c300 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/nip72Communities/FilterHomePostsFromCommunities.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent val HomePostsFromCommunityKinds = @@ -41,6 +42,7 @@ val HomePostsFromCommunityKinds = LongTextNoteEvent.KIND, ClassifiedsEvent.KIND, HighlightEvent.KIND, + PollEvent.KIND, WikiNoteEvent.KIND, NipTextEvent.KIND, CommunityPostApprovalEvent.KIND, @@ -54,6 +56,7 @@ val HomePostsFromCommunityKindsStr = LongTextNoteEvent.KIND.toString(), ClassifiedsEvent.KIND.toString(), HighlightEvent.KIND.toString(), + PollEvent.KIND.toString(), WikiNoteEvent.KIND.toString(), NipTextEvent.KIND.toString(), CommunityPostApprovalEvent.KIND.toString(), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt index f9449bb97..8fc0f713a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/dal/NotificationFeedFilter.kt @@ -66,6 +66,7 @@ import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent @@ -105,6 +106,7 @@ class NotificationFeedFilter( LnZapEvent.KIND, LiveActivitiesChatMessageEvent.KIND, PictureEvent.KIND, + PollEvent.KIND, PollNoteEvent.KIND, PrivateDmEvent.KIND, PublicMessageEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt index 281dd35f4..78b274973 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageScreen.kt @@ -58,7 +58,6 @@ import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.actions.UrlUserTagTransformation -import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton @@ -233,9 +232,7 @@ fun PublicMessageScreenContent( accountViewModel.account.settings.defaultFileServer, onAdd = { alt, server, sensitiveContent, mediaQuality, _ -> postViewModel.upload(alt, if (sensitiveContent) "" else null, mediaQuality, server, accountViewModel.toastManager::toast, context) - if (server.type != ServerType.NIP95) { - accountViewModel.account.settings.changeDefaultFileServer(server) - } + accountViewModel.account.settings.changeDefaultFileServer(server) }, onDelete = postViewModel::deleteMediaToUpload, onCancel = { postViewModel.multiOrchestrator = null }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt index 66f94a7c8..660a390f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/conversations/dal/UserProfileConversationsFeedFilter.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip22Comments.CommentEvent import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent class UserProfileConversationsFeedFilter( @@ -64,6 +65,7 @@ class UserProfileConversationsFeedFilter( ( it.event is TextNoteEvent || it.event is PollNoteEvent || + it.event is PollResponseEvent || it.event is ChannelMessageEvent || it.event is LiveActivitiesChatMessageEvent || it.event is CommentEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt index 89d78c81b..f36547074 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/datasource/FilterUserProfilePosts.kt @@ -39,6 +39,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip51Lists.PinListEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent @@ -48,7 +49,7 @@ val UserProfilePostKinds1 = GenericRepostEvent.KIND, RepostEvent.KIND, LongTextNoteEvent.KIND, - PollNoteEvent.KIND, + PollEvent.KIND, HighlightEvent.KIND, WikiNoteEvent.KIND, VoiceEvent.KIND, @@ -63,6 +64,7 @@ val UserProfilePostKinds2 = InteractiveStoryPrologueEvent.KIND, CommentEvent.KIND, VoiceReplyEvent.KIND, + PollNoteEvent.KIND, PinListEvent.KIND, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt index 640b187f6..9cfc790a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryCardCompose.kt @@ -24,10 +24,12 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Column import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.EventFinderFilterAssemblerSubscription +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.components.LoadNote import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.navigation.navs.INav @@ -45,7 +47,6 @@ fun GalleryCardCompose( modifier: Modifier = Modifier, accountViewModel: AccountViewModel, nav: INav, - ratio: Float = 1.0f, ) { WatchNoteEvent(baseNote = baseNote, accountViewModel = accountViewModel, nav, shortPreview = true) { CheckHiddenFeedWatchBlockAndReport( @@ -66,15 +67,15 @@ fun GalleryCardCompose( } if (redirectToEventId != null) { - LoadNote(baseNoteHex = redirectToEventId, accountViewModel = accountViewModel) { sourceNote -> - if (sourceNote != null) { + LoadNote(baseNoteHex = redirectToEventId, accountViewModel = accountViewModel) { baseSourceNote -> + if (baseSourceNote != null) { + val sourceNote by observeNote(baseSourceNote, accountViewModel) RedirectableGalleryCard( galleryNote = baseNote, - sourceNote = sourceNote, + sourceNote = sourceNote.note, modifier = modifier, accountViewModel = accountViewModel, nav = nav, - ratio = ratio, ) } else { RedirectableGalleryCard( @@ -83,7 +84,6 @@ fun GalleryCardCompose( modifier = modifier, accountViewModel = accountViewModel, nav = nav, - ratio = ratio, ) } } @@ -94,7 +94,6 @@ fun GalleryCardCompose( modifier = modifier, accountViewModel = accountViewModel, nav = nav, - ratio = ratio, ) } } @@ -108,7 +107,6 @@ fun RedirectableGalleryCard( modifier: Modifier = Modifier, accountViewModel: AccountViewModel, nav: INav, - ratio: Float = 1.0f, ) { QuickActionGallery(baseNote = galleryNote, accountViewModel = accountViewModel) { showPopup -> ClickableNote( @@ -127,7 +125,7 @@ fun RedirectableGalleryCard( note = galleryNote, accountViewModel = accountViewModel, ) { - GalleryThumbnail(galleryNote, accountViewModel, nav, ratio = ratio) + GalleryThumbnail(galleryNote, accountViewModel, nav) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt index 03aaa59eb..78e914483 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/GalleryThumb.kt @@ -20,63 +20,58 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery -import android.content.Context import androidx.annotation.OptIn import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.size -import androidx.compose.material3.IconButton +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PlayCircleOutline +import androidx.compose.material3.Icon 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.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.viewinterop.AndroidView import androidx.media3.common.util.UnstableApi -import androidx.media3.ui.AspectRatioFrameLayout -import androidx.media3.ui.PlayerView import coil3.compose.AsyncImagePainter import coil3.compose.SubcomposeAsyncImage import coil3.compose.SubcomposeAsyncImageContent +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.MediaUrlContent import com.vitorpamplona.amethyst.commons.richtext.MediaUrlImage import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo import com.vitorpamplona.amethyst.commons.richtext.RichTextParser.Companion.isVideoUrl import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.service.playback.composable.GetVideoController -import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled import com.vitorpamplona.amethyst.ui.components.AutoNonlazyGrid -import com.vitorpamplona.amethyst.ui.components.ClickableUrl import com.vitorpamplona.amethyst.ui.components.DisplayBlurHash -import com.vitorpamplona.amethyst.ui.components.ImageUrlWithDownloadButton import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.components.SensitivityWarning import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.note.DownloadForOfflineIcon import com.vitorpamplona.amethyst.ui.note.WatchAuthor import com.vitorpamplona.amethyst.ui.note.elements.BannerImage import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.QuoteBorder -import com.vitorpamplona.amethyst.ui.theme.Size75dp +import com.vitorpamplona.amethyst.ui.theme.Size50Modifier import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoEvent +import kotlinx.coroutines.flow.map @Composable fun GalleryThumbnail( baseNote: Note, accountViewModel: AccountViewModel, nav: INav, - ratio: Float = 1.0f, ) { val noteState by observeNote(baseNote, accountViewModel) val noteEvent = noteState.note.event ?: return @@ -137,7 +132,7 @@ fun GalleryThumbnail( emptyList() } - InnerRenderGalleryThumb(content, baseNote, accountViewModel, ratio) + InnerRenderGalleryThumb(content, baseNote, accountViewModel) } @Composable @@ -145,10 +140,9 @@ fun InnerRenderGalleryThumb( content: List, note: Note, accountViewModel: AccountViewModel, - ratio: Float = 1.0f, ) { if (content.isNotEmpty()) { - GalleryContentView(content, accountViewModel, ratio = ratio) + GalleryContentView(content, accountViewModel) } else { DisplayGalleryAuthorBanner(note, accountViewModel) } @@ -160,28 +154,30 @@ fun DisplayGalleryAuthorBanner( accountViewModel: AccountViewModel, ) { WatchAuthor(note, accountViewModel) { author -> - BannerImage(author, Modifier.fillMaxSize().clip(QuoteBorder), accountViewModel) + BannerImage( + author, + Modifier + .fillMaxSize() + .clip(QuoteBorder), + accountViewModel, + ) } } -@androidx.annotation.OptIn(UnstableApi::class) +@OptIn(UnstableApi::class) @Composable fun GalleryContentView( contentList: List, accountViewModel: AccountViewModel, - ratio: Float = 1.0f, ) { - AutoNonlazyGrid(contentList.size) { contentIndex -> + AutoNonlazyGrid(contentList.size, modifier = Modifier.fillMaxSize()) { contentIndex -> when (val content = contentList[contentIndex]) { - is MediaUrlImage -> { - SensitivityWarning(content.contentWarning != null, accountViewModel) { - UrlImageView(content, accountViewModel, ratio = ratio) - } - } - - is MediaUrlVideo -> { - SensitivityWarning(content.contentWarning != null, accountViewModel) { - UrlVideoView(content, accountViewModel, ratio = ratio) + is MediaUrlContent -> { + val hasSensitiveContent = + (content is MediaUrlVideo && content.contentWarning != null) || + (content is MediaUrlImage && content.contentWarning != null) + SensitivityWarning(hasSensitiveContent, accountViewModel) { + UrlImageView(content, accountViewModel) } } } @@ -190,17 +186,15 @@ fun GalleryContentView( @Composable fun UrlImageView( - content: MediaUrlImage, + content: MediaUrlContent, accountViewModel: AccountViewModel, - alwayShowImage: Boolean = false, - ratio: Float = 1.0f, ) { - val defaultModifier = Modifier.fillMaxSize().aspectRatio(ratio) + val defaultModifier = Modifier.fillMaxSize() val showImage = remember { mutableStateOf( - if (alwayShowImage) true else accountViewModel.settings.showImages(), + accountViewModel.settings.showImages(), ) } @@ -231,7 +225,14 @@ fun UrlImageView( } is AsyncImagePainter.State.Error -> { - ClickableUrl(urlText = "${content.url} ", url = content.url) + Box(defaultModifier, contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Default.PlayCircleOutline, + contentDescription = stringRes(id = R.string.play), + modifier = Size50Modifier, + tint = Color.White, + ) + } } is AsyncImagePainter.State.Success -> { @@ -249,86 +250,19 @@ fun UrlImageView( ContentScale.Crop, defaultModifier.clickable { showImage.value = true }, ) - IconButton( - modifier = Modifier.size(Size75dp), - onClick = { showImage.value = true }, - ) { - DownloadForOfflineIcon(Size75dp, Color.White) - } + Icon( + imageVector = Icons.Default.PlayCircleOutline, + contentDescription = stringRes(id = R.string.play), + modifier = Size50Modifier, + tint = Color.White, + ) } else { - ImageUrlWithDownloadButton(content.url, showImage) - } - } - } -} - -@OptIn(UnstableApi::class) -@Composable -fun UrlVideoView( - content: MediaUrlVideo, - accountViewModel: AccountViewModel, - ratio: Float = 1.0f, -) { - val defaultModifier = Modifier.fillMaxSize().aspectRatio(ratio) - - val automaticallyStartPlayback = - remember(content) { - mutableStateOf(accountViewModel.settings.startVideoPlayback()) - } - - Box(defaultModifier, contentAlignment = Alignment.Center) { - if (content.blurhash != null) { - // Always displays Blurharh to avoid size flickering - DisplayBlurHash( - content.blurhash, - null, - ContentScale.Crop, - defaultModifier, - ) - } - - if (!automaticallyStartPlayback.value) { - IconButton( - modifier = Modifier.size(Size75dp), - onClick = { automaticallyStartPlayback.value = true }, - ) { - DownloadForOfflineIcon(Size75dp, Color.White) - } - } else { - GetMediaItem( - videoUri = content.url, - title = content.description, - artworkUri = content.artworkUri, - authorName = content.authorName, - callbackUri = content.uri, - mimeType = content.mimeType, - aspectRatio = ratio, - proxyPort = accountViewModel.httpClientBuilder.proxyPortForVideo(content.url), - ) { mediaItem -> - GetVideoController( - mediaItem = mediaItem, - muted = true, - ) { controller -> - AndroidView( - modifier = Modifier, - factory = { context: Context -> - PlayerView(context).apply { - clipToOutline = true - player = controller.controller - setShowBuffering(PlayerView.SHOW_BUFFERING_ALWAYS) - - controllerAutoShow = false - useController = false - - hideController() - - resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FILL - - controller.controller?.playWhenReady = true - } - }, - ) - } + Icon( + imageVector = Icons.Default.PlayCircleOutline, + contentDescription = stringRes(id = R.string.play), + modifier = Size50Modifier, + tint = Color.White, + ) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt index a7bdbf0b0..cc80416fb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/ProfileGalleryFeed.kt @@ -115,7 +115,6 @@ private fun GalleryFeedLoaded( .animateItem(), accountViewModel = accountViewModel, nav = nav, - ratio = ratio, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt index 83a1f3986..60556e157 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/gallery/dal/UserProfileGalleryFeedFilter.kt @@ -21,9 +21,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.gallery.dal import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.model.filter import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder import com.vitorpamplona.amethyst.ui.dal.FilterByListParams @@ -31,7 +33,9 @@ import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryE import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent -import com.vitorpamplona.quartz.nip71Video.VideoEvent +import com.vitorpamplona.quartz.nip71Video.RegularVideoEvent +import com.vitorpamplona.quartz.nip71Video.ReplaceableVideoEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent class UserProfileGalleryFeedFilter( val user: User, @@ -51,6 +55,12 @@ class UserProfileGalleryFeedFilter( acceptableEvent(it, params, user) } + val addressableNotes = + LocalCache.addressables.filter( + listOf(VideoVerticalEvent.KIND, VideoVerticalEvent.KIND), + user.pubkeyHex, + ) + return sort(notes).toList() } @@ -72,7 +82,8 @@ class UserProfileGalleryFeedFilter( ( it.event?.pubKey == user.pubkeyHex && ( noteEvent is PictureEvent || - noteEvent is VideoEvent || + noteEvent is RegularVideoEvent || + (noteEvent is ReplaceableVideoEvent && it is AddressableNote) || (noteEvent is ProfileGalleryEntryEvent && noteEvent.hasUrl() && noteEvent.hasFromEvent()) ) ) // && noteEvent.isOneOf(SUPPORTED_VIDEO_FEED_MIME_TYPES_SET)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt index 860e270b7..cf401a723 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/newthreads/dal/UserProfileNewThreadFeedFilter.kt @@ -41,6 +41,7 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent @@ -80,6 +81,7 @@ class UserProfileNewThreadFeedFilter( it.event is WikiNoteEvent || it.event is NipTextEvent || it.event is PollNoteEvent || + it.event is PollEvent || it.event is HighlightEvent || it.event is InteractiveStoryPrologueEvent || it.event is AudioTrackEvent || diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt index 71947abfd..9f64b6f30 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/ThreadFeedView.kt @@ -156,6 +156,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderTextEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTextModificationEvent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrent import com.vitorpamplona.amethyst.ui.note.types.RenderTorrentComment +import com.vitorpamplona.amethyst.ui.note.types.RenderZapPoll import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.note.types.VoiceHeader import com.vitorpamplona.amethyst.ui.painterRes @@ -233,6 +234,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprov import com.vitorpamplona.quartz.nip72ModCommunities.communityAddress import com.vitorpamplona.quartz.nip72ModCommunities.isACommunityPost import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent @@ -668,7 +670,7 @@ private fun FullBleedNoteCompose( nav, ) } else if (noteEvent is PollNoteEvent) { - RenderPoll( + RenderZapPoll( baseNote, false, canPreview, @@ -678,6 +680,16 @@ private fun FullBleedNoteCompose( accountViewModel, nav, ) + } else if (noteEvent is PollEvent) { + RenderPoll( + note = baseNote, + makeItShort = false, + canPreview = canPreview, + quotesLeft = 3, + backgroundColor = backgroundColor, + accountViewModel = accountViewModel, + nav = nav, + ) } else if (noteEvent is PrivateDmEvent) { RenderPrivateMessage( baseNote, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt index ce07d2449..f1bd3b144 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/theme/Shape.kt @@ -221,8 +221,9 @@ val NotificationIconModifierSmaller = Modifier.width(55.dp).padding(end = 4.dp) val ZapPictureCommentModifier = Modifier.height(35.dp).widthIn(min = 35.dp) val ChatHeadlineBorders = StdPadding -val VolumeBottomIconSize = Modifier.size(70.dp).padding(10.dp) -val PinBottomIconSize = Modifier.size(70.dp).padding(10.dp) +val VolumeBottomIconSize = Modifier.size(60.dp).padding(5.dp) +val PinBottomIconSize = Modifier.size(60.dp).padding(5.dp) +val PlayIconSize = Modifier.size(110.dp).padding(10.dp) val NIP05IconSize = Modifier.size(13.dp).padding(top = 1.dp, start = 1.dp, end = 1.dp) val CashuCardBorders = Modifier.fillMaxWidth().padding(10.dp).clip(shape = QuoteBorder) @@ -375,6 +376,7 @@ val QuickActionPopupShadow = Modifier.shadow(elevation = Size6dp, shape = Smalle val SpacedBy2dp = Arrangement.spacedBy(Size2dp) val SpacedBy5dp = Arrangement.spacedBy(Size5dp) val SpacedBy10dp = Arrangement.spacedBy(Size10dp) +val SpacedBy55dp = Arrangement.spacedBy(Size55dp) val PopupUpEffect = RoundedCornerShape(0.dp, 0.dp, 15.dp, 15.dp) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorDialogViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorDialogViewModel.kt index ef1e4fc09..8db02d322 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorDialogViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorDialogViewModel.kt @@ -42,7 +42,7 @@ class TorDialogViewModel : ViewModel() { val videosViaTor = mutableStateOf(false) val moneyOperationsViaTor = mutableStateOf(false) val nip05VerificationsViaTor = mutableStateOf(false) - val nip96UploadsViaTor = mutableStateOf(false) + val mediaUploadsViaTor = mutableStateOf(false) val preset = derivedStateOf { @@ -60,7 +60,7 @@ class TorDialogViewModel : ViewModel() { videosViaTor = videosViaTor.value, moneyOperationsViaTor = moneyOperationsViaTor.value, nip05VerificationsViaTor = nip05VerificationsViaTor.value, - nip96UploadsViaTor = nip96UploadsViaTor.value, + mediaUploadsViaTor = mediaUploadsViaTor.value, ), ) } @@ -78,7 +78,7 @@ class TorDialogViewModel : ViewModel() { videosViaTor.value = torSettings.videosViaTor moneyOperationsViaTor.value = torSettings.moneyOperationsViaTor nip05VerificationsViaTor.value = torSettings.nip05VerificationsViaTor - nip96UploadsViaTor.value = torSettings.nip96UploadsViaTor + mediaUploadsViaTor.value = torSettings.mediaUploadsViaTor } fun save(): TorSettings = @@ -95,7 +95,7 @@ class TorDialogViewModel : ViewModel() { videosViaTor = videosViaTor.value, moneyOperationsViaTor = moneyOperationsViaTor.value, nip05VerificationsViaTor = nip05VerificationsViaTor.value, - nip96UploadsViaTor = nip96UploadsViaTor.value, + mediaUploadsViaTor = mediaUploadsViaTor.value, ) fun setPreset(preset: TorPresetType) { @@ -119,6 +119,6 @@ class TorDialogViewModel : ViewModel() { videosViaTor.value = torSettings.videosViaTor moneyOperationsViaTor.value = torSettings.moneyOperationsViaTor nip05VerificationsViaTor.value = torSettings.nip05VerificationsViaTor - nip96UploadsViaTor.value = torSettings.nip96UploadsViaTor + mediaUploadsViaTor.value = torSettings.mediaUploadsViaTor } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt index d60b06de0..ff1146c70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettings.kt @@ -35,7 +35,7 @@ data class TorSettings( val videosViaTor: Boolean = false, val moneyOperationsViaTor: Boolean = false, val nip05VerificationsViaTor: Boolean = false, - val nip96UploadsViaTor: Boolean = false, + val mediaUploadsViaTor: Boolean = false, ) enum class TorType( @@ -90,7 +90,7 @@ fun isPreset( torSettings.videosViaTor == preset.videosViaTor && torSettings.moneyOperationsViaTor == preset.moneyOperationsViaTor && torSettings.nip05VerificationsViaTor == preset.nip05VerificationsViaTor && - torSettings.nip96UploadsViaTor == preset.nip96UploadsViaTor + torSettings.mediaUploadsViaTor == preset.mediaUploadsViaTor fun whichPreset(torSettings: TorSettings): TorPresetType { if (isPreset(torSettings, torOnlyWhenNeededPreset)) return TorPresetType.ONLY_WHEN_NEEDED @@ -112,7 +112,7 @@ val torOnlyWhenNeededPreset = videosViaTor = false, moneyOperationsViaTor = false, nip05VerificationsViaTor = false, - nip96UploadsViaTor = false, + mediaUploadsViaTor = false, ) val torDefaultPreset = TorSettings( @@ -126,7 +126,7 @@ val torDefaultPreset = videosViaTor = false, moneyOperationsViaTor = false, nip05VerificationsViaTor = false, - nip96UploadsViaTor = false, + mediaUploadsViaTor = false, ) val torSmallPayloadsPreset = TorSettings( @@ -140,7 +140,7 @@ val torSmallPayloadsPreset = videosViaTor = false, moneyOperationsViaTor = true, nip05VerificationsViaTor = true, - nip96UploadsViaTor = false, + mediaUploadsViaTor = false, ) val torFullyPrivate = TorSettings( @@ -154,5 +154,5 @@ val torFullyPrivate = videosViaTor = true, moneyOperationsViaTor = true, nip05VerificationsViaTor = true, - nip96UploadsViaTor = true, + mediaUploadsViaTor = true, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt index d98bb953a..d64e072ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsDialog.kt @@ -291,9 +291,9 @@ fun PrivacySettingsBody(dialogViewModel: TorDialogViewModel) { ) SwitchSettingsRow( - R.string.tor_use_nip96_uploads, - R.string.tor_use_nip96_uploads_explainer, - dialogViewModel.nip96UploadsViaTor, + R.string.tor_use_media_uploads, + R.string.tor_use_media_uploads_explainer, + dialogViewModel.mediaUploadsViaTor, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt index c445b4235..4a8698c3a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/tor/TorSettingsFlow.kt @@ -36,7 +36,7 @@ class TorSettingsFlow( val videosViaTor: MutableStateFlow = MutableStateFlow(false), val moneyOperationsViaTor: MutableStateFlow = MutableStateFlow(false), val nip05VerificationsViaTor: MutableStateFlow = MutableStateFlow(false), - val nip96UploadsViaTor: MutableStateFlow = MutableStateFlow(false), + val mediaUploadsViaTor: MutableStateFlow = MutableStateFlow(false), ) { // emits at every change in any of the properties. val propertyWatchFlow = @@ -54,7 +54,7 @@ class TorSettingsFlow( videosViaTor, moneyOperationsViaTor, nip05VerificationsViaTor, - nip96UploadsViaTor, + mediaUploadsViaTor, ), ) { flows -> TorSettings( @@ -88,7 +88,7 @@ class TorSettingsFlow( videosViaTor.value, moneyOperationsViaTor.value, nip05VerificationsViaTor.value, - nip96UploadsViaTor.value, + mediaUploadsViaTor.value, ) fun update(torSettings: TorSettings): Boolean { @@ -143,8 +143,8 @@ class TorSettingsFlow( nip05VerificationsViaTor.tryEmit(torSettings.nip05VerificationsViaTor) any = true } - if (nip96UploadsViaTor.value != torSettings.nip96UploadsViaTor) { - nip96UploadsViaTor.tryEmit(torSettings.nip96UploadsViaTor) + if (mediaUploadsViaTor.value != torSettings.mediaUploadsViaTor) { + mediaUploadsViaTor.tryEmit(torSettings.mediaUploadsViaTor) any = true } @@ -166,7 +166,7 @@ class TorSettingsFlow( MutableStateFlow(torSettings.videosViaTor), MutableStateFlow(torSettings.moneyOperationsViaTor), MutableStateFlow(torSettings.nip05VerificationsViaTor), - MutableStateFlow(torSettings.nip96UploadsViaTor), + MutableStateFlow(torSettings.mediaUploadsViaTor), ) } } diff --git a/amethyst/src/main/res/drawable/ic_chess.xml b/amethyst/src/main/res/drawable/ic_chess.xml new file mode 100644 index 000000000..5786fe6c1 --- /dev/null +++ b/amethyst/src/main/res/drawable/ic_chess.xml @@ -0,0 +1,9 @@ + + + diff --git a/amethyst/src/main/res/values-ar-rSA/strings.xml b/amethyst/src/main/res/values-ar-rSA/strings.xml index 20539cee1..f3eb061cf 100644 --- a/amethyst/src/main/res/values-ar-rSA/strings.xml +++ b/amethyst/src/main/res/values-ar-rSA/strings.xml @@ -393,8 +393,8 @@ المعاملات المالية التحقق من عنوان Nostr أستخدام Tor عند التحقق من عناوين NIP-05 - رفع الوسائط - أستخدام Tor عند رفع الوسائط + رفع الوسائط + أستخدام Tor عند رفع الوسائط مضمن إيقاف أساسي diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index d0383cf67..15603d28e 100644 --- a/amethyst/src/main/res/values-bn-rBD/strings.xml +++ b/amethyst/src/main/res/values-bn-rBD/strings.xml @@ -15,10 +15,15 @@ মেসেজটি ডিক্রিপ্ট করা যায় নি দলগত ছবি খোলামেলা আধেয় + রিলে নোটিশ + ডুপ্লিকেট পোস্ট স্প্যাম + এই রিলে থেকে আসা স্প্যামিং ইভেন্টের সংখ্যা ছদ্মবেশ বেআইনী আচরণ অন্যান্য + হয়রানি + Violence অচেনা রিলে আইকন অজ্ঞাত-পরিচয় লেখক @@ -26,6 +31,8 @@ লেখক আইডি কপি করুন নোটের আইডি কপি করুন সম্প্রচার + টাইমস্ট্যাম্প করুন + টাইমস্ট্যাম্প: নিশ্চিতকরণ মুলতুবি আছে নোটটি মুছে ফেলার অনুরোধ করুন ব্লক/অভিযুক্ত করুন @@ -33,6 +40,9 @@ ছদ্মবেশ ধারণের অভিযোগ করুন খোলামেলা আধেয় হিসেবে অভিযোগ করুন বেআইনী আচরণের অভিযোগ করুন + ম্যালওয়্যার রিপোর্ট করুন + রিপোর্ট মোড + ম্যালওয়্যার মোড প্রতিউত্তর দিতে ব্যক্তিগত চাবি দিয়ে লগ ইন করুন পোস্টগুলি বুস্ট করতে ব্যক্তিগত চাবি দিয়ে লগ ইন করুন diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 06a21b6cd..fb894c055 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -424,6 +424,7 @@ Povinná pole: Příjemci zapsů Hlavní popis hlasování… + Možnost Možnost %s Popis možnosti hlasování Volitelná pole: @@ -431,6 +432,8 @@ Maximální zaps Konsensus (0–100)% + Datum a čas ukončení ankety + Anketa končí za %1$s Uzavřít po dnech Nebylo možné hlasovat @@ -505,6 +508,7 @@ Sleduje přes proxy Kolem mě Globální + Šachy Seznam ztlumení Seznamy sledování Toto jsou follow seznamy určené pro vaše vlastní použití. Můžete sledovat uživatele soukromě nebo veřejně. @@ -591,8 +595,8 @@ Vynutit Tor na zaps, lightning a cashu převody Ověření Nostr adresy Vynutit Tor při ověřování NIP-05 adres - Nahrávání médií - Vynutit Tor při nahrávání obsahu + Nahrávání médií + Vynutit Tor při nahrávání obsahu Interní Orbot Vypnuto @@ -1003,6 +1007,7 @@ Upozornění Globální Krátké + Šachy Bezpečnostní filtry Nový příspěvek Nové Shorts: obrázky nebo videa @@ -1237,4 +1242,35 @@ Smazat seznam Smazat sadu doporučení Typy + Opakování selhalo + Klepnutím zobrazíte podrobnosti + %1$s odesláno + Výsledky odesílání + Odesílání (%1$d) + Sbalit + Rozbalit + Opakovat selhané (%1$d) + +%1$d dalších odesílání + " (%1$d probíhá)" + %1$d/%2$d relayů + Časový limit vypršel + Opakuje se… + Opakovat + Zobrazit + Zavřít + %1$d/%2$d + Odesílání + Odesílání %1$s + Odesílání %1$d událostí… + Odesláno %1$d událostí + Některé události selhaly + Všechny události byly úspěšné + Všechny události selhaly + Reakce + Hlasový příspěvek + Hlasová odpověď + Čas ukončení + Potvrdit + Další + Přidat možnost ankety diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index fc5948211..b3e6b35dc 100644 --- a/amethyst/src/main/res/values-cs/strings.xml +++ b/amethyst/src/main/res/values-cs/strings.xml @@ -444,8 +444,8 @@ Vynutit Tor na zaps, lightning a cashu převody Ověření Nostr adresy Vynutit Tor při ověřování NIP-05 adres - Nahrávání médií - Vynutit Tor při nahrávání obsahu + Nahrávání médií + Vynutit Tor při nahrávání obsahu Interní Orbot Vypnuto diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 77c479819..75de70e59 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -430,6 +430,7 @@ anz der Bedingungen ist erforderlich Erforderliche Felder: Zap-Empfänger Hauptbeschreibung der Umfrage… + Wahl Wahl %s Beschreibung der Umfrageoption Optionale Felder: @@ -437,6 +438,8 @@ anz der Bedingungen ist erforderlich Maximaler Zap-Betrag Konsens (0–100)% + Schließdatum und -zeit + Umfrage endet in %1$s Schließen nach Tagen Konnte nicht abstimmen @@ -511,6 +514,7 @@ anz der Bedingungen ist erforderlich Folgt über Proxy In der Nähe Weltweit + Schach Stummliste Follower-Listen Dies sind Follow-Listen für deine eigene Nutzung. Du kannst Nutzer privat oder öffentlich folgen. @@ -596,8 +600,8 @@ anz der Bedingungen ist erforderlich Erzwingen Sie Tor bei Zaps, Lightning und Cashu-Transfers Nostr Adressverifizierung Erzwingen Sie Tor bei der Überprüfung von NIP-05-Adressen - Medien-Uploads - Erzwingen Sie Tor beim Hochladen von Inhalten + Medien-Uploads + Erzwingen Sie Tor beim Hochladen von Inhalten Intern Orbot Aus @@ -1008,6 +1012,7 @@ anz der Bedingungen ist erforderlich Benachrichtigungen Global Kurzfilme + Schach Sicherheitsfilter Neuer Beitrag Neue Kurzfilme: Bilder oder Videos @@ -1242,4 +1247,35 @@ anz der Bedingungen ist erforderlich Liste löschen Empfehlungspaket löschen Typen + Erneuter Versuch fehlgeschlagen + Tippen, um Details anzuzeigen + %1$s gesendet + Sendeergebnisse + Sendungen (%1$d) + Einklappen + Ausklappen + Fehlgeschlagene erneut senden (%1$d) + +%1$d weitere Sendungen + " (%1$d in Bearbeitung)" + %1$d/%2$d Relays + Zeitüberschreitung + Erneuter Versuch… + Erneut versuchen + Anzeigen + Schließen + %1$d/%2$d + Senden + Sende %1$s + Sende %1$d Ereignisse… + %1$d Ereignisse gesendet + Einige Ereignisse sind fehlgeschlagen + Alle Ereignisse waren erfolgreich + Alle Ereignisse sind fehlgeschlagen + Reaktion + Sprachbeitrag + Sprachantwort + Schließzeit + Bestätigen + Weiter + Umfrageoption hinzufügen diff --git a/amethyst/src/main/res/values-de/strings.xml b/amethyst/src/main/res/values-de/strings.xml index fd7c5ffa8..7e962d5e0 100644 --- a/amethyst/src/main/res/values-de/strings.xml +++ b/amethyst/src/main/res/values-de/strings.xml @@ -449,8 +449,8 @@ anz der Bedingungen ist erforderlich Erzwingen Sie Tor bei Zaps, Lightning und Cashu-Transfers Nostr Adressverifizierung Erzwingen Sie Tor bei der Überprüfung von NIP-05-Adressen - Medien-Uploads - Erzwingen Sie Tor beim Hochladen von Inhalten + Medien-Uploads + Erzwingen Sie Tor beim Hochladen von Inhalten Intern Orbot Aus diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index 205e5287f..592c21cb8 100644 --- a/amethyst/src/main/res/values-es-rES/strings.xml +++ b/amethyst/src/main/res/values-es-rES/strings.xml @@ -452,7 +452,7 @@ Público Privado Mixto - Al parecer, aún no tienes ningún conjunto de seguimiento. + Al parecer, aún no tienes ningún conjunto de seguimiento. \nPulsa abajo para actualizar o pulsa los botones de añadir para crear uno nuevo. Añadir autor a conjunto de seguimiento @@ -502,8 +502,8 @@ Forzar Tor en transferencias de zaps, Lightning y Cashu Verificación de dirección de Nostr Forzar Tor al verificar direcciones NIP-05 - Subidas multimedia - Forzar Tor al subir contenido + Subidas multimedia + Forzar Tor al subir contenido Interno Orbot Desactivado diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index 64f8bcab9..21b744527 100644 --- a/amethyst/src/main/res/values-es-rMX/strings.xml +++ b/amethyst/src/main/res/values-es-rMX/strings.xml @@ -447,7 +447,7 @@ Público Privado Mixto - Al parecer, aún no tienes ningún conjunto de seguimiento. + Al parecer, aún no tienes ningún conjunto de seguimiento. \nToca abajo para actualizar o toca los botones de agregar para crear uno nuevo. Agregar autor a conjunto de seguimiento @@ -497,8 +497,8 @@ Forzar Tor en transferencias de zaps, Lightning y Cashu Verificación de dirección de Nostr Forzar Tor al verificar direcciones NIP-05 - Subidas multimedia - Forzar Tor al subir contenido + Subidas multimedia + Forzar Tor al subir contenido Interno Orbot Desactivado diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index bb790d8d7..b9a8b7b91 100644 --- a/amethyst/src/main/res/values-es-rUS/strings.xml +++ b/amethyst/src/main/res/values-es-rUS/strings.xml @@ -447,7 +447,7 @@ Público Privado Mixto - Al parecer, aún no tienes ningún conjunto de seguimiento. + Al parecer, aún no tienes ningún conjunto de seguimiento. \nToca abajo para actualizar o toca los botones de agregar para crear uno nuevo. Agregar autor a conjunto de seguimiento @@ -497,8 +497,8 @@ Forzar Tor en transferencias de zaps, Lightning y Cashu Verificación de dirección de Nostr Forzar Tor al verificar direcciones NIP-05 - Subidas multimedia - Forzar Tor al subir contenido + Subidas multimedia + Forzar Tor al subir contenido Interno Orbot Desactivado diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index 7ee325eb6..617b462a7 100644 --- a/amethyst/src/main/res/values-fa-rIR/strings.xml +++ b/amethyst/src/main/res/values-fa-rIR/strings.xml @@ -446,8 +446,8 @@ تور را برای زپ، لایتنینگ و انتقال کَشو الزامی کن تایید آدرس ناستر تور را برای تایید آدرس های NIP-05 الزامی کن - بارگذاری رسانه - تور را برای بارگذاری محتوا الزامی کن + بارگذاری رسانه + تور را برای بارگذاری محتوا الزامی کن داخلی Orbot خاموش diff --git a/amethyst/src/main/res/values-fa/strings.xml b/amethyst/src/main/res/values-fa/strings.xml index 0c2e5174b..ac4b7bee3 100644 --- a/amethyst/src/main/res/values-fa/strings.xml +++ b/amethyst/src/main/res/values-fa/strings.xml @@ -428,8 +428,8 @@ تور را برای زپ، لایتنینگ و انتقال کَشو الزامی کن تایید آدرس ناستر تور را برای تایید آدرس های NIP-05 الزامی کن - بارگذاری رسانه - تور را برای بارگذاری محتوا الزامی کن + بارگذاری رسانه + تور را برای بارگذاری محتوا الزامی کن داخلی Orbot خاموش diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index 5ca190f7b..6f19c6d69 100644 --- a/amethyst/src/main/res/values-fr-rFR/strings.xml +++ b/amethyst/src/main/res/values-fr-rFR/strings.xml @@ -503,16 +503,25 @@ Liste des Abonnés Tous les abonnements Tous les abonnements de l\'utilisateur + Liste de suivi par défaut Suivre via Proxy Autour de moi Général Liste en Sourdine + Listes de suivi + Ce sont des listes de suivi conçues pour votre propre utilisation. Vous pouvez suivre des utilisateurs en privé ou publiquement Marque-pages étiquetés Marque-pages généraux Public Privé Mixte + Il semble que vous n\'ayez pas encore de liste de suivi. + \n Appuyez ci-dessous pour actualiser, ou appuyez sur les boutons d\'ajout pour en créer un nouveau. + Nouveau + Ajouter l\'auteur à la liste de suivi + Ajouter ou supprimer un utilisateur des listes, ou créer une nouvelle liste avec cet utilisateur. + Icône pour la liste de suivi %1$s est un membre public %1$s est un membre privé Ajouter en tant que membre public @@ -529,9 +538,14 @@ %1$s n\'est pas un membre Vos listes et %1$s Vos listes + Aucune liste de suivi n\'a été trouvée, ou vous n\'avez aucune liste de suivi. Appuyez ci-dessous pour rafraîchir, ou utilisez le menu pour en créer une. Un problème est survenu lors de la récupération : %1$s Créer une nouvelle liste Nouvelle liste avec %1$s membres + Crée une nouvele liste de suivi et ajoute %1$s en tant que membre %2$s. + Nouvelle liste de suivi + Nouveau pack de suivi + Copier/Cloner la liste de suivi Modifier la description Cette liste n\'a pas de description Description actuelle : @@ -581,8 +595,8 @@ Forcer Tor pour les transferts en zaps, lightning ou cashu Vérification de l\'adresse Nostr Forcer Tor lors de la vérification des adresses NIP-05 - Téléversements de média - Forcer Tor lors du téléversement de contenu + Téléversements de média + Forcer Tor lors du téléversement de contenu Interne Orbot Désactivé @@ -642,11 +656,17 @@ Le nombre d\'octets qui ont été reçus depuis ce relais, y compris les filtres et les événements Une erreur s\'est produite en récupérant les informations du relay depuis %1$s Propriétaire + Clé de service + Exécution %1$s + Exécution %1$s (%2$s) Version Logiciel Contact NIPs supportés + Grasps supportées Frais d\'admission + Admission + Souscription Publication Paiements %1$s URL de payments @@ -677,6 +697,9 @@ Accepte jusqu\'à %1$s dans le futur Il y a %1$s + %1$s zeros + %1$s bits + Rétention de l\'événement Taille du contenu Connectivité Contrôle d\'accès @@ -685,6 +708,11 @@ Authentification requise Paiement Paiement requis + Longueur du message maximum + Souscription maximale + Nombre maximum de filtres par sub + Limite Max (Renvoi des Évènements) + Longueur maximale du SubID Jeton Cashu Échanger Envoyer vers le portefeuille Zap @@ -1152,6 +1180,7 @@ Échec de l\'ouverture du fichier Aucune application torrent installée pour ouvrir et télécharger le fichier. L\'événement ne possède pas assez d\'informations pour construire un lien magnet + Mes listes Mes listes Utilisateurs Sélectionnez une liste pour filtrer le fil @@ -1162,6 +1191,8 @@ Le relais auquel tous les utilisateurs de ce chat se connectent Partager l\'image… Impossible de partager l\'image, veuillez réessayer plus tard… + Impossible de partager l\'image, veuillez réessayer plus tard… + Téléchargement de la vidéo… Hashtag de recherche : #%1$s Ne pas traduire depuis Les langues affichées ici ne seront pas traduites. Sélectionnez une langue pour la supprimer et l\'avoir traduite à nouveau. @@ -1175,6 +1206,7 @@ Voulez-vous envoyer le rapport d\'erreur récent à Amethyst dans un MP ? Aucune information personnelle ne sera partagée Envoyer Ce message disparaîtra dans %1$d jours + Sélectionner un signataire Déjà dans la liste Membres privés Membres publics @@ -1182,7 +1214,10 @@ Membres publics (%1$s) Ajouter l\'utilisateur à la liste Supprimer l\'utilisateur de la liste + Pack de suivi Membres + Pack de suivi metadata + Modifier le Pack de Suivi Nom du pack Nom du nouveau pack Description du pack @@ -1191,4 +1226,11 @@ Pack de diffusion Supprimer la liste Supprimer le pack + Kinds + Appuyer pour afficher les détails + %1$s envoyé + Diffusions (%1$d) + Réduire + Agrandir + +%1$d diffusions supplémentaires diff --git a/amethyst/src/main/res/values-fr/strings.xml b/amethyst/src/main/res/values-fr/strings.xml index bb35fd681..2272f8520 100644 --- a/amethyst/src/main/res/values-fr/strings.xml +++ b/amethyst/src/main/res/values-fr/strings.xml @@ -440,8 +440,8 @@ Forcer Tor pour les transferts en zaps, lightning ou cashu Vérification de l\'adresse Nostr Forcer Tor lors de la vérification des adresses NIP-05 - Téléversements de média - Forcer Tor lors du téléversement de contenu + Téléversements de média + Forcer Tor lors du téléversement de contenu Interne Orbot Désactivé diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index e2fd5d7b5..1a863423e 100644 --- a/amethyst/src/main/res/values-hi-rIN/strings.xml +++ b/amethyst/src/main/res/values-hi-rIN/strings.xml @@ -426,6 +426,7 @@ अनिवार्य प्रपत्रस्थान : ज्साप के ग्राहक प्रमुख मतदान विवरण… + विकल्प विकल्प %s मतदान विकल्प विवरण प्रपत्रस्थान जो अनिवार्य नहीं : @@ -433,6 +434,8 @@ ज्साप अधिकतम सर्वसम्मति (० से १००) प्रतिशत + मतदान समापन दिनांक तथा समय + मतदान %1$s में समाप्त होगा के पश्चात समाप्त करें दिन निर्वाचन करने में असफल @@ -476,6 +479,7 @@ आपके प्रसारसंगणक आद्यताएँ स्थापित करें। आपका कोई निप॰-९६ सेवासंगणक स्थापित नहीं। आप अमेथिस्ट की सूची का प्रयोग कर सकते हैं अथवा नीचे एक जोड सकते हैं ↓ आपका कोई ब्लोस्सम॰ प्रसारसंगणक स्थापित नहीं। आप अमेथिस्ट की सूची का प्रयोग कर सकते हैं अथवा नीचे एक जोड सकते हैं ↓ + अनुशम्सित प्रसारसंगणक अन्तर्निहित प्रसारसंगणक अमेथिस्त की मूलविकल्प सूची। आप एक एक करके जोड सकते हैं अथवा सूची जोड सकते हैं। मूलविकल्प सूची का प्रयोग करें @@ -507,6 +511,7 @@ प्रतिनिधि द्वारा अनुचरित मेरे आसपास वैश्विक + चतुरंग मौन सूची अनुचरण सूचियाँ ये अनुचरण सूचियाँ आपके उपयोग के लिए रूपांकित हैं। आप प्रयोक्ताओं का अनुचरण कर सकते हैं निजी अथवा सार्वजनिक रूप से @@ -595,8 +600,8 @@ ज्साप लैटनिंग तथा काशयु व्यापार के लिए टोर का प्रयोग अवश्य करें नोस्ट्र पता सत्यापन निप॰-०५ पता सत्यापन के लिए टोर का प्रयोग अवश्य करें - चित्र चलचित्र अभिलेख आरोहण - चित्र चलचित्र अभिलेख आरोहण के लिए टोर का प्रयोग अवश्य करें + चित्र चलचित्र अभिलेख आरोहण + चित्र चलचित्र अभिलेख आरोहण के लिए टोर का प्रयोग अवश्य करें आन्तरीय ओर्बोट निष्क्रिय @@ -1008,6 +1013,7 @@ सूचनाएँ वैश्विक छोटे + चतुरंग सुरक्षार्थ छलनियाँ नया पत्र प्रकाशन नये छोटे : चित्र अथवा चलचित्र @@ -1242,4 +1248,35 @@ सूची मिटाएँ पोटली मिटाएँ प्रकार + पुनःप्रयास असफल + विवरण देखने के लिए दबाएँ + %1$s प्रेषित + प्रसारण फल + प्रसारण (%1$d) + संकुचित करें + विस्तृत करें + पुनःप्रयास असफल (%1$d) + +%1$d अधिक प्रसारण + " (%1$d चालू)" + %1$d/%2$d पुनःप्रसारक + समयान्त + पुनःप्रयास चालू… + पुनःप्रयास करें + देखें + हटाएँ + %1$d/%2$d + प्रसारण चालू + %1$s का प्रसारण चालू + %1$d घटनाओं का प्रसारण चालू + %1$d घटनाएँ प्रेषित + कुछ घटनाएँ असफल + सभी घटनाएँ सफल + सभी घटनाएँ असफल + प्रतिक्रिया + ध्वनि सन्देश + ध्वनि उत्तर + समापन समय + पुष्टि करें + अगला + मतदान विकल्प घुण्डी जोडें diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 5c4b548ec..23b9ab04b 100644 --- a/amethyst/src/main/res/values-hu-rHU/strings.xml +++ b/amethyst/src/main/res/values-hu-rHU/strings.xml @@ -426,6 +426,7 @@ Kötelező mezők: Zap-kedvezményezettek Szavazás elsődleges leírása… + Lehetőség %s. válaszlehetőség Válaszlehetőség leírása Nem kötelező mezők: @@ -433,6 +434,8 @@ Maximum Zap Együttműködés (0–100)% + Szavazás lezárásának dátuma és ideje + A szavazás lezárul %1$s múlva Szavazás lezárása nap Nem lehet szavazni @@ -476,6 +479,7 @@ Állítsa be a kívánt médiafeltöltő-kiszolgálókat. Nincsenek beállítva NIP-96 kiszolgálók. Használhatja az Amethyst listáját, vagy hozzáadhat egyet az alábbiakból ↓ Nincsenek Blossom-kiszolgálók beállítva. Használhatja az Amethyst listáját, vagy hozzáadhat egyet az alábbiakból ↓ + Ajánlott médiakiszolgálók Beépített médiakiszolgálók Az Amethyst alapértelmezett listája. Hozzáadhatja őket egyenként, vagy hozzáadhatja a listát. Alapértelmezett lista használata @@ -507,6 +511,7 @@ Követés proxyn keresztül Közelben lévők bejegyzései Globális + Sakk Némítottak bejegyzései Követési listák Ezek a követési listák az Ön saját alkalmazás-használatából lettek kialakítva. A felhasználókat privát vagy nyilvánosan követheti @@ -595,8 +600,8 @@ Tor kényszerítése Zap küldésekor, lightning és cashu tranzakciókhoz Nostr-cím hitelesítése Tor kényszerítése a NIP-05 címek hitelesítésekor - Média-feltöltések - Tor kényszerítése média feltöltésekor + Médiafeltöltések + Tor kényszerítése tartalomfeltöltéskor Beépített Orbot Kikapcsolva @@ -1008,6 +1013,7 @@ Értesítések Globális Rövidek + Sakk Biztonsági szűrők Új bejegyzés Új rövidek: képek vagy videók @@ -1242,4 +1248,35 @@ Lista törlése Csomag törlése Változatok + Siikertelen újrapróbálkozás + Koppintson a részletek megtekintéséhez + %1$s elküldve + Közvetítési eredmények + Közvetítés (%1$d) + Összecsukás + Kibontás + Sikertelen újrapróbálkozás (%1$d) + több mint %1$d közvetítés + " (%1$d folyamatban)" + %1$d/%2$d továbbító + Időtúllépés + Újrapróbálkozás… + Újrapróbálkozás + Megtekintés + Elutasítás + %1$d/%2$d + Közvetítés + %1$s közvetítése + %1$d esemény közvetítése… + %1$d esemény elküldve + Néhány esemény sikertelen + Az összes esemény sikeres + Az összes esemény sikertelen + Reakció + Hangos bejegyzés + Hangos válasz + Befejezés időpontja + Megerősítés + Következő + Szavazati lehetőség hozzáadása diff --git a/amethyst/src/main/res/values-hu/strings.xml b/amethyst/src/main/res/values-hu/strings.xml index 039cf9427..3248c74f8 100644 --- a/amethyst/src/main/res/values-hu/strings.xml +++ b/amethyst/src/main/res/values-hu/strings.xml @@ -446,8 +446,8 @@ Tor kényszerítése Zap küldésekor, lightning és cashu tranzakciókhoz Nostr-cím hitelesítése Tor kényszerítése a NIP-05 címek hitelesítésekor - Média-feltöltések - Tor kényszerítése média feltöltésekor + Média-feltöltések + Tor kényszerítése média feltöltésekor Beépített Orbot Kikapcsolva diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 0d30b2b23..decfe0372 100644 --- a/amethyst/src/main/res/values-nl-rNL/strings.xml +++ b/amethyst/src/main/res/values-nl-rNL/strings.xml @@ -537,8 +537,8 @@ Forceer Tor op zaps, lightning en cashu transfers Nostr Adres verificatie Forceer Tor bij het verifiëren van NIP-05 adressen - Mediauploads - Forceer Tor bij het uploaden van content + Mediauploads + Forceer Tor bij het uploaden van content Intern Orbot Uit diff --git a/amethyst/src/main/res/values-nl/strings.xml b/amethyst/src/main/res/values-nl/strings.xml index e68d33635..d33b02be1 100644 --- a/amethyst/src/main/res/values-nl/strings.xml +++ b/amethyst/src/main/res/values-nl/strings.xml @@ -439,8 +439,8 @@ Forceer Tor op zaps, lightning en cashu transfers Nostr Adres verificatie Forceer Tor bij het verifiëren van NIP-05 adressen - Mediauploads - Forceer Tor bij het uploaden van content + Mediauploads + Forceer Tor bij het uploaden van content Intern Orbot Uit diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 71dbb02d9..7155a5890 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -423,6 +423,7 @@ Wymagane pola: Odbiorcy zap Podstawowy opis ankiety… + Opcja Opcja %s Opis opcji ankiety Pola opcjonalne: @@ -430,6 +431,8 @@ Maksymalny Zap Konsensus (0–100)% + Data & godzina zakończenia ankiety + Ankieta zostanie zamknięta za %1$s Zamknij po dni Nie można głosować @@ -473,6 +476,7 @@ Ustaw swoje preferowane serwery przesyłania multimediów. Nie masz ustawionych własnych serwerów NIP-96. Możesz użyć listy Amethysta lub dodać jeden poniżej ↓ Nie masz ustawionych własnych serwerów multimediów. Możesz użyć listy Amethysta lub dodać jeden poniżej ↓ + Polecane serwery multimediów Wbudowane serwery multimediów Domyślna lista Amethyst-a. Możesz dodawać je pojedynczo lub dodać pełną listę. Użyj domyślnej listy @@ -504,6 +508,7 @@ Obserwuje przez proxy W pobliżu Wszystkie + Szachy Zablokowane Listy obserwowanych Są to listy obserwowanych przeznaczone do prywatnego użytku. Możesz obserwować użytkowników dyskretnie lub otwarcie @@ -592,8 +597,8 @@ Wymuś Tor przy zapsach, transferach lightning and cashu Weryfikacja adresu Nostr Wymuś Tor podczas weryfikacji adresów NIP-05 - Wgrywanie mediów - Wymuś Tor podczas wgrywania zawartości + Wgrywanie mediów + Wymuś Tor podczas wgrywania zawartości Wewnętrznie Orbot Wył. @@ -1005,6 +1010,7 @@ Powiadomienia Wszystkie Filmiki + Szachy Filtry bezpieczeństwa Nowy post Nowe Króciaki: zdjęcia lub filmiki @@ -1239,4 +1245,35 @@ Usuń listę Usuń kategorię Typy + Powtórka nieudana + Naciśnij, aby zobaczyć szczegóły + %1$s wysłano + Wyniki przekazu + Przekazów (%1$d) + Zwiń + Rozwiń + Nieudanych powtórzeń (%1$d) + +%1$d kolejnych przekazów + " (%1$d w toku)" + %1$d/%2$d transmiterów + Czas się skończył + Ponawiam próbę… + Ponów + Pokaż + Ignoruj + %1$d/%2$d + Przekaz + Przekaz %1$s + Liczba przekazów: %1$d... + Wysłanych przekazów: %1$d + Niektóre akcje nie powiodły się + Wszystkie akcje udane + Wszystkie akcje nieudane + Reakcja + Wiadomość głosowa + Odpowiedź głosowa + Czas zakończenia głosowania + Potwierdź + Dalej + Przycisk \"Dodaj opcję ankiety\" diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 09aa6def8..5de3bf6d5 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -424,6 +424,7 @@ Campos obrigatórios: Destinatários Zap Descrição da enquete principal… + Opção Opção %s Descrição da opção de enquete Campos opcionais: @@ -431,6 +432,8 @@ Zap máximo Consenso (0–100)% + Data e hora de encerramento da enquete + Enquete encerra em %1$s Fechar depois dias Impossibilitado de votar @@ -505,6 +508,7 @@ Segue via proxy Perto de mim Global + Xadrez Lista Silenciada Listas de seguidores Essas são listas de seguidores criadas para seu próprio uso. Você pode seguir usuários de forma privada ou pública. @@ -591,8 +595,8 @@ Forçar Tor em zaps, transfers relâmpago e cashu Verificação de Endereço Nostr Forçar Tor ao verificar endereços NIP-05 - Envio de Mídia - Forçar Tor ao enviar conteúdo + Envio de Mídia + Forçar Tor ao enviar conteúdo Interno Orbot Desativado @@ -1003,6 +1007,7 @@ Notificações Global Vídeos Curtos + Xadrez Filtros de Segurança Novo Post Novos Vídeos Curtos: imagens ou vídeos @@ -1237,4 +1242,35 @@ Excluir lista Excluir pacote de recomendações Tipos + Nova tentativa falhou + Toque para ver detalhes + %1$s enviado + Resultados do envio + Envios (%1$d) + Recolher + Expandir + Tentar novamente falhas (%1$d) + +%1$d envios adicionais + " (%1$d em andamento)" + %1$d/%2$d relés + Tempo esgotado + Tentando novamente… + Tentar novamente + Ver + Dispensar + %1$d/%2$d + Enviando + Enviando %1$s + Enviando %1$d eventos… + %1$d eventos enviados + Alguns eventos falharam + Todos os eventos foram bem-sucedidos + Todos os eventos falharam + Reação + Post de voz + Resposta em voz + Hora de encerramento + Confirmar + Próximo + Adicionar opção à enquete diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index d72e872f7..4e5686b9d 100644 --- a/amethyst/src/main/res/values-sl-rSI/strings.xml +++ b/amethyst/src/main/res/values-sl-rSI/strings.xml @@ -437,6 +437,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Zahtevana polja: Prejemniki Zapov Opis primarne ankete… + Izbira Izbira %s Opis izbire v anketi Izbirna polja: @@ -444,6 +445,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Zap maksimum Soglasje (0–100)% + Datum in čas konca glasovanja + Glasovanje se zaključi %1$s Zapri po dni Nezmožen za glasovanje @@ -487,6 +490,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Nastavi svoje prednostne medijske strežnike za nalaganje medijev. Nimate nastavljenih NIP-96 strežnikov. Uporabite lahko Amethyst-ov seznam ali dodajte enega spodaj ↓ Nimate nastavljenih Blossom strežnikov. Uporabite lahko Amethyst-ov seznam ali dodajte enega spodaj ↓ + Priporočljivi medijski strežniki Vgrajeni medijski strežniki Privzeti seznam Amethysta. Lahko jih dodate posamično ali dodate celoten seznam. Uporabi privzeti seznam @@ -518,6 +522,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Sledi preko posrednika V moji okolici Globalno + Šah Spisek utišanih Seznam sledenih To so seznami sledenih, namenjeni za vašo lastno uporabo. Uporabnike lahko sledite javno ali zasebno @@ -606,8 +611,8 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Prisili tor pri Zapih, Lightning in Cashu plačilnih prenosih Preveritev Nostr naslova Prisili tor ob preverjanju NIP-05 naslovov - Nalaganje medijske vsebine - Prisili tor pri nalaganju vsebine + Nalaganje medijske vsebine + Prisili tor pri nalaganju vsebine Interno Orbot Izključeno @@ -1019,6 +1024,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Obvestila Globalno Kratki mediji + Šah Varnostni filtri Nova objava Novi kratki mediji: slike ali posnetki @@ -1214,6 +1220,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Rele, na katerega so povezani vsi uporabniki tega klepeta Deli sliko… Deljenje slike ni uspelo, prosimo poskusite znova… + Deli video… + Videa ni bilo mogoče deliti, prosim poskusite kasneje… + Prenašam video … Išči ključnik: #%1$s Ne prevedi iz Tu prikazani jeziki ne bodo prevedeni. Izberite jezik, da ga odstranite in ga znova prevedete. @@ -1251,4 +1260,35 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Izbriši seznam Izbriši paket Tipi + Ponovni poskus ni uspel + Kliknite za ogled podrobnosti + %1$s poslano + Rezultat oddajanja + Razpošlji (%1$d) + Strni + Razširi + Ponovni poskus ni uspel (%1$d) + +%1$d več oddajanj + " (%1$d v teku)" + %1$d/%2$d releji + Čas je potekel + Poskušam ponovno… + Poskusi ponovno + Ogled + Zavrni + %1$d/%2$d + Oddajanje + Oddajam %1$s + Oddajam %1$d dogodkov... + Poslano %1$d dogodkov + Nekaterim dogodkom ni uspelo + Vsem dogodkom je uspelo + Vsem dogodkom ni uspelo + Odziv + Glasovna objava + Zvočni odgovor + Končni čas + Potrdi + Naprej + Dodaj gumb za glasovanje diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index e8be7b477..f3b97af83 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -424,6 +424,7 @@ Obligatoriska fält: Zap mottagare Beskrivning av primär omröstning… + Alternativ Alternativ %s Beskrivning av omröstningsalternativ Valfria fält: @@ -431,6 +432,8 @@ Maximal Zap Konsensus (0–100)% + Stängningsdatum och tid + Omröstningen stänger om %1$s Avsluta efter dagar Kunde inte rösta @@ -505,6 +508,7 @@ Följer via proxy Runt mig Global + Schack Tyst listan Följelistor Detta är följlistor avsedda för ditt eget bruk. Du kan följa användare privat eller offentligt. @@ -590,8 +594,8 @@ Tvinga Tor på zaps, lightning och cashu överföringar Nostr Adress Verifiering Tvinga Tor vid verifiering av NIP-05 adresser - Mediauppladdningar - Tvinga Tor vid uppladdning av innehåll + Mediauppladdningar + Tvinga Tor vid uppladdning av innehåll Intern Orbot Av @@ -1002,6 +1006,7 @@ Aviseringar Globalt Kortfilmer + Schack Säkerhetsfilter Nytt inlägg Nya kort: bilder eller videor @@ -1236,4 +1241,35 @@ Ta bort lista Ta bort rekommendationspaket Typer + Försök igen misslyckades + Tryck för att visa detaljer + %1$s skickad + Sändningsresultat + Sändningar (%1$d) + Fäll ihop + Fäll ut + Försök igen misslyckade (%1$d) + +%1$d fler sändningar + " (%1$d pågår)" + %1$d/%2$d reläer + Tidsgräns överskriden + Försöker igen… + Försök igen + Visa + Avfärda + %1$d/%2$d + Sänder + Sänder %1$s + Sänder %1$d händelser… + %1$d händelser skickade + Vissa händelser misslyckades + Alla händelser lyckades + Alla händelser misslyckades + Reaktion + Röstinlägg + Röstsvar + Stängningstid + Bekräfta + Nästa + Lägg till omröstningsalternativ diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 23e488f30..593ec3732 100644 --- a/amethyst/src/main/res/values-zh-rCN/strings.xml +++ b/amethyst/src/main/res/values-zh-rCN/strings.xml @@ -426,6 +426,7 @@ 必填字段: 打闪接收方 投票主要说明… + 选项 选项 %s 投票选项说明 可选字段: @@ -433,6 +434,8 @@ 打闪最高金额 共识 (0–100)% + 投票结束日期 & 时间 + 投票结束于 %1$s 后关闭 无法投票 @@ -476,6 +479,7 @@ 设置首选媒体上传服务器。 尚未设置 NIP-96 服务器。可以使用默认的内置服务器列表或自行添加 ↓ 尚未设置 Blossom 服务器。可以使用默认的内置服务器列表或自行添加 ↓ + 推荐的媒体服务器 内置媒体服务器 Amethyst 的默认列表。可单独添加或添加整个列表。 使用默认列表 @@ -507,6 +511,7 @@ 通过代理关注 周围的人 全球 + 国际象棋 静音列表 关注集 这些是为您自己使用设计的关注列表。您可以私下或公开关注用户 @@ -595,8 +600,8 @@ 强制为 Zap、闪电网络和 Cashu 转账时使用 Tor Nostr 地址验证 强制为 NIP-05 地址验证使用 Tor - 媒体上传 - 强制为上传媒体内容时使用 Tor + 媒体上传 + 强制为上传媒体内容时使用 Tor 内置 Orbot 关闭 @@ -1008,6 +1013,7 @@ 通知 全球 短篇 + 国际象棋 安全滤镜 新帖子 新短篇媒体:图像或视频 @@ -1242,4 +1248,35 @@ 删除列表 删除包 类型 + 重试失败 + 轻按查看详情 + 已发送 %1$s + 广播结果 + 广播 (%1$d) + 折叠 + 展开 + 重试失败(%1$d) + 另外 %1$d 条广播 + " (%1$d 正在进行中)" + %1$d/%2$d 个中继 + 超时 + 重试中… + 重试 + 查看 + 取消 + %1$d/%2$d + 广播中 + 正在广播 %1$s + 正在广播 %1$d 个事件…… + %1$d 个事件已发送 + 一些事件失败了 + 所有事件均已成功 + 所有事件均失败 + 回应 + 语音帖子 + 语音回复 + 结束时间 + 确认 + 下一个 + 添加投票选项按钮 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0f1b45f0a..04cebd1bf 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -460,6 +460,7 @@ Required fields: Zap recipients Primary poll description… + Option Option %s Poll option description Optional fields: @@ -467,6 +468,8 @@ Zap maximum Consensus (0–100)% + Poll Closing Date & Time + Poll closes in %1$s Close after days Unable to vote @@ -532,6 +535,7 @@ You have no NIP-96 servers set. You can use Amethyst\'s list, or add one below ↓ You have no Blossom servers set. You can use Amethyst\'s list, or add one below ↓ + Recommended Media Servers Built-in Media Servers Amethyst\'s default list. You can add them individually or add the list. Use Default List @@ -570,6 +574,7 @@ Follows via Proxy Around Me Global + Chess Mute List Follow Lists @@ -676,8 +681,8 @@ Nostr Address Verification Force Tor when verifying NIP-05 addresses - Media Uploads - Force Tor when uploading content + Media Uploads + Force Tor when uploading content Internal Orbot @@ -1190,6 +1195,7 @@ Notifications Global Shorts + Chess Security Filters New Post @@ -1504,4 +1510,8 @@ Reaction Voice Post Voice Reply + Closing Time + Confirm + Next + Add poll option button diff --git a/ammolite/build.gradle b/ammolite/build.gradle index 7bbfc6eb6..fffb5cf02 100644 --- a/ammolite/build.gradle +++ b/ammolite/build.gradle @@ -44,7 +44,6 @@ android { kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_21 - freeCompilerArgs.add("-Xstring-concat=inline") } } diff --git a/benchmark/build.gradle b/benchmark/build.gradle index ac57e856b..d2a5ae81f 100644 --- a/benchmark/build.gradle +++ b/benchmark/build.gradle @@ -48,7 +48,6 @@ android { kotlin { compilerOptions { jvmTarget = JvmTarget.JVM_21 - freeCompilerArgs.add("-Xstring-concat=inline") } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt index f5b738388..296a8927f 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/ChaCha20Benchmark.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20 import com.vitorpamplona.quartz.utils.RandomInstance @@ -39,8 +39,8 @@ class ChaCha20Benchmark { val nip44v2 = Nip44v2() val msg = "Hi, how are you? this is supposed to be representative of an average message on Nostr" - val privateKey = Nip01.privKeyCreate() - val publicKey = Nip01.pubKeyCreate(privateKey) + val privateKey = Nip01Crypto.privKeyCreate() + val publicKey = Nip01Crypto.pubKeyCreate(privateKey) val sharedKey = nip44v2.getConversationKey(privateKey, publicKey) diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt index 9f364ba56..62c3e676a 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapReceivingBenchmark.kt @@ -170,7 +170,7 @@ class GiftWrapReceivingBenchmark { wrap.pubKey.hexToByteArray(), ) - benchmarkRule.measureRepeated { assertNotNull(innerJson?.let { Event.fromJson(it) }) } + benchmarkRule.measureRepeated { assertNotNull(innerJson.let { Event.fromJson(it) }) } } @Test @@ -205,6 +205,6 @@ class GiftWrapReceivingBenchmark { seal.pubKey.hexToByteArray(), ) - benchmarkRule.measureRepeated { assertNotNull(innerJson?.let { Rumor.fromJson(it) }) } + benchmarkRule.measureRepeated { assertNotNull(innerJson.let { Rumor.fromJson(it) }) } } } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt index ce0ffccaf..1395ab720 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/GiftWrapSigningBenchmark.kt @@ -138,7 +138,7 @@ class GiftWrapSigningBenchmark { benchmarkRule.measureRepeated { runBlocking { GiftWrapEvent.create( - event = seal!!, + event = seal, recipientPubKey = receiver.pubKey, ) } diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HkdfBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HkdfBenchmark.kt index 766ceccd8..c28360603 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HkdfBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/HkdfBenchmark.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 import com.vitorpamplona.quartz.nip44Encryption.crypto.Hkdf import org.junit.Rule @@ -38,8 +38,8 @@ class HkdfBenchmark { val nip44v2 = Nip44v2() val msg = "Hi, how are you? this is supposed to be representative of an average message on Nostr" - val privateKey = Nip01.privKeyCreate() - val publicKey = Nip01.pubKeyCreate(privateKey) + val privateKey = Nip01Crypto.privKeyCreate() + val publicKey = Nip01Crypto.pubKeyCreate(privateKey) val sharedKey = nip44v2.getConversationKey(privateKey, publicKey) val encrypted = nip44v2.encrypt(msg, sharedKey) diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Nip44EncryptDecryptBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Nip44EncryptDecryptBenchmark.kt index 265e6cb13..953824bc4 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Nip44EncryptDecryptBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/Nip44EncryptDecryptBenchmark.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.benchmark import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.nip44Encryption.Nip44v2 import org.junit.Rule import org.junit.Test @@ -37,8 +37,8 @@ class Nip44EncryptDecryptBenchmark { val nip44v2 = Nip44v2() val msg = "Hi, how are you? this is supposed to be representative of an average message on Nostr" - val privateKey = Nip01.privKeyCreate() - val publicKey = Nip01.pubKeyCreate(privateKey) + val privateKey = Nip01Crypto.privKeyCreate() + val publicKey = Nip01Crypto.pubKeyCreate(privateKey) val sharedKey = nip44v2.getConversationKey(privateKey, publicKey) val encrypted = nip44v2.encrypt(msg, sharedKey) diff --git a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/SignVerifyBenchmark.kt b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/SignVerifyBenchmark.kt index 7aea1caa0..5baa0b1c1 100644 --- a/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/SignVerifyBenchmark.kt +++ b/benchmark/src/androidTest/java/com/vitorpamplona/quartz/benchmark/SignVerifyBenchmark.kt @@ -24,7 +24,7 @@ import androidx.benchmark.junit4.BenchmarkRule import androidx.benchmark.junit4.measureRepeated import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.sha256.sha256 import junit.framework.TestCase.assertNotNull @@ -42,17 +42,17 @@ class SignVerifyBenchmark { val keyPair = KeyPair() val msg = sha256(RandomInstance.bytes(1000)) - benchmarkRule.measureRepeated { assertNotNull(Nip01.sign(msg, keyPair.privKey!!)) } + benchmarkRule.measureRepeated { assertNotNull(Nip01Crypto.sign(msg, keyPair.privKey!!)) } } @Test fun verify() { val keyPair = KeyPair() val msg = sha256(RandomInstance.bytes(1000)) - val signature = Nip01.sign(msg, keyPair.privKey!!) + val signature = Nip01Crypto.sign(msg, keyPair.privKey!!) benchmarkRule.measureRepeated { - assertTrue(Nip01.verify(signature, msg, keyPair.pubKey)) + assertTrue(Nip01Crypto.verify(signature, msg, keyPair.pubKey)) } } } diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 9c2b2e91c..07c10502a 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -10,7 +10,6 @@ plugins { kotlin { compilerOptions { - freeCompilerArgs.add("-Xstring-concat=inline") freeCompilerArgs.add("-Xexpect-actual-classes") } @@ -22,8 +21,14 @@ kotlin { androidLibrary { namespace = "com.vitorpamplona.amethyst.commons" - compileSdk = libs.versions.android.compileSdk.get().toInt() - minSdk = libs.versions.android.minSdk.get().toInt() + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + minSdk = + libs.versions.android.minSdk + .get() + .toInt() compilerOptions { jvmTarget.set(JvmTarget.JVM_21) @@ -75,13 +80,14 @@ kotlin { } // Shared JVM code for both Android and Desktop - val jvmAndroid = create("jvmAndroid") { - dependsOn(commonMain.get()) - dependencies { - // URL detection (JVM library, works on both) - implementation(libs.url.detector) + val jvmAndroid = + create("jvmAndroid") { + dependsOn(commonMain.get()) + dependencies { + // URL detection (JVM library, works on both) + implementation(libs.url.detector) + } } - } jvmMain { dependsOn(jvmAndroid) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt new file mode 100644 index 000000000..eb7b528af --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/AcceptedGamesRegistry.kt @@ -0,0 +1,57 @@ +/* + * 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.commons.chess + +/** + * Global registry of accepted game IDs. + * + * This singleton ensures that accepted game state is shared across all ViewModel instances. + * Without this, different ViewModels (e.g., lobby vs game screen) would have separate + * acceptedGameIds sets, causing the game screen to incorrectly load as spectator. + */ +object AcceptedGamesRegistry { + private val acceptedGameIds = mutableSetOf() + private val lock = Any() + + fun markAsAccepted(gameId: String) { + synchronized(lock) { + acceptedGameIds.add(gameId) + } + } + + fun wasAccepted(gameId: String): Boolean = + synchronized(lock) { + acceptedGameIds.contains(gameId) + } + + fun clear() { + synchronized(lock) { + acceptedGameIds.clear() + } + } + + /** Remove old entries - call periodically to prevent memory leak */ + fun clearOldEntries(keepGameIds: Set) { + synchronized(lock) { + acceptedGameIds.retainAll(keepGameIds) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt new file mode 100644 index 000000000..dc0cec5bb --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBoard.kt @@ -0,0 +1,146 @@ +/* + * 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.commons.chess + +import androidx.compose.foundation.Image +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.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +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.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.quartz.nip64Chess.ChessPiece +import com.vitorpamplona.quartz.nip64Chess.ChessPosition +import com.vitorpamplona.quartz.nip64Chess.PieceType +import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor + +/** + * Renders an 8x8 chess board with pieces + * Shared composable for Android and Desktop platforms + * + * @param position The chess position to display + * @param modifier Modifier for the board + * @param boardSize Total size of the board in dp + * @param showCoordinates Whether to show file labels (a-h) on bottom rank + */ +@Composable +fun ChessBoard( + position: ChessPosition, + modifier: Modifier = Modifier, + boardSize: Dp = 320.dp, + showCoordinates: Boolean = true, +) { + val squareSize = boardSize / 8 + + Column(modifier = modifier.size(boardSize)) { + // Render ranks 8 down to 1 (from white's perspective) + for (rank in 7 downTo 0) { + Row { + for (file in 0..7) { + val piece = position.pieceAt(file, rank) + val isLightSquare = (rank + file) % 2 == 0 + + ChessSquare( + piece = piece, + isLight = isLightSquare, + size = squareSize, + coordinate = + if (showCoordinates && rank == 0) { + ('a' + file).toString() + } else { + null + }, + ) + } + } + } + } +} + +/** + * Renders a single square on the chess board + */ +@Composable +private fun ChessSquare( + piece: com.vitorpamplona.quartz.nip64Chess.ChessPiece?, + isLight: Boolean, + size: Dp, + coordinate: String?, +) { + Box( + modifier = + Modifier + .size(size) + .background( + if (isLight) Color(0xFFF0D9B5) else Color(0xFFB58863), + ), + contentAlignment = Alignment.Center, + ) { + // Display piece using CBurnett ImageVector icons + piece?.let { + Image( + imageVector = it.toImageVector(), + contentDescription = "${it.color} ${it.type}", + modifier = Modifier.fillMaxSize().padding(2.dp), + ) + } + + // Show file coordinate (a-h) on bottom rank + coordinate?.let { + Text( + text = it, + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = if (isLight) Color(0xFFB58863) else Color(0xFFF0D9B5), + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(2.dp), + ) + } + } +} + +/** + * Extension function to convert ChessPiece to CBurnett ImageVector + */ +private fun ChessPiece.toImageVector(): ImageVector { + val white = color == ChessColor.WHITE + return when (type) { + PieceType.KING -> if (white) ChessPieceVectors.WhiteKing else ChessPieceVectors.BlackKing + PieceType.QUEEN -> if (white) ChessPieceVectors.WhiteQueen else ChessPieceVectors.BlackQueen + PieceType.ROOK -> if (white) ChessPieceVectors.WhiteRook else ChessPieceVectors.BlackRook + PieceType.BISHOP -> if (white) ChessPieceVectors.WhiteBishop else ChessPieceVectors.BlackBishop + PieceType.KNIGHT -> if (white) ChessPieceVectors.WhiteKnight else ChessPieceVectors.BlackKnight + PieceType.PAWN -> if (white) ChessPieceVectors.WhitePawn else ChessPieceVectors.BlackPawn + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBroadcastBanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBroadcastBanner.kt new file mode 100644 index 000000000..e876cfe80 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessBroadcastBanner.kt @@ -0,0 +1,241 @@ +/* + * 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.commons.chess + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +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.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CloudSync +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.HourglassBottom +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** + * Shared banner showing chess broadcast status - broadcast progress, sync status, etc. + * Works on both Android and Desktop via Compose Multiplatform. + */ +@Composable +fun ChessBroadcastBanner( + status: ChessBroadcastStatus, + onTap: () -> Unit, + modifier: Modifier = Modifier, +) { + val isVisible = status !is ChessBroadcastStatus.Idle + + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(tween(200)), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(tween(150)), + modifier = modifier, + ) { + Surface( + color = getStatusBackgroundColor(status), + tonalElevation = 2.dp, + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onTap), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .animateContentSize(), + ) { + Icon( + imageVector = getStatusIcon(status), + contentDescription = null, + tint = getStatusIconColor(status), + modifier = Modifier.size(18.dp), + ) + + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = getStatusText(status), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(Modifier.width(8.dp)) + + Text( + text = getStatusDetail(status), + style = MaterialTheme.typography.labelMedium, + color = getStatusDetailColor(status), + ) + } + + // Show progress bar for broadcasting/syncing + val progress = getStatusProgress(status) + if (progress != null) { + Spacer(Modifier.height(4.dp)) + + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = tween(300), + label = "progress", + ) + + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier.fillMaxWidth(), + color = getStatusProgressColor(status), + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun getStatusBackgroundColor(status: ChessBroadcastStatus): Color = + when (status) { + is ChessBroadcastStatus.Failed, is ChessBroadcastStatus.Desynced -> { + MaterialTheme.colorScheme.errorContainer + } + + is ChessBroadcastStatus.Success -> { + MaterialTheme.colorScheme.primaryContainer + } + + else -> { + MaterialTheme.colorScheme.surfaceContainer + } + } + +private fun getStatusIcon(status: ChessBroadcastStatus): ImageVector = + when (status) { + is ChessBroadcastStatus.Broadcasting -> Icons.Default.Sync + is ChessBroadcastStatus.Success -> Icons.Default.CheckCircle + is ChessBroadcastStatus.Failed -> Icons.Default.Error + is ChessBroadcastStatus.WaitingForOpponent -> Icons.Default.HourglassBottom + is ChessBroadcastStatus.Syncing -> Icons.Default.CloudSync + is ChessBroadcastStatus.Desynced -> Icons.Default.Error + is ChessBroadcastStatus.Idle -> Icons.Default.CheckCircle + } + +@Composable +private fun getStatusIconColor(status: ChessBroadcastStatus): Color = + when (status) { + is ChessBroadcastStatus.Failed, is ChessBroadcastStatus.Desynced -> { + MaterialTheme.colorScheme.error + } + + is ChessBroadcastStatus.Success -> { + MaterialTheme.colorScheme.primary + } + + is ChessBroadcastStatus.WaitingForOpponent -> { + MaterialTheme.colorScheme.secondary + } + + else -> { + MaterialTheme.colorScheme.primary + } + } + +private fun getStatusText(status: ChessBroadcastStatus): String = + when (status) { + is ChessBroadcastStatus.Broadcasting -> "Broadcasting: ${status.san}" + is ChessBroadcastStatus.Success -> "Sent: ${status.san}" + is ChessBroadcastStatus.Failed -> "Failed: ${status.san}" + is ChessBroadcastStatus.WaitingForOpponent -> "Waiting for opponent's move..." + is ChessBroadcastStatus.Syncing -> "Syncing game state..." + is ChessBroadcastStatus.Desynced -> "Game desynced: ${status.message}" + is ChessBroadcastStatus.Idle -> "" + } + +private fun getStatusDetail(status: ChessBroadcastStatus): String = + when (status) { + is ChessBroadcastStatus.Broadcasting -> "[${status.successCount}/${status.totalRelays}]" + is ChessBroadcastStatus.Success -> "${status.relayCount} relays" + is ChessBroadcastStatus.Failed -> "Tap to retry" + is ChessBroadcastStatus.Syncing -> "${(status.progress * 100).toInt()}%" + is ChessBroadcastStatus.Desynced -> "Tap to resync" + else -> "" + } + +@Composable +private fun getStatusDetailColor(status: ChessBroadcastStatus): Color = + when (status) { + is ChessBroadcastStatus.Failed, is ChessBroadcastStatus.Desynced -> { + MaterialTheme.colorScheme.error + } + + else -> { + MaterialTheme.colorScheme.primary + } + } + +private fun getStatusProgress(status: ChessBroadcastStatus): Float? = + when (status) { + is ChessBroadcastStatus.Broadcasting -> status.progress + is ChessBroadcastStatus.Syncing -> status.progress + else -> null + } + +@Composable +private fun getStatusProgressColor(status: ChessBroadcastStatus): Color = + when (status) { + is ChessBroadcastStatus.Failed -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessConfig.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessConfig.kt new file mode 100644 index 000000000..71a25aa4e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessConfig.kt @@ -0,0 +1,56 @@ +/* + * 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.commons.chess + +/** + * Global chess configuration shared across Android and Desktop. + * + * These relays are the primary relays used by Jester and other Nostr chess apps. + * Using a small, fixed set ensures fast queries and reliable game discovery. + */ +object ChessConfig { + /** + * The 3 main relays for chess events. + * These are used for both fetching and publishing chess events. + */ + val CHESS_RELAYS = + listOf( + "wss://relay.damus.io", + "wss://nos.lol", + "wss://relay.primal.net", + ) + + /** + * Display names for the chess relays (without protocol prefix) + */ + val CHESS_RELAY_NAMES = + listOf( + "relay.damus.io", + "nos.lol", + "relay.primal.net", + ) + + /** + * Timeout for relay queries in milliseconds. + * With only 3 relays, we can wait for all of them. + */ + const val FETCH_TIMEOUT_MS = 10_000L +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt new file mode 100644 index 000000000..c15195dbf --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventCollector.kt @@ -0,0 +1,266 @@ +/* + * 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.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 kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.ConcurrentHashMap + +/** + * Collects and aggregates Jester chess events for a game from any source. + * + * This class provides a unified way to collect events regardless of whether + * they come from LocalCache (Android), direct relay (Desktop), or test fixtures. + * It handles: + * - Deduplication by event ID + * - Thread-safe concurrent updates + * - Producing JesterGameEvents snapshots for reconstruction + * + * Jester Protocol: + * - All chess events use kind 30 + * - Start events (content.kind=0) reference START_POSITION_HASH via e-tag + * - Move events (content.kind=1) reference [startEventId, headEventId] via e-tags + * - Full move history is included in every move event + * + * Usage: + * ``` + * val collector = ChessEventCollector(startEventId) + * + * // Add events as they arrive from any source + * collector.addEvent(jesterEvent) + * + * // Get current state for reconstruction + * val events = collector.getEvents() + * val result = ChessStateReconstructor.reconstruct(events, viewerPubkey) + * ``` + */ +class ChessEventCollector( + /** The start event ID - this is the game identifier in Jester protocol */ + val startEventId: String, +) { + // Start event (only one per game) + private val _startEvent = MutableStateFlow(null) + val startEvent: StateFlow = _startEvent.asStateFlow() + + // Move events (deduplicated by event ID) + private val moves = ConcurrentHashMap() + + // Track all processed event IDs for fast deduplication + private val processedEventIds = ConcurrentHashMap.newKeySet() + + // Flow that emits when any event is added (for reactive updates) + private val _eventCount = MutableStateFlow(0) + val eventCount: StateFlow = _eventCount.asStateFlow() + + /** + * Check if an event has already been processed. + */ + fun hasEvent(eventId: String): Boolean = processedEventIds.contains(eventId) + + /** + * Add a Jester event for this game. + * Automatically categorizes as start or move based on content.kind. + * + * @return true if the event was added, false if already exists or invalid + */ + fun addEvent(event: JesterEvent): Boolean { + if (processedEventIds.contains(event.id)) return false + + // Check if this event belongs to our game + val eventStartId = event.startEventId() + val isStartEvent = event.isStartEvent() && event.id == startEventId + val isMoveEvent = event.isMoveEvent() && eventStartId == startEventId + + if (!isStartEvent && !isMoveEvent) return false + + return if (isStartEvent) { + addStartEvent(event) + } else { + addMoveEvent(event) + } + } + + /** + * Add a raw Event if it's a valid Jester event for this game. + * + * @return true if the event was added, false if invalid or not for this game + */ + fun addRawEvent(event: Event): Boolean { + if (event.kind != JesterProtocol.KIND) return false + val jesterEvent = event.toJesterEvent() ?: return false + return addEvent(jesterEvent) + } + + /** + * Add the start event for this game. + * Only the first valid start is kept. + * + * @return true if the event was added, false if already exists or invalid + */ + private fun addStartEvent(event: JesterEvent): Boolean { + if (processedEventIds.contains(event.id)) return false + if (!event.isStartEvent()) return false + if (event.id != startEventId) return false + + if (_startEvent.compareAndSet(null, event)) { + processedEventIds.add(event.id) + incrementEventCount() + return true + } + return false + } + + /** + * Add a move event for this game. + * Moves are deduplicated by event ID. + * + * @return true if the event was added, false if already exists or invalid + */ + private fun addMoveEvent(event: JesterEvent): Boolean { + if (processedEventIds.contains(event.id)) return false + if (!event.isMoveEvent()) return false + if (event.startEventId() != startEventId) return false + + if (moves.putIfAbsent(event.id, event) == null) { + processedEventIds.add(event.id) + incrementEventCount() + return true + } + return false + } + + /** + * Get a snapshot of all collected events for reconstruction. + */ + fun getEvents(): JesterGameEvents = + JesterGameEvents( + startEvent = _startEvent.value, + moves = moves.values.toList(), + ) + + /** + * Check if the game has a start event. + */ + fun hasStartEvent(): Boolean = _startEvent.value != null + + /** + * Check if the game has any moves (indicates game is active). + */ + fun hasMoves(): Boolean = moves.isNotEmpty() + + /** + * Check if the game has ended (has a move with result). + */ + fun hasEnded(): Boolean = moves.values.any { it.result() != null } + + /** + * Get the number of moves collected. + */ + fun moveCount(): Int = moves.size + + /** + * Get the latest move (with longest history). + */ + fun latestMove(): JesterEvent? = moves.values.maxByOrNull { it.history().size } + + /** + * Clear all collected events. + */ + fun clear() { + _startEvent.value = null + moves.clear() + processedEventIds.clear() + _eventCount.value = 0 + } + + private fun incrementEventCount() { + _eventCount.value = processedEventIds.size + } +} + +/** + * Manager for multiple game collectors. + * + * This is useful when managing multiple concurrent games, + * such as in a chess lobby or when spectating multiple games. + */ +class ChessEventCollectorManager { + private val collectors = ConcurrentHashMap() + + /** + * Get or create a collector for a game. + * + * @param startEventId The start event ID (game identifier) + */ + fun getOrCreate(startEventId: String): ChessEventCollector = collectors.getOrPut(startEventId) { ChessEventCollector(startEventId) } + + /** + * Get a collector if it exists. + * + * @param startEventId The start event ID (game identifier) + */ + fun get(startEventId: String): ChessEventCollector? = collectors[startEventId] + + /** + * Remove a collector for a game. + * + * @param startEventId The start event ID (game identifier) + */ + fun remove(startEventId: String): ChessEventCollector? = collectors.remove(startEventId) + + /** + * Get all active game IDs (start event IDs). + */ + fun activeGameIds(): Set = collectors.keys.toSet() + + /** + * Clear all collectors. + */ + fun clear() { + collectors.values.forEach { it.clear() } + collectors.clear() + } + + /** + * Add an event to the appropriate collector (auto-routing). + * Creates a collector if needed for start events. + * + * @return true if the event was added to a collector + */ + fun addEvent(event: JesterEvent): Boolean { + // For start events, create collector with event ID + if (event.isStartEvent()) { + val collector = getOrCreate(event.id) + return collector.addEvent(event) + } + + // For move events, find the collector by startEventId + val startId = event.startEventId() ?: return false + val collector = collectors[startId] ?: return false + return collector.addEvent(event) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt new file mode 100644 index 000000000..f73db65d9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventPolling.kt @@ -0,0 +1,327 @@ +/* + * 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.commons.chess + +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.isActive +import kotlinx.coroutines.launch + +/** + * Configuration for chess event polling + */ +data class ChessPollingConfig( + /** Interval for polling active games (ms) */ + val activeGamePollInterval: Long = 10_000L, + /** Interval for polling challenges (ms) */ + val challengePollInterval: Long = 30_000L, + /** Whether to continue polling in background */ + val pollInBackground: Boolean = false, + /** Challenge expiry time (seconds) */ + val challengeExpirySeconds: Long = 24 * 60 * 60L, + /** Cleanup interval (ms) */ + val cleanupInterval: Long = 5 * 60 * 1000L, +) + +/** + * Platform-specific defaults + */ +object ChessPollingDefaults { + /** Android: moderate intervals, no background polling */ + val android = + ChessPollingConfig( + activeGamePollInterval = 5_000L, // 5 seconds for responsive gameplay + challengePollInterval = 15_000L, // 15 seconds for challenges + pollInBackground = false, + ) + + /** Desktop: fast polling for responsive gameplay */ + val desktop = + ChessPollingConfig( + activeGamePollInterval = 2_000L, // 2 seconds for fast updates + challengePollInterval = 10_000L, // 10 seconds for challenges + pollInBackground = true, + ) +} + +/** + * Delegate for managing chess event polling + * + * Usage: + * ``` + * class ChessViewModel(...) : ViewModel() { + * private val pollingDelegate = ChessPollingDelegate( + * config = ChessPollingDefaults.android, + * scope = viewModelScope, + * onRefreshGames = { gameIds -> refreshGamesFromCache(gameIds) }, + * onRefreshChallenges = { refreshChallengesFromCache() }, + * ) + * + * fun startPolling() = pollingDelegate.start() + * fun stopPolling() = pollingDelegate.stop() + * } + * ``` + */ +class ChessPollingDelegate( + private val config: ChessPollingConfig, + private val scope: CoroutineScope, + private val onRefreshGames: suspend (Set) -> Unit, + private val onRefreshChallenges: suspend () -> Unit, + private val onCleanup: suspend () -> Unit = {}, +) { + private var gamePollingJob: Job? = null + private var challengePollingJob: Job? = null + private var cleanupJob: Job? = null + private var manualRefreshJob: Job? = null + + private val _isPolling = MutableStateFlow(false) + val isPolling: StateFlow = _isPolling.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + internal val activeGameIdsFlow = MutableStateFlow>(emptySet()) + + /** + * Focused game ID - when set, only this game is polled for updates. + * Used when viewing a specific game screen to avoid refreshing unrelated games. + * When null, all games in activeGameIdsFlow are polled (lobby mode). + */ + private val _focusedGameId = MutableStateFlow(null) + + /** + * Set focused game mode - only poll this specific game. + * Call this when entering a game screen. + * Pass null to return to lobby mode (poll all games). + */ + fun setFocusedGame(gameId: String?) { + _focusedGameId.value = gameId + } + + /** + * Get current focused game ID (null = lobby mode, polls all games) + */ + fun getFocusedGameId(): String? = _focusedGameId.value + + /** + * Get the effective game IDs to poll based on focused mode. + * In focused mode: only the focused game. + * In lobby mode: all active games. + */ + private fun getEffectiveGameIds(): Set { + val focused = _focusedGameId.value + return if (focused != null) { + // Focused mode - only poll this game + setOf(focused) + } else { + // Lobby mode - poll all games + activeGameIdsFlow.value + } + } + + /** + * Update the set of game IDs to poll for + */ + fun setActiveGameIds(gameIds: Set) { + activeGameIdsFlow.value = gameIds + } + + /** + * Add a game ID to poll for + */ + fun addGameId(gameId: String) { + activeGameIdsFlow.value = activeGameIdsFlow.value + gameId + } + + /** + * Remove a game ID from polling + */ + fun removeGameId(gameId: String) { + activeGameIdsFlow.value = activeGameIdsFlow.value - gameId + } + + /** + * Start polling for chess events + */ + fun start() { + if (_isPolling.value) { + return + } + _isPolling.value = true + + // Poll for active games + gamePollingJob = + scope.launch { + while (isActive) { + val gameIds = getEffectiveGameIds() + if (gameIds.isNotEmpty()) { + try { + onRefreshGames(gameIds) + } catch (_: Exception) { + // Error during refresh - continue polling + } + } + delay(config.activeGamePollInterval) + } + } + + // Poll for challenges (only in lobby mode) + challengePollingJob = + scope.launch { + // Initial fetch (only if not in focused mode) + if (_focusedGameId.value == null) { + onRefreshChallenges() + } + + while (isActive) { + delay(config.challengePollInterval) + // Skip challenge polling in focused mode - game screen doesn't need it + if (_focusedGameId.value == null) { + onRefreshChallenges() + } + } + } + + // Cleanup job + cleanupJob = + scope.launch { + while (isActive) { + delay(config.cleanupInterval) + onCleanup() + } + } + } + + /** + * Stop polling + */ + fun stop() { + _isPolling.value = false + gamePollingJob?.cancel() + challengePollingJob?.cancel() + cleanupJob?.cancel() + gamePollingJob = null + challengePollingJob = null + cleanupJob = null + } + + /** + * Pause polling (e.g., when app goes to background on Android) + */ + fun pause() { + if (!config.pollInBackground) { + stop() + } + } + + /** + * Resume polling (e.g., when app comes to foreground on Android) + */ + fun resume() { + if (!_isPolling.value) { + start() + } + } + + /** + * Force an immediate refresh (debounced - ignores calls if refresh already in progress) + */ + fun refreshNow() { + // Debounce: skip if already refreshing + if (_isRefreshing.value) { + return + } + + // Cancel any pending manual refresh + manualRefreshJob?.cancel() + + manualRefreshJob = + scope.launch { + _isRefreshing.value = true + try { + // In focused mode, skip challenge refresh (game screen doesn't need it) + val focusedId = _focusedGameId.value + if (focusedId == null) { + onRefreshChallenges() + } + val gameIds = getEffectiveGameIds() + if (gameIds.isNotEmpty()) { + onRefreshGames(gameIds) + } + } finally { + _isRefreshing.value = false + } + } + } +} + +/** + * Interface for chess event sources that can be refreshed + */ +interface ChessEventSource { + /** + * Fetch/refresh challenges from the event source + */ + suspend fun fetchChallenges(): List + + /** + * Fetch/refresh game state for specific game IDs + */ + suspend fun fetchGameUpdates(gameIds: Set): Map +} + +/** + * Data class representing a chess challenge + */ +data class ChessChallengeData( + val id: String, + val gameId: String, + val challengerPubkey: String, + val opponentPubkey: String?, + val challengerColor: com.vitorpamplona.quartz.nip64Chess.Color, + val createdAt: Long, + val isExpired: Boolean = false, +) + +/** + * Data class representing a game update + */ +data class ChessGameUpdate( + val gameId: String, + val moves: List, + val isEnded: Boolean = false, + val endReason: String? = null, +) + +/** + * Data class representing a chess move + */ +data class ChessMoveData( + val san: String, + val fen: String, + val moveNumber: Int, + val playerPubkey: String, + val timestamp: Long, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt new file mode 100644 index 000000000..f0557e2ab --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameLoader.kt @@ -0,0 +1,182 @@ +/* + * 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.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.LiveChessGameState +import com.vitorpamplona.quartz.nip64Chess.ReconstructedGameState +import com.vitorpamplona.quartz.nip64Chess.ReconstructionResult +import com.vitorpamplona.quartz.nip64Chess.ViewerRole + +/** + * Converts a ReconstructedGameState (from ChessStateReconstructor) into a + * LiveChessGameState (used by ViewModels for reactive updates). + * + * This bridges the gap between the deterministic reconstruction algorithm + * and the stateful game management that ViewModels need. + * + * Usage: + * ``` + * // Collect events from any source + * val collector = ChessEventCollector(startEventId) + * collector.addEvent(startEvent) + * collector.addEvent(moveEvent1) + * collector.addEvent(moveEvent2) + * + * // Reconstruct using shared algorithm + * val result = ChessStateReconstructor.reconstruct(collector.getEvents(), viewerPubkey) + * + * // Convert to LiveChessGameState for ViewModel use + * val gameState = ChessGameLoader.toLiveGameState(result, viewerPubkey) + * ``` + */ +object ChessGameLoader { + /** + * Convert a ReconstructionResult to a LiveChessGameState for ViewModel use. + * + * @param result The result from ChessStateReconstructor + * @param viewerPubkey The pubkey of the user viewing the game + * @return LiveChessGameState or null if reconstruction failed + */ + fun toLiveGameState( + result: ReconstructionResult, + viewerPubkey: String, + ): LiveChessGameState? { + if (result !is ReconstructionResult.Success) return null + + val state = result.state + val engine = result.engine + + val (playerPubkey, opponentPubkey) = + when (state.viewerRole) { + ViewerRole.WHITE_PLAYER -> viewerPubkey to (state.blackPubkey ?: "") + ViewerRole.BLACK_PLAYER -> viewerPubkey to (state.whitePubkey ?: "") + ViewerRole.SPECTATOR -> viewerPubkey to (state.blackPubkey ?: "") + } + + return LiveChessGameState( + startEventId = state.startEventId, + playerPubkey = playerPubkey, + opponentPubkey = opponentPubkey, + playerColor = state.playerColor, + engine = engine, + createdAt = state.challengeCreatedAt, + isSpectator = state.viewerRole == ViewerRole.SPECTATOR, + isPendingChallenge = state.isPendingChallenge, + initialHeadEventId = state.headEventId, + ).also { gameState -> + // Mark all loaded moves as received to prevent re-application during polling + gameState.markMovesAsReceived(state.appliedMoveNumbers) + + // Mark finished if the game has ended + if (state.isFinished() && state.gameStatus is com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished) { + gameState.markAsFinished( + (state.gameStatus as com.vitorpamplona.quartz.nip64Chess.GameStatus.Finished).result, + ) + } + + // Handle pending draw offer + val drawOfferer = state.pendingDrawOffer + if (drawOfferer != null && drawOfferer != viewerPubkey) { + gameState.receiveDrawOffer(drawOfferer) + } + } + } + + /** + * Load a game using the deterministic reconstruction algorithm. + * + * @param events Collected Jester events for the game + * @param viewerPubkey The pubkey of the user viewing the game + * @return Pair of (LiveChessGameState, ReconstructedGameState) or null if failed + */ + fun loadGame( + events: JesterGameEvents, + viewerPubkey: String, + ): LoadGameResult { + val result = ChessStateReconstructor.reconstruct(events, viewerPubkey) + + return when (result) { + is ReconstructionResult.Success -> { + val liveState = toLiveGameState(result, viewerPubkey) + if (liveState != null) { + LoadGameResult.Success(liveState, result.state) + } else { + LoadGameResult.Error("Failed to convert reconstructed state to live state") + } + } + + is ReconstructionResult.Error -> { + LoadGameResult.Error(result.message) + } + } + } + + /** + * Create a new game state without any events (for when accepting a challenge locally). + * + * @param startEventId The start event ID (game identifier) + * @param playerPubkey The player's pubkey + * @param opponentPubkey The opponent's pubkey + * @param playerColor The player's color + * @return A fresh LiveChessGameState + */ + fun createNewGame( + startEventId: String, + playerPubkey: String, + opponentPubkey: String, + playerColor: Color, + isPendingChallenge: Boolean = false, + ): LiveChessGameState { + val engine = ChessEngine() + engine.reset() + + return LiveChessGameState( + startEventId = startEventId, + playerPubkey = playerPubkey, + opponentPubkey = opponentPubkey, + playerColor = playerColor, + engine = engine, + isPendingChallenge = isPendingChallenge, + ) + } +} + +/** + * Result of loading a game. + */ +sealed class LoadGameResult { + data class Success( + val liveState: LiveChessGameState, + val reconstructedState: ReconstructedGameState, + ) : LoadGameResult() + + data class Error( + val message: String, + ) : LoadGameResult() + + fun isSuccess(): Boolean = this is Success + + fun getOrNull(): LiveChessGameState? = (this as? Success)?.liveState +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameViewer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameViewer.kt new file mode 100644 index 000000000..1fcc1ab3c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessGameViewer.kt @@ -0,0 +1,172 @@ +/* + * 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.commons.chess + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +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.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.vitorpamplona.quartz.nip64Chess.PGNParser + +/** + * Complete chess game viewer with board, metadata, and navigation + * Shared composable for Android and Desktop platforms + * + * Parses PGN content and displays: + * - Game metadata (event, players, result) + * - Interactive chess board + * - Move navigation controls + * + * @param pgnContent PGN format string + * @param modifier Modifier for the viewer + */ +@Composable +fun ChessGameViewer( + pgnContent: String, + modifier: Modifier = Modifier, +) { + val gameResult = + remember(pgnContent) { + PGNParser.parse(pgnContent) + } + + gameResult.fold( + onSuccess = { game -> + ChessGameDisplay(game, modifier) + }, + onFailure = { error -> + ChessGameError( + errorMessage = error.message ?: "Failed to parse PGN", + pgnContent = pgnContent, + modifier = modifier, + ) + }, + ) +} + +/** + * Displays a successfully parsed chess game + */ +@Composable +private fun ChessGameDisplay( + game: com.vitorpamplona.quartz.nip64Chess.ChessGame, + modifier: Modifier = Modifier, +) { + var currentMoveIndex by remember { mutableStateOf(0) } + + Card( + modifier = modifier.fillMaxWidth(), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Game metadata header + PGNMetadata(game = game) + + // Chess board + ChessBoard( + position = game.positionAt(currentMoveIndex), + modifier = + Modifier + .fillMaxWidth() + .aspectRatio(1f), + boardSize = 320.dp, + ) + + // Move navigation + MoveNavigator( + currentMove = currentMoveIndex, + totalMoves = game.moves.size, + onMoveChange = { currentMoveIndex = it }, + ) + } + } +} + +/** + * Displays error state when PGN parsing fails + */ +@Composable +private fun ChessGameError( + errorMessage: String, + pgnContent: String, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer, + ), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Error title + Text( + text = "Invalid Chess Game", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.error, + ) + + // Error message + Text( + text = errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + + // PGN content label + Text( + text = "PGN Content:", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(top = 4.dp), + ) + + // Raw PGN content + Text( + text = pgnContent, + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt new file mode 100644 index 000000000..e9f23f8f7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyCards.kt @@ -0,0 +1,342 @@ +/* + * 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.commons.chess + +import androidx.compose.foundation.BorderStroke +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.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator + +// Color constants for card borders +private val IncomingChallengeColor = Color(0xFFFF9800) // Orange +private val OpenChallengeColor = Color(0xFF4CAF50) // Green +private val SpectatingColor = Color(0xFF9C27B0) // Purple +private val LiveGameColor = Color(0xFF2196F3) // Blue + +/** + * Card for an active game where the user is a participant + */ +@Composable +fun ActiveGameCard( + gameId: String, + opponentName: String, + isYourTurn: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + avatar: @Composable (() -> Unit)? = null, +) { + val gameName = + remember(gameId) { + ChessGameNameGenerator.extractDisplayName(gameId) ?: gameId.take(12) + } + + Card( + modifier = modifier.fillMaxWidth().clickable(onClick = onClick), + border = + if (isYourTurn) { + BorderStroke(2.dp, MaterialTheme.colorScheme.primary) + } else { + null + }, + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + avatar?.invoke() + + Column(modifier = Modifier.weight(1f)) { + Text( + gameName, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + Text( + "vs $opponentName", + style = MaterialTheme.typography.bodyMedium, + ) + } + + Text( + if (isYourTurn) "Your turn" else "Waiting...", + style = MaterialTheme.typography.bodyMedium, + color = if (isYourTurn) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = if (isYourTurn) FontWeight.Bold else FontWeight.Normal, + ) + } + } +} + +/** + * Card for an incoming or open challenge + */ +@Composable +fun ChallengeCard( + challengerName: String, + challengerPlaysWhite: Boolean, + isIncoming: Boolean, + onAccept: () -> Unit, + modifier: Modifier = Modifier, + avatar: @Composable (() -> Unit)? = null, +) { + val borderColor = if (isIncoming) IncomingChallengeColor else OpenChallengeColor + + Card( + modifier = modifier.fillMaxWidth(), + border = BorderStroke(2.dp, borderColor), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + avatar?.invoke() + + Column(modifier = Modifier.weight(1f)) { + Text( + if (isIncoming) "Challenge from $challengerName" else "Open challenge by $challengerName", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "Challenger plays ${if (challengerPlaysWhite) "White" else "Black"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Button(onClick = onAccept) { + Text("Accept") + } + } + } +} + +/** + * Card for user's outgoing challenge (waiting for acceptance) + * Clickable so user can open the game board and make the first move when ready + */ +@Composable +fun OutgoingChallengeCard( + opponentName: String?, + userPlaysWhite: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + avatar: @Composable (() -> Unit)? = null, +) { + Card( + modifier = modifier.fillMaxWidth().clickable(onClick = onClick), + border = BorderStroke(2.dp, MaterialTheme.colorScheme.primary), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + avatar?.invoke() + + Column(modifier = Modifier.weight(1f)) { + Text( + if (opponentName != null) { + "Challenge to $opponentName" + } else { + "Open challenge (awaiting opponent)" + }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "You play ${if (userPlaysWhite) "White" else "Black"}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + "Waiting...", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +/** + * Card for a game the user is spectating + */ +@Composable +fun SpectatingGameCard( + moveCount: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth().clickable(onClick = onClick), + border = BorderStroke(1.dp, SpectatingColor), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "Watching game", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "$moveCount moves played", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + "Spectating", + style = MaterialTheme.typography.bodyMedium, + color = SpectatingColor, + fontWeight = FontWeight.Medium, + ) + } + } +} + +/** + * Card for a public live game that can be watched + */ +@Composable +fun PublicGameCard( + whiteName: String, + blackName: String, + moveCount: Int, + onWatch: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + border = BorderStroke(1.dp, LiveGameColor), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + "$whiteName vs $blackName", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "$moveCount moves", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + OutlinedButton(onClick = onWatch) { + Text("Watch") + } + } + } +} + +/** + * Card for a completed game in history + */ +@Composable +fun CompletedGameCard( + opponentName: String, + result: String, + didUserWin: Boolean, + isDraw: Boolean, + moveCount: Int, + modifier: Modifier = Modifier, + avatar: @Composable (() -> Unit)? = null, +) { + val resultText = + when { + isDraw -> "Draw" + didUserWin -> "Won" + else -> "Lost" + } + + val resultColor = + when { + isDraw -> MaterialTheme.colorScheme.onSurfaceVariant + + didUserWin -> Color(0xFF4CAF50) + + // Green + else -> Color(0xFFF44336) // Red + } + + Card( + modifier = modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp).fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + avatar?.invoke() + + Column(modifier = Modifier.weight(1f)) { + Text( + "vs $opponentName", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + ) + Text( + "$moveCount moves • $result", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Text( + resultText, + style = MaterialTheme.typography.bodyMedium, + color = resultColor, + fontWeight = FontWeight.Bold, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt new file mode 100644 index 000000000..56ae30b3d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyLogic.kt @@ -0,0 +1,845 @@ +/* + * 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.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.PieceType +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Interface for platform-specific chess event publishing using Jester protocol. + * + * All chess events use kind 30 with JSON content: + * - Start events: content.kind=0 + * - Move events: content.kind=1 with full history + */ +interface ChessEventPublisher { + /** + * Publish a game start event (challenge). + * Returns the startEventId (event ID) if successful. + */ + suspend fun publishStart( + playerColor: Color, + opponentPubkey: String?, + ): String? + + /** + * Publish a move event. + * Move events include full history and link to previous move. + */ + suspend fun publishMove(move: ChessMoveEvent): String? + + /** + * Publish a game end event (includes result in content). + */ + suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean + + /** + * Get count of write relays for UI feedback. + */ + fun getWriteRelayCount(): Int +} + +/** + * Relay-first fetcher interface for Jester protocol. + * Platforms provide one-shot relay queries. + * + * Every fetch does: one-shot REQ → collect events → EOSE → close. + * No caching — relays are the single source of truth. + */ +interface ChessRelayFetcher { + /** Fetch all events for a specific game by startEventId */ + suspend fun fetchGameEvents(startEventId: String): JesterGameEvents + + /** Fetch recent start/challenge events with optional progress callback */ + suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)? = null): List + + /** Fetch recent public game summaries for spectating */ + suspend fun fetchRecentGames(): List + + /** Fetch game IDs (startEventIds) where user is a participant */ + suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)? = null): Set + + /** Get the list of relay URLs that will be used for fetching */ + fun getRelayUrls(): List +} + +/** + * Summary of a game found on relays (for lobby display / spectating) + */ +data class RelayGameSummary( + val startEventId: String, + val whitePubkey: String, + val blackPubkey: String, + val moveCount: Int, + val lastMoveTime: Long, + val isActive: Boolean, +) { + // Legacy compatibility + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId +} + +/** + * Shared chess lobby logic — relay-first architecture with Jester protocol. + * + * Both Android and Desktop use this identically. + * Platform-specific code only implements: + * - ChessEventPublisher: sign + broadcast events + * - ChessRelayFetcher: one-shot relay queries + * - IUserMetadataProvider: display names / avatars + */ +class ChessLobbyLogic( + private val userPubkey: String, + private val publisher: ChessEventPublisher, + private val fetcher: ChessRelayFetcher, + private val metadataProvider: IUserMetadataProvider, + private val scope: CoroutineScope, + pollingConfig: ChessPollingConfig = ChessPollingDefaults.android, +) { + val state = ChessLobbyState(userPubkey, scope) + + private val pollingDelegate = + ChessPollingDelegate( + config = pollingConfig, + scope = scope, + onRefreshGames = { gameIds -> refreshGames(gameIds) }, + onRefreshChallenges = { refreshChallenges() }, + onCleanup = { cleanupExpiredChallenges() }, + ) + + // ======================================== + // Lifecycle + // ======================================== + + fun startPolling() { + pollingDelegate.start() + } + + fun stopPolling() { + pollingDelegate.stop() + } + + /** + * Ensure a game ID is being polled for updates. + * Call this when entering a game screen to guarantee polling is active for that game. + */ + fun ensureGamePolling(gameId: String) { + pollingDelegate.addGameId(gameId) + } + + /** + * Set focused game mode - only poll this specific game. + * Call this when entering a game screen to avoid refreshing unrelated games. + * Also ensures the game is in the polling set. + */ + fun setFocusedGame(gameId: String) { + pollingDelegate.addGameId(gameId) + pollingDelegate.setFocusedGame(gameId) + } + + /** + * Clear focused game mode - return to lobby mode (poll all games). + * Call this when returning to the lobby screen. + */ + fun clearFocusedGame() { + pollingDelegate.setFocusedGame(null) + } + + fun forceRefresh() { + pollingDelegate.refreshNow() + } + + // ======================================== + // Incoming event routing (real-time / optimistic) + // ======================================== + + /** + * Route an incoming Jester event to the appropriate handler. + * Called by platform subscription callbacks for real-time updates. + */ + fun handleIncomingEvent(event: JesterEvent) { + when { + event.isStartEvent() -> handleStartEvent(event) + event.isMoveEvent() -> handleMoveEvent(event) + } + } + + private fun handleStartEvent(event: JesterEvent) { + val startEventId = event.id + val challengerColor = event.playerColor() ?: Color.WHITE + + val challenge = + ChessChallenge( + eventId = event.id, + gameId = startEventId, // In Jester, gameId = startEventId + challengerPubkey = event.pubKey, + challengerDisplayName = metadataProvider.getDisplayName(event.pubKey), + challengerAvatarUrl = metadataProvider.getPictureUrl(event.pubKey), + opponentPubkey = event.opponentPubkey(), + challengerColor = challengerColor, + createdAt = event.createdAt, + ) + state.addChallenge(challenge) + } + + private fun handleMoveEvent(event: JesterEvent) { + val startEventId = event.startEventId() ?: return + val san = event.move() ?: return + val fen = event.fen() ?: return + val history = event.history() + val moveNumber = history.size + + // Check if this is our game (we're either the author or tagged as opponent) + val opponentFromTag = event.opponentPubkey() + val isOurGame = event.pubKey == userPubkey || opponentFromTag == userPubkey + + var gameState = state.getGameState(startEventId) + + // If this is our game but we haven't loaded it yet, load it now + // This happens when someone accepts our challenge (makes first move) + if (gameState == null && isOurGame) { + handleGameAccepted(startEventId) + return // handleGameAccepted will load the game and poll for events + } + + if (gameState == null) return + + // Check for game end + val result = event.result() + if (result != null) { + val gameResult = + when (result) { + "1-0" -> com.vitorpamplona.quartz.nip64Chess.GameResult.WHITE_WINS + "0-1" -> com.vitorpamplona.quartz.nip64Chess.GameResult.BLACK_WINS + "1/2-1/2" -> com.vitorpamplona.quartz.nip64Chess.GameResult.DRAW + else -> null + } + if (gameResult != null) { + gameState.markAsFinished(gameResult) + state.moveToCompleted(startEventId, result, event.termination()) + pollingDelegate.removeGameId(startEventId) + return + } + } + + // Only apply opponent moves optimistically + if (event.pubKey != userPubkey) { + gameState.applyOpponentMove(san, fen, moveNumber) + // Update head event ID for move linking + gameState.updateHeadEventId(event.id) + } + } + + // ======================================== + // Challenge operations + // ======================================== + + fun createChallenge( + opponentPubkey: String? = null, + playerColor: Color = Color.WHITE, + timeControl: String? = null, // Not supported in Jester, kept for API compatibility + ) { + scope.launch(Dispatchers.Default) { + state.setBroadcastStatus( + ChessBroadcastStatus.Broadcasting( + san = "Challenge", + successCount = 0, + totalRelays = publisher.getWriteRelayCount(), + ), + ) + + val startEventId = retryWithBackoffResult { publisher.publishStart(playerColor, opponentPubkey) } + + if (startEventId != null) { + // Add challenge to local state - shows in "Your Challenges" section + val challenge = + ChessChallenge( + eventId = startEventId, + gameId = startEventId, + challengerPubkey = userPubkey, + challengerDisplayName = metadataProvider.getDisplayName(userPubkey), + challengerAvatarUrl = metadataProvider.getPictureUrl(userPubkey), + opponentPubkey = opponentPubkey, + challengerColor = playerColor, + createdAt = TimeUtils.now(), + ) + state.addChallenge(challenge) + pollingDelegate.addGameId(startEventId) + + state.setBroadcastStatus( + ChessBroadcastStatus.Success("Challenge", publisher.getWriteRelayCount()), + ) + delay(2000) + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + state.setError(null) + } else { + state.setBroadcastStatus( + ChessBroadcastStatus.Failed("Challenge", "Failed to publish"), + ) + state.setError("Failed to create challenge") + } + } + } + + /** + * Accept a challenge by loading the game and making the first move (if we're black) + * or waiting for opponent's move (if we're white). + * + * In Jester protocol, acceptance is implicit - we just track the game locally. + */ + fun acceptChallenge(challenge: ChessChallenge) { + // Mark as accepted SYNCHRONOUSLY before launching coroutine + // This prevents race where navigation happens before coroutine runs, + // which would cause loadGame() to incorrectly mark as spectator + state.markAsAccepted(challenge.gameId) + + state.removeChallenge(challenge.gameId) + + // Add to polling delegate FIRST - before adding to activeGames + // This prevents race where Compose sees the game but forceRefresh() doesn't include it + pollingDelegate.addGameId(challenge.gameId) + + scope.launch(Dispatchers.Default) { + val playerColor = challenge.challengerColor.opposite() + val gameState = + ChessGameLoader.createNewGame( + startEventId = challenge.gameId, // gameId = startEventId in Jester + playerPubkey = userPubkey, + opponentPubkey = challenge.challengerPubkey, + playerColor = playerColor, + ) + state.addActiveGame(challenge.gameId, gameState) + state.selectGame(challenge.gameId) + state.setError(null) + + // Immediately fetch game from relays to load any existing moves + // This ensures moves are loaded without waiting for polling interval + refreshGame(challenge.gameId) + } + } + + /** + * Open user's own outgoing challenge to view the board and make moves. + * Creates game state and navigates to game view. + */ + fun openOwnChallenge(challenge: ChessChallenge) { + // Add to polling delegate FIRST - before adding to activeGames + pollingDelegate.addGameId(challenge.gameId) + + // Create game state with user's chosen color + val gameState = + ChessGameLoader.createNewGame( + startEventId = challenge.gameId, + playerPubkey = userPubkey, + opponentPubkey = challenge.opponentPubkey ?: "", + playerColor = challenge.challengerColor, + isPendingChallenge = true, + ) + + state.addActiveGame(challenge.gameId, gameState) + state.selectGame(challenge.gameId) + + // Fetch from relays in case opponent has already made moves + scope.launch(Dispatchers.Default) { + refreshGame(challenge.gameId) + } + } + + /** + * When we detect our challenge was accepted (opponent made first move), load game from relays. + */ + fun handleGameAccepted(startEventId: String) { + scope.launch(Dispatchers.Default) { + state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) + + val events = fetcher.fetchGameEvents(startEventId) + val result = ChessGameLoader.loadGame(events, userPubkey) + + when (result) { + is LoadGameResult.Success -> { + state.addActiveGame(startEventId, result.liveState) + pollingDelegate.addGameId(startEventId) + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + state.setError(null) + } + + is LoadGameResult.Error -> { + state.setError("Failed to load game: ${result.message}") + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + } + } + } + } + + // ======================================== + // Game operations + // ======================================== + + fun publishMove( + startEventId: String, + from: String, + to: String, + ) { + val gameState = state.getGameState(startEventId) ?: return + + if (state.isSpectating(startEventId)) { + state.setError("Cannot move while spectating") + return + } + + // Parse promotion suffix from `to` if present (e.g., "e8q" -> "e8" + QUEEN) + val (actualTo, promotion) = parsePromotionFromTo(to) + + val moveResult = gameState.makeMove(from, actualTo, promotion) ?: return + + scope.launch(Dispatchers.Default) { + state.setBroadcastStatus( + ChessBroadcastStatus.Broadcasting( + san = moveResult.san, + successCount = 0, + totalRelays = publisher.getWriteRelayCount(), + ), + ) + + val newEventId = retryWithBackoffResult { publisher.publishMove(moveResult) } + + if (newEventId != null) { + // Update head event ID for next move linking + gameState.updateHeadEventId(newEventId) + + state.setBroadcastStatus( + ChessBroadcastStatus.Success(moveResult.san, publisher.getWriteRelayCount()), + ) + delay(3000) + + val currentState = state.getGameState(startEventId) + state.setBroadcastStatus( + if (currentState?.isPlayerTurn() == false) { + ChessBroadcastStatus.WaitingForOpponent + } else { + ChessBroadcastStatus.Idle + }, + ) + state.setError(null) + } else { + // Revert the move since publishing failed + gameState.undoLastMove() + + state.setBroadcastStatus( + ChessBroadcastStatus.Failed(moveResult.san, "Failed to publish move"), + ) + state.setError("Failed to publish move - move reverted") + } + } + } + + fun resign(startEventId: String) { + val gameState = state.getGameState(startEventId) ?: return + + if (state.isSpectating(startEventId)) { + state.setError("Cannot resign while spectating") + return + } + + scope.launch(Dispatchers.Default) { + val endData = gameState.resign() + val success = retryWithBackoff { publisher.publishGameEnd(endData) } + + if (success) { + state.moveToCompleted(startEventId, endData.result.notation, endData.termination.name.lowercase()) + pollingDelegate.removeGameId(startEventId) + state.setError(null) + } else { + state.setError("Failed to resign") + } + } + } + + fun claimAbandonmentVictory(startEventId: String) { + val gameState = state.getGameState(startEventId) ?: return + val endData = gameState.claimAbandonmentVictory() ?: return + + scope.launch(Dispatchers.Default) { + val success = retryWithBackoff { publisher.publishGameEnd(endData) } + + if (success) { + state.moveToCompleted(startEventId, endData.result.notation, "abandonment") + pollingDelegate.removeGameId(startEventId) + state.setError(null) + } else { + state.setError("Failed to claim abandonment victory") + } + } + } + + // ======================================== + // Spectator mode + // ======================================== + + fun loadGameAsSpectator(startEventId: String) { + scope.launch(Dispatchers.Default) { + state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) + + val events = fetcher.fetchGameEvents(startEventId) + val result = ChessGameLoader.loadGame(events, userPubkey) + + when (result) { + is LoadGameResult.Success -> { + state.addSpectatingGame(startEventId, result.liveState) + pollingDelegate.addGameId(startEventId) + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + state.setError(null) + // Auto-select the game for Desktop (Android uses route navigation) + state.selectGame(startEventId) + } + + is LoadGameResult.Error -> { + state.setError("Failed to load game: ${result.message}") + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + } + } + } + } + + fun loadGame(startEventId: String) { + scope.launch(Dispatchers.Default) { + // Don't load if game already exists or was accepted (acceptChallenge will handle it) + if (state.getGameState(startEventId) != null || state.wasAccepted(startEventId)) { + return@launch + } + + state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f)) + + val events = fetcher.fetchGameEvents(startEventId) + val result = ChessGameLoader.loadGame(events, userPubkey) + + when (result) { + is LoadGameResult.Success -> { + if (result.liveState.isSpectator) { + state.addSpectatingGame(startEventId, result.liveState) + } else { + state.addActiveGame(startEventId, result.liveState) + } + pollingDelegate.addGameId(startEventId) + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + state.setError(null) + } + + is LoadGameResult.Error -> { + // Check again if game was added while we were fetching + // (e.g., by acceptChallenge completing in parallel) + if (state.getGameState(startEventId) != null) { + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + return@launch + } + state.setError("Failed to load game: ${result.message}") + state.setBroadcastStatus(ChessBroadcastStatus.Idle) + } + } + } + } + + // ======================================== + // Relay-first refresh (periodic reconstruction) + // ======================================== + + private suspend fun refreshGames(gameIds: Set) { + for (gameId in gameIds) { + refreshGame(gameId) + } + } + + private suspend fun refreshGame(startEventId: String) { + val events = fetcher.fetchGameEvents(startEventId) + + val result = ChessGameLoader.loadGame(events, userPubkey) + + when (result) { + is LoadGameResult.Success -> { + state.replaceGameState(startEventId, result.liveState) + } + + is LoadGameResult.Error -> { + // Don't overwrite error for periodic refresh failures + } + } + } + + private suspend fun refreshChallenges() { + state.setRefreshing(true) + try { + refreshChallengesInternal() + } finally { + state.setRefreshing(false) + } + } + + private suspend fun refreshChallengesInternal() { + val relayUrls = fetcher.getRelayUrls() + val relayStatesMap = mutableMapOf() + relayUrls.forEach { url -> + val displayName = url.substringAfter("://").substringBefore("/") + relayStatesMap[url] = RelaySyncState(url, displayName, RelaySyncStatus.CONNECTING, 0) + } + var totalEvents = 0 + + state.setSyncStatus( + ChessSyncStatus.Syncing( + phase = "challenges", + relayStates = relayStatesMap.values.toList(), + totalEventsReceived = 0, + ), + ) + + val startEvents = + fetcher.fetchChallenges { progress -> + val relayUrl = progress.relay.url + val displayName = relayUrl.substringAfter("://").substringBefore("/") + val status = + when (progress.status) { + RelayFetchStatus.WAITING -> RelaySyncStatus.WAITING + RelayFetchStatus.RECEIVING -> RelaySyncStatus.RECEIVING + RelayFetchStatus.EOSE_RECEIVED -> RelaySyncStatus.EOSE_RECEIVED + RelayFetchStatus.TIMEOUT -> RelaySyncStatus.FAILED + } + relayStatesMap[relayUrl] = + RelaySyncState( + relayUrl, + displayName, + status, + progress.eventCount, + ) + totalEvents = relayStatesMap.values.sumOf { it.eventsReceived } + state.setSyncStatus( + ChessSyncStatus.Syncing( + phase = "challenges", + relayStates = relayStatesMap.values.toList(), + totalEventsReceived = totalEvents, + ), + ) + } + + val fetchedChallenges = + startEvents.mapNotNull { event -> + if (!event.isStartEvent()) return@mapNotNull null + val startEventId = event.id + + // Skip challenges that user has already accepted + if (state.wasAccepted(startEventId)) return@mapNotNull null + + val challengerColor = event.playerColor() ?: Color.WHITE + + ChessChallenge( + eventId = event.id, + gameId = startEventId, + challengerPubkey = event.pubKey, + challengerDisplayName = metadataProvider.getDisplayName(event.pubKey), + challengerAvatarUrl = metadataProvider.getPictureUrl(event.pubKey), + opponentPubkey = event.opponentPubkey(), + challengerColor = challengerColor, + createdAt = event.createdAt, + ) + } + + // Merge only RECENT optimistic challenges (created in last 5 minutes, not yet propagated) + // This prevents stale challenges from accumulating across sessions + // Also exclude challenges that user has accepted (tracked in _acceptedGameIds) + val now = TimeUtils.now() + val recentThreshold = 5 * 60L // 5 minutes + val fetchedGameIds = fetchedChallenges.map { it.gameId }.toSet() + val optimisticChallenges = + state.challenges.value.filter { challenge -> + val isRecent = (now - challenge.createdAt) < recentThreshold + val notFetched = challenge.gameId !in fetchedGameIds + val notAccepted = !state.wasAccepted(challenge.gameId) + isRecent && notFetched && notAccepted + } + val mergedChallenges = fetchedChallenges + optimisticChallenges + + state.updateChallenges(mergedChallenges) + + state.setSyncStatus( + ChessSyncStatus.Syncing( + phase = "games", + relayStates = relayStatesMap.values.toList(), + totalEventsReceived = totalEvents, + ), + ) + + discoverUserGames() + + val recentGames = fetcher.fetchRecentGames() + val publicGames = + recentGames.map { summary -> + PublicGame( + gameId = summary.startEventId, + whitePubkey = summary.whitePubkey, + whiteDisplayName = metadataProvider.getDisplayName(summary.whitePubkey), + blackPubkey = summary.blackPubkey, + blackDisplayName = metadataProvider.getDisplayName(summary.blackPubkey), + moveCount = summary.moveCount, + lastMoveTime = summary.lastMoveTime, + isActive = summary.isActive, + ) + } + state.updatePublicGames(publicGames) + + val failedCount = relayStatesMap.values.count { it.status == RelaySyncStatus.FAILED } + val activeGamesCount = state.activeGames.value.size + + if (failedCount > 0 && failedCount < relayStatesMap.size) { + state.setSyncStatus( + ChessSyncStatus.PartialSync( + relayStates = relayStatesMap.values.toList(), + message = "$failedCount relay(s) timed out", + ), + ) + } else if (failedCount == relayStatesMap.size) { + state.setSyncStatus( + ChessSyncStatus.PartialSync( + relayStates = relayStatesMap.values.toList(), + message = "All relays failed", + ), + ) + } else { + state.setSyncStatus( + ChessSyncStatus.Synced( + relayStates = relayStatesMap.values.toList(), + challengeCount = mergedChallenges.size, + gameCount = activeGamesCount, + totalEventsReceived = totalEvents, + ), + ) + } + } + + private suspend fun discoverUserGames() { + val discoveredGameIds = fetcher.fetchUserGameIds() + val currentActiveIds = state.activeGames.value.keys + val currentSpectatingIds = state.spectatingGames.value.keys + + val newGameIds = discoveredGameIds - currentActiveIds - currentSpectatingIds + + for (startEventId in newGameIds) { + val events = fetcher.fetchGameEvents(startEventId) + val result = ChessGameLoader.loadGame(events, userPubkey) + + when (result) { + is LoadGameResult.Success -> { + if (!result.liveState.isSpectator) { + state.addActiveGame(startEventId, result.liveState) + pollingDelegate.addGameId(startEventId) + } + } + + is LoadGameResult.Error -> { + // Failed to load game - continue with others + } + } + } + } + + private fun cleanupExpiredChallenges() { + val now = TimeUtils.now() + val validChallenges = + state.challenges.value.filter { challenge -> + (now - challenge.createdAt) < CHALLENGE_EXPIRY_SECONDS + } + state.updateChallenges(validChallenges) + } + + // ======================================== + // Utilities + // ======================================== + + private suspend fun retryWithBackoff( + maxRetries: Int = 3, + initialDelayMs: Long = 1000, + action: suspend () -> Boolean, + ): Boolean { + var delayMs = initialDelayMs + repeat(maxRetries) { attempt -> + if (action()) return true + if (attempt < maxRetries - 1) { + delay(delayMs) + delayMs *= 2 + } + } + return false + } + + private suspend fun retryWithBackoffResult( + maxRetries: Int = 3, + initialDelayMs: Long = 1000, + action: suspend () -> T?, + ): T? { + var delayMs = initialDelayMs + repeat(maxRetries) { attempt -> + val result = action() + if (result != null) return result + if (attempt < maxRetries - 1) { + delay(delayMs) + delayMs *= 2 + } + } + return null + } + + fun clearError() { + state.setError(null) + } + + fun selectGame(gameId: String?) { + state.selectGame(gameId) + } +} + +/** + * Parse promotion piece from a "to" square string. + * For example: "e8q" -> Pair("e8", PieceType.QUEEN) + * Regular moves: "e4" -> Pair("e4", null) + */ +private fun parsePromotionFromTo(to: String): Pair { + if (to.length == 3) { + val square = to.take(2) + val promotion = + when (to.last().lowercaseChar()) { + 'q' -> PieceType.QUEEN + 'r' -> PieceType.ROOK + 'b' -> PieceType.BISHOP + 'n' -> PieceType.KNIGHT + else -> null + } + if (promotion != null) { + return square to promotion + } + } + return to to null +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt new file mode 100644 index 000000000..5ebcd39f0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessLobbyState.kt @@ -0,0 +1,518 @@ +/* + * 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.commons.chess + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import java.util.concurrent.atomic.AtomicLong + +/** + * Challenge expiry: 24 hours + */ +const val CHALLENGE_EXPIRY_SECONDS = 24 * 60 * 60L + +/** + * Represents a chess challenge that can be displayed in the lobby + */ +@Immutable +data class ChessChallenge( + val eventId: String, + val gameId: String, + val challengerPubkey: String, + val challengerDisplayName: String?, + val challengerAvatarUrl: String?, + val opponentPubkey: String?, + val challengerColor: Color, + val createdAt: Long, +) { + /** Whether this is an open challenge anyone can accept */ + val isOpen: Boolean get() = opponentPubkey == null + + /** Whether this challenge is directed at a specific user */ + fun isDirectedAt(pubkey: String): Boolean = opponentPubkey == pubkey + + /** Whether this challenge was created by a specific user */ + fun isFrom(pubkey: String): Boolean = challengerPubkey == pubkey +} + +/** + * Represents a public game that can be spectated + */ +@Immutable +data class PublicGame( + val gameId: String, + val whitePubkey: String, + val whiteDisplayName: String?, + val blackPubkey: String, + val blackDisplayName: String?, + val moveCount: Int, + val lastMoveTime: Long, + val isActive: Boolean, +) + +/** + * Represents a completed game for history display + */ +@Immutable +data class CompletedGame( + val gameId: String, + val whitePubkey: String, + val whiteDisplayName: String?, + val blackPubkey: String, + val blackDisplayName: String?, + val result: String, + val termination: String?, + val moveCount: Int, + val completedAt: Long, +) { + /** Whether user won this game */ + fun didUserWin(userPubkey: String): Boolean = + when (result) { + "1-0" -> whitePubkey == userPubkey + "0-1" -> blackPubkey == userPubkey + else -> false + } + + /** Whether this was a draw */ + val isDraw: Boolean get() = result == "1/2-1/2" +} + +/** + * Individual relay status during sync + */ +@Immutable +data class RelaySyncState( + val url: String, + val displayName: String, + val status: RelaySyncStatus, + val eventsReceived: Int = 0, +) + +@Immutable +enum class RelaySyncStatus { + CONNECTING, + WAITING, + RECEIVING, + EOSE_RECEIVED, + FAILED, +} + +/** + * Sync status for incoming events (subscription-side) + */ +@Immutable +sealed class ChessSyncStatus { + data object Idle : ChessSyncStatus() + + data class Syncing( + val phase: String, + val relayStates: List, + val totalEventsReceived: Int, + ) : ChessSyncStatus() { + val connectedCount: Int get() = relayStates.count { it.status != RelaySyncStatus.FAILED } + val eoseCount: Int get() = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED } + val totalCount: Int get() = relayStates.size + } + + data class Synced( + val relayStates: List, + val challengeCount: Int, + val gameCount: Int, + val totalEventsReceived: Int, + ) : ChessSyncStatus() { + val successCount: Int get() = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED } + val totalCount: Int get() = relayStates.size + } + + data class PartialSync( + val relayStates: List, + val message: String, + ) : ChessSyncStatus() { + val successCount: Int get() = relayStates.count { it.status == RelaySyncStatus.EOSE_RECEIVED } + val failedCount: Int get() = relayStates.count { it.status == RelaySyncStatus.FAILED } + val totalCount: Int get() = relayStates.size + } +} + +/** + * Chess status for UI feedback + */ +@Immutable +sealed class ChessBroadcastStatus { + data object Idle : ChessBroadcastStatus() + + data class Broadcasting( + val san: String, + val successCount: Int, + val totalRelays: Int, + ) : ChessBroadcastStatus() { + val progress: Float get() = if (totalRelays > 0) successCount.toFloat() / totalRelays else 0f + } + + data class Success( + val san: String, + val relayCount: Int, + ) : ChessBroadcastStatus() + + data class Failed( + val san: String, + val error: String, + ) : ChessBroadcastStatus() + + data object WaitingForOpponent : ChessBroadcastStatus() + + data class Syncing( + val progress: Float = 0f, + ) : ChessBroadcastStatus() + + data class Desynced( + val message: String, + ) : ChessBroadcastStatus() +} + +/** + * Shared chess lobby state that can be used by both Android and Desktop + */ +class ChessLobbyState( + private val userPubkey: String, + private val scope: CoroutineScope, +) { + // Active games where user is a participant + private val _activeGames = MutableStateFlow>(emptyMap()) + val activeGames: StateFlow> = _activeGames.asStateFlow() + + // Public games that can be spectated + private val _publicGames = MutableStateFlow>(emptyList()) + val publicGames: StateFlow> = _publicGames.asStateFlow() + + // All challenges (filtered by UI based on type) + private val _challenges = MutableStateFlow>(emptyList()) + val challenges: StateFlow> = _challenges.asStateFlow() + + // Spectating games (user is watching but not playing) + private val _spectatingGames = MutableStateFlow>(emptyMap()) + val spectatingGames: StateFlow> = _spectatingGames.asStateFlow() + + // Completed games history + private val _completedGames = MutableStateFlow>(emptyList()) + val completedGames: StateFlow> = _completedGames.asStateFlow() + + // Game IDs that user has accepted - uses global singleton to share across ViewModel instances + // This is critical because lobby and game screen may have different ViewModel instances + + // Broadcast status + private val _broadcastStatus = MutableStateFlow(ChessBroadcastStatus.Idle) + val broadcastStatus: StateFlow = _broadcastStatus.asStateFlow() + + // Error state + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + // Loading/refreshing state + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + + // Sync status for subscription banner + private val _syncStatus = MutableStateFlow(ChessSyncStatus.Idle) + val syncStatus: StateFlow = _syncStatus.asStateFlow() + + // Selected game ID for navigation + private val _selectedGameId = MutableStateFlow(null) + val selectedGameId: StateFlow = _selectedGameId.asStateFlow() + + // State version counter - increments on every game state update + // UI can observe this to force recomposition when internal state changes + private val stateVersionCounter = AtomicLong(0) + private val _stateVersion = MutableStateFlow(0L) + val stateVersion: StateFlow = _stateVersion.asStateFlow() + + // Badge count (incoming challenges + your turn games) + val badgeCount: Int + get() { + val incomingChallenges = _challenges.value.count { it.isDirectedAt(userPubkey) } + val yourTurnGames = _activeGames.value.values.count { it.isPlayerTurn() } + return incomingChallenges + yourTurnGames + } + + // ======================================== + // Derived state for UI sections + // ======================================== + + /** Challenges directed at the user */ + fun incomingChallenges(): List = _challenges.value.filter { it.isDirectedAt(userPubkey) } + + /** Challenges created by the user */ + fun outgoingChallenges(): List = _challenges.value.filter { it.isFrom(userPubkey) } + + /** Open challenges from others that user can join */ + fun openChallenges(): List = _challenges.value.filter { it.isOpen && !it.isFrom(userPubkey) } + + // ======================================== + // State updates + // ======================================== + + fun updateChallenges(challenges: List) { + _challenges.value = challenges + } + + fun addChallenge(challenge: ChessChallenge) { + _challenges.update { current -> + if (current.any { it.eventId == challenge.eventId || it.gameId == challenge.gameId }) { + current + } else { + current + challenge + } + } + } + + fun removeChallenge(gameId: String) { + _challenges.update { current -> + current.filter { it.gameId != gameId } + } + } + + fun updatePublicGames(games: List) { + _publicGames.value = games + } + + fun addActiveGame( + gameId: String, + state: LiveChessGameState, + ) { + _activeGames.update { current -> + val existing = current[gameId] + if (existing != null) { + // Only replace if new state has at least as many moves + val existingMoves = existing.moveHistory.value.size + val newMoves = state.moveHistory.value.size + if (newMoves < existingMoves) { + return@update current + } + } + current + (gameId to state) + } + // Track as accepted to prevent refresh from re-adding as optimistic challenge + AcceptedGamesRegistry.markAsAccepted(gameId) + // Remove from challenges if present + removeChallenge(gameId) + } + + fun removeActiveGame(gameId: String) { + _activeGames.update { it - gameId } + } + + fun updateActiveGame( + gameId: String, + update: (LiveChessGameState) -> LiveChessGameState, + ) { + _activeGames.update { current -> + current[gameId]?.let { state -> + current + (gameId to update(state)) + } ?: current + } + } + + /** + * Replace game state entirely after full reconstruction from relays. + * Preserves game location (active vs spectating). + * + * IMPORTANT: Only replaces if new state has >= moves than current state. + * This prevents race conditions where polling refresh could revert + * a user's move before it propagates to relays. + * + * IMPORTANT: Never replace a participant game with a spectator state. + * This prevents the race where relay fetch returns before acceptance propagates, + * which would incorrectly mark an accepted game as spectating. + */ + fun replaceGameState( + gameId: String, + newState: LiveChessGameState, + ) { + val inActiveGames = _activeGames.value.containsKey(gameId) + val inSpectatingGames = _spectatingGames.value.containsKey(gameId) + + val currentState = _activeGames.value[gameId] ?: _spectatingGames.value[gameId] + val currentMoveCount = currentState?.moveHistory?.value?.size ?: 0 + val newMoveCount = newState.moveHistory.value.size + + // Only replace if new state has at least as many moves + // This prevents reverting user's local moves during refresh + if (newMoveCount < currentMoveCount) { + return + } + + // Handle case where new state incorrectly has isSpectator=true but current is participant + // This happens with open challenges where opponent can't be determined from relay events alone + // Solution: Apply moves from new state while preserving participant status from current + val stateToUse = + if (currentState != null && !currentState.isSpectator && newState.isSpectator && newMoveCount > currentMoveCount) { + // Apply opponent's moves to current state's engine + currentState.applyMovesFrom(newState) + currentState // Keep using current state with updated engine + } else if (currentState != null && !currentState.isSpectator && newState.isSpectator) { + return + } else { + newState + } + + if (inActiveGames) { + _activeGames.update { it + (gameId to stateToUse) } + // Increment version to force UI recomposition even if map equals() returns true + val newVersion = stateVersionCounter.incrementAndGet() + _stateVersion.value = newVersion + } else if (inSpectatingGames) { + _spectatingGames.update { it + (gameId to stateToUse) } + val newVersion = stateVersionCounter.incrementAndGet() + _stateVersion.value = newVersion + } + } + + /** + * Move a game from active/spectating to completed. + * Display names are optional; UI can look them up later if needed. + */ + fun moveToCompleted( + gameId: String, + result: String, + termination: String?, + whiteDisplayName: String? = null, + blackDisplayName: String? = null, + ) { + val existingState = _activeGames.value[gameId] ?: _spectatingGames.value[gameId] + existingState?.let { gameState -> + // Derive white/black pubkeys from player color + val whitePubkey = + if (gameState.playerColor == Color.WHITE) { + gameState.playerPubkey + } else { + gameState.opponentPubkey + } + val blackPubkey = + if (gameState.playerColor == Color.BLACK) { + gameState.playerPubkey + } else { + gameState.opponentPubkey + } + + val completed = + CompletedGame( + gameId = gameId, + whitePubkey = whitePubkey, + whiteDisplayName = whiteDisplayName, + blackPubkey = blackPubkey, + blackDisplayName = blackDisplayName, + result = result, + termination = termination, + moveCount = gameState.moveHistory.value.size, + completedAt = + com.vitorpamplona.quartz.utils.TimeUtils + .now(), + ) + + _completedGames.update { current -> + // Avoid duplicates + if (current.any { it.gameId == gameId }) { + current + } else { + listOf(completed) + current + } + } + + // Remove from active/spectating + _activeGames.update { it - gameId } + _spectatingGames.update { it - gameId } + + // Clear selection if this game was selected + if (_selectedGameId.value == gameId) { + _selectedGameId.value = null + } + } + } + + fun addSpectatingGame( + gameId: String, + state: LiveChessGameState, + ) { + // Never add to spectating if this game was accepted - user is a participant + if (AcceptedGamesRegistry.wasAccepted(gameId)) { + // Add to active games instead + _activeGames.update { it + (gameId to state) } + return + } + _spectatingGames.update { it + (gameId to state) } + } + + fun removeSpectatingGame(gameId: String) { + _spectatingGames.update { it - gameId } + } + + fun setBroadcastStatus(status: ChessBroadcastStatus) { + _broadcastStatus.value = status + } + + fun setError(error: String?) { + _error.value = error + } + + fun setRefreshing(refreshing: Boolean) { + _isRefreshing.value = refreshing + } + + fun setSyncStatus(status: ChessSyncStatus) { + _syncStatus.value = status + } + + fun selectGame(gameId: String?) { + _selectedGameId.value = gameId + } + + fun getGameState(gameId: String): LiveChessGameState? = _activeGames.value[gameId] ?: _spectatingGames.value[gameId] + + fun isUserParticipant(gameId: String): Boolean = _activeGames.value.containsKey(gameId) + + fun isSpectating(gameId: String): Boolean = _spectatingGames.value.containsKey(gameId) + + /** Whether a game ID was accepted (prevents refresh from re-adding as challenge) */ + fun wasAccepted(gameId: String): Boolean = AcceptedGamesRegistry.wasAccepted(gameId) + + /** Mark a game as accepted synchronously (call before async game creation) */ + fun markAsAccepted(gameId: String) { + AcceptedGamesRegistry.markAsAccepted(gameId) + } + + fun clearAll() { + _activeGames.value = emptyMap() + _publicGames.value = emptyList() + _challenges.value = emptyList() + _spectatingGames.value = emptyMap() + _completedGames.value = emptyList() + AcceptedGamesRegistry.clear() + _broadcastStatus.value = ChessBroadcastStatus.Idle + _error.value = null + _selectedGameId.value = null + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPieceVectors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPieceVectors.kt new file mode 100644 index 000000000..e5034553d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessPieceVectors.kt @@ -0,0 +1,1018 @@ +/* + * 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.commons.chess + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.graphics.vector.path +import androidx.compose.ui.unit.dp + +/** + * CBurnett chess piece vectors from Lichess + * License: GPLv2+ + * Original author: Colin M.L. Burnett (Wikimedia user Cburnett) + */ +object ChessPieceVectors { + val WhiteKing: ImageVector by lazy { + ImageVector + .Builder( + name = "WhiteKing", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Cross on top + path( + fill = null, + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(22.5f, 11.63f) + verticalLineTo(6f) + moveTo(20f, 8f) + horizontalLineTo(25f) + } + // Head/crown + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(22.5f, 25f) + reflectiveCurveTo(27f, 17.5f, 25.5f, 14.5f) + curveTo(25.5f, 14.5f, 24.5f, 12f, 22.5f, 12f) + reflectiveCurveTo(19.5f, 14.5f, 19.5f, 14.5f) + curveTo(18f, 17.5f, 22.5f, 25f, 22.5f, 25f) + } + // Body + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(11.5f, 37f) + curveTo(17f, 40.5f, 27f, 40.5f, 32.5f, 37f) + verticalLineTo(30f) + reflectiveCurveTo(41.5f, 25.5f, 38.5f, 19.5f) + curveTo(34.5f, 13f, 25f, 16f, 22.5f, 23.5f) + verticalLineTo(27f) + verticalLineTo(23.5f) + curveTo(19f, 16f, 9.5f, 13f, 5.5f, 19.5f) + curveTo(2.5f, 25.5f, 10.5f, 29.5f, 10.5f, 29.5f) + verticalLineTo(37f) + close() + } + // Horizontal lines + path( + fill = null, + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(11.5f, 30f) + curveTo(17f, 27f, 27f, 27f, 32.5f, 30f) + moveTo(11.5f, 33.5f) + curveTo(17f, 30.5f, 27f, 30.5f, 32.5f, 33.5f) + moveTo(11.5f, 37f) + curveTo(17f, 34f, 27f, 34f, 32.5f, 37f) + } + }.build() + } + + val BlackKing: ImageVector by lazy { + ImageVector + .Builder( + name = "BlackKing", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Cross on top + path( + fill = null, + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(22.5f, 11.6f) + verticalLineTo(6f) + moveTo(20f, 8f) + horizontalLineTo(25f) + } + // Head/crown + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(22.5f, 25f) + reflectiveCurveTo(27f, 17.5f, 25.5f, 14.5f) + curveTo(25.5f, 14.5f, 24.5f, 12f, 22.5f, 12f) + reflectiveCurveTo(19.5f, 14.5f, 19.5f, 14.5f) + curveTo(18f, 17.5f, 22.5f, 25f, 22.5f, 25f) + } + // Body + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(11.5f, 37f) + curveTo(17f, 40.5f, 27f, 40.5f, 32.5f, 37f) + verticalLineTo(30f) + reflectiveCurveTo(41.5f, 25.5f, 38.5f, 19.5f) + curveTo(34.5f, 13f, 25f, 16f, 22.5f, 23.5f) + verticalLineTo(27f) + verticalLineTo(23.5f) + curveTo(19f, 16f, 9.5f, 13f, 5.5f, 19.5f) + curveTo(2.5f, 25.5f, 10.5f, 29.5f, 10.5f, 29.5f) + verticalLineTo(37f) + close() + } + // Inner highlight lines + path( + fill = null, + stroke = SolidColor(Color(0xFFECECEC)), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(32f, 29.5f) + reflectiveCurveTo(40.5f, 25.5f, 38f, 19.8f) + curveTo(34.1f, 14f, 25f, 18f, 22.5f, 24.6f) + verticalLineTo(26.7f) + verticalLineTo(24.6f) + curveTo(20f, 18f, 9.9f, 14f, 7f, 19.9f) + curveTo(4.5f, 25.5f, 11.8f, 28.9f, 11.8f, 28.9f) + } + path( + fill = null, + stroke = SolidColor(Color(0xFFECECEC)), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(11.5f, 30f) + curveTo(17f, 27f, 27f, 27f, 32.5f, 30f) + moveTo(11.5f, 33.5f) + curveTo(17f, 30.5f, 27f, 30.5f, 32.5f, 33.5f) + moveTo(11.5f, 37f) + curveTo(17f, 34f, 27f, 34f, 32.5f, 37f) + } + }.build() + } + + val WhiteQueen: ImageVector by lazy { + ImageVector + .Builder( + name = "WhiteQueen", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Crown circles + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + // Left circle + moveTo(8f, 12f) + arcTo(2f, 2f, 0f, true, true, 4f, 12f) + arcTo(2f, 2f, 0f, true, true, 8f, 12f) + close() + // Center-left circle + moveTo(16f, 8.5f) + arcTo(2f, 2f, 0f, true, true, 12f, 8.5f) + arcTo(2f, 2f, 0f, true, true, 16f, 8.5f) + close() + // Center circle + moveTo(24.5f, 7.5f) + arcTo(2f, 2f, 0f, true, true, 20.5f, 7.5f) + arcTo(2f, 2f, 0f, true, true, 24.5f, 7.5f) + close() + // Center-right circle + moveTo(33f, 9f) + arcTo(2f, 2f, 0f, true, true, 29f, 9f) + arcTo(2f, 2f, 0f, true, true, 33f, 9f) + close() + // Right circle + moveTo(41f, 12f) + arcTo(2f, 2f, 0f, true, true, 37f, 12f) + arcTo(2f, 2f, 0f, true, true, 41f, 12f) + close() + } + // Crown body + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(9f, 26f) + curveTo(17.5f, 24.5f, 30f, 24.5f, 36f, 26f) + lineTo(38f, 14f) + lineTo(31f, 25f) + verticalLineTo(11f) + lineTo(25.5f, 24.5f) + lineTo(22.5f, 9.5f) + lineTo(19.5f, 24.5f) + lineTo(14f, 11f) + verticalLineTo(25f) + lineTo(7f, 14f) + lineTo(9f, 26f) + close() + } + // Lower body + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(9f, 26f) + curveTo(9f, 28f, 10.5f, 28f, 11.5f, 30f) + curveTo(12.5f, 31.5f, 12.5f, 31f, 12f, 33.5f) + curveTo(10.5f, 34.5f, 10.5f, 36f, 10.5f, 36f) + curveTo(9f, 37.5f, 11f, 38.5f, 11f, 38.5f) + curveTo(17.5f, 39.5f, 27.5f, 39.5f, 34f, 38.5f) + curveTo(34f, 38.5f, 35.5f, 37.5f, 34f, 36f) + curveTo(34f, 36f, 34.5f, 34.5f, 33f, 33.5f) + curveTo(32.5f, 31f, 32.5f, 31.5f, 33.5f, 30f) + curveTo(34.5f, 28f, 36f, 28f, 36f, 26f) + curveTo(27.5f, 24.5f, 17.5f, 24.5f, 9f, 26f) + close() + } + // Inner lines + path( + fill = null, + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(11.5f, 30f) + curveTo(15f, 29f, 30f, 29f, 33.5f, 30f) + moveTo(12f, 33.5f) + curveTo(18f, 32.5f, 27f, 32.5f, 33f, 33.5f) + } + }.build() + } + + val BlackQueen: ImageVector by lazy { + ImageVector + .Builder( + name = "BlackQueen", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Crown circles (no stroke for fill) + path( + fill = SolidColor(Color.Black), + stroke = null, + ) { + // Circles at the top + moveTo(6f, 12f) + arcTo(2.75f, 2.75f, 0f, true, true, 6f, 12.01f) + close() + moveTo(14f, 9f) + arcTo(2.75f, 2.75f, 0f, true, true, 14f, 9.01f) + close() + moveTo(22.5f, 8f) + arcTo(2.75f, 2.75f, 0f, true, true, 22.5f, 8.01f) + close() + moveTo(31f, 9f) + arcTo(2.75f, 2.75f, 0f, true, true, 31f, 9.01f) + close() + moveTo(39f, 12f) + arcTo(2.75f, 2.75f, 0f, true, true, 39f, 12.01f) + close() + } + // Crown body + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(9f, 26f) + curveTo(17.5f, 24.5f, 30f, 24.5f, 36f, 26f) + lineTo(38.5f, 13.5f) + lineTo(31f, 25f) + lineTo(30.7f, 10.9f) + lineTo(25.5f, 24.5f) + lineTo(22.5f, 10f) + lineTo(19.5f, 24.5f) + lineTo(14.3f, 10.9f) + lineTo(14f, 25f) + lineTo(6.5f, 13.5f) + lineTo(9f, 26f) + close() + } + // Lower body + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(9f, 26f) + curveTo(9f, 28f, 10.5f, 28f, 11.5f, 30f) + curveTo(12.5f, 31.5f, 12.5f, 31f, 12f, 33.5f) + curveTo(10.5f, 34.5f, 10.5f, 36f, 10.5f, 36f) + curveTo(9f, 37.5f, 11f, 38.5f, 11f, 38.5f) + curveTo(17.5f, 39.5f, 27.5f, 39.5f, 34f, 38.5f) + curveTo(34f, 38.5f, 35.5f, 37.5f, 34f, 36f) + curveTo(34f, 36f, 34.5f, 34.5f, 33f, 33.5f) + curveTo(32.5f, 31f, 32.5f, 31.5f, 33.5f, 30f) + curveTo(34.5f, 28f, 36f, 28f, 36f, 26f) + curveTo(27.5f, 24.5f, 17.5f, 24.5f, 9f, 26f) + close() + } + // Bottom line + path( + fill = null, + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(11f, 38.5f) + arcTo(35f, 35f, 0f, false, false, 34f, 38.5f) + } + // Inner highlight lines + path( + fill = null, + stroke = SolidColor(Color(0xFFECECEC)), + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(11f, 29f) + arcTo(35f, 35f, 0f, false, true, 34f, 29f) + moveTo(12.5f, 31.5f) + horizontalLineTo(32.5f) + moveTo(11.5f, 34.5f) + arcTo(35f, 35f, 0f, false, false, 33.5f, 34.5f) + moveTo(10.5f, 37.5f) + arcTo(35f, 35f, 0f, false, false, 34.5f, 37.5f) + } + }.build() + } + + val WhiteRook: ImageVector by lazy { + ImageVector + .Builder( + name = "WhiteRook", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Base + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(9f, 39f) + horizontalLineTo(36f) + verticalLineTo(36f) + horizontalLineTo(9f) + verticalLineTo(39f) + close() + } + // Lower platform + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(12f, 36f) + verticalLineTo(32f) + horizontalLineTo(33f) + verticalLineTo(36f) + horizontalLineTo(12f) + close() + } + // Top battlements + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(11f, 14f) + verticalLineTo(9f) + horizontalLineTo(15f) + verticalLineTo(11f) + horizontalLineTo(20f) + verticalLineTo(9f) + horizontalLineTo(25f) + verticalLineTo(11f) + horizontalLineTo(30f) + verticalLineTo(9f) + horizontalLineTo(34f) + verticalLineTo(14f) + } + // Top slope + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(34f, 14f) + lineTo(31f, 17f) + horizontalLineTo(14f) + lineTo(11f, 14f) + } + // Body + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(31f, 17f) + verticalLineTo(29.5f) + horizontalLineTo(14f) + verticalLineTo(17f) + } + // Bottom slope + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(31f, 29.5f) + lineTo(32.5f, 32f) + horizontalLineTo(12.5f) + lineTo(14f, 29.5f) + } + // Top line + path( + fill = null, + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(11f, 14f) + horizontalLineTo(34f) + } + }.build() + } + + val BlackRook: ImageVector by lazy { + ImageVector + .Builder( + name = "BlackRook", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Base + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(9f, 39f) + horizontalLineTo(36f) + verticalLineTo(36f) + horizontalLineTo(9f) + verticalLineTo(39f) + close() + } + // Lower slope + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(12.5f, 32f) + lineTo(14f, 29.5f) + horizontalLineTo(31f) + lineTo(32.5f, 32f) + horizontalLineTo(12.5f) + close() + } + // Lower platform + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(12f, 36f) + verticalLineTo(32f) + horizontalLineTo(33f) + verticalLineTo(36f) + horizontalLineTo(12f) + close() + } + // Body + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(14f, 29.5f) + verticalLineTo(16.5f) + horizontalLineTo(31f) + verticalLineTo(29.5f) + horizontalLineTo(14f) + close() + } + // Top slope + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(14f, 16.5f) + lineTo(11f, 14f) + horizontalLineTo(34f) + lineTo(31f, 16.5f) + horizontalLineTo(14f) + close() + } + // Top battlements + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(11f, 14f) + verticalLineTo(9f) + horizontalLineTo(15f) + verticalLineTo(11f) + horizontalLineTo(20f) + verticalLineTo(9f) + horizontalLineTo(25f) + verticalLineTo(11f) + horizontalLineTo(30f) + verticalLineTo(9f) + horizontalLineTo(34f) + verticalLineTo(14f) + horizontalLineTo(11f) + close() + } + // Inner highlight lines + path( + fill = null, + stroke = SolidColor(Color(0xFFECECEC)), + strokeLineWidth = 1f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(12f, 35.5f) + horizontalLineTo(33f) + moveTo(13f, 31.5f) + horizontalLineTo(32f) + moveTo(14f, 29.5f) + horizontalLineTo(31f) + moveTo(14f, 16.5f) + horizontalLineTo(31f) + moveTo(11f, 14f) + horizontalLineTo(34f) + } + }.build() + } + + val WhiteBishop: ImageVector by lazy { + ImageVector + .Builder( + name = "WhiteBishop", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Base + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(9f, 36f) + curveTo(12.39f, 35.03f, 19.11f, 36.43f, 22.5f, 34f) + curveTo(25.89f, 36.43f, 32.61f, 35.03f, 36f, 36f) + curveTo(36f, 36f, 37.65f, 36.54f, 39f, 38f) + curveTo(38.32f, 38.97f, 37.35f, 38.99f, 36f, 38.5f) + curveTo(32.61f, 37.53f, 25.89f, 38.96f, 22.5f, 37.5f) + curveTo(19.11f, 38.96f, 12.39f, 37.53f, 9f, 38.5f) + curveTo(7.65f, 38.99f, 6.68f, 38.97f, 6f, 38f) + curveTo(7.35f, 36.06f, 9f, 36f, 9f, 36f) + close() + } + // Body + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(15f, 32f) + curveTo(17.5f, 34.5f, 27.5f, 34.5f, 30f, 32f) + curveTo(30.5f, 30.5f, 30f, 30f, 30f, 30f) + curveTo(30f, 27.5f, 27.5f, 26f, 27.5f, 26f) + curveTo(33f, 24.5f, 33.5f, 14.5f, 22.5f, 10.5f) + curveTo(11.5f, 14.5f, 12f, 24.5f, 17.5f, 26f) + curveTo(17.5f, 26f, 15f, 27.5f, 15f, 30f) + curveTo(15f, 30f, 14.5f, 30.5f, 15f, 32f) + close() + } + // Head circle + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(25f, 8f) + arcTo(2.5f, 2.5f, 0f, true, true, 20f, 8f) + arcTo(2.5f, 2.5f, 0f, true, true, 25f, 8f) + close() + } + // Inner lines + path( + fill = null, + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(17.5f, 26f) + horizontalLineTo(27.5f) + moveTo(15f, 30f) + horizontalLineTo(30f) + moveTo(22.5f, 15.5f) + verticalLineTo(20.5f) + moveTo(20f, 18f) + horizontalLineTo(25f) + } + }.build() + } + + val BlackBishop: ImageVector by lazy { + ImageVector + .Builder( + name = "BlackBishop", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Base + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(9f, 36f) + curveTo(12.4f, 35f, 19.1f, 36.4f, 22.5f, 34f) + curveTo(25.9f, 36.4f, 32.6f, 35f, 36f, 36f) + curveTo(36f, 36f, 37.6f, 36.5f, 39f, 38f) + curveTo(38.3f, 39f, 37.4f, 39f, 36f, 38.5f) + curveTo(32.6f, 37.5f, 25.9f, 39f, 22.5f, 37.5f) + curveTo(19.1f, 39f, 12.4f, 37.5f, 9f, 38.5f) + curveTo(7.6f, 39f, 6.7f, 39f, 6f, 38f) + curveTo(7.4f, 36f, 9f, 36f, 9f, 36f) + close() + } + // Body + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Butt, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(15f, 32f) + curveTo(17.5f, 34.5f, 27.5f, 34.5f, 30f, 32f) + curveTo(30.5f, 30.5f, 30f, 30f, 30f, 30f) + curveTo(30f, 27.5f, 27.5f, 26f, 27.5f, 26f) + curveTo(33f, 24.5f, 33.5f, 14.5f, 22.5f, 10.5f) + curveTo(11.5f, 14.5f, 12f, 24.5f, 17.5f, 26f) + curveTo(17.5f, 26f, 15f, 27.5f, 15f, 30f) + curveTo(15f, 30f, 14.5f, 30.5f, 15f, 32f) + close() + } + // Head circle + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(25f, 8f) + arcTo(2.5f, 2.5f, 0f, true, true, 20f, 8f) + arcTo(2.5f, 2.5f, 0f, true, true, 25f, 8f) + close() + } + // Inner highlight lines + path( + fill = null, + stroke = SolidColor(Color(0xFFECECEC)), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Miter, + ) { + moveTo(17.5f, 26f) + horizontalLineTo(27.5f) + moveTo(15f, 30f) + horizontalLineTo(30f) + moveTo(22.5f, 15.5f) + verticalLineTo(20.5f) + moveTo(20f, 18f) + horizontalLineTo(25f) + } + }.build() + } + + val WhiteKnight: ImageVector by lazy { + ImageVector + .Builder( + name = "WhiteKnight", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Body + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(22f, 10f) + curveTo(32.5f, 11f, 38.5f, 18f, 38f, 39f) + horizontalLineTo(15f) + curveTo(15f, 30f, 25f, 32.5f, 23f, 18f) + } + // Head/mane + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(24f, 18f) + curveTo(24.38f, 20.91f, 18.45f, 25.37f, 16f, 27f) + curveTo(13f, 29f, 13.18f, 31.34f, 11f, 31f) + curveTo(9.958f, 30.06f, 12.41f, 27.96f, 11f, 28f) + curveTo(10f, 28f, 11.19f, 29.23f, 10f, 30f) + curveTo(9f, 30f, 5.997f, 31f, 6f, 26f) + curveTo(6f, 24f, 12f, 14f, 12f, 14f) + reflectiveCurveTo(13.89f, 12.1f, 14f, 10.5f) + curveTo(13.27f, 9.506f, 13.5f, 8.5f, 13.5f, 7.5f) + curveTo(14.5f, 6.5f, 16.5f, 10f, 16.5f, 10f) + horizontalLineTo(18.5f) + reflectiveCurveTo(19.28f, 8.008f, 21f, 7f) + curveTo(22f, 7f, 22f, 10f, 22f, 10f) + } + // Eye and nostril + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + // Eye + moveTo(9.5f, 25.5f) + arcTo(0.5f, 0.5f, 0f, true, true, 8.5f, 25.5f) + arcTo(0.5f, 0.5f, 0f, true, true, 9.5f, 25.5f) + close() + // Nostril + moveTo(14.933f, 15.75f) + arcTo(0.5f, 1.5f, 30f, true, true, 14.067f, 15.25f) + arcTo(0.5f, 1.5f, 30f, true, true, 14.933f, 15.75f) + close() + } + }.build() + } + + val BlackKnight: ImageVector by lazy { + ImageVector + .Builder( + name = "BlackKnight", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + // Body + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(22f, 10f) + curveTo(32.5f, 11f, 38.5f, 18f, 38f, 39f) + horizontalLineTo(15f) + curveTo(15f, 30f, 25f, 32.5f, 23f, 18f) + } + // Head/mane + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(24f, 18f) + curveTo(24.38f, 20.91f, 18.45f, 25.37f, 16f, 27f) + curveTo(13f, 29f, 13.18f, 31.34f, 11f, 31f) + curveTo(9.96f, 30.06f, 12.41f, 27.96f, 11f, 28f) + curveTo(10f, 28f, 11.19f, 29.23f, 10f, 30f) + curveTo(9f, 30f, 6f, 31f, 6f, 26f) + curveTo(6f, 24f, 12f, 14f, 12f, 14f) + reflectiveCurveTo(13.89f, 12.1f, 14f, 10.5f) + curveTo(13.27f, 9.5f, 13.5f, 8.5f, 13.5f, 7.5f) + curveTo(14.5f, 6.5f, 16.5f, 10f, 16.5f, 10f) + horizontalLineTo(18.5f) + reflectiveCurveTo(19.28f, 8f, 21f, 7f) + curveTo(22f, 7f, 22f, 10f, 22f, 10f) + } + // Eye and nostril (highlights) + path( + fill = SolidColor(Color(0xFFECECEC)), + stroke = SolidColor(Color(0xFFECECEC)), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + // Eye + moveTo(9.5f, 25.5f) + arcTo(0.5f, 0.5f, 0f, true, true, 8.5f, 25.5f) + arcTo(0.5f, 0.5f, 0f, true, true, 9.5f, 25.5f) + close() + // Nostril + moveTo(14.93f, 15.75f) + arcTo(0.5f, 1.5f, 30f, true, true, 14.07f, 15.25f) + arcTo(0.5f, 1.5f, 30f, true, true, 14.93f, 15.75f) + close() + } + // Highlight on body edge + path( + fill = SolidColor(Color(0xFFECECEC)), + stroke = null, + ) { + moveTo(24.55f, 10.4f) + lineTo(24.1f, 11.85f) + lineTo(24.6f, 12f) + curveTo(27.75f, 13f, 30.25f, 14.49f, 32.5f, 18.75f) + reflectiveCurveTo(35.75f, 29.06f, 35.25f, 39f) + lineTo(35.2f, 39.5f) + horizontalLineTo(37.45f) + lineTo(37.5f, 39f) + curveTo(38f, 28.94f, 36.62f, 22.15f, 34.25f, 17.66f) + curveTo(31.88f, 13.17f, 28.46f, 11.02f, 25.06f, 10.5f) + lineTo(24.55f, 10.4f) + close() + } + }.build() + } + + val WhitePawn: ImageVector by lazy { + ImageVector + .Builder( + name = "WhitePawn", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + path( + fill = SolidColor(Color.White), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(22.5f, 9f) + curveTo(20.29f, 9f, 18.5f, 10.79f, 18.5f, 13f) + curveTo(18.5f, 13.89f, 18.79f, 14.71f, 19.28f, 15.38f) + curveTo(17.33f, 16.5f, 16f, 18.59f, 16f, 21f) + curveTo(16f, 23.03f, 16.94f, 24.84f, 18.41f, 26.03f) + curveTo(15.41f, 27.09f, 11f, 31.58f, 11f, 39.5f) + horizontalLineTo(34f) + curveTo(34f, 31.58f, 29.59f, 27.09f, 26.59f, 26.03f) + curveTo(28.06f, 24.84f, 29f, 23.03f, 29f, 21f) + curveTo(29f, 18.59f, 27.67f, 16.5f, 25.72f, 15.38f) + curveTo(26.21f, 14.71f, 26.5f, 13.89f, 26.5f, 13f) + curveTo(26.5f, 10.79f, 24.71f, 9f, 22.5f, 9f) + close() + } + }.build() + } + + val BlackPawn: ImageVector by lazy { + ImageVector + .Builder( + name = "BlackPawn", + defaultWidth = 45.dp, + defaultHeight = 45.dp, + viewportWidth = 45f, + viewportHeight = 45f, + ).apply { + path( + fill = SolidColor(Color.Black), + stroke = SolidColor(Color.Black), + strokeLineWidth = 1.5f, + strokeLineCap = StrokeCap.Round, + strokeLineJoin = StrokeJoin.Round, + ) { + moveTo(22.5f, 9f) + arcTo(4f, 4f, 0f, false, false, 19.28f, 15.38f) + arcTo(6.48f, 6.48f, 0f, false, false, 18.41f, 26.03f) + curveTo(15.41f, 27.09f, 11f, 31.58f, 11f, 39.5f) + horizontalLineTo(34f) + curveTo(34f, 31.58f, 29.59f, 27.09f, 26.59f, 26.03f) + arcTo(6.46f, 6.46f, 0f, false, false, 25.72f, 15.38f) + arcTo(4.01f, 4.01f, 0f, false, false, 22.5f, 9f) + close() + } + }.build() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt new file mode 100644 index 000000000..4a713d379 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessRelayFetchHelper.kt @@ -0,0 +1,134 @@ +/* + * 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.commons.chess + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +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 kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.withTimeoutOrNull +import java.util.concurrent.ConcurrentHashMap + +/** + * Progress callback for relay fetch operations + */ +data class RelayFetchProgress( + val relay: NormalizedRelayUrl, + val status: RelayFetchStatus, + val eventCount: Int, +) + +enum class RelayFetchStatus { + WAITING, + RECEIVING, + EOSE_RECEIVED, + TIMEOUT, +} + +/** + * One-shot relay fetch helper for chess events. + * + * Follows the existing INostrClient + IRequestListener + Channel pattern + * from quartz (see NostrClientSingleDownloadExt.kt). + * + * Each fetch opens a subscription, collects events until EOSE from all relays, + * then closes and returns the collected events. The subscription is transient — + * no state is cached between fetches. + */ +class ChessRelayFetchHelper( + private val client: INostrClient, +) { + /** + * Fetch events matching filters from relays, waiting for EOSE. + * + * @param filters Map of relay → filter list (same format as INostrClient.openReqSubscription) + * @param timeoutMs Max time to wait for relays to respond (default from ChessConfig) + * @param onProgress Optional callback for progress updates per relay + * @return Deduplicated list of events received before timeout/EOSE + */ + suspend fun fetchEvents( + filters: Map>, + timeoutMs: Long = ChessConfig.FETCH_TIMEOUT_MS, + onProgress: ((RelayFetchProgress) -> Unit)? = null, + ): List { + if (filters.isEmpty()) return emptyList() + + val events = ConcurrentHashMap() + val relayCount = filters.keys.size + val eoseReceived = ConcurrentHashMap.newKeySet() + val relayEventCounts = ConcurrentHashMap() + val allEose = CompletableDeferred() + val subId = newSubId() + + // Initialize all relays as WAITING + filters.keys.forEach { relay -> + relayEventCounts[relay] = 0 + onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.WAITING, 0)) + } + + val listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + events[event.id] = event + val count = relayEventCounts.compute(relay) { _, v -> (v ?: 0) + 1 } ?: 1 + onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.RECEIVING, count)) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + eoseReceived.add(relay) + val count = relayEventCounts[relay] ?: 0 + onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.EOSE_RECEIVED, count)) + // Complete when all relays respond + if (eoseReceived.size >= relayCount) { + allEose.complete(Unit) + } + } + } + + client.openReqSubscription(subId, filters, listener) + val eoseResult = withTimeoutOrNull(timeoutMs) { allEose.await() } + + // Mark timed-out relays + if (eoseResult == null) { + filters.keys.forEach { relay -> + if (relay !in eoseReceived) { + val count = relayEventCounts[relay] ?: 0 + onProgress?.invoke(RelayFetchProgress(relay, RelayFetchStatus.TIMEOUT, count)) + } + } + } + + client.close(subId) + + return events.values.toList() + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessSyncBanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessSyncBanner.kt new file mode 100644 index 000000000..e449e838c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessSyncBanner.kt @@ -0,0 +1,311 @@ +/* + * 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.commons.chess + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +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.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.CloudDownload +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material.icons.filled.HourglassEmpty +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +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.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** + * Shared banner showing chess sync/subscription status with expandable relay details. + * Shows incoming event progress from relays during refresh. + */ +@Composable +fun ChessSyncBanner( + status: ChessSyncStatus, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + val isVisible = status !is ChessSyncStatus.Idle + var isExpanded by remember { mutableStateOf(false) } + + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(tween(200)), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(tween(150)), + modifier = modifier, + ) { + Surface( + color = getSyncStatusBackgroundColor(status), + tonalElevation = 2.dp, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = + Modifier + .animateContentSize() + .clickable { + if (status is ChessSyncStatus.PartialSync) { + onRetry() + } else { + isExpanded = !isExpanded + } + }, + ) { + // Main status row + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Icon( + imageVector = getSyncStatusIcon(status), + contentDescription = null, + tint = getSyncStatusIconColor(status), + modifier = Modifier.size(18.dp), + ) + + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = getSyncStatusText(status), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(Modifier.width(8.dp)) + + Text( + text = getSyncStatusDetail(status), + style = MaterialTheme.typography.labelMedium, + color = getSyncStatusDetailColor(status), + ) + } + + // Progress bar for syncing + if (status is ChessSyncStatus.Syncing) { + Spacer(Modifier.height(4.dp)) + val progress = status.eoseCount.toFloat() / status.totalCount.coerceAtLeast(1) + val animatedProgress by animateFloatAsState( + targetValue = progress, + animationSpec = tween(300), + label = "syncProgress", + ) + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + + // Expand/collapse icon + if (status !is ChessSyncStatus.Idle && getRelayStates(status).isNotEmpty()) { + Icon( + imageVector = if (isExpanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (isExpanded) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + + // Expandable relay details + AnimatedVisibility( + visible = isExpanded && getRelayStates(status).isNotEmpty(), + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + Column { + HorizontalDivider( + color = MaterialTheme.colorScheme.outlineVariant, + thickness = 0.5.dp, + ) + RelayStatusList( + relayStates = getRelayStates(status), + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } + } + } + } +} + +@Composable +private fun RelayStatusList( + relayStates: List, + modifier: Modifier = Modifier, +) { + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier, + ) { + relayStates.forEach { relay -> + RelayStatusRow(relay) + } + } +} + +@Composable +private fun RelayStatusRow(relay: RelaySyncState) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.fillMaxWidth(), + ) { + Icon( + imageVector = getRelayStatusIcon(relay.status), + contentDescription = null, + tint = getRelayStatusColor(relay.status), + modifier = Modifier.size(14.dp), + ) + + Text( + text = relay.displayName, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Text( + text = "${relay.eventsReceived} events", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } +} + +private fun getRelayStates(status: ChessSyncStatus): List = + when (status) { + is ChessSyncStatus.Syncing -> status.relayStates + is ChessSyncStatus.Synced -> status.relayStates + is ChessSyncStatus.PartialSync -> status.relayStates + is ChessSyncStatus.Idle -> emptyList() + } + +private fun getRelayStatusIcon(status: RelaySyncStatus): ImageVector = + when (status) { + RelaySyncStatus.CONNECTING -> Icons.Default.HourglassEmpty + RelaySyncStatus.WAITING -> Icons.Default.HourglassEmpty + RelaySyncStatus.RECEIVING -> Icons.Default.CloudDownload + RelaySyncStatus.EOSE_RECEIVED -> Icons.Default.CheckCircle + RelaySyncStatus.FAILED -> Icons.Default.Error + } + +@Composable +private fun getRelayStatusColor(status: RelaySyncStatus): Color = + when (status) { + RelaySyncStatus.CONNECTING -> MaterialTheme.colorScheme.secondary + RelaySyncStatus.WAITING -> MaterialTheme.colorScheme.secondary + RelaySyncStatus.RECEIVING -> MaterialTheme.colorScheme.primary + RelaySyncStatus.EOSE_RECEIVED -> MaterialTheme.colorScheme.primary + RelaySyncStatus.FAILED -> MaterialTheme.colorScheme.error + } + +@Composable +private fun getSyncStatusBackgroundColor(status: ChessSyncStatus): Color = + when (status) { + is ChessSyncStatus.PartialSync -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f) + is ChessSyncStatus.Synced -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f) + else -> MaterialTheme.colorScheme.surfaceContainer + } + +private fun getSyncStatusIcon(status: ChessSyncStatus): ImageVector = + when (status) { + is ChessSyncStatus.Syncing -> Icons.Default.CloudDownload + is ChessSyncStatus.Synced -> Icons.Default.CheckCircle + is ChessSyncStatus.PartialSync -> Icons.Default.Warning + is ChessSyncStatus.Idle -> Icons.Default.CheckCircle + } + +@Composable +private fun getSyncStatusIconColor(status: ChessSyncStatus): Color = + when (status) { + is ChessSyncStatus.PartialSync -> MaterialTheme.colorScheme.error + is ChessSyncStatus.Synced -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.primary + } + +private fun getSyncStatusText(status: ChessSyncStatus): String = + when (status) { + is ChessSyncStatus.Syncing -> "Syncing ${status.phase}..." + is ChessSyncStatus.Synced -> "Synced" + is ChessSyncStatus.PartialSync -> status.message + is ChessSyncStatus.Idle -> "" + } + +private fun getSyncStatusDetail(status: ChessSyncStatus): String = + when (status) { + is ChessSyncStatus.Syncing -> "${status.eoseCount}/${status.totalCount} relays • ${status.totalEventsReceived} events" + is ChessSyncStatus.Synced -> "${status.successCount}/${status.totalCount} relays • ${status.challengeCount} challenges • ${status.gameCount} games" + is ChessSyncStatus.PartialSync -> "Tap to retry" + is ChessSyncStatus.Idle -> "" + } + +@Composable +private fun getSyncStatusDetailColor(status: ChessSyncStatus): Color = + when (status) { + is ChessSyncStatus.PartialSync -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/IUserMetadataProvider.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/IUserMetadataProvider.kt new file mode 100644 index 000000000..c10c9a90c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/IUserMetadataProvider.kt @@ -0,0 +1,33 @@ +/* + * 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.commons.chess + +/** + * Platform-specific metadata provider for enriching chess challenges with display info. + * + * Android: wraps LocalCache.users[pubkey].info + * Desktop: wraps UserMetadataCache + */ +interface IUserMetadataProvider { + fun getDisplayName(pubkey: String): String + + fun getPictureUrl(pubkey: String): String? +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt new file mode 100644 index 000000000..c91613b26 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/InteractiveChessBoard.kt @@ -0,0 +1,394 @@ +/* + * 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.commons.chess + +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.quartz.nip64Chess.ChessEngine +import com.vitorpamplona.quartz.nip64Chess.ChessPiece +import com.vitorpamplona.quartz.nip64Chess.PieceType +import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor + +/** + * Interactive chess board that allows piece selection and moves + * + * @param engine Chess engine for move validation and legal moves + * @param modifier Modifier for the board + * @param boardSize Total size of the board in dp + * @param flipped If true, renders from black's perspective (rank 1 at top) + * @param playerColor The color the player is playing as (only allows moving these pieces) + * @param isSpectator If true, disables all interaction (spectator/watch mode) + * @param onMoveMade Callback when a valid move is made, receives (from, to, san) + * NOTE: This callback should make the actual move - this component only validates + */ +@Composable +fun InteractiveChessBoard( + engine: ChessEngine, + modifier: Modifier = Modifier, + boardSize: Dp = 400.dp, + flipped: Boolean = false, + playerColor: ChessColor? = null, // null = allow both (for local play) + isSpectator: Boolean = false, + positionVersion: Int = 0, // External trigger for position refresh (e.g. moveHistory.size) + onMoveMade: (from: String, to: String, san: String) -> Unit = { _, _, _ -> }, +) { + // Track local move count + external version to trigger recomposition when board changes + var localMoveCount by remember { mutableStateOf(0) } + val effectiveVersion = localMoveCount + positionVersion + val position = remember(engine, effectiveVersion) { engine.getPosition() } + val sideToMove = remember(engine, effectiveVersion) { engine.getSideToMove() } + var selectedSquare by remember { mutableStateOf?>(null) } + var legalMoves by remember { mutableStateOf>(emptyList()) } + + // Pawn promotion state + var pendingPromotion by remember { mutableStateOf(null) } + + // Clear stale selection when position changes externally (e.g. opponent move) + LaunchedEffect(positionVersion) { + selectedSquare = null + legalMoves = emptyList() + pendingPromotion = null + } + + // Can only interact if not spectating and it's your turn (or playerColor is null for local play) + val canInteract = !isSpectator && (playerColor == null || sideToMove == playerColor) + + val squareSize = boardSize / 8 + + // Rank iteration order based on perspective + val rankRange = if (flipped) (0..7) else (7 downTo 0) + val fileRange = if (flipped) (7 downTo 0) else (0..7) + + Box(modifier = modifier.size(boardSize)) { + Column(modifier = Modifier.fillMaxSize()) { + for (rank in rankRange) { + Row { + for (file in fileRange) { + val piece = position.pieceAt(file, rank) + val isLightSquare = (rank + file) % 2 == 0 + val square = fileRankToSquare(file, rank) + val isSelected = selectedSquare == (file to rank) + val isLegalMove = legalMoves.contains(square) + + val showCoord = if (flipped) rank == 7 else rank == 0 + + InteractiveChessSquare( + piece = piece, + isLight = isLightSquare, + size = squareSize, + isSelected = isSelected, + isLegalMove = isLegalMove, + showCoordinate = showCoord, + file = file, + rank = rank, + onClick = { + if (!canInteract || pendingPromotion != null) return@InteractiveChessSquare + + if (selectedSquare != null) { + // Attempt to make move - validate first, don't actually make it + val from = fileRankToSquare(selectedSquare!!.first, selectedSquare!!.second) + val to = square + + if (legalMoves.contains(to)) { + // Check if this is a pawn promotion move + val fromRank = selectedSquare!!.second + val toRank = rank + val movingPiece = position.pieceAt(selectedSquare!!.first, fromRank) + val isPromotion = + movingPiece?.type == PieceType.PAWN && + (toRank == 7 || toRank == 0) + + if (isPromotion) { + // Show promotion dialog + pendingPromotion = + PendingPromotion( + from = from, + to = to, + file = file, + rank = toRank, + color = sideToMove, + ) + } else { + // Valid move - callback will make the actual move + selectedSquare = null + legalMoves = emptyList() + // Pass empty san - callback will get it from makeMove result + onMoveMade(from, to, "") + localMoveCount++ // Trigger recomposition after move is made + } + } else { + // Not a legal move target - try selecting this square instead + val validColor = playerColor ?: sideToMove + if (piece != null && piece.color == validColor) { + selectedSquare = file to rank + legalMoves = engine.getLegalMovesFrom(square) + } else { + selectedSquare = null + legalMoves = emptyList() + } + } + } else { + // Select piece - only allow selecting player's pieces + val validColor = playerColor ?: sideToMove + if (piece != null && piece.color == validColor) { + selectedSquare = file to rank + legalMoves = engine.getLegalMovesFrom(square) + } + } + }, + ) + } + } + } + } + + // Promotion dialog overlay + pendingPromotion?.let { promo -> + PromotionPicker( + color = promo.color, + squareSize = squareSize, + onPieceSelected = { pieceType -> + // Make the move with promotion + val promotionSuffix = + when (pieceType) { + PieceType.QUEEN -> "q" + PieceType.ROOK -> "r" + PieceType.BISHOP -> "b" + PieceType.KNIGHT -> "n" + else -> "q" + } + selectedSquare = null + legalMoves = emptyList() + pendingPromotion = null + // Include promotion in the 'to' square for the callback + onMoveMade(promo.from, promo.to + promotionSuffix, "") + localMoveCount++ + }, + onDismiss = { + pendingPromotion = null + selectedSquare = null + legalMoves = emptyList() + }, + ) + } + } +} + +/** + * Single interactive chess square + */ +@Composable +private fun InteractiveChessSquare( + piece: com.vitorpamplona.quartz.nip64Chess.ChessPiece?, + isLight: Boolean, + size: Dp, + isSelected: Boolean, + isLegalMove: Boolean, + showCoordinate: Boolean, + file: Int, + rank: Int, + onClick: () -> Unit, +) { + val backgroundColor = + when { + isSelected -> Color(0xFF7FA650) + isLegalMove -> Color(0xFFAAC75B).copy(alpha = 0.6f) + isLight -> Color(0xFFF0D9B5) + else -> Color(0xFFB58863) + } + + Box( + modifier = + Modifier + .size(size) + .background(backgroundColor) + .clickable { onClick() } + .let { + if (isSelected) { + it.border(2.dp, Color(0xFF4A7536)) + } else { + it + } + }, + contentAlignment = Alignment.Center, + ) { + // Display piece using CBurnett ImageVector icons + piece?.let { + Image( + imageVector = it.toImageVector(), + contentDescription = "${it.color} ${it.type}", + modifier = Modifier.fillMaxSize().padding(2.dp), + ) + } + + // Show legal move indicator + if (isLegalMove && piece == null) { + Box( + modifier = + Modifier + .size(size * 0.25f) + .background(Color(0xFF7FA650), CircleShape) + .alpha(0.5f), + ) + } else if (isLegalMove && piece != null) { + // Capture indicator - ring around piece + Box( + modifier = + Modifier + .size(size * 0.8f) + .border(3.dp, Color(0xFF7FA650).copy(alpha = 0.5f), CircleShape), + ) + } + + // Show file coordinate (a-h) on bottom rank + if (showCoordinate) { + Text( + text = ('a' + file).toString(), + fontSize = 10.sp, + fontWeight = FontWeight.Bold, + color = if (isLight) Color(0xFFB58863) else Color(0xFFF0D9B5), + modifier = + Modifier + .align(Alignment.BottomEnd) + .padding(2.dp), + ) + } + } +} + +/** + * Convert file/rank to algebraic notation (e.g., 0,0 -> "a1") + */ +private fun fileRankToSquare( + file: Int, + rank: Int, +): String = "${('a' + file)}${rank + 1}" + +/** + * Extension function to convert ChessPiece to CBurnett ImageVector + */ +private fun ChessPiece.toImageVector(): ImageVector { + val white = color == ChessColor.WHITE + return when (type) { + PieceType.KING -> if (white) ChessPieceVectors.WhiteKing else ChessPieceVectors.BlackKing + PieceType.QUEEN -> if (white) ChessPieceVectors.WhiteQueen else ChessPieceVectors.BlackQueen + PieceType.ROOK -> if (white) ChessPieceVectors.WhiteRook else ChessPieceVectors.BlackRook + PieceType.BISHOP -> if (white) ChessPieceVectors.WhiteBishop else ChessPieceVectors.BlackBishop + PieceType.KNIGHT -> if (white) ChessPieceVectors.WhiteKnight else ChessPieceVectors.BlackKnight + PieceType.PAWN -> if (white) ChessPieceVectors.WhitePawn else ChessPieceVectors.BlackPawn + } +} + +/** + * Data class for pending pawn promotion + */ +private data class PendingPromotion( + val from: String, + val to: String, + val file: Int, + val rank: Int, + val color: ChessColor, +) + +/** + * Promotion piece picker overlay + */ +@Composable +private fun PromotionPicker( + color: ChessColor, + squareSize: Dp, + onPieceSelected: (PieceType) -> Unit, + onDismiss: () -> Unit, +) { + val isWhite = color == ChessColor.WHITE + val promotionPieces = + listOf( + PieceType.QUEEN to if (isWhite) ChessPieceVectors.WhiteQueen else ChessPieceVectors.BlackQueen, + PieceType.ROOK to if (isWhite) ChessPieceVectors.WhiteRook else ChessPieceVectors.BlackRook, + PieceType.BISHOP to if (isWhite) ChessPieceVectors.WhiteBishop else ChessPieceVectors.BlackBishop, + PieceType.KNIGHT to if (isWhite) ChessPieceVectors.WhiteKnight else ChessPieceVectors.BlackKnight, + ) + + // Semi-transparent overlay + Box( + modifier = + Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.5f)) + .clickable { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + Card( + shape = RoundedCornerShape(8.dp), + modifier = Modifier.clickable { /* prevent dismiss */ }, + ) { + Row( + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + promotionPieces.forEach { (pieceType, icon) -> + Box( + modifier = + Modifier + .size(squareSize) + .background(Color(0xFFF0D9B5), RoundedCornerShape(4.dp)) + .clickable { onPieceSelected(pieceType) }, + contentAlignment = Alignment.Center, + ) { + Image( + imageVector = icon, + contentDescription = pieceType.name, + modifier = Modifier.fillMaxSize().padding(4.dp), + ) + } + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt new file mode 100644 index 000000000..977c452b6 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt @@ -0,0 +1,927 @@ +/* + * 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.commons.chess + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.scaleIn +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +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.draw.alpha +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.Dialog +import com.vitorpamplona.quartz.nip64Chess.ChessEngine +import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.GameResult +import com.vitorpamplona.quartz.nip64Chess.GameStatus +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import androidx.compose.ui.graphics.Color as ComposeColor + +/** + * Dialog for creating a new chess game challenge + * + * @param onDismiss Callback when dialog is dismissed + * @param onCreateGame Callback when game is created (opponentPubkey, playerColor) + */ +@Composable +fun NewChessGameDialog( + onDismiss: () -> Unit, + onCreateGame: (opponentPubkey: String?, playerColor: Color) -> Unit, +) { + var opponentPubkey by remember { mutableStateOf("") } + var selectedColor by remember { mutableStateOf(Color.WHITE) } + + Dialog(onDismissRequest = onDismiss) { + Card( + modifier = + Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "New Chess Game", + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + + Text( + text = "Create a new chess game challenge", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + OutlinedTextField( + value = opponentPubkey, + onValueChange = { opponentPubkey = it }, + label = { Text("Opponent npub (optional)") }, + placeholder = { Text("Leave blank for open challenge") }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + ) + + Text( + text = "Your color:", + style = MaterialTheme.typography.labelMedium, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedButton( + onClick = { selectedColor = Color.WHITE }, + modifier = Modifier.weight(1f), + ) { + Text( + text = if (selectedColor == Color.WHITE) "✓ White" else "White", + fontWeight = if (selectedColor == Color.WHITE) FontWeight.Bold else FontWeight.Normal, + ) + } + + OutlinedButton( + onClick = { selectedColor = Color.BLACK }, + modifier = Modifier.weight(1f), + ) { + Text( + text = if (selectedColor == Color.BLACK) "✓ Black" else "Black", + fontWeight = if (selectedColor == Color.BLACK) FontWeight.Bold else FontWeight.Normal, + ) + } + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + OutlinedButton(onClick = onDismiss) { + Text("Cancel") + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + onCreateGame( + opponentPubkey.ifBlank { null }, + selectedColor, + ) + }, + ) { + Text("Create Game") + } + } + } + } + } +} + +/** + * Complete live chess game UI with board, controls, and game info + * + * This version observes LiveChessGameState flows for automatic UI updates + * when polling refreshes the game state. + * + * @param modifier Modifier for the root layout + * @param gameState Live chess game state (observed for updates) + * @param opponentName Opponent's display name + * @param onMoveMade Callback when player makes a move (from, to, san) + * @param onResign Callback when player resigns + * @param isSpectatorOverride If non-null, overrides gameState.isSpectator. Use this when + * spectator status is determined by which map the game is in (activeGames vs spectatingGames) + * rather than the potentially stale isSpectator flag from relay reconstruction. + */ +@Composable +fun LiveChessGameScreen( + modifier: Modifier = Modifier, + gameState: LiveChessGameState, + opponentName: String, + onMoveMade: (from: String, to: String, san: String) -> Unit, + onResign: () -> Unit, + isSpectatorOverride: Boolean? = null, + onGameEndDismiss: (() -> Unit)? = null, +) { + // Observe state flows for automatic recomposition on updates + val currentPosition by gameState.currentPosition.collectAsState() + val moveHistory by gameState.moveHistory.collectAsState() + val gameStatus by gameState.gameStatus.collectAsState() + + // Use override if provided, otherwise fall back to gameState flag + val isSpectator = isSpectatorOverride ?: gameState.isSpectator + + // Pending challenges and spectators cannot make moves + val canMakeMoves = !isSpectator && !gameState.isPendingChallenge + + // Track if game end overlay was dismissed + var gameEndDismissed by remember { mutableStateOf(false) } + + Box(modifier = modifier.fillMaxSize()) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize(), + ) { + // Calculate board size dynamically based on available space + // Use a percentage of available height to leave room for other UI elements + val availableWidth = maxWidth - 32.dp // Account for horizontal padding + // Reserve ~35% of height for header, history, controls, and any banners + val availableHeight = maxHeight * 0.65f + val boardSize = min(availableWidth, availableHeight).coerceIn(150.dp, 520.dp) + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Game info - use currentPosition.activeColor for turn display + GameInfoHeader( + gameId = gameState.gameId, + opponentName = opponentName, + playerColor = gameState.playerColor, + currentTurn = currentPosition.activeColor, + isSpectator = isSpectator, + isPendingChallenge = gameState.isPendingChallenge, + gameStatus = gameStatus, + ) + + // Interactive chess board - flip when playing black (spectators see from white's view) + // Auto-sized to fit available space + InteractiveChessBoard( + engine = gameState.engine, + boardSize = boardSize, + flipped = !isSpectator && gameState.playerColor == Color.BLACK, + playerColor = gameState.playerColor, + isSpectator = !canMakeMoves, + positionVersion = moveHistory.size, + onMoveMade = onMoveMade, + ) + + // Move history (scrollable) - observed from state flow + MoveHistoryDisplay( + moves = moveHistory, + ) + + // Show appropriate controls based on game state + when { + gameStatus is GameStatus.Finished -> { + GameEndInfo( + result = (gameStatus as GameStatus.Finished).result, + playerColor = gameState.playerColor, + isSpectator = isSpectator, + ) + } + + gameState.isPendingChallenge -> { + PendingChallengeInfo() + } + + isSpectator -> { + SpectatorInfo() + } + + else -> { + GameControls(onResign = onResign) + } + } + } + } + + // Game end celebration overlay + if (gameStatus is GameStatus.Finished && !gameEndDismissed) { + GameEndOverlay( + result = (gameStatus as GameStatus.Finished).result, + playerColor = gameState.playerColor, + isSpectator = isSpectator, + onDismiss = { + gameEndDismissed = true + onGameEndDismiss?.invoke() + }, + ) + } + } +} + +/** + * Legacy version that takes engine directly (for backwards compatibility) + * + * @param modifier Modifier for the root layout + * @param engine Chess engine instance + * @param playerColor Color the player is playing as + * @param gameId Unique game identifier + * @param opponentName Opponent's display name + * @param onMoveMade Callback when player makes a move (from, to, san) + * @param onResign Callback when player resigns + */ +@Composable +fun LiveChessGameScreen( + modifier: Modifier = Modifier, + engine: ChessEngine, + playerColor: Color, + gameId: String, + opponentName: String, + onMoveMade: (from: String, to: String, san: String) -> Unit, + onResign: () -> Unit, +) { + BoxWithConstraints( + modifier = modifier.fillMaxSize(), + ) { + // Calculate board size based on available space + // Leave room for header (~100dp), history (~60dp), controls (~60dp), and padding + val availableWidth = maxWidth - 32.dp // Account for horizontal padding + val availableHeight = maxHeight - 250.dp // Account for other UI elements + val boardSize = min(availableWidth, availableHeight).coerceAtLeast(200.dp) + + Column( + modifier = + Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + // Game info + GameInfoHeader( + gameId = gameId, + opponentName = opponentName, + playerColor = playerColor, + currentTurn = engine.getSideToMove(), + ) + + // Interactive chess board - flip when playing black + // Auto-sized to fit available space + InteractiveChessBoard( + engine = engine, + boardSize = boardSize, + flipped = playerColor == Color.BLACK, + positionVersion = engine.getMoveHistory().size, + onMoveMade = onMoveMade, + ) + + // Move history (scrollable) + MoveHistoryDisplay( + moves = engine.getMoveHistory(), + ) + + // Game controls + GameControls( + onResign = onResign, + ) + } + } +} + +/** + * Game information header showing game ID, opponent, and current turn + */ +@Composable +private fun GameInfoHeader( + gameId: String, + opponentName: String, + playerColor: Color, + currentTurn: Color, + isSpectator: Boolean = false, + isPendingChallenge: Boolean = false, + gameStatus: GameStatus = GameStatus.InProgress, +) { + // Extract human-readable game name if available + val gameName = + remember(gameId) { + ChessGameNameGenerator.extractDisplayName(gameId) + } + + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + // Show readable name prominently if available + if (gameName != null) { + Text( + text = gameName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary, + ) + } + + Text( + text = if (gameName != null) gameId.take(16) else "Game: ${gameId.take(8)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + when { + isPendingChallenge -> { + Text( + text = "Challenge Pending", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.secondary, + ) + + Text( + text = "You are playing ${if (playerColor == Color.WHITE) "White" else "Black"}", + style = MaterialTheme.typography.bodyMedium, + ) + + Text( + text = if (opponentName.isNotEmpty()) "Waiting for $opponentName" else "Open challenge", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + isSpectator -> { + Text( + text = "Spectating", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.tertiary, + ) + + val turnText = "${if (currentTurn == Color.WHITE) "White" else "Black"}'s turn" + Text( + text = turnText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + else -> { + Text( + text = "vs $opponentName", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + + Text( + text = "You are playing ${if (playerColor == Color.WHITE) "White" else "Black"}", + style = MaterialTheme.typography.bodyMedium, + ) + + // Show turn or game result + when (gameStatus) { + is GameStatus.Finished -> { + val result = (gameStatus as GameStatus.Finished).result + val resultText = + when { + result == GameResult.DRAW -> "Draw" + + (result == GameResult.WHITE_WINS && playerColor == Color.WHITE) || + (result == GameResult.BLACK_WINS && playerColor == Color.BLACK) -> "You won!" + + else -> "You lost" + } + val resultColor = + when { + result == GameResult.DRAW -> { + MaterialTheme.colorScheme.secondary + } + + (result == GameResult.WHITE_WINS && playerColor == Color.WHITE) || + (result == GameResult.BLACK_WINS && playerColor == Color.BLACK) -> { + ComposeColor(0xFF4CAF50) + } + + else -> { + MaterialTheme.colorScheme.error + } + } + Text( + text = resultText, + style = MaterialTheme.typography.bodyMedium, + color = resultColor, + fontWeight = FontWeight.Bold, + ) + } + + else -> { + val turnText = + if (currentTurn == playerColor) { + "Your turn" + } else { + "Opponent's turn" + } + + Text( + text = turnText, + style = MaterialTheme.typography.bodyMedium, + color = + if (currentTurn == playerColor) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, + fontWeight = if (currentTurn == playerColor) FontWeight.Bold else FontWeight.Normal, + ) + } + } + } + } + } +} + +/** + * Info banner shown when spectating a game + */ +@Composable +private fun SpectatorInfo() { + Box( + modifier = + Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f), + RoundedCornerShape(8.dp), + ).padding(12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Watching game - spectator mode", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } +} + +/** + * Info banner shown when viewing a pending challenge + */ +@Composable +private fun PendingChallengeInfo() { + Box( + modifier = + Modifier + .fillMaxWidth() + .background( + MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f), + RoundedCornerShape(8.dp), + ).padding(12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Waiting for opponent to accept challenge", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } +} + +/** + * Display move history in SAN notation with move numbers + * Shows in a horizontally scrollable container + */ +@Composable +private fun MoveHistoryDisplay(moves: List) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = "Move History", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 4.dp), + ) + + Box( + modifier = + Modifier + .fillMaxWidth() + .height(48.dp) + .background( + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + RoundedCornerShape(8.dp), + ).padding(horizontal = 8.dp, vertical = 4.dp), + ) { + if (moves.isEmpty()) { + Text( + text = "No moves yet", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.align(Alignment.CenterStart), + ) + } else { + val scrollState = rememberScrollState() + + Row( + modifier = + Modifier + .horizontalScroll(scrollState) + .align(Alignment.CenterStart), + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Group moves into pairs (white, black) + moves.chunked(2).forEachIndexed { index, movePair -> + // Move number + Text( + text = "${index + 1}.", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + // White's move + Text( + text = movePair[0], + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .background( + MaterialTheme.colorScheme.surface, + RoundedCornerShape(4.dp), + ).padding(horizontal = 4.dp, vertical = 2.dp), + ) + + // Black's move (if exists) + if (movePair.size > 1) { + Text( + text = movePair[1], + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + modifier = + Modifier + .background( + MaterialTheme.colorScheme.surfaceVariant, + RoundedCornerShape(4.dp), + ).padding(horizontal = 4.dp, vertical = 2.dp), + ) + } + + Spacer(modifier = Modifier.width(8.dp)) + } + } + } + } + } +} + +/** + * Game control buttons (Resign) + * Note: Draw offers not supported in Jester protocol + */ +@Composable +private fun GameControls(onResign: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + ) { + OutlinedButton(onClick = onResign) { + Text("Resign") + } + } +} + +/** + * Game end celebration overlay + */ +@Composable +private fun GameEndOverlay( + result: GameResult, + playerColor: Color, + isSpectator: Boolean, + onDismiss: () -> Unit, +) { + val playerWon = + when (result) { + GameResult.WHITE_WINS -> playerColor == Color.WHITE + GameResult.BLACK_WINS -> playerColor == Color.BLACK + GameResult.DRAW, GameResult.IN_PROGRESS -> false + } + + val isDraw = result == GameResult.DRAW + + // Determine display based on result + val (emoji, title, subtitle, backgroundColor) = + when { + isSpectator -> { + val winnerText = + when (result) { + GameResult.WHITE_WINS -> "White wins!" + GameResult.BLACK_WINS -> "Black wins!" + GameResult.DRAW -> "Draw!" + GameResult.IN_PROGRESS -> "Game Over" + } + Quadruple("", "Game Over", winnerText, MaterialTheme.colorScheme.surfaceVariant) + } + + playerWon -> { + Quadruple( + "", + "Victory!", + "Congratulations!", + ComposeColor(0xFF4CAF50).copy(alpha = 0.95f), + ) + } + + isDraw -> { + Quadruple( + "", + "Draw", + "Game ended in a draw", + MaterialTheme.colorScheme.surfaceVariant, + ) + } + + else -> { + Quadruple( + "", + "Defeat", + "Better luck next time!", + ComposeColor(0xFFE57373).copy(alpha = 0.95f), + ) + } + } + + // Animated visibility + AnimatedVisibility( + visible = true, + enter = fadeIn(animationSpec = tween(300)) + scaleIn(animationSpec = tween(300)), + ) { + Box( + modifier = + Modifier + .fillMaxSize() + .background(ComposeColor.Black.copy(alpha = 0.7f)) + .clickable { onDismiss() }, + contentAlignment = Alignment.Center, + ) { + Card( + modifier = + Modifier + .padding(32.dp) + .clickable { /* prevent dismiss on card click */ }, + shape = RoundedCornerShape(24.dp), + colors = + CardDefaults.cardColors( + containerColor = backgroundColor, + ), + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + ) { + Column( + modifier = + Modifier + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Trophy/emoji based on result + val displayEmoji = + when { + isSpectator -> "GG" + playerWon -> "GG" + isDraw -> "=" + else -> "GG" + } + + Box( + modifier = + Modifier + .size(80.dp) + .background( + when { + isSpectator -> MaterialTheme.colorScheme.primary.copy(alpha = 0.2f) + playerWon -> ComposeColor(0xFFFFD700).copy(alpha = 0.3f) + isDraw -> MaterialTheme.colorScheme.secondary.copy(alpha = 0.2f) + else -> MaterialTheme.colorScheme.error.copy(alpha = 0.2f) + }, + CircleShape, + ), + contentAlignment = Alignment.Center, + ) { + Text( + text = displayEmoji, + fontSize = 28.sp, + fontWeight = FontWeight.Bold, + color = + when { + isSpectator -> MaterialTheme.colorScheme.primary + playerWon -> ComposeColor(0xFFFFD700) + isDraw -> MaterialTheme.colorScheme.secondary + else -> MaterialTheme.colorScheme.error + }, + ) + } + + Text( + text = title, + style = MaterialTheme.typography.headlineLarge, + fontWeight = FontWeight.Bold, + color = + when { + isSpectator -> MaterialTheme.colorScheme.onSurfaceVariant + playerWon -> ComposeColor.White + isDraw -> MaterialTheme.colorScheme.onSurfaceVariant + else -> ComposeColor.White + }, + ) + + Text( + text = subtitle, + style = MaterialTheme.typography.bodyLarge, + color = + when { + isSpectator -> MaterialTheme.colorScheme.onSurfaceVariant + playerWon -> ComposeColor.White.copy(alpha = 0.9f) + isDraw -> MaterialTheme.colorScheme.onSurfaceVariant + else -> ComposeColor.White.copy(alpha = 0.9f) + }, + textAlign = TextAlign.Center, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = onDismiss, + ) { + Text("Continue") + } + } + } + } + } +} + +/** + * Compact game end info shown in controls area + */ +@Composable +private fun GameEndInfo( + result: GameResult, + playerColor: Color, + isSpectator: Boolean, +) { + val resultText = + when { + isSpectator -> { + when (result) { + GameResult.WHITE_WINS -> "White wins" + GameResult.BLACK_WINS -> "Black wins" + GameResult.DRAW -> "Draw" + GameResult.IN_PROGRESS -> "In progress" + } + } + + result == GameResult.DRAW -> { + "Game drawn" + } + + (result == GameResult.WHITE_WINS && playerColor == Color.WHITE) || + (result == GameResult.BLACK_WINS && playerColor == Color.BLACK) -> { + "You won!" + } + + else -> { + "You lost" + } + } + + val backgroundColor = + when { + isSpectator -> { + MaterialTheme.colorScheme.surfaceVariant + } + + result == GameResult.DRAW -> { + MaterialTheme.colorScheme.secondaryContainer + } + + (result == GameResult.WHITE_WINS && playerColor == Color.WHITE) || + (result == GameResult.BLACK_WINS && playerColor == Color.BLACK) -> { + ComposeColor(0xFF4CAF50).copy(alpha = 0.3f) + } + + else -> { + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.5f) + } + } + + Box( + modifier = + Modifier + .fillMaxWidth() + .background(backgroundColor, RoundedCornerShape(8.dp)) + .padding(12.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Game Over - $resultText", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} + +/** + * Helper data class for quadruple values + */ +private data class Quadruple( + val first: A, + val second: B, + val third: C, + val fourth: D, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt new file mode 100644 index 000000000..f9a1b51c3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/MoveNavigator.kt @@ -0,0 +1,137 @@ +/* + * 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.commons.chess + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowLeft +import androidx.compose.material.icons.filled.KeyboardArrowRight +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material.icons.filled.SkipPrevious +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.unit.dp + +/** + * Move navigation controls for stepping through chess game positions + * Shared composable for Android and Desktop platforms + * + * @param currentMove Current move index (0 = starting position) + * @param totalMoves Total number of moves in the game + * @param onMoveChange Callback when user navigates to a different move + * @param modifier Modifier for the navigator + */ +@Composable +fun MoveNavigator( + currentMove: Int, + totalMoves: Int, + onMoveChange: (Int) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + // First move (starting position) + IconButton( + onClick = { onMoveChange(0) }, + enabled = currentMove > 0, + ) { + Icon( + Icons.Default.SkipPrevious, + contentDescription = "Start position", + tint = + if (currentMove > 0) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + + // Previous move + IconButton( + onClick = { onMoveChange((currentMove - 1).coerceAtLeast(0)) }, + enabled = currentMove > 0, + ) { + Icon( + Icons.Default.KeyboardArrowLeft, + contentDescription = "Previous move", + tint = + if (currentMove > 0) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + + // Current position indicator + Text( + text = "Move $currentMove / $totalMoves", + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(horizontal = 16.dp), + ) + + // Next move + IconButton( + onClick = { onMoveChange((currentMove + 1).coerceAtMost(totalMoves)) }, + enabled = currentMove < totalMoves, + ) { + Icon( + Icons.Default.KeyboardArrowRight, + contentDescription = "Next move", + tint = + if (currentMove < totalMoves) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + + // Last move (final position) + IconButton( + onClick = { onMoveChange(totalMoves) }, + enabled = currentMove < totalMoves, + ) { + Icon( + Icons.Default.SkipNext, + contentDescription = "Final position", + tint = + if (currentMove < totalMoves) { + MaterialTheme.colorScheme.onSurface + } else { + MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) + }, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/PGNMetadata.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/PGNMetadata.kt new file mode 100644 index 000000000..16441b52d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/PGNMetadata.kt @@ -0,0 +1,135 @@ +/* + * 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.commons.chess + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +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 com.vitorpamplona.quartz.nip64Chess.ChessGame + +/** + * Display PGN game metadata (Event, players, result, etc.) + * Shared composable for Android and Desktop platforms + * + * @param game The chess game with metadata to display + * @param modifier Modifier for the metadata display + */ +@Composable +fun PGNMetadata( + game: ChessGame, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // Event name + game.event?.let { event -> + Text( + text = event, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + // Site/location (if available) + game.site?.let { site -> + Text( + text = site, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Players + if (game.white != null || game.black != null) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + game.white?.let { white -> + Text( + text = white, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Text( + text = "vs", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + game.black?.let { black -> + Text( + text = black, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + + // Date and Result + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + game.date?.let { date -> + Text( + text = date, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Result + Text( + text = "Result: ${game.result.notation}", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Round (if available) + game.round?.let { round -> + Text( + text = "Round $round", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt new file mode 100644 index 000000000..90ff1070e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessFilterBuilder.kt @@ -0,0 +1,310 @@ +/* + * 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.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.utils.TimeUtils + +/** + * Shared filter builder for chess subscriptions using Jester protocol. + * Used by both Android and Desktop to ensure identical subscription behavior. + * + * Jester Protocol (kind 30): + * - All chess events use kind 30 + * - Start events: e-tag references START_POSITION_HASH + * - Move events: e-tags [startEventId, headEventId] + * - Content JSON determines event type (kind: 0=start, 1=move, 2=chat) + * + * Builds 4 types of filters: + * 1. Personal filters - Events tagged with user's pubkey + * 2. Start/Challenge filters - Events referencing START_POSITION_HASH + * 3. Active game filters - Events referencing specific game startEventIds + * 4. Recent game filters - For spectating discovery + */ +object ChessFilterBuilder { + /** Jester protocol kind for all chess events */ + private const val JESTER_KIND = JesterProtocol.KIND + + /** + * Build all chess filters for a given subscription state. + * This is the main entry point - platforms call this to get filters. + * + * @param state The current subscription state with user info and active games + * @param sinceForRelay Function to get cached EOSE time for a relay (null if no cache) + * @return List of relay-specific filters to subscribe with + */ + fun buildAllFilters( + state: ChessSubscriptionState, + sinceForRelay: (NormalizedRelayUrl) -> Long?, + ): List { + val filters = mutableListOf() + val now = TimeUtils.now() + + // Filter 1: Personal events (challenges to us, moves in our games) + filters.addAll( + buildPersonalFilters(state.userPubkey, state.relays, sinceForRelay, now), + ) + + // Filter 2: All start/challenge events (for lobby display) + filters.addAll( + buildStartEventFilters(state.relays, sinceForRelay, now), + ) + + // Filter 3: Active game subscriptions (game-specific) + if (state.hasActiveGames) { + filters.addAll( + buildActiveGameFilters( + state.allGameIds, + state.userPubkey, + state.opponentPubkeys, + state.relays, + ), + ) + } + + // Filter 4: Recent game events (for spectating discovery) + filters.addAll( + buildRecentGameFilters(state.relays, sinceForRelay, now), + ) + + return filters + } + + /** + * Personal events - tagged with user's pubkey. + * Catches: challenges directed at us, moves in our games. + */ + fun buildPersonalFilters( + userPubkey: String, + relays: Set, + sinceForRelay: (NormalizedRelayUrl) -> Long?, + now: Long = TimeUtils.now(), + ): List { + val filter = + Filter( + kinds = listOf(JESTER_KIND), + tags = mapOf("p" to listOf(userPubkey)), + limit = 100, + ) + + return relays.map { relay -> + val sinceTime = + sinceForRelay(relay) + ?: (now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS) + RelayBasedFilter( + relay = relay, + filter = filter.copy(since = sinceTime), + ) + } + } + + /** + * Start/challenge events (Jester pattern). + * In Jester protocol, start events reference the START_POSITION_HASH. + * This fetches all game starts for lobby display. + */ + fun buildStartEventFilters( + relays: Set, + sinceForRelay: (NormalizedRelayUrl) -> Long?, + now: Long = TimeUtils.now(), + ): List { + val filter = + Filter( + kinds = listOf(JESTER_KIND), + tags = mapOf("e" to listOf(JesterProtocol.START_POSITION_HASH)), + limit = 100, + ) + + return relays.map { relay -> + val sinceTime = + sinceForRelay(relay) + ?: (now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS) + RelayBasedFilter( + relay = relay, + filter = filter.copy(since = sinceTime), + ) + } + } + + /** + * Active game events - game-specific subscriptions. + * These filters ensure moves are received for games the user is playing. + * + * In Jester protocol: + * - Move events have e-tags: [startEventId, headEventId] + * - We filter by the first e-tag (startEventId) to get all moves for a game + * - We also filter by opponent authors and p-tag for redundancy + */ + fun buildActiveGameFilters( + startEventIds: Set, + userPubkey: String, + opponentPubkeys: Set, + relays: Set, + ): List { + if (startEventIds.isEmpty()) return emptyList() + + val filters = mutableListOf() + + // Game events: filter by e-tag (startEventId) + // This catches all moves/events for these games + val gameFilter = + Filter( + kinds = listOf(JESTER_KIND), + tags = mapOf("e" to startEventIds.toList()), + limit = 500, + ) + + relays.forEach { relay -> + filters.add(RelayBasedFilter(relay = relay, filter = gameFilter)) + } + + // Also filter by p-tag (opponent tagged us) for redundancy + val tagFilter = + Filter( + kinds = listOf(JESTER_KIND), + tags = mapOf("p" to listOf(userPubkey)), + limit = 200, + ) + + relays.forEach { relay -> + filters.add(RelayBasedFilter(relay = relay, filter = tagFilter)) + } + + // Also filter by authors (opponent pubkeys) for redundancy + if (opponentPubkeys.isNotEmpty()) { + val authorFilter = + Filter( + kinds = listOf(JESTER_KIND), + authors = opponentPubkeys.toList(), + limit = 200, + ) + + relays.forEach { relay -> + filters.add(RelayBasedFilter(relay = relay, filter = authorFilter)) + } + } + + return filters + } + + /** + * Recent game events for spectating discovery. + * Uses longer time window (7 days) to catch ongoing games. + */ + fun buildRecentGameFilters( + relays: Set, + sinceForRelay: (NormalizedRelayUrl) -> Long?, + now: Long = TimeUtils.now(), + ): List { + val filter = + Filter( + kinds = listOf(JESTER_KIND), + limit = 100, + ) + + return relays.map { relay -> + val sinceTime = + sinceForRelay(relay) + ?: (now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS) + RelayBasedFilter( + relay = relay, + filter = filter.copy(since = sinceTime), + ) + } + } + + // =================================================== + // Simple filters for one-shot fetches (ChessRelayFetcher) + // =================================================== + + /** + * Filter for all events related to a specific game. + * Used for one-shot fetch when loading a game. + * + * In Jester protocol, all game events reference the startEventId via e-tag. + */ + fun gameEventsFilter(startEventId: String): Filter = + Filter( + kinds = listOf(JESTER_KIND), + tags = mapOf("e" to listOf(startEventId)), + limit = 500, + ) + + /** + * Filter for start/challenge events in the last 24 hours. + * Used for one-shot fetch to populate lobby. + * + * Start events reference the START_POSITION_HASH. + */ + fun challengesFilter(userPubkey: String? = null): Filter { + val now = TimeUtils.now() + return Filter( + kinds = listOf(JESTER_KIND), + tags = mapOf("e" to listOf(JesterProtocol.START_POSITION_HASH)), + since = now - ChessTimeWindows.CHALLENGE_WINDOW_SECONDS, + limit = 100, + ) + } + + /** + * Filter for recent game activity for spectating discovery. + * Fetches events from the last 7 days. + */ + fun recentGamesFilter(): Filter { + val now = TimeUtils.now() + return Filter( + kinds = listOf(JESTER_KIND), + since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS, + limit = 200, + ) + } + + /** + * Filter for user's own chess events (events they authored). + * Used to discover games the user is participating in. + */ + fun userGamesFilter(userPubkey: String): Filter { + val now = TimeUtils.now() + return Filter( + kinds = listOf(JESTER_KIND), + authors = listOf(userPubkey), + since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS, + limit = 200, + ) + } + + /** + * Filter for events tagged with user's pubkey (games they're participating in). + * Complements userGamesFilter to find games where user is the opponent. + */ + fun userTaggedFilter(userPubkey: String): Filter { + val now = TimeUtils.now() + return Filter( + kinds = listOf(JESTER_KIND), + tags = mapOf("p" to listOf(userPubkey)), + since = now - ChessTimeWindows.GAME_EVENT_WINDOW_SECONDS, + limit = 200, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessSubscriptionController.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessSubscriptionController.kt new file mode 100644 index 000000000..4539e42a2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessSubscriptionController.kt @@ -0,0 +1,69 @@ +/* + * 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.commons.chess.subscription + +import kotlinx.coroutines.flow.StateFlow + +/** + * Interface for platform-specific chess subscription management. + * + * Implementations handle the actual relay subscription mechanics while + * using [ChessFilterBuilder] for consistent filter construction. + * + * Key responsibilities: + * - Subscribe/unsubscribe to chess events on relays + * - Update filters when active games change + * - Track EOSE (End of Stored Events) per relay for efficient re-subscription + */ +interface ChessSubscriptionController { + /** Current subscription state, null if not subscribed */ + val currentState: StateFlow + + /** + * Subscribe to chess events with the given state. + * Replaces any existing subscription. + */ + fun subscribe(state: ChessSubscriptionState) + + /** Unsubscribe from all chess events */ + fun unsubscribe() + + /** + * Update active game IDs and refresh subscription filters. + * This is the key method for dynamic subscription updates. + * + * When a game starts or ends, call this to update the filters + * so moves are properly received for active games. + * + * @param activeGameIds Game IDs the user is actively playing + * @param spectatingGameIds Game IDs the user is watching (optional) + */ + fun updateActiveGames( + activeGameIds: Set, + spectatingGameIds: Set = emptySet(), + ) + + /** + * Force refresh all filters from relays. + * Clears EOSE cache so full history is re-fetched. + */ + fun forceRefresh() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessSubscriptionState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessSubscriptionState.kt new file mode 100644 index 000000000..27c43f37c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessSubscriptionState.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess.subscription + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Immutable state for chess relay subscriptions. + * Used to generate subscription filters and track subscription identity. + * + * When any of these values change, a new subscription should be created + * with updated filters. + */ +@Immutable +data class ChessSubscriptionState( + /** User's public key (hex) for filtering personal events */ + val userPubkey: String, + /** Set of relays to subscribe to */ + val relays: Set, + /** Game IDs the user is actively playing */ + val activeGameIds: Set = emptySet(), + /** Game IDs the user is spectating */ + val spectatingGameIds: Set = emptySet(), + /** Opponent pubkeys for active games (to filter their moves) */ + val opponentPubkeys: Set = emptySet(), +) { + /** + * Unique subscription ID that changes when game IDs change. + * Used for: + * - Subscription deduplication + * - EOSE cache keying + * - Detecting when filters need to be refreshed + */ + fun subscriptionId(): String = + buildString { + append("chess-") + append(userPubkey.take(8)) + if (allGameIds.isNotEmpty()) { + append("-g") + append(allGameIds.sorted().hashCode()) + } + } + + /** All game IDs requiring move subscriptions (active + spectating) */ + val allGameIds: Set + get() = activeGameIds + spectatingGameIds + + /** True if user has any active or spectating games */ + val hasActiveGames: Boolean + get() = allGameIds.isNotEmpty() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessTimeWindows.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessTimeWindows.kt new file mode 100644 index 000000000..85267bfff --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/subscription/ChessTimeWindows.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.chess.subscription + +/** + * Shared time window constants for chess subscriptions. + * Used by both Android and Desktop to ensure consistent behavior. + */ +object ChessTimeWindows { + /** + * 24 hours - challenges older than this are considered expired. + * Used for challenge and accept event filters. + */ + const val CHALLENGE_WINDOW_SECONDS = 24 * 60 * 60L + + /** + * 7 days - default lookback for game events when no EOSE cache exists. + * This prevents loading ancient game history on first connection + * while still allowing recovery of recent games. + */ + const val GAME_EVENT_WINDOW_SECONDS = 7 * 24 * 60 * 60L +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/data/UserMetadataCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/data/UserMetadataCache.kt new file mode 100644 index 000000000..ac0c4b47f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/data/UserMetadataCache.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.data + +import com.vitorpamplona.quartz.nip01Core.metadata.UserMetadata +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Simple data class for cached user display info + */ +data class UserDisplayInfo( + val pubkey: String, + val displayName: String?, + val pictureUrl: String?, + val nip05: String?, +) + +/** + * Lightweight user metadata cache for UI display. + * Can be used by any feature (chess, chat, etc.) that needs user info. + * + * Thread-safe via StateFlow updates. + */ +class UserMetadataCache { + private val _metadata = MutableStateFlow>(emptyMap()) + val metadata: StateFlow> = _metadata.asStateFlow() + + private val _pubkeysNeeded = MutableStateFlow>(emptySet()) + val pubkeysNeeded: StateFlow> = _pubkeysNeeded.asStateFlow() + + /** + * Add or update metadata for a pubkey + */ + fun put( + pubkey: String, + userMetadata: UserMetadata, + ) { + _metadata.value = _metadata.value + (pubkey to userMetadata) + _pubkeysNeeded.value = _pubkeysNeeded.value - pubkey + } + + /** + * Get metadata for a pubkey (may be null if not cached) + */ + fun get(pubkey: String): UserMetadata? = _metadata.value[pubkey] + + /** + * Check if metadata is cached for a pubkey + */ + fun contains(pubkey: String): Boolean = _metadata.value.containsKey(pubkey) + + /** + * Request metadata for a pubkey (adds to needed set if not cached) + */ + fun request(pubkey: String) { + if (!contains(pubkey) && pubkey !in _pubkeysNeeded.value) { + _pubkeysNeeded.value = _pubkeysNeeded.value + pubkey + } + } + + /** + * Request metadata for multiple pubkeys + */ + fun requestAll(pubkeys: Collection) { + val newNeeded = pubkeys.filter { !contains(it) && it !in _pubkeysNeeded.value } + if (newNeeded.isNotEmpty()) { + _pubkeysNeeded.value = _pubkeysNeeded.value + newNeeded + } + } + + /** + * Get display name for a pubkey, falling back to truncated pubkey + */ + fun getDisplayName(pubkey: String): String { + val meta = get(pubkey) + return meta?.bestName() ?: formatPubkeyShort(pubkey) + } + + /** + * Get profile picture URL for a pubkey + */ + fun getPictureUrl(pubkey: String): String? = get(pubkey)?.profilePicture() + + /** + * Get display info for a pubkey (for UI binding) + */ + fun getDisplayInfo(pubkey: String): UserDisplayInfo { + val meta = get(pubkey) + return UserDisplayInfo( + pubkey = pubkey, + displayName = meta?.bestName(), + pictureUrl = meta?.profilePicture(), + nip05 = meta?.nip05, + ) + } + + /** + * Clear metadata that was requested but hasn't been fetched + * (call when pubkeys are no longer needed) + */ + fun clearNeeded(pubkeys: Collection) { + _pubkeysNeeded.value = _pubkeysNeeded.value - pubkeys.toSet() + } + + /** + * Clear all cached metadata + */ + fun clear() { + _metadata.value = emptyMap() + _pubkeysNeeded.value = emptySet() + } + + companion object { + /** + * Format pubkey for display (npub-style truncation) + */ + fun formatPubkeyShort(pubkey: String): String = + if (pubkey.length > 12) { + "${pubkey.take(8)}...${pubkey.takeLast(4)}" + } else { + pubkey + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt index ffd4c5dc7..9e95521a2 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/Note.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.model import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.nip88Polls.PollResponsesCache import com.vitorpamplona.amethyst.commons.threading.checkNotInMainThread import com.vitorpamplona.amethyst.commons.util.firstFullCharOrEmoji import com.vitorpamplona.amethyst.commons.util.replace @@ -131,6 +132,12 @@ open class Note( removeReport(note) } + var poll: PollResponsesCache? = null + + fun pollStateOrNull(): PollResponsesCache? = poll + + fun pollState(): PollResponsesCache = poll ?: PollResponsesCache().also { poll = it } + // These fields are updated every time an event related to this note is received. var replies = listOf() private set @@ -1007,6 +1014,34 @@ public inline fun Iterable.anyEvent(predicate: (T) -> Boolean): Boolea return false } +public inline fun Iterable.filterEvents(predicate: (T) -> Boolean): List { + if (this is Collection && isEmpty()) return emptyList() + + val dest = ArrayList() + for (note in this) { + val noteEvent = note.event as? T + if (noteEvent != null && predicate(noteEvent)) { + dest.add(noteEvent) + } + } + return dest +} + +public inline fun Iterable.filterAuthoredEvents(pubkey: HexKey): List { + if (this is Collection && isEmpty()) return emptyList() + + val dest = ArrayList() + for (note in this) { + if (note.author?.pubkeyHex != pubkey) { + val noteEvent = note.event as? T + if (noteEvent != null) { + dest.add(noteEvent) + } + } + } + return dest +} + public inline fun Iterable.anyNotNullEvent(predicate: (Event) -> Boolean): Boolean { if (this is Collection && isEmpty()) return false for (note in this) { @@ -1015,3 +1050,21 @@ public inline fun Iterable.anyNotNullEvent(predicate: (Event) -> Boolean): } return false } + +fun List.latestByAuthor(): Map { + val oneResponsePerUser = mutableMapOf() + + forEach { note -> + val author = note.author ?: return@forEach + val event = note.event as? T ?: return@forEach + + val currentResponse = oneResponsePerUser[author] + if (currentResponse == null) { + oneResponsePerUser[author] = event + } else if (event.createdAt > currentResponse.createdAt) { + oneResponsePerUser[author] = event + } + } + + return oneResponsePerUser +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip18Reposts/RepostAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip18Reposts/RepostAction.kt index f99c9f643..0da993242 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip18Reposts/RepostAction.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip18Reposts/RepostAction.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.amethyst.commons.model.nip18Reposts import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent import com.vitorpamplona.quartz.nip18Reposts.RepostEvent @@ -35,34 +35,26 @@ object RepostAction { /** * Creates a signed repost event. * - * @param event The event to repost + * @param eventHint The event to repost * @param signer The NostrSigner to sign the event - * @param noteHint Optional relay hint where the note was seen - * @param authorHint Optional relay hint for the author's outbox * @return Signed repost event ready to broadcast * @throws IllegalStateException if signer is not writeable */ suspend fun repost( - event: Event, + eventHint: EventHintBundle, signer: NostrSigner, - noteHint: String? = null, - authorHint: String? = null, ): Event { if (!signer.isWriteable()) { throw IllegalStateException("Cannot repost: signer is not writeable") } - // Normalize relay hints - val normalizedNoteHint = noteHint?.let { RelayUrlNormalizer.normalizeOrNull(it) } - val normalizedAuthorHint = authorHint?.let { RelayUrlNormalizer.normalizeOrNull(it) } - // Use NIP-18 RepostEvent (kind 6) for text notes (kind 1) // Use GenericRepostEvent (kind 16) for all other kinds val template = - if (event.kind == 1) { - RepostEvent.build(event, normalizedNoteHint, normalizedAuthorHint) + if (eventHint.event.kind == 1) { + RepostEvent.build(eventHint) } else { - GenericRepostEvent.build(event, normalizedNoteHint, normalizedAuthorHint) + GenericRepostEvent.build(eventHint) } return signer.sign(template) @@ -86,10 +78,8 @@ object RepostAction { if (!signer.isWriteable()) return null if (note.hasBoostedInTheLast5Minutes(signer.pubKey)) return null - val noteEvent = note.event ?: return null - val noteHint = note.relayHintUrl()?.url - val authorHint = note.author?.bestRelayHint()?.url + val hint = note.toEventHint() ?: return null - return repost(noteEvent, signer, noteHint, authorHint) + return repost(hint, signer) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt index 249dc74e9..5a1511b74 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip25Reactions/ReactionAction.kt @@ -24,7 +24,6 @@ import com.vitorpamplona.amethyst.commons.model.Note import com.vitorpamplona.amethyst.commons.model.User import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip17Dm.NIP17Factory import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group @@ -49,18 +48,14 @@ object ReactionAction { * @throws IllegalStateException if signer is not writeable */ suspend fun reactTo( - event: Event, + eventHint: EventHintBundle, reaction: String, signer: NostrSigner, - relayHint: String? = null, ): ReactionEvent { if (!signer.isWriteable()) { throw IllegalStateException("Cannot react: signer is not writeable") } - val normalizedRelayHint = relayHint?.let { RelayUrlNormalizer.normalizeOrNull(it) } - val eventHint = EventHintBundle(event, normalizedRelayHint) - // Handle custom emoji reactions (format: ":emoji_name:") val template = if (reaction.startsWith(":")) { @@ -82,10 +77,9 @@ object ReactionAction { * Creates a "like" reaction ("+"). */ suspend fun like( - event: Event, + event: EventHintBundle, signer: NostrSigner, - relayHint: String? = null, - ): ReactionEvent = reactTo(event, "+", signer, relayHint) + ): ReactionEvent = reactTo(event, "+", signer) /** * Advanced: React to an event with support for NIP-17 private groups. @@ -102,7 +96,6 @@ object ReactionAction { * @param onPrivate Callback for private group reactions (returns NIP17Factory.Result) */ suspend fun reactToWithGroupSupport( - event: Event, eventHint: EventHintBundle, reaction: String, signer: NostrSigner, @@ -113,6 +106,8 @@ object ReactionAction { throw IllegalStateException("Cannot react: signer is not writeable") } + val event = eventHint.event + // Check if this is a NIP-17 private group event if (event is NIP17Group) { val users = event.groupMembers().toList() @@ -185,9 +180,8 @@ object ReactionAction { if (!signer.isWriteable()) return if (note.hasReacted(by, reaction)) return - val noteEvent = note.event ?: return val eventHint = note.toEventHint() ?: return - reactToWithGroupSupport(noteEvent, eventHint, reaction, signer, onPublic, onPrivate) + reactToWithGroupSupport(eventHint, reaction, signer, onPublic, onPrivate) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt new file mode 100644 index 000000000..08f17280d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt @@ -0,0 +1,146 @@ +/* + * 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.commons.model.nip88Polls + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.UserDependencies +import com.vitorpamplona.amethyst.commons.model.latestByAuthor +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.update +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.set + +@Stable +class PollResponsesCache : UserDependencies { + companion object { + val DefaultFeedOrder: Comparator = + compareByDescending { it.createdAt }.thenBy { it.id } + } + + class ResponseTally( + val allResponses: List = emptyList(), + ) { + val votes: Map = allResponses.latestByAuthor() + val tally: Map> = votes.votesByOption() + + fun winning() = tally.maxByOrNull { it.value.size }?.key + + fun totalVotes() = tally.entries.sumOf { it.value.size } + } + + val responses = MutableStateFlow(ResponseTally()) + + fun addResponse(note: Note) { + // if it's already there, quick exit + if (responses.value.allResponses.contains(note)) return + + responses.update { + ResponseTally( + it.allResponses + note, + ) + } + } + + fun removeResponse(deleteNote: Note) { + // if it's not already there, quick exit + if (!responses.value.allResponses.contains(deleteNote)) return + + responses.update { + ResponseTally( + it.allResponses - deleteNote, + ) + } + } + + fun ResponseTally.filterTo( + code: String, + forKey: HexKey, + priority: Set, + ): TallyResults { + val comparator = compareByDescending { it.pubkeyHex == forKey }.thenByDescending { it.pubkeyHex in priority }.thenBy { it.pubkeyHex } + + val usersThatVotedForThisOption = tally[code] ?: emptyList() + + val votes = totalVotes() + + val percent = + if (votes > 0) { + usersThatVotedForThisOption.size.toFloat() / votes.toFloat() + } else { + 0f + } + + val sortedUsers = usersThatVotedForThisOption.sortedWith(comparator) + + return TallyResults(sortedUsers, percent, code == winning()) + } + + fun currentTally( + code: String, + forKey: HexKey, + priorityAccounts: Set, + ): TallyResults = responses.value.filterTo(code, forKey, priorityAccounts) + + fun tallyFlow( + code: String, + forKey: HexKey, + priorityAccounts: Flow>, + ): Flow = + combine(responses, priorityAccounts) { responses, priority -> + responses.filterTo(code, forKey, priority) + } + + fun hasPubKeyVoted(user: User): Boolean = responses.value.votes.containsKey(user) + + fun hasPubKeyVotedFlow(user: User): Flow = responses.map { hasPubKeyVoted(user) }.distinctUntilChanged() +} + +@Stable +class TallyResults( + val users: List = emptyList(), + val percent: Float = 0.0f, + val isWinning: Boolean = false, +) + +fun Map.votesByOption(): Map> { + val tally = mutableMapOf>() + + this.forEach { (user, responseEvent) -> + responseEvent.responses().forEach { code -> + val currentTally = tally[code] + if (currentTally == null) { + tally[code] = mutableSetOf(user) + } else { + currentTally.add(user) + } + } + } + return tally +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastBanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastBanner.kt new file mode 100644 index 000000000..3009fd10b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastBanner.kt @@ -0,0 +1,192 @@ +/* + * 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.commons.profile + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +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.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Error +import androidx.compose.material.icons.filled.Sync +import androidx.compose.material3.Icon +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +/** + * Shared banner showing profile metadata broadcast progress. + * Works on both Android and Desktop via Compose Multiplatform. + */ +@Composable +fun ProfileBroadcastBanner( + status: ProfileBroadcastStatus, + onTap: () -> Unit, + modifier: Modifier = Modifier, +) { + val isVisible = status !is ProfileBroadcastStatus.Idle + + AnimatedVisibility( + visible = isVisible, + enter = slideInVertically(initialOffsetY = { -it }) + fadeIn(tween(200)), + exit = slideOutVertically(targetOffsetY = { -it }) + fadeOut(tween(150)), + modifier = modifier, + ) { + Surface( + color = getStatusBackgroundColor(status), + tonalElevation = 2.dp, + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onTap), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = + Modifier + .padding(horizontal = 16.dp, vertical = 10.dp) + .animateContentSize(), + ) { + Icon( + imageVector = getStatusIcon(status), + contentDescription = null, + tint = getStatusIconColor(status), + modifier = Modifier.size(18.dp), + ) + + Column(modifier = Modifier.weight(1f)) { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = getStatusText(status), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Spacer(Modifier.width(8.dp)) + + Text( + text = getStatusDetail(status), + style = MaterialTheme.typography.labelMedium, + color = getStatusDetailColor(status), + ) + } + + // Progress bar for broadcasting + if (status is ProfileBroadcastStatus.Broadcasting) { + Spacer(Modifier.height(4.dp)) + + val animatedProgress by animateFloatAsState( + targetValue = status.progress, + animationSpec = tween(300), + label = "broadcastProgress", + ) + + LinearProgressIndicator( + progress = { animatedProgress }, + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + } + } + } + } +} + +@Composable +private fun getStatusBackgroundColor(status: ProfileBroadcastStatus): Color = + when (status) { + is ProfileBroadcastStatus.Failed -> MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.9f) + is ProfileBroadcastStatus.Success -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.9f) + else -> MaterialTheme.colorScheme.surfaceContainer + } + +private fun getStatusIcon(status: ProfileBroadcastStatus): ImageVector = + when (status) { + is ProfileBroadcastStatus.Broadcasting -> Icons.Default.Sync + is ProfileBroadcastStatus.Success -> Icons.Default.CheckCircle + is ProfileBroadcastStatus.Failed -> Icons.Default.Error + is ProfileBroadcastStatus.Idle -> Icons.Default.CheckCircle + } + +@Composable +private fun getStatusIconColor(status: ProfileBroadcastStatus): Color = + when (status) { + is ProfileBroadcastStatus.Failed -> MaterialTheme.colorScheme.error + is ProfileBroadcastStatus.Success -> MaterialTheme.colorScheme.primary + else -> MaterialTheme.colorScheme.primary + } + +private fun getStatusText(status: ProfileBroadcastStatus): String = + when (status) { + is ProfileBroadcastStatus.Broadcasting -> "Updating ${status.fieldName}..." + is ProfileBroadcastStatus.Success -> "Updated ${status.fieldName}" + is ProfileBroadcastStatus.Failed -> "Failed to update ${status.fieldName}" + is ProfileBroadcastStatus.Idle -> "" + } + +private fun getStatusDetail(status: ProfileBroadcastStatus): String = + when (status) { + is ProfileBroadcastStatus.Broadcasting -> "[${status.successCount}/${status.totalRelays}]" + is ProfileBroadcastStatus.Success -> "${status.relayCount} relays" + is ProfileBroadcastStatus.Failed -> "Tap to retry" + is ProfileBroadcastStatus.Idle -> "" + } + +@Composable +private fun getStatusDetailColor(status: ProfileBroadcastStatus): Color = + when (status) { + is ProfileBroadcastStatus.Failed -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.primary + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/DeterministicSigner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastStatus.kt similarity index 60% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/DeterministicSigner.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastStatus.kt index d3a5c34ce..c5906088d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/DeterministicSigner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastStatus.kt @@ -18,23 +18,29 @@ * 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.nip01Core.crypto +package com.vitorpamplona.amethyst.commons.profile -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +/** + * Status of profile metadata broadcast to relays. + */ +sealed class ProfileBroadcastStatus { + data object Idle : ProfileBroadcastStatus() -class DeterministicSigner( - val key: KeyPair, - val pubKey: HexKey = key.pubKey.toHexKey(), -) { - fun sign( - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - ): T = EventAssembler.hashAndSign(pubKey, createdAt, kind, tags, content, key.privKey!!, nonce = null) + data class Broadcasting( + val fieldName: String, + val successCount: Int, + val totalRelays: Int, + ) : ProfileBroadcastStatus() { + val progress: Float get() = if (totalRelays > 0) successCount.toFloat() / totalRelays else 0f + } - fun sign(ev: EventTemplate): T = sign(ev.createdAt, ev.kind, ev.tags, ev.content) + data class Success( + val fieldName: String, + val relayCount: Int, + ) : ProfileBroadcastStatus() + + data class Failed( + val fieldName: String, + val error: String, + ) : ProfileBroadcastStatus() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedState.kt index 8a0a3e864..f46ac43ea 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/FeedState.kt @@ -28,14 +28,18 @@ import kotlinx.coroutines.flow.MutableStateFlow @Stable sealed class FeedState { + @Stable object Loading : FeedState() + @Stable class Loaded( val feed: MutableStateFlow>, ) : FeedState() + @Stable object Empty : FeedState() + @Stable class FeedError( val errorMessage: String, ) : FeedState() diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt new file mode 100644 index 000000000..ac6e7d896 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessEventBroadcaster.kt @@ -0,0 +1,152 @@ +/* + * 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.commons.chess + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.sendAndWaitForResponse +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 kotlinx.coroutines.delay + +/** + * Result of broadcasting an event + */ +data class BroadcastResult( + val success: Boolean, + val relayResults: Map, + val message: String, +) + +/** + * Helper for broadcasting chess events to relays with reliable delivery. + * + * Uses sendAndWaitForResponse to get actual OK confirmations from relays, + * ensuring the event was actually received and accepted. + */ +class ChessEventBroadcaster( + private val client: INostrClient, +) { + /** + * Broadcast an event to the chess relays with confirmation. + * + * This method: + * 1. Triggers relay connections via a dummy subscription + * 2. Waits for relays to connect + * 3. Sends the event and waits for OK responses + * + * @param event The signed event to broadcast + * @param timeoutSeconds Maximum time to wait for relay responses + * @return BroadcastResult with success status + */ + suspend fun broadcast( + event: Event, + timeoutSeconds: Long = 15L, + ): BroadcastResult { + val targetRelays = ChessConfig.CHESS_RELAYS.map { NormalizedRelayUrl(it) }.toSet() + val subId = newSubId() + + // Step 1: Check which relays are already connected + val initialConnected = client.connectedRelaysFlow().value + val alreadyConnected = targetRelays.intersect(initialConnected) + val needsConnection = targetRelays - alreadyConnected + + // Step 2: If some relays need connection, open a subscription to trigger it + if (needsConnection.isNotEmpty()) { + // Use a valid filter with recent since timestamp to trigger connection + // without expecting real results + val dummyFilter = + Filter( + kinds = listOf(JesterProtocol.KIND), + since = (System.currentTimeMillis() / 1000) + 3600, // 1 hour in future = no results + limit = 1, + ) + + val listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { } + } + + // Open subscription to all target relays (triggers connection) + val filterMap = targetRelays.associateWith { listOf(dummyFilter) } + client.openReqSubscription(subId, filterMap, listener) + + // Wait for relays to connect (poll with timeout) + waitForRelays(targetRelays, 5000L) + + // Close the dummy subscription + client.close(subId) + } + + // Step 3: Send the event and wait for OK responses + val success = client.sendAndWaitForResponse(event, targetRelays, timeoutSeconds) + + // Note: sendAndWaitForResponse only returns aggregate success (any relay accepted) + // We don't have per-relay results, so relayResults is empty + return BroadcastResult( + success = success, + relayResults = emptyMap(), + message = if (success) "Event accepted by at least one relay" else "No relay accepted the event", + ) + } + + /** + * Wait for target relays to appear in connectedRelaysFlow. + */ + private suspend fun waitForRelays( + targetRelays: Set, + timeoutMs: Long, + ): Set { + val startTime = System.currentTimeMillis() + val pollInterval = 200L + + while (System.currentTimeMillis() - startTime < timeoutMs) { + val connected = client.connectedRelaysFlow().value + val targetConnected = targetRelays.intersect(connected) + + if (targetConnected.size == targetRelays.size) { + return targetConnected + } + + // At least one connected is good enough after half the timeout + if (targetConnected.isNotEmpty() && System.currentTimeMillis() - startTime > timeoutMs / 2) { + return targetConnected + } + + delay(pollInterval) + } + + return client.connectedRelaysFlow().value.intersect(targetRelays) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 3b41cdbeb..1220f08e2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -36,6 +36,7 @@ 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 @@ -83,6 +84,7 @@ import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount +import com.vitorpamplona.amethyst.desktop.chess.ChessScreen import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen @@ -124,6 +126,8 @@ sealed class DesktopScreen { object Notifications : DesktopScreen() + object Chess : DesktopScreen() + object MyProfile : DesktopScreen() data class UserProfile( @@ -510,6 +514,13 @@ fun MainContent( 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") }, @@ -617,6 +628,14 @@ fun MainContent( NotificationsScreen(relayManager, account, subscriptionsCoordinator) } + DesktopScreen.Chess -> { + ChessScreen( + relayManager = relayManager, + account = account, + onBack = { onScreenChange(DesktopScreen.Feed) }, + ) + } + DesktopScreen.MyProfile -> { UserProfileScreen( pubKeyHex = account.pubKeyHex, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt new file mode 100644 index 000000000..537cdd2f5 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -0,0 +1,823 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.chess + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +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.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +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.filled.Add +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.min +import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastBanner +import com.vitorpamplona.amethyst.commons.chess.ChessChallenge +import com.vitorpamplona.amethyst.commons.chess.ChessConfig +import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner +import com.vitorpamplona.amethyst.commons.chess.CompletedGame +import com.vitorpamplona.amethyst.commons.chess.InteractiveChessBoard +import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog +import com.vitorpamplona.amethyst.commons.chess.PublicGame +import com.vitorpamplona.amethyst.commons.data.UserMetadataCache +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.createChessSubscriptionWithGames +import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataListSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.quartz.nip64Chess.ChessGameNameGenerator +import com.vitorpamplona.quartz.nip64Chess.Color as ChessColor + +/** + * Desktop chess screen with challenge list and game view + */ +@Composable +fun ChessScreen( + relayManager: DesktopRelayConnectionManager, + account: AccountState.LoggedIn, + onBack: () -> Unit = {}, +) { + val scope = rememberCoroutineScope() + val viewModel = + remember(account.pubKeyHex) { + DesktopChessViewModelNew(account, relayManager, scope) + } + val relayStatuses by relayManager.relayStatuses.collectAsState() + val broadcastStatus by viewModel.broadcastStatus.collectAsState() + val activeGames by viewModel.activeGames.collectAsState() + // Observe state version to force recomposition when game state changes + val stateVersion by viewModel.stateVersion.collectAsState() + + // Ensure chess relays are added to the relay manager for broadcasting + LaunchedEffect(Unit) { + ChessConfig.CHESS_RELAYS.forEach { relayUrl -> + relayManager.addRelay(relayUrl) + } + println("[ChessScreen] Added ${ChessConfig.CHESS_RELAYS.size} chess relays to relay manager") + } + + // Derive stable keys to avoid recomposition from LiveChessGameState identity changes + val activeGameIds = remember(activeGames.keys) { activeGames.keys.toSet() } + val opponentPubkeys = + remember(activeGameIds) { + activeGames.values.map { it.opponentPubkey }.toSet() + } + + // Subscribe to chess events from dedicated chess relays + // Re-subscribes when active games change + val chessRelays = + remember { + ChessConfig.CHESS_RELAYS + .map { + com.vitorpamplona.quartz.nip01Core.relay.normalizer + .NormalizedRelayUrl(it) + }.toSet() + } + rememberSubscription(chessRelays, account, activeGameIds, opponentPubkeys, relayManager = relayManager) { + createChessSubscriptionWithGames( + relays = chessRelays, + userPubkey = account.pubKeyHex, + activeGameIds = activeGameIds, + opponentPubkeys = opponentPubkeys, + onEvent = { event, _, _, _ -> + viewModel.handleIncomingEvent(event) + }, + onEose = { _, _ -> + // ChessLobbyLogic handles loading state internally + }, + ) + } + + // Subscribe to user metadata for pubkeys that need it + val pubkeysNeeded by viewModel.userMetadataCache.pubkeysNeeded.collectAsState() + rememberSubscription(relayStatuses, pubkeysNeeded, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isNotEmpty() && pubkeysNeeded.isNotEmpty()) { + createMetadataListSubscription( + relays = configuredRelays, + pubKeys = pubkeysNeeded.toList(), + onEvent = { event, _, _, _ -> + viewModel.handleIncomingEvent(event) + }, + ) + } else { + null + } + } + + val challenges by viewModel.challenges.collectAsState() + val spectatingGames by viewModel.spectatingGames.collectAsState() + val publicGames by viewModel.publicGames.collectAsState() + val completedGames by viewModel.completedGames.collectAsState() + // Observe metadata changes to trigger recomposition + val userMetadata by viewModel.userMetadataCache.metadata.collectAsState() + val selectedGameId by viewModel.selectedGameId.collectAsState() + val error by viewModel.error.collectAsState() + val isRefreshing by viewModel.isRefreshing.collectAsState() + val syncStatus by viewModel.syncStatus.collectAsState() + var showNewGameDialog by remember { mutableStateOf(false) } + + Column(modifier = Modifier.fillMaxSize()) { + // Header + Row( + modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + if (selectedGameId != null) { + IconButton(onClick = { viewModel.selectGame(null) }) { + Icon(Icons.Default.ArrowBack, "Back to list") + } + Spacer(Modifier.width(8.dp)) + } + Text( + if (selectedGameId != null) "Live Game" else "Chess", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + } + + if (selectedGameId == null) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Refresh button + IconButton(onClick = { viewModel.forceRefresh() }) { + Icon(Icons.Default.Refresh, "Refresh") + } + + // New Game button + if (!account.isReadOnly) { + Button(onClick = { showNewGameDialog = true }) { + Icon(Icons.Default.Add, "New Game") + Spacer(Modifier.width(8.dp)) + Text("New Game") + } + } + } + } + } + + // Sync status banner (shown in both lobby and game views) + ChessSyncBanner( + status = syncStatus, + onRetry = { viewModel.forceRefresh() }, + modifier = Modifier.padding(bottom = 8.dp), + ) + + // Broadcast status banner (shows when publishing moves) + ChessBroadcastBanner( + status = broadcastStatus, + onTap = { viewModel.forceRefresh() }, + ) + + // Error display + error?.let { errorMsg -> + Card( + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text(errorMsg, color = MaterialTheme.colorScheme.onErrorContainer) + OutlinedButton(onClick = { viewModel.clearError() }) { + Text("Dismiss") + } + } + } + } + + // Main content + if (selectedGameId != null) { + // Set focused game mode - only poll this game, not others + LaunchedEffect(selectedGameId) { + viewModel.setFocusedGame(selectedGameId!!) + } + + // Use stateVersion to ensure recomposition when game state changes + val gameState = + remember(selectedGameId, stateVersion) { + viewModel.getGameState(selectedGameId!!) + } + if (gameState != null) { + // Determine spectator status: + // 1. If game was accepted locally, user is definitely NOT a spectator + // 2. Otherwise, check which map the game is in + val wasAccepted = viewModel.wasAccepted(selectedGameId!!) + val isSpectating = !wasAccepted && spectatingGames.containsKey(selectedGameId) + + DesktopChessGameLayout( + gameState = gameState, + opponentName = viewModel.userMetadataCache.getDisplayName(gameState.opponentPubkey), + opponentPicture = viewModel.userMetadataCache.getPictureUrl(gameState.opponentPubkey), + onMoveMade = { from, to, _ -> + viewModel.publishMove(gameState.startEventId, from, to) + }, + onResign = { viewModel.resign(gameState.startEventId) }, + isSpectatorOverride = isSpectating, + ) + } + } else { + // Clear focused game mode when returning to lobby - poll all games + LaunchedEffect(Unit) { + viewModel.clearFocusedGame() + } + + // Track outgoing challenges to scroll to top when a new one is created + val outgoingChallengesCount = challenges.count { it.isFrom(account.pubKeyHex) } + val listState = rememberLazyListState() + + // Scroll to top when user creates a new challenge + LaunchedEffect(outgoingChallengesCount) { + if (outgoingChallengesCount > 0) { + listState.animateScrollToItem(0) + } + } + + ChessLobby( + challenges = challenges, + activeGames = activeGames, + spectatingGames = spectatingGames, + publicGames = publicGames, + completedGames = completedGames, + userPubkey = account.pubKeyHex, + metadataCache = viewModel.userMetadataCache, + onAcceptChallenge = { viewModel.acceptChallenge(it) }, + onOpenOwnChallenge = { viewModel.openOwnChallenge(it) }, + onWatchGame = { viewModel.loadGameAsSpectator(it) }, + onSelectGame = { viewModel.selectGame(it) }, + listState = listState, + ) + } + } + + // New game dialog + if (showNewGameDialog) { + NewChessGameDialog( + onDismiss = { showNewGameDialog = false }, + onCreateGame = { opponentPubkey, color -> + viewModel.createChallenge(opponentPubkey, color) + showNewGameDialog = false + }, + ) + } +} + +/** + * Chess lobby showing challenges and active games + */ +@Composable +private fun ChessLobby( + challenges: List, + activeGames: Map, + spectatingGames: Map, + publicGames: List, + completedGames: List, + userPubkey: String, + metadataCache: UserMetadataCache, + onAcceptChallenge: (ChessChallenge) -> Unit, + onOpenOwnChallenge: (ChessChallenge) -> Unit, + onWatchGame: (String) -> Unit, + onSelectGame: (String) -> Unit, + listState: LazyListState = rememberLazyListState(), +) { + val hasContent = + activeGames.isNotEmpty() || spectatingGames.isNotEmpty() || + publicGames.isNotEmpty() || challenges.isNotEmpty() || completedGames.isNotEmpty() + + if (!hasContent) { + // Empty state + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "No games or challenges", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(8.dp)) + Text( + "Create a new game or refresh to load from relays", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + return + } + + LazyColumn( + state = listState, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Active games section (user is participant) + if (activeGames.isNotEmpty()) { + item { + Text( + "Your Games", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(activeGames.entries.toList(), key = { "active-${it.key}" }) { (gameId, state) -> + com.vitorpamplona.amethyst.commons.chess.ActiveGameCard( + gameId = gameId, + opponentName = metadataCache.getDisplayName(state.opponentPubkey), + isYourTurn = state.isPlayerTurn(), + onClick = { onSelectGame(gameId) }, + avatar = { + UserAvatar( + userHex = state.opponentPubkey, + pictureUrl = metadataCache.getPictureUrl(state.opponentPubkey), + size = 40.dp, + ) + }, + ) + } + } + + // User's outgoing challenges (right after active games - user wants to see their new challenges) + val outgoingChallenges = challenges.filter { it.isFrom(userPubkey) } + if (outgoingChallenges.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Your Challenges", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(outgoingChallenges, key = { "outgoing-${it.eventId}" }) { challenge -> + com.vitorpamplona.amethyst.commons.chess.OutgoingChallengeCard( + opponentName = challenge.opponentPubkey?.let { metadataCache.getDisplayName(it) }, + userPlaysWhite = challenge.challengerColor == ChessColor.WHITE, + onClick = { onOpenOwnChallenge(challenge) }, + avatar = + challenge.opponentPubkey?.let { pubkey -> + { + UserAvatar( + userHex = pubkey, + pictureUrl = metadataCache.getPictureUrl(pubkey), + size = 40.dp, + ) + } + }, + ) + } + } + + // Games user is spectating + if (spectatingGames.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Watching", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(spectatingGames.entries.toList(), key = { "spectating-${it.key}" }) { (gameId, state) -> + val moveCount by state.moveHistory.collectAsState() + com.vitorpamplona.amethyst.commons.chess.SpectatingGameCard( + moveCount = moveCount.size, + onClick = { onSelectGame(gameId) }, + ) + } + } + + // Incoming challenges (directed at user) + val incomingChallenges = challenges.filter { it.isDirectedAt(userPubkey) } + if (incomingChallenges.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Incoming Challenges", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(incomingChallenges, key = { "incoming-${it.eventId}" }) { challenge -> + com.vitorpamplona.amethyst.commons.chess.ChallengeCard( + challengerName = challenge.challengerDisplayName ?: metadataCache.getDisplayName(challenge.challengerPubkey), + challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, + isIncoming = true, + onAccept = { onAcceptChallenge(challenge) }, + avatar = { + UserAvatar( + userHex = challenge.challengerPubkey, + pictureUrl = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey), + size = 40.dp, + ) + }, + ) + } + } + + // Open challenges from others (can join) + val openChallenges = challenges.filter { it.isOpen && !it.isFrom(userPubkey) } + if (openChallenges.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Open Challenges", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(openChallenges, key = { "open-${it.eventId}" }) { challenge -> + com.vitorpamplona.amethyst.commons.chess.ChallengeCard( + challengerName = challenge.challengerDisplayName ?: metadataCache.getDisplayName(challenge.challengerPubkey), + challengerPlaysWhite = challenge.challengerColor == ChessColor.WHITE, + isIncoming = false, + onAccept = { onAcceptChallenge(challenge) }, + avatar = { + UserAvatar( + userHex = challenge.challengerPubkey, + pictureUrl = challenge.challengerAvatarUrl ?: metadataCache.getPictureUrl(challenge.challengerPubkey), + size = 40.dp, + ) + }, + ) + } + } + + // Live games to watch (public games user is not part of) + if (publicGames.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Live Games", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items(publicGames, key = { "public-${it.gameId}" }) { game -> + com.vitorpamplona.amethyst.commons.chess.PublicGameCard( + whiteName = game.whiteDisplayName ?: metadataCache.getDisplayName(game.whitePubkey), + blackName = game.blackDisplayName ?: metadataCache.getDisplayName(game.blackPubkey), + moveCount = game.moveCount, + onWatch = { onWatchGame(game.gameId) }, + ) + } + } + + // Completed games history + if (completedGames.isNotEmpty()) { + item { + Spacer(Modifier.height(16.dp)) + Text( + "Recent Games", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(vertical = 8.dp), + ) + } + + items( + completedGames.distinctBy { it.gameId }.take(10), + key = { "completed-${it.gameId}-${it.completedAt}" }, + ) { game -> + // Derive opponent pubkey based on who the user is + val opponentPubkey = + if (game.whitePubkey == userPubkey) game.blackPubkey else game.whitePubkey + com.vitorpamplona.amethyst.commons.chess.CompletedGameCard( + opponentName = game.blackDisplayName ?: game.whiteDisplayName ?: metadataCache.getDisplayName(opponentPubkey), + result = game.result, + didUserWin = game.didUserWin(userPubkey), + isDraw = game.isDraw, + moveCount = game.moveCount, + avatar = { + UserAvatar( + userHex = opponentPubkey, + pictureUrl = metadataCache.getPictureUrl(opponentPubkey), + size = 40.dp, + ) + }, + ) + } + } + + // Bottom padding + item { + Spacer(Modifier.height(16.dp)) + } + } +} + +/** + * Desktop-optimized chess game layout with board on left, info/controls on right + * + * @param isSpectatorOverride If non-null, overrides gameState.isSpectator. Use when spectator + * status is determined by which map the game is in (activeGames vs spectatingGames). + */ +@Composable +private fun DesktopChessGameLayout( + gameState: com.vitorpamplona.quartz.nip64Chess.LiveChessGameState, + opponentName: String, + opponentPicture: String?, + onMoveMade: (from: String, to: String, san: String) -> Unit, + onResign: () -> Unit, + isSpectatorOverride: Boolean? = null, +) { + // Collect state flows to trigger recomposition on changes + val currentPosition by gameState.currentPosition.collectAsState() + val moveHistory by gameState.moveHistory.collectAsState() + + val engine = gameState.engine + val playerColor = gameState.playerColor + val startEventId = gameState.startEventId + val opponentPubkey = gameState.opponentPubkey + val isSpectator = isSpectatorOverride ?: gameState.isSpectator + + 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 availableWidth = maxWidth - infoPanelWidth + val availableHeight = maxHeight + // Board should fit within available space, maintaining square aspect ratio + val boardSize = min(availableWidth, availableHeight).coerceIn(200.dp, 520.dp) + + Row( + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(24.dp), + ) { + // Left side: Chess board + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.Center, + ) { + InteractiveChessBoard( + engine = engine, + boardSize = boardSize, + flipped = if (isSpectator) false else playerColor == com.vitorpamplona.quartz.nip64Chess.Color.BLACK, + playerColor = playerColor, + isSpectator = isSpectator, + positionVersion = moveHistory.size, + onMoveMade = onMoveMade, + ) + } + + // 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(), + ) { + 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, + ) + } + + 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") + } + } + } + } 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, + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt new file mode 100644 index 000000000..f80a3f6a6 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessAdapter.kt @@ -0,0 +1,397 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.chess + +import com.vitorpamplona.amethyst.commons.chess.ChessConfig +import com.vitorpamplona.amethyst.commons.chess.ChessEventBroadcaster +import com.vitorpamplona.amethyst.commons.chess.ChessEventPublisher +import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetchHelper +import com.vitorpamplona.amethyst.commons.chess.ChessRelayFetcher +import com.vitorpamplona.amethyst.commons.chess.IUserMetadataProvider +import com.vitorpamplona.amethyst.commons.chess.RelayFetchProgress +import com.vitorpamplona.amethyst.commons.chess.RelayGameSummary +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder +import com.vitorpamplona.amethyst.commons.data.UserMetadataCache +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip64Chess.ChessGameEnd +import com.vitorpamplona.quartz.nip64Chess.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 + +/** + * Desktop implementation of ChessEventPublisher using Jester protocol. + * Wraps account.signer.sign() + relayManager.broadcastToAll() for event publishing. + * + * Jester Protocol: + * - All chess events use kind 30 + * - publishStart creates a start event (content.kind=0) + * - publishMove creates a move event (content.kind=1) with full history + * - publishGameEnd creates a move event with result in content + */ +class DesktopChessPublisher( + private val account: AccountState.LoggedIn, + private val relayManager: DesktopRelayConnectionManager, +) : ChessEventPublisher { + private val broadcaster = ChessEventBroadcaster(relayManager.client) + + /** + * Broadcast an event to the dedicated chess relays with reliable delivery. + * Uses ChessEventBroadcaster to ensure relay connections before sending. + */ + private suspend fun broadcastToChessRelays(event: com.vitorpamplona.quartz.nip01Core.core.Event): Boolean { + println("[DesktopChessPublisher] Broadcasting event ${event.id.take(8)} via ChessEventBroadcaster") + val result = broadcaster.broadcast(event) + println("[DesktopChessPublisher] Broadcast result: ${result.message}") + return result.success + } + + /** + * Publish a game start event (challenge). + * Returns the startEventId (event ID) if successful. + */ + override suspend fun publishStart( + playerColor: Color, + opponentPubkey: String?, + ): String? = + try { + val template = + if (opponentPubkey != null) { + JesterEvent.buildPrivateStart( + opponentPubkey = opponentPubkey, + playerColor = playerColor, + ) + } else { + JesterEvent.buildStart( + playerColor = playerColor, + ) + } + val signedEvent = account.signer.sign(template) + val success = broadcastToChessRelays(signedEvent) + if (success) signedEvent.id else null + } catch (e: Exception) { + println("[DesktopChessPublisher] publishStart failed: ${e.message}") + null + } + + /** + * Publish a move event. + * Returns the move event ID if successful. + */ + override suspend fun publishMove(move: ChessMoveEvent): String? = + try { + val template = + JesterEvent.buildMove( + startEventId = move.startEventId, + headEventId = move.headEventId, + move = move.san, + fen = move.fen, + history = move.history, + opponentPubkey = move.opponentPubkey, + ) + val signedEvent = account.signer.sign(template) + val success = broadcastToChessRelays(signedEvent) + if (success) signedEvent.id else null + } catch (e: Exception) { + println("[DesktopChessPublisher] publishMove failed: ${e.message}") + null + } + + /** + * Publish a game end event (includes result in content). + */ + override suspend fun publishGameEnd(gameEnd: ChessGameEnd): Boolean = + try { + val template = + JesterEvent.buildEndMove( + startEventId = gameEnd.startEventId, + headEventId = gameEnd.headEventId, + move = gameEnd.lastMove, + fen = gameEnd.fen, + history = gameEnd.history, + opponentPubkey = gameEnd.opponentPubkey, + result = gameEnd.result, + termination = gameEnd.termination, + ) + val signedEvent = account.signer.sign(template) + broadcastToChessRelays(signedEvent) + } catch (e: Exception) { + println("[DesktopChessPublisher] publishGameEnd failed: ${e.message}") + false + } + + override fun getWriteRelayCount(): Int = ChessConfig.CHESS_RELAYS.size +} + +/** + * Desktop implementation of ChessRelayFetcher using Jester protocol. + * + * Hybrid approach (like Android's LocalCache pattern): + * 1. Query DesktopChessEventCache first (populated by subscription) + * 2. Also do relay queries and merge results for completeness + * + * This solves the "missing games" issue where pure one-shot relay queries + * would miss events due to timing or slow relays. + */ +class DesktopRelayFetcher( + private val relayManager: DesktopRelayConnectionManager, + private val userPubkey: String, +) : ChessRelayFetcher { + private val fetchHelper = ChessRelayFetchHelper(relayManager.client) + + /** + * Get the normalized chess relay URLs. + * Always uses the 3 dedicated chess relays from ChessConfig. + */ + private fun chessRelayUrls(): List = ChessConfig.CHESS_RELAYS.map { NormalizedRelayUrl(it) } + + override fun getRelayUrls(): List { + println("[DesktopRelayFetcher] getRelayUrls: using ${ChessConfig.CHESS_RELAYS.size} chess relays") + return ChessConfig.CHESS_RELAYS + } + + /** + * Fetch game events - queries cache first, supplements with relay query. + * Similar to Android's LocalCache.addressables pattern. + * + * Uses TWO filters: + * 1. Fetch start event by ID (start events don't have #e tag to themselves) + * 2. Fetch moves that reference the start event via #e tag + */ + override suspend fun fetchGameEvents(startEventId: String): JesterGameEvents { + println("[DesktopRelayFetcher] fetchGameEvents: querying cache for startEventId=$startEventId") + + // Query cache first (populated by subscription) + val cachedEvents = DesktopChessEventCache.getGameEvents(startEventId) + println("[DesktopRelayFetcher] fetchGameEvents cache: start=${cachedEvents.startEvent != null}, moves=${cachedEvents.moves.size}") + + // Also do relay query to catch any new events and populate cache + // Filter 1: Fetch the start event by its ID + val startEventFilter = + Filter( + ids = listOf(startEventId), + kinds = listOf(JesterProtocol.KIND), + ) + // Filter 2: Fetch moves that reference the start event + val movesFilter = ChessFilterBuilder.gameEventsFilter(startEventId) + val relayFilters = chessRelayUrls().associateWith { listOf(startEventFilter, movesFilter) } + val relayEvents = fetchHelper.fetchEvents(relayFilters) + + // Add relay events to cache + relayEvents.forEach { DesktopChessEventCache.add(it) } + + // Convert to JesterEvents and merge + val relayJesterEvents = + relayEvents + .filter { it.kind == JesterProtocol.KIND } + .mapNotNull { it.toJesterEvent() } + + val relayStartEvent = + relayJesterEvents + .firstOrNull { it.isStartEvent() && it.id == startEventId } + + val relayMoves = + relayJesterEvents + .filter { it.isMoveEvent() && it.startEventId() == startEventId } + + // Merge: prefer cache start event, merge all moves + val allMoves = (cachedEvents.moves + relayMoves).distinctBy { it.id } + + println("[DesktopRelayFetcher] fetchGameEvents: found start=${cachedEvents.startEvent ?: relayStartEvent != null}, moves=${allMoves.size}") + + return JesterGameEvents( + startEvent = cachedEvents.startEvent ?: relayStartEvent, + moves = allMoves, + ) + } + + /** + * Fetch start events (challenges) - queries cache first. + */ + override suspend fun fetchChallenges(onProgress: ((RelayFetchProgress) -> Unit)?): List { + val relays = chessRelayUrls() + println("[DesktopRelayFetcher] fetchChallenges: querying cache first, then ${relays.size} chess relays") + + // Query cache for start events that are: + // 1. Open challenges (no specific opponent) + // 2. Directed at us + // 3. Created by us + val cachedStartEvents = + DesktopChessEventCache.queryStartEvents { event -> + event.opponentPubkey() == null || + event.opponentPubkey() == userPubkey || + event.pubKey == userPubkey + } + + println("[DesktopRelayFetcher] fetchChallenges: found ${cachedStartEvents.size} in cache") + + // Also do relay query to catch any new challenges + val filter = ChessFilterBuilder.challengesFilter(userPubkey) + val relayFilters = relays.associateWith { listOf(filter) } + val relayEvents = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress) + + // Add to cache + relayEvents.forEach { DesktopChessEventCache.add(it) } + + val relayStartEvents = + relayEvents + .filter { it.kind == JesterProtocol.KIND } + .mapNotNull { it.toJesterEvent() } + .filter { it.isStartEvent() } + + // Merge and deduplicate + val allStartEvents = (cachedStartEvents + relayStartEvents).distinctBy { it.id } + println("[DesktopRelayFetcher] fetchChallenges: total ${allStartEvents.size} after merge") + return allStartEvents + } + + /** + * Fetch user game IDs (startEventIds) - queries cache first. + */ + override suspend fun fetchUserGameIds(onProgress: ((RelayFetchProgress) -> Unit)?): Set { + val relays = chessRelayUrls() + println("[DesktopRelayFetcher] fetchUserGameIds: querying cache first, then ${relays.size} chess relays") + + val gameIds = mutableSetOf() + + // Query cache for start events created by user or directed at user + DesktopChessEventCache + .queryStartEvents { event -> + event.pubKey == userPubkey || event.opponentPubkey() == userPubkey + }.forEach { gameIds.add(it.id) } + + // Query cache for moves by user or tagged with user + DesktopChessEventCache + .queryMoveEvents { event -> + event.pubKey == userPubkey || event.opponentPubkey() == userPubkey + }.forEach { event -> + event.startEventId()?.let { gameIds.add(it) } + } + + println("[DesktopRelayFetcher] fetchUserGameIds: found ${gameIds.size} in cache") + + // Also do relay query to catch any new game IDs + val userFilter = ChessFilterBuilder.userGamesFilter(userPubkey) + val taggedFilter = ChessFilterBuilder.userTaggedFilter(userPubkey) + val relayFilters = relays.associateWith { listOf(userFilter, taggedFilter) } + val relayEvents = fetchHelper.fetchEvents(relayFilters, onProgress = onProgress) + + // Add to cache + relayEvents.forEach { DesktopChessEventCache.add(it) } + + // Extract game IDs from relay events + relayEvents + .filter { it.kind == JesterProtocol.KIND } + .mapNotNull { it.toJesterEvent() } + .forEach { event -> + if (event.isStartEvent()) { + gameIds.add(event.id) + } else if (event.isMoveEvent()) { + event.startEventId()?.let { gameIds.add(it) } + } + } + + println("[DesktopRelayFetcher] fetchUserGameIds: total ${gameIds.size} after merge") + return gameIds + } + + /** + * Fetch recent games for spectating. + */ + override suspend fun fetchRecentGames(): List { + val filter = ChessFilterBuilder.recentGamesFilter() + val relayFilters = chessRelayUrls().associateWith { listOf(filter) } + + val events = fetchHelper.fetchEvents(relayFilters) + + // Cache events so they're available when watching + events.forEach { DesktopChessEventCache.add(it) } + + // Convert to JesterEvents + val jesterEvents = + events + .filter { it.kind == JesterProtocol.KIND } + .mapNotNull { it.toJesterEvent() } + + // Group by game (startEventId for moves, id for start events) + val startEventsById = + jesterEvents + .filter { it.isStartEvent() } + .associateBy { it.id } + + val movesByGameId = + jesterEvents + .filter { it.isMoveEvent() } + .groupBy { it.startEventId() ?: "" } + .filterKeys { it.isNotEmpty() } + + // Get all game IDs (from start events and moves) + val allGameIds = startEventsById.keys + movesByGameId.keys + + return allGameIds.mapNotNull { startEventId -> + val startEvent = startEventsById[startEventId] ?: return@mapNotNull null + val moves = movesByGameId[startEventId] ?: emptyList() + + val challengerColor = startEvent.playerColor() ?: Color.WHITE + + // Determine players from start event and moves + val challengerPubkey = startEvent.pubKey + val opponentFromMoves = moves.firstOrNull { it.pubKey != challengerPubkey }?.pubKey + val opponentPubkey = startEvent.opponentPubkey() ?: opponentFromMoves ?: return@mapNotNull null + + val (whitePubkey, blackPubkey) = + if (challengerColor == Color.WHITE) { + challengerPubkey to opponentPubkey + } else { + opponentPubkey to challengerPubkey + } + + val lastMove = moves.maxByOrNull { it.history().size } + val hasEnded = lastMove?.result() != null + + RelayGameSummary( + startEventId = startEventId, + whitePubkey = whitePubkey, + blackPubkey = blackPubkey, + moveCount = lastMove?.history()?.size ?: 0, + lastMoveTime = lastMove?.createdAt ?: startEvent.createdAt, + isActive = !hasEnded, + ) + } + } +} + +/** + * Desktop implementation of IUserMetadataProvider. + * Wraps UserMetadataCache for user metadata lookup. + */ +class DesktopMetadataProvider( + private val metadataCache: UserMetadataCache, +) : IUserMetadataProvider { + override fun getDisplayName(pubkey: String): String = metadataCache.getDisplayName(pubkey) + + override fun getPictureUrl(pubkey: String): String? = metadataCache.getPictureUrl(pubkey) +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessEventCache.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessEventCache.kt new file mode 100644 index 000000000..c779afebd --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessEventCache.kt @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.chess + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip64Chess.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.JesterGameEvents +import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import java.util.concurrent.ConcurrentHashMap + +/** + * Local event cache for Desktop chess events using Jester protocol. + * + * Mimics Android's LocalCache.addressables behavior: + * - Stores events by ID (deduplication) + * - Queryable by kind with custom filters + * - Thread-safe via ConcurrentHashMap + * - Populated from subscription callbacks + * - Persists across fetcher queries (unlike one-shot relay queries) + * + * Jester Protocol Notes: + * - All chess events use kind 30 + * - Start events: content.kind=0, e-tag references START_POSITION_HASH + * - Move events: content.kind=1, e-tags=[startEventId, headEventId] + */ +object DesktopChessEventCache { + // Store all Jester events by ID for deduplication + @PublishedApi + internal val events = ConcurrentHashMap() + + // Index start events by event ID (for quick game lookup) + private val startEvents = ConcurrentHashMap() + + // Index move events by startEventId (game ID) for quick game reconstruction + private val movesByGame = ConcurrentHashMap>() + + /** + * Add an event to the cache. + * Returns true if the event was new, false if it was a duplicate. + */ + fun add(event: Event): Boolean { + if (event.kind != JesterProtocol.KIND) return false + + val jesterEvent = event.toJesterEvent() ?: return false + return addJesterEvent(jesterEvent) + } + + /** + * Add a JesterEvent directly. + */ + fun addJesterEvent(event: JesterEvent): Boolean { + val isNew = events.putIfAbsent(event.id, event) == null + if (isNew) { + if (event.isStartEvent()) { + startEvents[event.id] = event + } else if (event.isMoveEvent()) { + val startId = event.startEventId() + if (startId != null) { + movesByGame.computeIfAbsent(startId) { ConcurrentHashMap.newKeySet() }.add(event.id) + } + } + } + return isNew + } + + /** + * Get an event by ID. + */ + fun get(id: String): JesterEvent? = events[id] + + /** + * Get all start events (challenges). + */ + fun getStartEvents(filter: (JesterEvent) -> Boolean = { true }): List = startEvents.values.filter { it.isStartEvent() && filter(it) } + + /** + * Get all move events for a specific game. + */ + fun getMovesForGame(startEventId: String): List { + val moveIds = movesByGame[startEventId] ?: return emptyList() + return moveIds.mapNotNull { events[it] } + } + + /** + * Get all events for a specific game (start + moves). + */ + fun getGameEvents(startEventId: String): JesterGameEvents { + val startEvent = startEvents[startEventId] + val moves = getMovesForGame(startEventId) + return JesterGameEvents(startEvent, moves) + } + + /** + * Query events with custom filter. + */ + fun query(filter: (JesterEvent) -> Boolean): List = events.values.filter(filter) + + /** + * Query start events with custom filter. + */ + fun queryStartEvents(filter: (JesterEvent) -> Boolean = { true }): List = startEvents.values.filter { it.isStartEvent() && filter(it) } + + /** + * Query move events with custom filter. + */ + fun queryMoveEvents(filter: (JesterEvent) -> Boolean = { true }): List = events.values.filter { it.isMoveEvent() && filter(it) } + + /** + * Get all game IDs (startEventIds) in the cache. + */ + fun getAllGameIds(): Set = startEvents.keys.toSet() + + /** + * Get count of events. + */ + fun size(): Int = events.size + + /** + * Get count of start events. + */ + fun startEventCount(): Int = startEvents.size + + /** + * Clear all cached events. + */ + fun clear() { + events.clear() + startEvents.clear() + movesByGame.clear() + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt new file mode 100644 index 000000000..895aed70a --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/DesktopChessViewModelNew.kt @@ -0,0 +1,192 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.chess + +import com.vitorpamplona.amethyst.commons.chess.ChessBroadcastStatus +import com.vitorpamplona.amethyst.commons.chess.ChessChallenge +import com.vitorpamplona.amethyst.commons.chess.ChessLobbyLogic +import com.vitorpamplona.amethyst.commons.chess.ChessPollingDefaults +import com.vitorpamplona.amethyst.commons.chess.ChessSyncStatus +import com.vitorpamplona.amethyst.commons.chess.CompletedGame +import com.vitorpamplona.amethyst.commons.chess.PublicGame +import com.vitorpamplona.amethyst.commons.data.UserMetadataCache +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip64Chess.Color +import com.vitorpamplona.quartz.nip64Chess.JesterProtocol +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameState +import com.vitorpamplona.quartz.nip64Chess.toJesterEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.StateFlow + +/** + * Slim Desktop ViewModel for chess (~120 lines). + * + * Delegates all business logic to ChessLobbyLogic. + * Only handles Desktop-specific concerns: + * - Platform adapter creation + * - State exposure to Compose Desktop UI + * - UserMetadataCache for profile display + */ +class DesktopChessViewModelNew( + private val account: AccountState.LoggedIn, + private val relayManager: DesktopRelayConnectionManager, + private val scope: CoroutineScope, +) { + // Desktop-specific metadata cache + val userMetadataCache = UserMetadataCache() + + // Platform adapters + private val publisher = DesktopChessPublisher(account, relayManager) + private val fetcher = DesktopRelayFetcher(relayManager, account.pubKeyHex) + private val metadataProvider = DesktopMetadataProvider(userMetadataCache) + + // Shared business logic (creates its own ChessLobbyState internally) + private val logic = + ChessLobbyLogic( + userPubkey = account.pubKeyHex, + publisher = publisher, + fetcher = fetcher, + metadataProvider = metadataProvider, + scope = scope, + pollingConfig = ChessPollingDefaults.desktop, + ) + + // ============================================ + // State exposure (delegated from ChessLobbyLogic.state) + // ============================================ + + val activeGames: StateFlow> = logic.state.activeGames + val spectatingGames: StateFlow> = logic.state.spectatingGames + val challenges: StateFlow> = logic.state.challenges + val publicGames: StateFlow> = logic.state.publicGames + val completedGames: StateFlow> = logic.state.completedGames + val broadcastStatus: StateFlow = logic.state.broadcastStatus + val error: StateFlow = logic.state.error + val selectedGameId: StateFlow = logic.state.selectedGameId + val isRefreshing: StateFlow = logic.state.isRefreshing + val syncStatus: StateFlow = logic.state.syncStatus + val stateVersion: StateFlow = logic.state.stateVersion + + /** Badge count (incoming challenges + your turn games) - computed property */ + val badgeCount: Int get() = logic.state.badgeCount + + // ============================================ + // Lifecycle + // ============================================ + + init { + logic.startPolling() + } + + fun startPolling() = logic.startPolling() + + fun stopPolling() = logic.stopPolling() + + fun forceRefresh() = logic.forceRefresh() + + /** + * Ensure a game ID is being polled for updates. + * Call this when viewing a game. + */ + fun ensureGamePolling(gameId: String) = logic.ensureGamePolling(gameId) + + /** + * Set focused game mode - only poll this specific game. + * Call this when viewing a game to avoid refreshing unrelated games. + */ + fun setFocusedGame(gameId: String) = logic.setFocusedGame(gameId) + + /** + * Clear focused game mode - return to lobby mode (poll all games). + * Call this when returning to the lobby view. + */ + fun clearFocusedGame() = logic.clearFocusedGame() + + // ============================================ + // Incoming event routing (from relay subscriptions) + // ============================================ + + fun handleIncomingEvent(event: Event) { + if (event.kind != JesterProtocol.KIND) return + val jesterEvent = event.toJesterEvent() ?: return + logic.handleIncomingEvent(jesterEvent) + } + + // ============================================ + // Challenge operations + // ============================================ + + fun createChallenge( + opponentPubkey: String? = null, + playerColor: Color = Color.WHITE, + timeControl: String? = null, + ) = logic.createChallenge(opponentPubkey, playerColor, timeControl) + + fun acceptChallenge(challenge: ChessChallenge) = logic.acceptChallenge(challenge) + + fun openOwnChallenge(challenge: ChessChallenge) = logic.openOwnChallenge(challenge) + + // ============================================ + // Game operations + // ============================================ + + fun selectGame(gameId: String?) = logic.selectGame(gameId) + + fun publishMove( + gameId: String, + from: String, + to: String, + ) = logic.publishMove(gameId, from, to) + + fun resign(gameId: String) = logic.resign(gameId) + + fun claimAbandonmentVictory(gameId: String) = logic.claimAbandonmentVictory(gameId) + + // ============================================ + // Spectator operations + // ============================================ + + fun loadGame(gameId: String) = logic.loadGame(gameId) + + fun loadGameAsSpectator(gameId: String) = logic.loadGameAsSpectator(gameId) + + fun stopSpectating(gameId: String) = logic.state.removeSpectatingGame(gameId) + + // ============================================ + // Utility + // ============================================ + + fun clearError() = logic.clearError() + + fun getGameState(gameId: String): LiveChessGameState? = logic.state.getGameState(gameId) + + /** Check if a game was accepted (prevents loading as spectator during race) */ + fun wasAccepted(gameId: String): Boolean = logic.state.wasAccepted(gameId) + + /** Helper for derived challenge lists */ + fun incomingChallenges(): List = logic.state.incomingChallenges() + + fun outgoingChallenges(): List = logic.state.outgoingChallenges() + + fun openChallenges(): List = logic.state.openChallenges() +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt new file mode 100644 index 000000000..81d9bbde2 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ChessSubscription.kt @@ -0,0 +1,217 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.subscriptions + +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionController +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionState +import com.vitorpamplona.amethyst.desktop.chess.DesktopChessEventCache +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.groupByRelay +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Chess event kinds for cache filtering */ +private val CHESS_EVENT_KINDS = + setOf( + LiveChessGameChallengeEvent.KIND, + LiveChessGameAcceptEvent.KIND, + LiveChessMoveEvent.KIND, + LiveChessGameEndEvent.KIND, + LiveChessDrawOfferEvent.KIND, + ) + +/** + * Desktop implementation of [ChessSubscriptionController]. + * Uses the shared [ChessFilterBuilder] for consistent filter construction + * with dynamic updates when active games change. + */ +class DesktopChessSubscriptionController( + private val relayManager: RelayConnectionManager, + private val onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + private val onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +) : ChessSubscriptionController { + private val _currentState = MutableStateFlow(null) + override val currentState: StateFlow = _currentState.asStateFlow() + + // EOSE tracking per relay for efficient re-subscription + private val relayEoseTimes = mutableMapOf() + + // Current subscription ID + private var currentSubId: String? = null + + override fun subscribe(state: ChessSubscriptionState) { + // Unsubscribe previous if exists + unsubscribe() + + if (state.relays.isEmpty()) return + + _currentState.value = state + currentSubId = state.subscriptionId() + + // Build filters using shared logic + val relayBasedFilters = + ChessFilterBuilder.buildAllFilters(state) { relay -> + relayEoseTimes[relay] + } + + // Group filters by relay + val filtersByRelay = relayBasedFilters.groupByRelay() + + // Subscribe on each relay with its specific filters + val allFilters = filtersByRelay.values.flatten().distinct() + + relayManager.subscribe( + subId = currentSubId!!, + filters = allFilters, + relays = state.relays, + listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // Add chess events to local cache for persistence + if (event.kind in CHESS_EVENT_KINDS) { + DesktopChessEventCache.add(event) + } + onEvent(event, isLive, relay, forFilters) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // Track EOSE time for this relay for efficient re-subscription + relayEoseTimes[relay] = TimeUtils.now() + onEose(relay, forFilters) + } + }, + ) + } + + override fun unsubscribe() { + currentSubId?.let { relayManager.unsubscribe(it) } + currentSubId = null + _currentState.value = null + } + + override fun updateActiveGames( + activeGameIds: Set, + spectatingGameIds: Set, + ) { + val current = _currentState.value ?: return + + // Create new state with updated game IDs + val newState = + current.copy( + activeGameIds = activeGameIds, + spectatingGameIds = spectatingGameIds, + ) + + // Only re-subscribe if game IDs actually changed + if (newState.subscriptionId() != current.subscriptionId()) { + subscribe(newState) + } + } + + override fun forceRefresh() { + // Clear EOSE cache to re-fetch full history + relayEoseTimes.clear() + _currentState.value?.let { subscribe(it) } + } +} + +/** + * Creates a subscription config for chess events with support for active game filtering. + * Uses the shared [ChessFilterBuilder] for consistent filter construction. + * + * @param relays Set of relays to subscribe to + * @param userPubkey User's public key for filtering personal events + * @param activeGameIds Set of game IDs the user is actively playing (for game-specific filters) + * @param opponentPubkeys Set of opponent pubkeys for active games (for move filtering) + * @param onEvent Callback for incoming events + * @param onEose Callback for EOSE (End of Stored Events) + */ +fun createChessSubscriptionWithGames( + relays: Set, + userPubkey: String, + activeGameIds: Set = emptySet(), + opponentPubkeys: Set = emptySet(), + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (relays.isEmpty()) return null + + val state = + ChessSubscriptionState( + userPubkey = userPubkey, + relays = relays, + activeGameIds = activeGameIds, + opponentPubkeys = opponentPubkeys, + ) + + val relayBasedFilters = + ChessFilterBuilder.buildAllFilters(state) { null } + + val filters = + relayBasedFilters + .groupByRelay() + .values + .flatten() + .distinct() + + return SubscriptionConfig( + subId = state.subscriptionId(), + filters = filters, + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Legacy function for backward compatibility. + * @deprecated Use createChessSubscriptionWithGames instead + */ +@Deprecated( + "Use createChessSubscriptionWithGames for active game support", + ReplaceWith("createChessSubscriptionWithGames(relays, userPubkey, emptySet(), emptySet(), onEvent, onEose)"), +) +fun createChessSubscription( + relays: Set, + userPubkey: String, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? = createChessSubscriptionWithGames(relays, userPubkey, emptySet(), emptySet(), onEvent, onEose) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt index 8415393da..d4d3bac20 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FeedSubscription.kt @@ -315,3 +315,36 @@ fun createFollowingLongFormFeedSubscription( onEose = onEose, ) } + +/** + * Creates a subscription config for chess events (challenges, moves, etc.). + * Subscribes to: + * - Open challenges (kind 30064) + * - Challenges directed at the user + * - Events in games the user is playing + * + * @param userPubkey The user's public key to filter relevant games + * @param limit Maximum number of events to request + */ +fun createChessSubscription( + relays: Set, + userPubkey: String, + limit: Int = 100, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig = + SubscriptionConfig( + subId = generateSubId("chess-${userPubkey.take(8)}"), + filters = + listOf( + // Open challenges and challenges to user + FilterBuilders.chessOpenChallenges(limit = limit), + // Events where user is tagged (challenges to them, moves in their games) + FilterBuilders.chessEventsForUser(userPubkey, limit = limit), + // User's own events (their challenges, moves) + FilterBuilders.chessEventsByUser(userPubkey, limit = limit), + ), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt index ea29bcf11..04fb56ac2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterBuilders.kt @@ -95,6 +95,19 @@ object FilterBuilders { authors = pubKeyHexList, ) + /** + * Creates a filter for user metadata (kind 0) from multiple authors. + * Alias for userMetadataBatch for API compatibility. + * + * @param pubKeys List of author public keys (hex-encoded, 64 chars each) + * @return Filter for user metadata + */ + fun userMetadataMultiple(pubKeys: List): Filter = + Filter( + kinds = listOf(0), + authors = pubKeys, + ) + /** * Creates a filter for contact list (kind 3) from a specific author. * @@ -200,6 +213,103 @@ object FilterBuilders { ids = ids, ) + /** + * Creates a filter for chess game challenges (kind 30064). + * Includes open challenges (no opponent) and challenges directed at a specific user. + * + * @param userPubkey The user's public key to filter challenges for + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @return Filter for chess challenges + */ + fun chessChallengesToUser( + userPubkey: String, + limit: Int? = 50, + since: Long? = null, + ): Filter = + Filter( + kinds = listOf(30064), // LiveChessGameChallengeEvent.KIND + tags = mapOf("p" to listOf(userPubkey)), + limit = limit, + since = since, + ) + + /** + * Creates a filter for open chess challenges (kind 30064, no specific opponent). + * + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @return Filter for open chess challenges + */ + fun chessOpenChallenges( + limit: Int? = 50, + since: Long? = null, + ): Filter = + Filter( + kinds = listOf(30064), // LiveChessGameChallengeEvent.KIND + limit = limit, + since = since, + ) + + /** + * Creates a filter for all chess events (challenges, accepts, moves, ends, draw offers). + * Useful for loading the chess lobby. + * + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @return Filter for all chess events + */ + fun chessAllEvents( + limit: Int? = 100, + since: Long? = null, + ): Filter = + Filter( + kinds = listOf(30064, 30065, 30066, 30067, 30068), + limit = limit, + since = since, + ) + + /** + * Creates a filter for chess events involving a specific user. + * Includes challenges to/from user, moves in their games, draw offers, etc. + * + * @param userPubkey The user's public key + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @return Filter for chess events involving the user + */ + fun chessEventsForUser( + userPubkey: String, + limit: Int? = 100, + since: Long? = null, + ): Filter = + Filter( + kinds = listOf(30064, 30065, 30066, 30067, 30068), + tags = mapOf("p" to listOf(userPubkey)), + limit = limit, + since = since, + ) + + /** + * Creates a filter for chess events by the user (their own events). + * + * @param userPubkey The user's public key + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time ≥ this value + * @return Filter for chess events by the user + */ + fun chessEventsByUser( + userPubkey: String, + limit: Int? = 100, + since: Long? = null, + ): Filter = + Filter( + kinds = listOf(30064, 30065, 30066, 30067, 30068), + authors = listOf(userPubkey), + limit = limit, + since = since, + ) + /** * Creates a filter for events tagged with specific p-tags (mentioning users). * diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt index 40695162b..a6bdbc908 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/ProfileSubscription.kt @@ -71,6 +71,27 @@ fun createBatchMetadataSubscription( ) } +/** + * Creates a subscription config for multiple user metadata (kind 0). + * Useful for fetching metadata for a batch of users at once. + */ +fun createMetadataListSubscription( + relays: Set, + pubKeys: List, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (pubKeys.isEmpty()) return null + + return SubscriptionConfig( + subId = generateSubId("meta-batch-${pubKeys.hashCode()}"), + filters = listOf(FilterBuilders.userMetadataMultiple(pubKeys)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + /** * Creates a subscription config for user posts (kind 1). */ diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 224971776..b4268710f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -584,7 +584,8 @@ fun FeedScreen( LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(events, key = { it.id }) { event -> + // Use distinctBy to prevent duplicate key crashes from events with same ID + items(events.distinctBy { it.id }, key = { it.id }) { event -> FeedNoteCard( event = event, relayManager = relayManager, diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt index af9106f90..4e940173d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NoteActions.kt @@ -70,6 +70,7 @@ import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.nwc.NwcPaymentHandler import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter @@ -538,7 +539,8 @@ fun NoteActionsRow( if (!isLiked) { scope.launch { reactToNote( - event = event, + // TODO: Bring a hint to where the event came from + event = EventHintBundle(event, null), reaction = "+", account = account, relayManager = relayManager, @@ -578,7 +580,8 @@ fun NoteActionsRow( if (!isReposted) { scope.launch { repostNote( - event = event, + // TODO: Bring a hint to where the event came from + event = EventHintBundle(event, null), account = account, relayManager = relayManager, ) @@ -808,7 +811,7 @@ fun NoteActionsRow( * Creates a reaction event and broadcasts to relays. */ private suspend fun reactToNote( - event: Event, + event: EventHintBundle, reaction: String, account: AccountState.LoggedIn, relayManager: DesktopRelayConnectionManager, @@ -893,7 +896,7 @@ private suspend fun removeBookmark( * Creates a repost event and broadcasts to relays. */ private suspend fun repostNote( - event: Event, + event: EventHintBundle, account: AccountState.LoggedIn, relayManager: DesktopRelayConnectionManager, ) { diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt index 6956d0e8e..0fc6ba44a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/NotificationsScreen.kt @@ -226,7 +226,7 @@ fun NotificationsScreen( LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(notifications, key = { it.event.id }) { notification -> + items(notifications.distinctBy { it.event.id }, key = { it.event.id }) { notification -> NotificationCard(notification) } } diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt index 1d40a2866..d72b914ef 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/ThreadScreen.kt @@ -423,7 +423,7 @@ fun ThreadScreen( } // Reply notes with level indicators - items(replyEvents, key = { it.id }) { event -> + items(replyEvents.distinctBy { it.id }, key = { it.id }) { event -> val level = calculateLevel(event) Column( diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt index c239218ae..704a8f03a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/UserProfileScreen.kt @@ -37,8 +37,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.ContentCopy +import androidx.compose.material.icons.filled.Edit import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonRemove +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -46,7 +48,9 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -60,6 +64,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.nip02FollowList.FollowAction +import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastBanner +import com.vitorpamplona.amethyst.commons.profile.ProfileBroadcastStatus import com.vitorpamplona.amethyst.commons.state.EventCollectionState import com.vitorpamplona.amethyst.commons.state.FollowState import com.vitorpamplona.amethyst.commons.ui.components.LoadingState @@ -76,6 +82,7 @@ import com.vitorpamplona.amethyst.desktop.subscriptions.createUserPostsSubscript import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip19Bech32.toNpub import kotlinx.coroutines.Dispatchers @@ -111,6 +118,13 @@ fun UserProfileScreen( var followersCount by remember { mutableStateOf(0) } var followingCount by remember { mutableStateOf(0) } + // Profile editing state (only for own profile) + val isOwnProfile = account != null && pubKeyHex == account.pubKeyHex + var showEditDialog by remember { mutableStateOf(false) } + var editingDisplayName by remember { mutableStateOf("") } + var broadcastStatus by remember { mutableStateOf(ProfileBroadcastStatus.Idle) } + var latestMetadataEvent by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() // User's posts @@ -187,13 +201,25 @@ fun UserProfileScreen( relays = configuredRelays, pubKeyHex = pubKeyHex, onEvent = { event, _, _, _ -> - try { - val content = event.content - displayName = extractJsonField(content, "display_name") ?: extractJsonField(content, "name") - about = extractJsonField(content, "about") - picture = extractJsonField(content, "picture") - } catch (e: Exception) { - // Ignore parse errors + if (event is MetadataEvent) { + try { + val metadata = event.contactMetaData() + if (metadata != null) { + displayName = metadata.displayName ?: metadata.name + about = metadata.about + picture = metadata.picture + } + + // Store MetadataEvent for editing (only for own profile) + if (isOwnProfile) { + val current = latestMetadataEvent + if (current == null || event.createdAt > current.createdAt) { + latestMetadataEvent = event + } + } + } catch (e: Exception) { + // Ignore parse errors + } } }, ) @@ -281,6 +307,19 @@ fun UserProfileScreen( } Column(modifier = Modifier.fillMaxSize()) { + // Broadcast banner for profile updates + ProfileBroadcastBanner( + status = broadcastStatus, + onTap = { + // Clear banner on tap (could add retry logic for failed) + if (broadcastStatus is ProfileBroadcastStatus.Success || + broadcastStatus is ProfileBroadcastStatus.Failed + ) { + broadcastStatus = ProfileBroadcastStatus.Idle + } + }, + ) + // Header with back button Row( modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), @@ -298,6 +337,25 @@ fun UserProfileScreen( ) } + // Edit button for own profile + if (isOwnProfile && account?.isReadOnly == false) { + OutlinedButton( + onClick = { + editingDisplayName = displayName ?: "" + showEditDialog = true + }, + ) { + Icon( + Icons.Default.Edit, + contentDescription = "Edit profile", + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(8.dp)) + Text("Edit Profile") + } + } + + // Follow/Unfollow button for other profiles if (account != null && !account.isReadOnly && pubKeyHex != account.pubKeyHex) { Column(horizontalAlignment = Alignment.End) { Button( @@ -569,7 +627,7 @@ fun UserProfileScreen( LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(events, key = { it.id }) { event -> + items(events.distinctBy { it.id }, key = { it.id }) { event -> FeedNoteCard( event = event, relayManager = relayManager, @@ -586,17 +644,52 @@ fun UserProfileScreen( } } } -} -/** - * Simple JSON field extractor (not production-ready, just for demo). - */ -private fun extractJsonField( - json: String, - field: String, -): String? { - val regex = """"$field"\s*:\s*"([^"]*)"""".toRegex() - return regex.find(json)?.groupValues?.get(1) + // Edit Profile Dialog + if (showEditDialog && account != null) { + AlertDialog( + onDismissRequest = { showEditDialog = false }, + title = { Text("Edit Profile") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + OutlinedTextField( + value = editingDisplayName, + onValueChange = { editingDisplayName = it }, + label = { Text("Display Name") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + } + }, + confirmButton = { + Button( + onClick = { + showEditDialog = false + scope.launch { + updateProfileDisplayName( + newDisplayName = editingDisplayName, + account = account, + relayManager = relayManager, + latestMetadataEvent = latestMetadataEvent, + currentDisplayName = displayName, + currentAbout = about, + currentPicture = picture, + onStatusUpdate = { broadcastStatus = it }, + onSuccess = { displayName = editingDisplayName }, + ) + } + }, + ) { + Text("Save") + } + }, + dismissButton = { + TextButton(onClick = { showEditDialog = false }) { + Text("Cancel") + } + }, + ) + } } /** @@ -648,3 +741,62 @@ private suspend fun unfollowUser( throw IllegalStateException("Cannot unfollow: No contact list available") } } + +/** + * Updates the user's profile display name by creating and broadcasting a new MetadataEvent. + */ +private suspend fun updateProfileDisplayName( + newDisplayName: String, + account: AccountState.LoggedIn, + relayManager: DesktopRelayConnectionManager, + latestMetadataEvent: MetadataEvent?, + currentDisplayName: String?, + currentAbout: String?, + currentPicture: String?, + onStatusUpdate: (ProfileBroadcastStatus) -> Unit, + onSuccess: () -> Unit, +) = withContext(Dispatchers.IO) { + val connectedRelays = relayManager.connectedRelays.value + if (connectedRelays.isEmpty()) { + onStatusUpdate(ProfileBroadcastStatus.Failed("display name", "No connected relays")) + return@withContext + } + + val totalRelays = connectedRelays.size + onStatusUpdate(ProfileBroadcastStatus.Broadcasting("display name", 0, totalRelays)) + + try { + // Create the new MetadataEvent + val template = + if (latestMetadataEvent != null) { + MetadataEvent.updateFromPast( + latest = latestMetadataEvent, + displayName = newDisplayName, + ) + } else { + MetadataEvent.createNew( + name = currentDisplayName, + displayName = newDisplayName, + picture = currentPicture, + about = currentAbout, + ) + } + + // Sign the event + val signedEvent = account.signer.sign(template) + + // Broadcast to all relays + relayManager.broadcastToAll(signedEvent) + + // Update progress (simplified - just show success after broadcast) + // In a full implementation, you'd track OK responses from each relay + onStatusUpdate(ProfileBroadcastStatus.Success("display name", totalRelays)) + onSuccess() + + // Auto-hide banner after delay + delay(3000) + onStatusUpdate(ProfileBroadcastStatus.Idle) + } catch (e: Exception) { + onStatusUpdate(ProfileBroadcastStatus.Failed("display name", e.message ?: "Unknown error")) + } +} diff --git a/docs/chess-engine-refactoring.md b/docs/chess-engine-refactoring.md new file mode 100644 index 000000000..6e773646a --- /dev/null +++ b/docs/chess-engine-refactoring.md @@ -0,0 +1,287 @@ +# Chess Engine Refactoring + +## Context + +Chess feature had inconsistent state reconstruction: +- **Android**: Replays moves from `LocalCache.addressables` (can get stale/desync) +- **Desktop**: Buffers `pendingMoves` and syncs via `forceResync(fen)` (fragile) + +Both ViewModels (~1200 + ~956 lines) duplicate logic that now exists in shared components. + +## Completed Work + +### Shared Components (Done) + +| File | Location | Purpose | +|------|----------|---------| +| `ChessStateReconstructor` | `quartz/commonMain/` | Deterministic event→state reconstruction | +| `ChessStateReconstructorTest` | `quartz/jvmAndroidTest/` | 22 integration tests | +| `ChessEventCollector` | `commons/commonMain/` | Thread-safe event aggregation with dedup | +| `ChessGameLoader` | `commons/commonMain/` | Converts ReconstructionResult → LiveChessGameState | +| `ChessLobbyLogic` | `commons/commonMain/` | Shared business logic (challenges, moves, polling) | +| `ChessLobbyState` | `commons/commonMain/` | Shared UI state (StateFlows for games, challenges, status) | +| `ChessBroadcastStatus` | `commons/commonMain/` | Shared broadcast status sealed class | +| `ChessPollingDelegate` | `commons/commonMain/` | Configurable periodic refresh | +| `ChessFilterBuilder` | `commons/commonMain/subscription/` | Shared relay filter construction | +| `ChessSubscriptionController` | `commons/commonMain/subscription/` | Platform subscription interface | +| `ChessStatusBanner` | `amethyst/` (Android only) | Android broadcast status UI | + +### Test Coverage + +- Game lifecycle, move ordering/dedup, game end conditions +- Viewer perspectives, draw offers, castling, determinism + +--- + +## Target Architecture + +``` +BOTH PLATFORMS IDENTICAL: + +VM (~150 lines) → ChessLobbyLogic + │ + ├─ publish: ChessEventPublisher (platform impl) + │ + └─ refresh (periodic): + one-shot REQ to relays + ↓ + transient ChessEventCollector (use-once) + ↓ + ChessStateReconstructor.reconstruct() + ↓ + LiveChessGameState → UI + (collector discarded, no cache) +``` + +**Key principle**: No cache. Relays are the ONLY source of truth. + +Every refresh cycle: +1. One-shot REQ to relays for all game events +2. Transient collector → reconstruct → get state +3. Diff against UI state, update if changed +4. Discard collector + +Real-time subscription events apply moves **optimistically** to `LiveChessGameState`. Periodic full-reconstruction corrects any drift. + +--- + +## Resolved Decisions + +### 1. No LocalCache for Chess + +**Decision**: Chess events must NOT enter LocalCache. Chess is inherently remote-only. + +**How to stop writes**: Remove chess event handlers from `LocalCache.justConsumeInnerInner()` (lines 2956-2960 in `LocalCache.kt`). These are: +```kotlin +is LiveChessGameChallengeEvent -> consume(event, relay, wasVerified) // REMOVE +is LiveChessGameAcceptEvent -> consume(event, relay, wasVerified) // REMOVE +is LiveChessMoveEvent -> consume(event, relay, wasVerified) // REMOVE +is LiveChessGameEndEvent -> consume(event, relay, wasVerified) // REMOVE +``` + +Also remove chess DataSource from `RelaySubscriptionsCoordinator` (line 83: `val chess = ChessFilterAssembler(client)`). Chess manages its own relay subscriptions independently. + +### 2. One-Shot Relay Fetch (in commons) + +**Decision**: Create a shared one-shot fetch helper in commons using existing `IRequestListener` + `Channel` pattern. + +**Existing pattern** (proven in production): +- `quartz/.../accessories/NostrClientSingleDownloadExt.kt` — single event download +- `quartz/.../accessories/NostrClientSendAndWaitExt.kt` — multi-relay wait + +**Design**: The fetcher takes an `INostrClient` (available on both platforms) and uses the existing `IRequestListener` callback → `Channel` → `withTimeoutOrNull` pattern: + +```kotlin +// commons/commonMain - shared one-shot fetch +class ChessRelayFetchHelper(private val client: INostrClient) { + + suspend fun fetchEvents( + filters: Map>, + timeoutMs: Long = 30_000, + ): List { + val events = mutableListOf() + val eoseReceived = CompletableDeferred() + val subId = UUID.randomUUID().toString().take(8) + + val listener = object : IRequestListener { + override fun onEvent(event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List?) { + events.add(event) + } + override fun onEose(relay: NormalizedRelayUrl, forFilters: List?) { + eoseReceived.complete(Unit) + } + } + + client.openReqSubscription(subId, filters, listener) + withTimeoutOrNull(timeoutMs) { eoseReceived.await() } + client.close(subId) + + return events + } +} +``` + +Platform adapters inject their `INostrClient`: +- **Android**: `account.client` (or equivalent from relay pool) +- **Desktop**: `relayManager.client` + +### 3. Metadata Provider (interface in commons) + +**Decision**: Shared `IUserMetadataProvider` interface in commons. Platform-specific implementations. + +Android uses `LocalCache.users[pubkey].info`, Desktop uses `UserMetadataCache`. Both implement: + +```kotlin +// commons/commonMain +interface IUserMetadataProvider { + fun getDisplayName(pubkey: String): String + fun getPictureUrl(pubkey: String): String? +} +``` + +`ChessChallenge` gets enriched with display fields by calling provider at construction time. + +### 4. Challenge Type Migration + +**Decision**: Enrich `ChessChallenge` with display fields (displayName, avatarUrl) so it replaces both `Note` (Android) and `LiveChessGameChallengeEvent` (Desktop) in UI code. + +--- + +## Implementation Steps + +### Step 1: Stop chess events entering LocalCache + +**Files:** +- `amethyst/.../model/LocalCache.kt` — remove chess `when` branches (lines ~2956-2960) +- `amethyst/.../service/relayClient/RelaySubscriptionsCoordinator.kt` — remove `val chess` DataSource + +### Step 2: Create one-shot relay fetch helper in commons + +**File:** `commons/src/commonMain/.../chess/ChessRelayFetchHelper.kt` (new) + +Uses `INostrClient` + `IRequestListener` + `Channel` pattern. Shared by both platforms. + +### Step 3: Create IUserMetadataProvider in commons + +**File:** `commons/src/commonMain/.../chess/IUserMetadataProvider.kt` (new) + +### Step 4: Rewrite ChessLobbyLogic (relay-first) + +**File:** `commons/src/commonMain/.../chess/ChessLobbyLogic.kt` + +Replace `ChessEventFetcher` with `ChessRelayFetcher` (wraps `ChessRelayFetchHelper`): + +```kotlin +interface ChessRelayFetcher { + suspend fun fetchGameEvents(gameId: String): ChessGameEvents + suspend fun fetchChallenges(): List + suspend fun fetchRecentGames(): List +} +``` + +Rewrite refresh cycle: +```kotlin +private suspend fun refreshGame(gameId: String) { + val events = relayFetcher.fetchGameEvents(gameId) // one-shot from relays + val result = ChessStateReconstructor.reconstruct(events, userPubkey) + when (result) { + is ReconstructionResult.Success -> + state.replaceGameState(gameId, ChessGameLoader.toLiveGameState(result, userPubkey)) + is ReconstructionResult.Error -> + state.setError("Game $gameId: ${result.message}") + } +} +``` + +Add: +- `handleIncomingEvent(event)` — optimistic real-time event routing +- `handleGameAccepted()` + `startGameFromAcceptance()` +- `acceptDraw()`, `declineDraw()`, `claimAbandonmentVictory()` +- `retryWithBackoff()` for publish operations +- Subscription controller integration + +### Step 5: Enhance ChessLobbyState + +**File:** `commons/src/commonMain/.../chess/ChessLobbyState.kt` + +Add: +- `replaceGameState(gameId, newState)` — for full reconstruction updates +- `completedGames: StateFlow>` +- Enrich `ChessChallenge` with `displayName`, `avatarUrl` + +### Step 6: Platform adapters + +**Android:** `amethyst/.../chess/AndroidChessAdapter.kt` (new) +- `AndroidChessPublisher` — wraps `account.signAndComputeBroadcast()` +- `AndroidRelayFetcher` — wraps `ChessRelayFetchHelper(account.client)` +- `AndroidMetadataProvider` — wraps `LocalCache.users[pubkey].info` + +**Desktop:** `desktopApp/.../chess/DesktopChessAdapter.kt` (new) +- `DesktopChessPublisher` — wraps `account.signer.sign()` + `relayManager.broadcastToAll()` +- `DesktopRelayFetcher` — wraps `ChessRelayFetchHelper(relayManager.client)` +- `DesktopMetadataProvider` — wraps `UserMetadataCache` + +### Step 7: Rewrite Android ChessViewModel + +**File:** `amethyst/.../chess/ChessViewModel.kt` (1220 → ~150 lines) + +Thin wrapper: delegates to `ChessLobbyLogic`, exposes state, routes `LocalCache.live.newEventBundles` for real-time events. + +Delete: `RetryOperation`, `PublicGameInfo`, all LocalCache queries, all handle* methods, `ChessStatus`. + +### Step 8: Rewrite Desktop DesktopChessViewModel + +**File:** `desktopApp/.../chess/DesktopChessViewModel.kt` (956 → ~150 lines) + +Thin wrapper: delegates to `ChessLobbyLogic`, keeps `UserMetadataCache` platform-specific. + +Delete: `pendingMoves`, `pendingAccepts`, `challengesByGameId`, `processedEventIds`, `gamesBeingCreated`, all handle* methods, `applyPendingMoves`, `CompletedGame`. + +### Step 9: Create shared BroadcastBanner + +**File:** `commons/src/commonMain/.../chess/ChessBroadcastBanner.kt` (new) + +Extract from Android's `ChessStatusBanner.kt`, use `ChessBroadcastStatus` from commons. + +### Step 10: Update UI consumers + +**Android:** +- `ChessGameScreen.kt` / `ChessLobbyScreen.kt`: `ChessChallenge` instead of `Note` +- Remove `ChessStatusBanner.kt`, use shared `ChessBroadcastBanner` + +**Desktop:** +- `ChessScreen.kt`: `ChessChallenge` instead of `LiveChessGameChallengeEvent` +- Add `ChessBroadcastBanner` + +--- + +## Files Modified + +| File | Action | Notes | +|------|--------|-------| +| `LocalCache.kt` | Edit | Remove chess event handlers | +| `RelaySubscriptionsCoordinator.kt` | Edit | Remove chess DataSource | +| `commons/.../ChessRelayFetchHelper.kt` | **New** | One-shot relay query helper | +| `commons/.../IUserMetadataProvider.kt` | **New** | Metadata interface | +| `commons/.../ChessLobbyLogic.kt` | Rewrite | Relay-first, event routing, reconstructor | +| `commons/.../ChessLobbyState.kt` | Enhance | replaceGameState, completedGames, enriched ChessChallenge | +| `commons/.../ChessBroadcastBanner.kt` | **New** | Shared broadcast status UI | +| `amethyst/.../AndroidChessAdapter.kt` | **New** | Publisher + fetcher + metadata | +| `amethyst/.../ChessViewModel.kt` | Rewrite | 1220→150 lines | +| `amethyst/.../ChessStatusBanner.kt` | Delete | Replaced by shared | +| `amethyst/.../ChessGameScreen.kt` | Update | Challenge type + banner | +| `amethyst/.../ChessLobbyScreen.kt` | Update | Challenge type | +| `desktopApp/.../DesktopChessAdapter.kt` | **New** | Publisher + fetcher + metadata | +| `desktopApp/.../DesktopChessViewModel.kt` | Rewrite | 956→150 lines | +| `desktopApp/.../ChessScreen.kt` | Update | Challenge type + banner | + +--- + +## Verification + +1. `./gradlew :quartz:build` +2. `./gradlew :commons:build` +3. `./gradlew :amethyst:compileDebugKotlin` +4. `./gradlew :desktopApp:compileKotlin` +5. `./gradlew :quartz:jvmAndroidTest` — ChessStateReconstructor tests +6. `./gradlew :desktopApp:run` — manual test: create challenge, play moves diff --git a/docs/live-chess-implementation-status.md b/docs/live-chess-implementation-status.md new file mode 100644 index 000000000..e6b942434 --- /dev/null +++ b/docs/live-chess-implementation-status.md @@ -0,0 +1,266 @@ +# Live Chess Implementation Status + +## ✅ Completed Implementation + +### 1. Chess Engine & Move Validation +- **kchesslib dependency** added to `quartz/build.gradle.kts:150` +- **ChessEngine** interface with expect/actual pattern: + - `ChessEngine.kt` (commonMain) - Platform-agnostic API + - `ChessEngine.jvmAndroid.kt` (jvmAndroid) - kchesslib implementation +- **Full move validation**: Legal moves, check/checkmate, castling, en passant, pawn promotion +- **FEN import/export**: Board state serialization +- **Move history tracking**: SAN notation + +### 2. Interactive UI Components (commons/) +- **InteractiveChessBoard.kt**: Click-to-move with visual feedback + - Selected piece highlighting (green) + - Legal move indicators (circles/rings) + - Automatic move validation + - Callback system for move events + +- **LiveChessGame.kt**: Complete game UI + - NewChessGameDialog (challenge creation) + - LiveChessGameScreen (full game interface) + - GameInfoHeader (turn indicator, game status) + - MoveHistoryDisplay (SAN move list) + - GameControls (Resign, Offer Draw) + +### 3. Nostr Event Protocol (quartz/nip64Chess/) +**New Event Kinds:** +| Kind | Event | Purpose | +|------|-------|---------| +| 30064 | LiveChessGameChallengeEvent | Create/accept challenges | +| 30065 | LiveChessGameAcceptEvent | Accept challenge | +| 30066 | LiveChessMoveEvent | Individual moves (SAN + FEN) | +| 30067 | LiveChessGameEndEvent | Game result & termination | + +**Event Factory Integration** (EventFactory.kt:190-193): +- All live chess events registered for automatic parsing + +### 4. Game State Management +- **LiveChessGameState.kt**: Coordinates engine, Nostr, and UI + - Turn validation + - Move synchronization + - Automatic end detection (checkmate/stalemate) + - PGN generation + - Position mismatch recovery + +### 5. ChessViewModel (amethyst/ui/screen/loggedIn/chess/) +- **ChessViewModel.kt**: Full game state management & event publishing + - `activeGames: StateFlow>` - Active games by ID + - `challenges: StateFlow>` - Incoming/outgoing challenges + - `badgeCount: StateFlow` - Notification count + - `createChallenge()` - Create open or directed challenges + - `acceptChallenge()` - Accept challenge and start game + - `publishMove()` - Send moves to relays + - `resign()` / `offerDraw()` - End game actions + - **Relay subscriptions** via `LocalCache.live.newEventBundles` + - Auto-handles incoming moves, acceptances, endings, challenges + +- **ChessViewModelFactory.kt**: ViewModel factory with Account injection +- **ChessGameScreen.kt**: Wrapper connecting ViewModel to LiveChessGameScreen UI + +### 6. Feed Integration +**Chess Feed Filter** (TopNavFilterState.kt:130-142): +- Shows Kind 64 (completed games) +- Shows Kind 30064 (challenges - open & directed) +- Shows Kind 30067 (game endings) +- Excludes Kind 30066 (individual moves - too noisy) + +**Event Rendering** (Chess.kt, NoteCompose.kt): +- `RenderChessGame()` - Completed PGN games (Kind 64) +- `RenderLiveChessChallenge()` - Challenge cards with visual states: + - 🔓 **Open challenges** (green border) + - 💌 **Incoming challenges** (orange border) + - ⏳ **Sent challenges** (gray border) +- `RenderLiveChessGameEnd()` - Game results with PGN + +## 🚧 Remaining Work + +### Priority 1: FAB & Challenge Creation +**Files to modify:** +1. **HomeScreen.kt** or chess-specific screen + ```kotlin + var showNewGameDialog by remember { mutableStateOf(false) } + + Scaffold( + floatingActionButton = { + if (isChessFeed) { + FloatingActionButton( + onClick = { showNewGameDialog = true } + ) { + Icon(Icons.Default.Add, "New Game") + } + } + } + ) + + if (showNewGameDialog) { + NewChessGameDialog( + onDismiss = { showNewGameDialog = false }, + onCreateGame = { opponentPubkey, color -> + viewModel.createChallenge(opponentPubkey, color) + showNewGameDialog = false + } + ) + } + ``` + +### Priority 4: In-App Badges (Optional) +**Files to create:** +1. **BadgeProvider.kt** (commons/) + ```kotlin + object ChessBadgeProvider { + fun getBadgeCount( + challenges: List, + activeGames: List, + userPubkey: String + ): Int { + val incomingChallenges = challenges.count { + it.opponentPubkey() == userPubkey + } + val yourTurnGames = activeGames.count { it.isPlayerTurn() } + return incomingChallenges + yourTurnGames + } + } + ``` + +2. **Badge Display** - Add to chess filter icon: + ```kotlin + BadgedBox(badge = { Badge { Text("$count") } }) { + Icon(/* chess icon */) + } + ``` + +## 📋 Implementation Checklist + +### Phase 1: Basic Playability ✅ COMPLETE +- [x] Create ChessViewModel +- [x] Add navigation route for LiveChessGameScreen (Route.ChessGame exists) +- [x] Create ChessGameScreen wrapper +- [x] Wire up NewGameDialog → ViewModel.createChallenge() +- [x] Implement event publishing (challenges, moves, game end) +- [ ] Test: Create challenge, see it in feed + +### Phase 2: Two-Player Functionality ✅ COMPLETE +- [x] Implement relay subscriptions for game events +- [x] Wire up move synchronization (publish/receive) +- [x] Handle challenge acceptance flow +- [ ] Test: Play complete game between two accounts + +### Phase 3: Polish (IN PROGRESS) +- [ ] Add FAB to chess feed +- [ ] Implement badge counts display in nav +- [x] Add "Accept Challenge" button to incoming challenge cards +- [x] Add "View Game" navigation from challenge/game-end cards +- [ ] Handle edge cases (network errors, position desync, abandoned games) + +### Phase 4: Persistence (Optional) +- [ ] Create Room entities for active games +- [ ] Store games locally for offline viewing +- [ ] Sync on app start +- [ ] Background service for "your turn" notifications + +## 🎯 UX Decisions Implemented + +Based on user choices: + +1. ✅ **Entry Point**: FAB on Chess feed (pending UI implementation) +2. ✅ **Display**: Visual cards in one feed with borders/icons +3. ✅ **Discovery**: Open challenges shown in Chess feed (green border) +4. ✅ **Tap Action**: Navigate directly to full screen (pending nav) +5. ⏳ **Notifications**: In-app badge for counts (pending implementation) +6. ✅ **Filter Scope**: Meaningful events only (completed games, challenges, endings) +7. ⏳ **Storage**: Local cache + relay sync (pending Room/ViewModel) +8. ✅ **Input**: Click-to-move (already implemented) + +## 🔧 Quick Start for Development + +### Test the Chess Engine: +```kotlin +val engine = ChessEngine() +engine.reset() +val result = engine.makeMove("e2", "e4") +println("Move success: ${result.success}, SAN: ${result.san}") +``` + +### Test the Interactive Board: +```kotlin +@Composable +fun TestScreen() { + val engine = remember { ChessEngine().apply { reset() } } + InteractiveChessBoard( + engine = engine, + onMoveMade = { san -> println("Move made: $san") } + ) +} +``` + +### View Chess Feed: +1. Open Amethyst +2. Select "Chess" from home feed dropdown +3. See completed games (Kind 64) +4. See challenges with colored borders (Kind 30064) +5. See game endings with results (Kind 30067) + +## 📁 File Structure + +``` +AmethystMultiplatform/ +├── quartz/ # Protocol layer (KMP) +│ ├── src/commonMain/kotlin/.../nip64Chess/ +│ │ ├── ChessEngine.kt ✅ Engine interface +│ │ ├── ChessPosition.kt ✅ Board state +│ │ ├── ChessMove.kt ✅ Move representation +│ │ ├── ChessGame.kt ✅ Game model +│ │ ├── PGNParser.kt ✅ PGN parsing (31 tests) +│ │ ├── ChessGameEvent.kt ✅ Kind 64 +│ │ ├── LiveChessEvents.kt ✅ Data classes +│ │ ├── LiveChessGameEvents.kt ✅ Kind 30064-30067 +│ │ └── LiveChessGameState.kt ✅ Game coordinator +│ └── src/jvmAndroid/kotlin/.../nip64Chess/ +│ └── ChessEngine.jvmAndroid.kt ✅ kchesslib impl +│ +├── commons/ # Shared UI (Android/Desktop) - KMP +│ └── src/commonMain/kotlin/.../commons/chess/ +│ ├── ChessBoard.kt ✅ Static board +│ ├── InteractiveChessBoard.kt ✅ Click-to-move +│ ├── ChessGameViewer.kt ✅ PGN playback +│ ├── MoveNavigator.kt ✅ Move controls +│ ├── PGNMetadata.kt ✅ Game info +│ └── LiveChessGame.kt ✅ Dialog + screen +│ +├── amethyst/ # Android app +│ └── src/main/java/.../amethyst/ +│ ├── ui/note/types/ +│ │ └── Chess.kt ✅ Event renderers (3 types) +│ ├── ui/note/ +│ │ └── NoteCompose.kt ✅ Event dispatching +│ ├── ui/screen/ +│ │ ├── TopNavFilterState.kt ✅ Chess filter (3 kinds) +│ │ └── loggedIn/chess/ +│ │ ├── ChessViewModel.kt ✅ State & event publishing +│ │ ├── ChessViewModelFactory.kt ✅ ViewModel factory +│ │ ├── ChessGameScreen.kt ✅ Game screen wrapper +│ │ └── dal/ChessFeedFilter.kt ✅ Feed filter +│ └── ui/navigation/routes/ +│ └── Route.ChessGame ✅ Navigation route +│ +└── docs/ + └── live-chess-implementation-status.md ✅ This file +``` + +## 🚀 Next Steps + +**To complete the chess feature**, implement: + +1. ~~**ChessViewModel**~~ ✅ - Game state management & event publishing +2. ~~**Navigation**~~ ✅ - Route to LiveChessGameScreen +3. ~~**Event Subscriptions**~~ ✅ - Listen for opponent moves +4. **FAB** - Add "New Game" button to chess feed +5. **Badge Display** - Show badge count in navigation +6. **Test** - Play a complete game between two test accounts + +--- + +**Status**: Core chess functionality fully implemented. ViewModel and relay subscriptions complete. Ready for FAB/badge polish and end-to-end testing. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3b247e3ef..ef46ff88d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ accompanistAdaptive = "0.37.3" composeMultiplatform = "1.10.0" activityCompose = "1.12.4" -agp = "9.0.0" +agp = "9.0.1" android-compileSdk = "36" android-minSdk = "26" android-targetSdk = "36" @@ -26,6 +26,7 @@ javaKeyring = "1.0.4" jna = "5.18.1" jtorctl = "0.4.5.7" junit = "4.13.2" +kchesslib = "1.0.0" kotlin = "2.3.10" kotlinxCollectionsImmutable = "0.4.0" kotlinxCoroutinesCore = "1.10.2" @@ -48,7 +49,7 @@ secp256k1KmpJniAndroid = "0.22.0" securityCryptoKtx = "1.1.0" spotless = "8.2.1" tarsosdsp = "2.5" -torAndroid = "0.4.8.22" +torAndroid = "0.4.9.5" translate = "17.0.3" unifiedpush = "3.0.10" urlDetector = "0.1.23" @@ -99,6 +100,7 @@ androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplaye androidx-media3-exoplayer-hls = { group = "androidx.media3", name = "media3-exoplayer-hls", version.ref = "media3" } androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } androidx-media3-ui = { group = "androidx.media3", name = "media3-ui", version.ref = "media3" } +androidx-media3-ui-compose-material3 = { group = "androidx.media3", name = "media3-ui-compose-material3", version.ref = "media3" } androidx-navigation-compose = { group = "androidx.navigation", name = "navigation-compose", version.ref = "navigationCompose" } androidx-runner = { group = "androidx.test", name = "runner", version.ref = "runner" } androidx-security-crypto-ktx = { group = "androidx.security", name = "security-crypto-ktx", version.ref = "securityCryptoKtx" } @@ -113,6 +115,7 @@ coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref coil-gif = { group = "io.coil-kt.coil3", name = "coil-gif", version.ref = "coil" } coil-svg = { group = "io.coil-kt.coil3", name = "coil-svg", version.ref = "coil" } coil-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" } +coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" } drfonfon-geohash = { group = "com.github.drfonfon", name = "android-kotlin-geohash", version.ref = "androidKotlinGeohash" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebaseBom" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging" } @@ -123,6 +126,7 @@ java-keyring = { group = "com.github.javakeyring", name = "java-keyring", versio jna = { group = "net.java.dev.jna", name = "jna", version.ref = "jna" } jtorctl = { module = "info.guardianproject:jtorctl", version.ref = "jtorctl" } junit = { group = "junit", name = "junit", version.ref = "junit" } +kchesslib = { module = "io.github.cvb941:kchesslib", version.ref = "kchesslib" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinxCoroutinesCore" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 7e74ebf99..3a7c1c081 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Wed Jan 04 09:23:50 EST 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/quartz/build.gradle.kts b/quartz/build.gradle.kts index 11852a57c..a3bbcdd59 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -10,7 +10,6 @@ plugins { kotlin { compilerOptions { - freeCompilerArgs.add("-Xstring-concat=inline") freeCompilerArgs.add("-Xexpect-actual-classes") } jvm { @@ -21,8 +20,14 @@ kotlin { androidLibrary { namespace = "com.vitorpamplona.quartz" - compileSdk = libs.versions.android.compileSdk.get().toInt() - minSdk = libs.versions.android.minSdk.get().toInt() + compileSdk = + libs.versions.android.compileSdk + .get() + .toInt() + minSdk = + libs.versions.android.minSdk + .get() + .toInt() compilerOptions { jvmTarget.set(JvmTarget.JVM_21) @@ -51,7 +56,6 @@ kotlin { // https://developer.android.com/kotlin/multiplatform/migrate val xcfName = "quartz-kmpKit" - iosX64 { binaries.framework { baseName = xcfName @@ -106,43 +110,50 @@ kotlin { } // Must be defined before androidMain and jvmMain - val jvmAndroid = create("jvmAndroid") { - dependsOn(commonMain.get()) + val jvmAndroid = + create("jvmAndroid") { + dependsOn(commonMain.get()) - dependencies { - // Normalizes URLs - api(libs.rfc3986.normalizer) + dependencies { + // Normalizes URLs + api(libs.rfc3986.normalizer) - // Performant Parser of JSONs into Events - api(libs.jackson.module.kotlin) + // Performant Parser of JSONs into Events + api(libs.jackson.module.kotlin) - // Parses URLs from Text: - api(libs.url.detector) + // Parses URLs from Text: + api(libs.url.detector) - // Websockets API - implementation(libs.okhttp) - implementation(libs.okhttpCoroutines) + // Websockets API + implementation(libs.okhttp) + implementation(libs.okhttpCoroutines) + + // Chess engine for move validation and legal move generation + // NOTE: 1.0.4+ uses Java 21's removeLast() which crashes on Android API < 34 + // TODO: Test if 1.0.0 works, or fork library to fix + implementation(libs.kchesslib) + } } - } // Must be defined before androidMain and jvmMain - val jvmAndroidTest = create("jvmAndroidTest") { - dependsOn(commonTest.get()) - dependencies { - implementation(libs.kotlin.test) - implementation(libs.kotlinx.coroutines.test) + val jvmAndroidTest = + create("jvmAndroidTest") { + dependsOn(commonTest.get()) + dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } } - } jvmMain { dependsOn(jvmAndroid) dependencies { // Bitcoin secp256k1 bindings - implementation (libs.secp256k1.kmp.jni.jvm) + implementation(libs.secp256k1.kmp.jni.jvm) // LibSodium for ChaCha encryption (NIP-44) - implementation (libs.lazysodium.java) - implementation (libs.jna) + implementation(libs.lazysodium.java) + implementation(libs.jna) } } @@ -163,8 +174,8 @@ kotlin { api(libs.secp256k1.kmp.jni.android) // LibSodium for ChaCha encryption (NIP-44) - implementation ("com.goterl:lazysodium-android:5.2.0@aar") - implementation ("net.java.dev.jna:jna:5.18.1@aar") + implementation("com.goterl:lazysodium-android:5.2.0@aar") + implementation("net.java.dev.jna:jna:5.18.1@aar") } } @@ -188,7 +199,6 @@ kotlin { iosMain { dependsOn(commonMain.get()) dependencies { - } } @@ -207,7 +217,6 @@ kotlin { iosTest { dependsOn(commonTest.get()) dependencies { - } } @@ -225,20 +234,19 @@ kotlin { } } - mavenPublishing { // sources publishing is always enabled by the Kotlin Multiplatform plugin configure( KotlinMultiplatform( // whether to publish a sources jar sourcesJar = true, - ) + ), ) coordinates( groupId = "com.vitorpamplona.quartz", artifactId = "quartz", - version = "1.05.1" + version = "1.05.1", ) // Configure publishing to Maven Central diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/bloom/BloomFilter.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/bloom/BloomFilter.kt index f5d061223..d1f6fe2de 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/bloom/BloomFilter.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/bloom/BloomFilter.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.bloom import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.sha256.Sha256Hasher import org.junit.Assert.assertEquals @@ -135,7 +135,7 @@ class BloomFilterTest { var failureCounter = 0 for (seed in 0..1000000) { - if (bloomFilter.mightContains(Nip01.pubKeyCreate(Nip01.privKeyCreate()))) { + if (bloomFilter.mightContains(Nip01Crypto.pubKeyCreate(Nip01Crypto.privKeyCreate()))) { failureCounter++ } } diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/Nip01Test.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/Nip01CryptoTest.kt similarity index 86% rename from quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/Nip01Test.kt rename to quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/Nip01CryptoTest.kt index fd0fcf7f8..9a0edb3fb 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/Nip01Test.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/Nip01CryptoTest.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip01Core import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.utils.Secp256k1Instance import com.vitorpamplona.quartz.utils.sha256.sha256 import org.junit.Assert.assertEquals @@ -33,14 +33,14 @@ import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -class Nip01Test { +class Nip01CryptoTest { private val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() @Test fun testGetPublicFromPrivateKey() { assertEquals( "7d4b8806f1fd713c287235411bf95aa81b7242ead892733ec84b3f2719845be6", - Nip01.pubKeyCreate(privateKey).toHexKey(), + Nip01Crypto.pubKeyCreate(privateKey).toHexKey(), ) } @@ -66,7 +66,7 @@ class Nip01Test { fun testDeterministicSign() { assertEquals( "1484d0e0bd62165e822e31f1f4cc8e1ce8e20c30a060e24fb0ecd7baf7c624f661fb7a3e4f0ddb43018e5f0b4892c929af64d8b7a86021aa081ec8231e3dfa37", - Nip01.sign(sha256("Test".toByteArray()), privateKey, null).toHexKey(), + Nip01Crypto.sign(sha256("Test".toByteArray()), privateKey, null).toHexKey(), ) } @@ -81,10 +81,10 @@ class Nip01Test { @Test fun testDeterministicVerify() { assertTrue( - Nip01.verify( + Nip01Crypto.verify( "1484d0e0bd62165e822e31f1f4cc8e1ce8e20c30a060e24fb0ecd7baf7c624f661fb7a3e4f0ddb43018e5f0b4892c929af64d8b7a86021aa081ec8231e3dfa37".hexToByteArray(), sha256("Test".toByteArray()), - Nip01.pubKeyCreate(privateKey), + Nip01Crypto.pubKeyCreate(privateKey), ), ) } @@ -93,18 +93,18 @@ class Nip01Test { fun testNonDeterministicSign() { assertNotEquals( "1484d0e0bd62165e822e31f1f4cc8e1ce8e20c30a060e24fb0ecd7baf7c624f661fb7a3e4f0ddb43018e5f0b4892c929af64d8b7a86021aa081ec8231e3dfa37", - Nip01.sign(sha256("Test".toByteArray()), privateKey).toHexKey(), + Nip01Crypto.sign(sha256("Test".toByteArray()), privateKey).toHexKey(), ) } @Test fun testNonDeterministicSignVerify() { - val signature = Nip01.sign(sha256("Test".toByteArray()), privateKey) + val signature = Nip01Crypto.sign(sha256("Test".toByteArray()), privateKey) assertTrue( - Nip01.verify( + Nip01Crypto.verify( signature, sha256("Test".toByteArray()), - Nip01.pubKeyCreate(privateKey), + Nip01Crypto.pubKeyCreate(privateKey), ), ) } diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/Nip01CryptoTest.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/Nip01CryptoTest.kt index ed307e6da..c2ec4880b 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/Nip01CryptoTest.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/Nip01CryptoTest.kt @@ -33,7 +33,7 @@ class Nip01CryptoTest { fun testGetPublicFromPrivateKey() { val privateKey = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() - val publicKey = Nip01.pubKeyCreate(privateKey).toHexKey() + val publicKey = Nip01Crypto.pubKeyCreate(privateKey).toHexKey() assertEquals("7d4b8806f1fd713c287235411bf95aa81b7242ead892733ec84b3f2719845be6", publicKey) } } diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip04Dm/EncryptionTest.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip04Dm/EncryptionTest.kt index ac30d2121..ef1589a09 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip04Dm/EncryptionTest.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip04Dm/EncryptionTest.kt @@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip04Dm import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.nip04Dm.crypto.EncryptedInfo import com.vitorpamplona.quartz.nip04Dm.crypto.Encryption import org.junit.Assert.assertEquals @@ -34,8 +34,8 @@ class EncryptionTest { val sk1 = "91ba716fa9e7ea2fcbad360cf4f8e0d312f73984da63d90f524ad61a6a1e7dbe".hexToByteArray() val sk2 = "96f6fa197aa07477ab88f6981118466ae3a982faab8ad5db9d5426870c73d220".hexToByteArray() - val pk1 = Nip01.pubKeyCreate(sk1) - val pk2 = Nip01.pubKeyCreate(sk2) + val pk1 = Nip01Crypto.pubKeyCreate(sk1) + val pk2 = Nip01Crypto.pubKeyCreate(sk2) val expectedShared = "7ce22696eb0e303ddaa491bdf2a56b79d249f2d861b8e012a933e01dc4beba81" diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip04Dm/Nip04Test.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip04Dm/Nip04Test.kt index 0a2b79b2e..625c4e82c 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip04Dm/Nip04Test.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip04Dm/Nip04Test.kt @@ -21,7 +21,7 @@ package com.vitorpamplona.quartz.nip04Dm import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.nip04Dm.crypto.Nip04 import org.junit.Assert.assertEquals import org.junit.Test @@ -33,8 +33,8 @@ class Nip04Test { fun encryptDecryptNIP4Test() { val msg = "Hi" - val privateKey = Nip01.privKeyCreate() - val publicKey = Nip01.pubKeyCreate(privateKey) + val privateKey = Nip01Crypto.privKeyCreate() + val publicKey = Nip01Crypto.pubKeyCreate(privateKey) val encrypted = Nip04.encrypt(msg, privateKey, publicKey) val decrypted = Nip04.decrypt(encrypted, privateKey, publicKey) @@ -46,8 +46,8 @@ class Nip04Test { fun encryptSharedSecretDecryptNIP4Test() { val msg = "Hi" - val privateKey = Nip01.privKeyCreate() - val publicKey = Nip01.pubKeyCreate(privateKey) + val privateKey = Nip01Crypto.privKeyCreate() + val publicKey = Nip01Crypto.pubKeyCreate(privateKey) val encrypted = Nip04.encrypt(msg, privateKey, publicKey) val decrypted = Nip04.decrypt(encrypted, privateKey, publicKey) diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v1Test.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v1Test.kt index bc28ce35f..fc97ca488 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v1Test.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v1Test.kt @@ -24,7 +24,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @@ -68,8 +68,8 @@ class Nip44v1Test { fun encryptDecrypt() { val msg = "Hi" - val privateKey = Nip01.privKeyCreate() - val publicKey = Nip01.pubKeyCreate(privateKey) + val privateKey = Nip01Crypto.privKeyCreate() + val publicKey = Nip01Crypto.pubKeyCreate(privateKey) val encrypted = nip44v1.encrypt(msg, privateKey, publicKey) val decrypted = nip44v1.decrypt(encrypted, privateKey, publicKey) @@ -81,8 +81,8 @@ class Nip44v1Test { fun encryptDecryptSharedSecret() { val msg = "Hi" - val privateKey = Nip01.privKeyCreate() - val publicKey = Nip01.pubKeyCreate(privateKey) + val privateKey = Nip01Crypto.privKeyCreate() + val publicKey = Nip01Crypto.pubKeyCreate(privateKey) val sharedSecret = nip44v1.getSharedSecret(privateKey, publicKey) diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt index 8131635d7..a17fb31d7 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2Test.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.sha256.sha256 import junit.framework.TestCase.assertNotNull @@ -68,8 +68,8 @@ class Nip44v2Test { val privateKeyA = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() val privateKeyB = "65f039136f8da8d3e87b4818746b53318d5481e24b2673f162815144223a0b5a".hexToByteArray() - val publicKeyA = Nip01.pubKeyCreate(privateKeyA) - val publicKeyB = Nip01.pubKeyCreate(privateKeyB) + val publicKeyA = Nip01Crypto.pubKeyCreate(privateKeyA) + val publicKeyB = Nip01Crypto.pubKeyCreate(privateKeyB) assertEquals( nip44v2.getConversationKey(privateKeyA, publicKeyB).toHexKey(), @@ -82,8 +82,8 @@ class Nip44v2Test { val privateKeyA = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray() val privateKeyB = "e6159851715b4aa6190c22b899b0c792847de0a4435ac5b678f35738351c43b0".hexToByteArray() - val publicKeyA = Nip01.pubKeyCreate(privateKeyA) - val publicKeyB = Nip01.pubKeyCreate(privateKeyB) + val publicKeyA = Nip01Crypto.pubKeyCreate(privateKeyA) + val publicKeyB = Nip01Crypto.pubKeyCreate(privateKeyB) assertEquals( nip44v2.getConversationKey(privateKeyA, publicKeyB).toHexKey(), diff --git a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/SignStringTest.kt b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/SignStringTest.kt index 1787b0090..04d99c1f5 100644 --- a/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/SignStringTest.kt +++ b/quartz/src/androidDeviceTest/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/SignStringTest.kt @@ -24,7 +24,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import org.junit.Assert.assertEquals import org.junit.Test import org.junit.runner.RunWith @@ -37,7 +37,7 @@ class SignStringTest { val message = "8e58c8251bb406b6ded69e9eb14f55282a9a53bdab16fc49a3218c2ad3abc887".hexToByteArray() val keyPair = KeyPair("a5ab474552c8f9c46c2eda5a0b68f27430ad81f96cb405e0cb4e34bf0c6494a2".hexToByteArray()) - val signedMessage = Nip01.sign(message, keyPair.privKey!!, random).toHexKey() + val signedMessage = Nip01Crypto.sign(message, keyPair.privKey!!, random).toHexKey() val expectedValue = "0f9be7e01ba53d5ee6874b9180c7956269fda7a5be424634c3d17b5cfcea6da001be89183876415ba08b7dafa6cff4555e393dc228fb8769b384344e9a27b77c" assertEquals(expectedValue, signedMessage) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/ActivityExt.kt b/quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.android.kt similarity index 75% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/ActivityExt.kt rename to quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.android.kt index da0303f44..34edaf77c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/ActivityExt.kt +++ b/quartz/src/androidHostTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.android.kt @@ -18,18 +18,10 @@ * 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.playback.pip +package com.vitorpamplona.quartz -import android.app.Activity -import android.graphics.Rect - -fun Activity.enterPipMode( - ratio: Float?, - bounds: Rect?, -) { - if (!isInPictureInPictureMode) { - enterPictureInPictureMode(makePipParams(ratio, bounds)) - } else { - setPictureInPictureParams(makePipParams(ratio, bounds)) +actual class TestResourceLoader actual constructor() { + actual fun loadString(file: String): String { + TODO("Not yet implemented") } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/SignString.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/SignString.kt index 4c9a67cc5..27e5a40da 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/SignString.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip55AndroidSigner/SignString.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.quartz.nip55AndroidSigner -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.sha256.sha256 @@ -28,4 +28,4 @@ fun signString( message: String, privKey: ByteArray, nonce: ByteArray = RandomInstance.bytes(32), -): ByteArray = Nip01.sign(sha256(message.toByteArray()), privKey, nonce) +): ByteArray = Nip01Crypto.sign(sha256(message.toByteArray()), privKey, nonce) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt index 6a32f886e..51d45eb74 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryReadingStateEvent.kt @@ -28,7 +28,7 @@ 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.core.builder -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag @@ -83,17 +83,16 @@ class InteractiveStoryReadingStateEvent( fun update( base: InteractiveStoryReadingStateEvent, - currentScene: InteractiveStoryBaseEvent, - currentSceneRelay: NormalizedRelayUrl?, + currentScene: EventHintBundle, createdAt: Long = TimeUtils.now(), ): EventTemplate { val rootTag = base.dTag() - val sceneTag = currentScene.aTag(currentSceneRelay) + val sceneTag = ATag(currentScene.event.address(), currentScene.relay) val status = if (rootTag == sceneTag.toTag()) { ReadStatusTag.STATUS.NEW - } else if (currentScene.options().isEmpty()) { + } else if (currentScene.event.options().isEmpty()) { ReadStatusTag.STATUS.DONE } else { ReadStatusTag.STATUS.READING @@ -109,34 +108,32 @@ class InteractiveStoryReadingStateEvent( } fun build( - root: InteractiveStoryBaseEvent, - rootRelay: NormalizedRelayUrl?, - currentScene: InteractiveStoryBaseEvent, - currentSceneRelay: NormalizedRelayUrl?, + root: EventHintBundle, + currentScene: EventHintBundle, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = eventTemplate(KIND, "", createdAt) { - val rootTag = root.aTag(rootRelay) - val sceneTag = currentScene.aTag(currentSceneRelay) + val rootTag = ATag(root.event.address(), root.relay) + val sceneTag = ATag(currentScene.event.address(), currentScene.relay) val status = if (rootTag == sceneTag) { ReadStatusTag.STATUS.NEW - } else if (currentScene.options().isEmpty()) { + } else if (currentScene.event.options().isEmpty()) { ReadStatusTag.STATUS.DONE } else { ReadStatusTag.STATUS.READING } dTag(rootTag.toTag()) - alt(root.title()?.let { ALT2 + it } ?: ALT1) + alt(root.event.title()?.let { ALT2 + it } ?: ALT1) rootScene(rootTag) currentScene(sceneTag) status(status) - root.title()?.let { storyTitle(it) } - root.summary()?.let { storyImage(it) } - root.image()?.let { storySummary(it) } + root.event.title()?.let { storyTitle(it) } + root.event.summary()?.let { storyImage(it) } + root.event.image()?.let { storySummary(it) } initializer() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt index f1de38137..992bbb51d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipsOnNostr/NipTextEvent.kt @@ -32,7 +32,6 @@ import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag @@ -71,8 +70,6 @@ class NipTextEvent( override fun dTag() = tags.dTag() - override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint) - override fun address() = Address(kind, pubKey, dTag()) override fun addressTag() = Address.assemble(kind, pubKey, dTag()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt index 9fbdde36b..2f5732d44 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/AddressableEvent.kt @@ -21,15 +21,11 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag @Immutable interface AddressableEvent : IEvent { fun dTag(): String - fun aTag(relayHint: NormalizedRelayUrl? = null): ATag - fun address(): Address fun addressTag(): String diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt index 9ecaa83d6..4f9025527 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/BaseAddressableEvent.kt @@ -21,8 +21,6 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag @Immutable @@ -38,8 +36,6 @@ open class BaseAddressableEvent( AddressableEvent { override fun dTag() = tags.dTag() - override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint) - override fun address() = Address(kind, pubKey, dTag()) /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt index 622f854d2..f236a2c1c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/BaseReplaceableEvent.kt @@ -21,8 +21,6 @@ package com.vitorpamplona.quartz.nip01Core.core import androidx.compose.runtime.Immutable -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag @Immutable open class BaseReplaceableEvent( @@ -33,11 +31,10 @@ open class BaseReplaceableEvent( tags: TagArray, content: String, sig: HexKey, -) : BaseAddressableEvent(id, pubKey, createdAt, kind, tags, content, sig) { +) : Event(id, pubKey, createdAt, kind, tags, content, sig), + AddressableEvent { override fun dTag() = FIXED_D_TAG - override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, FIXED_D_TAG, relayHint) - override fun address(): Address = Address(kind, pubKey, dTag()) /** diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventAssembler.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventAssembler.kt index a387cc632..abc6791ba 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventAssembler.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventAssembler.kt @@ -26,29 +26,27 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.EventFactory import com.vitorpamplona.quartz.utils.RandomInstance -class EventAssembler { - companion object { - fun hashAndSign( - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - privKey: ByteArray, - nonce: ByteArray? = RandomInstance.bytes(32), - ): T { - val id = EventHasher.hashIdBytes(pubKey, createdAt, kind, tags, content) - val sig = Nip01.sign(id, privKey, nonce).toHexKey() +object EventAssembler { + fun hashAndSign( + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + privKey: ByteArray, + nonce: ByteArray? = RandomInstance.bytes(32), + ): T { + val id = EventHasher.hashIdBytes(pubKey, createdAt, kind, tags, content) + val sig = Nip01Crypto.sign(id, privKey, nonce).toHexKey() - return EventFactory.create( - id.toHexKey(), - pubKey, - createdAt, - kind, - tags, - content, - sig, - ) - } + return EventFactory.create( + id.toHexKey(), + pubKey, + createdAt, + kind, + tags, + content, + sig, + ) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventExt.kt index de69f6b35..644638d3c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventExt.kt @@ -33,7 +33,7 @@ fun Event.verifyId(): Boolean { fun Event.verifySignature(): Boolean { if (id.isEmpty() || sig.isEmpty()) return false - return Nip01.verify(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey)) + return Nip01Crypto.verify(Hex.decode(sig), Hex.decode(id), Hex.decode(pubKey)) } /** Checks if the ID is correct and then if the pubKey's secret key signed the event. */ diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt index 2828d086c..1c9ad080b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/EventHasher.kt @@ -24,33 +24,29 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.sha256.sha256 -class EventHasher { - companion object { - fun hashIdBytes( - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - ): ByteArray = sha256(EventHasherSerializer.fastMakeJsonForId(pubKey, createdAt, kind, tags, content)) +object EventHasher { + fun hashIdBytes( + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): ByteArray = sha256(EventHasherSerializer.fastMakeJsonForId(pubKey, createdAt, kind, tags, content)) - fun hashId(serializedJsonAsBytes: ByteArray): String = sha256(serializedJsonAsBytes).toHexKey() + fun hashId( + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): String = hashIdBytes(pubKey, createdAt, kind, tags, content).toHexKey() - fun hashId( - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - ): String = hashIdBytes(pubKey, createdAt, kind, tags, content).toHexKey() - - fun hashIdCheck( - id: HexKey, - pubKey: HexKey, - createdAt: Long, - kind: Int, - tags: Array>, - content: String, - ): Boolean = EventHasherSerializer.makeJsonForIdHashAndCheck(id, pubKey, createdAt, kind, tags, content) - } + fun hashIdCheck( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): Boolean = EventHasherSerializer.makeJsonForIdHashAndCheck(id, pubKey, createdAt, kind, tags, content) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/KeyPair.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/KeyPair.kt index 57975c7e5..e30ad7958 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/KeyPair.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/KeyPair.kt @@ -34,8 +34,8 @@ class KeyPair( if (privKey == null) { if (pubKey == null) { // create new, random keys - this.privKey = Nip01.privKeyCreate() - this.pubKey = Nip01.pubKeyCreate(this.privKey) + this.privKey = Nip01Crypto.privKeyCreate() + this.pubKey = Nip01Crypto.pubKeyCreate(this.privKey) } else { // this is a read-only account check(pubKey.size == 32) @@ -46,7 +46,7 @@ class KeyPair( // as private key is provided, ignore the public key and set keys according to private key this.privKey = privKey if (pubKey == null || forceReplacePubkey) { - this.pubKey = Nip01.pubKeyCreate(privKey) + this.pubKey = Nip01Crypto.pubKeyCreate(privKey) } else { this.pubKey = pubKey } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/Nip01.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/Nip01Crypto.kt similarity index 98% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/Nip01.kt rename to quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/Nip01Crypto.kt index b3ae86a65..7022d353e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/Nip01.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/Nip01Crypto.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.crypto import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.Secp256k1Instance -object Nip01 { +object Nip01Crypto { fun privKeyCreate() = RandomInstance.bytes(32) fun pubKeyCreate(privKey: ByteArray) = Secp256k1Instance.compressedPubKeyFor(privKey).copyOfRange(1, 33) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/aTag/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/aTag/TagArrayBuilderExt.kt index 1cce516a2..ca62c97c6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/aTag/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/tags/aTag/TagArrayBuilderExt.kt @@ -20,11 +20,20 @@ */ package com.vitorpamplona.quartz.nip01Core.tags.aTag +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl fun TagArrayBuilder.aTag(tag: ATag) = add(tag.toATagArray()) +fun TagArrayBuilder.aTag( + address: Address, + relayHint: NormalizedRelayUrl? = null, +) = add(ATag.assemble(address, relayHint)) + fun TagArrayBuilder.aTags(tag: List) = addAll(tag.map { it.toATagArray() }) fun TagArrayBuilder.removeATag(tag: ATag) = this.removeIf(ATag::isSameAddress, tag.toATagArray()) + +fun TagArrayBuilder.removeAddress(address: Address) = this.removeIf(ATag::isSameAddress, ATag.assemble(address, null)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt index b3df7fad8..2bd326595 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip09Deletions/DeletionEvent.kt @@ -89,7 +89,7 @@ class DeletionEvent( deleteEvents.forEach { eTag(ETag(it.id)) if (it is AddressableEvent) { - aTag(it.aTag()) + aTag(it.address()) } } @@ -108,7 +108,7 @@ class DeletionEvent( deleteEvents.forEach { if (it is AddressableEvent) { - aTag(it.aTag()) + aTag(it.address()) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt index 9420bb11d..9ef37f5d8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/GenericRepostEvent.kt @@ -26,9 +26,9 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag @@ -38,7 +38,6 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.eTag import com.vitorpamplona.quartz.nip01Core.tags.kinds.kind import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip01Core.tags.people.pTag -import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.nip31Alts.alt import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.lastNotNullOfOrNull @@ -93,45 +92,28 @@ class GenericRepostEvent( const val ALT = "Generic repost" fun build( - boostedPost: Event, - eventSourceRelay: NormalizedRelayUrl?, - authorHomeRelay: NormalizedRelayUrl?, + eventHint: EventHintBundle, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, boostedPost.toJson(), createdAt) { + ) = eventTemplate(KIND, eventHint.event.toJson(), createdAt) { + val boostedPost = eventHint.event + alt(ALT) kind(boostedPost.kind) - pTag(PTag(boostedPost.pubKey, authorHomeRelay)) - eTag(ETag(boostedPost.id, eventSourceRelay, boostedPost.pubKey)) + pTag(PTag(boostedPost.pubKey, eventHint.authorHomeRelay)) + eTag(ETag(boostedPost.id, eventHint.relay, boostedPost.pubKey)) if (boostedPost is AddressableEvent) { - aTag(boostedPost.aTag(eventSourceRelay)) + aTag(boostedPost.address(), eventHint.relay) } initializer() } suspend fun create( - boostedPost: Event, + eventHint: EventHintBundle, signer: NostrSigner, createdAt: Long = TimeUtils.now(), - ): GenericRepostEvent { - val content = boostedPost.toJson() - - val tags = - mutableListOf( - arrayOf("e", boostedPost.id), - arrayOf("p", boostedPost.pubKey), - ) - - if (boostedPost is AddressableEvent) { - tags.add(arrayOf("a", boostedPost.aTag().toTag())) - } - - tags.add(arrayOf("k", "${boostedPost.kind}")) - tags.add(AltTag.assemble(ALT)) - - return signer.sign(createdAt, KIND, tags.toTypedArray(), content) - } + ): GenericRepostEvent = signer.sign(build(eventHint, createdAt)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt index a8abc54a2..39a6391a6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip18Reposts/RepostEvent.kt @@ -26,9 +26,10 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag @@ -82,7 +83,7 @@ class RepostEvent( fun containedPost() = try { fromJson(content) - } catch (e: Exception) { + } catch (_: Exception) { null } @@ -91,22 +92,28 @@ class RepostEvent( const val ALT = "Repost event" fun build( - boostedPost: Event, - eventSourceRelay: NormalizedRelayUrl?, - authorHomeRelay: NormalizedRelayUrl?, + eventHint: EventHintBundle, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, - ) = eventTemplate(KIND, boostedPost.toJson(), createdAt) { + ) = eventTemplate(KIND, eventHint.event.toJson(), createdAt) { + val boostedPost = eventHint.event + alt(ALT) - pTag(PTag(boostedPost.pubKey, authorHomeRelay)) - eTag(ETag(boostedPost.id, eventSourceRelay, boostedPost.pubKey)) + pTag(PTag(boostedPost.pubKey, eventHint.authorHomeRelay)) + eTag(ETag(boostedPost.id, eventHint.relay, boostedPost.pubKey)) if (boostedPost is AddressableEvent) { - aTag(boostedPost.aTag(eventSourceRelay)) + aTag(boostedPost.address(), eventHint.relay) } kind(boostedPost.kind) initializer() } + + suspend fun create( + eventHint: EventHintBundle, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): RepostEvent = signer.sign(build(eventHint, createdAt)) } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt index 018fc3632..d9df56831 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/Nip19Parser.kt @@ -24,7 +24,6 @@ import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes import com.vitorpamplona.quartz.nip19Bech32.entities.Entity import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress @@ -175,7 +174,7 @@ object Nip19Parser { fun decodePublicKey(key: String): ByteArray = when (val parsed = Nip19Parser.uriToRoute(key)?.entity) { - is NSec -> Nip01.pubKeyCreate(parsed.hex.hexToByteArray()) + is NSec -> parsed.toPubKey() is NPub -> parsed.hex.hexToByteArray() is NProfile -> parsed.hex.hexToByteArray() else -> Hex.decode(key) // crashes on purpose @@ -202,7 +201,7 @@ fun decodePrivateKeyAsHexOrNull(key: String): HexKey? = fun decodePublicKeyAsHexOrNull(key: String): HexKey? = try { when (val parsed = Nip19Parser.uriToRoute(key)?.entity) { - is NSec -> Nip01.pubKeyCreate(parsed.hex.hexToByteArray()).toHexKey() + is NSec -> parsed.toPubKeyHex() is NPub -> parsed.hex is NProfile -> parsed.hex is NNote -> null diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt index 1316aea73..cdbb6141e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip19Bech32/entities/NSec.kt @@ -23,13 +23,15 @@ package com.vitorpamplona.quartz.nip19Bech32.entities import androidx.compose.runtime.Immutable import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto @Immutable data class NSec( val hex: String, ) : Entity { - fun toPubKeyHex() = Nip01.pubKeyCreate(hex.hexToByteArray()).toHexKey() + fun toPubKey() = Nip01Crypto.pubKeyCreate(hex.hexToByteArray()) + + fun toPubKeyHex() = toPubKey().toHexKey() companion object { fun parse(bytes: ByteArray): NSec? { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt index 15a283c23..6eabe0f37 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip23LongContent/LongTextNoteEvent.kt @@ -31,9 +31,7 @@ import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate -import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags import com.vitorpamplona.quartz.nip01Core.tags.people.PTag @@ -119,8 +117,6 @@ class LongTextNoteEvent( override fun dTag() = tags.dTag() - override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint) - override fun address() = Address(kind, pubKey, dTag()) override fun addressTag() = Address.assemble(kind, pubKey, dTag()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt index a7ff4fc17..69901c396 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip25Reactions/ReactionEvent.kt @@ -91,7 +91,7 @@ class ReactionEvent( ) = eventTemplate(KIND, reaction, createdAt) { eTag(reactedTo.toETag()) if (reactedTo.event is AddressableEvent) { - aTag(reactedTo.event.aTag(reactedTo.relay)) + aTag(reactedTo.event.address(), reactedTo.relay) } pTag(reactedTo.event.pubKey, reactedTo.relay) kind(reactedTo.event.kind) @@ -104,7 +104,7 @@ class ReactionEvent( ) = eventTemplate(KIND, reaction.toContentEncode(), createdAt) { eTag(reactedTo.toETag()) if (reactedTo.event is AddressableEvent) { - aTag(reactedTo.event.aTag(reactedTo.relay)) + aTag(reactedTo.event.address(), reactedTo.relay) } pTag(reactedTo.event.pubKey, reactedTo.relay) kind(reactedTo.event.kind) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiPackSelectionEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiPackSelectionEvent.kt index 9515507f4..a2174bf87 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiPackSelectionEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/selection/EmojiPackSelectionEvent.kt @@ -78,7 +78,7 @@ class EmojiPackSelectionEvent( createdAt: Long = TimeUtils.now(), updater: TagArrayBuilder.() -> Unit = {}, ) = eventUpdate(currentSelection, createdAt) { - removePack(packToRemove.aTag(null)) + removePack(packToRemove.address()) updater() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/selection/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/selection/TagArrayBuilderExt.kt index e3d6dae4b..c97a08a7f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/selection/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip30CustomEmoji/selection/TagArrayBuilderExt.kt @@ -20,14 +20,15 @@ */ package com.vitorpamplona.quartz.nip30CustomEmoji.selection +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTags -import com.vitorpamplona.quartz.nip01Core.tags.aTag.removeATag +import com.vitorpamplona.quartz.nip01Core.tags.aTag.removeAddress fun TagArrayBuilder.pack(tag: ATag) = aTag(tag) fun TagArrayBuilder.packs(tags: List) = aTags(tags) -fun TagArrayBuilder.removePack(tag: ATag) = removeATag(tag) +fun TagArrayBuilder.removePack(address: Address) = removeAddress(address) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt index 0af55aac4..295c38fd4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip54Wiki/WikiNoteEvent.kt @@ -34,7 +34,6 @@ import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag @@ -129,8 +128,6 @@ class WikiNoteEvent( override fun dTag() = tags.dTag() - override fun aTag(relayHint: NormalizedRelayUrl?) = ATag(kind, pubKey, dTag(), relayHint) - override fun address() = Address(kind, pubKey, dTag()) override fun addressTag() = Address.assemble(kind, pubKey, dTag()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt index b204dafdb..efa2cec2b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/LnZapRequestEvent.kt @@ -104,7 +104,7 @@ class LnZapRequestEvent( AltTag.assemble(ALT), ) if (zappedEvent is AddressableEvent) { - tags = tags + listOf(arrayOf("a", zappedEvent.aTag().toTag())) + tags = tags + listOf(ATag.assemble(zappedEvent.address(), null)) } if (pollOption != null && pollOption >= 0) { tags = tags + listOf(arrayOf(PollOptionTag.TAG_NAME, pollOption.toString())) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt index e8afb8f88..2b91ea693 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip57Zaps/PrivateZapRequestBuilder.kt @@ -25,7 +25,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.crypto.Nip01 +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions @@ -106,7 +106,7 @@ class PrivateZapRequestBuilder { try { if (altPubkeyToUse != null) { - val altPubKeyFromPrivate = Nip01.pubKeyCreate(myPrivateKeyForThisEvent).toHexKey() + val altPubKeyFromPrivate = Nip01Crypto.pubKeyCreate(myPrivateKeyForThisEvent).toHexKey() if (altPubKeyFromPrivate == event.pubKey) { // the sender is logged in. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.kt new file mode 100644 index 000000000..0ae8c39d4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.kt @@ -0,0 +1,139 @@ +/* + * 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 + +/** + * Platform-agnostic chess engine interface for move validation and generation + * Implemented using expect/actual for JVM/Android platforms + */ +expect class ChessEngine() { + /** + * Get the current position as FEN string + */ + fun getFen(): String + + /** + * Load a position from FEN notation + */ + fun loadFen(fen: String) + + /** + * Reset to starting position + */ + fun reset() + + /** + * Make a move using Standard Algebraic Notation (SAN) + * @param san Move in SAN format (e.g., "Nf3", "e4", "O-O") + * @return MoveResult with success status and resulting position + */ + fun makeMove(san: String): MoveResult + + /** + * Make a move from source to destination square + * @param from Source square in algebraic notation (e.g., "e2") + * @param to Destination square in algebraic notation (e.g., "e4") + * @param promotion Optional promotion piece type for pawn promotion + * @return MoveResult with success status and resulting position + */ + fun makeMove( + from: String, + to: String, + promotion: PieceType? = null, + ): MoveResult + + /** + * Get all legal moves from the current position + * @return List of legal moves in SAN notation + */ + fun getLegalMoves(): List + + /** + * Get all legal moves for a specific square + * @param square Square in algebraic notation (e.g., "e2") + * @return List of destination squares that are legal + */ + fun getLegalMovesFrom(square: String): List + + /** + * Check if a move is legal in the current position + * @param san Move in SAN format + * @return true if the move is legal + */ + fun isLegalMove(san: String): Boolean + + /** + * Check if a move from-to is legal + */ + fun isLegalMove( + from: String, + to: String, + promotion: PieceType? = null, + ): Boolean + + /** + * Check if current position is checkmate + */ + fun isCheckmate(): Boolean + + /** + * Check if current position is stalemate + */ + fun isStalemate(): Boolean + + /** + * Check if current position is in check + */ + fun isInCheck(): Boolean + + /** + * Undo the last move + */ + fun undoMove() + + /** + * Get the current position as ChessPosition + */ + fun getPosition(): ChessPosition + + /** + * Get side to move + */ + fun getSideToMove(): Color + + /** + * Get move history in SAN notation + */ + fun getMoveHistory(): List +} + +/** + * Result of attempting a chess move + */ +@Immutable +data class MoveResult( + val success: Boolean, + val san: String? = null, // Move in SAN notation if successful + val position: ChessPosition? = null, // Resulting position if successful + val error: String? = null, // Error message if unsuccessful +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGame.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGame.kt new file mode 100644 index 000000000..083ab167a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGame.kt @@ -0,0 +1,84 @@ +/* + * 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 + +/** + * Represents a complete chess game parsed from PGN + * Per NIP-64, content must be valid PGN format + */ +@Immutable +data class ChessGame( + val metadata: Map, + val moves: List, + val positions: List, + val result: GameResult, +) { + init { + require(positions.isNotEmpty()) { "Game must have at least starting position" } + require(positions.size == moves.size + 1) { "Position count must be moves + 1" } + } + + /** + * Standard PGN tag accessors + */ + val event: String? get() = metadata["Event"] + val site: String? get() = metadata["Site"] + val date: String? get() = metadata["Date"] + val round: String? get() = metadata["Round"] + val white: String? get() = metadata["White"] + val black: String? get() = metadata["Black"] + + /** + * Get position after move N (0 = starting position) + */ + fun positionAt(moveIndex: Int): ChessPosition = positions.getOrElse(moveIndex) { positions.last() } + + /** + * Check if game has required metadata + */ + fun hasRequiredMetadata(): Boolean = + metadata.containsKey("Event") && + metadata.containsKey("Site") && + metadata.containsKey("Date") && + metadata.containsKey("Round") && + metadata.containsKey("White") && + metadata.containsKey("Black") && + metadata.containsKey("Result") +} + +/** + * Game result per PGN specification + */ +enum class GameResult( + val notation: String, +) { + WHITE_WINS("1-0"), + BLACK_WINS("0-1"), + DRAW("1/2-1/2"), + IN_PROGRESS("*"), + ; + + companion object { + fun parse(notation: String): GameResult = entries.find { it.notation == notation } ?: IN_PROGRESS + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEvent.kt new file mode 100644 index 000000000..664c60b0b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEvent.kt @@ -0,0 +1,86 @@ +/* + * 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.Event +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 + +/** + * NIP-64: Chess Game Event + * + * Kind 64 events contain chess games in PGN (Portable Game Notation) format. + * Per NIP-64 specification: + * - Content must be valid PGN format (PGN-database) + * - Clients should accept "import format" (human-created, flexible) + * - Clients should publish in "export format" (machine-generated, strict) + * - Moves must comply with chess rules + * - Clients should display content as rendered chessboard + * + * @see NIP-64 + */ +@Immutable +class ChessGameEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, // PGN database format + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 64 + const val ALT_DESCRIPTION = "Chess Game" + + /** + * Create a chess game event from PGN content + * Per NIP-64, clients should publish in strict "export format" + * + * @param pgnContent Valid PGN format string + * @param altDescription Alt text for non-supporting clients (NIP-31) + * @param createdAt Event timestamp + * @param initializer Additional tag builder operations + */ + fun build( + pgnContent: String, + altDescription: String = ALT_DESCRIPTION, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, pgnContent, createdAt) { + alt(altDescription) + initializer() + } + } + + /** + * Get PGN content from event + */ + fun pgn(): String = content + + /** + * Get alt text for non-supporting clients (NIP-31) + */ + fun altText(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "alt" }?.get(1) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameNameGenerator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameNameGenerator.kt new file mode 100644 index 000000000..4a36d1bf1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameNameGenerator.kt @@ -0,0 +1,234 @@ +/* + * 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 kotlin.random.Random + +/** + * Generates human-readable chess game names. + * + * Produces memorable names like "Bold Knight", "Swift Rook", etc. + */ +object ChessGameNameGenerator { + // Chess-themed adjectives + private val adjectives = + listOf( + "Bold", + "Swift", + "Cunning", + "Royal", + "Noble", + "Shadow", + "Silent", + "Golden", + "Silver", + "Iron", + "Fierce", + "Wise", + "Dark", + "Bright", + "Ancient", + "Brave", + "Mystic", + "Grand", + "Crimson", + "Azure", + "Ivory", + "Obsidian", + "Emerald", + "Sapphire", + "Ruby", + "Phantom", + "Eternal", + "Secret", + "Hidden", + "Blazing", + ) + + // Chess pieces and related nouns + private val chessNouns = + listOf( + "King", + "Queen", + "Rook", + "Bishop", + "Knight", + "Pawn", + "Castle", + "Crown", + "Throne", + "Gambit", + "Defense", + "Attack", + "Checkmate", + "Fortress", + "Tower", + "Endgame", + "Opening", + "Sacrifice", + "Pin", + "Fork", + "Strategy", + "Victory", + "Battle", + "Duel", + "Match", + "Challenge", + ) + + // Famous chess-related animals/themes + private val animals = + listOf( + "Dragon", + "Phoenix", + "Griffin", + "Eagle", + "Lion", + "Wolf", + "Tiger", + "Falcon", + "Hawk", + "Raven", + "Bear", + "Stallion", + "Serpent", + "Panther", + "Cobra", + ) + + /** + * Generate a random chess game name. + * + * @param includeNumber Whether to append a random number (1-99) + * @return A name like "Bold Knight" or "Swift Dragon 42" + */ + fun generate(includeNumber: Boolean = false): String { + val adjective = adjectives.random() + val noun = if (Random.nextBoolean()) chessNouns.random() else animals.random() + val base = "$adjective $noun" + + return if (includeNumber) { + val num = Random.nextInt(1, 100) + "$base $num" + } else { + base + } + } + + /** + * Generate a name based on two player display names. + * + * Creates a name that incorporates elements from both players + * for a more personalized game name. + * + * @param playerName First player's display name (can be npub/pubkey) + * @param opponentName Second player's display name (can be npub/pubkey, or null for open challenge) + * @return A personalized game name + */ + fun generateFromPlayers( + playerName: String?, + opponentName: String?, + ): String { + // Extract first letter or use adjective if name is a pubkey/npub + val p1Initial = extractInitial(playerName) + val p2Initial = opponentName?.let { extractInitial(it) } + + // If we have good initials, use them in the name + val adjective = + if (p1Initial != null) { + // Try to find an adjective starting with that letter + adjectives.find { it.startsWith(p1Initial, ignoreCase = true) } + ?: adjectives.random() + } else { + adjectives.random() + } + + val noun = + if (p2Initial != null) { + // Try to find a noun starting with opponent's initial + (chessNouns + animals).find { it.startsWith(p2Initial, ignoreCase = true) } + ?: if (Random.nextBoolean()) chessNouns.random() else animals.random() + } else { + // Open challenge - use "Challenge" or similar + listOf("Challenge", "Gambit", "Opening", "Battle", "Duel").random() + } + + return "$adjective $noun" + } + + /** + * Extract a usable initial from a name. + * Returns null if the name looks like a pubkey/npub. + */ + private fun extractInitial(name: String?): Char? { + if (name.isNullOrBlank()) return null + + // Skip if it looks like a hex pubkey or npub + if (name.length > 20 && name.all { it.isLetterOrDigit() }) return null + if (name.startsWith("npub") || name.startsWith("nprofile")) return null + + // Get first letter of the display name + val firstChar = name.firstOrNull { it.isLetter() } + return firstChar?.uppercaseChar() + } + + /** + * Generate a game ID that includes a readable component. + * + * @param timestamp Unix timestamp + * @return Game ID like "chess-1234567890-bold-knight" + */ + fun generateGameId(timestamp: Long): String { + val name = + generate(includeNumber = false) + .lowercase() + .replace(" ", "-") + return "chess-$timestamp-$name" + } + + /** + * Extract the human-readable name from a game ID. + * + * @param gameId Game ID like "chess-1234567890-bold-knight" + * @return Display name like "Bold Knight", or null if not a named game ID + */ + fun extractDisplayName(gameId: String): String? { + // Pattern: chess-timestamp-name-parts + if (!gameId.startsWith("chess-")) return null + + val parts = gameId.removePrefix("chess-").split("-") + if (parts.size < 2) return null + + // First part is timestamp, rest is the name + val nameParts = parts.drop(1) + if (nameParts.isEmpty()) return null + + // Check if it looks like a UUID (8 hex chars) - old format + if (nameParts.size == 1 && nameParts[0].length == 8 && nameParts[0].all { it.isLetterOrDigit() }) { + return null + } + + // Convert to title case + return nameParts.joinToString(" ") { part -> + part.replaceFirstChar { it.uppercaseChar() } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessMove.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessMove.kt new file mode 100644 index 000000000..e715719d5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessMove.kt @@ -0,0 +1,78 @@ +/* + * 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 + +/** + * Represents a single chess move in Standard Algebraic Notation (SAN) + * Per NIP-64, moves must comply with chess rules + */ +@Immutable +data class ChessMove( + val san: String, // Standard Algebraic Notation, e.g., "Nf3", "e4", "O-O", "Qxe5+" + val moveNumber: Int, // Which move in the game (1-based) + val color: Color, // Who made the move + val piece: PieceType = PieceType.PAWN, + val fromSquare: String? = null, // Source square if known + val toSquare: String, // Destination square + val isCapture: Boolean = false, + val isCheck: Boolean = false, + val isCheckmate: Boolean = false, + val isCastling: Boolean = false, + val promotion: PieceType? = null, +) { + override fun toString(): String = san +} + +/** + * Chess piece types per standard notation + */ +enum class PieceType( + val symbol: Char, +) { + KING('K'), + QUEEN('Q'), + ROOK('R'), + BISHOP('B'), + KNIGHT('N'), + PAWN('P'), + ; + + companion object { + fun fromSymbol(c: Char): PieceType? = entries.find { it.symbol == c.uppercaseChar() } + } +} + +/** + * Player color + */ +enum class Color { + WHITE, + BLACK, + ; + + fun opposite(): Color = + when (this) { + WHITE -> BLACK + BLACK -> WHITE + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessPosition.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessPosition.kt new file mode 100644 index 000000000..a31a49d8c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessPosition.kt @@ -0,0 +1,215 @@ +/* + * 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 + +/** + * Represents a chess position using 8x8 board array + * Coordinates: file (a-h) = columns 0-7, rank (1-8) = rows 0-7 + * a1 = [0,0], h1 = [7,0], a8 = [0,7], h8 = [7,7] + */ +@Immutable +data class ChessPosition( + private val board: Array>, + val activeColor: Color, + val moveNumber: Int, + val castlingRights: CastlingRights = CastlingRights(), + val enPassantSquare: String? = null, + val halfMoveClock: Int = 0, +) { + init { + require(board.size == 8) { "Board must be 8x8, got ${board.size} ranks" } + require(board.all { it.size == 8 }) { "Board must be 8x8, some ranks are not 8 files wide" } + } + + /** + * Get piece at square using algebraic notation (e.g., "e4") + */ + operator fun get(square: String): ChessPiece? { + val (file, rank) = parseSquare(square) + return board[rank][file] + } + + /** + * Get piece at coordinates (file: 0-7, rank: 0-7) + */ + fun pieceAt( + file: Int, + rank: Int, + ): ChessPiece? = board.getOrNull(rank)?.getOrNull(file) + + /** + * Create new position with a piece moved + */ + fun makeMove( + from: String, + to: String, + promotion: PieceType? = null, + ): ChessPosition { + val (fromFile, fromRank) = parseSquare(from) + val (toFile, toRank) = parseSquare(to) + + val newBoard = board.map { it.copyOf() }.toTypedArray() + val piece = newBoard[fromRank][fromFile] ?: throw IllegalArgumentException("No piece at $from") + + // Handle promotion + val finalPiece = + if (promotion != null && piece.type == PieceType.PAWN) { + piece.copy(type = promotion) + } else { + piece + } + + newBoard[toRank][toFile] = finalPiece + newBoard[fromRank][fromFile] = null + + return copy( + board = newBoard, + activeColor = activeColor.opposite(), + moveNumber = if (activeColor == Color.BLACK) moveNumber + 1 else moveNumber, + ) + } + + /** + * Create new position with castling + */ + fun makeCastlingMove(kingSide: Boolean): ChessPosition { + val rank = if (activeColor == Color.WHITE) 0 else 7 + val newBoard = board.map { it.copyOf() }.toTypedArray() + + if (kingSide) { + // King-side castling (O-O) + val king = newBoard[rank][4] + val rook = newBoard[rank][7] + newBoard[rank][6] = king + newBoard[rank][5] = rook + newBoard[rank][4] = null + newBoard[rank][7] = null + } else { + // Queen-side castling (O-O-O) + val king = newBoard[rank][4] + val rook = newBoard[rank][0] + newBoard[rank][2] = king + newBoard[rank][3] = rook + newBoard[rank][4] = null + newBoard[rank][0] = null + } + + return copy( + board = newBoard, + activeColor = activeColor.opposite(), + moveNumber = if (activeColor == Color.BLACK) moveNumber + 1 else moveNumber, + ) + } + + private fun parseSquare(square: String): Pair { + require(square.length == 2) { "Invalid square notation: $square" } + val file = square[0].lowercaseChar() - 'a' // 0-7 + val rank = square[1] - '1' // 0-7 + require(file in 0..7 && rank in 0..7) { "Square out of bounds: $square" } + return file to rank + } + + companion object { + /** + * Standard starting position per FIDE rules + */ + fun initial(): ChessPosition { + val board = Array(8) { Array(8) { null } } + + // White pieces (ranks 0-1) + board[0] = + arrayOf( + ChessPiece(PieceType.ROOK, Color.WHITE), + ChessPiece(PieceType.KNIGHT, Color.WHITE), + ChessPiece(PieceType.BISHOP, Color.WHITE), + ChessPiece(PieceType.QUEEN, Color.WHITE), + ChessPiece(PieceType.KING, Color.WHITE), + ChessPiece(PieceType.BISHOP, Color.WHITE), + ChessPiece(PieceType.KNIGHT, Color.WHITE), + ChessPiece(PieceType.ROOK, Color.WHITE), + ) + board[1] = Array(8) { ChessPiece(PieceType.PAWN, Color.WHITE) } + + // Black pieces (ranks 6-7) + board[6] = Array(8) { ChessPiece(PieceType.PAWN, Color.BLACK) } + board[7] = + arrayOf( + ChessPiece(PieceType.ROOK, Color.BLACK), + ChessPiece(PieceType.KNIGHT, Color.BLACK), + ChessPiece(PieceType.BISHOP, Color.BLACK), + ChessPiece(PieceType.QUEEN, Color.BLACK), + ChessPiece(PieceType.KING, Color.BLACK), + ChessPiece(PieceType.BISHOP, Color.BLACK), + ChessPiece(PieceType.KNIGHT, Color.BLACK), + ChessPiece(PieceType.ROOK, Color.BLACK), + ) + + return ChessPosition( + board = board, + activeColor = Color.WHITE, + moveNumber = 1, + ) + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ChessPosition) return false + return board.contentDeepEquals(other.board) && + activeColor == other.activeColor && + moveNumber == other.moveNumber && + castlingRights == other.castlingRights && + enPassantSquare == other.enPassantSquare && + halfMoveClock == other.halfMoveClock + } + + override fun hashCode(): Int { + var result = board.contentDeepHashCode() + result = 31 * result + activeColor.hashCode() + result = 31 * result + moveNumber + result = 31 * result + castlingRights.hashCode() + result = 31 * result + (enPassantSquare?.hashCode() ?: 0) + result = 31 * result + halfMoveClock + return result + } +} + +/** + * Represents a chess piece + */ +@Immutable +data class ChessPiece( + val type: PieceType, + val color: Color, +) + +/** + * Castling availability + */ +@Immutable +data class CastlingRights( + val whiteKingSide: Boolean = true, + val whiteQueenSide: Boolean = true, + val blackKingSide: Boolean = true, + val blackQueenSide: Boolean = true, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt new file mode 100644 index 000000000..65849aa77 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructor.kt @@ -0,0 +1,304 @@ +/* + * 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 + +/** + * Deterministic chess state reconstruction from Jester protocol events. + * + * This is the single source of truth for converting a collection of Jester events + * into a consistent game state. Both Android and Desktop platforms must use this + * algorithm to ensure identical state from the same events. + * + * Jester Protocol Reconstruction: + * 1. Find start event (content.kind=0) → establishes startEventId, challenger color, players + * 2. Find move with longest history → this is the current state + * 3. Replay moves from history to build engine state + * 4. Check for result/termination in latest move → mark finished if present + * + * Key Jester differences: + * - No separate accept event (acceptance is implicit) + * - Full move history in every move event + * - Can reconstruct from any single move (has complete history) + * - startEventId is the game identifier (event ID of start event) + * + * Guarantees: + * - Same events always produce same state (deterministic) + * - Uses longest history for authoritative state + * - Invalid moves are skipped without error + */ +object ChessStateReconstructor { + /** + * Reconstruct game state from a collection of Jester events. + * + * @param events The collected Jester events for a single game + * @param viewerPubkey The pubkey of the user viewing the game (determines perspective) + * @return ReconstructionResult or error if reconstruction fails + */ + fun reconstruct( + events: JesterGameEvents, + viewerPubkey: String, + ): ReconstructionResult { + // Step 1: Get start event (required for player info) + val startEvent = + events.startEvent + ?: return ReconstructionResult.Error("No start event found") + + val startEventId = startEvent.id + val challengerPubkey = startEvent.pubKey + val challengerColor = startEvent.playerColor() ?: Color.WHITE + val challengedPubkey = startEvent.opponentPubkey() + + // In Jester, we determine opponent from the p-tag or from move events + // For open challenges, we need to find who made the first move + val opponentFromMoves = + events.moves + .firstOrNull { it.pubKey != challengerPubkey } + ?.pubKey + + val actualOpponent = challengedPubkey ?: opponentFromMoves + + // Determine players based on challenger's color choice + val (whitePubkey, blackPubkey) = + if (challengerColor == Color.WHITE) { + challengerPubkey to actualOpponent + } else { + actualOpponent to challengerPubkey + } + + // Determine viewer's role + val viewerRole = + when (viewerPubkey) { + whitePubkey -> ViewerRole.WHITE_PLAYER + blackPubkey -> ViewerRole.BLACK_PLAYER + else -> ViewerRole.SPECTATOR + } + + val playerColor = + when (viewerRole) { + ViewerRole.WHITE_PLAYER -> Color.WHITE + ViewerRole.BLACK_PLAYER -> Color.BLACK + ViewerRole.SPECTATOR -> Color.WHITE // Spectators see from white's perspective + } + + val opponentPubkey = + when (viewerRole) { + ViewerRole.WHITE_PLAYER -> blackPubkey ?: "" + ViewerRole.BLACK_PLAYER -> whitePubkey ?: "" + ViewerRole.SPECTATOR -> blackPubkey ?: "" // For spectators, "opponent" is black + } + + // Check if game is pending (no moves yet AND viewer is the challenger) + // If viewer accepted someone else's challenge, the game is NOT pending + val isChallenger = viewerPubkey == startEvent.pubKey + val isPendingChallenge = events.moves.isEmpty() && isChallenger + + // Step 2: Create engine and apply moves from the longest history + val engine = ChessEngine() + val latestMove = events.latestMove() + val history = latestMove?.history() ?: emptyList() + + // Track applied moves + val appliedMoveNumbers = mutableSetOf() + var isDesynced = false + + // Apply moves from history + for ((index, san) in history.withIndex()) { + val moveNumber = index + 1 + val result = engine.makeMove(san) + if (result.success) { + appliedMoveNumbers.add(moveNumber) + } else { + // Move failed - game might be desynced + isDesynced = true + // Try to recover by loading the FEN from latest move if available + latestMove?.fen()?.let { fen -> + engine.loadFen(fen) + } + break + } + } + + // Verify final position matches if we have FEN + latestMove?.fen()?.let { expectedFen -> + val currentFen = engine.getFen() + if (!fenPositionsMatch(currentFen, expectedFen)) { + isDesynced = true + engine.loadFen(expectedFen) + } + } + + // Step 3: Check game end status + val gameResult = latestMove?.result() + val gameStatus = + when { + gameResult != null -> { + val result = parseGameResult(gameResult) + GameStatus.Finished(result) + } + + engine.isCheckmate() -> { + val winner = engine.getSideToMove().opposite() + GameStatus.Finished( + if (winner == Color.WHITE) GameResult.WHITE_WINS else GameResult.BLACK_WINS, + ) + } + + engine.isStalemate() -> { + GameStatus.Finished(GameResult.DRAW) + } + + else -> { + GameStatus.InProgress + } + } + + // Get the head event ID (for linking next move) + val headEventId = latestMove?.id ?: startEventId + + // Build the reconstructed state + val state = + ReconstructedGameState( + startEventId = startEventId, + headEventId = headEventId, + whitePubkey = whitePubkey, + blackPubkey = blackPubkey, + viewerRole = viewerRole, + playerColor = playerColor, + opponentPubkey = opponentPubkey, + isPendingChallenge = isPendingChallenge, + currentPosition = engine.getPosition(), + moveHistory = engine.getMoveHistory(), + gameStatus = gameStatus, + isDesynced = isDesynced, + pendingDrawOffer = null, // Jester doesn't have explicit draw offers + appliedMoveNumbers = appliedMoveNumbers, + challengeCreatedAt = startEvent.createdAt, + timeControl = null, // Not supported in Jester + ) + + return ReconstructionResult.Success(state, engine) + } + + /** + * Compare FEN positions, ignoring halfmove clock and fullmove number. + * This provides a more lenient comparison that focuses on the actual board state. + */ + private fun fenPositionsMatch( + fen1: String, + fen2: String, + ): Boolean { + // FEN format: position activeColor castling enPassant halfmove fullmove + // Compare only first 4 parts (position, activeColor, castling, enPassant) + val parts1 = fen1.split(" ") + val parts2 = fen2.split(" ") + + if (parts1.size < 4 || parts2.size < 4) { + return fen1 == fen2 // Fallback to exact match + } + + return parts1[0] == parts2[0] && // Board position + parts1[1] == parts2[1] && // Active color + parts1[2] == parts2[2] && // Castling rights + parts1[3] == parts2[3] // En passant + } + + private fun parseGameResult(result: String?): GameResult = + when (result) { + "1-0" -> GameResult.WHITE_WINS + "0-1" -> GameResult.BLACK_WINS + "1/2-1/2" -> GameResult.DRAW + else -> GameResult.IN_PROGRESS + } +} + +/** + * The viewer's role in the game. + */ +enum class ViewerRole { + WHITE_PLAYER, + BLACK_PLAYER, + SPECTATOR, +} + +/** + * Result of game state reconstruction. + */ +sealed class ReconstructionResult { + data class Success( + val state: ReconstructedGameState, + val engine: ChessEngine, + ) : ReconstructionResult() + + data class Error( + val message: String, + ) : ReconstructionResult() + + fun isSuccess(): Boolean = this is Success + + fun getOrNull(): ReconstructedGameState? = (this as? Success)?.state + + fun getEngineOrNull(): ChessEngine? = (this as? Success)?.engine +} + +/** + * Reconstructed game state from Jester events. + * This is an immutable snapshot of the game at the time of reconstruction. + */ +@Immutable +data class ReconstructedGameState( + /** The start event ID - this is the game identifier in Jester protocol */ + val startEventId: String, + /** The current head event ID - used for linking the next move */ + val headEventId: String, + val whitePubkey: String?, + val blackPubkey: String?, + val viewerRole: ViewerRole, + val playerColor: Color, + val opponentPubkey: String, + val isPendingChallenge: Boolean, + val currentPosition: ChessPosition, + val moveHistory: List, + val gameStatus: GameStatus, + val isDesynced: Boolean, + val pendingDrawOffer: String?, + val appliedMoveNumbers: Set, + val challengeCreatedAt: Long, + val timeControl: String?, +) { + /** Legacy alias for startEventId */ + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId + + fun isPlayerTurn(): Boolean = + when (viewerRole) { + ViewerRole.SPECTATOR -> false + ViewerRole.WHITE_PLAYER -> currentPosition.activeColor == Color.WHITE + ViewerRole.BLACK_PLAYER -> currentPosition.activeColor == Color.BLACK + } + + fun isFinished(): Boolean = gameStatus is GameStatus.Finished + + fun hasOpponentDrawOffer(playerPubkey: String): Boolean = pendingDrawOffer != null && pendingDrawOffer != playerPubkey + + fun hasOurDrawOffer(playerPubkey: String): Boolean = pendingDrawOffer == playerPubkey +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEvents.kt new file mode 100644 index 000000000..32ec8c390 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEvents.kt @@ -0,0 +1,382 @@ +/* + * 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.Event +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.utils.TimeUtils +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +/** + * Jester Protocol Implementation + * + * Compatible with jesterui (https://github.com/jesterui/jesterui) + * + * Key differences from previous implementation: + * - Single event kind (30) for all chess messages + * - Content is JSON with: version, kind, fen, move, history, nonce + * - Event linking via e-tags: [startId] or [startId, headId] + * - Full move history included in every move event + * + * Content kind values: + * - 0: Game start (challenge) + * - 1: Move + * - 2: Chat (not implemented) + * + * Reference: https://github.com/jesterui/jesterui/blob/devel/FLOW.md + */ +object JesterProtocol { + /** Jester uses kind 30 for all chess events */ + const val KIND = 30 + + /** SHA256 of starting FEN position - used as reference for game discovery */ + const val START_POSITION_HASH = "b1791d7fc9ae3d38966568c257ffb3a02cbf8394cdb4805bc70f64fc3c0b6879" + + /** Standard starting FEN */ + const val FEN_START = "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1" + + /** Content kind for game start */ + const val CONTENT_KIND_START = 0 + + /** Content kind for move */ + const val CONTENT_KIND_MOVE = 1 + + /** Content kind for chat */ + const val CONTENT_KIND_CHAT = 2 +} + +/** + * JSON content structure for Jester events + */ +@Serializable +data class JesterContent( + val version: String = "0", + val kind: Int, + val fen: String = JesterProtocol.FEN_START, + val move: String? = null, + val history: List = emptyList(), + val nonce: String? = null, + // Extended fields for Amethyst (backward compatible - jesterui ignores unknown fields) + val playerColor: String? = null, // "white" or "black" - challenger's color choice + val result: String? = null, // "1-0", "0-1", "1/2-1/2" for game end + val termination: String? = null, // "checkmate", "resignation", "draw_agreement", etc. +) + +private val json = + Json { + ignoreUnknownKeys = true + encodeDefaults = false + } + +/** + * Jester Chess Event (Kind 30) + * + * Unified event class for all Jester chess messages. + * The content.kind field determines the event type: + * - 0: Game start/challenge + * - 1: Move + * - 2: Chat + */ +@Immutable +class JesterEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, JesterProtocol.KIND, tags, content, sig) { + private val parsedContent: JesterContent? by lazy { + try { + json.decodeFromString(content) + } catch (e: Exception) { + null + } + } + + /** Get the content kind (0=start, 1=move, 2=chat) */ + fun contentKind(): Int? = parsedContent?.kind + + /** Check if this is a game start event */ + fun isStartEvent(): Boolean = parsedContent?.kind == JesterProtocol.CONTENT_KIND_START + + /** Check if this is a move event */ + fun isMoveEvent(): Boolean = parsedContent?.kind == JesterProtocol.CONTENT_KIND_MOVE + + /** Get the FEN position */ + fun fen(): String? = parsedContent?.fen + + /** Get the latest move (SAN notation) */ + fun move(): String? = parsedContent?.move + + /** Get the full move history */ + fun history(): List = parsedContent?.history ?: emptyList() + + /** Get the nonce (for start events) */ + fun nonce(): String? = parsedContent?.nonce + + /** Get the player color choice (for start events) */ + fun playerColor(): Color? = + parsedContent?.playerColor?.let { + if (it == "white") Color.WHITE else Color.BLACK + } + + /** Get the game result (for end events) */ + fun result(): String? = parsedContent?.result + + /** Get the termination reason (for end events) */ + fun termination(): String? = parsedContent?.termination + + /** Get the start event ID (first e-tag) */ + fun startEventId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) + + /** Get the head/parent move ID (second e-tag, for moves) */ + fun headEventId(): String? = tags.filter { it.size >= 2 && it[0] == "e" }.getOrNull(1)?.get(1) + + /** Get opponent pubkey (p-tag, for private games) */ + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + /** Get all e-tags */ + fun eTags(): List = tags.filter { it.size >= 2 && it[0] == "e" }.map { it[1] } + + companion object { + const val KIND = JesterProtocol.KIND + + /** + * Build a game start event (open challenge) + * + * @param playerColor Color the challenger wants to play + * @param nonce Unique nonce for this game + * @param createdAt Event timestamp + */ + fun buildStart( + playerColor: Color, + nonce: String = generateNonce(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val content = + JesterContent( + kind = JesterProtocol.CONTENT_KIND_START, + fen = JesterProtocol.FEN_START, + history = emptyList(), + nonce = nonce, + playerColor = if (playerColor == Color.WHITE) "white" else "black", + ) + return eventTemplate(KIND, json.encodeToString(content), createdAt) { + // Reference the standard start position for game discovery + add(arrayOf("e", JesterProtocol.START_POSITION_HASH)) + initializer() + } + } + + /** + * Build a private game start event (direct challenge) + * + * @param opponentPubkey Pubkey of the challenged player + * @param playerColor Color the challenger wants to play + * @param nonce Unique nonce for this game + * @param createdAt Event timestamp + */ + fun buildPrivateStart( + opponentPubkey: String, + playerColor: Color, + nonce: String = generateNonce(), + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val content = + JesterContent( + kind = JesterProtocol.CONTENT_KIND_START, + fen = JesterProtocol.FEN_START, + history = emptyList(), + nonce = nonce, + playerColor = if (playerColor == Color.WHITE) "white" else "black", + ) + return eventTemplate(KIND, json.encodeToString(content), createdAt) { + // Reference the standard start position + add(arrayOf("e", JesterProtocol.START_POSITION_HASH)) + // Tag the opponent for notifications (only if valid pubkey) + if (opponentPubkey.isNotEmpty() && opponentPubkey.length == 64) { + add(arrayOf("p", opponentPubkey)) + } + initializer() + } + } + + /** + * Build a move event + * + * @param startEventId ID of the game start event + * @param headEventId ID of the previous move (or start event for first move) + * @param move The move in SAN notation (e.g., "e4", "Nf3") + * @param fen Resulting board position in FEN + * @param history Complete move history including this move + * @param opponentPubkey Opponent's pubkey for notifications + * @param createdAt Event timestamp + */ + fun buildMove( + startEventId: String, + headEventId: String, + move: String, + fen: String, + history: List, + opponentPubkey: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val content = + JesterContent( + kind = JesterProtocol.CONTENT_KIND_MOVE, + fen = fen, + move = move, + history = history, + ) + return eventTemplate(KIND, json.encodeToString(content), createdAt) { + // e-tags: [startId, headId] + add(arrayOf("e", startEventId)) + add(arrayOf("e", headEventId)) + // Tag opponent for notifications (only if valid pubkey) + if (opponentPubkey.isNotEmpty() && opponentPubkey.length == 64) { + add(arrayOf("p", opponentPubkey)) + } + initializer() + } + } + + /** + * Build a game end move (includes result in content) + * + * This is a move event with additional result/termination fields. + * Used when the game ends (checkmate, resignation, draw). + */ + fun buildEndMove( + startEventId: String, + headEventId: String, + move: String?, + fen: String, + history: List, + opponentPubkey: String, + result: GameResult, + termination: GameTermination, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val content = + JesterContent( + kind = JesterProtocol.CONTENT_KIND_MOVE, + fen = fen, + move = move, + history = history, + result = result.notation, + termination = termination.name.lowercase(), + ) + return eventTemplate(KIND, json.encodeToString(content), createdAt) { + add(arrayOf("e", startEventId)) + add(arrayOf("e", headEventId)) + // Tag opponent for notifications (only if valid pubkey) + if (opponentPubkey.isNotEmpty() && opponentPubkey.length == 64) { + add(arrayOf("p", opponentPubkey)) + } + initializer() + } + } + + private fun generateNonce(): String { + val chars = "abcdefghijklmnopqrstuvwxyz0123456789" + return (1..8).map { chars.random() }.joinToString("") + } + } +} + +/** + * Helper to check if an event is a Jester chess event + */ +fun Event.isJesterEvent(): Boolean = kind == JesterProtocol.KIND + +/** + * Helper to check if an event is a Jester start event + */ +fun Event.isJesterStartEvent(): Boolean { + if (kind != JesterProtocol.KIND) return false + return try { + val content = json.decodeFromString(this.content) + content.kind == JesterProtocol.CONTENT_KIND_START && content.history.isEmpty() + } catch (e: Exception) { + false + } +} + +/** + * Helper to check if an event is a Jester move event + */ +fun Event.isJesterMoveEvent(): Boolean { + if (kind != JesterProtocol.KIND) return false + return try { + val content = json.decodeFromString(this.content) + content.kind == JesterProtocol.CONTENT_KIND_MOVE && + content.history.isNotEmpty() && + tags.count { it.size >= 2 && it[0] == "e" } == 2 + } catch (e: Exception) { + false + } +} + +/** + * Convert a raw Event to JesterEvent if valid + */ +fun Event.toJesterEvent(): JesterEvent? { + if (kind != JesterProtocol.KIND) return null + return JesterEvent(id, pubKey, createdAt, tags, content, sig) +} + +/** + * Game events container for Jester protocol + */ +data class JesterGameEvents( + val startEvent: JesterEvent?, + val moves: List, +) { + companion object { + fun empty() = JesterGameEvents(null, emptyList()) + } + + /** Get the latest move (with longest history) */ + fun latestMove(): JesterEvent? = moves.maxByOrNull { it.history().size } + + /** Get the current FEN position */ + fun currentFen(): String = latestMove()?.fen() ?: startEvent?.fen() ?: JesterProtocol.FEN_START + + /** Get the complete move history */ + fun fullHistory(): List = latestMove()?.history() ?: emptyList() + + /** Check if game has ended */ + fun isEnded(): Boolean = latestMove()?.result() != null + + /** Get the game result if ended */ + fun result(): String? = latestMove()?.result() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessEvents.kt new file mode 100644 index 000000000..0c2119a51 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessEvents.kt @@ -0,0 +1,224 @@ +/* + * 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 + +/* + * Live chess game state and move events + * + * Uses Jester protocol (kind 30) for compatibility with jesterui. + * See: https://github.com/jesterui/jesterui/blob/devel/FLOW.md + * + * Key features of Jester protocol: + * - Single event kind (30) for all chess messages + * - Content is JSON with: version, kind (0=start, 1=move), fen, move, history + * - Full move history included in every move event + * - Event linking via e-tags: [startId] or [startId, headId] + * + * This enables: + * - Easy game reconstruction from any single move event + * - Tolerance for missing intermediate moves + * - Compatibility with jesterui and other Jester clients + */ + +/** + * Game start/challenge data for publishing + * + * Jester protocol (kind 30) start event: + * - e-tag: reference to standard start position hash + * - p-tag: opponent pubkey (for private/direct challenges) + * - Content JSON with: version, kind=0, fen, history=[], nonce, playerColor + * + * @param opponentPubkey Opponent's pubkey for direct challenge (null = open challenge) + * @param playerColor Color the challenger wants to play + * @param nonce Unique identifier for this game + */ +data class ChessGameChallenge( + val opponentPubkey: String? = null, // null = open challenge + val playerColor: Color, + val nonce: String = generateNonce(), +) { + companion object { + private fun generateNonce(): String { + val chars = "abcdefghijklmnopqrstuvwxyz0123456789" + return (1..8).map { chars.random() }.joinToString("") + } + } + + // Legacy compatibility - gameId is now derived from start event ID after signing + @Deprecated("gameId is determined after event creation", ReplaceWith("startEventId")) + val gameId: String get() = nonce +} + +/** + * Game acceptance data + * + * In Jester protocol, accepting a game is implicit - the opponent + * simply makes the first move (if challenger chose white) or waits + * for challenger's first move (if challenger chose black). + * + * This data class is kept for internal state tracking. + * + * @param startEventId ID of the game start event to accept + * @param challengerPubkey Pubkey of the challenger + */ +data class ChessGameAccept( + val startEventId: String, + val challengerPubkey: String, +) { + // Legacy compatibility + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId + + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val challengeEventId: String get() = startEventId +} + +/** + * Chess move data for publishing + * + * Jester protocol (kind 30) requires: + * - Full move history in every move event + * - e-tags linking: [startEventId, headEventId] + * - Content JSON with: version, kind=1, fen, move, history + * + * @param startEventId ID of the game start event + * @param headEventId ID of the previous move (or startEventId for first move) + * @param san Move in Standard Algebraic Notation (e.g., "e4", "Nf3") + * @param fen Resulting board position in FEN notation + * @param history Complete move history including this move + * @param opponentPubkey Opponent's pubkey for p-tag notification + */ +data class ChessMoveEvent( + val startEventId: String, + val headEventId: String, + val san: String, + val fen: String, + val history: List, + val opponentPubkey: String, +) { + /** Move number (1-based, derived from history length) */ + val moveNumber: Int get() = history.size + + // Legacy compatibility - some code still uses gameId + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId +} + +/** + * Game end data for publishing + * + * In Jester protocol, game end is a move event with result/termination + * fields in the content JSON. + * + * @param startEventId ID of the game start event + * @param headEventId ID of the previous move + * @param lastMove The final move (null for resignation without move) + * @param fen Final board position + * @param history Complete move history + * @param result Game result (1-0, 0-1, 1/2-1/2) + * @param termination How the game ended + * @param opponentPubkey Opponent's pubkey for notification + */ +data class ChessGameEnd( + val startEventId: String, + val headEventId: String, + val lastMove: String?, + val fen: String, + val history: List, + val result: GameResult, + val termination: GameTermination, + val opponentPubkey: String, +) { + // Legacy compatibility + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId + + val winnerPubkey: String? get() = null // Determined from result +} + +/** + * Draw offer data + * + * In Jester protocol, draw offers can be communicated via: + * - Chat message (kind=2 in content) + * - Special field in move content + * + * Opponent can: + * - Accept by sending a game end with DRAW_AGREEMENT + * - Decline implicitly by making their next move + * + * @param startEventId ID of the game start event + * @param headEventId ID of the current head move + * @param opponentPubkey Opponent's pubkey for notification + * @param message Optional message with the draw offer + */ +data class ChessDrawOffer( + val startEventId: String, + val headEventId: String, + val opponentPubkey: String, + val message: String? = null, +) { + // Legacy compatibility + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId +} + +/** + * Game termination reason + */ +enum class GameTermination { + CHECKMATE, + RESIGNATION, + DRAW_AGREEMENT, + STALEMATE, + TIMEOUT, + ABANDONMENT, +} + +/** + * Event kind constants for live chess (Jester protocol) + * + * Jester uses a single kind (30) for all chess events. + * The event type is determined by the content.kind field: + * - 0: Game start/challenge + * - 1: Move + * - 2: Chat + */ +object LiveChessEventKinds { + /** Jester protocol uses kind 30 for all chess events */ + const val JESTER = 30 + + // Legacy constants - deprecated, use JESTER instead + @Deprecated("Jester uses single kind 30", ReplaceWith("JESTER")) + const val GAME_CHALLENGE = 30 + + @Deprecated("Jester uses single kind 30", ReplaceWith("JESTER")) + const val GAME_ACCEPT = 30 + + @Deprecated("Jester uses single kind 30", ReplaceWith("JESTER")) + const val CHESS_MOVE = 30 + + @Deprecated("Jester uses single kind 30", ReplaceWith("JESTER")) + const val GAME_END = 30 + + @Deprecated("Jester uses single kind 30", ReplaceWith("JESTER")) + const val DRAW_OFFER = 30 +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt new file mode 100644 index 000000000..4381f832c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameEvents.kt @@ -0,0 +1,295 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip64Chess + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Live Chess Game Challenge Event (Kind 30064) + * + * Challenge another player to a chess game or create an open challenge. + * This is a parameterized replaceable event. + * + * Tags: + * - d: game_id (unique identifier for this game) + * - p: opponent pubkey (optional, for direct challenges) + * - player_color: "white" or "black" + * - time_control: optional time control (e.g., "10+0", "5+3") + */ +@Immutable +class LiveChessGameChallengeEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30064 + + fun build( + gameId: String, + playerColor: Color, + opponentPubkey: String? = null, + timeControl: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + add(arrayOf("d", gameId)) + add(arrayOf("player_color", if (playerColor == Color.WHITE) "white" else "black")) + opponentPubkey?.let { add(arrayOf("p", it)) } + timeControl?.let { add(arrayOf("time_control", it)) } + alt("Chess game challenge") + initializer() + } + } + + fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) + + fun playerColor(): Color? = + tags + .firstOrNull { it.size >= 2 && it[0] == "player_color" } + ?.get(1) + ?.let { if (it == "white") Color.WHITE else Color.BLACK } + + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + fun timeControl(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "time_control" }?.get(1) +} + +/** + * Live Chess Game Accept Event (Kind 30065) + * + * Accept a chess game challenge + * + * Tags: + * - d: game_id (same as challenge) + * - e: challenge event ID + * - p: challenger pubkey + */ +@Immutable +class LiveChessGameAcceptEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30065 + + fun build( + gameId: String, + challengeEventId: String, + challengerPubkey: String, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + add(arrayOf("d", gameId)) + add(arrayOf("e", challengeEventId)) + add(arrayOf("p", challengerPubkey)) + alt("Chess game acceptance") + initializer() + } + } + + fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) + + fun challengeEventId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "e" }?.get(1) + + fun challengerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) +} + +/** + * Live Chess Move Event (Kind 30066) + * + * Individual move in a live chess game + * + * Tags: + * - d: game_id + * - move_number: move number (1-based) + * - san: move in Standard Algebraic Notation + * - fen: resulting position in FEN notation + * - p: opponent pubkey + * + * Content: Optional move comment + */ +@Immutable +class LiveChessMoveEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30066 + + fun build( + gameId: String, + moveNumber: Int, + san: String, + fen: String, + opponentPubkey: String, + comment: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, comment, createdAt) { + add(arrayOf("d", "$gameId-$moveNumber")) + add(arrayOf("game_id", gameId)) + add(arrayOf("move_number", moveNumber.toString())) + add(arrayOf("san", san)) + add(arrayOf("fen", fen)) + add(arrayOf("p", opponentPubkey)) + alt("Chess move: $san") + initializer() + } + } + + fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "game_id" }?.get(1) + + fun moveNumber(): Int? = + tags + .firstOrNull { it.size >= 2 && it[0] == "move_number" } + ?.get(1) + ?.toIntOrNull() + + fun san(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "san" }?.get(1) + + fun fen(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "fen" }?.get(1) + + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + fun comment(): String = content +} + +/** + * Live Chess Game End Event (Kind 30067) + * + * Game result and termination + * + * Tags: + * - d: game_id + * - result: "1-0"|"0-1"|"1/2-1/2" + * - termination: reason for game end + * - winner: pubkey of winner (if applicable) + * - p: opponent pubkey + * + * Content: Optional PGN of complete game + */ +@Immutable +class LiveChessGameEndEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30067 + + fun build( + gameId: String, + result: GameResult, + termination: GameTermination, + winnerPubkey: String? = null, + opponentPubkey: String, + pgn: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, pgn, createdAt) { + add(arrayOf("d", gameId)) + add(arrayOf("result", result.notation)) + add(arrayOf("termination", termination.name.lowercase())) + winnerPubkey?.let { add(arrayOf("winner", it)) } + add(arrayOf("p", opponentPubkey)) + alt("Chess game ended: ${result.notation}") + initializer() + } + } + + fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) + + fun result(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "result" }?.get(1) + + fun termination(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "termination" }?.get(1) + + fun winnerPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "winner" }?.get(1) + + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + fun pgn(): String = content +} + +/** + * Live Chess Draw Offer Event (Kind 30068) + * + * Offer a draw to opponent. Opponent can accept by sending a game end event + * with DRAW_AGREEMENT termination, or decline/ignore by making their next move. + * + * Tags: + * - d: game_id + * - p: opponent pubkey + * + * Content: Optional message + */ +@Immutable +class LiveChessDrawOfferEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 30068 + + fun build( + gameId: String, + opponentPubkey: String, + message: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, message, createdAt) { + add(arrayOf("d", gameId)) + add(arrayOf("p", opponentPubkey)) + alt("Chess draw offer") + initializer() + } + } + + fun gameId(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "d" }?.get(1) + + fun opponentPubkey(): String? = tags.firstOrNull { it.size >= 2 && it[0] == "p" }?.get(1) + + fun message(): String = content +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt new file mode 100644 index 000000000..7e0daed77 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/LiveChessGameState.kt @@ -0,0 +1,548 @@ +/* + * 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 com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Manages state for a live chess game + * + * This class coordinates between: + * - ChessEngine (move validation, board state) + * - Nostr events (publishing moves, receiving opponent moves) + * - UI (game state, turn management) + * + * Jester Protocol Notes: + * - startEventId: ID of the game start event (used as game identifier) + * - headEventId: ID of the current head move (updated after each move) + * - Full move history is included in every published move event + */ +class LiveChessGameState( + /** Start event ID - the game identifier in Jester protocol */ + val startEventId: String, + val playerPubkey: String, + val opponentPubkey: String, + val playerColor: Color, + val engine: ChessEngine, + val createdAt: Long = TimeUtils.now(), + val isSpectator: Boolean = false, + val isPendingChallenge: Boolean = false, + /** Initial head event ID - same as startEventId for new games */ + initialHeadEventId: String? = null, +) { + /** Legacy alias for startEventId */ + @Deprecated("Use startEventId instead", ReplaceWith("startEventId")) + val gameId: String get() = startEventId + + /** Current head event ID - tracks the most recent move for e-tag linking */ + private val _headEventId = MutableStateFlow(initialHeadEventId ?: startEventId) + val headEventId: StateFlow = _headEventId.asStateFlow() + + companion object { + // Abandon timeout: 24 hours without a move + const val ABANDON_TIMEOUT_SECONDS = 24 * 60 * 60L + + // Inactivity warning: 1 hour without a move + const val INACTIVITY_WARNING_SECONDS = 60 * 60L + } + + private val _gameStatus = MutableStateFlow(GameStatus.InProgress) + val gameStatus: StateFlow = _gameStatus.asStateFlow() + + private val _currentPosition = MutableStateFlow(engine.getPosition()) + val currentPosition: StateFlow = _currentPosition.asStateFlow() + + // Initialize from engine's history so freshly loaded games show all moves + private val _moveHistory = MutableStateFlow(engine.getMoveHistory()) + val moveHistory: StateFlow> = _moveHistory.asStateFlow() + + private val _lastError = MutableStateFlow(null) + val lastError: StateFlow = _lastError.asStateFlow() + + // Track when last activity occurred (move made or received) + private val _lastActivityAt = MutableStateFlow(createdAt) + val lastActivityAt: StateFlow = _lastActivityAt.asStateFlow() + + // Track received move numbers to detect duplicates and out-of-order + private val receivedMoveNumbers = mutableSetOf() + + // Pending moves waiting for earlier moves (move number -> move data) + private val pendingMoves = mutableMapOf>() + + // Desync detection + private val _isDesynced = MutableStateFlow(false) + val isDesynced: StateFlow = _isDesynced.asStateFlow() + + // Draw offer tracking - pubkey of who offered the draw (null = no pending offer) + private val _pendingDrawOffer = MutableStateFlow(null) + val pendingDrawOffer: StateFlow = _pendingDrawOffer.asStateFlow() + + /** + * Check if there's a pending draw offer from opponent + */ + fun hasOpponentDrawOffer(): Boolean = _pendingDrawOffer.value == opponentPubkey + + /** + * Check if we have an outgoing draw offer + */ + fun hasOurDrawOffer(): Boolean = _pendingDrawOffer.value == playerPubkey + + /** + * Check if it's the player's turn + * Spectators never have a turn + */ + fun isPlayerTurn(): Boolean = !isSpectator && engine.getSideToMove() == playerColor + + /** + * Make a move (called when player makes a move on the board) + * Returns the move event data to be published to Nostr + * + * Jester Protocol: Move events include full history and link to previous event + */ + fun makeMove( + from: String, + to: String, + promotion: PieceType? = null, + ): ChessMoveEvent? { + if (!isPlayerTurn()) { + _lastError.value = "Not your turn" + return null + } + + if (_gameStatus.value != GameStatus.InProgress) { + _lastError.value = "Game is not in progress" + return null + } + + val result = engine.makeMove(from, to, promotion) + + if (result.success && result.san != null && result.position != null) { + _currentPosition.value = result.position + _moveHistory.value = engine.getMoveHistory() + _lastActivityAt.value = TimeUtils.now() + _lastError.value = null + + // Making a move implicitly declines any pending draw offer + _pendingDrawOffer.value = null + + // Check for game end conditions + checkGameEnd() + + // Return move event data with full history for Jester protocol + return ChessMoveEvent( + startEventId = startEventId, + headEventId = _headEventId.value, + san = result.san, + fen = engine.getFen(), + history = _moveHistory.value, // Full history for Jester + opponentPubkey = opponentPubkey, + ) + } else { + _lastError.value = result.error ?: "Invalid move" + return null + } + } + + /** + * Update the head event ID after a move is successfully published. + * Call this after the move event is signed and broadcast. + * + * @param newHeadEventId The ID of the newly published move event + */ + fun updateHeadEventId(newHeadEventId: String) { + _headEventId.value = newHeadEventId + } + + /** + * Undo the last move made. + * Used to revert a move when publishing fails. + * + * @return true if the move was successfully undone, false if there was nothing to undo + */ + fun undoLastMove(): Boolean { + if (_moveHistory.value.isEmpty()) return false + + engine.undoMove() + _currentPosition.value = engine.getPosition() + _moveHistory.value = engine.getMoveHistory() + _lastError.value = null + + // Reset game status in case checkmate was detected + if (_gameStatus.value is GameStatus.Finished) { + _gameStatus.value = GameStatus.InProgress + } + + return true + } + + /** + * Apply opponent's move (called when receiving move event from Nostr) + */ + fun applyOpponentMove( + san: String, + fen: String, + moveNumber: Int? = null, + ): Boolean { + // Check for duplicate move + if (moveNumber != null && receivedMoveNumbers.contains(moveNumber)) { + // Already processed this move, ignore + return true + } + + // Check for out-of-order move + val expectedMoveNumber = _moveHistory.value.size + 1 + if (moveNumber != null && moveNumber > expectedMoveNumber) { + // Store for later processing + pendingMoves[moveNumber] = san to fen + return true + } + + if (isPlayerTurn()) { + _lastError.value = "Received move but it's not opponent's turn" + return false + } + + // Verify the move is valid by trying to make it + val result = engine.makeMove(san) + + if (result.success) { + // Mark as received + if (moveNumber != null) { + receivedMoveNumbers.add(moveNumber) + } + + // Verify the resulting FEN matches what opponent sent + val currentFen = engine.getFen() + if (currentFen != fen) { + // Positions don't match - desync detected + _isDesynced.value = true + _lastError.value = "Position mismatch - syncing to opponent's position" + // Load the opponent's FEN to stay in sync + engine.loadFen(fen) + } else { + _isDesynced.value = false + } + + _currentPosition.value = engine.getPosition() + _moveHistory.value = engine.getMoveHistory() + _lastActivityAt.value = TimeUtils.now() + _lastError.value = null + + // Opponent making a move declines any pending draw offer + _pendingDrawOffer.value = null + + // Check for game end conditions + checkGameEnd() + + // Process any pending moves that are now valid + processPendingMoves() + + return true + } else { + _lastError.value = "Invalid opponent move: $san" + return false + } + } + + /** + * Process any pending out-of-order moves + */ + private fun processPendingMoves() { + val nextMoveNumber = _moveHistory.value.size + 1 + val pendingMove = pendingMoves.remove(nextMoveNumber) + if (pendingMove != null) { + val (san, fen) = pendingMove + applyOpponentMove(san, fen, nextMoveNumber) + } + } + + /** + * Force resync to a specific FEN (manual recovery) + */ + fun forceResync(fen: String) { + engine.loadFen(fen) + _currentPosition.value = engine.getPosition() + _moveHistory.value = engine.getMoveHistory() + _isDesynced.value = false + _lastError.value = null + } + + /** + * Apply moves from another game state while preserving this state's participant info. + * This is used when relay reconstruction incorrectly marks us as spectator but has newer moves. + * + * @param other The other game state to take moves from + */ + fun applyMovesFrom(other: LiveChessGameState) { + val currentMoves = _moveHistory.value + val otherMoves = other.moveHistory.value + + if (otherMoves.size <= currentMoves.size) return // Nothing new to apply + + // Apply only the new moves + for (i in currentMoves.size until otherMoves.size) { + val san = otherMoves[i] + val result = engine.makeMove(san) + if (!result.success) { + // Try to resync from the other engine's FEN + val otherFen = other.engine.getFen() + engine.loadFen(otherFen) + break + } + } + + // Update state flows + _currentPosition.value = engine.getPosition() + _moveHistory.value = engine.getMoveHistory() + _lastActivityAt.value = other.lastActivityAt.value + _isDesynced.value = false + _lastError.value = null + + // Update head event ID from other state + _headEventId.value = other.headEventId.value + + // Mark new moves as received + val newMoveNumbers = (currentMoves.size + 1..otherMoves.size).toSet() + receivedMoveNumbers.addAll(newMoveNumbers) + + // Check for game end conditions + checkGameEnd() + } + + /** + * Mark move numbers as already received. + * Used when loading a game from cache to prevent duplicate move application during refresh. + * + * @param moveNumbers Set of move numbers that have been loaded + */ + fun markMovesAsReceived(moveNumbers: Set) { + receivedMoveNumbers.addAll(moveNumbers) + } + + /** + * Check if game appears abandoned (opponent hasn't moved in a long time) + */ + fun isAbandoned(): Boolean { + if (_gameStatus.value != GameStatus.InProgress) return false + if (isPlayerTurn()) return false // Only check when waiting for opponent + + val elapsed = TimeUtils.now() - _lastActivityAt.value + return elapsed > ABANDON_TIMEOUT_SECONDS + } + + /** + * Check if opponent is inactive (warning threshold) + */ + fun isOpponentInactive(): Boolean { + if (_gameStatus.value != GameStatus.InProgress) return false + if (isPlayerTurn()) return false + + val elapsed = TimeUtils.now() - _lastActivityAt.value + return elapsed > INACTIVITY_WARNING_SECONDS + } + + /** + * Claim victory due to abandonment + */ + fun claimAbandonmentVictory(): ChessGameEnd? { + if (!isAbandoned()) return null + + _gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(playerColor)) + + return ChessGameEnd( + startEventId = startEventId, + headEventId = _headEventId.value, + lastMove = null, + fen = engine.getFen(), + history = _moveHistory.value, + result = GameResult.getResultForWinner(playerColor), + termination = GameTermination.ABANDONMENT, + opponentPubkey = opponentPubkey, + ) + } + + /** + * Offer resignation (player resigns) + */ + fun resign(): ChessGameEnd { + _gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(playerColor.opposite())) + + return ChessGameEnd( + startEventId = startEventId, + headEventId = _headEventId.value, + lastMove = null, + fen = engine.getFen(), + history = _moveHistory.value, + result = GameResult.getResultForWinner(playerColor.opposite()), + termination = GameTermination.RESIGNATION, + opponentPubkey = opponentPubkey, + ) + } + + /** + * Offer a draw to opponent. + * Returns the draw offer data to be published to Nostr. + */ + fun offerDraw(): ChessDrawOffer { + _pendingDrawOffer.value = playerPubkey + return ChessDrawOffer( + startEventId = startEventId, + headEventId = _headEventId.value, + opponentPubkey = opponentPubkey, + ) + } + + /** + * Handle receiving a draw offer from opponent (via Nostr event) + */ + fun receiveDrawOffer(fromPubkey: String): Boolean { + if (fromPubkey != opponentPubkey) return false + _pendingDrawOffer.value = opponentPubkey + return true + } + + /** + * Accept opponent's draw offer. + * Returns game end data to be published to Nostr. + */ + fun acceptDraw(): ChessGameEnd? { + if (_pendingDrawOffer.value != opponentPubkey) { + _lastError.value = "No draw offer to accept" + return null + } + + _pendingDrawOffer.value = null + _gameStatus.value = GameStatus.Finished(GameResult.DRAW) + + return ChessGameEnd( + startEventId = startEventId, + headEventId = _headEventId.value, + lastMove = null, + fen = engine.getFen(), + history = _moveHistory.value, + result = GameResult.DRAW, + termination = GameTermination.DRAW_AGREEMENT, + opponentPubkey = opponentPubkey, + ) + } + + /** + * Decline a draw offer (explicit decline, also happens implicitly on move) + */ + fun declineDraw() { + _pendingDrawOffer.value = null + } + + /** + * Check if game has ended (checkmate, stalemate, etc.) + */ + private fun checkGameEnd() { + when { + engine.isCheckmate() -> { + val winner = engine.getSideToMove().opposite() + _gameStatus.value = GameStatus.Finished(GameResult.getResultForWinner(winner)) + // In a real implementation, you'd publish GameEnd event here + } + + engine.isStalemate() -> { + _gameStatus.value = GameStatus.Finished(GameResult.DRAW) + // In a real implementation, you'd publish GameEnd event here + } + } + } + + /** + * Generate PGN for the current game + */ + private fun generatePGN(): String { + val moves = _moveHistory.value + val movePairs = moves.chunked(2) + + val moveText = + movePairs + .mapIndexed { index, pair -> + val moveNum = index + 1 + when (pair.size) { + 2 -> "$moveNum. ${pair[0]} ${pair[1]}" + 1 -> "$moveNum. ${pair[0]}" + else -> "" + } + }.joinToString(" ") + + val result = + when (val status = _gameStatus.value) { + is GameStatus.Finished -> status.result.notation + else -> "*" + } + + return """ + [Event "Live Chess Game"] + [Site "Nostr"] + [White "${if (playerColor == Color.WHITE) playerPubkey else opponentPubkey}"] + [Black "${if (playerColor == Color.BLACK) playerPubkey else opponentPubkey}"] + [Result "$result"] + + $moveText $result + """.trimIndent() + } + + /** + * Mark game as finished with the given result. + * Used when loading a game from cache that already has an end event. + */ + fun markAsFinished(result: GameResult) { + _gameStatus.value = GameStatus.Finished(result) + } + + /** + * Reset game to initial position + */ + fun reset() { + engine.reset() + _currentPosition.value = engine.getPosition() + _moveHistory.value = emptyList() + _gameStatus.value = GameStatus.InProgress + _lastError.value = null + } +} + +/** + * Game status + */ +sealed class GameStatus { + data object InProgress : GameStatus() + + data class Finished( + val result: GameResult, + ) : GameStatus() +} + +/** + * Extension to get result for a winning color + */ +private fun GameResult.Companion.getResultForWinner(winner: Color): GameResult = + when (winner) { + Color.WHITE -> GameResult.WHITE_WINS + Color.BLACK -> GameResult.BLACK_WINS + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParser.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParser.kt new file mode 100644 index 000000000..e9793b405 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParser.kt @@ -0,0 +1,325 @@ +/* + * 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 + +/** + * Parse PGN (Portable Game Notation) format per NIP-64 specification + * + * Accepts "import format" (human-created, flexible) as per PGN spec + * Handles: + * - Tag pairs: [TagName "TagValue"] + * - Move text in Standard Algebraic Notation (SAN) + * - Comments: {...} + * - Variations: (...) + * - Result markers: 1-0, 0-1, 1/2-1/2, * + */ +object PGNParser { + /** + * Parse PGN content into ChessGame + * + * @param pgn PGN format string + * @return Result containing ChessGame or error + */ + fun parse(pgn: String): Result = + runCatching { + val lines = pgn.lines().map { it.trim() } + + // Extract metadata tags [Key "Value"] + val metadata = parseMetadata(lines) + + // Extract move text (everything after metadata) + val moveText = + lines + .dropWhile { it.startsWith("[") || it.isEmpty() } + .joinToString(" ") + + val (moves, result) = parseMoves(moveText) + + // Generate positions by replaying moves + val positions = generatePositions(moves) + + ChessGame( + metadata = metadata, + moves = moves, + positions = positions, + result = result, + ) + } + + /** + * Extract PGN tag pairs from lines + * Format: [TagName "TagValue"] + */ + private fun parseMetadata(lines: List): Map { + val tagRegex = """\[(\w+)\s+"([^"]*)"\]""".toRegex() + return lines + .mapNotNull { tagRegex.matchEntire(it) } + .associate { it.groupValues[1] to it.groupValues[2] } + } + + /** + * Parse move text into list of moves and game result + */ + private fun parseMoves(moveText: String): Pair, GameResult> { + // Remove comments {...} and variations (...) + val cleaned = + moveText + .replace(Regex("""\{[^}]*\}"""), "") // Remove comments + .replace(Regex("""\([^)]*\)"""), "") // Remove variations + .replace(Regex("""\$\d+"""), "") // Remove NAG annotations + .trim() + + // Extract result marker + val result = + when { + cleaned.contains("1-0") -> GameResult.WHITE_WINS + cleaned.contains("0-1") -> GameResult.BLACK_WINS + cleaned.contains("1/2-1/2") -> GameResult.DRAW + else -> GameResult.IN_PROGRESS + } + + // Remove result marker from move text + val movesOnly = + cleaned + .replace("1-0", "") + .replace("0-1", "") + .replace("1/2-1/2", "") + .replace("*", "") + .replace(Regex("""\s+"""), " ") // Normalize whitespace + .trim() + + // Parse move pairs: "1. e4 e5 2. Nf3 Nc6" + val moveRegex = """(\d+)\.\s*([^\s]+)(?:\s+([^\s]+))?""".toRegex() + val moves = mutableListOf() + + moveRegex.findAll(movesOnly).forEach { match -> + val moveNum = match.groupValues[1].toIntOrNull() ?: return@forEach + val whiteMove = match.groupValues[2] + val blackMove = match.groupValues.getOrNull(3)?.takeIf { it.isNotEmpty() } + + // Skip if this is just a result marker + if (!isResultMarker(whiteMove)) { + moves.add(parseMove(whiteMove, moveNum, Color.WHITE)) + } + + blackMove?.let { + if (!isResultMarker(it)) { + moves.add(parseMove(it, moveNum, Color.BLACK)) + } + } + } + + return moves to result + } + + /** + * Check if text is a game result marker + * Valid results: 1-0, 0-1, 1/2-1/2, * + */ + private fun isResultMarker(text: String): Boolean = text == "1-0" || text == "0-1" || text == "1/2-1/2" || text == "*" + + /** + * Parse a single move in Standard Algebraic Notation (SAN) + * + * Examples: + * - e4 (pawn move) + * - Nf3 (knight to f3) + * - Bxe5 (bishop captures on e5) + * - O-O (kingside castling) + * - O-O-O (queenside castling) + * - e8=Q (pawn promotion to queen) + * - Nbd2 (knight from b-file to d2) + * - R1a3 (rook from rank 1 to a3) + * - Qh4+ (queen to h4 with check) + * - Qh4# (queen to h4 with checkmate) + */ + private fun parseMove( + san: String, + moveNumber: Int, + color: Color, + ): ChessMove { + // Clean up annotations + var text = san.replace(Regex("[!?]+"), "").trim() + + val isCheck = text.contains("+") + val isCheckmate = text.contains("#") + val isCapture = text.contains("x") + + // Remove check/checkmate markers + text = text.replace("+", "").replace("#", "") + + // Castling + if (text == "O-O" || text == "0-0") { + return ChessMove( + san = san, + moveNumber = moveNumber, + color = color, + piece = PieceType.KING, + toSquare = if (color == Color.WHITE) "g1" else "g8", + isCheck = isCheck, + isCheckmate = isCheckmate, + isCastling = true, + ) + } + + if (text == "O-O-O" || text == "0-0-0") { + return ChessMove( + san = san, + moveNumber = moveNumber, + color = color, + piece = PieceType.KING, + toSquare = if (color == Color.WHITE) "c1" else "c8", + isCheck = isCheck, + isCheckmate = isCheckmate, + isCastling = true, + ) + } + + // Remove capture marker + text = text.replace("x", "") + + // Check for promotion (e.g., e8=Q) + val promotionRegex = """([a-h][18])=([QRBN])""".toRegex() + val promotionMatch = promotionRegex.find(text) + val promotion = promotionMatch?.groupValues?.get(2)?.let { PieceType.fromSymbol(it[0]) } + if (promotionMatch != null) { + text = text.replace(promotionRegex, promotionMatch.groupValues[1]) + } + + // Determine piece type (first char if uppercase, otherwise pawn) + val piece = + if (text.isNotEmpty() && text[0].isUpperCase()) { + PieceType.fromSymbol(text[0]) ?: PieceType.PAWN + } else { + PieceType.PAWN + } + + // Remove piece symbol if present + if (piece != PieceType.PAWN && text.isNotEmpty()) { + text = text.substring(1) + } + + // Extract destination square (last 2 chars should be file+rank) + val squareRegex = """([a-h][1-8])$""".toRegex() + val squareMatch = squareRegex.find(text) + val toSquare = squareMatch?.value ?: "" + + // Extract disambiguation (file or rank between piece and destination) + val fromSquare = text.replace(toSquare, "").takeIf { it.isNotEmpty() } + + return ChessMove( + san = san, + moveNumber = moveNumber, + color = color, + piece = piece, + fromSquare = fromSquare, + toSquare = toSquare, + isCapture = isCapture, + isCheck = isCheck, + isCheckmate = isCheckmate, + promotion = promotion, + ) + } + + /** + * Generate list of positions by replaying moves from starting position + * + * Note: This implementation uses simplified move application. + * Full legal move validation would require complete chess engine logic. + */ + private fun generatePositions(moves: List): List { + val positions = mutableListOf(ChessPosition.initial()) + var current = ChessPosition.initial() + + moves.forEach { move -> + try { + current = + when { + move.isCastling -> { + // Castling + val kingSide = move.toSquare.contains("g") + current.makeCastlingMove(kingSide) + } + + move.fromSquare != null -> { + // Move with disambiguation (we can infer full source) + // For now, just use simplified move + makeSimplifiedMove(current, move) + } + + else -> { + // Regular move + makeSimplifiedMove(current, move) + } + } + positions.add(current) + } catch (e: Exception) { + // If move application fails, keep previous position + positions.add(current) + } + } + + return positions + } + + /** + * Simplified move application without full legal move validation + * Finds piece that can move to destination and creates new position + */ + private fun makeSimplifiedMove( + position: ChessPosition, + move: ChessMove, + ): ChessPosition { + // For MVP: Try to find a piece of the right type that could move to destination + // This is simplified and doesn't validate full chess rules + val targetSquare = move.toSquare + + // Find all pieces of the moving color and type + val candidates = mutableListOf() + for (rank in 0..7) { + for (file in 0..7) { + val piece = position.pieceAt(file, rank) + if (piece != null && + piece.color == move.color && + piece.type == move.piece + ) { + val square = "${'a' + file}${rank + 1}" + candidates.add(square) + } + } + } + + // Use disambiguation if provided + val fromSquare = + if (move.fromSquare != null) { + candidates.firstOrNull { it.contains(move.fromSquare) } + } else { + candidates.firstOrNull() + } ?: "" + + return if (fromSquare.isNotEmpty()) { + position.makeMove(fromSquare, targetSquare, move.promotion) + } else { + // If we can't find source, return current position unchanged + position + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/PollEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/PollEvent.kt new file mode 100644 index 000000000..63647352e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/PollEvent.kt @@ -0,0 +1,78 @@ +/* + * 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.nip88Polls.poll + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip22Comments.RootScope +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class PollEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + RootScope { + fun options() = tags.options() + + fun relays() = tags.relays() + + fun pollType() = tags.pollType() + + fun endsAt() = tags.endsAt() + + fun hasEnded() = tags.hasEnded() + + companion object { + const val KIND = 1068 + const val ALT_DESCRIPTION = "Poll" + + fun build( + description: String, + options: List, + endsAt: Long?, + relays: List, + pollType: PollType = PollType.SINGLE_CHOICE, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, description, createdAt) { + alt(ALT_DESCRIPTION) + poolType(pollType) + options(options) + relays(relays) + endsAt?.let { + endsAt(it) + } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/TagArrayBuilderExt.kt new file mode 100644 index 000000000..319f797a3 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/TagArrayBuilderExt.kt @@ -0,0 +1,37 @@ +/* + * 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.nip88Polls.poll + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip88Polls.poll.tags.EndsAtTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollTypeTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.RelayTag + +fun TagArrayBuilder.poolType(type: PollType) = addUnique(PollTypeTag.assemble(type)) + +fun TagArrayBuilder.endsAt(timestamp: Long) = addUnique(EndsAtTag.assemble(timestamp)) + +fun TagArrayBuilder.relays(relays: List) = addAll(RelayTag.assemble(relays)) + +fun TagArrayBuilder.options(options: List) = addAll(OptionTag.assemble(options)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/TagArrayExt.kt new file mode 100644 index 000000000..86d00ef73 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/TagArrayExt.kt @@ -0,0 +1,43 @@ +/* + * 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.nip88Polls.poll + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip88Polls.poll.tags.EndsAtTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.EndsAtTag.Companion.parse +import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag.Companion.parse +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollTypeTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollTypeTag.Companion.parse +import com.vitorpamplona.quartz.nip88Polls.poll.tags.RelayTag +import com.vitorpamplona.quartz.nip88Polls.poll.tags.RelayTag.Companion.parse +import com.vitorpamplona.quartz.utils.TimeUtils + +fun TagArray.options() = mapNotNull(OptionTag::parse) + +fun TagArray.relays() = mapNotNull(RelayTag::parse) + +fun TagArray.pollType() = firstNotNullOfOrNull(PollTypeTag::parse) ?: PollType.SINGLE_CHOICE + +fun TagArray.endsAt() = firstNotNullOfOrNull(EndsAtTag::parse) + +fun TagArray.hasEnded() = endsAt()?.let { it < TimeUtils.now() } ?: false diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/EndsAtTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/EndsAtTag.kt new file mode 100644 index 000000000..341d69be1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/EndsAtTag.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip88Polls.poll.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +class EndsAtTag { + companion object { + const val TAG_NAME = "endsAt" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): Long? { + ensure(tag.has(1) && tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(time: Long) = arrayOfNotNull(TAG_NAME, time.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/OptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/OptionTag.kt new file mode 100644 index 000000000..0aa40db02 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/OptionTag.kt @@ -0,0 +1,54 @@ +/* + * 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.nip88Polls.poll.tags + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +@Stable +class OptionTag( + val code: String, + val label: String, +) { + companion object { + const val TAG_NAME = "option" + + fun isTag(tag: Array) = tag.has(2) && tag[0] == TAG_NAME && tag[1].isNotEmpty() && tag[2].isNotEmpty() + + fun parse(tag: Array): OptionTag? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[2].isNotEmpty()) { return null } + return OptionTag(tag[1], tag[2]) + } + + fun assemble( + code: String, + label: String, + ) = arrayOf(TAG_NAME, code, label) + + fun assemble(option: OptionTag) = assemble(option.code, option.label) + + fun assemble(labels: List) = labels.map { opt -> assemble(opt) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/PollTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/PollTypeTag.kt new file mode 100644 index 000000000..a8d50ce6b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/PollTypeTag.kt @@ -0,0 +1,53 @@ +/* + * 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.nip88Polls.poll.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +enum class PollType( + val code: String, +) { + SINGLE_CHOICE("singlechoice"), + MULTI_CHOICE("multiplechoice"), +} + +class PollTypeTag { + companion object { + const val TAG_NAME = "polltype" + + fun isTag(tag: Array) = tag.has(2) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): PollType? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return when (tag[1]) { + PollType.SINGLE_CHOICE.code -> PollType.SINGLE_CHOICE + PollType.MULTI_CHOICE.code -> PollType.MULTI_CHOICE + else -> null + } + } + + fun assemble(type: PollType) = arrayOf(TAG_NAME, type.code) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/RelayTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/RelayTag.kt new file mode 100644 index 000000000..294d69b49 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/tags/RelayTag.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip88Polls.poll.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.ensure + +class RelayTag { + companion object { + const val TAG_NAME = "relay" + + fun match(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun notMatch(tag: Array) = !(tag.has(0) && tag[0] == TAG_NAME) + + fun parse(tag: Array): NormalizedRelayUrl? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + + return RelayUrlNormalizer.normalizeOrNull(tag[1]) + } + + fun assemble(relay: NormalizedRelayUrl) = arrayOf(TAG_NAME, relay.url) + + fun assemble(relays: List) = relays.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/PollResponseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/PollResponseEvent.kt new file mode 100644 index 000000000..5ef16deee --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/PollResponseEvent.kt @@ -0,0 +1,84 @@ +/* + * 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.nip88Polls.response + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.EventHintProvider +import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parseId +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseAsHint +import com.vitorpamplona.quartz.nip01Core.tags.people.PTag.Companion.parseKey +import com.vitorpamplona.quartz.nip22Comments.RootScope +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.responses +import com.vitorpamplona.quartz.nip88Polls.response.tags.PollTag +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class PollResponseEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + EventHintProvider, + PubKeyHintProvider, + RootScope { + override fun eventHints() = tags.mapNotNull(PollTag::parseAsHint) + + override fun linkedEventIds() = tags.mapNotNull(PollTag::parseId) + + override fun pubKeyHints() = tags.mapNotNull(PTag::parseAsHint) + + override fun linkedPubKeys() = tags.mapNotNull(PTag::parseKey) + + fun responses() = tags.responses() + + fun poll() = tags.poll() + + companion object { + const val KIND = 1018 + const val ALT_DESCRIPTION = "Poll Response" + + fun build( + poll: EventHintBundle, + responses: Set, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + poll(poll) + notifyAuthor(poll) + responses(responses) + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/TagArrayBuilderExt.kt new file mode 100644 index 000000000..6176916f6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/TagArrayBuilderExt.kt @@ -0,0 +1,34 @@ +/* + * 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.nip88Polls.response + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.tags.people.toPTag +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.tags.PollTag +import com.vitorpamplona.quartz.nip88Polls.response.tags.ResponseTag + +fun TagArrayBuilder.poll(pollHint: EventHintBundle) = addUnique(PollTag.assemble(pollHint)) + +fun TagArrayBuilder.responses(responses: Set) = addAll(ResponseTag.assemble(responses)) + +fun TagArrayBuilder.notifyAuthor(pollHint: EventHintBundle) = add(pollHint.toPTag().toTagArray()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/TagArrayExt.kt new file mode 100644 index 000000000..83ea261e1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/TagArrayExt.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip88Polls.response + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.events.ETag.Companion.parse +import com.vitorpamplona.quartz.nip88Polls.poll.tags.EndsAtTag.Companion.parse +import com.vitorpamplona.quartz.nip88Polls.poll.tags.OptionTag.Companion.parse +import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollTypeTag.Companion.parse +import com.vitorpamplona.quartz.nip88Polls.poll.tags.RelayTag.Companion.parse +import com.vitorpamplona.quartz.nip88Polls.response.tags.PollTag +import com.vitorpamplona.quartz.nip88Polls.response.tags.ResponseTag +import com.vitorpamplona.quartz.nip88Polls.response.tags.ResponseTag.Companion.parse + +fun TagArray.responses() = mapNotNull(ResponseTag::parse) + +fun TagArray.poll() = firstNotNullOfOrNull(PollTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/tags/PollTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/tags/PollTag.kt new file mode 100644 index 000000000..ddaee8e50 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/tags/PollTag.kt @@ -0,0 +1,105 @@ +/* + * 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.nip88Polls.response.tags + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip01Core.hints.types.EventIdHint +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag +import com.vitorpamplona.quartz.nip19Bech32.entities.NEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.utils.arrayOfNotNull +import com.vitorpamplona.quartz.utils.ensure + +@Immutable +data class PollTag( + override val eventId: HexKey, +) : GenericETag { + override var relay: NormalizedRelayUrl? = null + override var author: HexKey? = null + + constructor(eventId: HexKey, relayHint: NormalizedRelayUrl? = null, authorPubKeyHex: HexKey? = null) : this(eventId) { + this.relay = relayHint + this.author = authorPubKeyHex + } + + fun toNEvent(): String = NEvent.create(eventId, author, null, relay) + + override fun toTagArray() = assemble(eventId, relay, author) + + companion object { + const val TAG_NAME = "e" + + fun isTagged(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].length == 64 + + fun isTagged( + tag: Array, + eventId: HexKey, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] == eventId + + fun isTagged( + tag: Array, + eventIds: Set, + ) = tag.has(1) && tag[0] == TAG_NAME && tag[1] in eventIds + + fun parse(tag: Array): PollTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + + val hint = tag.getOrNull(2)?.let { RelayUrlNormalizer.normalizeOrNull(it) } + + return PollTag(tag[1], hint, tag.getOrNull(3)) + } + + fun parseId(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + return tag[1] + } + + fun parseAsHint(tag: Array): EventIdHint? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].length == 64) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val hint = RelayUrlNormalizer.normalizeOrNull(tag[2]) + + ensure(hint != null) { return null } + + return EventIdHint(tag[1], hint) + } + + fun assemble( + eventId: HexKey, + relay: NormalizedRelayUrl?, + author: HexKey?, + ) = arrayOfNotNull(TAG_NAME, eventId, relay?.url, author) + + fun assemble(eventHint: EventHintBundle) = assemble(eventHint.event.id, eventHint.relay, eventHint.event.pubKey) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/tags/ResponseTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/tags/ResponseTag.kt new file mode 100644 index 000000000..4ef10f85c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/tags/ResponseTag.kt @@ -0,0 +1,43 @@ +/* + * 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.nip88Polls.response.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class ResponseTag { + companion object { + const val TAG_NAME = "response" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME && tag[1].isNotEmpty() + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(code: String) = arrayOf(TAG_NAME, code) + + fun assemble(responses: Set) = responses.map { code -> assemble(code) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index 2b79f5074..cbd2924ec 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -112,6 +112,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.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent @@ -124,6 +131,8 @@ import com.vitorpamplona.quartz.nip72ModCommunities.follow.CommunityListEvent import com.vitorpamplona.quartz.nip75ZapGoals.GoalEvent import com.vitorpamplona.quartz.nip78AppData.AppSpecificDataEvent import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent +import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent +import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.nip89AppHandlers.recommendation.AppRecommendationEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryRequestEvent @@ -183,6 +192,13 @@ class EventFactory { CalendarEvent.KIND -> CalendarEvent(id, pubKey, createdAt, tags, content, sig) CalendarTimeSlotEvent.KIND -> CalendarTimeSlotEvent(id, pubKey, createdAt, tags, content, sig) CalendarRSVPEvent.KIND -> CalendarRSVPEvent(id, pubKey, createdAt, tags, content, sig) + ChessGameEvent.KIND -> ChessGameEvent(id, pubKey, createdAt, tags, content, sig) + JesterEvent.KIND -> JesterEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessGameChallengeEvent.KIND -> LiveChessGameChallengeEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessGameAcceptEvent.KIND -> LiveChessGameAcceptEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessMoveEvent.KIND -> LiveChessMoveEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessGameEndEvent.KIND -> LiveChessGameEndEvent(id, pubKey, createdAt, tags, content, sig) + LiveChessDrawOfferEvent.KIND -> LiveChessDrawOfferEvent(id, pubKey, createdAt, tags, content, sig) ChannelCreateEvent.KIND -> ChannelCreateEvent(id, pubKey, createdAt, tags, content, sig) ChannelHideMessageEvent.KIND -> ChannelHideMessageEvent(id, pubKey, createdAt, tags, content, sig) ChannelListEvent.KIND -> ChannelListEvent(id, pubKey, createdAt, tags, content, sig) @@ -254,6 +270,8 @@ class EventFactory { PictureEvent.KIND -> PictureEvent(id, pubKey, createdAt, tags, content, sig) PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig) PollNoteEvent.KIND -> PollNoteEvent(id, pubKey, createdAt, tags, content, sig) + PollEvent.KIND -> PollEvent(id, pubKey, createdAt, tags, content, sig) + PollResponseEvent.KIND -> PollResponseEvent(id, pubKey, createdAt, tags, content, sig) PrivateDmEvent.KIND -> PrivateDmEvent(id, pubKey, createdAt, tags, content, sig) PrivateOutboxRelayListEvent.KIND -> PrivateOutboxRelayListEvent(id, pubKey, createdAt, tags, content, sig) ProxyRelayListEvent.KIND -> ProxyRelayListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt index 313710f10..c8fcbde7d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/TimeUtils.kt @@ -55,6 +55,8 @@ object TimeUtils { fun oneDayAgo() = now() - ONE_DAY + fun oneDayAhead() = now() + ONE_DAY + fun eightHoursAgo() = now() - EIGHT_HOURS fun twoDays() = ONE_DAY * 2 diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt index 81c31d65b..633888a45 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt @@ -20,26 +20,6 @@ */ package com.vitorpamplona.quartz -/** - * 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. - */ expect class TestResourceLoader() { fun loadString(file: String): String } diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt index d6c0fd534..4c4293e51 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt @@ -56,18 +56,20 @@ class HintIndexerTest { ) } - val relays = - TestResourceLoader() - .loadString("relayDB.txt") - .split('\n') - .mapNotNull { - val relay = RelayUrlNormalizer.normalizeOrNull(it) - if (relay == null || relay.isLocalHost()) { - null - } else { - relay + val relays by + lazy { + TestResourceLoader() + .loadString("relayDB.txt") + .split('\n') + .mapNotNull { + val relay = RelayUrlNormalizer.normalizeOrNull(it) + if (relay == null || relay.isLocalHost()) { + null + } else { + relay + } } - } + } val indexer by lazy { val result = HintIndexer() diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEventTest.kt new file mode 100644 index 000000000..e470989d5 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessGameEventTest.kt @@ -0,0 +1,288 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Tests for ChessGameEvent (NIP-64 Kind 64 event) + * + * Verifies: + * - Event kind is 64 + * - PGN content storage + * - Alt text support (NIP-31) + * - Event structure compliance + */ +class ChessGameEventTest { + private val samplePGN = + """ + [Event "Test Game"] + [Site "Internet"] + [Date "2024.12.28"] + [Round "1"] + [White "Alice"] + [Black "Bob"] + [Result "1-0"] + + 1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0 + """.trimIndent() + + @Test + fun `verify event kind is 64`() { + assertEquals(64, ChessGameEvent.KIND, "Chess game event should be kind 64") + } + + @Test + fun `pgn content is accessible`() { + // Create a mock event manually for testing + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", "Chess Game")), + content = samplePGN, + sig = "test_sig", + ) + + assertEquals(samplePGN, testEvent.pgn(), "PGN content should be accessible via pgn()") + assertEquals(samplePGN, testEvent.content, "PGN should be in content field") + } + + @Test + fun `alt text is accessible when present`() { + val customAltText = "Scholar's Mate Example" + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", customAltText)), + content = samplePGN, + sig = "test_sig", + ) + + assertEquals(customAltText, testEvent.altText(), "Alt text should be extractable from tags") + } + + @Test + fun `alt text returns null when not present`() { + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = emptyArray(), + content = samplePGN, + sig = "test_sig", + ) + + assertEquals(null, testEvent.altText(), "Should return null when no alt tag present") + } + + @Test + fun `event can store complete game with metadata`() { + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", "Chess Game")), + content = samplePGN, + sig = "test_sig", + ) + + // Parse the PGN to verify it's valid + val gameResult = PGNParser.parse(testEvent.pgn()) + assertTrue(gameResult.isSuccess, "Event should contain valid PGN") + + val game = gameResult.getOrThrow() + assertEquals("Test Game", game.event) + assertEquals("Alice", game.white) + assertEquals("Bob", game.black) + assertEquals(GameResult.WHITE_WINS, game.result) + } + + @Test + fun `event can store minimal PGN`() { + val minimalPGN = "1. e4 *" + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", "Chess Game")), + content = minimalPGN, + sig = "test_sig", + ) + + val gameResult = PGNParser.parse(testEvent.pgn()) + assertTrue(gameResult.isSuccess, "Should handle minimal PGN") + + val game = gameResult.getOrThrow() + assertEquals(1, game.moves.size) + assertEquals(GameResult.IN_PROGRESS, game.result) + } + + @Test + fun `event can store game in progress`() { + val inProgressPGN = + """ + [Event "Live Game"] + [Result "*"] + + 1. e4 e5 2. Nf3 Nc6 3. Bb5 * + """.trimIndent() + + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", "Live Chess Game")), + content = inProgressPGN, + sig = "test_sig", + ) + + val gameResult = PGNParser.parse(testEvent.pgn()) + assertTrue(gameResult.isSuccess) + + val game = gameResult.getOrThrow() + assertEquals(GameResult.IN_PROGRESS, game.result) + assertEquals("*", game.metadata["Result"]) + } + + @Test + fun `event preserves PGN formatting`() { + val formattedPGN = + """ + [Event "Formatted Game"] + [White "Player 1"] + [Black "Player 2"] + + 1. e4 e5 + 2. Nf3 Nc6 + 3. Bb5 a6 + * + """.trimIndent() + + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", "Chess Game")), + content = formattedPGN, + sig = "test_sig", + ) + + // Content should be preserved exactly as provided + assertEquals(formattedPGN, testEvent.pgn()) + } + + @Test + fun `default alt text is Chess Game`() { + assertEquals("Chess Game", ChessGameEvent.ALT_DESCRIPTION) + } + + @Test + fun `event inherits from Event base class`() { + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = emptyArray(), + content = "1. e4 *", + sig = "test_sig", + ) + + // Verify base Event properties + assertEquals("test_id", testEvent.id) + assertEquals("test_pubkey", testEvent.pubKey) + assertEquals(1000L, testEvent.createdAt) + assertEquals(64, testEvent.kind) + assertEquals("test_sig", testEvent.sig) + } + + @Test + fun `event can contain long tournament game`() { + val longPGN = + """ + [Event "Tournament Game"] + [Site "Online"] + [Date "2024.12.28"] + [Round "5"] + [White "GM Player"] + [Black "IM Player"] + [Result "1/2-1/2"] + + 1. d4 Nf6 2. c4 g6 3. Nc3 Bg7 4. e4 d6 5. Nf3 O-O 6. Be2 e5 + 7. O-O Nc6 8. d5 Ne7 9. Ne1 Nd7 10. Nd3 f5 11. Bd2 Nf6 12. f3 f4 + 13. Rc1 g5 14. Nb5 Ng6 15. c5 Rf7 16. Qa4 h5 17. Rfe1 Bf8 + 18. cxd6 cxd6 19. Rc6 Bd7 20. Rec1 Bxc6 21. Rxc6 Qd7 22. Rc1 Rc8 + 23. Rxc8 Qxc8 24. Qa6 Qc2 25. Qxb7 Qxb2 26. Qxa7 Qxa2 27. Qb7 Qa1+ + 28. Kf2 Qb2 29. Qa7 Ra7 30. Qa4 Qa2 31. Qa8 Qb2 32. Qa4 Qa2 1/2-1/2 + """.trimIndent() + + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = arrayOf(arrayOf("alt", "Long Tournament Game")), + content = longPGN, + sig = "test_sig", + ) + + val gameResult = PGNParser.parse(testEvent.pgn()) + assertTrue(gameResult.isSuccess) + + val game = gameResult.getOrThrow() + assertTrue(game.moves.size > 50, "Should handle long games") + assertEquals(GameResult.DRAW, game.result) + } + + @Test + fun `verify tags array structure`() { + val testEvent = + ChessGameEvent( + id = "test_id", + pubKey = "test_pubkey", + createdAt = 1000L, + tags = + arrayOf( + arrayOf("alt", "Test Alt Text"), + arrayOf("t", "chess"), + arrayOf("t", "game"), + ), + content = "1. e4 *", + sig = "test_sig", + ) + + // Verify tags structure + assertTrue(testEvent.tags.size >= 1) + assertEquals("alt", testEvent.tags[0][0]) + assertEquals("Test Alt Text", testEvent.tags[0][1]) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEventTest.kt new file mode 100644 index 000000000..1c96c2e92 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEventTest.kt @@ -0,0 +1,573 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Tests for JesterEvent (Jester Protocol kind 30 events) + * + * Verifies: + * - Event kind is 30 + * - JSON content parsing (version, kind, fen, move, history) + * - e-tag structure for event linking + * - p-tag for opponent tagging + * - Start vs Move event detection + * - Compatibility with jesterui protocol + * + * Reference: https://github.com/jesterui/jesterui/blob/devel/FLOW.md + */ +class JesterEventTest { + private val testPubkey = "abc123def456" + private val opponentPubkey = "opponent789xyz" + private val startEventId = "start-event-id-001" + + // ========================================================================== + // PROTOCOL CONSTANTS + // ========================================================================== + + @Test + fun `verify event kind is 30`() { + assertEquals(30, JesterProtocol.KIND, "Jester protocol uses kind 30") + assertEquals(30, JesterEvent.KIND, "JesterEvent.KIND should be 30") + } + + @Test + fun `verify start position hash constant`() { + assertEquals( + "b1791d7fc9ae3d38966568c257ffb3a02cbf8394cdb4805bc70f64fc3c0b6879", + JesterProtocol.START_POSITION_HASH, + "Should match jesterui's START_POSITION_HASH", + ) + } + + @Test + fun `verify starting FEN constant`() { + assertEquals( + "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1", + JesterProtocol.FEN_START, + "Standard chess starting position", + ) + } + + @Test + fun `verify content kind constants`() { + assertEquals(0, JesterProtocol.CONTENT_KIND_START, "Start event kind should be 0") + assertEquals(1, JesterProtocol.CONTENT_KIND_MOVE, "Move event kind should be 1") + assertEquals(2, JesterProtocol.CONTENT_KIND_CHAT, "Chat event kind should be 2") + } + + // ========================================================================== + // START EVENT TESTS + // ========================================================================== + + @Test + fun `parse start event - open challenge`() { + val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"abc12345","playerColor":"white"}""" + val event = + JesterEvent( + id = startEventId, + pubKey = testPubkey, + createdAt = 1000L, + tags = + arrayOf( + arrayOf("e", JesterProtocol.START_POSITION_HASH), + ), + content = content, + sig = "test_sig", + ) + + assertEquals(30, event.kind) + assertTrue(event.isStartEvent(), "Should be detected as start event") + assertFalse(event.isMoveEvent(), "Should not be a move event") + assertEquals(0, event.contentKind()) + assertEquals(JesterProtocol.FEN_START, event.fen()) + assertEquals(Color.WHITE, event.playerColor()) + assertEquals("abc12345", event.nonce()) + assertTrue(event.history().isEmpty(), "Start event has no history") + assertNull(event.opponentPubkey(), "Open challenge has no opponent") + } + + @Test + fun `parse start event - private challenge`() { + val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"xyz98765","playerColor":"black"}""" + val event = + JesterEvent( + id = startEventId, + pubKey = testPubkey, + createdAt = 1000L, + tags = + arrayOf( + arrayOf("e", JesterProtocol.START_POSITION_HASH), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertTrue(event.isStartEvent()) + assertEquals(Color.BLACK, event.playerColor()) + assertEquals(opponentPubkey, event.opponentPubkey(), "Private challenge should have opponent") + } + + @Test + fun `start event e-tag references START_POSITION_HASH`() { + val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[]}""" + val event = + JesterEvent( + id = startEventId, + pubKey = testPubkey, + createdAt = 1000L, + tags = + arrayOf( + arrayOf("e", JesterProtocol.START_POSITION_HASH), + ), + content = content, + sig = "test_sig", + ) + + val eTags = event.eTags() + assertEquals(1, eTags.size) + assertEquals(JesterProtocol.START_POSITION_HASH, eTags[0], "Start event should reference START_POSITION_HASH") + } + + // ========================================================================== + // MOVE EVENT TESTS + // ========================================================================== + + @Test + fun `parse move event - first move e4`() { + val content = """{"version":"0","kind":1,"fen":"rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1","move":"e4","history":["e4"]}""" + val event = + JesterEvent( + id = "move-001", + pubKey = testPubkey, + createdAt = 2000L, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", startEventId), // For first move, head is also start + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertFalse(event.isStartEvent(), "Should not be a start event") + assertTrue(event.isMoveEvent(), "Should be detected as move event") + assertEquals(1, event.contentKind()) + assertEquals("e4", event.move()) + assertEquals(listOf("e4"), event.history()) + assertEquals("rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", event.fen()) + assertEquals(opponentPubkey, event.opponentPubkey()) + } + + @Test + fun `parse move event - multiple moves in history`() { + val content = """{"version":"0","kind":1,"fen":"rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2","move":"Nf3","history":["e4","e5","Nf3"]}""" + val event = + JesterEvent( + id = "move-003", + pubKey = testPubkey, + createdAt = 4000L, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", "move-002"), // Previous move + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertTrue(event.isMoveEvent()) + assertEquals("Nf3", event.move()) + assertEquals(listOf("e4", "e5", "Nf3"), event.history()) + assertEquals(3, event.history().size) + } + + @Test + fun `move event e-tags structure - startEventId and headEventId`() { + val content = """{"version":"0","kind":1,"fen":"test","move":"e4","history":["e4"]}""" + val headEventId = "move-002" + val event = + JesterEvent( + id = "move-003", + pubKey = testPubkey, + createdAt = 3000L, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", headEventId), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertEquals(startEventId, event.startEventId(), "First e-tag should be startEventId") + assertEquals(headEventId, event.headEventId(), "Second e-tag should be headEventId") + + val eTags = event.eTags() + assertEquals(2, eTags.size) + assertEquals(startEventId, eTags[0]) + assertEquals(headEventId, eTags[1]) + } + + // ========================================================================== + // GAME END EVENT TESTS + // ========================================================================== + + @Test + fun `parse game end event - checkmate`() { + val content = """{"version":"0","kind":1,"fen":"r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4","move":"Qxf7#","history":["e4","e5","Qh5","Nc6","Bc4","Nf6","Qxf7#"],"result":"1-0","termination":"checkmate"}""" + val event = + JesterEvent( + id = "move-007", + pubKey = testPubkey, + createdAt = 8000L, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", "move-006"), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertTrue(event.isMoveEvent(), "End event is still a move event") + assertEquals("1-0", event.result(), "Should have result") + assertEquals("checkmate", event.termination(), "Should have termination reason") + assertEquals(7, event.history().size) + } + + @Test + fun `parse game end event - resignation`() { + val content = """{"version":"0","kind":1,"fen":"test","move":"e5","history":["e4","e5"],"result":"0-1","termination":"resignation"}""" + val event = + JesterEvent( + id = "move-002", + pubKey = opponentPubkey, + createdAt = 3000L, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", "move-001"), + arrayOf("p", testPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertEquals("0-1", event.result(), "Black wins") + assertEquals("resignation", event.termination()) + } + + @Test + fun `parse game end event - draw`() { + val content = """{"version":"0","kind":1,"fen":"test","move":"Kf1","history":["e4","e5","Kf1"],"result":"1/2-1/2","termination":"draw_agreement"}""" + val event = + JesterEvent( + id = "move-003", + pubKey = testPubkey, + createdAt = 4000L, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", "move-002"), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "test_sig", + ) + + assertEquals("1/2-1/2", event.result(), "Draw") + assertEquals("draw_agreement", event.termination()) + } + + // ========================================================================== + // EDGE CASES AND ERROR HANDLING + // ========================================================================== + + @Test + fun `handle malformed JSON content gracefully`() { + val event = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = emptyArray(), + content = "invalid json {{{}", + sig = "test_sig", + ) + + assertNull(event.contentKind(), "Should return null for invalid JSON") + assertFalse(event.isStartEvent(), "Should not crash on invalid content") + assertFalse(event.isMoveEvent(), "Should not crash on invalid content") + assertNull(event.fen()) + assertNull(event.move()) + assertTrue(event.history().isEmpty()) + } + + @Test + fun `handle empty content`() { + val event = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = emptyArray(), + content = "", + sig = "test_sig", + ) + + assertNull(event.contentKind()) + assertFalse(event.isStartEvent()) + assertFalse(event.isMoveEvent()) + } + + @Test + fun `handle missing optional fields`() { + // Minimal valid content with only required fields + val content = """{"kind":1}""" + val event = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = emptyArray(), + content = content, + sig = "test_sig", + ) + + assertEquals(1, event.contentKind()) + assertNull(event.move()) + assertTrue(event.history().isEmpty()) + assertNull(event.result()) + assertNull(event.termination()) + assertNull(event.playerColor()) + } + + @Test + fun `handle event with no e-tags`() { + val content = """{"version":"0","kind":0}""" + val event = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = emptyArray(), + content = content, + sig = "test_sig", + ) + + assertNull(event.startEventId(), "No e-tags means no startEventId") + assertNull(event.headEventId(), "No e-tags means no headEventId") + assertTrue(event.eTags().isEmpty()) + } + + @Test + fun `handle event with only one e-tag`() { + val content = """{"version":"0","kind":1}""" + val event = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = + arrayOf( + arrayOf("e", startEventId), + ), + content = content, + sig = "test_sig", + ) + + assertEquals(startEventId, event.startEventId()) + assertNull(event.headEventId(), "Only one e-tag means no headEventId") + } + + // ========================================================================== + // JESTER GAME EVENTS CONTAINER TESTS + // ========================================================================== + + @Test + fun `JesterGameEvents - empty returns correct values`() { + val events = JesterGameEvents.empty() + + assertNull(events.startEvent) + assertTrue(events.moves.isEmpty()) + assertNull(events.latestMove()) + assertEquals(JesterProtocol.FEN_START, events.currentFen()) + assertTrue(events.fullHistory().isEmpty()) + assertFalse(events.isEnded()) + assertNull(events.result()) + } + + @Test + fun `JesterGameEvents - latestMove returns move with longest history`() { + val move1 = createTestMoveEvent("move-001", listOf("e4")) + val move2 = createTestMoveEvent("move-002", listOf("e4", "e5")) + val move3 = createTestMoveEvent("move-003", listOf("e4", "e5", "Nf3")) + + // Provide moves in random order + val events = + JesterGameEvents( + startEvent = null, + moves = listOf(move2, move3, move1), + ) + + val latest = events.latestMove() + assertNotNull(latest) + assertEquals("move-003", latest.id, "Should return move with longest history") + assertEquals(3, latest.history().size) + } + + @Test + fun `JesterGameEvents - currentFen returns FEN from latest move`() { + val move1 = createTestMoveEvent("move-001", listOf("e4"), fen = "fen-after-e4") + val move2 = createTestMoveEvent("move-002", listOf("e4", "e5"), fen = "fen-after-e5") + + val events = + JesterGameEvents( + startEvent = null, + moves = listOf(move1, move2), + ) + + assertEquals("fen-after-e5", events.currentFen()) + } + + @Test + fun `JesterGameEvents - fullHistory returns history from latest move`() { + val move1 = createTestMoveEvent("move-001", listOf("e4")) + val move2 = createTestMoveEvent("move-002", listOf("e4", "e5")) + + val events = + JesterGameEvents( + startEvent = null, + moves = listOf(move1, move2), + ) + + assertEquals(listOf("e4", "e5"), events.fullHistory()) + } + + @Test + fun `JesterGameEvents - isEnded detects result in latest move`() { + val normalMove = createTestMoveEvent("move-001", listOf("e4")) + val endMove = createTestMoveEventWithResult("move-002", listOf("e4", "Qxf7#"), result = "1-0") + + val ongoingGame = JesterGameEvents(startEvent = null, moves = listOf(normalMove)) + val finishedGame = JesterGameEvents(startEvent = null, moves = listOf(normalMove, endMove)) + + assertFalse(ongoingGame.isEnded()) + assertTrue(finishedGame.isEnded()) + assertEquals("1-0", finishedGame.result()) + } + + // ========================================================================== + // EXTENSION FUNCTION TESTS + // ========================================================================== + + @Test + fun `isJesterEvent extension detects kind 30`() { + val jesterEvent = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = emptyArray(), + content = "{}", + sig = "sig", + ) + + assertTrue(jesterEvent.isJesterEvent()) + } + + @Test + fun `toJesterEvent converts valid Event`() { + val event = + JesterEvent( + id = "test", + pubKey = testPubkey, + createdAt = 1000L, + tags = emptyArray(), + content = """{"kind":0}""", + sig = "sig", + ) + + val jesterEvent = event.toJesterEvent() + assertNotNull(jesterEvent) + assertEquals("test", jesterEvent.id) + } + + // ========================================================================== + // HELPER FUNCTIONS + // ========================================================================== + + private fun createTestMoveEvent( + id: String, + history: List, + fen: String = "test-fen", + ): JesterEvent { + val historyJson = history.joinToString(",") { "\"$it\"" } + val content = """{"version":"0","kind":1,"fen":"$fen","move":"${history.last()}","history":[$historyJson]}""" + return JesterEvent( + id = id, + pubKey = testPubkey, + createdAt = 1000L + history.size * 1000, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", "prev-move"), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "sig", + ) + } + + private fun createTestMoveEventWithResult( + id: String, + history: List, + result: String, + ): JesterEvent { + val historyJson = history.joinToString(",") { "\"$it\"" } + val content = """{"version":"0","kind":1,"fen":"test-fen","move":"${history.last()}","history":[$historyJson],"result":"$result","termination":"checkmate"}""" + return JesterEvent( + id = id, + pubKey = testPubkey, + createdAt = 1000L + history.size * 1000, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", "prev-move"), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "sig", + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParserTest.kt new file mode 100644 index 000000000..fb1d55ded --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip64Chess/PGNParserTest.kt @@ -0,0 +1,454 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +/** + * Comprehensive tests for PGN parsing per NIP-64 specification + * + * Tests cover: + * - PGN metadata extraction + * - Move parsing in Standard Algebraic Notation + * - Game result parsing + * - Comments and variations handling + * - Edge cases and error handling + */ +class PGNParserTest { + // Test 1: Parse minimal PGN (NIP-64 requirement: accept import format) + @Test + fun `parse minimal PGN with single move`() { + val pgn = "1. e4 *" + val result = PGNParser.parse(pgn) + + assertTrue(result.isSuccess, "Should successfully parse minimal PGN") + val game = result.getOrThrow() + assertEquals(1, game.moves.size, "Should have 1 move") + assertEquals("e4", game.moves[0].san) + assertEquals(GameResult.IN_PROGRESS, game.result) + } + + // Test 2: Parse complete game with metadata (NIP-64 requirement) + @Test + fun `parse PGN with full metadata tags`() { + val pgn = + """ + [Event "F/S Return Match"] + [Site "Belgrade, Serbia JUG"] + [Date "1992.11.04"] + [Round "29"] + [White "Fischer, Robert J."] + [Black "Spassky, Boris V."] + [Result "1/2-1/2"] + + 1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 1/2-1/2 + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // Verify metadata extraction + assertEquals("F/S Return Match", game.event) + assertEquals("Belgrade, Serbia JUG", game.site) + assertEquals("1992.11.04", game.date) + assertEquals("29", game.round) + assertEquals("Fischer, Robert J.", game.white) + assertEquals("Spassky, Boris V.", game.black) + assertEquals(GameResult.DRAW, game.result) + + // Verify moves + assertEquals(6, game.moves.size) + assertEquals("e4", game.moves[0].san) + assertEquals("Nf3", game.moves[2].san) + } + + // Test 3: Scholar's Mate (4 move checkmate) + @Test + fun `parse scholars mate with checkmate notation`() { + val pgn = + """ + [Event "Scholar's Mate"] + [White "Alice"] + [Black "Bob"] + [Result "1-0"] + + 1. e4 e5 2. Bc4 Nc6 3. Qh5 Nf6 4. Qxf7# 1-0 + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + assertEquals(7, game.moves.size) + assertEquals(GameResult.WHITE_WINS, game.result) + + // Verify last move has checkmate marker + val lastMove = game.moves.last() + assertEquals("Qxf7#", lastMove.san) + assertTrue(lastMove.isCheckmate, "Last move should be checkmate") + assertTrue(lastMove.isCapture, "Last move should be capture") + } + + // Test 4: Fool's Mate (2 move checkmate) + @Test + fun `parse fools mate shortest checkmate`() { + val pgn = + """ + 1. f3 e5 2. g4 Qh4# 0-1 + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + assertEquals(4, game.moves.size) + assertEquals(GameResult.BLACK_WINS, game.result) + + val lastMove = game.moves.last() + assertEquals("Qh4#", lastMove.san) + assertTrue(lastMove.isCheckmate) + } + + // Test 5: Castling notation + @Test + fun `parse castling moves kingside and queenside`() { + val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O O-O 5. d3 d6 6. c3 a6 7. a4 a5 8. O-O-O *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // Find castling moves + val kingsideCastling = game.moves.filter { it.san == "O-O" } + val queensideCastling = game.moves.filter { it.san == "O-O-O" } + + assertEquals(2, kingsideCastling.size, "Should have 2 kingside castling moves") + assertEquals(1, queensideCastling.size, "Should have 1 queenside castling move") + + kingsideCastling.forEach { move -> + assertTrue(move.isCastling, "O-O should be marked as castling") + assertEquals(PieceType.KING, move.piece) + } + + queensideCastling.forEach { move -> + assertTrue(move.isCastling, "O-O-O should be marked as castling") + assertEquals(PieceType.KING, move.piece) + } + } + + // Test 6: Pawn promotion + @Test + fun `parse pawn promotion to queen`() { + val pgn = + """ + [Event "Promotion Example"] + + 1. e4 d5 2. exd5 Qxd5 3. Nc3 Qa5 4. d4 c6 5. Nf3 Bg4 6. Bf4 e6 + 7. h3 Bxf3 8. Qxf3 Bb4 9. Be2 Nd7 10. a3 O-O-O 11. axb4 Qxa1+ + 12. Kd2 Qxh1 13. Qxh1 a6 14. c4 f6 15. b5 axb5 16. cxb5 c5 + 17. b6 Ne7 18. Qh2 h6 19. dxc5 Nxc5 20. b3 Kc8 21. Qg3 Ncd7 + 22. Bd6 Nf5 23. Qf4 Nxd6 24. Qxd6 Nb8 25. Kc3 Rh7 26. Kb4 Rd7 + 27. Qc5+ Kd8 28. Bf3 Ke8 29. Bd5 exd5 30. Nxd5 Kf7 31. b7 Kg6 + 32. b8=Q * + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // Find promotion move + val promotionMove = game.moves.firstOrNull { it.promotion != null } + assertNotNull(promotionMove, "Should have promotion move") + assertEquals(PieceType.QUEEN, promotionMove.promotion) + assertTrue(promotionMove.san.contains("=Q"), "Promotion move should contain =Q") + } + + // Test 7: Captures + @Test + fun `parse capture notation`() { + val pgn = "1. e4 d5 2. exd5 Qxd5 3. Nc3 Qxd4 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + val captures = game.moves.filter { it.isCapture } + assertEquals(3, captures.size, "Should have 3 capture moves") + + captures.forEach { move -> + assertTrue(move.san.contains("x"), "Capture moves should contain 'x'") + } + } + + // Test 8: Check notation + @Test + fun `parse check and checkmate markers`() { + val pgn = "1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + val checkMoves = game.moves.filter { it.isCheck } + val checkmateMoves = game.moves.filter { it.isCheckmate } + + assertTrue(checkmateMoves.isNotEmpty(), "Should have checkmate moves") + checkmateMoves.forEach { move -> + assertTrue(move.san.contains("#"), "Checkmate moves should contain #") + } + } + + // Test 9: Comments and variations (NIP-64: should handle PGN comments) + @Test + fun `parse PGN with comments and variations stripped`() { + val pgn = + """ + 1. e4 {Best by test} e5 (1...c5 2. Nf3) 2. Nf3 Nc6 {Developing} 3. Bb5 * + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // Comments and variations should be stripped + assertEquals(5, game.moves.size) + assertEquals("e4", game.moves[0].san) + assertEquals("e5", game.moves[1].san) + assertEquals("Nf3", game.moves[2].san) + } + + // Test 10: NAG annotations (Numeric Annotation Glyphs) + @Test + fun `parse PGN with NAG annotations`() { + val pgn = "1. e4$1 e5$6 2. Nf3$10 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + assertEquals(3, game.moves.size) + } + + // Test 11: Disambiguating moves + @Test + fun `parse moves with disambiguation`() { + val pgn = + """ + 1. Nf3 Nf6 2. Nc3 Nc6 3. d4 d5 4. Bf4 Bf5 5. e3 e6 + 6. Nbd2 Nbd7 7. Bd3 Bd6 * + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // Check for disambiguated moves (Nbd2, Nbd7) + val disambiguatedMoves = game.moves.filter { it.fromSquare != null } + assertTrue(disambiguatedMoves.isNotEmpty(), "Should have disambiguated moves") + } + + // Test 12: All possible game results + @Test + fun `parse all game result notations`() { + val whiteWins = "1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# 1-0" + val blackWins = "1. f3 e5 2. g4 Qh4# 0-1" + val draw = "1. e4 e5 2. Nf3 Nc6 1/2-1/2" + val inProgress = "1. e4 e5 *" + + assertEquals(GameResult.WHITE_WINS, PGNParser.parse(whiteWins).getOrThrow().result) + assertEquals(GameResult.BLACK_WINS, PGNParser.parse(blackWins).getOrThrow().result) + assertEquals(GameResult.DRAW, PGNParser.parse(draw).getOrThrow().result) + assertEquals(GameResult.IN_PROGRESS, PGNParser.parse(inProgress).getOrThrow().result) + } + + // Test 13: Empty/invalid PGN handling + @Test + fun `handle empty PGN gracefully`() { + val emptyPgn = "" + val result = PGNParser.parse(emptyPgn) + + // Should not crash, might return empty game + assertTrue(result.isSuccess || result.isFailure) + } + + // Test 14: Position generation + @Test + fun `generate positions for each move`() { + val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // Should have starting position + one per move + assertEquals(game.moves.size + 1, game.positions.size) + + // First position should be starting position + val startPos = game.positions[0] + assertEquals(Color.WHITE, startPos.activeColor) + assertEquals(1, startPos.moveNumber) + } + + // Test 15: Verify required metadata (NIP-64: PGN should have standard tags) + @Test + fun `detect presence of required PGN metadata tags`() { + val fullPgn = + """ + [Event "FIDE World Championship"] + [Site "London"] + [Date "2018.11.28"] + [Round "12"] + [White "Carlsen, Magnus"] + [Black "Caruana, Fabiano"] + [Result "1-0"] + + 1. e4 * + """.trimIndent() + + val minimalPgn = "1. e4 *" + + val fullGame = PGNParser.parse(fullPgn).getOrThrow() + val minimalGame = PGNParser.parse(minimalPgn).getOrThrow() + + assertTrue(fullGame.hasRequiredMetadata(), "Full PGN should have required metadata") + assertFalse(minimalGame.hasRequiredMetadata(), "Minimal PGN should not have required metadata") + } + + // Test 16: Long tournament game + @Test + fun `parse realistic tournament game`() { + val pgn = + """ + [Event "Wch"] + [Site "New York"] + [Date "1886.??.??"] + [Round "1"] + [White "Zukertort, Johannes"] + [Black "Steinitz, William"] + [Result "0-1"] + + 1. d4 d5 2. c4 e6 3. Nc3 Nf6 4. e3 c5 5. Nf3 Nc6 6. a3 dxc4 + 7. Bxc4 cxd4 8. exd4 Be7 9. O-O O-O 10. Qd3 Bd7 11. Qe2 Qb8 + 12. Rd1 Rd8 13. Be3 Be8 14. Ne5 Nxe5 15. dxe5 Rxd1+ 16. Rxd1 Nd7 + 17. f4 Nc5 18. Qf2 Rc8 19. b4 Na6 20. Bd3 Nb8 21. Ne4 Nc6 + 22. Nd6 Bxd6 23. exd6 Qxd6 24. Bxh7+ Kh8 25. Bf5 Qc7 26. Bxc8 Qxc8 + 27. Qd2 Bg6 28. Qd7 Qxd7 29. Rxd7 b6 30. Bc1 Nd8 31. Rxd8+ 0-1 + """.trimIndent() + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + assertEquals("Zukertort, Johannes", game.white) + assertEquals("Steinitz, William", game.black) + assertEquals(GameResult.BLACK_WINS, game.result) + assertTrue(game.moves.size > 50, "Tournament game should have many moves") + } + + // Test 17: Alternative castling notation (0-0 instead of O-O) + @Test + fun `parse alternative castling notation with zeros`() { + val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. 0-0 0-0 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + val castlingMoves = game.moves.filter { it.isCastling } + assertEquals(2, castlingMoves.size) + } + + // Test 18: Move count verification + @Test + fun `verify move numbers are correct`() { + val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + assertEquals(6, game.moves.size) + + // Verify move numbers + assertEquals(1, game.moves[0].moveNumber) // e4 + assertEquals(1, game.moves[1].moveNumber) // e5 + assertEquals(2, game.moves[2].moveNumber) // Nf3 + assertEquals(2, game.moves[3].moveNumber) // Nc6 + assertEquals(3, game.moves[4].moveNumber) // Bb5 + assertEquals(3, game.moves[5].moveNumber) // a6 + } + + // Test 19: Move colors are correct + @Test + fun `verify move colors alternate correctly`() { + val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 a6 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + assertEquals(Color.WHITE, game.moves[0].color) + assertEquals(Color.BLACK, game.moves[1].color) + assertEquals(Color.WHITE, game.moves[2].color) + assertEquals(Color.BLACK, game.moves[3].color) + assertEquals(Color.WHITE, game.moves[4].color) + assertEquals(Color.BLACK, game.moves[5].color) + } + + // Test 20: Piece type detection + @Test + fun `detect piece types from SAN notation`() { + val pgn = "1. e4 e5 2. Nf3 Nc6 3. Bb5 Bc5 4. Qa4 Qf6 5. Ke2 Ke7 6. Ra3 Ra6 *" + + val result = PGNParser.parse(pgn) + assertTrue(result.isSuccess) + val game = result.getOrThrow() + + // e4, e5 - pawns + assertEquals(PieceType.PAWN, game.moves[0].piece) + assertEquals(PieceType.PAWN, game.moves[1].piece) + + // Nf3, Nc6 - knights + assertEquals(PieceType.KNIGHT, game.moves[2].piece) + assertEquals(PieceType.KNIGHT, game.moves[3].piece) + + // Bb5, Bc5 - bishops + assertEquals(PieceType.BISHOP, game.moves[4].piece) + assertEquals(PieceType.BISHOP, game.moves[5].piece) + + // Qa4, Qf6 - queens + assertEquals(PieceType.QUEEN, game.moves[6].piece) + assertEquals(PieceType.QUEEN, game.moves[7].piece) + + // Ke2, Ke7 - kings + assertEquals(PieceType.KING, game.moves[8].piece) + assertEquals(PieceType.KING, game.moves[9].piece) + + // Ra3, Ra6 - rooks + assertEquals(PieceType.ROOK, game.moves[10].piece) + assertEquals(PieceType.ROOK, game.moves[11].piece) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/SignerUtils.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/SignerUtils.kt index 077fec078..2b16ae2a4 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/SignerUtils.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/SignerUtils.kt @@ -20,10 +20,28 @@ */ package com.vitorpamplona.quartz.utils -import com.vitorpamplona.quartz.nip01Core.crypto.DeterministicSigner +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventAssembler import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes fun String.nsecToKeyPair() = KeyPair(this.bechToBytes()) fun String.nsecToSigner() = this.nsecToKeyPair().let { DeterministicSigner(it) } + +class DeterministicSigner( + val key: KeyPair, + val pubKey: HexKey = key.pubKey.toHexKey(), +) { + fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T = EventAssembler.hashAndSign(pubKey, createdAt, kind, tags, content, key.privKey!!, nonce = null) + + fun sign(ev: EventTemplate): T = sign(ev.createdAt, ev.kind, ev.tags, ev.content) +} diff --git a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.ios.kt new file mode 100644 index 000000000..a87275781 --- /dev/null +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.ios.kt @@ -0,0 +1,92 @@ +/* + * 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 + +actual class ChessEngine actual constructor() { + actual fun getFen(): String { + TODO("Not yet implemented") + } + + actual fun loadFen(fen: String) { + } + + actual fun reset() { + } + + actual fun makeMove(san: String): MoveResult { + TODO("Not yet implemented") + } + + actual fun makeMove( + from: String, + to: String, + promotion: PieceType?, + ): MoveResult { + TODO("Not yet implemented") + } + + actual fun getLegalMoves(): List { + TODO("Not yet implemented") + } + + actual fun getLegalMovesFrom(square: String): List { + TODO("Not yet implemented") + } + + actual fun isLegalMove(san: String): Boolean { + TODO("Not yet implemented") + } + + actual fun isLegalMove( + from: String, + to: String, + promotion: PieceType?, + ): Boolean { + TODO("Not yet implemented") + } + + actual fun isCheckmate(): Boolean { + TODO("Not yet implemented") + } + + actual fun isStalemate(): Boolean { + TODO("Not yet implemented") + } + + actual fun isInCheck(): Boolean { + TODO("Not yet implemented") + } + + actual fun undoMove() { + } + + actual fun getPosition(): ChessPosition { + TODO("Not yet implemented") + } + + actual fun getSideToMove(): Color { + TODO("Not yet implemented") + } + + actual fun getMoveHistory(): List { + TODO("Not yet implemented") + } +} diff --git a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt index 01c653066..493c189e2 100644 --- a/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt +++ b/quartz/src/iosTest/kotlin/com/vitorpamplona/quartz/TestResourceLoader.kt @@ -32,11 +32,22 @@ import platform.darwin.NSObjectMeta actual class TestResourceLoader { @OptIn(ExperimentalForeignApi::class, BetaInteropApi::class) actual fun loadString(file: String): String { - val bundle = NSBundle.Companion.bundleForClass(BundleMarker) + // Split the filename and extension (e.g., "data.json" -> "data", "json") + val basename = file.substringBeforeLast(".") + val extension = file.substringAfterLast(".", "") + + // Locate the file in the main application bundle val path = - bundle.pathForResource(file, ofType = null) + NSBundle.mainBundle.pathForResource(basename, ofType = extension) ?: throw IllegalArgumentException("Resource not found: $file") - return NSString.Companion.stringWithContentsOfFile(path, encoding = NSUTF8StringEncoding, error = null)!! + + // Read the file content as a UTF-8 string + return NSString + .stringWithContentsOfFile( + path = path, + encoding = NSUTF8StringEncoding, + error = null, + ).toString() } private class BundleMarker : NSObject() { diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt new file mode 100644 index 000000000..42f5dd891 --- /dev/null +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessEngine.jvmAndroid.kt @@ -0,0 +1,349 @@ +/* + * 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 com.github.bhlangonijr.chesslib.Board +import com.github.bhlangonijr.chesslib.Piece +import com.github.bhlangonijr.chesslib.Side +import com.github.bhlangonijr.chesslib.Square +import com.github.bhlangonijr.chesslib.move.Move + +/** + * JVM/Android implementation of ChessEngine using kchesslib. + * + * Maintains its own SAN history because chesslib's Move.toString() returns + * UCI/coordinate notation (e.g. "e2e4") rather than proper SAN (e.g. "e4"). + */ +actual class ChessEngine { + private val board = Board() + private val sanHistory = mutableListOf() + + actual fun getFen(): String = board.fen + + actual fun loadFen(fen: String) { + board.loadFromFen(fen) + sanHistory.clear() + } + + actual fun reset() { + board.loadFromFen("rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1") + sanHistory.clear() + } + + actual fun makeMove(san: String): MoveResult = + try { + if (board.doMove(san)) { + // Input is already SAN from Nostr events, store directly + sanHistory.add(san) + MoveResult( + success = true, + san = san, + position = boardToPosition(), + ) + } else { + MoveResult( + success = false, + error = "Invalid move: $san", + ) + } + } catch (e: Exception) { + MoveResult( + success = false, + error = e.message ?: "Unknown error making move $san", + ) + } + + actual fun makeMove( + from: String, + to: String, + promotion: PieceType?, + ): MoveResult = + try { + val fromSquare = Square.fromValue(from.uppercase()) + val toSquare = Square.fromValue(to.uppercase()) + + val promotionPiece = + when (promotion) { + PieceType.QUEEN -> if (board.sideToMove == Side.WHITE) Piece.WHITE_QUEEN else Piece.BLACK_QUEEN + PieceType.ROOK -> if (board.sideToMove == Side.WHITE) Piece.WHITE_ROOK else Piece.BLACK_ROOK + PieceType.BISHOP -> if (board.sideToMove == Side.WHITE) Piece.WHITE_BISHOP else Piece.BLACK_BISHOP + PieceType.KNIGHT -> if (board.sideToMove == Side.WHITE) Piece.WHITE_KNIGHT else Piece.BLACK_KNIGHT + else -> Piece.NONE + } + + val move = Move(fromSquare, toSquare, promotionPiece) + + if (board.legalMoves().contains(move)) { + val san = computeSan(move) + board.doMove(move) + sanHistory.add(san) + MoveResult( + success = true, + san = san, + position = boardToPosition(), + ) + } else { + MoveResult( + success = false, + error = "Illegal move: $from to $to", + ) + } + } catch (e: Exception) { + MoveResult( + success = false, + error = e.message ?: "Unknown error making move $from to $to", + ) + } + + actual fun getLegalMoves(): List = board.legalMoves().map { it.toString() } + + actual fun getLegalMovesFrom(square: String): List { + val sq = Square.fromValue(square.uppercase()) + return board + .legalMoves() + .filterNotNull() + .filter { it.from == sq } + .map { it.to.toString().lowercase() } + } + + actual fun isLegalMove(san: String): Boolean = + try { + if (board.doMove(san)) { + board.undoMove() + true + } else { + false + } + } catch (e: Exception) { + false + } + + actual fun isLegalMove( + from: String, + to: String, + promotion: PieceType?, + ): Boolean = + try { + val fromSquare = Square.fromValue(from.uppercase()) + val toSquare = Square.fromValue(to.uppercase()) + + val promotionPiece = + when (promotion) { + PieceType.QUEEN -> if (board.sideToMove == Side.WHITE) Piece.WHITE_QUEEN else Piece.BLACK_QUEEN + PieceType.ROOK -> if (board.sideToMove == Side.WHITE) Piece.WHITE_ROOK else Piece.BLACK_ROOK + PieceType.BISHOP -> if (board.sideToMove == Side.WHITE) Piece.WHITE_BISHOP else Piece.BLACK_BISHOP + PieceType.KNIGHT -> if (board.sideToMove == Side.WHITE) Piece.WHITE_KNIGHT else Piece.BLACK_KNIGHT + else -> Piece.NONE + } + + val move = Move(fromSquare, toSquare, promotionPiece) + board.legalMoves().contains(move) + } catch (e: Exception) { + false + } + + actual fun isCheckmate(): Boolean = board.isMated + + actual fun isStalemate(): Boolean = board.isStaleMate + + actual fun isInCheck(): Boolean = board.isKingAttacked + + actual fun undoMove() { + board.undoMove() + if (sanHistory.isNotEmpty()) { + sanHistory.removeAt(sanHistory.lastIndex) + } + } + + actual fun getPosition(): ChessPosition = boardToPosition() + + actual fun getSideToMove(): Color = + when (board.sideToMove) { + Side.WHITE -> Color.WHITE + Side.BLACK -> Color.BLACK + } + + actual fun getMoveHistory(): List = sanHistory.toList() + + /** + * Compute proper SAN notation for a move BEFORE it is applied to the board. + * Handles pieces, pawns, castling, captures, promotion, disambiguation, check/checkmate. + */ + private fun computeSan(move: Move): String { + val fromSquare = move.from + val toSquare = move.to + val piece = board.getPiece(fromSquare) + val pt = piece.pieceType ?: return move.toString() + val promotionPiece = move.promotion ?: Piece.NONE + + // Castling + if (pt == com.github.bhlangonijr.chesslib.PieceType.KING) { + val fileDiff = toSquare.file.ordinal - fromSquare.file.ordinal + if (fileDiff == 2) { + val suffix = checkSuffixAfterMove(move) + return "O-O$suffix" + } + if (fileDiff == -2) { + val suffix = checkSuffixAfterMove(move) + return "O-O-O$suffix" + } + } + + val sb = StringBuilder() + val epTarget = board.enPassantTarget + val isCapture = + board.getPiece(toSquare) != Piece.NONE || + ( + pt == com.github.bhlangonijr.chesslib.PieceType.PAWN && + epTarget != null && epTarget != Square.NONE && toSquare == epTarget + ) + + if (pt != com.github.bhlangonijr.chesslib.PieceType.PAWN) { + sb.append(sanSymbol(pt)) + + // Disambiguation: check if other pieces of same type can reach the same square + val ambiguous = + board.legalMoves().filterNotNull().filter { + it.to == toSquare && + board.getPiece(it.from).pieceType == pt && + it.from != fromSquare + } + + if (ambiguous.isNotEmpty()) { + val sameFile = ambiguous.any { it.from.file == fromSquare.file } + val sameRank = ambiguous.any { it.from.rank == fromSquare.rank } + when { + !sameFile -> { + sb.append(fileChar(fromSquare)) + } + + !sameRank -> { + sb.append(rankChar(fromSquare)) + } + + else -> { + sb.append(fileChar(fromSquare)) + sb.append(rankChar(fromSquare)) + } + } + } + } else if (isCapture) { + // Pawn captures include the source file + sb.append(fileChar(fromSquare)) + } + + if (isCapture) sb.append('x') + + sb.append(toSquare.toString().lowercase()) + + // Promotion + if (promotionPiece != Piece.NONE) { + sb.append('=') + val promType = promotionPiece.pieceType + if (promType != null) sb.append(sanSymbol(promType)) + } + + // Check/checkmate suffix + sb.append(checkSuffixAfterMove(move)) + + return sb.toString() + } + + /** + * Temporarily apply a move to check for check/checkmate, then undo. + */ + private fun checkSuffixAfterMove(move: Move): String { + board.doMove(move) + val suffix = + when { + board.isMated -> "#" + board.isKingAttacked -> "+" + else -> "" + } + board.undoMove() + return suffix + } + + private fun sanSymbol(pt: com.github.bhlangonijr.chesslib.PieceType): String = + when (pt) { + com.github.bhlangonijr.chesslib.PieceType.KING -> "K" + com.github.bhlangonijr.chesslib.PieceType.QUEEN -> "Q" + com.github.bhlangonijr.chesslib.PieceType.ROOK -> "R" + com.github.bhlangonijr.chesslib.PieceType.BISHOP -> "B" + com.github.bhlangonijr.chesslib.PieceType.KNIGHT -> "N" + else -> "" + } + + private fun fileChar(square: Square): Char = 'a' + square.file.ordinal + + private fun rankChar(square: Square): Char = '1' + square.rank.ordinal + + /** + * Convert kchesslib Board to our ChessPosition model + */ + private fun boardToPosition(): ChessPosition { + val positionArray = Array(8) { Array(8) { null } } + + for (rank in 0..7) { + for (file in 0..7) { + val square = Square.squareAt(rank * 8 + file) + val piece = board.getPiece(square) + + if (piece != Piece.NONE) { + val pieceType = + when (piece.pieceType) { + com.github.bhlangonijr.chesslib.PieceType.KING -> PieceType.KING + com.github.bhlangonijr.chesslib.PieceType.QUEEN -> PieceType.QUEEN + com.github.bhlangonijr.chesslib.PieceType.ROOK -> PieceType.ROOK + com.github.bhlangonijr.chesslib.PieceType.BISHOP -> PieceType.BISHOP + com.github.bhlangonijr.chesslib.PieceType.KNIGHT -> PieceType.KNIGHT + com.github.bhlangonijr.chesslib.PieceType.PAWN -> PieceType.PAWN + else -> PieceType.PAWN + } + + val color = + when (piece.pieceSide) { + Side.WHITE -> Color.WHITE + Side.BLACK -> Color.BLACK + else -> Color.WHITE + } + + positionArray[rank][file] = ChessPiece(pieceType, color) + } + } + } + + return ChessPosition( + board = positionArray, + activeColor = getSideToMove(), + moveNumber = board.moveCounter, + castlingRights = + CastlingRights( + whiteKingSide = board.castleRight.toString().contains("K"), + whiteQueenSide = board.castleRight.toString().contains("Q"), + blackKingSide = board.castleRight.toString().contains("k"), + blackQueenSide = board.castleRight.toString().contains("q"), + ), + enPassantSquare = board.enPassantTarget?.let { it.toString().lowercase() }, + halfMoveClock = board.halfMoveCounter, + ) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructorTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructorTest.kt new file mode 100644 index 000000000..3b6e8fe98 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip64Chess/ChessStateReconstructorTest.kt @@ -0,0 +1,808 @@ +/* + * 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 kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Integration tests for ChessStateReconstructor with Jester protocol. + * + * These tests verify that the reconstruction algorithm produces correct, + * deterministic game states from Jester events (kind 30). + * + * Jester Protocol: + * - Single event kind (30) for all chess messages + * - Content is JSON with: version, kind, fen, move, history + * - Start events have content.kind=0 and reference START_POSITION_HASH via e-tag + * - Move events have content.kind=1 and reference [startEventId, headEventId] via e-tags + * - Full move history is included in every move event + * - No separate accept event - acceptance is implicit when opponent makes first move + * + * Test Categories: + * 1. Basic game lifecycle (challenge, moves, end) + * 2. Move ordering and reconstruction from history + * 3. Game end conditions (checkmate, resignation) + * 4. Viewer perspective (white player, black player, spectator) + * 5. Determinism tests + */ +class ChessStateReconstructorTest { + // Test pubkeys + private val whitePubkey = "white_pubkey_abc123" + private val blackPubkey = "black_pubkey_def456" + private val spectatorPubkey = "spectator_pubkey_xyz789" + private val startEventId = "start-event-001" + + // ========================================================================== + // 1. BASIC GAME LIFECYCLE TESTS + // ========================================================================== + + @Test + fun `reconstruct pending challenge - no moves yet`() { + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = emptyList(), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess(), "Should reconstruct pending challenge") + + val state = result.getOrNull()!! + assertTrue(state.isPendingChallenge, "Game should be pending") + assertEquals(startEventId, state.startEventId) + assertEquals(whitePubkey, state.whitePubkey) + assertEquals(ViewerRole.WHITE_PLAYER, state.viewerRole) + assertEquals(Color.WHITE, state.playerColor) + assertTrue(state.moveHistory.isEmpty(), "No moves yet") + assertEquals(GameStatus.InProgress, state.gameStatus) + } + + @Test + fun `reconstruct game with initial moves - e4 e5`() { + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(move1, move2), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(2, state.moveHistory.size) + assertEquals("e4", state.moveHistory[0]) + assertEquals("e5", state.moveHistory[1]) + assertTrue(state.isPlayerTurn(), "White's turn after e4 e5") + assertEquals(Color.WHITE, state.currentPosition.activeColor) + } + + @Test + fun `reconstruct game - acceptance is implicit via first opponent move`() { + // In Jester, there's no separate accept event + // Game is considered accepted when opponent makes their first move + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(move1), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertFalse(state.isPendingChallenge, "Game is active with moves") + assertEquals(blackPubkey, state.blackPubkey) + assertEquals(Color.BLACK, state.currentPosition.activeColor, "Black's turn after e4") + } + + // ========================================================================== + // 2. MOVE RECONSTRUCTION FROM HISTORY + // ========================================================================== + + @Test + fun `reconstruct from latest move with full history`() { + // In Jester, each move contains the full history + // Reconstruction uses the move with the longest history + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + createdAt = 1000, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + createdAt = 2000, + ) + val move3 = + createMoveEvent( + id = "move-003", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = "move-002", + move = "Nf3", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2", + history = listOf("e4", "e5", "Nf3"), + opponentPubkey = blackPubkey, + createdAt = 3000, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(move1, move2, move3), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(3, state.moveHistory.size) + assertEquals(listOf("e4", "e5", "Nf3"), state.moveHistory) + assertEquals("move-003", state.headEventId, "Head should be latest move") + } + + @Test + fun `reconstruct with out-of-order moves - uses longest history`() { + // Events might arrive out of order, but we use the longest history + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + ) + + // Events arrive in reverse order + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(move2, move1), // Reverse order + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(2, state.moveHistory.size) + assertEquals("e4", state.moveHistory[0], "First move should be e4") + assertEquals("e5", state.moveHistory[1], "Second move should be e5") + } + + @Test + fun `reconstruct with duplicate moves - uses longest history`() { + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move1Duplicate = + createMoveEvent( + id = "move-001-dup", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(move1, move1Duplicate, move2), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + // Move2 has the longest history (2 moves), so that's what we reconstruct + assertEquals(2, state.moveHistory.size, "Should have 2 moves from longest history") + } + + // ========================================================================== + // 3. GAME END CONDITIONS TESTS + // ========================================================================== + + @Test + fun `reconstruct scholar's mate - checkmate by engine detection`() { + // Scholar's mate: 1. e4 e5 2. Qh5 Nc6 3. Bc4 Nf6 4. Qxf7# + val finalMove = + createMoveEvent( + id = "move-007", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = "move-006", + move = "Qxf7#", + fen = "r1bqkb1r/pppp1Qpp/2n2n2/4p3/2B1P3/8/PPPP1PPP/RNB1K1NR b KQkq - 0 4", + history = listOf("e4", "e5", "Qh5", "Nc6", "Bc4", "Nf6", "Qxf7#"), + opponentPubkey = blackPubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(finalMove), // Only need latest move with full history + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(7, state.moveHistory.size) + + val engine = result.getEngineOrNull()!! + assertTrue(engine.isCheckmate(), "Engine should detect checkmate") + + assertTrue(state.gameStatus is GameStatus.Finished) + assertEquals( + GameResult.WHITE_WINS, + state.gameStatus.result, + "White wins by checkmate", + ) + } + + @Test + fun `reconstruct game with resignation - result in move content`() { + // In Jester, resignation is indicated by result field in move content + val resignationMove = + createMoveEventWithResult( + id = "move-002", + pubKey = blackPubkey, // Black resigns + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + result = "1-0", // White wins by resignation + termination = "resignation", + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(resignationMove), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertTrue(state.gameStatus is GameStatus.Finished) + assertEquals( + GameResult.WHITE_WINS, + state.gameStatus.result, + "White wins by resignation", + ) + } + + @Test + fun `reconstruct fool's mate - 4 move checkmate for black`() { + // 1. f3 e5 2. g4 Qh4# + val finalMove = + createMoveEvent( + id = "move-004", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-003", + move = "Qh4#", + fen = "rnb1kbnr/pppp1ppp/8/4p3/6Pq/5P2/PPPPP2P/RNBQKBNR w KQkq - 1 3", + history = listOf("f3", "e5", "g4", "Qh4#"), + opponentPubkey = whitePubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(finalMove), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertTrue(state.gameStatus is GameStatus.Finished) + assertEquals( + GameResult.BLACK_WINS, + state.gameStatus.result, + "Black wins by fool's mate", + ) + } + + // ========================================================================== + // 4. VIEWER PERSPECTIVE TESTS + // ========================================================================== + + @Test + fun `reconstruct as white player - correct perspective`() { + val events = createBasicGameEvents() + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(ViewerRole.WHITE_PLAYER, state.viewerRole) + assertEquals(Color.WHITE, state.playerColor) + assertEquals(blackPubkey, state.opponentPubkey) + } + + @Test + fun `reconstruct as black player - correct perspective`() { + val events = createBasicGameEvents() + + val result = ChessStateReconstructor.reconstruct(events, blackPubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(ViewerRole.BLACK_PLAYER, state.viewerRole) + assertEquals(Color.BLACK, state.playerColor) + assertEquals(whitePubkey, state.opponentPubkey) + } + + @Test + fun `reconstruct as spectator - white perspective, no turn`() { + val events = createBasicGameEvents() + + val result = ChessStateReconstructor.reconstruct(events, spectatorPubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(ViewerRole.SPECTATOR, state.viewerRole) + assertEquals(Color.WHITE, state.playerColor, "Spectators see from white's perspective") + assertFalse(state.isPlayerTurn(), "Spectators never have a turn") + } + + @Test + fun `challenger is black - roles reversed correctly`() { + // Challenger chooses black + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, // Opponent (white) makes first move + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, blackPubkey, Color.BLACK, whitePubkey), + moves = listOf(move1), + ) + + val whiteResult = ChessStateReconstructor.reconstruct(events, whitePubkey) + val blackResult = ChessStateReconstructor.reconstruct(events, blackPubkey) + + assertTrue(whiteResult.isSuccess()) + assertTrue(blackResult.isSuccess()) + + val whiteState = whiteResult.getOrNull()!! + val blackState = blackResult.getOrNull()!! + + assertEquals(whitePubkey, whiteState.whitePubkey) + assertEquals(blackPubkey, whiteState.blackPubkey) + assertEquals(ViewerRole.WHITE_PLAYER, whiteState.viewerRole) + assertEquals(ViewerRole.BLACK_PLAYER, blackState.viewerRole) + } + + // ========================================================================== + // 5. ERROR HANDLING TESTS + // ========================================================================== + + @Test + fun `reconstruct without start event - should fail`() { + val events = JesterGameEvents(startEvent = null, moves = emptyList()) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result is ReconstructionResult.Error) + assertEquals("No start event found", result.message) + } + + @Test + fun `reconstruct with empty events - should fail`() { + val events = JesterGameEvents.empty() + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result is ReconstructionResult.Error) + } + + // ========================================================================== + // 6. CASTLING AND SPECIAL MOVES TESTS + // ========================================================================== + + @Test + fun `reconstruct game with kingside castling`() { + // 1. e4 e5 2. Nf3 Nc6 3. Bc4 Bc5 4. O-O + val finalMove = + createMoveEvent( + id = "move-007", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = "move-006", + move = "O-O", + fen = "r1bqk1nr/pppp1ppp/2n5/2b1p3/2B1P3/5N2/PPPP1PPP/RNBQ1RK1 b kq - 5 4", + history = listOf("e4", "e5", "Nf3", "Nc6", "Bc4", "Bc5", "O-O"), + opponentPubkey = blackPubkey, + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(finalMove), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertEquals(7, state.moveHistory.size) + assertEquals("O-O", state.moveHistory[6]) + assertEquals(Color.BLACK, state.currentPosition.activeColor, "Black's turn after white castled") + } + + // ========================================================================== + // 7. DETERMINISM TESTS + // ========================================================================== + + @Test + fun `same events always produce same state`() { + val events = createBasicGameEvents() + + // Reconstruct multiple times + val results = + (1..5).map { + ChessStateReconstructor.reconstruct(events, whitePubkey) + } + + // All should succeed + assertTrue(results.all { it.isSuccess() }) + + // All states should be identical + val states = results.map { it.getOrNull()!! } + val first = states.first() + + states.forEach { state -> + assertEquals(first.startEventId, state.startEventId) + assertEquals(first.moveHistory, state.moveHistory) + assertEquals(first.gameStatus, state.gameStatus) + assertEquals(first.currentPosition.activeColor, state.currentPosition.activeColor) + assertEquals(first.currentPosition.moveNumber, state.currentPosition.moveNumber) + assertEquals(first.appliedMoveNumbers, state.appliedMoveNumbers) + } + } + + @Test + fun `different move list order produces same state`() { + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + ) + val move3 = + createMoveEvent( + id = "move-003", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = "move-002", + move = "Nf3", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/5N2/PPPP1PPP/RNBQKB1R b KQkq - 1 2", + history = listOf("e4", "e5", "Nf3"), + opponentPubkey = blackPubkey, + ) + + val startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey) + + // Order 1: moves in order + val events1 = JesterGameEvents(startEvent, listOf(move1, move2, move3)) + + // Order 2: moves reversed (but move3 has longest history, so result is same) + val events2 = JesterGameEvents(startEvent, listOf(move3, move1, move2)) + + val result1 = ChessStateReconstructor.reconstruct(events1, whitePubkey) + val result2 = ChessStateReconstructor.reconstruct(events2, whitePubkey) + + assertTrue(result1.isSuccess()) + assertTrue(result2.isSuccess()) + + val state1 = result1.getOrNull()!! + val state2 = result2.getOrNull()!! + + assertEquals(state1.moveHistory, state2.moveHistory, "Move history should be identical") + assertEquals(state1.appliedMoveNumbers, state2.appliedMoveNumbers) + } + + // ========================================================================== + // 8. OPEN CHALLENGE TESTS + // ========================================================================== + + @Test + fun `reconstruct open challenge - no specific opponent`() { + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, null), // Open challenge + moves = emptyList(), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertTrue(state.isPendingChallenge) + assertEquals(whitePubkey, state.whitePubkey) + assertEquals(null, state.blackPubkey, "No opponent assigned yet for open challenge") + } + + @Test + fun `reconstruct open challenge with first opponent move`() { + // When an unknown player makes the first move, they become the opponent + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, // Now we know the opponent + ) + + val events = + JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, null), // Open challenge + moves = listOf(move1), + ) + + val result = ChessStateReconstructor.reconstruct(events, whitePubkey) + assertTrue(result.isSuccess()) + + val state = result.getOrNull()!! + assertFalse(state.isPendingChallenge) + assertEquals(whitePubkey, state.whitePubkey) + // Opponent is determined from move events when not specified in start + } + + // ========================================================================== + // HELPER FUNCTIONS + // ========================================================================== + + private fun createBasicGameEvents(): JesterGameEvents { + val move1 = + createMoveEvent( + id = "move-001", + pubKey = whitePubkey, + startEventId = startEventId, + headEventId = startEventId, + move = "e4", + fen = "rnbqkbnr/pppppppp/8/8/4P3/8/PPPP1PPP/RNBQKBNR b KQkq e3 0 1", + history = listOf("e4"), + opponentPubkey = blackPubkey, + ) + val move2 = + createMoveEvent( + id = "move-002", + pubKey = blackPubkey, + startEventId = startEventId, + headEventId = "move-001", + move = "e5", + fen = "rnbqkbnr/pppp1ppp/8/4p3/4P3/8/PPPP1PPP/RNBQKBNR w KQkq e6 0 2", + history = listOf("e4", "e5"), + opponentPubkey = whitePubkey, + ) + + return JesterGameEvents( + startEvent = createStartEvent(startEventId, whitePubkey, Color.WHITE, blackPubkey), + moves = listOf(move1, move2), + ) + } + + private fun createStartEvent( + id: String, + challengerPubkey: String, + challengerColor: Color, + opponentPubkey: String?, + createdAt: Long = 1000, + ): JesterEvent { + val tags = + mutableListOf( + arrayOf("e", JesterProtocol.START_POSITION_HASH), + ) + opponentPubkey?.let { tags.add(arrayOf("p", it)) } + + val colorString = if (challengerColor == Color.WHITE) "white" else "black" + val content = """{"version":"0","kind":0,"fen":"${JesterProtocol.FEN_START}","history":[],"nonce":"test123","playerColor":"$colorString"}""" + + return JesterEvent( + id = id, + pubKey = challengerPubkey, + createdAt = createdAt, + tags = tags.toTypedArray(), + content = content, + sig = "sig-start", + ) + } + + private fun createMoveEvent( + id: String, + pubKey: String, + startEventId: String, + headEventId: String, + move: String, + fen: String, + history: List, + opponentPubkey: String, + createdAt: Long = 2000, + ): JesterEvent { + val historyJson = history.joinToString(",") { "\"$it\"" } + val content = """{"version":"0","kind":1,"fen":"$fen","move":"$move","history":[$historyJson]}""" + + return JesterEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", headEventId), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "sig-move", + ) + } + + private fun createMoveEventWithResult( + id: String, + pubKey: String, + startEventId: String, + headEventId: String, + move: String, + fen: String, + history: List, + opponentPubkey: String, + result: String, + termination: String, + createdAt: Long = 2000, + ): JesterEvent { + val historyJson = history.joinToString(",") { "\"$it\"" } + val content = """{"version":"0","kind":1,"fen":"$fen","move":"$move","history":[$historyJson],"result":"$result","termination":"$termination"}""" + + return JesterEvent( + id = id, + pubKey = pubKey, + createdAt = createdAt, + tags = + arrayOf( + arrayOf("e", startEventId), + arrayOf("e", headEventId), + arrayOf("p", opponentPubkey), + ), + content = content, + sig = "sig-move", + ) + } +}