feat: Extract shared UI components to commons for Android/Desktop reuse

Extract reusable components from desktopApp to commons module:

Utilities (commonMain):
- NumberFormatters: countToHumanReadable, countToHumanReadableBytes
- PubKeyFormatter: toShortDisplay, toDisplayHexKey for key formatting

Utilities (jvmAndroid):
- TimeAgoFormatter: Human-readable time formatting for Nostr timestamps
- ZapFormatter: BigDecimal amount formatting with G/M/k suffixes

UI Components (commonMain):
- AppScreen enum for shared navigation
- LoadingState, EmptyState, ErrorState with refresh/retry support
- FeedHeader with relay status indicator
- NoteCard for displaying Nostr notes
- ActionButtons (AddButton, RemoveButton)
- RobohashImage for avatar display
- PlaceholderScreens (Search, Messages, Notifications)
- RelayStatusColors and shared color definitions

UI Components (jvmAndroid):
- LoginCard with key input field
- NewKeyWarningCard for new key backup warnings
- KeyInputField and SelectableKeyText
- ProfileInfoCard for account display
- RelayStatusCard for relay management

Core (jvmAndroid):
- AccountManager with login/logout/key generation
- RelayConnectionManager base class
- RelayStatus and DefaultRelays

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
nrobi144
2025-12-27 15:12:04 +02:00
parent e5a064d0c3
commit 201acc8140
28 changed files with 2145 additions and 707 deletions
@@ -30,13 +30,10 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Email
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Notifications
@@ -47,7 +44,6 @@ import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -78,22 +74,18 @@ import androidx.compose.ui.window.Window
import androidx.compose.ui.window.WindowPosition
import androidx.compose.ui.window.application
import androidx.compose.ui.window.rememberWindowState
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.commons.account.AccountManager
import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.navigation.AppScreen
import com.vitorpamplona.amethyst.commons.ui.profile.ProfileInfoCard
import com.vitorpamplona.amethyst.commons.ui.relay.RelayStatusCard
import com.vitorpamplona.amethyst.commons.ui.screens.MessagesPlaceholder
import com.vitorpamplona.amethyst.commons.ui.screens.NotificationsPlaceholder
import com.vitorpamplona.amethyst.commons.ui.screens.SearchPlaceholder
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.amethyst.desktop.network.RelayStatus
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
import com.vitorpamplona.amethyst.desktop.ui.LoginScreen
enum class Screen {
Feed,
Search,
Messages,
Notifications,
Profile,
Settings
}
fun main() = application {
val windowState = rememberWindowState(
width = 1200.dp,
@@ -147,7 +139,7 @@ fun main() = application {
@Composable
fun App() {
var currentScreen by remember { mutableStateOf(Screen.Feed) }
var currentScreen by remember { mutableStateOf(AppScreen.Feed) }
val relayManager = remember { DesktopRelayConnectionManager() }
val accountManager = remember { AccountManager() }
val accountState by accountManager.accountState.collectAsState()
@@ -171,7 +163,7 @@ fun App() {
is AccountState.LoggedOut -> {
LoginScreen(
accountManager = accountManager,
onLoginSuccess = { currentScreen = Screen.Feed }
onLoginSuccess = { currentScreen = AppScreen.Feed }
)
}
is AccountState.LoggedIn -> {
@@ -190,8 +182,8 @@ fun App() {
@Composable
fun MainContent(
currentScreen: Screen,
onScreenChange: (Screen) -> Unit,
currentScreen: AppScreen,
onScreenChange: (AppScreen) -> Unit,
relayManager: DesktopRelayConnectionManager,
accountManager: AccountManager,
account: AccountState.LoggedIn,
@@ -207,36 +199,36 @@ fun MainContent(
NavigationRailItem(
icon = { Icon(Icons.Default.Home, contentDescription = "Feed") },
label = { Text("Feed") },
selected = currentScreen == Screen.Feed,
onClick = { onScreenChange(Screen.Feed) }
selected = currentScreen == AppScreen.Feed,
onClick = { onScreenChange(AppScreen.Feed) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Search, contentDescription = "Search") },
label = { Text("Search") },
selected = currentScreen == Screen.Search,
onClick = { onScreenChange(Screen.Search) }
selected = currentScreen == AppScreen.Search,
onClick = { onScreenChange(AppScreen.Search) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Email, contentDescription = "Messages") },
label = { Text("DMs") },
selected = currentScreen == Screen.Messages,
onClick = { onScreenChange(Screen.Messages) }
selected = currentScreen == AppScreen.Messages,
onClick = { onScreenChange(AppScreen.Messages) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") },
label = { Text("Alerts") },
selected = currentScreen == Screen.Notifications,
onClick = { onScreenChange(Screen.Notifications) }
selected = currentScreen == AppScreen.Notifications,
onClick = { onScreenChange(AppScreen.Notifications) }
)
NavigationRailItem(
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
label = { Text("Profile") },
selected = currentScreen == Screen.Profile,
onClick = { onScreenChange(Screen.Profile) }
selected = currentScreen == AppScreen.Profile,
onClick = { onScreenChange(AppScreen.Profile) }
)
Spacer(Modifier.weight(1f))
@@ -246,8 +238,8 @@ fun MainContent(
NavigationRailItem(
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
label = { Text("Settings") },
selected = currentScreen == Screen.Settings,
onClick = { onScreenChange(Screen.Settings) }
selected = currentScreen == AppScreen.Settings,
onClick = { onScreenChange(AppScreen.Settings) }
)
Spacer(Modifier.height(16.dp))
@@ -260,64 +252,17 @@ fun MainContent(
modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp)
) {
when (currentScreen) {
Screen.Feed -> FeedScreen(relayManager)
Screen.Search -> SearchPlaceholder()
Screen.Messages -> MessagesPlaceholder()
Screen.Notifications -> NotificationsPlaceholder()
Screen.Profile -> ProfileScreen(account, accountManager)
Screen.Settings -> RelaySettingsScreen(relayManager)
AppScreen.Feed -> FeedScreen(relayManager)
AppScreen.Search -> SearchPlaceholder()
AppScreen.Messages -> MessagesPlaceholder()
AppScreen.Notifications -> NotificationsPlaceholder()
AppScreen.Profile -> ProfileScreen(account, accountManager)
AppScreen.Settings -> RelaySettingsScreen(relayManager)
}
}
}
}
@Composable
fun SearchPlaceholder() {
Column {
Text(
"Search",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(Modifier.height(16.dp))
Text(
"Search for users, notes, and hashtags.",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
fun MessagesPlaceholder() {
Column {
Text(
"Messages",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(Modifier.height(16.dp))
Text(
"Your encrypted direct messages will appear here.",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
fun NotificationsPlaceholder() {
Column {
Text(
"Notifications",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Spacer(Modifier.height(16.dp))
Text(
"Mentions, replies, and reactions will appear here.",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@Composable
fun ProfileScreen(account: AccountState.LoggedIn, accountManager: AccountManager) {
@@ -329,47 +274,11 @@ fun ProfileScreen(account: AccountState.LoggedIn, accountManager: AccountManager
)
Spacer(Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(16.dp)) {
if (account.isReadOnly) {
Text(
"Read-only mode",
style = MaterialTheme.typography.labelMedium,
color = Color.Yellow
)
Spacer(Modifier.height(8.dp))
}
Text(
"Public Key",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
account.npub,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(16.dp))
Text(
"Hex",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
account.pubKeyHex,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface
)
}
}
ProfileInfoCard(
npub = account.npub,
pubKeyHex = account.pubKeyHex,
isReadOnly = account.isReadOnly,
)
Spacer(Modifier.height(24.dp))
@@ -451,9 +360,10 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) {
modifier = Modifier.weight(1f)
) {
items(relayStatuses.values.toList()) { status ->
RelayStatusCard(status) {
relayManager.removeRelay(status.url)
}
RelayStatusCard(
status = status,
onRemove = { relayManager.removeRelay(status.url) }
)
}
}
@@ -466,82 +376,3 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) {
}
}
}
@Composable
fun RelayStatusCard(status: RelayStatus, onRemove: () -> Unit) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier.fillMaxWidth().padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp),
modifier = Modifier.weight(1f)
) {
val statusColor = when {
status.connected -> Color.Green
status.error != null -> Color.Red
else -> Color.Gray
}
if (status.connected) {
Icon(
Icons.Default.Check,
contentDescription = "Connected",
tint = statusColor,
modifier = Modifier.size(20.dp)
)
} else if (status.error != null) {
Icon(
Icons.Default.Close,
contentDescription = "Error",
tint = statusColor,
modifier = Modifier.size(20.dp)
)
} else {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
strokeWidth = 2.dp
)
}
Column {
Text(
status.url.url,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
if (status.connected && status.pingMs != null) {
Text(
"${status.pingMs}ms${if (status.compressed) " • compressed" else ""}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
status.error?.let { error ->
Text(
error,
style = MaterialTheme.typography.bodySmall,
color = Color.Red.copy(alpha = 0.8f)
)
}
}
}
IconButton(onClick = onRemove) {
Icon(
Icons.Default.Close,
contentDescription = "Remove relay",
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@@ -1,121 +0,0 @@
/**
* 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
}
@@ -20,132 +20,13 @@
*/
package com.vitorpamplona.amethyst.desktop.network
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.amethyst.commons.network.RelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
data class RelayStatus(
val url: NormalizedRelayUrl,
val connected: Boolean,
val pingMs: Int? = null,
val compressed: Boolean = false,
val error: String? = null,
/**
* Desktop-specific relay connection manager that configures OkHttp for websockets.
* Delegates to the shared RelayConnectionManager from commons.
*/
class DesktopRelayConnectionManager : RelayConnectionManager(
websocketBuilder = BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient)
)
class DesktopRelayConnectionManager : IRelayClientListener {
private val websocketBuilder = BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient)
private val client = NostrClient(websocketBuilder)
private val _relayStatuses = MutableStateFlow<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
}
}
@@ -22,49 +22,29 @@ package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.ui.components.LoadingState
import com.vitorpamplona.amethyst.commons.ui.feed.FeedHeader
import com.vitorpamplona.amethyst.commons.ui.note.NoteCard
import com.vitorpamplona.amethyst.commons.util.toNoteDisplayData
import com.vitorpamplona.amethyst.desktop.network.DesktopRelayConnectionManager
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
@Composable
fun FeedScreen(relayManager: DesktopRelayConnectionManager) {
@@ -126,159 +106,29 @@ fun FeedScreen(relayManager: DesktopRelayConnectionManager) {
}
Column(modifier = Modifier.fillMaxSize()) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
"Global Feed",
style = MaterialTheme.typography.headlineMedium,
color = MaterialTheme.colorScheme.onBackground
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
val connected = connectedRelays.size
val color = when {
connected == 0 -> Color.Red
connected < 3 -> Color.Yellow
else -> Color.Green
}
Icon(
if (connected > 0) Icons.Default.Check else Icons.Default.Close,
contentDescription = null,
tint = color,
modifier = Modifier.size(16.dp)
)
Text(
"$connected relay${if (connected != 1) "s" else ""}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall
)
IconButton(onClick = { relayManager.connect() }) {
Icon(
Icons.Default.Refresh,
contentDescription = "Reconnect",
tint = MaterialTheme.colorScheme.primary
)
}
}
}
FeedHeader(
title = "Global Feed",
connectedRelayCount = connectedRelays.size,
onRefresh = { relayManager.connect() }
)
Spacer(Modifier.height(16.dp))
if (connectedRelays.isEmpty()) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
Spacer(Modifier.height(16.dp))
Text(
"Connecting to relays...",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
LoadingState("Connecting to relays...")
} else if (events.isEmpty()) {
Column(
modifier = Modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator()
Spacer(Modifier.height(16.dp))
Text(
"Loading notes...",
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
LoadingState("Loading notes...")
} else {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(events, key = { it.id }) { event ->
NoteCard(event)
// Use NoteCard from commons
NoteCard(
note = event.toNoteDisplayData()
)
}
}
}
}
}
@Composable
fun NoteCard(event: Event) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(modifier = Modifier.padding(12.dp)) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
// Author npub (truncated)
val npub = try {
event.pubKey.hexToByteArrayOrNull()?.toNpub() ?: event.pubKey.take(16) + "..."
} catch (e: Exception) {
event.pubKey.take(16) + "..."
}
Text(
text = npub.take(20) + "...",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.primary,
maxLines = 1
)
// Timestamp
val date = Date(event.createdAt * 1000)
val format = SimpleDateFormat("MMM d, HH:mm", Locale.getDefault())
Text(
text = format.format(date),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Spacer(Modifier.height(8.dp))
Text(
text = event.content,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 10,
overflow = TextOverflow.Ellipsis
)
Spacer(Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outline.copy(alpha = 0.3f))
Spacer(Modifier.height(4.dp))
// Event ID (truncated)
Text(
text = "ID: ${event.id.take(12)}...",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f)
)
}
}
}
private fun String.hexToByteArrayOrNull(): ByteArray? {
return try {
if (length % 2 != 0) return null
ByteArray(length / 2) { i ->
substring(i * 2, i * 2 + 2).toInt(16).toByte()
}
} catch (e: Exception) {
null
}
}
@@ -22,24 +22,11 @@ package com.vitorpamplona.amethyst.desktop.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
@@ -48,21 +35,17 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.desktop.account.AccountManager
import com.vitorpamplona.amethyst.desktop.account.AccountState
import com.vitorpamplona.amethyst.commons.account.AccountManager
import com.vitorpamplona.amethyst.commons.account.AccountState
import com.vitorpamplona.amethyst.commons.ui.auth.LoginCard
import com.vitorpamplona.amethyst.commons.ui.auth.NewKeyWarningCard
@Composable
fun LoginScreen(
accountManager: AccountManager,
onLoginSuccess: () -> Unit,
) {
var keyInput by remember { mutableStateOf("") }
var showKey by remember { mutableStateOf(false) }
var errorMessage by remember { mutableStateOf<String?>(null) }
var showNewKeyDialog by remember { mutableStateOf(false) }
var generatedAccount by remember { mutableStateOf<AccountState.LoggedIn?>(null) }
@@ -87,97 +70,23 @@ fun LoginScreen(
Spacer(Modifier.height(48.dp))
Card(
modifier = Modifier.width(400.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(
"Login with your Nostr key",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = keyInput,
onValueChange = {
keyInput = it
errorMessage = null
},
label = { Text("nsec or npub") },
placeholder = { Text("nsec1... or npub1...") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
visualTransformation = if (showKey) {
VisualTransformation.None
} else {
PasswordVisualTransformation()
},
trailingIcon = {
IconButton(onClick = { showKey = !showKey }) {
Icon(
if (showKey) Icons.Default.VisibilityOff else Icons.Default.Visibility,
contentDescription = if (showKey) "Hide key" else "Show key",
)
}
},
isError = errorMessage != null,
supportingText = errorMessage?.let {
{ Text(it, color = MaterialTheme.colorScheme.error) }
},
)
Spacer(Modifier.height(8.dp))
Text(
"Use nsec for full access or npub for read-only",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Button(
onClick = {
accountManager.loginWithKey(keyInput).fold(
onSuccess = { onLoginSuccess() },
onFailure = { errorMessage = it.message },
)
},
modifier = Modifier.weight(1f),
enabled = keyInput.isNotBlank(),
) {
Text("Login")
}
OutlinedButton(
onClick = {
generatedAccount = accountManager.generateNewAccount()
showNewKeyDialog = true
},
modifier = Modifier.weight(1f),
) {
Text("Generate New")
}
LoginCard(
onLogin = { keyInput ->
accountManager.loginWithKey(keyInput).map {
onLoginSuccess()
}
}
}
},
onGenerateNew = {
generatedAccount = accountManager.generateNewAccount()
showNewKeyDialog = true
},
)
if (showNewKeyDialog && generatedAccount != null) {
Spacer(Modifier.height(24.dp))
NewKeyCard(
account = generatedAccount!!,
NewKeyWarningCard(
npub = generatedAccount!!.npub,
nsec = generatedAccount!!.nsec,
onContinue = {
showNewKeyDialog = false
onLoginSuccess()
@@ -186,80 +95,3 @@ fun LoginScreen(
}
}
}
@Composable
fun NewKeyCard(
account: AccountState.LoggedIn,
onContinue: () -> Unit,
) {
Card(
modifier = Modifier.width(500.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f),
),
) {
Column(
modifier = Modifier.padding(24.dp),
) {
Text(
"IMPORTANT: Save your keys!",
style = MaterialTheme.typography.titleMedium,
color = Color.Red,
)
Spacer(Modifier.height(16.dp))
Text(
"Your secret key (nsec) is the ONLY way to access your account. " +
"If you lose it, your account is gone forever. Save it somewhere safe!",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(Modifier.height(16.dp))
Text(
"Public Key (shareable):",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
SelectableKeyText(account.npub)
Spacer(Modifier.height(12.dp))
account.nsec?.let { nsec ->
Text(
"Secret Key (NEVER share this!):",
style = MaterialTheme.typography.labelMedium,
color = Color.Red,
)
SelectableKeyText(nsec)
}
Spacer(Modifier.height(24.dp))
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth(),
) {
Text("I've saved my keys, continue")
}
}
}
}
@Composable
fun SelectableKeyText(key: String) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surface,
),
) {
Text(
text = key,
modifier = Modifier.padding(12.dp).fillMaxWidth(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurface,
)
}
}