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 0000000000..dbb5d7153d Binary files /dev/null and b/desktopApp/src/jvmMain/resources/icon.png differ diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 65bff466a8..b28210259b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,6 @@ [versions] accompanistAdaptive = "0.37.3" +composeMultiplatform = "1.7.1" activityCompose = "1.12.1" agp = "8.13.2" android-compileSdk = "36" @@ -122,6 +123,7 @@ jtorctl = { module = "info.guardianproject:jtorctl", version.ref = "jtorctl" } junit = { group = "junit", name = "junit", version.ref = "junit" } kotlinx-collections-immutable = { group = "org.jetbrains.kotlinx", name = "kotlinx-collections-immutable", version.ref = "kotlinxCollectionsImmutable" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } +kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinxCoroutinesCore" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerialization" } kotlinx-serialization-cbor = { module = "org.jetbrains.kotlinx:kotlinx-serialization-cbor", version.ref = "kotlinxSerialization" } lazysodium-java = { group = "com.goterl", name = "lazysodium-java", version.ref = "lazysodiumJava" } @@ -167,4 +169,5 @@ serialization = { id = 'org.jetbrains.kotlin.plugin.serialization', version.ref kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } androidKotlinMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } vanniktech-mavenPublish = { id = "com.vanniktech.maven.publish", version.ref = "mavenPublish" } -stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.1" } \ No newline at end of file +stability-analyzer = { id = "com.github.skydoves.compose.stability.analyzer", version = "0.6.1" } +composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMultiplatform" } diff --git a/settings.gradle b/settings.gradle index 0a0b52faa4..cad6e37728 100644 --- a/settings.gradle +++ b/settings.gradle @@ -34,3 +34,4 @@ include ':benchmark' include ':quartz' include ':commons' include ':ammolite' +include ':desktopApp'