diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 000000000..9ecd75d86 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,174 @@ +# Amethyst Desktop Fork + +## Project Overview + +Fork of [Amethyst](https://github.com/vitorpamplona/amethyst) adding Compose Multiplatform Desktop support. Quartz library converted to full KMP for code sharing between Android and Desktop JVM. + +## Architecture + +``` +amethyst/ +├── quartz/ # Nostr KMP library (protocol only, no UI) +│ └── src/ +│ ├── commonMain/ # Shared Nostr protocol, data models +│ ├── androidMain/ # Android-specific (crypto, storage) +│ └── jvmMain/ # Desktop JVM-specific +├── commons/ # Shared UI components (convert to KMP) +│ └── src/ +│ ├── commonMain/ # Shared composables, icons, state +│ ├── androidMain/ # Android-specific UI utilities +│ └── jvmMain/ # Desktop-specific UI utilities +├── desktopApp/ # Desktop JVM application (layouts, navigation) +├── amethyst/ # Android app (layouts, navigation) +└── ammolite/ # Support module +``` + +**Sharing Philosophy:** +- `quartz/` = Business logic, protocol, data (no UI) +- `commons/` = Shared UI components, icons, composables +- `amethyst/` & `desktopApp/` = Platform-native layouts and navigation + +## Tech Stack + +| Layer | Technology | +|-------|------------| +| **Core** | Quartz (Nostr KMP) | +| **UI** | Compose Multiplatform 1.7.x | +| **Async** | kotlinx.coroutines + Flow | +| **Network** | OkHttp (JVM) | +| **Serialization** | Jackson | +| **DI** | Manual / Koin | +| **Build** | Gradle 8.x, Kotlin 2.1.0 | + +## Agents + +Use these specialized agents for domain expertise: + +| Agent | Expertise | When to Use | +|-------|-----------|-------------| +| `nostr-protocol` | NIPs, events, relays, crypto | Protocol questions, NIP implementation | +| `kotlin-multiplatform` | KMP, source sets, expect/actual | Project structure, code sharing | +| `compose-ui` | Composables, Desktop features | UI components, navigation | +| `kotlin-coroutines` | Flows, async, concurrency | Data streams, async operations | + +## Commands + +- `/desktop-run` - Build and run desktop app +- `/extract ` - Move composable to shared code +- `/nip ` - Get NIP implementation guidance + +## Feature Workflow + +When picking up a new task or feature, follow this process: + +### Step 1: Analyze Android Implementation + +Start by examining the existing Android Amethyst codebase: +1. Find the relevant feature/component in `amethyst/` module +2. Understand the current implementation patterns +3. Identify dependencies and integrations + +### Step 2: Create Implementation Plan + +Before coding, create a plan that categorizes work into three buckets: + +| Category | Description | Location | +|----------|-------------|----------| +| **Android-Specific** | Platform APIs, navigation, layouts | `amethyst/`, `androidMain/` | +| **Reusable (Shared)** | Business logic, UI components, state | `quartz/commonMain/`, `commons/` (convert to KMP) | +| **Desktop-Specific** | Desktop navigation, layouts, platform APIs | `desktopApp/`, `jvmMain/` | + +### Step 3: Code Sharing Strategy + +**Share:** +- Business logic and data models → `quartz/commonMain/` +- Major UI components (cards, lists, dialogs) → `commons/` (convert to KMP as needed) +- State management and ViewModels → shared +- Icons and visual assets → `commons/commonMain/` + +**Keep Platform-Native:** +- Navigation patterns (sidebar vs bottom nav) +- Screen layouts and scaffolding +- Platform-specific interactions (gestures, keyboard shortcuts) +- System integrations (notifications, file pickers) + +### Step 4: Extract Shared Components + +When extracting UI components: +1. Identify reusable composables in Android code +2. Use `/extract ` to move to `commons/commonMain/` +3. Create expect/actual declarations for platform-specific behavior +4. Update both Android and Desktop to use shared component + +**Note:** `quartz/` is protocol-only (no composables). Shared UI goes in `commons/` after converting it to KMP. + +## Build Commands + +```bash +# Run desktop app +./gradlew :desktopApp:run + +# Run Android app +./gradlew :amethyst:installDebug + +# Build Quartz for all targets +./gradlew :quartz:build + +# Run tests +./gradlew test + +# Format code +./gradlew spotlessApply +``` + +## Quartz KMP Structure + +The Quartz library uses expect/actual for platform-specific implementations: + +```kotlin +// commonMain - shared protocol logic +expect class CryptoProvider { + fun sign(message: ByteArray, privateKey: ByteArray): ByteArray + fun verify(message: ByteArray, signature: ByteArray, publicKey: ByteArray): Boolean +} + +// androidMain - uses secp256k1-kmp-jni-android +actual class CryptoProvider { /* Android implementation */ } + +// jvmMain - uses secp256k1-kmp-jni-jvm +actual class CryptoProvider { /* JVM implementation */ } +``` + +## Key Patterns + +### Platform Abstraction +```kotlin +// commonMain +expect fun openExternalUrl(url: String) + +// androidMain +actual fun openExternalUrl(url: String) { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url))) +} + +// jvmMain (Desktop) +actual fun openExternalUrl(url: String) { + Desktop.getDesktop().browse(URI(url)) +} +``` + +### Navigation Shell +- **Desktop**: Sidebar + main content area +- **Android**: Bottom navigation + +## Git Workflow + +- Branch: `feat/desktop-` or `fix/desktop-` +- Commits: Conventional commits (`feat:`, `fix:`, etc.) +- Never use `--no-verify` + +## Resources + +- [Nostr NIPs](https://github.com/nostr-protocol/nips) +- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) +- [KMP Documentation](https://kotlinlang.org/docs/multiplatform.html) diff --git a/.claude/agents/compose-ui.md b/.claude/agents/compose-ui.md new file mode 100644 index 000000000..d51b2eca8 --- /dev/null +++ b/.claude/agents/compose-ui.md @@ -0,0 +1,169 @@ +--- +name: compose-ui +description: Automatically invoked when working with Compose Multiplatform UI code, @Composable functions, desktop Window/MenuBar/Tray, navigation patterns, or UI components in desktopApp/ or shared UI modules. +tools: Read, Edit, Write, Bash, Grep, Glob, Task, WebFetch +model: sonnet +--- + +# Compose Multiplatform UI Agent + +You are a Compose Multiplatform UI expert specializing in shared composables and desktop-specific features. + +## Auto-Trigger Contexts + +Activate when user works with: +- `@Composable` functions +- `desktopApp/` module files +- `Window`, `MenuBar`, `Tray` components +- Navigation patterns (NavigationRail, screens) +- Material3 theming +- Keyboard shortcuts, context menus + +## Core Knowledge + +### Desktop Entry Point +```kotlin +fun main() = application { + Window( + onCloseRequest = ::exitApplication, + state = rememberWindowState(width = 1200.dp, height = 800.dp), + title = "Amethyst Desktop" + ) { + MenuBar { + Menu("File") { + Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true)) { } + Item("Quit", onClick = ::exitApplication) + } + } + App() + } +} +``` + +### Desktop-Specific Features + +**Menu Bar** +```kotlin +MenuBar { + Menu("File") { + Item("New", shortcut = KeyShortcut(Key.N, ctrl = true)) { } + Separator() + Item("Quit", onClick = ::exitApplication) + } +} +``` + +**System Tray** +```kotlin +Tray( + icon = painterResource("icon.png"), + menu = { + Item("Show", onClick = { windowVisible = true }) + Item("Exit", onClick = ::exitApplication) + } +) +``` + +**Context Menus** +```kotlin +ContextMenuArea(items = { + listOf( + ContextMenuItem("Copy") { copyToClipboard(text) }, + ContextMenuItem("Reply") { openReply() } + ) +}) { + Text(content) +} +``` + +**Keyboard Shortcuts** +```kotlin +Modifier.onKeyEvent { event -> + when { + event.isCtrlPressed && event.key == Key.Enter -> { send(); true } + event.key == Key.Escape -> { close(); true } + else -> false + } +} +``` + +### Navigation Pattern +```kotlin +@Composable +fun DesktopLayout(currentScreen: Screen, onNavigate: (Screen) -> Unit) { + Row(Modifier.fillMaxSize()) { + NavigationRail { + NavigationRailItem( + icon = { Icon(Icons.Default.Home, "Feed") }, + selected = currentScreen == Screen.Feed, + onClick = { onNavigate(Screen.Feed) } + ) + // More items... + } + Box(Modifier.weight(1f)) { + when (currentScreen) { + Screen.Feed -> FeedScreen() + Screen.Messages -> MessagesScreen() + } + } + } +} +``` + +### Platform Differences + +| Aspect | Android | Desktop | +|--------|---------|---------| +| **Entry** | Activity | main() + Window | +| **Navigation** | Bottom nav | Sidebar / MenuBar | +| **Input** | Touch | Mouse + Keyboard | +| **Windows** | Single | Multi-window | +| **Menus** | Overflow | MenuBar | + +## Workflow + +### 1. Assess Task +- Shared composable or desktop-specific? +- Navigation change or component work? +- State management needs? + +### 2. Investigate +```bash +# Find existing composables +grep -r "@Composable" desktopApp/src/ +# Check navigation structure +grep -r "Screen\|navigate" desktopApp/src/ +``` + +### 3. Implement +- Shared composables go in shared UI module +- Desktop-specific (Window, MenuBar) in desktopApp +- Use Material3 components +- Handle keyboard/mouse input for desktop + +### 4. Verify +```bash +./gradlew :desktopApp:run +``` + +## State Management +```kotlin +class FeedViewModel { + private val _state = MutableStateFlow(FeedState()) + val state: StateFlow = _state.asStateFlow() +} + +@Composable +fun FeedScreen(viewModel: FeedViewModel) { + val state by viewModel.state.collectAsState() + // UI based on state +} +``` + +## Constraints + +- Prefer shared composables in commonMain when possible +- Desktop-specific features only in desktopApp +- Follow Material3 design guidelines +- Support keyboard navigation for accessibility +- Test on macOS, Windows, Linux when possible diff --git a/.claude/agents/kotlin-coroutines.md b/.claude/agents/kotlin-coroutines.md new file mode 100644 index 000000000..ed1679972 --- /dev/null +++ b/.claude/agents/kotlin-coroutines.md @@ -0,0 +1,187 @@ +--- +name: kotlin-coroutines +description: Automatically invoked when working with coroutines, Flow, StateFlow, SharedFlow, suspend functions, CoroutineScope, channels, or async patterns in Kotlin code. +tools: Read, Edit, Write, Bash, Grep, Glob, Task, WebFetch +model: sonnet +--- + +# Kotlin Coroutines Agent + +You are a Kotlin Coroutines expert specializing in async patterns, Flow, and structured concurrency. + +## Auto-Trigger Contexts + +Activate when user works with: +- `suspend` functions +- `Flow`, `StateFlow`, `SharedFlow` +- `CoroutineScope`, `launch`, `async` +- `Channel`, `channelFlow` +- Dispatchers configuration +- Exception handling in coroutines + +## Core Knowledge + +### Coroutine Builders +```kotlin +// launch: fire-and-forget +val job = launch { delay(1000); println("Done") } + +// async: returns result +val deferred = async { computeValue() } +val result = deferred.await() + +// Structured concurrency +suspend fun loadProfile(userId: String) = coroutineScope { + val user = async { fetchUser(userId) } + val notes = async { fetchNotes(userId) } + Profile(user.await(), notes.await()) +} +``` + +### Dispatchers + +| Dispatcher | Use Case | +|------------|----------| +| `Dispatchers.Main` | UI updates | +| `Dispatchers.IO` | Network, disk I/O | +| `Dispatchers.Default` | CPU-intensive | + +### Flow (Cold Streams) +```kotlin +fun observeNotes(): Flow> = flow { + while (true) { + emit(repository.getNotes()) + delay(30_000) + } +} + +// Operators +repository.observeNotes() + .map { it.filter { note -> note.isVisible } } + .distinctUntilChanged() + .catch { emit(emptyList()) } + .flowOn(Dispatchers.IO) + .collect { updateUI(it) } +``` + +### StateFlow (Hot, Always Has Value) +```kotlin +class FeedViewModel { + private val _state = MutableStateFlow(FeedState()) + val state: StateFlow = _state.asStateFlow() + + fun updateFilter(filter: Filter) { + _state.update { it.copy(filter = filter) } + } +} +``` + +### SharedFlow (Hot, Configurable Replay) +```kotlin +private val _events = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST +) +val events: SharedFlow = _events.asSharedFlow() +``` + +### Channels +```kotlin +fun relayEvents(relay: Relay): Flow = channelFlow { + relay.connect() + relay.onEvent { event -> trySend(event) } + awaitClose { relay.disconnect() } +} +``` + +### Exception Handling +```kotlin +val handler = CoroutineExceptionHandler { _, e -> + log.error("Coroutine failed", e) +} +val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler) + +// supervisorScope: child failures don't cancel siblings +supervisorScope { + launch { task1() } // Can fail independently + launch { task2() } // Continues if task1 fails +} +``` + +## Nostr-Specific Patterns + +### Relay Connection Pool +```kotlin +class RelayPool(private val scope: CoroutineScope) { + fun connect(url: String) { + scope.launch { + supervisorScope { + launch { connection.receiveLoop() } + launch { connection.sendLoop() } + } + } + } + + fun observeEvents(): Flow = relays.values + .map { it.events } + .merge() + .distinctBy { it.id } +} +``` + +### Subscription Management +```kotlin +fun subscribe(filters: List): Flow = channelFlow { + val subId = UUID.randomUUID().toString() + try { + relayPool.activeRelays.collect { relays -> + relays.forEach { relay -> + launch { relay.subscribe(subId, filters).collect { send(it) } } + } + } + } finally { + relayPool.unsubscribe(subId) + } +} +``` + +## Workflow + +### 1. Assess Task +- Cold stream (Flow) or hot stream (StateFlow/SharedFlow)? +- Need structured concurrency? +- Error handling strategy? + +### 2. Investigate +```bash +# Find coroutine usage +grep -r "suspend \|launch\|async\|Flow<" quartz/src/ +# Check existing patterns +grep -r "CoroutineScope\|StateFlow" quartz/src/ +``` + +### 3. Implement +- Use appropriate dispatcher +- Implement proper cancellation +- Handle exceptions at right level +- Use structured concurrency + +### 4. Test +```kotlin +@Test +fun `test async operation`() = runTest { + val result = viewModel.loadData() + advanceUntilIdle() + assertEquals(expected, result) +} +``` + +## Constraints + +- Always use structured concurrency +- Never use `GlobalScope` +- Handle cancellation cooperatively (`ensureActive()`, `yield()`) +- Use `SupervisorJob` when children should fail independently +- Prefer Flow over callbacks +- Use `flowOn` to switch dispatchers, not `withContext` in flow diff --git a/.claude/agents/kotlin-multiplatform.md b/.claude/agents/kotlin-multiplatform.md new file mode 100644 index 000000000..419a7c740 --- /dev/null +++ b/.claude/agents/kotlin-multiplatform.md @@ -0,0 +1,127 @@ +--- +name: kotlin-multiplatform +description: Automatically invoked when working with KMP project structure, build.gradle.kts files, expect/actual declarations, source sets (commonMain, androidMain, jvmMain), or multiplatform migration tasks. Expert in code sharing across Android and Desktop JVM. +tools: Read, Edit, Write, Bash, Grep, Glob, Task, WebFetch +model: sonnet +--- + +# Kotlin Multiplatform Agent + +You are a Kotlin Multiplatform expert specializing in KMP architecture for Android and Desktop JVM targets. + +## Auto-Trigger Contexts + +Activate when user works with: +- `build.gradle.kts` files with multiplatform plugin +- Files in `commonMain/`, `androidMain/`, `jvmMain/` source sets +- `expect` or `actual` declarations +- Cross-platform library selection +- Migration from Android-only to multiplatform + +## Core Knowledge + +### Source Set Hierarchy +``` + commonMain + │ + ┌───────────┼───────────┐ + │ │ │ + jvmMain nativeMain jsMain + │ │ + ┌──────┴───┐ ┌───┴───┐ + │ │ │ │ +androidMain desktopMain iosMain +``` + +### expect/actual Pattern +```kotlin +// commonMain - Declaration +expect class PlatformContext +expect fun getPlatform(): Platform + +// androidMain +actual class PlatformContext(val context: Context) +actual fun getPlatform(): Platform = Platform.Android + +// jvmMain (Desktop) +actual class PlatformContext +actual fun getPlatform(): Platform = Platform.Desktop +``` + +### Platform Dependencies + +| Concern | Android | Desktop JVM | +|---------|---------|-------------| +| **Crypto** | secp256k1-kmp-jni-android | secp256k1-kmp-jni-jvm | +| **Storage** | Android KeyStore | Java KeyStore / File | +| **Sodium** | lazysodium-android | lazysodium-java | +| **UI** | Jetpack Compose | Compose Desktop | + +## Workflow + +### 1. Assess Task +- Identify if task involves shared vs platform-specific code +- Check existing source set structure +- Understand dependency requirements + +### 2. Investigate +```bash +# Check existing structure +ls -la quartz/src/ +# Find expect declarations +grep -r "expect " quartz/src/commonMain/ +# Find actual implementations +grep -r "actual " quartz/src/androidMain/ quartz/src/jvmMain/ +``` + +### 3. Implement +- Place shared code in `commonMain` +- Create `expect` declarations for platform APIs +- Implement `actual` in each target source set +- Update `build.gradle.kts` dependencies per source set + +### 4. Verify +```bash +./gradlew :quartz:build +./gradlew :desktopApp:compileKotlinJvm +``` + +## Quartz KMP Structure + +``` +quartz/src/ +├── commonMain/kotlin/ # Shared Nostr protocol +├── commonTest/kotlin/ # Shared tests +├── androidMain/kotlin/ # Android crypto, storage +└── jvmMain/kotlin/ # Desktop crypto, storage +``` + +## Key Abstractions for This Project + +- `CryptoProvider` - secp256k1 signing/verification +- `SodiumProvider` - NIP-44 encryption +- `SecureStorage` - Key storage +- `PlatformContext` - Platform-specific context + +## Constraints + +- Maximize code in `commonMain` (target 70-80%) +- Use KMP-compatible libraries only in commonMain +- Platform implementations must have identical signatures +- Run tests on all targets before completing + +## Resources + +Reference these GitHub repositories for KMP patterns and libraries: + +| Repository | Focus | Key Examples | +|------------|-------|--------------| +| [joreilly](https://github.com/joreilly) | KMP samples | PeopleInSpace, Confetti, GeminiKMP | +| [touchlab](https://github.com/touchlab) | KMP tooling | Kermit (logging), Stately (state), SKIE | +| [cashapp](https://github.com/cashapp) | KMP libraries | SQLDelight, Turbine, Molecule | + +**Useful libraries from these sources:** +- `SQLDelight` - Type-safe SQL for all platforms +- `Kermit` - Multiplatform logging +- `Turbine` - Testing Kotlin Flows +- `Molecule` - Build UI state with Compose diff --git a/.claude/agents/nostr-protocol.md b/.claude/agents/nostr-protocol.md new file mode 100644 index 000000000..ea6073f66 --- /dev/null +++ b/.claude/agents/nostr-protocol.md @@ -0,0 +1,168 @@ +--- +name: nostr-protocol +description: Automatically invoked when working with Nostr events, NIPs, relay communication, cryptographic operations (signing, encryption), or Quartz library code for protocol implementation. +tools: Read, Edit, Write, Bash, Grep, Glob, Task, WebFetch +model: sonnet +--- + +# Nostr Protocol Agent + +You are a Nostr Protocol expert specializing in NIPs, events, relays, and cryptographic operations. + +## Auto-Trigger Contexts + +Activate when user works with: +- Event classes (kind, tags, content, sig) +- NIP implementations +- Relay WebSocket communication +- Cryptographic operations (secp256k1, NIP-44) +- Quartz library protocol code +- Filters and subscriptions + +## Core Knowledge + +### Event Structure +```kotlin +data class Event( + val id: String, // SHA256 of serialized event + val pubkey: String, // 32-byte hex public key + val created_at: Long, // Unix timestamp + val kind: Int, // Event type + val tags: List>, + val content: String, + val sig: String // 64-byte Schnorr signature +) + +// Event ID = SHA256([0, pubkey, created_at, kind, tags, content]) +``` + +### NIP Categories + +| Category | NIPs | Scope | +|----------|------|-------| +| **Core** | 01, 02, 10, 11 | Basic events, follows, threads, relay info | +| **Messaging** | 04, 17, 44 | DMs, encrypted messaging | +| **Social** | 18, 25, 32, 51 | Reactions, reports, lists | +| **Identity** | 05, 19, 39, 46 | DNS, bech32, bunker, signer | +| **Media** | 23, 30, 54, 94 | Long-form, audio, video, blobs | +| **Payments** | 47, 57, 60 | Wallet Connect, zaps, cashu | + +### Relay Messages +``` +Client -> Relay: + ["REQ", , ...] # Subscribe + ["EVENT", ] # Publish + ["CLOSE", ] # Unsubscribe + +Relay -> Client: + ["EVENT", , ] # Event received + ["EOSE", ] # End of stored events + ["OK", , , ] # Publish result + ["NOTICE", ] # Info message +``` + +### Cryptographic Operations + +**Signing (Schnorr/BIP-340)** +```kotlin +val signature = Secp256k1.sign( + data = eventHash, + privateKey = privateKeyBytes +) +``` + +**NIP-44 Encryption (Modern)** +```kotlin +val ciphertext = Nip44.encrypt( + plaintext = message, + sharedSecret = computeSharedSecret(myPrivKey, theirPubKey) +) +``` + +**NIP-04 Encryption (Deprecated)** +```kotlin +// AES-256-CBC - only for legacy compatibility +val ciphertext = Nip04.encrypt(message, sharedSecret) +``` + +### Common Event Kinds + +| Kind | NIP | Description | +|------|-----|-------------| +| 0 | 01 | Metadata (profile) | +| 1 | 01 | Short text note | +| 3 | 02 | Follows list | +| 4 | 04 | Encrypted DM (deprecated) | +| 7 | 25 | Reaction | +| 1984 | 32 | Report | +| 30023 | 23 | Long-form content | + +### Tag Patterns +```kotlin +// Reply threading (NIP-10) +tags = listOf( + listOf("e", rootEventId, relayUrl, "root"), + listOf("e", replyToId, relayUrl, "reply"), + listOf("p", authorPubkey) +) + +// Mentions +tags = listOf( + listOf("p", mentionedPubkey), + listOf("t", "hashtag") +) +``` + +## Workflow + +### 1. Assess Task +- Which NIP(s) are involved? +- Event creation or parsing? +- Relay communication? +- Crypto operations needed? + +### 2. Investigate +```bash +# Check existing NIP implementations +grep -r "kind.*=" quartz/src/ +# Find event classes +grep -r "class.*Event" quartz/src/ +# Check crypto usage +grep -r "Secp256k1\|Nip44\|sign\|verify" quartz/src/ +``` + +### 3. Reference NIP Spec +```bash +# Fetch NIP specification +curl https://raw.githubusercontent.com/nostr-protocol/nips/master/XX.md +``` + +### 4. Implement +- Follow NIP spec exactly +- Use existing Quartz patterns +- Validate event structure +- Test signature verification + +### 5. Verify +```bash +./gradlew :quartz:test +``` + +## Quartz Library Structure +``` +quartz/src/commonMain/kotlin/ +├── events/ # Event types per NIP +├── encoders/ # Bech32, hex encoding +├── crypto/ # Signing, encryption +├── relay/ # WebSocket communication +└── filters/ # Subscription filters +``` + +## Constraints + +- Always follow NIP specifications exactly +- Use NIP-44 for new encryption (not NIP-04) +- Validate all incoming events (sig, id) +- Never log private keys or decrypted content +- Use existing Quartz classes when available +- Test with real relay responses when possible diff --git a/.claude/commands/desktop-run.md b/.claude/commands/desktop-run.md new file mode 100644 index 000000000..6b43f0d2b --- /dev/null +++ b/.claude/commands/desktop-run.md @@ -0,0 +1,45 @@ +--- +description: Build and run the desktop app +--- + +Build and run the Amethyst Desktop application: + +```bash +./gradlew :desktopApp:run +``` + +## Troubleshooting + +If the build fails, check: + +1. **JDK Version**: Requires JDK 17+ + ```bash + java -version + ``` + +2. **Compose Multiplatform Plugin**: Verify version in `gradle/libs.versions.toml` + +3. **Quartz Build**: Ensure Quartz compiles first + ```bash + ./gradlew :quartz:build + ``` + +4. **Desktop Dependencies**: Check `desktopApp/build.gradle.kts` has: + ```kotlin + implementation(compose.desktop.currentOs) + ``` + +## Creating Distributable + +```bash +# macOS +./gradlew :desktopApp:packageDmg + +# Windows +./gradlew :desktopApp:packageMsi + +# Linux +./gradlew :desktopApp:packageDeb +``` + +Outputs will be in `desktopApp/build/compose/binaries/` diff --git a/.claude/commands/extract.md b/.claude/commands/extract.md new file mode 100644 index 000000000..99c01eea7 --- /dev/null +++ b/.claude/commands/extract.md @@ -0,0 +1,50 @@ +--- +description: Extract a composable from amethyst to shared code +--- + +Extract the component `$ARGUMENTS` from the Android app to shared KMP code: + +## Process + +1. **Locate the component** in the amethyst module: + ```bash + find amethyst/src -name "*$ARGUMENTS*" -o -name "*$ARGUMENTS*" + grep -r "fun $ARGUMENTS\|class $ARGUMENTS" amethyst/src/ + ``` + +2. **Analyze dependencies**: + - Android-specific imports (Context, Intent, etc.) + - Platform APIs (Camera, MediaStore, etc.) + - Android Compose specifics vs standard Compose + +3. **Identify what can be shared**: + - Pure Composable functions → `shared-ui/commonMain/` + - Business logic → `quartz/commonMain/` + - Platform-specific → create expect/actual + +4. **Create shared version**: + - Move to appropriate shared module + - Replace Android imports with multiplatform alternatives + - Add expect declarations for platform-specific parts + +5. **Update references**: + - Change imports in amethyst module + - Add implementations in desktopApp if needed + +## Common Replacements + +| Android | Multiplatform | +|---------|---------------| +| `LocalContext.current` | expect/actual or parameter | +| `stringResource()` | `Res.string.*` | +| `painterResource()` | `painterResource(Res.drawable.*)` | +| `Toast.makeText()` | Custom snackbar/notification | +| `Intent` | expect/actual for navigation | + +## Example + +``` +/extract NoteCard +``` + +This will find NoteCard, analyze its dependencies, and guide you through extracting it to shared code. diff --git a/.claude/commands/nip.md b/.claude/commands/nip.md new file mode 100644 index 000000000..2c7631be0 --- /dev/null +++ b/.claude/commands/nip.md @@ -0,0 +1,32 @@ +--- +description: Get NIP specification and implementation guidance +--- + +Fetch and explain NIP-$ARGUMENTS from the Nostr protocol: + +1. **Get the specification** from https://github.com/nostr-protocol/nips/blob/master/$ARGUMENTS.md + +2. **Show key details**: + - Event kind(s) used + - Required and optional fields + - Tag structure + - Message flow between client and relay + +3. **Check implementation status** in Quartz: + ```bash + grep -r "NIP-$ARGUMENTS\|nip$ARGUMENTS\|kind.*=" quartz/src/ + ``` + +4. **Provide implementation guidance**: + - Which Quartz classes to use or create + - Event construction example + - Relay subscription filters + - Verification/validation logic + +## Example Usage + +``` +/nip 01 # Basic protocol +/nip 44 # Versioned encryption +/nip 57 # Zaps +``` diff --git a/.claude/skills/compose-desktop.md b/.claude/skills/compose-desktop.md new file mode 100644 index 000000000..7f9d3b6c9 --- /dev/null +++ b/.claude/skills/compose-desktop.md @@ -0,0 +1,339 @@ +# Compose Desktop Skill + +## Desktop Application Entry Point + +```kotlin +// desktopApp/src/jvmMain/kotlin/Main.kt +package com.vitorpamplona.amethyst.desktop + +import androidx.compose.ui.Alignment +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.* + +fun main() = application { + val windowState = rememberWindowState( + width = 1200.dp, + height = 800.dp, + position = WindowPosition.Aligned(Alignment.Center) + ) + + // System tray + Tray( + icon = painterResource("icon.png"), + tooltip = "Amethyst", + menu = { + Item("Show", onClick = { windowState.isMinimized = false }) + Separator() + Item("Exit", onClick = ::exitApplication) + } + ) + + Window( + onCloseRequest = ::exitApplication, + state = windowState, + title = "Amethyst", + icon = painterResource("icon.png") + ) { + MenuBar { + Menu("File") { + Item("New Note", shortcut = KeyShortcut(Key.N, ctrl = true)) { } + Separator() + Item("Settings", shortcut = KeyShortcut(Key.Comma, ctrl = true)) { } + Separator() + Item("Quit", shortcut = KeyShortcut(Key.Q, ctrl = true), onClick = ::exitApplication) + } + Menu("Edit") { + Item("Copy", shortcut = KeyShortcut(Key.C, ctrl = true)) { } + Item("Paste", shortcut = KeyShortcut(Key.V, ctrl = true)) { } + } + Menu("View") { + Item("Feed") { } + Item("Messages") { } + Item("Notifications") { } + } + Menu("Help") { + Item("About Amethyst") { } + } + } + + App() + } +} +``` + +## Desktop-Specific Components + +### File Dialog +```kotlin +@Composable +fun rememberFileDialog(): FileDialogState { + return remember { FileDialogState() } +} + +class FileDialogState { + var isOpen by mutableStateOf(false) + var result by mutableStateOf(null) + + fun open() { isOpen = true } + + @Composable + fun Dialog( + title: String = "Select File", + allowedExtensions: List = emptyList() + ) { + if (isOpen) { + DisposableEffect(Unit) { + val dialog = java.awt.FileDialog(null as java.awt.Frame?, title) + if (allowedExtensions.isNotEmpty()) { + dialog.setFilenameFilter { _, name -> + allowedExtensions.any { name.endsWith(it) } + } + } + dialog.isVisible = true + result = dialog.file?.let { File(dialog.directory, it) } + isOpen = false + onDispose { } + } + } + } +} +``` + +### Scroll Behavior +```kotlin +@Composable +fun DesktopScrollableColumn( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + val scrollState = rememberScrollState() + + Box(modifier) { + Column( + modifier = Modifier + .verticalScroll(scrollState) + .fillMaxSize() + ) { + content() + } + + VerticalScrollbar( + modifier = Modifier.align(Alignment.CenterEnd), + adapter = rememberScrollbarAdapter(scrollState) + ) + } +} +``` + +### Keyboard Navigation +```kotlin +@Composable +fun KeyboardNavigableList( + items: List, + selectedIndex: Int, + onSelect: (Int) -> Unit, + onActivate: (Note) -> Unit +) { + val focusRequester = remember { FocusRequester() } + + LaunchedEffect(Unit) { + focusRequester.requestFocus() + } + + LazyColumn( + modifier = Modifier + .focusRequester(focusRequester) + .focusable() + .onKeyEvent { event -> + when { + event.key == Key.DirectionDown && event.type == KeyEventType.KeyDown -> { + onSelect((selectedIndex + 1).coerceAtMost(items.lastIndex)) + true + } + event.key == Key.DirectionUp && event.type == KeyEventType.KeyDown -> { + onSelect((selectedIndex - 1).coerceAtLeast(0)) + true + } + event.key == Key.Enter && event.type == KeyEventType.KeyDown -> { + items.getOrNull(selectedIndex)?.let { onActivate(it) } + true + } + else -> false + } + } + ) { + itemsIndexed(items) { index, note -> + NoteCard( + note = note, + isSelected = index == selectedIndex, + modifier = Modifier.clickable { onSelect(index) } + ) + } + } +} +``` + +### Multi-Window Support +```kotlin +@Composable +fun ApplicationScope.NoteDetailWindow( + note: Note, + onClose: () -> Unit +) { + Window( + onCloseRequest = onClose, + title = "Note by ${note.author.name}", + state = rememberWindowState(width = 600.dp, height = 400.dp) + ) { + NoteDetailScreen(note) + } +} + +// Usage in main application +var openNotes by remember { mutableStateOf>(emptyList()) } + +openNotes.forEach { note -> + key(note.id) { + NoteDetailWindow( + note = note, + onClose = { openNotes = openNotes - note } + ) + } +} +``` + +### Tooltips +```kotlin +@Composable +fun TooltipButton( + tooltip: String, + onClick: () -> Unit, + content: @Composable () -> Unit +) { + TooltipArea( + tooltip = { + Surface( + shape = RoundedCornerShape(4.dp), + color = MaterialTheme.colorScheme.inverseSurface + ) { + Text( + text = tooltip, + modifier = Modifier.padding(8.dp), + color = MaterialTheme.colorScheme.inverseOnSurface + ) + } + } + ) { + IconButton(onClick = onClick) { + content() + } + } +} +``` + +## Desktop Layout Pattern + +```kotlin +@Composable +fun DesktopAppLayout( + currentScreen: Screen, + onNavigate: (Screen) -> Unit, + content: @Composable () -> Unit +) { + Row(Modifier.fillMaxSize()) { + // Sidebar navigation + NavigationRail( + modifier = Modifier.width(72.dp), + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) { + Spacer(Modifier.height(16.dp)) + + NavigationRailItem( + icon = { Icon(Icons.Default.Home, "Feed") }, + label = { Text("Feed") }, + selected = currentScreen == Screen.Feed, + onClick = { onNavigate(Screen.Feed) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Email, "Messages") }, + label = { Text("DMs") }, + selected = currentScreen == Screen.Messages, + onClick = { onNavigate(Screen.Messages) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Notifications, "Notifications") }, + label = { Text("Alerts") }, + selected = currentScreen == Screen.Notifications, + onClick = { onNavigate(Screen.Notifications) } + ) + + Spacer(Modifier.weight(1f)) + + NavigationRailItem( + icon = { Icon(Icons.Default.Settings, "Settings") }, + label = { Text("Settings") }, + selected = currentScreen == Screen.Settings, + onClick = { onNavigate(Screen.Settings) } + ) + } + + // Divider + VerticalDivider() + + // Main content + Box(Modifier.weight(1f).fillMaxHeight()) { + content() + } + } +} +``` + +## Build Configuration + +```kotlin +// desktopApp/build.gradle.kts +plugins { + kotlin("jvm") + id("org.jetbrains.compose") +} + +dependencies { + implementation(compose.desktop.currentOs) + implementation(compose.material3) + implementation(project(":quartz")) +} + +compose.desktop { + application { + mainClass = "com.vitorpamplona.amethyst.desktop.MainKt" + + nativeDistributions { + targetFormats( + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg, + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Msi, + org.jetbrains.compose.desktop.application.dsl.TargetFormat.Deb + ) + + packageName = "Amethyst" + packageVersion = "1.0.0" + + macOS { + bundleID = "com.vitorpamplona.amethyst.desktop" + iconFile.set(project.file("icons/icon.icns")) + } + + windows { + iconFile.set(project.file("icons/icon.ico")) + menuGroup = "Amethyst" + } + + linux { + iconFile.set(project.file("icons/icon.png")) + } + } + } +} +``` diff --git a/.claude/skills/quartz-kmp.md b/.claude/skills/quartz-kmp.md new file mode 100644 index 000000000..49e5000ff --- /dev/null +++ b/.claude/skills/quartz-kmp.md @@ -0,0 +1,165 @@ +# Quartz KMP Conversion Skill + +When working with Quartz library conversion to Kotlin Multiplatform: + +## Current Structure (Android-only) +``` +quartz/ +├── build.gradle +└── src/ + ├── main/kotlin/ # All Nostr code here + ├── test/ + └── androidTest/ +``` + +## 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 diff --git a/.gitignore b/.gitignore index 72ba3e1b4..e1e4e4ec7 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,9 @@ /.idea/kotlinNotebook.xml /.idea/ChatHistory_schema_v3.xml /.idea/markdown.xml +/.idea/AndroidProjectSystem.xml +/.idea/deviceManager.xml +/.idea/inspectionProfiles/ .DS_Store /build /captures @@ -138,3 +141,6 @@ lint/generated/ lint/outputs/ lint/tmp/ # lint/reports/ + +# Local task tracking +TASKS.md diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml deleted file mode 100644 index 4a53bee8c..000000000 --- a/.idea/AndroidProjectSystem.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml deleted file mode 100644 index 91f95584d..000000000 --- a/.idea/deviceManager.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index bb270b80a..000000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,77 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/kotlinNotebook.xml b/.idea/kotlinNotebook.xml deleted file mode 100644 index 665e38dae..000000000 --- a/.idea/kotlinNotebook.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index bdfaa5e06..26a6b8f2a 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -4,9 +4,13 @@ - + + - \ No newline at end of file diff --git a/README.md b/README.md index 46f7773a0..54297b800 100644 --- a/README.md +++ b/README.md @@ -153,9 +153,11 @@ Information shared on Nostr can be re-broadcasted to other servers and should be # Development Overview -This repository is split between Amethyst and Quartz: -- Amethyst is a native Android app made with Kotlin and Jetpack Compose. -- Quartz is our own Nostr-commons library to host classes that are of interest to other Nostr Clients. +This repository is split between Amethyst, Quartz, Commons, and DesktopApp: +- **Amethyst** - Native Android app with Kotlin and Jetpack Compose +- **Quartz** - Nostr-commons KMP library for protocol classes shared across platforms +- **Commons** - Kotlin Multiplatform module with shared UI components (icons, robohash, blurhash, composables) +- **DesktopApp** - Compose Multiplatform Desktop application reusing commons and quartz The app architecture consists of the UI, which uses the usual State/ViewModel/Composition, the service layer that connects with Nostr relays, and the model/repository layer, which keeps all Nostr objects in memory, in a full OO graph. @@ -187,11 +189,17 @@ git clone https://github.com/vitorpamplona/amethyst.git Use an Android Studio build action to install and run the app on your device or a simulator. ## Building -Build the app: + +Build the Android app: ```bash ./gradlew assembleDebug ``` +Build and run the Desktop app (requires Java 21+): +```bash +./gradlew :desktopApp:run +``` + ## Testing ```bash ./gradlew test @@ -369,6 +377,28 @@ When your app goes to the background, you can use NostrClient's `connect` and `d methods to stop all communication to relays. Add the `connect` to your `onResume` and `disconnect` to `onPause` methods. +### Feature Parity Table + +| Feature Category | Feature / Component | Android / JVM Support | iOS Support | Notes | +| :--- | :--- | :---: | :---: | :--- | +| **Cryptography** | Secp256k1 (Schnorr, Keys) | ✅ Full | ❌ No | Core Nostr signing/verification is missing on iOS. | +| | LibSodium (ChaCha20, Poly1305) | ✅ Full | ❌ No | AEAD and stream ciphers are unimplemented. | +| | AES Encryption (CBC & GCM) | ✅ Full | ❌ No | `AESCBC` and `AESGCM` are stubs on iOS. | +| | Hashing (SHA-256, etc.) | ✅ Full | ❌ No | `DigestInstance` is unimplemented. | +| | MAC (HmacSHA256, etc.) | ✅ Full | ❌ No | `MacInstance` is unimplemented. | +| **Data & Serialization** | JSON Mapping (Optimized) | ✅ Full | ❌ No | `OptimizedJsonMapper` is a stub; cannot parse/serialize Events. | +| | GZip Compression | ✅ Full | ❌ No | `GZip` implementation is missing. | +| | BitSet | ✅ Full | ❌ No | `BitSet` utility is unimplemented. | +| | LargeCache | ✅ Full | ❌ No | `LargeCache` methods (get, keys, size, etc.) are stubs. | +| **NIP Support** | NIP-96 (File Storage Info) | ✅ Full | ❌ No | `ServerInfoParser` is unimplemented. | +| | NIP-46 (Remote Signer) | ✅ Full | ⚠️ Partial | Some methods in `NostrSignerRemote` are unimplemented in `commonMain`. | +| | NIP-03 (OTS / Timestamps) | ✅ Full | ❌ No | `BitcoinExplorer` and `RemoteCalendar` have stubs in `commonMain`. | +| **Utilities** | URL Encoding / Decoding | ✅ Full | ❌ No | `UrlEncoder` and `URLs.ios.kt` are unimplemented. | +| | Unicode Normalization | ✅ Full | ❌ No | `UnicodeNormalizer` is a stub. | +| | Platform Logging | ✅ Full | ✅ Full | iOS uses `NSLog`, Android uses standard Log. | +| | Current Time | ✅ Full | ✅ Full | Implemented using `NSDate` on iOS. | + + ## Contributing Issues can be logged on: [https://gitworkshop.dev/repo/amethyst](https://gitworkshop.dev/repo/amethyst) 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 d32ca043b..c38e80510 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -202,6 +202,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.mimeType import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.Log @@ -1002,7 +1003,7 @@ class Account( hash: String, duration: Int, waveform: List, - replyTo: EventHintBundle, + replyTo: EventHintBundle, ) { signAndComputeBroadcast(VoiceReplyEvent.build(url, mimeType, hash, duration, waveform, replyTo)) } 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 e51f89b14..d1d4303a7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/UiSettingsFlow.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.model +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.combine @@ -36,23 +37,24 @@ class UiSettingsFlow( val featureSet: MutableStateFlow = MutableStateFlow(FeatureSetType.SIMPLIFIED), val gallerySet: MutableStateFlow = MutableStateFlow(ProfileGalleryType.CLASSIC), ) { + val listOfFlows: List> = + listOf>( + theme, + preferredLanguage, + automaticallyShowImages, + automaticallyStartPlayback, + automaticallyShowUrlPreview, + automaticallyHideNavigationBars, + automaticallyShowProfilePictures, + dontShowPushNotificationSelector, + dontAskForNotificationPermissions, + featureSet, + gallerySet, + ) + // emits at every change in any of the propertyes. - val propertyWatchFlow = - combine( - listOf( - theme, - preferredLanguage, - automaticallyShowImages, - automaticallyStartPlayback, - automaticallyShowUrlPreview, - automaticallyHideNavigationBars, - automaticallyShowProfilePictures, - dontShowPushNotificationSelector, - dontAskForNotificationPermissions, - featureSet, - gallerySet, - ), - ) { flows -> + val propertyWatchFlow: Flow = + combine(listOfFlows) { flows: Array -> UiSettings( flows[0] as ThemeType, flows[1] as String?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt index 50b1621bf..bbab146da 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/Base64Fetcher.kt @@ -31,6 +31,7 @@ import coil3.fetch.ImageFetchResult import coil3.key.Keyer import coil3.request.Options import com.vitorpamplona.amethyst.commons.base64Image.Base64Image +import com.vitorpamplona.amethyst.commons.base64Image.toBitmap import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.utils.sha256.sha256 @@ -42,7 +43,7 @@ class Base64Fetcher( override suspend fun fetch(): FetchResult? = runCatching { ImageFetchResult( - image = Base64Image.Companion.toBitmap(data.toString()).asImage(true), + image = Base64Image.toBitmap(data.toString()).asImage(true), isSampled = false, dataSource = DataSource.MEMORY, ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt index c755f8d14..f128bcd02 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/images/BlurHashFetcher.kt @@ -30,6 +30,7 @@ import coil3.fetch.ImageFetchResult import coil3.key.Keyer import coil3.request.Options import com.vitorpamplona.amethyst.commons.blurhash.BlurHashDecoder +import com.vitorpamplona.amethyst.commons.blurhash.toAndroidBitmap data class BlurhashWrapper( val blurhash: String, @@ -43,10 +44,10 @@ class BlurHashFetcher( override suspend fun fetch(): FetchResult? { val hash = data.blurhash - val bitmap = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null + val platformImage = BlurHashDecoder.decodeKeepAspectRatio(hash, 25) ?: return null return ImageFetchResult( - image = bitmap.asImage(true), + image = platformImage.toAndroidBitmap().asImage(true), isSampled = false, dataSource = DataSource.MEMORY, ) 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 new file mode 100644 index 000000000..d7ecec0e0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/mediaServers/FileServerSelectionRow.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.amethyst.ui.actions.mediaServers + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.components.TextSpinner +import com.vitorpamplona.amethyst.ui.components.TitleExplainer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SettingsRow +import com.vitorpamplona.amethyst.ui.stringRes +import kotlinx.collections.immutable.toImmutableList + +@Composable +fun FileServerSelectionRow( + fileServers: List, + selectedServer: ServerName?, + onSelect: (ServerName) -> Unit, +) { + val nip95description = stringRes(id = R.string.upload_server_relays_nip95) + + val fileServerOptions = + remember(fileServers) { + fileServers + .map { + if (it.type == ServerType.NIP95) { + TitleExplainer(it.name, nip95description) + } else { + TitleExplainer(it.name, it.baseUrl) + } + }.toImmutableList() + } + + val placeholder = + fileServers + .firstOrNull { it == selectedServer } + ?.name + ?: fileServers.firstOrNull()?.name + ?: "" + + SettingsRow(R.string.file_server, R.string.file_server_description) { + TextSpinner( + label = "", + placeholder = placeholder, + options = fileServerOptions, + onSelect = { index -> fileServers.getOrNull(index)?.let(onSelect) }, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt index 9b81d710f..a4e3ecd65 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/RecordingIndicators.kt @@ -175,18 +175,27 @@ fun FloatingRecordingIndicator( modifier: Modifier = Modifier, isRecording: Boolean, elapsedSeconds: Int, + isCompact: Boolean = false, ) { if (!isRecording) return val recordingLabel = stringRes(id = R.string.recording_indicator_description) - val recordingWithTime = stringRes(id = R.string.recording_indicator_with_time, formatSecondsToTime(elapsedSeconds)) + val recordingWithTime = + if (isCompact) { + formatSecondsToTime(elapsedSeconds) + } else { + stringRes(id = R.string.recording_indicator_with_time, formatSecondsToTime(elapsedSeconds)) + } + val horizontalPadding = if (isCompact) 8.dp else 16.dp + val innerPadding = if (isCompact) 6.dp else 12.dp + val textSize = if (isCompact) 12.sp else 14.sp Box( modifier = modifier .fillMaxWidth() .height(48.dp) - .padding(horizontal = 16.dp) + .padding(horizontal = horizontalPadding) .background( color = MaterialTheme.colorScheme.primary, shape = RoundedCornerShape(12.dp), @@ -195,7 +204,7 @@ fun FloatingRecordingIndicator( ) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 12.dp), + modifier = Modifier.padding(horizontal = innerPadding), ) { // Pulsing red dot val infiniteTransition = rememberInfiniteTransition(label = "recording_dot") @@ -223,8 +232,10 @@ fun FloatingRecordingIndicator( Text( text = recordingWithTime, color = Color.White, - fontSize = 14.sp, + fontSize = textSize, fontWeight = FontWeight.Medium, + maxLines = 1, + softWrap = false, ) } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt new file mode 100644 index 000000000..2720dc877 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/actions/uploads/UploadProgressIndicator.kt @@ -0,0 +1,101 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.uploads + +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size55Modifier + +@Composable +fun UploadProgressIndicator( + orchestrator: UploadOrchestrator, + modifier: Modifier = Modifier, +) { + val progressValue = orchestrator.progress.collectAsState().value + val progressStatusValue = orchestrator.progressState.collectAsState().value + + Box( + modifier = + modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier.size(55.dp), + contentAlignment = Alignment.Center, + ) { + val animatedProgress = + animateFloatAsState( + targetValue = progressValue.toFloat(), + animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, + ).value + + CircularProgressIndicator( + progress = { animatedProgress }, + modifier = + Size55Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.background), + strokeWidth = 5.dp, + ) + + val txt = + when (progressStatusValue) { + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished) + is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error) + } + + Text( + txt, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 10.sp, + textAlign = TextAlign.Center, + ) + } + } +} 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 b1557de3a..30d7e9a14 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 @@ -85,6 +85,7 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.hashtag.HashtagPostScreen 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.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 @@ -289,6 +290,18 @@ fun AppNavigation( nav = nav, ) } + + composableFromBottomArgs { + VoiceReplyScreen( + replyToNoteId = it.replyToNoteId, + recordingFilePath = it.recordingFilePath, + mimeType = it.mimeType, + duration = it.duration, + amplitudesJson = it.amplitudes, + accountViewModel = accountViewModel, + nav = nav, + ) + } } } 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 9c3ccf5a9..631edb320 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 @@ -285,6 +285,15 @@ sealed class Route { val draft: String? = null, ) : Route() + @Serializable + data class VoiceReply( + val replyToNoteId: String, + val recordingFilePath: String, + val mimeType: String, + val duration: Int, + val amplitudes: String, // JSON-encoded List + ) : Route() + @Serializable data class ManualZapSplitPayment( val paymentId: String, @@ -334,6 +343,7 @@ fun getRouteWithArguments(navController: NavHostController): Route? { dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() + dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() dest.hasRoute() -> entry.toRoute() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/ReactionsRow.kt index 716d0191f..279a3414f 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 @@ -47,6 +47,8 @@ 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.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -61,6 +63,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.State import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -109,6 +112,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNo import com.vitorpamplona.amethyst.service.relayClient.reqCommand.event.observeNoteZaps import com.vitorpamplona.amethyst.service.relayClient.reqCommand.nwc.NWCFinderFilterAssemblerSubscription import com.vitorpamplona.amethyst.ui.actions.CrossfadeIfEnabled +import com.vitorpamplona.amethyst.ui.actions.uploads.FloatingRecordingIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox import com.vitorpamplona.amethyst.ui.components.AnimatedBorderTextCornerRadius import com.vitorpamplona.amethyst.ui.components.ClickableBox @@ -169,6 +173,7 @@ import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json import kotlin.math.roundToInt import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.Uuid @@ -204,9 +209,12 @@ private fun InnerReactionRow( accountViewModel: AccountViewModel, nav: INav, ) { + val voiceRecordingState = remember(baseNote.idHex) { mutableStateOf(false) } + GenericInnerReactionRow( showReactionDetail = showReactionDetail, addPadding = addPadding, + weightTwo = if (voiceRecordingState.value) 2f else 1f, one = { WatchReactionsZapsBoostsAndDisplayIfExists(baseNote, accountViewModel) { RenderShowIndividualReactionsButton(wantsToSeeReactions, accountViewModel) @@ -218,6 +226,7 @@ private fun InnerReactionRow( MaterialTheme.colorScheme.placeholderText, accountViewModel, nav, + voiceRecordingState = voiceRecordingState, ) }, three = { @@ -288,6 +297,7 @@ fun ShareReaction( private fun GenericInnerReactionRow( showReactionDetail: Boolean, addPadding: Boolean, + weightTwo: Float = 1f, one: @Composable () -> Unit, two: @Composable () -> Unit, three: @Composable () -> Unit, @@ -309,7 +319,7 @@ private fun GenericInnerReactionRow( } } - Row(verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing, modifier = Modifier.weight(1f)) { two() } + Row(verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing, modifier = Modifier.weight(weightTwo)) { two() } Row(verticalAlignment = CenterVertically, horizontalArrangement = RowColSpacing, modifier = Modifier.weight(1f)) { three() } @@ -583,9 +593,16 @@ private fun ReplyReactionWithDialog( grayTint: Color, accountViewModel: AccountViewModel, nav: INav, + voiceRecordingState: MutableState? = null, ) { if (baseNote.event is BaseVoiceEvent) { - ReplyViaVoiceReaction(baseNote, grayTint, accountViewModel) + ReplyViaVoiceReaction( + baseNote, + grayTint, + accountViewModel, + nav, + voiceRecordingState = voiceRecordingState, + ) } else { ReplyReaction(baseNote, grayTint, accountViewModel) { nav.nav { routeReplyTo(baseNote, accountViewModel.account) } @@ -599,18 +616,45 @@ fun ReplyViaVoiceReaction( baseNote: Note, grayTint: Color, accountViewModel: AccountViewModel, + nav: INav, showCounter: Boolean = true, iconSizeModifier: Modifier = Size19Modifier, + voiceRecordingState: MutableState? = null, ) { - val context = LocalContext.current - RecordAudioBox( - modifier = iconSizeModifier, + modifier = Modifier, onRecordTaken = { audio -> - accountViewModel.sendVoiceReply(baseNote, audio, context) + nav.nav { + Route.VoiceReply( + replyToNoteId = baseNote.idHex, + recordingFilePath = audio.file.absolutePath, + mimeType = audio.mimeType, + duration = audio.duration, + amplitudes = Json.encodeToString(audio.amplitudes), + ) + } }, - ) { _, _ -> - VoiceReplyIcon(iconSizeModifier, grayTint) + ) { isRecording, elapsedSeconds -> + if (voiceRecordingState != null) { + SideEffect { + if (voiceRecordingState.value != isRecording) { + voiceRecordingState.value = isRecording + } + } + } + if (isRecording) { + FloatingRecordingIndicator( + modifier = + Modifier + .fillMaxWidth() + .height(40.dp), + isRecording = true, + elapsedSeconds = elapsedSeconds, + isCompact = true, + ) + } else { + VoiceReplyIcon(iconSizeModifier, grayTint) + } } if (showCounter) { 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 3e44b809e..8264d3cd5 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 @@ -134,7 +134,7 @@ import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip90Dvms.NIP90ContentDiscoveryResponseEvent import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag -import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.TimeUtils @@ -1154,7 +1154,7 @@ class AccountViewModel( context: Context, ) { if (isWriteable()) { - val hint = note.toEventHint() ?: return + val hint = note.toEventHint() ?: return launchSigner { val uploader = UploadOrchestrator() 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 b347e68b3..504c723be 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 @@ -24,10 +24,7 @@ import android.content.Intent import android.net.Uri import android.os.Parcelable import androidx.activity.compose.BackHandler -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -38,47 +35,37 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ProgressIndicatorDefaults import androidx.compose.material3.Scaffold import androidx.compose.material3.Surface import androidx.compose.material3.Switch -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment.Companion.CenterVertically import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp 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.service.uploads.UploadOrchestrator +import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType import com.vitorpamplona.amethyst.ui.actions.uploads.RecordVoiceButton import com.vitorpamplona.amethyst.ui.actions.uploads.SelectFromGallery import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia import com.vitorpamplona.amethyst.ui.actions.uploads.TakePictureButton import com.vitorpamplona.amethyst.ui.actions.uploads.TakeVideoButton +import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator import com.vitorpamplona.amethyst.ui.actions.uploads.VoiceMessagePreview -import com.vitorpamplona.amethyst.ui.components.TextSpinner -import com.vitorpamplona.amethyst.ui.components.TitleExplainer import com.vitorpamplona.amethyst.ui.components.getActivity import com.vitorpamplona.amethyst.ui.navigation.navs.Nav import com.vitorpamplona.amethyst.ui.navigation.topbars.PostingTopBar @@ -111,7 +98,6 @@ 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.Size55Modifier import com.vitorpamplona.amethyst.ui.theme.Size5dp import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.ui.theme.replyModifier @@ -365,24 +351,11 @@ private fun NewPostScreenBody( // Show preview for both uploaded messages (voiceMetadata) and pending recordings (postViewModel.voiceMetadata ?: postViewModel.getVoicePreviewMetadata())?.let { metadata -> - val nip95description = stringRes(id = R.string.upload_server_relays_nip95) val fileServersState = accountViewModel.account.serverLists.liveServerList .collectAsState() val fileServers = fileServersState.value - val fileServerOptions = - remember(fileServers) { - fileServers - .map { - if (it.type == ServerType.NIP95) { - TitleExplainer(it.name, nip95description) - } else { - TitleExplainer(it.name, it.baseUrl) - } - }.toImmutableList() - } - Column( modifier = Modifier @@ -391,7 +364,7 @@ private fun NewPostScreenBody( ) { // Display voice preview or uploading progress postViewModel.voiceOrchestrator?.let { orchestrator -> - VoiceUploadingProgress(orchestrator) + UploadProgressIndicator(orchestrator) } ?: run { VoiceMessagePreview( voiceMetadata = metadata, @@ -400,18 +373,11 @@ private fun NewPostScreenBody( ) } - SettingsRow(R.string.file_server, R.string.file_server_description) { - TextSpinner( - label = "", - placeholder = - fileServers - .firstOrNull { it == (postViewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer) } - ?.name - ?: fileServers[0].name, - options = fileServerOptions, - onSelect = { postViewModel.voiceSelectedServer = fileServers[it] }, - ) - } + FileServerSelectionRow( + fileServers = fileServers, + selectedServer = postViewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer, + onSelect = { postViewModel.voiceSelectedServer = it }, + ) } } @@ -584,56 +550,3 @@ private fun AddPollButton( } } } - -@Composable -private fun VoiceUploadingProgress(orchestrator: UploadOrchestrator) { - val progressValue = orchestrator.progress.collectAsState().value - val progressStatusValue = orchestrator.progressState.collectAsState().value - - Box( - modifier = - Modifier - .fillMaxWidth() - .padding(vertical = 24.dp), - contentAlignment = androidx.compose.ui.Alignment.Center, - ) { - Box( - modifier = Modifier.size(55.dp), - contentAlignment = androidx.compose.ui.Alignment.Center, - ) { - val animatedProgress = - animateFloatAsState( - targetValue = progressValue.toFloat(), - animationSpec = ProgressIndicatorDefaults.ProgressAnimationSpec, - ).value - - CircularProgressIndicator( - progress = { animatedProgress }, - modifier = - Size55Modifier - .clip(CircleShape) - .background(MaterialTheme.colorScheme.background), - strokeWidth = 5.dp, - ) - - val txt = - when (progressStatusValue) { - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Ready -> stringRes(R.string.uploading_state_ready) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Compressing -> stringRes(R.string.uploading_state_compressing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Uploading -> stringRes(R.string.uploading_state_uploading) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.ServerProcessing -> stringRes(R.string.uploading_state_server_processing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Downloading -> stringRes(R.string.uploading_state_downloading) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Hashing -> stringRes(R.string.uploading_state_hashing) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Finished -> stringRes(R.string.uploading_state_finished) - is com.vitorpamplona.amethyst.service.uploads.UploadingState.Error -> stringRes(R.string.uploading_state_error) - } - - Text( - txt, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 10.sp, - textAlign = TextAlign.Center, - ) - } - } -} 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 87b87b5fc..652cc425f 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 @@ -121,6 +121,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.originalHash import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent import com.vitorpamplona.quartz.nip94FileMetadata.size import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent import com.vitorpamplona.quartz.utils.Log @@ -539,8 +540,8 @@ open class ShortNotePostViewModel : private suspend fun createTemplate(): EventTemplate? { // Check if this is a voice message voiceMetadata?.let { audioMeta -> - // Only create voice reply if original note is also a VoiceEvent - val originalVoiceHint = originalNote?.toEventHint() + // Only create voice reply if original note is also a voice message + val originalVoiceHint = originalNote?.toEventHint() return if (originalVoiceHint != null) { // Create voice reply event VoiceReplyEvent.build( @@ -1024,9 +1025,6 @@ open class ShortNotePostViewModel : onError(uploadErrorTitle, uploadVoiceFailed) voiceRecording = null } - else -> { - onError(uploadErrorTitle, uploadVoiceUnexpected) - } } } catch (e: Exception) { onError(uploadErrorTitle, uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName)) 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 new file mode 100644 index 000000000..da1dd4b1c --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyScreen.kt @@ -0,0 +1,242 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.activity.compose.BackHandler +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.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.navigationBarsPadding +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.filled.Mic +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.actions.mediaServers.FileServerSelectionRow +import com.vitorpamplona.amethyst.ui.actions.uploads.RecordAudioBox +import com.vitorpamplona.amethyst.ui.actions.uploads.UploadProgressIndicator +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.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.amethyst.ui.theme.Size10dp +import com.vitorpamplona.amethyst.ui.theme.StdVertSpacer +import com.vitorpamplona.amethyst.ui.theme.replyModifier + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun VoiceReplyScreen( + replyToNoteId: String, + recordingFilePath: String, + mimeType: String, + duration: Int, + amplitudesJson: String, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val viewModel: VoiceReplyViewModel = viewModel() + + LaunchedEffect(replyToNoteId, recordingFilePath, accountViewModel) { + viewModel.init(accountViewModel) + viewModel.load(replyToNoteId, recordingFilePath, mimeType, duration, amplitudesJson) + } + + BackHandler { + viewModel.cancel() + nav.popBack() + } + + Scaffold( + topBar = { + PostingTopBar( + isActive = viewModel::canSend, + onPost = { + viewModel.sendVoiceReply { + nav.popBack() + } + }, + onCancel = { + viewModel.cancel() + nav.popBack() + }, + ) + }, + bottomBar = { + ReRecordButton(viewModel) + }, + ) { pad -> + Surface( + modifier = + Modifier + .padding(pad) + .consumeWindowInsets(pad), + ) { + VoiceReplyScreenBody(viewModel, accountViewModel, nav) + } + } +} + +@Composable +private fun VoiceReplyScreenBody( + viewModel: VoiceReplyViewModel, + accountViewModel: AccountViewModel, + nav: Nav, +) { + val scrollState = rememberScrollState() + + Column( + modifier = + Modifier + .fillMaxSize() + .verticalScroll(scrollState) + .padding(horizontal = Size10dp), + ) { + // Show the note being replied to + viewModel.replyToNote?.let { note -> + Spacer(modifier = StdVertSpacer) + NoteCompose( + baseNote = note, + modifier = MaterialTheme.colorScheme.replyModifier, + isQuotedNote = true, + unPackReply = false, + makeItShort = true, + quotesLeft = 1, + accountViewModel = accountViewModel, + nav = nav, + ) + Spacer(modifier = StdVertSpacer) + } + + // Voice preview or upload progress + viewModel.voiceOrchestrator?.let { orchestrator -> + UploadProgressIndicator(orchestrator) + } ?: run { + viewModel.getVoicePreviewMetadata()?.let { metadata -> + VoiceMessagePreview( + voiceMetadata = metadata, + localFile = viewModel.voiceLocalFile, + onRemove = { + viewModel.cancel() + nav.popBack() + }, + ) + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Server selection + val fileServers = + accountViewModel.account.serverLists.liveServerList + .collectAsState() + .value + FileServerSelectionRow( + fileServers = fileServers, + selectedServer = viewModel.voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer, + onSelect = { viewModel.voiceSelectedServer = it }, + ) + + Spacer(modifier = Modifier.height(80.dp)) + } +} + +@Composable +private fun ReRecordButton(viewModel: VoiceReplyViewModel) { + Column( + modifier = + Modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(vertical = 16.dp, horizontal = Size10dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (viewModel.isUploading) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = stringRes(id = R.string.record_a_message), + tint = MaterialTheme.colorScheme.onBackground, + ) + Text( + text = stringRes(id = R.string.re_record), + color = MaterialTheme.colorScheme.onBackground, + ) + } + return + } + + RecordAudioBox( + modifier = Modifier, + onRecordTaken = { recording -> + viewModel.selectRecording(recording) + }, + ) { isRecording, _ -> + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = Icons.Default.Mic, + contentDescription = stringRes(id = R.string.record_a_message), + tint = + if (isRecording) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + }, + ) + Text( + text = stringRes(id = R.string.re_record), + color = + if (isRecording) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onBackground + }, + ) + } + } + } +} 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 new file mode 100644 index 000000000..faa5d677a --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/VoiceReplyViewModel.kt @@ -0,0 +1,261 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.Note +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.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.stringRes +import com.vitorpamplona.quartz.nipA0VoiceMessages.AudioMeta +import com.vitorpamplona.quartz.nipA0VoiceMessages.BaseVoiceEvent +import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent +import com.vitorpamplona.quartz.utils.Log +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import java.io.File + +@Stable +class VoiceReplyViewModel : ViewModel() { + lateinit var accountViewModel: AccountViewModel + + var replyToNote: Note? by mutableStateOf(null) + + var voiceRecording: RecordingResult? by mutableStateOf(null) + var voiceLocalFile: File? by mutableStateOf(null) + var voiceMetadata: AudioMeta? by mutableStateOf(null) + var voiceSelectedServer: ServerName? by mutableStateOf(null) + var voiceOrchestrator: UploadOrchestrator? by mutableStateOf(null) + var isUploading: Boolean by mutableStateOf(false) + + private var uploadJob: Job? = null + + fun init(accountVM: AccountViewModel) { + this.accountViewModel = accountVM + } + + fun load( + replyToNoteId: String, + recordingFilePath: String, + mimeType: String, + duration: Int, + amplitudesJson: String, + ) { + replyToNote = accountViewModel.getNoteIfExists(replyToNoteId) + + val amplitudes = + try { + Json.decodeFromString>(amplitudesJson) + } catch (e: Exception) { + Log.w("VoiceReplyViewModel", "Failed to parse amplitudes", e) + emptyList() + } + + val file = File(recordingFilePath) + voiceLocalFile = file + voiceRecording = + RecordingResult( + file = file, + mimeType = mimeType, + duration = duration, + amplitudes = amplitudes, + ) + } + + fun getVoicePreviewMetadata(): AudioMeta? = + voiceRecording?.let { recording -> + AudioMeta( + url = "", + mimeType = recording.mimeType, + duration = recording.duration, + waveform = recording.amplitudes, + ) + } + + fun selectRecording(recording: RecordingResult) { + cancelUpload() + deleteVoiceLocalFile() + voiceRecording = recording + voiceLocalFile = recording.file + voiceMetadata = null + } + + private fun cancelUpload() { + uploadJob?.cancel() + uploadJob = null + } + + private fun deleteVoiceLocalFile() { + voiceLocalFile?.let { file -> + try { + if (file.exists()) { + file.delete() + Log.d("VoiceReplyViewModel", "Deleted voice file: ${file.absolutePath}") + } + } catch (e: Exception) { + Log.w("VoiceReplyViewModel", "Failed to delete voice file: ${file.absolutePath}", e) + } + } + } + + fun canSend(): Boolean = voiceRecording != null && !isUploading + + fun sendVoiceReply(onSuccess: () -> Unit) { + val note = replyToNote ?: return + val recording = voiceRecording ?: return + val serverToUse = voiceSelectedServer ?: accountViewModel.account.settings.defaultFileServer + + cancelUpload() + uploadJob = + viewModelScope.launch { + isUploading = true + val orchestrator = UploadOrchestrator() + voiceOrchestrator = orchestrator + + try { + val result = + withContext(Dispatchers.IO) { + val uri = android.net.Uri.fromFile(recording.file) + orchestrator.upload( + uri = uri, + mimeType = recording.mimeType, + alt = null, + contentWarningReason = null, + compressionQuality = CompressorQuality.UNCOMPRESSED, + server = serverToUse, + account = accountViewModel.account, + context = Amethyst.instance.appContext, + useH265 = false, + ) + } + + handleUploadResult(note, recording, serverToUse, result, onSuccess) + } catch (e: CancellationException) { + Log.w("VoiceReplyViewModel", "User canceled, or ViewModel cleared", e) + } catch (e: Exception) { + val appContext = Amethyst.instance.appContext + val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) + val uploadVoiceExceptionMessage: (String) -> String = { detail -> + stringRes(appContext, R.string.upload_error_voice_message_exception, detail) + } + accountViewModel.toastManager.toast( + uploadErrorTitle, + uploadVoiceExceptionMessage(e.message ?: e.javaClass.simpleName), + ) + } finally { + isUploading = false + voiceOrchestrator = null + uploadJob = null + } + } + } + + private suspend fun handleUploadResult( + note: Note, + recording: RecordingResult, + server: ServerName, + result: UploadingState, + onSuccess: () -> Unit, + ) { + val appContext = Amethyst.instance.appContext + val uploadErrorTitle = stringRes(appContext, R.string.upload_error_title) + val uploadVoiceNip95NotSupported = stringRes(appContext, R.string.upload_error_voice_message_nip95_not_supported) + val uploadVoiceFailed = stringRes(appContext, R.string.upload_error_voice_message_failed) + + when (result) { + is UploadingState.Finished -> { + when (val orchestratorResult = result.result) { + is UploadOrchestrator.OrchestratorResult.ServerResult -> { + val hint = note.toEventHint() + if (hint == null) { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) + return + } + + val audioMeta = + AudioMeta( + url = orchestratorResult.url, + mimeType = recording.mimeType, + hash = orchestratorResult.fileHeader.hash, + duration = recording.duration, + waveform = recording.amplitudes, + ) + + accountViewModel.account.signAndComputeBroadcast(VoiceReplyEvent.build(audioMeta, hint)) + + if (server.type != ServerType.NIP95) { + accountViewModel.account.settings.changeDefaultFileServer(server) + } + + deleteVoiceLocalFile() + voiceLocalFile = null + voiceRecording = null + voiceMetadata = audioMeta + + onSuccess() + } + is UploadOrchestrator.OrchestratorResult.NIP95Result -> { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceNip95NotSupported) + } + } + } + is UploadingState.Error -> { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) + } + else -> { + accountViewModel.toastManager.toast(uploadErrorTitle, uploadVoiceFailed) + } + } + } + + fun cancel() { + cancelUpload() + deleteVoiceLocalFile() + voiceRecording = null + voiceLocalFile = null + voiceMetadata = null + voiceSelectedServer = null + isUploading = false + voiceOrchestrator = null + } + + override fun onCleared() { + cancel() + super.onCleared() + Log.d("Init", "OnCleared: ${this.javaClass.simpleName}") + } +} 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 1fcd3daab..05bf255fc 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 @@ -190,8 +190,8 @@ val VideoReactionColumnPadding = Modifier.padding(bottom = 75.dp) val DividerThickness = 0.25.dp -val ReactionRowHeight = Modifier.padding(vertical = 7.dp).height(24.dp) -val ReactionRowHeightWithPadding = Modifier.padding(vertical = 6.dp).height(24.dp).padding(horizontal = 10.dp) +val ReactionRowHeight = Modifier.padding(vertical = 7.dp).heightIn(min = 24.dp) +val ReactionRowHeightWithPadding = Modifier.padding(vertical = 6.dp).heightIn(min = 24.dp).padding(horizontal = 10.dp) val ReactionRowHeightChat = Modifier.height(20.dp) val ReactionRowHeightChatMaxWidth = Modifier.height(25.dp).fillMaxWidth() val UserNameRowHeight = Modifier.fillMaxWidth() 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 5c7c0a361..3b46a3272 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 @@ -40,7 +40,7 @@ class TorSettingsFlow( ) { // emits at every change in any of the properties. val propertyWatchFlow = - combine( + combine( listOf( torType, externalSocksPort, diff --git a/amethyst/src/main/res/values-cs-rCZ/strings.xml b/amethyst/src/main/res/values-cs-rCZ/strings.xml index 5cc85b46e..c32931f87 100644 --- a/amethyst/src/main/res/values-cs-rCZ/strings.xml +++ b/amethyst/src/main/res/values-cs-rCZ/strings.xml @@ -158,6 +158,7 @@ Nahrajte zprávu Nahrajte zprávu Stiskněte a podržte pro nahrání zprávy + Znovu nahrát Nahrávání Nahrávání %1$s Nahrávání… diff --git a/amethyst/src/main/res/values-de-rDE/strings.xml b/amethyst/src/main/res/values-de-rDE/strings.xml index 4dea3cb35..2073348b2 100644 --- a/amethyst/src/main/res/values-de-rDE/strings.xml +++ b/amethyst/src/main/res/values-de-rDE/strings.xml @@ -160,6 +160,7 @@ erie gespeichert Eine Nachricht aufnehmen Eine Nachricht aufnehmen Zum Aufnehmen einer Nachricht gedrückt halten + Neu aufnehmen Aufnahme Aufnahme %1$s Hochladen… diff --git a/amethyst/src/main/res/values-pt-rBR/strings.xml b/amethyst/src/main/res/values-pt-rBR/strings.xml index c8278d893..6cbc364be 100644 --- a/amethyst/src/main/res/values-pt-rBR/strings.xml +++ b/amethyst/src/main/res/values-pt-rBR/strings.xml @@ -158,6 +158,7 @@ Gravar uma mensagem Gravar uma mensagem Clique e segure para gravar uma mensagem + Gravar novamente Gravando Gravando %1$s Enviando… diff --git a/amethyst/src/main/res/values-sv-rSE/strings.xml b/amethyst/src/main/res/values-sv-rSE/strings.xml index a3393dcdf..6939e8824 100644 --- a/amethyst/src/main/res/values-sv-rSE/strings.xml +++ b/amethyst/src/main/res/values-sv-rSE/strings.xml @@ -158,6 +158,7 @@ Spela in ett meddelande Spela in ett meddelande Tryck och håll in för att spela in ett meddelande + Spela in igen Spelar in Spelar in %1$s Laddar upp… diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 8db1152f4..7893b01aa 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -170,6 +170,7 @@ Record a message Record a message Click and hold to record a message + Re-record Recording Recording %1$s Uploading… diff --git a/build.gradle b/build.gradle index f27850355..5bf6769b7 100644 --- a/build.gradle +++ b/build.gradle @@ -7,6 +7,7 @@ plugins { alias(libs.plugins.diffplugSpotless) alias(libs.plugins.googleServices) apply false alias(libs.plugins.jetbrainsComposeCompiler) apply false + alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.androidKotlinMultiplatformLibrary) apply false alias(libs.plugins.serialization) @@ -64,4 +65,4 @@ tasks.register('installGitHook', Copy) { into { new File(rootProject.rootDir, '.git/hooks') } filePermissions { unix(0777) } } -tasks.getByPath(':amethyst:preBuild').dependsOn installGitHook \ No newline at end of file +tasks.getByPath(':amethyst:preBuild').dependsOn installGitHook diff --git a/commons/build.gradle b/commons/build.gradle deleted file mode 100644 index b37d1acc4..000000000 --- a/commons/build.gradle +++ /dev/null @@ -1,69 +0,0 @@ -import org.jetbrains.kotlin.gradle.dsl.JvmTarget - -plugins { - alias(libs.plugins.androidLibrary) - alias(libs.plugins.jetbrainsKotlinAndroid) - alias(libs.plugins.jetbrainsComposeCompiler) -} - -android { - namespace = 'com.vitorpamplona.amethyst.commons' - compileSdk = libs.versions.android.compileSdk.get().toInteger() - - defaultConfig { - minSdk = libs.versions.android.minSdk.get().toInteger() - targetSdk = libs.versions.android.targetSdk.get().toInteger() - - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles "consumer-rules.pro" - } - - buildTypes { - release { - minifyEnabled true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - create("benchmark") { - initWith(getByName("release")) - signingConfig = signingConfigs.debug - } - } - compileOptions { - sourceCompatibility JavaVersion.VERSION_21 - targetCompatibility JavaVersion.VERSION_21 - } - - buildFeatures { - compose = true - } -} - -kotlin { - compilerOptions { - jvmTarget = JvmTarget.JVM_21 - freeCompilerArgs.add("-Xstring-concat=inline") - } -} - -composeCompiler { - reportsDestination = layout.buildDirectory.dir("compose_compiler") -} - -dependencies { - implementation project(path: ':quartz') - // Import @Immutable and @Stable - implementation platform(libs.androidx.compose.bom) - implementation libs.androidx.ui - implementation libs.androidx.compose.foundation - - debugImplementation libs.androidx.ui.tooling - implementation libs.androidx.ui.tooling.preview - - // immutable collections to avoid recomposition - api libs.kotlinx.collections.immutable - - testImplementation libs.junit - androidTestImplementation platform(libs.androidx.compose.bom) - androidTestImplementation libs.androidx.junit - androidTestImplementation libs.androidx.espresso.core -} \ No newline at end of file diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts new file mode 100644 index 000000000..e66de925e --- /dev/null +++ b/commons/build.gradle.kts @@ -0,0 +1,121 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) + alias(libs.plugins.jetbrainsComposeCompiler) + alias(libs.plugins.composeMultiplatform) +} + +android { + namespace = "com.vitorpamplona.amethyst.commons" + compileSdk = libs.versions.android.compileSdk.get().toInt() + + defaultConfig { + minSdk = libs.versions.android.minSdk.get().toInt() + targetSdk = libs.versions.android.targetSdk.get().toInt() + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + create("benchmark") { + initWith(getByName("release")) + signingConfig = signingConfigs.getByName("debug") + } + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 + } +} + +kotlin { + compilerOptions { + freeCompilerArgs.add("-Xstring-concat=inline") + freeCompilerArgs.add("-Xexpect-actual-classes") + } + + jvm { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_21) + } + } + + sourceSets { + commonMain { + dependencies { + implementation(project(":quartz")) + + // Compose Multiplatform + implementation(compose.ui) + implementation(compose.foundation) + implementation(compose.runtime) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + + // LruCache (KMP-ready) + implementation(libs.androidx.collection) + + // Immutable collections + api(libs.kotlinx.collections.immutable) + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + } + } + + // 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) + } + } + + jvmMain { + dependsOn(jvmAndroid) + dependencies { + // Desktop-specific Compose + implementation(compose.desktop.currentOs) + implementation(compose.uiTooling) + } + } + + androidMain { + dependsOn(jvmAndroid) + dependencies { + // Android-specific Compose tooling + implementation(libs.androidx.ui.tooling.preview) + } + } + + androidUnitTest { + dependencies { + implementation(libs.junit) + } + } + + androidInstrumentedTest { + dependencies { + implementation(libs.androidx.junit) + implementation(libs.androidx.espresso.core) + } + } + } +} diff --git a/commons/src/main/AndroidManifest.xml b/commons/src/androidMain/AndroidManifest.xml similarity index 100% rename from commons/src/main/AndroidManifest.xml rename to commons/src/androidMain/AndroidManifest.xml diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImageBitmap.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImageBitmap.kt new file mode 100644 index 000000000..9340c5d08 --- /dev/null +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImageBitmap.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.commons.base64Image + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage +import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage +import java.util.Base64 + +fun Base64Image.toBitmap(content: String): Bitmap { + val matcher = pattern.matcher(content) + + if (matcher.find()) { + val base64String = matcher.group(2) + val byteArray = Base64.getDecoder().decode(base64String) + return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) + } + + throw Exception("Unable to convert base64 to image $content") +} + +/** + * Converts a base64 image data URI to a PlatformImage. + * Delegates to toBitmap and wraps the result. + */ +fun Base64Image.toPlatformImage(content: String): PlatformImage = toBitmap(content).toPlatformImage() diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt new file mode 100644 index 000000000..bf40e816b --- /dev/null +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.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.commons.blurhash + +import android.graphics.Bitmap + +/** + * Encodes this Android Bitmap to a blurhash string. + * Delegates to PlatformImage.toBlurhash() for the actual encoding. + */ +fun Bitmap.toBlurhash(): String = this.toPlatformImage().toBlurhash() diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt rename to commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt diff --git a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.kt new file mode 100644 index 000000000..b864da631 --- /dev/null +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.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.amethyst.commons.blurhash + +import android.graphics.Bitmap + +actual class PlatformImage( + val bitmap: Bitmap, +) { + actual val width: Int get() = bitmap.width + actual val height: Int get() = bitmap.height + + actual fun getPixels( + pixels: IntArray, + offset: Int, + stride: Int, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + bitmap.getPixels(pixels, offset, stride, x, y, width, height) + } + + actual fun scale( + width: Int, + height: Int, + ): PlatformImage = PlatformImage(Bitmap.createScaledBitmap(bitmap, width, height, false)) + + actual companion object { + actual fun create( + pixels: IntArray, + width: Int, + height: Int, + ): PlatformImage = PlatformImage(Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)) + } +} + +/** + * Extension to convert Android Bitmap to PlatformImage. + */ +fun Bitmap.toPlatformImage(): PlatformImage = PlatformImage(this) + +/** + * Extension to get the underlying Android Bitmap. + */ +fun PlatformImage.toAndroidBitmap(): Bitmap = this.bitmap diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt similarity index 97% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt index baf540fd3..140f3c86c 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.blurhash -import android.graphics.Bitmap import com.vitorpamplona.amethyst.commons.blurhash.SRGB.Companion.linearToSrgb import com.vitorpamplona.amethyst.commons.blurhash.SRGB.Companion.srgbToLinear import kotlin.math.pow @@ -66,7 +65,7 @@ object BlurHashDecoder { } /** - * Decode a blur hash into a new bitmap. + * Decode a blur hash into a new PlatformImage. * * @param useCache use in memory cache for the calculated math, reused by images with same size. * if the cache does not exist yet it will be created and populated with new calculations. By @@ -76,7 +75,7 @@ object BlurHashDecoder { blurHash: String?, width: Int, useCache: Boolean = true, - ): Bitmap? { + ): PlatformImage? { if (blurHash == null || blurHash.length < 6) { return null } @@ -87,7 +86,7 @@ object BlurHashDecoder { val height = (width * (1 / (numCompX.toFloat() / numCompY.toFloat()))).roundToInt() val colors = computeColors(numCompX, numCompY, blurHash) val imageArray = composeImageArray(width, height, numCompX, numCompY, colors, useCache) - return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) + return PlatformImage.create(imageArray, width, height) } private fun decodeDc(colorEnc: Int): FloatArray { diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoderExt.kt similarity index 88% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoderExt.kt index 7a3bd652c..f476afb6a 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoderExt.kt @@ -20,14 +20,17 @@ */ package com.vitorpamplona.amethyst.commons.blurhash -import android.graphics.Bitmap import kotlin.math.roundToInt -fun Bitmap.toBlurhash(): String { +/** + * Encodes this PlatformImage to a blurhash string. + * The image will be scaled down if larger than 100x100 for performance. + */ +fun PlatformImage.toBlurhash(): String { val aspectRatio = this.width.toFloat() / this.height.toFloat() if (this.width > 100 && this.height > 100) { - return Bitmap.createScaledBitmap(this, 100, (100 / aspectRatio).toInt(), false).toBlurhash() + return this.scale(100, (100 / aspectRatio).toInt()).toBlurhash() } val intArray = IntArray(width * height) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt similarity index 96% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt index 7248cf5a1..a3d609915 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.amethyst.commons.blurhash import androidx.collection.LruCache +import kotlin.math.PI import kotlin.math.cos object CosineCache { @@ -54,7 +55,7 @@ object CosineCache { DoubleArray(height * numCompY) { val y = it / numCompY val j = it % numCompY - cos(Math.PI * y * j / height) + cos(PI * y * j / height) }.also { cacheCosinesY.put(height * numCompY, it) } @@ -73,7 +74,7 @@ object CosineCache { DoubleArray(width * numCompX) { val x = it / numCompX val i = it % numCompX - cos(Math.PI * x * i / width) + cos(PI * x * i / width) }.also { cacheCosinesX.put(width * numCompX, it) } } else -> cacheCosinesX[width * numCompX]!! diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.kt new file mode 100644 index 000000000..2e745ca8e --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.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.amethyst.commons.blurhash + +/** + * Platform-agnostic image wrapper for blurhash encoding/decoding. + * + * On Android: wraps android.graphics.Bitmap + * On Desktop JVM: wraps java.awt.image.BufferedImage + */ +expect class PlatformImage { + val width: Int + val height: Int + + /** + * Read ARGB pixels into the provided array. + * Pixels are in ARGB format (0xAARRGGBB). + */ + fun getPixels( + pixels: IntArray, + offset: Int, + stride: Int, + x: Int, + y: Int, + width: Int, + height: Int, + ) + + /** + * Create a scaled copy of this image. + */ + fun scale( + width: Int, + height: Int, + ): PlatformImage + + companion object { + /** + * Create a new image from ARGB pixel array. + */ + fun create( + pixels: IntArray, + width: Int, + height: Int, + ): PlatformImage + } +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt similarity index 91% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt index 733dfdac2..8fdaf3fd2 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.commons.compose -import android.util.LruCache +import androidx.collection.LruCache import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.produceState @Composable -fun produceCachedStateAsync( +fun produceCachedStateAsync( cache: AsyncCachedState, key: K, ): State = @@ -39,7 +39,7 @@ fun produceCachedStateAsync( } @Composable -fun produceCachedStateAsync( +fun produceCachedStateAsync( cache: AsyncCachedState, key: String, updateValue: K, @@ -52,13 +52,13 @@ fun produceCachedStateAsync( } } -interface AsyncCachedState { +interface AsyncCachedState { fun cached(k: K): V? suspend fun update(k: K): V? } -abstract class GenericBaseCacheAsync( +abstract class GenericBaseCacheAsync( capacity: Int, ) : AsyncCachedState { private val cache = LruCache(capacity) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/CachedState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/CachedState.kt similarity index 91% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/CachedState.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/CachedState.kt index ba955f4d6..28cbeb6f9 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/CachedState.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/CachedState.kt @@ -20,13 +20,13 @@ */ package com.vitorpamplona.amethyst.commons.compose -import android.util.LruCache +import androidx.collection.LruCache import androidx.compose.runtime.Composable import androidx.compose.runtime.State import androidx.compose.runtime.produceState @Composable -fun produceCachedState( +fun produceCachedState( cache: CachedState, key: K, ): State = @@ -39,7 +39,7 @@ fun produceCachedState( } @Composable -fun produceCachedState( +fun produceCachedState( cache: CachedState, key: String, updateValue: K, @@ -52,13 +52,13 @@ fun produceCachedState( } } -interface CachedState { +interface CachedState { fun cached(k: K): V? suspend fun update(k: K): V? } -abstract class GenericBaseCache( +abstract class GenericBaseCache( capacity: Int, ) : CachedState { private val cache = LruCache(capacity) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt index 0a7ee0dd0..c47beb329 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt @@ -32,10 +32,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsAmethystPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt index 5a4fc5ba2..1bb9aef79 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt @@ -33,10 +33,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsBtcPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt index de72f6eb4..96be5b124 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsCashuPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt index f59def11e..6c2543cd2 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsCoffeePreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt index 7cb20efa8..d8a9ba332 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsFlowerstrPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt index 6b907ede5..70111c982 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsFootstrPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt index fec5833a7..9a07e47ae 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt @@ -28,10 +28,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsGamestrPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt index dc6628974..afec134fe 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsGrownostrPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt index 281a1688d..3b884cc17 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsLightningPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt index 5b27fd5bd..037b8a7ec 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsMatePreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt index b88317911..24160dde7 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsNostrPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt index 7ce7b8ae6..d1a17353f 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsPlebsPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt index 6a4d95899..8e0a23216 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsSkullPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt index 9add8ed4b..2d307df19 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsTunestrPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt index 4a40e3d16..668f43b67 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsWeedPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt index b98538cf0..eec30fc12 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable fun CustomHashTagIconsZapPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Following.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Following.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt index 2eb7b112d..fe0b9cfc8 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Following.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Following.kt @@ -29,10 +29,8 @@ 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.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable private fun VectorPreview() { Image(Following, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Like.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Like.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt index afeccd3ae..994b6db33 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Like.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Like.kt @@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable private fun VectorPreview() { Image(Like, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Liked.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Liked.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt index b509ebc10..8e89cc135 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Liked.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Liked.kt @@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable private fun VectorPreview() { Image(Liked, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reply.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reply.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt index 4db2c8b12..31349a88b 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reply.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reply.kt @@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable private fun VectorPreview() { Image(Reply, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Repost.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Repost.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt index a599d032f..ffbb79b22 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Repost.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Repost.kt @@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable private fun VectorPreview() { Image(Repost, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reposted.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reposted.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt index 6e11930eb..2cad31d4a 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Reposted.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Reposted.kt @@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable private fun VectorPreview() { Image(Reposted, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Search.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Search.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt index e996be38a..e967053d2 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Search.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Search.kt @@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable private fun VectorPreview() { Image(Search, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Share.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Share.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt index 5107eca3f..49923f688 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Share.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Share.kt @@ -30,10 +30,8 @@ import androidx.compose.ui.graphics.StrokeJoin.Companion.Miter import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable private fun VectorPreview() { Image(Share, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Zap.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Zap.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt index acf40c45c..d5169185f 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/Zap.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/Zap.kt @@ -31,9 +31,7 @@ import androidx.compose.ui.graphics.vector.DefaultFillType import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathBuilder import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview -@Preview @Composable private fun VectorPreview() { Image(Zap, null) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt index 84ef32ff7..91a111e19 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt @@ -31,10 +31,8 @@ import androidx.compose.ui.graphics.vector.DefaultFillType import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathBuilder import androidx.compose.ui.graphics.vector.path -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -@Preview @Composable private fun VectorPreview() { Image(ZapSplit, null) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt new file mode 100644 index 000000000..e3502dfc2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.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.navigation + +/** + * Main application screens shared between Desktop and Android. + * Each platform implements its own navigation using these identifiers. + */ +enum class AppScreen( + val label: String, + val route: String, +) { + Feed("Feed", "feed"), + Search("Search", "search"), + Messages("Messages", "messages"), + Notifications("Notifications", "notifications"), + Profile("Profile", "profile"), + Settings("Settings", "settings"), +} + +/** + * Primary navigation destinations (shown in bottom bar on mobile, sidebar on desktop). + */ +val primaryScreens = + listOf( + AppScreen.Feed, + AppScreen.Search, + AppScreen.Messages, + AppScreen.Notifications, + AppScreen.Profile, + ) + +/** + * Secondary navigation destinations (settings, etc.) + */ +val secondaryScreens = + listOf( + AppScreen.Settings, + ) diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt index 8811c0fb0..d3c7a5c22 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt @@ -20,7 +20,7 @@ */ package com.vitorpamplona.amethyst.commons.robohash -import android.util.LruCache +import androidx.collection.LruCache import androidx.compose.ui.graphics.vector.ImageVector object CachedRobohash { diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt index 99e81fb85..982196e72 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt @@ -32,7 +32,6 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.vitorpamplona.amethyst.commons.robohash.parts.accessory0Seven import com.vitorpamplona.amethyst.commons.robohash.parts.accessory1Nose @@ -107,7 +106,6 @@ val MediumGray = SolidColor(Color(0xFFd0d2d3)) val DefaultSize = 55.dp const val VIEWPORT_SIZE = 300f -@Preview @Composable fun RobohashPreview() { val assembler = RobohashAssembler() diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt index 3f97d864f..e58577de5 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory0SevenPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt index 2793862ab..b467f1c61 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory1NosePreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt index 3f6f9813d..75ae3d3e6 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightBrown @@ -36,7 +35,6 @@ import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.MediumGray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory2HornRedPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt index bee5d0446..d52a26aaa 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory3ButtonPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt index cf10cd4e3..d414a5302 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory4SatellitePreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt index 9967abd02..ec6cad933 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory5MustachePreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt index ea3809b9c..debad6a99 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory6Hat() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt index 9bf7fd570..4802982ef 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory7Antenna() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt index 3b94fcf84..869baec5f 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory8Brush() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt index a9baa4597..c77558585 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.MediumGray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Accessory9Horn() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt index e84bd23ed..f11ab8e24 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body0TropperPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt index 6d5502289..b20f22d08 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body1ThinPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt index ead54ed93..520bc1566 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body2ThinnestPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt index aa95e8108..646f5e332 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body3FrontPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt index 1ec10ab8b..9a5115f7f 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body4RoundPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt index ff939af00..11bb4cd4d 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body5NeckPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt index 62c505821..373a62121 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.Yellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body6IronManPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt index 58bef8c43..f6cf8f407 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body7NeckThinnerPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt index d12739adb..ab93ec93b 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body8BigPreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt index 841aef162..98810d09d 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Gray import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Body9HugePreview() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt index 52486c701..6e46a40db 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes0Squint() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt index 0535fad99..10ea055cf 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.DarkYellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes1Round() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt index fcf23af08..0e38faffd 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes2Single() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt index f8ffd01ca..2e6aa7410 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes3Scott() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt index 5d3d55312..fe7d0b3f6 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes4RoundSingle() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt index 4c30a3240..cfc62a1f2 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightRed import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes5RoundSmall() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt index 11e1bf901..b30766bf9 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightYellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes6WallE() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt index 7bd1093ad..9ea12638c 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes7Bar() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt index 3064d1665..a93424325 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.LightYellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes8SmallBar() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt index 20ab0a875..900be2d90 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Eyes9Shield() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt index 4d18e3453..b353e9588 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face0C3po() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt index 1fbf5f73d..7ebd9acd8 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face1Rock() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt index 6c5a51cab..c7979de54 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face2Long() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt index bc3979d89..39e25f75c 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face3Oval() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt index 8707de72c..6f58c5a1a 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face4Cylinder() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt index bc16e370f..e6d72fb92 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face5Baloon() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt index b592cd4ed..591425eb9 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face6Triangle() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt index b9e888caa..29de82282 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face7Bent() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt index d0c5bf806..f7492afd6 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face8TriangleInv() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt index cfd5b8f7b..240edc78c 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Face9Square() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt index fb0de441a..f7e6002f3 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth0Horz() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt index 85925bc21..2f7917243 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth1Cylinder() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt index 44cb1951f..b74b0e6af 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt @@ -27,14 +27,12 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.DarkYellow import com.vitorpamplona.amethyst.commons.robohash.OrangeOne import com.vitorpamplona.amethyst.commons.robohash.OrangeTwo import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth2Teeth() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt index 6069be799..dec4f1651 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.DarkYellow import com.vitorpamplona.amethyst.commons.robohash.Yellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth3Grid() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt index bf827cdde..443a5b183 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth4Vert() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt index 080db5980..4af586f96 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth5MidOpen() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt index bece6f091..f3dfe65ba 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt @@ -27,12 +27,10 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.Brown import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth6Cell() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt index 30983dc1f..b2620d5a3 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt @@ -27,13 +27,11 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.OrangeThree import com.vitorpamplona.amethyst.commons.robohash.Yellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth7Happy() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt index 9981f4391..c93675884 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt @@ -27,14 +27,12 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.OrangeThree import com.vitorpamplona.amethyst.commons.robohash.OrangeTwo import com.vitorpamplona.amethyst.commons.robohash.Yellow import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth8Buttons() { Image( diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt similarity index 98% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt index 2890d7376..e792b0adc 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt @@ -27,11 +27,9 @@ import androidx.compose.ui.graphics.SolidColor import androidx.compose.ui.graphics.vector.ImageVector.Builder import androidx.compose.ui.graphics.vector.PathData import androidx.compose.ui.graphics.vector.rememberVectorPainter -import androidx.compose.ui.tooling.preview.Preview import com.vitorpamplona.amethyst.commons.robohash.Black import com.vitorpamplona.amethyst.commons.robohash.roboBuilder -@Preview @Composable fun Mouth9Closed() { Image( diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt new file mode 100644 index 000000000..ea87b8ed1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.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.amethyst.commons.ui.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** + * Standard button shape used for action buttons. + */ +val ActionButtonShape = RoundedCornerShape(20.dp) + +/** + * Standard content padding for action buttons. + */ +val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp) + +/** + * An "Add" action button with consistent styling. + * + * @param onClick Action to perform when clicked + * @param modifier Modifier for the button + * @param text Button text (default: "Add") + * @param enabled Whether the button is enabled + */ +@Composable +fun AddButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: String = "Add", + enabled: Boolean = true, +) { + OutlinedButton( + modifier = modifier, + enabled = enabled, + onClick = onClick, + shape = ActionButtonShape, + contentPadding = ActionButtonPadding, + ) { + Text(text = text, textAlign = TextAlign.Center) + } +} + +/** + * A "Remove" action button with consistent styling. + * + * @param onClick Action to perform when clicked + * @param modifier Modifier for the button + * @param text Button text (default: "Remove") + * @param enabled Whether the button is enabled + */ +@Composable +fun RemoveButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: String = "Remove", + enabled: Boolean = true, +) { + OutlinedButton( + modifier = modifier, + onClick = onClick, + shape = ActionButtonShape, + enabled = enabled, + contentPadding = ActionButtonPadding, + ) { + Text(text = text) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt new file mode 100644 index 000000000..b549466dd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt @@ -0,0 +1,158 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +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 + +/** + * A centered loading state with a progress indicator and message. + * Can be used by both Desktop and Android apps. + */ +@Composable +fun LoadingState( + message: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + message, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * A centered empty state with title, optional description, and optional refresh action. + */ +@Composable +fun EmptyState( + title: String, + modifier: Modifier = Modifier, + description: String? = null, + onRefresh: (() -> Unit)? = null, + refreshLabel: String = "Refresh", +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (description != null) { + Spacer(Modifier.height(8.dp)) + Text( + description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + if (onRefresh != null) { + Spacer(Modifier.height(16.dp)) + OutlinedButton(onClick = onRefresh) { + Text(refreshLabel) + } + } + } +} + +/** + * A centered error state with message and optional retry action. + */ +@Composable +fun ErrorState( + message: String, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, + retryLabel: String = "Try Again", +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + if (onRetry != null) { + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text(retryLabel) + } + } + } +} + +/** + * Empty feed state - a common pattern for feed views. + */ +@Composable +fun FeedEmptyState( + modifier: Modifier = Modifier, + title: String = "Feed is empty", + onRefresh: (() -> Unit)? = null, +) { + EmptyState( + title = title, + modifier = modifier, + onRefresh = onRefresh, + ) +} + +/** + * Feed error state - a common pattern for feed views. + */ +@Composable +fun FeedErrorState( + errorMessage: String, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, +) { + ErrorState( + message = "Error loading feed: $errorMessage", + modifier = modifier, + onRetry = onRetry, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt new file mode 100644 index 000000000..82e0123bf --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.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.amethyst.commons.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Face +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash + +/** + * Determines if the current color scheme is light. + * Uses the luminance of the background color. + */ +@Composable +private fun isLightTheme(): Boolean { + val background = MaterialTheme.colorScheme.background + // Simple luminance check: if any RGB component > 0.5, consider it light + return (background.red + background.green + background.blue) / 3 > 0.5f +} + +/** + * Displays a robohash image based on a seed string (typically a public key). + * Falls back to a generic face icon if robohash is disabled. + * + * @param robot The seed string for generating the robohash (e.g., pubkey hex) + * @param modifier Modifier for the image + * @param contentDescription Accessibility description + * @param loadRobohash Whether to load the robohash or show a placeholder + */ +@Composable +fun RobohashImage( + robot: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + loadRobohash: Boolean = true, +) { + if (loadRobohash) { + Image( + imageVector = CachedRobohash.get(robot, isLightTheme()), + contentDescription = contentDescription, + modifier = modifier, + ) + } else { + Image( + imageVector = Icons.Default.Face, + contentDescription = contentDescription, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = modifier, + ) + } +} + +/** + * Displays a robohash image with configurable alignment and content scale. + */ +@Composable +fun RobohashImage( + robot: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + colorFilter: ColorFilter? = null, + loadRobohash: Boolean = true, +) { + if (loadRobohash) { + Image( + painter = rememberVectorPainter(CachedRobohash.get(robot, isLightTheme())), + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + colorFilter = colorFilter, + ) + } else { + Image( + imageVector = Icons.Default.Face, + contentDescription = contentDescription, + colorFilter = colorFilter ?: ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt new file mode 100644 index 000000000..36efd264d --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.feed + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material.icons.filled.Refresh +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 +import com.vitorpamplona.amethyst.commons.ui.theme.RelayStatusColors + +/** + * Header component for feed screens with title and relay connection status. + * + * @param title The feed title + * @param connectedRelayCount Number of connected relays + * @param onRefresh Callback when refresh button is clicked + * @param modifier Modifier for the header row + */ +@Composable +fun FeedHeader( + title: String, + connectedRelayCount: Int, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + + RelayStatusIndicator( + connectedCount = connectedRelayCount, + onRefresh = onRefresh, + ) + } +} + +/** + * Compact relay connection status indicator with refresh button. + */ +@Composable +fun RelayStatusIndicator( + connectedCount: Int, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + val statusColor = + when { + connectedCount == 0 -> RelayStatusColors.Disconnected + connectedCount < 3 -> RelayStatusColors.Connecting + else -> RelayStatusColors.Connected + } + + Icon( + imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close, + contentDescription = null, + tint = statusColor, + modifier = Modifier.size(16.dp), + ) + + Text( + "$connectedCount relay${if (connectedCount != 1) "s" else ""}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + ) + + IconButton(onClick = onRefresh) { + Icon( + Icons.Default.Refresh, + contentDescription = "Reconnect", + tint = MaterialTheme.colorScheme.primary, + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt new file mode 100644 index 000000000..4048a5c02 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.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.commons.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Generic placeholder screen with title and description. + */ +@Composable +fun PlaceholderScreen( + title: String, + description: String, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(16.dp)) + Text( + description, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * Search screen placeholder. + */ +@Composable +fun SearchPlaceholder(modifier: Modifier = Modifier) { + PlaceholderScreen( + title = "Search", + description = "Search for users, notes, and hashtags.", + modifier = modifier, + ) +} + +/** + * Messages/DMs screen placeholder. + */ +@Composable +fun MessagesPlaceholder(modifier: Modifier = Modifier) { + PlaceholderScreen( + title = "Messages", + description = "Your encrypted direct messages will appear here.", + modifier = modifier, + ) +} + +/** + * Notifications screen placeholder. + */ +@Composable +fun NotificationsPlaceholder(modifier: Modifier = Modifier) { + PlaceholderScreen( + title = "Notifications", + description = "Mentions, replies, and reactions will appear here.", + modifier = modifier, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt new file mode 100644 index 000000000..edca6b6c3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.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.commons.ui.theme + +import androidx.compose.ui.graphics.Color + +// Primary brand colors +val Primary50 = Color(red = 127, green = 103, blue = 190) +val Primary60 = Color(red = 154, green = 130, blue = 219) +val Primary70 = Color(red = 182, green = 157, blue = 248) +val Primary80 = Color(red = 208, green = 188, blue = 255) +val DefaultPrimary = Color(red = 208, green = 188, blue = 255) +val LightPurple = Color(red = 187, green = 134, blue = 252) + +// Material Design colors +val Purple200 = Color(0xFFBB86FC) +val Purple500 = Color(0xFF6200EE) +val Purple700 = Color(0xFF3700B3) +val Teal200 = Color(0xFF03DAC5) + +// Bitcoin colors +val BitcoinOrange = Color(0xFFF7931A) +val BitcoinDark = Color(0xFFF7931A) +val BitcoinLight = Color(0xFFB66605) + +// Status colors +val RoyalBlue = Color(0xFF4169E1) +val Following = Color(0xFF03DAC5) +val FollowsFollow = Color.Yellow +val Nip05Verified = Color.Blue + +// NIP-05 email colors +val Nip05EmailColor = Color(0xFFb198ec) +val Nip05EmailColorDark = Color(0xFF6e5490) +val Nip05EmailColorLight = Color(0xFFa770f3) + +// Feedback colors +val DarkerGreen = Color.Green.copy(alpha = 0.32f) +val LightRedColor = Color(0xFFC62828) +val LighterRedColor = Color(0xFFFF0E0E) + +// Warning colors +val LightWarningColor = Color(0xFFffcc00) +val DarkWarningColor = Color(0xFFF8DE22) + +// Surface variant colors +val LightRedColorOnSecondSurface = Color(0xFFC62828) +val DarkRedColorOnSecondSurface = Color(0xFFF34747) +val LightWarningColorOnSecondSurface = Color(0xFFC09B14) +val DarkWarningColorOnSecondSurface = Color(0xFFE1C419) + +// Success colors +val LightAllGoodColor = Color(0xFF339900) +val DarkAllGoodColor = Color(0xFF99cc33) + +// Fundraiser colors +val LightFundraiserProgressColor = Color(0xFF3DB601) +val DarkFundraiserProgressColor = Color(0xFF61A229) + +// Relay status colors +object RelayStatusColors { + val Connected = Color.Green + val Connecting = Color.Yellow + val Disconnected = Color.Red + val Unknown = Color.Gray +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt new file mode 100644 index 000000000..abb8d1d47 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt @@ -0,0 +1,98 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.util + +import kotlin.math.roundToInt + +/** + * Formats a count to human-readable format with suffix (K, M, G). + * Example: 1500 -> "1K items", 2500000 -> "2M items" + */ +fun countToHumanReadable( + counter: Int, + suffix: String, +): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G $suffix" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M $suffix" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K $suffix" + else -> "$counter $suffix" + } + +/** + * Formats a count to human-readable format with suffix (K, M, G). + */ +fun countToHumanReadable( + counter: Long, + suffix: String, +): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G $suffix" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M $suffix" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K $suffix" + else -> "$counter $suffix" + } + +/** + * Formats a count to human-readable format without suffix. + * Example: 1500 -> "1K", 2500000 -> "2M" + */ +fun countToHumanReadable(counter: Int): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K" + else -> "$counter" + } + +/** + * Formats a count to human-readable format without suffix. + */ +fun countToHumanReadable(counter: Long): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K" + else -> "$counter" + } + +/** + * Formats byte count to human-readable format (KB, MB, GB). + * Example: 1500 -> "1 KB", 2500000 -> "2 MB" + */ +fun countToHumanReadableBytes(counter: Int): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()} GB" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()} MB" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()} KB" + else -> "$counter B" + } + +/** + * Formats byte count to human-readable format (KB, MB, GB). + */ +fun countToHumanReadableBytes(counter: Long): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()} GB" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()} MB" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()} KB" + else -> "$counter B" + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt new file mode 100644 index 000000000..250e17ea1 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.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.commons.util + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * Shortens a hex key or npub for display. + * Example: "npub1abcdefghijklmnop..." -> "npub1abc…mnop" + * + * @return Shortened string with ellipsis in the middle, or original if <= 16 chars + */ +fun String.toShortDisplay(): String { + if (length <= 16) return this + return replaceRange(8, length - 8, "…") +} + +/** + * Converts a ByteArray to a shortened hex display. + */ +fun ByteArray.toHexShortDisplay(): String = toHexKey().toShortDisplay() + +/** + * Shortens a HexKey for display. + */ +fun HexKey.toDisplayHexKey(): String = this.toShortDisplay() diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt new file mode 100644 index 000000000..6fb4df3e3 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.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.amethyst.commons.account + +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.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip19Bech32.decodePrivateKeyAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.decodePublicKeyAsHexOrNull +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import com.vitorpamplona.quartz.nip19Bech32.toNsec +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +sealed class AccountState { + data object LoggedOut : AccountState() + + data class LoggedIn( + val signer: NostrSigner, + val pubKeyHex: String, + val npub: String, + val nsec: String?, + val isReadOnly: Boolean, + ) : AccountState() +} + +class AccountManager { + private val _accountState = MutableStateFlow(AccountState.LoggedOut) + val accountState: StateFlow = _accountState.asStateFlow() + + fun generateNewAccount(): AccountState.LoggedIn { + val keyPair = KeyPair() + val signer = NostrSignerInternal(keyPair) + + val state = + AccountState.LoggedIn( + signer = signer, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = keyPair.privKey?.toNsec(), + isReadOnly = false, + ) + _accountState.value = state + return state + } + + fun loginWithKey(keyInput: String): Result { + val trimmedInput = keyInput.trim() + + // Try as private key first (nsec or hex) + val privKeyHex = decodePrivateKeyAsHexOrNull(trimmedInput) + if (privKeyHex != null) { + return try { + val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray()) + val signer = NostrSignerInternal(keyPair) + + val state = + AccountState.LoggedIn( + signer = signer, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = keyPair.privKey?.toNsec(), + isReadOnly = false, + ) + _accountState.value = state + Result.success(state) + } catch (e: Exception) { + Result.failure(IllegalArgumentException("Invalid private key format")) + } + } + + // Try as public key (npub or hex) - read-only mode + val pubKeyHex = decodePublicKeyAsHexOrNull(trimmedInput) + if (pubKeyHex != null) { + return try { + val keyPair = KeyPair(pubKey = pubKeyHex.hexToByteArray()) + val signer = NostrSignerInternal(keyPair) + + val state = + AccountState.LoggedIn( + signer = signer, + pubKeyHex = keyPair.pubKey.toHexKey(), + npub = keyPair.pubKey.toNpub(), + nsec = null, + isReadOnly = true, + ) + _accountState.value = state + Result.success(state) + } catch (e: Exception) { + Result.failure(IllegalArgumentException("Invalid public key format")) + } + } + + return Result.failure(IllegalArgumentException("Invalid key format. Use nsec1, npub1, or hex format.")) + } + + fun logout() { + _accountState.value = AccountState.LoggedOut + } + + fun isLoggedIn(): Boolean = _accountState.value is AccountState.LoggedIn + + fun currentAccount(): AccountState.LoggedIn? = _accountState.value as? AccountState.LoggedIn +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt similarity index 58% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt index cc26f5549..ac7bef89c 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt @@ -20,33 +20,17 @@ */ package com.vitorpamplona.amethyst.commons.base64Image -import android.R.attr.data -import android.graphics.Bitmap -import android.graphics.BitmapFactory import com.vitorpamplona.amethyst.commons.richtext.RichTextParser -import java.util.Base64 import java.util.regex.Pattern -class Base64Image { - companion object { - val pattern = Pattern.compile("data:image/(${RichTextParser.imageExtensions.joinToString(separator = "|") { it } });base64,([a-zA-Z0-9+/]+={0,2})") +object Base64Image { + val pattern: Pattern = + Pattern.compile( + "data:image/(${RichTextParser.imageExtensions.joinToString(separator = "|")});base64,([a-zA-Z0-9+/]+={0,2})", + ) - fun isBase64(content: String): Boolean { - val matcher = pattern.matcher(content) - return matcher.find() - } - - fun toBitmap(content: String): Bitmap { - val matcher = pattern.matcher(data.toString()) - - if (matcher.find()) { - val base64String = matcher.group(2) - - val byteArray = Base64.getDecoder().decode(base64String) - return BitmapFactory.decodeByteArray(byteArray, 0, byteArray.size) - } - - throw Exception("Unable to convert base64 to image $content") - } + fun isBase64(content: String): Boolean { + val matcher = pattern.matcher(content) + return matcher.find() } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt new file mode 100644 index 000000000..45f9571c2 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt @@ -0,0 +1,160 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.network + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener +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.toRelay.Command +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.sockets.WebsocketBuilder +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Manages Nostr relay connections, subscriptions, and status tracking. + * Can be used by both Android and Desktop apps. + * + * @param websocketBuilder Platform-specific websocket builder (e.g., OkHttp-based) + */ +open class RelayConnectionManager( + websocketBuilder: WebsocketBuilder, +) : IRelayClientListener { + private val client = NostrClient(websocketBuilder) + + private val _relayStatuses = MutableStateFlow>(emptyMap()) + val relayStatuses: StateFlow> = _relayStatuses.asStateFlow() + + val connectedRelays: StateFlow> = client.connectedRelaysFlow() + val availableRelays: StateFlow> = client.availableRelaysFlow() + + init { + client.subscribe(this) + } + + fun connect() { + client.connect() + } + + fun disconnect() { + client.disconnect() + } + + fun addRelay(url: String): NormalizedRelayUrl? { + val normalized = RelayUrlNormalizer.normalizeOrNull(url) ?: return null + updateRelayStatus(normalized) { it.copy(connected = false, error = null) } + return normalized + } + + fun removeRelay(url: NormalizedRelayUrl) { + _relayStatuses.value = _relayStatuses.value - url + } + + fun addDefaultRelays() { + DefaultRelays.RELAYS.forEach { addRelay(it) } + } + + fun subscribe( + subId: String, + filters: List, + relays: Set = availableRelays.value, + listener: IRequestListener? = null, + ) { + val filterMap = relays.associateWith { filters } + client.openReqSubscription(subId, filterMap, listener) + } + + fun unsubscribe(subId: String) { + client.close(subId) + } + + fun send( + event: Event, + relays: Set = connectedRelays.value, + ) { + client.send(event, relays) + } + + private fun updateRelayStatus( + url: NormalizedRelayUrl, + update: (RelayStatus) -> RelayStatus, + ) { + _relayStatuses.value = + _relayStatuses.value.toMutableMap().apply { + val current = this[url] ?: RelayStatus(url, connected = false) + this[url] = update(current) + } + } + + override fun onConnecting(relay: IRelayClient) { + updateRelayStatus(relay.url) { + it.copy(connected = false, error = null) + } + } + + override fun onConnected( + relay: IRelayClient, + pingMillis: Int, + compressed: Boolean, + ) { + updateRelayStatus(relay.url) { + it.copy(connected = true, pingMs = pingMillis, compressed = compressed, error = null) + } + } + + override fun onDisconnected(relay: IRelayClient) { + updateRelayStatus(relay.url) { + it.copy(connected = false) + } + } + + override fun onCannotConnect( + relay: IRelayClient, + errorMessage: String, + ) { + updateRelayStatus(relay.url) { + it.copy(connected = false, error = errorMessage) + } + } + + override fun onIncomingMessage( + relay: IRelayClient, + msgStr: String, + msg: Message, + ) { + // Events are handled by subscription listeners + } + + override fun onSent( + relay: IRelayClient, + cmdStr: String, + cmd: Command, + success: Boolean, + ) { + // Command send tracking + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt new file mode 100644 index 000000000..f446c7eba --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.network + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Represents the connection status of a Nostr relay. + * Used by both Android and Desktop apps. + */ +data class RelayStatus( + val url: NormalizedRelayUrl, + val connected: Boolean, + val pingMs: Int? = null, + val compressed: Boolean = false, + val error: String? = null, +) + +/** + * Default relay URLs for Nostr connectivity. + */ +object DefaultRelays { + val RELAYS = + listOf( + "wss://relay.damus.io", + "wss://relay.nostr.band", + "wss://nos.lol", + "wss://relay.snort.social", + "wss://nostr.wine", + ) +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/Patterns.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/Patterns.kt new file mode 100644 index 000000000..d0ef9308b --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/Patterns.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.commons.richtext + +import java.util.regex.Pattern + +/** + * Pattern constants for email and phone validation. + * These replace android.util.Patterns for KMP compatibility. + */ +object Patterns { + /** + * Email address pattern from RFC 5322. + */ + val EMAIL_ADDRESS: Pattern = + Pattern.compile( + "[a-zA-Z0-9+._%-]+@[a-zA-Z0-9][a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}", + ) + + /** + * Phone number pattern - matches common phone formats. + */ + val PHONE: Pattern = + Pattern.compile( + "^[+]?[(]?[0-9]{1,4}[)]?[-\\s./0-9]*\$", + ) +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt similarity index 99% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt index 14bc34a4f..243a07957 100644 --- a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt @@ -20,7 +20,6 @@ */ package com.vitorpamplona.amethyst.commons.richtext -import android.util.Patterns import com.linkedin.urls.detection.UrlDetector import com.linkedin.urls.detection.UrlDetectorOptions import com.vitorpamplona.amethyst.commons.base64Image.Base64Image diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt new file mode 100644 index 000000000..e27629c9f --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.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.amethyst.commons.ui.auth + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +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.OutlinedTextField +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.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp + +/** + * Text field for entering Nostr keys (nsec or npub) with visibility toggle. + */ +@Composable +fun KeyInputField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String = "nsec or npub", + placeholder: String = "nsec1... or npub1...", + errorMessage: String? = null, +) { + var showKey by remember { mutableStateOf(false) } + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + placeholder = { Text(placeholder) }, + modifier = modifier.fillMaxWidth(), + singleLine = true, + visualTransformation = + if (showKey) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + trailingIcon = { + IconButton(onClick = { showKey = !showKey }) { + Icon( + if (showKey) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = if (showKey) "Hide key" else "Show key", + ) + } + }, + isError = errorMessage != null, + supportingText = + errorMessage?.let { + { Text(it, color = MaterialTheme.colorScheme.error) } + }, + ) +} + +/** + * Card displaying a Nostr key that users can select/copy. + */ +@Composable +fun SelectableKeyText( + key: String, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Text( + text = key, + modifier = Modifier.padding(12.dp).fillMaxWidth(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt new file mode 100644 index 000000000..a80912b91 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.auth + +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.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.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Login card with Nostr key input field and action buttons. + * + * @param onLogin Callback when login is attempted with the key input + * @param onGenerateNew Callback when "Generate New" is clicked + * @param modifier Modifier for the card + * @param cardWidth Width of the card (default 400.dp) + * @param title Card title + * @param subtitle Subtitle/hint text + */ +@Composable +fun LoginCard( + onLogin: (String) -> Result, + onGenerateNew: () -> Unit, + modifier: Modifier = Modifier, + cardWidth: Dp = 400.dp, + title: String = "Login with your Nostr key", + subtitle: String = "Use nsec for full access or npub for read-only", +) { + var keyInput by remember { mutableStateOf("") } + var errorMessage by remember { mutableStateOf(null) } + + Card( + modifier = modifier.width(cardWidth), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(16.dp)) + + KeyInputField( + value = keyInput, + onValueChange = { + keyInput = it + errorMessage = null + }, + errorMessage = errorMessage, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + onLogin(keyInput).fold( + onSuccess = { /* handled by caller */ }, + onFailure = { errorMessage = it.message }, + ) + }, + modifier = Modifier.weight(1f), + enabled = keyInput.isNotBlank(), + ) { + Text("Login") + } + + OutlinedButton( + onClick = onGenerateNew, + modifier = Modifier.weight(1f), + ) { + Text("Generate New") + } + } + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt new file mode 100644 index 000000000..4fa976aa9 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.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.amethyst.commons.ui.auth + +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.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.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Warning card displayed after generating a new Nostr key pair. + * Reminds users to save their keys and shows both public and secret keys. + * + * @param npub The public key in npub format + * @param nsec The secret key in nsec format (nullable for read-only accounts) + * @param onContinue Callback when user acknowledges they've saved their keys + * @param modifier Modifier for the card + * @param cardWidth Width of the card (default 500.dp) + */ +@Composable +fun NewKeyWarningCard( + npub: String, + nsec: String?, + onContinue: () -> Unit, + modifier: Modifier = Modifier, + cardWidth: Dp = 500.dp, +) { + Card( + modifier = modifier.width(cardWidth), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + ), + ) { + Column( + modifier = Modifier.padding(24.dp), + ) { + Text( + "IMPORTANT: Save your keys!", + style = MaterialTheme.typography.titleMedium, + color = Color.Red, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + "Your secret key (nsec) is the ONLY way to access your account. " + + "If you lose it, your account is gone forever. Save it somewhere safe!", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + "Public Key (shareable):", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SelectableKeyText(npub) + + Spacer(Modifier.height(12.dp)) + + nsec?.let { secretKey -> + Text( + "Secret Key (NEVER share this!):", + style = MaterialTheme.typography.labelMedium, + color = Color.Red, + ) + SelectableKeyText(secretKey) + } + + Spacer(Modifier.height(24.dp)) + + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth(), + ) { + Text("I've saved my keys, continue") + } + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt new file mode 100644 index 000000000..a82e4faf8 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt @@ -0,0 +1,188 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.note + +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.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +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.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.util.toTimeAgo + +/** + * Data class for displaying a note card. + */ +data class NoteDisplayData( + val id: String, + val pubKeyDisplay: String, + val content: String, + val createdAt: Long, +) + +/** + * Reusable note card composable that displays a Nostr note. + * Can be used by both Desktop and Android apps. + */ +@Composable +fun NoteCard( + note: NoteDisplayData, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, +) { + val richTextParser = remember { RichTextParser() } + val urls = remember(note.content) { richTextParser.parseValidUrls(note.content) } + + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + onClick = onClick ?: {}, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + // Author (truncated) + Text( + text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + ) + + // Timestamp + Text( + text = note.createdAt.toTimeAgo(withDot = false), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(8.dp)) + + RichTextContent( + content = note.content, + urls = urls, + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(8.dp)) + + HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)) + + Spacer(Modifier.height(4.dp)) + + // Event ID (truncated) + Text( + text = "ID: ${note.id.take(12)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } +} + +/** + * Renders text content with highlighted URLs. + * Uses RichTextParser from commons to detect and highlight links. + */ +@Composable +fun RichTextContent( + content: String, + urls: Set, + modifier: Modifier = Modifier, + maxLines: Int = 10, +) { + if (urls.isEmpty()) { + Text( + text = content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) + } else { + val annotatedText = + buildAnnotatedString { + var lastIndex = 0 + val sortedUrls = urls.sortedBy { content.indexOf(it) } + + for (url in sortedUrls) { + val startIndex = content.indexOf(url, lastIndex) + if (startIndex == -1) continue + + // Add text before URL + if (startIndex > lastIndex) { + append(content.substring(lastIndex, startIndex)) + } + + // Add URL with styling + withStyle( + SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + ) { + append(url) + } + + lastIndex = startIndex + url.length + } + + // Add remaining text + if (lastIndex < content.length) { + append(content.substring(lastIndex)) + } + } + + Text( + text = annotatedText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.kt new file mode 100644 index 000000000..53bf73bbc --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.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.profile + +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.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.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Card displaying Nostr account key information. + * Shows public key (npub), hex format, and optional read-only indicator. + */ +@Composable +fun ProfileInfoCard( + npub: String, + pubKeyHex: String, + isReadOnly: Boolean, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column(modifier = Modifier.padding(16.dp)) { + if (isReadOnly) { + Text( + "Read-only mode", + style = MaterialTheme.typography.labelMedium, + color = Color.Yellow, + ) + Spacer(Modifier.height(8.dp)) + } + + Text( + "Public Key", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + npub, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(16.dp)) + + Text( + "Hex", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + pubKeyHex, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt new file mode 100644 index 000000000..c82c5102b --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt @@ -0,0 +1,133 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.relay + +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.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.network.RelayStatus + +/** + * Card displaying the status of a Nostr relay connection. + * Shows connection status, ping time, compression, and errors. + */ +@Composable +fun RelayStatusCard( + status: RelayStatus, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.weight(1f), + ) { + val statusColor = + when { + status.connected -> Color.Green + status.error != null -> Color.Red + else -> Color.Gray + } + + if (status.connected) { + Icon( + Icons.Default.Check, + contentDescription = "Connected", + tint = statusColor, + modifier = Modifier.size(20.dp), + ) + } else if (status.error != null) { + Icon( + Icons.Default.Close, + contentDescription = "Error", + tint = statusColor, + modifier = Modifier.size(20.dp), + ) + } else { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp, + ) + } + + Column { + Text( + status.url.url, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + if (status.connected && status.pingMs != null) { + Text( + "${status.pingMs}ms${if (status.compressed) " • compressed" else ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + status.error?.let { error -> + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = Color.Red.copy(alpha = 0.8f), + ) + } + } + } + + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Close, + contentDescription = "Remove relay", + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt new file mode 100644 index 000000000..1f0de406e --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.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.commons.util + +import com.vitorpamplona.amethyst.commons.ui.note.NoteDisplayData +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip19Bech32.toNpub + +/** + * Extension to convert Event to NoteDisplayData for the shared NoteCard. + */ +fun Event.toNoteDisplayData(): NoteDisplayData { + val npub = + try { + pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..." + } catch (e: Exception) { + pubKey.take(16) + "..." + } + + return NoteDisplayData( + id = id, + pubKeyDisplay = npub, + content = content, + createdAt = createdAt, + ) +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt new file mode 100644 index 000000000..88ef514b9 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.util + +import com.vitorpamplona.quartz.utils.TimeUtils +import java.text.SimpleDateFormat +import java.util.Locale + +private const val YEAR_DATE_FORMAT = "MMM dd, yyyy" +private const val MONTH_DATE_FORMAT = "MMM dd" + +private var locale = Locale.getDefault() +private var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) +private var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) + +private fun updateFormattersIfNeeded() { + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) + monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) + } +} + +/** + * Formats a Unix timestamp (seconds) as a human-readable time ago string. + * Returns strings like " • 5m", " • 2h", " • Dec 12" + * + * @param time Unix timestamp in seconds, or null + * @param withDot Whether to prefix with " • " (default true) + * @return Formatted time ago string + */ +fun timeAgo( + time: Long?, + withDot: Boolean = true, +): String { + if (time == null) return " " + if (time == 0L) return if (withDot) " • never" else "never" + + val timeDifference = TimeUtils.now() - time + val prefix = if (withDot) " • " else "" + + return when { + timeDifference > TimeUtils.ONE_YEAR -> { + updateFormattersIfNeeded() + prefix + yearFormatter.format(time * 1000) + } + timeDifference > TimeUtils.ONE_MONTH -> { + updateFormattersIfNeeded() + prefix + monthFormatter.format(time * 1000) + } + timeDifference > TimeUtils.ONE_DAY -> { + prefix + (timeDifference / TimeUtils.ONE_DAY).toString() + "d" + } + timeDifference > TimeUtils.ONE_HOUR -> { + prefix + (timeDifference / TimeUtils.ONE_HOUR).toString() + "h" + } + timeDifference > TimeUtils.ONE_MINUTE -> { + prefix + (timeDifference / TimeUtils.ONE_MINUTE).toString() + "m" + } + else -> { + prefix + "now" + } + } +} + +/** + * Formats a Unix timestamp as a date string. + * For recent dates (< 1 day), returns the provided today string. + * + * @param time Unix timestamp in seconds + * @param never String to show for time == 0 + * @param today String to show for today's date + */ +fun dateFormatter( + time: Long?, + never: String = "never", + today: String = "today", +): String { + if (time == null) return " " + if (time == 0L) return " $never" + + val timeDifference = TimeUtils.now() - time + + return when { + timeDifference > TimeUtils.ONE_YEAR -> { + updateFormattersIfNeeded() + yearFormatter.format(time * 1000) + } + timeDifference > TimeUtils.ONE_DAY -> { + updateFormattersIfNeeded() + monthFormatter.format(time * 1000) + } + else -> today + } +} + +/** + * Extension function for Long timestamps. + */ +fun Long.toTimeAgo(withDot: Boolean = true): String = timeAgo(this, withDot) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt new file mode 100644 index 000000000..7e0e3c6cb --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.util + +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat + +private val TenGiga = BigDecimal(10_000_000_000) +private val OneGiga = BigDecimal(1_000_000_000) +private val TenMega = BigDecimal(10_000_000) +private val OneMega = BigDecimal(1_000_000) +private val TenKilo = BigDecimal(10_000) +private val OneKilo = BigDecimal(1_000) + +private val dfGBig = ThreadLocal.withInitial { DecimalFormat("#.#G") } +private val dfGSmall = ThreadLocal.withInitial { DecimalFormat("#.0G") } +private val dfMBig = ThreadLocal.withInitial { DecimalFormat("#.#M") } +private val dfMSmall = ThreadLocal.withInitial { DecimalFormat("#.0M") } +private val dfK = ThreadLocal.withInitial { DecimalFormat("#.#k") } +private val dfN = ThreadLocal.withInitial { DecimalFormat("#") } + +/** + * Formats a BigDecimal amount to human-readable format with G/M/K suffixes. + * Returns empty string for null or very small amounts. + * + * Examples: + * - 1500 -> "1.5k" + * - 2500000 -> "2.5M" + * - 10000000000 -> "10G" + */ +fun showAmount(amount: BigDecimal?): String { + if (amount == null) return "" + if (amount.abs() < BigDecimal(0.01)) return "" + + return when { + amount >= TenGiga -> dfGBig.get()!!.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= OneGiga -> dfGSmall.get()!!.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= TenMega -> dfMBig.get()!!.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= OneMega -> dfMSmall.get()!!.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= TenKilo -> dfK.get()!!.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) + else -> dfN.get()!!.format(amount) + } +} + +/** + * Formats a BigDecimal amount to human-readable format. + * Returns "0" for null or very small amounts instead of empty string. + */ +fun showAmountWithZero(amount: BigDecimal?): String { + if (amount == null) return "0" + if (amount.abs() < BigDecimal(0.01)) return "0" + return showAmount(amount) +} + +/** + * Extension function to format Long as zap amount. + */ +fun Long.toZapAmount(): String = showAmount(BigDecimal(this)) + +/** + * Extension function to format Int as zap amount. + */ +fun Int.toZapAmount(): String = showAmount(BigDecimal(this)) diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImagePlatform.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImagePlatform.jvm.kt new file mode 100644 index 000000000..6c523a33e --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImagePlatform.jvm.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.commons.base64Image + +import com.vitorpamplona.amethyst.commons.blurhash.PlatformImage +import com.vitorpamplona.amethyst.commons.blurhash.toPlatformImage +import java.io.ByteArrayInputStream +import java.util.Base64 +import javax.imageio.ImageIO + +/** + * Converts a base64 image data URI to a PlatformImage (BufferedImage wrapper). + */ +fun Base64Image.toPlatformImage(content: String): PlatformImage { + val matcher = pattern.matcher(content) + + if (matcher.find()) { + val base64String = matcher.group(2) + val byteArray = Base64.getDecoder().decode(base64String) + val bufferedImage = + ImageIO.read(ByteArrayInputStream(byteArray)) + ?: throw Exception("Unable to decode base64 image: $content") + return bufferedImage.toPlatformImage() + } + + throw Exception("Unable to convert base64 to image $content") +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.jvm.kt new file mode 100644 index 000000000..7cb20fc93 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.jvm.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.commons.blurhash + +import java.awt.image.BufferedImage + +/** + * Encodes this BufferedImage to a blurhash string. + * Delegates to PlatformImage.toBlurhash() for the actual encoding. + */ +fun BufferedImage.toBlurhash(): String = this.toPlatformImage().toBlurhash() diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.jvm.kt new file mode 100644 index 000000000..8d4c990f6 --- /dev/null +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.jvm.kt @@ -0,0 +1,75 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.blurhash + +import java.awt.Image +import java.awt.image.BufferedImage + +actual class PlatformImage( + val image: BufferedImage, +) { + actual val width: Int get() = image.width + actual val height: Int get() = image.height + + actual fun getPixels( + pixels: IntArray, + offset: Int, + stride: Int, + x: Int, + y: Int, + width: Int, + height: Int, + ) { + image.getRGB(x, y, width, height, pixels, offset, stride) + } + + actual fun scale( + width: Int, + height: Int, + ): PlatformImage { + val scaled = image.getScaledInstance(width, height, Image.SCALE_FAST) + val buffered = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + buffered.graphics.drawImage(scaled, 0, 0, null) + return PlatformImage(buffered) + } + + actual companion object { + actual fun create( + pixels: IntArray, + width: Int, + height: Int, + ): PlatformImage { + val image = BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + image.setRGB(0, 0, width, height, pixels, 0, width) + return PlatformImage(image) + } + } +} + +/** + * Extension to convert BufferedImage to PlatformImage. + */ +fun BufferedImage.toPlatformImage(): PlatformImage = PlatformImage(this) + +/** + * Extension to get the underlying BufferedImage. + */ +fun PlatformImage.toBufferedImage(): BufferedImage = this.image diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts new file mode 100644 index 000000000..5bd8c6c60 --- /dev/null +++ b/desktopApp/build.gradle.kts @@ -0,0 +1,73 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + alias(libs.plugins.jetbrainsKotlinJvm) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.jetbrainsComposeCompiler) +} + +sourceSets { + main { + kotlin.srcDir("src/jvmMain/kotlin") + resources.srcDir("src/jvmMain/resources") + } +} + +kotlin { + jvmToolchain(21) +} + +dependencies { + implementation(compose.desktop.currentOs) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + + // Quartz Nostr library (will use JVM target) + implementation(project(":quartz")) + + // Commons library + implementation(project(":commons")) + + // Coroutines + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.coroutines.swing) + + // Networking + implementation(libs.okhttp) + + // JSON + implementation(libs.jackson.module.kotlin) + + // Collections + implementation(libs.kotlinx.collections.immutable) +} + +compose.desktop { + application { + mainClass = "com.vitorpamplona.amethyst.desktop.MainKt" + + nativeDistributions { + targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb) + + packageName = "Amethyst" + packageVersion = "1.0.0" + description = "Nostr client for desktop" + vendor = "Amethyst Contributors" + + macOS { + bundleID = "com.vitorpamplona.amethyst.desktop" + iconFile.set(project.file("src/jvmMain/resources/icon.icns")) + } + + windows { + iconFile.set(project.file("src/jvmMain/resources/icon.ico")) + menuGroup = "Amethyst" + upgradeUuid = "A1B2C3D4-E5F6-7890-ABCD-EF1234567890" + } + + linux { + iconFile.set(project.file("src/jvmMain/resources/icon.png")) + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt new file mode 100644 index 000000000..7e91eb32d --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.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.amethyst.desktop + +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.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.Button +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.VerticalDivider +import androidx.compose.material3.darkColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyShortcut +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.MenuBar +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.account.AccountManager +import com.vitorpamplona.amethyst.commons.account.AccountState +import com.vitorpamplona.amethyst.commons.navigation.AppScreen +import com.vitorpamplona.amethyst.commons.ui.profile.ProfileInfoCard +import com.vitorpamplona.amethyst.commons.ui.relay.RelayStatusCard +import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder +import com.vitorpamplona.amethyst.commons.ui.screens.NotificationsPlaceholder +import com.vitorpamplona.amethyst.commons.ui.screens.SearchPlaceholder +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.ui.FeedScreen +import com.vitorpamplona.amethyst.desktop.ui.LoginScreen + +fun main() = + application { + val windowState = + rememberWindowState( + width = 1200.dp, + height = 800.dp, + position = WindowPosition.Aligned(Alignment.Center), + ) + + Window( + onCloseRequest = ::exitApplication, + state = windowState, + title = "Amethyst", + ) { + MenuBar { + Menu("File") { + Item( + "New Note", + shortcut = KeyShortcut(Key.N, ctrl = true), + onClick = { /* TODO: Open new note dialog */ }, + ) + Separator() + Item( + "Settings", + shortcut = KeyShortcut(Key.Comma, ctrl = true), + onClick = { /* TODO: Open settings */ }, + ) + Separator() + Item( + "Quit", + shortcut = KeyShortcut(Key.Q, ctrl = true), + onClick = ::exitApplication, + ) + } + Menu("Edit") { + Item("Copy", shortcut = KeyShortcut(Key.C, ctrl = true), onClick = { }) + Item("Paste", shortcut = KeyShortcut(Key.V, ctrl = true), onClick = { }) + } + Menu("View") { + Item("Feed", onClick = { }) + Item("Messages", onClick = { }) + Item("Notifications", onClick = { }) + } + Menu("Help") { + Item("About Amethyst", onClick = { }) + Item("Keyboard Shortcuts", onClick = { }) + } + } + + App() + } + } + +@Composable +fun App() { + var currentScreen by remember { mutableStateOf(AppScreen.Feed) } + val relayManager = remember { DesktopRelayConnectionManager() } + val accountManager = remember { AccountManager() } + val accountState by accountManager.accountState.collectAsState() + + DisposableEffect(Unit) { + relayManager.addDefaultRelays() + relayManager.connect() + onDispose { + relayManager.disconnect() + } + } + + MaterialTheme( + colorScheme = darkColorScheme(), + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + when (accountState) { + is AccountState.LoggedOut -> { + LoginScreen( + accountManager = accountManager, + onLoginSuccess = { currentScreen = AppScreen.Feed }, + ) + } + is AccountState.LoggedIn -> { + MainContent( + currentScreen = currentScreen, + onScreenChange = { currentScreen = it }, + relayManager = relayManager, + accountManager = accountManager, + account = accountState as AccountState.LoggedIn, + ) + } + } + } + } +} + +@Composable +fun MainContent( + currentScreen: AppScreen, + onScreenChange: (AppScreen) -> Unit, + relayManager: DesktopRelayConnectionManager, + accountManager: AccountManager, + account: AccountState.LoggedIn, +) { + Row(Modifier.fillMaxSize()) { + // Sidebar Navigation + NavigationRail( + modifier = Modifier.width(80.dp).fillMaxHeight(), + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ) { + Spacer(Modifier.height(16.dp)) + + NavigationRailItem( + icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, + label = { Text("Feed") }, + selected = currentScreen == AppScreen.Feed, + onClick = { onScreenChange(AppScreen.Feed) }, + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, + label = { Text("Search") }, + selected = currentScreen == AppScreen.Search, + onClick = { onScreenChange(AppScreen.Search) }, + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, + label = { Text("DMs") }, + selected = currentScreen == AppScreen.Messages, + onClick = { onScreenChange(AppScreen.Messages) }, + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, + label = { Text("Alerts") }, + selected = currentScreen == AppScreen.Notifications, + onClick = { onScreenChange(AppScreen.Notifications) }, + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, + label = { Text("Profile") }, + selected = currentScreen == AppScreen.Profile, + onClick = { onScreenChange(AppScreen.Profile) }, + ) + + Spacer(Modifier.weight(1f)) + + HorizontalDivider(Modifier.padding(horizontal = 16.dp)) + + NavigationRailItem( + icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, + label = { Text("Settings") }, + selected = currentScreen == AppScreen.Settings, + onClick = { onScreenChange(AppScreen.Settings) }, + ) + + Spacer(Modifier.height(16.dp)) + } + + VerticalDivider() + + // Main Content + Box( + modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp), + ) { + when (currentScreen) { + AppScreen.Feed -> FeedScreen(relayManager) + AppScreen.Search -> SearchPlaceholder() + AppScreen.Messages -> MessagesPlaceholder() + AppScreen.Notifications -> NotificationsPlaceholder() + AppScreen.Profile -> ProfileScreen(account, accountManager) + AppScreen.Settings -> RelaySettingsScreen(relayManager) + } + } + } +} + +@Composable +fun ProfileScreen( + account: AccountState.LoggedIn, + accountManager: AccountManager, +) { + Column { + Text( + "Profile", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(16.dp)) + + ProfileInfoCard( + npub = account.npub, + pubKeyHex = account.pubKeyHex, + isReadOnly = account.isReadOnly, + ) + + Spacer(Modifier.height(24.dp)) + + OutlinedButton( + onClick = { accountManager.logout() }, + colors = + androidx.compose.material3.ButtonDefaults.outlinedButtonColors( + contentColor = Color.Red, + ), + ) { + Text("Logout") + } + } +} + +@Composable +fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { + val relayStatuses by relayManager.relayStatuses.collectAsState() + val connectedRelays by relayManager.connectedRelays.collectAsState() + var newRelayUrl by remember { mutableStateOf("") } + + Column(modifier = Modifier.fillMaxSize()) { + Text( + "Relay Settings", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground, + ) + Spacer(Modifier.height(8.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "${connectedRelays.size} of ${relayStatuses.size} relays connected", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyMedium, + ) + IconButton(onClick = { relayManager.connect() }) { + Icon( + Icons.Default.Refresh, + contentDescription = "Reconnect", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = newRelayUrl, + onValueChange = { newRelayUrl = it }, + label = { Text("Add relay") }, + placeholder = { Text("wss://relay.example.com") }, + modifier = Modifier.weight(1f), + singleLine = true, + ) + Button( + onClick = { + if (newRelayUrl.isNotBlank()) { + relayManager.addRelay(newRelayUrl) + newRelayUrl = "" + } + }, + enabled = newRelayUrl.isNotBlank(), + ) { + Text("Add") + } + } + + Spacer(Modifier.height(16.dp)) + + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.weight(1f), + ) { + items(relayStatuses.values.toList()) { status -> + RelayStatusCard( + status = status, + onRemove = { relayManager.removeRelay(status.url) }, + ) + } + } + + Spacer(Modifier.height(16.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { relayManager.addDefaultRelays() }) { + Text("Reset to Defaults") + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt new file mode 100644 index 000000000..c94df538f --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.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.desktop.network + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import okhttp3.OkHttpClient +import java.util.concurrent.TimeUnit + +object DesktopHttpClient { + private val client: OkHttpClient by lazy { + OkHttpClient + .Builder() + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .pingInterval(30, TimeUnit.SECONDS) + .retryOnConnectionFailure(true) + .build() + } + + fun getHttpClient(url: NormalizedRelayUrl): OkHttpClient = client +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt new file mode 100644 index 000000000..34dd8db54 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.network + +import com.vitorpamplona.amethyst.commons.network.RelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket + +/** + * Desktop-specific relay connection manager that configures OkHttp for websockets. + * Delegates to the shared RelayConnectionManager from commons. + */ +class DesktopRelayConnectionManager : + RelayConnectionManager( + websocketBuilder = BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient), + ) 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 new file mode 100644 index 000000000..9eb70e313 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.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.desktop.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.components.LoadingState +import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader +import com.vitorpamplona.amethyst.commons.ui.note.NoteCard +import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.quartz.nip01Core.core.Event +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.nip10Notes.TextNoteEvent + +@Composable +fun FeedScreen(relayManager: DesktopRelayConnectionManager) { + val connectedRelays by relayManager.connectedRelays.collectAsState() + val relayStatuses by relayManager.relayStatuses.collectAsState() + val events = remember { mutableStateListOf() } + val seenIds = remember { mutableSetOf() } + + // Use relayStatuses.keys (configured relays) to initiate subscription, + // not connectedRelays (which is empty until subscriptions trigger connections) + val configuredRelays = relayStatuses.keys + + DisposableEffect(configuredRelays) { + if (configuredRelays.isNotEmpty()) { + val subId = "global-feed-${System.currentTimeMillis()}" + val filters = + listOf( + Filter( + kinds = listOf(TextNoteEvent.KIND), + limit = 50, + ), + ) + + relayManager.subscribe( + subId = subId, + filters = filters, + relays = configuredRelays, + listener = + object : IRequestListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + if (event.id !in seenIds) { + seenIds.add(event.id) + events.add(0, event) + if (events.size > 200) { + val removed = events.removeAt(events.size - 1) + seenIds.remove(removed.id) + } + } + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // End of stored events + } + }, + ) + + onDispose { + relayManager.unsubscribe(subId) + } + } else { + onDispose { } + } + } + + Column(modifier = Modifier.fillMaxSize()) { + FeedHeader( + title = "Global Feed", + connectedRelayCount = connectedRelays.size, + onRefresh = { relayManager.connect() }, + ) + + Spacer(Modifier.height(16.dp)) + + if (connectedRelays.isEmpty()) { + LoadingState("Connecting to relays...") + } else if (events.isEmpty()) { + LoadingState("Loading notes...") + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + items(events, key = { it.id }) { event -> + // Use NoteCard from commons + NoteCard( + note = event.toNoteDisplayData(), + ) + } + } + } + } +} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt new file mode 100644 index 000000000..68761246e --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.desktop.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +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.unit.dp +import com.vitorpamplona.amethyst.commons.account.AccountManager +import com.vitorpamplona.amethyst.commons.account.AccountState +import com.vitorpamplona.amethyst.commons.ui.auth.LoginCard +import com.vitorpamplona.amethyst.commons.ui.auth.NewKeyWarningCard + +@Composable +fun LoginScreen( + accountManager: AccountManager, + onLoginSuccess: () -> Unit, +) { + var showNewKeyDialog by remember { mutableStateOf(false) } + var generatedAccount by remember { mutableStateOf(null) } + + Column( + modifier = Modifier.fillMaxSize().padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + "Welcome to Amethyst", + style = MaterialTheme.typography.headlineLarge, + color = MaterialTheme.colorScheme.onBackground, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + "A Nostr client for desktop", + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(48.dp)) + + LoginCard( + onLogin = { keyInput -> + accountManager.loginWithKey(keyInput).map { + onLoginSuccess() + } + }, + onGenerateNew = { + generatedAccount = accountManager.generateNewAccount() + showNewKeyDialog = true + }, + ) + + if (showNewKeyDialog && generatedAccount != null) { + Spacer(Modifier.height(24.dp)) + NewKeyWarningCard( + npub = generatedAccount!!.npub, + nsec = generatedAccount!!.nsec, + onContinue = { + showNewKeyDialog = false + onLoginSuccess() + }, + ) + } + } +} diff --git a/desktopApp/src/jvmMain/resources/icon.png b/desktopApp/src/jvmMain/resources/icon.png new file mode 100644 index 000000000..dbb5d7153 Binary files /dev/null and b/desktopApp/src/jvmMain/resources/icon.png differ diff --git a/docs/shared-ui-analysis.md b/docs/shared-ui-analysis.md new file mode 100644 index 000000000..174d2d058 --- /dev/null +++ b/docs/shared-ui-analysis.md @@ -0,0 +1,219 @@ +# Shared UI Components Analysis + +Analysis of Android vs Desktop UI to identify reusable Compose Multiplatform components. + +## Executive Summary + +| Category | Android Files | Shareable As-Is | Needs Abstraction | +|----------|---------------|-----------------|-------------------| +| Formatters | 4 | 2 | 2 | +| Theme | 4 | 4 | 0 | +| Note Components | 35 | ~10 | ~25 | +| Total Potential | ~450+ | ~100+ | ~200+ | + +## Immediately Shareable Components + +These have **zero Android dependencies** and can be moved to a shared module today: + +### Formatters (Pure Kotlin) +``` +amethyst/ui/note/PubKeyFormatter.kt -> shared/ui/formatters/ +amethyst/ui/note/ZapFormatter.kt -> shared/ui/formatters/ +amethyst/ui/note/ZapFormatterNoDecimals.kt -> shared/ui/formatters/ +``` + +### Theme (Pure Compose) +``` +amethyst/ui/theme/Color.kt -> shared/ui/theme/ +amethyst/ui/theme/Shape.kt -> shared/ui/theme/ +amethyst/ui/theme/Type.kt -> shared/ui/theme/ +``` + +### Layout Primitives +``` +amethyst/ui/theme/ButtonBorder (padding values) +amethyst/ui/theme/Size* (dimension constants) +``` + +## Components Requiring Abstraction + +### 1. String Resources (R.string.*) + +**Problem:** Android uses `R.string.xxx` for localized strings. + +**Solution:** Create `StringProvider` interface: + +```kotlin +// commonMain +interface StringProvider { + fun get(key: String): String + fun get(key: String, vararg args: Any): String +} + +// androidMain +class AndroidStringProvider(private val context: Context) : StringProvider { + override fun get(key: String) = context.getString(keyToId(key)) +} + +// jvmMain +class DesktopStringProvider : StringProvider { + private val strings = mapOf( + "post_not_found" to "Post not found", + "now" to "now", + // ... + ) + override fun get(key: String) = strings[key] ?: key +} +``` + +**Affected files:** +- TimeAgoFormatter.kt +- BlankNote.kt +- All UI components with text + +### 2. AccountViewModel + +**Problem:** Most UI components depend on `AccountViewModel` for: +- User profile data +- Settings (image loading, NSFW, etc.) +- Actions (follow, mute, report) + +**Solution:** Create interface in shared module: + +```kotlin +// commonMain +interface IAccountState { + val userPubKey: String + val userNpub: String + val settings: IUserSettings +} + +interface IUserSettings { + val showImages: Boolean + val showSensitiveContent: Boolean +} +``` + +### 3. Navigation (INav) + +**Problem:** Android uses custom `INav` interface tied to Compose Navigation. + +**Solution:** Already abstracted! `INav` is an interface - just needs to be in shared module. + +### 4. Image Loading + +**Problem:** Uses Coil with Android-specific caching. + +**Solution:** Coil 3.x supports Compose Multiplatform. Create shared wrapper: + +```kotlin +// commonMain +@Composable +expect fun AsyncImage( + url: String, + contentDescription: String?, + modifier: Modifier, +) +``` + +## Recommended Shared Module Structure + +``` +shared-ui/ +├── src/ +│ ├── commonMain/kotlin/ +│ │ ├── formatters/ +│ │ │ ├── PubKeyFormatter.kt +│ │ │ ├── ZapFormatter.kt +│ │ │ └── TimeAgoFormatter.kt (abstracted) +│ │ ├── theme/ +│ │ │ ├── Color.kt +│ │ │ ├── Shape.kt +│ │ │ ├── Type.kt +│ │ │ └── Dimensions.kt +│ │ ├── components/ +│ │ │ ├── NoteCard.kt +│ │ │ ├── UserAvatar.kt +│ │ │ └── LoadingIndicator.kt +│ │ └── providers/ +│ │ ├── StringProvider.kt +│ │ └── ImageProvider.kt +│ ├── androidMain/kotlin/ +│ │ └── providers/ +│ │ └── AndroidStringProvider.kt +│ └── jvmMain/kotlin/ +│ └── providers/ +│ └── DesktopStringProvider.kt +└── build.gradle.kts +``` + +## Implementation Priority + +### Phase 1: Low-Hanging Fruit (1-2 days) +1. Create `shared-ui` module +2. Move pure Kotlin formatters (PubKeyFormatter, ZapFormatter) +3. Move theme colors and dimensions +4. Update both apps to use shared module + +### Phase 2: String Abstraction (2-3 days) +1. Create StringProvider interface +2. Implement for Android and Desktop +3. Migrate TimeAgoFormatter +4. Create composable wrappers for common strings + +### Phase 3: Core UI Components (1 week) +1. Abstract AccountViewModel to IAccountState +2. Move simple components (BlankNote, LoadingIndicator) +3. Create NoteCard shared component +4. Integrate with both apps + +### Phase 4: Complex Components (2+ weeks) +1. Image loading abstraction +2. RichTextViewer +3. ReactionsRow +4. Full NoteCompose migration + +## Desktop vs Android Comparison + +| Feature | Android | Desktop | Share Strategy | +|---------|---------|---------|----------------| +| Note display | NoteCompose.kt (51KB) | NoteCard in FeedScreen | Extract common layout | +| Time formatting | TimeAgoFormatter.kt | SimpleDateFormat inline | Abstract with interface | +| Key display | PubKeyFormatter.kt | Manual truncation | Use shared formatter | +| Colors | Color.kt | darkColorScheme() | Share Color.kt | +| Navigation | INav + Compose Nav | enum Screen | Abstract navigation | +| Images | Coil + OkHttp | Not implemented | Coil Multiplatform | + +## Current Desktop Implementation Gaps + +Desktop currently has basic implementations that could benefit from sharing: + +1. **NoteCard** - Has inline date formatting, could use TimeAgoFormatter +2. **Key display** - Manual truncation, should use PubKeyFormatter +3. **Theme** - Uses default darkColorScheme, could use shared theme +4. **No image loading** - Needs Coil integration + +## Quick Wins + +Immediate improvements to desktop using patterns from Android: + +```kotlin +// Instead of: +val npub = event.pubKey.take(16) + "..." + +// Use shared formatter: +val npub = event.pubKey.toDisplayHexKey() + +// Instead of: +val format = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) + +// Use shared formatter: +val timeAgo = timeAgoNoDot(event.createdAt, stringProvider) +``` + +## Next Steps + +1. **Create shared-ui module** in project structure +2. **Move formatters** as proof of concept +3. **Create StringProvider** abstraction +4. **Iterate** on more complex components diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 65bff466a..f2c9693c5 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] accompanistAdaptive = "0.37.3" -activityCompose = "1.12.1" +composeMultiplatform = "1.9.3" +activityCompose = "1.12.2" agp = "8.13.2" android-compileSdk = "36" android-minSdk = "26" @@ -13,40 +14,40 @@ benchmark = "1.4.1" benchmarkJunit4 = "1.4.1" biometricKtx = "1.2.0-alpha05" coil = "3.3.0" -composeBom = "2025.12.00" +composeBom = "2025.12.01" composeRuntimeAnnotation = "1.10.0" coreKtx = "1.17.0" datastore = "1.2.0" espressoCore = "3.7.0" -firebaseBom = "34.6.0" +firebaseBom = "34.7.0" fragmentKtx = "1.8.9" gms = "4.4.4" jacksonModuleKotlin = "2.20.1" jna = "5.18.1" jtorctl = "0.4.5.7" junit = "4.13.2" -kotlin = "2.2.21" +kotlin = "2.3.0" kotlinxCollectionsImmutable = "0.4.0" kotlinxCoroutinesCore = "1.10.2" kotlinxSerialization = "1.9.0" -kotlinxSerializationPlugin = "2.2.21" +kotlinxSerializationPlugin = "2.3.0" languageId = "17.0.6" lazysodiumAndroid = "5.2.0" lazysodiumJava = "5.2.0" lifecycleRuntimeKtx = "2.10.0" lightcompressor-enhanced = "1.6.0" markdown = "f92ef49c9d" -media3 = "1.8.0" -mockk = "1.14.6" +media3 = "1.9.0" +mockk = "1.14.7" kotlinx-coroutines-test = "1.10.2" navigationCompose = "2.9.6" okhttp = "5.3.2" runner = "1.7.0" rfc3986 = "0.1.2" -secp256k1KmpJniAndroid = "0.21.0" +secp256k1KmpJniAndroid = "0.22.0" securityCryptoKtx = "1.1.0" spotless = "8.1.0" -torAndroid = "0.4.8.21" +torAndroid = "0.4.8.21.1" translate = "17.0.3" unifiedpush = "3.0.10" urlDetector = "0.1.23" @@ -56,9 +57,9 @@ zoomable = "2.9.0" zxing = "3.5.4" zxingAndroidEmbedded = "4.3.0" windowCoreAndroid = "1.5.1" -androidxCamera = "1.5.1" +androidxCamera = "1.5.2" androidxCollection = "1.5.0" -kotlinStdlib = "2.2.21" +kotlinStdlib = "2.3.0" kotlinTest = "2.2.20" core = "1.7.0" mavenPublish = "0.35.0" @@ -122,6 +123,7 @@ jtorctl = { module = "info.guardianproject:jtorctl", version.ref = "jtorctl" } junit = { group = "junit", name = "junit", version.ref = "junit" } 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" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" } lazysodium-java = { group = "com.goterl", name = "lazysodium-java", version.ref = "lazysodiumJava" } @@ -167,4 +169,5 @@ serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } -stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.1" } \ No newline at end of file +stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.1" } +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt index 540833e51..6214aecc6 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableTest.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase +import junit.framework.TestCase.assertEquals import junit.framework.TestCase.fail import org.junit.After import org.junit.Before @@ -41,15 +42,12 @@ class AddressableTest { @Before fun setup() { val context = ApplicationProvider.getApplicationContext() - context.deleteDatabase("test.db") - db = EventStore(context, "test.db", relayUrl = "testUrl") + db = EventStore(context, null) } @After fun tearDown() { db.close() - val context = ApplicationProvider.getApplicationContext() - context.deleteDatabase("test.db") } @Test @@ -59,20 +57,25 @@ class AddressableTest { val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) + db.insert(version1) db.assertQuery(version1, Filter(ids = listOf(version1.id))) + db.assertQuery(version1, addressableQuery) db.insert(version2) db.assertQuery(null, Filter(ids = listOf(version1.id))) db.assertQuery(version2, Filter(ids = listOf(version2.id))) + db.assertQuery(version2, addressableQuery) db.insert(version3) db.assertQuery(null, Filter(ids = listOf(version1.id))) db.assertQuery(null, Filter(ids = listOf(version2.id))) db.assertQuery(version3, Filter(ids = listOf(version3.id))) + db.assertQuery(version3, addressableQuery) } @Test @@ -82,6 +85,8 @@ class AddressableTest { val version2 = signer.sign(LongTextNoteEvent.build("my cool blog, version 2", "title", dTag = "my-cool-blog", createdAt = time + 1)) val version3 = signer.sign(LongTextNoteEvent.build("my cool blog, version 3", "title", dTag = "my-cool-blog", createdAt = time + 2)) + val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) + db.insert(version3) db.assertQuery(version3, Filter(ids = listOf(version3.id))) @@ -101,7 +106,38 @@ class AddressableTest { } db.assertQuery(version3, Filter(ids = listOf(version3.id))) + db.assertQuery(version3, addressableQuery) db.assertQuery(null, Filter(ids = listOf(version2.id))) db.assertQuery(null, Filter(ids = listOf(version1.id))) } + + @Test + fun testTriggersIndexUsage() { + val explainer = + db.store.explainQuery( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 30000 AND + event_headers.pubkey = 'aa' AND + event_headers.d_tag = 'test-tag' AND + event_headers.created_at < 1766686500 AND + event_headers.kind >= 30000 AND event_headers.kind < 40000 + """.trimIndent(), + ) + + assertEquals( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 30000 AND + event_headers.pubkey = 'aa' AND + event_headers.d_tag = 'test-tag' AND + event_headers.created_at < 1766686500 AND + event_headers.kind >= 30000 AND event_headers.kind < 40000 + └── SEARCH event_headers USING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + """.trimIndent(), + explainer, + ) + } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt index 02789a115..1f7f771b3 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionTest.kt @@ -23,12 +23,15 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import android.content.Context import android.database.sqlite.SQLiteConstraintException import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip23LongContent.LongTextNoteEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.TimeUtils +import junit.framework.TestCase import junit.framework.TestCase.fail import org.junit.After import org.junit.Assert.assertEquals @@ -170,4 +173,240 @@ class DeletionTest { db.assertQuery(null, Filter(ids = listOf(note2.id))) db.assertQuery(null, Filter(ids = listOf(note3.id))) } + + @Test + fun testInsertDeleteWrap() { + val me = NostrSignerSync() + val myFriend = NostrSignerSync() + + val note1 = me.sign(TextNoteEvent.build("test1")) + val wrap1 = GiftWrapEvent.create(note1, me.pubKey) + val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey) + + db.insert(wrap1) + db.insert(wrap2) + + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val randomDeletionToWrap = signer.sign(DeletionEvent.build(listOf(wrap1))) + + db.insert(randomDeletionToWrap) + + db.assertQuery(randomDeletionToWrap, Filter(ids = listOf(randomDeletionToWrap.id))) + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val deletion = me.sign(DeletionEvent.build(listOf(wrap1))) + + db.insert(deletion) + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + // trying to insert again should fail. + try { + db.insert(wrap1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a deletion event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(deletion, Filter(ids = listOf(deletion.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + } + + @Test + fun testTriggersIndexUsage() { + val sql = + """ + SELECT 1 FROM event_tags + INNER JOIN event_headers + ON event_headers.row_id = event_tags.event_header_row_id + WHERE + event_tags.tag_hash IN (3221122, 223322) AND + event_headers.kind = 5 AND + event_headers.created_at >= 1766686500 AND + event_headers.pubkey_owner_hash = 22332323 + """.trimIndent() + + val explainer = + db.store.explainQuery(sql) + + TestCase.assertEquals( + """ + ${sql.replace("\n","\n ")} + ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) + └── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + """.trimIndent(), + explainer, + ) + } + + @Test + fun testDeleteById() { + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = listOf("ca29c211f", "ca29c211d"), + addresses = emptyList(), + hasher = TagNameValueHasher(0), + ).first() + + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE + id IN ("ca29c211f","ca29c211d") AND + pubkey_owner_hash = "1573573083296714675" + ├── SEARCH event_headers USING INDEX event_headers_id (id=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } + + @Test + fun testDeleteAddressable() { + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(30000, "key1", "a"), + ), + hasher = TagNameValueHasher(0), + ).first() + + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE ( + (kind = "30000" AND pubkey = "key1" AND d_tag = "a") + ) AND + kind >= 30000 AND kind < 40000 + ├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } + + @Test + fun testDeleteAddressablesSingleKind() { + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(30000, "key1", "a"), + Address(30000, "key1", "b"), + Address(30000, "key1", "c"), + Address(30000, "key1", "d"), + ), + hasher = TagNameValueHasher(0), + ).first() + + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE ( + (kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b","c","d")) + ) AND + kind >= 30000 AND kind < 40000 + ├── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } + + @Test + fun testDeleteAddressablesMultipleKinds() { + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(30000, "key1", "a"), + Address(30000, "key1", "b"), + Address(30101, "key1", "c"), + Address(30101, "key1", "d"), + Address(30001, "key2", "e"), + Address(30001, "key2", "f"), + ), + hasher = TagNameValueHasher(0), + ).first() + + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE ( + (kind = "30000" AND pubkey = "key1" AND d_tag IN ("a","b")) + OR + (kind = "30101" AND pubkey = "key1" AND d_tag IN ("c","d")) + ) AND + kind >= 30000 AND kind < 40000 + ├── MULTI-INDEX OR + │ ├── INDEX 1 + │ │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + │ └── INDEX 2 + │ └── SEARCH event_headers USING COVERING INDEX addressable_idx (kind=? AND pubkey=? AND d_tag=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } + + @Test + fun testDeleteReplaceables() { + val sql = + db.store.deletionModule + .deleteSQL( + pubkey = "key1", + idValues = emptyList(), + addresses = + listOf( + Address(10000, "key1", ""), + Address(10000, "key1", ""), + Address(10001, "key1", ""), + Address(10001, "key1", ""), + Address(10001, "key2", ""), + Address(10001, "key2", ""), + ), + hasher = TagNameValueHasher(0), + ).first() + + TestCase.assertEquals( + """ + DELETE FROM event_headers + WHERE + kind IN ("10000","10001") AND + pubkey = "key1" AND + ((kind in (0,3)) OR (kind >= 10000 AND kind < 20000)) + ├── SEARCH event_headers USING COVERING INDEX replaceable_idx (kind=? AND pubkey=?) + ├── SEARCH event_vanish USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_expirations USING INTEGER PRIMARY KEY (rowid=?) + └── SEARCH event_tags USING COVERING INDEX fk_event_tags_header_id (event_header_row_id=?) + """.trimIndent(), + db.store.explainQuery(sql.sql, sql.args), + ) + } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt index add6ce20b..6197a8967 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationTest.kt @@ -42,7 +42,7 @@ class ExpirationTest { @Before fun setup() { val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null, relayUrl = "testUrl") + db = EventStore(context, null) } @After diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt index 53277c750..a16e2909b 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/LargeDBTests.kt @@ -56,15 +56,12 @@ class LargeDBTests { @Before fun setup() { val context = ApplicationProvider.getApplicationContext() - context.deleteDatabase("test_large.db") - db = EventStore(context, "largeDBTest.db") + db = EventStore(context, null) } @After fun tearDown() { db.close() - val context = ApplicationProvider.getApplicationContext() - context.deleteDatabase("test_large.db") } @Test diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt new file mode 100644 index 000000000..2a3983a8d --- /dev/null +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryAssemblerTest.kt @@ -0,0 +1,480 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.store.sqlite + +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent +import junit.framework.TestCase +import org.junit.After +import org.junit.Assert +import org.junit.Before +import org.junit.Test + +class QueryAssemblerTest { + val hasher = TagNameValueHasher(0L) + val builder = EventIndexesModule(FullTextSearchModule(), { hasher }) + + val key1 = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" + val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14" + val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9" + + private lateinit var db: EventStore + + @Before + fun setup() { + val context = ApplicationProvider.getApplicationContext() + db = EventStore(context, null) + } + + @After + fun tearDown() { + db.close() + } + + fun explain(f: Filter) = builder.planQuery(f, hasher, db.store.readableDatabase) + + fun explain(f: List) = builder.planQuery(f, hasher, db.store.readableDatabase) + + @Test + fun testEmpty() { + Assert.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id + └── SCAN event_headers USING INDEX query_by_created_at_id + """.trimIndent(), + explain(Filter()), + ) + } + + @Test + fun testCheckDeletionEventExists() { + val query = + explain( + Filter( + kinds = listOf(5), + authors = listOf(key1), + tags = mapOf("e" to listOf(key2)), + since = 1750889190, + ), + ) + + println(query) + + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_headers.created_at >= "1750889190") AND (event_tags.tag_hash = "2657743813502222172") AND (event_headers.kind = "5") AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + query, + ) + } + + @Test + fun testLimit() { + val explainer = explain(Filter(limit = 10)) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 10 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + explainer, + ) + } + + @Test + fun testLimits() { + val explainer = explain(listOf(Filter(limit = 10), Filter(limit = 30))) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 10) + UNION + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30) + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ └── COMPOUND QUERY + │ ├── LEFT-MOST SUBQUERY + │ │ ├── CO-ROUTINE (subquery-1) + │ │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id + │ │ └── SCAN (subquery-1) + │ └── UNION USING TEMP B-TREE + │ ├── CO-ROUTINE (subquery-3) + │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id + │ └── SCAN (subquery-3) + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + explainer, + ) + } + + @Test + fun testAllFeatures() { + val sql = + explain( + listOf( + Filter(limit = 10), + Filter( + authors = listOf(key1), + kinds = listOf(1, 1111), + search = "keywords", + limit = 100, + ), + Filter(kinds = listOf(20), search = "cats", limit = 30), + ), + ) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT row_id FROM (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 10) + UNION + SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111")) AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") AND (event_fts MATCH "keywords") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 100) + UNION + SELECT row_id FROM (SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind = "20") AND (event_fts MATCH "cats") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30) + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ └── COMPOUND QUERY + │ ├── LEFT-MOST SUBQUERY + │ │ ├── CO-ROUTINE (subquery-1) + │ │ │ └── SCAN event_headers USING COVERING INDEX query_by_created_at_id + │ │ └── SCAN (subquery-1) + │ ├── UNION USING TEMP B-TREE + │ │ ├── CO-ROUTINE (subquery-3) + │ │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ │ │ └── USE TEMP B-TREE FOR ORDER BY + │ │ └── SCAN (subquery-3) + │ └── UNION USING TEMP B-TREE + │ ├── CO-ROUTINE (subquery-5) + │ │ ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + │ │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ │ └── USE TEMP B-TREE FOR ORDER BY + │ └── SCAN (subquery-5) + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } + + @Test + fun testKind() { + val sql = + explain( + listOf( + Filter( + kinds = listOf(ContactListEvent.KIND), + limit = 30, + ), + ), + ) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.kind = "3" ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_dtag_idx (kind=?) + │ └── USE TEMP B-TREE FOR ORDER BY + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } + + @Test + fun testKindAndDTag() { + val sql = + explain( + listOf( + Filter( + kinds = listOf(ContactListEvent.KIND), + tags = mapOf("d" to listOf("")), + limit = 30, + ), + ), + ) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_headers.row_id as row_id FROM event_headers WHERE (event_headers.kind = "3") AND (event_headers.d_tag = "") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_dtag_idx (kind=?) + │ └── USE TEMP B-TREE FOR ORDER BY + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } + + @Test + fun testFollowersOf() { + val sql = + explain( + listOf( + Filter( + tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), + limit = 30, + ), + ), + ) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE event_tags.tag_hash = "-4551135004136952885" ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) + │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ └── USE TEMP B-TREE FOR ORDER BY + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } + + @Test + fun testTagsAndKinds() { + val sql = + explain( + listOf( + Filter( + kinds = listOf(ContactListEvent.KIND), + tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), + limit = 30, + ), + ), + ) + + println(sql) + + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_headers.kind = "3") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) + │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ └── USE TEMP B-TREE FOR ORDER BY + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } + + @Test + fun testTagsAndAuthors() { + val sql = + explain( + listOf( + Filter( + authors = listOf(key1), + tags = mapOf("p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c")), + limit = 30, + ), + ), + ) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_headers.pubkey = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) + │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ └── USE TEMP B-TREE FOR ORDER BY + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } + + @Test + fun testTwoTags() { + val sql = + explain( + listOf( + Filter( + kinds = listOf(1), + tags = + mapOf( + "p" to listOf("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c"), + "t" to listOf("hashtag"), + ), + limit = 30, + ), + ), + ) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_tags.event_header_row_id as row_id FROM event_tags INNER JOIN event_tags as event_tagst ON event_tagst.event_header_row_id = event_tags.event_header_row_id INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE (event_tags.tag_hash = "-4551135004136952885") AND (event_tagst.tag_hash = "-6379614208644810021") AND (event_headers.kind = "1") ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 30 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ ├── SEARCH event_tags USING COVERING INDEX query_by_tags_hash (tag_hash=?) + │ ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + │ ├── SEARCH event_tagst USING COVERING INDEX query_by_tags_hash (tag_hash=? AND event_header_row_id=?) + │ └── USE TEMP B-TREE FOR ORDER BY + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } + + @Test + fun testIdQuery() { + val sql = explain(Filter(ids = listOf(key1))) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.id = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── SEARCH event_headers USING COVERING INDEX event_headers_id (id=?) + └── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + """.trimIndent(), + sql, + ) + } + + @Test + fun testAuthors() { + val sql = explain(Filter(authors = listOf(key1, key2), kinds = listOf(1, 30023), limit = 300)) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_headers.row_id as row_id FROM event_headers WHERE (event_headers.kind IN ("1", "30023")) AND (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14")) ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT 300 + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── CO-ROUTINE filtered + │ ├── SEARCH event_headers USING INDEX query_by_kind_pubkey_dtag_idx (kind=? AND pubkey=?) + │ └── USE TEMP B-TREE FOR ORDER BY + ├── SCAN filtered + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } + + @Test + fun testAuthorsAndSearch() { + val sql = explain(Filter(authors = listOf(key1, key2, key3), search = "keywords")) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.pubkey IN ("7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d", "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14", "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9")) AND (event_fts MATCH "keywords") + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } + + @Test + fun testKindAndSearch() { + val sql = explain(Filter(kinds = listOf(1, 1111, 10000), search = "keywords")) + TestCase.assertEquals( + """ + SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers + INNER JOIN ( + SELECT event_fts.event_header_row_id as row_id FROM event_fts INNER JOIN event_headers ON event_headers.row_id = event_fts.event_header_row_id WHERE (event_headers.kind IN ("1", "1111", "10000")) AND (event_fts MATCH "keywords") + ) AS filtered + ON event_headers.row_id = filtered.row_id + ORDER BY created_at DESC, id + ├── SCAN event_fts VIRTUAL TABLE INDEX 4: + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + ├── SEARCH event_headers USING INTEGER PRIMARY KEY (rowid=?) + └── USE TEMP B-TREE FOR ORDER BY + """.trimIndent(), + sql, + ) + } +} diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt index 0a85cfc75..97c8aa75d 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableTest.kt @@ -28,6 +28,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase +import junit.framework.TestCase.assertEquals import junit.framework.TestCase.fail import org.junit.After import org.junit.Before @@ -41,7 +42,7 @@ class ReplaceableTest { @Before fun setup() { val context = ApplicationProvider.getApplicationContext() - db = EventStore(context, null, relayUrl = "testUrl") + db = EventStore(context, null) } @After @@ -56,20 +57,25 @@ class ReplaceableTest { val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2)) + val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) + db.insert(version1) db.assertQuery(version1, Filter(ids = listOf(version1.id))) + db.assertQuery(version1, addressableQuery) db.insert(version2) db.assertQuery(null, Filter(ids = listOf(version1.id))) db.assertQuery(version2, Filter(ids = listOf(version2.id))) + db.assertQuery(version2, addressableQuery) db.insert(version3) db.assertQuery(null, Filter(ids = listOf(version1.id))) db.assertQuery(null, Filter(ids = listOf(version2.id))) db.assertQuery(version3, Filter(ids = listOf(version3.id))) + db.assertQuery(version3, addressableQuery) } @Test @@ -79,6 +85,8 @@ class ReplaceableTest { val version2 = signer.sign(MetadataEvent.createNew("Vitor 2", createdAt = time + 1)) val version3 = signer.sign(MetadataEvent.createNew("Vitor 3", createdAt = time + 2)) + val addressableQuery = Filter(kinds = listOf(version1.kind), authors = listOf(version1.pubKey), tags = mapOf("d" to listOf(version1.dTag()))) + db.insert(version3) db.assertQuery(version3, Filter(ids = listOf(version3.id))) @@ -98,7 +106,64 @@ class ReplaceableTest { } db.assertQuery(version3, Filter(ids = listOf(version3.id))) + db.assertQuery(version3, addressableQuery) db.assertQuery(null, Filter(ids = listOf(version2.id))) db.assertQuery(null, Filter(ids = listOf(version1.id))) } + + @Test + fun testTriggersIndexUsageKind0() { + val explainer = + db.store.explainQuery( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 0 AND + event_headers.pubkey = 'aa' AND + event_headers.created_at < 1766686500 AND + ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); + """.trimIndent(), + ) + + assertEquals( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 0 AND + event_headers.pubkey = 'aa' AND + event_headers.created_at < 1766686500 AND + ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); + └── SEARCH event_headers USING INDEX replaceable_idx (kind=? AND pubkey=?) + """.trimIndent(), + explainer, + ) + } + + @Test + fun testTriggersIndexUsageKind3() { + val explainer = + db.store.explainQuery( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 3 AND + event_headers.pubkey = 'aa' AND + event_headers.created_at < 1766686500 AND + ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); + """.trimIndent(), + ) + + assertEquals( + """ + SELECT * FROM event_headers + WHERE + event_headers.kind = 3 AND + event_headers.pubkey = 'aa' AND + event_headers.created_at < 1766686500 AND + ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); + └── SEARCH event_headers USING INDEX replaceable_idx (kind=? AND pubkey=?) + """.trimIndent(), + explainer, + ) + } } diff --git a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt index 578d6aeb7..1a7f7aad5 100644 --- a/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt +++ b/quartz/src/androidInstrumentedTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishTest.kt @@ -26,6 +26,7 @@ import androidx.test.core.app.ApplicationProvider import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import com.vitorpamplona.quartz.utils.TimeUtils import junit.framework.TestCase.fail @@ -87,4 +88,49 @@ class RightToVanishTest { db.assertQuery(null, Filter(ids = listOf(note2.id))) db.assertQuery(note3, Filter(ids = listOf(note3.id))) } + + @Test + fun testInsertDeleteGiftWrap() { + val time = TimeUtils.now() + + val me = NostrSignerSync() + val myFriend = NostrSignerSync() + + val note1 = me.sign(TextNoteEvent.build("test1", createdAt = time)) + val wrap1 = GiftWrapEvent.create(note1, me.pubKey) + val wrap2 = GiftWrapEvent.create(note1, myFriend.pubKey) + + db.insert(wrap1) + db.insert(wrap2) + + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val randomVanishToWrap = signer.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2)) + + db.insert(randomVanishToWrap) + + db.assertQuery(wrap1, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + val vanish = me.sign(RequestToVanishEvent.build("testUrl", createdAt = time + 2)) + + db.insert(vanish) + + db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + + // trying to insert again should fail. + try { + db.insert(wrap1) + fail("Should not be able to insert a deleted event") + } catch (e: SQLiteConstraintException) { + assertEquals("blocked: a request to vanish event exists (code 1811 SQLITE_CONSTRAINT_TRIGGER)", e.message) + } + + db.assertQuery(vanish, Filter(ids = listOf(vanish.id))) + db.assertQuery(null, Filter(ids = listOf(wrap1.id))) + db.assertQuery(wrap2, Filter(ids = listOf(wrap2.id))) + } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt index 27f625d66..735997518 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/AddressableModule.kt @@ -36,6 +36,8 @@ class AddressableModule : IModule { // if a newer addressable is inserted the unique index // above will be triggered. Delete cascade will take // care of the event_tags table + // the duplicate: kind >= 30000 AND kind < 40000 + // helps SQLlite find the index above db.execSQL( """ CREATE TRIGGER delete_older_addressable_event @@ -48,7 +50,8 @@ class AddressableModule : IModule { event_headers.kind = NEW.kind AND event_headers.pubkey = NEW.pubkey AND event_headers.d_tag = NEW.d_tag AND - event_headers.created_at < NEW.created_at; + event_headers.created_at < NEW.created_at AND + event_headers.kind >= 30000 AND event_headers.kind < 40000; END; """.trimIndent(), ) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt index 51c81d6ee..32e567fd1 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/DeletionRequestModule.kt @@ -21,12 +21,22 @@ package com.vitorpamplona.quartz.nip01Core.store.sqlite import android.database.sqlite.SQLiteDatabase +import com.vitorpamplona.quartz.nip01Core.core.Address import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.isAddressable +import com.vitorpamplona.quartz.nip01Core.core.isReplaceable import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -class DeletionRequestModule : IModule { +class DeletionRequestModule( + val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, +) : IModule { + /** + * Creates a trigger to reject events that have been + * deleted by ID or ATag including GiftWraps that + * must be checked against the p-tag (pubkey_owner_hash) + */ override fun create(db: SQLiteDatabase) { - // rejects deleted events. db.execSQL( """ CREATE TRIGGER reject_deleted_events @@ -36,14 +46,14 @@ class DeletionRequestModule : IModule { -- Check for ID-based deletion record SELECT RAISE(ABORT, 'blocked: a deletion event exists') WHERE EXISTS ( - SELECT 1 FROM event_headers - INNER JOIN event_tags + SELECT 1 FROM event_tags + INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id WHERE - event_headers.created_at >= NEW.created_at AND + event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) AND event_headers.kind = 5 AND - event_headers.pubkey = NEW.pubkey AND - event_tags.tag_hash IN (NEW.etag_hash, NEW.atag_hash) + event_headers.pubkey_owner_hash = NEW.pubkey_owner_hash AND + event_headers.created_at >= NEW.created_at ); END; """.trimIndent(), @@ -56,36 +66,110 @@ class DeletionRequestModule : IModule { fun insert( event: Event, - headerId: Long, db: SQLiteDatabase, ) { if (event is DeletionEvent) { val idValues = event.deleteEventIds() - val idParams = idValues.joinToString(",") { "?" } - val addresses = event.deleteAddresses() - val addressParams = addresses.joinToString(",") { "(?, ?)" } - val addressValues = addresses.flatMap { listOf(it.kind, it.dTag) } - val whereClause = - if (idValues.isNotEmpty() && addresses.isNotEmpty()) { - "(id IN ($idParams) OR (kind, d_tag) IN ($addressParams)) AND pubkey = ?" - } else if (idValues.isNotEmpty()) { - "id IN ($idParams) AND pubkey = ?" - } else if (addresses.isNotEmpty()) { - "(kind, d_tag) IN ($addressParams) AND pubkey = ?" - } else { - return - } - val whereParams = idValues.plus(addressValues).plus(event.pubKey).toTypedArray() - - db.execSQL( - """ - DELETE FROM event_headers - WHERE $whereClause; - """.trimIndent(), - whereParams, - ) + deleteSQL(event.pubKey, idValues, addresses, hasher(db)).forEach { delete -> + db.execSQL(delete.sql, delete.args) + } } } + + /** + * Creates a Delete statement that correctly deletes by id, + * by address and by replaceable (no d-tag) using each index + * appropriately, including GiftWraps where the owner is the + * p-tag (via event_header.pubkey_owner_hash) + */ + fun deleteSQL( + pubkey: HexKey, + idValues: List, + addresses: List
, + hasher: TagNameValueHasher, + ): List { + val owner = hasher.hash(pubkey) + val idParams = idValues.joinToString(",") { "?" } + + // aligns each type of param with the need to filter d-tag + // and thus each index type + val addressablesByKind = addresses.filter { it.kind.isAddressable() && it.pubKeyHex == pubkey }.groupBy { it.kind } + val replaceablesByKind = addresses.filter { it.kind.isReplaceable() && it.pubKeyHex == pubkey }.groupBy { it.kind } + + val addressableParams = + addressablesByKind.keys.joinToString("\n OR\n ") { + val tagList = addressablesByKind[it] + if (tagList == null) { + "" + } else if (tagList.size == 1) { + "(kind = ? AND pubkey = ? AND d_tag = ?)" + } else { + "(kind = ? AND pubkey = ? AND d_tag IN (${tagList.joinToString(",") { "?" }}))" + } + } + + val addressableValues = + addressablesByKind.flatMap { + listOf(it.key.toLong(), pubkey) + it.value.map { it.dTag } + } + + val replaceableKindsParam = replaceablesByKind.keys.joinToString(",") { "?" } + val replaceableKindsValues = replaceablesByKind.keys.map { it.toLong() } + + val deleteById = + if (idValues.isNotEmpty()) { + SqlArgs( + """ + DELETE FROM event_headers + WHERE + id IN ($idParams) AND + pubkey_owner_hash = ? + """.trimIndent(), + idValues.plus(owner).toTypedArray(), + ) + } else { + null + } + + val deleteByAddress = + if (addressableValues.isNotEmpty()) { + SqlArgs( + """ + DELETE FROM event_headers + WHERE ( + $addressableParams + ) AND + kind >= 30000 AND kind < 40000 + """.trimIndent(), + addressableValues.toTypedArray(), + ) + } else { + null + } + + val deleteByReplaceable = + if (replaceableKindsParam.isNotEmpty()) { + SqlArgs( + """ + DELETE FROM event_headers + WHERE + kind IN ($replaceableKindsParam) AND + pubkey = ? AND + ((kind in (0,3)) OR (kind >= 10000 AND kind < 20000)) + """.trimIndent(), + replaceableKindsValues.plus(pubkey).toTypedArray(), + ) + } else { + null + } + + return listOfNotNull(deleteById, deleteByAddress, deleteByReplaceable) + } + + class SqlArgs( + val sql: String, + val args: Array, + ) } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt index f8ceb93cc..10fcf8301 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventIndexesModule.kt @@ -25,21 +25,19 @@ import android.database.sqlite.SQLiteDatabase import com.vitorpamplona.quartz.nip01Core.core.AddressSerializer 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.core.Kind import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper -import com.vitorpamplona.quartz.nip01Core.core.Tag import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.sqlite.sql.where +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.utils.EventFactory class EventIndexesModule( val fts: FullTextSearchModule, - val seedModule: SeedModule, - val tagIndexStrategy: IndexingStrategy = IndexingStrategy(), + val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, + val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : IModule { - private var hasherCache: TagNameValueHasher? = null - - fun hasher(db: SQLiteDatabase): TagNameValueHasher = hasherCache ?: TagNameValueHasher(seedModule.getSeed(db)).also { hasherCache = it } - override fun create(db: SQLiteDatabase) { db.execSQL( """ @@ -50,11 +48,12 @@ class EventIndexesModule( created_at INTEGER NOT NULL, kind INTEGER NOT NULL, d_tag TEXT, - etag_hash INTEGER NOT NULL, - atag_hash INTEGER, tags TEXT NOT NULL, content TEXT NOT NULL, - sig TEXT NOT NULL + sig TEXT NOT NULL, + pubkey_owner_hash INTEGER NOT NULL, + etag_hash INTEGER, + atag_hash INTEGER ) """.trimIndent(), ) @@ -69,10 +68,14 @@ class EventIndexesModule( """.trimIndent(), ) - db.execSQL("CREATE UNIQUE INDEX event_headers_id ON event_headers (id)") - db.execSQL("CREATE INDEX query_by_kind_pubkey_idx ON event_headers (created_at desc, kind, pubkey, d_tag)") - db.execSQL("CREATE INDEX query_by_id_idx ON event_headers (created_at desc, id)") - db.execSQL("CREATE INDEX query_by_tags_idx ON event_tags (tag_hash)") + db.execSQL("CREATE UNIQUE INDEX event_headers_id ON event_headers (id)") + db.execSQL("CREATE INDEX query_by_kind_pubkey_dtag_idx ON event_headers (kind, pubkey, d_tag)") + db.execSQL("CREATE INDEX query_by_created_at_id ON event_headers (created_at desc, id)") + // need to check if this is actually needed. + db.execSQL("CREATE INDEX query_by_created_at_kind_key ON event_headers (created_at desc, kind, pubkey)") + + db.execSQL("CREATE INDEX fk_event_tags_header_id ON event_tags (event_header_row_id)") + db.execSQL("CREATE INDEX query_by_tags_hash ON event_tags (tag_hash, event_header_row_id)") // Prevent updates to maintain immutability db.execSQL( @@ -106,9 +109,9 @@ class EventIndexesModule( val sqlInsertHeader = """ INSERT INTO event_headers - (id, pubkey, created_at, kind, tags, content, sig, d_tag, etag_hash, atag_hash) + (id, pubkey, created_at, kind, tags, content, sig, d_tag, pubkey_owner_hash, etag_hash, atag_hash) VALUES - (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """.trimIndent() val sqlInsertTags = @@ -125,56 +128,75 @@ class EventIndexesModule( ): Long { val hasher = hasher(db) val stmt = db.compileStatement(sqlInsertHeader) + + val kindLong = event.kind.toLong() + val pubkeyHash = hasher.hash(event.pubKey) + + val eventOwnerHash = + if (event is GiftWrapEvent) { + event.recipientPubKey()?.let { hasher.hash(it) } ?: pubkeyHash + } else { + pubkeyHash + } + + val eTagHash = hasher.hashETag(event.id) + stmt.bindString(1, event.id) stmt.bindString(2, event.pubKey) stmt.bindLong(3, event.createdAt) - stmt.bindLong(4, event.kind.toLong()) + stmt.bindLong(4, kindLong) stmt.bindString(5, OptimizedJsonMapper.toJson(event.tags)) stmt.bindString(6, event.content) stmt.bindString(7, event.sig) if (event is AddressableEvent) { val dTag = event.dTag() stmt.bindString(8, dTag) - stmt.bindLong(9, hasher.hashETag(event.id)) - stmt.bindLong(10, hasher.hashATag(AddressSerializer.assemble(event.kind, event.pubKey, dTag))) + stmt.bindLong(9, eventOwnerHash) + stmt.bindLong(10, eTagHash) + stmt.bindLong(11, hasher.hashATag(AddressSerializer.assemble(event.kind, event.pubKey, dTag))) } else { stmt.bindNull(8) - stmt.bindLong(9, hasher.hashETag(event.id)) - stmt.bindNull(10) + stmt.bindLong(9, eventOwnerHash) + stmt.bindLong(10, eTagHash) + stmt.bindNull(11) } val headerId = stmt.executeInsert() val stmtTags = db.compileStatement(sqlInsertTags) - event.tags.forEach { tag -> - if (tagIndexStrategy.shouldIndex(event.kind, tag)) { - stmtTags.bindLong(1, headerId) - stmtTags.bindLong(2, hasher.hash(tag[0], tag[1])) - stmtTags.executeInsert() + // sorting helps SQLLite by avoiding + // rebalancing the tree every new insert + val indexableTags = ArrayList() + for (idx in event.tags.indices) { + if (tagIndexStrategy.shouldIndex(event.kind, event.tags[idx])) { + indexableTags.add(hasher.hash(event.tags[idx][0], event.tags[idx][1])) } } + indexableTags.sort() + indexableTags.forEach { + stmtTags.bindLong(1, headerId) + stmtTags.bindLong(2, it) + stmtTags.executeInsert() + } return headerId } - /** - * By default, we index all tags that have a single letter name and some value - */ - class IndexingStrategy { - fun shouldIndex( - kind: Int, - tag: Tag, - ) = tag.size >= 2 && tag[0].length == 1 - } - fun planQuery( filter: Filter, hasher: TagNameValueHasher, + db: SQLiteDatabase, ): String { - val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher) ?: return makeEverythingQuery() + val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher) - return makeQueryIn(rowIdSubQuery.sql) + return if (rowIdSubQuery == null) { + val query = makeEverythingQuery() + db.explainQuery(query) + } else { + val query = makeQueryIn(rowIdSubQuery.sql) + db.explainQuery(query, rowIdSubQuery.args.toTypedArray()) + } } fun query( @@ -195,33 +217,37 @@ class EventIndexesModule( db: SQLiteDatabase, onEach: (T) -> Unit, ) { - val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) ?: return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach) + val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) - db.runQueryEmitting(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args, onEach) + return if (rowIdSubQuery == null) { + db.runQuery(makeEverythingQuery(), onEach = onEach) + } else { + db.runQuery(makeQueryIn(rowIdSubQuery.sql), rowIdSubQuery.args, onEach) + } } fun planQuery( filters: List, hasher: TagNameValueHasher, + db: SQLiteDatabase, ): String { - val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher) } - if (rowIdSubQueries.isEmpty()) return makeEverythingQuery() - val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } - return makeQueryIn(unions) + val rowIdSubQuery = unionSubqueriesIfNeeded(filters, hasher) + + return if (rowIdSubQuery == null) { + val query = makeEverythingQuery() + db.explainQuery(query) + } else { + val query = makeQueryIn(rowIdSubQuery.sql) + db.explainQuery(query, rowIdSubQuery.args.toTypedArray()) + } } fun query( filters: List, db: SQLiteDatabase, ): List { - val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) } - - if (rowIdSubQueries.isEmpty()) return db.runQuery(makeEverythingQuery()) - - val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } - val args = rowIdSubQueries.flatMap { it.args } - - return db.runQuery(makeQueryIn(unions), args) + val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.runQuery(makeEverythingQuery()) + return db.runQuery(makeQueryIn(rowIdSubqueries.sql), rowIdSubqueries.args) } fun query( @@ -229,14 +255,13 @@ class EventIndexesModule( db: SQLiteDatabase, onEach: (T) -> Unit, ) { - val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) } + val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) - if (rowIdSubQueries.isEmpty()) return db.runQueryEmitting(makeEverythingQuery(), onEach = onEach) - - val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } - val args = rowIdSubQueries.flatMap { it.args } - - db.runQueryEmitting(makeQueryIn(unions), args, onEach) + if (rowIdSubqueries == null) { + db.runQuery(makeEverythingQuery(), onEach = onEach) + } else { + db.runQuery(makeQueryIn(rowIdSubqueries.sql), rowIdSubqueries.args, onEach) + } } private fun makeEverythingQuery() = "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id" @@ -244,7 +269,9 @@ class EventIndexesModule( private fun makeQueryIn(rowIdQuery: String) = """ SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN ($rowIdQuery) AS filtered + INNER JOIN ( + $rowIdQuery + ) AS filtered ON event_headers.row_id = filtered.row_id ORDER BY created_at DESC, id """.trimIndent() @@ -254,56 +281,66 @@ class EventIndexesModule( args: List = emptyList(), ): List = rawQuery(sql, args.toTypedArray()).use { cursor -> - parseResults(cursor) + ArrayList(cursor.count).apply { + while (cursor.moveToNext()) { + add(cursor.toEvent()) + } + } } - private fun parseResults(cursor: Cursor): List { - val events = ArrayList(cursor.count) - - while (cursor.moveToNext()) { - events.add( - EventFactory.create( - cursor.getString(0).intern(), - cursor.getString(1).intern(), - cursor.getLong(2), - cursor.getInt(3), - OptimizedJsonMapper.fromJsonToTagArray(cursor.getString(4)), - cursor.getString(5), - cursor.getString(6), - ), - ) - } - - return events - } - - private fun SQLiteDatabase.runQueryEmitting( + private inline fun SQLiteDatabase.runQuery( sql: String, args: List = emptyList(), onEach: (T) -> Unit, ) = rawQuery(sql, args.toTypedArray()).use { cursor -> - emitResults(cursor, onEach) - } - - private fun emitResults( - cursor: Cursor, - onEach: (T) -> Unit, - ) { while (cursor.moveToNext()) { - onEach( - EventFactory.create( - cursor.getString(0).intern(), - cursor.getString(1).intern(), - cursor.getLong(2), - cursor.getInt(3), - OptimizedJsonMapper.fromJsonToTagArray(cursor.getString(4)), - cursor.getString(5), - cursor.getString(6), - ), - ) + onEach(cursor.toEvent()) } } + private fun Cursor.toEvent() = + EventFactory.create( + getString(0).intern(), + getString(1).intern(), + getLong(2), + getInt(3), + OptimizedJsonMapper.fromJsonToTagArray(getString(4)), + getString(5), + getString(6), + ) + + class RawEvent( + val id: HexKey, + val pubKey: HexKey, + val createdAt: Long, + val kind: Kind, + val jsonTags: String, + val content: String, + val sig: HexKey, + ) { + fun toEvent() = + EventFactory.create( + id.intern(), + pubKey.intern(), + createdAt, + kind, + OptimizedJsonMapper.fromJsonToTagArray(jsonTags), + content, + sig, + ) + } + + private fun Cursor.toRawEvent() = + RawEvent( + getString(0), + getString(1), + getLong(2), + getInt(3), + getString(4), + getString(5), + getString(6), + ) + // -------------- // Counts // ------------- @@ -311,23 +348,22 @@ class EventIndexesModule( filter: Filter, db: SQLiteDatabase, ): Int { - val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) ?: return db.countEverything() + val rowIdSubQuery = prepareRowIDSubQueries(filter, hasher(db)) - return db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args) + return if (rowIdSubQuery == null) { + db.countEverything() + } else { + db.countIn(rowIdSubQuery.sql, rowIdSubQuery.args) + } } fun count( filters: List, db: SQLiteDatabase, ): Int { - val rowIdSubQueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) } + val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return db.countEverything() - if (rowIdSubQueries.isEmpty()) return db.countEverything() - - val unions = rowIdSubQueries.joinToString(" UNION ") { it.sql } - val args = rowIdSubQueries.flatMap { it.args } - - return db.countIn(unions, args) + return db.countIn(rowIdSubqueries.sql, rowIdSubqueries.args) } private fun SQLiteDatabase.countEverything() = runCount("SELECT count(*) as count FROM event_headers") @@ -353,22 +389,22 @@ class EventIndexesModule( filter: Filter, db: SQLiteDatabase, ): Int { - val rowIdQuery = prepareRowIDSubQueries(filter, hasher(db)) ?: return 0 - return db.runDelete(rowIdQuery.sql, rowIdQuery.args) + val rowIdQuery = prepareRowIDSubQueries(filter, hasher(db)) + + return if (rowIdQuery == null) { + 0 + } else { + db.runDelete(rowIdQuery.sql, rowIdQuery.args) + } } fun delete( filters: List, db: SQLiteDatabase, ): Int { - val rowIdSubqueries = filters.mapNotNull { prepareRowIDSubQueries(it, hasher(db)) } + val rowIdSubqueries = unionSubqueriesIfNeeded(filters, hasher(db)) ?: return 0 - if (rowIdSubqueries.isEmpty()) return 0 - - val unions = rowIdSubqueries.joinToString(" UNION ") { it.sql } - val args = rowIdSubqueries.flatMap { it.args } - - return db.runDelete(unions, args) + return db.runDelete(rowIdSubqueries.sql, rowIdSubqueries.args) } private fun SQLiteDatabase.runDelete( @@ -376,6 +412,30 @@ class EventIndexesModule( args: List = emptyList(), ): Int = delete("event_headers", "row_id IN ($sql)", args.toTypedArray()) + // --------------------------------- + // Prepare unions of all the filters + // --------------------------------- + fun unionSubqueriesIfNeeded( + filters: List, + hasher: TagNameValueHasher, + ): RowIdSubQuery? { + val inner = + filters.mapNotNull { filter -> + prepareRowIDSubQueries(filter, hasher) + } + + if (inner.isEmpty()) return null + + return if (inner.size == 1) { + inner.first() + } else { + RowIdSubQuery( + sql = inner.joinToString("\n UNION\n ") { "SELECT row_id FROM (${it.sql})" }, + args = inner.flatMap { it.args }, + ) + } + } + // ---------------------------- // Inner row id selections // ---------------------------- @@ -385,92 +445,105 @@ class EventIndexesModule( ): RowIdSubQuery? { if (!filter.isFilledFilter()) return null + val mustJoinSearch = (filter.search != null) + + val nonDTags = filter.tags?.filter { it.key != "d" } ?: emptyMap() + val hasHeaders = with(filter) { - (ids != null && ids.isNotEmpty()) || + (ids != null) || (authors != null && authors.isNotEmpty()) || (kinds != null && kinds.isNotEmpty()) || + (tags != null && tags.containsKey("d")) || (since != null) || (until != null) || - (tags != null && tags.containsKey("d")) + (limit != null) } - val hasSearch = (filter.search != null && filter.search.isNotBlank()) + var defaultTagKey: String? = null val projection = buildString { - val joins = mutableListOf() + // always do tags if there are any + if (nonDTags.isNotEmpty()) { + append("SELECT event_tags.event_header_row_id as row_id FROM event_tags ") - if (hasHeaders) { - append("SELECT event_headers.row_id as row_id FROM event_headers") - - if (hasSearch) { - joins.add("INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = event_headers.row_id") - } - - filter.tags?.forEach { (tagName, _) -> - if (tagName != "d") { - joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = event_headers.row_id") + // it's quite rare to have 2 tags in the filter, but possible + nonDTags.keys.forEachIndexed { index, tagName -> + if (index > 0) { + append("INNER JOIN event_tags as event_tags$tagName ON event_tags$tagName.event_header_row_id = event_tags.event_header_row_id ") + } else { + defaultTagKey = tagName } } - } else if (hasSearch) { - append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName}") - filter.tags?.forEach { (tagName, _) -> - if (tagName != "d") { - joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}") - } + if (hasHeaders) { + append("INNER JOIN event_headers ON event_headers.row_id = event_tags.event_header_row_id ") + } + + if (mustJoinSearch) { + append("INNER JOIN ${fts.tableName} ON ${fts.tableName}.${fts.eventHeaderRowIdName} = event_tags.event_header_row_id ") + } + } else if (mustJoinSearch) { + append("SELECT ${fts.tableName}.${fts.eventHeaderRowIdName} as row_id FROM ${fts.tableName} ") + + if (hasHeaders) { + append("INNER JOIN event_headers ON event_headers.row_id = ${fts.tableName}.${fts.eventHeaderRowIdName}") } } else { - // has only tags - filter.tags?.forEach { (tagName, _) -> - if (tagName != "d") { - if (isEmpty()) { - append("SELECT tag$tagName.event_header_row_id as row_id FROM event_tags as tag$tagName") - } else { - joins.add("INNER JOIN event_tags as tag$tagName ON tag$tagName.event_header_row_id = tag${tagName.takeLast(1)}.event_header_row_id") - } - } - } - - if (isEmpty()) { - // only limit is present - append("SELECT event_headers.row_id as row_id FROM event_headers") - } - } - - if (joins.isNotEmpty()) { - append(" ${joins.joinToString(" ")}") + // no tags and no search. + append("SELECT event_headers.row_id as row_id FROM event_headers ") } } val clause = where { + // the order should match indexes + // ids reduce the filter the most filter.ids?.let { equalsOrIn("event_headers.id", it) } - filter.kinds?.let { equalsOrIn("event_headers.kind", it) } - filter.authors?.let { equalsOrIn("event_headers.pubkey", it) } + + // range search is bad but most of the time these are up the top with few elements. filter.since?.let { greaterThanOrEquals("event_headers.created_at", it) } filter.until?.let { lessThanOrEquals("event_headers.created_at", it) } + // there are indexes for these, starting with tags. + nonDTags.forEach { (tagName, tagValues) -> + val column = + if (defaultTagKey == null || defaultTagKey == tagName) { + "event_tags.tag_hash" + } else { + "event_tags$tagName.tag_hash" + } + + equalsOrIn( + column, + tagValues.map { + hasher.hash(tagName, it) + }, + ) + } + + filter.kinds?.let { equalsOrIn("event_headers.kind", it) } + filter.authors?.let { equalsOrIn("event_headers.pubkey", it) } + + // there are indexes for these, starting with tags. filter.tags?.forEach { (tagName, tagValues) -> if (tagName == "d") { equalsOrIn("event_headers.d_tag", tagValues) - } else { - equalsOrIn( - "tag$tagName.tag_hash", - tagValues.map { - hasher.hash(tagName, it) - }, - ) } } - filter.search?.let { match(fts.tableName, it) } + // if search is included, SQLLite will always start here. + filter.search?.let { + if (it.isNotBlank()) { + match(fts.tableName, it) + } + } } val whereClause = if (filter.limit != null) { - "${clause.conditions} ORDER BY created_at DESC, id ASC LIMIT ${filter.limit}" + "${clause.conditions} ORDER BY event_headers.created_at DESC, event_headers.id ASC LIMIT ${filter.limit}" } else { clause.conditions } @@ -483,7 +556,7 @@ class EventIndexesModule( db.execSQL("DELETE FROM event_headers") } - class RowIdSubQuery( + data class RowIdSubQuery( val sql: String, val args: List, ) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt index 1a9a3e57b..a4c85102e 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/EventStore.kt @@ -24,13 +24,12 @@ import android.content.Context import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.IEventStore -import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventIndexesModule.IndexingStrategy class EventStore( context: Context, dbName: String? = "events.db", val relayUrl: String? = "wss://quartz.local", - val tagIndexStrategy: IndexingStrategy = IndexingStrategy(), + val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : IEventStore { val store = SQLiteEventStore(context, dbName, relayUrl, tagIndexStrategy) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt index 03f1992f8..2a8b1a94e 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ExpirationModule.kt @@ -29,7 +29,7 @@ class ExpirationModule : IModule { db.execSQL( """ CREATE TABLE event_expirations ( - event_header_row_id INTEGER, + event_header_row_id INTEGER PRIMARY KEY NOT NULL, expiration INTEGER NOT NULL, FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE ) @@ -49,8 +49,6 @@ class ExpirationModule : IModule { END; """.trimIndent(), ) - - db.execSQL("CREATE UNIQUE INDEX events_exp_id ON event_expirations (event_header_row_id)") } override fun drop(db: SQLiteDatabase) { diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.kt new file mode 100644 index 000000000..4e50b4225 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/IndexingStrategy.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.nip01Core.store.sqlite + +import com.vitorpamplona.quartz.nip01Core.core.Tag + +interface IndexingStrategy { + fun shouldIndex( + kind: Int, + tag: Tag, + ): Boolean +} + +/** + * By default, we index all tags that have a single letter name and some value + */ +class DefaultIndexingStrategy : IndexingStrategy { + override fun shouldIndex( + kind: Int, + tag: Tag, + ) = tag.size >= 2 && tag[0].length == 1 +} diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.kt new file mode 100644 index 000000000..68bea3398 --- /dev/null +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/QueryExplainer.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.quartz.nip01Core.store.sqlite + +import android.database.sqlite.SQLiteDatabase + +fun SQLiteEventStore.explainQuery( + sql: String, + args: Array = emptyArray(), +) = readableDatabase.explainQuery(sql, args.map { it.toString() }.toTypedArray()) + +fun SQLiteDatabase.explainQuery( + sql: String, + args: Array = emptyArray(), +): String = + rawQuery("EXPLAIN QUERY PLAN $sql", args).use { cursor -> + val treeIndex = mutableMapOf() + val rootNodes = mutableListOf() + + while (cursor.moveToNext()) { + val id = cursor.getInt(0) + val parentId = cursor.getInt(1) + val detail = cursor.getString(3) + + val line = PlanNode(detail) + + treeIndex[id] = line + + val parent = treeIndex[parentId] + if (parent != null) { + parent.children.add(line) + } else { + rootNodes.add(line) + } + } + + buildString { + appendLine(populateArgs(sql, args)) + for (idx in rootNodes.indices) { + printNode(rootNodes[idx], "", idx == rootNodes.size - 1, idx > 0) + } + } + } + +private fun populateArgs( + sql: String, + args: Array = emptyArray(), +): String { + var result = sql + args.forEach { + result = result.replaceFirst("?", "\"$it\"") + } + return result +} + +fun StringBuilder.printNode( + node: PlanNode, + prefix: String, + isLast: Boolean, + newLine: Boolean, +) { + if (newLine) append('\n') + append(prefix) + append(if (isLast) "└── " else "├── ") + append(node.detail) + + val newPrefix = prefix + if (isLast) " " else "│ " + for (i in node.children.indices) { + printNode(node.children[i], newPrefix, i == node.children.size - 1, true) + } +} + +data class PlanNode( + val detail: String, + val children: MutableList = mutableListOf(), +) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt index 04a2d3c7f..7aa87e594 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/ReplaceableModule.kt @@ -28,7 +28,7 @@ class ReplaceableModule : IModule { """ CREATE UNIQUE INDEX replaceable_idx ON event_headers (kind, pubkey) - WHERE (kind >= 10000 AND kind < 20000) OR (kind IN (0, 3)) + WHERE (kind IN (0, 3)) OR (kind >= 10000 AND kind < 20000) """.trimIndent(), ) @@ -41,14 +41,15 @@ class ReplaceableModule : IModule { CREATE TRIGGER delete_older_replaceable_event BEFORE INSERT ON event_headers FOR EACH ROW - WHEN (NEW.kind >= 10000 AND NEW.kind < 20000) OR (NEW.kind IN (0, 3)) + WHEN (NEW.kind IN (0, 3)) OR (NEW.kind >= 10000 AND NEW.kind < 20000) BEGIN -- Delete older records if this is the newest DELETE FROM event_headers WHERE event_headers.kind = NEW.kind AND event_headers.pubkey = NEW.pubkey AND - event_headers.created_at < NEW.created_at; + event_headers.created_at < NEW.created_at AND + ((event_headers.kind IN (0, 3)) OR (event_headers.kind >= 10000 AND event_headers.kind < 20000)); END; """.trimIndent(), ) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt index db07539c8..979574c10 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/RightToVanishModule.kt @@ -24,20 +24,22 @@ import android.database.sqlite.SQLiteDatabase import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent -class RightToVanishModule : IModule { +class RightToVanishModule( + val hasher: (db: SQLiteDatabase) -> TagNameValueHasher, +) : IModule { override fun create(db: SQLiteDatabase) { db.execSQL( """ CREATE TABLE event_vanish ( - event_header_row_id INTEGER, - pubkey TEXT NOT NULL, + event_header_row_id INTEGER PRIMARY KEY NOT NULL, + pubkey_hash INTEGER NOT NULL, created_at INTEGER NOT NULL, FOREIGN KEY (event_header_row_id) REFERENCES event_headers(row_id) ON DELETE CASCADE ) """.trimIndent(), ) - db.execSQL("CREATE UNIQUE INDEX event_vanish_key ON event_vanish (pubkey)") + db.execSQL("CREATE UNIQUE INDEX event_vanish_key ON event_vanish (pubkey_hash)") db.execSQL( """ @@ -48,8 +50,8 @@ class RightToVanishModule : IModule { -- Delete older records if this is the newest DELETE FROM event_vanish WHERE - event_vanish.created_at < NEW.created_at AND - event_vanish.pubkey = NEW.pubkey; + event_vanish.pubkey_hash = NEW.pubkey_hash AND + event_vanish.created_at < NEW.created_at; END; """.trimIndent(), ) @@ -61,8 +63,8 @@ class RightToVanishModule : IModule { FOR EACH ROW BEGIN DELETE FROM event_headers - WHERE created_at < NEW.created_at AND - pubkey = NEW.pubkey; + WHERE event_headers.created_at < NEW.created_at AND + event_headers.pubkey_owner_hash = NEW.pubkey_hash; END; """.trimIndent(), ) @@ -78,8 +80,8 @@ class RightToVanishModule : IModule { WHERE EXISTS ( SELECT 1 FROM event_vanish WHERE - event_vanish.created_at >= NEW.created_at AND - event_vanish.pubkey = NEW.pubkey + event_vanish.pubkey_hash = NEW.pubkey_owner_hash AND + event_vanish.created_at >= NEW.created_at ); END; """.trimIndent(), @@ -92,7 +94,7 @@ class RightToVanishModule : IModule { val insertRTV = """ - INSERT OR ROLLBACK INTO event_vanish (event_header_row_id, pubkey, created_at) + INSERT OR ROLLBACK INTO event_vanish (event_header_row_id, pubkey_hash, created_at) VALUES (?, ?, ?) """.trimIndent() @@ -105,7 +107,7 @@ class RightToVanishModule : IModule { if (event is RequestToVanishEvent && event.shouldVanishFrom(relayUrl)) { val stmt = db.compileStatement(insertRTV) stmt.bindLong(1, headerId) - stmt.bindString(2, event.pubKey) + stmt.bindLong(2, hasher(db).hash(event.pubKey)) stmt.bindLong(3, event.createdAt) stmt.executeInsert() } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt index 3c86d9271..c1e72a3bb 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SQLiteEventStore.kt @@ -30,30 +30,32 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.isEphemeral import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.store.IEventStore -import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventIndexesModule.IndexingStrategy import com.vitorpamplona.quartz.nip40Expiration.isExpired +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext class SQLiteEventStore( val context: Context, val dbName: String? = "events.db", val relayUrl: String? = null, - val tagIndexStrategy: IndexingStrategy = IndexingStrategy(), + val tagIndexStrategy: IndexingStrategy = DefaultIndexingStrategy(), ) : SQLiteOpenHelper(context, dbName, null, DATABASE_VERSION) { companion object { const val DATABASE_VERSION = 2 } val seedModule = SeedModule() + val fullTextSearchModule = FullTextSearchModule() - val eventIndexModule = EventIndexesModule(fullTextSearchModule, seedModule, tagIndexStrategy) + val eventIndexModule = EventIndexesModule(fullTextSearchModule, seedModule::hasher, tagIndexStrategy) val replaceableModule = ReplaceableModule() val addressableModule = AddressableModule() val ephemeralModule = EphemeralModule() - val deletionModule = DeletionRequestModule() + val deletionModule = DeletionRequestModule(seedModule::hasher) val expirationModule = ExpirationModule() - val rightToVanishModule = RightToVanishModule() + val rightToVanishModule = RightToVanishModule(seedModule::hasher) val modules = listOf( @@ -112,12 +114,28 @@ class SQLiteEventStore( modules.reversed().forEach { it.deleteAll(db) } } + suspend fun vacuum() { + // 1. ANALYZE: Collects statistics about tables and indices + // to help the query planner optimize queries. + withContext(Dispatchers.IO) { + writableDatabase.execSQL("VACUUM") + } + } + + suspend fun analyse() { + // 2. VACUUM: Rebuilds the database file, reclaiming unused space + // and reducing fragmentation. + withContext(Dispatchers.IO) { + writableDatabase.execSQL("ANALYZE") + } + } + private fun innerInsertEvent( event: Event, db: SQLiteDatabase, ) { val headerId = eventIndexModule.insert(event, db) - deletionModule.insert(event, headerId, db) + deletionModule.insert(event, db) expirationModule.insert(event, headerId, db) fullTextSearchModule.insert(event, headerId, db) rightToVanishModule.insert(event, relayUrl, headerId, db) diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt index ece45f6f1..7788213b7 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/SeedModule.kt @@ -76,4 +76,8 @@ class SeedModule : IModule { } override fun deleteAll(db: SQLiteDatabase) {} + + private var hasherCache: TagNameValueHasher? = null + + fun hasher(db: SQLiteDatabase): TagNameValueHasher = hasherCache ?: TagNameValueHasher(getSeed(db)).also { hasherCache = it } } diff --git a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt index 876f26d01..c8b73f1bf 100644 --- a/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt +++ b/quartz/src/androidMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqlite/TagNameValueHasher.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.quartz.nip01Core.store.sqlite +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.hints.bloom.MurmurHash3 /** @@ -32,6 +33,9 @@ class TagNameValueHasher( val hasher = MurmurHash3() // small performance improvements on inserting + val pTagHash by lazy { + hasher.hash128x64Half("p".encodeToByteArray(), seed) + } val eTagHash by lazy { hasher.hash128x64Half("e".encodeToByteArray(), seed) } @@ -56,4 +60,8 @@ class TagNameValueHasher( fun hashATag(value: String) = hasher.hash128x64Half(value.encodeToByteArray(), aTagHash) fun hashETag(value: String) = hasher.hash128x64Half(value.encodeToByteArray(), eTagHash) + + fun hashPTag(value: String) = hasher.hash128x64Half(value.encodeToByteArray(), pTagHash) + + fun hash(value: HexKey) = hasher.hash128x64Half(value.encodeToByteArray(), seed) } diff --git a/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqllite/EventDbQueryAssemblerTest.kt b/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqllite/EventDbQueryAssemblerTest.kt deleted file mode 100644 index 8575333c5..000000000 --- a/quartz/src/androidUnitTest/kotlin/com/vitorpamplona/quartz/nip01Core/store/sqllite/EventDbQueryAssemblerTest.kt +++ /dev/null @@ -1,135 +0,0 @@ -/** - * Copyright (c) 2025 Vitor Pamplona - * - * Permission is hereby granted, free of charge, to any person obtaining a copy of - * this software and associated documentation files (the "Software"), to deal in - * the Software without restriction, including without limitation the rights to use, - * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the - * Software, and to permit persons to whom the Software is furnished to do so, - * subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS - * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR - * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN - * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - */ -package com.vitorpamplona.quartz.nip01Core.store.sqlite - -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import junit.framework.TestCase -import kotlin.test.Test - -class EventDbQueryAssemblerTest { - val builder = EventIndexesModule(FullTextSearchModule(), SeedModule()) - val hasher = TagNameValueHasher(0L) - - val key1 = "7c5eb72a4584fdaaeaa145b25c92ea9917704224951219dbd43acef9e91fb88d" - val key2 = "f3ac434d61bc0f491a814782ccfdf9c439dae1f0bde9097ad4a245f4c495cd14" - val key3 = "12ae0fd81c85e1e7d9ed096397dc3129849425fe6f8afce7213ebf38ddfc6ca9" - - @Test - fun testEmpty() { - val sql = builder.planQuery(Filter(), hasher) - TestCase.assertEquals( - "SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers ORDER BY created_at DESC, id", - sql, - ) - } - - @Test - fun testLimit() { - val sql = builder.planQuery(Filter(limit = 10), hasher) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY created_at DESC, id ASC LIMIT 10) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - """.trimIndent(), - sql, - ) - } - - @Test - fun testAllFearures() { - val sql = - builder.planQuery( - listOf( - Filter(limit = 10), - Filter(authors = listOf(key1), kinds = listOf(1, 1111), search = "keywords", limit = 100), - Filter(kinds = listOf(20), search = "cats", limit = 30), - ), - hasher, - ) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE 1 = 1 ORDER BY created_at DESC, id ASC LIMIT 10 UNION SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.kind IN (?, ?)) AND (event_headers.pubkey = ?) AND (event_fts MATCH ?) ORDER BY created_at DESC, id ASC LIMIT 100 UNION SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.kind = ?) AND (event_fts MATCH ?) ORDER BY created_at DESC, id ASC LIMIT 30) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - """.trimIndent(), - sql, - ) - } - - @Test - fun testIdQuery() { - val sql = builder.planQuery(Filter(ids = listOf(key1)), hasher) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.id = ?) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - """.trimIndent(), - sql, - ) - } - - @Test - fun testAuthors() { - val sql = builder.planQuery(Filter(authors = listOf(key1, key2)), hasher) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers WHERE event_headers.pubkey IN (?, ?)) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - """.trimIndent(), - sql, - ) - } - - @Test - fun testAuthorsAndSearch() { - val sql = builder.planQuery(Filter(authors = listOf(key1, key2, key3), search = "keywords"), hasher) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.pubkey IN (?, ?, ?)) AND (event_fts MATCH ?)) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - """.trimIndent(), - sql, - ) - } - - @Test - fun testKindAndSearch() { - val sql = builder.planQuery(Filter(kinds = listOf(1, 1111, 10000), search = "keywords"), hasher) - TestCase.assertEquals( - """ - SELECT id, pubkey, created_at, kind, tags, content, sig FROM event_headers - INNER JOIN (SELECT event_headers.row_id as row_id FROM event_headers INNER JOIN event_fts ON event_fts.event_header_row_id = event_headers.row_id WHERE (event_headers.kind IN (?, ?, ?)) AND (event_fts MATCH ?)) AS filtered - ON event_headers.row_id = filtered.row_id - ORDER BY created_at DESC, id - """.trimIndent(), - sql, - ) - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt new file mode 100644 index 000000000..f0b4a6131 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTarget.kt @@ -0,0 +1,26 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.experimental.nipA3 + +class PaymentTarget( + val type: String, + val authority: String, +) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetTag.kt new file mode 100644 index 000000000..422b8e494 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetTag.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.experimental.nipA3 + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +class PaymentTargetTag { + companion object { + const val TAG_NAME = "payto" + + 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): PaymentTarget? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[2].isNotEmpty()) { return null } + + val paymentTarget = PaymentTarget(tag[1], tag[2]) + + return paymentTarget + } + + fun assemble(paymentTarget: PaymentTarget) = arrayOf(TAG_NAME, paymentTarget.type, paymentTarget.authority) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.kt new file mode 100644 index 000000000..204e57f87 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/experimental/nipA3/PaymentTargetsEvent.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.experimental.nipA3 + +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.utils.TimeUtils + +class PaymentTargetsEvent( + 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 = 10133 + + suspend fun updatePaymentTargets( + earlierVersion: PaymentTargetsEvent, + targets: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PaymentTargetsEvent { + val tags = + earlierVersion.tags + .filter(PaymentTargetTag::notMatch) + .plus(targets.map { PaymentTargetTag.assemble(it) }) + .toTypedArray() + + return signer.sign(createdAt, KIND, tags, earlierVersion.content) + } + + fun createPaymentTargets(targets: List) = targets.map { PaymentTargetTag.assemble(it) }.toTypedArray() + + suspend fun create( + targets: List, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): PaymentTargetsEvent = signer.sign(createdAt, KIND, createPaymentTargets(targets), "") + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt index eae01d726..e59fd24a4 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt @@ -29,6 +29,8 @@ fun ByteArray.toHexKey(): HexKey = Hex.encode(this) fun HexKey.hexToByteArray(): ByteArray = Hex.decode(this) +fun HexKey.hexToByteArrayOrNull(): ByteArray? = if (Hex.isHex(this)) Hex.decode(this) else null + const val PUBKEY_LENGTH = 64 const val EVENT_ID_LENGTH = 64 diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/Merkle.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/Merkle.kt index 4d96a22d7..378b42f40 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/Merkle.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/Merkle.kt @@ -47,7 +47,7 @@ object Merkle { // left.ops[OpAppend(right.msg)] = right_prepend_stamp // leftAppendStamp = left.ops.add(OpAppend(right.msg)) // Timestamp leftPrependStamp = left.add(new OpAppend(right.msg)); - left.ops.put(OpAppend(right.digest), rightPrependStamp!!) + left.ops[OpAppend(right.digest)] = rightPrependStamp // return rightPrependStamp.ops.add(unaryOpCls()) val res = rightPrependStamp.add(OpSHA256()) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/Timestamp.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/Timestamp.kt index b44e59a73..92c611a8f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/Timestamp.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip03Timestamp/ots/Timestamp.kt @@ -453,12 +453,12 @@ class Timestamp( fun allTips(): MutableSet { val set: MutableSet = HashSet() - if (this.ops.size == 0) { + if (this.ops.isEmpty()) { set.add(this.digest) } for (entry in this.ops.entries) { - val ts: Timestamp = entry.value!! + val ts: Timestamp = entry.value // Op op = entry.getKey(); val subSet = ts.allTips() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/tags/Positional.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/tags/Positional.kt index 91884e339..0f68990b8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/tags/Positional.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip10Notes/tags/Positional.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip10Notes.tags import com.vitorpamplona.quartz.nip01Core.tags.events.ETag +import com.vitorpamplona.quartz.nip01Core.tags.events.GenericETag /** * Returns a list of NIP-10 marked tags that are also ordered at best effort to support the @@ -32,47 +33,25 @@ import com.vitorpamplona.quartz.nip01Core.tags.events.ETag * The tag to the root of the reply chain goes first. The tag to the reply event being responded * to goes last. The order for any other tag does not matter, so keep the relative order. */ -fun List.positionalMarkedTags( - tagName: String, - root: String?, - replyingTo: String?, - forkedFrom: String?, -) = sortedWith { o1, o2 -> - when { - o1 == o2 -> 0 - o1 == root -> -1 // root goes first - o2 == root -> 1 // root goes first - o1 == replyingTo -> 1 // reply event being responded to goes last - o2 == replyingTo -> -1 // reply event being responded to goes last - else -> 0 // keep the relative order for any other tag - } -}.map { - when (it) { - root -> arrayOf(tagName, it, "", MarkedETag.MARKER.ROOT) - replyingTo -> arrayOf(tagName, it, "", MarkedETag.MARKER.REPLY) - forkedFrom -> arrayOf(tagName, it, "", MarkedETag.MARKER.FORK) - else -> arrayOf(tagName, it) - } -} - fun List.positionalMarkedTags( root: ETag?, replyingTo: ETag?, forkedFrom: ETag?, -) = sortedWith { o1, o2 -> - when { - o1.eventId == o2.eventId -> 0 - o1.eventId == root?.eventId -> -1 // root goes first - o2.eventId == root?.eventId -> 1 // root goes first - o1.eventId == replyingTo?.eventId -> 1 // reply event being responded to goes last - o2.eventId == replyingTo?.eventId -> -1 // reply event being responded to goes last - else -> 0 // keep the relative order for any other tag +): List = + sortedWith { o1, o2 -> + when { + o1.eventId == o2.eventId -> 0 + o1.eventId == root?.eventId -> -1 // root goes first + o2.eventId == root?.eventId -> 1 // root goes first + o1.eventId == replyingTo?.eventId -> 1 // reply event being responded to goes last + o2.eventId == replyingTo?.eventId -> -1 // reply event being responded to goes last + else -> 0 // keep the relative order for any other tag + } + }.map { + when (it.eventId) { + root?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.ROOT, it.author) + replyingTo?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.REPLY, it.author) + forkedFrom?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.FORK, it.author) + else -> it + } } -}.map { - when (it.eventId) { - root?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.ROOT, it.author) - replyingTo?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.REPLY, it.author) - forkedFrom?.eventId -> MarkedETag(it.eventId, it.relay, MarkedETag.MARKER.FORK, it.author) - else -> it - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt index b47e875ce..0e2d3da7c 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/BaseDMGroupEvent.kt @@ -69,7 +69,7 @@ open class BaseDMGroupEvent( return result } - override fun isIncluded(pubKey: HexKey) = pubKey == this.pubKey || tags.any(PTag::isTagged, pubKey) + override fun isIncluded(user: HexKey) = pubKey == this.pubKey || tags.any(PTag::isTagged, pubKey) override fun groupMembers() = recipientsPubKey().plus(pubKey).toSet() diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt index 0beca6658..6f3a49d68 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip17Dm/base/NIP17Group.kt @@ -23,7 +23,7 @@ package com.vitorpamplona.quartz.nip17Dm.base import com.vitorpamplona.quartz.nip01Core.core.HexKey interface NIP17Group { - fun isIncluded(pubKey: HexKey): Boolean + fun isIncluded(user: HexKey): Boolean fun groupMembers(): Set } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt index 02ead09a9..9f6ffd5f9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip28PublicChat/admin/ChannelMetadataEvent.kt @@ -70,7 +70,7 @@ class ChannelMetadataEvent( if (content.isEmpty() || !content.startsWith("{") || isEncrypted()) { ChannelDataNorm() } else { - ChannelData.parse(content).normalize() ?: ChannelDataNorm() + ChannelData.parse(content).normalize() } } catch (e: Exception) { if (e is CancellationException) throw e diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt index b408fd19b..de404e7a8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip59Giftwrap/wraps/GiftWrapEvent.kt @@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.firstTagValue import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner -import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip21UriScheme.toNostrUri import com.vitorpamplona.quartz.nip40Expiration.ExpirationTag @@ -104,13 +104,13 @@ class GiftWrapEvent( const val KIND = 1059 const val ALT = "Encrypted event" - suspend fun create( + fun create( event: Event, recipientPubKey: HexKey, expirationDelta: Long? = null, createdAt: Long = TimeUtils.randomWithTwoDays(), ): GiftWrapEvent { - val signer = NostrSignerInternal(KeyPair()) // GiftWrap is always a random key + val signer = NostrSignerSync(KeyPair()) // GiftWrap is always a random key val tags = expirationDelta?.let { diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt index 2b064deb3..dfb3158d0 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nipA0VoiceMessages/VoiceReplyEvent.kt @@ -65,12 +65,12 @@ class VoiceReplyEvent( hash: String, duration: Int, waveform: List, - replyingTo: EventHintBundle, + replyingTo: EventHintBundle, ) = build(AudioMeta(url, mimeType, hash, duration, waveform), replyingTo) fun build( voiceMessage: AudioMeta, - replyingTo: EventHintBundle, + replyingTo: EventHintBundle, createdAt: Long = TimeUtils.now(), initializer: TagArrayBuilder.() -> Unit = {}, ) = build(voiceMessage, KIND, ALT_DESCRIPTION, createdAt) { 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 43e304b18..ef11c9d3f 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -31,6 +31,7 @@ import com.vitorpamplona.quartz.experimental.interactiveStories.InteractiveStory 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.nns.NNSEvent import com.vitorpamplona.quartz.experimental.profileGallery.ProfileGalleryEntryEvent import com.vitorpamplona.quartz.experimental.publicMessages.PublicMessageEvent @@ -272,6 +273,7 @@ class EventFactory { NIP90UserDiscoveryRequestEvent.KIND -> NIP90UserDiscoveryRequestEvent(id, pubKey, createdAt, tags, content, sig) NIP90UserDiscoveryResponseEvent.KIND -> NIP90UserDiscoveryResponseEvent(id, pubKey, createdAt, tags, content, sig) OtsEvent.KIND -> OtsEvent(id, pubKey, createdAt, tags, content, sig) + PaymentTargetsEvent.KIND -> PaymentTargetsEvent(id, pubKey, createdAt, tags, content, sig) PeopleListEvent.KIND -> PeopleListEvent(id, pubKey, createdAt, tags, content, sig) PictureEvent.KIND -> PictureEvent(id, pubKey, createdAt, tags, content, sig) PinListEvent.KIND -> PinListEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializerTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializerTest.kt index b082372ba..d061ebd8f 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializerTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/jackson/EventDeserializerTest.kt @@ -57,7 +57,7 @@ class EventDeserializerTest { val templateJson = """{"kind":1,"content":"This is an unsigned event.","created_at":1234,"tags":[]}""" val template = EventTemplate.fromJson(templateJson) - val signedEvent = signer.signerSync.sign(template)!! + val signedEvent = signer.signerSync.sign(template) assertTrue(signedEvent.id.isNotEmpty()) assertTrue(signedEvent.sig.isNotEmpty()) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt index 63198581c..58eb50846 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip19Bech32/NIP19ParserTest.kt @@ -107,8 +107,8 @@ class NIP19ParserTest { assertNotNull(actual) assertTrue(actual?.entity is NProfile) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", (actual?.entity as? NProfile)?.hex) - assertEquals(NormalizedRelayUrl("wss://vitor.nostr1.com/"), (actual?.entity as? NProfile)?.relay?.first()) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", actual.entity.hex) + assertEquals(NormalizedRelayUrl("wss://vitor.nostr1.com/"), actual.entity.relay?.first()) } @Test() @@ -116,7 +116,7 @@ class NIP19ParserTest { val actual = Nip19Parser.uriToRoute("nostr:nprofile1qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7qgwwaehxw309ahx7uewd3hkctcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uqzq9thu3vem5gvsc6f3l3uyz7c92h6lq56t9wws0zulzkrgc6nrvym5jfztf") assertTrue(actual?.entity is NProfile) - assertEquals("1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b", (actual?.entity as? NProfile)?.hex) + assertEquals("1577e4599dd10c863498fe3c20bd82aafaf829a595ce83c5cf8ac3463531b09b", actual.entity.hex) } @Test() @@ -124,7 +124,7 @@ class NIP19ParserTest { val actual = Nip19Parser.uriToRoute("nostr:nevent1qy2hwumn8ghj7un9d3shjtnyv9kh2uewd9hj7qgwwaehxw309ahx7uewd3hkctcpr9mhxue69uhhyetvv9ujuumwdae8gtnnda3kjctv9uq36amnwvaz7tmjv4kxz7fwvd5xjcmpvahhqmr9vfejucm0d5hsz9mhwden5te0wfjkccte9ec8y6tdv9kzumn9wshsz8thwden5te0dehhxarj9ekh2arfdeuhwctvd3jhgtnrdakj7qg3waehxw309ucngvpwvcmh5tnfduhszythwden5te0dehhxarj9emkjmn99uq3jamnwvaz7tmhv4kxxmmdv5hxummnw3ezuamfdejj7qpqvsup5xk3e2quedxjvn2gjppc0lqny5dmnr2ypc9tftwmdxta0yjqrd6n50") assertTrue(actual?.entity is NEvent) - assertEquals("64381a1ad1ca81ccb4d264d48904387fc13251bb98d440e0ab4addb6997d7924", (actual?.entity as? NEvent)?.hex) + assertEquals("64381a1ad1ca81ccb4d264d48904387fc13251bb98d440e0ab4addb6997d7924", actual.entity.hex) } @Test() @@ -132,7 +132,7 @@ class NIP19ParserTest { val actual = Nip19Parser.uriToRoute("nostr:naddr1qqyxzmt9w358jum5qyt8wumn8ghj7un9d3shjtnwdaehgu3wvfskueqzypd7v3r24z33cydnk3fmlrd0exe5dlej3506zxs05q4puerp765mzqcyqqq8scsq6mk7u") assertTrue(actual?.entity is NAddress) - assertEquals("30818:5be6446aa8a31c11b3b453bf8dafc9b346ff328d1fa11a0fa02a1e6461f6a9b1:amethyst", (actual?.entity as? NAddress)?.aTag()) + assertEquals("30818:5be6446aa8a31c11b3b453bf8dafc9b346ff328d1fa11a0fa02a1e6461f6a9b1:amethyst", actual.entity.aTag()) } @Test @@ -141,9 +141,10 @@ class NIP19ParserTest { Nip19Parser.uriToRoute( "nostr:naddr1qqqqygzxpsj7dqha57pjk5k37gkn6g4nzakewtmqmnwryyhd3jfwlpgxtspsgqqqw4rs3xyxus", ) + assertNotNull(result) assertEquals( "30023:460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c:", - (result?.entity as? NAddress)?.aTag(), + (result.entity as? NAddress)?.aTag(), ) } @@ -153,9 +154,10 @@ class NIP19ParserTest { Nip19Parser.uriToRoute( "nostr:naddr1qqqqzxthwden5te0wfjkccte9ejxjanfdejjuanfv3jk7tczyrv4428upmlcujyf2fy4hqrynywj07ukakr99ufvmmw95n5tttj5qqcyqqqgt0qeresua", ) + assertNotNull(result) assertEquals( "34236:d95aa8fc0eff8e488952495b8064991d27fb96ed8652f12cdedc5a4e8b5ae540:", - (result?.entity as? NAddress)?.aTag(), + (result.entity as? NAddress)?.aTag(), ) } @@ -165,9 +167,10 @@ class NIP19ParserTest { Nip19Parser.uriToRoute( "nostr:naddr1qq8kwatfv3jj6amfwfjkwatpwfjqygxsm6lelvfda7qlg0tud9pfhduysy4vrexj65azqtdk4tr75j6xdspsgqqqw4rsg32ag8", ) + assertNotNull(result) assertEquals( "30023:d0debf9fb12def81f43d7c69429bb784812ac1e4d2d53a202db6aac7ea4b466c:guide-wireguard", - (result?.entity as? NAddress)?.aTag(), + (result.entity as? NAddress)?.aTag(), ) } @@ -177,12 +180,13 @@ class NIP19ParserTest { Nip19Parser.uriToRoute( "naddr1qqyrswtyv5mnjv3sqy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsygx3uczxts4hwue9ayfn7ggq62anzstde2qs749pm9tx2csuthhpjvpsgqqqw4rs8pmj38", ) - assertTrue(result?.entity is NAddress) + assertNotNull(result) + assertTrue(result.entity is NAddress) assertEquals( "30023:d1e60465c2b777325e9133f2100d2bb31416dca810f54a1d95665621c5dee193:89de7920", - (result?.entity as? NAddress)?.aTag(), + result.entity.aTag(), ) - assertEquals(NormalizedRelayUrl("wss://relay.damus.io/"), (result?.entity as? NAddress)?.relay?.get(0)) + assertEquals(NormalizedRelayUrl("wss://relay.damus.io/"), result.entity.relay[0]) } @Test @@ -261,11 +265,11 @@ class NIP19ParserTest { assertNotNull(result) assertEquals( "31337:27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402:xx1xulrf7wdbdlbc31far", - result?.aTag(), + result.aTag(), ) - assertEquals(true, result?.relay?.isEmpty()) - assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result?.author) - assertEquals(31337, result?.kind) + assertEquals(true, result.relay?.isEmpty()) + assertEquals("27241bb702d145a975260cfedee6265936dcd939eaecb88ea0e4071752c30402", result.author) + assertEquals(31337, result.kind) } @Test @@ -279,11 +283,11 @@ class NIP19ParserTest { assertNotNull(result) assertEquals( "30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:613f014d2911fb9df52e048aae70268c0d216790287b5814910e1e781e8e0509", - result?.aTag(), + result.aTag(), ) - assertEquals(true, result?.relay?.isEmpty()) - assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) - assertEquals(30023, result?.kind) + assertEquals(true, result.relay?.isEmpty()) + assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author) + assertEquals(30023, result.kind) } @Test @@ -297,11 +301,11 @@ class NIP19ParserTest { assertNotNull(result) assertEquals( "30023:46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d:1679509418", - result?.aTag(), + result.aTag(), ) - assertEquals(true, result?.relay?.isEmpty()) - assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result?.author) - assertEquals(30023, result?.kind) + assertEquals(true, result.relay?.isEmpty()) + assertEquals("46fcbe3065eaf1ae7811465924e48923363ff3f526bd6f73d7c184b16bd8ce4d", result.author) + assertEquals(30023, result.kind) } @Test @@ -310,10 +314,10 @@ class NIP19ParserTest { Nip19Parser.uriToRoute("nostr:nevent1qqsdw6xpk28tjnrajz4xhy2jqg0md8ywxj6997rsutjzxs0207tedjspz4mhxue69uhhyetvv9ujumn0wd68ytnzvuhsygx2crjrydvqdksffurc0fdsfc566pxtrg78afw0v8kursecwdqg9vpsgqqqqqqsnknas6")?.entity as? NEvent assertNotNull(result) - assertEquals("d768c1b28eb94c7d90aa6b9152021fb69c8e34b452f870e2e42341ea7f9796ca", result?.hex) - assertEquals("wss://relay.nostr.bg/", result?.relay?.firstOrNull()?.url) - assertEquals("cac0e43235806da094f0787a5b04e29ad04cb1a3c7ea5cf61edc1c338734082b", result?.author) - assertEquals(1, result?.kind) + assertEquals("d768c1b28eb94c7d90aa6b9152021fb69c8e34b452f870e2e42341ea7f9796ca", result.hex) + assertEquals("wss://relay.nostr.bg/", result.relay.firstOrNull()?.url) + assertEquals("cac0e43235806da094f0787a5b04e29ad04cb1a3c7ea5cf61edc1c338734082b", result.author) + assertEquals(1, result.kind) } @Test @@ -322,10 +326,10 @@ class NIP19ParserTest { Nip19Parser.uriToRoute("nostr:nevent1qqs0tsw8hjacs4fppgdg7f5yhgwwfkyua4xcs3re9wwkpkk2qeu6mhql22rcy")?.entity as? NEvent assertNotNull(result) - assertEquals("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", result?.hex) - assertEquals(true, result?.relay?.isEmpty()) - assertEquals(null, result?.author) - assertEquals(null, result?.kind) + assertEquals("f5c1c7bcbb8855210a1a8f2684ba1ce4d89ced4d8844792b9d60daca0679addc", result.hex) + assertEquals(true, result.relay.isEmpty()) + assertEquals(null, result.author) + assertEquals(null, result.kind) } @Test @@ -334,10 +338,10 @@ class NIP19ParserTest { Nip19Parser.uriToRoute("nostr:nevent1qqsfvaa2w3nkw472lt2ezr6x5x347k8hht398vp7hrl6wrdjldry86sprfmhxue69uhhyetvv9ujuam9wd6x2unwvf6xxtnrdaks5myyah")?.entity as? NEvent assertNotNull(result) - assertEquals("9677aa74676757cafad5910f46a1a35f58f7bae253b03eb8ffa70db2fb4643ea", result?.hex) - assertEquals("wss://relay.westernbtc.com/", result?.relay?.firstOrNull()?.url) - assertEquals(null, result?.author) - assertEquals(null, result?.kind) + assertEquals("9677aa74676757cafad5910f46a1a35f58f7bae253b03eb8ffa70db2fb4643ea", result.hex) + assertEquals("wss://relay.westernbtc.com/", result.relay.firstOrNull()?.url) + assertEquals(null, result.author) + assertEquals(null, result.kind) } @Test @@ -349,13 +353,13 @@ class NIP19ParserTest { )?.entity as? NEvent assertNotNull(result) - assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result?.hex) + assertEquals("b60ffa7256d3dd7543d830eb717ae50d05a6c32c5f791ed15b867c2bb0b954ac", result.hex) assertEquals( NormalizedRelayUrl("wss://nostr.mom/"), - result?.relay?.get(0), + result.relay?.get(0), ) - assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result?.author) - assertEquals(1, result?.kind) + assertEquals("f8ff11c7a7d3478355d3b4d174e5a473797a906ea4aa61aa9b6bc0652c1ea17a", result.author) + assertEquals(1, result.kind) } @Test @@ -367,10 +371,10 @@ class NIP19ParserTest { )?.entity as? NEvent assertNotNull(result) - assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result?.hex) - assertEquals(NormalizedRelayUrl("wss://relay.damus.io/"), result.relay.get(0)) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) - assertEquals(1, result?.kind) + assertEquals("1f878e82063d80f41a781d3a2ef7bc336f1beb7942bf3b49b42aee1251eb5cf0", result.hex) + assertEquals(NormalizedRelayUrl("wss://relay.damus.io/"), result.relay[0]) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result.author) + assertEquals(1, result.kind) } @Test @@ -382,10 +386,10 @@ class NIP19ParserTest { )?.entity as? NEvent assertNotNull(result) - assertEquals("8d2338bb62db88d13cea23f55f27c4886f68cf777dac42f2b65e6f709a5e6926", result?.hex) + assertEquals("8d2338bb62db88d13cea23f55f27c4886f68cf777dac42f2b65e6f709a5e6926", result.hex) assertEquals( "wss://nos.lol/,wss://nostr.oxtr.dev/,wss://relay.nostr.bg/,wss://nostr.einundzwanzig.space/,wss://relay.nostr.band/,wss://relay.damus.io/", - result?.relay?.joinToString(",") { it.url }, + result.relay.joinToString(",") { it.url }, ) } @@ -398,10 +402,10 @@ class NIP19ParserTest { )?.entity as? NEvent assertNotNull(result) - assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result?.hex) - assertEquals("wss://relay.damus.io/", result?.relay?.get(0)?.url) - assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result?.author) - assertEquals(1, result?.kind) + assertEquals("4300ec7fa2f98a276f033908349651620aa8e282b76030ab22abca63e85e07e6", result.hex) + assertEquals("wss://relay.damus.io/", result.relay.get(0)?.url) + assertEquals("460c25e682fda7832b52d1f22d3d22b3176d972f60dcdc3212ed8c92ef85065c", result.author) + assertEquals(1, result.kind) } @Test diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt index fe6c4f7a9..32d82c516 100644 --- a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/BunkerRequestTest.kt @@ -22,6 +22,7 @@ package com.vitorpamplona.quartz.nip46RemoteSigner import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import kotlin.test.Test +import kotlin.test.assertEquals import kotlin.test.assertTrue class BunkerRequestTest { @@ -31,6 +32,6 @@ class BunkerRequestTest { val bunkerRequest = OptimizedJsonMapper.fromJsonTo(requestJson) assertTrue(bunkerRequest is BunkerRequestSign) - assertTrue((bunkerRequest as BunkerRequestSign).event.kind == 1) + assertEquals(1, bunkerRequest.event.kind) } } diff --git a/settings.gradle b/settings.gradle index 0a0b52faa..cad6e3772 100644 --- a/settings.gradle +++ b/settings.gradle @@ -34,3 +34,4 @@ include ':benchmark' include ':quartz' include ':commons' include ':ammolite' +include ':desktopApp'