diff --git a/.claude/skills/quartz-integration/SKILL.md b/.claude/skills/quartz-integration/SKILL.md new file mode 100644 index 0000000000..516903f50c --- /dev/null +++ b/.claude/skills/quartz-integration/SKILL.md @@ -0,0 +1,672 @@ +--- +name: quartz-integration +description: Integration guide for using the Quartz Nostr KMP library in external projects. Use when: (1) adding Quartz as a Gradle dependency, (2) setting up NostrClient with WebSocket, (3) creating/signing/sending events, (4) building relay subscriptions with Filter, (5) handling keys with KeyPair/NostrSignerInternal, (6) using Bech32 encoding/decoding (NIP-19), (7) platform-specific setup (Android vs JVM/Desktop), (8) NIP-57 zaps, NIP-17 DMs, NIP-44 encryption in external projects. +--- + +# Quartz Integration Guide + +Reference for integrating `com.vitorpamplona.quartz:quartz` into external Nostr KMP projects. + +**Published artifact**: `com.vitorpamplona.quartz:quartz:1.05.1` (Maven Central) +**Targets**: JVM 21+, Android (minSdk 21+), iOS (XCFramework `quartz-kmpKit`) +**License**: MIT + +--- + +## 1. Gradle Setup + +### Version Catalog (`libs.versions.toml`) + +```toml +[versions] +quartz = "1.05.1" + +[libraries] +quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } +``` + +### `build.gradle.kts` (KMP project) + +```kotlin +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.quartz) + } + } +} +``` + +### Android-only project + +```kotlin +dependencies { + implementation("com.vitorpamplona.quartz:quartz:1.05.1") +} +``` + +### Transitive dependencies pulled in automatically + +Quartz exposes these as `api` (you get them transitively): + +| Dependency | Used for | +|-----------|----------| +| `fr.acinq.secp256k1:secp256k1-kmp-*` | Schnorr signing | +| `com.github.anthonynsimon:rfc3986-normalizer` | Relay URL normalization | +| `com.fasterxml.jackson.module:jackson-module-kotlin` | Event JSON parsing | +| `com.linkedin.urls:url-detector` | URL extraction from content | + +For Android, add to `build.gradle.kts`: +```kotlin +android { + packaging { + resources.excludes += "/META-INF/{AL2.0,LGPL2.1}" + } +} +``` + +--- + +## 2. Key Concepts + +### Core Types + +```kotlin +typealias HexKey = String // 64-char hex string (pubkey, event id, sig) +typealias Kind = Int // Event kind number +typealias TagArray = Array> +``` + +### Event Anatomy + +```kotlin +@Immutable +open class Event( + val id: HexKey, // SHA-256 of canonical JSON (64 hex chars) + val pubKey: HexKey, // Author public key (64 hex chars) + val createdAt: Long, // Unix timestamp (seconds) + val kind: Kind, // Event type + val tags: TagArray, // [["e","eventid"], ["p","pubkey"], ...] + val content: String, + val sig: HexKey, // Schnorr signature (128 hex chars) +) +``` + +### Kind Classification + +```kotlin +// Regular events — stored by relays forever +val isRegular = kind in 1..9999 + +// Replaceable events — relay keeps only latest per (pubkey, kind) +val isReplaceable = kind == 0 || kind == 3 || kind in 10000..19999 + +// Addressable events — relay keeps latest per (pubkey, kind, d-tag) +val isAddressable = kind in 30000..39999 + +// Ephemeral events — relays don't persist +val isEphemeral = kind in 20000..29999 +``` + +--- + +## 3. Key Management + +### Generate a new keypair + +```kotlin +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair + +// Generate fresh random keys +val keyPair = KeyPair() + +// From existing private key bytes +val keyPair = KeyPair(privKey = myPrivKeyBytes) + +// Read-only (public key only, cannot sign) +val keyPair = KeyPair(pubKey = myPubKeyBytes) + +// Access +val pubKeyHex: String = keyPair.pubKey.toHexKey() +val privKeyHex: String? = keyPair.privKey?.toHexKey() +``` + +### Convert between formats + +```kotlin +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser + +// ByteArray → hex +val hex = byteArray.toHexKey() + +// hex → ByteArray +val bytes = HexKey.decodeHex(hex) + +// Bech32 import (npub, nsec) +val parsed = Nip19Parser.uriToRoute("npub1abc...") +// or +val parsed = Nip19Parser.uriToRoute("nsec1abc...") +``` + +--- + +## 4. Signing Events + +### `NostrSignerInternal` (local key, JVM + Android) + +```kotlin +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal + +val keyPair = KeyPair() +val signer = NostrSignerInternal(keyPair) + +// Sign any EventTemplate +val template = TextNoteEvent.build("Hello Nostr!") +val signedEvent: TextNoteEvent = signer.sign(template) +``` + +### `NostrSignerSync` (synchronous, for testing) + +```kotlin +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync + +val signerSync = NostrSignerSync(keyPair) +val event = signerSync.sign( + createdAt = TimeUtils.now(), + kind = 1, + tags = emptyArray(), + content = "Hello!" +) +``` + +### NostrSigner interface (for custom signers) + +```kotlin +abstract class NostrSigner(val pubKey: HexKey) { + abstract fun isWriteable(): Boolean + abstract suspend fun sign(createdAt: Long, kind: Int, tags: Array>, content: String): T + abstract suspend fun nip04Encrypt(plaintext: String, toPublicKey: HexKey): String + abstract suspend fun nip04Decrypt(ciphertext: String, fromPublicKey: HexKey): String + abstract suspend fun nip44Encrypt(plaintext: String, toPublicKey: HexKey): String + abstract suspend fun nip44Decrypt(ciphertext: String, fromPublicKey: HexKey): String + abstract suspend fun deriveKey(nonce: HexKey): HexKey + abstract fun hasForegroundSupport(): Boolean + // Convenience: auto-detects NIP-04 vs NIP-44 by ciphertext format + suspend fun decrypt(encryptedContent: String, fromPublicKey: HexKey): String +} +``` + +--- + +## 5. Creating Events + +### Using typed event builders (recommended) + +```kotlin +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent + +// Kind 1 — Text note +val template = TextNoteEvent.build("Hello Nostr!") +val event: TextNoteEvent = signer.sign(template) + +// Kind 1 — Reply +val replyTemplate = TextNoteEvent.build( + note = "Interesting thread!", + replyingTo = originalEventHintBundle +) + +// Kind 7 — Reaction +val reactionTemplate = ReactionEvent.build( + content = "+", // "+" = like, "-" = dislike, emoji = custom + originalNote = targetEvent +) +``` + +### Using low-level `Event.build` DSL + +```kotlin +import com.vitorpamplona.quartz.nip01Core.core.Event + +val template = Event.build( + kind = 1, + content = "Hello world", + createdAt = TimeUtils.now() +) { + // TagArrayBuilder DSL + add(arrayOf("p", mentionedPubKey)) + add(arrayOf("t", "nostr")) + add(arrayOf("subject", "Greeting")) +} + +val event: Event = signer.sign(template) +``` + +### TagArrayBuilder DSL methods + +```kotlin +// In the DSL lambda: +add(arrayOf("tagname", "value")) // append +addFirst(arrayOf("tagname", "value")) // prepend +addUnique(arrayOf("d", "my-slug")) // replace all tags with same name +addAll(listOf(arrayOf("t", "tag1"), ...)) // bulk add +remove("tagname") // remove all with this name +``` + +## 6. Relay Client Setup (JVM / Android) + +The relay client requires an OkHttp WebSocket builder (available on JVM + Android). + +### Minimal setup + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket +import okhttp3.OkHttpClient + +// Build the WebSocket factory +val okHttpClient = OkHttpClient.Builder().build() +val wsBuilder = BasicOkHttpWebSocket.Builder { _ -> okHttpClient } + +// Create client (manages its own CoroutineScope internally) +val nostrClient = NostrClient(websocketBuilder = wsBuilder) +nostrClient.connect() +``` + +### With custom OkHttpClient per relay + +```kotlin +val wsBuilder = BasicOkHttpWebSocket.Builder { normalizedUrl -> + if (normalizedUrl.url.contains(".onion")) { + torEnabledOkHttpClient // Tor proxy for .onion relays + } else { + regularOkHttpClient + } +} +``` + +### With custom CoroutineScope + +```kotlin +val appScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) +val nostrClient = NostrClient(wsBuilder, scope = appScope) +``` + +--- + +## 7. Subscribing to Events + +### Normalize relay URLs first + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +// Returns NormalizedRelayUrl (wrapper with validated wss:// URL) +val relayUrl = RelayUrlNormalizer.normalize("wss://relay.damus.io") +val relayUrlOrNull = RelayUrlNormalizer.normalizeOrNull("wss://relay.damus.io") + +// Handles common fixes: https:// → wss://, strips whitespace, etc. +``` + +### Build a Filter + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter + +// Fetch a user's notes +val filter = Filter( + authors = listOf(pubKeyHex), + kinds = listOf(1), + limit = 50 +) + +// Since a timestamp +val filter = Filter( + kinds = listOf(1, 6), + since = System.currentTimeMillis() / 1000 - 3600 // last hour +) + +// By event tags +val filter = Filter( + kinds = listOf(7), + tags = mapOf("e" to listOf(eventId)) // reactions to an event +) + +// AND tag filter (NIP-91) +val filter = Filter( + kinds = listOf(1), + tagsAll = mapOf( + "t" to listOf("nostr"), + "p" to listOf(specificPubKey) + ) +) + +// Full-text search (NIP-50) +val filter = Filter( + kinds = listOf(1), + search = "bitcoin lightning" +) +``` + +### Open a subscription + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage +import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EoseMessage + +val relay = RelayUrlNormalizer.normalize("wss://relay.damus.io") + +val subId = "my-sub-${System.currentTimeMillis()}" +val filtersMap = mapOf(relay to listOf(filter)) + +nostrClient.openReqSubscription( + subId = subId, + filters = filtersMap, + listener = object : IRequestListener { + override fun onEvent(subId: String, event: Event, relay: IRelayClient) { + println("Got event: ${event.id}") + } + override fun onEOSE(subId: String, relay: IRelayClient) { + println("End of stored events from ${relay.url}") + } + } +) + +// Close when done +nostrClient.close(subId) +``` + +### Global relay listener + +```kotlin +nostrClient.subscribe(object : IRelayClientListener { + override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) { + when (msg) { + is EventMessage -> handleEvent(msg.subscriptionId, msg.event) + is EoseMessage -> handleEose(msg.subscriptionId) + else -> {} + } + } + override fun onConnected(relay: IRelayClient, pingMillis: Int, compressed: Boolean) { + println("Connected to ${relay.url} in ${pingMillis}ms") + } + override fun onDisconnected(relay: IRelayClient) { + println("Disconnected from ${relay.url}") + } + // other callbacks: onConnecting, onSent, onCannotConnect +}) +``` + +--- + +## 8. Publishing Events + +```kotlin +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +val relaySet = setOf( + RelayUrlNormalizer.normalize("wss://relay.damus.io"), + RelayUrlNormalizer.normalize("wss://nos.lol"), +) + +// Sign the event +val template = TextNoteEvent.build("Hello Nostr!") +val event: TextNoteEvent = signer.sign(template) + +// Send to relays (handles retry + reconnect automatically) +nostrClient.send(event, relaySet) +``` + +--- + +## 9. Event Serialization + +```kotlin +import com.vitorpamplona.quartz.nip01Core.core.Event + +// Serialize to JSON string +val json: String = event.toJson() + +// Parse from JSON string +val event: Event = Event.fromJson(json) + +// Null-safe parse +val event: Event? = Event.fromJsonOrNull(json) + +// Specific typed parse (returns base Event, cast if needed) +val textNote = Event.fromJson(json) as? TextNoteEvent +``` + +--- + +## 10. Bech32 Encoding / Decoding (NIP-19) + +```kotlin +import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser + +// Decode any bech32 entity +val result = Nip19Parser.uriToRoute("npub1abc...") +// Returns: NPub | NSec | Note | NEvent | NProfile | NAddr | null + +when (val r = Nip19Parser.uriToRoute(input)) { + is Nip19Parser.Return.NPub -> println("pubkey: ${r.hex}") + is Nip19Parser.Return.Note -> println("event id: ${r.hex}") + is Nip19Parser.Return.NEvent -> println("event: ${r.hex}, relays: ${r.relays}") + is Nip19Parser.Return.NProfile -> println("profile: ${r.hex}") + is Nip19Parser.Return.NAddr -> println("address: ${r.kind}:${r.pubKey}:${r.dTag}") + null -> println("not a valid bech32 entity") + else -> {} +} + +// The parser also handles nostr: URI scheme +val result = Nip19Parser.uriToRoute("nostr:npub1abc...") +``` + +--- + +## 11. Encryption + +### NIP-44 (modern, recommended) + +```kotlin +// Via signer (preferred) +val encrypted = signer.nip44Encrypt( + plaintext = "Secret message", + toPublicKey = recipientPubKeyHex +) +val decrypted = signer.nip44Decrypt( + ciphertext = encrypted, + fromPublicKey = senderPubKeyHex +) + +// Auto-detect format (NIP-04 or NIP-44) +val plaintext = signer.decrypt(encryptedContent, fromPublicKeyHex) +``` + +### NIP-04 (legacy, avoid for new code) + +```kotlin +val encrypted = signer.nip04Encrypt(plaintext, recipientPubKeyHex) +val decrypted = signer.nip04Decrypt(ciphertext, senderPubKeyHex) +``` + +--- + +## 12. Common NIP Event Builders + +### NIP-02 — Follow list (kind 3) + +```kotlin +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent + +val template = ContactListEvent.build( + follows = listOf( + ContactListEvent.Contact(pubKey = alicePubKey, relayUrl = "wss://relay.damus.io", petname = "alice"), + ContactListEvent.Contact(pubKey = bobPubKey) + ) +) +``` + +### NIP-25 — Reaction (kind 7) + +```kotlin +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent + +val like = ReactionEvent.build("+", targetEvent) +val dislike = ReactionEvent.build("-", targetEvent) +val custom = ReactionEvent.build("🤙", targetEvent) +``` + +### NIP-57 — Zap request (kind 9734) + +```kotlin +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent + +val template = LnZapRequestEvent.build( + message = "Great post!", + relays = listOf("wss://relay.damus.io"), + target = targetEvent, + zapType = LnZapRequestEvent.ZapType.PUBLIC +) +val zapRequest: LnZapRequestEvent = signer.sign(template) +``` + +### NIP-59 — Gift wrap / sealed DM (kind 1059 + 14) + +```kotlin +import com.vitorpamplona.quartz.nip17Dm.NIP17Factory + +// Creates sealed rumor + gift wrap pair +val (dmEvent, giftWrap) = NIP17Factory.create( + msg = "Private message", + fromSigner = senderSigner, + toUsers = listOf(recipientPubKey), + relayList = listOf("wss://relay.damus.io") +) +``` + +### NIP-23 — Long-form article (kind 30023) + +```kotlin +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent + +val template = LongTextNoteEvent.build( + body = markdownContent, + title = "My Article", + image = "https://example.com/cover.jpg", + summary = "A brief summary", + slug = "my-article" // d-tag +) +``` + +--- + +## 13. Platform-Specific Notes + +### JVM / Desktop + +```kotlin +// jvmMain dependencies needed in consuming project: +// secp256k1-kmp-jni-jvm and lazysodium-java are transitive from quartz +// But you need JNA on the classpath for libsodium: +implementation("net.java.dev.jna:jna:5.18.1") +``` + +### Android + +```kotlin +// androidMain dependencies (transitive from quartz): +// secp256k1-kmp-jni-android, lazysodium-android, jna (aar) +// No extra setup needed beyond the maven dependency. + +// For NIP-55 (Android external signer apps): +import com.vitorpamplona.quartz.nip55AndroidSigner.ExternalSignerLauncher +``` + +### iOS + +The library produces an XCFramework named `quartz-kmpKit`. + +```bash +# Build XCFramework +./gradlew :quartz:assembleQuartz-kmpKitReleaseXCFramework +# Output: quartz/build/XCFrameworks/release/quartz-kmpKit.xcframework +``` + +In Xcode: drag & drop the `.xcframework` into your project, then use from Swift via Kotlin/Native interop. + +--- + +## 14. Event Store (Android only) + +SQLite-based storage with full NIP support (NIP-09, NIP-40, NIP-45, NIP-50, NIP-62): + +```kotlin +import com.vitorpamplona.quartz.nip01Core.store.EventStore +import android.content.Context + +val store = EventStore(context) + +// Insert +store.insert(event) + +// Query +val events = store.query( + Filter(authors = listOf(pubKey), kinds = listOf(1), limit = 50) +) + +// Count (NIP-45) +val count = store.count(Filter(kinds = listOf(1))) + +// Full-text search (NIP-50) +val results = store.query(Filter(search = "bitcoin")) +``` + +--- + +## 15. Quick Reference + +| Task | API | Package | +|------|-----|---------| +| Generate keys | `KeyPair()` | `nip01Core.crypto` | +| Create signer | `NostrSignerInternal(keyPair)` | `nip01Core.signers` | +| Build event | `TextNoteEvent.build(...)` or `Event.build(kind, content) { tags }` | `nip10Notes`, `nip01Core.core` | +| Sign event | `signer.sign(template)` | `nip01Core.signers` | +| Serialize | `event.toJson()` | `nip01Core.core` | +| Parse | `Event.fromJson(json)` | `nip01Core.core` | +| Normalize relay URL | `RelayUrlNormalizer.normalize("wss://...")` | `nip01Core.relay.normalizer` | +| Setup relay client | `NostrClient(BasicOkHttpWebSocket.Builder { okhttp })` | `nip01Core.relay.client` | +| Subscribe | `client.openReqSubscription(subId, mapOf(relay to filters), listener)` | `nip01Core.relay.client` | +| Publish | `client.send(event, setOf(relayUrl))` | `nip01Core.relay.client` | +| NIP-44 encrypt | `signer.nip44Encrypt(text, recipientPubKey)` | `nip01Core.signers` | +| Bech32 decode | `Nip19Parser.uriToRoute("npub1...")` | `nip19Bech32` | +| Bech32 encode | `Nip19Bech32.createNPub(pubKeyHex)` | `nip19Bech32` | + +## Common Event Kinds + +| Kind | Event Type | NIP | Quartz class | +|------|-----------|-----|-------------| +| 0 | User metadata | 01 | `MetadataEvent` | +| 1 | Text note | 10 | `TextNoteEvent` | +| 3 | Follow list | 02 | `ContactListEvent` | +| 4 | Legacy DM | 04 | `PrivateDmEvent` | +| 5 | Deletion | 09 | `DeletionEvent` | +| 6 | Repost | 18 | `RepostEvent` | +| 7 | Reaction | 25 | `ReactionEvent` | +| 14 | Chat message (sealed) | 17 | `NIP17GroupMessage` | +| 1059 | Gift wrap | 59 | `GiftWrapEvent` | +| 9734 | Zap request | 57 | `LnZapRequestEvent` | +| 9735 | Zap receipt | 57 | `LnZapEvent` | +| 10002 | Relay list | 65 | `AdvertisedRelayListEvent` | +| 30023 | Long-form content | 23 | `LongTextNoteEvent` | + +## Related Skills + +- **nostr-expert** — Internal Quartz patterns for Amethyst development +- **kotlin-multiplatform** — KMP source sets, expect/actual patterns +- **kotlin-coroutines** — Flow patterns for relay event streams diff --git a/.claude/skills/quartz-integration/references/gradle-setup.md b/.claude/skills/quartz-integration/references/gradle-setup.md new file mode 100644 index 0000000000..7a17c0be5e --- /dev/null +++ b/.claude/skills/quartz-integration/references/gradle-setup.md @@ -0,0 +1,143 @@ +# Quartz Gradle Dependency Setup + +## Current version + +``` +com.vitorpamplona.quartz:quartz:1.05.1 +``` + +Check latest: https://central.sonatype.com/artifact/com.vitorpamplona.quartz/quartz + +--- + +## KMP Project Setup + +### `gradle/libs.versions.toml` + +```toml +[versions] +quartz = "1.05.1" + +[libraries] +quartz = { module = "com.vitorpamplona.quartz:quartz", version.ref = "quartz" } +``` + +### `build.gradle.kts` (library/app module) + +```kotlin +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidKotlinMultiplatformLibrary) // or androidLibrary/androidApplication +} + +kotlin { + jvm() + androidLibrary { + namespace = "com.example.myapp" + compileSdk = 35 + minSdk = 21 + } + // optional: iOS targets + + sourceSets { + commonMain.dependencies { + implementation(libs.quartz) + } + // No platform-specific deps needed — Quartz provides them transitively + } +} +``` + +--- + +## Android-only Project + +```kotlin +// build.gradle.kts (app module) +dependencies { + implementation("com.vitorpamplona.quartz:quartz:1.05.1") +} +``` + +--- + +## JVM (Desktop) standalone + +```kotlin +// build.gradle.kts +plugins { + kotlin("jvm") +} + +dependencies { + implementation("com.vitorpamplona.quartz:quartz:1.05.1") + // JNA needed for libsodium (NIP-44) on JVM + implementation("net.java.dev.jna:jna:5.18.1") +} +``` + +--- + +## Transitive Dependencies (what you get automatically) + +### All platforms (`commonMain`) +- `org.jetbrains.kotlin:kotlin-stdlib` +- `org.jetbrains.kotlinx:kotlinx-coroutines-core` +- `org.jetbrains.kotlinx:kotlinx-collections-immutable` +- `org.jetbrains.kotlinx:kotlinx-serialization-json` +- `androidx.collection:collection` (LruCache) +- `androidx.compose.runtime:runtime-annotation` (@Immutable/@Stable) +- `fr.acinq.secp256k1:secp256k1-kmp` (Schnorr crypto — common) + +### JVM + Android (`jvmAndroid`) +- `com.github.anthonynsimon:rfc3986-normalizer` (URL normalization) +- `com.fasterxml.jackson.module:jackson-module-kotlin` (JSON) +- `com.linkedin.urls:url-detector` (URL extraction) +- `com.squareup.okhttp3:okhttp` (WebSocket) +- `ru.gildor.coroutines:kotlin-coroutines-okhttp` +- `nl.bommber:kchesslib` (NIP-64 chess, version pinned to 1.0.0) + +### JVM only +- `fr.acinq.secp256k1:secp256k1-kmp-jni-jvm` +- `com.goterl:lazysodium-java` (NIP-44 encryption) +- `net.java.dev.jna:jna` + +### Android only +- `fr.acinq.secp256k1:secp256k1-kmp-jni-android` +- `com.goterl:lazysodium-android` +- `net.java.dev.jna:jna` (aar) +- `androidx.core:core-ktx` + +--- + +## Packaging / ProGuard + +Quartz ships consumer ProGuard rules automatically (`publish = true` in the library). +You don't need to add manual keep rules for Quartz classes in your app. + +For Android release builds, these classes are preserved: +- All `com.vitorpamplona.quartz.**` event and model classes +- Jackson serialization annotations + +--- + +## Common Build Errors + +### `Duplicate class kotlin.collections.jdk8` +Add to `gradle.properties`: +```properties +android.useFullClasspathForDexingTransform=true +``` + +### `Could not find lazysodium-java` on JVM +Ensure JNA is on the classpath: +```kotlin +implementation("net.java.dev.jna:jna:5.18.1") +``` + +### iOS: `Framework not found quartz-kmpKit` +Build the XCFramework first: +```bash +./gradlew :quartz:assembleQuartz-kmpKitReleaseXCFramework +``` +Then drag `quartz/build/XCFrameworks/release/quartz-kmpKit.xcframework` into Xcode. diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md index 49e5000ffb..246c840e37 100644 --- a/.claude/skills/quartz-kmp.md +++ b/.claude/skills/quartz-kmp.md @@ -1,165 +1,23 @@ -# Quartz KMP Conversion Skill +# Quartz KMP (Legacy Skill — Migration Complete) -When working with Quartz library conversion to Kotlin Multiplatform: +> The KMP migration of Quartz is **complete**. This file is kept for historical reference. +> +> For integrating Quartz into external projects, use the **`quartz-integration`** skill instead. +> For working with Quartz internals within Amethyst, use the **`nostr-expert`** skill. + +## What was migrated + +The Quartz library was successfully converted from Android-only to full KMP supporting: +- **commonMain** — All Nostr protocol logic, events, filters, tags +- **jvmAndroid** — OkHttp WebSocket, Jackson JSON, relay serializers +- **androidMain** — SQLite event store, NIP-55 Android signer +- **jvmMain** — Desktop JVM crypto (lazysodium-java, secp256k1-jni-jvm) +- **iosMain** — iOS targets (XCFramework `quartz-kmpKit`) + +## Current artifact -## Current Structure (Android-only) ``` -quartz/ -├── build.gradle -└── src/ - ├── main/kotlin/ # All Nostr code here - ├── test/ - └── androidTest/ +com.vitorpamplona.quartz:quartz:1.05.1 ``` -## Target Structure (KMP) -``` -quartz/ -├── build.gradle.kts -└── src/ - ├── commonMain/kotlin/ # Shared protocol code - ├── commonTest/kotlin/ # Shared tests - ├── androidMain/kotlin/ # Android crypto, storage - ├── androidTest/kotlin/ - ├── jvmMain/kotlin/ # Desktop crypto, storage - └── jvmTest/kotlin/ -``` - -## Platform Abstractions Required - -### 1. Cryptography (expect/actual) -```kotlin -// commonMain -expect object Secp256k1 { - fun sign(data: ByteArray, privateKey: ByteArray): ByteArray - fun verify(data: ByteArray, signature: ByteArray, pubKey: ByteArray): Boolean - fun pubKeyCreate(privateKey: ByteArray): ByteArray -} - -// androidMain - uses secp256k1-kmp-jni-android -actual object Secp256k1 { - actual fun sign(data: ByteArray, privateKey: ByteArray): ByteArray { - return fr.acinq.secp256k1.Secp256k1.sign(data, privateKey) - } - // ... -} - -// jvmMain - uses secp256k1-kmp-jni-jvm -actual object Secp256k1 { - actual fun sign(data: ByteArray, privateKey: ByteArray): ByteArray { - return fr.acinq.secp256k1.Secp256k1.sign(data, privateKey) - } - // ... -} -``` - -### 2. NIP-44 Encryption (Sodium) -```kotlin -// commonMain -expect object Nip44 { - fun encrypt(plaintext: String, sharedSecret: ByteArray): String - fun decrypt(ciphertext: String, sharedSecret: ByteArray): String -} - -// androidMain - lazysodium-android -// jvmMain - lazysodium-java or libsodium-jni -``` - -### 3. Secure Random -```kotlin -// commonMain -expect fun secureRandomBytes(size: Int): ByteArray - -// androidMain -actual fun secureRandomBytes(size: Int): ByteArray { - return SecureRandom().let { random -> - ByteArray(size).also { random.nextBytes(it) } - } -} - -// jvmMain -actual fun secureRandomBytes(size: Int): ByteArray { - return java.security.SecureRandom().let { random -> - ByteArray(size).also { random.nextBytes(it) } - } -} -``` - -## Build Configuration - -```kotlin -// quartz/build.gradle.kts -plugins { - kotlin("multiplatform") - id("com.android.library") -} - -kotlin { - androidTarget { - compilations.all { - kotlinOptions.jvmTarget = "17" - } - } - - jvm("desktop") { - compilations.all { - kotlinOptions.jvmTarget = "17" - } - } - - sourceSets { - val commonMain by getting { - dependencies { - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.collections.immutable) - } - } - - val androidMain by getting { - dependencies { - implementation(libs.secp256k1.kmp.jni.android) - implementation(libs.lazysodium.android) - } - } - - val desktopMain by getting { - dependencies { - implementation(libs.secp256k1.kmp.jni.jvm) - // lazysodium-java or alternative - } - } - } -} - -android { - namespace = "com.vitorpamplona.quartz" - compileSdk = 35 - defaultConfig.minSdk = 26 -} -``` - -## Migration Steps - -1. **Convert build.gradle to build.gradle.kts** with KMP plugin -2. **Move pure Kotlin code** to `commonMain/` -3. **Identify platform dependencies** (crypto, JNA, Android APIs) -4. **Create expect declarations** for platform-specific APIs -5. **Implement actuals** in androidMain and jvmMain -6. **Update imports** in amethyst module -7. **Test on both platforms** - -## Files to Move to commonMain - -Most of Quartz can be shared: -- Event classes and parsing -- Filter definitions -- Relay message types -- NIP implementations (logic only) -- Utilities (hex encoding, bech32) - -## Files Needing expect/actual - -- `Secp256k1.kt` - Signature operations -- `Nip04.kt` - Legacy encryption (uses AES) -- `Nip44.kt` - Modern encryption (uses ChaCha) -- `KeyPair.kt` - Key generation +See `.claude/skills/quartz-integration/SKILL.md` for full integration guide. \ No newline at end of file diff --git a/.gitignore b/.gitignore index e1e4e4ec7e..3b77c9bd65 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,9 @@ /.idea/AndroidProjectSystem.xml /.idea/deviceManager.xml /.idea/inspectionProfiles/ +/commons/.idea/gradle.xml +/commons/.idea/misc.xml +/commons/.idea/workspace.xml .DS_Store /build /captures diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index b65547ca69..513727d825 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -6,10 +6,6 @@ - - diff --git a/README.md b/README.md index 2762617d6f..28219bf875 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 432929a9f0..fe6c881a89 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 b3dcb47c54..3aebe1c1c0 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 9bbfce57b1..7339ba1bb1 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 f89c08a226..5599344076 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/AppModules.kt @@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model.Notify import com.vitorpamplona.amethyst.service.relayClient.reqCommand.RelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.service.relayClient.speedLogger.RelaySpeedLogger import com.vitorpamplona.amethyst.service.uploads.nip95.Nip95CacheFactory +import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.UiSettingsState import com.vitorpamplona.amethyst.ui.tor.TorManager import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -73,6 +74,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 @@ -88,7 +90,6 @@ class AppModules( } val applicationIOScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler) - val applicationDefaultScope = CoroutineScope(Dispatchers.IO + SupervisorJob() + exceptionHandler) // Blocking load of UI Preferences to avoid theme/language blinking val uiPrefs by lazy { @@ -160,7 +161,7 @@ class AppModules( TorRelayState( okHttpClients, torPrefs.value, - applicationDefaultScope, + applicationIOScope, ) // Connects the NostrClient class with okHttp @@ -174,7 +175,7 @@ class AppModules( val cache: LocalCache = LocalCache // Provides a relay pool - val client: INostrClient = NostrClient(websocketBuilder, applicationDefaultScope) + val client: INostrClient = NostrClient(websocketBuilder, applicationIOScope) // Watches for changes on Tor and Relay List Settings val relayProxyClientConnector = @@ -184,7 +185,7 @@ class AppModules( connManager, torManager, client, - applicationDefaultScope, + applicationIOScope, ) // Verifies and inserts in the cache from all relays, all subscriptions @@ -194,10 +195,10 @@ class AppModules( val notifyCoordinator = NotifyCoordinator(client) // Authenticates with relays. - val authCoordinator = AuthCoordinator(client, applicationDefaultScope) + val authCoordinator = AuthCoordinator(client, applicationIOScope) // Tries to verify new OTS events when they arrive. - val otsEventVerifier = IncomingOtsEventVerifier(otsVerifCache, cache, applicationDefaultScope) + val otsEventVerifier = IncomingOtsEventVerifier(otsVerifCache, cache, applicationIOScope) // Tracks if it is possible to connect to relays. val failureTracker = RelayOfflineTracker(client) @@ -217,7 +218,7 @@ class AppModules( client, authCoordinator.receiver, failureTracker, - applicationDefaultScope, + applicationIOScope, ) // keeps all accounts live @@ -231,12 +232,21 @@ class AppModules( client = client, ) + val sessionManager = + AccountSessionManager( + accountsCache = accountsCache, + nip05Client = nip05Client, + client = client, + localPreferences = LocalPreferences, + scope = applicationIOScope, + ) + // Organizes cache clearing val trimmingService = MemoryTrimmingService(cache) // as new accounts are loaded, updates the state of the TorRelaySettings, which produces new TorRelayEvaluator // and reconnects relays if the configuration has been changed. - val accountsTorStateConnector = AccountsTorStateConnector(accountsCache, torEvaluatorFlow, applicationDefaultScope) + val accountsTorStateConnector = AccountsTorStateConnector(accountsCache, torEvaluatorFlow, applicationIOScope) // saves the .content of NIP-95 blobs in disk to save memory val nip95cache: File by lazy { Nip95CacheFactory.new(appContext) } @@ -274,6 +284,7 @@ class AppModules( applicationIOScope.launch { // loads main account quickly. LocalPreferences.loadAccountConfigFromEncryptedStorage() + sessionManager.loginWithDefaultAccountIfLoggedOff() } // forces initialization of uiPrefs in the main thread to avoid blinking themes @@ -293,17 +304,23 @@ 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) { pokeyReceiver.unregister(appContext) applicationIOScope.cancel("Application onTerminate $appContext") - applicationDefaultScope.cancel("Application onTerminate $appContext") accountsCache.clear() } fun trim() { - applicationDefaultScope.launch { + applicationIOScope.launch { val loggedIn = accountsCache.accounts.value.values trimmingService.run(loggedIn, LocalPreferences.allSavedAccounts()) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt index e7011a919f..50b6ad326f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/LocalPreferences.kt @@ -25,9 +25,8 @@ import android.content.Context import android.content.SharedPreferences import androidx.compose.runtime.Immutable import androidx.core.content.edit -import com.vitorpamplona.amethyst.model.ALL_FOLLOWS import com.vitorpamplona.amethyst.model.AccountSettings -import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.UiSettings import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS @@ -54,6 +53,7 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -102,6 +102,7 @@ private object PrefKeys { const val LATEST_NIP65_RELAY_LIST = "latestNIP65RelayList" const val LATEST_SEARCH_RELAY_LIST = "latestSearchRelayList" const val LATEST_INDEX_RELAY_LIST = "latestIndexRelayList" + const val LATEST_FAVORITE_RELAY_LIST = "latestFavoriteRelayList" const val LATEST_BLOCKED_RELAY_LIST = "latestBlockedRelayList" const val LATEST_TRUSTED_RELAY_LIST = "latestTrustedRelayList" const val LATEST_MUTE_LIST = "latestMuteList" @@ -320,16 +321,11 @@ object LocalPreferences { PrefKeys.DEFAULT_FILE_SERVER, JsonMapper.toJson(settings.defaultFileServer), ) - putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, settings.defaultHomeFollowList.value) - putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, settings.defaultStoriesFollowList.value) - putString( - PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, - settings.defaultNotificationFollowList.value, - ) - putString( - PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, - settings.defaultDiscoveryFollowList.value, - ) + + putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value)) + putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value)) + putString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, JsonMapper.toJson(settings.defaultNotificationFollowList.value)) + putString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, JsonMapper.toJson(settings.defaultDiscoveryFollowList.value)) putOrRemove(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER, settings.zapPaymentRequest.value?.denormalize()) @@ -340,6 +336,7 @@ object LocalPreferences { putOrRemove(PrefKeys.LATEST_NIP65_RELAY_LIST, settings.backupNIP65RelayList) putOrRemove(PrefKeys.LATEST_SEARCH_RELAY_LIST, settings.backupSearchRelayList) putOrRemove(PrefKeys.LATEST_INDEX_RELAY_LIST, settings.backupIndexRelayList) + putOrRemove(PrefKeys.LATEST_FAVORITE_RELAY_LIST, settings.backupIndexRelayList) putOrRemove(PrefKeys.LATEST_BLOCKED_RELAY_LIST, settings.backupBlockedRelayList) putOrRemove(PrefKeys.LATEST_TRUSTED_RELAY_LIST, settings.backupTrustedRelayList) @@ -457,14 +454,10 @@ object LocalPreferences { getString(PrefKeys.SIGNER_PACKAGE_NAME, null) ?: if (getBoolean(PrefKeys.LOGIN_WITH_EXTERNAL_SIGNER, false)) "com.greenart7c3.nostrsigner" else null - val defaultHomeFollowList = - getString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, null) ?: ALL_FOLLOWS - val defaultStoriesFollowList = - getString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS - val defaultNotificationFollowList = - getString(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS - val defaultDiscoveryFollowList = - getString(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST, null) ?: GLOBAL_FOLLOWS + val defaultHomeFollowList = parseOrNull(PrefKeys.DEFAULT_HOME_FOLLOW_LIST) ?: TopFilter.AllFollows + val defaultStoriesFollowList = parseOrNull(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST) ?: TopFilter.Global + val defaultNotificationFollowList = parseOrNull(PrefKeys.DEFAULT_NOTIFICATION_FOLLOW_LIST) ?: TopFilter.Global + val defaultDiscoveryFollowList = parseOrNull(PrefKeys.DEFAULT_DISCOVERY_FOLLOW_LIST) ?: TopFilter.Global val zapPaymentRequestServer = parseOrNull(PrefKeys.ZAP_PAYMENT_REQUEST_SERVER) val defaultFileServer = parseOrNull(PrefKeys.DEFAULT_FILE_SERVER) ?: DEFAULT_MEDIA_SERVERS[0] @@ -478,6 +471,7 @@ object LocalPreferences { val latestNip65RelayList = parseEventOrNull(PrefKeys.LATEST_NIP65_RELAY_LIST) val latestSearchRelayList = parseEventOrNull(PrefKeys.LATEST_SEARCH_RELAY_LIST) val latestIndexRelayList = parseEventOrNull(PrefKeys.LATEST_INDEX_RELAY_LIST) + val latestFavoriteRelayList = parseEventOrNull(PrefKeys.LATEST_FAVORITE_RELAY_LIST) val latestBlockedRelayList = parseEventOrNull(PrefKeys.LATEST_BLOCKED_RELAY_LIST) val latestTrustedRelayList = parseEventOrNull(PrefKeys.LATEST_TRUSTED_RELAY_LIST) val latestMuteList = parseEventOrNull(PrefKeys.LATEST_MUTE_LIST) @@ -523,6 +517,7 @@ object LocalPreferences { backupDMRelayList = latestDmRelayList, backupSearchRelayList = latestSearchRelayList, backupIndexRelayList = latestIndexRelayList, + backupFavoriteRelayList = latestFavoriteRelayList, backupBlockedRelayList = latestBlockedRelayList, backupTrustedRelayList = latestTrustedRelayList, backupPrivateHomeRelayList = latestPrivateHomeRelayList, 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 d29997fae7..d17e2a3492 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -60,6 +60,8 @@ import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayLis import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState +import com.vitorpamplona.amethyst.model.nip51Lists.favoriteRelays.FavoriteRelayListDecryptionCache +import com.vitorpamplona.amethyst.model.nip51Lists.favoriteRelays.FavoriteRelayListState import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListDecryptionCache import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListDecryptionCache @@ -82,14 +84,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 +192,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 @@ -229,7 +231,7 @@ import kotlin.coroutines.cancellation.CancellationException @Stable class Account( val settings: AccountSettings = AccountSettings(KeyPair()), - val signer: NostrSigner, + override val signer: NostrSigner, val geolocationFlow: StateFlow, val nwcFilterAssembler: NWCPaymentFilterAssembler, val otsResolverBuilder: OtsResolverBuilder, @@ -275,6 +277,9 @@ class Account( val indexerRelayListDecryptionCache = IndexerRelayListDecryptionCache(signer) val indexerRelayList = IndexerRelayListState(signer, cache, indexerRelayListDecryptionCache, scope, settings) + val favoriteRelayListDecryptionCache = FavoriteRelayListDecryptionCache(signer) + val favoriteRelayList = FavoriteRelayListState(signer, cache, favoriteRelayListDecryptionCache, scope, settings) + val blockedRelayListDecryptionCache = BlockedRelayListDecryptionCache(signer) val blockedRelayList = BlockedRelayListState(signer, cache, blockedRelayListDecryptionCache, scope, settings) @@ -315,8 +320,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) @@ -349,7 +352,7 @@ class Account( override val privateZapsDecryptionCache = PrivateZapCache(signer) val draftsDecryptionCache = DraftEventCache(signer) - val chatroomList = cache.getOrCreateChatroomList(signer.pubKey) + override val chatroomList = cache.getOrCreateChatroomList(signer.pubKey) val newNotesPreProcessor = EventProcessor(this, cache) @@ -527,13 +530,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 @@ -798,12 +800,7 @@ class Account( .dmInboxRelayList() ?.relays() ?.ifEmpty { null } - if (relayList != null) { - client.send(event, relayList.toSet()) - } else { - val publicRelayList = computeRelayListForLinkedUser(receiver) - client.send(event, publicRelayList) - } + return relayList?.toSet() ?: computeRelayListForLinkedUser(receiver) } else { return emptySet() } @@ -882,6 +879,10 @@ class Account( } } + if (event is PollEvent) { + relayList.addAll(event.relays()) + } + relayList.addAll(computeRelaysForChannels(event)) return relayList @@ -907,12 +908,15 @@ class Account( note.relays.associateWith { relay -> listOf( Filter( + kinds = listOf(host.kind), + tags = mapOf("p" to listOf(pubKey)), ids = listOf(host.id), ), ) }, )?.let { downloadedEvent -> - client.send(downloadedEvent, computeRelayListToBroadcast(downloadedEvent)) + val toRelays = computeRelayListToBroadcast(downloadedEvent) + client.send(downloadedEvent, toRelays) } } } else { @@ -991,6 +995,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 +1358,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 +1385,7 @@ class Account( suspend fun updateInteractiveStoryReadingState( readingState: InteractiveStoryReadingStateEvent, - readingScene: InteractiveStoryBaseEvent, - readingSceneRelay: NormalizedRelayUrl?, + readingScene: EventHintBundle, ) { if (!isWriteable()) return @@ -1377,7 +1393,6 @@ class Account( InteractiveStoryReadingStateEvent.update( base = readingState, currentScene = readingScene, - currentSceneRelay = readingSceneRelay, ) val event = signer.sign(template) @@ -1555,7 +1570,7 @@ class Account( broadcast.forEach { client.send(it, relayList) } } - suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) { + override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) { if (!isWriteable()) return val newEvent = signer.sign(eventTemplate) @@ -1573,11 +1588,18 @@ class Account( broadcastPrivately(wraps) } - suspend fun sendNip17PrivateMessage(template: EventTemplate) { + override suspend fun sendNip17PrivateMessage(template: EventTemplate) { val events = NIP17Factory().createMessageNIP17(template, signer) broadcastPrivately(events) } + override suspend fun sendGiftWraps(wraps: List) { + wraps.forEach { wrap -> + val relayList = computeRelayListToBroadcast(wrap) + client.send(wrap, relayList) + } + } + suspend fun broadcastPrivately(signedEvents: NIP17Factory.Result) { val mine = signedEvents.wraps.filter { (it.recipientPubKey() == signer.pubKey) } @@ -1854,7 +1876,7 @@ class Account( fun isKnown(user: HexKey): Boolean = user in allFollows.flow.value.authors - fun isAcceptable(note: Note): Boolean { + override fun isAcceptable(note: Note): Boolean { return note.author?.let { isAcceptable(it) } ?: true && // if user hasn't hided this author isAcceptableDirect(note) && @@ -1896,12 +1918,16 @@ class Account( suspend fun saveTrustedRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(trustedRelayList.saveRelayList(trustedRelays)) + suspend fun saveFavoriteRelayList(trustedRelays: List) = sendMyPublicAndPrivateOutbox(favoriteRelayList.saveRelayList(trustedRelays)) + + suspend fun followFavoriteRelay(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(favoriteRelayList.addRelay(url)) + + suspend fun unfollowFavoriteRelay(url: NormalizedRelayUrl) = sendMyPublicAndPrivateOutbox(favoriteRelayList.removeRelay(url)) + suspend fun saveBlockedRelayList(blockedRelays: List) = sendMyPublicAndPrivateOutbox(blockedRelayList.saveRelayList(blockedRelays)) 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 d26cc68f1e..8edbdad230 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -29,6 +29,7 @@ import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -45,6 +46,7 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip55AndroidSigner.api.CommandType @@ -60,6 +62,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update +import kotlinx.serialization.Serializable import java.util.Locale val DefaultChannels = @@ -81,7 +84,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) @@ -96,20 +99,58 @@ val DefaultSignerPermissions = Permission(CommandType.DECRYPT_ZAP_EVENT), ) -// This has spaces to avoid mixing with a potential NIP-51 list with the same name. -val GLOBAL_FOLLOWS = " Global " +@Serializable +sealed class TopFilter( + val code: String, +) { + @Serializable + object Global : TopFilter(" Global ") -// This has spaces to avoid mixing with a potential NIP-51 list with the same name. -val ALL_FOLLOWS = " All Follows " + @Serializable + object AllFollows : TopFilter(" All Follows ") -// This has spaces to avoid mixing with a potential NIP-51 list with the same name. -val ALL_USER_FOLLOWS = " All User Follows " + @Serializable + object AllUserFollows : TopFilter(" All User Follows ") -// This has spaces to avoid mixing with a potential NIP-51 list with the same name. -val KIND3_FOLLOWS = " Main User Follows " + @Serializable + object DefaultFollows : TopFilter(" Main User Follows ") -// This has spaces to avoid mixing with a potential NIP-51 list with the same name. -val AROUND_ME = " Around Me " + @Serializable + object AroundMe : TopFilter(" Around Me ") + + @Serializable + object Chess : TopFilter(" Chess ") + + @Serializable + class PeopleList( + val address: Address, + ) : TopFilter(address.toValue()) + + @Serializable + class MuteList( + val address: Address, + ) : TopFilter(address.toValue()) + + @Serializable + class Community( + val address: Address, + ) : TopFilter("Community/${address.toValue()}") + + @Serializable + class Hashtag( + val tag: String, + ) : TopFilter("Hashtag/$tag") + + @Serializable + class Geohash( + val tag: String, + ) : TopFilter("Geohash/$tag") + + @Serializable + class Relay( + val url: String, + ) : TopFilter("Relay/$url") +} @Stable class AccountSettings( @@ -118,10 +159,10 @@ class AccountSettings( var externalSignerPackageName: String? = null, var localRelayServers: MutableStateFlow> = MutableStateFlow(setOf()), var defaultFileServer: ServerName = DEFAULT_MEDIA_SERVERS[0], - val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(ALL_FOLLOWS), - val defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), - val defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), - val defaultDiscoveryFollowList: MutableStateFlow = MutableStateFlow(GLOBAL_FOLLOWS), + val defaultHomeFollowList: MutableStateFlow = MutableStateFlow(TopFilter.AllFollows), + val defaultStoriesFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultNotificationFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), + val defaultDiscoveryFollowList: MutableStateFlow = MutableStateFlow(TopFilter.Global), val zapPaymentRequest: MutableStateFlow = MutableStateFlow(null), var hideDeleteRequestDialog: Boolean = false, var hideBlockAlertDialog: Boolean = false, @@ -132,6 +173,7 @@ class AccountSettings( var backupNIP65RelayList: AdvertisedRelayListEvent? = null, var backupSearchRelayList: SearchRelayListEvent? = null, var backupIndexRelayList: IndexerRelayListEvent? = null, + var backupFavoriteRelayList: FavoriteRelayListEvent? = null, var backupBlockedRelayList: BlockedRelayListEvent? = null, var backupTrustedRelayList: TrustedRelayListEvent? = null, var backupMuteList: MuteListEvent? = null, @@ -221,7 +263,7 @@ class AccountSettings( changeDefaultHomeFollowList(name.code) } - fun changeDefaultHomeFollowList(name: String) { + fun changeDefaultHomeFollowList(name: TopFilter) { if (defaultHomeFollowList.value != name) { defaultHomeFollowList.tryEmit(name) saveAccountSettings() @@ -232,7 +274,7 @@ class AccountSettings( changeDefaultStoriesFollowList(name.code) } - fun changeDefaultStoriesFollowList(name: String) { + fun changeDefaultStoriesFollowList(name: TopFilter) { if (defaultStoriesFollowList.value != name) { defaultStoriesFollowList.tryEmit(name) saveAccountSettings() @@ -243,7 +285,7 @@ class AccountSettings( changeDefaultNotificationFollowList(name.code) } - fun changeDefaultNotificationFollowList(name: String) { + fun changeDefaultNotificationFollowList(name: TopFilter) { if (defaultNotificationFollowList.value != name) { defaultNotificationFollowList.tryEmit(name) saveAccountSettings() @@ -254,7 +296,7 @@ class AccountSettings( changeDefaultDiscoveryFollowList(name.code) } - fun changeDefaultDiscoveryFollowList(name: String) { + fun changeDefaultDiscoveryFollowList(name: TopFilter) { if (defaultDiscoveryFollowList.value != name) { defaultDiscoveryFollowList.tryEmit(name) saveAccountSettings() @@ -374,6 +416,16 @@ class AccountSettings( } } + fun updateFavoriteRelayList(newFavoriteRelayList: FavoriteRelayListEvent?) { + if (newFavoriteRelayList == null || newFavoriteRelayList.tags.isEmpty()) return + + // Events might be different objects, we have to compare their ids. + if (backupFavoriteRelayList?.id != newFavoriteRelayList.id) { + backupFavoriteRelayList = newFavoriteRelayList + saveAccountSettings() + } + } + fun updateBlockedRelayList(newBlockedRelayList: BlockedRelayListEvent?) { if (newBlockedRelayList == null || newBlockedRelayList.tags.isEmpty()) return 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 3ac691e917..417b4d7b87 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 df3ae4ed4d..7172894604 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 f4f8f743b4..5da610d013 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 @@ -133,6 +132,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.nip40Expiration.isExpirationBefore import com.vitorpamplona.quartz.nip40Expiration.isExpired import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent @@ -148,6 +148,7 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent @@ -168,6 +169,13 @@ import com.vitorpamplona.quartz.nip58Badges.BadgeProfilesEvent import com.vitorpamplona.quartz.nip59Giftwrap.WrappedEvent import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.JesterEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessDrawOfferEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameAcceptEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessMoveEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent @@ -180,6 +188,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 @@ -193,6 +203,7 @@ import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -219,7 +230,9 @@ interface ILocalCache { fun markAsSeen( eventId: String, relay: NormalizedRelayUrl, - ) {} + ) { + // Default no-op; implementations may override to track seen events per relay + } } object LocalCache : ILocalCache, ICacheProvider { @@ -411,18 +424,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) @@ -551,6 +564,12 @@ object LocalCache : ILocalCache, ICacheProvider { return false } + fun consume( + event: ExternalIdentitiesEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + fun consume( event: ContactListEvent, relay: NormalizedRelayUrl?, @@ -715,6 +734,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?, @@ -1150,6 +1212,12 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeBaseReplaceable(event, relay, wasVerified) + private fun consume( + event: FavoriteRelayListEvent, + relay: NormalizedRelayUrl?, + wasVerified: Boolean, + ) = consumeBaseReplaceable(event, relay, wasVerified) + private fun consume( event: TrustProviderListEvent, relay: NormalizedRelayUrl?, @@ -1355,10 +1423,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 +1591,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 +2075,38 @@ object LocalCache : ILocalCache, ICacheProvider { wasVerified: Boolean, ) = consumeRegularEvent(event, relay, wasVerified) + fun consume( + event: CodeSnippetEvent, + relay: NormalizedRelayUrl?, + 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 +2720,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 +2792,7 @@ object LocalCache : ILocalCache, ICacheProvider { note?.event?.let { noteEvent -> if (noteEvent is AddressableEvent) { - getAddressableNoteIfExists(noteEvent.aTag().toTag())?.addRelay(relay) + getAddressableNoteIfExists(noteEvent.address())?.addRelay(relay) } } @@ -2951,6 +3066,7 @@ object LocalCache : ILocalCache, ICacheProvider { is EmojiPackSelectionEvent -> consume(event, relay, wasVerified) is EphemeralChatEvent -> consume(event, relay, wasVerified) is EphemeralChatListEvent -> consume(event, relay, wasVerified) + is ExternalIdentitiesEvent -> consume(event, relay, wasVerified) is GenericRepostEvent -> consume(event, relay, wasVerified) is FhirResourceEvent -> consume(event, relay, wasVerified) is FileHeaderEvent -> consume(event, relay, wasVerified) @@ -2966,6 +3082,14 @@ 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 FavoriteRelayListEvent -> 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) @@ -2997,7 +3121,10 @@ object LocalCache : ILocalCache, ICacheProvider { is PinListEvent -> consume(event, relay, wasVerified) is PublicMessageEvent -> consume(event, relay, wasVerified) is PeopleListEvent -> consume(event, relay, wasVerified) + is CodeSnippetEvent -> 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/UiSettingsFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt index bcdf57d018..c1981297e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -20,10 +20,12 @@ */ package com.vitorpamplona.amethyst.model +import androidx.compose.runtime.Stable import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine +@Stable class UiSettingsFlow( val theme: MutableStateFlow = MutableStateFlow(ThemeType.SYSTEM), val preferredLanguage: MutableStateFlow = MutableStateFlow(null), diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt index 9af5f06f6e..27c295c5ca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip01UserMetadata/UserMetadataState.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi @@ -51,6 +52,12 @@ class UserMetadataState( fun getUserMetadataEvent(): MetadataEvent? = note.event as? MetadataEvent + fun getIdentitiesEventAddress() = ExternalIdentitiesEvent.createAddress(signer.pubKey) + + val identitiesNote = cache.getOrCreateAddressableNote(getIdentitiesEventAddress()) + + fun getExternalIdentitiesEvent(): ExternalIdentitiesEvent? = identitiesNote.event as? ExternalIdentitiesEvent + suspend fun sendNewUserMetadata( name: String? = null, displayName: String? = null, @@ -62,9 +69,6 @@ class UserMetadataState( nip05: String? = null, lnAddress: String? = null, lnURL: String? = null, - twitter: String? = null, - mastodon: String? = null, - github: String? = null, ): MetadataEvent { val latest = getUserMetadataEvent() @@ -82,9 +86,6 @@ class UserMetadataState( nip05 = nip05, lnAddress = lnAddress, lnURL = lnURL, - twitter = twitter, - mastodon = mastodon, - github = github, ) } else { MetadataEvent.createNew( @@ -98,6 +99,29 @@ class UserMetadataState( nip05 = nip05, lnAddress = lnAddress, lnURL = lnURL, + ) + } + + return signer.sign(template) + } + + suspend fun sendNewUserIdentities( + twitter: String? = null, + mastodon: String? = null, + github: String? = null, + ): ExternalIdentitiesEvent { + val latest = getExternalIdentitiesEvent() + + val template = + if (latest != null) { + ExternalIdentitiesEvent.updateFromPast( + latest = latest, + twitter = twitter, + mastodon = mastodon, + github = github, + ) + } else { + ExternalIdentitiesEvent.createNew( twitter = twitter, mastodon = mastodon, github = github, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt index 2a9b560681..ed87c5deb0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip11RelayInfo/Nip11CachedRetriever.kt @@ -21,12 +21,14 @@ package com.vitorpamplona.amethyst.model.nip11RelayInfo import android.util.LruCache +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import okhttp3.OkHttpClient +@Stable class Nip11CachedRetriever( val okHttpClient: (NormalizedRelayUrl) -> OkHttpClient, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListDecryptionCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListDecryptionCache.kt new file mode 100644 index 0000000000..dee6884356 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListDecryptionCache.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.model.nip51Lists.favoriteRelays + +import com.vitorpamplona.amethyst.model.nip51Lists.relayLists.GenericRelayListCache +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent + +class FavoriteRelayListDecryptionCache( + signer: NostrSigner, +) : GenericRelayListCache(signer) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListState.kt new file mode 100644 index 0000000000..ac7cfe9bd9 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip51Lists/favoriteRelays/FavoriteRelayListState.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.model.nip51Lists.favoriteRelays + +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.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +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 +import kotlinx.coroutines.launch + +class FavoriteRelayListState( + val signer: NostrSigner, + val cache: LocalCache, + val decryptionCache: FavoriteRelayListDecryptionCache, + 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 favoriteListNote = cache.getOrCreateAddressableNote(getFavoriteRelayListAddress()) + + fun getFavoriteRelayListAddress() = FavoriteRelayListEvent.createAddress(signer.pubKey) + + fun getFavoriteRelayListFlow(): StateFlow = favoriteListNote.flow().metadata.stateFlow + + fun getFavoriteRelayList(): FavoriteRelayListEvent? = favoriteListNote.event as? FavoriteRelayListEvent + + fun favoriteListEvent(note: Note) = note.event as? FavoriteRelayListEvent ?: settings.backupFavoriteRelayList + + suspend fun normalizeFavoriteRelayListWithBackup(note: Note): Set = favoriteListEvent(note)?.let { decryptionCache.relays(it) }?.ifEmpty { null } ?: emptySet() + + suspend fun normalizeFavoriteRelayListWithBackupNoDefaults(note: Note): Set = favoriteListEvent(note)?.let { decryptionCache.relays(it) } ?: emptySet() + + val flow = + getFavoriteRelayListFlow() + .map { normalizeFavoriteRelayListWithBackup(it.note) } + .onStart { emit(normalizeFavoriteRelayListWithBackup(favoriteListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + val flowNoDefaults = + getFavoriteRelayListFlow() + .map { normalizeFavoriteRelayListWithBackupNoDefaults(it.note) } + .onStart { emit(normalizeFavoriteRelayListWithBackupNoDefaults(favoriteListNote)) } + .flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + emptySet(), + ) + + suspend fun addRelay(relay: NormalizedRelayUrl): FavoriteRelayListEvent { + val current = normalizeFavoriteRelayListWithBackupNoDefaults(favoriteListNote).toMutableList() + if (relay !in current) current.add(relay) + return saveRelayList(current) + } + + suspend fun removeRelay(relay: NormalizedRelayUrl): FavoriteRelayListEvent? { + val current = normalizeFavoriteRelayListWithBackupNoDefaults(favoriteListNote).toMutableList() + if (relay !in current) return null + current.remove(relay) + return saveRelayList(current) + } + + suspend fun saveRelayList(favoriteRelays: List): FavoriteRelayListEvent { + val relayListForFavorite = getFavoriteRelayList() + + return if (relayListForFavorite != null && relayListForFavorite.tags.isNotEmpty()) { + FavoriteRelayListEvent.updateRelayList( + earlierVersion = relayListForFavorite, + relays = favoriteRelays, + signer = signer, + ) + } else { + FavoriteRelayListEvent.create( + relays = favoriteRelays, + signer = signer, + ) + } + } + + init { + settings.backupFavoriteRelayList?.let { + Log.d("AccountRegisterObservers", "Loading saved favorite relay list ${it.toJson()}") + @OptIn(DelicateCoroutinesApi::class) + GlobalScope.launch(Dispatchers.IO) { LocalCache.justConsumeMyOwnEvent(it) } + } + + scope.launch(Dispatchers.IO) { + Log.d("AccountRegisterObservers", "Favorite Relay List Collector Start") + getFavoriteRelayListFlow().collect { + Log.d("AccountRegisterObservers", "Updating Favorite Relay List for ${signer.pubKey}") + (it.note.event as? FavoriteRelayListEvent)?.let { + settings.updateFavoriteRelayList(it) + } + } + } + } +} 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 0000000000..143f6ce8b4 --- /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 6868249962..0000000000 --- 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 39539fadfa..735868c780 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 7e6284ec7b..97422d41bd 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 76d918be79..aacb3e2621 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 9889f0b188..0000000000 --- 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 746776ac92..44d62f84ae 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 @@ -20,18 +20,15 @@ */ 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.GLOBAL_FOLLOWS -import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.nip02FollowLists.Kind3FollowListState import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsFeedFlow 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 @@ -51,7 +48,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest class FeedTopNavFilterState( - val feedFilterListName: MutableStateFlow, + val feedFilterListName: MutableStateFlow, val kind3Follows: StateFlow, val allFollows: StateFlow, val locationFlow: StateFlow, @@ -62,35 +59,84 @@ class FeedTopNavFilterState( val signer: NostrSigner, val scope: CoroutineScope, ) { - fun loadFlowsFor(listName: String): IFeedFlowsType = + fun loadFlowsFor(listName: TopFilter): IFeedFlowsType = when (listName) { - GLOBAL_FOLLOWS -> { + TopFilter.Global -> { GlobalFeedFlow(followsRelays, proxyRelays) } - ALL_FOLLOWS -> { + TopFilter.AllFollows -> { AllFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays) } - ALL_USER_FOLLOWS -> { + TopFilter.AllUserFollows -> { AllUserFollowsFeedFlow(allFollows, followsRelays, blockedRelays, proxyRelays) } - KIND3_FOLLOWS -> { + TopFilter.DefaultFollows -> { Kind3UserFollowsFeedFlow(kind3Follows, followsRelays, blockedRelays, proxyRelays) } - AROUND_ME -> { + TopFilter.AroundMe -> { AroundMeFeedFlow(locationFlow, followsRelays, proxyRelays) } - else -> { - val note = LocalCache.checkGetOrCreateAddressableNote(listName) - if (note != null) { - NoteFeedFlow(note.flow().metadata.stateFlow, signer, followsRelays, blockedRelays, proxyRelays, caches) - } else { - UnknownFeedFlow(listName) - } + TopFilter.Chess -> { + ChessFeedFlow(followsRelays, proxyRelays) + } + + is TopFilter.Community -> { + NoteFeedFlow( + LocalCache + .getOrCreateAddressableNote(listName.address) + .flow() + .metadata.stateFlow, + signer, + followsRelays, + blockedRelays, + proxyRelays, + caches, + ) + } + + is TopFilter.PeopleList -> { + NoteFeedFlow( + LocalCache + .getOrCreateAddressableNote(listName.address) + .flow() + .metadata.stateFlow, + signer, + followsRelays, + blockedRelays, + proxyRelays, + caches, + ) + } + + is TopFilter.MuteList -> { + NoteFeedFlow( + LocalCache + .getOrCreateAddressableNote(listName.address) + .flow() + .metadata.stateFlow, + signer, + followsRelays, + blockedRelays, + proxyRelays, + caches, + ) + } + + is TopFilter.Geohash -> { + UnknownFeedFlow(listName) + } + + is TopFilter.Hashtag -> { + UnknownFeedFlow(listName) + } + + is TopFilter.Relay -> { + UnknownFeedFlow(listName) } } 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 287589899b..2d323ad748 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 0000000000..de820177d8 --- /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 0000000000..49da8bb5fa --- /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 0000000000..cfc5898830 --- /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/model/topNavFeeds/unknown/UnknownFeedFlow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownFeedFlow.kt index 25eecd2eb2..f484134356 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownFeedFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownFeedFlow.kt @@ -20,13 +20,14 @@ */ package com.vitorpamplona.amethyst.model.topNavFeeds.unknown +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedFlowsType import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.flow.MutableStateFlow class UnknownFeedFlow( - val feedName: String, + val feedName: TopFilter, ) : IFeedFlowsType { override fun flow() = MutableStateFlow(UnknownTopNavFilter(feedName)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt index 4260d43ecd..d5734bda9d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/topNavFeeds/unknown/UnknownTopNavFilter.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.topNavFeeds.unknown import androidx.compose.runtime.Immutable import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.topNavFeeds.IFeedTopNavFilter import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -30,7 +31,7 @@ import kotlinx.coroutines.flow.MutableStateFlow @Immutable class UnknownTopNavFilter( - val feedName: String, + val feedName: TopFilter, ) : IFeedTopNavFilter { override fun matchAuthor(pubkey: HexKey) = false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt index 72f010dca6..d12b1ea081 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/cashu/melt/MeltProcessor.kt @@ -91,6 +91,7 @@ class MeltProcessor { onError: (String, String) -> Unit, context: Context, ) { + // TODO: Implement Cashu token melting via Lightning invoice } suspend fun feeCalculator( 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 e259a3bff9..fe33323c96 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 40a36abbf1..79119551f2 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 02c05ca05b..3375b79194 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 a16aeea85a..d8f4f68d30 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 42b49036f4..b1a4f3b514 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 cef1e7ef55..99a0b08831 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,60 @@ */ 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.gestures.detectTapGestures 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.input.pointer.pointerInput import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize +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.BottomGradientOverlay +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.controls.TopGradientOverlay import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.LoadedMediaItem import com.vitorpamplona.amethyst.service.playback.composable.wavefront.Waveform +import com.vitorpamplona.amethyst.service.playback.diskCache.isLiveStreaming import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +internal const val SKIP_SECONDS = 10 +internal const val SKIP_MILLIS = SKIP_SECONDS * 1000L + +internal fun Player.seekBackward() { + seekTo((currentPosition - SKIP_MILLIS).coerceAtLeast(0)) +} + +internal fun Player.skipForward() { + val newPosition = currentPosition + SKIP_MILLIS + seekTo(if (duration > 0) newPosition.coerceAtMost(duration) else newPosition) +} + +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 +84,70 @@ 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) } + val containerSize = remember { mutableStateOf(IntSize.Zero) } + val isLive = isLiveStreaming(mediaItem.src.videoUri) - 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) + Box( + modifier = + borderModifier + .onSizeChanged { containerSize.value = it } + .pointerInput(isLive, controllerState) { + detectTapGestures( + onTap = { controllerVisible.value = !controllerVisible.value }, + onDoubleTap = { offset -> + if (!isLive) { + val isLeftSide = offset.x < containerSize.value.width / 2 + if (isLeftSide) { + controllerState.controller.seekBackward() + } else { + controllerState.controller.skipForward() + } } - } - setControllerVisibilityListener( - PlayerView.ControllerVisibilityListener { visible -> - controllerVisible.value = visible == View.VISIBLE - onControllerVisibilityChanged?.let { callback -> callback(visible == View.VISIBLE) } - }, - ) - } - } - }, + }, + ) + }, + ) { + ContentFrame( + player = controllerState.controller, + modifier = videoModifier, + surfaceType = SURFACE_TYPE_TEXTURE_VIEW, + 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, + TopGradientOverlay( + controllerVisible = controllerVisible, + modifier = Modifier.align(Alignment.TopCenter), ) - } else { - controllerState.controller?.volume = 0f + + BottomGradientOverlay( + controllerVisible = controllerVisible, + modifier = Modifier.align(Alignment.BottomCenter), + ) + + RenderTopButtons( + mediaData = mediaItem.src, + controllerState = controllerState, + controllerVisible = controllerVisible, + onZoomClick = onDialog, + modifier = Modifier.align(Alignment.TopEnd), + accountViewModel = accountViewModel, + ) + + RenderCenterButtons( + controllerState = controllerState, + controllerVisible = controllerVisible, + modifier = Modifier.align(Alignment.Center), + isLiveStream = isLive, + ) + + 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 a5658a15a4..f7e31c1628 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 6f560f0c77..68e285b646 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 0000000000..280f86c9fe --- /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/GradientOverlay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/GradientOverlay.kt new file mode 100644 index 0000000000..938aa8ef9b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/GradientOverlay.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +private val FadeIn = fadeIn() +private val FadeOut = fadeOut() + +private val TopGradientColors = + listOf( + Color.Black.copy(alpha = 0.6f), + Color.Black.copy(alpha = 0.3f), + Color.Transparent, + ) + +private val BottomGradientColors = + listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.4f), + Color.Black.copy(alpha = 0.7f), + ) + +@Composable +private fun GradientOverlay( + controllerVisible: State, + colors: List, + height: Dp, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = controllerVisible.value, + modifier = modifier, + enter = FadeIn, + exit = FadeOut, + ) { + Box( + modifier = + Modifier + .fillMaxWidth() + .height(height) + .background(brush = Brush.verticalGradient(colors = colors)), + ) + } +} + +@Composable +fun TopGradientOverlay( + controllerVisible: State, + modifier: Modifier = Modifier, + height: Dp = 80.dp, +) = GradientOverlay(controllerVisible, TopGradientColors, height, modifier) + +@Composable +fun BottomGradientOverlay( + controllerVisible: State, + modifier: Modifier = Modifier, + height: Dp = 120.dp, +) = GradientOverlay(controllerVisible, BottomGradientColors, height, modifier) 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 0000000000..88628f69a2 --- /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.White.copy(alpha = 0.5f), + unplayedColor: Color = Color.White.copy(alpha = 0.3f), + scrubberColor: Color = playedColor, + rectHeightDp: Dp = 4.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 * 2f, + 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 60f95ceaf2..0fe24ca925 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,37 @@ 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.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 +100,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/OverflowMenu.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt new file mode 100644 index 0000000000..491634b68c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/OverflowMenu.kt @@ -0,0 +1,195 @@ +/* + * 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.MoreVert +import androidx.compose.material.icons.filled.PictureInPicture +import androidx.compose.material.icons.filled.SaveAlt +import androidx.compose.material.icons.filled.Share +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +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.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.graphics.Color +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.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size50Modifier +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +import com.vitorpamplona.amethyst.ui.theme.VolumeBottomIconSize + +private val FadeIn = fadeIn() +private val FadeOut = fadeOut() + +@Preview +@Composable +fun OverflowMenuButtonPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + OverflowMenuButton( + showShare = true, + showSave = true, + showPip = true, + onShareClick = {}, + onSaveClick = {}, + onPipClick = {}, + ) + } + } +} + +@Composable +fun AnimatedOverflowMenuButton( + controllerVisible: State, + modifier: Modifier = Modifier, + showShare: Boolean = true, + showSave: Boolean = true, + showPip: Boolean = false, + onShareClick: () -> Unit, + onSaveClick: () -> Unit, + onPipClick: () -> Unit, +) { + AnimatedVisibility( + visible = controllerVisible.value, + modifier = modifier, + enter = FadeIn, + exit = FadeOut, + ) { + OverflowMenuButton( + showShare = showShare, + showSave = showSave, + showPip = showPip, + onShareClick = onShareClick, + onSaveClick = onSaveClick, + onPipClick = onPipClick, + ) + } +} + +@Composable +fun OverflowMenuButton( + showShare: Boolean, + showSave: Boolean, + showPip: Boolean, + onShareClick: () -> Unit, + onSaveClick: () -> Unit, + onPipClick: () -> Unit, +) { + val menuExpanded = remember { mutableStateOf(false) } + + Box(modifier = VolumeBottomIconSize) { + Box( + Modifier + .clip(CircleShape) + .fillMaxSize(0.7f) + .align(Alignment.Center) + .background(MaterialTheme.colorScheme.background), + ) + + IconButton( + onClick = { menuExpanded.value = true }, + modifier = Size50Modifier, + ) { + Icon( + imageVector = Icons.Default.MoreVert, + contentDescription = stringRes(R.string.more_options), + tint = MaterialTheme.colorScheme.onBackground, + modifier = Size20Modifier, + ) + } + + DropdownMenu( + expanded = menuExpanded.value, + onDismissRequest = { menuExpanded.value = false }, + containerColor = Color.Black.copy(alpha = 0.85f), + ) { + if (showShare) { + DropdownMenuItem( + text = { Text(stringRes(R.string.share_or_save), color = Color.White) }, + onClick = { + menuExpanded.value = false + onShareClick() + }, + leadingIcon = { + Icon( + Icons.Default.Share, + contentDescription = null, + tint = Color.White, + ) + }, + ) + } + + if (showSave) { + DropdownMenuItem( + text = { Text(stringRes(R.string.save_to_gallery), color = Color.White) }, + onClick = { + menuExpanded.value = false + onSaveClick() + }, + leadingIcon = { + Icon( + Icons.Default.SaveAlt, + contentDescription = null, + tint = Color.White, + ) + }, + ) + } + + if (showPip) { + DropdownMenuItem( + text = { Text(stringRes(R.string.picture_in_picture), color = Color.White) }, + onClick = { + menuExpanded.value = false + onPipClick() + }, + leadingIcon = { + Icon( + Icons.Default.PictureInPicture, + contentDescription = null, + tint = Color.White, + ) + }, + ) + } + } + } +} 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 885f49a1b7..f3dc6f4ada 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 0000000000..ae4682787c --- /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.pause), + ) + } + } + } +} 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 0000000000..a23bd54917 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PlaybackSpeedPopUpButton.kt @@ -0,0 +1,169 @@ +/* + * 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.graphics.Color +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), + color = Color.White, + 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 0000000000..f8ca77825c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/PositionAndDurationText.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.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.graphics.Color +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, + color = Color.White, + 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 0000000000..905041c3d9 --- /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 0000000000..6b2f6df454 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderCenterButtons.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.service.playback.composable.controls + +import androidx.annotation.OptIn +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.media3.common.util.UnstableApi +import androidx.media3.ui.compose.state.rememberPlayPauseButtonState +import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState +import com.vitorpamplona.amethyst.service.playback.composable.seekBackward +import com.vitorpamplona.amethyst.service.playback.composable.skipForward +import kotlinx.coroutines.delay + +@OptIn(UnstableApi::class) +@Composable +fun RenderCenterButtons( + controllerState: MediaControllerState, + controllerVisible: MutableState, + modifier: Modifier, + isLiveStream: Boolean = false, +) { + val state = rememberPlayPauseButtonState(controllerState.controller) + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(32.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (!isLiveStream) { + AnimatedSkipButton(controllerVisible = controllerVisible, isForward = false) { + controllerState.controller.seekBackward() + } + } + + AnimatedPlayPauseButton(controllerVisible, Modifier, !state.showPlay) { + state.onClick() + } + + if (!isLiveStream) { + AnimatedSkipButton(controllerVisible = controllerVisible, isForward = true) { + controllerState.controller.skipForward() + } + } + } + + if (!state.showPlay) { + LaunchedEffect(state.showPlay) { + delay(3000) + 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 888a6ddb07..0000000000 --- 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 0000000000..bbf4ad8e45 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/RenderTopButtons.kt @@ -0,0 +1,185 @@ +/* + * 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 android.content.pm.PackageManager +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.LaunchedEffect +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.google.accompanist.permissions.ExperimentalPermissionsApi +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, + isLive = false, + pipSupported = true, + 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 + val isLive = isLiveStreaming(mediaData.videoUri) + val pipSupported = + remember { + context.packageManager.hasSystemFeature(PackageManager.FEATURE_PICTURE_IN_PICTURE) + } + + RenderTopButtons( + mediaData = mediaData, + controllerVisible = controllerVisible, + startingMuteState = controllerState.controller.volume < 0.001, + isLive = isLive, + pipSupported = pipSupported, + onMuteClick = { mute -> + DEFAULT_MUTED_SETTING.value = mute + controllerState.controller.volume = if (mute) 0f else 1f + }, + onPictureInPictureClick = { + controllerState.controller.pause() + PipVideoActivity.callIn(mediaData, controllerState.visibility.bounds, context.getActivity()) + }, + onZoomClick = + onZoomClick?.let { + { + controllerState.controller.pause() + it() + } + }, + modifier = modifier, + accountViewModel = accountViewModel, + ) +} + +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun RenderTopButtons( + mediaData: MediaItemData, + controllerVisible: MutableState, + startingMuteState: Boolean, + isLive: Boolean, + pipSupported: Boolean, + onMuteClick: (Boolean) -> Unit, + onPictureInPictureClick: () -> Unit, + onZoomClick: (() -> Unit)?, + modifier: Modifier, + accountViewModel: AccountViewModel, +) { + val shareDialogVisible = remember { mutableStateOf(false) } + val saveAction = + rememberSaveMediaAction { context -> + accountViewModel.saveMediaToGallery(mediaData.videoUri, mediaData.mimeType, context) + } + + LaunchedEffect(controllerVisible.value) { + if (!controllerVisible.value) shareDialogVisible.value = false + } + + Row(modifier) { + if (onZoomClick != null) { + FullScreenButton( + controllerVisible = controllerVisible, + onClick = onZoomClick, + ) + } + + MuteButton( + controllerVisible = controllerVisible, + startingMuteState = startingMuteState, + toggle = onMuteClick, + ) + + Box { + AnimatedOverflowMenuButton( + controllerVisible = controllerVisible, + showShare = true, + showSave = !isLive, + showPip = pipSupported, + onShareClick = { shareDialogVisible.value = true }, + onSaveClick = saveAction, + onPipClick = onPictureInPictureClick, + ) + + if (shareDialogVisible.value) { + ShareMediaAction( + popupExpanded = shareDialogVisible, + videoUri = mediaData.videoUri, + postNostrUri = mediaData.callbackUri, + blurhash = null, + dim = null, + hash = null, + mimeType = mediaData.mimeType, + onDismiss = { shareDialogVisible.value = false }, + content = + MediaUrlVideo( + url = mediaData.videoUri, + mimeType = mediaData.mimeType, + artworkUri = mediaData.artworkUri, + authorName = mediaData.authorName, + description = mediaData.title, + uri = mediaData.callbackUri, + ), + accountViewModel = accountViewModel, + ) + } + } + } +} 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 f31531c04c..5d46e1748b 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( @@ -70,46 +87,46 @@ fun AnimatedSaveButton( } } +@OptIn(ExperimentalPermissionsApi::class) +@Composable +fun rememberSaveMediaAction(onSaveClick: (Context) -> Unit): () -> Unit { + val context = LocalContext.current + val scope = rememberCoroutineScope() + val writeStoragePermission = + rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted -> + if (isGranted) onSaveClick(context) + } + return { + scope.launch { + Toast + .makeText( + context, + stringRes(context, R.string.video_download_has_started_toast), + Toast.LENGTH_SHORT, + ).show() + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || writeStoragePermission.status.isGranted) { + onSaveClick(context) + } else { + writeStoragePermission.launchPermissionRequest() + } + } +} + @OptIn(ExperimentalPermissionsApi::class) @Composable fun SaveMediaButton(onSaveClick: (localContext: Context) -> Unit) { + val saveAction = rememberSaveMediaAction(onSaveClick) Box(modifier = PinBottomIconSize) { Box( Modifier .clip(CircleShape) - .fillMaxSize(0.6f) + .fillMaxSize(0.7f) .align(Alignment.Center) .background(MaterialTheme.colorScheme.background), ) - - val localContext = LocalContext.current - - val writeStoragePermissionState = - rememberPermissionState(Manifest.permission.WRITE_EXTERNAL_STORAGE) { isGranted -> - if (isGranted) { - onSaveClick(localContext) - } - } - val scope = rememberCoroutineScope() IconButton( - onClick = { - scope.launch { - Toast - .makeText( - localContext, - stringRes(localContext, R.string.video_download_has_started_toast), - Toast.LENGTH_SHORT, - ).show() - } - if ( - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q || - writeStoragePermissionState.status.isGranted - ) { - onSaveClick(localContext) - } else { - writeStoragePermissionState.launchPermissionRequest() - } - }, + onClick = saveAction, modifier = Size50Modifier, ) { Icon( 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 e26e6db552..4e96d9f32d 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/controls/SkipButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SkipButton.kt new file mode 100644 index 0000000000..a30e6fa986 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/composable/controls/SkipButton.kt @@ -0,0 +1,96 @@ +/* + * 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.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Forward10 +import androidx.compose.material.icons.filled.Replay10 +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.playback.composable.SKIP_SECONDS +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +private val FadeIn = fadeIn() +private val FadeOut = fadeOut() + +@Preview +@Composable +fun SkipBackButtonPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + SkipButton(isForward = false, onClick = {}) + } + } +} + +@Preview +@Composable +fun SkipForwardButtonPreview() { + ThemeComparisonColumn { + Box(Modifier.background(BitcoinOrange)) { + SkipButton(isForward = true, onClick = {}) + } + } +} + +@Composable +fun AnimatedSkipButton( + controllerVisible: State, + isForward: Boolean, + modifier: Modifier = Modifier, + onClick: () -> Unit, +) { + AnimatedVisibility( + visible = controllerVisible.value, + modifier = modifier, + enter = FadeIn, + exit = FadeOut, + ) { + SkipButton(isForward = isForward, onClick = onClick) + } +} + +@Composable +fun SkipButton( + isForward: Boolean, + onClick: () -> Unit, +) { + val icon = if (isForward) Icons.Default.Forward10 else Icons.Default.Replay10 + val label = if (isForward) stringRes(R.string.skip_forward, SKIP_SECONDS) else stringRes(R.string.skip_back, SKIP_SECONDS) + IconButton(onClick = onClick, modifier = Modifier.size(48.dp)) { + Icon(imageVector = icon, contentDescription = label, tint = Color.White, modifier = Modifier.size(32.dp)) + } +} 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 b34df0c0fa..1a15d7d1f1 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 b5bc0648f9..71d9d3a165 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 477a230105..a316de3048 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 0000000000..815338ac8c --- /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 04aadfe742..ba836e13c0 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 379dd42ffd..f69ce32c43 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 14a51f3418..765138b3e9 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,57 @@ */ 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.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 +78,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 0000000000..5f0a1afa4b --- /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 a8496a07f7..c8d4f30823 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 7cd6c1dfcd..48811b1309 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 401b37bcd8..b69eaefaec 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 15ff30a61e..1b3bd3934d 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/authCommand/model/AuthCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt index 245d6b543e..2437c21a26 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/authCommand/model/AuthCoordinator.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.authCommand.model +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.isDebug import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -32,6 +33,7 @@ class ScreenAuthAccount( val account: Account, ) +@Stable class AuthCoordinator( client: INostrClient, scope: CoroutineScope, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt index 0c10818285..e42954b26c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/notifyCommand/model/NotifyRequestsCache.kt @@ -20,10 +20,12 @@ */ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.model +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update +@Stable class NotifyRequestsCache { val transientPaymentRequestDismissals: MutableStateFlow> = MutableStateFlow(emptySet()) val transientPaymentRequests: MutableStateFlow> = MutableStateFlow(emptySet()) 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 01d9b07a6f..bc00b34b96 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.datasource.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 @@ -38,6 +39,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.geohash.datasource.GeoHashF import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.datasource.HashtagFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.datasource.UserProfileFilterAssembler +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource.RelayFeedFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.ThreadFilterAssembler import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.VideoFilterAssembler import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -78,7 +80,9 @@ class RelaySubscriptionsCoordinator( val profile = UserProfileFilterAssembler(client) val hashtags = HashtagFilterAssembler(client) val geohashes = GeoHashFilterAssembler(client) + val relayFeed = RelayFeedFilterAssembler(client) val followPacks = FollowPackFeedFilterAssembler(client) + val chess = ChessFilterAssembler(client) // active when sending zaps via NWC val nwc = NWCPaymentFilterAssembler(client) @@ -101,6 +105,8 @@ class RelaySubscriptionsCoordinator( profile, hashtags, geohashes, + relayFeed, + chess, nwc, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt index 13902b9eab..1c387a3042 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/AccountFilterAssembler.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -29,7 +30,6 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadat import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromInboxRelaysManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip01Notifications.AccountNotificationsEoseFromRandomRelaysManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.nip59GiftWraps.AccountGiftWrapsEoseManager -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.paymentTargets.AccountPaymentTargetsEoseManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient @@ -38,6 +38,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.auth.IAuthStatus import kotlinx.coroutines.CoroutineScope // This allows multiple screen to be listening to logged-in accounts. +@Stable class AccountQueryState( val account: Account, val feedContentStates: AccountFeedContentStates, @@ -47,6 +48,7 @@ class AccountQueryState( /** * This assembler loads everything eech account needs. */ +@Stable class AccountFilterAssembler( client: INostrClient, cache: LocalCache, @@ -62,7 +64,6 @@ class AccountFilterAssembler( AccountDraftsEoseManager(client, ::allKeys), AccountNotificationsEoseFromInboxRelaysManager(client, ::allKeys), AccountNotificationsEoseFromRandomRelaysManager(client, ::allKeys), - AccountPaymentTargetsEoseManager(client, ::allKeys), ) override fun invalidateKeys() = invalidateFilters() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt index 9b6ebb9537..eb23bfe13e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/follows/AccountFollowsLoaderSubAssembler.kt @@ -152,8 +152,6 @@ class AccountFollowsLoaderSubAssembler( if (users.isEmpty()) return null - println("AccountFollowNeeds ${users.size}") - val connectedRelays = client.connectedRelaysFlow().value val perRelay = pickRelaysToLoadUsers(users, accounts, connectedRelays, failureTracker.cannotConnectRelays, hasTried) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt index cb92abf927..0b2e2d706f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterAccountInfoAndListsFromKey.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.metadata import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState.Companion.APP_SPECIFIC_DATA_D_TAG +import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent @@ -36,6 +37,7 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent @@ -67,6 +69,8 @@ val AccountInfoAndListsFromKeyKinds2 = HashtagListEvent.KIND, GeohashListEvent.KIND, TrustProviderListEvent.KIND, + PaymentTargetsEvent.KIND, + FavoriteRelayListEvent.KIND, ) val AmethystMetadataKinds = listOf(AppSpecificDataEvent.KIND) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt index ef81d1d674..6d78b6a096 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/metadata/FilterBasicAccountInfoFromKeys.kt @@ -33,6 +33,7 @@ import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent @@ -61,6 +62,7 @@ val BasicAccountInfoKinds2 = HashtagListEvent.KIND, GeohashListEvent.KIND, TrustProviderListEvent.KIND, + FavoriteRelayListEvent.KIND, ) fun filterBasicAccountInfoFromKeys( 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 1ded5bbe58..164291266f 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/account/paymentTargets/AccountPaymentTargetsEoseManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/paymentTargets/AccountPaymentTargetsEoseManager.kt deleted file mode 100644 index 6eb07f7192..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/paymentTargets/AccountPaymentTargetsEoseManager.kt +++ /dev/null @@ -1,83 +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.relayClient.reqCommand.account.paymentTargets - -import com.vitorpamplona.amethyst.model.User -import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserEoseManager -import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountQueryState -import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap -import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch - -class AccountPaymentTargetsEoseManager( - client: INostrClient, - allKeys: () -> Set, -) : PerUserEoseManager(client, allKeys) { - override fun user(key: AccountQueryState) = key.account.userProfile() - - fun relayFlow(query: AccountQueryState) = query.account.homeRelays.flow - - override fun updateFilter( - key: AccountQueryState, - since: SincePerRelayMap?, - ): List = - if (key.account.isWriteable()) { - relayFlow(key).value.flatMap { - listOf( - filterPaymentTargetsFromKey(it, user(key).pubkeyHex, since?.get(it)?.time), - ).flatten() - } - } else { - emptyList() - } - - val userJobMap = mutableMapOf>() - - @OptIn(FlowPreview::class) - override fun newSub(key: AccountQueryState): Subscription { - val user = user(key) - userJobMap[user]?.forEach { it.cancel() } - userJobMap[user] = - listOf( - key.account.scope.launch(Dispatchers.IO) { - relayFlow(key).collectLatest { - invalidateFilters() - } - }, - ) - - return super.newSub(key) - } - - override fun endSub( - key: User, - subId: String, - ) { - super.endSub(key, subId) - userJobMap[key]?.forEach { it.cancel() } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt index 48c9a9cdd0..9ea5235108 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/channel/ChannelFinderFilterAssemblyGroup.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.mixChatsLive.ChannelMetadataAndLiveActivityWatcherSubAssembler @@ -27,10 +28,12 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.nip28Pu import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag +@Stable class ChannelFinderQueryState( val channel: Channel, ) +@Stable class ChannelFinderFilterAssemblyGroup( client: INostrClient, ) : ComposeSubscriptionManager() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt index 15a47e65f2..29a6767490 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/event/EventFinderFilterAssembler.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.event +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note @@ -28,11 +29,13 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.watchers. import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag +@Stable class EventFinderQueryState( val note: Note, val account: Account, ) +@Stable class EventFinderFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { 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 4298599fec..4047222233 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/reqCommand/nwc/NWCPaymentFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt index 8d721bfded..48c66be4ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/nwc/NWCPaymentFilterAssembler.kt @@ -20,12 +20,14 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl // This allows multiple screen to be listening to tags, even the same tag +@Stable class NWCPaymentQueryState( val fromServiceHex: HexKey, val toUserHex: HexKey, @@ -33,6 +35,7 @@ class NWCPaymentQueryState( val relay: NormalizedRelayUrl, ) +@Stable class NWCPaymentFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt index 7846d61c43..e6b572e096 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserFinderFilterAssembler.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.service.relayClient.reqCommand.user +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache @@ -32,11 +33,13 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.RelayOfflineTracker // This allows multiple screen to be listening to tags, even the same tag +@Stable class UserFinderQueryState( val user: User, val account: Account, ) +@Stable class UserFinderFilterAssembler( client: INostrClient, cache: LocalCache, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt index 44025e5d3c..859186f4ab 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/UserObservers.kt @@ -43,7 +43,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapLatest @@ -355,6 +354,27 @@ fun observeUserIsFollowingGeohash( return flow.collectAsStateWithLifecycle(geohash in accountViewModel.account.geohashList.flow.value) } +@SuppressLint("StateFlowValueCalledInComposition") +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@Composable +fun observeUserIsFollowingRelay( + relayUrl: NormalizedRelayUrl, + accountViewModel: AccountViewModel, +): State { + val flow = + remember(accountViewModel) { + accountViewModel.account.favoriteRelayList.flowNoDefaults + .mapLatest { relays -> + relayUrl in relays + }.onStart { + emit(relayUrl in accountViewModel.account.favoriteRelayList.flowNoDefaults.value) + }.distinctUntilChanged() + .flowOn(Dispatchers.IO) + } + + return flow.collectAsStateWithLifecycle(relayUrl in accountViewModel.account.favoriteRelayList.flowNoDefaults.value) +} + @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @Composable fun observeUserIsFollowingChannel( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt index 4b7ebff6c2..56c9982e36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/loaders/UserOutboxFinderSubAssembler.kt @@ -55,7 +55,7 @@ class UserOutboxFinderSubAssembler( relay: NormalizedRelayUrl, time: Long, filters: List? = null, - ) = { + ) { filters?.forEach { it.authors?.forEach { cache.getUserIfExists(it)?.let { user -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt index badc74fe54..a9acace0e2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/FilterUserMetadataForKey.kt @@ -30,12 +30,14 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.mapOfSet val UserMetadataForKeyKinds = listOf( MetadataEvent.KIND, + ExternalIdentitiesEvent.KIND, StatusEvent.KIND, AdvertisedRelayListEvent.KIND, ChatMessageRelayListEvent.KIND, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt index e63d8b05ff..4975a63a91 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/user/watchers/UserWatcherSubAssembler.kt @@ -49,7 +49,7 @@ class UserWatcherSubAssembler( relay: NormalizedRelayUrl, time: Long, filters: List? = null, - ) = { + ) { filters?.forEach { it.authors?.forEach { cache.getUserIfExists(it)?.let { user -> diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt index 3e13f429fb..ebbd64c4aa 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/searchCommand/SearchFilterAssembler.kt @@ -40,6 +40,7 @@ class SearchQueryState( override fun flow(): Flow = searchQuery } +@Stable class SearchFilterAssembler( client: INostrClient, scope: CoroutineScope, 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 0e2e2c5b99..25f386b483 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/FileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt index 35a615d58a..49abc96161 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/FileHeader.kt @@ -110,5 +110,7 @@ class ByteArrayMediaDataSource( } @Throws(IOException::class) - override fun close() {} + override fun close() { + // No resources to release; data is an in-memory byte array + } } 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 6c7a0939c2..ada46f1dd4 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/service/uploads/VideoCompressionHelper.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt index 9484e5e07b..8f98f6fb9a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/VideoCompressionHelper.kt @@ -199,12 +199,16 @@ object VideoCompressionHelper { ), listener = object : CompressionListener { - override fun onStart(index: Int) {} + override fun onStart(index: Int) { + // No action needed on compression start + } override fun onProgress( index: Int, percent: Float, - ) {} + ) { + // Progress tracking not needed for this use case + } override fun onSuccess( index: Int, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 4ac789e64b..9bc0d00573 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -26,8 +26,6 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.annotation.RequiresApi import androidx.appcompat.app.AppCompatActivity -import androidx.compose.runtime.LaunchedEffect -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.debugState import com.vitorpamplona.amethyst.model.Account @@ -38,7 +36,6 @@ import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.screen.AccountScreen -import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel import com.vitorpamplona.amethyst.ui.theme.AmethystTheme import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser @@ -68,13 +65,7 @@ class MainActivity : AppCompatActivity() { setContent { StringResSetup() AmethystTheme { - val accountStateViewModel: AccountStateViewModel = viewModel() - - LaunchedEffect(key1 = Unit) { - accountStateViewModel.loginWithDefaultAccountIfLoggedOff() - } - - AccountScreen(accountStateViewModel) + AccountScreen(Amethyst.instance.sessionManager) } } } 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 2945de5dc4..41fad0869a 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 @@ -89,6 +88,7 @@ import com.vitorpamplona.amethyst.ui.note.creators.invoice.AddLnInvoiceButton import com.vitorpamplona.amethyst.ui.note.creators.invoice.InvoiceRequest import com.vitorpamplona.amethyst.ui.note.creators.uploads.ImageVideoDescription import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.ShowUserSuggestionList +import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange @@ -193,7 +193,7 @@ fun EditPostView( baseNote = it, modifier = MaterialTheme.colorScheme.replyModifier, isQuotedNote = true, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, makeItShort = true, quotesLeft = 1, accountViewModel = accountViewModel, @@ -264,9 +264,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/InformationDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt index 04c6da4b64..411996a8f4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/InformationDialog.kt @@ -44,6 +44,7 @@ import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.toasts.ThrowableToastMsg import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size16dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer @@ -52,25 +53,23 @@ import java.io.StringWriter @Composable fun InformationDialog( - title: String, - textContent: String?, - throwable: Throwable, + obj: ThrowableToastMsg, buttonColors: ButtonColors = ButtonDefaults.buttonColors(), onDismiss: () -> Unit, ) { - val str = textContent ?: throwable.localizedMessage ?: throwable.message ?: throwable.javaClass.simpleName + val str = obj.msg ?: obj.throwable.localizedMessage ?: obj.throwable.message ?: obj.throwable.javaClass.simpleName val stack = - remember(throwable) { + remember(obj) { val writer = StringWriter() writer.append("\n") - throwable.printStackTrace(PrintWriter(writer)) + obj.throwable.printStackTrace(PrintWriter(writer)) writer.toString() } - InformationDialog(title = title, textContent = str, moreInfo = stack, buttonColors, onDismiss) + InformationDialog(title = stringRes(obj.titleResId), textContent = str, moreInfo = stack, buttonColors, onDismiss) } @Composable 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 7f812155fd..1263816fc2 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 b770c54737..ee3810e72b 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 13d6c10e4c..0000000000 --- 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 d90793bf8f..0000000000 --- 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 b88e9aa5db..0000000000 --- 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 d3c5400a7e..0000000000 --- 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/NewPollVoteValueRange.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollVoteValueRange.kt deleted file mode 100644 index 20c9c8dceb..0000000000 --- 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/NewUserMetadataScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt index b96b7098bc..33722468d8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataScreen.kt @@ -20,7 +20,12 @@ */ package com.vitorpamplona.amethyst.ui.actions +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.consumeWindowInsets import androidx.compose.foundation.layout.fillMaxWidth @@ -30,16 +35,24 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable 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.text.input.KeyboardCapitalization import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel @@ -61,8 +74,15 @@ fun NewUserMetadataScreen( postViewModel.init(accountViewModel) val context = LocalContext.current + val socialExpanded = rememberSaveable { mutableStateOf(false) } + LaunchedEffect(postViewModel, accountViewModel) { postViewModel.load() + + // Auto-expand social proofs if any have data + if (postViewModel.twitter.value.isNotBlank() || postViewModel.mastodon.value.isNotBlank() || postViewModel.github.value.isNotBlank()) { + socialExpanded.value = true + } } Scaffold( @@ -95,8 +115,9 @@ fun NewUserMetadataScreen( Column( modifier = Modifier.padding(10.dp).verticalScroll(rememberScrollState()), ) { + // -- Profile (always visible) -- OutlinedTextField( - label = { Text(text = stringRes(R.string.profile_name_with_explainer)) }, + label = { Text(text = stringRes(R.string.username)) }, modifier = Modifier.fillMaxWidth(), value = postViewModel.name.value, onValueChange = { postViewModel.name.value = it }, @@ -106,6 +127,7 @@ fun NewUserMetadataScreen( color = MaterialTheme.colorScheme.placeholderText, ) }, + prefix = { Text("@") }, keyboardOptions = KeyboardOptions.Default.copy( capitalization = KeyboardCapitalization.Sentences, @@ -195,7 +217,7 @@ fun NewUserMetadataScreen( SelectSingleFromGallery( isUploading = postViewModel.isUploadingImageForBanner, tint = MaterialTheme.colorScheme.placeholderText, - modifier = Modifier.padding(start = 5.dp).align(Alignment.CenterHorizontally), + modifier = Modifier.padding(start = 5.dp), ) { postViewModel.uploadForBanner(it, context, onError = accountViewModel.toastManager::toast) } @@ -206,13 +228,29 @@ fun NewUserMetadataScreen( Spacer(modifier = Modifier.height(10.dp)) OutlinedTextField( - label = { Text(text = stringRes(R.string.pronouns)) }, + label = { Text(text = stringRes(R.string.lightning_address)) }, modifier = Modifier.fillMaxWidth(), - value = postViewModel.pronouns.value, - onValueChange = { postViewModel.pronouns.value = it }, + value = postViewModel.lnAddress.value, + onValueChange = { postViewModel.lnAddress.value = it }, placeholder = { Text( - text = "they/them, ...", + text = "me@mylightningnode.com", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + ) + + Spacer(modifier = Modifier.height(10.dp)) + + OutlinedTextField( + label = { Text(text = stringRes(R.string.nip_05) + " (NIP-05)") }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.nip05.value, + onValueChange = { postViewModel.nip05.value = it }, + placeholder = { + Text( + text = "_@mywebsite.com", color = MaterialTheme.colorScheme.placeholderText, ) }, @@ -238,94 +276,107 @@ fun NewUserMetadataScreen( Spacer(modifier = Modifier.height(10.dp)) OutlinedTextField( - label = { Text(text = stringRes(R.string.nip_05)) }, + label = { Text(text = stringRes(R.string.pronouns)) }, modifier = Modifier.fillMaxWidth(), - value = postViewModel.nip05.value, - onValueChange = { postViewModel.nip05.value = it }, + value = postViewModel.pronouns.value, + onValueChange = { postViewModel.pronouns.value = it }, placeholder = { Text( - text = "_@mywebsite.com", + text = "they/them, ...", color = MaterialTheme.colorScheme.placeholderText, ) }, singleLine = true, ) - Spacer(modifier = Modifier.height(10.dp)) - OutlinedTextField( - label = { Text(text = stringRes(R.string.ln_address)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.lnAddress.value, - onValueChange = { postViewModel.lnAddress.value = it }, - placeholder = { - Text( - text = "me@mylightningnode.com", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - singleLine = true, - ) + // -- Social Proofs -- + ExpandableSection( + title = stringRes(R.string.social_proof), + expanded = socialExpanded, + ) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.twitter)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.twitter.value, + onValueChange = { postViewModel.twitter.value = it }, + placeholder = { + Text( + text = stringRes(R.string.twitter_proof_url_template), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) - Spacer(modifier = Modifier.height(10.dp)) + Spacer(modifier = Modifier.height(10.dp)) - OutlinedTextField( - label = { Text(text = stringRes(R.string.ln_url_outdated)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.lnURL.value, - onValueChange = { postViewModel.lnURL.value = it }, - placeholder = { - Text( - text = stringRes(R.string.lnurl), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) + OutlinedTextField( + label = { Text(text = stringRes(R.string.mastodon)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.mastodon.value, + onValueChange = { postViewModel.mastodon.value = it }, + placeholder = { + Text( + text = stringRes(R.string.mastodon_proof_url_template), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) - Spacer(modifier = Modifier.height(10.dp)) + Spacer(modifier = Modifier.height(10.dp)) - OutlinedTextField( - label = { Text(text = stringRes(R.string.twitter)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.twitter.value, - onValueChange = { postViewModel.twitter.value = it }, - placeholder = { - Text( - text = stringRes(R.string.twitter_proof_url_template), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - OutlinedTextField( - label = { Text(text = stringRes(R.string.mastodon)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.mastodon.value, - onValueChange = { postViewModel.mastodon.value = it }, - placeholder = { - Text( - text = stringRes(R.string.mastodon_proof_url_template), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) - - Spacer(modifier = Modifier.height(10.dp)) - - OutlinedTextField( - label = { Text(text = stringRes(R.string.github)) }, - modifier = Modifier.fillMaxWidth(), - value = postViewModel.github.value, - onValueChange = { postViewModel.github.value = it }, - placeholder = { - Text( - text = stringRes(R.string.github_proof_url_template), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - ) + OutlinedTextField( + label = { Text(text = stringRes(R.string.github)) }, + modifier = Modifier.fillMaxWidth(), + value = postViewModel.github.value, + onValueChange = { postViewModel.github.value = it }, + placeholder = { + Text( + text = stringRes(R.string.github_proof_url_template), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + ) + } } } } } + +@Composable +private fun ExpandableSection( + title: String, + expanded: MutableState, + content: @Composable () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { expanded.value = !expanded.value } + .padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Icon( + imageVector = if (expanded.value) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = if (expanded.value) "Collapse" else "Expand", + tint = MaterialTheme.colorScheme.placeholderText, + ) + } + + AnimatedVisibility( + visible = expanded.value, + enter = expandVertically(), + exit = shrinkVertically(), + ) { + Column { + content() + Spacer(modifier = Modifier.height(10.dp)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt index edebdb0ef4..ee6550f6c4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewUserMetadataViewModel.kt @@ -36,10 +36,10 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions import com.vitorpamplona.quartz.nip39ExtIdentities.GitHubIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.MastodonIdentity import com.vitorpamplona.quartz.nip39ExtIdentities.TwitterIdentity +import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims import kotlin.coroutines.cancellation.CancellationException class NewUserMetadataViewModel : ViewModel() { @@ -83,18 +83,28 @@ class NewUserMetadataViewModel : ViewModel() { nip05.value = it.info.nip05 ?: "" lnAddress.value = it.info.lud16 ?: "" lnURL.value = it.info.lud06 ?: "" + } - twitter.value = "" - github.value = "" - mastodon.value = "" + twitter.value = "" + github.value = "" + mastodon.value = "" - // TODO: Validate Telegram input, somehow. - it.identities.forEach { identity -> - when (identity) { - is TwitterIdentity -> twitter.value = identity.toProofUrl() - is GitHubIdentity -> github.value = identity.toProofUrl() - is MastodonIdentity -> mastodon.value = identity.toProofUrl() - } + // Load identities from kind 10011 first, fall back to kind 0 for backwards compat + val identities = + account.userMetadata.getExternalIdentitiesEvent()?.identityClaims() + ?: account + .userProfile() + .metadataOrNull() + ?.flow + ?.value + ?.identities + ?: emptyList() + + identities.forEach { identity -> + when (identity) { + is TwitterIdentity -> twitter.value = identity.toProofUrl() + is GitHubIdentity -> github.value = identity.toProofUrl() + is MastodonIdentity -> mastodon.value = identity.toProofUrl() } } } @@ -112,12 +122,17 @@ class NewUserMetadataViewModel : ViewModel() { nip05 = nip05.value, lnAddress = lnAddress.value, lnURL = lnURL.value, + ) + + val identities = + account.userMetadata.sendNewUserIdentities( twitter = twitter.value, mastodon = mastodon.value, github = github.value, ) account.sendLiterallyEverywhere(metadata) + account.sendLiterallyEverywhere(identities) clear() } @@ -144,12 +159,12 @@ class NewUserMetadataViewModel : ViewModel() { ) { accountViewModel.launchSigner { upload( - uri, - context, - onUploading = { isUploadingImageForPicture = it }, - onUploaded = { picture.value = it }, + galleryUri = uri, + context = context, onError = onError, - ) + )?.let { + picture.value = it + } } } @@ -160,27 +175,44 @@ class NewUserMetadataViewModel : ViewModel() { ) { accountViewModel.launchSigner { upload( - uri, - context, - onUploading = { isUploadingImageForBanner = it }, - onUploaded = { banner.value = it }, + galleryUri = uri, + context = context, onError = onError, - ) + )?.let { + banner.value = it + } + } + } + + fun uploadPictureAndSave( + uri: SelectedMedia, + context: Context, + onError: (String, String) -> Unit, + ) { + load() + accountViewModel.launchSigner { + upload( + galleryUri = uri, + context = context, + onError = onError, + )?.let { + picture.value = it + } + + create() } } private suspend fun upload( galleryUri: SelectedMedia, context: Context, - onUploading: (Boolean) -> Unit, - onUploaded: (String) -> Unit, onError: (String, String) -> Unit, - ) { - onUploading(true) + ): String? { + isUploadingImageForPicture = true val compResult = MediaCompressor().compress(galleryUri.uri, galleryUri.mimeType, CompressorQuality.MEDIUM, context.applicationContext) - try { + return try { val result = if (account.settings.defaultFileServer.type == ServerType.NIP96) { Nip96Uploader().upload( @@ -209,20 +241,17 @@ class NewUserMetadataViewModel : ViewModel() { ) } - if (result.url != null) { - onUploading(false) - onUploaded(result.url) - } else { - onUploading(false) + if (result.url == null) { onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.server_did_not_provide_a_url_after_uploading)) } - } catch (_: SignerExceptions.ReadOnlyException) { - onUploading(false) - onError(stringRes(context, R.string.failed_to_upload_media_no_details), stringRes(context, R.string.login_with_a_private_key_to_be_able_to_upload)) + + result.url } catch (e: Exception) { if (e is CancellationException) throw e - onUploading(false) onError(stringRes(context, R.string.failed_to_upload_media_no_details), e.message ?: e.javaClass.simpleName) + null + } finally { + isUploadingImageForPicture = false } } } 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 3055595567..ddab82030a 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 9f32890c34..6924b98d81 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 efac80079c..84142984be 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 2726f9487c..2239d0b946 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 e4d8d873db..0000000000 --- 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 c17e5b62cd..e81f65e51c 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 9e598bc791..32613496f9 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 5d1e2eb444..608424282a 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/actions/NewPollRecipientsField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayUrl.kt similarity index 51% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayUrl.kt index e52d53a78d..00964101dd 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/NewPollRecipientsField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRelayUrl.kt @@ -20,46 +20,38 @@ */ package com.vitorpamplona.amethyst.ui.components -import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.combinedClickable import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember 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 +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextOverflow +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route @Composable -fun NewPollRecipientsField( - pollViewModel: ShortNotePostViewModel, - account: Account, +fun ClickableRelayUrl( + relayUrl: String, + nav: INav, ) { - // if no recipients, add user's pubkey - if (pollViewModel.zapRecipients.isEmpty()) { - pollViewModel.zapRecipients.add(account.userProfile().pubkeyHex) - } + val clipboardManager = LocalClipboardManager.current + val clickableModifier = + remember(relayUrl) { + Modifier + .combinedClickable( + onLongClick = { clipboardManager.setText(AnnotatedString(relayUrl)) }, + onClick = { nav.nav(Route.RelayInfo(relayUrl)) }, + ) + } - // 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, - ) - }, + Text( + text = relayUrl, + modifier = clickableModifier, + color = MaterialTheme.colorScheme.primary, + overflow = TextOverflow.MiddleEllipsis, + maxLines = 1, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt index 112f0c432d..ad2498aadf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/ClickableRoute.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.ui.components -import android.R.attr.maxLines import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.InlineTextContent @@ -550,7 +549,10 @@ fun CreateClickableTextWithEmoji( text = buildAnnotatedString { withLink( - LinkAnnotation.Clickable("me") { + LinkAnnotation.Clickable( + "me", + TextLinkStyles(style = style.toSpanStyle()), + ) { onClick() }, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt index 73aa212b62..060b751d92 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/RichTextViewer.kt @@ -79,6 +79,7 @@ import com.vitorpamplona.amethyst.commons.richtext.LinkSegment import com.vitorpamplona.amethyst.commons.richtext.ParagraphState import com.vitorpamplona.amethyst.commons.richtext.PhoneSegment import com.vitorpamplona.amethyst.commons.richtext.RegularTextSegment +import com.vitorpamplona.amethyst.commons.richtext.RelayUrlSegment import com.vitorpamplona.amethyst.commons.richtext.RichTextViewerState import com.vitorpamplona.amethyst.commons.richtext.SchemelessUrlSegment import com.vitorpamplona.amethyst.commons.richtext.SecretEmoji @@ -101,6 +102,7 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.creators.invoice.MayBeInvoicePreview import com.vitorpamplona.amethyst.ui.note.toShortDisplay +import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.LoadUser import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel @@ -148,7 +150,7 @@ fun RichTextViewer( fun RenderStrangeNamePreview() { Column(modifier = Modifier.padding(10.dp)) { RenderRegular( - "If you want to stream or download the music from nostr:npub1sctag667a7np6p6ety2up94pnwwxhd2ep8n8afr2gtr47cwd4ewsvdmmjm can you here", + "If you want to FreeFrom Official \uD80C\uDD66 stream or download the music from nostr:npub1sctag667a7np6p6ety2up94pnwwxhd2ep8n8afr2gtr47cwd4ewsvdmmjm at wss://relay.damus.io can you here", EmptyTagList, ) { paragraph, state, spaceWidth, modifier -> RenderTextParagraph(paragraph, spaceWidth, modifier) { word -> @@ -163,6 +165,10 @@ fun RenderStrangeNamePreview() { is RegularTextSegment -> { Text(word.segmentText) } + + is RelayUrlSegment -> { + ClickableRelayUrl(word.segmentText, EmptyNav()) + } } } } @@ -254,6 +260,8 @@ fun RenderRegularPreview2() { is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) is RegularTextSegment -> Text(word.segmentText) + + is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, EmptyNav()) } } } @@ -302,6 +310,8 @@ fun RenderRegularPreview3() { is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) is RegularTextSegment -> Text(word.segmentText) + + is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, EmptyNav()) } } } @@ -478,6 +488,8 @@ private fun RenderWordWithoutPreview( is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) is RegularTextSegment -> Text(word.segmentText) + + is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav) } } @@ -509,6 +521,7 @@ private fun RenderWordWithPreview( is SchemelessUrlSegment -> NoProtocolUrlRenderer(word) is RegularTextSegment -> Text(word.segmentText) is Base64Segment -> ZoomableContentView(word.segmentText, state, accountViewModel) + is RelayUrlSegment -> ClickableRelayUrl(word.segmentText, nav) } } @@ -595,6 +608,7 @@ fun DisplayFullNote( baseNote = note, modifier = MaterialTheme.colorScheme.innerPostModifier, isQuotedNote = true, + unPackReply = ReplyRenderType.LINE, quotesLeft = quotesLeft - 1, parentBackgroundColor = backgroundColor, accountViewModel = accountViewModel, @@ -867,6 +881,7 @@ private fun DisplayNoteFromTag( baseNote = baseNote, modifier = MaterialTheme.colorScheme.innerPostModifier, isQuotedNote = true, + unPackReply = ReplyRenderType.LINE, quotesLeft = quotesLeft - 1, parentBackgroundColor = backgroundColor, accountViewModel = accountViewModel, 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 c39f05996a..18b45ffc4f 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/WindowUtils.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/WindowUtils.kt index 07c0ba06cc..07522149c2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/WindowUtils.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/WindowUtils.kt @@ -46,7 +46,7 @@ private tailrec fun Context.getActivityWindow(): Window? = } @Composable -fun getActivity(): Activity? = LocalContext.current.getActivity() +fun getActivity(): Activity = LocalContext.current.getActivity() tailrec fun Context.getActivity(): ComponentActivity = when (this) { @@ -55,6 +55,13 @@ tailrec fun Context.getActivity(): ComponentActivity = else -> throw IllegalStateException("Requires a ComponentActivity to run") } +fun Context.getActivityOrNull(): ComponentActivity? = + when (this) { + is ComponentActivity -> this + is ContextWrapper -> baseContext.getActivity() + else -> null + } + @Composable fun amethystApp(): Amethyst = LocalContext.current.asAmethystApp() 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 a5a2dfab9e..7a77466895 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,7 @@ import android.widget.Toast import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +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 +126,7 @@ fun ZoomableImageDialog( } Surface(Modifier.fillMaxSize()) { - Box(Modifier.fillMaxSize(), Alignment.TopCenter) { - DialogContent(allImages, imageUrl, onDismiss, accountViewModel) - } + DialogContent(allImages, imageUrl, onDismiss, accountViewModel) } } } @@ -158,136 +157,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 +353,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 +371,7 @@ private fun RenderImageOrVideo( .zoomable( rememberZoomState(), onTap = { - if (onToggleControllerVisibility != null) { - onToggleControllerVisibility() - } + controllerVisible.value = !controllerVisible.value }, ) @@ -412,8 +413,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 +428,7 @@ private fun RenderImageOrVideo( .zoomable( rememberZoomState(), onTap = { - if (onToggleControllerVisibility != null) { - onToggleControllerVisibility() - } + controllerVisible.value = !controllerVisible.value }, ) @@ -471,8 +471,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 751a98db37..f8cc1c82af 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/components/markdown/RenderContentAsMarkdown.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt index d03a84f615..67bc8981ee 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/markdown/RenderContentAsMarkdown.kt @@ -117,6 +117,8 @@ fun RenderContentAsMarkdown( } } +private const val PREVIEW_CALLBACK_URI = "nostr:something" + @Preview @Composable fun RenderContentAsMarkdownPreview() { @@ -162,7 +164,7 @@ fun RenderContentAsMarkdownPreview() { remember { mutableStateOf(background) }, - callbackUri = "nostr:something", + callbackUri = PREVIEW_CALLBACK_URI, accountViewModel = accountViewModel, nav = nav, ) @@ -212,7 +214,7 @@ fun RenderContentAsMarkdownListsPreview() { remember { mutableStateOf(background) }, - callbackUri = "nostr:something", + callbackUri = PREVIEW_CALLBACK_URI, accountViewModel = accountViewModel, nav = nav, ) @@ -266,7 +268,7 @@ fun RenderContentAsMarkdownCodePreview() { remember { mutableStateOf(background) }, - callbackUri = "nostr:something", + callbackUri = PREVIEW_CALLBACK_URI, accountViewModel = accountViewModel, nav = nav, ) @@ -306,7 +308,7 @@ fun RenderContentAsMarkdownTablesPreview() { remember { mutableStateOf(background) }, - callbackUri = "nostr:something", + callbackUri = PREVIEW_CALLBACK_URI, accountViewModel = accountViewModel, nav = nav, ) @@ -344,7 +346,7 @@ fun RenderContentAsMarkdownFootNotesPreview() { remember { mutableStateOf(background) }, - callbackUri = "nostr:something", + callbackUri = PREVIEW_CALLBACK_URI, accountViewModel = accountViewModel, nav = nav, ) @@ -405,7 +407,7 @@ fun RenderContentAsMarkdownUserPreview() { remember { mutableStateOf(background) }, - callbackUri = "nostr:something", + callbackUri = PREVIEW_CALLBACK_URI, accountViewModel = accountViewModel, nav = nav, ) @@ -455,7 +457,7 @@ fun RenderContentAsMarkdownNotePreview() { remember { mutableStateOf(background) }, - callbackUri = "nostr:something", + callbackUri = PREVIEW_CALLBACK_URI, accountViewModel = accountViewModel, nav = nav, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt index 936d50d7fa..89c945c919 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/components/toasts/DisplayErrorMessages.kt @@ -77,11 +77,7 @@ fun DisplayErrorMessages( } is ThrowableToastMsg -> { - InformationDialog( - stringRes(obj.titleResId), - obj.msg, - obj.throwable, - ) { + InformationDialog(obj) { toastManager.clearToasts() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChangesFlowFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChangesFlowFilter.kt index 99c9e74f1d..e13b8a73e6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChangesFlowFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/dal/ChangesFlowFilter.kt @@ -20,9 +20,5 @@ */ package com.vitorpamplona.amethyst.ui.dal -import com.vitorpamplona.amethyst.commons.model.ListChange -import kotlinx.coroutines.flow.MutableSharedFlow - -interface ChangesFlowFilter : IAdditiveFeedFilter { - fun changesFlow(): MutableSharedFlow> -} +// Re-export from commons for backwards compatibility +typealias ChangesFlowFilter = com.vitorpamplona.amethyst.commons.ui.feeds.ChangesFlowFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt index 49fa16623d..ba25e3a42f 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/ChannelFeedContentState.kt @@ -106,7 +106,9 @@ class ChannelFeedContentState( } } - fun deleteFromFeed(deletedNotes: Set) {} + fun deleteFromFeed(deletedNotes: Set) { + // TODO: Implement deletion of notes from the channel feed + } fun refreshFromOldState(newItems: Set) { val oldNotesState = _feedContent.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt index aa5152f64f..44b025b61a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/ChatHeaderLayout.kt @@ -51,6 +51,9 @@ import com.vitorpamplona.amethyst.ui.theme.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn import com.vitorpamplona.quartz.utils.TimeUtils +private const val PREVIEW_AUTHOR = "This is my author" +private const val PREVIEW_MESSAGE = "This is a message from this person" + @Composable @Preview fun ChannelNamePreview() { @@ -65,11 +68,11 @@ fun ChannelNamePreview() { ) }, firstRow = { - Text("This is my author", Modifier.weight(1f)) + Text(PREVIEW_AUTHOR, Modifier.weight(1f)) TimeAgo(TimeUtils.now()) }, secondRow = { - Text("This is a message from this person", Modifier.weight(1f)) + Text(PREVIEW_MESSAGE, Modifier.weight(1f)) Spacer(modifier = Height4dpModifier) NewItemsBubble() }, @@ -81,13 +84,13 @@ fun ChannelNamePreview() { ListItem( headlineContent = { Row(verticalAlignment = Alignment.CenterVertically) { - Text("This is my author", Modifier.weight(1f)) + Text(PREVIEW_AUTHOR, Modifier.weight(1f)) TimeAgo(TimeUtils.now()) } }, supportingContent = { Row { - Text("This is a message from this person", Modifier.weight(1f)) + Text(PREVIEW_MESSAGE, Modifier.weight(1f)) NewItemsBubble() } }, @@ -106,13 +109,13 @@ fun ChannelNamePreview() { SlimListItem( headlineContent = { Row(verticalAlignment = Alignment.CenterVertically) { - Text("This is my author", Modifier.weight(1f)) + Text(PREVIEW_AUTHOR, Modifier.weight(1f)) TimeAgo(TimeUtils.now()) } }, supportingContent = { Row(verticalAlignment = Alignment.CenterVertically) { - Text("This is a message from this person", Modifier.weight(1f)) + Text(PREVIEW_MESSAGE, Modifier.weight(1f)) NewItemsBubble() } }, 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 76b1c8cb61..6cdf473fcd 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/layouts/listItem/SlimListItemLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt index ffb54b2a3b..d6d8925c93 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/layouts/listItem/SlimListItemLayout.kt @@ -80,6 +80,9 @@ import kotlin.math.max * This is a copy of Material3's ListItemLayout.kt file, with the only change being the padding change from 16.dp to 10.dp */ +private const val PREVIEW_AUTHOR = "This is my author" +private const val PREVIEW_MESSAGE = "This is a message from this person" + @Composable @Preview fun ChannelNamePreview() { @@ -94,11 +97,11 @@ fun ChannelNamePreview() { ) }, firstRow = { - Text("This is my author", Modifier.weight(1f)) + Text(PREVIEW_AUTHOR, Modifier.weight(1f)) TimeAgo(TimeUtils.now()) }, secondRow = { - Text("This is a message from this person", Modifier.weight(1f)) + Text(PREVIEW_MESSAGE, Modifier.weight(1f)) NewItemsBubble() }, onClick = {}, @@ -109,13 +112,13 @@ fun ChannelNamePreview() { SlimListItem( headlineContent = { Row(verticalAlignment = CenterVertically) { - Text("This is my author", Modifier.weight(1f)) + Text(PREVIEW_AUTHOR, Modifier.weight(1f)) TimeAgo(TimeUtils.now()) } }, supportingContent = { Row(verticalAlignment = CenterVertically) { - Text("This is a message from this person", Modifier.weight(1f)) + Text(PREVIEW_MESSAGE, Modifier.weight(1f)) Spacer(modifier = Height4dpModifier) NewItemsBubble() } @@ -135,13 +138,13 @@ fun ChannelNamePreview() { ListItem( headlineContent = { Row(verticalAlignment = CenterVertically) { - Text("This is my author", Modifier.weight(1f)) + Text(PREVIEW_AUTHOR, Modifier.weight(1f)) TimeAgo(TimeUtils.now()) } }, supportingContent = { Row(verticalAlignment = CenterVertically) { - Text("This is a message from this person", Modifier.weight(1f)) + Text(PREVIEW_MESSAGE, Modifier.weight(1f)) Spacer(modifier = Height4dpModifier) NewItemsBubble() } 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 78ff36813d..df3db28914 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 @@ -55,8 +55,9 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.getRouteWithArguments import com.vitorpamplona.amethyst.ui.navigation.routes.isBaseRoute import com.vitorpamplona.amethyst.ui.navigation.routes.isSameRoute import com.vitorpamplona.amethyst.ui.note.PayViaIntentScreen +import com.vitorpamplona.amethyst.ui.note.UpdateReactionTypeScreen import com.vitorpamplona.amethyst.ui.note.nip22Comments.ReplyCommentPostScreen -import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountSwitcherAndLeftDrawerLayout import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.bookmarkgroups.default.BookmarkListScreen @@ -74,6 +75,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 @@ -87,6 +90,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.HomeScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.ShortNotePostScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.VoiceReplyScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.AccountBackupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.lists.PeopleListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.display.packs.FollowPackScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.lists.list.ListOfPeopleListsScreen @@ -99,12 +103,15 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.privacy.PrivacyOptionsScree import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.ProfileScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.ShowQRScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.redirect.LoadRedirectScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.RelayFeedScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.AllRelayListScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.RelayInformationScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.search.SearchScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.AllSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.NIP47SetupScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SecurityFiltersScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.ThreadScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.VideoScreen @@ -121,11 +128,11 @@ import java.net.URI @Composable fun AppNavigation( accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel, + accountSessionManager: AccountSessionManager, ) { val nav = rememberNav() - AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountStateViewModel, nav) { + AccountSwitcherAndLeftDrawerLayout(accountViewModel, accountSessionManager, nav) { NavHost( navController = nav.controller, startDestination = Route.Home, @@ -137,6 +144,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) } @@ -159,21 +167,27 @@ fun AppNavigation( composableFromBottomArgs { NewUserMetadataScreen(nav, accountViewModel) } composable { SearchScreen(accountViewModel, nav) } + composableFromEnd { AllSettingsScreen(accountViewModel, nav) } + composableFromEnd { AccountBackupScreen(accountViewModel, nav) } composableFromEnd { SecurityFiltersScreen(accountViewModel, nav) } composableFromEnd { PrivacyOptionsScreen(Amethyst.instance.torPrefs.value, nav) } composableFromEnd { BookmarkListScreen(accountViewModel, nav) } composableFromEnd { DraftListScreen(accountViewModel, nav) } composableFromEnd { SettingsScreen(accountViewModel, nav) } composableFromEnd { UserSettingsScreen(accountViewModel, nav) } - composableFromBottomArgs { NIP47SetupScreen(accountViewModel, nav, it.nip47) } + composableFromEndArgs { NIP47SetupScreen(accountViewModel, nav, it.nip47) } + composableFromEndArgs { UpdateZapAmountScreen(accountViewModel, nav, it.nip47) } composableFromEndArgs { AllRelayListScreen(accountViewModel, nav) } composableFromEndArgs { AllMediaServersScreen(accountViewModel, nav) } + composableFromEndArgs { UpdateReactionTypeScreen(accountViewModel, nav) } composableFromEndArgs { DvmContentDiscoveryScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ProfileScreen(it.id, accountViewModel, nav) } composableFromEndArgs { ThreadScreen(it.id, accountViewModel, nav) } composableFromEndArgs { HashtagScreen(it, accountViewModel, nav) } composableFromEndArgs { GeoHashScreen(it, accountViewModel, nav) } + composableFromEndArgs { RelayFeedScreen(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) } @@ -306,7 +320,7 @@ fun AppNavigation( } } - NavigateIfIntentRequested(nav, accountViewModel, accountStateViewModel) + NavigateIfIntentRequested(nav, accountViewModel, accountSessionManager) DisplayErrorMessages(accountViewModel.toastManager, accountViewModel, nav) DisplayNotifyMessages(accountViewModel, nav) @@ -318,13 +332,13 @@ fun AppNavigation( private fun NavigateIfIntentRequested( nav: Nav, accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel, + accountSessionManager: AccountSessionManager, ) { - accountViewModel.firstRoute?.let { + accountViewModel.firstRoute?.let { newRoute -> accountViewModel.firstRoute = null - val currentRoute = getRouteWithArguments(nav.controller) - if (!isSameRoute(currentRoute, it)) { - nav.newStack(it) + val currentRoute = getRouteWithArguments(newRoute::class, nav.controller) + if (!isSameRoute(currentRoute, newRoute)) { + nav.newStack(newRoute) } } @@ -372,10 +386,10 @@ private fun NavigateIfIntentRequested( if (actionableNextPage != null) { actionableNextPage?.let { nextRoute -> val npub = runCatching { URI(intentNextPage.removePrefix("nostr:")).findParameterValue("account") }.getOrNull() - if (npub != null && accountStateViewModel.currentAccountNPub() != npub) { - accountStateViewModel.checkAndSwitchUserSync(npub, nextRoute) + if (npub != null && accountSessionManager.currentAccountNPub() != npub) { + accountSessionManager.checkAndSwitchUserSync(npub, nextRoute) } else { - val currentRoute = getRouteWithArguments(nav.controller) + val currentRoute = getRouteWithArguments(nextRoute::class, nav.controller) if (!isSameRoute(currentRoute, nextRoute)) { nav.newStack(nextRoute) } @@ -428,10 +442,10 @@ private fun NavigateIfIntentRequested( if (newPage != null) { scope.launch { val npub = runCatching { URI(uri.removePrefix("nostr:")).findParameterValue("account") }.getOrNull() - if (npub != null && accountStateViewModel.currentAccountNPub() != npub) { - accountStateViewModel.checkAndSwitchUserSync(npub, newPage) + if (npub != null && accountSessionManager.currentAccountNPub() != npub) { + accountSessionManager.checkAndSwitchUserSync(npub, newPage) } else { - val currentRoute = getRouteWithArguments(nav.controller) + val currentRoute = getRouteWithArguments(newPage::class, nav.controller) if (!isSameRoute(currentRoute, newPage)) { nav.newStack(newPage) } @@ -460,7 +474,7 @@ private fun NavigateIfIntentRequested( } if (newAccount != null) { - AddAccountDialog(newAccount, accountStateViewModel) { newAccount = null } + AddAccountDialog(newAccount, accountSessionManager) { newAccount = null } } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt index 1d27fb432a..4cbd74bfec 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/drawer/AccountSwitchBottomSheet.kt @@ -63,7 +63,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUse import com.vitorpamplona.amethyst.ui.components.CreateTextWithEmoji import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage import com.vitorpamplona.amethyst.ui.note.toShortDisplay -import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedOff.AddAccountDialog import com.vitorpamplona.amethyst.ui.stringRes @@ -78,7 +78,7 @@ import kotlinx.coroutines.launch @Composable fun AccountSwitchBottomSheet( accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel, + accountSessionManager: AccountSessionManager, ) { var popupExpanded by remember { mutableStateOf(false) } val scrollState = rememberScrollState() @@ -94,7 +94,7 @@ fun AccountSwitchBottomSheet( ) { Text(stringRes(R.string.account_switch_select_account), fontWeight = FontWeight.Bold) } - DisplayAllAccounts(accountViewModel, accountStateViewModel) + DisplayAllAccounts(accountViewModel, accountSessionManager) Row( modifier = Modifier @@ -110,24 +110,24 @@ fun AccountSwitchBottomSheet( } if (popupExpanded) { - AddAccountDialog(null, accountStateViewModel) { popupExpanded = false } + AddAccountDialog(null, accountSessionManager) { popupExpanded = false } } } @Composable private fun DisplayAllAccounts( accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel, + accountSessionManager: AccountSessionManager, ) { val accounts by LocalPreferences.accountsFlow().collectAsStateWithLifecycle() - accounts?.forEach { acc -> DisplayAccount(acc, accountViewModel, accountStateViewModel) } + accounts?.forEach { acc -> DisplayAccount(acc, accountViewModel, accountSessionManager) } } @Composable fun DisplayAccount( acc: AccountInfo, accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel, + accountSessionManager: AccountSessionManager, ) { var baseUser by remember(acc) { mutableStateOf( @@ -153,7 +153,7 @@ fun DisplayAccount( modifier = Modifier .fillMaxWidth() - .clickable { accountStateViewModel.switchUser(acc) } + .clickable { accountSessionManager.switchUser(acc) } .padding(16.dp, 16.dp), verticalAlignment = Alignment.CenterVertically, ) { @@ -179,7 +179,7 @@ fun DisplayAccount( } } - LogoutButton(acc, accountStateViewModel) + LogoutButton(acc, accountSessionManager) } } } @@ -247,7 +247,7 @@ private fun AccountName( @Composable private fun LogoutButton( acc: AccountInfo, - accountStateViewModel: AccountStateViewModel, + accountSessionManager: AccountSessionManager, ) { var logoutDialog by remember { mutableStateOf(false) } if (logoutDialog) { @@ -259,7 +259,7 @@ private fun LogoutButton( TextButton( onClick = { logoutDialog = false - accountStateViewModel.logOff(acc) + accountSessionManager.logOff(acc) }, ) { Text(text = stringRes(R.string.log_out)) 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 259fb88b2a..e12771d272 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 @@ -46,14 +46,10 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Send import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.Delete -import androidx.compose.material.icons.outlined.CloudUpload import androidx.compose.material.icons.outlined.CollectionsBookmark import androidx.compose.material.icons.outlined.Drafts import androidx.compose.material.icons.outlined.GroupAdd -import androidx.compose.material.icons.outlined.Key -import androidx.compose.material.icons.outlined.Security import androidx.compose.material.icons.outlined.Settings -import androidx.compose.material.icons.outlined.Translate import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -67,7 +63,6 @@ import androidx.compose.runtime.derivedStateOf 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 @@ -105,7 +100,6 @@ import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel -import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.AccountBackupDialog import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.DividerThickness import com.vitorpamplona.amethyst.ui.theme.DoubleHorzSpacer @@ -436,8 +430,6 @@ fun ListContent( accountViewModel: AccountViewModel, nav: INav, ) { - var backupDialogOpen by remember { mutableStateOf(false) } - Column(modifier) { NavigationRow( title = R.string.profile, @@ -471,6 +463,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 = { @@ -479,59 +480,12 @@ fun ListContent( }, ) - IconRow( - title = R.string.media_servers, - icon = Icons.Outlined.CloudUpload, - tint = MaterialTheme.colorScheme.onBackground, - onClick = { - nav.closeDrawer() - nav.nav(Route.EditMediaServers) - }, - ) - NavigationRow( - title = R.string.security_filters, - icon = Icons.Outlined.Security, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.SecurityFilters, - ) - - NavigationRow( - title = R.string.privacy_options, - icon = R.drawable.ic_tor, - iconReference = 1, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.PrivacyOptions, - ) - - accountViewModel.account.settings.keyPair.privKey?.let { - IconRow( - title = R.string.backup_keys, - icon = Icons.Outlined.Key, - tint = MaterialTheme.colorScheme.onBackground, - onClick = { - nav.closeDrawer() - backupDialogOpen = true - }, - ) - } - - NavigationRow( - title = R.string.preferences, + title = R.string.settings, icon = Icons.Outlined.Settings, tint = MaterialTheme.colorScheme.onBackground, nav = nav, - route = Route.Settings, - ) - - NavigationRow( - title = R.string.user_preferences, - icon = Icons.Outlined.Translate, - tint = MaterialTheme.colorScheme.onBackground, - nav = nav, - route = Route.UserSettings, + route = Route.AllSettings, ) Spacer(modifier = Modifier.weight(1f)) @@ -543,10 +497,6 @@ fun ListContent( onClick = openSheet, ) } - - if (backupDialogOpen) { - AccountBackupDialog(accountViewModel, onClose = { backupDialogOpen = false }) - } } @Composable diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/EmptyNav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/EmptyNav.kt index c8dc1e3cc4..aece943849 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/EmptyNav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/EmptyNav.kt @@ -37,6 +37,7 @@ class EmptyNav : INav { override fun openDrawer() = runBlocking { drawerState.open() } + // All navigation methods are intentionally no-op; this is a stub for previews and tests override fun nav(route: Route) {} override fun nav(computeRoute: suspend () -> Route?) {} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt index d91b264da5..7956d1e776 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/navs/Nav.kt @@ -48,7 +48,7 @@ class Nav( override fun nav(route: Route) { navigationScope.launch { - if (getRouteWithArguments(controller) != route) { + if (getRouteWithArguments(route::class, controller) != route) { controller.navigate(route) } } @@ -57,7 +57,7 @@ class Nav( override fun nav(computeRoute: suspend () -> Route?) { navigationScope.launch { val route = computeRoute() - if (route != null && getRouteWithArguments(controller) != route) { + if (route != null && getRouteWithArguments(route::class, controller) != route) { controller.navigate(route) } } 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 b85ba7c877..25bdca7df2 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 6cffc431ea..f5fa4a8fd2 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 @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey import kotlinx.serialization.Serializable +import kotlin.reflect.KClass sealed class Route { @Serializable object Home : Route() @@ -40,6 +41,8 @@ sealed class Route { @Serializable object Notification : Route() + @Serializable object Chess : Route() + @Serializable object Search : Route() @Serializable object SecurityFilters : Route() @@ -77,6 +80,10 @@ sealed class Route { @Serializable object Drafts : Route() + @Serializable object AllSettings : Route() + + @Serializable object AccountBackup : Route() + @Serializable object Settings : Route() @Serializable object UserSettings : Route() @@ -109,10 +116,16 @@ sealed class Route { @Serializable object EditMediaServers : Route() + @Serializable object UpdateReactionType : Route() + @Serializable data class Nip47NWCSetup( val nip47: String? = null, ) : Route() + @Serializable data class UpdateZapAmount( + val nip47: String? = null, + ) : Route() + @Serializable data class Profile( val id: String, ) : Route() @@ -137,6 +150,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, @@ -179,6 +196,10 @@ sealed class Route { val url: String, ) : Route() + @Serializable data class RelayFeed( + val url: String, + ) : Route() + @Serializable data class EphemeralChat( val id: String, val relayUrl: String, @@ -302,59 +323,17 @@ sealed class Route { inline fun isBaseRoute(navController: NavHostController): Boolean = navController.currentBackStackEntry?.destination?.hasRoute() == true -fun getRouteWithArguments(navController: NavHostController): Route? { +fun getRouteWithArguments( + klazz: KClass, + navController: NavHostController, +): Route? { val entry = navController.currentBackStackEntry ?: return null val dest = entry.destination - return when { - 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() - 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() - 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() - 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() - 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() - 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() - dest.hasRoute() -> entry.toRoute() - dest.hasRoute() -> entry.toRoute() - dest.hasRoute() -> entry.toRoute() - dest.hasRoute() -> entry.toRoute() - dest.hasRoute() -> entry.toRoute() - else -> null + return if (dest.hasRoute(klazz)) { + entry.toRoute(klazz) + } else { + null } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt index a0ba5ef345..ed4d1888e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/topbars/FeedFilterSpinner.kt @@ -58,18 +58,19 @@ import com.google.accompanist.permissions.rememberPermissionState import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.AddressableNote +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNote import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.components.SpinnerSelectionDialog import com.vitorpamplona.amethyst.ui.note.creators.location.LoadCityName -import com.vitorpamplona.amethyst.ui.screen.AroundMeFeedDefinition import com.vitorpamplona.amethyst.ui.screen.CommunityName import com.vitorpamplona.amethyst.ui.screen.FeedDefinition import com.vitorpamplona.amethyst.ui.screen.GeoHashName import com.vitorpamplona.amethyst.ui.screen.HashtagName import com.vitorpamplona.amethyst.ui.screen.Name import com.vitorpamplona.amethyst.ui.screen.PeopleListName +import com.vitorpamplona.amethyst.ui.screen.RelayName import com.vitorpamplona.amethyst.ui.screen.ResourceName import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes @@ -83,7 +84,7 @@ import kotlinx.collections.immutable.ImmutableList @OptIn(ExperimentalPermissionsApi::class) @Composable fun FeedFilterSpinner( - placeholderCode: String, + placeholderCode: TopFilter, explainer: String, options: ImmutableList, onSelect: (Int) -> Unit, @@ -135,7 +136,7 @@ fun FeedFilterSpinner( Column(horizontalAlignment = Alignment.CenterHorizontally) { Text(currentText) - if (selected is AroundMeFeedDefinition) { + if (selected?.code is TopFilter.AroundMe) { if (!locationPermissionState.status.isGranted) { LaunchedEffect(locationPermissionState) { locationPermissionState.launchPermissionRequest() } @@ -312,5 +313,17 @@ fun RenderOption( Text(text = "/n/${((it.note as? AddressableNote)?.dTag() ?: "")}", color = MaterialTheme.colorScheme.onSurface) } } + + is RelayName -> { + Row( + horizontalArrangement = Arrangement.Center, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = option.name(), + color = MaterialTheme.colorScheme.onSurface, + ) + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt index dee03ea65b..44475cfc7b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/MultiSetCompose.kt @@ -422,7 +422,7 @@ data class ZapAmountCommentNotification( @Composable private fun ParseAuthorCommentAndAmount( zapRequest: Note, - zapEvent: Note?, + zapEvent: Note, accountViewModel: AccountViewModel, onReady: @Composable (MutableState) -> Unit, ) { @@ -437,7 +437,7 @@ private fun ParseAuthorCommentAndAmount( ) } - LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent?.idHex) { + LaunchedEffect(key1 = zapRequest.idHex, key2 = zapEvent.idHex) { accountViewModel.decryptAmountMessage(zapRequest, zapEvent) { newState -> if (newState != null) { content.value = newState 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 4ff83b259c..9a95f5a550 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 @@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.defaultMinSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text @@ -38,7 +37,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState import androidx.compose.runtime.State -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -83,6 +81,7 @@ import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayFavoriteRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayNIP65RelayList @@ -103,6 +102,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 +114,9 @@ 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.RenderLnZap 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,8 @@ 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.ReplyRenderType 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 @@ -140,6 +145,7 @@ import com.vitorpamplona.amethyst.ui.theme.Font12SP import com.vitorpamplona.amethyst.ui.theme.HalfDoubleVertSpacer import com.vitorpamplona.amethyst.ui.theme.HalfPadding import com.vitorpamplona.amethyst.ui.theme.HalfStartPadding +import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier import com.vitorpamplona.amethyst.ui.theme.RowColSpacing10dp import com.vitorpamplona.amethyst.ui.theme.RowColSpacing5dp import com.vitorpamplona.amethyst.ui.theme.Size10dp @@ -198,6 +204,7 @@ import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent @@ -206,9 +213,13 @@ import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessa import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent +import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent @@ -220,6 +231,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 @@ -236,7 +248,7 @@ fun NoteCompose( routeForLastRead: String? = null, isBoostedNote: Boolean = false, isQuotedNote: Boolean = false, - unPackReply: Boolean = true, + unPackReply: ReplyRenderType = ReplyRenderType.FULL, makeItShort: Boolean = false, isHiddenFeed: Boolean = false, quotesLeft: Int, @@ -285,7 +297,7 @@ fun AcceptableNote( routeForLastRead: String? = null, isBoostedNote: Boolean = false, isQuotedNote: Boolean = false, - unPackReply: Boolean = true, + unPackReply: ReplyRenderType = ReplyRenderType.FULL, makeItShort: Boolean = false, canPreview: Boolean = true, quotesLeft: Int, @@ -457,7 +469,7 @@ private fun CheckNewAndRenderNote( routeForLastRead: String? = null, isBoostedNote: Boolean = false, isQuotedNote: Boolean = false, - unPackReply: Boolean = true, + unPackReply: ReplyRenderType = ReplyRenderType.FULL, makeItShort: Boolean = false, canPreview: Boolean = true, quotesLeft: Int, @@ -511,7 +523,7 @@ fun ClickableNote( content: @Composable () -> Unit, ) { val updatedModifier = - remember(baseNote, backgroundColor.value, modifier) { + remember(baseNote, modifier) { modifier .combinedClickable( onClick = { @@ -533,10 +545,10 @@ fun ClickableNote( } }, onLongClick = showPopup, - ).background(backgroundColor.value) + ) } - Column(modifier = updatedModifier) { content() } + Column(modifier = updatedModifier.background(backgroundColor.value)) { content() } } @Composable @@ -545,7 +557,7 @@ fun InnerNoteWithReactions( backgroundColor: MutableState, isBoostedNote: Boolean, isQuotedNote: Boolean, - unPackReply: Boolean, + unPackReply: ReplyRenderType, makeItShort: Boolean, canPreview: Boolean, quotesLeft: Int, @@ -661,7 +673,7 @@ fun RenderApproveButton( accountViewModel.approveCommunityPost(post, community) }, ) { - Text("Approve") + Text(stringRes(R.string.approve)) } } @@ -669,7 +681,7 @@ fun RenderApproveButton( fun NoteBody( baseNote: Note, showAuthorPicture: Boolean = false, - unPackReply: Boolean = true, + unPackReply: ReplyRenderType = ReplyRenderType.FULL, makeItShort: Boolean = false, canPreview: Boolean = true, showSecondRow: Boolean, @@ -699,7 +711,7 @@ fun NoteBody( } if (baseNote.event !is RepostEvent && baseNote.event !is GenericRepostEvent) { - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Height4dpModifier) } RenderNoteRow( @@ -731,7 +743,7 @@ private fun RenderNoteRow( makeItShort: Boolean, canPreview: Boolean, quotesLeft: Int, - unPackReply: Boolean, + unPackReply: ReplyRenderType, editState: State>, accountViewModel: AccountViewModel, nav: INav, @@ -785,6 +797,10 @@ private fun RenderNoteRow( RenderBadgeAward(baseNote, backgroundColor, accountViewModel, nav) } + is LnZapEvent -> { + RenderLnZap(baseNote, backgroundColor, accountViewModel, nav) + } + is FhirResourceEvent -> { RenderFhirResource(baseNote, accountViewModel, nav) } @@ -821,6 +837,10 @@ private fun RenderNoteRow( DisplayTrustedRelayList(baseNote, backgroundColor, accountViewModel, nav) } + is FavoriteRelayListEvent -> { + DisplayFavoriteRelayList(baseNote, backgroundColor, accountViewModel, nav) + } + is IndexerRelayListEvent -> { DisplayIndexerRelayList(baseNote, backgroundColor, accountViewModel, nav) } @@ -911,6 +931,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 +1014,7 @@ private fun RenderNoteRow( } is PollNoteEvent -> { - RenderPoll( + RenderZapPoll( baseNote, makeItShort, canPreview, @@ -979,6 +1026,18 @@ private fun RenderNoteRow( ) } + is PollEvent -> { + RenderPoll( + baseNote, + makeItShort, + canPreview, + quotesLeft, + backgroundColor, + accountViewModel, + nav, + ) + } + is FileHeaderEvent -> { FileHeaderDisplay(baseNote, true, ContentScale.FillWidth, accountViewModel) } @@ -1143,7 +1202,7 @@ fun ObserveDraftEvent( fun RenderDraft( note: Note, quotesLeft: Int, - unPackReply: Boolean, + unPackReply: ReplyRenderType, backgroundColor: MutableState, accountViewModel: AccountViewModel, nav: INav, @@ -1184,7 +1243,7 @@ fun RenderRepost( it, modifier = Modifier, isBoostedNote = true, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, quotesLeft = quotesLeft - 1, parentBackgroundColor = backgroundColor, accountViewModel = accountViewModel, @@ -1213,7 +1272,7 @@ fun ReplyNoteComposition( baseNote = replyingDirectlyTo, modifier = MaterialTheme.colorScheme.replyModifier, isQuotedNote = true, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, makeItShort = true, quotesLeft = 0, parentBackgroundColor = backgroundColor, @@ -1236,7 +1295,7 @@ fun SecondUserInfoRow( verticalAlignment = CenterVertically, modifier = UserNameMaxRowHeight, ) { - Column(modifier = remember(noteEvent) { Modifier.weight(1f) }) { + Column(modifier = remember { Modifier.weight(1f) }) { if (noteEvent is IForkableEvent && noteEvent.isAFork()) { ShowForkInformation(noteEvent, Modifier, accountViewModel, nav) } else { @@ -1328,9 +1387,10 @@ fun FirstUserInfoRow( if (isDraft) { ObserveDraftEvent(baseNote, accountViewModel) { draftNote -> - val isCommunityPost by remember(draftNote) { - derivedStateOf { draftNote.event?.isACommunityPost() == true } - } + val isCommunityPost = + remember(draftNote) { + draftNote.event?.isACommunityPost() == true + } if (isCommunityPost) { DisplayFollowingCommunityInPost(draftNote, accountViewModel, nav) @@ -1339,9 +1399,10 @@ fun FirstUserInfoRow( } } } else { - val isCommunityPost by remember(baseNote) { - derivedStateOf { baseNote.event?.isACommunityPost() == true } - } + val isCommunityPost = + remember(baseNote) { + baseNote.event?.isACommunityPost() == true + } if (isCommunityPost) { DisplayFollowingCommunityInPost(baseNote, accountViewModel, nav) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 128af382f1..2bb7af9fe8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt @@ -871,7 +871,6 @@ fun LikeReaction( heartSizeModifier: Modifier = Size18Modifier, iconFontSize: TextUnit = Font14SP, ) { - var wantsToChangeReactionSymbol by remember { mutableStateOf(false) } var wantsToReact by remember { mutableStateOf(false) } ClickableBox( @@ -883,7 +882,7 @@ fun LikeReaction( onWantsToSignReaction = { accountViewModel.reactToOrDelete(baseNote) }, ) }, - onLongClick = { wantsToChangeReactionSymbol = true }, + onLongClick = { nav.nav(Route.UpdateReactionType) }, ) { ObserveLikeIcon(baseNote, accountViewModel) { reactionType -> CrossfadeIfEnabled(targetState = reactionType, contentAlignment = Center, label = "LikeIcon", accountViewModel = accountViewModel) { @@ -895,14 +894,6 @@ fun LikeReaction( } } - if (wantsToChangeReactionSymbol) { - UpdateReactionTypeDialog( - { wantsToChangeReactionSymbol = false }, - accountViewModel = accountViewModel, - nav, - ) - } - if (wantsToReact) { ReactionChoicePopup( baseNote, @@ -911,7 +902,7 @@ fun LikeReaction( onDismiss = { wantsToReact = false }, onChangeAmount = { wantsToReact = false - wantsToChangeReactionSymbol = true + nav.nav(Route.UpdateReactionType) }, ) } @@ -1039,7 +1030,6 @@ fun ZapReaction( nav: INav, ) { var wantsToZap by remember { mutableStateOf(false) } - var wantsToChangeZapAmount by remember { mutableStateOf(false) } var wantsToSetCustomZap by remember { mutableStateOf(false) } val context = LocalContext.current @@ -1090,7 +1080,7 @@ fun ZapReaction( ) } }, - onLongClick = { wantsToChangeZapAmount = true }, + onLongClick = { nav.nav(Route.UpdateZapAmount()) }, onDoubleClick = { wantsToSetCustomZap = true }, ), ) { @@ -1107,7 +1097,7 @@ fun ZapReaction( onChangeAmount = { scope.launch { wantsToZap = false - wantsToChangeZapAmount = true + nav.nav(Route.UpdateZapAmount()) } }, onError = { _, message, user -> @@ -1133,13 +1123,6 @@ fun ZapReaction( ) } - if (wantsToChangeZapAmount) { - UpdateZapAmountDialog( - onClose = { wantsToChangeZapAmount = false }, - accountViewModel = accountViewModel, - ) - } - if (wantsToSetCustomZap) { ZapCustomDialog( onZapStarts = { zapStartingTime = TimeUtils.now() }, @@ -1542,33 +1525,38 @@ fun ReactionChoicePopupContent( } } +private const val EMOJI_ROCKET = "\uD83D\uDE80" +private const val EMOJI_THINKING = "\uD83E\uDD14" +private const val EMOJI_SCREAM = "\uD83D\uDE31" +private const val EMOJI_PARTY = "\uD83C\uDF89" + @Preview() @Composable fun ReactionChoicePopupPeeview() { ThemeComparisonColumn { ReactionChoicePopupContent( persistentListOf( - "\uD83D\uDE80", + EMOJI_ROCKET, "\uD83E\uDEC2", "\uD83D\uDC40", "\uD83D\uDE02", - "\uD83C\uDF89", - "\uD83E\uDD14", - "\uD83D\uDE31", - "\uD83E\uDD14", - "\uD83D\uDE31", + EMOJI_PARTY, + EMOJI_THINKING, + EMOJI_SCREAM, + EMOJI_THINKING, + EMOJI_SCREAM, "+", "-", - "\uD83C\uDF89", - "\uD83E\uDD14", - "\uD83D\uDE31", - "\uD83E\uDD14", - "\uD83D\uDE31", + EMOJI_PARTY, + EMOJI_THINKING, + EMOJI_SCREAM, + EMOJI_THINKING, + EMOJI_SCREAM, "\uD83D\uDE80\uDB40\uDD58\uDB40\uDD64\uDB40\uDD64\uDB40\uDD60\uDB40\uDD63\uDB40\uDD2A\uDB40\uDD1F\uDB40\uDD1F\uDB40\uDD53\uDB40\uDD54\uDB40\uDD5E\uDB40\uDD1E\uDB40\uDD63\uDB40\uDD51\uDB40\uDD64\uDB40\uDD55\uDB40\uDD5C\uDB40\uDD5C\uDB40\uDD59\uDB40\uDD64\uDB40\uDD55\uDB40\uDD1E\uDB40\uDD55\uDB40\uDD51\uDB40\uDD62\uDB40\uDD64\uDB40\uDD58\uDB40\uDD1F\uDB40\uDD29\uDB40\uDD24\uDB40\uDD27\uDB40\uDD55\uDB40\uDD24\uDB40\uDD51\uDB40\uDD52\uDB40\uDD22\uDB40\uDD54\uDB40\uDD23\uDB40\uDD21\uDB40\uDD21\uDB40\uDD25\uDB40\uDD52\uDB40\uDD55\uDB40\uDD25\uDB40\uDD26\uDB40\uDD25\uDB40\uDD51\uDB40\uDD24\uDB40\uDD29\uDB40\uDD53\uDB40\uDD56\uDB40\uDD25\uDB40\uDD54\uDB40\uDD52\uDB40\uDD20\uDB40\uDD22\uDB40\uDD25\uDB40\uDD25\uDB40\uDD29\uDB40\uDD56\uDB40\uDD23\uDB40\uDD21\uDB40\uDD20\uDB40\uDD53\uDB40\uDD51\uDB40\uDD20\uDB40\uDD51\uDB40\uDD26\uDB40\uDD54\uDB40\uDD54\uDB40\uDD56\uDB40\uDD54\uDB40\uDD54\uDB40\uDD52\uDB40\uDD54\uDB40\uDD24\uDB40\uDD52\uDB40\uDD54\uDB40\uDD28\uDB40\uDD53\uDB40\uDD52\uDB40\uDD55\uDB40\uDD53\uDB40\uDD24\uDB40\uDD24\uDB40\uDD29\uDB40\uDD29\uDB40\uDD25\uDB40\uDD53\uDB40\uDD22\uDB40\uDD55\uDB40\uDD27\uDB40\uDD1E\uDB40\uDD67\uDB40\uDD55\uDB40\uDD52\uDB40\uDD60", ), onClick = {}, onChangeAmount = {}, - toRemove = persistentSetOf("\uD83D\uDE80", "\uD83E\uDD14", "\uD83D\uDE31"), + toRemove = persistentSetOf(EMOJI_ROCKET, EMOJI_THINKING, EMOJI_SCREAM), ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt index 5dd1517927..1a50d6e23a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReplyInformation.kt @@ -116,6 +116,31 @@ fun ReplyInformationChannel( } } +@OptIn(ExperimentalLayoutApi::class) +@Composable +fun ReplyToLabel( + replyingDirectlyTo: Note, + accountViewModel: AccountViewModel, + nav: INav, +) { + val user = replyingDirectlyTo.author ?: return + + FlowRow { + Text( + stringRes(id = R.string.replying_to), + fontSize = 13.sp, + color = MaterialTheme.colorScheme.placeholderText, + ) + + ReplyInfoMention( + user = user, + prefix = "", + accountViewModel = accountViewModel, + onUserTagClick = { nav.nav(routeFor(it)) }, + ) + } +} + @Composable private fun ReplyInfoMention( user: User, 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 dbc0ed5ae5..99d2cc8934 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/UpdateReactionTypeDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt index 95116950f3..d51dd4a7a9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateReactionTypeDialog.kt @@ -54,6 +54,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -68,8 +69,6 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.ViewModel import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R @@ -80,7 +79,6 @@ import com.vitorpamplona.amethyst.service.firstFullChar import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteEventAndMapNotNull import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.InLineIconRenderer -import com.vitorpamplona.amethyst.ui.components.SetDialogToEdgeToEdge import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar @@ -99,6 +97,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.launch +@Stable class UpdateReactionTypeViewModel : ViewModel() { lateinit var accountViewModel: AccountViewModel lateinit var account: Account @@ -151,8 +150,7 @@ class UpdateReactionTypeViewModel : ViewModel() { } @Composable -fun UpdateReactionTypeDialog( - onClose: () -> Unit, +fun UpdateReactionTypeScreen( accountViewModel: AccountViewModel, nav: INav, ) { @@ -163,119 +161,106 @@ fun UpdateReactionTypeDialog( postViewModel.load() } - UpdateReactionTypeDialog(postViewModel, onClose, accountViewModel, nav) + UpdateReactionTypeScreen(postViewModel, accountViewModel, nav) } @OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable -fun UpdateReactionTypeDialog( +fun UpdateReactionTypeScreen( postViewModel: UpdateReactionTypeViewModel, - onClose: () -> Unit, accountViewModel: AccountViewModel, nav: INav, ) { - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false, - ), - ) { - SetDialogToEdgeToEdge() - - Scaffold( - topBar = { - SavingTopBar( - isActive = postViewModel::hasChanged, - onCancel = { - postViewModel.cancel() - onClose() - }, - onPost = { - postViewModel.sendPost() - onClose() - }, - ) - }, - ) { pad -> - Surface( - modifier = - Modifier - .padding(pad) - .consumeWindowInsets(pad) - .imePadding(), + Scaffold( + topBar = { + SavingTopBar( + isActive = postViewModel::hasChanged, + onCancel = { + postViewModel.cancel() + nav.popBack() + }, + onPost = { + postViewModel.sendPost() + nav.popBack() + }, + ) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad) + .imePadding(), + ) { + Column( + modifier = Modifier.padding(10.dp), ) { - Column( - modifier = Modifier.padding(10.dp), + Row( + modifier = Modifier.fillMaxWidth(), ) { - Row( - modifier = Modifier.fillMaxWidth(), + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), ) { - Column( - modifier = Modifier.verticalScroll(rememberScrollState()), - ) { - Row(modifier = Modifier.fillMaxWidth()) { - Column(modifier = Modifier.animateContentSize()) { - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, - ) { - postViewModel.reactionSet.forEach { reactionType -> - RenderReactionOption(reactionType, postViewModel) - } + Row(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.animateContentSize()) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + postViewModel.reactionSet.forEach { reactionType -> + RenderReactionOption(reactionType, postViewModel) } } } + } - Spacer(modifier = Modifier.height(10.dp)) + Spacer(modifier = Modifier.height(10.dp)) - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.new_reaction_symbol)) }, + value = postViewModel.nextChoice, + onValueChange = { postViewModel.nextChoice = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Text, + ), + placeholder = { + Text( + text = "\uD83D\uDCAF, \uD83C\uDF89, \uD83D\uDC4E", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + modifier = Modifier.padding(end = 10.dp).weight(1f), + ) + + Button( + onClick = { postViewModel.addChoice() }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + ), ) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.new_reaction_symbol)) }, - value = postViewModel.nextChoice, - onValueChange = { postViewModel.nextChoice = it }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Text, - ), - placeholder = { - Text( - text = "\uD83D\uDCAF, \uD83C\uDF89, \uD83D\uDC4E", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - singleLine = true, - modifier = Modifier.padding(end = 10.dp).weight(1f), - ) - - Button( - onClick = { postViewModel.addChoice() }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - ), - ) { - Text(text = stringRes(R.string.add), color = Color.White) - } + Text(text = stringRes(R.string.add), color = Color.White) } } } + } - Spacer(StdVertSpacer) + Spacer(StdVertSpacer) - EmojiSelector( - accountViewModel = accountViewModel, - nav = nav, - ) { - postViewModel.addChoice(it) - } + EmojiSelector( + accountViewModel = accountViewModel, + nav = nav, + ) { + postViewModel.addChoice(it) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt index 5277583916..52859f724d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UpdateZapAmountDialog.kt @@ -57,7 +57,6 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -78,14 +77,9 @@ import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.components.TextSpinner import com.vitorpamplona.amethyst.ui.components.TitleExplainer -import com.vitorpamplona.amethyst.ui.note.buttons.CloseButton -import com.vitorpamplona.amethyst.ui.note.buttons.SaveButton import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.keyBackup.getFragmentActivity @@ -99,70 +93,6 @@ import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.collections.immutable.toImmutableList -@Composable -fun UpdateZapAmountDialog( - onClose: () -> Unit, - nip47uri: String? = null, - accountViewModel: AccountViewModel, -) { - val postViewModel: UpdateZapAmountViewModel = viewModel() - postViewModel.init(accountViewModel) - - LaunchedEffect(accountViewModel, postViewModel) { - postViewModel.load() - } - UpdateZapAmountDialog(postViewModel, onClose, nip47uri, accountViewModel) -} - -@Composable -fun UpdateZapAmountDialog( - postViewModel: UpdateZapAmountViewModel, - onClose: () -> Unit, - nip47uri: String? = null, - accountViewModel: AccountViewModel, -) { - Dialog( - onDismissRequest = { onClose() }, - properties = - DialogProperties( - usePlatformDefaultWidth = false, - dismissOnClickOutside = false, - decorFitsSystemWindows = false, - ), - ) { - Surface( - modifier = Modifier.fillMaxWidth(), - ) { - Column { - Row( - modifier = Modifier.fillMaxWidth().padding(10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - CloseButton( - onPress = { - postViewModel.cancel() - onClose() - }, - ) - - SaveButton( - onPost = { - postViewModel.sendPost() - onClose() - }, - isActive = postViewModel.hasChanged(), - ) - } - - Spacer(modifier = Modifier.height(10.dp)) - - UpdateZapAmountContent(postViewModel, onClose, nip47uri, accountViewModel) - } - } - } -} - @OptIn(ExperimentalLayoutApi::class) @Composable fun UpdateZapAmountContent( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt index 979b796acd..a5697d7d05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/UserProfilePicture.kt @@ -433,6 +433,10 @@ fun ObserveAndDrawInnerUserPicture( ) } +private const val PREVIEW_USER_HEX = "989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799" +private const val PREVIEW_PICTURE_URL = "http://null" +private const val PREVIEW_USER_NAME = "vitor" + @Preview @Composable fun ScoreTag55Preview() { @@ -442,9 +446,9 @@ fun ScoreTag55Preview() { var size = 75.dp Box(Modifier.size(size), contentAlignment = Alignment.TopEnd) { InnerUserPicture( - userHex = "989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799", - userPicture = "http://null", - userName = "vitor", + userHex = PREVIEW_USER_HEX, + userPicture = PREVIEW_PICTURE_URL, + userName = PREVIEW_USER_NAME, size = size, modifier = Modifier, accountViewModel = accountViewModel, @@ -455,9 +459,9 @@ fun ScoreTag55Preview() { size = 55.dp Box(Modifier.size(size), contentAlignment = Alignment.TopEnd) { InnerUserPicture( - userHex = "989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799", - userPicture = "http://null", - userName = "vitor", + userHex = PREVIEW_USER_HEX, + userPicture = PREVIEW_PICTURE_URL, + userName = PREVIEW_USER_NAME, size = size, modifier = Modifier, accountViewModel = accountViewModel, @@ -468,9 +472,9 @@ fun ScoreTag55Preview() { size = 35.dp Box(Modifier.size(size), contentAlignment = Alignment.TopEnd) { InnerUserPicture( - userHex = "989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799", - userPicture = "http://null", - userName = "vitor", + userHex = PREVIEW_USER_HEX, + userPicture = PREVIEW_PICTURE_URL, + userName = PREVIEW_USER_NAME, size = size, modifier = Modifier, accountViewModel = accountViewModel, @@ -481,9 +485,9 @@ fun ScoreTag55Preview() { size = 25.dp Box(Modifier.size(size), contentAlignment = Alignment.TopEnd) { InnerUserPicture( - userHex = "989c3734c46abac7ce3ce229971581a5a6ee39cdd6aa7261a55823fa7f8c4799", - userPicture = "http://null", - userName = "vitor", + userHex = PREVIEW_USER_HEX, + userPicture = PREVIEW_PICTURE_URL, + userName = PREVIEW_USER_NAME, size = size, modifier = Modifier, accountViewModel = accountViewModel, @@ -495,8 +499,8 @@ fun ScoreTag55Preview() { Box(Modifier.size(size), contentAlignment = Alignment.TopEnd) { InnerUserPicture( userHex = "AABBCC", - userPicture = "http://null", - userName = "vitor", + userPicture = PREVIEW_PICTURE_URL, + userName = PREVIEW_USER_NAME, size = size, modifier = Modifier, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt index 2d9d789aea..3e17859e16 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ZapCustomDialog.kt @@ -24,20 +24,28 @@ import android.content.Context import android.content.Intent import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -68,8 +76,6 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.ZapPaymentHandler -import com.vitorpamplona.amethyst.ui.components.TextSpinner -import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.components.toasts.multiline.UserBasedErrorMessage import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton @@ -85,7 +91,6 @@ import com.vitorpamplona.amethyst.ui.theme.ZeroPadding import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CancellationException class ZapOptionViewModel : ViewModel() { @@ -102,9 +107,12 @@ class ZapOptionViewModel : ViewModel() { fun value(): Long? = customAmount.text.trim().toLongOrNull() - fun cancel() {} + fun cancel() { + // No cleanup needed; dialog state is reset on close + } } +@OptIn(ExperimentalLayoutApi::class) @Composable fun ZapCustomDialog( onZapStarts: () -> Unit, @@ -144,14 +152,11 @@ fun ZapCustomDialog( ), ) - val zapOptions = - remember { - zapTypes.map { TitleExplainer(it.second, it.third) }.toImmutableList() - } - var selectedZapType by remember(accountViewModel) { mutableStateOf(accountViewModel.defaultZapType()) } + val presetAmounts = remember(accountViewModel) { accountViewModel.zapAmountChoices() } + Dialog( onDismissRequest = { onClose() }, properties = @@ -160,110 +165,166 @@ fun ZapCustomDialog( usePlatformDefaultWidth = false, ), ) { - Surface { - Column(modifier = Modifier.padding(10.dp)) { + Surface( + shape = RoundedCornerShape(16.dp), + modifier = Modifier.padding(horizontal = 16.dp), + ) { + Column( + modifier = + Modifier + .padding(16.dp) + .imePadding(), + ) { + // Header Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { + Text( + text = stringRes(id = R.string.send_zap), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) CloseButton( onPress = { postViewModel.cancel() onClose() }, ) + } - ZapButton( - isActive = postViewModel.canSend() && !baseNote.isDraft(), + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + + // Preset amount quick-select chips + if (presetAmounts.isNotEmpty()) { + FlowRow( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - onZapStarts() - accountViewModel.zap( - baseNote, - postViewModel.value()!! * 1000L, - null, - postViewModel.customMessage.text, - context, - onError = onError, - onProgress = onProgress, - onPayViaIntent = onPayViaIntent, - zapType = selectedZapType, - ) - onClose() + presetAmounts.forEach { amount -> + SuggestionChip( + onClick = { + postViewModel.customAmount = TextFieldValue(amount.toString()) + }, + label = { Text("⚡ ${showAmount(amount.toBigDecimal())}") }, + ) + } } } - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - // stringRes(R.string.new_amount_in_sats - label = { Text(text = stringRes(id = R.string.amount_in_sats)) }, - value = postViewModel.customAmount, - onValueChange = { postViewModel.customAmount = it }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Number, - ), - placeholder = { - Text( - text = "100, 1000, 5000", - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - singleLine = true, - modifier = Modifier.padding(end = 5.dp).weight(1f), - ) + // Amount input + OutlinedTextField( + label = { Text(text = stringRes(id = R.string.amount_in_sats)) }, + value = postViewModel.customAmount, + onValueChange = { postViewModel.customAmount = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Number, + ), + placeholder = { + Text( + text = "100, 1000, 5000", + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + suffix = { Text(text = stringRes(id = R.string.sats)) }, + singleLine = true, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + ) - TextSpinner( - label = stringRes(id = R.string.zap_type), - placeholder = zapTypes.first { it.first == accountViewModel.defaultZapType() }.second, - options = zapOptions, - onSelect = { selectedZapType = zapTypes[it].first }, - modifier = Modifier.weight(1f).padding(end = 5.dp), - ) + // Zap type label + Text( + text = stringRes(id = R.string.zap_type), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 4.dp), + ) + + // Zap type selection chips + FlowRow( + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + zapTypes.forEach { (type, label, _) -> + FilterChip( + selected = selectedZapType == type, + onClick = { selectedZapType = type }, + label = { Text(label) }, + colors = + FilterChipDefaults.filterChipColors( + selectedContainerColor = MaterialTheme.colorScheme.primary, + selectedLabelColor = MaterialTheme.colorScheme.onPrimary, + ), + ) + } } - Row( - modifier = Modifier.fillMaxWidth().padding(vertical = 5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - // stringRes(R.string.new_amount_in_sats - label = { - when (selectedZapType) { - LnZapEvent.ZapType.PUBLIC, LnZapEvent.ZapType.ANONYMOUS -> { - Text(text = stringRes(id = R.string.custom_zaps_add_a_message)) - } - - LnZapEvent.ZapType.PRIVATE -> { - Text(text = stringRes(id = R.string.custom_zaps_add_a_message_private)) - } - - LnZapEvent.ZapType.NONZAP -> { - Text(text = stringRes(id = R.string.custom_zaps_add_a_message_nonzap)) - } + // Message input + OutlinedTextField( + label = { + when (selectedZapType) { + LnZapEvent.ZapType.PUBLIC, LnZapEvent.ZapType.ANONYMOUS -> { + Text(text = stringRes(id = R.string.custom_zaps_add_a_message)) } - }, - value = postViewModel.customMessage, - onValueChange = { postViewModel.customMessage = it }, - keyboardOptions = - KeyboardOptions.Default.copy( - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Text, - ), - placeholder = { - Text( - text = stringRes(id = R.string.custom_zaps_add_a_message_example), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - singleLine = true, - modifier = Modifier.padding(end = 5.dp).weight(1f), + + LnZapEvent.ZapType.PRIVATE -> { + Text(text = stringRes(id = R.string.custom_zaps_add_a_message_private)) + } + + LnZapEvent.ZapType.NONZAP -> { + Text(text = stringRes(id = R.string.custom_zaps_add_a_message_nonzap)) + } + } + }, + value = postViewModel.customMessage, + onValueChange = { postViewModel.customMessage = it }, + keyboardOptions = + KeyboardOptions.Default.copy( + capitalization = KeyboardCapitalization.Sentences, + keyboardType = KeyboardType.Text, + ), + placeholder = { + Text( + text = stringRes(id = R.string.custom_zaps_add_a_message_example), + color = MaterialTheme.colorScheme.placeholderText, + ) + }, + singleLine = true, + modifier = + Modifier + .fillMaxWidth() + .padding(bottom = 12.dp), + ) + + // Full-width send button + ZapButton( + isActive = postViewModel.canSend() && !baseNote.isDraft(), + modifier = Modifier.fillMaxWidth(), + ) { + onZapStarts() + accountViewModel.zap( + baseNote, + postViewModel.value()!! * 1000L, + null, + postViewModel.customMessage.text, + context, + onError = onError, + onProgress = onProgress, + onPayViaIntent = onPayViaIntent, + zapType = selectedZapType, ) + onClose() } } } @@ -273,11 +334,13 @@ fun ZapCustomDialog( @Composable fun ZapButton( isActive: Boolean, + modifier: Modifier = Modifier, onPost: () -> Unit, ) { Button( onClick = { if (isActive) onPost() }, shape = ButtonBorder, + modifier = modifier, colors = ButtonDefaults.buttonColors( containerColor = if (isActive) MaterialTheme.colorScheme.primary else Color.Gray, 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 cc4fc65fe7..a9c5f7a818 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 155f31fb9d..c4c6f9f8c9 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 0000000000..f67671f582 --- /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 = stringResource(R.string.accessibility_select_date)) + 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 0000000000..5a973d3047 --- /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 cc6b1fc6bd..075f0aaf75 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 ac1d91cc3a..0000000000 --- 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/creators/zapraiser/IZapRaiser.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/IZapRaiser.kt index 3f590d436b..194cc81ec8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/IZapRaiser.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapraiser/IZapRaiser.kt @@ -21,7 +21,9 @@ package com.vitorpamplona.amethyst.ui.note.creators.zapraiser import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable +@Stable interface IZapRaiser { val zapRaiserAmount: MutableState diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/IZapField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/IZapField.kt index 594b4e3e29..011ec6cc59 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/IZapField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/creators/zapsplits/IZapField.kt @@ -21,9 +21,11 @@ package com.vitorpamplona.amethyst.ui.note.creators.zapsplits import androidx.compose.runtime.MutableState +import androidx.compose.runtime.Stable import androidx.compose.ui.text.input.TextFieldValue import com.vitorpamplona.amethyst.model.User +@Stable interface IZapField { val forwardZapTo: MutableState> val forwardZapToEditting: MutableState 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 bedcfbc701..a3ab729168 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 @@ -77,6 +76,7 @@ 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.note.types.ReplyRenderType import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size10dp @@ -217,7 +217,7 @@ private fun GenericCommentPostBody( baseNote = it, modifier = MaterialTheme.colorScheme.replyModifier, isQuotedNote = true, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, makeItShort = true, quotesLeft = 1, accountViewModel = accountViewModel, @@ -287,9 +287,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/Badge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt index 639612b34c..cf21563758 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Badge.kt @@ -172,7 +172,7 @@ fun RenderBadgeAward( } if (awardees.size > 100) { - Text(" and ${awardees.size - 100} others", maxLines = 1) + Text(stringRes(R.string.badge_and_n_others, awardees.size - 100), maxLines = 1) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt index 3141beed4e..b1925b38fe 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChannelMessage.kt @@ -75,7 +75,7 @@ fun RenderChannelMessage( makeItShort, canPreview, quotesLeft, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, backgroundColor, editState, accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt index 6f0dea396b..6318b62135 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ChatMessage.kt @@ -75,7 +75,7 @@ fun RenderChatMessage( makeItShort, canPreview, quotesLeft, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, backgroundColor, editState, accountViewModel, 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 0000000000..ab22901ef6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Chess.kt @@ -0,0 +1,270 @@ +/* + * 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.R +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.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip64Chess.ChessGameEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameChallengeEvent +import com.vitorpamplona.quartz.nip64Chess.LiveChessGameEndEvent +import com.vitorpamplona.quartz.nip64Chess.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(stringRes(R.string.chess_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(stringRes(R.string.chess_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 58d55e338f..2ed51aec15 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 @@ -37,6 +37,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.model.EmptyTagList import com.vitorpamplona.amethyst.model.AddressableNote import com.vitorpamplona.amethyst.model.Note @@ -47,6 +48,7 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadAddressableNote import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryBaseEvent import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -61,12 +63,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 +86,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 +106,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, @@ -182,7 +185,7 @@ fun RenderInteractiveStory( OutlinedButton( onClick = onRestart, ) { - Text("Restart") + Text(stringRes(R.string.restart)) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt index f65eff9622..44104f1ec8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/LiveActivityChatMessage.kt @@ -80,7 +80,7 @@ fun RenderLiveActivityChatMessage( makeItShort, canPreview, quotesLeft, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, backgroundColor, editState, accountViewModel, 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 b16f0df290..e972fc750e 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,86 @@ */ 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.R 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.stringRes +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 @Composable fun RenderPoll( @@ -56,81 +107,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(stringRes(R.string.poll_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/Reaction.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt index 1f42963ab1..f29e023151 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Reaction.kt @@ -43,7 +43,7 @@ fun RenderReaction( it, modifier = Modifier, isBoostedNote = true, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, quotesLeft = quotesLeft - 1, parentBackgroundColor = backgroundColor, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt index b5e6d44d17..58e0e03f79 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RelayList.kt @@ -234,6 +234,27 @@ fun DisplayTrustedRelayList( ) } +@Composable +fun DisplayFavoriteRelayList( + baseNote: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val relays by accountViewModel.account.favoriteRelayListDecryptionCache.observeDecryptedRelayList(baseNote).collectAsStateWithLifecycle( + accountViewModel.account.favoriteRelayListDecryptionCache.fastStartValueForRelayList(baseNote), + ) + + DisplayRelaySet( + relays, + stringRes(id = R.string.favorite_relays_title), + null, + backgroundColor, + accountViewModel, + nav, + ) +} + @Composable fun DisplayProxyRelayList( baseNote: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt index 35b5e87d30..5248866a89 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/RenderPostApproval.kt @@ -85,7 +85,7 @@ fun RenderPostApproval( it, modifier = MaterialTheme.colorScheme.replyModifier, isQuotedNote = true, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, makeItShort = true, quotesLeft = quotesLeft - 1, parentBackgroundColor = backgroundColor, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt index ce4c2e11b8..7585c9b55e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Report.kt @@ -97,7 +97,7 @@ fun RenderReport( baseNote = it, modifier = MaterialTheme.colorScheme.replyModifier, isQuotedNote = true, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, makeItShort = true, quotesLeft = quotesLeft - 1, parentBackgroundColor = backgroundColor, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt index fc3a43e491..9c15c15134 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/Text.kt @@ -43,8 +43,10 @@ import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.LoadDecryptedContent import com.vitorpamplona.amethyst.ui.note.ReplyNoteComposition +import com.vitorpamplona.amethyst.ui.note.ReplyToLabel import com.vitorpamplona.amethyst.ui.note.elements.DisplayUncitedHashtags import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.theme.HalfVertSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.placeholderText import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hasHashtags @@ -54,13 +56,19 @@ import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip14Subject.subject import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +enum class ReplyRenderType { + FULL, + LINE, + NONE, +} + @Composable fun RenderTextEvent( note: Note, makeItShort: Boolean, canPreview: Boolean, quotesLeft: Int, - unPackReply: Boolean, + unPackReply: ReplyRenderType, backgroundColor: MutableState, editState: State>, accountViewModel: AccountViewModel, @@ -68,14 +76,14 @@ fun RenderTextEvent( ) { val noteEvent = note.event ?: return - val showReply by - remember(note) { - derivedStateOf { - noteEvent is BaseThreadedEvent && !makeItShort && unPackReply && (note.replyTo != null || noteEvent.hasAnyTaggedUser()) + if (unPackReply != ReplyRenderType.NONE) { + val canShowReply by + remember(note) { + derivedStateOf { + noteEvent is BaseThreadedEvent && !makeItShort && (note.replyTo != null || noteEvent.hasAnyTaggedUser()) + } } - } - if (showReply) { val replyingDirectlyTo = remember(note) { if (noteEvent is BaseThreadedEvent) { @@ -94,9 +102,23 @@ fun RenderTextEvent( note.replyTo?.lastOrNull { it.event?.kind != CommunityDefinitionEvent.KIND } } } - if (replyingDirectlyTo != null && unPackReply) { - ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav) - Spacer(modifier = StdVertSpacer) + + if (replyingDirectlyTo != null && canShowReply) { + when (unPackReply) { + ReplyRenderType.FULL -> { + ReplyNoteComposition(replyingDirectlyTo, backgroundColor, accountViewModel, nav) + Spacer(modifier = StdVertSpacer) + } + + ReplyRenderType.LINE -> { + ReplyToLabel( + replyingDirectlyTo = replyingDirectlyTo, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = HalfVertSpacer) + } + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt index 0a36d87739..d69e4a5a07 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TextModification.kt @@ -184,7 +184,7 @@ fun RenderTextModificationEvent( NoteBody( baseNote = baseNote, showAuthorPicture = true, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, makeItShort = false, canPreview = true, showSecondRow = false, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt index 5795813b2d..8b063890e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/TorrentComment.kt @@ -142,7 +142,7 @@ fun TorrentCommentPreview() { false, true, 3, - true, + ReplyRenderType.FULL, remember { mutableStateOf(Color.Transparent) }, EmptyState, accountViewModel = accountViewModel, @@ -158,7 +158,7 @@ fun RenderTorrentComment( makeItShort: Boolean, canPreview: Boolean, quotesLeft: Int, - unPackReply: Boolean, + unPackReply: ReplyRenderType, backgroundColor: MutableState, editState: State>, accountViewModel: AccountViewModel, @@ -167,7 +167,7 @@ fun RenderTorrentComment( Column { val noteEvent = note.event - if (unPackReply) { + if (unPackReply == ReplyRenderType.FULL) { val torrentInfo = remember(noteEvent) { if (noteEvent is TorrentCommentEvent) { @@ -193,7 +193,7 @@ fun RenderTorrentComment( makeItShort, canPreview, quotesLeft, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, backgroundColor, editState, accountViewModel, 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 976916d38e..bf835557bb 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,31 +36,40 @@ 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.getValue +import androidx.compose.runtime.MutableState 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.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 +149,7 @@ fun RenderAudioWithWaveform( Column(modifier = MaxWidthPaddingTop5dp, horizontalAlignment = Alignment.CenterHorizontally) { Row( + Modifier.fillMaxWidth().height(100.dp), verticalAlignment = Alignment.CenterVertically, ) { GetMediaItem( @@ -189,35 +199,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 +222,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 +307,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/ZapEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt new file mode 100644 index 0000000000..11ba6a8987 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/types/ZapEvent.kt @@ -0,0 +1,185 @@ +/* + * 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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowForwardIos +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +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.tooling.preview.Preview +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.note.CrossfadeToDisplayComment +import com.vitorpamplona.amethyst.ui.note.UserPicture +import com.vitorpamplona.amethyst.ui.note.ZapAmountCommentNotification +import com.vitorpamplona.amethyst.ui.note.ZapIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.theme.Size20Modifier +import com.vitorpamplona.amethyst.ui.theme.Size24Modifier +import com.vitorpamplona.amethyst.ui.theme.Size25dp +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.bitcoinColor +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext + +@Composable +fun RenderLnZap( + note: Note, + backgroundColor: MutableState, + accountViewModel: AccountViewModel, + nav: INav, +) { + val zapEvent = note.event as? LnZapEvent ?: return + + val card by parseAuthorCommentAndAmount(note, accountViewModel) + + val destinationKey = zapEvent.zappedAuthor().firstOrNull() ?: return + + TransferCard( + card, + destinationKey, + backgroundColor, + Modifier, + accountViewModel, + nav, + ) +} + +@Composable +private fun parseAuthorCommentAndAmount( + zapEventNote: Note, + accountViewModel: AccountViewModel, +) = produceState( + ZapAmountCommentNotification( + user = null, + comment = null, + amount = null, + ), + zapEventNote, +) { + val newState = accountViewModel.innerDecryptAmountMessage(zapEventNote) + value = newState ?: ZapAmountCommentNotification( + user = null, + comment = null, + amount = null, + ) +} + +@Preview +@Composable +fun TransferCardPreview() { + var user1: User? = null + var user2: User? = null + + runBlocking { + withContext(Dispatchers.IO) { + user1 = LocalCache.getOrCreateUser("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c") + user2 = LocalCache.getOrCreateUser("ca89cb11f1c75d5b6622268ff43d2288ea8b2cb5b9aa996ff9ff704fc904b78b") + } + } + + ThemeComparisonColumn { + TransferCard( + transfer = + ZapAmountCommentNotification( + user = user1, + comment = "This is a comment", + amount = "100", + ), + toUser = user2!!.pubkeyHex, + backgroundColor = remember { mutableStateOf(Color.Transparent) }, + accountViewModel = mockAccountViewModel(), + nav = EmptyNav(), + ) + } +} + +@Composable +fun TransferCard( + transfer: ZapAmountCommentNotification, + toUser: String, + backgroundColor: MutableState, + modifier: Modifier = Modifier, + accountViewModel: AccountViewModel, + nav: INav, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = SpacedBy5dp, + ) { + transfer.user?.let { + UserPicture(it, Size25dp, Modifier, accountViewModel, nav) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + ZapIcon( + Size24Modifier, + MaterialTheme.colorScheme.bitcoinColor, + ) + + // Amount Display + Text( + text = transfer.amount ?: "", + style = MaterialTheme.typography.headlineSmall, + color = MaterialTheme.colorScheme.bitcoinColor, + ) + + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowForwardIos, + contentDescription = null, + tint = MaterialTheme.colorScheme.bitcoinColor, + modifier = Size20Modifier, + ) + } + + UserPicture(toUser, Size25dp, Modifier, accountViewModel, nav) + + // Optional Message (conditionally displayed) + transfer.comment?.let { message -> + Spacer(StdHorzSpacer) + CrossfadeToDisplayComment(message, backgroundColor, accountViewModel = accountViewModel, nav = nav) + } + } +} 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 0000000000..aa40605d62 --- /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: ReplyRenderType, + 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 == ReplyRenderType.FULL && (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/AccountScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt index 8207b38803..4df5ab1346 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountScreen.kt @@ -41,14 +41,14 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.utils.Log @Composable -fun AccountScreen(accountStateViewModel: AccountStateViewModel) { +fun AccountScreen(accountSessionManager: AccountSessionManager) { // Pauses relay services when the app pauses ManageRelayServices() ManageWebOkHttp() - val accountState by accountStateViewModel.accountContent.collectAsStateWithLifecycle() + val accountState by accountSessionManager.accountContent.collectAsStateWithLifecycle() - Log.d("ActivityLifecycle", "AccountScreen $accountState $accountStateViewModel") + Log.d("ActivityLifecycle", "AccountScreen $accountState $accountSessionManager") Crossfade( targetState = accountState, @@ -56,8 +56,8 @@ fun AccountScreen(accountStateViewModel: AccountStateViewModel) { ) { state -> when (state) { is AccountState.Loading -> LoadingSetup() - is AccountState.LoggedOff -> LoggedOffSetup(accountStateViewModel) - is AccountState.LoggedIn -> LoggedInSetup(state, accountStateViewModel) + is AccountState.LoggedOff -> LoggedOffSetup(accountSessionManager) + is AccountState.LoggedIn -> LoggedInSetup(state, accountSessionManager) } } } @@ -94,25 +94,25 @@ fun LoadingSetup() { } @Composable -fun LoggedOffSetup(accountStateViewModel: AccountStateViewModel) { +fun LoggedOffSetup(accountSessionManager: AccountSessionManager) { Surface( modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background, ) { - LoginOrSignupScreen(null, accountStateViewModel, isFirstLogin = true) + LoginOrSignupScreen(null, accountSessionManager, isFirstLogin = true) } } @Composable fun LoggedInSetup( state: AccountState.LoggedIn, - accountStateViewModel: AccountStateViewModel, + accountSessionManager: AccountSessionManager, ) { SetAccountCentricViewModelStore(state) { LoggedInPage( account = state.account, route = state.route, - accountStateViewModel = accountStateViewModel, + accountSessionManager = accountSessionManager, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt similarity index 86% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt index 65b87c9b72..a518e06ef5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountStateViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/AccountSessionManager.kt @@ -21,10 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen import androidx.compose.runtime.Stable -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope import com.vitorpamplona.amethyst.AccountInfo -import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.DefaultChannels @@ -33,14 +30,17 @@ import com.vitorpamplona.amethyst.model.DefaultIndexerRelayList import com.vitorpamplona.amethyst.model.DefaultNIP65List import com.vitorpamplona.amethyst.model.DefaultNIP65RelaySet import com.vitorpamplona.amethyst.model.DefaultSearchRelayList +import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.ui.navigation.routes.Route 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.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag +import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Client import com.vitorpamplona.quartz.nip05DnsIdentifiers.Nip05Id import com.vitorpamplona.quartz.nip06KeyDerivation.Nip06 import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent @@ -63,9 +63,8 @@ import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -77,21 +76,27 @@ import java.util.regex.Pattern val EMAIL_PATTERN: Pattern = Pattern.compile(".+@.+\\.[a-z]+") @Stable -class AccountStateViewModel : ViewModel() { +class AccountSessionManager( + val accountsCache: AccountCacheState, + val nip05Client: Nip05Client, + val client: INostrClient, + val localPreferences: LocalPreferences, + val scope: CoroutineScope, +) { private val _accountContent = MutableStateFlow(AccountState.Loading) val accountContent = _accountContent.asStateFlow() fun loginWithDefaultAccountIfLoggedOff() { // pulls account from storage. if (_accountContent.value !is AccountState.LoggedIn) { - viewModelScope.launch(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { loginWithDefaultAccount() } } } private suspend fun loginWithDefaultAccount(route: Route? = null) { - val accountSettings = LocalPreferences.loadAccountConfigFromEncryptedStorage() + val accountSettings = localPreferences.loadAccountConfigFromEncryptedStorage() if (accountSettings != null) { startUI(accountSettings, route) @@ -155,7 +160,7 @@ class AccountStateViewModel : ViewModel() { ) } - LocalPreferences.setDefaultAccount(accountSettings) + localPreferences.setDefaultAccount(accountSettings) startUI(accountSettings) } @@ -164,7 +169,7 @@ class AccountStateViewModel : ViewModel() { accountSettings: AccountSettings, route: Route? = null, ) { - val account = Amethyst.instance.accountsCache.loadAccount(accountSettings) + val account = accountsCache.loadAccount(accountSettings) _accountContent.update { AccountState.LoggedIn(account, route) } @@ -187,7 +192,7 @@ class AccountStateViewModel : ViewModel() { packageName: String = "", onError: (String?) -> Unit, ) { - viewModelScope.launch(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { if (key.startsWith("ncryptsec")) { val newKey = try { @@ -214,7 +219,7 @@ class AccountStateViewModel : ViewModel() { onError("Could not parse nip05 address: $nip05") } else { try { - val pubkeyInfo = Amethyst.instance.nip05Client.get(nip05) + val pubkeyInfo = nip05Client.get(nip05) if (pubkeyInfo == null) { onError("User not found in the nip05 server: $nip05") } else { @@ -238,7 +243,7 @@ class AccountStateViewModel : ViewModel() { packageName: String = "", onError: (String) -> Unit, ) { - viewModelScope.launch(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { loginSync(key, transientAccount, loginWithExternalSigner, packageName, onError) } } @@ -263,7 +268,7 @@ class AccountStateViewModel : ViewModel() { } fun newKey(name: String? = null) { - viewModelScope.launch(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { if (_accountContent.value is AccountState.LoggedIn) { prepareLogoutOrSwitch() } @@ -272,22 +277,21 @@ class AccountStateViewModel : ViewModel() { val accountSettings = createNewAccount(name) - LocalPreferences.setDefaultAccount(accountSettings) + localPreferences.setDefaultAccount(accountSettings) startUI(accountSettings) - @OptIn(DelicateCoroutinesApi::class) - GlobalScope.launch(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { delay(2000) // waits for the new user to connect to the new relays. val toPost = accountSettings.backupNIP65RelayList?.writeRelaysNorm()?.toSet() ?: DefaultNIP65RelaySet - accountSettings.backupUserMetadata?.let { Amethyst.instance.client.send(it, toPost) } - accountSettings.backupContactList?.let { Amethyst.instance.client.send(it, toPost) } - accountSettings.backupNIP65RelayList?.let { Amethyst.instance.client.send(it, toPost) } - accountSettings.backupDMRelayList?.let { Amethyst.instance.client.send(it, toPost) } - accountSettings.backupSearchRelayList?.let { Amethyst.instance.client.send(it, toPost) } - accountSettings.backupIndexRelayList?.let { Amethyst.instance.client.send(it, toPost) } + accountSettings.backupUserMetadata?.let { client.send(it, toPost) } + accountSettings.backupContactList?.let { client.send(it, toPost) } + accountSettings.backupNIP65RelayList?.let { client.send(it, toPost) } + accountSettings.backupDMRelayList?.let { client.send(it, toPost) } + accountSettings.backupSearchRelayList?.let { client.send(it, toPost) } + accountSettings.backupIndexRelayList?.let { client.send(it, toPost) } } } } @@ -315,7 +319,7 @@ class AccountStateViewModel : ViewModel() { } fun switchUser(accountInfo: AccountInfo) { - viewModelScope.launch(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { switchUserSync(accountInfo) } } @@ -324,8 +328,8 @@ class AccountStateViewModel : ViewModel() { npub: String, route: Route, ): Boolean { - if (npub != LocalPreferences.currentAccount()) { - val account = LocalPreferences.allSavedAccounts().firstOrNull { it.npub == npub } + if (npub != localPreferences.currentAccount()) { + val account = localPreferences.allSavedAccounts().firstOrNull { it.npub == npub } if (account != null) { switchUserSync(account, route) return true @@ -339,7 +343,7 @@ class AccountStateViewModel : ViewModel() { route: Route? = null, ) { prepareLogoutOrSwitch() - LocalPreferences.switchToAccount(accountInfo) + localPreferences.switchToAccount(accountInfo) loginWithDefaultAccount(route) } @@ -357,17 +361,17 @@ class AccountStateViewModel : ViewModel() { } fun logOff(accountInfo: AccountInfo) { - viewModelScope.launch(Dispatchers.IO) { + scope.launch(Dispatchers.IO) { if (accountInfo.npub == currentAccountNPub()) { // log off and relogin with the 0 account prepareLogoutOrSwitch() - LocalPreferences.deleteAccount(accountInfo) - Amethyst.instance.accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey()) + localPreferences.deleteAccount(accountInfo) + accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey()) loginWithDefaultAccount() } else { // delete without switching logins - LocalPreferences.deleteAccount(accountInfo) - Amethyst.instance.accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey()) + localPreferences.deleteAccount(accountInfo) + accountsCache.removeAccount(accountInfo.npub.bechToBytes().toHexKey()) } } } 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 4f8368962c..4721d9cef6 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 @@ -24,37 +24,16 @@ import android.content.Context import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.R -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.Account import com.vitorpamplona.amethyst.model.AddressableNote -import com.vitorpamplona.amethyst.model.GLOBAL_FOLLOWS -import com.vitorpamplona.amethyst.model.KIND3_FOLLOWS +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.service.checkNotInMainThread import com.vitorpamplona.amethyst.ui.navigation.routes.Route import com.vitorpamplona.amethyst.ui.stringRes -import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent -import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent -import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent -import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent -import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent -import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent -import com.vitorpamplona.quartz.nip18Reposts.RepostEvent -import com.vitorpamplona.quartz.nip22Comments.CommentEvent -import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent 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.nip72ModCommunities.approval.CommunityPostApprovalEvent -import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent -import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent import com.vitorpamplona.quartz.utils.Log import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList @@ -68,6 +47,7 @@ import kotlinx.coroutines.flow.combineTransform import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.transform @Stable class TopNavFilterState( @@ -75,55 +55,45 @@ class TopNavFilterState( val scope: CoroutineScope, ) { val allFollows = - PeopleListOutBoxFeedDefinition( - code = ALL_FOLLOWS, + FeedDefinition( + code = TopFilter.AllFollows, name = ResourceName(R.string.follow_list_kind3follows), - type = CodeNameType.HARDCODED, - kinds = DEFAULT_FEED_KINDS, - unpackList = listOf(ContactListEvent.blockListFor(account.signer.pubKey)), ) val userFollows = - PeopleListOutBoxFeedDefinition( - code = ALL_USER_FOLLOWS, + FeedDefinition( + code = TopFilter.AllUserFollows, name = ResourceName(R.string.follow_list_kind3follows_users_only), - type = CodeNameType.HARDCODED, - kinds = DEFAULT_FEED_KINDS, - unpackList = listOf(ContactListEvent.blockListFor(account.signer.pubKey)), ) val kind3Follows = - PeopleListOutBoxFeedDefinition( - code = KIND3_FOLLOWS, + FeedDefinition( + code = TopFilter.DefaultFollows, name = ResourceName(R.string.follow_list_kind3_follows_users_only), - type = CodeNameType.HARDCODED, - kinds = DEFAULT_FEED_KINDS, - unpackList = listOf(ContactListEvent.blockListFor(account.signer.pubKey)), ) val globalFollow = - GlobalFeedDefinition( - code = GLOBAL_FOLLOWS, + FeedDefinition( + code = TopFilter.Global, name = ResourceName(R.string.follow_list_global), - type = CodeNameType.HARDCODED, - kinds = DEFAULT_FEED_KINDS, ) val aroundMe = - AroundMeFeedDefinition( - code = AROUND_ME, + FeedDefinition( + code = TopFilter.AroundMe, name = ResourceName(R.string.follow_list_aroundme), - type = CodeNameType.HARDCODED, - kinds = DEFAULT_FEED_KINDS, ) val muteListFollow = - PeopleListOutBoxFeedDefinition( - code = MuteListEvent.blockListFor(account.userProfile().pubkeyHex), + FeedDefinition( + code = TopFilter.MuteList(account.muteList.getMuteListAddress()), name = ResourceName(R.string.follow_list_mute_list), - type = CodeNameType.HARDCODED, - kinds = DEFAULT_FEED_KINDS, - unpackList = listOf(MuteListEvent.blockListFor(account.userProfile().pubkeyHex)), + ) + + val chessFollow = + FeedDefinition( + code = TopFilter.Chess, + name = ResourceName(R.string.follow_list_chess), ) val defaultLists = persistentListOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow, muteListFollow) @@ -134,23 +104,17 @@ class TopNavFilterState( ): List { val peopleListsDefs = peopleLists.map { - PeopleListOutBoxFeedDefinition( - it.idHex, + FeedDefinition( + TopFilter.PeopleList(it.address), PeopleListName(it), - CodeNameType.PEOPLE_LIST, - kinds = DEFAULT_FEED_KINDS, - listOf(it.idHex), ) } val followListsDefs = followLists.map { - PeopleListOutBoxFeedDefinition( - it.idHex, + FeedDefinition( + TopFilter.PeopleList(it.address), PeopleListName(it), - CodeNameType.PEOPLE_LIST, - kinds = DEFAULT_FEED_KINDS, - listOf(it.idHex), ) } @@ -175,44 +139,45 @@ class TopNavFilterState( hashtagList: Set, geotagList: Set, communityList: List, + relayList: Set, ): List { val hashtags = hashtagList.map { - TagFeedDefinition( - "Hashtag/$it", + FeedDefinition( + TopFilter.Hashtag(it), HashtagName(it), - CodeNameType.ROUTE, route = Route.Hashtag(it), - kinds = DEFAULT_FEED_KINDS, - tTags = listOf(it), ) } val geotags = geotagList.map { - TagFeedDefinition( - "Geohash/$it", + FeedDefinition( + TopFilter.Geohash(it), GeoHashName(it), - CodeNameType.ROUTE, route = Route.Geohash(it), - kinds = DEFAULT_FEED_KINDS, - gTags = listOf(it), ) } val communities = communityList.map { communityNote -> - TagFeedDefinition( - "Community/${communityNote.idHex}", + FeedDefinition( + TopFilter.Community(communityNote.address), CommunityName(communityNote), - CodeNameType.ROUTE, route = Route.Community(communityNote.address.kind, communityNote.address.pubKeyHex, communityNote.address.dTag), - kinds = DEFAULT_COMMUNITY_FEEDS, - aTags = listOf(communityNote.idHex), ) } - return (communities + hashtags + geotags).sortedBy { it.name.name() } + val relays = + relayList.map { relayUrl -> + FeedDefinition( + TopFilter.Relay(relayUrl.url), + RelayName(relayUrl), + route = Route.RelayFeed(relayUrl.url), + ) + } + + return (communities + hashtags + geotags + relays).sortedBy { it.name.name() } } @OptIn(ExperimentalCoroutinesApi::class) @@ -221,6 +186,7 @@ class TopNavFilterState( account.hashtagList.flow, account.geohashList.flow, account.communityList.flowNotes, + account.favoriteRelayList.flow, ::mergeInterests, ).onStart { emit( @@ -228,6 +194,7 @@ class TopNavFilterState( account.hashtagList.flow.value, account.geohashList.flow.value, account.communityList.flowNotes.value, + account.favoriteRelayList.flow.value, ), ) } @@ -236,28 +203,25 @@ class TopNavFilterState( combineTransform( livePeopleListsFlow, liveInterestFlows, - ) { myLivePeopleListsFlow, myLiveKind3FollowsFlow -> + ) { peopleLists, interests -> checkNotInMainThread() emit( listOf( listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow), - myLivePeopleListsFlow, - myLiveKind3FollowsFlow, + peopleLists, + interests, listOf(muteListFollow), ).flatten().toImmutableList(), ) } private val _kind3GlobalPeople = - combineTransform( - livePeopleListsFlow, - liveInterestFlows, - ) { myLivePeopleListsFlow, myLiveKind3FollowsFlow -> + livePeopleListsFlow.transform { peopleLists -> checkNotInMainThread() emit( listOf( listOf(allFollows, userFollows, kind3Follows, aroundMe, globalFollow), - myLivePeopleListsFlow, + peopleLists, listOf(muteListFollow), ).flatten().toImmutableList(), ) @@ -278,30 +242,35 @@ class TopNavFilterState( } } -enum class CodeNameType { - HARDCODED, - PEOPLE_LIST, - ROUTE, -} - +@Stable abstract class Name { abstract fun name(): String open fun name(context: Context) = name() } +@Stable class GeoHashName( val geoHashTag: String, ) : Name() { override fun name() = "/g/$geoHashTag" } +@Stable class HashtagName( val hashTag: String, ) : Name() { override fun name() = "#$hashTag" } +@Stable +class RelayName( + val url: NormalizedRelayUrl, +) : Name() { + override fun name() = url.displayUrl() +} + +@Stable class ResourceName( val resourceId: Int, ) : Name() { @@ -310,6 +279,7 @@ class ResourceName( override fun name(context: Context) = stringRes(context, resourceId) } +@Stable class PeopleListName( val note: AddressableNote, ) : Name() { @@ -325,6 +295,7 @@ class PeopleListName( } } +@Stable class CommunityName( val note: AddressableNote, ) : Name() { @@ -332,83 +303,8 @@ class CommunityName( } @Immutable -abstract class FeedDefinition( - val code: String, +class FeedDefinition( + val code: TopFilter, val name: Name, - val type: CodeNameType, - val route: Route?, + val route: Route? = null, ) - -@Immutable -class GlobalFeedDefinition( - code: String, - name: Name, - type: CodeNameType, - val kinds: List, -) : FeedDefinition(code, name, type, null) - -@Immutable -class TagFeedDefinition( - code: String, - name: Name, - type: CodeNameType, - route: Route?, - val kinds: List, - val pTags: List? = null, - val eTags: List? = null, - val aTags: List? = null, - val tTags: List? = null, - val gTags: List? = null, -) : FeedDefinition(code, name, type, route) - -@Immutable -class AroundMeFeedDefinition( - code: String, - name: Name, - type: CodeNameType, - val kinds: List, -) : FeedDefinition(code, name, type, null) - -@Immutable -class PeopleListOutBoxFeedDefinition( - code: String, - name: Name, - type: CodeNameType, - val kinds: List, - val unpackList: List, -) : FeedDefinition(code, name, type, null) - -val DEFAULT_FEED_KINDS = - listOf( - TextNoteEvent.KIND, - RepostEvent.KIND, - GenericRepostEvent.KIND, - ClassifiedsEvent.KIND, - LongTextNoteEvent.KIND, - PollNoteEvent.KIND, - HighlightEvent.KIND, - AudioTrackEvent.KIND, - AudioHeaderEvent.KIND, - PinListEvent.KIND, - LiveActivitiesChatMessageEvent.KIND, - LiveActivitiesEvent.KIND, - WikiNoteEvent.KIND, - NipTextEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - ) - -val DEFAULT_COMMUNITY_FEEDS = - listOf( - TextNoteEvent.KIND, - CommentEvent.KIND, - LongTextNoteEvent.KIND, - ClassifiedsEvent.KIND, - HighlightEvent.KIND, - AudioHeaderEvent.KIND, - AudioTrackEvent.KIND, - PinListEvent.KIND, - WikiNoteEvent.KIND, - NipTextEvent.KIND, - CommunityPostApprovalEvent.KIND, - InteractiveStoryPrologueEvent.KIND, - ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt index f75fe0f9a5..a0121ae349 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/UserFeedState.kt @@ -20,19 +20,25 @@ */ package com.vitorpamplona.amethyst.ui.screen +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.model.User import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.flow.MutableStateFlow +@Stable sealed class UserFeedState { + @Stable object Loading : UserFeedState() + @Stable class Loaded( val feed: MutableStateFlow>, ) : UserFeedState() + @Stable object Empty : UserFeedState() + @Stable class FeedError( val errorMessage: String, ) : UserFeedState() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt index 4c25c43926..84ec07af70 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountSwitcherAndLeftDrawerLayout.kt @@ -40,14 +40,14 @@ import androidx.compose.ui.platform.LocalConfiguration import com.vitorpamplona.amethyst.ui.navigation.drawer.AccountSwitchBottomSheet import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerContent import com.vitorpamplona.amethyst.ui.navigation.navs.INav -import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable fun AccountSwitcherAndLeftDrawerLayout( accountViewModel: AccountViewModel, - accountStateViewModel: AccountStateViewModel, + accountSessionManager: AccountSessionManager, nav: INav, content: @Composable () -> Unit, ) { @@ -106,7 +106,7 @@ fun AccountSwitcherAndLeftDrawerLayout( ) { AccountSwitchBottomSheet( accountViewModel = accountViewModel, - accountStateViewModel = accountStateViewModel, + accountSessionManager = accountSessionManager, ) } } 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 c1c2eca360..4bf0127d41 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 @@ -616,9 +617,14 @@ class AccountViewModel( val decryptedInfo = mapNotNullAsync(myList) { next -> - val info = innerDecryptAmountMessage(next.first, next.second) - if (info != null) { - DecryptedInfo(next.first, next.second, info) + val zap = next.second + if (zap != null) { + val info = innerDecryptAmountMessage(next.first, zap) + if (info != null) { + DecryptedInfo(next.first, zap, info) + } else { + null + } } else { null } @@ -632,7 +638,7 @@ class AccountViewModel( fun decryptAmountMessage( zapRequest: Note, - zapEvent: Note?, + zapEvent: Note, onNewState: (ZapAmountCommentNotification?) -> Unit, ) { viewModelScope.launch(Dispatchers.IO) { @@ -640,35 +646,50 @@ class AccountViewModel( } } - private suspend fun innerDecryptAmountMessage( + suspend fun innerDecryptAmountMessage(zapNote: Note): ZapAmountCommentNotification? { + val zapEvent = zapNote.event as? LnZapEvent ?: return null + val zapRequest = zapEvent.zapRequest ?: return null + + return innerDecryptAmountMessage(zapRequest, zapEvent) + } + + suspend fun innerDecryptAmountMessage( zapRequest: Note, - zapEvent: Note?, - ): ZapAmountCommentNotification? = - (zapRequest.event as? LnZapRequestEvent)?.let { - val amount = showAmountInteger((zapEvent?.event as? LnZapEvent)?.amount) - if (it.isPrivateZap()) { - val decryptedContent = account.decryptZapOrNull(it) - if (decryptedContent != null) { - ZapAmountCommentNotification( - LocalCache.checkGetOrCreateUser(decryptedContent.pubKey), - decryptedContent.content.ifBlank { null }, - amount, - ) - } else { - ZapAmountCommentNotification( - zapRequest.author, - null, - amount, - ) - } + zapEvent: Note, + ): ZapAmountCommentNotification? { + val zapEvent = zapEvent.event as? LnZapEvent ?: return null + val zapRequest = zapRequest.event as? LnZapRequestEvent ?: return null + return innerDecryptAmountMessage(zapRequest, zapEvent) + } + + suspend fun innerDecryptAmountMessage( + zapRequestEvent: LnZapRequestEvent, + zapEvent: LnZapEvent, + ): ZapAmountCommentNotification? { + val amount = showAmountInteger(zapEvent.amount) + return if (zapRequestEvent.isPrivateZap()) { + val decryptedContent = account.decryptZapOrNull(zapRequestEvent) + if (decryptedContent != null) { + ZapAmountCommentNotification( + LocalCache.checkGetOrCreateUser(decryptedContent.pubKey), + decryptedContent.content.ifBlank { null }, + amount, + ) } else { ZapAmountCommentNotification( - zapRequest.author, - zapRequest.event?.content?.ifBlank { null }, + account.cache.getOrCreateUser(zapRequestEvent.pubKey), + null, amount, ) } + } else { + ZapAmountCommentNotification( + account.cache.getOrCreateUser(zapRequestEvent.pubKey), + zapRequestEvent.content.ifBlank { null }, + amount, + ) } + } fun zap( note: Note, @@ -905,6 +926,10 @@ class AccountViewModel( fun unfollowHashtag(tag: String) = launchSigner { account.unfollowHashtag(tag) } + fun followFavoriteRelay(url: NormalizedRelayUrl) = launchSigner { account.followFavoriteRelay(url) } + + fun unfollowFavoriteRelay(url: NormalizedRelayUrl) = launchSigner { account.unfollowFavoriteRelay(url) } + fun showWord(word: String) = launchSigner { account.showWord(word) } fun hideWord(word: String) = launchSigner { account.hideWord(word) } @@ -1480,21 +1505,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/DecryptAndIndexProcessor.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt index 652d5c5e77..2b3eeeaf95 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/DecryptAndIndexProcessor.kt @@ -141,6 +141,7 @@ class EventProcessor( } interface EventHandler { + // Default no-ops; implementations override only the operations they handle suspend fun add( event: T, eventNote: Note, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt index 63903ed3c0..655a60487e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/LoggedInPage.kt @@ -46,7 +46,7 @@ import com.vitorpamplona.amethyst.service.relayClient.authCommand.compose.RelayA import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.navigation.AppNavigation import com.vitorpamplona.amethyst.ui.navigation.routes.Route -import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.ChatroomListFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource.DiscoveryFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.HomeFilterAssemblerSubscription @@ -59,7 +59,7 @@ import kotlinx.coroutines.launch fun LoggedInPage( account: Account, route: Route?, - accountStateViewModel: AccountStateViewModel, + accountSessionManager: AccountSessionManager, ) { val accountViewModel: AccountViewModel = viewModel( @@ -99,7 +99,7 @@ fun LoggedInPage( AppNavigation( accountViewModel = accountViewModel, - accountStateViewModel = accountStateViewModel, + accountSessionManager = accountSessionManager, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt index 61147eca12..bebea047dc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedFilter.kt @@ -20,31 +20,5 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal -import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter -import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter -import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder -import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey - -class ChatroomFeedFilter( - val withUser: ChatroomKey, - val account: Account, -) : AdditiveFeedFilter(), - ChangesFlowFilter { - fun chatroom() = account.chatroomList.getOrCreatePrivateChatroom(withUser) - - override fun changesFlow() = chatroom().changesFlow() - - // returns the last Note of each user. - override fun feedKey(): String = withUser.hashCode().toString() - - override fun feed(): List = chatroom().messages.filter { account.isAcceptable(it) }.sortedWith(DefaultFeedOrder) - - override fun applyFilter(newItems: Set): Set { - val chatroom = chatroom() - return newItems.filter { it in chatroom.messages && account.isAcceptable(it) }.toSet() - } - - override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) -} +// Re-export from commons for backwards compatibility +typealias ChatroomFeedFilter = com.vitorpamplona.amethyst.commons.ui.feeds.ChatroomFeedFilter diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt index e40bcfb718..d1b670e540 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/dal/ChatroomFeedViewModel.kt @@ -20,26 +20,22 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal -import androidx.compose.runtime.Stable import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider -import androidx.lifecycle.viewModelScope -import com.vitorpamplona.amethyst.commons.model.ListChange -import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState -import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache -import com.vitorpamplona.amethyst.model.Note -import com.vitorpamplona.amethyst.ui.dal.ChangesFlowFilter import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey -import com.vitorpamplona.quartz.utils.Log -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch + +// Re-export from commons for backwards compatibility +typealias ListChangeFeedViewModel = com.vitorpamplona.amethyst.commons.viewmodels.ListChangeFeedViewModel class ChatroomFeedViewModel( val user: ChatroomKey, val account: Account, -) : ListChangeFeedViewModel(ChatroomFeedFilter(user, account)) { +) : com.vitorpamplona.amethyst.commons.viewmodels.ListChangeFeedViewModel( + ChatroomFeedFilter(user, account), + LocalCache, + ) { class Factory( val user: ChatroomKey, val account: Account, @@ -48,30 +44,3 @@ class ChatroomFeedViewModel( override fun create(modelClass: Class): T = ChatroomFeedViewModel(user, account) as T } } - -@Stable -abstract class ListChangeFeedViewModel( - localFilter: ChangesFlowFilter, -) : ViewModel(), - InvalidatableContent { - val feedState = FeedContentState(localFilter, viewModelScope, LocalCache) - - override val isRefreshing = feedState.isRefreshing - - override fun invalidateData(ignoreIfDoing: Boolean) = feedState.invalidateData(ignoreIfDoing) - - init { - Log.d("Init", "Starting new Model: ${this.javaClass.simpleName}") - viewModelScope.launch(Dispatchers.IO) { - localFilter.changesFlow().collect { - Log.d("Init", "Collecting changes to: ${this@ListChangeFeedViewModel.javaClass.simpleName}") - when (it) { - is ListChange.Addition -> feedState.updateFeedWith(setOf(it.item)) - is ListChange.Deletion -> feedState.deleteFromFeed(setOf(it.item)) - is ListChange.SetAddition -> feedState.updateFeedWith(it.item) - is ListChange.SetDeletion -> feedState.deleteFromFeed(it.item) - } - } - } - } -} 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 b9f35449bb..f2cd402497 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 6d46f1e0a5..1dca30b9fd 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/dal/ChannelFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt index 81caac22ed..8d3fa89a04 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/publicChannels/dal/ChannelFeedViewModel.kt @@ -24,12 +24,13 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModelProvider import com.vitorpamplona.amethyst.commons.model.Channel import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.dal.ListChangeFeedViewModel class ChannelFeedViewModel( val channel: Channel, val account: Account, -) : ListChangeFeedViewModel(ChannelFeedFilter(channel, account)) { +) : ListChangeFeedViewModel(ChannelFeedFilter(channel, account), LocalCache) { class Factory( val channel: Channel, val account: Account, 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 1888c8b5ec..2ef6776d83 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/rooms/datasource/ChatroomListFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt index 381f16cd26..2b9c534502 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/datasource/ChatroomListFilterAssembler.kt @@ -20,15 +20,18 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag +@Stable class ChatroomListState( val account: Account, ) +@Stable class ChatroomListFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt index bfb95ac5a2..9f48375a48 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/feed/ChatroomListTabs.kt @@ -54,6 +54,7 @@ import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size40dp import com.vitorpamplona.amethyst.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.ui.theme.placeholderText +import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.launch @Immutable @@ -66,7 +67,7 @@ class MessagesTabItem( @Composable fun MessagesTabHeader( pagerState: PagerState, - tabs: List, + tabs: ImmutableList, onMarkKnownAsRead: () -> Unit, onMarkNewAsRead: () -> Unit, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt index eadd6c0e84..f2c215e082 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/singlepane/MessagesSinglePane.kt @@ -42,6 +42,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.Chat import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesPager import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesTabHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesTabItem +import kotlinx.collections.immutable.persistentListOf @Composable fun MessagesSinglePane( @@ -60,7 +61,7 @@ fun MessagesSinglePane( val tabs by remember(knownFeedContentState) { derivedStateOf { - listOf( + persistentListOf( MessagesTabItem(R.string.known, ScrollStateKeys.MESSAGES_KNOWN, knownFeedContentState), MessagesTabItem(R.string.new_requests, ScrollStateKeys.MESSAGES_NEW, newFeedContentState), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt index 214bd74c89..14928b2470 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/rooms/twopane/ChatroomListPane.kt @@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.datasource.Chat import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesPager import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesTabHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.rooms.feed.MessagesTabItem +import kotlinx.collections.immutable.persistentListOf @Composable fun ChatroomList( @@ -56,7 +57,7 @@ fun ChatroomList( val tabs by remember(knownFeedContentState) { derivedStateOf { - listOf( + persistentListOf( MessagesTabItem(R.string.known, ScrollStateKeys.MESSAGES_KNOWN, knownFeedContentState), MessagesTabItem(R.string.new_requests, ScrollStateKeys.MESSAGES_NEW, newFeedContentState), ) 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 2c28ba396e..de7626e471 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 0000000000..18c639ec4c --- /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 0000000000..99f9579113 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessGameScreen.kt @@ -0,0 +1,434 @@ +/* + * 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.activity.compose.LocalActivity +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.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.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.amethyst.ui.screen.loggedIn.chess.datasource.ChessSubscription +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Height4dpModifier +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 = LocalActivity.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(chessViewModel, accountViewModel) + + // 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 = stringRes(R.string.back), + ) + } + }, + actions = { + IconButton(onClick = { showRelaySettings = true }) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = stringRes(R.string.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(stringRes(R.string.chess_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 = stringRes(R.string.chess_game_not_found), + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Show specific error if available + Text( + text = error ?: stringRes(R.string.chess_game_waiting), + style = MaterialTheme.typography.bodyMedium, + color = if (error != null) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Text( + text = stringRes(R.string.chess_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 = stringRes(R.string.back), + modifier = Modifier.padding(end = 8.dp), + ) + Text(stringRes(R.string.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 = Height4dpModifier) + + 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 0000000000..3b669b127d --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessLobbyScreen.kt @@ -0,0 +1,511 @@ +/* + * 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.activity.compose.LocalActivity +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.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +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.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.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.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.ActiveGameCard +import com.vitorpamplona.amethyst.commons.chess.ChallengeCard +import com.vitorpamplona.amethyst.commons.chess.ChessChallenge +import com.vitorpamplona.amethyst.commons.chess.ChessSyncBanner +import com.vitorpamplona.amethyst.commons.chess.NewChessGameDialog +import com.vitorpamplona.amethyst.commons.chess.OutgoingChallengeCard +import com.vitorpamplona.amethyst.commons.chess.PublicGameCard +import com.vitorpamplona.amethyst.commons.chess.SpectatingGameCard +import com.vitorpamplona.amethyst.ui.feeds.RefresheableBox +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.datasource.ChessSubscription +import com.vitorpamplona.amethyst.ui.stringRes +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 = LocalActivity.current as FragmentActivity + val chessViewModel: ChessViewModelNew = + viewModel( + viewModelStoreOwner = activity, + key = "ChessViewModelNew-${accountViewModel.account.userProfile().pubkeyHex}", + factory = ChessViewModelFactory(accountViewModel.account), + ) + + // Subscribe to chess events when screen is visible + ChessSubscription(chessViewModel, accountViewModel) + + // Clear any stale game selection on mount + LaunchedEffect(Unit) { + 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 + } + } + + NavigateIfInAGame(chessViewModel, nav) + + ChessLobbyScreen(chessViewModel, accountViewModel, nav) +} + +@Composable +fun NavigateIfInAGame( + chessViewModel: ChessViewModelNew, + nav: INav, +) { + val selectedGameId by chessViewModel.selectedGameId.collectAsState() + val activeGames by chessViewModel.activeGames.collectAsState() + + // 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) + } + } + } +} + +/** + * Chess lobby screen showing challenges and active games + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChessLobbyScreen( + chessViewModel: ChessViewModelNew, + accountViewModel: AccountViewModel, + nav: INav, +) { + var showNewGameDialog by remember { mutableStateOf(false) } + var showRelaySettings by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.route_chess)) }, + navigationIcon = { + IconButton(onClick = { nav.popBack() }) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringRes(R.string.back), + ) + } + }, + actions = { + IconButton(onClick = { showRelaySettings = true }) { + Icon( + imageVector = Icons.Default.Settings, + contentDescription = stringRes(R.string.relay_settings), + ) + } + }, + ) + }, + floatingActionButton = { + NewChessGameButton { showNewGameDialog = true } + }, + ) { paddingValues -> + RefresheableBox( + onRefresh = { chessViewModel.forceRefresh() }, + ) { + Column( + modifier = + Modifier + .fillMaxSize() + .padding(paddingValues) + .padding(horizontal = 16.dp), + ) { + // Sync status banner (shows relay progress during loading) + ChessSyncBanner(chessViewModel) + + ErrorDisplay( + chessViewModel, + ) + + // Main content + ChessLobbyContent( + chessViewModel = chessViewModel, + 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( + chessViewModel = chessViewModel, + accountViewModel = accountViewModel, + ) + } + } +} + +@Composable +fun ChessSyncBanner(chessViewModel: ChessViewModelNew) { + val syncStatus by chessViewModel.syncStatus.collectAsState() + + ChessSyncBanner( + status = syncStatus, + onRetry = { chessViewModel.forceRefresh() }, + modifier = Modifier.padding(bottom = 8.dp), + ) +} + +@Composable +fun ErrorDisplay(chessViewModel: ChessViewModelNew) { + // Error display + val error by chessViewModel.error.collectAsState() + + 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(stringRes(R.string.dismiss)) + } + } + } + } +} + +@Composable +fun ChessLobbyContent( + chessViewModel: ChessViewModelNew, + accountViewModel: AccountViewModel, + onAcceptChallenge: (ChessChallenge) -> Unit, + onOpenOwnChallenge: (ChessChallenge) -> Unit, + onSelectGame: (String) -> Unit, + onWatchGame: (String) -> Unit, +) { + 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 userPubkey = accountViewModel.account.userProfile().pubkeyHex + + 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) + } + 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) + } + 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 + 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) + } + 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) + } + 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) + PublicGameCard( + whiteName = whiteName, + blackName = blackName, + moveCount = game.moveCount, + onWatch = { onWatchGame(game.gameId) }, + ) + } + } + + // Bottom padding for FAB + item { + Spacer(Modifier.height(80.dp)) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessRelaySettingsSheet.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessRelaySettingsSheet.kt new file mode 100644 index 0000000000..1c1b7fb6d4 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessRelaySettingsSheet.kt @@ -0,0 +1,353 @@ +/* + * 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 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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.chess.ChessConfig +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import kotlinx.coroutines.flow.map + +/** + * Bottom sheet showing relay settings and debug info for chess subscriptions + */ +@Composable +fun ChessRelaySettingsSheet( + chessViewModel: ChessViewModelNew, + accountViewModel: AccountViewModel, +) { + // 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() + + 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, + ) { + StatRow(chessViewModel) + } + + 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.contains(relay) || + outboxRelays.contains(relay) || + globalRelays.contains(relay) + ChessRelayRow(relay = 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)) + } +} + +@SuppressLint("StateFlowValueCalledInComposition") +@Composable +fun StatRow(chessViewModel: ChessViewModelNew) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + val challengeCount by remember { + chessViewModel.challenges.map { it.size } + }.collectAsStateWithLifecycle(chessViewModel.challenges.value.size) + + 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) { + val publicGameCount by remember { + chessViewModel.publicGames.map { it.size } + }.collectAsStateWithLifecycle(chessViewModel.publicGames.value.size) + + 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, + ) + } +} + +@Composable +private fun RelayRow( + relayUrl: NormalizedRelayUrl, + 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 = stringRes(R.string.connected), + tint = + if (isPreferred) { + MaterialTheme.colorScheme.tertiary + } else { + MaterialTheme.colorScheme.primary + }, + modifier = Modifier.size(12.dp), + ) + Text( + text = relayUrl.displayUrl(), + 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( + relay: NormalizedRelayUrl, + 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 = relay.displayUrl(), + 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/ChessViewModelFactory.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelFactory.kt new file mode 100644 index 0000000000..b4c33a68dc --- /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 0000000000..7c7fef8b73 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/ChessViewModelNew.kt @@ -0,0 +1,196 @@ +/* + * 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.Stable +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 + */ +@Stable +class ChessViewModelNew( + private val account: Account, +) : ViewModel() { + // Instance ID for debugging ViewModel sharing + val instanceId = System.identityHashCode(this) + + // 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 0000000000..7281899c08 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/NewChessGameButton.kt @@ -0,0 +1,55 @@ +/* + * 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.R +import com.vitorpamplona.amethyst.ui.stringRes +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 = stringRes(R.string.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 0000000000..26749dd38a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/dal/ChessFeedFilter.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.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.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.amethyst.ui.dal.FilterByListParams +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 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/chess/datasource/ChessFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.kt new file mode 100644 index 0000000000..3a1cccd1d7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFeedFilterSubAssembler.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.amethyst.ui.screen.loggedIn.chess.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +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 + +/** + * 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(), +) + +/** + * 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 = key.userPubkey +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/DeterministicSigner.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFilterAssembler.kt similarity index 60% rename from quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/DeterministicSigner.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFilterAssembler.kt index d3a5c34ce7..04a4fc5b98 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/crypto/DeterministicSigner.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessFilterAssembler.kt @@ -18,23 +18,25 @@ * 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.ui.screen.loggedIn.chess.datasource -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 +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -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) +/** + * Filter assembler for chess events + */ +class ChessFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + ChessFeedFilterSubAssembler(client, ::allKeys), + ) - fun sign(ev: EventTemplate): T = sign(ev.createdAt, ev.kind, ev.tags, ev.content) + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt new file mode 100644 index 0000000000..11e955ef2b --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/ChessSubscription.kt @@ -0,0 +1,80 @@ +/* + * 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.datasource + +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.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.ChessViewModelNew + +/** + * 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( + chessViewModel: ChessViewModelNew, + accountViewModel: AccountViewModel, +) { + // 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 + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/FilterChessEvent.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/FilterChessEvent.kt new file mode 100644 index 0000000000..1a6f7f8f30 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chess/datasource/FilterChessEvent.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.chess.datasource + +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessFilterBuilder +import com.vitorpamplona.amethyst.commons.chess.subscription.ChessSubscriptionState +import com.vitorpamplona.amethyst.commons.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +/** + * 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/discover/DiscoverScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoverScreen.kt index a4ec833542..cb02b626ce 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/DiscoveryTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt index 8bd1e34fba..4c3b57af36 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/DiscoveryTopBar.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar @@ -53,7 +54,7 @@ fun DiscoveryTopBar( @Composable private fun TopNavFilterBar( followListsModel: TopNavFilterState, - listName: String, + listName: TopFilter, accountViewModel: AccountViewModel, onChange: (FeedDefinition) -> Unit, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsSetsAndLiveStreamsSubAssembler2.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsSetsAndLiveStreamsSubAssembler2.kt index ba3d066df3..78eb084d1b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsSetsAndLiveStreamsSubAssembler2.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryFollowsSetsAndLiveStreamsSubAssembler2.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -39,11 +40,11 @@ import kotlinx.coroutines.launch class DiscoveryFollowsSetsAndLiveStreamsSubAssembler2( client: INostrClient, allKeys: () -> Set, -) : PerUserAndFollowListEoseManager(client, allKeys) { +) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( key: DiscoveryQueryState, since: SincePerRelayMap?, - ): List? { + ): List { val feedSettings = key.followsPerRelay() return makeFollowSetsFilter(feedSettings, since, key.feedStates.discoverFollowSets.lastNoteCreatedAtIfFilled()) + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryLongFormClassifiedsAndDVMSubAssembler1.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryLongFormClassifiedsAndDVMSubAssembler1.kt index abe9ab2dd7..941b95c28c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryLongFormClassifiedsAndDVMSubAssembler1.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryLongFormClassifiedsAndDVMSubAssembler1.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -40,11 +41,11 @@ import kotlinx.coroutines.launch class DiscoveryLongFormClassifiedsAndDVMSubAssembler1( client: INostrClient, allKeys: () -> Set, -) : PerUserAndFollowListEoseManager(client, allKeys) { +) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( key: DiscoveryQueryState, since: SincePerRelayMap?, - ): List? { + ): List { val feedSettings = key.followsPerRelay() return makeLongFormFilter(feedSettings, since, key.feedStates.discoverReads.lastNoteCreatedAtIfFilled()) + diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryPublicChatsAndCommunitiesSubAssembler3.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryPublicChatsAndCommunitiesSubAssembler3.kt index a6891b3b74..a6e94c133b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryPublicChatsAndCommunitiesSubAssembler3.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/datasource/DiscoveryPublicChatsAndCommunitiesSubAssembler3.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.datasource +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUserAndFollowListEoseManager import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap @@ -39,11 +40,11 @@ import kotlinx.coroutines.launch class DiscoveryPublicChatsAndCommunitiesSubAssembler3( client: INostrClient, allKeys: () -> Set, -) : PerUserAndFollowListEoseManager(client, allKeys) { +) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( key: DiscoveryQueryState, since: SincePerRelayMap?, - ): List? { + ): List { val feedSettings = key.followsPerRelay() return makePublicChatsFilter(feedSettings, since, key.feedStates.discoverPublicChats.lastNoteCreatedAtIfFilled()) + makeCommunitiesFilter(feedSettings, since, key.feedStates.discoverCommunities.lastNoteCreatedAtIfFilled()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt index 334d717139..c0b260a430 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/DiscoverLongFormFeedFilter.kt @@ -23,26 +23,29 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip23LongForm import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.TopFilter 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.nip23LongContent.LongTextNoteEvent -import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent open class DiscoverLongFormFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList() + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code override fun limit() = 100 - open fun followList(): String = account.settings.defaultDiscoveryFollowList.value + open fun followList(): TopFilter = account.settings.defaultDiscoveryFollowList.value - override fun showHiddenKey(): Boolean = - followList() == PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || - followList() == MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() override fun feed(): List { val params = buildFilterParams(account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt index 6511de6833..425adc273d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip28Chats/DiscoverChatFeedFilter.kt @@ -23,25 +23,28 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip28Chats import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent import com.vitorpamplona.quartz.nip28PublicChat.base.IsInPublicChatChannel -import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent open class DiscoverChatFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultDiscoveryFollowList.value + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code override fun limit() = 100 - override fun showHiddenKey(): Boolean = - account.settings.defaultDiscoveryFollowList.value == - PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || - account.settings.defaultDiscoveryFollowList.value == - MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) + open fun followList(): TopFilter = account.settings.defaultDiscoveryFollowList.value + + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() override fun feed(): List { val params = buildFilterParams(account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt index f9db851338..0487f35b75 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip51FollowSets/DiscoverFollowSetsFeedFilter.kt @@ -23,24 +23,27 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip51FollowSets import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.TopFilter 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.followList.FollowListEvent -import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent open class DiscoverFollowSetsFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList() + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code - open fun followList(): String = account.settings.defaultDiscoveryFollowList.value + open fun followList(): TopFilter = account.settings.defaultDiscoveryFollowList.value - override fun showHiddenKey(): Boolean = - followList() == PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || - followList() == MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() override fun feed(): List { val params = buildFilterParams(account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt index 9b0e8e6841..68ad9f1374 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip53LiveActivities/DiscoverLiveFeedFilter.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ParticipantListBuilder +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.author.AuthorsByOutboxTopNavFilter @@ -34,23 +35,25 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthors import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter 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.nip53LiveActivities.streaming.LiveActivitiesEvent import com.vitorpamplona.quartz.nip53LiveActivities.streaming.tags.StatusTag open class DiscoverLiveFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList() - - open fun followList(): String = account.settings.defaultDiscoveryFollowList.value + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code override fun limit() = 50 - override fun showHiddenKey(): Boolean = - followList() == PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || - followList() == MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) + open fun followList(): TopFilter = account.settings.defaultDiscoveryFollowList.value + + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() override fun feed(): List { val allChannelNotes = LocalCache.liveChatChannels.mapNotNull { _, channel -> LocalCache.getAddressableNoteIfExists(channel.address) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt index 5ee8d834d5..cb11ba51ff 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip72Communities/DiscoverCommunityFeedFilter.kt @@ -23,28 +23,31 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip72Communities import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.mapNotNullIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent open class DiscoverCommunityFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultDiscoveryFollowList.value + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code override fun limit() = 150 - override fun showHiddenKey(): Boolean = - account.settings.defaultDiscoveryFollowList.value == - PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || - account.settings.defaultDiscoveryFollowList.value == - MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) + open fun followList(): TopFilter = account.settings.defaultDiscoveryFollowList.value + + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() override fun feed(): List { val filterParams = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt index cb1923f74a..74078dda73 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip90DVMs/DiscoverNIP89FeedFilter.kt @@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.model.ParticipantListBuilder +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByOutboxTopNavFilter import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsByProxyTopNavFilter @@ -35,8 +36,6 @@ import com.vitorpamplona.amethyst.model.topNavFeeds.noteBased.muted.MutedAuthors import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip89AppHandlers.definition.AppDefinitionEvent import com.vitorpamplona.quartz.utils.TimeUtils @@ -45,15 +44,19 @@ open class DiscoverNIP89FeedFilter( ) : AdditiveFeedFilter() { val lastAnnounced = TimeUtils.oneYearAgo() - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList() + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code override fun limit() = 50 - open fun followList(): String = account.settings.defaultDiscoveryFollowList.value + open fun followList(): TopFilter = account.settings.defaultDiscoveryFollowList.value - override fun showHiddenKey(): Boolean = - followList() == PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || - followList() == MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() override fun feed(): List { val notes = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt index b82e22f782..ebf796682c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip99Classifieds/DiscoverMarketplaceFeedFilter.kt @@ -23,25 +23,28 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.discover.nip99Classifieds import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter 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.nip99Classifieds.ClassifiedsEvent open class DiscoverMarketplaceFeedFilter( val account: Account, ) : AdditiveFeedFilter() { - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList() - - open fun followList(): String = account.settings.defaultDiscoveryFollowList.value + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code override fun limit() = 150 - override fun showHiddenKey(): Boolean = - followList() == PeopleListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) || - followList() == MuteListEvent.Companion.blockListFor(account.userProfile().pubkeyHex) + open fun followList(): TopFilter = account.settings.defaultDiscoveryFollowList.value + + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() override fun feed(): List { val params = buildFilterParams(account) 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 26a3e1222a..cac04a8697 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/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt index f868962b9c..8a1ac91e3c 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/dvms/dal/NIP90ContentDiscoveryResponseFilter.kt @@ -27,8 +27,6 @@ import com.vitorpamplona.amethyst.model.Note import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.quartz.nip01Core.tags.events.isTaggedEvent -import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent open class NIP90ContentDiscoveryResponseFilter( @@ -40,12 +38,6 @@ open class NIP90ContentDiscoveryResponseFilter( override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + request - open fun followList(): String = account.settings.defaultDiscoveryFollowList.value - - override fun showHiddenKey(): Boolean = - followList() == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - followList() == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) - fun acceptableEvent(note: Note): Boolean { val noteEvent = note.event return noteEvent is NIP90ContentDiscoveryResponseEvent && noteEvent.isTaggedEvent(request) 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 3af73498f0..913be738da 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/dal/HashtagFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt index 5df2dbdac7..fbb048075b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/hashtag/dal/HashtagFeedFilter.kt @@ -83,8 +83,7 @@ class HashtagFeedFilter( event is PrivateDmEvent || event is PollNoteEvent || event is AudioHeaderEvent - ) && - event.isTaggedHash(hashTag) == true + ) && event.isTaggedHash(hashTag) fun acceptableViaScope( event: Event?, 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 0e233a3358..69bbeed3d9 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 06fca2a827..86891c7fe9 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 @@ -59,7 +59,7 @@ import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel 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.TopFilter import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled @@ -234,21 +234,29 @@ fun HomeScreenFloatingButton( accountViewModel.account.settings.defaultHomeFollowList .collectAsStateWithLifecycle() - if (list.value == AROUND_ME) { - val location by Amethyst.instance.locationManager.geohashStateFlow - .collectAsStateWithLifecycle() + when (list.value) { + TopFilter.AroundMe -> { + 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) + + TopFilter.Chess -> { + NewChessGameButton(accountViewModel, nav) + } + + else -> { + NewNoteButton(nav) + } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt index cd54b07217..59e23c43c7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/HomeTopBar.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar @@ -58,7 +59,7 @@ fun HomeTopBar( @Composable private fun TopNavFilterBar( followListsModel: TopNavFilterState, - listName: String, + listName: TopFilter, accountViewModel: AccountViewModel, onChange: (FeedDefinition) -> Unit, ) { 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 0000000000..0a5e34b9e3 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/NewChessGameButton.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.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.R +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 +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * 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 = stringRes(R.string.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 63dce9f112..d03df69d33 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,25 @@ 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.note.types.ReplyRenderType 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 @@ -251,7 +254,7 @@ private fun NewPostScreenBody( baseNote = it, modifier = MaterialTheme.colorScheme.replyModifier, isQuotedNote = true, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, makeItShort = true, quotesLeft = 1, accountViewModel = accountViewModel, @@ -289,7 +292,7 @@ private fun NewPostScreenBody( verticalAlignment = CenterVertically, modifier = Modifier.padding(vertical = Size5dp, horizontal = Size10dp), ) { - PollField(postViewModel) + PollOptionsField(postViewModel) } } @@ -340,9 +343,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 +355,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 +543,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 +564,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 314e58db52..c45eb4b203 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 f23dd893bf..181733a1b1 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 @@ -46,6 +47,7 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.theme.Size10dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer @@ -123,7 +125,7 @@ private fun VoiceReplyScreenBody( baseNote = note, modifier = MaterialTheme.colorScheme.replyModifier, isQuotedNote = true, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, makeItShort = true, quotesLeft = 1, accountViewModel = accountViewModel, @@ -164,10 +166,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 d20e9d3a4b..d44630a468 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 b4977ddcb2..53384e1dd7 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 ea33290fe9..fcf5941dce 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/HomeFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt index e653828364..e76959df4b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/datasource/HomeFilterAssembler.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountFeedContentStates @@ -28,12 +29,15 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import kotlinx.coroutines.CoroutineScope // This allows multiple screen to be listening to tags, even the same tag + +@Stable class HomeQueryState( val account: Account, val feedState: AccountFeedContentStates, val scope: CoroutineScope, ) +@Stable class HomeFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { 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 0000000000..3999badfc2 --- /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 798530826e..fb9ca719ef 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 d3d7f35ae5..ff7f313654 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 @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.home.datasource.nip65Follows +import com.vitorpamplona.amethyst.model.TopFilter 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 +37,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 @@ -50,7 +53,7 @@ import kotlinx.coroutines.launch class HomeOutboxEventsEoseManager( client: INostrClient, allKeys: () -> Set, -) : PerUserAndFollowListEoseManager(client, allKeys) { +) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( key: HomeQueryState, since: SincePerRelayMap?, @@ -62,6 +65,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 7c4c6365b9..4c7f5c3006 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/keyBackup/AccountBackupDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt similarity index 72% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupDialog.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt index d0c669badd..94a178514b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/keyBackup/AccountBackupScreen.kt @@ -32,7 +32,6 @@ 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 @@ -45,11 +44,13 @@ import androidx.compose.material.icons.outlined.Visibility import androidx.compose.material.icons.outlined.VisibilityOff import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.ExperimentalMaterial3Api 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.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -91,7 +92,8 @@ import com.halilibo.richtext.ui.material3.RichText import com.halilibo.richtext.ui.resolveDefaults import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.note.authenticate import com.vitorpamplona.amethyst.ui.painterRes import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel @@ -112,192 +114,167 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalComposeUiApi::class) @Composable -fun AccountBackupDialog( +fun AccountBackupScreen( accountViewModel: AccountViewModel, - onClose: () -> Unit, + nav: INav, ) { - Dialog( - onDismissRequest = onClose, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - DialogContents(accountViewModel, onClose) - } + AccountBackupScreenContent(accountViewModel, nav::popBack) } @Preview(device = "spec:width=2160px,height=2340px,dpi=440") @Composable -fun DialogContentsPreview() { +fun AccountBackupScreenPreview() { ThemeComparisonRow { - DialogContents( + AccountBackupScreenContent( mockAccountViewModel(), ) {} } } -@OptIn(ExperimentalComposeUiApi::class) +@OptIn(ExperimentalComposeUiApi::class, ExperimentalMaterial3Api::class) @Composable -private fun DialogContents( +private fun AccountBackupScreenContent( accountViewModel: AccountViewModel, onClose: () -> Unit, ) { - Surface( - modifier = - Modifier - .verticalScroll(rememberScrollState()), + Scaffold( + topBar = { + TopBarWithBackButton( + stringRes(R.string.backup_keys), + popBack = onClose, + ) + }, ) { Column( - modifier = - Modifier, + Modifier + .fillMaxSize() + .padding(it) + .padding(horizontal = 20.dp, vertical = 10.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally, ) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(10.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + val content1 = stringRes(R.string.account_backup_tips2_md) + + val astNode1 = + remember { + CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(content1) + } + + RichText( + style = RichTextStyle().resolveDefaults(), + renderer = null, ) { - IconButton( - onClick = onClose, - modifier = Modifier, - ) { - ArrowBackIcon() + BasicMarkdown(astNode1) + } + + Spacer(modifier = Modifier.height(20.dp)) + + Row { + Column { + NSecCopyButton(accountViewModel) + } + + Column { + QrCodeButton(accountViewModel) } } - Column( - modifier = - Modifier - .fillMaxSize() - .padding(horizontal = 30.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + Spacer(modifier = Modifier.height(30.dp)) + + val content = stringRes(R.string.account_backup_tips3_md) + + val astNode = + remember { + CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(content) + } + + RichText( + style = RichTextStyle().resolveDefaults(), + renderer = null, ) { - val content1 = stringRes(R.string.account_backup_tips2_md) + BasicMarkdown(astNode) + } - val astNode1 = - remember { - CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(content1) - } + val password = remember { mutableStateOf(TextFieldValue("")) } + var errorMessage by remember { mutableStateOf("") } + var showCharsPassword by remember { mutableStateOf(false) } - RichText( - style = RichTextStyle().resolveDefaults(), - renderer = null, - ) { - BasicMarkdown(astNode1) - } - - Spacer(modifier = Modifier.height(20.dp)) - - Row { - Column { - NSecCopyButton(accountViewModel) - } - - Column { - QrCodeButton(accountViewModel) - } - } - - Spacer(modifier = Modifier.height(30.dp)) - - val content = stringRes(R.string.account_backup_tips3_md) - - val astNode = - remember { - CommonmarkAstNodeParser(CommonMarkdownParseOptions.MarkdownWithLinks).parse(content) - } - - RichText( - style = RichTextStyle().resolveDefaults(), - renderer = null, - ) { - BasicMarkdown(astNode) - } - - val password = remember { mutableStateOf(TextFieldValue("")) } - var errorMessage by remember { mutableStateOf("") } - var showCharsPassword by remember { mutableStateOf(false) } - - val autofillNode = - AutofillNode( - autofillTypes = listOf(AutofillType.Password), - onFill = { password.value = TextFieldValue(it) }, - ) - val autofill = LocalAutofill.current - LocalAutofillTree.current += autofillNode - - Spacer(modifier = Modifier.height(20.dp)) - - OutlinedTextField( - modifier = - Modifier - .onGloballyPositioned { coordinates -> - autofillNode.boundingBox = coordinates.boundsInWindow() - }.onFocusChanged { focusState -> - autofill?.run { - if (focusState.isFocused) { - requestAutofillForNode(autofillNode) - } else { - cancelAutofillForNode(autofillNode) - } - } - }, - value = password.value, - onValueChange = { - password.value = it - if (errorMessage.isNotEmpty()) { - errorMessage = "" - } - }, - keyboardOptions = - KeyboardOptions( - autoCorrectEnabled = false, - keyboardType = KeyboardType.Password, - imeAction = ImeAction.Go, - ), - placeholder = { - Text( - text = stringRes(R.string.ncryptsec_password), - color = MaterialTheme.colorScheme.placeholderText, - ) - }, - trailingIcon = { - Row { - IconButton(onClick = { showCharsPassword = !showCharsPassword }) { - Icon( - imageVector = - if (showCharsPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, - contentDescription = - if (showCharsPassword) { - stringRes(R.string.show_password) - } else { - stringRes( - R.string.hide_password, - ) - }, - ) - } - } - }, - visualTransformation = - if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(), + val autofillNode = + AutofillNode( + autofillTypes = listOf(AutofillType.Password), + onFill = { password.value = TextFieldValue(it) }, ) + val autofill = LocalAutofill.current + LocalAutofillTree.current += autofillNode - if (errorMessage.isNotBlank()) { + Spacer(modifier = Modifier.height(20.dp)) + + OutlinedTextField( + modifier = + Modifier + .onGloballyPositioned { coordinates -> + autofillNode.boundingBox = coordinates.boundsInWindow() + }.onFocusChanged { focusState -> + autofill?.run { + if (focusState.isFocused) { + requestAutofillForNode(autofillNode) + } else { + cancelAutofillForNode(autofillNode) + } + } + }, + value = password.value, + onValueChange = { + password.value = it + if (errorMessage.isNotEmpty()) { + errorMessage = "" + } + }, + keyboardOptions = + KeyboardOptions( + autoCorrectEnabled = false, + keyboardType = KeyboardType.Password, + imeAction = ImeAction.Go, + ), + placeholder = { Text( - text = errorMessage, - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodySmall, + text = stringRes(R.string.ncryptsec_password), + color = MaterialTheme.colorScheme.placeholderText, ) - } + }, + trailingIcon = { + Row { + IconButton(onClick = { showCharsPassword = !showCharsPassword }) { + Icon( + imageVector = + if (showCharsPassword) Icons.Outlined.VisibilityOff else Icons.Outlined.Visibility, + contentDescription = + if (showCharsPassword) { + stringRes(R.string.show_password) + } else { + stringRes( + R.string.hide_password, + ) + }, + ) + } + } + }, + visualTransformation = + if (showCharsPassword) VisualTransformation.None else PasswordVisualTransformation(), + ) - Spacer(modifier = Modifier.height(10.dp)) - - EncryptNSecCopyButton(accountViewModel, password) - - Spacer(modifier = Modifier.height(30.dp)) + if (errorMessage.isNotBlank()) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodySmall, + ) } + + Spacer(modifier = Modifier.height(10.dp)) + + EncryptNSecCopyButton(accountViewModel, password) } } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt index db37043e8d..2ac13c5f77 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/lists/list/PeopleListItem.kt @@ -71,6 +71,10 @@ import com.vitorpamplona.amethyst.ui.theme.SpacedBy2dp import com.vitorpamplona.amethyst.ui.theme.SpacedBy5dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn +private const val PREVIEW_LIST_TITLE = "Sample List Title" +private const val PREVIEW_LIST_DESCRIPTION = "Sample List Description" +private const val PREVIEW_LIST_IMAGE = "http://some.com/image.png" + @Preview() @Composable private fun PeopleListItemPreview() { @@ -82,8 +86,8 @@ private fun PeopleListItemPreview() { PeopleList( identifierTag = "00001-2222", title = "Sample List Title, Very long title, very very very long", - description = "Sample List Description", - image = "http://some.com/image.png", + description = PREVIEW_LIST_DESCRIPTION, + image = PREVIEW_LIST_IMAGE, emptySet(), emptySet(), ) @@ -91,9 +95,9 @@ private fun PeopleListItemPreview() { val samplePeopleList2 = PeopleList( identifierTag = "00001-2223", - title = "Sample List Title", - description = "Sample List Description", - image = "http://some.com/image.png", + title = PREVIEW_LIST_TITLE, + description = PREVIEW_LIST_DESCRIPTION, + image = PREVIEW_LIST_IMAGE, setOf(user1, user3), emptySet(), ) @@ -101,9 +105,9 @@ private fun PeopleListItemPreview() { val samplePeopleList3 = PeopleList( identifierTag = "00001-2224", - title = "Sample List Title", - description = "Sample List Description", - image = "http://some.com/image.png", + title = PREVIEW_LIST_TITLE, + description = PREVIEW_LIST_DESCRIPTION, + image = PREVIEW_LIST_IMAGE, emptySet(), setOf(user1, user3), ) @@ -111,9 +115,9 @@ private fun PeopleListItemPreview() { val samplePeopleList4 = PeopleList( identifierTag = "00001-2225", - title = "Sample List Title", - description = "Sample List Description", - image = "http://some.com/image.png", + title = PREVIEW_LIST_TITLE, + description = PREVIEW_LIST_DESCRIPTION, + image = PREVIEW_LIST_IMAGE, setOf(user3), setOf(user1, user2, user3), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt index 74a8e33459..eb53679fd2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedContentState.kt @@ -92,7 +92,7 @@ class CardFeedContentState( viewModelScope.launch(Dispatchers.IO) { _scrollToTop.emit(_scrollToTop.value + 1) } } - suspend fun sentToTop() { + fun sentToTop() { scrolltoTopPending = false } @@ -212,7 +212,7 @@ class CardFeedContentState( val zapRequest = event.zapRequest if (zapRequest != null) { - val zapRequestNote = LocalCache.getNoteIfExists(zapRequest.id) + val zapRequestNote = getNoteIfExists(zapRequest.id) if (zapRequestNote != null) { zapsPerUser .getOrPut(author, { mutableListOf() }) @@ -352,6 +352,7 @@ class CardFeedContentState( } fun deleteFromFeed(deletedNotes: Set) { + // TODO: Implement deletion of notes from the notification feed } private fun refreshFromOldState(newItems: Set) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt index 99dcaf5d55..dfe09eb44a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/CardFeedView.kt @@ -58,6 +58,7 @@ import com.vitorpamplona.amethyst.ui.note.MessageSetCompose import com.vitorpamplona.amethyst.ui.note.MultiSetCompose import com.vitorpamplona.amethyst.ui.note.NoteCompose import com.vitorpamplona.amethyst.ui.note.ZapUserSetCompose +import com.vitorpamplona.amethyst.ui.note.types.ReplyRenderType import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.donations.ShowDonationCard import com.vitorpamplona.amethyst.ui.stringRes @@ -267,7 +268,7 @@ fun NoteCardCompose( routeForLastRead: String? = null, isBoostedNote: Boolean = false, isQuotedNote: Boolean = false, - unPackReply: Boolean = true, + unPackReply: ReplyRenderType = ReplyRenderType.FULL, makeItShort: Boolean = false, showHidden: Boolean = false, parentBackgroundColor: MutableState? = null, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt index 01b7b54759..59cd27aef1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/NotificationTopBar.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar @@ -53,7 +54,7 @@ fun NotificationTopBar( @Composable private fun TopNavFilterBar( followListsModel: TopNavFilterState, - listName: String, + listName: TopFilter, accountViewModel: AccountViewModel, onChange: (FeedDefinition) -> Unit, ) { 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 f9449bb97b..efc1df497c 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 @@ -24,6 +24,7 @@ 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.TopFilter import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder @@ -48,8 +49,6 @@ import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent -import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent import com.vitorpamplona.quartz.nip52Calendar.CalendarTimeSlotEvent @@ -66,6 +65,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 +105,7 @@ class NotificationFeedFilter( LnZapEvent.KIND, LiveActivitiesChatMessageEvent.KIND, PictureEvent.KIND, + PollEvent.KIND, PollNoteEvent.KIND, PrivateDmEvent.KIND, PublicMessageEvent.KIND, @@ -115,13 +116,17 @@ class NotificationFeedFilter( ) + ADDRESSABLE_KINDS } - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultNotificationFollowList.value + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + followList().code - override fun showHiddenKey(): Boolean = - account.settings.defaultNotificationFollowList.value == - PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.settings.defaultNotificationFollowList.value == - MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + fun followList(): TopFilter = account.settings.defaultDiscoveryFollowList.value + + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() fun buildFilterParams(account: Account): FilterByListParams = FilterByListParams.create( 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 281dd35f4e..adfe9b5947 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 @@ -84,6 +83,7 @@ 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.note.types.ReplyRenderType import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.chats.privateDM.send.MessageFieldRow import com.vitorpamplona.amethyst.ui.stringRes @@ -197,7 +197,7 @@ fun PublicMessageScreenContent( baseNote = replyTo, modifier = MaterialTheme.colorScheme.imageModifier, isQuotedNote = true, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, makeItShort = true, quotesLeft = 1, accountViewModel = accountViewModel, @@ -233,9 +233,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/notifications/publicMessages/NewPublicMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt index dd5a748053..daedcac623 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/publicMessages/NewPublicMessageViewModel.kt @@ -340,11 +340,16 @@ class NewPublicMessageViewModel : } } - private suspend fun createTemplate(): EventTemplate? { + private suspend fun createTemplate(): EventTemplate { val toUsersTagger = NewMessageTagger(this@NewPublicMessageViewModel.toUsers.text, null, null, accountViewModel) toUsersTagger.run() - val tagger = NewMessageTagger(message.text, null, null, accountViewModel) + val msg = + replyingTo?.let { + it.toNostrUri() + "\n" + message.text + } ?: message.text + + val tagger = NewMessageTagger(msg, null, null, accountViewModel) tagger.run() val users = (toUsersTagger.pTags ?: emptyList()) + (tagger.pTags ?: emptyList()) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt index 4a2f6fc0f9..f2568cecf1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/ProfileScreen.kt @@ -82,6 +82,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.hashtags.FollowedTa import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.hashtags.TabFollowedTags import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.ProfileHeader import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserAppRecommendationsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.identity.UserExternalIdentitiesViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.TabMutualConversations import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.mutual.dal.UserProfileMutualFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.newthreads.TabNotesNewThreads @@ -169,6 +170,12 @@ fun PrepareViewModels( factory = UserAppRecommendationsFeedViewModel.Factory(baseUser), ) + val externalIdentities: UserExternalIdentitiesViewModel = + viewModel( + key = baseUser.pubkeyHex + "UserExternalIdentitiesViewModel", + factory = UserExternalIdentitiesViewModel.Factory(baseUser), + ) + val zapFeedViewModel: UserProfileZapsViewModel = viewModel( key = baseUser.pubkeyHex + "UserProfileZapsFeedViewModel", @@ -233,6 +240,7 @@ fun PrepareViewModels( followsFeedViewModel, followersFeedViewModel, appRecommendations, + externalIdentities, zapFeedViewModel, bookmarksFeedViewModel, galleryFeedViewModel, @@ -251,6 +259,7 @@ fun ProfileScreen( followsFeedViewModel: UserProfileFollowsUserFeedViewModel, followersFeedViewModel: UserProfileFollowersUserFeedViewModel, appRecommendations: UserAppRecommendationsFeedViewModel, + externalIdentities: UserExternalIdentitiesViewModel, zapFeedViewModel: UserProfileZapsViewModel, bookmarksFeedViewModel: UserProfileBookmarksFeedViewModel, galleryFeedViewModel: UserProfileGalleryFeedViewModel, @@ -277,6 +286,7 @@ fun ProfileScreen( repliesViewModel, mutualViewModel, appRecommendations, + externalIdentities, followsFeedViewModel, followersFeedViewModel, zapFeedViewModel, @@ -370,6 +380,7 @@ private fun RenderScreen( repliesViewModel: UserProfileConversationsFeedViewModel, mutualViewModel: UserProfileMutualFeedViewModel, appRecommendations: UserAppRecommendationsFeedViewModel, + externalIdentities: UserExternalIdentitiesViewModel, followsFeedViewModel: UserProfileFollowsUserFeedViewModel, followersFeedViewModel: UserProfileFollowersUserFeedViewModel, zapFeedViewModel: UserProfileZapsViewModel, @@ -382,7 +393,7 @@ private fun RenderScreen( val pagerState = rememberPagerState { 11 } Column { - ProfileHeader(baseUser, appRecommendations, nav, accountViewModel) + ProfileHeader(baseUser, appRecommendations, externalIdentities, nav, accountViewModel) ScrollableTabRow( containerColor = Color.Transparent, contentColor = MaterialTheme.colorScheme.onBackground, 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 66f94a7c8b..660a390f61 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 89d78c81b0..f365470743 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 640b187f66..9cfc790a9a 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 03aaa59eb8..b194b7b940 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,14 +20,13 @@ */ 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 @@ -38,35 +37,29 @@ 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 @@ -76,7 +69,6 @@ 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 +129,7 @@ fun GalleryThumbnail( emptyList() } - InnerRenderGalleryThumb(content, baseNote, accountViewModel, ratio) + InnerRenderGalleryThumb(content, baseNote, accountViewModel) } @Composable @@ -145,10 +137,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 +151,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 +183,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 +222,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 +247,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 a7bdbf0b0c..cc80416fb1 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 83a1f3986a..0a8f6f31d4 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,17 +21,19 @@ 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 import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent -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, @@ -39,10 +41,6 @@ class UserProfileGalleryFeedFilter( ) : AdditiveFeedFilter() { override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + "ProfileGallery" - 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) @@ -51,7 +49,16 @@ class UserProfileGalleryFeedFilter( acceptableEvent(it, params, user) } - return sort(notes).toList() + val addressableNotes = + LocalCache.addressables + .filter( + listOf(VideoVerticalEvent.KIND, VideoVerticalEvent.KIND), + user.pubkeyHex, + ) { _, it -> + acceptableEvent(it, params, user) + } + + return sort(addressableNotes + notes).toList() } override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) @@ -72,7 +79,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/header/DrawAdditionalInfo.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt index 6859af7b66..83aa8448d2 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/DrawAdditionalInfo.kt @@ -62,6 +62,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.DisplayAppRecommendations import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserAppRecommendationsFeedViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.badges.DisplayBadges +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.identity.UserExternalIdentitiesViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.Size15Modifier import com.vitorpamplona.amethyst.ui.theme.Size16Modifier @@ -81,6 +82,7 @@ private const val IDENTITY_ICON_CACHE_KEY = 0 fun DrawAdditionalInfo( baseUser: User, appRecommendations: UserAppRecommendationsFeedViewModel, + externalIdentities: UserExternalIdentitiesViewModel, accountViewModel: AccountViewModel, nav: INav, ) { @@ -88,6 +90,7 @@ fun DrawAdditionalInfo( val user = userState ?: return val uri = LocalUriHandler.current val clipboardManager = LocalClipboardManager.current + val identities by externalIdentities.identities.collectAsStateWithLifecycle() Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 7.dp)) { CreateTextWithEmoji( @@ -183,8 +186,9 @@ fun DrawAdditionalInfo( } DisplayLNAddress(lud16, baseUser, accountViewModel, nav) - if (user.identities.isNotEmpty()) { - user.identities.forEach { identity: IdentityClaimTag -> + val displayIdentities = identities.ifEmpty { user.identities } + if (displayIdentities.isNotEmpty()) { + displayIdentities.forEach { identity: IdentityClaimTag -> Row(verticalAlignment = Alignment.CenterVertically) { Icon( tint = Color.Unspecified, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt index 19c31a91b4..7cc5855387 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/ProfileHeader.kt @@ -21,6 +21,8 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header import android.content.ClipData +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 @@ -30,7 +32,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.AddPhotoAlternate import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -38,6 +42,7 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -45,18 +50,26 @@ import androidx.compose.runtime.rememberCoroutineScope 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.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.richtext.RichTextParser import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserPicture +import com.vitorpamplona.amethyst.ui.actions.NewUserMetadataViewModel +import com.vitorpamplona.amethyst.ui.actions.uploads.GallerySelectSingle +import com.vitorpamplona.amethyst.ui.components.LoadingAnimation import com.vitorpamplona.amethyst.ui.components.ZoomableImageDialog import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.apps.UserAppRecommendationsFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.header.identity.UserExternalIdentitiesViewModel import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.amethyst.ui.theme.ButtonBorder import com.vitorpamplona.amethyst.ui.theme.Size100dp @@ -70,6 +83,7 @@ import kotlinx.coroutines.launch fun ProfileHeader( baseUser: User, appRecommendations: UserAppRecommendationsFeedViewModel, + externalIdentities: UserExternalIdentitiesViewModel, nav: INav, accountViewModel: AccountViewModel, ) { @@ -126,7 +140,7 @@ fun ProfileHeader( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Bottom, ) { - ZoomableUserPicture( + ProfilePictureWithUploadOverlay( baseUser = baseUser, accountViewModel = accountViewModel, size = Size100dp, @@ -142,7 +156,7 @@ fun ProfileHeader( } } - DrawAdditionalInfo(baseUser, appRecommendations, accountViewModel, nav) + DrawAdditionalInfo(baseUser, appRecommendations, externalIdentities, accountViewModel, nav) HorizontalDivider(modifier = Modifier.padding(top = 6.dp)) } @@ -189,3 +203,76 @@ fun ZoomableUserPicture( ) } } + +@Composable +fun ProfilePictureWithUploadOverlay( + baseUser: User, + accountViewModel: AccountViewModel, + size: Dp, +) { + val isMe by remember(accountViewModel) { derivedStateOf { accountViewModel.userProfile() == baseUser } } + val picture by observeUserPicture(baseUser, accountViewModel) + + Box(contentAlignment = Alignment.Center) { + ZoomableUserPicture( + baseUser = baseUser, + accountViewModel = accountViewModel, + size = size, + ) + if (isMe && picture.isNullOrBlank()) { + ProfilePictureUploadButton(accountViewModel, size) + } + } +} + +@Composable +private fun ProfilePictureUploadButton( + accountViewModel: AccountViewModel, + size: Dp, +) { + val postViewModel: NewUserMetadataViewModel = viewModel() + postViewModel.init(accountViewModel) + val context = LocalContext.current + + var showGallerySelect by remember { mutableStateOf(false) } + if (showGallerySelect) { + GallerySelectSingle( + onImageUri = { media -> + showGallerySelect = false + if (media != null) { + postViewModel.uploadPictureAndSave(media, context, accountViewModel.toastManager::toast) + } + }, + ) + } + + if (postViewModel.isUploadingImageForPicture) { + Box( + modifier = + Modifier + .size(size) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)), + contentAlignment = Alignment.Center, + ) { + LoadingAnimation() + } + } else { + Box( + modifier = + Modifier + .size(size) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.7f)) + .clickable { showGallerySelect = true }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Default.AddPhotoAlternate, + contentDescription = stringRes(R.string.upload_image), + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/identity/UserExternalIdentitiesViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/identity/UserExternalIdentitiesViewModel.kt new file mode 100644 index 0000000000..a091fefc15 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/header/identity/UserExternalIdentitiesViewModel.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.ui.screen.loggedIn.profile.header.identity + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.User +import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent +import com.vitorpamplona.quartz.nip39ExtIdentities.IdentityClaimTag +import com.vitorpamplona.quartz.nip39ExtIdentities.identityClaims +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch + +@Stable +class UserExternalIdentitiesViewModel( + val user: User, +) : ViewModel() { + private val _identities = MutableStateFlow>(emptyList()) + val identities = _identities.asStateFlow() + + private val note = + LocalCache.getOrCreateAddressableNote( + ExternalIdentitiesEvent.createAddress(user.pubkeyHex), + ) + + init { + viewModelScope.launch { + note.flow().metadata.stateFlow.collect { state -> + val event = state.note.event as? ExternalIdentitiesEvent + _identities.value = event?.identityClaims() ?: emptyList() + } + } + } + + class Factory( + val user: User, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = UserExternalIdentitiesViewModel(user) as T + } +} 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 860e270b70..cf401a7230 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/profile/relays/RelayFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt index a369254a8c..30579bda94 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/relays/RelayFeedViewModel.kt @@ -41,7 +41,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.emitAll -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapNoteCompose.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapNoteCompose.kt index a9275aeef6..b8f50709b4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapNoteCompose.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/profile/zaps/ZapNoteCompose.kt @@ -26,9 +26,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue 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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/NewRelayNoteButton.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/NewRelayNoteButton.kt new file mode 100644 index 0000000000..592aa4cd03 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/NewRelayNoteButton.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.relay + +import androidx.compose.foundation.shape.CircleShape +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.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size26Modifier +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +@Composable +fun NewRelayNoteButton( + accountViewModel: AccountViewModel, + nav: INav, +) { + FloatingActionButton( + onClick = { + nav.nav(Route.NewShortNote()) + }, + modifier = Size55Modifier, + shape = CircleShape, + containerColor = MaterialTheme.colorScheme.primary, + ) { + Icon( + painter = painterRes(R.drawable.ic_compose, 3), + contentDescription = stringRes(id = R.string.new_community_note), + modifier = Size26Modifier, + tint = Color.White, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/RelayFeedScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/RelayFeedScreen.kt new file mode 100644 index 0000000000..54ef78f54c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/RelayFeedScreen.kt @@ -0,0 +1,149 @@ +/* + * 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.relay + +import android.annotation.SuppressLint +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserIsFollowingRelay +import com.vitorpamplona.amethyst.ui.feeds.WatchLifecycleAndUpdateModel +import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarExtensibleWithBackButton +import com.vitorpamplona.amethyst.ui.screen.RefresheableFeedView +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.FollowButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.profile.UnfollowButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.dal.SingleRelayFeedViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource.RelayFeedFilterAssemblerSubscription +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +@Composable +fun RelayFeedScreen( + tag: Route.RelayFeed, + accountViewModel: AccountViewModel, + nav: INav, +) { + if (tag.url.isEmpty()) return + + val normalizedUrl = RelayUrlNormalizer.normalizeOrNull(tag.url) ?: return + + PrepareViewModelsRelayFeedScreen(tag, normalizedUrl, accountViewModel, nav) +} + +@SuppressLint("StateFlowValueCalledInComposition") +@Composable +fun PrepareViewModelsRelayFeedScreen( + tag: Route.RelayFeed, + normalizedUrl: NormalizedRelayUrl, + accountViewModel: AccountViewModel, + nav: INav, +) { + val feedViewModel: SingleRelayFeedViewModel = + viewModel( + key = normalizedUrl.url + "SingleRelayFeedViewModel", + factory = + SingleRelayFeedViewModel.Factory( + normalizedUrl, + accountViewModel.account, + ), + ) + + RelayFeedScreen(tag, normalizedUrl, feedViewModel, accountViewModel, nav) +} + +@Composable +fun RelayFeedScreen( + tag: Route.RelayFeed, + normalizedUrl: NormalizedRelayUrl, + feedViewModel: SingleRelayFeedViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + WatchLifecycleAndUpdateModel(feedViewModel) + RelayFeedFilterAssemblerSubscription(tag, accountViewModel) + + DisappearingScaffold( + isInvertedLayout = false, + topBar = { + TopBarExtensibleWithBackButton( + title = { + Text(normalizedUrl.url, modifier = Modifier.weight(1f)) + RelayFeedActionOptions(normalizedUrl, accountViewModel) + }, + popBack = nav::popBack, + ) + }, + floatingButton = { + NewRelayNoteButton(accountViewModel, nav) + }, + accountViewModel = accountViewModel, + ) { + Column(Modifier.padding(it)) { + RefresheableFeedView( + feedViewModel, + null, + accountViewModel = accountViewModel, + nav = nav, + ) + } + } +} + +@Composable +fun RelayFeedActionOptions( + relayUrl: NormalizedRelayUrl, + accountViewModel: AccountViewModel, +) { + val isFollowingRelay by observeUserIsFollowingRelay(relayUrl, accountViewModel) + + if (isFollowingRelay) { + UnfollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_unfollow, + ) + } else { + accountViewModel.unfollowFavoriteRelay(relayUrl) + } + } + } else { + FollowButton { + if (!accountViewModel.isWriteable()) { + accountViewModel.toastManager.toast( + R.string.read_only_user, + R.string.login_with_a_private_key_to_be_able_to_follow, + ) + } else { + accountViewModel.followFavoriteRelay(relayUrl) + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/dal/RelayFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/dal/RelayFeedFilter.kt new file mode 100644 index 0000000000..6e1178995f --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/dal/RelayFeedFilter.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.ui.screen.loggedIn.relay.dal + +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.model.Note +import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter +import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +class RelayFeedFilter( + val relayUrl: NormalizedRelayUrl, + val account: Account, + val cache: LocalCache, +) : AdditiveFeedFilter() { + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + relayUrl.url + + override fun feed(): List { + val notes = + cache.notes.filterIntoSet { _, it -> + acceptableEvent(it) + } + + return sort(notes) + } + + override fun applyFilter(newItems: Set): Set = innerApplyFilter(newItems) + + private fun innerApplyFilter(collection: Collection): Set = collection.filterTo(HashSet()) { acceptableEvent(it) } + + fun acceptableEvent(it: Note): Boolean = + it.hasRelay(relayUrl) && + !it.isHiddenFor(account.hiddenUsers.flow.value) && + account.isAcceptable(it) + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/dal/SingleRelayFeedViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/dal/SingleRelayFeedViewModel.kt new file mode 100644 index 0000000000..ca6549d7ad --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/dal/SingleRelayFeedViewModel.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.dal + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.vitorpamplona.amethyst.model.Account +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.ui.screen.AndroidFeedViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +@Stable +class SingleRelayFeedViewModel( + val relayUrl: NormalizedRelayUrl, + val account: Account, +) : AndroidFeedViewModel( + RelayFeedFilter(relayUrl, account, LocalCache), + ) { + class Factory( + val relayUrl: NormalizedRelayUrl, + val account: Account, + ) : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = SingleRelayFeedViewModel(relayUrl, account) as T + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt new file mode 100644 index 0000000000..84ac86e8c6 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/FilterPostsByRelay.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource + +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +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.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +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 + +val PostsByRelayKinds = + listOf( + TextNoteEvent.KIND, + ChannelMessageEvent.KIND, + LongTextNoteEvent.KIND, + PollEvent.KIND, + LiveActivitiesChatMessageEvent.KIND, + ClassifiedsEvent.KIND, + HighlightEvent.KIND, + WikiNoteEvent.KIND, + CommentEvent.KIND, + ) + +val PostsByRelayKinds2 = + listOf( + InteractiveStorySceneEvent.KIND, + AudioTrackEvent.KIND, + AudioHeaderEvent.KIND, + NipTextEvent.KIND, + PollNoteEvent.KIND, + ) + +fun filterPostsByRelay( + relayUrl: NormalizedRelayUrl, + since: SincePerRelayMap?, +): List { + val sinceTime = since?.get(relayUrl)?.time + return listOf( + RelayBasedFilter( + relay = relayUrl, + filter = + Filter( + kinds = PostsByRelayKinds, + limit = 400, + since = sinceTime, + ), + ), + RelayBasedFilter( + relay = relayUrl, + filter = + Filter( + kinds = PostsByRelayKinds2, + limit = 100, + since = sinceTime, + ), + ), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/paymentTargets/FilterPaymentTargetsFromKey.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterAssembler.kt similarity index 56% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/paymentTargets/FilterPaymentTargetsFromKey.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterAssembler.kt index 3dc3d5bd43..a439a9d43a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/account/paymentTargets/FilterPaymentTargetsFromKey.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterAssembler.kt @@ -18,35 +18,28 @@ * 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.relayClient.reqCommand.account.paymentTargets +package com.vitorpamplona.amethyst.ui.screen.loggedIn.relay.datasource -import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -val paymentTargetsKinds = - listOf( - PaymentTargetsEvent.KIND, - ) +// This allows multiple screens to be listening to the same or different relay feeds. +class RelayFeedQueryState( + val relayUrl: NormalizedRelayUrl, +) -fun filterPaymentTargetsFromKey( - relay: NormalizedRelayUrl, - pubkey: HexKey?, - since: Long?, -): List { - if (pubkey == null || pubkey.isEmpty()) return emptyList() +class RelayFeedFilterAssembler( + client: INostrClient, +) : ComposeSubscriptionManager() { + val group = + listOf( + RelayFeedFilterSubAssembler(client, ::allKeys), + ) - return listOf( - RelayBasedFilter( - relay = relay, - filter = - Filter( - kinds = paymentTargetsKinds, - authors = listOf(pubkey), - since = since, - ), - ), - ) + override fun invalidateKeys() = invalidateFilters() + + override fun invalidateFilters() = group.forEach { it.invalidateFilters() } + + override fun destroy() = group.forEach { it.destroy() } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterAssemblerSubscription.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterAssemblerSubscription.kt new file mode 100644 index 0000000000..8780a35efb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterAssemblerSubscription.kt @@ -0,0 +1,42 @@ +/* + * 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.relay.datasource + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.KeyDataSourceSubscription +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer + +@Composable +fun RelayFeedFilterAssemblerSubscription( + tag: Route.RelayFeed, + accountViewModel: AccountViewModel, +) { + val state = + remember(tag) { + val normalizedUrl = RelayUrlNormalizer.normalizeOrNull(tag.url) ?: return@remember null + RelayFeedQueryState(normalizedUrl) + } ?: return + + KeyDataSourceSubscription(state, accountViewModel.dataSources().relayFeed) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterSubAssembler.kt new file mode 100644 index 0000000000..2a90bd26b0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relay/datasource/RelayFeedFilterSubAssembler.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.relay.datasource + +import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager +import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter + +class RelayFeedFilterSubAssembler( + client: INostrClient, + allKeys: () -> Set, +) : PerUniqueIdEoseManager(client, allKeys) { + override fun updateFilter( + key: RelayFeedQueryState, + since: SincePerRelayMap?, + ): List = filterPostsByRelay(key.relayUrl, since) + + /** + * One key per relay URL. + */ + override fun id(key: RelayFeedQueryState) = key.relayUrl.url +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt index 71cd1f9204..44f9361a54 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/AllRelayListScreen.kt @@ -55,6 +55,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected.ConnectedR import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected.renderConnectedItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.DMRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm.renderDMItems +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.favorites.FavoriteRelayListViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.favorites.renderFavoriteItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer.IndexerRelayListViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer.renderIndexerItems import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local.LocalRelayListViewModel @@ -93,6 +95,7 @@ fun AllRelayListScreen( val broadcastViewModel: BroadcastRelayListViewModel = viewModel() val indexerViewModel: IndexerRelayListViewModel = viewModel() val proxyViewModel: ProxyRelayListViewModel = viewModel() + val favoriteViewModel: FavoriteRelayListViewModel = viewModel() dmViewModel.init(accountViewModel) nip65ViewModel.init(accountViewModel) @@ -105,6 +108,7 @@ fun AllRelayListScreen( broadcastViewModel.init(accountViewModel) indexerViewModel.init(accountViewModel) proxyViewModel.init(accountViewModel) + favoriteViewModel.init(accountViewModel) LaunchedEffect(accountViewModel) { dmViewModel.load() @@ -118,6 +122,7 @@ fun AllRelayListScreen( broadcastViewModel.load() indexerViewModel.load() proxyViewModel.load() + favoriteViewModel.load() } MappedAllRelayListView( @@ -132,6 +137,7 @@ fun AllRelayListScreen( broadcastViewModel, indexerViewModel, proxyViewModel, + favoriteViewModel, accountViewModel, nav, ) @@ -151,6 +157,7 @@ fun MappedAllRelayListView( broadcastViewModel: BroadcastRelayListViewModel, indexerViewModel: IndexerRelayListViewModel, proxyViewModel: ProxyRelayListViewModel, + favoriteViewModel: FavoriteRelayListViewModel, accountViewModel: AccountViewModel, newNav: INav, ) { @@ -166,6 +173,7 @@ fun MappedAllRelayListView( val broadcastRelays by broadcastViewModel.relays.collectAsStateWithLifecycle() val indexerRelays by indexerViewModel.relays.collectAsStateWithLifecycle() val proxyRelays by proxyViewModel.relays.collectAsStateWithLifecycle() + val favoriteFeedState by favoriteViewModel.relays.collectAsStateWithLifecycle() Scaffold( topBar = { @@ -182,6 +190,7 @@ fun MappedAllRelayListView( broadcastViewModel.clear() indexerViewModel.clear() proxyViewModel.clear() + favoriteViewModel.clear() newNav.popBack() }, onPost = { @@ -195,6 +204,7 @@ fun MappedAllRelayListView( broadcastViewModel.create() indexerViewModel.create() proxyViewModel.create() + favoriteViewModel.create() newNav.popBack() }, ) @@ -310,6 +320,15 @@ fun MappedAllRelayListView( } renderTrustedItems(trustedFeedState, trustedViewModel, accountViewModel, newNav) + item { + SettingsCategory( + R.string.favorite_section, + R.string.favorite_section_explainer, + SettingsCategorySpacingModifier, + ) + } + renderFavoriteItems(favoriteFeedState, favoriteViewModel, accountViewModel, newNav) + item { SettingsCategory( R.string.blocked_section, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt index c45474b942..375f0dd121 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/RelayInformationScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Article @@ -58,6 +59,7 @@ import androidx.compose.material.icons.filled.Language import androidx.compose.material.icons.filled.Lock import androidx.compose.material.icons.filled.Payment import androidx.compose.material.icons.filled.PrivacyTip +import androidx.compose.material.icons.filled.Send import androidx.compose.material.icons.filled.Storage import androidx.compose.material.icons.filled.Tag import androidx.compose.material.icons.filled.Topic @@ -71,6 +73,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Scaffold import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -79,9 +82,11 @@ 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.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight @@ -112,15 +117,148 @@ import com.vitorpamplona.amethyst.ui.theme.Size100dp import com.vitorpamplona.amethyst.ui.theme.StdHorzSpacer import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow +import com.vitorpamplona.amethyst.ui.theme.bitcoinColor +import com.vitorpamplona.amethyst.ui.theme.redColorOnSecondSurface +import com.vitorpamplona.quartz.experimental.audio.header.AudioHeaderEvent +import com.vitorpamplona.quartz.experimental.audio.track.AudioTrackEvent +import com.vitorpamplona.quartz.experimental.edits.TextNoteModificationEvent +import com.vitorpamplona.quartz.experimental.ephemChat.chat.EphemeralChatEvent +import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryPrologueEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStoryReadingStateEvent +import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStorySceneEvent +import com.vitorpamplona.quartz.experimental.medical.FhirResourceEvent +import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent +import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent +import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent +import com.vitorpamplona.quartz.experimental.nipsOnNostr.NipTextEvent +import com.vitorpamplona.quartz.experimental.nns.NNSEvent +import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent +import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent +import com.vitorpamplona.quartz.experimental.relationshipStatus.ContactCardEvent +import com.vitorpamplona.quartz.experimental.trustedAssertions.list.TrustProviderListEvent +import com.vitorpamplona.quartz.experimental.zapPolls.PollNoteEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.stats.ErrorDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.client.stats.IRelayDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.client.stats.NoticeDebugMessage import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat import com.vitorpamplona.quartz.nip01Core.relay.client.stats.SpamDebugMessage +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import com.vitorpamplona.quartz.nip03Timestamp.OtsEvent +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation +import com.vitorpamplona.quartz.nip17Dm.files.ChatMessageEncryptedFileHeaderEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent +import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent +import com.vitorpamplona.quartz.nip18Reposts.RepostEvent +import com.vitorpamplona.quartz.nip22Comments.CommentEvent +import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelCreateEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelHideMessageEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMetadataEvent +import com.vitorpamplona.quartz.nip28PublicChat.admin.ChannelMuteUserEvent +import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent +import com.vitorpamplona.quartz.nip28PublicChat.message.ChannelMessageEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.pack.EmojiPackEvent +import com.vitorpamplona.quartz.nip30CustomEmoji.selection.EmojiPackSelectionEvent +import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent +import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent +import com.vitorpamplona.quartz.nip34Git.reply.GitReplyEvent +import com.vitorpamplona.quartz.nip34Git.repository.GitRepositoryEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentCommentEvent +import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent +import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent +import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent +import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.PinListEvent +import com.vitorpamplona.quartz.nip51Lists.bookmarkList.BookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent +import com.vitorpamplona.quartz.nip51Lists.geohashList.GeohashListEvent +import com.vitorpamplona.quartz.nip51Lists.hashtagList.HashtagListEvent +import com.vitorpamplona.quartz.nip51Lists.labeledBookmarkList.LabeledBookmarkListEvent +import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent +import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent +import com.vitorpamplona.quartz.nip52Calendar.CalendarDateSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.CalendarEvent +import com.vitorpamplona.quartz.nip52Calendar.CalendarRSVPEvent +import com.vitorpamplona.quartz.nip52Calendar.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent +import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent +import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent +import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip56Reports.ReportEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip58Badges.BadgeAwardEvent +import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent +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.nip66RelayMonitor.discovery.RelayDiscoveryEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent +import com.vitorpamplona.quartz.nip68Picture.PictureEvent +import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent +import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent +import com.vitorpamplona.quartz.nip71Video.VideoShortEvent +import com.vitorpamplona.quartz.nip71Video.VideoVerticalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.approval.CommunityPostApprovalEvent +import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent +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 +import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip90Dvms.NIP90StatusEvent +import com.vitorpamplona.quartz.nip90Dvms.NIP90UserDiscoveryRequestEvent +import com.vitorpamplona.quartz.nip90Dvms.NIP90UserDiscoveryResponseEvent +import com.vitorpamplona.quartz.nip94FileMetadata.FileHeaderEvent +import com.vitorpamplona.quartz.nip96FileStorage.config.FileServersEvent +import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.nip99Classifieds.ClassifiedsEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent +import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent import com.vitorpamplona.quartz.utils.TimeUtils import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -202,6 +340,11 @@ fun RelayInformationBody( accountViewModel: AccountViewModel, nav: INav, ) { + val nostrClient = remember { Amethyst.instance.client as? NostrClient } + val activeReqs = remember(relay) { nostrClient?.activeRequests(relay) ?: emptyMap() } + val activeCounts = remember(relay) { nostrClient?.activeCounts(relay) ?: emptyMap() } + val activeOutbox = remember(relay) { nostrClient?.activeOutboxCache(relay) ?: emptySet() } + LazyColumn( modifier = Modifier @@ -264,6 +407,59 @@ fun RelayInformationBody( item { SoftwareCard(relayInfo) } } + // Active subscriptions and outbox section + val hasReqs = activeReqs.isNotEmpty() + val hasCounts = activeCounts.isNotEmpty() + val hasOutbox = activeOutbox.isNotEmpty() + + if (hasReqs || hasCounts || hasOutbox) { + item { SectionHeader(stringRes(R.string.relay_active_subscriptions)) } + + if (hasReqs) { + item { + Text( + text = stringRes(R.string.relay_req_subscriptions, activeReqs.size), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(bottom = 4.dp), + ) + } + items(activeReqs.entries.toList(), key = { "req_${it.key}" }) { (subId, filters) -> + SubscriptionCard(subId = subId, filters = filters) + Spacer(modifier = StdVertSpacer) + } + } + + if (hasCounts) { + item { + Text( + text = stringRes(R.string.relay_count_subscriptions, activeCounts.size), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier.padding(bottom = 4.dp, top = if (hasReqs) 8.dp else 0.dp), + ) + } + items(activeCounts.entries.toList(), key = { "count_${it.key}" }) { (subId, filters) -> + SubscriptionCard(subId = subId, filters = filters) + Spacer(modifier = StdVertSpacer) + } + } + + if (hasOutbox) { + item { + Text( + text = stringRes(R.string.relay_outbox_events, activeOutbox.size), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.tertiary, + modifier = Modifier.padding(bottom = 4.dp, top = if (hasReqs || hasCounts) 8.dp else 0.dp), + ) + } + item { + OutboxEventsCard(eventIds = activeOutbox) + } + } + } + item { SectionHeader(stringRes(R.string.relay_error_messages)) } @@ -276,6 +472,437 @@ fun RelayInformationBody( } } +// --------------------------------------------------------------------------- +// Active subscriptions + outbox display +// --------------------------------------------------------------------------- + +private fun kindDisplayName(kind: Int): Int = + when (kind) { + AdvertisedRelayListEvent.KIND -> R.string.kind_outbox_relays + AppDefinitionEvent.KIND -> R.string.kind_apps + AppRecommendationEvent.KIND -> R.string.kind_app_recommendations + AppSpecificDataEvent.KIND -> R.string.kind_user_settings + AudioHeaderEvent.KIND -> R.string.kind_audio_header + AudioTrackEvent.KIND -> R.string.kind_audio_track + BadgeAwardEvent.KIND -> R.string.kind_badge_awards + BadgeDefinitionEvent.KIND -> R.string.kind_badge_definitions + BadgeProfilesEvent.KIND -> R.string.kind_profile_badges + BlockedRelayListEvent.KIND -> R.string.kind_blocked_relays + BlossomServersEvent.KIND -> R.string.kind_blossom_servers + BlossomAuthorizationEvent.KIND -> R.string.kind_blossom_auth + BroadcastRelayListEvent.KIND -> R.string.kind_broadcast_relays + BookmarkListEvent.KIND -> R.string.kind_bookmark_list + CalendarDateSlotEvent.KIND -> R.string.kind_day_appointment + CalendarEvent.KIND -> R.string.kind_calendar + CalendarTimeSlotEvent.KIND -> R.string.kind_appointment + CalendarRSVPEvent.KIND -> R.string.kind_appt_rsvp + ChessGameEvent.KIND -> R.string.kind_chess_games + JesterEvent.KIND -> R.string.kind_chess_auth + FavoriteRelayListEvent.KIND -> R.string.kind_favorite_relays + LiveChessGameChallengeEvent.KIND -> R.string.kind_chess_challenges + LiveChessGameAcceptEvent.KIND -> R.string.kind_chess_game_accept + LiveChessMoveEvent.KIND -> R.string.kind_chess_move + LiveChessGameEndEvent.KIND -> R.string.kind_chess_game_end + LiveChessDrawOfferEvent.KIND -> R.string.kind_chess_draw_offer + ChannelCreateEvent.KIND -> R.string.kind_channel_definition + ChannelHideMessageEvent.KIND -> R.string.kind_channel_hide_msg + ChannelListEvent.KIND -> R.string.kind_channel_list + ChannelMessageEvent.KIND -> R.string.kind_channel_message + ChannelMetadataEvent.KIND -> R.string.kind_channel_metadata + ChannelMuteUserEvent.KIND -> R.string.kind_channel_mute_user + ChatMessageEncryptedFileHeaderEvent.KIND -> R.string.kind_dm_file + ChatMessageEvent.KIND -> R.string.kind_dm_message + ChatMessageRelayListEvent.KIND -> R.string.kind_dm_relays + ClassifiedsEvent.KIND -> R.string.kind_classifieds + CommentEvent.KIND -> R.string.kind_comments + CommunityDefinitionEvent.KIND -> R.string.kind_community_def + CommunityListEvent.KIND -> R.string.kind_community_list + CommunityPostApprovalEvent.KIND -> R.string.kind_community_post + ContactListEvent.KIND -> R.string.kind_follow_list + DeletionEvent.KIND -> R.string.kind_deletions + DraftWrapEvent.KIND -> R.string.kind_drafts + EmojiPackEvent.KIND -> R.string.kind_emoji_packs + EmojiPackSelectionEvent.KIND -> R.string.kind_emoji_pack_list + EphemeralChatEvent.KIND -> R.string.kind_ephemeral_chat + EphemeralChatListEvent.KIND -> R.string.kind_ephemeral_chatrooms + FileHeaderEvent.KIND -> R.string.kind_file_headers + ProfileGalleryEntryEvent.KIND -> R.string.kind_profile_gallery + FileServersEvent.KIND -> R.string.kind_file_servers + FileStorageEvent.KIND -> R.string.kind_blob_data + FileStorageHeaderEvent.KIND -> R.string.kind_blob_headers + FhirResourceEvent.KIND -> R.string.kind_medical_data + FollowListEvent.KIND -> R.string.kind_follow_packs + GenericRepostEvent.KIND -> R.string.kind_reposts_16 + GeohashListEvent.KIND -> R.string.kind_geohash_follows + GiftWrapEvent.KIND -> R.string.kind_gift_wraps + GitIssueEvent.KIND -> R.string.kind_git_issue + GitPatchEvent.KIND -> R.string.kind_git_patch + GitRepositoryEvent.KIND -> R.string.kind_git_repo + GitReplyEvent.KIND -> R.string.kind_git_reply + GoalEvent.KIND -> R.string.kind_zap_goals + HashtagListEvent.KIND -> R.string.kind_hashtag_follows + HighlightEvent.KIND -> R.string.kind_highlights + HTTPAuthorizationEvent.KIND -> R.string.kind_http_auth + IndexerRelayListEvent.KIND -> R.string.kind_index_relay_list + InteractiveStoryPrologueEvent.KIND -> R.string.kind_adventure_prologue + InteractiveStorySceneEvent.KIND -> R.string.kind_adventure_scene + InteractiveStoryReadingStateEvent.KIND -> R.string.kind_adventure_reading + LabeledBookmarkListEvent.KIND -> R.string.kind_named_bookmarks + LiveActivitiesChatMessageEvent.KIND -> R.string.kind_live_chats + LiveActivitiesEvent.KIND -> R.string.kind_live_streams + LnZapEvent.KIND -> R.string.kind_zaps + LnZapPaymentRequestEvent.KIND -> R.string.kind_nwc_request + LnZapPaymentResponseEvent.KIND -> R.string.kind_nwc_response + LnZapPrivateEvent.KIND -> R.string.kind_private_zaps + LnZapRequestEvent.KIND -> R.string.kind_zap_req + LongTextNoteEvent.KIND -> R.string.kind_blogs + MeetingRoomEvent.KIND -> R.string.kind_meeting_room + MeetingRoomPresenceEvent.KIND -> R.string.kind_room_presence + MeetingSpaceEvent.KIND -> R.string.kind_meeting_space + MetadataEvent.KIND -> R.string.kind_profile + MuteListEvent.KIND -> R.string.kind_mute_list + NNSEvent.KIND -> R.string.kind_nns + NipTextEvent.KIND -> R.string.kind_nip + NostrConnectEvent.KIND -> R.string.kind_nostr_connect + NIP90StatusEvent.KIND -> R.string.kind_dvm_status + NIP90ContentDiscoveryRequestEvent.KIND -> R.string.kind_dvm_content_req + NIP90ContentDiscoveryResponseEvent.KIND -> R.string.kind_dvm_content_resp + NIP90UserDiscoveryRequestEvent.KIND -> R.string.kind_dvm_user_req + NIP90UserDiscoveryResponseEvent.KIND -> R.string.kind_dvm_user_resp + OtsEvent.KIND -> R.string.kind_ots + PaymentTargetsEvent.KIND -> R.string.kind_pay_to + PeopleListEvent.KIND -> R.string.kind_people_lists + PictureEvent.KIND -> R.string.kind_pictures + PinListEvent.KIND -> R.string.kind_pins + PollNoteEvent.KIND -> R.string.kind_zap_poll + PollEvent.KIND -> R.string.kind_poll + PollResponseEvent.KIND -> R.string.kind_poll_response + PrivateDmEvent.KIND -> R.string.kind_nip04_dms + PrivateOutboxRelayListEvent.KIND -> R.string.kind_private_relays + ProxyRelayListEvent.KIND -> R.string.kind_proxy_relays + PublicMessageEvent.KIND -> R.string.kind_public_message + ReactionEvent.KIND -> R.string.kind_reactions + ContactCardEvent.KIND -> R.string.kind_contact_card + RelayAuthEvent.KIND -> R.string.kind_relay_auth + RelayDiscoveryEvent.KIND -> R.string.kind_relay_discovery + RelayMonitorEvent.KIND -> R.string.kind_relay_monitor + RelaySetEvent.KIND -> R.string.kind_relay_set + ReportEvent.KIND -> R.string.kind_reports + RepostEvent.KIND -> R.string.kind_reposts + RequestToVanishEvent.KIND -> R.string.kind_user_delete + SealedRumorEvent.KIND -> R.string.kind_seals + SearchRelayListEvent.KIND -> R.string.kind_search_relays + StatusEvent.KIND -> R.string.kind_user_status + TextNoteEvent.KIND -> R.string.kind_notes + TextNoteModificationEvent.KIND -> R.string.kind_edits + TorrentEvent.KIND -> R.string.kind_torrents + TorrentCommentEvent.KIND -> R.string.kind_torrent_comments + TrustedRelayListEvent.KIND -> R.string.kind_trusted_relays + TrustProviderListEvent.KIND -> R.string.kind_trusted_providers + VideoHorizontalEvent.KIND -> R.string.kind_video_repl + VideoVerticalEvent.KIND -> R.string.kind_shorts_repl + VideoNormalEvent.KIND -> R.string.kind_video + VideoShortEvent.KIND -> R.string.kind_shorts + VoiceEvent.KIND -> R.string.kind_voice_msg + VoiceReplyEvent.KIND -> R.string.kind_voice_reply + WikiNoteEvent.KIND -> R.string.kind_wiki + else -> -1 + } + +val posts = setOf(0, 1, 6, 7, 16, 30023) +val settings = setOf(3, 10002, 10000, 10001, 10003, 10004, 30000) +val dms = setOf(4, 1059, 10050) +val zaps = setOf(9734, 9735, 9041, 17375, 23194, 23195) +val reports = setOf(ReportEvent.KIND, MuteListEvent.KIND, DeletionEvent.KIND, RequestToVanishEvent.KIND) + +@Composable +private fun KindChip(kind: Int) { + val nameResId = kindDisplayName(kind) + val name = if (nameResId != -1) stringResource(nameResId) else "k$kind" + val (bg, fg) = + when (kind) { + in posts -> { + MaterialTheme.colorScheme.primaryContainer to MaterialTheme.colorScheme.onPrimaryContainer + } + + // + in settings -> { + MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.onSecondaryContainer + } + + // dms + in dms -> { + MaterialTheme.colorScheme.tertiaryContainer to MaterialTheme.colorScheme.onTertiaryContainer + } + + // zaps + in zaps -> { + MaterialTheme.colorScheme.background to MaterialTheme.colorScheme.bitcoinColor + } + + in reports -> { + MaterialTheme.colorScheme.secondaryContainer to MaterialTheme.colorScheme.redColorOnSecondSurface + } + + else -> { + MaterialTheme.colorScheme.surfaceVariant to MaterialTheme.colorScheme.onSurfaceVariant + } + } + Surface( + shape = RoundedCornerShape(50), + color = bg, + ) { + Text( + text = name, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = fg, + ) + } +} + +@Composable +private fun FilterAttributeChip( + text: String, + color: Color = MaterialTheme.colorScheme.surfaceVariant, + textColor: Color = MaterialTheme.colorScheme.onSurfaceVariant, +) { + Surface( + shape = RoundedCornerShape(50), + color = color, + ) { + Text( + text = text, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = textColor, + ) + } +} + +@Composable +private fun FilterVisual( + filter: Filter, + index: Int, +) { + val context = LocalContext.current + + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + ) { + Column(modifier = Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = "filter ${index + 1}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + // Kinds + filter.kinds?.forEach { kind -> + KindChip(kind) + } + + // Authors + filter.authors?.let { authors -> + if (authors.isNotEmpty()) { + FilterAttributeChip( + text = "👤 ${stringRes(R.string.relay_filter_authors, authors.size)}", + color = MaterialTheme.colorScheme.secondaryContainer, + textColor = MaterialTheme.colorScheme.onSecondaryContainer, + ) + } + } + + // IDs + filter.ids?.let { ids -> + if (ids.isNotEmpty()) { + FilterAttributeChip( + text = "🆔 ${stringRes(R.string.relay_filter_ids, ids.size)}", + ) + } + } + + // Tags + filter.tags?.forEach { (tagName, values) -> + if (values.isNotEmpty()) { + FilterAttributeChip( + text = + when { + values.size == 1 -> { + if (values[0].length > 8) { + "#$tagName:${values[0].take(8)}..." + } else { + "#$tagName:${values[0]}" + } + } + + else -> { + "#$tagName ×${values.size}" + } + }, + color = MaterialTheme.colorScheme.tertiaryContainer, + textColor = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + } + + // tagsAll + filter.tagsAll?.forEach { (tagName, values) -> + if (values.isNotEmpty()) { + FilterAttributeChip( + text = + when { + values.size == 1 -> { + if (values[0].length > 8) { + "&$tagName:${values[0].take(8)}..." + } else { + "&$tagName:${values[0]}" + } + } + + else -> { + "&$tagName ×${values.size}" + } + }, + color = MaterialTheme.colorScheme.tertiaryContainer, + textColor = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + } + + // Since + filter.since?.let { since -> + FilterAttributeChip( + text = stringRes(R.string.relay_filter_since, timeAgoNoDot(since, context)), + color = MaterialTheme.colorScheme.surfaceVariant, + textColor = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + // Until + filter.until?.let { until -> + FilterAttributeChip( + text = stringRes(R.string.relay_filter_until, timeAgoNoDot(until, context)), + ) + } + + // Limit + filter.limit?.let { limit -> + FilterAttributeChip( + text = stringRes(R.string.relay_filter_limit, limit), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + ) + } + + // Search + filter.search?.let { search -> + if (search.isNotEmpty()) { + FilterAttributeChip( + text = "🔍 ${search.take(20)}${if (search.length > 20) "…" else ""}", + color = MaterialTheme.colorScheme.primaryContainer, + textColor = MaterialTheme.colorScheme.onPrimaryContainer, + ) + } + } + } + } + } +} + +@Composable +private fun SubscriptionCard( + subId: String, + filters: List, +) { + val displayId = + if (subId.length > 28) { + subId.take(22) + "…" + subId.takeLast(4) + } else { + subId + } + + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.FilterAlt, + contentDescription = null, + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.primary, + ) + SelectionContainer(modifier = Modifier.weight(1f)) { + Text( + text = displayId, + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Text( + text = "${filters.size}f", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + + filters.forEachIndexed { index, filter -> + FilterVisual(filter = filter, index = index) + } + } + } +} + +@Composable +private fun OutboxEventsCard(eventIds: Set) { + OutlinedCard( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + FlowRow( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + eventIds.forEach { eventId -> + Surface( + shape = RoundedCornerShape(50), + color = MaterialTheme.colorScheme.tertiaryContainer, + ) { + Row( + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Icon( + imageVector = Icons.Default.Send, + contentDescription = null, + modifier = Modifier.size(12.dp), + tint = MaterialTheme.colorScheme.onTertiaryContainer, + ) + Text( + text = eventId.take(8) + "…" + eventId.takeLast(4), + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onTertiaryContainer, + ) + } + } + } + } + } +} + @Composable private fun RenderDebugMessage(msg: IRelayDebugMessage) { val context = LocalContext.current diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt index 8576fd313e..25081c10bc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/blocked/BlockedRelayListViewModel.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.blocked +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +@Stable class BlockedRelayListViewModel : BasicRelaySetupInfoModel() { override fun getRelayList(): List? = account.blockedRelayList.flow.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt index 6601d26f5a..608eb94053 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/broadcast/BroadcastRelayListViewModel.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.broadcast +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +@Stable class BroadcastRelayListViewModel : BasicRelaySetupInfoModel() { override fun getRelayList(): List? = account.broadcastRelayList.flow.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelaySuggestionState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelaySuggestionState.kt new file mode 100644 index 0000000000..cd772c2f92 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelaySuggestionState.kt @@ -0,0 +1,62 @@ +/* + * 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.relays.common + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map + +@Stable +class RelaySuggestionState { + val currentInput = MutableStateFlow("") + + @OptIn(FlowPreview::class) + val results = + currentInput + .debounce(300) + .distinctUntilChanged() + .map { input -> + if (input.length > 1) { + val lower = input.lowercase() + LocalCache.relayHints.relayDB + .filter { _, relay -> relay.url.contains(lower) } + .sortedBy { it.url } + .take(20) + } else { + emptyList() + } + }.flowOn(Dispatchers.IO) + + fun processInput(input: String) { + currentInput.tryEmit(input) + } + + fun reset() { + currentInput.tryEmit("") + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt index cb56ce80ca..cdbf1a048e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions @@ -61,64 +62,73 @@ fun RelayUrlEditFieldPreview() { @Composable fun RelayUrlEditField(onNewRelay: (NormalizedRelayUrl) -> Unit) { var url by remember { mutableStateOf("") } + val relaySuggestions = remember { RelaySuggestionState() } - Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Size10dp)) { - OutlinedTextField( - label = { Text(text = stringRes(R.string.add_a_relay)) }, - modifier = Modifier.weight(1f), - value = url, - onValueChange = { url = it }, - placeholder = { - Text( - text = "server.com", - color = MaterialTheme.colorScheme.placeholderText, - maxLines = 1, - ) - }, - singleLine = true, - keyboardOptions = - KeyboardOptions.Default.copy( - autoCorrectEnabled = false, - imeAction = ImeAction.Go, - capitalization = KeyboardCapitalization.None, - keyboardType = KeyboardType.Text, - ), - keyboardActions = - KeyboardActions( - onGo = { - if (url.isNotBlank() && url != "/") { - val relay = RelayUrlNormalizer.normalizeOrNull(url) - if (relay != null) { - onNewRelay(relay) - url = "" - } - } - }, - ), - ) - - Button( - onClick = { - if (url.isNotBlank() && url != "/") { - val relay = RelayUrlNormalizer.normalizeOrNull(url) - if (relay != null) { - onNewRelay(relay) - url = "" - } - } - }, - shape = ButtonBorder, - colors = - ButtonDefaults.buttonColors( - containerColor = - if (url.isNotBlank()) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.placeholderText - }, - ), - ) { - Text(text = stringRes(id = R.string.add), color = Color.White) + fun submitRelay() { + if (url.isNotBlank() && url != "/") { + val relay = RelayUrlNormalizer.normalizeOrNull(url) + if (relay != null) { + onNewRelay(relay) + url = "" + relaySuggestions.reset() + } } } + + Column { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(Size10dp)) { + OutlinedTextField( + label = { Text(text = stringRes(R.string.add_a_relay)) }, + modifier = Modifier.weight(1f), + value = url, + onValueChange = { + url = it + relaySuggestions.processInput(it) + }, + placeholder = { + Text( + text = "server.com", + color = MaterialTheme.colorScheme.placeholderText, + maxLines = 1, + ) + }, + singleLine = true, + keyboardOptions = + KeyboardOptions.Default.copy( + autoCorrectEnabled = false, + imeAction = ImeAction.Go, + capitalization = KeyboardCapitalization.None, + keyboardType = KeyboardType.Text, + ), + keyboardActions = + KeyboardActions( + onGo = { submitRelay() }, + ), + ) + + Button( + onClick = { submitRelay() }, + shape = ButtonBorder, + colors = + ButtonDefaults.buttonColors( + containerColor = + if (url.isNotBlank()) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.placeholderText + }, + ), + ) { + Text(text = stringRes(id = R.string.add), color = Color.White) + } + } + + ShowRelaySuggestionList( + relaySuggestions = relaySuggestions, + onSelect = { relay -> + url = relay.url + relaySuggestions.reset() + }, + ) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.kt new file mode 100644 index 0000000000..8ce412ce67 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/ShowRelaySuggestionList.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.ui.screen.loggedIn.relays.common + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.theme.DividerThickness +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl + +@Composable +fun ShowRelaySuggestionList( + relaySuggestions: RelaySuggestionState, + onSelect: (NormalizedRelayUrl) -> Unit, + modifier: Modifier = Modifier, +) { + val suggestions by relaySuggestions.results.collectAsStateWithLifecycle(emptyList()) + + if (suggestions.isNotEmpty()) { + Column(modifier = modifier) { + suggestions.forEachIndexed { index, relay -> + RelayUrlLine(relay) { onSelect(relay) } + if (index < suggestions.lastIndex) { + HorizontalDivider(thickness = DividerThickness) + } + } + } + } +} + +@Composable +fun RelayUrlLine( + relay: NormalizedRelayUrl, + onClick: () -> Unit, +) { + Text( + text = relay.displayUrl(), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt index bec8dbc5f9..58bd31dc1a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/connected/ConnectedRelayListViewModel.kt @@ -20,12 +20,14 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.connected +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.model.LocalCache import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +@Stable class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() { override fun relayListBuilder(): List { val relayList = getRelayList() @@ -55,5 +57,6 @@ class ConnectedRelayListViewModel : BasicRelaySetupInfoModel() { .sorted() override suspend fun saveRelayList(urlList: List) { + // Connected relays are read-only; saving is not applicable } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt index 0af4125e11..d08612a485 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/dm/DMRelayListViewModel.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.dm +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +@Stable class DMRelayListViewModel : BasicRelaySetupInfoModel() { override fun getRelayList(): List? = account.dmRelayList.getDMRelayList()?.relays() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListView.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListView.kt new file mode 100644 index 0000000000..f8af962a3c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListView.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.ui.screen.loggedIn.relays.favorites + +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfo +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoDialog +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.RelayUrlEditField +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.relaySetupInfoBuilder +import com.vitorpamplona.amethyst.ui.theme.FeedPadding +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer + +@Composable +fun FavoriteRelayList( + postViewModel: FavoriteRelayListViewModel, + accountViewModel: AccountViewModel, + onClose: () -> Unit, + nav: INav, +) { + val feedState by postViewModel.relays.collectAsStateWithLifecycle() + + val newNav = rememberExtendedNav(nav, onClose) + + Row(verticalAlignment = Alignment.CenterVertically) { + LazyColumn( + contentPadding = FeedPadding, + ) { + renderFavoriteItems(feedState, postViewModel, accountViewModel, newNav) + } + } +} + +fun LazyListScope.renderFavoriteItems( + feedState: List, + postViewModel: FavoriteRelayListViewModel, + accountViewModel: AccountViewModel, + nav: INav, +) { + itemsIndexed(feedState, key = { _, item -> "Favorite" + item.relay.url }) { index, item -> + BasicRelaySetupInfoDialog( + item, + onDelete = { postViewModel.deleteRelay(item) }, + accountViewModel = accountViewModel, + nav, + ) + } + + item { + Spacer(modifier = StdVertSpacer) + RelayUrlEditField { postViewModel.addRelay(relaySetupInfoBuilder(it)) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListViewModel.kt new file mode 100644 index 0000000000..a0d3ff0a60 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/favorites/FavoriteRelayListViewModel.kt @@ -0,0 +1,36 @@ +/* + * 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.relays.favorites + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +@Stable +class FavoriteRelayListViewModel : BasicRelaySetupInfoModel() { + override fun getRelayList(): List? = + account.favoriteRelayList.flow.value + .toList() + + override suspend fun saveRelayList(urlList: List) { + account.saveFavoriteRelayList(urlList) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt index 408e675271..b4af90aeb6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/indexer/IndexerRelayListViewModel.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.indexer +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +@Stable class IndexerRelayListViewModel : BasicRelaySetupInfoModel() { override fun getRelayList(): List? = account.indexerRelayList.flowNoDefaults.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt index 8a7ee6c190..aa518d2f05 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/local/LocalRelayListViewModel.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.local +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +@Stable class LocalRelayListViewModel : BasicRelaySetupInfoModel() { override fun getRelayList(): List = account.localRelayList.flow.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt index 6a2666231a..628864a708 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/nip37/PrivateOutboxRelayListViewModel.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.nip37 +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +@Stable class PrivateOutboxRelayListViewModel : BasicRelaySetupInfoModel() { override fun getRelayList(): List? = account.privateStorageRelayList.flow.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt index 1846237e08..0d32e58c45 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/proxy/ProxyRelayListViewModel.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.proxy +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +@Stable class ProxyRelayListViewModel : BasicRelaySetupInfoModel() { override fun getRelayList(): List? = account.proxyRelayList.flow.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt index 436905dbc8..bbbbfc1483 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/search/SearchRelayListViewModel.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.search +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +@Stable class SearchRelayListViewModel : BasicRelaySetupInfoModel() { override fun getRelayList(): List? = account.searchRelayList.flowNoDefaults.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt index 4876cd46f8..057c710988 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/trusted/TrustedRelayListViewModel.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.trusted +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.BasicRelaySetupInfoModel import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +@Stable class TrustedRelayListViewModel : BasicRelaySetupInfoModel() { override fun getRelayList(): List? = account.trustedRelayList.flow.value diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt new file mode 100644 index 0000000000..2157afe8a7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/AllSettingsScreen.kt @@ -0,0 +1,228 @@ +/* + * 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.settings + +import androidx.compose.foundation.clickable +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.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Bolt +import androidx.compose.material.icons.outlined.CloudUpload +import androidx.compose.material.icons.outlined.FavoriteBorder +import androidx.compose.material.icons.outlined.Key +import androidx.compose.material.icons.outlined.Security +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material.icons.outlined.Translate +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.painterRes +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonColumn + +@Preview +@Composable +fun AllSettingsScreenPreview() { + ThemeComparisonColumn { + AllSettingsScreen( + mockAccountViewModel(), + EmptyNav(), + ) + } +} + +@Composable +fun AllSettingsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val tint = MaterialTheme.colorScheme.onBackground + + Scaffold( + topBar = { + TopBarWithBackButton(stringRes(id = R.string.settings), nav::popBack) + }, + ) { padding -> + Column(Modifier.padding(padding)) { + SettingsSectionHeader(R.string.account_settings) + SettingsNavigationRow( + title = R.string.relay_setup, + iconPainter = R.drawable.relays, + iconPainterRef = 4, + tint = tint, + onClick = { nav.nav(Route.EditRelays) }, + ) + HorizontalDivider() + SettingsNavigationRow( + title = R.string.media_servers, + icon = Icons.Outlined.CloudUpload, + tint = tint, + onClick = { nav.nav(Route.EditMediaServers) }, + ) + HorizontalDivider() + SettingsNavigationRow( + title = R.string.reactions, + icon = Icons.Outlined.FavoriteBorder, + tint = tint, + onClick = { nav.nav(Route.UpdateReactionType) }, + ) + HorizontalDivider() + SettingsNavigationRow( + title = R.string.zaps, + icon = Icons.Outlined.Bolt, + tint = tint, + onClick = { nav.nav(Route.UpdateZapAmount()) }, + ) + HorizontalDivider() + SettingsNavigationRow( + title = R.string.security_filters, + icon = Icons.Outlined.Security, + tint = tint, + onClick = { nav.nav(Route.SecurityFilters) }, + ) + HorizontalDivider() + SettingsNavigationRow( + title = R.string.translations, + icon = Icons.Outlined.Translate, + tint = tint, + onClick = { nav.nav(Route.UserSettings) }, + ) + accountViewModel.account.settings.keyPair.privKey?.let { + HorizontalDivider() + SettingsNavigationRow( + title = R.string.backup_keys, + icon = Icons.Outlined.Key, + tint = tint, + onClick = { nav.nav(Route.AccountBackup) }, + ) + } + HorizontalDivider(thickness = 4.dp) + SettingsSectionHeader(R.string.app_settings) + SettingsNavigationRow( + title = R.string.privacy_options, + iconPainter = R.drawable.ic_tor, + iconPainterRef = 1, + tint = tint, + onClick = { nav.nav(Route.PrivacyOptions) }, + ) + HorizontalDivider() + SettingsNavigationRow( + title = R.string.ui_preferences, + icon = Icons.Outlined.Settings, + tint = tint, + onClick = { nav.nav(Route.Settings) }, + ) + } + } +} + +@Composable +private fun SettingsSectionHeader(title: Int) { + Text( + text = stringRes(title), + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 24.dp, end = 24.dp, top = 16.dp, bottom = 4.dp), + ) +} + +@Composable +private fun SettingsNavigationRow( + title: Int, + icon: ImageVector, + tint: Color, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 16.dp, horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = icon, + contentDescription = stringRes(title), + modifier = Modifier.size(24.dp), + tint = tint, + ) + Text( + text = stringRes(title), + fontSize = 18.sp, + modifier = Modifier.padding(start = 16.dp), + ) + } +} + +@Composable +private fun SettingsNavigationRow( + title: Int, + iconPainter: Int, + iconPainterRef: Int, + tint: Color, + onClick: () -> Unit, +) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(vertical = 16.dp, horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + painter = painterRes(iconPainter, iconPainterRef), + contentDescription = stringRes(title), + modifier = Modifier.size(24.dp), + tint = tint, + ) + Text( + text = stringRes(title), + fontSize = 18.sp, + modifier = Modifier.padding(start = 16.dp), + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UpdateZapAmountScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UpdateZapAmountScreen.kt new file mode 100644 index 0000000000..055620acd8 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UpdateZapAmountScreen.kt @@ -0,0 +1,85 @@ +/* + * 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.settings + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Scaffold +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.Modifier +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.topbars.SavingTopBar +import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountContent +import com.vitorpamplona.amethyst.ui.note.UpdateZapAmountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel + +@Composable +fun UpdateZapAmountScreen( + accountViewModel: AccountViewModel, + nav: INav, + nip47: String? = null, +) { + val postViewModel: UpdateZapAmountViewModel = viewModel() + postViewModel.init(accountViewModel) + + LaunchedEffect(accountViewModel, postViewModel) { + postViewModel.load() + } + + UpdateZapAmountScreen(postViewModel, accountViewModel, nav, nip47) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun UpdateZapAmountScreen( + postViewModel: UpdateZapAmountViewModel, + accountViewModel: AccountViewModel, + nav: INav, + nip47: String? = null, +) { + Scaffold( + topBar = { + SavingTopBar( + titleRes = R.string.zaps, + isActive = postViewModel::hasChanged, + onCancel = { + postViewModel.cancel() + nav.popBack() + }, + onPost = { + postViewModel.sendPost() + nav.popBack() + }, + ) + }, + ) { + Column(Modifier.padding(it)) { + UpdateZapAmountContent(postViewModel, onClose = { + postViewModel.cancel() + nav.popBack() + }, nip47, accountViewModel) + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt index ecbece34f6..79c685f827 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/UserSettingsScreen.kt @@ -131,7 +131,7 @@ fun DontTranslateFromSetting(accountViewModel: AccountViewModel) { trailingIcon = { Icon( imageVector = Icons.Default.Close, - contentDescription = "Remove $languageCode", + contentDescription = stringRes(R.string.remove_language, languageCode), tint = Color.Red, modifier = Modifier.size(16.dp), ) 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 71947abfdf..fac25dca4c 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 @@ -124,6 +124,7 @@ import com.vitorpamplona.amethyst.ui.note.types.BadgeDisplay import com.vitorpamplona.amethyst.ui.note.types.DisplayBlockedRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayBroadcastRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayDMRelayList +import com.vitorpamplona.amethyst.ui.note.types.DisplayFavoriteRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayFollowList import com.vitorpamplona.amethyst.ui.note.types.DisplayIndexerRelayList import com.vitorpamplona.amethyst.ui.note.types.DisplayNIP65RelayList @@ -147,6 +148,7 @@ import com.vitorpamplona.amethyst.ui.note.types.RenderGitRepositoryEvent 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.RenderLnZap import com.vitorpamplona.amethyst.ui.note.types.RenderPinListEvent import com.vitorpamplona.amethyst.ui.note.types.RenderPoll import com.vitorpamplona.amethyst.ui.note.types.RenderPostApproval @@ -156,6 +158,8 @@ 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.ReplyRenderType import com.vitorpamplona.amethyst.ui.note.types.VideoDisplay import com.vitorpamplona.amethyst.ui.note.types.VoiceHeader import com.vitorpamplona.amethyst.ui.painterRes @@ -218,12 +222,14 @@ import com.vitorpamplona.quartz.nip51Lists.followList.FollowListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relaySets.RelaySetEvent import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent import com.vitorpamplona.quartz.nip57Zaps.splits.hasZapSplitSetup import com.vitorpamplona.quartz.nip58Badges.BadgeDefinitionEvent import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent @@ -233,6 +239,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 @@ -344,7 +351,7 @@ fun RenderThreadFeed( baseNote = item, modifier = modifier, isBoostedNote = false, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, quotesLeft = 3, parentBackgroundColor = background, accountViewModel = accountViewModel, @@ -605,6 +612,8 @@ private fun FullBleedNoteCompose( ) } else if (noteEvent is AdvertisedRelayListEvent) { DisplayNIP65RelayList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is LnZapEvent) { + RenderLnZap(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is SearchRelayListEvent) { DisplaySearchRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is BlockedRelayListEvent) { @@ -613,6 +622,8 @@ private fun FullBleedNoteCompose( DisplayProxyRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is TrustedRelayListEvent) { DisplayTrustedRelayList(baseNote, backgroundColor, accountViewModel, nav) + } else if (noteEvent is FavoriteRelayListEvent) { + DisplayFavoriteRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is IndexerRelayListEvent) { DisplayIndexerRelayList(baseNote, backgroundColor, accountViewModel, nav) } else if (noteEvent is BroadcastRelayListEvent) { @@ -638,7 +649,7 @@ private fun FullBleedNoteCompose( } else if (noteEvent is AppDefinitionEvent) { RenderAppDefinition(baseNote, accountViewModel, nav) } else if (noteEvent is DraftWrapEvent) { - RenderDraft(baseNote, 3, true, backgroundColor, accountViewModel, nav) + RenderDraft(baseNote, 3, ReplyRenderType.FULL, backgroundColor, accountViewModel, nav) } else if (noteEvent is HighlightEvent) { RenderHighlight(baseNote, false, canPreview, quotesLeft = 3, backgroundColor, accountViewModel, nav) } else if (noteEvent is PublicMessageEvent) { @@ -649,7 +660,7 @@ private fun FullBleedNoteCompose( false, canPreview, quotesLeft = 3, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, backgroundColor, editState, accountViewModel, @@ -668,16 +679,26 @@ private fun FullBleedNoteCompose( nav, ) } else if (noteEvent is PollNoteEvent) { - RenderPoll( + RenderZapPoll( baseNote, false, canPreview, quotesLeft = 3, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, backgroundColor, 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, @@ -723,7 +744,7 @@ private fun FullBleedNoteCompose( false, canPreview, quotesLeft = 3, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, backgroundColor, editState, accountViewModel, @@ -735,7 +756,7 @@ private fun FullBleedNoteCompose( false, canPreview, quotesLeft = 3, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, backgroundColor, editState, accountViewModel, @@ -1073,7 +1094,7 @@ private fun RenderWikiHeaderForThreadPreview() { makeItShort = false, canPreview = true, quotesLeft = 3, - unPackReply = false, + unPackReply = ReplyRenderType.NONE, backgroundColor = backgroundColor, editState = editState, accountViewModel = accountViewModel, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt index a366341b3e..9cb7560137 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/threadview/datasources/ThreadFilterAssembler.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources +import androidx.compose.runtime.Stable import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.ui.screen.loggedIn.threadview.datasources.subassembies.ThreadEventLoaderSubAssembler @@ -28,11 +29,13 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient // This allows multiple screen to be listening to tags, even the same tag +@Stable class ThreadQueryState( val eventId: HexKey, val account: Account, ) +@Stable class ThreadFilterAssembler( client: INostrClient, ) : ComposeSubscriptionManager() { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt index 4fe6975003..d74ab4e67d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/StoriesTopBar.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.FeedFilterSpinner import com.vitorpamplona.amethyst.ui.navigation.topbars.UserDrawerSearchTopBar @@ -54,7 +55,7 @@ fun StoriesTopBar( @Composable private fun TopNavFilterBar( followListsModel: TopNavFilterState, - listName: String, + listName: TopFilter, accountViewModel: AccountViewModel, onChange: (FeedDefinition) -> Unit, ) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt index caea4d9614..91d6d5dee4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/dal/VideoFeedFilter.kt @@ -25,6 +25,7 @@ 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.TopFilter import com.vitorpamplona.amethyst.model.filterIntoSet import com.vitorpamplona.amethyst.ui.dal.AdditiveFeedFilter import com.vitorpamplona.amethyst.ui.dal.DefaultFeedOrder @@ -32,8 +33,6 @@ import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.SUPPORTED_VIDEO_FEED_MIME_TYPES_SET import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent -import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent -import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip68Picture.PictureMeta import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent @@ -53,13 +52,19 @@ class VideoFeedFilter( supportedFileExtensions = (RichTextParser.videoExtensions + RichTextParser.imageExtensions).toSet(), ) - override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultStoriesFollowList.value + override fun feedKey(): String = account.userProfile().pubkeyHex + "-" + account.settings.defaultStoriesFollowList.value.code override fun limit() = 300 - override fun showHiddenKey(): Boolean = - account.settings.defaultStoriesFollowList.value == PeopleListEvent.blockListFor(account.userProfile().pubkeyHex) || - account.settings.defaultStoriesFollowList.value == MuteListEvent.blockListFor(account.userProfile().pubkeyHex) + fun followList(): TopFilter = account.settings.defaultStoriesFollowList.value + + fun TopFilter.isMuteList() = this is TopFilter.MuteList + + fun TopFilter.isBlockList() = this is TopFilter.PeopleList && this.address == account.blockPeopleList.getBlockListAddress() + + fun TopFilter.wantsToSeeNegativeStuff() = isMuteList() || isBlockList() + + override fun showHiddenKey(): Boolean = followList().wantsToSeeNegativeStuff() override fun feed(): List { val params = buildFilterParams(account) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt index 4aea04c411..5b02873027 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/video/datasource/subassemblies/VideoOutboxEventsFilterSubAssembler.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.video.datasource.subassemblies +import com.vitorpamplona.amethyst.model.TopFilter import com.vitorpamplona.amethyst.model.User import com.vitorpamplona.amethyst.model.topNavFeeds.allFollows.AllFollowsTopNavPerRelayFilterSet import com.vitorpamplona.amethyst.model.topNavFeeds.aroundMe.LocationTopNavPerRelayFilterSet @@ -53,7 +54,7 @@ import kotlinx.coroutines.launch class VideoOutboxEventsFilterSubAssembler( client: INostrClient, allKeys: () -> Set, -) : PerUserAndFollowListEoseManager(client, allKeys) { +) : PerUserAndFollowListEoseManager(client, allKeys) { override fun updateFilter( key: VideoQueryState, since: SincePerRelayMap?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AddAccountDialog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AddAccountDialog.kt index 9f6986625d..d79431f63e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AddAccountDialog.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/AddAccountDialog.kt @@ -35,14 +35,14 @@ import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.ui.note.ArrowBackIcon -import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.stringRes @OptIn(ExperimentalMaterial3Api::class) @Composable fun AddAccountDialog( newAccountKey: String?, - accountStateViewModel: AccountStateViewModel, + accountSessionManager: AccountSessionManager, onClose: () -> Unit, ) { Dialog( @@ -51,7 +51,7 @@ fun AddAccountDialog( ) { Surface(modifier = Modifier.fillMaxSize()) { Box { - LoginOrSignupScreen(newAccountKey, accountStateViewModel, isFirstLogin = false) + LoginOrSignupScreen(newAccountKey, accountSessionManager, isFirstLogin = false) TopAppBar( title = { Text(text = stringRes(R.string.account_switch_add_account_dialog_title)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt index edea2959cf..9dd0c70900 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/LoginOrSignupScreen.kt @@ -26,14 +26,14 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginPage import com.vitorpamplona.amethyst.ui.screen.loggedOff.signup.SignUpPage @Composable fun LoginOrSignupScreen( newAccountKey: String?, - accountStateViewModel: AccountStateViewModel, + accountSessionManager: AccountSessionManager, isFirstLogin: Boolean, ) { var wantsNewUser by remember { @@ -42,11 +42,11 @@ fun LoginOrSignupScreen( Crossfade(wantsNewUser, label = "LoginOrSignupScreen") { if (it) { - SignUpPage(accountStateViewModel) { + SignUpPage(accountSessionManager) { wantsNewUser = false } } else { - LoginPage(accountStateViewModel, isFirstLogin, newAccountKey) { + LoginPage(accountSessionManager, isFirstLogin, newAccountKey) { wantsNewUser = true } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt index 7a6269393a..d671fe53e9 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginScreen.kt @@ -78,7 +78,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.Amethyst import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons -import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedOff.AcceptTerms import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup import com.vitorpamplona.amethyst.ui.stringRes @@ -87,6 +87,7 @@ import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size40dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import com.vitorpamplona.quartz.nip55AndroidSigner.client.isExternalSignerInstalled import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -94,24 +95,26 @@ import kotlinx.coroutines.launch @Preview(device = "spec:width=2160px,height=2340px,dpi=440") @Composable fun LoginPagePreview() { - val accountViewModel: AccountStateViewModel = viewModel() + val loginViewModel: LoginViewModel = viewModel() + loginViewModel.init(TorSettingsFlow()) ThemeComparisonRow( toPreview = { - LoginPage(accountViewModel, true) {} + LoginPage(loginViewModel) {} }, ) } @Composable fun LoginPage( - accountStateViewModel: AccountStateViewModel, + accountSessionManager: AccountSessionManager, isFirstLogin: Boolean, newAccountKey: String? = null, onWantsToLogin: () -> Unit, ) { val loginViewModel: LoginViewModel = viewModel() - loginViewModel.init(accountStateViewModel) + loginViewModel.init(accountSessionManager) + loginViewModel.init(Amethyst.instance.torPrefs.value) LaunchedEffect(loginViewModel, isFirstLogin, newAccountKey) { loginViewModel.load(isFirstLogin, newAccountKey) @@ -180,7 +183,7 @@ fun LoginPage( Spacer(modifier = Modifier.height(10.dp)) TorSettingsSetup( - torSettingsFlow = Amethyst.instance.torPrefs.value, + torSettingsFlow = loginViewModel.torSettings, onError = { scope.launch { Toast diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt index e553e7a156..35d4bfff56 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/LoginViewModel.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedOff.login +import androidx.compose.runtime.Stable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -27,10 +28,13 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager +import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow +@Stable class LoginViewModel : ViewModel() { - lateinit var accountStateViewModel: AccountStateViewModel + lateinit var accountSessionManager: AccountSessionManager + lateinit var torSettings: TorSettingsFlow val errorManager = LoginErrorManager() @@ -50,8 +54,12 @@ class LoginViewModel : ViewModel() { var isFirstLogin by mutableStateOf(false) - fun init(accountStateViewModel: AccountStateViewModel) { - this.accountStateViewModel = accountStateViewModel + fun init(accountSessionManager: AccountSessionManager) { + this.accountSessionManager = accountSessionManager + } + + fun init(torSettings: TorSettingsFlow) { + this.torSettings = torSettings } fun load( @@ -130,7 +138,7 @@ class LoginViewModel : ViewModel() { fun login() { if (checkCanLogin()) { processingLogin = true - accountStateViewModel.login( + accountSessionManager.login( key = key.text, password = password.text, transientAccount = isTemporary, @@ -148,7 +156,7 @@ class LoginViewModel : ViewModel() { fun loginWithExternalSigner(packageName: String) { if (checkCanLogin()) { processingLogin = true - accountStateViewModel.login( + accountSessionManager.login( key = key.text, transientAccount = isTemporary, loginWithExternalSigner = true, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/OpenURIIfNotLoggedIn.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/OpenURIIfNotLoggedIn.kt index 018ca4e4cc..5c1748cca1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/OpenURIIfNotLoggedIn.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/login/OpenURIIfNotLoggedIn.kt @@ -33,7 +33,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.core.util.Consumer import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.components.getActivity +import com.vitorpamplona.amethyst.ui.components.getActivityOrNull import com.vitorpamplona.amethyst.ui.stringRes import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser import kotlinx.coroutines.delay @@ -42,7 +42,7 @@ import kotlinx.coroutines.launch @Composable fun OpenURIIfNotLoggedIn(onNewNIP19: suspend (String) -> Unit) { val context = LocalContext.current - val activity = context.getActivity() + val activity = context.getActivityOrNull() ?: return val scope = rememberCoroutineScope() var currentIntentNextPage by remember { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt index 4e741e275c..62616886ce 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpScreen.kt @@ -53,7 +53,7 @@ import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.hashtags.Amethyst import com.vitorpamplona.amethyst.commons.hashtags.CustomHashTagIcons -import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedOff.AcceptTerms import com.vitorpamplona.amethyst.ui.screen.loggedOff.TorSettingsSetup import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager @@ -63,27 +63,30 @@ import com.vitorpamplona.amethyst.ui.theme.Size20dp import com.vitorpamplona.amethyst.ui.theme.Size40dp import com.vitorpamplona.amethyst.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.ui.theme.placeholderText +import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow import kotlinx.coroutines.launch @Preview(device = "spec:width=2160px,height=2340px,dpi=440") @Composable fun SignUpPagePreview() { - val accountViewModel: AccountStateViewModel = viewModel() + val signUpViewModel: SignUpViewModel = viewModel() + signUpViewModel.init(TorSettingsFlow()) ThemeComparisonRow( toPreview = { - SignUpPage(accountViewModel) {} + SignUpPage(signUpViewModel) {} }, ) } @Composable fun SignUpPage( - accountStateViewModel: AccountStateViewModel, + accountSessionManager: AccountSessionManager, onWantsToLogin: () -> Unit, ) { val signUpViewModel: SignUpViewModel = viewModel() - signUpViewModel.init(accountStateViewModel) + signUpViewModel.init(accountSessionManager) + signUpViewModel.init(Amethyst.instance.torPrefs.value) SignUpPage(signUpViewModel, onWantsToLogin) } @@ -171,7 +174,7 @@ fun SignUpPage( Spacer(modifier = Modifier.height(10.dp)) TorSettingsSetup( - torSettingsFlow = Amethyst.instance.torPrefs.value, + torSettingsFlow = signUpViewModel.torSettings, onError = { scope.launch { Toast diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt index eb4b0c2d03..6f87efac7a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedOff/signup/SignUpViewModel.kt @@ -20,17 +20,21 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedOff.signup +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue import androidx.compose.ui.text.input.TextFieldValue import androidx.lifecycle.ViewModel import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.ui.screen.AccountStateViewModel +import com.vitorpamplona.amethyst.ui.screen.AccountSessionManager import com.vitorpamplona.amethyst.ui.screen.loggedOff.login.LoginErrorManager +import com.vitorpamplona.amethyst.ui.tor.TorSettingsFlow +@Stable class SignUpViewModel : ViewModel() { - lateinit var accountStateViewModel: AccountStateViewModel + lateinit var accountSessionManager: AccountSessionManager + lateinit var torSettings: TorSettingsFlow val errorManager = LoginErrorManager() @@ -39,8 +43,12 @@ class SignUpViewModel : ViewModel() { var acceptedTerms by mutableStateOf(false) var termsAcceptanceIsRequiredError by mutableStateOf(false) - fun init(accountStateViewModel: AccountStateViewModel) { - this.accountStateViewModel = accountStateViewModel + fun init(accountSessionManager: AccountSessionManager) { + this.accountSessionManager = accountSessionManager + } + + fun init(torSettings: TorSettingsFlow) { + this.torSettings = torSettings } fun updateDisplayName(value: TextFieldValue) { @@ -73,7 +81,7 @@ class SignUpViewModel : ViewModel() { fun signup() { if (checkCanSignup()) { - accountStateViewModel.newKey(displayName.text) + accountSessionManager.newKey(displayName.text) } } } 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 ce07d2449e..f1bd3b1445 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 ef1e4fc09e..8db02d3228 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 d60b06de0d..ff1146c706 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 d98bb953ae..d64e072ee9 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 c445b42351..d299c8f081 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 @@ -20,9 +20,11 @@ */ package com.vitorpamplona.amethyst.ui.tor +import androidx.compose.runtime.Stable import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine +@Stable class TorSettingsFlow( val torType: MutableStateFlow = MutableStateFlow(TorType.INTERNAL), val externalSocksPort: MutableStateFlow = MutableStateFlow(9050), @@ -36,7 +38,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 +56,7 @@ class TorSettingsFlow( videosViaTor, moneyOperationsViaTor, nip05VerificationsViaTor, - nip96UploadsViaTor, + mediaUploadsViaTor, ), ) { flows -> TorSettings( @@ -88,7 +90,7 @@ class TorSettingsFlow( videosViaTor.value, moneyOperationsViaTor.value, nip05VerificationsViaTor.value, - nip96UploadsViaTor.value, + mediaUploadsViaTor.value, ) fun update(torSettings: TorSettings): Boolean { @@ -143,8 +145,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 +168,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 0000000000..5786fe6c18 --- /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 20539cee1b..fb662a5156 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 عند رفع الوسائط مضمن إيقاف أساسي @@ -660,4 +660,5 @@ البحث عن تطبيق حذف الكل متأكد من حذف جميع المسودات؟ + diff --git a/amethyst/src/main/res/values-bn-rBD/strings.xml b/amethyst/src/main/res/values-bn-rBD/strings.xml index d0383cf675..c15476f541 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 @@ ছদ্মবেশ ধারণের অভিযোগ করুন খোলামেলা আধেয় হিসেবে অভিযোগ করুন বেআইনী আচরণের অভিযোগ করুন + ম্যালওয়্যার রিপোর্ট করুন + রিপোর্ট মোড + ম্যালওয়্যার মোড প্রতিউত্তর দিতে ব্যক্তিগত চাবি দিয়ে লগ ইন করুন পোস্টগুলি বুস্ট করতে ব্যক্তিগত চাবি দিয়ে লগ ইন করুন @@ -562,4 +572,5 @@ আপনার অনুদান আমাদের পার্থক্য গড়তে সাহায্য করে। প্রতিটি ইঞ্চি গুরুত্বপূর্ণ! এখনই দান করুন QR কোড স্ক্যান করুন + diff --git a/amethyst/src/main/res/values-ca-rES/strings.xml b/amethyst/src/main/res/values-ca-rES/strings.xml index 66a570486b..57843dc08c 100644 --- a/amethyst/src/main/res/values-ca-rES/strings.xml +++ b/amethyst/src/main/res/values-ca-rES/strings.xml @@ -1,2 +1,4 @@ - + + + diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 06a21b6cd5..9eac3c56f7 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 @@ -438,6 +441,7 @@ Částka zapsu U tohoto typu hlasování je povolen pouze jeden hlas na uživatele "Hledá se událost %1$s" + Odeslat Zap Přidat veřejnou zprávu Přidat soukromou zprávu Přidat fakturu @@ -474,6 +478,7 @@ Nastavte preferované servery pro nahrávání médií. Nemáte nastaveny žádné servery NIP-96. Můžete použít Amethystův seznam nebo přidat některý níže ↓ Nemáte nastaveny žádné Blossom servery. Můžete použít Amethystův seznam, nebo přidat některý níže ↓ + Doporučené média servery Integrované mediální servery Výchozí seznam Amethysty. Můžete je přidat jednotlivě nebo přidat seznam. Použít výchozí seznam @@ -505,6 +510,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 +597,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 @@ -681,6 +687,17 @@ Podmínky & ujednání N/A Chyby a upozornění z tohoto relé + Aktivní odběry + Čekající události k odeslání + REQ odběry (%1$d) + COUNT odběry (%1$d) + Události k odeslání (%1$d) + Pro tento relay nejsou žádné aktivní odběry + %1$d autorů + %1$d ID + od %1$s + do %1$s + limit %1$d Délka zprávy Předplatné Filtry @@ -740,9 +757,14 @@ Tato komunita nemá popis. Promluvte si s majitelem, aby ho přidal/a. Citlivý obsah Před zobrazením tohoto obsahu přidá upozornění na citlivý obsah. + Nastavení rozhraní Nastavení aplikace Uživatelské nastavení + Překlady + Reakce Nastavení + Nastavení účtu + Nastavení aplikace Vždy Pouze Wi-Fi Neomezená WiFi @@ -772,6 +794,9 @@ Spamovací uživatelé Ztlumené. Klikněte pro odztlumení Zvuk zapnutý. Klikněte pro ztlumení + Přeskočit zpět o %d sekund + Přeskočit vpřed o %d sekund + Obraz v obraze Hledat lokální a vzdálené záznamy Adresa Nostr byla ověřena Ověření adresy Nostr se nezdařilo @@ -1003,6 +1028,7 @@ Upozornění Globální Krátké + Šachy Bezpečnostní filtry Nový příspěvek Nové Shorts: obrázky nebo videa @@ -1087,6 +1113,9 @@ Důvěryhodné Relé Důvěryhodné přenašeče Přenašeče, kterým důvěřujete a nepotřebujete Tor + Oblíbené feedy relayí + Oblíbené feedy relayí + Relaye, které často navštěvujete pro zobrazení jejich globálního feedu Proxy Relé Proxy přenašeče Agregační přenašeče, které aplikace používá ke stahování vašich zdrojů, jako např. filter.nostr.wine @@ -1132,6 +1161,7 @@ Připínáček Skenovat QR kód Přejít na poskytovatele peněženky třetí strany Alby + Vyberte datum Není možné odpovědět na koncept Není možné citovat koncept Není možné reagovat na koncept @@ -1237,4 +1267,110 @@ 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 + Jít zpět + Schválit + Připojeno + Sociální důkaz + Odeslat + Restartovat + Přijmout + Odmítnout + Nová hra + Nová šachová hra + Načítání hry… + Hra nenalezena + Tato hra mohla skončit nebo čeká na soupeře. + ID hry: %1$s… + a %1$d dalších + Odebrat %1$s + + Outbox relaye + Aplikace + Doporučení aplikací + Uživatelská nastavení + Audio hlavička + Audio stopa + Ocenění odznaků + Definice odznaků + Profilové odznaky + Blokované relaye + Blossom servery + Blossom ověření + Broadcast relaye + Seznam záložek + Denní schůzka + Kalendář + Schůzka + RSVP na schůzku + Šachové hry + Šachové ověření + Oblíbené relaye + Šachové výzvy + Přijetí šachové hry + Šachový tah + Konec šachové hry + Nabídka remízy + Definice kanálu + Skrýt zprávu kanálu + Seznam kanálů + Zpráva kanálu + Metadata kanálu + Ztlumit uživatele v kanálu + Soubor DM + Zpráva DM + DM relaye + Inzeráty + Komentáře + Definice komunity + Seznam komunit + Příspěvek komunity + Seznam sledovaných + Smazání + Koncepty + Balíčky emoji + Seznam balíčků emoji + Dočasný chat + Dočasné chatovací místnosti + Hlavičky souborů + Galerie profilu + Souborové servery + Blob data + Blob hlavičky + Lékařská data + Balíčky sledování + Reposty (16) + Sledování geohashe + Objevování relayí + Oznámení monitoru relayí diff --git a/amethyst/src/main/res/values-cs/strings.xml b/amethyst/src/main/res/values-cs/strings.xml index fc59482114..b3e6b35dc8 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-cy-rGB/strings.xml b/amethyst/src/main/res/values-cy-rGB/strings.xml index 66a570486b..57843dc08c 100644 --- a/amethyst/src/main/res/values-cy-rGB/strings.xml +++ b/amethyst/src/main/res/values-cy-rGB/strings.xml @@ -1,2 +1,4 @@ - + + + diff --git a/amethyst/src/main/res/values-da-rDK/strings.xml b/amethyst/src/main/res/values-da-rDK/strings.xml index 66a570486b..57843dc08c 100644 --- a/amethyst/src/main/res/values-da-rDK/strings.xml +++ b/amethyst/src/main/res/values-da-rDK/strings.xml @@ -1,2 +1,4 @@ - + + + diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 77c479819f..38d7efd0fd 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 @@ -444,6 +447,7 @@ anz der Bedingungen ist erforderlich Zap-Betrag Es ist nur eine Stimme pro Benutzer für diesen Umfragetyp erlaubt "Veranstaltung suchen %1$s" + Zap senden Füge eine öffentliche Nachricht hinzu Füge eine private Nachricht hinzu Füge eine Rechnungsnachricht hinzu @@ -480,6 +484,7 @@ anz der Bedingungen ist erforderlich Legen Sie Ihre bevorzugten Medienupload Server fest. Du hast keine NIP-96-Server gesetzt. Du kannst die Amethyst-Liste verwenden oder unten einen hinzufügen ↓ Du hast keine Blossom Server gesetzt. Du kannst die Amethyst-Liste verwenden oder unten einen hinzufügen ↓ + Empfohlene Medienserver Integrierte Medienserver Amethysts Standardliste. Sie können sie einzeln hinzufügen oder die Liste hinzufügen. Standardliste verwenden @@ -511,6 +516,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 +602,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 @@ -686,6 +692,17 @@ anz der Bedingungen ist erforderlich Allgemeine Geschäftsbedingungen N/A Fehler und Hinweise von diesem Relais + Aktive Abonnements + Ausstehende Outbox-Ereignisse + REQ-Abonnements (%1$d) + COUNT-Abonnements (%1$d) + Outbox-Ereignisse (%1$d) + Keine aktiven Abonnements für dieses Relay + %1$d Autoren + %1$d IDs + seit %1$s + bis %1$s + Limit %1$d Nachrichtenlänge Abonnements Filter @@ -745,9 +762,14 @@ anz der Bedingungen ist erforderlich Diese Community hat keine Beschreibung. Sprechen Sie mit dem Eigentümer, um eine hinzuzufügen. Sensibler Inhalt Fügt eine Warnung für sensiblen Inhalt hinzu, bevor dieser Inhalt angezeigt wird. + UI-Einstellungen App Einstellungen Benutzereinstellungen + Übersetzungen + Reaktionen Einstellungen + Kontoeinstellungen + App-Einstellungen Immer Nur WLAN Unbegrenztes WLAN @@ -777,6 +799,9 @@ anz der Bedingungen ist erforderlich Spammer Stummgeschaltet. Drücken, um Ton einzuschalten Ton ein. Zur Stummschaltung drücken + Springe %d Sekunden zurück + Springe %d Sekunden vor + Bild-in-Bild Lokale und entfernte Einträge durchsuchen Nostr-Adresse verifiziert Nostr-Adresse konnte nicht verifiziert werden @@ -1008,6 +1033,7 @@ anz der Bedingungen ist erforderlich Benachrichtigungen Global Kurzfilme + Schach Sicherheitsfilter Neuer Beitrag Neue Kurzfilme: Bilder oder Videos @@ -1092,6 +1118,9 @@ anz der Bedingungen ist erforderlich Vertrauenswürdige Relays Vertrauenswürdige Relays Relays, denen du vertraust und für die keine Tor-Verbindung erforderlich ist + Favorisierte Relay-Feeds + Favorisierte Relay-Feeds + Relays, die du häufig besuchst, um ihren globalen Feed zu sehen Proxy-Relays Proxy-Relays Aggregator-Relays wie filter.nostr.wine, von denen die App deine Feeds herunterlädt @@ -1137,6 +1166,7 @@ anz der Bedingungen ist erforderlich Reißzwecke QR-Code scannen Navigieren Sie zum Drittanbieter-Wallet-Anbieter Alby + Datum auswählen Es ist nicht möglich, auf einen Entwurf zu antworten Es ist nicht möglich, einen Entwurf zu zitieren Es ist nicht möglich, auf einen Entwurf zu reagieren @@ -1242,4 +1272,108 @@ 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 + Zurückgehen + Genehmigen + Verbunden + Sozialer Nachweis + Absenden + Neu starten + Annehmen + Ablehnen + Neues Spiel + Neues Schachspiel + Spiel wird geladen… + Spiel nicht gefunden + Dieses Spiel ist möglicherweise beendet oder wartet auf einen Gegner. + Spiel-ID: %1$s… + und %1$d weitere + %1$s entfernen + + Outbox-Relays + App-Empfehlungen + Benutzereinstellungen + Audio-Header + Audiospur + Abzeichen-Auszeichnungen + Abzeichen-Definitionen + Profil-Abzeichen + Blockierte Relays + Blossom-Server + Blossom-Authentifizierung + Broadcast-Relays + Lesezeichenliste + Tagestermin + Kalender + Termin + RSVP für Termin + Schachspiele + Schach-Authentifizierung + Bevorzugte Relays + Schach-Herausforderungen + Schachspiel annehmen + Schachzug + Ende des Schachspiels + Remisangebot + Kanaldefinition + Kanalnachricht ausblenden + Kanalliste + Kanalnachricht + Kanal-Metadaten + Benutzer im Kanal stummschalten + DM-Datei + DM-Nachricht + DM-Relays + Kleinanzeigen + Kommentare + Community-Definition + Community-Liste + Community-Beitrag + Folgeliste + Löschungen + Entwürfe + Emoji-Pakete + Emoji-Paketliste + Ephemerer Chat + Ephemere Chaträume + Datei-Header + Profilgalerie + Dateiserver + Blob-Daten + Blob-Header + Medizinische Daten + Follow-Pakete + Geohash-Follows + Relay-Entdeckung + Relay-Monitor-Ankündigung diff --git a/amethyst/src/main/res/values-de/strings.xml b/amethyst/src/main/res/values-de/strings.xml index fd7c5ffa82..7e962d5e02 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-el-rGR/strings.xml b/amethyst/src/main/res/values-el-rGR/strings.xml index 9b6fa49fc7..c4e013be18 100644 --- a/amethyst/src/main/res/values-el-rGR/strings.xml +++ b/amethyst/src/main/res/values-el-rGR/strings.xml @@ -514,4 +514,5 @@ Για να λαμβάνετε ειδοποιήσεις Push, εγκαταστήστε οποιαδήποτε εφαρμογή τις υποστηρίζει [Unified Push](https://unifiedpush.org/), όπως πχ το [Ntfy](https://ntfy.sh/). Μετά την εγκατάσταση, επιλέξτε την εφαρμογή που θέλετε να χρησιμοποιήσετε από τις Ρυθμίσεις. + diff --git a/amethyst/src/main/res/values-en-rGB/strings.xml b/amethyst/src/main/res/values-en-rGB/strings.xml index 2e780a1344..29171ea5f1 100644 --- a/amethyst/src/main/res/values-en-rGB/strings.xml +++ b/amethyst/src/main/res/values-en-rGB/strings.xml @@ -7,4 +7,5 @@ Scan QR Show Anyway 👀 + diff --git a/amethyst/src/main/res/values-eo-rUY/strings.xml b/amethyst/src/main/res/values-eo-rUY/strings.xml index 3fce4d2058..69a3a6a64d 100644 --- a/amethyst/src/main/res/values-eo-rUY/strings.xml +++ b/amethyst/src/main/res/values-eo-rUY/strings.xml @@ -442,4 +442,5 @@ Nekapable sendi zapon Mesaĝi la Uzanto Okej + diff --git a/amethyst/src/main/res/values-es-rES/strings.xml b/amethyst/src/main/res/values-es-rES/strings.xml index 205e5287f0..5ef8dfde6b 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 @@ -1063,4 +1063,5 @@ Enviarlo Este mensaje desaparecerá en %1$d días Seleccionar firmante + diff --git a/amethyst/src/main/res/values-es-rMX/strings.xml b/amethyst/src/main/res/values-es-rMX/strings.xml index 64f8bcab9d..1639273c36 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 @@ -1039,4 +1039,5 @@ No traducir del Los idiomas que se muestran aquí no se traducirán. Selecciona un idioma para eliminarlo y traducirlo de nuevo. Seleccionar firmante + diff --git a/amethyst/src/main/res/values-es-rUS/strings.xml b/amethyst/src/main/res/values-es-rUS/strings.xml index bb790d8d7f..ce5ac0327a 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 @@ -1039,4 +1039,5 @@ No traducir del Los idiomas que se muestran aquí no se traducirán. Selecciona un idioma para eliminarlo y traducirlo de nuevo. Seleccionar firmante + diff --git a/amethyst/src/main/res/values-fa-rIR/strings.xml b/amethyst/src/main/res/values-fa-rIR/strings.xml index 7ee325eb60..ca89c3a9e7 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 خاموش @@ -947,4 +947,5 @@ لیستی را برای فیلتر خبرنامه انتخاب کنید با قفل کردن دستگاه از حساب کاربری خارج شو پیام خصوصی + diff --git a/amethyst/src/main/res/values-fa/strings.xml b/amethyst/src/main/res/values-fa/strings.xml index 0c2e5174b0..ac4b7bee32 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-fi-rFI/strings.xml b/amethyst/src/main/res/values-fi-rFI/strings.xml index 72ed458278..a8aaca7bd9 100644 --- a/amethyst/src/main/res/values-fi-rFI/strings.xml +++ b/amethyst/src/main/res/values-fi-rFI/strings.xml @@ -569,4 +569,5 @@ Palvelin ei antanut URL-osoitetta latauksen jälkeen Mediaa ei voitu ladata palvelimelta Paikallista tiedostoa ei voitu valmistella ladattavaksi: %1$s + diff --git a/amethyst/src/main/res/values-fr-rCA/strings.xml b/amethyst/src/main/res/values-fr-rCA/strings.xml index 66a570486b..57843dc08c 100644 --- a/amethyst/src/main/res/values-fr-rCA/strings.xml +++ b/amethyst/src/main/res/values-fr-rCA/strings.xml @@ -1,2 +1,4 @@ - + + + diff --git a/amethyst/src/main/res/values-fr-rFR/strings.xml b/amethyst/src/main/res/values-fr-rFR/strings.xml index 5ca190f7b5..e757476397 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,12 @@ 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 bb35fd681d..2272f85208 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-gu-rIN/strings.xml b/amethyst/src/main/res/values-gu-rIN/strings.xml index 66a570486b..57843dc08c 100644 --- a/amethyst/src/main/res/values-gu-rIN/strings.xml +++ b/amethyst/src/main/res/values-gu-rIN/strings.xml @@ -1,2 +1,4 @@ - + + + diff --git a/amethyst/src/main/res/values-hi-rIN/strings.xml b/amethyst/src/main/res/values-hi-rIN/strings.xml index e2fd5d7b5e..27c0d27480 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,36 @@ सूची मिटाएँ पोटली मिटाएँ प्रकार + पुनःप्रयास असफल + विवरण देखने के लिए दबाएँ + %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-hr-rHR/strings.xml b/amethyst/src/main/res/values-hr-rHR/strings.xml index 66a570486b..57843dc08c 100644 --- a/amethyst/src/main/res/values-hr-rHR/strings.xml +++ b/amethyst/src/main/res/values-hr-rHR/strings.xml @@ -1,2 +1,4 @@ - + + + diff --git a/amethyst/src/main/res/values-hu-rHU/strings.xml b/amethyst/src/main/res/values-hu-rHU/strings.xml index 5c4b548ec2..3aeac7fdf6 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,36 @@ 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 039cf9427c..3248c74f87 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-in-rID/strings.xml b/amethyst/src/main/res/values-in-rID/strings.xml index 422033638a..735d180cbe 100644 --- a/amethyst/src/main/res/values-in-rID/strings.xml +++ b/amethyst/src/main/res/values-in-rID/strings.xml @@ -511,4 +511,5 @@ Seharusnya %3$s Untuk menerima Pemberitahuan Push, Install aplikasi yang mendukung [Unified Push](https://unifiedpush.org/), seperti [Ntfy](https://ntfy.sh/). Setelah diinstall, Pilih aplikasi yang ingin digunakan di pengaturan. + diff --git a/amethyst/src/main/res/values-it-rIT/strings.xml b/amethyst/src/main/res/values-it-rIT/strings.xml index b79e7df3ce..c84df0a1ac 100644 --- a/amethyst/src/main/res/values-it-rIT/strings.xml +++ b/amethyst/src/main/res/values-it-rIT/strings.xml @@ -432,4 +432,5 @@ Chat Globale Cerca + diff --git a/amethyst/src/main/res/values-iw-rIL/strings.xml b/amethyst/src/main/res/values-iw-rIL/strings.xml index 66a570486b..57843dc08c 100644 --- a/amethyst/src/main/res/values-iw-rIL/strings.xml +++ b/amethyst/src/main/res/values-iw-rIL/strings.xml @@ -1,2 +1,4 @@ - + + + diff --git a/amethyst/src/main/res/values-ja-rJP/strings.xml b/amethyst/src/main/res/values-ja-rJP/strings.xml index 21a5107d2c..a8b78fd9ae 100644 --- a/amethyst/src/main/res/values-ja-rJP/strings.xml +++ b/amethyst/src/main/res/values-ja-rJP/strings.xml @@ -389,4 +389,5 @@ 自動的に動画を再生 自動的にURLプレビューを表示 画像読み込み + diff --git a/amethyst/src/main/res/values-kk-rKZ/strings.xml b/amethyst/src/main/res/values-kk-rKZ/strings.xml index 66a570486b..57843dc08c 100644 --- a/amethyst/src/main/res/values-kk-rKZ/strings.xml +++ b/amethyst/src/main/res/values-kk-rKZ/strings.xml @@ -1,2 +1,4 @@ - + + + diff --git a/amethyst/src/main/res/values-ko-rKR/strings.xml b/amethyst/src/main/res/values-ko-rKR/strings.xml index 66a570486b..57843dc08c 100644 --- a/amethyst/src/main/res/values-ko-rKR/strings.xml +++ b/amethyst/src/main/res/values-ko-rKR/strings.xml @@ -1,2 +1,4 @@ - + + + diff --git a/amethyst/src/main/res/values-ks-rIN/strings.xml b/amethyst/src/main/res/values-ks-rIN/strings.xml index 66a570486b..57843dc08c 100644 --- a/amethyst/src/main/res/values-ks-rIN/strings.xml +++ b/amethyst/src/main/res/values-ks-rIN/strings.xml @@ -1,2 +1,4 @@ - + + + diff --git a/amethyst/src/main/res/values-lt-rLT/strings.xml b/amethyst/src/main/res/values-lt-rLT/strings.xml index 66a570486b..57843dc08c 100644 --- a/amethyst/src/main/res/values-lt-rLT/strings.xml +++ b/amethyst/src/main/res/values-lt-rLT/strings.xml @@ -1,2 +1,4 @@ - + + + diff --git a/amethyst/src/main/res/values-lv-rLV/strings.xml b/amethyst/src/main/res/values-lv-rLV/strings.xml index a3cb39fd4b..e35584eb3b 100644 --- a/amethyst/src/main/res/values-lv-rLV/strings.xml +++ b/amethyst/src/main/res/values-lv-rLV/strings.xml @@ -171,4 +171,5 @@ Torrenta datne Lejupielādēt Nav uzstādītas torrent lietotnes, kas atvērtu un lejupielādētu datni. + diff --git a/amethyst/src/main/res/values-ne-rNP/strings.xml b/amethyst/src/main/res/values-ne-rNP/strings.xml index 66a570486b..57843dc08c 100644 --- a/amethyst/src/main/res/values-ne-rNP/strings.xml +++ b/amethyst/src/main/res/values-ne-rNP/strings.xml @@ -1,2 +1,4 @@ - + + + diff --git a/amethyst/src/main/res/values-nl-rNL/strings.xml b/amethyst/src/main/res/values-nl-rNL/strings.xml index 0d30b2b235..d79ecae4fb 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 @@ -1144,4 +1144,5 @@ Uitzendpakket Verwijder lijst Verwijder pakket + diff --git a/amethyst/src/main/res/values-nl/strings.xml b/amethyst/src/main/res/values-nl/strings.xml index e68d336353..d33b02be1e 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-pcm-rNG/strings.xml b/amethyst/src/main/res/values-pcm-rNG/strings.xml index 66a570486b..57843dc08c 100644 --- a/amethyst/src/main/res/values-pcm-rNG/strings.xml +++ b/amethyst/src/main/res/values-pcm-rNG/strings.xml @@ -1,2 +1,4 @@ - + + + diff --git a/amethyst/src/main/res/values-pl-rPL/strings.xml b/amethyst/src/main/res/values-pl-rPL/strings.xml index 71dbb02d93..00f40801c6 100644 --- a/amethyst/src/main/res/values-pl-rPL/strings.xml +++ b/amethyst/src/main/res/values-pl-rPL/strings.xml @@ -369,7 +369,7 @@ Ręczny podział zapów Zakładki Domyślne zakładki - Twoje domyślne zakładki obsługiwane przez wielu klientów + Twoje domyślne zakładki obsługiwane przez większość klientów Projekty Prywatne Zakładki Publiczne zakładki @@ -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ć @@ -437,6 +440,7 @@ Kwota Zap Tylko jeden głos na użytkownika jest dozwolony dla tego typu ankiety "Szukam wydarzenia %1$s" + Wyślij Zapa Dodaj wiadomość publiczną Dodaj prywatną wiadomość Dodaj wiadomość do faktury @@ -473,6 +477,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 +509,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 +598,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ł. @@ -682,6 +688,7 @@ Zasady i warunki użytkowania Nie dotyczy Błędy i powiadomienia z tego transmitera + Brak aktywnych subskrypcji dla tego transmitera Długość wiadomości Subskrybcje Filtry @@ -741,9 +748,14 @@ Ta społeczność nie ma opisu. Zwróć się do właściciela, aby go dodać Treść wrażliwa Dodaje ostrzeżenie o wrażliwej treści przed wyświetleniem tej zawartości + Ustawienia interfejsu Ustawienia Aplikacji Preferencje użytkownika + Tłumaczenia + Reakcje Ustawienia + Ustawienia konta + Ustawienia aplikacji Zawsze Tylko WiFi Nielimitowany Wi-Fi @@ -773,6 +785,7 @@ Spamerzy Wyciszone. Kliknij, aby włączyć dźwięk Dźwięk włączony. Kliknij, aby wyciszyć + Obraz w Obrazie Wyszukiwanie lokalne i zdalne Adres Nostr został zweryfikowany Nieudana weryfikacja adresu Nostr @@ -1005,6 +1018,7 @@ Powiadomienia Wszystkie Filmiki + Szachy Filtry bezpieczeństwa Nowy post Nowe Króciaki: zdjęcia lub filmiki @@ -1089,6 +1103,8 @@ Zaufane Transmitery Zaufane Transmitery Transmitery, którym ufasz, nie potrzebujesz połączenia Tor + Ulubione kanały transmitera + Ulubione kanały transmitera Transmitery Proxy Transmitery Proxy Transmitery agregatora, z których aplikacja musi pobierać kanały, takie jak filter.nostr.wine. To zastępuje model skrzynki nadawczej i sprawia, że aplikacja łączy się tylko z transmiterami z Twojej listy. @@ -1134,6 +1150,7 @@ Pinezka Zeskanuj QR kod Przejdź do Alby zewnętrznego dostawcy portfela + Wybierz datę Nie jest możliwa odpowiedź na szkic wpisu Nie jest możliwe zacytowanie szkicu wpisu Nie jest możliwe reagowanie na szkic wpisu @@ -1239,4 +1256,93 @@ 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\" + Wstecz + Zatwierdź + Połączony + Potwierdzenie socjalne + Wyślij + Zrestartuj + Akceptuj + Odrzuć + Nowa gra + Gra nie znaleziona + + Aplikacje + Ustawienia użytkownika + Ścieżka audio + Zablokowane Transmitery + Serwery Blossom + Lista zakładek + Kalendarz + Ulubione Transmitery + Ogłoszenia + Wersje robocze + Pakiety emoji + Lista Pakietów Emoji + Serwery Plików + Zapy + Prywatne Zapy + Blogi + Pokój spotkań + Strefa spotkań + Profil + Lista zablokowanych + Listy osób + Zdjęcia + Pineski + Sondaż + Odpowiedź w sondażu + Transmitery Prywatne + Transmitery Proxy + Wiadomość publiczna + Reakcje + Wizytówka + Autoryzacja transmitera + Wyszukiwanie transmiterów + Zestaw transmiterów + Zgłoszenia + Powtórzenia + Usuń użytkownika + Szukaj Transmiterów + Status użytkownika + Wpisy + Edytowane + Torrenty + Komentarze torrentów + Zaufane Transmitery + Zaufani dostawcy + Filmiki + Odpowiedź głosowa + Wiki diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index 09aa6def80..7974b4b694 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 @@ -438,6 +441,7 @@ Valor do Zap Apenas um voto por usuário é permitido neste tipo de enquete "Procurando o evento %1$s" + Enviar Zap Adicionar uma mensagem pública Adicionar uma mensagem privada Adicionar uma mensagem @@ -474,6 +478,7 @@ Defina seus servidores de upload de mídia preferidos. Você não tem nenhum servidor NIP-96. Você pode usar a lista de Amethyst, ou adicionar um abaixo ↓ Você não tem nenhum servidor de Blossom configurado. Você pode usar a lista de Amethyst, ou adicionar um abaixo ↓ + Servidores de Mídia Recomendados Servidores de Mídia Integrados Lista padrão do Amethyst. Você pode adicioná-los individualmente ou adicionar a lista. Usar Lista Padrão @@ -505,6 +510,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 +597,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 @@ -681,6 +687,17 @@ Termos & condições N/A Erros e Avisos deste Relé + Assinaturas Ativas + Eventos Pendentes na Outbox + Assinaturas REQ (%1$d) + Assinaturas COUNT (%1$d) + Eventos da Outbox (%1$d) + Nenhuma assinatura ativa para este relay + %1$d autores + %1$d IDs + desde %1$s + até %1$s + limite %1$d Tamanho da mensagem Assinaturas Filtros @@ -740,9 +757,14 @@ Esta comunidade não tem uma descrição. Fale com o proprietário para adicionar Conteúdo sensível Adiciona aviso de conteúdo sensível antes de mostrar este conteúdo + Preferências da Interface Preferências do aplicativo Preferências do Usuário + Traduções + Reações Configurações + Configurações da Conta + Configurações do App Sempre Somente wifi WiFi não medida @@ -772,6 +794,8 @@ Spammers Silenciado. Clique para ativar o som Som ligado. Clique para silenciar + Voltar %d segundos + Avançar %d segundos Pesquise registros locais e remotos O endereço Nostr foi verificado Endereço Nostr falhou na verificação @@ -1003,6 +1027,7 @@ Notificações Global Vídeos Curtos + Xadrez Filtros de Segurança Novo Post Novos Vídeos Curtos: imagens ou vídeos @@ -1087,6 +1112,9 @@ Relays confiáveis Relays confiáveis Relays em que você confia e que não precisam de conexão via Tor + Feeds de Relays Favoritos + Feeds de Relays Favoritos + Relays que você visita com frequência para ver o feed Global deles Relays proxy Relays proxy Relays agregadores que o app usa para baixar seus feeds, como filter.nostr.wine. Isso substitui o modelo outbox e faz o app se conectar apenas aos relays das suas listas. @@ -1132,6 +1160,7 @@ Piones Escanear código QR Navegar para o provedor de carteira de terceiros Alby + Selecionar data Não é possível responder uma nota em rascunho Não é possível citar uma nota em rascunho Não é possível reagir uma nota em rascunho @@ -1237,4 +1266,108 @@ 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 + Voltar + Aprovar + Conectado + Prova social + Enviar + Reiniciar + Aceitar + Recusar + Novo Jogo + Novo Jogo de Xadrez + Carregando jogo… + Jogo não encontrado + Este jogo pode ter terminado ou está aguardando um oponente. + ID do Jogo: %1$s… + e mais %1$d + Remover %1$s + + Relays de Saída + Recomendações de Apps + Configurações do Usuário + Cabeçalho de Áudio + Faixa de Áudio + Concessões de Emblemas + Definições de Emblemas + Emblemas do Perfil + Relays Bloqueados + Servidores Blossom + Autenticação Blossom + Relays de Broadcast + Lista de Favoritos + Compromisso do Dia + Calendário + Compromisso + RSVP do Compromisso + Jogos de Xadrez + Autenticação de Xadrez + Relays Favoritos + Desafios de Xadrez + Aceitar Jogo de Xadrez + Movimento de Xadrez + Fim do Jogo de Xadrez + Oferta de Empate + Definição de Canal + Ocultar Mensagem do Canal + Lista de Canais + Mensagem do Canal + Metadados do Canal + Silenciar Usuário no Canal + Arquivo DM + Mensagem DM + Relays de DM + Classificados + Comentários + Definição de Comunidade + Lista de Comunidades + Post da Comunidade + Lista de Seguindo + Exclusões + Rascunhos + Pacotes de Emoji + Lista de Pacotes de Emoji + Chat Efêmero + Salas de Chat Efêmeras + Cabeçalhos de Arquivo + Galeria do Perfil + Servidores de Arquivo + Dados Blob + Cabeçalhos Blob + Dados Médicos + Pacotes de Seguir + Seguindo Geohash + Descoberta de Relays + Anúncio de Monitor de Relay diff --git a/amethyst/src/main/res/values-pt-rPT/strings.xml b/amethyst/src/main/res/values-pt-rPT/strings.xml index 66a570486b..57843dc08c 100644 --- a/amethyst/src/main/res/values-pt-rPT/strings.xml +++ b/amethyst/src/main/res/values-pt-rPT/strings.xml @@ -1,2 +1,4 @@ - + + + diff --git a/amethyst/src/main/res/values-ru-rRU/strings.xml b/amethyst/src/main/res/values-ru-rRU/strings.xml index 2c8f565159..e461d5d2a0 100644 --- a/amethyst/src/main/res/values-ru-rRU/strings.xml +++ b/amethyst/src/main/res/values-ru-rRU/strings.xml @@ -414,4 +414,5 @@ Еда Разное Другое + diff --git a/amethyst/src/main/res/values-ru-rUA/strings.xml b/amethyst/src/main/res/values-ru-rUA/strings.xml index 66a570486b..57843dc08c 100644 --- a/amethyst/src/main/res/values-ru-rUA/strings.xml +++ b/amethyst/src/main/res/values-ru-rUA/strings.xml @@ -1,2 +1,4 @@ - + + + diff --git a/amethyst/src/main/res/values-sa-rIN/strings.xml b/amethyst/src/main/res/values-sa-rIN/strings.xml index 66a570486b..57843dc08c 100644 --- a/amethyst/src/main/res/values-sa-rIN/strings.xml +++ b/amethyst/src/main/res/values-sa-rIN/strings.xml @@ -1,2 +1,4 @@ - + + + diff --git a/amethyst/src/main/res/values-sl-rSI/strings.xml b/amethyst/src/main/res/values-sl-rSI/strings.xml index d72e872f76..e4e1ee3bf7 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 @@ -696,6 +701,17 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Določila & Pogoji N/A Napake in obvestila s tega releja + Aktivne naročnine + Izhodni dogodki v teku + REQ Naročnine (%1$d) + COUNT Naročnine (%1$d) + Izhodni dogodki (%1$d) + Ni aktivne naročnine za ta rele + %1$d avtorjev + %1$d ids + od %1$s + do %1$s + limit %1$d Dolžina sporočila Naročnine Filtri @@ -755,9 +771,14 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Ta skupnost nima opisa. Obrnite se na lastnika, da ga doda Občutljiva vsebina Doda opozorilo o občutljivi vsebini pred prikazom te vsebine + UI Možnosti Nastavitve aplikacije Nastavitve uporabnika + Prevodi + Reakcije Nastavitve + Nastavitve računa + Nastavitve aplikacije Vedno Samo Wifi Samo z WiFi @@ -1019,19 +1040,20 @@ 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 Novo zapisek skupnosti Nov produkt Nova geo-ekskluzivna objava - Odpri vse odzive na to objavo - Zapri vse odzive na to objavo + Odpri vse reakcije na to objavo + Zapri vse reakcije na to objavo Odgovori Posreduj ali citiraj Všečkaj Zap - Spremeni hitre odzivne ikone + Spremeni hitre reakcije Slika profila %1$s Rele %1$s Razširi seznam relejev @@ -1104,6 +1126,9 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Zaupanja vredni releji Zaupanja vredni releji Releji katerim zaupaš in zanje ne rabiš Tor povezave + Najljubše vsebine releja + Najljubše vsebine releja + Releji katerih pogosto spremljaš globalne vsebine Proxy releji Proxy releji Agregatorji, ki jih mora aplikacija uporabljati za prenos vaših vsebin, na primer filter.nostr.wine. To nadomesti model odhodne pošte (outbox model) in bo prisililo aplikacijo k povezovanju prek relejev iz vašega seznama. @@ -1149,6 +1174,7 @@ Za podpisovanje se je potrebno prijaviti s privatnim ključem Pushpin Skeniraj QR kodo Pojdite na stran tretjega ponudnika denarnice Alby + Izberi datum Ni mogoče odgovoriti na osnutek zapiska Ni mogoče citirati osnutka zapiska Ni mogoče reagirati na osnutek zapiska @@ -1214,6 +1240,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 +1280,52 @@ 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 + Nazaj + Dovoli + Povezano + Družbeno dokazilo + Oddaj + Ponovno zaženi + Sprejmi + Zavrni + Nova igra + Nova partija šaha + Nalagam partijo\u2026 + Partija ni najdena + Ta partija je zaključena ali pa, čaka na nasprotnika + ID Partije: %1$s\u2026 + in %1$d ostalih + Odstrani %1$s + diff --git a/amethyst/src/main/res/values-sr-rSP/strings.xml b/amethyst/src/main/res/values-sr-rSP/strings.xml index 25197aa216..0f3a3ed066 100644 --- a/amethyst/src/main/res/values-sr-rSP/strings.xml +++ b/amethyst/src/main/res/values-sr-rSP/strings.xml @@ -33,4 +33,5 @@ Користите јавни кључ и јавни кључеви су само за читање. Пријавите се са приватним кључем да бисте могли да појачате постове Користите јавни кључ и јавни кључеви су само за читање. Пријавите се са приватним кључем да бисте лајковали постове Нема подешавања износа Зап. Дуго притисните да бисте променили + diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index e8be7b477c..81772fedcf 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 @@ -438,6 +441,7 @@ Zap belopp Endast en röst per användare tillåts i denna typ av omröstning "Letar efter Event %1$s" + Skicka Zap Lägg till ett offentligt meddelande Lägg till ett privat meddelande Lägg till ett fakturameddelande @@ -474,6 +478,7 @@ Ställ in dina önskade mediauppladdningsservrar. Du har inga NIP-96 servrar. Du kan använda Amethyst\'s lista, eller lägga till en nedan ↓ Du har inga Blossom servrar satta. Du kan använda Amethyst\'s lista, eller lägga till en nedan ↓ + Rekommenderade Mediaservrar Inbyggda mediaservrar Amethysts standardlista. Du kan lägga till dem individuellt eller lägga till listan. Använd standardlistan @@ -505,6 +510,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 +596,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 @@ -680,6 +686,17 @@ Villkor & bestämmelser N/A Fel och meddelanden från detta relä + Aktiva prenumerationer + Väntande utgående händelser + REQ-prenumerationer (%1$d) + COUNT-prenumerationer (%1$d) + Utgående händelser (%1$d) + Inga aktiva prenumerationer för detta relä + %1$d författare + %1$d ID:n + sedan %1$s + till %1$s + gräns %1$d Meddelandelängd Prenumerationer Filter @@ -739,9 +756,14 @@ Denna gemenskap har ingen beskrivning. Prata med ägaren för att lägga till en. Känsligt innehåll Lägger till varning för känsligt innehåll innan detta innehåll visas. + Gränssnittsinställningar Appinställningar Användarinställningar + Översättningar + Reaktioner Inställningar + Kontoinställningar + Appinställningar Alltid Endast Wi-Fi Obegränsad WiFi @@ -771,6 +793,9 @@ Spammare Ljud avstängt. Klicka för att ta bort ljudlöst Ljud på. Klicka för att stänga av ljudet + Hoppa tillbaka %d sekunder + Hoppa framåt %d sekunder + Bild-i-bild Sök lokala och externa poster Nostr-adress verifierad Verifikation av Nostr-adress misslyckades @@ -1002,6 +1027,7 @@ Aviseringar Globalt Kortfilmer + Schack Säkerhetsfilter Nytt inlägg Nya kort: bilder eller videor @@ -1086,6 +1112,9 @@ Betrodda reläer Betrodda reläer Reläer du litar på och inte kräver Tor för + Favorit-reläflöden + Favorit-reläflöden + Reläer som du ofta besöker för att se deras globala flöde Proxyreläer Proxyreläer Aggregeringsreläer som används för att hämta flöden, t.ex. filter.nostr.wine @@ -1131,6 +1160,7 @@ Häftstift Skanna QR-kod Navigera till tredjeparts plånboksleverantören Alby + Välj datum Det går inte att svara på ett utkast Det går inte att citera ett utkast Det går inte att reagera på ett utkast @@ -1236,4 +1266,109 @@ 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 + Gå tillbaka + Godkänn + Ansluten + Socialt bevis + Skicka + Starta om + Acceptera + Avböj + Nytt parti + Nytt schackparti + Laddar parti… + Parti hittades inte + Detta parti kan ha avslutats eller väntar på motståndare. + Parti-ID: %1$s… + och %1$d andra + Ta bort %1$s + + Outbox relayer + Appar + Apprekommendationer + Användarinställningar + Ljudhuvud + Ljudspår + Utmärkelser + Utmärkelsedefinitioner + Profilutmärkelser + Blockerade relayer + Blossom-servrar + Blossom-autentisering + Broadcast-relayer + Bokmärkeslista + Dagsmöte + Kalender + Möte + OSA för möte + Schackpartier + Schackautentisering + Favoritrelayer + Schackutmaningar + Acceptera schackparti + Schackdrag + Slut på schackparti + Remianbud + Kanaldefinition + Dölj kanalmeddelande + Kanallista + Kanalmeddelande + Kanalmetadata + Tysta användare i kanal + DM-fil + DM-meddelande + DM-relayer + Annonser + Kommentarer + Community-definition + Communitylista + Communityinlägg + Följerlista + Raderingar + Utkast + Emoji-paket + Emoji-paketlista + Tillfällig chatt + Tillfälliga chattrum + Filhuvuden + Profilgalleri + Filservrar + Blob-data + Blob-huvuden + Medicinska data + Följ-paket + Geohash-följer + Relay upptäckt + Relay övervakningsmeddelande diff --git a/amethyst/src/main/res/values-sw-rKE/strings.xml b/amethyst/src/main/res/values-sw-rKE/strings.xml index eaae02bd91..eede00f35d 100644 --- a/amethyst/src/main/res/values-sw-rKE/strings.xml +++ b/amethyst/src/main/res/values-sw-rKE/strings.xml @@ -443,4 +443,5 @@ Soga Ulimwenguni Tafuta + diff --git a/amethyst/src/main/res/values-sw-rTZ/strings.xml b/amethyst/src/main/res/values-sw-rTZ/strings.xml index 66a570486b..57843dc08c 100644 --- a/amethyst/src/main/res/values-sw-rTZ/strings.xml +++ b/amethyst/src/main/res/values-sw-rTZ/strings.xml @@ -1,2 +1,4 @@ - + + + diff --git a/amethyst/src/main/res/values-ta-rIN/strings.xml b/amethyst/src/main/res/values-ta-rIN/strings.xml index 5471010d5e..e79aecaf2c 100644 --- a/amethyst/src/main/res/values-ta-rIN/strings.xml +++ b/amethyst/src/main/res/values-ta-rIN/strings.xml @@ -369,4 +369,5 @@ அங்கீகரிக்கப்பட்ட குறிப்பு இந்த அரட்டைக் குழுவின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் இந்த சமூகத்தின் விளக்கமும் விதிகளும் இன்னும் சேர்க்கபடவில்லை. உரிமையாளரை அணுகி அவற்றை சேர்க்க கோரவும் + diff --git a/amethyst/src/main/res/values-th-rTH/strings.xml b/amethyst/src/main/res/values-th-rTH/strings.xml index e03e052818..337dec3eb9 100644 --- a/amethyst/src/main/res/values-th-rTH/strings.xml +++ b/amethyst/src/main/res/values-th-rTH/strings.xml @@ -814,4 +814,5 @@ ไม่สามารถเปิดไฟล์ได้ แลือกลิสต์เพื่อโชว์ ออกจากระบบ + diff --git a/amethyst/src/main/res/values-tr-rTR/strings.xml b/amethyst/src/main/res/values-tr-rTR/strings.xml index 35621aa3a6..7fde8f9e54 100644 --- a/amethyst/src/main/res/values-tr-rTR/strings.xml +++ b/amethyst/src/main/res/values-tr-rTR/strings.xml @@ -167,4 +167,5 @@ Tüm yenileri okundu olarak işaretle Tümünü okundu olarak işaretle Hata + diff --git a/amethyst/src/main/res/values-uk-rUA/strings.xml b/amethyst/src/main/res/values-uk-rUA/strings.xml index 3c028d4482..9e18b6bfcb 100644 --- a/amethyst/src/main/res/values-uk-rUA/strings.xml +++ b/amethyst/src/main/res/values-uk-rUA/strings.xml @@ -499,4 +499,5 @@ Сервер не задав URL після завантаження Не вдалося завантажити завантажені медіафайли з сервера Не вдалося підготувати локальний файл для завантаження: %1$s + diff --git a/amethyst/src/main/res/values-uz-rUZ/strings.xml b/amethyst/src/main/res/values-uz-rUZ/strings.xml index 7f89d981a2..ae64d7d1f1 100644 --- a/amethyst/src/main/res/values-uz-rUZ/strings.xml +++ b/amethyst/src/main/res/values-uz-rUZ/strings.xml @@ -68,4 +68,5 @@ " va " "bu kanalda: " Profil banneri + diff --git a/amethyst/src/main/res/values-vi-rVN/strings.xml b/amethyst/src/main/res/values-vi-rVN/strings.xml index d374dcabd9..5b4876f370 100644 --- a/amethyst/src/main/res/values-vi-rVN/strings.xml +++ b/amethyst/src/main/res/values-vi-rVN/strings.xml @@ -11,4 +11,5 @@ Thanh toán Lưu ý cho người nhận Cảm ơn rất nhiều! + diff --git a/amethyst/src/main/res/values-zh-rCN/strings.xml b/amethyst/src/main/res/values-zh-rCN/strings.xml index 23e488f30c..d90248bd8c 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,36 @@ 删除列表 删除包 类型 + 重试失败 + 轻按查看详情 + 已发送 %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-zh-rHK/strings.xml b/amethyst/src/main/res/values-zh-rHK/strings.xml index baa1d2ee16..9c99f2f57e 100644 --- a/amethyst/src/main/res/values-zh-rHK/strings.xml +++ b/amethyst/src/main/res/values-zh-rHK/strings.xml @@ -239,4 +239,5 @@ 錢包綁定中繼 聰數量 "“正在查找事件%1$s”" + diff --git a/amethyst/src/main/res/values-zh-rSG/strings.xml b/amethyst/src/main/res/values-zh-rSG/strings.xml index 66a570486b..57843dc08c 100644 --- a/amethyst/src/main/res/values-zh-rSG/strings.xml +++ b/amethyst/src/main/res/values-zh-rSG/strings.xml @@ -1,2 +1,4 @@ - + + + diff --git a/amethyst/src/main/res/values-zh-rTW/strings.xml b/amethyst/src/main/res/values-zh-rTW/strings.xml index dc10677446..913f33afd9 100644 --- a/amethyst/src/main/res/values-zh-rTW/strings.xml +++ b/amethyst/src/main/res/values-zh-rTW/strings.xml @@ -762,4 +762,5 @@ 付款請求已發送,正在等待來自你的錢包的確認 正在等待 DVM 確認付款或返回結果 添加 NIP-96 服務器 + diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 0f1b45f0a2..76146bd36d 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -123,7 +123,7 @@ Picture Url(Optional) Description Description not found - "About us.. " + "About us… " What\'s on your mind? Write a message… Post @@ -191,7 +191,7 @@ Anonymize Alters your voice pitch. Note: basic pitch changes can potentially be reversed by determined listeners. User does not have a lightning address set up to receive sats - "reply here.. " + "reply here… " Copies the Note ID to the clipboard for sharing in Nostr Copy Channel ID (Note) to the Clipboard Edits the Channel Metadata @@ -281,7 +281,7 @@ Public Chat Metadata Public chats are visible to everyone on Nostr and anyone can participate on them. They are great for open communities around specific topics. - Moderation can be controlled by deleting posts on the its relays + Moderation can be controlled by deleting posts on its relays Relays Insert between 1–3 relays that host this group. @@ -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 @@ -476,6 +479,7 @@ "Looking for Event %1$s" + Send Zap Add a public message Add a private message Add an invoice message @@ -532,6 +536,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 +575,7 @@ Follows via Proxy Around Me Global + Chess Mute List Follow Lists @@ -676,8 +682,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 @@ -787,6 +793,17 @@ Terms & Conditions N/A Errors and Notices from this Relay + Active Subscriptions + Pending Outbox Events + REQ Subscriptions (%1$d) + COUNT Subscriptions (%1$d) + Outbox Events (%1$d) + No active subscriptions for this relay + %1$d authors + %1$d ids + since %1$s + until %1$s + limit %1$d Message length Subscriptions Filters @@ -855,10 +872,15 @@ Sensitive Content Adds sensitive content warning before showing this content + UI Preferences App Preferences User Preferences + Translations + Reactions Settings + Account Settings + App Settings Always Wifi-only Unmetered WiFi @@ -896,6 +918,9 @@ Muted. Click to unmute Sound on. Click to mute + Skip back %d seconds + Skip forward %d seconds + Picture-in-Picture Search local and remote records Nostr address was verified @@ -1190,6 +1215,7 @@ Notifications Global Shorts + Chess Security Filters New Post @@ -1295,6 +1321,10 @@ Trusted Relays Relays you trust to not need a Tor connection for + Favorite Relay Feeds + Favorite Relay Feeds + Relays you frequently go to see their Global feed + Proxy Relays Proxy Relays Aggregator relays the app must use to download your feeds from, like filter.nostr.wine. This replaces the outbox model and will make the app connect to only the relays in your lists. @@ -1349,6 +1379,7 @@ Pushpin Scan QR code Navigate to the third-party wallet provider Alby + Select date It\'s not possible to reply a draft note It\'s not possible to quote a draft note It\'s not possible to react a draft note @@ -1494,7 +1525,7 @@ %1$d/%2$d Broadcasting Broadcasting %1$s - Broadcasting %1$d events... + Broadcasting %1$d events… %1$d events sent Some events failed @@ -1504,4 +1535,155 @@ Reaction Voice Post Voice Reply + Closing Time + Confirm + Next + Add poll option button + + Go Back + Approve + Connected + Social proof + Submit + Restart + Accept + Decline + New Game + New Chess Game + Loading game\u2026 + Game Not Found + This game may have ended or is waiting for opponent. + Game ID: %1$s\u2026 + and %1$d others + Remove %1$s + + + Outbox Relays + Apps + App Recommendations + User Settings + Audio Header + Audio Track + Badge Awards + Badge Definitions + Profile Badges + Blocked Relays + Blossom Servers + Blossom Auth + Broadcast Relays + Bookmark List + Day Appointment + Calendar + Appointment + Appt RSVP + Chess Games + Chess Auth + Favorite Relays + Chess Challenges + Chess Game Accept + Chess Move + Chess Game End + Chess Draw Offer + Channel Definition + Channel Hide Msg + Channel List + Channel Message + Channel Metadata + Channel Mute User + DM File + DM Message + DM Relays + Classifieds + Comments + Community Def + Community List + Community Post + Follow List + Deletions + Drafts + Emoji Packs + Emoji Pack List + Ephemeral Chat + Ephemeral Chatrooms + File Headers + Profile Gallery + File Servers + Blob Data + Blob Headers + Medical Data + Follow Packs + Reposts (16) + Geohash Follows + GiftWraps + Git Issue + Git Patch + Git Repo + Git Reply + Zap Goals + Hashtag Follows + Highlights + Http Auth + Index Relay List + Adventure Prologue + Adventure Scene + Adventure Reading + Named Bookmarks + Live Chats + Live Streams + Zaps + NWC Request + NWC Response + Private Zaps + Zap Req + Blogs + Meeting Room + Room Presence + Meeting Space + Profile + Mute List + NNS + NIP + Nostr Connect + DVM Status + DVM Content Req + DVM Content Resp + DVM User Req + DVM User Resp + OTS + PayTo + People Lists + Pictures + Pins + Zap Poll + Poll + Poll Response + NIP-04 DMs + Private Relays + Proxy Relays + Public Message + Reactions + Contact Card + Relay Auth + Relay Discovery + Relay Monitor Announcement + Relay Set + Reports + Reposts + User Delete + Seals + Search Relays + User Status + Notes + Edits + Torrents + Torrent Comments + Trusted Relays + Trusted Providers + Video (Repl) + Shorts (Repl) + Video + Shorts + Voice Msg + Voice Reply + Wiki diff --git a/amethyst/src/main/res/xml/backup_rules.xml b/amethyst/src/main/res/xml/backup_rules.xml index fa0f996d2c..a082938263 100644 --- a/amethyst/src/main/res/xml/backup_rules.xml +++ b/amethyst/src/main/res/xml/backup_rules.xml @@ -1,13 +1,14 @@ - + + - + + + + + + + + + \ No newline at end of file diff --git a/amethyst/src/main/res/xml/data_extraction_rules.xml b/amethyst/src/main/res/xml/data_extraction_rules.xml index 9ee9997b0b..bc72335b91 100644 --- a/amethyst/src/main/res/xml/data_extraction_rules.xml +++ b/amethyst/src/main/res/xml/data_extraction_rules.xml @@ -1,19 +1,28 @@ - + + - + + + + + + + + + - + + + + + + + + - --> \ No newline at end of file diff --git a/ammolite/build.gradle b/ammolite/build.gradle index 7bbfc6eb65..fffb5cf023 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 ac57e856b5..d2a5ae81fc 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 f5b7383886..296a8927f8 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 9f364ba56e..62c3e676af 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 ce0ffccaf8..1395ab7202 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 766ceccd88..c28360603f 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 265e6cb132..953824bc48 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 7aea1caa07..5baa0b1c11 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 9c2b2e91cf..40a31eb703 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") } @@ -20,15 +19,23 @@ kotlin { } } - androidLibrary { + android { 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) } + androidResources.enable = true + withHostTest {} withDeviceTest { @@ -75,13 +82,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/composeResources/values/strings.xml b/commons/src/commonMain/composeResources/values/strings.xml index 7403080ba4..ef99a2aa04 100644 --- a/commons/src/commonMain/composeResources/values/strings.xml +++ b/commons/src/commonMain/composeResources/values/strings.xml @@ -50,4 +50,8 @@ Your encrypted direct messages will appear here. Notifications Mentions, replies, and reactions will appear here. + + + User avatar + Navigate 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 0000000000..eb7b528af7 --- /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 0000000000..dc0cec5bb7 --- /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 0000000000..e876cfe800 --- /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 0000000000..8c5a951fbe --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/ChessConfig.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.commons.chess + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl + +/** + * 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".normalizeRelayUrl(), + "nos.lol".normalizeRelayUrl(), + "relay.primal.net".normalizeRelayUrl(), + ) + + /** + * 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 0000000000..c15195dbfe --- /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 0000000000..f73db65d9b --- /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 0000000000..f0557e2ab5 --- /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 0000000000..1fcc1ab3c6 --- /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 0000000000..e9f23f8f73 --- /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 0000000000..56ae30b3db --- /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 0000000000..5ebcd39f09 --- /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 0000000000..e5034553d1 --- /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 0000000000..4a713d3793 --- /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 0000000000..e449e838c8 --- /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/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/ActivityExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/IUserMetadataProvider.kt similarity index 75% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/ActivityExt.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/IUserMetadataProvider.kt index da0303f44b..c10c9a90cf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/playback/pip/ActivityExt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/IUserMetadataProvider.kt @@ -18,18 +18,16 @@ * 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.amethyst.commons.chess -import android.app.Activity -import android.graphics.Rect +/** + * 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 Activity.enterPipMode( - ratio: Float?, - bounds: Rect?, -) { - if (!isInPictureInPictureMode) { - enterPictureInPictureMode(makePipParams(ratio, bounds)) - } else { - setPictureInPictureParams(makePipParams(ratio, bounds)) - } + 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 0000000000..c91613b26e --- /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 0000000000..2dba6f7601 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/chess/LiveChessGame.kt @@ -0,0 +1,926 @@ +/* + * 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.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 0000000000..f9a1b51c3a --- /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 0000000000..16441b52df --- /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 0000000000..90ff1070e3 --- /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 0000000000..4539e42a2c --- /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 0000000000..27c43f37c2 --- /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 0000000000..85267bfff1 --- /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 0000000000..ac0c4b47f2 --- /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/IAccount.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt index 10bc242646..a42ce31b01 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/IAccount.kt @@ -20,11 +20,17 @@ */ package com.vitorpamplona.amethyst.commons.model +import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent import com.vitorpamplona.quartz.nip47WalletConnect.Request import com.vitorpamplona.quartz.nip47WalletConnect.Response import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.DualCase /** @@ -73,6 +79,9 @@ interface IAccount { /** Whether account has write permissions */ fun isWriteable(): Boolean + /** Nostr signer for signing/encrypting events */ + val signer: NostrSigner + /** Current user's public key */ val pubKey: String @@ -86,4 +95,19 @@ interface IAccount { fun followingKeySet(): Set fun isHidden(user: User): Boolean + + /** Chatroom list for private DM conversations */ + val chatroomList: ChatroomList + + /** Whether a note is acceptable (not hidden, not blocked, etc.) */ + fun isAcceptable(note: Note): Boolean + + /** Send a NIP-04 encrypted direct message */ + suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) + + /** Send a NIP-17 gift-wrapped direct message */ + suspend fun sendNip17PrivateMessage(template: EventTemplate) + + /** Broadcast pre-created gift wraps (e.g. reactions within group DMs) */ + suspend fun sendGiftWraps(wraps: List) } 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 ffd4c5dc7e..9e95521a29 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/nip01Core/UserRelaysCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt index 6724451e84..6717126d48 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip01Core/UserRelaysCache.kt @@ -25,8 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost import kotlinx.coroutines.flow.MutableStateFlow import java.lang.ref.WeakReference -import kotlin.collections.mapOf -import kotlin.collections.plus @Stable data class RelayInfo( 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 f99c9f6430..0da9932423 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 249dc74e91..5a1511b74c 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 0000000000..95bfb6a3ad --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/nip88Polls/PollResponsesCache.kt @@ -0,0 +1,143 @@ +/* + * 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 + +@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/model/observables/EventListMatchingFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt index cff0ba7f24..09e5b58d8b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/EventListMatchingFilter.kt @@ -25,7 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import java.util.SortedSet import java.util.concurrent.ConcurrentSkipListSet -import kotlin.collections.mapNotNull /** * Creates a list of events (regular and addressable) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt index c780575d75..f94bed11c9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/model/observables/NoteListMatchingFilter.kt @@ -25,7 +25,6 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import java.util.SortedSet import java.util.concurrent.ConcurrentSkipListSet -import kotlin.collections.toList /** * Creates a list of notes (regular and addressable) 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 0000000000..3009fd10b7 --- /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/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastStatus.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastStatus.kt new file mode 100644 index 0000000000..c5906088d7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/profile/ProfileBroadcastStatus.kt @@ -0,0 +1,46 @@ +/* + * 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 + +/** + * Status of profile metadata broadcast to relays. + */ +sealed class ProfileBroadcastStatus { + data object Idle : ProfileBroadcastStatus() + + 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 + } + + 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/relayClient/eoseManagers/BaseEoseManager.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt index 552a473361..8d0393d380 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/relayClient/eoseManagers/BaseEoseManager.kt @@ -21,12 +21,10 @@ package com.vitorpamplona.amethyst.commons.relayClient.eoseManagers import com.vitorpamplona.amethyst.commons.service.BundledUpdate -import com.vitorpamplona.amethyst.commons.utils.isDebug 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.client.subscriptions.SubscriptionController -import com.vitorpamplona.quartz.utils.Log import kotlinx.coroutines.Dispatchers abstract class BaseEoseManager( @@ -34,17 +32,13 @@ abstract class BaseEoseManager( val allKeys: () -> Set, val sampleTime: Long = 500, ) : IEoseManager { - protected val logTag: String = this::class.simpleName ?: "BaseEoseManager" - private val orchestrator = SubscriptionController(client) abstract fun updateSubscriptions(keys: Set) - fun newSubscriptionId() = if (isDebug) logTag + newSubId() else newSubId() - fun getSubscription(subId: String) = orchestrator.getSub(subId) - fun requestNewSubscription(listener: IRequestListener) = orchestrator.requestNewSubscription(newSubscriptionId(), listener) + fun requestNewSubscription(listener: IRequestListener) = orchestrator.requestNewSubscription(newSubId(), listener) fun dismissSubscription(subId: String) = orchestrator.dismissSubscription(subId) @@ -62,8 +56,5 @@ abstract class BaseEoseManager( override fun destroy() { bundler.cancel() - if (isDebug) { - Log.d(logTag, "Destroy, Unsubscribe") - } } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 615d64ce89..a4034d60ab 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -64,10 +64,10 @@ class RichTextParser { if (contentType != null) { isImage = contentType.startsWith("image/") - isVideo = contentType.startsWith("video/") + isVideo = contentType.startsWith("video/") || contentType.startsWith("audio/") } else if (fullUrl.startsWith("data:")) { isImage = fullUrl.startsWith("data:image/") - isVideo = fullUrl.startsWith("data:video/") + isVideo = fullUrl.startsWith("data:video/") || fullUrl.startsWith("data:audio/") } else { val removedParamsFromUrl = removeQueryParamsForExtensionComparison(fullUrl) isImage = imageExtensions.any { removedParamsFromUrl.endsWith(it) } @@ -175,13 +175,18 @@ class RichTextParser { lines.forEach { paragraph -> val isRTL = isArabic(paragraph) - val wordList = paragraph.trimEnd().split(' ') - val segments = ArrayList(wordList.size) - wordList.forEach { word -> - segments.add(wordIdentifier(word, images, videos, urls, emojis, tags)) - } + val wordList = paragraph.trimEnd().split(wordBoundaryRegex).filter { it.isNotEmpty() } - paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL)) + if (wordList.isEmpty()) { + paragraphSegments.add(ParagraphState(persistentListOf(RegularTextSegment("")), isRTL)) + } else { + val segments = ArrayList(wordList.size) + wordList.forEach { word -> + segments.add(wordIdentifier(word, images, videos, urls, emojis, tags)) + } + + paragraphSegments.add(ParagraphState(segments.toPersistentList(), isRTL)) + } } val segmentsWithGalleries = GalleryParser().processParagraphs(paragraphSegments) @@ -245,6 +250,8 @@ class RichTextParser { if (videos.contains(word)) return VideoSegment(word) + if (word.startsWith("ws://") || word.startsWith("wss://")) return RelayUrlSegment(word) + if (urls.contains(word)) return LinkSegment(word) if (CustomEmoji.fastMightContainEmoji(word, emojis) && emojis.any { word.contains(it.key) }) return EmojiSegment(word) @@ -334,17 +341,14 @@ class RichTextParser { val shortDatePattern: Regex = Regex("^\\d{2}-\\d{2}-\\d{2}$") val numberPattern: Regex = Regex("^(-?[\\d.]+)([a-zA-Z%]*)$") - // Android9 seems to have an issue starting this regex. val noProtocolUrlValidator = - try { - Regex( - "(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+[^\\p{IsHan}\\p{IsHiragana}\\p{IsKatakana}])*\\/?)(.*)", - ) - } catch (e: Exception) { - Regex( - "(([\\w\\d-]+\\.)*[a-zA-Z][\\w-]+[\\.\\:]\\w+([\\/\\?\\=\\&\\#\\.]?[\\w-]+)*\\/?)(.*)", - ) - } + Regex( + "(([a-zA-Z0-9_-]+\\.)*[a-zA-Z][a-zA-Z0-9_-]+[\\.\\:][a-zA-Z0-9_]+([\\/ \\?\\=\\&\\#\\.]?[a-zA-Z0-9_-]+)*\\/?)(.*)", + ) + + // Splits at spaces AND at ASCII/multibyte character boundaries + // e.g. "ああexample.com" -> ["ああ", "example.com"] + private val wordBoundaryRegex = Regex("(?<=[\\u0000-\\u007F])(?=[\\u0080-\\uFFFF])|(?<=[\\u0080-\\uFFFF])(?=[\\u0000-\\u007F])| +") val additionalUrlSchema = """^([A-Za-z0-9-_]+(\.[A-Za-z0-9-_]+)+)(:[0-9]+)?(/[^?#]*)?(\?[^#]*)?(#.*)?""" @@ -355,7 +359,7 @@ class RichTextParser { .toRegex(RegexOption.IGNORE_CASE) val imageExt = listOf("png", "jpg", "gif", "bmp", "jpeg", "webp", "svg", "avif") - val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8") + val videoExt = listOf("mp4", "avi", "wmv", "mpg", "amv", "webm", "mov", "mp3", "m3u8", "ogg", "wav", "flac", "aac", "opus", "m4a") val imageExtensions = imageExt + imageExt.map { it.uppercase() } val videoExtensions = videoExt + videoExt.map { it.uppercase() } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt index 5d34859c30..292b07ddc8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt @@ -146,6 +146,11 @@ class SchemelessUrlSegment( val extras: String?, ) : Segment(segment) +@Immutable +class RelayUrlSegment( + segment: String, +) : Segment(segment) + @Immutable class RegularTextSegment( segment: String, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatBubbleLayout.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatBubbleLayout.kt new file mode 100644 index 0000000000..bd92d401ad --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatBubbleLayout.kt @@ -0,0 +1,180 @@ +/* + * 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.ui.chat + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +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.compositeOver +import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleMaxSizeModifier +import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeMe +import com.vitorpamplona.amethyst.commons.ui.theme.ChatBubbleShapeThem +import com.vitorpamplona.amethyst.commons.ui.theme.ChatHalfHalfVertPadding +import com.vitorpamplona.amethyst.commons.ui.theme.ChatPaddingInnerQuoteModifier +import com.vitorpamplona.amethyst.commons.ui.theme.ChatPaddingModifier +import com.vitorpamplona.amethyst.commons.ui.theme.ChatRowColSpacing5dp +import com.vitorpamplona.amethyst.commons.ui.theme.MessageBubbleLimits +import com.vitorpamplona.amethyst.commons.ui.theme.chatBubbleBackground +import com.vitorpamplona.amethyst.commons.ui.theme.chatBubbleDraftBackground +import com.vitorpamplona.amethyst.commons.ui.theme.chatBubbleMeBackground + +/** + * Shared chat bubble layout used by both Android and Desktop. + * + * Renders a chat message bubble with appropriate shape, color, and alignment + * depending on whether the message is from the logged-in user or another user. + * + * Content is provided via composable lambdas (slots) so platform-specific + * implementations can plug in their own action menus, author lines, detail + * rows, and message content. + * + * @param isLoggedInUser Whether this message was sent by the current user + * @param isDraft Whether this message is a draft + * @param innerQuote Whether this bubble is rendered as an inner quote (reply preview) + * @param isComplete Whether the UI is in "complete" mode (always show details) + * @param hasDetailsToShow Whether there are details (reactions, zaps) to display + * @param drawAuthorInfo Whether to show the author avatar and name + * @param parentBackgroundColor Background color of the parent (for compositing) + * @param onClick Called on tap; return true if the click was consumed + * @param onAuthorClick Called when the author line is tapped + * @param actionMenu Composable for the long-press action menu popup + * @param detailRow Composable for the detail row (time, reactions, relays) + * @param drawAuthorLine Composable for the author avatar + name row + * @param inner Composable for the message body content; receives the resolved background color + */ +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ChatBubbleLayout( + isLoggedInUser: Boolean, + isDraft: Boolean, + innerQuote: Boolean, + isComplete: Boolean, + hasDetailsToShow: Boolean, + drawAuthorInfo: Boolean, + parentBackgroundColor: MutableState? = null, + onClick: () -> Boolean, + onAuthorClick: () -> Unit, + actionMenu: @Composable (onDismiss: () -> Unit) -> Unit, + detailRow: @Composable () -> Unit, + drawAuthorLine: @Composable () -> Unit, + inner: @Composable (MutableState) -> Unit, +) { + val loggedInColors = MaterialTheme.colorScheme.chatBubbleMeBackground + val otherColors = MaterialTheme.colorScheme.chatBubbleBackground + val defaultBackground = MaterialTheme.colorScheme.background + val draftColor = MaterialTheme.colorScheme.chatBubbleDraftBackground + + val bgColor = + remember { + if (isLoggedInUser) { + if (isDraft) { + mutableStateOf( + draftColor.compositeOver(parentBackgroundColor?.value ?: defaultBackground), + ) + } else { + mutableStateOf( + loggedInColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground), + ) + } + } else { + mutableStateOf(otherColors.compositeOver(parentBackgroundColor?.value ?: defaultBackground)) + } + } + + Row( + modifier = if (innerQuote) ChatPaddingInnerQuoteModifier else ChatPaddingModifier, + horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start, + ) { + val popupExpanded = remember { mutableStateOf(false) } + + val showDetails = + remember { + mutableStateOf( + if (isComplete) { + true + } else { + hasDetailsToShow + }, + ) + } + + val clickableModifier = + remember { + Modifier.combinedClickable( + onClick = { + if (!onClick()) { + if (!isComplete) { + showDetails.value = !showDetails.value + } + } + }, + onLongClick = { popupExpanded.value = true }, + ) + } + + Row( + horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start, + modifier = if (innerQuote) Modifier else ChatBubbleMaxSizeModifier, + ) { + Surface( + color = bgColor.value, + shape = if (isLoggedInUser) ChatBubbleShapeMe else ChatBubbleShapeThem, + modifier = clickableModifier, + ) { + Column(modifier = MessageBubbleLimits, verticalArrangement = ChatRowColSpacing5dp) { + if (drawAuthorInfo) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = if (isLoggedInUser) Arrangement.End else Arrangement.Start, + modifier = ChatHalfHalfVertPadding.clickable(onClick = onAuthorClick), + ) { + drawAuthorLine() + } + } + + inner(bgColor) + + if (showDetails.value) { + detailRow() + } + } + } + } + + if (popupExpanded.value) { + actionMenu { + popupExpanded.value = false + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatMessageCompose.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatMessageCompose.kt new file mode 100644 index 0000000000..a848bfd8a5 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatMessageCompose.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.chat + +import androidx.compose.foundation.layout.Row +import androidx.compose.runtime.Composable +import androidx.compose.runtime.MutableState +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color +import com.vitorpamplona.amethyst.commons.model.Note + +/** + * Shared chat message composable that provides structure without platform-specific dependencies. + * + * This is a simplified, slot-based version of the Android ChatroomMessageCompose. + * Platform implementations provide the actual content via lambda slots, allowing + * both Android and Desktop to use the same bubble layout with different content renderers. + * + * The Android app can wrap this or continue using its own deeper implementation. + * The Desktop app uses this directly with simpler content slots. + * + * @param note The chat message note + * @param isLoggedInUser Whether this note was authored by the current user + * @param isDraft Whether this is a draft message + * @param innerQuote Whether this is rendered as a quoted reply + * @param isComplete Whether to always show detail row + * @param hasDetailsToShow Whether there are reactions/zaps to display + * @param drawAuthorInfo Whether to show author avatar and name + * @param parentBackgroundColor Parent background for color compositing + * @param onClick Called on tap; return true if consumed + * @param onAuthorClick Called when author info is tapped + * @param actionMenu Long-press action menu popup slot + * @param authorLine Author avatar and name slot + * @param detailRow Detail row slot (time, reactions) + * @param messageContent Message body slot; receives the resolved background color + */ +@Composable +fun ChatMessageCompose( + note: Note, + isLoggedInUser: Boolean, + isDraft: Boolean, + innerQuote: Boolean = false, + isComplete: Boolean = true, + hasDetailsToShow: Boolean = false, + drawAuthorInfo: Boolean = false, + parentBackgroundColor: MutableState? = null, + onClick: () -> Boolean = { false }, + onAuthorClick: () -> Unit = {}, + actionMenu: @Composable (onDismiss: () -> Unit) -> Unit = {}, + authorLine: @Composable () -> Unit = {}, + detailRow: @Composable () -> Unit = {}, + messageContent: @Composable (MutableState) -> Unit, +) { + ChatBubbleLayout( + isLoggedInUser = isLoggedInUser, + isDraft = isDraft, + innerQuote = innerQuote, + isComplete = isComplete, + hasDetailsToShow = hasDetailsToShow, + drawAuthorInfo = drawAuthorInfo, + parentBackgroundColor = parentBackgroundColor, + onClick = onClick, + onAuthorClick = onAuthorClick, + actionMenu = actionMenu, + drawAuthorLine = authorLine, + detailRow = detailRow, + ) { bgColor -> + Row(verticalAlignment = Alignment.CenterVertically) { + messageContent(bgColor) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatroomHeader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatroomHeader.kt new file mode 100644 index 0000000000..021b7530f9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/ChatroomHeader.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.ui.chat + +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.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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.accessibility_user_avatar +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.commons.ui.theme.ChatSize34dp +import com.vitorpamplona.amethyst.commons.ui.theme.ChatStdPadding +import org.jetbrains.compose.resources.stringResource + +/** + * Shared chatroom header for a single-user conversation. + * Displays the user's avatar and display name. + * + * Uses the shared UserAvatar component from commons for image loading, + * avoiding Android-specific dependencies. + * + * @param user The chat partner + * @param modifier Layout modifier (defaults to standard padding) + * @param onClick Called when the header is tapped (e.g., navigate to profile) + */ +@Composable +fun ChatroomHeader( + user: User, + modifier: Modifier = ChatStdPadding, + onClick: () -> Unit, +) { + Column( + Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Column(modifier, Arrangement.Center) { + Row(verticalAlignment = Alignment.CenterVertically) { + UserAvatar( + userHex = user.pubkeyHex, + pictureUrl = user.profilePicture(), + size = ChatSize34dp, + contentDescription = stringResource(Res.string.accessibility_user_avatar), + ) + + Column(modifier = Modifier.padding(start = 10.dp)) { + Text( + text = user.toBestDisplayName(), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +/** + * Shared chatroom header for a group conversation. + * Displays multiple user avatars and a combined room name. + * + * @param users List of users in the group conversation + * @param modifier Layout modifier (defaults to standard padding) + * @param onClick Called when the header is tapped + */ +@Composable +fun GroupChatroomHeader( + users: List, + modifier: Modifier = ChatStdPadding, + onClick: () -> Unit, +) { + Column( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + ) { + Column( + verticalArrangement = Arrangement.Center, + modifier = modifier, + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + // Show first user's avatar as the group icon + users.firstOrNull()?.let { firstUser -> + UserAvatar( + userHex = firstUser.pubkeyHex, + pictureUrl = firstUser.profilePicture(), + size = ChatSize34dp, + contentDescription = stringResource(Res.string.accessibility_user_avatar), + ) + } + + Column(modifier = Modifier.padding(start = 10.dp)) { + Text( + text = users.joinToString(", ") { it.toBestDisplayName() }, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastBanner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastBanner.kt new file mode 100644 index 0000000000..2b9b8b1a51 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastBanner.kt @@ -0,0 +1,142 @@ +/* + * 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.ui.chat + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +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.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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun DmBroadcastBanner( + status: DmBroadcastStatus, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = status !is DmBroadcastStatus.Idle, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + modifier = modifier, + ) { + val isFailed = status is DmBroadcastStatus.Failed + val containerColor = + if (isFailed) { + MaterialTheme.colorScheme.errorContainer + } else { + MaterialTheme.colorScheme.surfaceContainerHigh + } + val contentColor = + if (isFailed) { + MaterialTheme.colorScheme.onErrorContainer + } else { + MaterialTheme.colorScheme.onSurface + } + + Surface( + color = containerColor, + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val icon = + when (status) { + is DmBroadcastStatus.Subscribing, + is DmBroadcastStatus.Sending, + -> Icons.Default.Sync + + is DmBroadcastStatus.Sent -> Icons.Default.CheckCircle + + is DmBroadcastStatus.Failed -> Icons.Default.Error + + is DmBroadcastStatus.Idle -> Icons.Default.Sync + } + + Icon( + imageVector = icon, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = contentColor, + ) + + Spacer(Modifier.width(4.dp)) + + Text( + text = + when (status) { + is DmBroadcastStatus.Subscribing -> "Connecting to DM relays..." + is DmBroadcastStatus.Sending -> "Sending message... [${status.successCount}/${status.totalRelays}]" + is DmBroadcastStatus.Sent -> "Sent to ${status.relayCount} relays" + is DmBroadcastStatus.Failed -> "Send failed: ${status.error}" + is DmBroadcastStatus.Idle -> "" + }, + style = MaterialTheme.typography.labelMedium, + color = contentColor, + ) + } + + if (status is DmBroadcastStatus.Sending) { + LinearProgressIndicator( + progress = { status.progress }, + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + color = MaterialTheme.colorScheme.primary, + trackColor = MaterialTheme.colorScheme.surfaceVariant, + ) + } + + if (status is DmBroadcastStatus.Sent && status.relayUrls.isNotEmpty()) { + Text( + text = status.relayUrls.joinToString(", "), + style = MaterialTheme.typography.bodySmall, + color = contentColor.copy(alpha = 0.7f), + modifier = Modifier.padding(start = 28.dp), + ) + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastStatus.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastStatus.kt new file mode 100644 index 0000000000..0d69735607 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/DmBroadcastStatus.kt @@ -0,0 +1,47 @@ +/* + * 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.ui.chat + +import androidx.compose.runtime.Immutable + +@Immutable +sealed class DmBroadcastStatus { + data object Idle : DmBroadcastStatus() + + data object Subscribing : DmBroadcastStatus() + + data class Sending( + val successCount: Int, + val totalRelays: Int, + ) : DmBroadcastStatus() { + val progress: Float + get() = if (totalRelays > 0) successCount.toFloat() / totalRelays else 0f + } + + data class Sent( + val relayCount: Int, + val relayUrls: List = emptyList(), + ) : DmBroadcastStatus() + + data class Failed( + val error: String, + ) : DmBroadcastStatus() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/UserDisplayNameLayout.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/UserDisplayNameLayout.kt new file mode 100644 index 0000000000..7033697521 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/chat/UserDisplayNameLayout.kt @@ -0,0 +1,50 @@ +/* + * 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.ui.chat + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import com.vitorpamplona.amethyst.commons.ui.theme.ChatAuthorBox +import com.vitorpamplona.amethyst.commons.ui.theme.ChatStdHorzSpacer + +/** + * Layout for displaying a user's picture and name in a chat message author line. + * Shared between Android and Desktop. + * + * The picture slot uses a BoxScope so callers can overlay badges/icons + * (e.g., following indicator, user cards) on top of the avatar. + */ +@Composable +fun UserDisplayNameLayout( + picture: @Composable BoxScope.() -> Unit, + name: @Composable () -> Unit, +) { + Box(ChatAuthorBox, contentAlignment = Alignment.TopEnd) { + picture() + } + + Spacer(modifier = ChatStdHorzSpacer) + + name() +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt index eb2b0d51d3..14e41bbce5 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/UserSearchCard.kt @@ -39,6 +39,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.resources.Res +import com.vitorpamplona.amethyst.commons.resources.accessibility_navigate +import com.vitorpamplona.amethyst.commons.resources.accessibility_user_avatar +import org.jetbrains.compose.resources.stringResource /** * A card displaying user search result with avatar, name, and nip05/pubkey. @@ -69,7 +73,7 @@ fun UserSearchCard( userHex = user.pubkeyHex, pictureUrl = user.profilePicture(), size = 40.dp, - contentDescription = "Profile picture", + contentDescription = stringResource(Res.string.accessibility_user_avatar), ) Column(modifier = Modifier.weight(1f)) { @@ -97,7 +101,7 @@ fun UserSearchCard( Icon( Icons.AutoMirrored.Filled.ArrowForward, - contentDescription = "Navigate", + contentDescription = stringResource(Res.string.accessibility_navigate), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChangesFlowFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChangesFlowFilter.kt new file mode 100644 index 0000000000..419cc268d7 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChangesFlowFilter.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.commons.ui.feeds + +import com.vitorpamplona.amethyst.commons.model.ListChange +import kotlinx.coroutines.flow.MutableSharedFlow + +interface ChangesFlowFilter : IAdditiveFeedFilter { + fun changesFlow(): MutableSharedFlow> +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChatroomFeedFilter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChatroomFeedFilter.kt new file mode 100644 index 0000000000..cf2674e9e9 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/ChatroomFeedFilter.kt @@ -0,0 +1,47 @@ +/* + * 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.ui.feeds + +import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey + +class ChatroomFeedFilter( + val withUser: ChatroomKey, + val account: IAccount, +) : AdditiveFeedFilter(), + ChangesFlowFilter { + fun chatroom() = account.chatroomList.getOrCreatePrivateChatroom(withUser) + + override fun changesFlow() = chatroom().changesFlow() + + // returns the last Note of each user. + override fun feedKey(): String = withUser.hashCode().toString() + + override fun feed(): List = chatroom().messages.filter { account.isAcceptable(it) }.sortedWith(DefaultFeedOrder) + + override fun applyFilter(newItems: Set): Set { + val chatroom = chatroom() + return newItems.filter { it in chatroom.messages && account.isAcceptable(it) }.toSet() + } + + override fun sort(items: Set): List = items.sortedWith(DefaultFeedOrder) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DefaultFeedOrder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DefaultFeedOrder.kt new file mode 100644 index 0000000000..47bd192eab --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feeds/DefaultFeedOrder.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.ui.feeds + +import com.vitorpamplona.amethyst.commons.model.Note + +val DefaultFeedOrder: Comparator = + compareByDescending { it.createdAt() }.thenBy { it.idHex } 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 8a0a3e8648..f46ac43ea8 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/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ChatTheme.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ChatTheme.kt new file mode 100644 index 0000000000..ae778e98a8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/ChatTheme.kt @@ -0,0 +1,83 @@ +/* + * 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.ui.theme + +import androidx.compose.foundation.layout.Arrangement +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.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +// Chat bubble shapes +val ChatBubbleShapeMe = RoundedCornerShape(15.dp, 15.dp, 3.dp, 15.dp) +val ChatBubbleShapeThem = RoundedCornerShape(3.dp, 15.dp, 15.dp, 15.dp) + +// Chat bubble modifiers +val ChatBubbleMaxSizeModifier = Modifier.fillMaxWidth(0.85f) +val ChatPaddingInnerQuoteModifier = Modifier +val ChatPaddingModifier = + Modifier + .fillMaxWidth(1f) + .padding( + start = 12.dp, + end = 12.dp, + top = 3.dp, + bottom = 3.dp, + ) + +// Message bubble internal padding +val MessageBubbleLimits = Modifier.padding(start = 10.dp, end = 10.dp, top = 7.dp, bottom = 6.dp) + +// Chat author area +val ChatAuthorBox = Modifier.size(20.dp) +val ChatAuthorImage = Modifier.size(20.dp).clip(shape = CircleShape) + +// Spacing values reused across chat composables +val ChatRowColSpacing5dp = Arrangement.spacedBy(5.dp) +val ChatHalfHalfVertPadding = Modifier.padding(vertical = 3.dp) +val ChatStdHorzSpacer = Modifier.width(5.dp) +val ChatStdPadding = Modifier.padding(10.dp) +val ChatReactionRowHeight = Modifier.height(20.dp) + +// Common size values +val ChatSize20dp = 20.dp +val ChatSize34dp = 34.dp + +// Chat color extensions for ColorScheme +val ColorScheme.chatBubbleBackground: Color + get() = if (isLight) onSurface.copy(alpha = 0.08f) else onSurface.copy(alpha = 0.12f) + +val ColorScheme.chatBubbleDraftBackground: Color + get() = onSurface.copy(alpha = 0.15f) + +val ColorScheme.chatBubbleMeBackground: Color + get() = primary.copy(alpha = 0.32f) + +val ColorScheme.chatPlaceholderText: Color + get() = onSurface.copy(alpha = 0.42f) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt new file mode 100644 index 0000000000..9b9cc7f066 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatNewMessageState.kt @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.viewmodels + +import androidx.compose.runtime.Stable +import androidx.compose.ui.text.input.TextFieldValue +import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip01Core.tags.references.references +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip10Notes.content.findHashtags +import com.vitorpamplona.quartz.nip10Notes.content.findNostrEventUris +import com.vitorpamplona.quartz.nip10Notes.content.findURLs +import com.vitorpamplona.quartz.nip17Dm.base.BaseDMGroupEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip17Dm.base.NIP17Group +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip18Reposts.quotes.quotes +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.utils.Hex +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Slim shared state for DM message composition. + * Holds only core fields needed for typing and sending messages. + * + * Platform-specific concerns (uploads, emoji suggestions, drafts, zapraiser, + * location, user suggestions) remain in the platform ViewModel layer. + * + * Used by both Android ChatNewMessageViewModel and Desktop DM screen. + */ +@Stable +class ChatNewMessageState( + val account: IAccount, + val cache: ICacheProvider, + val scope: CoroutineScope, +) { + private val _message = MutableStateFlow(TextFieldValue("")) + val message: StateFlow = _message.asStateFlow() + + private val _nip17 = MutableStateFlow(false) + val nip17: StateFlow = _nip17.asStateFlow() + + private val _replyTo = MutableStateFlow(null) + val replyTo: StateFlow = _replyTo.asStateFlow() + + private val _subject = MutableStateFlow(TextFieldValue("")) + val subject: StateFlow = _subject.asStateFlow() + + private val _room = MutableStateFlow(null) + val room: StateFlow = _room.asStateFlow() + + /** Whether NIP-17 is required (group chat with >1 recipient) */ + private val _requiresNip17 = MutableStateFlow(false) + val requiresNip17: StateFlow = _requiresNip17.asStateFlow() + + /** Whether a message can be sent (non-blank text + room set) */ + val canSend: Boolean + get() = _message.value.text.isNotBlank() && _room.value != null + + /** + * Load a chatroom. Sets the room key, formats toUsers display, + * and auto-detects NIP-17 requirement (group chats require NIP-17). + */ + fun load(roomKey: ChatroomKey) { + _room.value = roomKey + updateNip17FromRoom() + } + + /** + * Auto-detect NIP-17 based on room: + * - Group chats (>1 recipient) always require NIP-17 + * - Single recipient: NIP-17 off by default (can be toggled) + */ + fun updateNip17FromRoom() { + val currentRoom = _room.value + if (currentRoom != null) { + _requiresNip17.value = currentRoom.users.size > 1 + if (_requiresNip17.value) { + _nip17.value = true + } + } else { + _requiresNip17.value = false + _nip17.value = false + } + } + + fun updateMessage(newMessage: TextFieldValue) { + _message.value = newMessage + } + + fun updateSubject(newSubject: TextFieldValue) { + _subject.value = newSubject + } + + fun setReply(note: Note) { + _replyTo.value = note + } + + fun clearReply() { + _replyTo.value = null + } + + /** + * Toggle NIP-04/NIP-17 mode. + * If NIP-17 is required (group chat), stays on NIP-17. + */ + fun toggleNip17() { + if (_requiresNip17.value) { + _nip17.value = true + } else { + _nip17.value = !_nip17.value + } + } + + /** + * Enable NIP-17 (e.g., when recipient has DM relay list). + */ + fun enableNip17() { + _nip17.value = true + } + + /** + * Send the current message. Builds the appropriate event template + * (NIP-04 or NIP-17) and delegates to IAccount for signing/broadcasting. + * + * @return true if send was initiated, false if preconditions not met + */ + suspend fun send(): Boolean { + val currentRoom = _room.value ?: return false + val messageText = _message.value.text + if (messageText.isBlank()) return false + + if (_nip17.value || currentRoom.users.size > 1 || _replyTo.value?.event is NIP17Group) { + sendNip17(currentRoom, messageText) + } else { + sendNip04(currentRoom, messageText) + } + + return true + } + + private suspend fun sendNip17( + room: ChatroomKey, + messageText: String, + ) { + val pTags = + room.users.mapNotNull { hexKey -> + (cache.getOrCreateUser(hexKey) as? User)?.toPTag() + } + + val replyHint = _replyTo.value?.toEventHint() + + val template = + if (replyHint == null) { + ChatMessageEvent.build(messageText, pTags) { + hashtags(findHashtags(messageText)) + references(findURLs(messageText)) + quotes(findNostrEventUris(messageText)) + } + } else { + ChatMessageEvent.reply(messageText, replyHint) { + hashtags(findHashtags(messageText)) + references(findURLs(messageText)) + quotes(findNostrEventUris(messageText)) + } + } + + account.sendNip17PrivateMessage(template) + } + + private suspend fun sendNip04( + room: ChatroomKey, + messageText: String, + ) { + val toUser = (cache.getOrCreateUser(room.users.first()) as? User)?.toPTag() ?: return + + val template = + PrivateDmEvent.build( + toUser = toUser, + message = messageText, + replyingTo = _replyTo.value?.toEventHint(), + signer = account.signer, + ) + + account.sendNip04PrivateMessage(template) + } + + /** + * Clear all composition state after sending or cancelling. + */ + fun clear() { + _message.value = TextFieldValue("") + _subject.value = TextFieldValue("") + _replyTo.value = null + } + + /** + * Format room users as npub display string. + * Useful for showing recipients in the UI. + */ + fun toUsersDisplay(): String { + val currentRoom = _room.value ?: return "" + return currentRoom.users + .mapNotNull { hexKey -> + runCatching { Hex.decode(hexKey).toNpub() }.getOrNull() + }.joinToString(", ") { "@$it" } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatroomFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatroomFeedViewModel.kt new file mode 100644 index 0000000000..94f00b7a92 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ChatroomFeedViewModel.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.viewmodels + +import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.ui.feeds.ChatroomFeedFilter +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey + +class ChatroomFeedViewModel( + val user: ChatroomKey, + val account: IAccount, + cacheProvider: ICacheProvider, +) : ListChangeFeedViewModel(ChatroomFeedFilter(user, account), cacheProvider) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt new file mode 100644 index 0000000000..4bce8f7a1c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/ListChangeFeedViewModel.kt @@ -0,0 +1,66 @@ +/* + * 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.viewmodels + +import androidx.compose.runtime.Stable +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.commons.model.ListChange +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.ui.feeds.ChangesFlowFilter +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedContentState +import com.vitorpamplona.amethyst.commons.ui.feeds.InvalidatableContent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Stable +abstract class ListChangeFeedViewModel( + localFilter: ChangesFlowFilter, + cacheProvider: ICacheProvider, +) : ViewModel(), + InvalidatableContent { + val feedState = FeedContentState(localFilter, viewModelScope, cacheProvider) + + override val isRefreshing = feedState.isRefreshing + + override fun invalidateData(ignoreIfDoing: Boolean) = feedState.invalidateData(ignoreIfDoing) + + init { + Log.d("Init", "Starting new Model: ${this::class.simpleName}") + // Trigger initial load so empty rooms show Empty instead of Loading + viewModelScope.launch(Dispatchers.IO) { + feedState.invalidateData(ignoreIfDoing = false) + } + viewModelScope.launch(Dispatchers.IO) { + localFilter.changesFlow().collect { + Log.d("Init", "Collecting changes to: ${this@ListChangeFeedViewModel::class.simpleName}") + when (it) { + is ListChange.Addition -> feedState.updateFeedWith(setOf(it.item)) + is ListChange.Deletion -> feedState.deleteFromFeed(setOf(it.item)) + is ListChange.SetAddition -> feedState.updateFeedWith(it.item) + is ListChange.SetDeletion -> feedState.deleteFromFeed(it.item) + } + } + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt index 8982f04fd8..a3b0b2c32c 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/thread/LevelFeedViewModel.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.commons.viewmodels.thread import androidx.compose.foundation.interaction.DragInteraction import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -45,6 +46,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.transformLatest +@Stable abstract class LevelFeedViewModel( localFilter: FeedFilter, cacheProvider: ICacheProvider, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMultibyteTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMultibyteTest.kt new file mode 100644 index 0000000000..75af7b8a07 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserMultibyteTest.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.commons.richtext + +import com.vitorpamplona.amethyst.commons.model.EmptyTagList +import org.junit.Assert.assertTrue +import org.junit.Test + +class RichTextParserMultibyteTest { + @Test + fun testFullTextWithMultibyte() { + // Multibyte characters around an email address should not produce URL/Link segments + val text = + "マルチバイト文字テストuser@example.com ほげほげ" + + val state = + RichTextParser() + .parseText(text, EmptyTagList, null) + + val allSegments = + state.paragraphs + .flatMap { it.words } + + // user@example.com should be EmailSegment + assertTrue( + "user@example.com should be EmailSegment", + allSegments.any { it is EmailSegment && it.segmentText == "user@example.com" }, + ) + + // user@example.com should NOT be a LinkSegment + assertTrue( + "user@example.com should not be a LinkSegment", + allSegments.none { it is LinkSegment && it.segmentText == "user@example.com" }, + ) + + // user@example.com should not be in urlSet + assertTrue( + "user@example.com should not be in urlSet", + !state.urlSet.contains("user@example.com"), + ) + } + + @Test + fun testEmailSegmentStandalone() { + val text = "user@example.com" + val state = RichTextParser().parseText(text, EmptyTagList, null) + val allSegments = state.paragraphs.flatMap { it.words } + assertTrue( + "user@example.com should be EmailSegment", + allSegments.any { it is EmailSegment && it.segmentText == "user@example.com" }, + ) + } + + @Test + fun testMultibytePrefix_SchemelessUrl() { + // ああexample.com → RegularText(ああ) + SchemelessUrl(example.com) + val text = "ああexample.com" + val state = RichTextParser().parseText(text, EmptyTagList, null) + val allSegments = state.paragraphs.flatMap { it.words } + + val urlSegments = allSegments.filterIsInstance() + assertTrue("Should have SchemelessUrlSegment", urlSegments.isNotEmpty()) + assertTrue("URL should be example.com", urlSegments.any { it.url == "example.com" }) + + val textSegments = allSegments.filterIsInstance() + assertTrue("Should have prefix ああ", textSegments.any { it.segmentText == "ああ" }) + } + + @Test + fun testMultibyteSuffix_SchemelessUrl() { + // example.comああ → SchemelessUrl(example.com) + RegularText(ああ) + val text = "example.comああ" + val state = RichTextParser().parseText(text, EmptyTagList, null) + val allSegments = state.paragraphs.flatMap { it.words } + + val urlSegments = allSegments.filterIsInstance() + assertTrue("Should have SchemelessUrlSegment", urlSegments.isNotEmpty()) + assertTrue("URL should be example.com", urlSegments.any { it.url == "example.com" }) + + val textSegments = allSegments.filterIsInstance() + assertTrue("Should have suffix ああ", textSegments.any { it.segmentText == "ああ" }) + } + + @Test + fun testMultibytePrefix_Email() { + // ほむほむuser@example.comほげほげ → RegularText(ほむほむ) + Email(user@example.com) + RegularText(ほげほげ) + val text = "ほむほむuser@example.comほげほげ" + val state = RichTextParser().parseText(text, EmptyTagList, null) + val allSegments = state.paragraphs.flatMap { it.words } + + val emailSegment = allSegments.filterIsInstance() + assertTrue("Should have EmailSegment", emailSegment.isNotEmpty()) + assertTrue("Email should be user@example.com", emailSegment.any { it.segmentText == "user@example.com" }) + + val textSegments = allSegments.filterIsInstance() + assertTrue("Should have prefix ほむほむ", textSegments.any { it.segmentText == "ほむほむ" }) + assertTrue("Should have suffix ほげほげ", textSegments.any { it.segmentText == "ほげほげ" }) + } + + @Test + fun testEmailWithSpaceAndMultibyteText() { + // user@example.com ふがふが → Email(user@example.com) + RegularText(ふがふが) + val text = "user@example.com ふがふが" + val state = RichTextParser().parseText(text, EmptyTagList, null) + val allSegments = state.paragraphs.flatMap { it.words } + + val emailSegment = allSegments.filterIsInstance() + assertTrue("Should have EmailSegment", emailSegment.isNotEmpty()) + assertTrue("Email should be user@example.com", emailSegment.any { it.segmentText == "user@example.com" }) + + val textSegments = allSegments.filterIsInstance() + assertTrue("Should have suffix ふがふが", textSegments.any { it.segmentText == "ふがふが" }) + } +} 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 0000000000..ac6e7d896f --- /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/build.gradle.kts b/desktopApp/build.gradle.kts index c9d5ee7c4e..40f6e6d8c5 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -33,6 +33,9 @@ dependencies { // Commons library implementation(project(":commons")) + // Lifecycle ViewModel (needed to access ViewModel supertype from commons) + implementation(libs.androidx.lifecycle.viewmodel.compose) + // Coroutines implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.swing) 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 358bdd0447..5614e4e4b7 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 @@ -78,10 +79,12 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState -import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder import com.vitorpamplona.amethyst.desktop.account.AccountManager import com.vitorpamplona.amethyst.desktop.account.AccountState import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.chess.ChessScreen +import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState +import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager import com.vitorpamplona.amethyst.desktop.subscriptions.DesktopRelaySubscriptionsCoordinator import com.vitorpamplona.amethyst.desktop.ui.BookmarksScreen @@ -94,12 +97,15 @@ import com.vitorpamplona.amethyst.desktop.ui.SearchScreen import com.vitorpamplona.amethyst.desktop.ui.ThreadScreen import com.vitorpamplona.amethyst.desktop.ui.UserProfileScreen import com.vitorpamplona.amethyst.desktop.ui.ZapFeedback +import com.vitorpamplona.amethyst.desktop.ui.chats.DesktopMessagesScreen +import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker import com.vitorpamplona.amethyst.desktop.ui.profile.ProfileInfoCard import com.vitorpamplona.amethyst.desktop.ui.relay.RelayStatusCard import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") @@ -120,6 +126,8 @@ sealed class DesktopScreen { object Notifications : DesktopScreen() + object Chess : DesktopScreen() + object MyProfile : DesktopScreen() data class UserProfile( @@ -350,6 +358,97 @@ fun MainContent( val snackbarHostState = remember { SnackbarHostState() } val scope = rememberCoroutineScope() + // DM infrastructure — hoisted here so it survives screen navigation + val dmSendTracker = + remember(relayManager) { + DmSendTracker(relayManager.client) + } + val iAccount = + remember(account, localCache, relayManager, dmSendTracker) { + DesktopIAccount(account, localCache, relayManager, dmSendTracker, scope) + } + + // Subscribe to incoming DMs and process into chatroomList + LaunchedEffect(account) { + relayManager.connectedRelays.first { it.isNotEmpty() } + + val dmRelayState = + DesktopDmRelayState( + dmRelayList = kotlinx.coroutines.flow.MutableStateFlow(emptySet()), + connectedRelays = relayManager.connectedRelays, + scope = scope, + ) + subscriptionsCoordinator.subscribeToDms( + userPubKeyHex = account.pubKeyHex, + dmRelayState = dmRelayState, + onDmEvent = { event, relay -> + // Store raw event in cache + val note = localCache.getOrCreateNote(event.id) + val author = localCache.getOrCreateUser(event.pubKey) + if (note.event == null) { + note.loadEvent(event, author, emptyList()) + note.addRelay(relay) + } + + // Process into chatroomList based on event type + when (event) { + is com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent -> { + iAccount.chatroomList.addMessage( + event.chatroomKey(iAccount.pubKey), + note, + ) + } + + is com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent -> { + // NIP-17: unwrap gift wrap → seal → inner event + scope.launch { + val seal = + event.unwrapOrNull(iAccount.signer) + as? com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent + ?: return@launch + val innerEvent = seal.unsealOrNull(iAccount.signer) ?: return@launch + when (innerEvent) { + is com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent -> { + val innerNote = localCache.getOrCreateNote(innerEvent.id) + val innerAuthor = localCache.getOrCreateUser(innerEvent.pubKey) + if (innerNote.event == null) { + innerNote.loadEvent(innerEvent, innerAuthor, emptyList()) + } + iAccount.chatroomList.addMessage( + innerEvent.chatroomKey(iAccount.pubKey), + innerNote, + ) + } + + is com.vitorpamplona.quartz.nip25Reactions.ReactionEvent -> { + val reactionNote = localCache.getOrCreateNote(innerEvent.id) + val reactionAuthor = localCache.getOrCreateUser(innerEvent.pubKey) + if (reactionNote.event == null) { + reactionNote.loadEvent(innerEvent, reactionAuthor, emptyList()) + } + // Attach reaction to the target message note + innerEvent.originalPost().forEach { targetId -> + val targetNote = localCache.getNoteIfExists(targetId) + targetNote?.addReaction(reactionNote) + } + } + + else -> { + println("Unhandled NIP-17 inner event: ${innerEvent.kind}") + } + } + } + } + } + }, + ) + } + + // Clean up DM subscriptions when this composable leaves (logout) + DisposableEffect(Unit) { + onDispose { subscriptionsCoordinator.unsubscribeFromDms() } + } + val onZapFeedback: (ZapFeedback) -> Unit = { feedback -> scope.launch { val message = @@ -415,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") }, @@ -507,13 +613,29 @@ fun MainContent( } DesktopScreen.Messages -> { - MessagesPlaceholder() + DesktopMessagesScreen( + account = iAccount, + cacheProvider = localCache, + relayManager = relayManager, + localCache = localCache, + onNavigateToProfile = { pubKeyHex -> + onScreenChange(DesktopScreen.UserProfile(pubKeyHex)) + }, + ) } DesktopScreen.Notifications -> { NotificationsScreen(relayManager, account, subscriptionsCoordinator) } + DesktopScreen.Chess -> { + ChessScreen( + relayManager = relayManager, + account = account, + onBack = { onScreenChange(DesktopScreen.Feed) }, + ) + } + DesktopScreen.MyProfile -> { UserProfileScreen( pubKeyHex = account.pubKeyHex, 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 0000000000..48f0d794f3 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/chess/ChessScreen.kt @@ -0,0 +1,821 @@ +/* + * 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.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.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 0000000000..f80a3f6a6d --- /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 0000000000..c779afebd7 --- /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 0000000000..895aed70a8 --- /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/model/DesktopAccountRelays.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopAccountRelays.kt new file mode 100644 index 0000000000..0aaec0c1f2 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopAccountRelays.kt @@ -0,0 +1,94 @@ +/* + * 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.model + +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +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.nip17Dm.settings.ChatMessageRelayListEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Manages relay state for a desktop account. + * + * Bridges the gap between Android's Account.dmRelays (which depends on + * LocalCache, AccountSettings, and multiple relay list states) and desktop's + * simpler relay management. + * + * On Android, Account.dmRelays aggregates: + * - DmRelayListState (ChatMessageRelayListEvent, kind 10050) + * - Nip65RelayListState (NIP-65 advertised relays) + * - PrivateStorageRelayListState + * - LocalRelayListState + * + * On Desktop, we start with the DM relay list and connected relays as fallback. + * As the desktop Account evolves, this class will grow to match Android's behavior. + */ +class DesktopAccountRelays( + val userPubKeyHex: HexKey, + relayManager: RelayConnectionManager, + scope: CoroutineScope, +) { + /** User-configured DM relays from kind 10050 events */ + private val _dmRelayList = MutableStateFlow>(emptySet()) + val dmRelayList: StateFlow> = _dmRelayList.asStateFlow() + + /** Aggregated DM relay state (DM relays + fallback to connected relays) */ + val dmRelays = + DesktopDmRelayState( + dmRelayList = _dmRelayList, + connectedRelays = relayManager.connectedRelays, + scope = scope, + ) + + /** + * Processes a ChatMessageRelayListEvent (kind 10050) to update DM relays. + * Call this when receiving kind 10050 events from relay subscriptions. + */ + fun consumeDmRelayList(event: ChatMessageRelayListEvent) { + if (event.pubKey != userPubKeyHex) return + val relays = event.relays().toSet() + _dmRelayList.value = relays + } + + /** + * Processes any event that might be a DM relay list. + * Returns true if the event was consumed as a DM relay list. + */ + fun consumeIfDmRelayList(event: Event): Boolean { + if (event.kind == ChatMessageRelayListEvent.KIND && event is ChatMessageRelayListEvent) { + consumeDmRelayList(event) + return true + } + return false + } + + /** + * Manually sets DM relays (e.g., from saved preferences). + */ + fun setDmRelays(relays: Set) { + _dmRelayList.value = relays + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopDmRelayState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopDmRelayState.kt new file mode 100644 index 0000000000..3a193f95ec --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopDmRelayState.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.desktop.model + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +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.stateIn + +/** + * Desktop equivalent of DmInboxRelayState. + * + * Aggregates DM inbox relays from multiple sources: + * - DM relay list (NIP-17 ChatMessageRelayListEvent, kind 10050) + * - Connected relays (fallback when no DM-specific relays are configured) + * + * On Android, the full Account class manages four relay sources: + * - NIP-65 advertised inbox relays + * - NIP-17 DM relay list + * - Private storage outbox relays + * - Local relays + * + * Desktop simplifies this: we combine the user's DM relay list with + * the connected relays as a fallback. As the desktop Account evolves, + * additional relay sources can be added here. + */ +class DesktopDmRelayState( + dmRelayList: StateFlow>, + connectedRelays: StateFlow>, + scope: CoroutineScope, +) { + /** + * Combined DM relay set. + * Prefers DM-specific relays when available, falls back to connected relays. + */ + val flow: StateFlow> = + combine( + dmRelayList, + connectedRelays, + ) { dmRelays, connected -> + if (dmRelays.isNotEmpty()) { + dmRelays + } else { + // Fallback: use all connected relays when no DM relays are configured + connected + } + }.flowOn(Dispatchers.IO) + .stateIn( + scope, + SharingStarted.Eagerly, + dmRelayList.value.ifEmpty { connectedRelays.value }, + ) + + /** + * Outbox relays for sending DMs FROM the user. + * Uses the connected relays (home relay equivalent on desktop). + */ + val outboxFlow: StateFlow> = connectedRelays +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.kt new file mode 100644 index 0000000000..e0f5086d1b --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/model/DesktopIAccount.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.model + +import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.INwcSignerState +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.privateChats.ChatroomList +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.amethyst.desktop.ui.chats.DmSendTracker +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.nip17Dm.messages.ChatMessageEvent +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent +import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentResponseEvent +import com.vitorpamplona.quartz.nip47WalletConnect.Request +import com.vitorpamplona.quartz.nip47WalletConnect.Response +import com.vitorpamplona.quartz.nip57Zaps.IPrivateZapsDecryptionCache +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.DualCase +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Desktop implementation of IAccount. + * + * Bridges the desktop AccountState.LoggedIn and DesktopLocalCache to the + * shared IAccount interface used by commons ViewModels (ChatroomFeedViewModel, + * ChatNewMessageState, etc.). + * + * Bridges the desktop relay client for DM sending (NIP-04 and NIP-17). + */ +class DesktopIAccount( + private val accountState: AccountState.LoggedIn, + private val localCache: DesktopLocalCache, + private val relayManager: RelayConnectionManager, + val dmSendTracker: DmSendTracker, + private val scope: CoroutineScope, +) : IAccount { + override val signer: NostrSigner = accountState.signer + + override val pubKey: String = accountState.pubKeyHex + + override val showSensitiveContent: Boolean? = null + + override val hiddenWordsCase: List = emptyList() + + override val hiddenUsersHashCodes: Set = emptySet() + + override val spammersHashCodes: Set = emptySet() + + override val chatroomList: ChatroomList = ChatroomList(accountState.pubKeyHex) + + override val nip47SignerState: INwcSignerState = + object : INwcSignerState { + override suspend fun decryptResponse(event: LnZapPaymentResponseEvent): Response? = null + + override suspend fun decryptRequest(event: LnZapPaymentRequestEvent): Request? = null + + override fun isNIP47Author(pubKey: String?): Boolean = false + } + + override val privateZapsDecryptionCache: IPrivateZapsDecryptionCache = + object : IPrivateZapsDecryptionCache { + override fun cachedPrivateZap(zapRequest: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null + + override suspend fun decryptPrivateZap(zapRequest: LnZapRequestEvent): com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent? = null + } + + override fun userProfile(): User = localCache.getOrCreateUser(pubKey) + + override fun isWriteable(): Boolean = !accountState.isReadOnly + + override fun followingKeySet(): Set = emptySet() + + override fun isHidden(user: User): Boolean = false + + override fun isAcceptable(note: Note): Boolean { + // Accept all notes on desktop for now + val event = note.event ?: return true + return !localCache.hasBeenDeleted(event) + } + + override suspend fun sendNip04PrivateMessage(eventTemplate: EventTemplate) { + if (!isWriteable()) return + + val signedEvent = signer.sign(eventTemplate) + val recipient = signedEvent.verifiedRecipientPubKey() + + // Optimistic local add so the message appears immediately + addEventToChatroom(signedEvent, signedEvent.chatroomKey(pubKey)) + + // Broadcast to connected relays + recipient's DM inbox relays + val targetRelays = relayManager.connectedRelays.value.toMutableSet() + if (recipient != null) { + localCache.getOrCreateUser(recipient).dmInboxRelays()?.let { + targetRelays.addAll(it) + } + } + + scope.launch { dmSendTracker.sendAndTrack(signedEvent, targetRelays) } + } + + override suspend fun sendNip17PrivateMessage(template: EventTemplate) { + if (!isWriteable()) return + + val result = NIP17Factory().createMessageNIP17(template, signer) + + // Optimistic local add — use the inner ChatMessageEvent, not the wraps + val innerMsg = result.msg as ChatMessageEvent + addEventToChatroom(innerMsg, innerMsg.chatroomKey(pubKey)) + + // Collect all wraps with their target relays for batch sending + val batch = + result.wraps.map { wrap -> + val recipientKey = wrap.recipientPubKey() + val targetRelays = + if (recipientKey != null) { + val dmRelays = + localCache + .getOrCreateUser(recipientKey) + .dmInboxRelays() + ?.toSet() + dmRelays?.ifEmpty { null } + ?: relayManager.connectedRelays.value + } else { + relayManager.connectedRelays.value + } + wrap to targetRelays + } + + scope.launch { dmSendTracker.sendBatch(batch) } + } + + override suspend fun sendGiftWraps(wraps: List) { + val batch = + wraps.map { wrap -> + val recipientKey = wrap.recipientPubKey() + val targetRelays = + if (recipientKey != null) { + val dmRelays = + localCache + .getOrCreateUser(recipientKey) + .dmInboxRelays() + ?.toSet() + dmRelays?.ifEmpty { null } + ?: relayManager.connectedRelays.value + } else { + relayManager.connectedRelays.value + } + wrap to targetRelays + } + + scope.launch { dmSendTracker.sendBatch(batch) } + } + + private fun addEventToChatroom( + event: com.vitorpamplona.quartz.nip01Core.core.Event, + roomKey: com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey, + ) { + val note = localCache.getOrCreateNote(event.id) + val author = localCache.getOrCreateUser(event.pubKey) + if (note.event == null) { + note.loadEvent(event, author, emptyList()) + } + chatroomList.addMessage(roomKey, note) + } +} 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 0000000000..81d9bbde25 --- /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/DesktopRelaySubscriptionsCoordinator.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt index 9849acfc64..e47b44169c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/DesktopRelaySubscriptionsCoordinator.kt @@ -25,9 +25,13 @@ import com.vitorpamplona.amethyst.commons.relayClient.assemblers.FeedMetadataCoo import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataPreloader import com.vitorpamplona.amethyst.commons.relayClient.preload.MetadataRateLimiter import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.model.DesktopDmRelayState +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +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 kotlinx.coroutines.CoroutineScope @@ -130,11 +134,107 @@ class DesktopRelaySubscriptionsCoordinator( feedMetadata.loadReactionsForNotes(noteIds) } + // -- DM Subscription Support -- + + /** Active DM subscription IDs for cleanup */ + private val activeDmSubIds = mutableSetOf() + + /** + * Subscribes to DM events for the given user. + * + * Creates subscriptions for: + * - NIP-04 DMs TO the user (kind 4) on inbox/DM relays + * - NIP-04 DMs FROM the user (kind 4) on outbox/home relays + * - NIP-59 gift-wrapped DMs (kind 1059) on DM relays + * + * @param userPubKeyHex The logged-in user's pubkey (hex) + * @param dmRelayState Aggregated DM relay state for relay selection + * @param onDmEvent Callback for incoming DM events (kind 4 or kind 1059) + */ + fun subscribeToDms( + userPubKeyHex: HexKey, + dmRelayState: DesktopDmRelayState, + onDmEvent: (Event, NormalizedRelayUrl) -> Unit, + ) { + // Clean up any previous DM subscriptions + unsubscribeFromDms() + + val inboxRelays = dmRelayState.flow.value + val outboxRelays = dmRelayState.outboxFlow.value + + if (inboxRelays.isEmpty() && outboxRelays.isEmpty()) return + + val listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + onDmEvent(event, relay) + } + } + + // NIP-04 DMs TO user on inbox relays + if (inboxRelays.isNotEmpty()) { + val inboxSubId = generateSubId("dm-inbox-${userPubKeyHex.take(8)}") + activeDmSubIds.add(inboxSubId) + client.openReqSubscription( + subId = inboxSubId, + filters = + inboxRelays.associateWith { + listOf(FilterDMs.nip04ToMe(userPubKeyHex)) + }, + listener = listener, + ) + } + + // NIP-04 DMs FROM user on outbox relays + if (outboxRelays.isNotEmpty()) { + val outboxSubId = generateSubId("dm-outbox-${userPubKeyHex.take(8)}") + activeDmSubIds.add(outboxSubId) + client.openReqSubscription( + subId = outboxSubId, + filters = + outboxRelays.associateWith { + listOf(FilterDMs.nip04FromMe(userPubKeyHex)) + }, + listener = listener, + ) + } + + // NIP-59 gift-wrapped DMs on DM relays + if (inboxRelays.isNotEmpty()) { + val giftWrapSubId = generateSubId("giftwrap-${userPubKeyHex.take(8)}") + activeDmSubIds.add(giftWrapSubId) + client.openReqSubscription( + subId = giftWrapSubId, + filters = + inboxRelays.associateWith { + listOf(FilterDMs.giftWrapsToMe(userPubKeyHex)) + }, + listener = listener, + ) + } + } + + /** + * Unsubscribes from all active DM subscriptions. + */ + fun unsubscribeFromDms() { + activeDmSubIds.forEach { subId -> + client.close(subId) + } + activeDmSubIds.clear() + } + /** * Clear all queued requests. * Call when switching accounts or during cleanup. */ fun clear() { + unsubscribeFromDms() feedMetadata.clear() rateLimiter.reset() } 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 8415393da6..d4d3bac207 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 b558853c60..04fb56ac25 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). * @@ -375,6 +485,77 @@ object FilterBuilders { limit = limit, ) + /** + * Creates a filter for NIP-04 DMs (kind 4) sent to a user. + * + * @param pubKeyHex Recipient public key (hex-encoded, 64 chars) + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time >= this value + * @return Filter for DMs addressed to the specified user + */ + fun nip04DmsToUser( + pubKeyHex: String, + limit: Int? = null, + since: Long? = null, + ): Filter = + Filter( + kinds = listOf(4), // PrivateDmEvent.KIND + tags = mapOf("p" to listOf(pubKeyHex)), + limit = limit, + since = since, + ) + + /** + * Creates a filter for NIP-04 DMs (kind 4) sent from a user. + * + * @param pubKeyHex Author public key (hex-encoded, 64 chars) + * @param limit Maximum number of events to request + * @param since Timestamp for events with publication time >= this value + * @return Filter for DMs authored by the specified user + */ + fun nip04DmsFromUser( + pubKeyHex: String, + limit: Int? = null, + since: Long? = null, + ): Filter = + Filter( + kinds = listOf(4), // PrivateDmEvent.KIND + authors = listOf(pubKeyHex), + limit = limit, + since = since, + ) + + /** + * Creates a filter for NIP-59 gift-wrapped events (kind 1059) to a user. + * Gift wraps contain encrypted NIP-17 DMs. + * + * @param pubKeyHex Recipient public key (hex-encoded, 64 chars) + * @param since Timestamp (adjusted -2 days due to randomized created_at) + * @return Filter for gift wraps addressed to the specified user + */ + fun giftWrapsToUser( + pubKeyHex: String, + since: Long? = null, + ): Filter = + Filter( + kinds = listOf(1059), // GiftWrapEvent.KIND + tags = mapOf("p" to listOf(pubKeyHex)), + since = since, + ) + + /** + * Creates a filter for DM relay list events (kind 10050, NIP-17). + * + * @param pubKeyHex Author public key (hex-encoded, 64 chars) + * @return Filter for DM relay list (limit=1 since only latest is needed) + */ + fun dmRelayList(pubKeyHex: String): Filter = + Filter( + kinds = listOf(10050), // ChatMessageRelayListEvent.KIND + authors = listOf(pubKeyHex), + limit = 1, + ) + /** * Creates a filter for long-form content (kind 30023, NIP-23). * diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt new file mode 100644 index 0000000000..e1b9ced846 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/subscriptions/FilterDMs.kt @@ -0,0 +1,230 @@ +/* + * 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.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Filter builders for DM subscriptions on desktop. + * + * Supports two DM protocols: + * - NIP-04: Legacy encrypted DMs (kind 4) + * - NIP-17: Gift-wrapped DMs via NIP-59 (kind 1059) + */ +object FilterDMs { + /** + * Creates a filter for NIP-04 DMs sent TO the user. + * Subscribes on the user's inbox/DM relays. + * + * @param userPubKeyHex The user's public key (hex) + * @param since Optional since timestamp for incremental loading + * @param limit Optional limit on number of events + */ + fun nip04ToMe( + userPubKeyHex: HexKey, + since: Long? = null, + limit: Int? = null, + ): Filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + tags = mapOf("p" to listOf(userPubKeyHex)), + since = since, + limit = limit, + ) + + /** + * Creates a filter for NIP-04 DMs sent FROM the user. + * Subscribes on the user's outbox/home relays. + * + * @param userPubKeyHex The user's public key (hex) + * @param since Optional since timestamp for incremental loading + * @param limit Optional limit on number of events + */ + fun nip04FromMe( + userPubKeyHex: HexKey, + since: Long? = null, + limit: Int? = null, + ): Filter = + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(userPubKeyHex), + since = since, + limit = limit, + ) + + /** + * Creates a filter for NIP-04 DMs in a specific conversation. + * + * Messages TO user: kind 4, authors=group, tags=["p": userPubKey] + * Messages FROM user: kind 4, authors=[userPubKey], tags=["p": group] + * + * @param userPubKeyHex The user's public key (hex) + * @param conversationPubKeys Set of pubkeys in the conversation (excluding the user) + * @param since Optional since timestamp + */ + fun nip04Conversation( + userPubKeyHex: HexKey, + conversationPubKeys: Set, + since: Long? = null, + ): List { + if (conversationPubKeys.isEmpty()) return emptyList() + + return listOf( + // Messages TO me from conversation participants + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = conversationPubKeys.toList(), + tags = mapOf("p" to listOf(userPubKeyHex)), + since = since, + ), + // Messages FROM me to conversation participants + Filter( + kinds = listOf(PrivateDmEvent.KIND), + authors = listOf(userPubKeyHex), + tags = mapOf("p" to conversationPubKeys.toList()), + since = since, + ), + ) + } + + /** + * Creates a filter for NIP-59 gift-wrapped events TO the user. + * Gift wraps (kind 1059) contain encrypted NIP-17 DMs. + * + * The since is adjusted back by 2 days because gift wrap created_at + * timestamps are randomized within a 2-day window for privacy. + * + * @param userPubKeyHex The user's public key (hex) + * @param since Optional since timestamp (will be adjusted -2 days) + */ + fun giftWrapsToMe( + userPubKeyHex: HexKey, + since: Long? = null, + ): Filter = + Filter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(userPubKeyHex)), + since = since?.minus(TimeUtils.twoDays()), + ) +} + +// -- Subscription factory functions -- + +/** + * Creates a subscription config for NIP-04 DMs TO the user (inbox). + * Subscribes on DM/inbox relays. + */ +fun createNip04DmInboxSubscription( + relays: Set, + userPubKeyHex: HexKey, + since: Long? = null, + limit: Int? = null, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (relays.isEmpty() || userPubKeyHex.length != 64) return null + + return SubscriptionConfig( + subId = generateSubId("dm-inbox-${userPubKeyHex.take(8)}"), + filters = listOf(FilterDMs.nip04ToMe(userPubKeyHex, since, limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a subscription config for NIP-04 DMs FROM the user (outbox). + * Subscribes on home/outbox relays. + */ +fun createNip04DmOutboxSubscription( + relays: Set, + userPubKeyHex: HexKey, + since: Long? = null, + limit: Int? = null, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (relays.isEmpty() || userPubKeyHex.length != 64) return null + + return SubscriptionConfig( + subId = generateSubId("dm-outbox-${userPubKeyHex.take(8)}"), + filters = listOf(FilterDMs.nip04FromMe(userPubKeyHex, since, limit)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a subscription config for NIP-59 gift-wrapped DMs TO the user. + * Subscribes on DM/inbox relays. + */ +fun createGiftWrapSubscription( + relays: Set, + userPubKeyHex: HexKey, + since: Long? = null, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (relays.isEmpty() || userPubKeyHex.length != 64) return null + + return SubscriptionConfig( + subId = generateSubId("giftwrap-${userPubKeyHex.take(8)}"), + filters = listOf(FilterDMs.giftWrapsToMe(userPubKeyHex, since)), + relays = relays, + onEvent = onEvent, + onEose = onEose, + ) +} + +/** + * Creates a subscription config for a NIP-04 DM conversation. + * Uses both inbox and outbox relays. + */ +fun createDmConversationSubscription( + inboxRelays: Set, + outboxRelays: Set, + userPubKeyHex: HexKey, + conversationPubKeys: Set, + since: Long? = null, + onEvent: (Event, Boolean, NormalizedRelayUrl, List?) -> Unit, + onEose: (NormalizedRelayUrl, List?) -> Unit = { _, _ -> }, +): SubscriptionConfig? { + if (conversationPubKeys.isEmpty() || userPubKeyHex.length != 64) return null + + val allRelays = inboxRelays + outboxRelays + if (allRelays.isEmpty()) return null + + return SubscriptionConfig( + subId = generateSubId("dm-conv-${userPubKeyHex.take(8)}"), + filters = FilterDMs.nip04Conversation(userPubKeyHex, conversationPubKeys, since), + relays = allRelays, + onEvent = onEvent, + onEose = onEose, + ) +} 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 40695162bd..a6bdbc9085 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 2249717761..b4268710fb 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 af9106f900..4e940173df 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 6956d0e8e0..0fc6ba44a1 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 1d40a2866f..d72b914ef1 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 c239218aea..704a8f03ac 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/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt new file mode 100644 index 0000000000..6c936d8859 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatPane.kt @@ -0,0 +1,618 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.chats + +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.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.LockOpen +import androidx.compose.material.icons.outlined.AddReaction +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +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 +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.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.Note +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.ui.chat.ChatMessageCompose +import com.vitorpamplona.amethyst.commons.ui.chat.ChatroomHeader +import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastBanner +import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus +import com.vitorpamplona.amethyst.commons.ui.chat.GroupChatroomHeader +import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.feeds.FeedState +import com.vitorpamplona.amethyst.commons.util.toTimeAgo +import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState +import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel +import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle +import com.vitorpamplona.quartz.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.NIP17Factory +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import kotlinx.coroutines.launch + +private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") + +/** + * Right panel of the DM split-pane layout (flexible width). + * + * Displays: + * - ChatroomHeader at top (shared component from commons) + * - Message list (LazyColumn, reversed - newest at bottom, auto-scroll) + * - Message input at bottom with Send button and NIP-17 toggle + * + * @param roomKey The chatroom key for the selected conversation + * @param account The user's account (IAccount) + * @param cacheProvider The cache provider for user/note lookups + * @param feedViewModel ChatroomFeedViewModel for message data + * @param messageState ChatNewMessageState for composition + * @param onNavigateToProfile Called when user clicks on a profile + */ +@Composable +fun ChatPane( + roomKey: ChatroomKey, + account: IAccount, + cacheProvider: ICacheProvider, + feedViewModel: ChatroomFeedViewModel, + messageState: ChatNewMessageState, + dmBroadcastStatus: DmBroadcastStatus = DmBroadcastStatus.Idle, + onNavigateToProfile: (String) -> Unit = {}, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + val feedState by feedViewModel.feedState.feedContent.collectAsState() + val messageText by messageState.message.collectAsState() + val isNip17 by messageState.nip17.collectAsState() + val requiresNip17 by messageState.requiresNip17.collectAsState() + + // Resolve users for the header + val users = roomKey.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User } + val isGroup = users.size > 1 + + // Load room into message state + LaunchedEffect(roomKey) { + messageState.load(roomKey) + } + + Column(modifier = modifier.fillMaxSize()) { + // Header + if (isGroup) { + GroupChatroomHeader( + users = users, + onClick = { users.firstOrNull()?.let { onNavigateToProfile(it.pubkeyHex) } }, + ) + } else { + users.firstOrNull()?.let { user -> + ChatroomHeader( + user = user, + onClick = { onNavigateToProfile(user.pubkeyHex) }, + ) + } ?: run { + // Fallback header with raw pubkey + Text( + text = roomKey.users.firstOrNull()?.take(20) ?: "Unknown", + style = MaterialTheme.typography.titleSmall, + modifier = Modifier.padding(10.dp), + ) + } + } + + HorizontalDivider() + + // Broadcast status banner + DmBroadcastBanner(status = dmBroadcastStatus) + + // Message list + Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + when (feedState) { + is FeedState.Loading -> { + LoadingState("Loading messages...") + } + + is FeedState.Empty -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + "No messages yet. Send the first one!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + is FeedState.Loaded -> { + val loaded = feedState as FeedState.Loaded + val loadedState by loaded.feed.collectAsState() + val messages = loadedState.list + + MessageList( + messages = messages, + account = account, + cacheProvider = cacheProvider, + onAuthorClick = onNavigateToProfile, + onReaction = { note, emoji -> + scope.launch { + try { + sendWrappedReaction(note, emoji, roomKey, account) + } catch (e: Exception) { + println("Failed to send reaction: ${e.message}") + } + } + }, + ) + } + + is FeedState.FeedError -> { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + Text( + "Error loading messages", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + + HorizontalDivider() + + // Message input + MessageInput( + messageText = messageText.text, + isNip17 = isNip17, + requiresNip17 = requiresNip17, + canSend = messageState.canSend, + onMessageChange = { messageState.updateMessage(messageText.copy(text = it)) }, + onToggleNip17 = { messageState.toggleNip17() }, + onSend = { + scope.launch { + if (messageState.send()) { + messageState.clear() + } + } + }, + ) + } +} + +/** + * Scrollable message list, reversed so newest messages appear at the bottom. + */ +@Composable +private fun MessageList( + messages: List, + account: IAccount, + cacheProvider: ICacheProvider, + onAuthorClick: (String) -> Unit, + onReaction: (Note, String) -> Unit = { _, _ -> }, +) { + val listState = rememberLazyListState() + + // Auto-scroll to bottom when new messages arrive + LaunchedEffect(messages.size) { + if (messages.isNotEmpty()) { + listState.animateScrollToItem(0) + } + } + + LazyColumn( + state = listState, + reverseLayout = true, + verticalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier.fillMaxSize().padding(vertical = 4.dp), + ) { + items(messages, key = { it.idHex }) { note -> + val isMe = note.author?.pubkeyHex == account.pubKey + val isDraft = note.isDraft() + + MessageWithReactions( + note = note, + isMe = isMe, + isDraft = isDraft, + account = account, + onAuthorClick = onAuthorClick, + onReaction = { emoji -> onReaction(note, emoji) }, + ) + } + } +} + +/** + * Common reaction emojis for quick access. + */ +private val QUICK_REACTIONS = + listOf( + "\uD83D\uDC4D", // thumbs up + "\u2764\uFE0F", // red heart + "\uD83D\uDE02", // face with tears of joy + "\uD83D\uDD25", // fire + ) + +/** + * Wraps a ChatMessageCompose with reaction icon in the detail row + * and displays existing reactions below the bubble. + */ +@OptIn(ExperimentalComposeUiApi::class) +@Composable +private fun MessageWithReactions( + note: Note, + isMe: Boolean, + isDraft: Boolean, + account: IAccount, + onAuthorClick: (String) -> Unit, + onReaction: (String) -> Unit, +) { + var isHovered by remember { mutableStateOf(false) } + var showPicker by remember { mutableStateOf(false) } + val showIcon = (isHovered || showPicker) && !isDraft + + // Decrypt NIP-04 content asynchronously; NIP-17 content is already plaintext + val event = note.event + var decryptedContent by remember(note.idHex) { mutableStateOf(null) } + + LaunchedEffect(note.idHex, event) { + decryptedContent = + when (event) { + is PrivateDmEvent -> { + try { + event.decryptContent(account.signer) + } catch (_: Exception) { + event.content + } + } + + else -> { + event?.content + } + } + } + + // Observe reaction changes + val reactions = note.reactions + + Box( + modifier = + Modifier + .fillMaxWidth() + .onPointerEvent(PointerEventType.Enter) { isHovered = true } + .onPointerEvent(PointerEventType.Exit) { isHovered = false }, + ) { + Column { + ChatMessageCompose( + note = note, + isLoggedInUser = isMe, + isDraft = isDraft, + isComplete = true, + hasDetailsToShow = reactions.isNotEmpty(), + drawAuthorInfo = !isMe, + onClick = { false }, + onAuthorClick = { + note.author?.pubkeyHex?.let { onAuthorClick(it) } + }, + authorLine = { + note.author?.let { author -> + Text( + text = author.toBestDisplayName(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + }, + detailRow = { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + // Timestamp + note.createdAt()?.let { timestamp -> + Text( + text = timestamp.toTimeAgo(withDot = false), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + + // Reaction counts + reactions.forEach { (emoji, notes) -> + Surface( + shape = RoundedCornerShape(10.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f), + ) { + Text( + text = + if (notes.size > 1) { + "$emoji ${notes.size}" + } else { + emoji + }, + style = MaterialTheme.typography.labelSmall, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 1.dp), + ) + } + } + + // AddReaction icon on hover + if (showIcon) { + Box { + IconButton( + onClick = { showPicker = !showPicker }, + modifier = Modifier.size(20.dp), + ) { + Icon( + imageVector = Icons.Outlined.AddReaction, + contentDescription = "React", + modifier = Modifier.size(14.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (showPicker) { + Popup( + alignment = Alignment.TopCenter, + offset = IntOffset(0, -44), + onDismissRequest = { showPicker = false }, + properties = PopupProperties(focusable = true), + ) { + ReactionBar( + onReaction = { emoji -> + onReaction(emoji) + showPicker = false + }, + ) + } + } + } + } + } + }, + ) { _ -> + SelectionContainer { + Text( + text = decryptedContent ?: "", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} + +/** + * Sends a NIP-17 gift-wrapped reaction to a note within a group DM. + */ +private suspend fun sendWrappedReaction( + note: Note, + emoji: String, + roomKey: ChatroomKey, + account: IAccount, +) { + val event = note.event + if (event == null) { + println("sendWrappedReaction: note.event is null for ${note.idHex}") + return + } + val eventBundle = EventHintBundle(event) + val recipients = roomKey.users.toList() + println("sendWrappedReaction: sending '$emoji' to ${recipients.size} recipients for event ${event.id.take(8)}") + + val result = + NIP17Factory().createReactionWithinGroup( + content = emoji, + originalNote = eventBundle, + to = recipients, + signer = account.signer, + ) + + println("sendWrappedReaction: created ${result.wraps.size} wraps, broadcasting...") + account.sendGiftWraps(result.wraps) +} + +/** + * Floating row of quick emoji reaction buttons. + */ +@Composable +private fun ReactionBar(onReaction: (String) -> Unit) { + Surface( + shape = RoundedCornerShape(16.dp), + color = MaterialTheme.colorScheme.surfaceVariant, + shadowElevation = 2.dp, + tonalElevation = 2.dp, + ) { + Row( + modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(0.dp), + ) { + QUICK_REACTIONS.forEach { emoji -> + TextButton( + onClick = { onReaction(emoji) }, + modifier = Modifier.size(32.dp), + contentPadding = + androidx.compose.foundation.layout + .PaddingValues(0.dp), + ) { + Text( + text = emoji, + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } +} + +/** + * Message input area at the bottom of the chat pane. + */ +@Composable +private fun MessageInput( + messageText: String, + isNip17: Boolean, + requiresNip17: Boolean, + canSend: Boolean, + onMessageChange: (String) -> Unit, + onToggleNip17: () -> Unit, + onSend: () -> Unit, +) { + Column(modifier = Modifier.fillMaxWidth().padding(8.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + OutlinedTextField( + value = messageText, + onValueChange = onMessageChange, + modifier = + Modifier + .weight(1f) + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + // Cmd+Enter (Mac) or Ctrl+Enter to send + val hasModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed + if (event.key == Key.Enter && hasModifier) { + if (canSend) onSend() + true + } else { + false + } + }, + placeholder = { Text("Message... (${if (isMacOS) "\u2318" else "Ctrl"}+Enter to send)") }, + singleLine = false, + maxLines = 4, + shape = RoundedCornerShape(12.dp), + ) + + IconButton( + onClick = onSend, + enabled = canSend, + modifier = Modifier.size(40.dp), + ) { + Icon( + Icons.AutoMirrored.Filled.Send, + contentDescription = "Send", + tint = + if (canSend) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f) + }, + ) + } + } + + // NIP-17 indicator + Spacer(Modifier.height(4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.padding(start = 4.dp), + ) { + IconButton( + onClick = onToggleNip17, + enabled = !requiresNip17, + modifier = Modifier.size(20.dp), + ) { + Icon( + imageVector = if (isNip17) Icons.Default.Lock else Icons.Default.LockOpen, + contentDescription = if (isNip17) "NIP-17 (encrypted)" else "NIP-04 (legacy)", + modifier = Modifier.size(16.dp), + tint = + if (isNip17) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + }, + ) + } + Spacer(Modifier.width(4.dp)) + Text( + text = if (isNip17) "NIP-17" else "NIP-04", + style = MaterialTheme.typography.labelSmall, + color = + if (isNip17) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f) + }, + ) + if (requiresNip17) { + Spacer(Modifier.width(4.dp)) + Text( + text = "(required for groups)", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt new file mode 100644 index 0000000000..26e0cbd810 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ChatroomListState.kt @@ -0,0 +1,252 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.chats + +import androidx.compose.runtime.Stable +import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.model.privateChats.Chatroom +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.RelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +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.nip04Dm.messages.PrivateDmEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +/** + * Represents a conversation entry in the list pane. + */ +@Stable +data class ConversationItem( + val roomKey: ChatroomKey, + val chatroom: Chatroom, + val users: List, + val displayName: String, + val lastMessagePreview: String, + val lastMessageTimestamp: Long, + val isGroup: Boolean, + val hasUnread: Boolean, +) + +/** + * Tab selection for the conversation list. + */ +enum class ConversationTab { + KNOWN, + NEW, +} + +/** + * Manages conversation list state for the desktop DM screen. + * + * Derives known/new rooms from IAccount.chatroomList: + * - Known: rooms where the user has sent a message (ownerSentMessage = true) + * - New: rooms where the user has NOT sent a message (incoming from unknown) + * + * Provides selectedTab and selectedRoom StateFlows that drive the UI. + */ +@Stable +class ChatroomListState( + private val account: IAccount, + private val cacheProvider: ICacheProvider, + private val relayManager: RelayConnectionManager, + private val localCache: DesktopLocalCache, + private val scope: CoroutineScope, +) { + private val _selectedTab = MutableStateFlow(ConversationTab.KNOWN) + val selectedTab: StateFlow = _selectedTab.asStateFlow() + + private val _selectedRoom = MutableStateFlow(null) + val selectedRoom: StateFlow = _selectedRoom.asStateFlow() + + private val _knownRooms = MutableStateFlow>(emptyList()) + val knownRooms: StateFlow> = _knownRooms.asStateFlow() + + private val _newRooms = MutableStateFlow>(emptyList()) + val newRooms: StateFlow> = _newRooms.asStateFlow() + + // Cache decrypted NIP-04 content by event id to avoid re-decrypting every poll + private val decryptedContentCache = mutableMapOf() + + // Track pubkeys we've already requested metadata for + private val fetchedMetadataKeys = mutableSetOf() + + init { + scope.launch(Dispatchers.IO) { + while (isActive) { + refreshRooms() + delay(2000) + } + } + } + + fun selectTab(tab: ConversationTab) { + _selectedTab.value = tab + } + + fun selectRoom(roomKey: ChatroomKey) { + _selectedRoom.value = roomKey + } + + fun clearSelection() { + _selectedRoom.value = null + } + + private suspend fun decryptPreview(event: com.vitorpamplona.quartz.nip01Core.core.Event?): String { + if (event == null) return "" + return when (event) { + is PrivateDmEvent -> { + decryptedContentCache.getOrPut(event.id) { + try { + event.decryptContent(account.signer) + } catch (_: Exception) { + event.content + } + } + } + + else -> { + event.content + } + }.take(80) + } + + private var metadataSubCounter = 0 + + private fun fetchMetadataIfNeeded(pubkeys: List) { + val needed = pubkeys.filter { it !in fetchedMetadataKeys && it.length == 64 } + if (needed.isEmpty()) return + + val relays = relayManager.relayStatuses.value.keys + if (relays.isEmpty()) return + + fetchedMetadataKeys.addAll(needed) + + val subId = "dm-meta-${metadataSubCounter++}" + val filter = + Filter( + kinds = listOf(MetadataEvent.KIND), + authors = needed, + limit = needed.size, + ) + + val listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + } + } + } + + relayManager.subscribe(subId, listOf(filter), relays, listener) + + // Auto-close after 15s — enough time for all relays to respond + scope.launch { + delay(15_000) + relayManager.unsubscribe(subId) + } + } + + private suspend fun refreshRooms() { + val chatroomList = account.chatroomList + val known = mutableListOf() + val new = mutableListOf() + + // Collect room entries (forEach is non-suspend) + val entries = mutableListOf>() + chatroomList.rooms.forEach { key, chatroom -> entries.add(key to chatroom) } + + // Collect pubkeys needing metadata across all rooms + val pubkeysNeedingMetadata = mutableListOf() + + for ((key, chatroom) in entries) { + // Skip rooms with no messages + if (chatroom.messages.isEmpty()) continue + + val users = key.users.mapNotNull { cacheProvider.getUserIfExists(it) as? User } + + // Collect pubkeys without profile info + for (pubkey in key.users) { + val user = cacheProvider.getUserIfExists(pubkey) as? User + if (user == null || user.metadataOrNull() == null) { + pubkeysNeedingMetadata.add(pubkey) + } + } + + val displayName = + if (users.isNotEmpty()) { + users.joinToString(", ") { it.toBestDisplayName() } + } else { + key.users + .firstOrNull() + ?.take(12) + ?.let { "$it..." } ?: "Unknown" + } + + val newestMessage = chatroom.newestMessage + val lastPreview = decryptPreview(newestMessage?.event) + val lastTimestamp = newestMessage?.createdAt() ?: 0L + + val item = + ConversationItem( + roomKey = key, + chatroom = chatroom, + users = users, + displayName = displayName, + lastMessagePreview = lastPreview, + lastMessageTimestamp = lastTimestamp, + isGroup = key.users.size > 1, + hasUnread = !chatroom.ownerSentMessage && newestMessage != null, + ) + + if (chatroom.ownerSentMessage) { + known.add(item) + } else { + new.add(item) + } + } + + // Batch-request metadata for all users who need it + fetchMetadataIfNeeded(pubkeysNeedingMetadata) + + // Sort by most recent message + _knownRooms.value = known.sortedByDescending { it.lastMessageTimestamp } + _newRooms.value = new.sortedByDescending { it.lastMessageTimestamp } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt new file mode 100644 index 0000000000..ec2c9d9e73 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/ConversationListPane.kt @@ -0,0 +1,369 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.chats + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.focusable +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.fillMaxHeight +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.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Group +import androidx.compose.material3.FilterChip +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.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.components.UserAvatar +import com.vitorpamplona.amethyst.commons.util.toTimeAgo +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import kotlinx.coroutines.launch + +/** + * Left panel of the DM split-pane layout (280dp fixed width). + * + * Displays two tabs (Known / New) with conversation cards showing: + * - User avatar, display name, last message preview, timestamp + * - Unread indicator (blue dot) for new messages + * - Selected conversation highlighting + * - "+" button for starting new DMs + * + * @param state ChatroomListState managing the conversation list + * @param selectedRoom Currently selected room key (for highlighting) + * @param onConversationSelected Called when a conversation card is tapped + * @param onNewConversation Called when the "+" button is tapped + */ +@Composable +fun ConversationListPane( + state: ChatroomListState, + selectedRoom: ChatroomKey?, + onConversationSelected: (ChatroomKey) -> Unit, + onNewConversation: () -> Unit = {}, + focusRequester: FocusRequester = remember { FocusRequester() }, + modifier: Modifier = Modifier, +) { + val selectedTab by state.selectedTab.collectAsState() + val knownRooms by state.knownRooms.collectAsState() + val newRooms by state.newRooms.collectAsState() + val scope = rememberCoroutineScope() + + val currentList = + when (selectedTab) { + ConversationTab.KNOWN -> knownRooms + ConversationTab.NEW -> newRooms + } + + // Track focused index for keyboard navigation + var focusedIndex by remember { mutableIntStateOf(-1) } + val listScrollState = rememberLazyListState() + + // Derive the focused index from the selected room when it changes externally + val selectedIndex by remember(selectedRoom, currentList) { + derivedStateOf { + currentList.indexOfFirst { it.roomKey == selectedRoom } + } + } + + Column( + modifier = + modifier + .width(280.dp) + .fillMaxHeight() + .focusRequester(focusRequester) + .focusable() + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + + when (event.key) { + Key.DirectionDown -> { + if (currentList.isNotEmpty()) { + val newIndex = + if (focusedIndex < 0) { + 0 + } else { + (focusedIndex + 1).coerceAtMost(currentList.size - 1) + } + focusedIndex = newIndex + scope.launch { listScrollState.animateScrollToItem(newIndex) } + } + true + } + + Key.DirectionUp -> { + if (currentList.isNotEmpty()) { + val newIndex = + if (focusedIndex < 0) { + currentList.size - 1 + } else { + (focusedIndex - 1).coerceAtLeast(0) + } + focusedIndex = newIndex + scope.launch { listScrollState.animateScrollToItem(newIndex) } + } + true + } + + Key.Enter -> { + if (focusedIndex in currentList.indices) { + onConversationSelected(currentList[focusedIndex].roomKey) + } + true + } + + else -> { + false + } + } + }, + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "Messages", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + IconButton( + onClick = onNewConversation, + modifier = Modifier.size(32.dp), + ) { + Icon( + Icons.Default.Add, + contentDescription = "New conversation", + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } + + // Tab selector + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = selectedTab == ConversationTab.KNOWN, + onClick = { + state.selectTab(ConversationTab.KNOWN) + focusedIndex = -1 + }, + label = { + Text("Known (${knownRooms.size})") + }, + ) + FilterChip( + selected = selectedTab == ConversationTab.NEW, + onClick = { + state.selectTab(ConversationTab.NEW) + focusedIndex = -1 + }, + label = { + Text("New (${newRooms.size})") + }, + ) + } + + Spacer(Modifier.height(8.dp)) + + // Conversation list + if (currentList.isEmpty()) { + Column( + modifier = Modifier.fillMaxWidth().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + text = + if (selectedTab == ConversationTab.KNOWN) { + "No conversations yet" + } else { + "No new messages" + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } else { + LazyColumn( + state = listScrollState, + modifier = Modifier.weight(1f), + ) { + items(currentList, key = { it.roomKey.hashCode() }) { item -> + val index = currentList.indexOf(item) + val isFocused = index == focusedIndex + ConversationCard( + item = item, + isSelected = selectedRoom == item.roomKey, + isFocused = isFocused, + onClick = { + focusedIndex = index + onConversationSelected(item.roomKey) + }, + ) + } + } + } + } +} + +/** + * A single conversation card in the list. + */ +@Composable +private fun ConversationCard( + item: ConversationItem, + isSelected: Boolean, + isFocused: Boolean = false, + onClick: () -> Unit, +) { + val backgroundColor = + when { + isSelected -> MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + isFocused -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f) + else -> MaterialTheme.colorScheme.surface + } + + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .background(backgroundColor) + .padding(horizontal = 12.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + // Unread indicator + if (item.hasUnread) { + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary), + ) + Spacer(Modifier.width(6.dp)) + } else { + Spacer(Modifier.width(14.dp)) + } + + // Avatar + val firstUser = item.users.firstOrNull() + if (firstUser != null) { + UserAvatar( + userHex = firstUser.pubkeyHex, + pictureUrl = firstUser.profilePicture(), + size = 40.dp, + ) + } else if (item.isGroup) { + Icon( + Icons.Default.Group, + contentDescription = "Group", + modifier = Modifier.size(40.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.width(10.dp)) + + // Name, preview, timestamp + Column(modifier = Modifier.weight(1f)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = item.displayName, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f, fill = false), + ) + + if (item.lastMessageTimestamp > 0) { + Text( + text = item.lastMessageTimestamp.toTimeAgo(withDot = false), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + } + + if (item.lastMessagePreview.isNotEmpty()) { + Text( + text = item.lastMessagePreview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + // Group indicator + if (item.isGroup) { + Text( + text = "Group (${item.users.size + 1})", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.7f), + ) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt new file mode 100644 index 0000000000..599ee6929d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DesktopMessagesScreen.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.chats + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.isCtrlPressed +import androidx.compose.ui.input.key.isMetaPressed +import androidx.compose.ui.input.key.isShiftPressed +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.model.IAccount +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus +import com.vitorpamplona.amethyst.commons.viewmodels.ChatNewMessageState +import com.vitorpamplona.amethyst.commons.viewmodels.ChatroomFeedViewModel +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.model.DesktopIAccount +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager + +private val isMacOS = System.getProperty("os.name").lowercase().contains("mac") + +/** + * Desktop DM screen with split-pane layout. + * + * Left pane (280dp): ConversationListPane with Known/New tabs + * Right pane (flex): ChatPane with messages + input, or empty state + * + * @param account The user's IAccount for DM operations + * @param cacheProvider ICacheProvider for user/note lookups + * @param onNavigateToProfile Called when navigating to a user profile + */ +@Composable +fun DesktopMessagesScreen( + account: IAccount, + cacheProvider: ICacheProvider, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + onNavigateToProfile: (String) -> Unit = {}, +) { + val scope = rememberCoroutineScope() + val listState = + remember(account) { + ChatroomListState(account, cacheProvider, relayManager, localCache, scope) + } + val selectedRoom by listState.selectedRoom.collectAsState() + val listFocusRequester = remember { FocusRequester() } + var showNewDmDialog by remember { mutableStateOf(false) } + + Row( + modifier = + Modifier + .fillMaxSize() + .onPreviewKeyEvent { event -> + if (event.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false + val isModifier = if (isMacOS) event.isMetaPressed else event.isCtrlPressed + + when { + // Escape -> deselect conversation + event.key == Key.Escape -> { + listState.clearSelection() + true + } + + // Cmd+Shift+N / Ctrl+Shift+N -> new DM + event.key == Key.N && isModifier && event.isShiftPressed -> { + showNewDmDialog = true + true + } + + else -> { + false + } + } + }, + ) { + // Left pane: conversation list (280dp fixed) + ConversationListPane( + state = listState, + selectedRoom = selectedRoom, + onConversationSelected = { roomKey -> + listState.selectRoom(roomKey) + }, + onNewConversation = { showNewDmDialog = true }, + focusRequester = listFocusRequester, + ) + + VerticalDivider(modifier = Modifier.fillMaxHeight()) + + // Right pane: chat or empty state (flex) + Box(modifier = Modifier.weight(1f).fillMaxHeight()) { + val currentRoom = selectedRoom + if (currentRoom != null) { + // Create feed VM and message state scoped to the selected room + val feedViewModel = + remember(currentRoom) { + ChatroomFeedViewModel(currentRoom, account, cacheProvider) + } + val messageState = + remember(currentRoom) { + ChatNewMessageState(account, cacheProvider, scope) + } + + val broadcastStatus = + if (account is DesktopIAccount) { + account.dmSendTracker.status + .collectAsState() + .value + } else { + DmBroadcastStatus.Idle + } + + ChatPane( + roomKey = currentRoom, + account = account, + cacheProvider = cacheProvider, + feedViewModel = feedViewModel, + messageState = messageState, + dmBroadcastStatus = broadcastStatus, + onNavigateToProfile = onNavigateToProfile, + ) + } else { + // Empty state + EmptyConversationState() + } + } + } + + if (showNewDmDialog) { + NewDmDialog( + cacheProvider = cacheProvider, + relayManager = relayManager, + localCache = localCache, + onUserSelected = { roomKey -> + listState.selectRoom(roomKey) + showNewDmDialog = false + }, + onDismiss = { showNewDmDialog = false }, + ) + } +} + +/** + * Shown when no conversation is selected in the right pane. + */ +@Composable +private fun EmptyConversationState() { + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + androidx.compose.foundation.layout.Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + Icons.Default.Email, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f), + modifier = Modifier.size(48.dp), + ) + androidx.compose.foundation.layout.Spacer( + modifier = Modifier.height(16.dp), + ) + Text( + "Select a conversation to start messaging", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt new file mode 100644 index 0000000000..216d3ab439 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/DmSendTracker.kt @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.chats + +import com.vitorpamplona.amethyst.commons.ui.chat.DmBroadcastStatus +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.sendAndWaitForResponseDetailed +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class DmSendTracker( + private val client: INostrClient, +) { + private val _status = MutableStateFlow(DmBroadcastStatus.Idle) + val status: StateFlow = _status.asStateFlow() + + /** + * Sends multiple event/relay pairs (e.g. NIP-17 wraps) and aggregates results. + * Shows per-relay success/failure across all wraps. + */ + suspend fun sendBatch(events: List>>) { + if (events.isEmpty()) return + + val totalRelays = events.sumOf { it.second.size } + if (totalRelays == 0) { + _status.value = DmBroadcastStatus.Failed("No relays available") + delay(3000) + _status.value = DmBroadcastStatus.Idle + return + } + + _status.value = DmBroadcastStatus.Sending(0, totalRelays) + + val allSuccessful = mutableSetOf() + + for ((event, relays) in events) { + val results = client.sendAndWaitForResponseDetailed(event, relays, 10) + allSuccessful.addAll(results.filter { it.value }.keys) + _status.value = DmBroadcastStatus.Sending(allSuccessful.size, totalRelays) + } + + _status.value = + if (allSuccessful.isNotEmpty()) { + DmBroadcastStatus.Sent( + relayCount = allSuccessful.size, + relayUrls = allSuccessful.map { it.url }, + ) + } else { + DmBroadcastStatus.Failed("No relay accepted the message") + } + + delay(3000) + _status.value = DmBroadcastStatus.Idle + } + + /** + * Sends a single event to relays with tracking. + */ + suspend fun sendAndTrack( + event: Event, + relays: Set, + ) { + sendBatch(listOf(event to relays)) + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt new file mode 100644 index 0000000000..a015930132 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/chats/NewDmDialog.kt @@ -0,0 +1,272 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui.chats + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +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.filled.Clear +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +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.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import com.vitorpamplona.amethyst.commons.model.User +import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider +import com.vitorpamplona.amethyst.commons.search.SearchResult +import com.vitorpamplona.amethyst.commons.ui.components.UserSearchCard +import com.vitorpamplona.amethyst.commons.viewmodels.SearchBarState +import com.vitorpamplona.amethyst.desktop.cache.DesktopLocalCache +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.subscriptions.createMetadataSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.createSearchPeopleSubscription +import com.vitorpamplona.amethyst.desktop.subscriptions.rememberSubscription +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull + +@Composable +fun NewDmDialog( + cacheProvider: ICacheProvider, + relayManager: DesktopRelayConnectionManager, + localCache: DesktopLocalCache, + onUserSelected: (ChatroomKey) -> Unit, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + val searchState = remember { SearchBarState(cacheProvider, scope) } + val searchText by searchState.searchText.collectAsState() + val bech32Results by searchState.bech32Results.collectAsState() + val cachedUsers by searchState.cachedUserResults.collectAsState() + val relaySearchResults by searchState.relaySearchResults.collectAsState() + val isSearchingRelays by searchState.isSearchingRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val focusRequester = remember { FocusRequester() } + + // NIP-50 relay search when local cache has few/no results + rememberSubscription(relayStatuses, searchText, cachedUsers.size, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty()) return@rememberSubscription null + + if (searchState.shouldSearchRelays) { + searchState.startRelaySearch() + createSearchPeopleSubscription( + relays = configuredRelays, + searchQuery = searchText, + limit = 20, + onEvent = { event, _, _, _ -> + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + val user = localCache.getUserIfExists(event.pubKey) + if (user != null) { + searchState.addRelaySearchResult(user) + } + } + }, + onEose = { _, _ -> + searchState.endRelaySearch() + }, + ) + } else { + null + } + } + + // Bech32 npub metadata loading + rememberSubscription(relayStatuses, searchText, relayManager = relayManager) { + val configuredRelays = relayStatuses.keys + if (configuredRelays.isEmpty() || searchText.length < 2) { + return@rememberSubscription null + } + + val pubkeyHex = decodePublicKeyAsHexOrNull(searchText) + if (pubkeyHex != null) { + createMetadataSubscription( + relays = configuredRelays, + pubKeyHex = pubkeyHex, + onEvent = { event, _, _, _ -> + if (event is MetadataEvent) { + localCache.consumeMetadata(event) + } + }, + ) + } else { + null + } + } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + Dialog(onDismissRequest = onDismiss) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + ) { + Column( + modifier = Modifier.padding(24.dp).fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + "New Message", + style = MaterialTheme.typography.titleLarge, + ) + + OutlinedTextField( + value = searchText, + onValueChange = { searchState.updateSearchText(it) }, + modifier = + Modifier + .fillMaxWidth() + .focusRequester(focusRequester), + placeholder = { Text("Search users by name or npub...") }, + leadingIcon = { + Icon(Icons.Default.Search, contentDescription = null) + }, + trailingIcon = { + if (searchText.isNotEmpty()) { + IconButton(onClick = { searchState.clearSearch() }) { + Icon(Icons.Default.Clear, contentDescription = "Clear") + } + } + }, + singleLine = true, + ) + + LazyColumn( + modifier = Modifier.fillMaxWidth().heightIn(max = 400.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + // Bech32 user results + val userResults = + bech32Results.filterIsInstance() + items(userResults) { result -> + val user = + cacheProvider.getUserIfExists(result.pubKeyHex) as? User + if (user != null) { + UserSearchCard( + user = user, + onClick = { + onUserSelected( + ChatroomKey(setOf(user.pubkeyHex)), + ) + }, + ) + } else { + // Minimal card for unloaded users + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + ) { + Text( + result.displayId, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + + // Cached user results + items(cachedUsers) { user -> + UserSearchCard( + user = user, + onClick = { + onUserSelected( + ChatroomKey(setOf(user.pubkeyHex)), + ) + }, + ) + } + + // Relay search results + items(relaySearchResults) { user -> + UserSearchCard( + user = user, + onClick = { + onUserSelected( + ChatroomKey(setOf(user.pubkeyHex)), + ) + }, + ) + } + + // Relay search loading indicator + if (isSearchingRelays) { + item { + Box( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp, + ) + } + } + } + + // No results hint + if (searchText.length >= 2 && + !isSearchingRelays && + userResults.isEmpty() && + cachedUsers.isEmpty() && + relaySearchResults.isEmpty() + ) { + item { + Text( + "No users found", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } +} diff --git a/docs/chess-engine-refactoring.md b/docs/chess-engine-refactoring.md new file mode 100644 index 0000000000..6e773646a2 --- /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 0000000000..e6b942434e --- /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 5cc49456a2..96bd8a8856 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,10 +1,9 @@ [versions] accompanistAdaptive = "0.37.3" cachemapVersion = "0.2.4" -comDitchoomBufferCompression = "2.1.0" composeMultiplatform = "1.10.0" activityCompose = "1.12.4" -agp = "9.0.0" +agp = "9.1.0" android-compileSdk = "36" android-minSdk = "26" android-targetSdk = "36" @@ -29,6 +28,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" @@ -52,7 +52,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" @@ -103,6 +103,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" } @@ -118,7 +119,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" } -com-ditchoom-buffer-compression = { module = "com.ditchoom:buffer-compression", version.ref = "comDitchoomBufferCompression" } +coil-video = { group = "io.coil-kt.coil3", name = "coil-video", version.ref = "coil" } dev-whyoleg-cryptography-provider-apple-optimal = { module = "dev.whyoleg.cryptography:cryptography-provider-optimal", version.ref = "devWhyolegCryptography" } 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" } @@ -130,6 +131,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 7e74ebf99c..1bdd7b167e 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.3.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 d292ff3eb5..66a9c60610 100644 --- a/quartz/build.gradle.kts +++ b/quartz/build.gradle.kts @@ -16,7 +16,6 @@ plugins { kotlin { compilerOptions { - freeCompilerArgs.add("-Xstring-concat=inline") freeCompilerArgs.add("-Xexpect-actual-classes") } jvm { @@ -25,10 +24,16 @@ kotlin { } } - androidLibrary { + android { 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) @@ -145,43 +150,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) } } @@ -202,8 +214,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") } } @@ -249,7 +261,6 @@ kotlin { iosTest { dependsOn(commonTest.get()) dependencies { - } } @@ -267,20 +278,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 f5d061223c..d1f6fe2de8 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 fd0fcf7f80..9a0edb3fb0 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 ed307e6da0..c2ec4880b1 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 ac30d21213..ef1589a091 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 0a2b79b2ef..625c4e82c9 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 bc28ce35f3..fc97ca4883 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 8131635d74..a17fb31d70 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 1787b00907..04d99c1f5b 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/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.kt index 98709640b3..e553dbe8dd 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.kt @@ -23,7 +23,9 @@ package com.vitorpamplona.quartz.nip01Core.core import android.os.Parcel import android.os.Parcelable import androidx.compose.runtime.Stable +import kotlinx.serialization.Serializable +@Serializable @Stable actual data class Address actual constructor( actual val kind: Kind, 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 4c9a67cc5a..27e5a40da8 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/InteractiveStoryBaseEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryBaseEvent.kt index 5b84588be0..a4e2516dd1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryBaseEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/interactiveStories/InteractiveStoryBaseEvent.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.experimental.interactiveStories +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.experimental.interactiveStories.tags.StoryOptionTag import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -27,6 +28,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +@Stable open class InteractiveStoryBaseEvent( id: HexKey, pubKey: HexKey, 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 6a32f886e0..51d45eb745 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 f1de381377..992bbb51d7 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 9fbdde36b4..2f5732d443 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 9ecaa83d6a..4f90255274 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 622f854d2a..f236a2c1c4 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 a387cc632f..abc6791baa 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 de69f6b359..644638d3c8 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 2828d086c0..1c9ad080bc 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 57975c7e5e..e30ad7958b 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 b3ae86a652..7022d353ec 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/metadata/MetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt index 5e141493ea..eaae4027de 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/metadata/MetadataEvent.kt @@ -26,7 +26,6 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.JsonMapper import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip01Core.core.builder -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent.Companion.updateOrDeleteTagNames import com.vitorpamplona.quartz.nip01Core.metadata.tags.AboutTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.BannerTag import com.vitorpamplona.quartz.nip01Core.metadata.tags.DisplayNameTag @@ -54,7 +53,6 @@ import com.vitorpamplona.quartz.utils.text import kotlinx.coroutines.CancellationException import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull.content import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt index 25af7f7eb4..4af4749eae 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientSendAndWaitExt.kt @@ -45,7 +45,18 @@ suspend fun INostrClient.sendAndWaitForResponse( event: Event, relayList: Set, timeoutInSeconds: Long = 15, -): Boolean { +): Boolean = sendAndWaitForResponseDetailed(event, relayList, timeoutInSeconds).any { it.value } + +/** + * Sends an event to the given relays and waits for OK responses. + * Returns per-relay results: relay URL -> accepted (true/false). + */ +@OptIn(DelicateCoroutinesApi::class) +suspend fun INostrClient.sendAndWaitForResponseDetailed( + event: Event, + relayList: Set, + timeoutInSeconds: Long = 15, +): Map { val resultChannel = Channel(UNLIMITED) Log.d("sendAndWaitForResponse", "Waiting for ${relayList.size} responses") @@ -124,5 +135,5 @@ suspend fun INostrClient.sendAndWaitForResponse( Log.d("sendAndWaitForResponse", "Finished with ${receivedResults.size} results") - return receivedResults.any { it.value } + return receivedResults } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt index d868297c2f..ac84dfd84e 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthStatus.kt @@ -20,16 +20,17 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.client.auth +import androidx.collection.LruCache import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent class RelayAuthStatus { // Keeps track of auth responses to update the relay with all filters // after the authentication happen - private val authResponseWatcher: MutableMap = mutableMapOf() + private val authResponseWatcher: LruCache = LruCache(10) // Avoids sending multiple replies for each auth. - private val uniqueAuthChallengesSent: MutableSet = mutableSetOf() + private val uniqueAuthChallengesSent: LruCache = LruCache(10) enum class AuthEventReceiptStatus { AUTHENTICATING, @@ -43,15 +44,14 @@ class RelayAuthStatus { ) fun saveAuthSubmission(authEvent: RelayAuthEvent): Boolean { - val challenge = authEvent.challenge() - if (challenge == null) return false + val challenge = authEvent.challenge() ?: return false val challengePair = ChallengePair(authEvent.pubKey, challenge) // only send replies to new challenges to avoid infinite loop: - return if (challengePair !in uniqueAuthChallengesSent) { - authResponseWatcher[authEvent.id] = AuthEventReceiptStatus.AUTHENTICATING - uniqueAuthChallengesSent.add(challengePair) + return if (uniqueAuthChallengesSent[challengePair] == null) { + authResponseWatcher.put(authEvent.id, AuthEventReceiptStatus.AUTHENTICATING) + uniqueAuthChallengesSent.put(challengePair, challengePair) true } else { false @@ -61,10 +61,9 @@ class RelayAuthStatus { fun checkAuthResults( eventId: HexKey, success: Boolean, - ): Boolean = - if (authResponseWatcher.containsKey(eventId)) { - val wasAlreadyAuthenticated = authResponseWatcher[eventId] - + ): Boolean { + val wasAlreadyAuthenticated = authResponseWatcher[eventId] + return if (wasAlreadyAuthenticated != null) { if (success) { authResponseWatcher.put(eventId, AuthEventReceiptStatus.AUTHENTICATED) } else { @@ -75,6 +74,7 @@ class RelayAuthStatus { } else { false } + } - fun hasFinishedAllAuths() = authResponseWatcher.all { it.value != AuthEventReceiptStatus.AUTHENTICATING } + fun hasFinishedAllAuths() = authResponseWatcher.snapshot().all { it.value != AuthEventReceiptStatus.AUTHENTICATING } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt index dfb28ffe49..226f0ce8dd 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/auth/RelayAuthenticator.kt @@ -66,7 +66,7 @@ class RelayAuthenticator( } override fun onConnecting(relay: IRelayClient) { - authStatus.put(relay.url, RelayAuthStatus()) + authStatus[relay.url] = RelayAuthStatus() } override fun onDisconnected(relay: IRelayClient) { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticReqUntilEoseAsFlow.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticReqUntilEoseAsFlow.kt new file mode 100644 index 0000000000..371c48d29d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/reqs/NostrClientStaticReqUntilEoseAsFlow.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.nip01Core.relay.client.reqs + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.RandomInstance +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +/** + * 1. Subscribes to a req while the flow is active + * 2. Emits accumulated events as a list on each new arrival + * 3. Closes the flow (completes) when the relay sends EOSE, + * cancelling the subscription automatically via [awaitClose]. + */ +fun INostrClient.reqUntilEoseAsFlow( + relay: String, + filters: List, +) = reqUntilEoseAsFlow(RelayUrlNormalizer.normalize(relay), filters) + +fun INostrClient.reqUntilEoseAsFlow( + relay: String, + filter: Filter, +) = reqUntilEoseAsFlow(RelayUrlNormalizer.normalize(relay), listOf(filter)) + +fun INostrClient.reqUntilEoseAsFlow( + relay: NormalizedRelayUrl, + filter: Filter, +) = reqUntilEoseAsFlow(relay, listOf(filter)) + +fun INostrClient.reqUntilEoseAsFlow( + relay: NormalizedRelayUrl, + filters: List, +): Flow> = + callbackFlow { + val subId = RandomInstance.randomChars(10) + val eventIds = mutableSetOf() + var currentEvents = listOf() + + val listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event.id !in eventIds) { + currentEvents = currentEvents + event + eventIds.add(event.id) + trySend(currentEvents) + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + close() + } + } + + openReqSubscription(subId, mapOf(relay to filters), listener) + + awaitClose { + close(subId) + } + } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt index 67537aeab3..4fa6c68472 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/filters/Filter.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.relay.filters +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey @@ -46,6 +47,7 @@ import com.vitorpamplona.quartz.utils.Log * This class performs validation on construction to ensure all string-based identifiers * follow Nostr requirements (64-char hex, onion addresses) and logs errors for invalid inputs. */ +@Stable class Filter( val ids: List? = null, val authors: List? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt index c0a0623863..c89c5df524 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt @@ -191,3 +191,7 @@ class RelayUrlNormalizer { } } } + +fun String.normalizeRelayUrl() = RelayUrlNormalizer.normalize(this) + +fun String.normalizeRelayUrlOrNull() = RelayUrlNormalizer.normalizeOrNull(this) 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 1cce516a21..ca62c97c61 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/nip02FollowList/ContactListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt index 48fc4935b1..a371e13350 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip02FollowList/ContactListEvent.kt @@ -35,7 +35,6 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.isTaggedUser import com.vitorpamplona.quartz.nip02FollowList.tags.ContactTag import com.vitorpamplona.quartz.nip31Alts.AltTag import com.vitorpamplona.quartz.utils.TimeUtils -import kotlinx.serialization.json.JsonNull.content @Stable class ContactListEvent( 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 b3df7fad89..2bd3265953 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 9420bb11d3..9ef37f5d8c 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 a8abc54a20..39a6391a6d 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 018fc3632d..d9df568313 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 1316aea734..cdbb6141e5 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 15a283c23b..6eabe0f372 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 a7ff4fc179..69901c3965 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 9515507f46..a2174bf871 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 e3d6dae4b3..c97a08a7fb 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/nip39ExtIdentities/EventExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/EventExt.kt index cedcef3710..93f7cc06d9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/EventExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/EventExt.kt @@ -24,16 +24,17 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent fun MetadataEvent.identityClaims() = tags.mapNotNull(IdentityClaimTag::parse) -fun MetadataEvent.replaceClaims( +fun ExternalIdentitiesEvent.identityClaims() = tags.mapNotNull(IdentityClaimTag::parse) + +fun List.replaceClaims( twitter: String?, mastodon: String?, github: String?, ): List { - var claims = identityClaims() + var claims = this // null leave as is. blank deletes it. if (twitter != null) { - // delete twitter claims = claims.filter { it !is TwitterIdentity } if (twitter.isNotBlank()) { TwitterIdentity.parseProofUrl(twitter)?.let { claims = claims + it } @@ -42,7 +43,6 @@ fun MetadataEvent.replaceClaims( // null leave as is. blank deletes it. if (github != null) { - // delete github claims = claims.filter { it !is GitHubIdentity } if (github.isNotBlank()) { GitHubIdentity.parseProofUrl(github)?.let { claims = claims + it } @@ -59,3 +59,15 @@ fun MetadataEvent.replaceClaims( return claims } + +fun MetadataEvent.replaceClaims( + twitter: String?, + mastodon: String?, + github: String?, +): List = identityClaims().replaceClaims(twitter, mastodon, github) + +fun ExternalIdentitiesEvent.replaceClaims( + twitter: String?, + mastodon: String?, + github: String?, +): List = identityClaims().replaceClaims(twitter, mastodon, github) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/ExternalIdentitiesEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/ExternalIdentitiesEvent.kt new file mode 100644 index 0000000000..fb3fc40a7b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/ExternalIdentitiesEvent.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.nip39ExtIdentities + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +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.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class ExternalIdentitiesEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + companion object { + const val KIND = 10011 + const val ALT_DESCRIPTION = "External Identities" + const val FIXED_D_TAG = "" + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, FIXED_D_TAG) + + fun createAddressTag(pubKey: HexKey): String = Address.assemble(KIND, pubKey, FIXED_D_TAG) + + fun createNew( + twitter: String? = null, + mastodon: String? = null, + github: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate = + eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + twitter?.let { twitterClaim(it) } + mastodon?.let { mastodonClaim(it) } + github?.let { githubClaim(it) } + initializer() + } + + fun updateFromPast( + latest: ExternalIdentitiesEvent, + twitter: String? = null, + mastodon: String? = null, + github: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ): EventTemplate { + val tags = + latest.tags.builder { + alt(ALT_DESCRIPTION) + val newClaims = latest.replaceClaims(twitter, mastodon, github) + remove(IdentityClaimTag.TAG_NAME) + claims(newClaims) + initializer() + } + return EventTemplate(createdAt, KIND, tags, "") + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/TagArrayBuilderExt.kt index 746b37d627..24481dfd63 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip39ExtIdentities/TagArrayBuilderExt.kt @@ -20,19 +20,19 @@ */ package com.vitorpamplona.quartz.nip39ExtIdentities +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder -import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent -fun TagArrayBuilder.claims(identities: List) = addAll(identities.map { it.toTagArray() }) +fun TagArrayBuilder.claims(identities: List) = addAll(identities.map { it.toTagArray() }) -fun TagArrayBuilder.twitterClaim(twitter: TwitterIdentity) = add(twitter.toTagArray()) +fun TagArrayBuilder.twitterClaim(twitter: TwitterIdentity) = add(twitter.toTagArray()) -fun TagArrayBuilder.mastodonClaim(mastodon: MastodonIdentity) = add(mastodon.toTagArray()) +fun TagArrayBuilder.mastodonClaim(mastodon: MastodonIdentity) = add(mastodon.toTagArray()) -fun TagArrayBuilder.githubClaim(github: GitHubIdentity) = add(github.toTagArray()) +fun TagArrayBuilder.githubClaim(github: GitHubIdentity) = add(github.toTagArray()) -fun TagArrayBuilder.twitterClaim(twitterUrl: String) = TwitterIdentity.parseProofUrl(twitterUrl)?.let { twitterClaim(it) } +fun TagArrayBuilder.twitterClaim(twitterUrl: String) = TwitterIdentity.parseProofUrl(twitterUrl)?.let { twitterClaim(it) } -fun TagArrayBuilder.mastodonClaim(mastodonUrl: String) = MastodonIdentity.parseProofUrl(mastodonUrl)?.let { mastodonClaim(it) } +fun TagArrayBuilder.mastodonClaim(mastodonUrl: String) = MastodonIdentity.parseProofUrl(mastodonUrl)?.let { mastodonClaim(it) } -fun TagArrayBuilder.githubClaim(githubUrl: String) = GitHubIdentity.parseProofUrl(githubUrl)?.let { githubClaim(it) } +fun TagArrayBuilder.githubClaim(githubUrl: String) = GitHubIdentity.parseProofUrl(githubUrl)?.let { githubClaim(it) } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt new file mode 100644 index 0000000000..c1d61c018a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/FavoriteRelayListEvent.kt @@ -0,0 +1,120 @@ +/* + * 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.nip51Lists.relayLists + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Address +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.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag +import com.vitorpamplona.quartz.nip31Alts.AltTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent +import com.vitorpamplona.quartz.nip51Lists.encryption.PrivateTagsInContent +import com.vitorpamplona.quartz.nip51Lists.encryption.signNip51List +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.RelayTag +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.favoriteRelays +import com.vitorpamplona.quartz.nip51Lists.relayLists.tags.relays +import com.vitorpamplona.quartz.nip51Lists.remove +import com.vitorpamplona.quartz.utils.TimeUtils + +@Immutable +class FavoriteRelayListEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : PrivateTagArrayEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun publicRelays() = tags.relays() + + suspend fun decryptPrivateRelays(signer: NostrSigner) = privateTags(signer)?.relays() + + suspend fun decryptRelays(signer: NostrSigner): List = publicRelays() + (decryptPrivateRelays(signer) ?: emptyList()) + + companion object { + const val KIND = 10012 + const val ALT = "Favorite Relays" + val TAGS = arrayOf(AltTag.assemble(ALT)) + + fun createAddress(pubKey: HexKey): Address = Address(KIND, pubKey, "") + + fun createAddressATag(pubKey: HexKey): ATag = ATag(KIND, pubKey, "", null) + + fun createAddressTag(pubKey: HexKey): String = Address.Companion.assemble(KIND, pubKey, "") + + suspend fun updateRelayList( + earlierVersion: FavoriteRelayListEvent, + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteRelayListEvent { + val newRelayList = relays.map { RelayTag.assemble(it) } + val privateTags = earlierVersion.privateTags(signer) ?: throw SignerExceptions.UnauthorizedDecryptionException() + + val publicTags = earlierVersion.tags.remove(RelayTag::match) + val newPrivateTags = privateTags.remove(RelayTag::match).plus(newRelayList) + + return signer.signNip51List(createdAt, KIND, publicTags, newPrivateTags) + } + + suspend fun create( + relays: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): FavoriteRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + fun create( + relays: List, + signer: NostrSignerSync, + createdAt: Long = TimeUtils.now(), + ): FavoriteRelayListEvent { + val privateTagArray = relays.map { RelayTag.assemble(it) }.toTypedArray() + return signer.signNip51List(createdAt, KIND, TAGS, privateTagArray) + } + + suspend fun build( + publicRelays: List = emptyList(), + privateRelays: List = emptyList(), + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate( + kind = KIND, + description = PrivateTagsInContent.encryptNip44(privateRelays.map { RelayTag.assemble(it) }.toTypedArray(), signer), + createdAt = createdAt, + ) { + alt(ALT) + favoriteRelays(publicRelays) + + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt index faa9bfa298..67a12b2399 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip51Lists/relayLists/tags/TagArrayBuilderExt.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip50Search.SearchRelayListEvent import com.vitorpamplona.quartz.nip51Lists.PrivateTagArrayEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent @@ -50,3 +51,5 @@ fun TagArrayBuilder.proxyRelays(relays: List.privateRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) fun TagArrayBuilder.relaySet(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) + +fun TagArrayBuilder.favoriteRelays(relays: List) = addAll(relays.map { RelayTag.assemble(it) }) 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 0af55aac40..295c38fd48 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 b204dafdb1..efa2cec2b4 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 e8afb8f887..2b91ea6934 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 0000000000..0ae8c39d44 --- /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 0000000000..083ab167a8 --- /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 0000000000..664c60b0b6 --- /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 0000000000..4a36d1bf19 --- /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 0000000000..e715719d58 --- /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 0000000000..a31a49d8c0 --- /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 0000000000..65849aa777 --- /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 0000000000..f650b21940 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip64Chess/JesterEvents.kt @@ -0,0 +1,381 @@ +/* + * 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.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 0000000000..0c2119a516 --- /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 0000000000..4381f832cb --- /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 0000000000..7e0daed77f --- /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 0000000000..e9793b4051 --- /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/nip66RelayMonitor/discovery/RelayDiscoveryEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/RelayDiscoveryEvent.kt new file mode 100644 index 0000000000..5601954437 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/RelayDiscoveryEvent.kt @@ -0,0 +1,110 @@ +/* + * 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.nip66RelayMonitor.discovery + +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.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttType +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-66 Kind 30166: Relay Discovery Event + * + * Published by relay monitors to document the state of a specific relay. + * The `d` tag holds the normalized relay URL, making each relay URL + * addressable per monitor pubkey. + * + * Carries metrics and metadata observed during monitoring: + * - Round-trip times (rtt-open, rtt-read, rtt-write) + * - Network type (n), relay type (T) + * - Supported NIPs (N), accepted kinds (k) + * - Requirements (R: auth, payment, pow, writes) + * - Topics (t) and geohash (g) + */ +@Immutable +class RelayDiscoveryEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun relay(): NormalizedRelayUrl? = RelayUrlNormalizer.normalizeOrNull(dTag()) + + fun rtt() = tags.rtts() + + fun rttOpen() = tags.rtt(RttType.OPEN) + + fun rttRead() = tags.rtt(RttType.READ) + + fun rttWrite() = tags.rtt(RttType.WRITE) + + fun networkTypes() = tags.networkTypes() + + fun relayTypes() = tags.relayTypes() + + fun supportedNips() = tags.supportedNips() + + fun requirements() = tags.requirements() + + fun acceptedKinds() = tags.acceptedKinds() + + fun topics() = tags.topics() + + fun geohashes() = tags.geohashes() + + companion object { + const val KIND = 30166 + const val ALT_DESCRIPTION = "Relay discovery" + + fun build( + relayUrl: NormalizedRelayUrl, + content: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, content, createdAt) { + alt(ALT_DESCRIPTION) + dTag(relayUrl.url) + initializer() + } + + fun build( + relayUrl: String, + content: String = "", + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = build( + relayUrl = RelayUrlNormalizer.normalize(relayUrl), + content = content, + createdAt = createdAt, + initializer = initializer, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..73234032d2 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/TagArrayBuilderExt.kt @@ -0,0 +1,62 @@ +/* + * 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.nip66RelayMonitor.discovery + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.AcceptedKindTag +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkType +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkTypeTag +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RelayTypeTag +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RequirementTag +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttTag +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttType +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.SupportedNipTag + +fun TagArrayBuilder.rtt( + type: RttType, + milliseconds: Long, +) = addUnique(RttTag.assemble(type, milliseconds)) + +fun TagArrayBuilder.networkType(network: NetworkType) = addUnique(NetworkTypeTag.assemble(network)) + +fun TagArrayBuilder.networkTypes(networks: List) = addAll(NetworkTypeTag.assemble(networks)) + +fun TagArrayBuilder.relayType(type: String) = addUnique(RelayTypeTag.assemble(type)) + +fun TagArrayBuilder.relayTypes(types: List) = addAll(RelayTypeTag.assemble(types)) + +fun TagArrayBuilder.supportedNip(nip: Int) = add(SupportedNipTag.assemble(nip)) + +fun TagArrayBuilder.supportedNips(nips: List) = addAll(SupportedNipTag.assemble(nips)) + +fun TagArrayBuilder.requirement( + value: String, + negated: Boolean = false, +) = add(RequirementTag.assemble(value, negated)) + +fun TagArrayBuilder.requirements(reqs: List) = addAll(RequirementTag.assemble(reqs)) + +fun TagArrayBuilder.acceptedKind( + kind: Int, + negated: Boolean = false, +) = add(AcceptedKindTag.assemble(kind, negated)) + +fun TagArrayBuilder.acceptedKinds(kinds: List) = addAll(AcceptedKindTag.assemble(kinds)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/TagArrayExt.kt new file mode 100644 index 0000000000..a2944d40bf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/TagArrayExt.kt @@ -0,0 +1,47 @@ +/* + * 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.nip66RelayMonitor.discovery + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.tags.hashtags.hashtags +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.AcceptedKindTag +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.NetworkTypeTag +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RelayTypeTag +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RequirementTag +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttTag +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.RttType +import com.vitorpamplona.quartz.nip66RelayMonitor.discovery.tags.SupportedNipTag + +fun TagArray.rtts() = mapNotNull(RttTag::parse) + +fun TagArray.rtt(type: RttType) = mapNotNull(RttTag::parse).firstOrNull { it.type == type }?.milliseconds + +fun TagArray.networkTypes() = mapNotNull(NetworkTypeTag::parse) + +fun TagArray.relayTypes() = mapNotNull(RelayTypeTag::parse) + +fun TagArray.supportedNips() = mapNotNull(SupportedNipTag::parse) + +fun TagArray.requirements() = mapNotNull(RequirementTag::parse) + +fun TagArray.acceptedKinds() = mapNotNull(AcceptedKindTag::parse) + +fun TagArray.topics() = hashtags() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/tags/AcceptedKindTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/tags/AcceptedKindTag.kt new file mode 100644 index 0000000000..a19e1d93e4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/tags/AcceptedKindTag.kt @@ -0,0 +1,67 @@ +/* + * 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.nip66RelayMonitor.discovery.tags + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * ["k", ""] - an event kind accepted by this relay. + * + * A leading "!" negates, meaning the relay does NOT accept that kind. + * Examples: "1" (accepts kind 1), "!1" (rejects kind 1) + */ +@Stable +class AcceptedKindTag( + val kind: Int, + val negated: Boolean, +) { + companion object { + const val TAG_NAME = "k" + private const val NEGATION_PREFIX = "!" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Array): AcceptedKindTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + val raw = tag[1] + return if (raw.startsWith(NEGATION_PREFIX)) { + val kind = raw.removePrefix(NEGATION_PREFIX).toIntOrNull() ?: return null + AcceptedKindTag(kind = kind, negated = true) + } else { + val kind = raw.toIntOrNull() ?: return null + AcceptedKindTag(kind = kind, negated = false) + } + } + + fun assemble( + kind: Int, + negated: Boolean = false, + ) = arrayOf(TAG_NAME, if (negated) "$NEGATION_PREFIX$kind" else kind.toString()) + + fun assemble(entry: AcceptedKindTag) = assemble(entry.kind, entry.negated) + + fun assemble(kinds: List) = kinds.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/tags/NetworkTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/tags/NetworkTypeTag.kt new file mode 100644 index 0000000000..91d76e68e8 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/tags/NetworkTypeTag.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.nip66RelayMonitor.discovery.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Network accessibility types for relay connections */ +enum class NetworkType( + val code: String, +) { + CLEARNET("clearnet"), + TOR("tor"), + I2P("i2p"), + LOKI("loki"), +} + +/** ["n", ""] - the network type the relay is accessible on */ +class NetworkTypeTag { + companion object { + const val TAG_NAME = "n" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Array): NetworkType? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return NetworkType.entries.find { it.code == tag[1] } + } + + fun assemble(network: NetworkType) = arrayOf(TAG_NAME, network.code) + + fun assemble(networks: List) = networks.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/tags/RelayTypeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/tags/RelayTypeTag.kt new file mode 100644 index 0000000000..2e6f9b7ffd --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/tags/RelayTypeTag.kt @@ -0,0 +1,47 @@ +/* + * 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.nip66RelayMonitor.discovery.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * ["T", ""] - the type of relay in PascalCase (e.g. PrivateInbox, PublicOutbox). + * Values are arbitrary strings defined by relay operators; not an enum. + */ +class RelayTypeTag { + companion object { + const val TAG_NAME = "T" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME + + 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(relayType: String) = arrayOf(TAG_NAME, relayType) + + fun assemble(relayTypes: List) = relayTypes.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/tags/RequirementTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/tags/RequirementTag.kt new file mode 100644 index 0000000000..4815bccbd4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/tags/RequirementTag.kt @@ -0,0 +1,65 @@ +/* + * 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.nip66RelayMonitor.discovery.tags + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * ["R", ""] - access requirement enforced by the relay. + * + * A leading "!" negates the requirement, meaning the relay does NOT enforce it. + * Examples: "auth", "payment", "!payment" (no payment required), "pow", "writes" + */ +@Stable +class RequirementTag( + val value: String, + val negated: Boolean, +) { + companion object { + const val TAG_NAME = "R" + private const val NEGATION_PREFIX = "!" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Array): RequirementTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + val raw = tag[1] + return if (raw.startsWith(NEGATION_PREFIX)) { + RequirementTag(value = raw.removePrefix(NEGATION_PREFIX), negated = true) + } else { + RequirementTag(value = raw, negated = false) + } + } + + fun assemble( + value: String, + negated: Boolean = false, + ) = arrayOf(TAG_NAME, if (negated) "$NEGATION_PREFIX$value" else value) + + fun assemble(req: RequirementTag) = assemble(req.value, req.negated) + + fun assemble(requirements: List) = requirements.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/tags/RttTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/tags/RttTag.kt new file mode 100644 index 0000000000..534d2ab483 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/tags/RttTag.kt @@ -0,0 +1,65 @@ +/* + * 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.nip66RelayMonitor.discovery.tags + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Round-trip time measurement types */ +enum class RttType( + val tagName: String, +) { + OPEN("rtt-open"), + READ("rtt-read"), + WRITE("rtt-write"), +} + +/** + * Round-trip time tags measured in milliseconds. + * + * ["rtt-open", ""] + * ["rtt-read", ""] + * ["rtt-write", ""] + */ +@Stable +class RttTag( + val type: RttType, + val milliseconds: Long, +) { + companion object { + fun isTag(tag: Array) = tag.has(1) && RttType.entries.any { it.tagName == tag[0] } + + fun parse(tag: Array): RttTag? { + ensure(tag.has(1)) { return null } + val type = RttType.entries.find { it.tagName == tag[0] } ?: return null + val ms = tag[1].toLongOrNull() ?: return null + return RttTag(type, ms) + } + + fun assemble( + type: RttType, + milliseconds: Long, + ) = arrayOf(type.tagName, milliseconds.toString()) + + fun assemble(rtt: RttTag) = assemble(rtt.type, rtt.milliseconds) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/tags/SupportedNipTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/tags/SupportedNipTag.kt new file mode 100644 index 0000000000..6cfe3ba87d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/discovery/tags/SupportedNipTag.kt @@ -0,0 +1,44 @@ +/* + * 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.nip66RelayMonitor.discovery.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** ["N", ""] - a NIP number supported by this relay (repeated per supported NIP) */ +class SupportedNipTag { + companion object { + const val TAG_NAME = "N" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Array): Int? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toIntOrNull() + } + + fun assemble(nip: Int) = arrayOf(TAG_NAME, nip.toString()) + + fun assemble(nips: List) = nips.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/monitor/RelayMonitorEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/monitor/RelayMonitorEvent.kt new file mode 100644 index 0000000000..a84925eb36 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/monitor/RelayMonitorEvent.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.nip66RelayMonitor.monitor + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +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.tags.geohash.geohash +import com.vitorpamplona.quartz.nip01Core.tags.geohash.geohashes +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.CheckType +import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.TimeoutTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-66 Kind 10166: Relay Monitor Announcement + * + * Published by monitoring services to advertise themselves. Declares: + * - How frequently the monitor publishes results (frequency tag, required) + * - Which types of checks it performs (c tags) + * - Timeout values used for each check type (timeout tags) + * - Geographical location of the monitor (g tags) + * + * Monitors should also publish complementary kind 0 (metadata) and + * kind 10002 (relay list) events for full discoverability. + */ +@Immutable +class RelayMonitorEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig) { + fun frequency() = tags.frequency() + + fun checks() = tags.checks() + + fun timeouts() = tags.timeouts() + + fun geohashes() = tags.geohashes() + + companion object { + const val KIND = 10166 + const val ALT_DESCRIPTION = "Relay monitor announcement" + + fun build( + frequencySeconds: Long, + checks: List = emptyList(), + timeouts: List = emptyList(), + geohash: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + frequency(frequencySeconds) + checks(checks) + timeouts(timeouts) + geohash?.let { geohash(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/monitor/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/monitor/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..ba2d64568d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/monitor/TagArrayBuilderExt.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.quartz.nip66RelayMonitor.monitor + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.CheckTag +import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.CheckType +import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.FrequencyTag +import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.TimeoutTag + +fun TagArrayBuilder.frequency(seconds: Long) = addUnique(FrequencyTag.assemble(seconds)) + +fun TagArrayBuilder.checks(checks: List) = addAll(CheckTag.assemble(checks)) + +fun TagArrayBuilder.timeouts(timeouts: List) = addAll(TimeoutTag.assemble(timeouts)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/monitor/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/monitor/TagArrayExt.kt new file mode 100644 index 0000000000..ee507b330a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/monitor/TagArrayExt.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip66RelayMonitor.monitor + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.CheckTag +import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.FrequencyTag +import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags.TimeoutTag + +fun TagArray.frequency() = firstNotNullOfOrNull(FrequencyTag::parse) + +fun TagArray.checks() = mapNotNull(CheckTag::parse) + +fun TagArray.timeouts() = mapNotNull(TimeoutTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/monitor/tags/CheckTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/monitor/tags/CheckTag.kt new file mode 100644 index 0000000000..2db69a3140 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/monitor/tags/CheckTag.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.quartz.nip66RelayMonitor.monitor.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** Types of checks a relay monitor can perform */ +enum class CheckType( + val code: String, +) { + OPEN("open"), + READ("read"), + WRITE("write"), + AUTH("auth"), + NIP11("nip11"), + DNS("dns"), + GEO("geo"), +} + +/** ["c", ""] - a capability check conducted by this monitor */ +class CheckTag { + companion object { + const val TAG_NAME = "c" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Array): CheckType? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return CheckType.entries.find { it.code == tag[1] } + } + + fun assemble(check: CheckType) = arrayOf(TAG_NAME, check.code) + + fun assemble(checks: List) = checks.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/monitor/tags/FrequencyTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/monitor/tags/FrequencyTag.kt new file mode 100644 index 0000000000..95c6d7d1ad --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/monitor/tags/FrequencyTag.kt @@ -0,0 +1,42 @@ +/* + * 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.nip66RelayMonitor.monitor.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** ["frequency", ""] - how often this monitor publishes check results */ +class FrequencyTag { + companion object { + const val TAG_NAME = "frequency" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Array): Long? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(seconds: Long) = arrayOf(TAG_NAME, seconds.toString()) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/monitor/tags/TimeoutTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/monitor/tags/TimeoutTag.kt new file mode 100644 index 0000000000..da9a752a3e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip66RelayMonitor/monitor/tags/TimeoutTag.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip66RelayMonitor.monitor.tags + +import androidx.compose.runtime.Stable +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * Timeout tag for relay monitors. + * + * General form: ["timeout", ""] + * Per-check form: ["timeout", "", ""] + * + * Where check-type is one of: open, read, write, nip11 + */ +@Stable +class TimeoutTag( + val checkType: String?, + val milliseconds: Long, +) { + companion object { + const val TAG_NAME = "timeout" + + fun isTag(tag: Array) = tag.has(1) && tag[0] == TAG_NAME + + fun parse(tag: Array): TimeoutTag? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return if (tag.has(2)) { + val ms = tag[2].toLongOrNull() ?: return null + TimeoutTag(checkType = tag[1], milliseconds = ms) + } else { + val ms = tag[1].toLongOrNull() ?: return null + TimeoutTag(checkType = null, milliseconds = ms) + } + } + + fun assemble(milliseconds: Long) = arrayOf(TAG_NAME, milliseconds.toString()) + + fun assemble( + checkType: String, + milliseconds: Long, + ) = arrayOf(TAG_NAME, checkType, milliseconds.toString()) + + fun assemble(timeout: TimeoutTag) = + if (timeout.checkType != null) { + assemble(timeout.checkType, timeout.milliseconds) + } else { + assemble(timeout.milliseconds) + } + + fun assemble(timeouts: List) = timeouts.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/ExternalId.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/ExternalId.kt index 8c454f997d..49f57d7d00 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/ExternalId.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/ExternalId.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.quartz.nip73ExternalIds +import androidx.compose.runtime.Stable + +@Stable interface ExternalId { fun toScope(): String diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/location/GeohashId.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/location/GeohashId.kt index 7eab80d0b8..0ab6d46a2b 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/location/GeohashId.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/location/GeohashId.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.quartz.nip73ExternalIds.location +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.utils.ensure +@Stable class GeohashId( val geohash: String, val hint: String? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/movies/MovieId.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/movies/MovieId.kt index aebf021a9e..a8b9fe9992 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/movies/MovieId.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/movies/MovieId.kt @@ -20,10 +20,12 @@ */ package com.vitorpamplona.quartz.nip73ExternalIds.movies +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.utils.ensure import kotlin.math.min +@Stable class MovieId( val isan: String, val hint: String? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/papers/PaperId.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/papers/PaperId.kt index 3b4ff78f95..d6a80444b7 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/papers/PaperId.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/papers/PaperId.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.quartz.nip73ExternalIds.papers +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.utils.ensure +@Stable class PaperId( val doi: String, val hint: String? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastEpisodeId.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastEpisodeId.kt index 371c4f1948..0ce3a6919d 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastEpisodeId.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastEpisodeId.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.quartz.nip73ExternalIds.podcasts +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.utils.ensure +@Stable class PodcastEpisodeId( val guid: String, val hint: String? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastFeedId.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastFeedId.kt index c2666fdce7..954901af9f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastFeedId.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastFeedId.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.quartz.nip73ExternalIds.podcasts +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.utils.ensure +@Stable class PodcastFeedId( val guid: String, val hint: String? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastPublisherId.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastPublisherId.kt index e481cef5c5..f83c3628a9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastPublisherId.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/podcasts/PodcastPublisherId.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.quartz.nip73ExternalIds.podcasts +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.utils.ensure +@Stable class PodcastPublisherId( val guid: String, val hint: String? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/topics/HashtagId.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/topics/HashtagId.kt index 90c13f5d51..501b9632c1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/topics/HashtagId.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/topics/HashtagId.kt @@ -20,9 +20,11 @@ */ package com.vitorpamplona.quartz.nip73ExternalIds.topics +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.utils.ensure +@Stable class HashtagId( val topic: String, val hint: String? = null, diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/urls/UrlId.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/urls/UrlId.kt index 1a88376ced..42ac89e2d8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/urls/UrlId.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip73ExternalIds/urls/UrlId.kt @@ -20,10 +20,12 @@ */ package com.vitorpamplona.quartz.nip73ExternalIds.urls +import androidx.compose.runtime.Stable import com.vitorpamplona.quartz.nip73ExternalIds.ExternalId import com.vitorpamplona.quartz.utils.Rfc3986 import com.vitorpamplona.quartz.utils.ensure +@Stable class UrlId( val url: String, val hint: String? = null, 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 0000000000..63647352ec --- /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 0000000000..319f797a3e --- /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 0000000000..f8e18368dc --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/poll/TagArrayExt.kt @@ -0,0 +1,39 @@ +/* + * 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.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 +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 0000000000..341d69be10 --- /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 0000000000..0aa40db027 --- /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 0000000000..a8d50ce6bb --- /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 0000000000..294d69b491 --- /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 0000000000..9d579e2bb5 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/PollResponseEvent.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.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.people.PTag +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.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 0000000000..6176916f63 --- /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 0000000000..acfbd8b0a0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip88Polls/response/TagArrayExt.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip88Polls.response + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip88Polls.response.tags.PollTag +import com.vitorpamplona.quartz.nip88Polls.response.tags.ResponseTag + +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 0000000000..ddaee8e50e --- /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 0000000000..4ef10f85cb --- /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/nipC0CodeSnippets/CodeSnippetEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/CodeSnippetEvent.kt new file mode 100644 index 0000000000..42008395f7 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/CodeSnippetEvent.kt @@ -0,0 +1,100 @@ +/* + * 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.nipC0CodeSnippets + +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.nip22Comments.RootScope +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-C0: Code Snippet event (kind:1337). + * + * The `.content` field holds the raw code text. All metadata is stored as optional tags. + */ +@Immutable +class CodeSnippetEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + RootScope { + /** Programming language, lowercase (e.g. "python"). */ + fun language() = tags.language() + + /** File extension without the leading dot (e.g. "py"). */ + fun extension() = tags.snippetExtension() + + /** Snippet filename (e.g. "hello-world.py"). */ + fun snippetName() = tags.snippetName() + + /** Brief description of the snippet's purpose. */ + fun snippetDescription() = tags.snippetDescription() + + /** Execution runtime (e.g. "node v18.15.0"). */ + fun runtime() = tags.runtime() + + /** SPDX license identifier (e.g. "MIT"). */ + fun license() = tags.license() + + /** List of required dependencies. */ + fun deps() = tags.deps() + + /** Repository URL or NIP-34 Git announcement reference. */ + fun repo() = tags.repo() + + companion object { + const val KIND = 1337 + const val ALT_DESCRIPTION = "Code snippet" + + fun build( + code: String, + language: String? = null, + extension: String? = null, + name: String? = null, + description: String? = null, + runtime: String? = null, + license: String? = null, + deps: List = emptyList(), + repo: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, code, createdAt) { + alt(ALT_DESCRIPTION) + language?.let { language(it) } + extension?.let { extension(it) } + name?.let { snippetName(it) } + description?.let { snippetDescription(it) } + runtime?.let { runtime(it) } + license?.let { license(it) } + if (deps.isNotEmpty()) deps(deps) + repo?.let { repo(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..2f7e988914 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/TagArrayBuilderExt.kt @@ -0,0 +1,47 @@ +/* + * 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.nipC0CodeSnippets + +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.DepTag +import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.ExtensionTag +import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.LanguageTag +import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.LicenseTag +import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.RepoTag +import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.RuntimeTag +import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.SnippetDescriptionTag +import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.SnippetNameTag + +fun TagArrayBuilder.language(language: String) = addUnique(LanguageTag.assemble(language)) + +fun TagArrayBuilder.extension(extension: String) = addUnique(ExtensionTag.assemble(extension)) + +fun TagArrayBuilder.snippetName(name: String) = addUnique(SnippetNameTag.assemble(name)) + +fun TagArrayBuilder.snippetDescription(description: String) = addUnique(SnippetDescriptionTag.assemble(description)) + +fun TagArrayBuilder.runtime(runtime: String) = addUnique(RuntimeTag.assemble(runtime)) + +fun TagArrayBuilder.license(license: String) = addUnique(LicenseTag.assemble(license)) + +fun TagArrayBuilder.deps(deps: List) = addAll(DepTag.assemble(deps)) + +fun TagArrayBuilder.repo(repo: String) = addUnique(RepoTag.assemble(repo)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/TagArrayExt.kt new file mode 100644 index 0000000000..e25e39029d --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/TagArrayExt.kt @@ -0,0 +1,47 @@ +/* + * 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.nipC0CodeSnippets + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.DepTag +import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.ExtensionTag +import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.LanguageTag +import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.LicenseTag +import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.RepoTag +import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.RuntimeTag +import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.SnippetDescriptionTag +import com.vitorpamplona.quartz.nipC0CodeSnippets.tags.SnippetNameTag + +fun TagArray.language() = firstNotNullOfOrNull(LanguageTag::parse) + +fun TagArray.snippetExtension() = firstNotNullOfOrNull(ExtensionTag::parse) + +fun TagArray.snippetName() = firstNotNullOfOrNull(SnippetNameTag::parse) + +fun TagArray.snippetDescription() = firstNotNullOfOrNull(SnippetDescriptionTag::parse) + +fun TagArray.runtime() = firstNotNullOfOrNull(RuntimeTag::parse) + +fun TagArray.license() = firstNotNullOfOrNull(LicenseTag::parse) + +fun TagArray.deps() = mapNotNull(DepTag::parse) + +fun TagArray.repo() = firstNotNullOfOrNull(RepoTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/DepTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/DepTag.kt new file mode 100644 index 0000000000..2e31b6ee9b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/DepTag.kt @@ -0,0 +1,42 @@ +/* + * 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.nipC0CodeSnippets.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** NIP-C0: `dep` tag — a dependency required to run the snippet (repeatable) */ +class DepTag { + companion object { + const val TAG_NAME = "dep" + + 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(dep: String) = arrayOf(TAG_NAME, dep) + + fun assemble(deps: List) = deps.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/ExtensionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/ExtensionTag.kt new file mode 100644 index 0000000000..dc2e380332 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/ExtensionTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipC0CodeSnippets.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** NIP-C0: `extension` tag — file extension without the leading dot (e.g. "py", "js") */ +class ExtensionTag { + companion object { + const val TAG_NAME = "extension" + + 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(extension: String) = arrayOf(TAG_NAME, extension) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/LanguageTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/LanguageTag.kt new file mode 100644 index 0000000000..12f78c9e7c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/LanguageTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipC0CodeSnippets.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** NIP-C0: `l` tag — programming language name (lowercase, e.g. "python", "javascript") */ +class LanguageTag { + companion object { + const val TAG_NAME = "l" + + 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(language: String) = arrayOf(TAG_NAME, language) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/LicenseTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/LicenseTag.kt new file mode 100644 index 0000000000..89dcb1fcaa --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/LicenseTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipC0CodeSnippets.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** NIP-C0: `license` tag — SPDX license identifier (e.g. "MIT", "Apache-2.0") */ +class LicenseTag { + companion object { + const val TAG_NAME = "license" + + 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(license: String) = arrayOf(TAG_NAME, license) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/RepoTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/RepoTag.kt new file mode 100644 index 0000000000..8bbd1a98f9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/RepoTag.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.nipC0CodeSnippets.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-C0: `repo` tag — repository reference as a URL or NIP-34 Git announcement + * (e.g. "https://github.com/user/repo" or a NIP-34 naddr) + */ +class RepoTag { + companion object { + const val TAG_NAME = "repo" + + 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(repo: String) = arrayOf(TAG_NAME, repo) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/RuntimeTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/RuntimeTag.kt new file mode 100644 index 0000000000..70736b8853 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/RuntimeTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipC0CodeSnippets.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** NIP-C0: `runtime` tag — execution environment specification (e.g. "node v18.15.0") */ +class RuntimeTag { + companion object { + const val TAG_NAME = "runtime" + + 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(runtime: String) = arrayOf(TAG_NAME, runtime) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/SnippetDescriptionTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/SnippetDescriptionTag.kt new file mode 100644 index 0000000000..9b966fc054 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/SnippetDescriptionTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipC0CodeSnippets.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** NIP-C0: `description` tag — brief explanation of the snippet's purpose */ +class SnippetDescriptionTag { + companion object { + const val TAG_NAME = "description" + + 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(description: String) = arrayOf(TAG_NAME, description) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/SnippetNameTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/SnippetNameTag.kt new file mode 100644 index 0000000000..50fff0ca7e --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipC0CodeSnippets/tags/SnippetNameTag.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nipC0CodeSnippets.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** NIP-C0: `name` tag — snippet filename (e.g. "hello-world.js") */ +class SnippetNameTag { + companion object { + const val TAG_NAME = "name" + + 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(name: String) = arrayOf(TAG_NAME, name) + } +} 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 2b79f50747..9cb287dc68 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -73,6 +73,7 @@ import com.vitorpamplona.quartz.nip35Torrents.TorrentEvent import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent import com.vitorpamplona.quartz.nip38UserStatus.StatusEvent +import com.vitorpamplona.quartz.nip39ExtIdentities.ExternalIdentitiesEvent import com.vitorpamplona.quartz.nip42RelayAuth.RelayAuthEvent import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip47WalletConnect.LnZapPaymentRequestEvent @@ -88,6 +89,7 @@ import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent import com.vitorpamplona.quartz.nip51Lists.peopleList.PeopleListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BlockedRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.BroadcastRelayListEvent +import com.vitorpamplona.quartz.nip51Lists.relayLists.FavoriteRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.IndexerRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.ProxyRelayListEvent import com.vitorpamplona.quartz.nip51Lists.relayLists.TrustedRelayListEvent @@ -112,7 +114,16 @@ 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.nip66RelayMonitor.discovery.RelayDiscoveryEvent +import com.vitorpamplona.quartz.nip66RelayMonitor.monitor.RelayMonitorEvent import com.vitorpamplona.quartz.nip68Picture.PictureEvent import com.vitorpamplona.quartz.nip71Video.VideoHorizontalEvent import com.vitorpamplona.quartz.nip71Video.VideoNormalEvent @@ -124,6 +135,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 @@ -139,6 +152,7 @@ import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent import com.vitorpamplona.quartz.nipB7Blossom.BlossomServersEvent +import com.vitorpamplona.quartz.nipC0CodeSnippets.CodeSnippetEvent interface EventBuilder { fun build( @@ -183,6 +197,15 @@ 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) + CodeSnippetEvent.KIND -> CodeSnippetEvent(id, pubKey, createdAt, tags, content, sig) + FavoriteRelayListEvent.KIND -> FavoriteRelayListEvent(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) @@ -204,6 +227,7 @@ class EventFactory { EmojiPackSelectionEvent.KIND -> EmojiPackSelectionEvent(id, pubKey, createdAt, tags, content, sig) EphemeralChatEvent.KIND -> EphemeralChatEvent(id, pubKey, createdAt, tags, content, sig) EphemeralChatListEvent.KIND -> EphemeralChatListEvent(id, pubKey, createdAt, tags, content, sig) + ExternalIdentitiesEvent.KIND -> ExternalIdentitiesEvent(id, pubKey, createdAt, tags, content, sig) FileHeaderEvent.KIND -> FileHeaderEvent(id, pubKey, createdAt, tags, content, sig) ProfileGalleryEntryEvent.KIND -> ProfileGalleryEntryEvent(id, pubKey, createdAt, tags, content, sig) FileServersEvent.KIND -> FileServersEvent(id, pubKey, createdAt, tags, content, sig) @@ -254,6 +278,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) @@ -261,6 +287,8 @@ class EventFactory { ReactionEvent.KIND -> ReactionEvent(id, pubKey, createdAt, tags, content, sig) ContactCardEvent.KIND -> ContactCardEvent(id, pubKey, createdAt, tags, content, sig) RelayAuthEvent.KIND -> RelayAuthEvent(id, pubKey, createdAt, tags, content, sig) + RelayDiscoveryEvent.KIND -> RelayDiscoveryEvent(id, pubKey, createdAt, tags, content, sig) + RelayMonitorEvent.KIND -> RelayMonitorEvent(id, pubKey, createdAt, tags, content, sig) RelaySetEvent.KIND -> RelaySetEvent(id, pubKey, createdAt, tags, content, sig) ReportEvent.KIND -> ReportEvent(id, pubKey, createdAt, tags, content, sig) RepostEvent.KIND -> RepostEvent(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 313710f10d..c8fcbde7db 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/nip01Core/hints/HintIndexerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/hints/HintIndexerTest.kt index d6c0fd534b..4c4293e514 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 0000000000..e470989d54 --- /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 0000000000..1c96c2e926 --- /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 0000000000..fb1d55ded6 --- /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/GZipTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/GZipTest.kt new file mode 100644 index 0000000000..d3e259996f --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/GZipTest.kt @@ -0,0 +1,113 @@ +/* + * 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.utils + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFails +import kotlin.test.assertTrue + +class GZipTest { + @Test + fun roundTripSimpleString() { + val original = "Hello, Nostr!" + val decompressed = GZip.decompress(GZip.compress(original)) + assertEquals(original, decompressed) + } + + @Test + fun roundTripEmptyString() { + val original = "" + val decompressed = GZip.decompress(GZip.compress(original)) + assertEquals(original, decompressed) + } + + @Test + fun roundTripLongRepetitiveString() { + val original = "abcdefghij".repeat(1000) + val compressed = GZip.compress(original) + val decompressed = GZip.decompress(compressed) + assertEquals(original, decompressed) + } + + @Test + fun compressedSizeIsSmallerForRepetitiveInput() { + val original = "abcdefghij".repeat(1000) + val compressed = GZip.compress(original) + assertTrue( + compressed.size < original.encodeToByteArray().size, + "Expected compressed size (${compressed.size}) to be smaller than original (${original.encodeToByteArray().size})", + ) + } + + @Test + fun roundTripUnicodeString() { + val original = "こんにちは世界 🌍 مرحبا Привет" + val decompressed = GZip.decompress(GZip.compress(original)) + assertEquals(original, decompressed) + } + + @Test + fun roundTripNostrJsonEvent() { + val original = + """{"id":"abc123","pubkey":"deadbeef","created_at":1700000000,"kind":1,""" + + """"tags":[],"content":"Hello world","sig":"cafebabe"}""" + val decompressed = GZip.decompress(GZip.compress(original)) + assertEquals(original, decompressed) + } + + @Test + fun compressedBytesStartWithGzipMagicNumber() { + // gzip streams always begin with 0x1F 0x8B + val compressed = GZip.compress("test") + assertTrue(compressed.size >= 2, "Compressed output too short to contain gzip header") + assertEquals(0x1F.toByte(), compressed[0], "Expected gzip magic byte 0 (0x1F)") + assertEquals(0x8B.toByte(), compressed[1], "Expected gzip magic byte 1 (0x8B)") + } + + @Test + fun compressedOutputDiffersFromInput() { + val original = "Hello, Nostr!" + val compressed = GZip.compress(original) + assertTrue( + !compressed.contentEquals(original.encodeToByteArray()), + "Compressed output should differ from the raw input bytes", + ) + } + + @Test + fun decompressInvalidDataThrows() { + val garbage = byteArrayOf(0x00, 0x01, 0x02, 0x03, 0x04) + assertFails { GZip.decompress(garbage) } + } + + @Test + fun roundTripSingleCharacter() { + val original = "A" + assertEquals(original, GZip.decompress(GZip.compress(original))) + } + + @Test + fun roundTripSpecialCharacters() { + val original = "\t\n\r\u0000\u001F\u007F" + assertEquals(original, GZip.decompress(GZip.compress(original))) + } +} 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 077fec0780..2b16ae2a4a 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 0000000000..a872757811 --- /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/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt index 67dc900430..24676f8126 100644 --- a/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt +++ b/quartz/src/iosMain/kotlin/com/vitorpamplona/quartz/utils/GZip.ios.kt @@ -20,28 +20,122 @@ */ package com.vitorpamplona.quartz.utils -import com.ditchoom.buffer.PlatformBuffer -import com.ditchoom.buffer.compression.CompressionAlgorithm -import com.ditchoom.buffer.compression.getOrThrow -import com.ditchoom.buffer.toByteArray -import com.ditchoom.buffer.toReadBuffer -import com.ditchoom.buffer.wrap -import com.ditchoom.buffer.compression.compress as compressData -import com.ditchoom.buffer.compression.decompress as decompressData +import kotlinx.cinterop.ExperimentalForeignApi +import kotlinx.cinterop.addressOf +import kotlinx.cinterop.alloc +import kotlinx.cinterop.memScoped +import kotlinx.cinterop.ptr +import kotlinx.cinterop.reinterpret +import kotlinx.cinterop.usePinned +import platform.zlib.Z_DEFAULT_COMPRESSION +import platform.zlib.Z_DEFAULT_STRATEGY +import platform.zlib.Z_DEFLATED +import platform.zlib.Z_FINISH +import platform.zlib.Z_NO_FLUSH +import platform.zlib.Z_OK +import platform.zlib.Z_STREAM_END +import platform.zlib.deflate +import platform.zlib.deflateBound +import platform.zlib.deflateEnd +import platform.zlib.deflateInit2 +import platform.zlib.inflate +import platform.zlib.inflateEnd +import platform.zlib.inflateInit2 +import platform.zlib.z_stream +@OptIn(ExperimentalForeignApi::class) actual object GZip { + /** + * Compresses [content] into a gzip byte array using zlib's deflateInit2 with + * windowBits=31 (MAX_WBITS + 16), which requests the gzip wrapper format. + * deflateBound gives an upper-bound on output size so a single deflate call + * with Z_FINISH is always sufficient. + */ actual fun compress(content: String): ByteArray { - val contentBuffer = content.toReadBuffer() - val compressedContent = compressData(contentBuffer, algorithm = CompressionAlgorithm.Gzip).getOrThrow().toByteArray() - return compressedContent + val input = content.encodeToByteArray() + + return memScoped { + val stream = alloc() + + // windowBits = 15 + 16 = 31 → gzip format + deflateInit2( + stream.ptr, + Z_DEFAULT_COMPRESSION, + Z_DEFLATED, + 31, + 8, + Z_DEFAULT_STRATEGY, + ).let { check(it == Z_OK) { "deflateInit2 failed: $it" } } + + val maxSize = deflateBound(stream.ptr, input.size.toULong()).toInt() + val output = ByteArray(maxSize) + + val written = + input.usePinned { pinIn -> + output.usePinned { pinOut -> + stream.next_in = pinIn.addressOf(0).reinterpret() + stream.avail_in = input.size.toUInt() + stream.next_out = pinOut.addressOf(0).reinterpret() + stream.avail_out = maxSize.toUInt() + + deflate(stream.ptr, Z_FINISH) + .let { check(it == Z_STREAM_END) { "deflate failed: $it" } } + + maxSize - stream.avail_out.toInt() + } + } + + deflateEnd(stream.ptr) + output.copyOf(written) + } } + /** + * Decompresses a gzip [content] byte array back to a UTF-8 string using + * zlib's inflateInit2 with windowBits=47 (MAX_WBITS + 32), which enables + * automatic detection of both gzip and zlib formats. + * Output is collected in fixed-size chunks to handle arbitrary output size. + */ actual fun decompress(content: ByteArray): String { - val contentBuffer = PlatformBuffer.wrap(content) - val decompressedContentBuffer = - decompressData(contentBuffer, algorithm = CompressionAlgorithm.Gzip) - .getOrThrow() - val content = decompressedContentBuffer.toByteArray().decodeToString() - return content + val chunks = ArrayList() + val chunkSize = maxOf(content.size * 4, 4096) + + memScoped { + val stream = alloc() + + // windowBits = 15 + 32 = 47 → auto-detect gzip or zlib + inflateInit2(stream.ptr, 47) + .let { check(it == Z_OK) { "inflateInit2 failed: $it" } } + + content.usePinned { pinIn -> + stream.next_in = pinIn.addressOf(0).reinterpret() + stream.avail_in = content.size.toUInt() + + var status: Int = Z_OK + do { + val chunk = ByteArray(chunkSize) + chunk.usePinned { pinOut -> + stream.next_out = pinOut.addressOf(0).reinterpret() + stream.avail_out = chunkSize.toUInt() + status = inflate(stream.ptr, Z_NO_FLUSH) + val produced = chunkSize - stream.avail_out.toInt() + if (produced > 0) chunks.add(chunk.copyOf(produced)) + } + } while (status == Z_OK) + + check(status == Z_STREAM_END) { "inflate failed: $status" } + } + + inflateEnd(stream.ptr) + } + + val totalSize = chunks.sumOf { it.size } + val result = ByteArray(totalSize) + var pos = 0 + chunks.forEach { chunk -> + chunk.copyInto(result, pos) + pos += chunk.size + } + return result.decodeToString() } } 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 0000000000..42f5dd8911 --- /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/nip01Core/relay/NostrClientSendAndWaitTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt index e2ffc03a77..5f289a4c61 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSendAndWaitTest.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip01Core.relay import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.sendAndWaitForResponse -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import kotlinx.coroutines.CoroutineScope @@ -48,13 +48,13 @@ class NostrClientSendAndWaitTest : BaseNostrClientTest() { val resultDamus = client.sendAndWaitForResponse( event = event, - relayList = setOf(RelayUrlNormalizer.normalize("wss://relay.damus.io")), + relayList = setOf("wss://relay.damus.io".normalizeRelayUrl()), ) val resultNos = client.sendAndWaitForResponse( event = event, - relayList = setOf(RelayUrlNormalizer.normalize("wss://nos.lol")), + relayList = setOf("wss://nos.lol".normalizeRelayUrl()), ) client.disconnect() diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt new file mode 100644 index 0000000000..ddcb9fde94 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/NostrClientSubscriptionUntilEoseAsFlowTest.kt @@ -0,0 +1,125 @@ +/* + * 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.nip01Core.relay + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.reqUntilEoseAsFlow +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class NostrClientSubscriptionUntilEoseAsFlowTest : BaseNostrClientTest() { + fun List.printDates(): String { + val starting = this[0].createdAt + return joinToString { (it.createdAt - starting).toString() } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun testNostrClientSubscriptionUntilEoseAsFlow() = + runTest { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val flow = + client.reqUntilEoseAsFlow( + relay = "wss://relay.damus.io", + filter = + Filter( + kinds = listOf(MetadataEvent.KIND), + limit = 10, + ), + ) + + var feedStates = listOf() + val job = + launch { + flow.collect { + Log.d("ZZ", "List timestamp deltas ${it.printDates()}") + feedStates = it + } + } + + // Advance the test dispatcher to ensure emissions are processed + while (feedStates.size < 10) { + advanceUntilIdle() + } + + job.cancel() // Cancel the collection job + + client.disconnect() + appScope.cancel() + + assertEquals(10, feedStates.size) + } + + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) + @Test + fun testNostrClientSubscriptionUntilEoseAsFlowDebouncing() = + runTest { + val appScope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + val client = NostrClient(socketBuilder, appScope) + + val flow = + client.reqUntilEoseAsFlow( + relay = "wss://relay.damus.io", + filter = + Filter( + kinds = listOf(MetadataEvent.KIND), + limit = 10, + ), + ) + + var feedStates = listOf() + val job = + launch { + flow.debounce(100).collect { + Log.d("ZZ", "List timestamp deltas ${it.printDates()}") + feedStates = it + } + } + + // Advance the test dispatcher to ensure emissions are processed + while (feedStates.size < 10) { + advanceUntilIdle() + } + + job.cancel() // Cancel the collection job + + client.disconnect() + appScope.cancel() + + assertEquals(10, feedStates.size) + } +} 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 0000000000..3b6e8fe98d --- /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", + ) + } +} diff --git a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.jvm.kt b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.jvm.kt index 1f5a4c80b4..e620fb99fc 100644 --- a/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.jvm.kt +++ b/quartz/src/jvmMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/Address.jvm.kt @@ -20,6 +20,9 @@ */ package com.vitorpamplona.quartz.nip01Core.core +import kotlinx.serialization.Serializable + +@Serializable actual data class Address actual constructor( actual val kind: Kind, actual val pubKeyHex: HexKey,