From 82f8bc62a0b4e2388030db30d5c4d71c3cf9842f Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 26 Dec 2025 07:51:43 +0200 Subject: [PATCH 01/10] feat: Add Compose Multiplatform Desktop support foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add desktopApp module with JVM entry point and sidebar navigation - Add Claude specs for AI-assisted development: - Agent definitions: nostr-protocol, kotlin-multiplatform, compose-ui, kotlin-coroutines - Skills: quartz-kmp conversion, compose-desktop patterns - Commands: desktop-run, nip, extract - Update Gradle configuration with Compose Multiplatform 1.7.1 plugin - Add coroutines and secp256k1 JVM dependencies to version catalog Next steps: - Convert Quartz library to full KMP (expect/actual for crypto) - Implement relay connections in desktop app - Share UI components between Android and Desktop 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/CLAUDE.md | 120 +++++++ .claude/agents/compose-ui.md | 243 +++++++++++++ .claude/agents/kotlin-coroutines.md | 284 +++++++++++++++ .claude/agents/kotlin-multiplatform.md | 161 +++++++++ .claude/agents/nostr-protocol.md | 102 ++++++ .claude/commands/desktop-run.md | 45 +++ .claude/commands/extract.md | 50 +++ .claude/commands/nip.md | 32 ++ .claude/skills/compose-desktop.md | 339 ++++++++++++++++++ .claude/skills/quartz-kmp.md | 165 +++++++++ build.gradle | 3 +- desktopApp/build.gradle.kts | 59 +++ .../vitorpamplona/amethyst/desktop/Main.kt | 306 ++++++++++++++++ desktopApp/src/jvmMain/resources/icon.png | Bin 0 -> 7016 bytes gradle/libs.versions.toml | 5 +- settings.gradle | 1 + 16 files changed, 1913 insertions(+), 2 deletions(-) create mode 100644 .claude/CLAUDE.md create mode 100644 .claude/agents/compose-ui.md create mode 100644 .claude/agents/kotlin-coroutines.md create mode 100644 .claude/agents/kotlin-multiplatform.md create mode 100644 .claude/agents/nostr-protocol.md create mode 100644 .claude/commands/desktop-run.md create mode 100644 .claude/commands/extract.md create mode 100644 .claude/commands/nip.md create mode 100644 .claude/skills/compose-desktop.md create mode 100644 .claude/skills/quartz-kmp.md create mode 100644 desktopApp/build.gradle.kts create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt create mode 100644 desktopApp/src/jvmMain/resources/icon.png diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md new file mode 100644 index 0000000000..44e046f046 --- /dev/null +++ b/.claude/CLAUDE.md @@ -0,0 +1,120 @@ +# 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 (converted to multiplatform) +│ └── src/ +│ ├── commonMain/ # Shared Nostr protocol +│ ├── androidMain/ # Android-specific (crypto, storage) +│ └── jvmMain/ # Desktop JVM-specific +├── desktopApp/ # Desktop JVM application +├── amethyst/ # Android app (existing) +├── ammolite/ # Support module +└── commons/ # Utilities +``` + +## 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 + +## Development Workflow + +```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 0000000000..2a8b837d85 --- /dev/null +++ b/.claude/agents/compose-ui.md @@ -0,0 +1,243 @@ +# Compose Multiplatform UI Agent + +## Expertise Domain + +This agent specializes in Compose Multiplatform for building declarative UIs that share code across Android and Desktop JVM. + +## Platform Support + +| Platform | Status | Notes | +|----------|--------|-------| +| Android | Stable | Jetpack Compose | +| Desktop (JVM) | Stable | Windows, macOS, Linux | +| iOS | Beta | Future consideration | +| Web (Wasm) | Alpha | Future consideration | + +## Core Knowledge Areas + +### Shared Composables (commonMain) +```kotlin +@Composable +fun NoteCard( + note: Note, + onReply: () -> Unit, + onRepost: () -> Unit, + onZap: () -> Unit, + modifier: Modifier = Modifier +) { + Card(modifier = modifier) { + Column(Modifier.padding(16.dp)) { + AuthorRow(note.author) + Spacer(Modifier.height(8.dp)) + Text(note.content) + Spacer(Modifier.height(8.dp)) + ActionRow(onReply, onRepost, onZap) + } + } +} +``` + +### Desktop-Specific Features +```kotlin +// Window management +fun main() = application { + Window( + onCloseRequest = ::exitApplication, + state = rememberWindowState( + width = 1200.dp, + height = 800.dp, + position = WindowPosition.Aligned(Alignment.Center) + ), + title = "Amethyst Desktop" + ) { + App() + } +} + +// Menu bar +MenuBar { + Menu("File") { + Item("New Note", onClick = { }, shortcut = KeyShortcut(Key.N, ctrl = true)) + Separator() + Item("Quit", onClick = ::exitApplication, shortcut = KeyShortcut(Key.Q, ctrl = true)) + } + Menu("View") { + Item("Feed", onClick = { navigateTo(Screen.Feed) }) + Item("Messages", onClick = { navigateTo(Screen.Messages) }) + } +} + +// System tray +Tray( + icon = painterResource("icon.png"), + menu = { + Item("Show", onClick = { windowVisible = true }) + Item("Exit", onClick = ::exitApplication) + } +) + +// Keyboard shortcuts +Modifier.onKeyEvent { event -> + when { + event.isCtrlPressed && event.key == Key.Enter -> { + sendNote() + true + } + event.key == Key.Escape -> { + closeDialog() + true + } + else -> false + } +} + +// Context menus +ContextMenuArea(items = { + listOf( + ContextMenuItem("Copy") { copyToClipboard(note.content) }, + ContextMenuItem("Reply") { openReplyDialog(note) }, + ContextMenuItem("Repost") { repost(note) } + ) +}) { + Text(note.content) +} +``` + +### Navigation Patterns +```kotlin +// Sidebar + Content pattern for Desktop +@Composable +fun DesktopLayout(currentScreen: Screen, onNavigate: (Screen) -> Unit) { + Row(Modifier.fillMaxSize()) { + // Sidebar + NavigationRail { + NavigationRailItem( + icon = { Icon(Icons.Default.Home, "Feed") }, + label = { Text("Feed") }, + selected = currentScreen == Screen.Feed, + onClick = { onNavigate(Screen.Feed) } + ) + NavigationRailItem( + icon = { Icon(Icons.Default.Message, "Messages") }, + label = { Text("Messages") }, + selected = currentScreen == Screen.Messages, + onClick = { onNavigate(Screen.Messages) } + ) + NavigationRailItem( + icon = { Icon(Icons.Default.Person, "Profile") }, + label = { Text("Profile") }, + selected = currentScreen == Screen.Profile, + onClick = { onNavigate(Screen.Profile) } + ) + } + + // Content + Box(Modifier.weight(1f)) { + when (currentScreen) { + Screen.Feed -> FeedScreen() + Screen.Messages -> MessagesScreen() + Screen.Profile -> ProfileScreen() + } + } + } +} +``` + +### State Management +```kotlin +// StateFlow for shared state +class FeedViewModel(private val repository: FeedRepository) { + private val _notes = MutableStateFlow>(emptyList()) + val notes: StateFlow> = _notes.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + fun loadFeed() { + viewModelScope.launch { + _isLoading.value = true + repository.getFeed() + .catch { /* handle error */ } + .collect { _notes.value = it } + _isLoading.value = false + } + } +} + +// In Composable +@Composable +fun FeedScreen(viewModel: FeedViewModel) { + val notes by viewModel.notes.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + + Box(Modifier.fillMaxSize()) { + LazyColumn { + items(notes, key = { it.id }) { note -> + NoteCard(note) + } + } + if (isLoading) { + CircularProgressIndicator(Modifier.align(Alignment.Center)) + } + } +} +``` + +## Agent Capabilities + +1. **Composable Design** + - Create shareable UI components + - Implement Material3 design + - Handle responsive layouts + +2. **Desktop UI Patterns** + - Window management (size, position, multi-window) + - Menu bars and context menus + - System tray integration + - Keyboard shortcuts + - Native file dialogs + +3. **Navigation Architecture** + - Screen-based navigation + - Platform-specific shells (sidebar vs bottom nav) + - Deep linking patterns + +4. **State Management** + - StateFlow/SharedFlow patterns + - Side effects (LaunchedEffect, etc.) + - ViewModel integration + +## Android vs Desktop Differences + +| Aspect | Android | Desktop | +|--------|---------|---------| +| **Entry** | Activity | main() + Window | +| **Navigation** | Bottom nav / Drawer | Sidebar / MenuBar | +| **Input** | Touch | Mouse + Keyboard | +| **Windows** | Single | Multi-window | +| **Menus** | Overflow menu | MenuBar | +| **Files** | SAF / MediaStore | JFileChooser | +| **Notifications** | System notifications | Tray notifications | +| **Clipboard** | ClipboardManager | Toolkit.clipboard | + +## Scope Boundaries + +### In Scope +- Composable architecture and design +- UI component patterns +- Desktop-specific features (menus, tray, keyboard) +- Navigation patterns +- State → UI binding +- Theming and styling +- Responsive layouts + +### Out of Scope +- KMP project setup (use kotlin-multiplatform agent) +- Async data loading (use kotlin-coroutines agent) +- Nostr protocol details (use nostr-protocol agent) +- Business logic implementation + +## Key References +- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) +- [Desktop Tutorials](https://github.com/JetBrains/compose-multiplatform/tree/master/tutorials) +- [Material3 Components](https://m3.material.io/components) diff --git a/.claude/agents/kotlin-coroutines.md b/.claude/agents/kotlin-coroutines.md new file mode 100644 index 0000000000..2f56b1f391 --- /dev/null +++ b/.claude/agents/kotlin-coroutines.md @@ -0,0 +1,284 @@ +# Kotlin Coroutines Agent + +## Expertise Domain + +This agent specializes in Kotlin coroutines and the kotlinx.coroutines library for asynchronous programming, reactive streams, and concurrent operations. + +## Core Knowledge Areas + +### Coroutine Fundamentals +```kotlin +// Suspending functions +suspend fun fetchNote(id: String): Note { + return withContext(Dispatchers.IO) { + api.getNote(id) + } +} + +// Coroutine builders +fun main() = runBlocking { + // launch: fire-and-forget, returns Job + val job = launch { + delay(1000) + println("World") + } + + // async: returns Deferred + val deferred = async { + computeValue() + } + val result = deferred.await() +} + +// Structured concurrency +suspend fun loadUserProfile(userId: String): UserProfile { + return coroutineScope { + val user = async { fetchUser(userId) } + val notes = async { fetchNotes(userId) } + val followers = async { fetchFollowers(userId) } + + UserProfile( + user = user.await(), + notes = notes.await(), + followers = followers.await() + ) + } // All complete or all cancel together +} +``` + +### Dispatchers + +| Dispatcher | Use Case | Notes | +|------------|----------|-------| +| `Dispatchers.Main` | UI updates | Main thread (Android/Desktop) | +| `Dispatchers.IO` | Network, disk | Optimized for blocking I/O | +| `Dispatchers.Default` | CPU-intensive | Parallelism = CPU cores | +| `Dispatchers.Unconfined` | Testing only | Runs in caller's thread | + +### Flow (Cold Streams) +```kotlin +// Creating flows +fun observeNotes(): Flow> = flow { + while (true) { + val notes = repository.getNotes() + emit(notes) + delay(30_000) // Refresh every 30s + } +} + +// Operators +repository.observeNotes() + .map { notes -> notes.filter { it.isVisible } } + .distinctUntilChanged() + .debounce(300) + .catch { e -> + log.error("Failed to load notes", e) + emit(emptyList()) + } + .flowOn(Dispatchers.IO) + .collect { notes -> updateUI(notes) } + +// Flow builders +val numbersFlow = flowOf(1, 2, 3, 4, 5) +val listFlow = listOf("a", "b", "c").asFlow() +``` + +### StateFlow & SharedFlow (Hot Streams) +```kotlin +// StateFlow - always has a value, replays latest +class FeedViewModel { + private val _state = MutableStateFlow(FeedState()) + val state: StateFlow = _state.asStateFlow() + + fun updateFilter(filter: Filter) { + _state.update { current -> + current.copy(filter = filter) + } + } +} + +// SharedFlow - no initial value, configurable replay +class EventBus { + private val _events = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST + ) + val events: SharedFlow = _events.asSharedFlow() + + suspend fun emit(event: AppEvent) { + _events.emit(event) + } +} +``` + +### Channels +```kotlin +// Producer-consumer pattern +val channel = Channel(Channel.BUFFERED) + +// Producer +launch { + for (event in eventSource) { + channel.send(event) + } + channel.close() +} + +// Consumer +launch { + for (event in channel) { + process(event) + } +} + +// channelFlow for complex producers +fun relayEvents(relay: Relay): Flow = channelFlow { + relay.connect() + relay.onEvent { event -> + trySend(event) + } + awaitClose { relay.disconnect() } +} +``` + +### Cancellation & Exception Handling +```kotlin +// Cooperative cancellation +suspend fun processNotes(notes: List) { + for (note in notes) { + ensureActive() // Throws if cancelled + process(note) + yield() // Suspend point for cancellation + } +} + +// Exception handling +val handler = CoroutineExceptionHandler { _, exception -> + log.error("Coroutine failed", exception) +} + +val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler) + +// supervisorScope: child failures don't cancel siblings +supervisorScope { + launch { task1() } // Can fail independently + launch { task2() } // Continues even if task1 fails +} +``` + +### Testing Coroutines +```kotlin +class FeedViewModelTest { + @Test + fun `loadFeed updates state with notes`() = runTest { + val repository = mockk() + coEvery { repository.getFeed() } returns flowOf(testNotes) + + val viewModel = FeedViewModel(repository) + viewModel.loadFeed() + + advanceUntilIdle() + + assertEquals(testNotes, viewModel.state.value.notes) + } +} + +// Inject test dispatcher +val testDispatcher = StandardTestDispatcher() +Dispatchers.setMain(testDispatcher) +``` + +## Nostr-Specific Patterns + +### Relay Connection Pool +```kotlin +class RelayPool(private val scope: CoroutineScope) { + private val relays = ConcurrentHashMap() + + fun connect(url: String) { + scope.launch { + val connection = RelayConnection(url) + relays[url] = connection + + 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 subscriptionId = UUID.randomUUID().toString() + + try { + relayPool.activeRelays.collect { relays -> + relays.forEach { relay -> + launch { + relay.subscribe(subscriptionId, filters) + .collect { send(it) } + } + } + } + } finally { + relayPool.unsubscribe(subscriptionId) + } +} +``` + +## Agent Capabilities + +1. **Async Architecture Design** + - Coroutine scope hierarchy + - Structured concurrency patterns + - Error propagation strategies + +2. **Flow Pipeline Design** + - Cold vs hot stream selection + - Operator chaining + - Backpressure handling + +3. **Concurrency Patterns** + - Parallel decomposition + - Rate limiting + - Resource pooling + +4. **Testing Strategies** + - runTest usage + - Dispatcher injection + - Flow testing with Turbine + +5. **Performance Optimization** + - Dispatcher selection + - Buffer sizing + - Cancellation efficiency + +## Scope Boundaries + +### In Scope +- kotlinx.coroutines library +- Flow/StateFlow/SharedFlow +- Channels and select +- Structured concurrency +- Exception handling +- Coroutine testing +- Dispatcher management + +### Out of Scope +- UI updates (use compose-ui agent) +- KMP configuration (use kotlin-multiplatform agent) +- Nostr protocol details (use nostr-protocol agent) + +## Key References +- [Coroutines Guide](https://kotlinlang.org/docs/coroutines-guide.html) +- [Flow Documentation](https://kotlinlang.org/docs/flow.html) +- [StateFlow/SharedFlow](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/) diff --git a/.claude/agents/kotlin-multiplatform.md b/.claude/agents/kotlin-multiplatform.md new file mode 100644 index 0000000000..2e148cf269 --- /dev/null +++ b/.claude/agents/kotlin-multiplatform.md @@ -0,0 +1,161 @@ +# Kotlin Multiplatform Agent + +## Expertise Domain + +This agent specializes in Kotlin Multiplatform (KMP) project architecture, enabling code sharing across Android, iOS, Desktop (JVM), and Web targets. + +## Core Knowledge Areas + +### Project Structure +``` +module/ +├── build.gradle.kts +└── src/ + ├── commonMain/ # Shared code (all targets) + ├── commonTest/ # Shared tests + ├── androidMain/ # Android-specific + ├── jvmMain/ # Desktop JVM-specific + └── iosMain/ # iOS-specific (future) +``` + +### Source Set Hierarchy +``` + commonMain + │ + ┌───────────┼───────────┐ + │ │ │ + jvmMain nativeMain jsMain + │ │ + ┌──────┴───┐ ┌───┴───┐ + │ │ │ │ +androidMain desktopMain iosMain +``` + +### expect/actual Mechanism +```kotlin +// commonMain - Declaration only +expect class PlatformContext + +expect fun getPlatform(): Platform + +expect class SecureStorage { + fun store(key: String, value: ByteArray) + fun retrieve(key: String): ByteArray? +} + +// androidMain - Android implementation +actual class PlatformContext(val context: Context) + +actual fun getPlatform(): Platform = Platform.Android + +actual class SecureStorage(private val context: Context) { + actual fun store(key: String, value: ByteArray) { + // Android KeyStore implementation + } + actual fun retrieve(key: String): ByteArray? = TODO() +} + +// jvmMain - Desktop implementation +actual class PlatformContext + +actual fun getPlatform(): Platform = Platform.Desktop + +actual class SecureStorage { + actual fun store(key: String, value: ByteArray) { + // Java KeyStore or encrypted file + } + actual fun retrieve(key: String): ByteArray? = TODO() +} +``` + +### Dependency Management +```kotlin +kotlin { + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.serialization.json) + } + androidMain.dependencies { + implementation(libs.secp256k1.kmp.jni.android) + } + jvmMain.dependencies { + implementation(libs.secp256k1.kmp.jni.jvm) + } + } +} +``` + +## Agent Capabilities + +1. **Project Configuration** + - Set up KMP modules from scratch + - Configure targets (Android, JVM, iOS) + - Manage Gradle build scripts + - Version catalog setup + +2. **Code Sharing Strategy** + - Identify shareable vs platform-specific code + - Design expect/actual interfaces + - Create intermediate source sets + - Maximize code reuse (target: 70-80%) + +3. **Dependency Selection** + - Recommend KMP-compatible libraries + - Handle platform-specific variants + - Resolve version conflicts + +4. **Migration Guidance** + - Port Android code to commonMain + - Extract platform abstractions + - Refactor for multiplatform + +5. **Build & Tooling** + - Gradle configuration + - IDE setup (Android Studio) + - CI/CD for multiple targets + +## Platform-Specific Patterns + +| 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 | +| **Context** | Context object | None needed | + +## Quartz KMP Conversion + +Current Quartz is Android-only. Conversion steps: + +1. **Add KMP plugin** to build.gradle.kts +2. **Define targets**: android(), jvm() +3. **Move shared code** to `src/commonMain/` +4. **Create expect declarations** for platform-specific APIs +5. **Implement actuals** in androidMain/jvmMain + +Key abstractions needed: +- `CryptoProvider` - secp256k1 signing/verification +- `SodiumProvider` - NIP-44 encryption +- `PlatformJson` - Jackson vs kotlinx.serialization + +## Scope Boundaries + +### In Scope +- KMP project structure and configuration +- Source set hierarchy design +- expect/actual declarations +- Gradle multiplatform plugin +- Cross-platform library selection +- Migration from Android-only + +### Out of Scope +- UI implementation details (use compose-ui agent) +- Coroutine patterns (use kotlin-coroutines agent) +- Nostr protocol specifics (use nostr-protocol agent) + +## Key References +- [KMP Documentation](https://kotlinlang.org/docs/multiplatform.html) +- [expect/actual](https://kotlinlang.org/docs/multiplatform-expect-actual.html) +- [Hierarchical Structure](https://kotlinlang.org/docs/multiplatform-hierarchy.html) diff --git a/.claude/agents/nostr-protocol.md b/.claude/agents/nostr-protocol.md new file mode 100644 index 0000000000..06ac8de5d4 --- /dev/null +++ b/.claude/agents/nostr-protocol.md @@ -0,0 +1,102 @@ +# Nostr Protocol Agent + +## Expertise Domain + +This agent specializes in the Nostr decentralized social protocol, covering 94+ NIPs (Nostr Implementation Possibilities) that define the protocol specification. + +## Core Knowledge Areas + +### Protocol Fundamentals + +| Concept | Description | +|---------|-------------| +| **Events** | Signed JSON objects: id, pubkey, created_at, kind, tags, content, sig | +| **Relays** | WebSocket servers that store/forward events | +| **Keys** | secp256k1 keypairs, Schnorr signatures (BIP-340) | +| **Filters** | Subscription queries (kinds, authors, tags, since, until, limit) | + +### 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 +) +``` + +### NIP Categories + +| Category | NIPs | Scope | +|----------|------|-------| +| **Core Protocol** | 01, 02, 10, 11 | Basic events, follows, threads, relay info | +| **Messaging** | 04 (deprecated), 17, 44 | DMs, encrypted messaging | +| **Social** | 18, 25, 32, 38, 51, 52 | Reactions, reports, lists, communities | +| **Identity** | 05, 19, 39, 46, 55 | DNS, bech32, external identity, bunker, signer | +| **Media** | 23, 30, 54, 71, 94 | Long-form, audio, video, blobs | +| **Payments** | 47, 57, 60, 61 | Wallet Connect, zaps, cashu | +| **Relay** | 42, 65, 66 | Auth, relay lists, closed groups | + +### Cryptographic Operations +- **Signing**: Schnorr signatures on secp256k1 curve +- **Encryption**: NIP-44 (XChaCha20-Poly1305), NIP-04 (deprecated AES) +- **Key derivation**: BIP-32/39 compatible +- **Event ID**: SHA256 of `[0, pubkey, created_at, kind, tags, content]` + +## Agent Capabilities + +1. **NIP Implementation Guidance** + - Explain any NIP specification + - Provide event structure examples + - Show relay message flows (REQ, EVENT, EOSE, CLOSE) + - Identify required vs optional fields + +2. **Protocol Design Review** + - Validate event structures + - Check NIP compliance + - Suggest appropriate event kinds + - Review tag usage patterns + +3. **Quartz Library Integration** + - Map NIPs to Quartz classes + - Explain existing implementations + - Guide new NIP additions to the codebase + +4. **Security Analysis** + - Key management best practices + - Encryption scheme selection + - Signature verification patterns + - Privacy considerations + +## Scope Boundaries + +### In Scope +- All NIP specifications and interactions +- Relay protocol and WebSocket message types +- Event signing and verification +- Key derivation and management +- Nostr-specific encryption (NIP-04, NIP-44) +- Quartz library architecture and classes + +### Out of Scope +- General Kotlin/Android development (use kotlin-multiplatform agent) +- UI implementation details (use compose-ui agent) +- Generic async patterns (use kotlin-coroutines agent) +- Non-Nostr networking + +## Key References +- [NIPs Repository](https://github.com/nostr-protocol/nips) +- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md) +- [NIP-44: Versioned Encryption](https://github.com/nostr-protocol/nips/blob/master/44.md) +- Quartz source: `quartz/src/` + +## Example Queries + +- "How do I implement NIP-57 zap receipts?" +- "What's the correct tag structure for a reply?" +- "How does NIP-44 encryption work?" +- "Which event kind for a long-form article?" +- "How to verify a Schnorr signature in Quartz?" diff --git a/.claude/commands/desktop-run.md b/.claude/commands/desktop-run.md new file mode 100644 index 0000000000..6b43f0d2bf --- /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 0000000000..99c01eea7c --- /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 0000000000..2c7631be04 --- /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 0000000000..7f9d3b6c98 --- /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 0000000000..49e5000ffb --- /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/build.gradle b/build.gradle index f278503557..5bf6769b77 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/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts new file mode 100644 index 0000000000..42722007e6 --- /dev/null +++ b/desktopApp/build.gradle.kts @@ -0,0 +1,59 @@ +import org.jetbrains.compose.desktop.application.dsl.TargetFormat + +plugins { + alias(libs.plugins.jetbrainsKotlinJvm) + alias(libs.plugins.composeMultiplatform) + alias(libs.plugins.composeCompiler) +} + +dependencies { + implementation(compose.desktop.currentOs) + implementation(compose.material3) + implementation(compose.materialIconsExtended) + + // Quartz Nostr library (will use JVM target) + implementation(project(":quartz")) + + // 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 0000000000..843e6f6594 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -0,0 +1,306 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.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.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +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.Search +import androidx.compose.material.icons.filled.Settings +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +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.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.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 + +enum class Screen { + Feed, + Search, + Messages, + Notifications, + Profile, + Settings +} + +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(Screen.Feed) } + + MaterialTheme( + colorScheme = darkColorScheme() + ) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + 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 == Screen.Feed, + onClick = { currentScreen = Screen.Feed } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, + label = { Text("Search") }, + selected = currentScreen == Screen.Search, + onClick = { currentScreen = Screen.Search } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, + label = { Text("DMs") }, + selected = currentScreen == Screen.Messages, + onClick = { currentScreen = Screen.Messages } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, + label = { Text("Alerts") }, + selected = currentScreen == Screen.Notifications, + onClick = { currentScreen = Screen.Notifications } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, + label = { Text("Profile") }, + selected = currentScreen == Screen.Profile, + onClick = { currentScreen = Screen.Profile } + ) + + Spacer(Modifier.weight(1f)) + + HorizontalDivider(Modifier.padding(horizontal = 16.dp)) + + NavigationRailItem( + icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, + label = { Text("Settings") }, + selected = currentScreen == Screen.Settings, + onClick = { currentScreen = Screen.Settings } + ) + + Spacer(Modifier.height(16.dp)) + } + + VerticalDivider() + + // Main Content + Box( + modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp) + ) { + when (currentScreen) { + Screen.Feed -> FeedPlaceholder() + Screen.Search -> SearchPlaceholder() + Screen.Messages -> MessagesPlaceholder() + Screen.Notifications -> NotificationsPlaceholder() + Screen.Profile -> ProfilePlaceholder() + Screen.Settings -> SettingsPlaceholder() + } + } + } + } + } +} + +@Composable +fun FeedPlaceholder() { + Column { + Text( + "Feed", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(16.dp)) + Text( + "Your Nostr feed will appear here.\n\nConnect to relays and follow users to see notes.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +fun SearchPlaceholder() { + Column { + Text( + "Search", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(16.dp)) + Text( + "Search for users, notes, and hashtags.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +fun MessagesPlaceholder() { + Column { + Text( + "Messages", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(16.dp)) + Text( + "Your encrypted direct messages will appear here.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +fun NotificationsPlaceholder() { + Column { + Text( + "Notifications", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(16.dp)) + Text( + "Mentions, replies, and reactions will appear here.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +fun ProfilePlaceholder() { + Column { + Text( + "Profile", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(16.dp)) + Text( + "Login with your Nostr keys to see your profile.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +@Composable +fun SettingsPlaceholder() { + Column { + Text( + "Settings", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(16.dp)) + Text( + "Configure relays, appearance, and account settings.", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} diff --git a/desktopApp/src/jvmMain/resources/icon.png b/desktopApp/src/jvmMain/resources/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..dbb5d7153df9e4fe370bfde124098a89b0582392 GIT binary patch literal 7016 zcmeHLc{o(<`ycyQv%HB=V^Fp+i?PjX$-XsZdr2s>988!QW31U?Y$*{%3MJ7-vQySb zQ7KD8Aw(Nl3Z?Wrqu$=SzTfNmUGH`Me*ZOd&7AYx_vd_``~E!lbI&=GY`4o&SYWdN z1OgGZwlZ@7k7V`-$_swe$4@wchxTx%Jxm8e2#n64c~SfT7&D9xz<^MS7X%VIXSFVXq*FGs+yt>ZRn>5gvUvn_XPINokS5+l!$KgLNEBEd0 z6N}3d<2#-$={(}Wd5B2PbHOjIP2K`fj^-O_7WJpgN=&Zv7H&}RZHt7P1Z3Nz6^c~2 zH81uzI$%ee%CwqHrUMM#`iHK1KFbTa`(#AS7-LU*m~;=rHx|_vFvAH)Cz#0DcX_fZ z@UiiqhkN;#xg3bDBbi~{J0TDb7RA)m&f3)U?}WfK&P1jgSl!wo+rHn?TxDaT{tR9oPGLRCVP-RJ!yrtx&oyLgN}tP2QMwXJLMd0^rV>4E%r55 z11!973Rqx!@H=rNn!g5tOd|psq5gER@*og>qfk15 zXll?H-bl2bo*oi~L1Hin5P=8`qcRDh2x_1Lo8lXX84yTfQ0PnwjS6FP5{R@QrU4ud z`eA>^=TFDuf6!9{zpDW1feaW;9X|ds1sN12{Wg9GOOxq1l#TS zkH?AP4>&RjY}L>@7(E>{28-3v(gl;(`Um6;Fap7fW0Rs$8k*R351E9s04WJzXWnKA$Zlg4nO(fkbH>|kMR${%h#xSz-bCc%ur z1i;x)7%UEr!eKO>&{{Yw3WwD}ps+a9Pk0)c;uZG4q1n3!rvGitttf%u{9)^+Z(GU{ z2>3SoHuR&c?9OD&LeiDVtJ z9sxln5w#ImO^hCbs0UUB3eeOc0%#oq2GIPKJ&@+b3?VQ8V{cGLP%E%L*R_Hve_KM8 zU*RD>0DFBv#Smx|;y)Ebe$N=m?izomtdIN;QS{dVKeZX~x^FhHeSzH&`J)|v7meL` z{vTi87vukN29WyqB>zg^-*Wwy>t8AGufV^n>$hD0N`Zd`{#{-FXL1Sr^FReq!P}q^ z@NsErxs3;W)Z!)DTAD$`+3yXZVO^kwpKj$E2!V*)Vt+U~XcyUk4e&9o@#cIl1vW`2 zBVFw~#UT(u5oivz2@ao24>*+F=2moRruNjm z%E3<$C(j{snYiAp!rooO(+?Cib1RWS+fO~g-$n$xZ=biYk~`9(eFb&81y;e&Z*2uF zajB1NR`ZmRLoC}or6ow~KK+{a;>PsX?yvbr>9QThEKiu&=^ct@EUP4|Bxt&5x~LdB z20J3X*K6uwua!dGE%;3L!zqq4tQdt9qtKVjtFOd4)$s>c29+>-jPn#iz?cCQNBZY4vdSA(eBFRL z_bu+a&$iwWy7PL=>7a_2FZ0Fyt#)@npDuEYsNo@4tc;jYfNxfsoic0DJDhmhT59H? zf5aAriRvobJ5T&266(Ep90NGVTD2yIlnU!!c}48i6Z%|C;&o2+o)H^os-K;Ie|D_V zB09~~w!3Xep>X08Vx^(wiMJWyyb*0nqLHZkpDgY{(FZ!r_HGm_**-O z`v_NHtcc5*x^O;|Q$;Cy@>$5u<1R9t77d|Ysr(h1ANjAl=B}*0rAKs>XX&@Z(n=Ecl^#}QHO$wTy3yz03sf6)MzrMlM`%+(=f4kf4+?LV9 z((Qbtd|vA#^m_K&+F)wt-o{F^_%rHyNMXh-Z_{mqZ4yocZlA;i7`9%V zE_`)oTHYD1pnG~89`%F^X5}OV_}=4>PIEi+K0}lYbe7dvq#Q=PNiTJd5l-;DUXRj# z6*I7v)zLxhtBhHD#lzhy>v-mMZoAv+vpe^9+KQM&j6fbl8`JLXj|@^Pujq%wd^s5O zq{$_br)I0+=r}ZqAf<_B!Dzo}u8vGZ`G*m^OljvPe=LrG zZ8vJ%sC9leV3j@}7(P1DY%F);Y2S)^YGe;*oK})Yzf$P*L+z`Mcb&^jF=;5v$fF*P zp*lti=z~_YX_UJp@`PVN^5)apiA{9GnD_jnt-H=OMDl98K)%jiADg(HzA;}(F`_HD zAdokeCwg40HC`8+ccA!W^hY{Rp~E&mz16E+j;5)WJsSAhF;dnQWRsCpHJ;9E;*$~> zp{B|@a_kg}4&;+0-VZUftp>7?mIu=t(K6X=q#ifqZUDE8h%` zN=NFa98R)#?<-23Ny7dwAE66^nJMp7?Zz9pfXZIDT0zH@c@sM7O$}W8wMMbbXx`cV z5SJQn2|VkCVYQRb{G&vp_>MP;4k~;Kt{;I*msHYXVs1bCbJ&o_!!_6`In{N;t2#7y zehM{A`Ie~Pv!bK5;wmaaJ^hC}j&nfX^f7!i0jGB~sdCcoDapo80Upy@<^pe3ME`6A znXEkFXwTpBC@0PG~=vtNHF?ha;)`8Ul~wrppHvGv?{d`PFpT+itLl#lzFkt5UDwP zF8O$D(8|g{_XHi-^`w0JwcYLdjpleOZ#N^(EuS>5UQ^+?HCNGYpHbRO8||4u(rAg= zkslgfEL6(W6z8@ou!OlEt$aM1go!FD5REW_%RljOWkrAX+OQVN?7E!vy!pAwa^)OU zTDmq)g{z^WjpO0TNfW4Drz5>AoGZ1#B}FILuwiG%!IH8Y5v}ma)oa6I$yu2$g{*D1 z!t%x!tlE*soQcPl?!MRyO?cQtlN6D6UX(4>F{>URB{rt%tj;SI+DP{qEHX*aH=nT} zGUB}QaxPT3)boXhp+mZN^0CX~h`jZby?F`{HB$lrVh^u;0SC>GfQ- zL$sDbXIO)w=uSc#P*)!^>6Ek>7!aIRm%W18k_m6&r5Fxs>ith@% z^Wls4@>Y75krmP6@`rMF_xLT1KhDfBGCABOSavCI%D*C zh|v8OUCA}kJtfB{Ueps?E|)})RJKS=iB;NrloVYXwvZdeY<|V0dIVkao{}2s`m3#-j=$MyPdbPt~}M^mW3pn#mfEpa&z!Rf_vtqs%K^B zCjUF)RaMt8eDw=g`8M6sXVeLX?Zj^>F z>0u$HbRSdh7_7O#zPdLVNd{)HizPkV3dOAVn%JaNoKFBR+1>c7{8djI9wcVqPt?` Y!Xv2regUZt-Yi3`&3BoVns~ Date: Fri, 26 Dec 2025 08:24:38 +0200 Subject: [PATCH 02/10] fix: Correct compose compiler plugin alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use jetbrainsComposeCompiler alias to match version catalog. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- desktopApp/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 42722007e6..25522b1317 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -3,7 +3,7 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat plugins { alias(libs.plugins.jetbrainsKotlinJvm) alias(libs.plugins.composeMultiplatform) - alias(libs.plugins.composeCompiler) + alias(libs.plugins.jetbrainsComposeCompiler) } dependencies { From 8c8a4ab9e8b57880ea6f6246b118aea162a3b380 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 26 Dec 2025 14:02:44 +0200 Subject: [PATCH 03/10] feat: Add relay connections, login, and feed view to desktop app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add DesktopHttpClient and DesktopRelayConnectionManager for relay WebSocket connections - Implement AccountManager with key generation and nsec/npub login support - Create LoginScreen with key import and new key generation UI - Build FeedScreen with live global feed subscription and note cards - Add ProfileScreen showing account info and logout - Update RelaySettingsScreen with connection status and relay management - Configure JDK 21 toolchain for desktop builds - Add shared UI analysis documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- desktopApp/build.gradle.kts | 11 + .../vitorpamplona/amethyst/desktop/Main.kt | 427 ++++++++++++++---- .../desktop/account/AccountManager.kt | 121 +++++ .../desktop/network/DesktopHttpClient.kt | 39 ++ .../network/DesktopRelayConnectionManager.kt | 151 +++++++ .../amethyst/desktop/ui/FeedScreen.kt | 279 ++++++++++++ .../amethyst/desktop/ui/LoginScreen.kt | 265 +++++++++++ docs/shared-ui-analysis.md | 219 +++++++++ 8 files changed, 1419 insertions(+), 93 deletions(-) create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt create mode 100644 desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt create mode 100644 docs/shared-ui-analysis.md diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 25522b1317..86b7367346 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -6,6 +6,17 @@ plugins { 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) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 843e6f6594..5f02774451 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -20,38 +20,56 @@ */ 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.size 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.Check +import androidx.compose.material.icons.filled.Close 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.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator 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 @@ -60,6 +78,12 @@ import androidx.compose.ui.window.Window import androidx.compose.ui.window.WindowPosition import androidx.compose.ui.window.application import androidx.compose.ui.window.rememberWindowState +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.account.AccountState +import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager +import com.vitorpamplona.amethyst.desktop.network.RelayStatus +import com.vitorpamplona.amethyst.desktop.ui.FeedScreen +import com.vitorpamplona.amethyst.desktop.ui.LoginScreen enum class Screen { Feed, @@ -124,6 +148,17 @@ fun main() = application { @Composable fun App() { var currentScreen by remember { mutableStateOf(Screen.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() @@ -132,77 +167,21 @@ fun App() { modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background ) { - 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 == Screen.Feed, - onClick = { currentScreen = Screen.Feed } + when (accountState) { + is AccountState.LoggedOut -> { + LoginScreen( + accountManager = accountManager, + onLoginSuccess = { currentScreen = Screen.Feed } ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, - label = { Text("Search") }, - selected = currentScreen == Screen.Search, - onClick = { currentScreen = Screen.Search } - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, - label = { Text("DMs") }, - selected = currentScreen == Screen.Messages, - onClick = { currentScreen = Screen.Messages } - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, - label = { Text("Alerts") }, - selected = currentScreen == Screen.Notifications, - onClick = { currentScreen = Screen.Notifications } - ) - - NavigationRailItem( - icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, - label = { Text("Profile") }, - selected = currentScreen == Screen.Profile, - onClick = { currentScreen = Screen.Profile } - ) - - Spacer(Modifier.weight(1f)) - - HorizontalDivider(Modifier.padding(horizontal = 16.dp)) - - NavigationRailItem( - icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, - label = { Text("Settings") }, - selected = currentScreen == Screen.Settings, - onClick = { currentScreen = Screen.Settings } - ) - - Spacer(Modifier.height(16.dp)) } - - VerticalDivider() - - // Main Content - Box( - modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp) - ) { - when (currentScreen) { - Screen.Feed -> FeedPlaceholder() - Screen.Search -> SearchPlaceholder() - Screen.Messages -> MessagesPlaceholder() - Screen.Notifications -> NotificationsPlaceholder() - Screen.Profile -> ProfilePlaceholder() - Screen.Settings -> SettingsPlaceholder() - } + is AccountState.LoggedIn -> { + MainContent( + currentScreen = currentScreen, + onScreenChange = { currentScreen = it }, + relayManager = relayManager, + accountManager = accountManager, + account = accountState as AccountState.LoggedIn, + ) } } } @@ -210,18 +189,85 @@ fun App() { } @Composable -fun FeedPlaceholder() { - Column { - Text( - "Feed", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(Modifier.height(16.dp)) - Text( - "Your Nostr feed will appear here.\n\nConnect to relays and follow users to see notes.", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) +fun MainContent( + currentScreen: Screen, + onScreenChange: (Screen) -> 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 == Screen.Feed, + onClick = { onScreenChange(Screen.Feed) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, + label = { Text("Search") }, + selected = currentScreen == Screen.Search, + onClick = { onScreenChange(Screen.Search) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, + label = { Text("DMs") }, + selected = currentScreen == Screen.Messages, + onClick = { onScreenChange(Screen.Messages) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, + label = { Text("Alerts") }, + selected = currentScreen == Screen.Notifications, + onClick = { onScreenChange(Screen.Notifications) } + ) + + NavigationRailItem( + icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, + label = { Text("Profile") }, + selected = currentScreen == Screen.Profile, + onClick = { onScreenChange(Screen.Profile) } + ) + + Spacer(Modifier.weight(1f)) + + HorizontalDivider(Modifier.padding(horizontal = 16.dp)) + + NavigationRailItem( + icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, + label = { Text("Settings") }, + selected = currentScreen == Screen.Settings, + onClick = { onScreenChange(Screen.Settings) } + ) + + Spacer(Modifier.height(16.dp)) + } + + VerticalDivider() + + // Main Content + Box( + modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp) + ) { + when (currentScreen) { + Screen.Feed -> FeedScreen(relayManager) + Screen.Search -> SearchPlaceholder() + Screen.Messages -> MessagesPlaceholder() + Screen.Notifications -> NotificationsPlaceholder() + Screen.Profile -> ProfileScreen(account, accountManager) + Screen.Settings -> RelaySettingsScreen(relayManager) + } + } } } @@ -274,7 +320,7 @@ fun NotificationsPlaceholder() { } @Composable -fun ProfilePlaceholder() { +fun ProfileScreen(account: AccountState.LoggedIn, accountManager: AccountManager) { Column { Text( "Profile", @@ -282,25 +328,220 @@ fun ProfilePlaceholder() { color = MaterialTheme.colorScheme.onBackground ) Spacer(Modifier.height(16.dp)) - Text( - "Login with your Nostr keys to see your profile.", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + if (account.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( + account.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( + account.pubKeyHex, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + } + } + + Spacer(Modifier.height(24.dp)) + + OutlinedButton( + onClick = { accountManager.logout() }, + colors = androidx.compose.material3.ButtonDefaults.outlinedButtonColors( + contentColor = Color.Red + ) + ) { + Text("Logout") + } } } @Composable -fun SettingsPlaceholder() { - Column { +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( - "Settings", + "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)) - Text( - "Configure relays, appearance, and account settings.", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) + + 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) { + relayManager.removeRelay(status.url) + } + } + } + + Spacer(Modifier.height(16.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { relayManager.addDefaultRelays() }) { + Text("Reset to Defaults") + } + } + } +} + +@Composable +fun RelayStatusCard(status: RelayStatus, onRemove: () -> Unit) { + 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/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt new file mode 100644 index 0000000000..91f544c254 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt @@ -0,0 +1,121 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.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/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 0000000000..9046e2d1f3 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt @@ -0,0 +1,39 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 0000000000..a8583c4fd7 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt @@ -0,0 +1,151 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.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.okhttp.BasicOkHttpWebSocket +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +data class RelayStatus( + val url: NormalizedRelayUrl, + val connected: Boolean, + val pingMs: Int? = null, + val compressed: Boolean = false, + val error: String? = null, +) + +class DesktopRelayConnectionManager : IRelayClientListener { + private val websocketBuilder = BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient) + 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() + + companion object { + val DEFAULT_RELAYS = listOf( + "wss://relay.damus.io", + "wss://relay.nostr.band", + "wss://nos.lol", + "wss://relay.snort.social", + "wss://nostr.wine", + ) + } + + 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() { + DEFAULT_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/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 0000000000..139326a7df --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -0,0 +1,279 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.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.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +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 +import com.vitorpamplona.quartz.nip19Bech32.toNpub +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +@Composable +fun FeedScreen(relayManager: DesktopRelayConnectionManager) { + val connectedRelays by relayManager.connectedRelays.collectAsState() + val events = remember { mutableStateListOf() } + val seenIds = remember { mutableSetOf() } + + DisposableEffect(connectedRelays) { + if (connectedRelays.isNotEmpty()) { + val subId = "global-feed-${System.currentTimeMillis()}" + val filters = listOf( + Filter( + kinds = listOf(TextNoteEvent.KIND), + limit = 50, + ) + ) + + relayManager.subscribe( + subId = subId, + filters = filters, + relays = connectedRelays, + 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()) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + "Global Feed", + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val connected = connectedRelays.size + val color = when { + connected == 0 -> Color.Red + connected < 3 -> Color.Yellow + else -> Color.Green + } + Icon( + if (connected > 0) Icons.Default.Check else Icons.Default.Close, + contentDescription = null, + tint = color, + modifier = Modifier.size(16.dp) + ) + Text( + "$connected relay${if (connected != 1) "s" else ""}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + IconButton(onClick = { relayManager.connect() }) { + Icon( + Icons.Default.Refresh, + contentDescription = "Reconnect", + tint = MaterialTheme.colorScheme.primary + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + + if (connectedRelays.isEmpty()) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + "Connecting to relays...", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else if (events.isEmpty()) { + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + "Loading notes...", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } else { + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + items(events, key = { it.id }) { event -> + NoteCard(event) + } + } + } + } +} + +@Composable +fun NoteCard(event: Event) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + // Author npub (truncated) + val npub = try { + event.pubKey.hexToByteArrayOrNull()?.toNpub() ?: event.pubKey.take(16) + "..." + } catch (e: Exception) { + event.pubKey.take(16) + "..." + } + Text( + text = npub.take(20) + "...", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1 + ) + + // Timestamp + val date = Date(event.createdAt * 1000) + val format = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) + Text( + text = format.format(date), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + + Spacer(Modifier.height(8.dp)) + + Text( + text = event.content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 10, + overflow = TextOverflow.Ellipsis + ) + + 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: ${event.id.take(12)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) + ) + } + } +} + +private fun String.hexToByteArrayOrNull(): ByteArray? { + return try { + if (length % 2 != 0) return null + ByteArray(length / 2) { i -> + substring(i * 2, i * 2 + 2).toInt(16).toByte() + } + } catch (e: Exception) { + null + } +} 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 0000000000..2f9ba4fb14 --- /dev/null +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt @@ -0,0 +1,265 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.desktop.account.AccountManager +import com.vitorpamplona.amethyst.desktop.account.AccountState + +@Composable +fun LoginScreen( + accountManager: AccountManager, + onLoginSuccess: () -> Unit, +) { + var keyInput by remember { mutableStateOf("") } + var showKey by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + 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)) + + Card( + modifier = Modifier.width(400.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + "Login with your Nostr key", + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(16.dp)) + + OutlinedTextField( + value = keyInput, + onValueChange = { + keyInput = it + errorMessage = null + }, + label = { Text("nsec or npub") }, + placeholder = { Text("nsec1... or npub1...") }, + 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) } + }, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + "Use nsec for full access or npub for read-only", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + accountManager.loginWithKey(keyInput).fold( + onSuccess = { onLoginSuccess() }, + onFailure = { errorMessage = it.message }, + ) + }, + modifier = Modifier.weight(1f), + enabled = keyInput.isNotBlank(), + ) { + Text("Login") + } + + OutlinedButton( + onClick = { + generatedAccount = accountManager.generateNewAccount() + showNewKeyDialog = true + }, + modifier = Modifier.weight(1f), + ) { + Text("Generate New") + } + } + } + } + + if (showNewKeyDialog && generatedAccount != null) { + Spacer(Modifier.height(24.dp)) + NewKeyCard( + account = generatedAccount!!, + onContinue = { + showNewKeyDialog = false + onLoginSuccess() + }, + ) + } + } +} + +@Composable +fun NewKeyCard( + account: AccountState.LoggedIn, + onContinue: () -> Unit, +) { + Card( + modifier = Modifier.width(500.dp), + 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(account.npub) + + Spacer(Modifier.height(12.dp)) + + account.nsec?.let { nsec -> + Text( + "Secret Key (NEVER share this!):", + style = MaterialTheme.typography.labelMedium, + color = Color.Red, + ) + SelectableKeyText(nsec) + } + + Spacer(Modifier.height(24.dp)) + + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth(), + ) { + Text("I've saved my keys, continue") + } + } + } +} + +@Composable +fun SelectableKeyText(key: String) { + Card( + 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/docs/shared-ui-analysis.md b/docs/shared-ui-analysis.md new file mode 100644 index 0000000000..174d2d0582 --- /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 From 59eeb1ff0ca3691ec90dd2b0b273b73e0a9d2b3d Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 26 Dec 2025 14:10:02 +0200 Subject: [PATCH 04/10] fix: Use configured relays to initiate subscriptions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The FeedScreen was waiting for connectedRelays to be non-empty before subscribing, but relays are only added to the NostrClient pool when subscriptions request them. This created a deadlock where no relays would ever connect. Now uses relayStatuses.keys (configured relays) to initiate the subscription, which triggers the NostrClient to add those relays to its pool and connect to them. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../vitorpamplona/amethyst/desktop/ui/FeedScreen.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 139326a7df..8eef7d1e6d 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -69,11 +69,16 @@ import java.util.Locale @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() } - DisposableEffect(connectedRelays) { - if (connectedRelays.isNotEmpty()) { + // 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( @@ -85,7 +90,7 @@ fun FeedScreen(relayManager: DesktopRelayConnectionManager) { relayManager.subscribe( subId = subId, filters = filters, - relays = connectedRelays, + relays = configuredRelays, listener = object : IRequestListener { override fun onEvent( event: Event, From 131252f19d51d35ca84d1fa4a09e18923e2715ac Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sat, 27 Dec 2025 07:52:45 +0200 Subject: [PATCH 05/10] feat: Convert commons module to Kotlin Multiplatform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite build.gradle.kts for KMP with Android + JVM targets - Restructure source sets: commonMain, jvmAndroid, androidMain, jvmMain - Replace android.util.LruCache with androidx.collection.LruCache (KMP-ready) - Replace android.util.Patterns with local regex constants - Move shared code to commonMain (icons, hashtags, robohash, compose, etc.) - Move JVM-shared code to jvmAndroid (richtext, base64Image detection) - Keep Android-specific code in androidMain (blurhash, bitmap handling) - Remove @Preview annotations from shared code (Android-only feature) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .claude/CLAUDE.md | 68 ++++- .claude/agents/compose-ui.md | 280 +++++++----------- .claude/agents/kotlin-coroutines.md | 277 ++++++----------- .claude/agents/kotlin-multiplatform.md | 210 ++++++------- .claude/agents/nostr-protocol.md | 198 ++++++++----- .gitignore | 3 + commons/build.gradle | 69 ----- commons/build.gradle.kts | 119 ++++++++ .../{main => androidMain}/AndroidManifest.xml | 0 .../commons/base64Image/Base64ImageBitmap.kt | 37 +++ .../amethyst/commons/blurhash/Base83.kt | 0 .../amethyst/commons/blurhash/BitmapUtils.kt | 0 .../commons/blurhash/BlurHashDecoder.kt | 0 .../commons/blurhash/BlurHashDecoderOld.kt | 0 .../commons/blurhash/BlurHashEncoder.kt | 0 .../amethyst/commons/blurhash/CosineCache.kt | 0 .../amethyst/commons/blurhash/SRGB.kt | 0 .../commons/compose/AsyncCachedState.kt | 10 +- .../amethyst/commons/compose/CachedState.kt | 10 +- .../compose/TextFieldValueExtensions.kt | 0 .../amethyst/commons/emojicoder/EmojiCoder.kt | 0 .../amethyst/commons/hashtags/Amethyst.kt | 2 - .../amethyst/commons/hashtags/Btc.kt | 2 - .../amethyst/commons/hashtags/Cashu.kt | 2 - .../amethyst/commons/hashtags/Coffee.kt | 2 - .../commons/hashtags/CustomHashTagIcons.kt | 0 .../amethyst/commons/hashtags/Flowerstr.kt | 2 - .../amethyst/commons/hashtags/Footstr.kt | 2 - .../amethyst/commons/hashtags/Gamestr.kt | 2 - .../amethyst/commons/hashtags/Grownostr.kt | 2 - .../amethyst/commons/hashtags/Lightning.kt | 2 - .../amethyst/commons/hashtags/Mate.kt | 2 - .../amethyst/commons/hashtags/Nostr.kt | 2 - .../amethyst/commons/hashtags/Plebs.kt | 2 - .../amethyst/commons/hashtags/Skull.kt | 2 - .../amethyst/commons/hashtags/Tunestr.kt | 2 - .../amethyst/commons/hashtags/Weed.kt | 2 - .../amethyst/commons/hashtags/Zap.kt | 2 - .../amethyst/commons/icons/Following.kt | 2 - .../amethyst/commons/icons/Like.kt | 2 - .../amethyst/commons/icons/Liked.kt | 2 - .../amethyst/commons/icons/Reply.kt | 2 - .../amethyst/commons/icons/Repost.kt | 2 - .../amethyst/commons/icons/Reposted.kt | 2 - .../amethyst/commons/icons/Search.kt | 2 - .../amethyst/commons/icons/Share.kt | 2 - .../amethyst/commons/icons/Zap.kt | 2 - .../amethyst/commons/icons/ZapSplit.kt | 2 - .../commons/preview/MetaTagsParser.kt | 0 .../commons/robohash/CachedRobohash.kt | 2 +- .../commons/robohash/RobohashAssembler.kt | 2 - .../commons/robohash/parts/Accessory0Seven.kt | 2 - .../commons/robohash/parts/Accessory1Nose.kt | 2 - .../robohash/parts/Accessory2HornRed.kt | 2 - .../robohash/parts/Accessory3Button.kt | 2 - .../robohash/parts/Accessory4Satellite.kt | 2 - .../robohash/parts/Accessory5Mustache.kt | 2 - .../commons/robohash/parts/Accessory6Hat.kt | 2 - .../robohash/parts/Accessory7Antenna.kt | 2 - .../commons/robohash/parts/Accessory8Brush.kt | 2 - .../commons/robohash/parts/Accessory9Horn.kt | 2 - .../commons/robohash/parts/Body0Trooper.kt | 2 - .../commons/robohash/parts/Body1Thin.kt | 2 - .../commons/robohash/parts/Body2Thinnest.kt | 2 - .../commons/robohash/parts/Body3Front.kt | 2 - .../commons/robohash/parts/Body4Round.kt | 2 - .../commons/robohash/parts/Body5Neck.kt | 2 - .../commons/robohash/parts/Body6Ironman.kt | 2 - .../robohash/parts/Body7Neckthinner.kt | 2 - .../commons/robohash/parts/Body8Big.kt | 2 - .../commons/robohash/parts/Body9Huge.kt | 2 - .../commons/robohash/parts/Eyes0Squint.kt | 2 - .../commons/robohash/parts/Eyes1Round.kt | 2 - .../commons/robohash/parts/Eyes2Single.kt | 2 - .../commons/robohash/parts/Eyes3Scott.kt | 2 - .../robohash/parts/Eyes4Roundsingle.kt | 2 - .../commons/robohash/parts/Eyes5Roundsmall.kt | 2 - .../commons/robohash/parts/Eyes6Walle.kt | 2 - .../commons/robohash/parts/Eyes7Bar.kt | 2 - .../commons/robohash/parts/Eyes8Smallbar.kt | 2 - .../commons/robohash/parts/Eyes9Shield.kt | 2 - .../commons/robohash/parts/Face0C3po.kt | 2 - .../commons/robohash/parts/Face1Rock.kt | 2 - .../commons/robohash/parts/Face2Long.kt | 2 - .../commons/robohash/parts/Face3Oval.kt | 2 - .../commons/robohash/parts/Face4Cylinder.kt | 2 - .../commons/robohash/parts/Face5Baloon.kt | 2 - .../commons/robohash/parts/Face6Triangle.kt | 2 - .../commons/robohash/parts/Face7Bent.kt | 2 - .../robohash/parts/Face8TriangleInv.kt | 2 - .../commons/robohash/parts/Face9Square.kt | 2 - .../commons/robohash/parts/Mouth0Horz.kt | 2 - .../commons/robohash/parts/Mouth1Cylinder.kt | 2 - .../commons/robohash/parts/Mouth2Teeth.kt | 2 - .../commons/robohash/parts/Mouth3Grid.kt | 2 - .../commons/robohash/parts/Mouth4Vert.kt | 2 - .../commons/robohash/parts/Mouth5Midopen.kt | 2 - .../commons/robohash/parts/Mouth6Cell.kt | 2 - .../commons/robohash/parts/Mouth7Happy.kt | 2 - .../commons/robohash/parts/Mouth8Buttons.kt | 2 - .../commons/robohash/parts/Mouth9Closed.kt | 2 - .../commons/base64Image/Base64Image.kt | 32 +- .../ExpandableTextCutOffCalculator.kt | 0 .../commons/richtext/GalleryParser.kt | 0 .../commons/richtext/MediaContentModels.kt | 0 .../amethyst/commons/richtext/Patterns.kt | 45 +++ .../commons/richtext/RichTextParser.kt | 1 - .../richtext/RichTextParserSegments.kt | 0 108 files changed, 697 insertions(+), 818 deletions(-) delete mode 100644 commons/build.gradle create mode 100644 commons/build.gradle.kts rename commons/src/{main => androidMain}/AndroidManifest.xml (100%) create mode 100644 commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImageBitmap.kt rename commons/src/{main/java => androidMain/kotlin}/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt (100%) rename commons/src/{main/java => androidMain/kotlin}/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt (100%) rename commons/src/{main/java => androidMain/kotlin}/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt (100%) rename commons/src/{main/java => androidMain/kotlin}/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoderOld.kt (100%) rename commons/src/{main/java => androidMain/kotlin}/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt (100%) rename commons/src/{main/java => androidMain/kotlin}/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt (100%) rename commons/src/{main/java => androidMain/kotlin}/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt (100%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/compose/AsyncCachedState.kt (91%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/compose/CachedState.kt (91%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/compose/TextFieldValueExtensions.kt (100%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/emojicoder/EmojiCoder.kt (100%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Amethyst.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Btc.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Cashu.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Coffee.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/CustomHashTagIcons.kt (100%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Flowerstr.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Footstr.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Gamestr.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Grownostr.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Lightning.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Mate.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Nostr.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Plebs.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Skull.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Tunestr.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Weed.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/hashtags/Zap.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/Following.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/Like.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/Liked.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/Reply.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/Repost.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/Reposted.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/Search.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/Share.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/Zap.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/icons/ZapSplit.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/preview/MetaTagsParser.kt (100%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/CachedRobohash.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/RobohashAssembler.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory0Seven.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory1Nose.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory2HornRed.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory3Button.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory4Satellite.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory5Mustache.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory6Hat.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory7Antenna.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory8Brush.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Accessory9Horn.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body0Trooper.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body1Thin.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body2Thinnest.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body3Front.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body4Round.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body5Neck.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body6Ironman.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body7Neckthinner.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body8Big.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Body9Huge.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes0Squint.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes1Round.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes2Single.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes3Scott.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes4Roundsingle.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes5Roundsmall.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes6Walle.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes7Bar.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes8Smallbar.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Eyes9Shield.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face0C3po.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face1Rock.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face2Long.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face3Oval.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face4Cylinder.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face5Baloon.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face6Triangle.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face7Bent.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face8TriangleInv.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Face9Square.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth0Horz.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth1Cylinder.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth2Teeth.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth3Grid.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth4Vert.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth5Midopen.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth6Cell.kt (99%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth7Happy.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth8Buttons.kt (98%) rename commons/src/{main/java => commonMain/kotlin}/com/vitorpamplona/amethyst/commons/robohash/parts/Mouth9Closed.kt (98%) rename commons/src/{main/java => jvmAndroid/kotlin}/com/vitorpamplona/amethyst/commons/base64Image/Base64Image.kt (58%) rename commons/src/{main/java => jvmAndroid/kotlin}/com/vitorpamplona/amethyst/commons/richtext/ExpandableTextCutOffCalculator.kt (100%) rename commons/src/{main/java => jvmAndroid/kotlin}/com/vitorpamplona/amethyst/commons/richtext/GalleryParser.kt (100%) rename commons/src/{main/java => jvmAndroid/kotlin}/com/vitorpamplona/amethyst/commons/richtext/MediaContentModels.kt (100%) create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/richtext/Patterns.kt rename commons/src/{main/java => jvmAndroid/kotlin}/com/vitorpamplona/amethyst/commons/richtext/RichTextParser.kt (99%) rename commons/src/{main/java => jvmAndroid/kotlin}/com/vitorpamplona/amethyst/commons/richtext/RichTextParserSegments.kt (100%) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 44e046f046..9ecd75d86d 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -8,17 +8,26 @@ Fork of [Amethyst](https://github.com/vitorpamplona/amethyst) adding Compose Mul ``` amethyst/ -├── quartz/ # Nostr KMP library (converted to multiplatform) +├── quartz/ # Nostr KMP library (protocol only, no UI) │ └── src/ -│ ├── commonMain/ # Shared Nostr protocol +│ ├── commonMain/ # Shared Nostr protocol, data models │ ├── androidMain/ # Android-specific (crypto, storage) │ └── jvmMain/ # Desktop JVM-specific -├── desktopApp/ # Desktop JVM application -├── amethyst/ # Android app (existing) -├── ammolite/ # Support module -└── commons/ # Utilities +├── 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 | @@ -48,7 +57,52 @@ Use these specialized agents for domain expertise: - `/extract ` - Move composable to shared code - `/nip ` - Get NIP implementation guidance -## Development Workflow +## 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 diff --git a/.claude/agents/compose-ui.md b/.claude/agents/compose-ui.md index 2a8b837d85..d51b2eca8b 100644 --- a/.claude/agents/compose-ui.md +++ b/.claude/agents/compose-ui.md @@ -1,73 +1,60 @@ +--- +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 -## Expertise Domain +You are a Compose Multiplatform UI expert specializing in shared composables and desktop-specific features. -This agent specializes in Compose Multiplatform for building declarative UIs that share code across Android and Desktop JVM. +## Auto-Trigger Contexts -## Platform Support +Activate when user works with: +- `@Composable` functions +- `desktopApp/` module files +- `Window`, `MenuBar`, `Tray` components +- Navigation patterns (NavigationRail, screens) +- Material3 theming +- Keyboard shortcuts, context menus -| Platform | Status | Notes | -|----------|--------|-------| -| Android | Stable | Jetpack Compose | -| Desktop (JVM) | Stable | Windows, macOS, Linux | -| iOS | Beta | Future consideration | -| Web (Wasm) | Alpha | Future consideration | +## Core Knowledge -## Core Knowledge Areas - -### Shared Composables (commonMain) +### Desktop Entry Point ```kotlin -@Composable -fun NoteCard( - note: Note, - onReply: () -> Unit, - onRepost: () -> Unit, - onZap: () -> Unit, - modifier: Modifier = Modifier -) { - Card(modifier = modifier) { - Column(Modifier.padding(16.dp)) { - AuthorRow(note.author) - Spacer(Modifier.height(8.dp)) - Text(note.content) - Spacer(Modifier.height(8.dp)) - ActionRow(onReply, onRepost, onZap) +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 -```kotlin -// Window management -fun main() = application { - Window( - onCloseRequest = ::exitApplication, - state = rememberWindowState( - width = 1200.dp, - height = 800.dp, - position = WindowPosition.Aligned(Alignment.Center) - ), - title = "Amethyst Desktop" - ) { - App() - } -} -// Menu bar +**Menu Bar** +```kotlin MenuBar { Menu("File") { - Item("New Note", onClick = { }, shortcut = KeyShortcut(Key.N, ctrl = true)) + Item("New", shortcut = KeyShortcut(Key.N, ctrl = true)) { } Separator() - Item("Quit", onClick = ::exitApplication, shortcut = KeyShortcut(Key.Q, ctrl = true)) - } - Menu("View") { - Item("Feed", onClick = { navigateTo(Screen.Feed) }) - Item("Messages", onClick = { navigateTo(Screen.Messages) }) + Item("Quit", onClick = ::exitApplication) } } +``` -// System tray +**System Tray** +```kotlin Tray( icon = painterResource("icon.png"), menu = { @@ -75,169 +62,108 @@ Tray( Item("Exit", onClick = ::exitApplication) } ) +``` -// Keyboard shortcuts -Modifier.onKeyEvent { event -> - when { - event.isCtrlPressed && event.key == Key.Enter -> { - sendNote() - true - } - event.key == Key.Escape -> { - closeDialog() - true - } - else -> false - } -} - -// Context menus +**Context Menus** +```kotlin ContextMenuArea(items = { listOf( - ContextMenuItem("Copy") { copyToClipboard(note.content) }, - ContextMenuItem("Reply") { openReplyDialog(note) }, - ContextMenuItem("Repost") { repost(note) } + ContextMenuItem("Copy") { copyToClipboard(text) }, + ContextMenuItem("Reply") { openReply() } ) }) { - Text(note.content) + Text(content) } ``` -### Navigation Patterns +**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 -// Sidebar + Content pattern for Desktop @Composable fun DesktopLayout(currentScreen: Screen, onNavigate: (Screen) -> Unit) { Row(Modifier.fillMaxSize()) { - // Sidebar NavigationRail { NavigationRailItem( icon = { Icon(Icons.Default.Home, "Feed") }, - label = { Text("Feed") }, selected = currentScreen == Screen.Feed, onClick = { onNavigate(Screen.Feed) } ) - NavigationRailItem( - icon = { Icon(Icons.Default.Message, "Messages") }, - label = { Text("Messages") }, - selected = currentScreen == Screen.Messages, - onClick = { onNavigate(Screen.Messages) } - ) - NavigationRailItem( - icon = { Icon(Icons.Default.Person, "Profile") }, - label = { Text("Profile") }, - selected = currentScreen == Screen.Profile, - onClick = { onNavigate(Screen.Profile) } - ) + // More items... } - - // Content Box(Modifier.weight(1f)) { when (currentScreen) { Screen.Feed -> FeedScreen() Screen.Messages -> MessagesScreen() - Screen.Profile -> ProfileScreen() } } } } ``` -### State Management -```kotlin -// StateFlow for shared state -class FeedViewModel(private val repository: FeedRepository) { - private val _notes = MutableStateFlow>(emptyList()) - val notes: StateFlow> = _notes.asStateFlow() - - private val _isLoading = MutableStateFlow(false) - val isLoading: StateFlow = _isLoading.asStateFlow() - - fun loadFeed() { - viewModelScope.launch { - _isLoading.value = true - repository.getFeed() - .catch { /* handle error */ } - .collect { _notes.value = it } - _isLoading.value = false - } - } -} - -// In Composable -@Composable -fun FeedScreen(viewModel: FeedViewModel) { - val notes by viewModel.notes.collectAsState() - val isLoading by viewModel.isLoading.collectAsState() - - Box(Modifier.fillMaxSize()) { - LazyColumn { - items(notes, key = { it.id }) { note -> - NoteCard(note) - } - } - if (isLoading) { - CircularProgressIndicator(Modifier.align(Alignment.Center)) - } - } -} -``` - -## Agent Capabilities - -1. **Composable Design** - - Create shareable UI components - - Implement Material3 design - - Handle responsive layouts - -2. **Desktop UI Patterns** - - Window management (size, position, multi-window) - - Menu bars and context menus - - System tray integration - - Keyboard shortcuts - - Native file dialogs - -3. **Navigation Architecture** - - Screen-based navigation - - Platform-specific shells (sidebar vs bottom nav) - - Deep linking patterns - -4. **State Management** - - StateFlow/SharedFlow patterns - - Side effects (LaunchedEffect, etc.) - - ViewModel integration - -## Android vs Desktop Differences +### Platform Differences | Aspect | Android | Desktop | |--------|---------|---------| | **Entry** | Activity | main() + Window | -| **Navigation** | Bottom nav / Drawer | Sidebar / MenuBar | +| **Navigation** | Bottom nav | Sidebar / MenuBar | | **Input** | Touch | Mouse + Keyboard | | **Windows** | Single | Multi-window | -| **Menus** | Overflow menu | MenuBar | -| **Files** | SAF / MediaStore | JFileChooser | -| **Notifications** | System notifications | Tray notifications | -| **Clipboard** | ClipboardManager | Toolkit.clipboard | +| **Menus** | Overflow | MenuBar | -## Scope Boundaries +## Workflow -### In Scope -- Composable architecture and design -- UI component patterns -- Desktop-specific features (menus, tray, keyboard) -- Navigation patterns -- State → UI binding -- Theming and styling -- Responsive layouts +### 1. Assess Task +- Shared composable or desktop-specific? +- Navigation change or component work? +- State management needs? -### Out of Scope -- KMP project setup (use kotlin-multiplatform agent) -- Async data loading (use kotlin-coroutines agent) -- Nostr protocol details (use nostr-protocol agent) -- Business logic implementation +### 2. Investigate +```bash +# Find existing composables +grep -r "@Composable" desktopApp/src/ +# Check navigation structure +grep -r "Screen\|navigate" desktopApp/src/ +``` -## Key References -- [Compose Multiplatform](https://www.jetbrains.com/compose-multiplatform/) -- [Desktop Tutorials](https://github.com/JetBrains/compose-multiplatform/tree/master/tutorials) -- [Material3 Components](https://m3.material.io/components) +### 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 index 2f56b1f391..ed16799728 100644 --- a/.claude/agents/kotlin-coroutines.md +++ b/.claude/agents/kotlin-coroutines.md @@ -1,206 +1,121 @@ +--- +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 -## Expertise Domain +You are a Kotlin Coroutines expert specializing in async patterns, Flow, and structured concurrency. -This agent specializes in Kotlin coroutines and the kotlinx.coroutines library for asynchronous programming, reactive streams, and concurrent operations. +## Auto-Trigger Contexts -## Core Knowledge Areas +Activate when user works with: +- `suspend` functions +- `Flow`, `StateFlow`, `SharedFlow` +- `CoroutineScope`, `launch`, `async` +- `Channel`, `channelFlow` +- Dispatchers configuration +- Exception handling in coroutines -### Coroutine Fundamentals +## Core Knowledge + +### Coroutine Builders ```kotlin -// Suspending functions -suspend fun fetchNote(id: String): Note { - return withContext(Dispatchers.IO) { - api.getNote(id) - } -} +// launch: fire-and-forget +val job = launch { delay(1000); println("Done") } -// Coroutine builders -fun main() = runBlocking { - // launch: fire-and-forget, returns Job - val job = launch { - delay(1000) - println("World") - } - - // async: returns Deferred - val deferred = async { - computeValue() - } - val result = deferred.await() -} +// async: returns result +val deferred = async { computeValue() } +val result = deferred.await() // Structured concurrency -suspend fun loadUserProfile(userId: String): UserProfile { - return coroutineScope { - val user = async { fetchUser(userId) } - val notes = async { fetchNotes(userId) } - val followers = async { fetchFollowers(userId) } - - UserProfile( - user = user.await(), - notes = notes.await(), - followers = followers.await() - ) - } // All complete or all cancel together +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 | Notes | -|------------|----------|-------| -| `Dispatchers.Main` | UI updates | Main thread (Android/Desktop) | -| `Dispatchers.IO` | Network, disk | Optimized for blocking I/O | -| `Dispatchers.Default` | CPU-intensive | Parallelism = CPU cores | -| `Dispatchers.Unconfined` | Testing only | Runs in caller's thread | +| Dispatcher | Use Case | +|------------|----------| +| `Dispatchers.Main` | UI updates | +| `Dispatchers.IO` | Network, disk I/O | +| `Dispatchers.Default` | CPU-intensive | ### Flow (Cold Streams) ```kotlin -// Creating flows fun observeNotes(): Flow> = flow { while (true) { - val notes = repository.getNotes() - emit(notes) - delay(30_000) // Refresh every 30s + emit(repository.getNotes()) + delay(30_000) } } // Operators repository.observeNotes() - .map { notes -> notes.filter { it.isVisible } } + .map { it.filter { note -> note.isVisible } } .distinctUntilChanged() - .debounce(300) - .catch { e -> - log.error("Failed to load notes", e) - emit(emptyList()) - } + .catch { emit(emptyList()) } .flowOn(Dispatchers.IO) - .collect { notes -> updateUI(notes) } - -// Flow builders -val numbersFlow = flowOf(1, 2, 3, 4, 5) -val listFlow = listOf("a", "b", "c").asFlow() + .collect { updateUI(it) } ``` -### StateFlow & SharedFlow (Hot Streams) +### StateFlow (Hot, Always Has Value) ```kotlin -// StateFlow - always has a value, replays latest class FeedViewModel { private val _state = MutableStateFlow(FeedState()) val state: StateFlow = _state.asStateFlow() fun updateFilter(filter: Filter) { - _state.update { current -> - current.copy(filter = filter) - } + _state.update { it.copy(filter = filter) } } } +``` -// SharedFlow - no initial value, configurable replay -class EventBus { - private val _events = MutableSharedFlow( - replay = 0, - extraBufferCapacity = 64, - onBufferOverflow = BufferOverflow.DROP_OLDEST - ) - val events: SharedFlow = _events.asSharedFlow() - - suspend fun emit(event: AppEvent) { - _events.emit(event) - } -} +### SharedFlow (Hot, Configurable Replay) +```kotlin +private val _events = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 64, + onBufferOverflow = BufferOverflow.DROP_OLDEST +) +val events: SharedFlow = _events.asSharedFlow() ``` ### Channels ```kotlin -// Producer-consumer pattern -val channel = Channel(Channel.BUFFERED) - -// Producer -launch { - for (event in eventSource) { - channel.send(event) - } - channel.close() -} - -// Consumer -launch { - for (event in channel) { - process(event) - } -} - -// channelFlow for complex producers fun relayEvents(relay: Relay): Flow = channelFlow { relay.connect() - relay.onEvent { event -> - trySend(event) - } + relay.onEvent { event -> trySend(event) } awaitClose { relay.disconnect() } } ``` -### Cancellation & Exception Handling +### Exception Handling ```kotlin -// Cooperative cancellation -suspend fun processNotes(notes: List) { - for (note in notes) { - ensureActive() // Throws if cancelled - process(note) - yield() // Suspend point for cancellation - } +val handler = CoroutineExceptionHandler { _, e -> + log.error("Coroutine failed", e) } - -// Exception handling -val handler = CoroutineExceptionHandler { _, exception -> - log.error("Coroutine failed", exception) -} - val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default + handler) // supervisorScope: child failures don't cancel siblings supervisorScope { launch { task1() } // Can fail independently - launch { task2() } // Continues even if task1 fails + launch { task2() } // Continues if task1 fails } ``` -### Testing Coroutines -```kotlin -class FeedViewModelTest { - @Test - fun `loadFeed updates state with notes`() = runTest { - val repository = mockk() - coEvery { repository.getFeed() } returns flowOf(testNotes) - - val viewModel = FeedViewModel(repository) - viewModel.loadFeed() - - advanceUntilIdle() - - assertEquals(testNotes, viewModel.state.value.notes) - } -} - -// Inject test dispatcher -val testDispatcher = StandardTestDispatcher() -Dispatchers.setMain(testDispatcher) -``` - ## Nostr-Specific Patterns ### Relay Connection Pool ```kotlin class RelayPool(private val scope: CoroutineScope) { - private val relays = ConcurrentHashMap() - fun connect(url: String) { scope.launch { - val connection = RelayConnection(url) - relays[url] = connection - supervisorScope { launch { connection.receiveLoop() } launch { connection.sendLoop() } @@ -218,67 +133,55 @@ class RelayPool(private val scope: CoroutineScope) { ### Subscription Management ```kotlin fun subscribe(filters: List): Flow = channelFlow { - val subscriptionId = UUID.randomUUID().toString() - + val subId = UUID.randomUUID().toString() try { relayPool.activeRelays.collect { relays -> relays.forEach { relay -> - launch { - relay.subscribe(subscriptionId, filters) - .collect { send(it) } - } + launch { relay.subscribe(subId, filters).collect { send(it) } } } } } finally { - relayPool.unsubscribe(subscriptionId) + relayPool.unsubscribe(subId) } } ``` -## Agent Capabilities +## Workflow -1. **Async Architecture Design** - - Coroutine scope hierarchy - - Structured concurrency patterns - - Error propagation strategies +### 1. Assess Task +- Cold stream (Flow) or hot stream (StateFlow/SharedFlow)? +- Need structured concurrency? +- Error handling strategy? -2. **Flow Pipeline Design** - - Cold vs hot stream selection - - Operator chaining - - Backpressure handling +### 2. Investigate +```bash +# Find coroutine usage +grep -r "suspend \|launch\|async\|Flow<" quartz/src/ +# Check existing patterns +grep -r "CoroutineScope\|StateFlow" quartz/src/ +``` -3. **Concurrency Patterns** - - Parallel decomposition - - Rate limiting - - Resource pooling +### 3. Implement +- Use appropriate dispatcher +- Implement proper cancellation +- Handle exceptions at right level +- Use structured concurrency -4. **Testing Strategies** - - runTest usage - - Dispatcher injection - - Flow testing with Turbine +### 4. Test +```kotlin +@Test +fun `test async operation`() = runTest { + val result = viewModel.loadData() + advanceUntilIdle() + assertEquals(expected, result) +} +``` -5. **Performance Optimization** - - Dispatcher selection - - Buffer sizing - - Cancellation efficiency +## Constraints -## Scope Boundaries - -### In Scope -- kotlinx.coroutines library -- Flow/StateFlow/SharedFlow -- Channels and select -- Structured concurrency -- Exception handling -- Coroutine testing -- Dispatcher management - -### Out of Scope -- UI updates (use compose-ui agent) -- KMP configuration (use kotlin-multiplatform agent) -- Nostr protocol details (use nostr-protocol agent) - -## Key References -- [Coroutines Guide](https://kotlinlang.org/docs/coroutines-guide.html) -- [Flow Documentation](https://kotlinlang.org/docs/flow.html) -- [StateFlow/SharedFlow](https://kotlin.github.io/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/-state-flow/) +- 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 index 2e148cf269..419a7c7402 100644 --- a/.claude/agents/kotlin-multiplatform.md +++ b/.claude/agents/kotlin-multiplatform.md @@ -1,121 +1,54 @@ +--- +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 -## Expertise Domain +You are a Kotlin Multiplatform expert specializing in KMP architecture for Android and Desktop JVM targets. -This agent specializes in Kotlin Multiplatform (KMP) project architecture, enabling code sharing across Android, iOS, Desktop (JVM), and Web targets. +## Auto-Trigger Contexts -## Core Knowledge Areas +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 -### Project Structure -``` -module/ -├── build.gradle.kts -└── src/ - ├── commonMain/ # Shared code (all targets) - ├── commonTest/ # Shared tests - ├── androidMain/ # Android-specific - ├── jvmMain/ # Desktop JVM-specific - └── iosMain/ # iOS-specific (future) -``` +## Core Knowledge ### Source Set Hierarchy ``` - commonMain - │ - ┌───────────┼───────────┐ - │ │ │ - jvmMain nativeMain jsMain - │ │ - ┌──────┴───┐ ┌───┴───┐ - │ │ │ │ + commonMain + │ + ┌───────────┼───────────┐ + │ │ │ + jvmMain nativeMain jsMain + │ │ + ┌──────┴───┐ ┌───┴───┐ + │ │ │ │ androidMain desktopMain iosMain ``` -### expect/actual Mechanism +### expect/actual Pattern ```kotlin -// commonMain - Declaration only +// commonMain - Declaration expect class PlatformContext - expect fun getPlatform(): Platform -expect class SecureStorage { - fun store(key: String, value: ByteArray) - fun retrieve(key: String): ByteArray? -} - -// androidMain - Android implementation +// androidMain actual class PlatformContext(val context: Context) - actual fun getPlatform(): Platform = Platform.Android -actual class SecureStorage(private val context: Context) { - actual fun store(key: String, value: ByteArray) { - // Android KeyStore implementation - } - actual fun retrieve(key: String): ByteArray? = TODO() -} - -// jvmMain - Desktop implementation +// jvmMain (Desktop) actual class PlatformContext - actual fun getPlatform(): Platform = Platform.Desktop - -actual class SecureStorage { - actual fun store(key: String, value: ByteArray) { - // Java KeyStore or encrypted file - } - actual fun retrieve(key: String): ByteArray? = TODO() -} ``` -### Dependency Management -```kotlin -kotlin { - sourceSets { - commonMain.dependencies { - implementation(libs.kotlinx.coroutines.core) - implementation(libs.kotlinx.serialization.json) - } - androidMain.dependencies { - implementation(libs.secp256k1.kmp.jni.android) - } - jvmMain.dependencies { - implementation(libs.secp256k1.kmp.jni.jvm) - } - } -} -``` - -## Agent Capabilities - -1. **Project Configuration** - - Set up KMP modules from scratch - - Configure targets (Android, JVM, iOS) - - Manage Gradle build scripts - - Version catalog setup - -2. **Code Sharing Strategy** - - Identify shareable vs platform-specific code - - Design expect/actual interfaces - - Create intermediate source sets - - Maximize code reuse (target: 70-80%) - -3. **Dependency Selection** - - Recommend KMP-compatible libraries - - Handle platform-specific variants - - Resolve version conflicts - -4. **Migration Guidance** - - Port Android code to commonMain - - Extract platform abstractions - - Refactor for multiplatform - -5. **Build & Tooling** - - Gradle configuration - - IDE setup (Android Studio) - - CI/CD for multiple targets - -## Platform-Specific Patterns +### Platform Dependencies | Concern | Android | Desktop JVM | |---------|---------|-------------| @@ -123,39 +56,72 @@ kotlin { | **Storage** | Android KeyStore | Java KeyStore / File | | **Sodium** | lazysodium-android | lazysodium-java | | **UI** | Jetpack Compose | Compose Desktop | -| **Context** | Context object | None needed | -## Quartz KMP Conversion +## Workflow -Current Quartz is Android-only. Conversion steps: +### 1. Assess Task +- Identify if task involves shared vs platform-specific code +- Check existing source set structure +- Understand dependency requirements -1. **Add KMP plugin** to build.gradle.kts -2. **Define targets**: android(), jvm() -3. **Move shared code** to `src/commonMain/` -4. **Create expect declarations** for platform-specific APIs -5. **Implement actuals** in androidMain/jvmMain +### 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 -Key abstractions needed: - `CryptoProvider` - secp256k1 signing/verification - `SodiumProvider` - NIP-44 encryption -- `PlatformJson` - Jackson vs kotlinx.serialization +- `SecureStorage` - Key storage +- `PlatformContext` - Platform-specific context -## Scope Boundaries +## Constraints -### In Scope -- KMP project structure and configuration -- Source set hierarchy design -- expect/actual declarations -- Gradle multiplatform plugin -- Cross-platform library selection -- Migration from Android-only +- 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 -### Out of Scope -- UI implementation details (use compose-ui agent) -- Coroutine patterns (use kotlin-coroutines agent) -- Nostr protocol specifics (use nostr-protocol agent) +## Resources -## Key References -- [KMP Documentation](https://kotlinlang.org/docs/multiplatform.html) -- [expect/actual](https://kotlinlang.org/docs/multiplatform-expect-actual.html) -- [Hierarchical Structure](https://kotlinlang.org/docs/multiplatform-hierarchy.html) +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 index 06ac8de5d4..ea6073f667 100644 --- a/.claude/agents/nostr-protocol.md +++ b/.claude/agents/nostr-protocol.md @@ -1,19 +1,25 @@ +--- +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 -## Expertise Domain +You are a Nostr Protocol expert specializing in NIPs, events, relays, and cryptographic operations. -This agent specializes in the Nostr decentralized social protocol, covering 94+ NIPs (Nostr Implementation Possibilities) that define the protocol specification. +## Auto-Trigger Contexts -## Core Knowledge Areas +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 -### Protocol Fundamentals - -| Concept | Description | -|---------|-------------| -| **Events** | Signed JSON objects: id, pubkey, created_at, kind, tags, content, sig | -| **Relays** | WebSocket servers that store/forward events | -| **Keys** | secp256k1 keypairs, Schnorr signatures (BIP-340) | -| **Filters** | Subscription queries (kinds, authors, tags, since, until, limit) | +## Core Knowledge ### Event Structure ```kotlin @@ -26,77 +32,137 @@ data class Event( 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 Protocol** | 01, 02, 10, 11 | Basic events, follows, threads, relay info | -| **Messaging** | 04 (deprecated), 17, 44 | DMs, encrypted messaging | -| **Social** | 18, 25, 32, 38, 51, 52 | Reactions, reports, lists, communities | -| **Identity** | 05, 19, 39, 46, 55 | DNS, bech32, external identity, bunker, signer | -| **Media** | 23, 30, 54, 71, 94 | Long-form, audio, video, blobs | -| **Payments** | 47, 57, 60, 61 | Wallet Connect, zaps, cashu | -| **Relay** | 42, 65, 66 | Auth, relay lists, closed groups | +| **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 signatures on secp256k1 curve -- **Encryption**: NIP-44 (XChaCha20-Poly1305), NIP-04 (deprecated AES) -- **Key derivation**: BIP-32/39 compatible -- **Event ID**: SHA256 of `[0, pubkey, created_at, kind, tags, content]` -## Agent Capabilities +**Signing (Schnorr/BIP-340)** +```kotlin +val signature = Secp256k1.sign( + data = eventHash, + privateKey = privateKeyBytes +) +``` -1. **NIP Implementation Guidance** - - Explain any NIP specification - - Provide event structure examples - - Show relay message flows (REQ, EVENT, EOSE, CLOSE) - - Identify required vs optional fields +**NIP-44 Encryption (Modern)** +```kotlin +val ciphertext = Nip44.encrypt( + plaintext = message, + sharedSecret = computeSharedSecret(myPrivKey, theirPubKey) +) +``` -2. **Protocol Design Review** - - Validate event structures - - Check NIP compliance - - Suggest appropriate event kinds - - Review tag usage patterns +**NIP-04 Encryption (Deprecated)** +```kotlin +// AES-256-CBC - only for legacy compatibility +val ciphertext = Nip04.encrypt(message, sharedSecret) +``` -3. **Quartz Library Integration** - - Map NIPs to Quartz classes - - Explain existing implementations - - Guide new NIP additions to the codebase +### Common Event Kinds -4. **Security Analysis** - - Key management best practices - - Encryption scheme selection - - Signature verification patterns - - Privacy considerations +| 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 | -## Scope Boundaries +### Tag Patterns +```kotlin +// Reply threading (NIP-10) +tags = listOf( + listOf("e", rootEventId, relayUrl, "root"), + listOf("e", replyToId, relayUrl, "reply"), + listOf("p", authorPubkey) +) -### In Scope -- All NIP specifications and interactions -- Relay protocol and WebSocket message types -- Event signing and verification -- Key derivation and management -- Nostr-specific encryption (NIP-04, NIP-44) -- Quartz library architecture and classes +// Mentions +tags = listOf( + listOf("p", mentionedPubkey), + listOf("t", "hashtag") +) +``` -### Out of Scope -- General Kotlin/Android development (use kotlin-multiplatform agent) -- UI implementation details (use compose-ui agent) -- Generic async patterns (use kotlin-coroutines agent) -- Non-Nostr networking +## Workflow -## Key References -- [NIPs Repository](https://github.com/nostr-protocol/nips) -- [NIP-01: Basic Protocol](https://github.com/nostr-protocol/nips/blob/master/01.md) -- [NIP-44: Versioned Encryption](https://github.com/nostr-protocol/nips/blob/master/44.md) -- Quartz source: `quartz/src/` +### 1. Assess Task +- Which NIP(s) are involved? +- Event creation or parsing? +- Relay communication? +- Crypto operations needed? -## Example Queries +### 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/ +``` -- "How do I implement NIP-57 zap receipts?" -- "What's the correct tag structure for a reply?" -- "How does NIP-44 encryption work?" -- "Which event kind for a long-form article?" -- "How to verify a Schnorr signature in Quartz?" +### 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/.gitignore b/.gitignore index 72ba3e1b4d..b4fc788e4a 100644 --- a/.gitignore +++ b/.gitignore @@ -138,3 +138,6 @@ lint/generated/ lint/outputs/ lint/tmp/ # lint/reports/ + +# Local task tracking +TASKS.md diff --git a/commons/build.gradle b/commons/build.gradle deleted file mode 100644 index b37d1acc4b..0000000000 --- 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 0000000000..0c642c3c85 --- /dev/null +++ b/commons/build.gradle.kts @@ -0,0 +1,119 @@ +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) + + // 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 0000000000..975331bc33 --- /dev/null +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImageBitmap.kt @@ -0,0 +1,37 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.base64Image + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +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") +} diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt b/commons/src/androidMain/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/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt rename to commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt rename to commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt 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/main/java/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt b/commons/src/androidMain/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/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt similarity index 100% rename from commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt rename to commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt diff --git a/commons/src/main/java/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt b/commons/src/androidMain/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/androidMain/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 733dfdac20..8fdaf3fd25 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 ba955f4d66..28cbeb6f94 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 0a7ee0dd01..c47beb329d 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 5a4fc5ba20..1bb9aef792 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 de72f6eb4a..96be5b124f 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 f59def11ec..6c2543cd29 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 7cb20efa84..d8a9ba3322 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 6b907ede52..70111c9828 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 fec5833a7a..9a07e47aef 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 dc66289747..afec134fe3 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 281a1688d8..3b884cc179 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 5b27fd5bd6..037b8a7ecc 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 b883179111..24160dde7c 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 7ce7b8ae69..d1a17353fd 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 6a4d95899c..8e0a23216c 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 9add8ed4bd..2d307df199 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 4a40e3d165..668f43b67c 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 b98538cf00..eec30fc12e 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 2eb7b112d7..fe0b9cfc89 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 afeccd3aed..994b6db338 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 b509ebc10b..8e89cc1355 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 4db2c8b121..31349a88be 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 a599d032f9..ffbb79b226 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 6e11930eb8..2cad31d4ae 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 e996be38a9..e967053d23 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 5107eca3f9..49923f6887 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 acf40c45c7..d5169185ff 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 84ef32ff73..91a111e190 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/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 8811c0fb0a..d3c7a5c22f 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 99e81fb854..982196e72f 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 3f97d864f0..e58577de5e 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 2793862ab9..b467f1c615 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 3f6f9813dd..75ae3d3e64 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 bee5d04465..d52a26aaab 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 cf10cd4e38..d414a5302f 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 9967abd02e..ec6cad9332 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 ea3809b9cc..debad6a99a 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 9bf7fd5702..4802982ef1 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 3b94fcf84a..869baec5fa 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 a9baa4597a..c775585854 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 e84bd23ed8..f11ab8e24c 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 6d55022896..b20f22d084 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 ead54ed93c..520bc1566d 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 aa95e81089..646f5e3329 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 1ec10ab8b8..9a5115f7f2 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 ff939af00e..11bb4cd4db 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 62c505821d..373a62121b 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 58bef8c433..f6cf8f4070 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 d12739adbb..ab93ec93b3 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 841aef1620..98810d09dd 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 52486c701f..6e46a40db2 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 0535fad990..10ea055cff 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 fcf23af08b..0e38faffd0 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 f8ffd01ca2..2e6aa74106 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 5d3d553122..fe7d0b3f6f 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 4c30a32400..cfc62a1f2b 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 11e1bf901b..b30766bf90 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 7bd1093ad3..9ea12638ce 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 3064d1665b..a93424325a 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 20ab0a8754..900be2d902 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 4d18e34532..b353e95889 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 1fbf5f73d2..7ebd9acd82 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 6c5a51cab6..c7979de543 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 bc3979d891..39e25f75cb 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 8707de72c7..6f58c5a1ae 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 bc16e370fd..e6d72fb926 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 b592cd4ed4..591425eb9a 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 b9e888caa6..29de822820 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 d0c5bf806a..f7492afd6f 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 cfd5b8f7ba..240edc78ce 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 fb0de441ae..f7e6002f30 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 85925bc213..2f79172430 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 44cb1951f0..b74b0e6afd 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 6069be7996..dec4f16518 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 bf827cddeb..443a5b1836 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 080db59802..4af586f966 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 bece6f0914..f3dfe65ba9 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 30983dc1f9..b2620d5a3b 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 9981f43918..c936758844 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 2890d73768..e792b0adcb 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/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 cc26f55498..ac7bef89cb 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/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 0000000000..d0ef9308b5 --- /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 14bc34a4f6..243a079577 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 From e5a064d0c353a2065297281f0ac068f100698068 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sat, 27 Dec 2025 12:48:32 +0200 Subject: [PATCH 06/10] feat: Add expect/actual for blurhash and base64Image in commons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create PlatformImage expect/actual abstracting Bitmap/BufferedImage - Move pure blurhash algorithms (Base83, SRGB, CosineCache, BlurHashEncoder) to commonMain - Rewrite BlurHashDecoder to return PlatformImage instead of Bitmap - Add PlatformImage.toBlurhash() extension in commonMain - Add platform-specific Bitmap.toBlurhash() and BufferedImage.toBlurhash() - Add base64Image toPlatformImage() for both Android and JVM 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../commons/base64Image/Base64ImageBitmap.kt | 8 ++ .../amethyst/commons/blurhash/BitmapUtils.kt | 42 ++--------- .../commons/blurhash/PlatformImage.android.kt | 65 ++++++++++++++++ .../amethyst/commons/blurhash/Base83.kt | 0 .../commons/blurhash/BlurHashDecoder.kt | 7 +- .../commons/blurhash/BlurHashEncoder.kt | 0 .../commons/blurhash/BlurHashEncoderExt.kt | 64 ++++++++++++++++ .../amethyst/commons/blurhash/CosineCache.kt | 5 +- .../commons/blurhash/PlatformImage.kt | 65 ++++++++++++++++ .../amethyst/commons/blurhash/SRGB.kt | 0 .../base64Image/Base64ImagePlatform.jvm.kt | 45 +++++++++++ .../commons/blurhash/BitmapUtils.jvm.kt | 29 +++++++ .../commons/blurhash/PlatformImage.jvm.kt | 75 +++++++++++++++++++ 13 files changed, 362 insertions(+), 43 deletions(-) create mode 100644 commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.android.kt rename commons/src/{androidMain => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/blurhash/Base83.kt (100%) rename commons/src/{androidMain => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt (97%) rename commons/src/{androidMain => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoder.kt (100%) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoderExt.kt rename commons/src/{androidMain => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt (96%) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.kt rename commons/src/{androidMain => commonMain}/kotlin/com/vitorpamplona/amethyst/commons/blurhash/SRGB.kt (100%) create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImagePlatform.jvm.kt create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.jvm.kt create mode 100644 commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/PlatformImage.jvm.kt 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 index 975331bc33..9340c5d08e 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImageBitmap.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/base64Image/Base64ImageBitmap.kt @@ -22,6 +22,8 @@ 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 { @@ -35,3 +37,9 @@ fun Base64Image.toBitmap(content: String): Bitmap { 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 index 7a3bd652c9..bf40e816b6 100644 --- a/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt +++ b/commons/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BitmapUtils.kt @@ -21,41 +21,9 @@ package com.vitorpamplona.amethyst.commons.blurhash import android.graphics.Bitmap -import kotlin.math.roundToInt -fun Bitmap.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() - } - - val intArray = IntArray(width * height) - this.getPixels(intArray, 0, width, 0, 0, width, height) - - val numX = - if (aspectRatio > 1) { - 9 - } else if (aspectRatio < 1) { - (9 * aspectRatio).roundToInt() - } else { - 4 - } - - val numY = - if (aspectRatio > 1) { - (9 * (1 / aspectRatio)).roundToInt() - } else if (aspectRatio < 1) { - 9 - } else { - 4 - } - - return BlurHashEncoder().encode( - intArray, - width, - height, - numX, - numY, - ) -} +/** + * 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/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 0000000000..b864da631c --- /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/androidMain/kotlin/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/androidMain/kotlin/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/androidMain/kotlin/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/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashDecoder.kt index baf540fd39..140f3c86c3 100644 --- a/commons/src/androidMain/kotlin/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/androidMain/kotlin/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/androidMain/kotlin/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/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoderExt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoderExt.kt new file mode 100644 index 0000000000..f476afb6a2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/BlurHashEncoderExt.kt @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 kotlin.math.roundToInt + +/** + * 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 this.scale(100, (100 / aspectRatio).toInt()).toBlurhash() + } + + val intArray = IntArray(width * height) + this.getPixels(intArray, 0, width, 0, 0, width, height) + + val numX = + if (aspectRatio > 1) { + 9 + } else if (aspectRatio < 1) { + (9 * aspectRatio).roundToInt() + } else { + 4 + } + + val numY = + if (aspectRatio > 1) { + (9 * (1 / aspectRatio)).roundToInt() + } else if (aspectRatio < 1) { + 9 + } else { + 4 + } + + return BlurHashEncoder().encode( + intArray, + width, + height, + numX, + numY, + ) +} diff --git a/commons/src/androidMain/kotlin/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/androidMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/blurhash/CosineCache.kt index 7248cf5a13..a3d609915a 100644 --- a/commons/src/androidMain/kotlin/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 0000000000..2e745ca8e1 --- /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/androidMain/kotlin/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/androidMain/kotlin/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/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 0000000000..6c523a33e2 --- /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 0000000000..7cb20fc93f --- /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 0000000000..8d4c990f63 --- /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 From 201acc8140ebd077e13248fdf5ddd6303ad54aae Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sat, 27 Dec 2025 15:12:04 +0200 Subject: [PATCH 07/10] feat: Extract shared UI components to commons for Android/Desktop reuse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract reusable components from desktopApp to commons module: Utilities (commonMain): - NumberFormatters: countToHumanReadable, countToHumanReadableBytes - PubKeyFormatter: toShortDisplay, toDisplayHexKey for key formatting Utilities (jvmAndroid): - TimeAgoFormatter: Human-readable time formatting for Nostr timestamps - ZapFormatter: BigDecimal amount formatting with G/M/k suffixes UI Components (commonMain): - AppScreen enum for shared navigation - LoadingState, EmptyState, ErrorState with refresh/retry support - FeedHeader with relay status indicator - NoteCard for displaying Nostr notes - ActionButtons (AddButton, RemoveButton) - RobohashImage for avatar display - PlaceholderScreens (Search, Messages, Notifications) - RelayStatusColors and shared color definitions UI Components (jvmAndroid): - LoginCard with key input field - NewKeyWarningCard for new key backup warnings - KeyInputField and SelectableKeyText - ProfileInfoCard for account display - RelayStatusCard for relay management Core (jvmAndroid): - AccountManager with login/logout/key generation - RelayConnectionManager base class - RelayStatus and DefaultRelays 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- commons/build.gradle.kts | 2 + .../amethyst/commons/navigation/AppScreen.kt | 55 ++++ .../commons/ui/components/ActionButtons.kt | 92 +++++++ .../commons/ui/components/LoadingState.kt | 158 +++++++++++ .../commons/ui/components/RobohashImage.kt | 110 ++++++++ .../amethyst/commons/ui/feed/FeedHeader.kt | 116 ++++++++ .../commons/ui/screens/PlaceholderScreens.kt | 89 +++++++ .../amethyst/commons/ui/theme/Colors.kt | 84 ++++++ .../amethyst/commons/util/NumberFormatters.kt | 92 +++++++ .../amethyst/commons/util/PubKeyFormatter.kt | 45 ++++ .../commons}/account/AccountManager.kt | 50 ++-- .../commons/network/RelayConnectionManager.kt | 140 ++++++++++ .../amethyst/commons/network/RelayStatus.kt | 48 ++++ .../amethyst/commons/ui/auth/KeyInputField.kt | 107 ++++++++ .../amethyst/commons/ui/auth/LoginCard.kt | 132 ++++++++++ .../commons/ui/auth/NewKeyWarningCard.kt | 112 ++++++++ .../amethyst/commons/ui/note/NoteCard.kt | 188 +++++++++++++ .../commons/ui/profile/ProfileInfoCard.kt | 89 +++++++ .../commons/ui/relay/RelayStatusCard.kt | 131 ++++++++++ .../amethyst/commons/util/EventExtensions.kt | 44 ++++ .../amethyst/commons/util/TimeAgoFormatter.kt | 118 +++++++++ .../amethyst/commons/util/ZapFormatter.kt | 82 ++++++ desktopApp/build.gradle.kts | 3 + .../vitorpamplona/amethyst/desktop/Main.kt | 247 +++--------------- .../network/DesktopRelayConnectionManager.kt | 133 +--------- .../amethyst/desktop/ui/FeedScreen.kt | 180 ++----------- .../amethyst/desktop/ui/LoginScreen.kt | 202 ++------------ .../quartz/nip01Core/core/HexKey.kt | 3 + 28 files changed, 2145 insertions(+), 707 deletions(-) create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt create mode 100644 commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt rename {desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop => commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons}/account/AccountManager.kt (78%) create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt create mode 100644 commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 0c642c3c85..e66de925e7 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -62,6 +62,8 @@ kotlin { implementation(compose.ui) implementation(compose.foundation) implementation(compose.runtime) + implementation(compose.material3) + implementation(compose.materialIconsExtended) // LruCache (KMP-ready) implementation(libs.androidx.collection) 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 0000000000..c6b005d0b3 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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/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 0000000000..93ab2de846 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 0000000000..b549466dd1 --- /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 0000000000..f5dfce7362 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt @@ -0,0 +1,110 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 0000000000..7884b9ba21 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt @@ -0,0 +1,116 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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.graphics.Color +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 0000000000..92820ee25c --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 0000000000..5ff1b209ae --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt @@ -0,0 +1,84 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 0000000000..f64005f08f --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt @@ -0,0 +1,92 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 0000000000..40520d4ab8 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt @@ -0,0 +1,45 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt similarity index 78% rename from desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt index 91f544c254..6fb4df3e38 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -18,7 +18,7 @@ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ -package com.vitorpamplona.amethyst.desktop.account +package com.vitorpamplona.amethyst.commons.account import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.asStateFlow sealed class AccountState { data object LoggedOut : AccountState() + data class LoggedIn( val signer: NostrSigner, val pubKeyHex: String, @@ -52,13 +53,14 @@ class AccountManager { 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, - ) + 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 } @@ -73,13 +75,14 @@ class AccountManager { 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, - ) + 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) { @@ -94,13 +97,14 @@ class AccountManager { 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, - ) + 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) { 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 0000000000..c14c1365fe --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt @@ -0,0 +1,140 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 0000000000..decd6428f9 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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/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 0000000000..e93045d37d --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt @@ -0,0 +1,107 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 0000000000..951089a5bd --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt @@ -0,0 +1,132 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 0000000000..a5926a37e0 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt @@ -0,0 +1,112 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 0000000000..a82e4faf86 --- /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 0000000000..2a6b01d675 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.kt @@ -0,0 +1,89 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 0000000000..a523f2e05d --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt @@ -0,0 +1,131 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 0000000000..8f8b8d078c --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 0000000000..1b2fb8ea0a --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt @@ -0,0 +1,118 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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 0000000000..d28deafef8 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2024 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT 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/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 86b7367346..5bd8c6c60a 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -25,6 +25,9 @@ dependencies { // 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) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 5f02774451..1027609e4c 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -30,13 +30,10 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Email import androidx.compose.material.icons.filled.Home import androidx.compose.material.icons.filled.Notifications @@ -47,7 +44,6 @@ import androidx.compose.material.icons.filled.Settings import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -78,22 +74,18 @@ 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.desktop.account.AccountManager -import com.vitorpamplona.amethyst.desktop.account.AccountState +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.network.RelayStatus import com.vitorpamplona.amethyst.desktop.ui.FeedScreen import com.vitorpamplona.amethyst.desktop.ui.LoginScreen -enum class Screen { - Feed, - Search, - Messages, - Notifications, - Profile, - Settings -} - fun main() = application { val windowState = rememberWindowState( width = 1200.dp, @@ -147,7 +139,7 @@ fun main() = application { @Composable fun App() { - var currentScreen by remember { mutableStateOf(Screen.Feed) } + var currentScreen by remember { mutableStateOf(AppScreen.Feed) } val relayManager = remember { DesktopRelayConnectionManager() } val accountManager = remember { AccountManager() } val accountState by accountManager.accountState.collectAsState() @@ -171,7 +163,7 @@ fun App() { is AccountState.LoggedOut -> { LoginScreen( accountManager = accountManager, - onLoginSuccess = { currentScreen = Screen.Feed } + onLoginSuccess = { currentScreen = AppScreen.Feed } ) } is AccountState.LoggedIn -> { @@ -190,8 +182,8 @@ fun App() { @Composable fun MainContent( - currentScreen: Screen, - onScreenChange: (Screen) -> Unit, + currentScreen: AppScreen, + onScreenChange: (AppScreen) -> Unit, relayManager: DesktopRelayConnectionManager, accountManager: AccountManager, account: AccountState.LoggedIn, @@ -207,36 +199,36 @@ fun MainContent( NavigationRailItem( icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, label = { Text("Feed") }, - selected = currentScreen == Screen.Feed, - onClick = { onScreenChange(Screen.Feed) } + selected = currentScreen == AppScreen.Feed, + onClick = { onScreenChange(AppScreen.Feed) } ) NavigationRailItem( icon = { Icon(Icons.Default.Search, contentDescription = "Search") }, label = { Text("Search") }, - selected = currentScreen == Screen.Search, - onClick = { onScreenChange(Screen.Search) } + selected = currentScreen == AppScreen.Search, + onClick = { onScreenChange(AppScreen.Search) } ) NavigationRailItem( icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, label = { Text("DMs") }, - selected = currentScreen == Screen.Messages, - onClick = { onScreenChange(Screen.Messages) } + selected = currentScreen == AppScreen.Messages, + onClick = { onScreenChange(AppScreen.Messages) } ) NavigationRailItem( icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") }, label = { Text("Alerts") }, - selected = currentScreen == Screen.Notifications, - onClick = { onScreenChange(Screen.Notifications) } + selected = currentScreen == AppScreen.Notifications, + onClick = { onScreenChange(AppScreen.Notifications) } ) NavigationRailItem( icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, label = { Text("Profile") }, - selected = currentScreen == Screen.Profile, - onClick = { onScreenChange(Screen.Profile) } + selected = currentScreen == AppScreen.Profile, + onClick = { onScreenChange(AppScreen.Profile) } ) Spacer(Modifier.weight(1f)) @@ -246,8 +238,8 @@ fun MainContent( NavigationRailItem( icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, label = { Text("Settings") }, - selected = currentScreen == Screen.Settings, - onClick = { onScreenChange(Screen.Settings) } + selected = currentScreen == AppScreen.Settings, + onClick = { onScreenChange(AppScreen.Settings) } ) Spacer(Modifier.height(16.dp)) @@ -260,64 +252,17 @@ fun MainContent( modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp) ) { when (currentScreen) { - Screen.Feed -> FeedScreen(relayManager) - Screen.Search -> SearchPlaceholder() - Screen.Messages -> MessagesPlaceholder() - Screen.Notifications -> NotificationsPlaceholder() - Screen.Profile -> ProfileScreen(account, accountManager) - Screen.Settings -> RelaySettingsScreen(relayManager) + AppScreen.Feed -> FeedScreen(relayManager) + AppScreen.Search -> SearchPlaceholder() + AppScreen.Messages -> MessagesPlaceholder() + AppScreen.Notifications -> NotificationsPlaceholder() + AppScreen.Profile -> ProfileScreen(account, accountManager) + AppScreen.Settings -> RelaySettingsScreen(relayManager) } } } } -@Composable -fun SearchPlaceholder() { - Column { - Text( - "Search", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(Modifier.height(16.dp)) - Text( - "Search for users, notes, and hashtags.", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - -@Composable -fun MessagesPlaceholder() { - Column { - Text( - "Messages", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(Modifier.height(16.dp)) - Text( - "Your encrypted direct messages will appear here.", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} - -@Composable -fun NotificationsPlaceholder() { - Column { - Text( - "Notifications", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground - ) - Spacer(Modifier.height(16.dp)) - Text( - "Mentions, replies, and reactions will appear here.", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } -} @Composable fun ProfileScreen(account: AccountState.LoggedIn, accountManager: AccountManager) { @@ -329,47 +274,11 @@ fun ProfileScreen(account: AccountState.LoggedIn, accountManager: AccountManager ) Spacer(Modifier.height(16.dp)) - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Column(modifier = Modifier.padding(16.dp)) { - if (account.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( - account.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( - account.pubKeyHex, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface - ) - } - } + ProfileInfoCard( + npub = account.npub, + pubKeyHex = account.pubKeyHex, + isReadOnly = account.isReadOnly, + ) Spacer(Modifier.height(24.dp)) @@ -451,9 +360,10 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { modifier = Modifier.weight(1f) ) { items(relayStatuses.values.toList()) { status -> - RelayStatusCard(status) { - relayManager.removeRelay(status.url) - } + RelayStatusCard( + status = status, + onRemove = { relayManager.removeRelay(status.url) } + ) } } @@ -466,82 +376,3 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { } } } - -@Composable -fun RelayStatusCard(status: RelayStatus, onRemove: () -> Unit) { - 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/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt index a8583c4fd7..c143b4d7b2 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt @@ -20,132 +20,13 @@ */ package com.vitorpamplona.amethyst.desktop.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.amethyst.commons.network.RelayConnectionManager import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow -data class RelayStatus( - val url: NormalizedRelayUrl, - val connected: Boolean, - val pingMs: Int? = null, - val compressed: Boolean = false, - val error: String? = null, +/** + * 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) ) - -class DesktopRelayConnectionManager : IRelayClientListener { - private val websocketBuilder = BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient) - 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() - - companion object { - val DEFAULT_RELAYS = listOf( - "wss://relay.damus.io", - "wss://relay.nostr.band", - "wss://nos.lol", - "wss://relay.snort.social", - "wss://nostr.wine", - ) - } - - 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() { - DEFAULT_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/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 8eef7d1e6d..5ebb75994e 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -22,49 +22,29 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.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.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Refresh -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.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.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextOverflow 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 -import com.vitorpamplona.quartz.nip19Bech32.toNpub -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale @Composable fun FeedScreen(relayManager: DesktopRelayConnectionManager) { @@ -126,159 +106,29 @@ fun FeedScreen(relayManager: DesktopRelayConnectionManager) { } Column(modifier = Modifier.fillMaxSize()) { - // Header - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - "Global Feed", - style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground - ) - - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - val connected = connectedRelays.size - val color = when { - connected == 0 -> Color.Red - connected < 3 -> Color.Yellow - else -> Color.Green - } - Icon( - if (connected > 0) Icons.Default.Check else Icons.Default.Close, - contentDescription = null, - tint = color, - modifier = Modifier.size(16.dp) - ) - Text( - "$connected relay${if (connected != 1) "s" else ""}", - color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall - ) - IconButton(onClick = { relayManager.connect() }) { - Icon( - Icons.Default.Refresh, - contentDescription = "Reconnect", - tint = MaterialTheme.colorScheme.primary - ) - } - } - } + FeedHeader( + title = "Global Feed", + connectedRelayCount = connectedRelays.size, + onRefresh = { relayManager.connect() } + ) Spacer(Modifier.height(16.dp)) if (connectedRelays.isEmpty()) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - CircularProgressIndicator() - Spacer(Modifier.height(16.dp)) - Text( - "Connecting to relays...", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + LoadingState("Connecting to relays...") } else if (events.isEmpty()) { - Column( - modifier = Modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center - ) { - CircularProgressIndicator() - Spacer(Modifier.height(16.dp)) - Text( - "Loading notes...", - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } + LoadingState("Loading notes...") } else { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp) ) { items(events, key = { it.id }) { event -> - NoteCard(event) + // Use NoteCard from commons + NoteCard( + note = event.toNoteDisplayData() + ) } } } } } - -@Composable -fun NoteCard(event: Event) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) - ) { - Column(modifier = Modifier.padding(12.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - // Author npub (truncated) - val npub = try { - event.pubKey.hexToByteArrayOrNull()?.toNpub() ?: event.pubKey.take(16) + "..." - } catch (e: Exception) { - event.pubKey.take(16) + "..." - } - Text( - text = npub.take(20) + "...", - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, - maxLines = 1 - ) - - // Timestamp - val date = Date(event.createdAt * 1000) - val format = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault()) - Text( - text = format.format(date), - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - - Spacer(Modifier.height(8.dp)) - - Text( - text = event.content, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 10, - overflow = TextOverflow.Ellipsis - ) - - 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: ${event.id.take(12)}...", - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f) - ) - } - } -} - -private fun String.hexToByteArrayOrNull(): ByteArray? { - return try { - if (length % 2 != 0) return null - ByteArray(length / 2) { i -> - substring(i * 2, i * 2 + 2).toInt(16).toByte() - } - } catch (e: Exception) { - null - } -} 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 index 2f9ba4fb14..28632b99bb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt @@ -22,24 +22,11 @@ package com.vitorpamplona.amethyst.desktop.ui import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Visibility -import androidx.compose.material.icons.filled.VisibilityOff -import androidx.compose.material3.Button -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -48,21 +35,17 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.input.PasswordVisualTransformation -import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp -import com.vitorpamplona.amethyst.desktop.account.AccountManager -import com.vitorpamplona.amethyst.desktop.account.AccountState +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 keyInput by remember { mutableStateOf("") } - var showKey by remember { mutableStateOf(false) } - var errorMessage by remember { mutableStateOf(null) } var showNewKeyDialog by remember { mutableStateOf(false) } var generatedAccount by remember { mutableStateOf(null) } @@ -87,97 +70,23 @@ fun LoginScreen( Spacer(Modifier.height(48.dp)) - Card( - modifier = Modifier.width(400.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), - ) { - Column( - modifier = Modifier.padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Text( - "Login with your Nostr key", - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - ) - - Spacer(Modifier.height(16.dp)) - - OutlinedTextField( - value = keyInput, - onValueChange = { - keyInput = it - errorMessage = null - }, - label = { Text("nsec or npub") }, - placeholder = { Text("nsec1... or npub1...") }, - 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) } - }, - ) - - Spacer(Modifier.height(8.dp)) - - Text( - "Use nsec for full access or npub for read-only", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - - Spacer(Modifier.height(16.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Button( - onClick = { - accountManager.loginWithKey(keyInput).fold( - onSuccess = { onLoginSuccess() }, - onFailure = { errorMessage = it.message }, - ) - }, - modifier = Modifier.weight(1f), - enabled = keyInput.isNotBlank(), - ) { - Text("Login") - } - - OutlinedButton( - onClick = { - generatedAccount = accountManager.generateNewAccount() - showNewKeyDialog = true - }, - modifier = Modifier.weight(1f), - ) { - Text("Generate New") - } + LoginCard( + onLogin = { keyInput -> + accountManager.loginWithKey(keyInput).map { + onLoginSuccess() } - } - } + }, + onGenerateNew = { + generatedAccount = accountManager.generateNewAccount() + showNewKeyDialog = true + }, + ) if (showNewKeyDialog && generatedAccount != null) { Spacer(Modifier.height(24.dp)) - NewKeyCard( - account = generatedAccount!!, + NewKeyWarningCard( + npub = generatedAccount!!.npub, + nsec = generatedAccount!!.nsec, onContinue = { showNewKeyDialog = false onLoginSuccess() @@ -186,80 +95,3 @@ fun LoginScreen( } } } - -@Composable -fun NewKeyCard( - account: AccountState.LoggedIn, - onContinue: () -> Unit, -) { - Card( - modifier = Modifier.width(500.dp), - 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(account.npub) - - Spacer(Modifier.height(12.dp)) - - account.nsec?.let { nsec -> - Text( - "Secret Key (NEVER share this!):", - style = MaterialTheme.typography.labelMedium, - color = Color.Red, - ) - SelectableKeyText(nsec) - } - - Spacer(Modifier.height(24.dp)) - - Button( - onClick = onContinue, - modifier = Modifier.fillMaxWidth(), - ) { - Text("I've saved my keys, continue") - } - } - } -} - -@Composable -fun SelectableKeyText(key: String) { - Card( - 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/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt index eae01d7264..c7ffb06561 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,9 @@ 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 From b23ff488edf2a988d73377536255ee39eca411a3 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sun, 28 Dec 2025 07:03:57 +0200 Subject: [PATCH 08/10] refactor: Polish shared commons components and fix formatting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Improve RelayConnectionManager with better connection handling - Refine UI components (LoginCard, ProfileInfoCard, RelayStatusCard) - Update KeyInputField with improved layout - Fix formatting in FeedHeader and AppScreen - Add proper number formatting utilities - Remove .claude config from version control 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- .../amethyst/service/images/Base64Fetcher.kt | 3 +- .../service/images/BlurHashFetcher.kt | 5 ++- .../amethyst/commons/navigation/AppScreen.kt | 24 ++++++----- .../commons/ui/components/ActionButtons.kt | 2 +- .../commons/ui/components/RobohashImage.kt | 2 +- .../amethyst/commons/ui/feed/FeedHeader.kt | 26 ++++++------ .../commons/ui/screens/PlaceholderScreens.kt | 6 +-- .../amethyst/commons/ui/theme/Colors.kt | 2 +- .../amethyst/commons/util/NumberFormatters.kt | 12 ++++-- .../amethyst/commons/util/PubKeyFormatter.kt | 2 +- .../commons/network/RelayConnectionManager.kt | 40 ++++++++++++++----- .../amethyst/commons/network/RelayStatus.kt | 17 ++++---- .../amethyst/commons/ui/auth/KeyInputField.kt | 27 +++++++------ .../amethyst/commons/ui/auth/LoginCard.kt | 9 +++-- .../commons/ui/auth/NewKeyWarningCard.kt | 9 +++-- .../commons/ui/profile/ProfileInfoCard.kt | 19 ++++----- .../commons/ui/relay/RelayStatusCard.kt | 38 +++++++++--------- .../amethyst/commons/util/EventExtensions.kt | 15 +++---- .../amethyst/commons/util/TimeAgoFormatter.kt | 2 +- .../amethyst/commons/util/ZapFormatter.kt | 2 +- .../quartz/nip01Core/core/HexKey.kt | 3 +- 21 files changed, 152 insertions(+), 113 deletions(-) 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 50b1621bfb..bbab146dab 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 c755f8d14e..f128bcd022 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/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt index c6b005d0b3..e3502dfc23 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -39,17 +39,19 @@ enum class AppScreen( /** * 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, -) +val primaryScreens = + listOf( + AppScreen.Feed, + AppScreen.Search, + AppScreen.Messages, + AppScreen.Notifications, + AppScreen.Profile, + ) /** * Secondary navigation destinations (settings, etc.) */ -val secondaryScreens = listOf( - AppScreen.Settings, -) +val secondaryScreens = + listOf( + AppScreen.Settings, + ) 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 index 93ab2de846..ea87b8ed12 100644 --- 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 @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in 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 index f5dfce7362..82e0123bf3 100644 --- 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 @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in 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 index 7884b9ba21..36efd264dc 100644 --- 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 @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -35,7 +35,6 @@ 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.ui.theme.RelayStatusColors @@ -57,12 +56,12 @@ fun FeedHeader( Row( modifier = modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { Text( title, style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) RelayStatusIndicator( @@ -84,32 +83,33 @@ fun RelayStatusIndicator( Row( modifier = modifier, verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - val statusColor = when { - connectedCount == 0 -> RelayStatusColors.Disconnected - connectedCount < 3 -> RelayStatusColors.Connecting - else -> RelayStatusColors.Connected - } + 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) + modifier = Modifier.size(16.dp), ) Text( "$connectedCount relay${if (connectedCount != 1) "s" else ""}", color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodySmall + style = MaterialTheme.typography.bodySmall, ) IconButton(onClick = onRefresh) { Icon( Icons.Default.Refresh, contentDescription = "Reconnect", - tint = MaterialTheme.colorScheme.primary + 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 index 92820ee25c..4048a5c025 100644 --- 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 @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -42,12 +42,12 @@ fun PlaceholderScreen( Text( title, style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) Spacer(Modifier.height(16.dp)) Text( description, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } 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 index 5ff1b209ae..edca6b6c34 100644 --- 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 @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in 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 index f64005f08f..abb8d1d472 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -26,7 +26,10 @@ 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 = +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" @@ -37,7 +40,10 @@ fun countToHumanReadable(counter: Int, suffix: String): String = /** * Formats a count to human-readable format with suffix (K, M, G). */ -fun countToHumanReadable(counter: Long, suffix: String): String = +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" 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 index 40520d4ab8..250e17ea1a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in 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 index c14c1365fe..45f9571c22 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -92,7 +92,10 @@ open class RelayConnectionManager( client.close(subId) } - fun send(event: Event, relays: Set = connectedRelays.value) { + fun send( + event: Event, + relays: Set = connectedRelays.value, + ) { client.send(event, relays) } @@ -100,10 +103,11 @@ open class RelayConnectionManager( url: NormalizedRelayUrl, update: (RelayStatus) -> RelayStatus, ) { - _relayStatuses.value = _relayStatuses.value.toMutableMap().apply { - val current = this[url] ?: RelayStatus(url, connected = false) - this[url] = update(current) - } + _relayStatuses.value = + _relayStatuses.value.toMutableMap().apply { + val current = this[url] ?: RelayStatus(url, connected = false) + this[url] = update(current) + } } override fun onConnecting(relay: IRelayClient) { @@ -112,7 +116,11 @@ open class RelayConnectionManager( } } - override fun onConnected(relay: IRelayClient, pingMillis: Int, compressed: Boolean) { + override fun onConnected( + relay: IRelayClient, + pingMillis: Int, + compressed: Boolean, + ) { updateRelayStatus(relay.url) { it.copy(connected = true, pingMs = pingMillis, compressed = compressed, error = null) } @@ -124,17 +132,29 @@ open class RelayConnectionManager( } } - override fun onCannotConnect(relay: IRelayClient, errorMessage: String) { + 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) { + 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) { + 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 index decd6428f9..f446c7eba3 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -38,11 +38,12 @@ data class RelayStatus( * 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", - ) + 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/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt index e93045d37d..e27629c9fb 100644 --- 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 @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -63,11 +63,12 @@ fun KeyInputField( placeholder = { Text(placeholder) }, modifier = modifier.fillMaxWidth(), singleLine = true, - visualTransformation = if (showKey) { - VisualTransformation.None - } else { - PasswordVisualTransformation() - }, + visualTransformation = + if (showKey) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, trailingIcon = { IconButton(onClick = { showKey = !showKey }) { Icon( @@ -77,9 +78,10 @@ fun KeyInputField( } }, isError = errorMessage != null, - supportingText = errorMessage?.let { - { Text(it, color = MaterialTheme.colorScheme.error) } - }, + supportingText = + errorMessage?.let { + { Text(it, color = MaterialTheme.colorScheme.error) } + }, ) } @@ -93,9 +95,10 @@ fun SelectableKeyText( ) { Card( modifier = modifier, - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surface, - ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), ) { Text( text = key, 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 index 951089a5bd..a80912b913 100644 --- 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 @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -68,9 +68,10 @@ fun LoginCard( Card( modifier = modifier.width(cardWidth), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), ) { Column( modifier = Modifier.padding(24.dp), 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 index a5926a37e0..4fa976aa97 100644 --- 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 @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -57,9 +57,10 @@ fun NewKeyWarningCard( ) { Card( modifier = modifier.width(cardWidth), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), - ), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f), + ), ) { Column( modifier = Modifier.padding(24.dp), 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 index 2a6b01d675..53bf73bbce 100644 --- 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 @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -47,16 +47,17 @@ fun ProfileInfoCard( ) { Card( modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) + 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 + color = Color.Yellow, ) Spacer(Modifier.height(8.dp)) } @@ -64,12 +65,12 @@ fun ProfileInfoCard( Text( "Public Key", style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( npub, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface + color = MaterialTheme.colorScheme.onSurface, ) Spacer(Modifier.height(16.dp)) @@ -77,12 +78,12 @@ fun ProfileInfoCard( Text( "Hex", style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( pubKeyHex, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurface + 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 index a523f2e05d..c82c5102b0 100644 --- 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 @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -55,44 +55,46 @@ fun RelayStatusCard( ) { Card( modifier = modifier.fillMaxWidth(), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant - ) + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), ) { Row( modifier = Modifier.fillMaxWidth().padding(12.dp), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween + horizontalArrangement = Arrangement.SpaceBetween, ) { Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { - val statusColor = when { - status.connected -> Color.Green - status.error != null -> Color.Red - else -> Color.Gray - } + 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) + modifier = Modifier.size(20.dp), ) } else if (status.error != null) { Icon( Icons.Default.Close, contentDescription = "Error", tint = statusColor, - modifier = Modifier.size(20.dp) + modifier = Modifier.size(20.dp), ) } else { CircularProgressIndicator( modifier = Modifier.size(20.dp), - strokeWidth = 2.dp + strokeWidth = 2.dp, ) } @@ -100,20 +102,20 @@ fun RelayStatusCard( Text( status.url.url, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface + 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 + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } status.error?.let { error -> Text( error, style = MaterialTheme.typography.bodySmall, - color = Color.Red.copy(alpha = 0.8f) + color = Color.Red.copy(alpha = 0.8f), ) } } @@ -123,7 +125,7 @@ fun RelayStatusCard( Icon( Icons.Default.Close, contentDescription = "Remove relay", - tint = MaterialTheme.colorScheme.onSurfaceVariant + 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 index 8f8b8d078c..1f0de406e5 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -29,16 +29,17 @@ 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) + "..." - } + 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 + 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 index 1b2fb8ea0a..88ef514b9f 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in 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 index d28deafef8..7e0e3c6cb2 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in 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 c7ffb06561..e59fd24a4e 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,8 +29,7 @@ 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 +fun HexKey.hexToByteArrayOrNull(): ByteArray? = if (Hex.isHex(this)) Hex.decode(this) else null const val PUBKEY_LENGTH = 64 From e407e03e9067a910b23c3b7ec80c837eff55315a Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sun, 28 Dec 2025 07:21:36 +0200 Subject: [PATCH 09/10] update readme --- README.md | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 46f7773a0c..7a620308b7 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 From 0dcd480d4369de4e86786f32f6481f454b394029 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Sun, 28 Dec 2025 07:35:48 +0200 Subject: [PATCH 10/10] leftover changes --- .../vitorpamplona/amethyst/desktop/Main.kt | 153 +++++++++--------- .../desktop/network/DesktopHttpClient.kt | 5 +- .../network/DesktopRelayConnectionManager.kt | 9 +- .../amethyst/desktop/ui/FeedScreen.kt | 62 +++---- .../amethyst/desktop/ui/LoginScreen.kt | 2 +- 5 files changed, 119 insertions(+), 112 deletions(-) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 1027609e4c..7e91eb32d6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -42,8 +42,6 @@ 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.Card -import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -86,56 +84,58 @@ 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) - ) +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 = { }) + 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() } - - App() } -} @Composable fun App() { @@ -153,17 +153,17 @@ fun App() { } MaterialTheme( - colorScheme = darkColorScheme() + colorScheme = darkColorScheme(), ) { Surface( modifier = Modifier.fillMaxSize(), - color = MaterialTheme.colorScheme.background + color = MaterialTheme.colorScheme.background, ) { when (accountState) { is AccountState.LoggedOut -> { LoginScreen( accountManager = accountManager, - onLoginSuccess = { currentScreen = AppScreen.Feed } + onLoginSuccess = { currentScreen = AppScreen.Feed }, ) } is AccountState.LoggedIn -> { @@ -192,7 +192,7 @@ fun MainContent( // Sidebar Navigation NavigationRail( modifier = Modifier.width(80.dp).fillMaxHeight(), - containerColor = MaterialTheme.colorScheme.surfaceVariant + containerColor = MaterialTheme.colorScheme.surfaceVariant, ) { Spacer(Modifier.height(16.dp)) @@ -200,35 +200,35 @@ fun MainContent( icon = { Icon(Icons.Default.Home, contentDescription = "Feed") }, label = { Text("Feed") }, selected = currentScreen == AppScreen.Feed, - onClick = { onScreenChange(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) } + onClick = { onScreenChange(AppScreen.Search) }, ) NavigationRailItem( icon = { Icon(Icons.Default.Email, contentDescription = "Messages") }, label = { Text("DMs") }, selected = currentScreen == AppScreen.Messages, - onClick = { onScreenChange(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) } + onClick = { onScreenChange(AppScreen.Notifications) }, ) NavigationRailItem( icon = { Icon(Icons.Default.Person, contentDescription = "Profile") }, label = { Text("Profile") }, selected = currentScreen == AppScreen.Profile, - onClick = { onScreenChange(AppScreen.Profile) } + onClick = { onScreenChange(AppScreen.Profile) }, ) Spacer(Modifier.weight(1f)) @@ -239,7 +239,7 @@ fun MainContent( icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") }, label = { Text("Settings") }, selected = currentScreen == AppScreen.Settings, - onClick = { onScreenChange(AppScreen.Settings) } + onClick = { onScreenChange(AppScreen.Settings) }, ) Spacer(Modifier.height(16.dp)) @@ -249,7 +249,7 @@ fun MainContent( // Main Content Box( - modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp) + modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp), ) { when (currentScreen) { AppScreen.Feed -> FeedScreen(relayManager) @@ -263,14 +263,16 @@ fun MainContent( } } - @Composable -fun ProfileScreen(account: AccountState.LoggedIn, accountManager: AccountManager) { +fun ProfileScreen( + account: AccountState.LoggedIn, + accountManager: AccountManager, +) { Column { Text( "Profile", style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) Spacer(Modifier.height(16.dp)) @@ -284,9 +286,10 @@ fun ProfileScreen(account: AccountState.LoggedIn, accountManager: AccountManager OutlinedButton( onClick = { accountManager.logout() }, - colors = androidx.compose.material3.ButtonDefaults.outlinedButtonColors( - contentColor = Color.Red - ) + colors = + androidx.compose.material3.ButtonDefaults.outlinedButtonColors( + contentColor = Color.Red, + ), ) { Text("Logout") } @@ -303,24 +306,24 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { Text( "Relay Settings", style = MaterialTheme.typography.headlineMedium, - color = MaterialTheme.colorScheme.onBackground + color = MaterialTheme.colorScheme.onBackground, ) Spacer(Modifier.height(8.dp)) Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { Text( "${connectedRelays.size} of ${relayStatuses.size} relays connected", color = MaterialTheme.colorScheme.onSurfaceVariant, - style = MaterialTheme.typography.bodyMedium + style = MaterialTheme.typography.bodyMedium, ) IconButton(onClick = { relayManager.connect() }) { Icon( Icons.Default.Refresh, contentDescription = "Reconnect", - tint = MaterialTheme.colorScheme.primary + tint = MaterialTheme.colorScheme.primary, ) } } @@ -330,7 +333,7 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically + verticalAlignment = Alignment.CenterVertically, ) { OutlinedTextField( value = newRelayUrl, @@ -338,7 +341,7 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { label = { Text("Add relay") }, placeholder = { Text("wss://relay.example.com") }, modifier = Modifier.weight(1f), - singleLine = true + singleLine = true, ) Button( onClick = { @@ -347,7 +350,7 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { newRelayUrl = "" } }, - enabled = newRelayUrl.isNotBlank() + enabled = newRelayUrl.isNotBlank(), ) { Text("Add") } @@ -357,12 +360,12 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) { LazyColumn( verticalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier.weight(1f) + modifier = Modifier.weight(1f), ) { items(relayStatuses.values.toList()) { status -> RelayStatusCard( status = status, - onRemove = { relayManager.removeRelay(status.url) } + onRemove = { relayManager.removeRelay(status.url) }, ) } } 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 index 9046e2d1f3..c94df538fb 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopHttpClient.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -26,7 +26,8 @@ import java.util.concurrent.TimeUnit object DesktopHttpClient { private val client: OkHttpClient by lazy { - OkHttpClient.Builder() + OkHttpClient + .Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(30, TimeUnit.SECONDS) 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 index c143b4d7b2..34dd8db54f 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -27,6 +27,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSoc * 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) -) +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 index 5ebb75994e..9eb70e313a 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in @@ -60,41 +60,43 @@ fun FeedScreen(relayManager: DesktopRelayConnectionManager) { DisposableEffect(configuredRelays) { if (configuredRelays.isNotEmpty()) { val subId = "global-feed-${System.currentTimeMillis()}" - val filters = listOf( - Filter( - kinds = listOf(TextNoteEvent.KIND), - limit = 50, + 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) + 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 - } - } + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // End of stored events + } + }, ) onDispose { @@ -109,7 +111,7 @@ fun FeedScreen(relayManager: DesktopRelayConnectionManager) { FeedHeader( title = "Global Feed", connectedRelayCount = connectedRelays.size, - onRefresh = { relayManager.connect() } + onRefresh = { relayManager.connect() }, ) Spacer(Modifier.height(16.dp)) @@ -120,12 +122,12 @@ fun FeedScreen(relayManager: DesktopRelayConnectionManager) { LoadingState("Loading notes...") } else { LazyColumn( - verticalArrangement = Arrangement.spacedBy(8.dp) + verticalArrangement = Arrangement.spacedBy(8.dp), ) { items(events, key = { it.id }) { event -> // Use NoteCard from commons NoteCard( - note = event.toNoteDisplayData() + 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 index 28632b99bb..68761246e6 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 Vitor Pamplona * * Permission is hereby granted, free of charge, to any person obtaining a copy of * this software and associated documentation files (the "Software"), to deal in