From 8c8a4ab9e8b57880ea6f6246b118aea162a3b380 Mon Sep 17 00:00:00 2001 From: nrobi144 Date: Fri, 26 Dec 2025 14:02:44 +0200 Subject: [PATCH] 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