feat: Add relay connections, login, and feed view to desktop app
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+121
@@ -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>(AccountState.LoggedOut)
|
||||
val accountState: StateFlow<AccountState> = _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<AccountState.LoggedIn> {
|
||||
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
|
||||
}
|
||||
+39
@@ -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
|
||||
}
|
||||
+151
@@ -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<Map<NormalizedRelayUrl, RelayStatus>>(emptyMap())
|
||||
val relayStatuses: StateFlow<Map<NormalizedRelayUrl, RelayStatus>> = _relayStatuses.asStateFlow()
|
||||
|
||||
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = client.connectedRelaysFlow()
|
||||
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = 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<Filter>,
|
||||
relays: Set<NormalizedRelayUrl> = 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<NormalizedRelayUrl> = 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
|
||||
}
|
||||
}
|
||||
@@ -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<Event>() }
|
||||
val seenIds = remember { mutableSetOf<String>() }
|
||||
|
||||
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<Filter>?
|
||||
) {
|
||||
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<Filter>?
|
||||
) {
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -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<String?>(null) }
|
||||
var showNewKeyDialog by remember { mutableStateOf(false) }
|
||||
var generatedAccount by remember { mutableStateOf<AccountState.LoggedIn?>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user