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:
nrobi144
2025-12-26 14:02:44 +02:00
parent e441a67e2b
commit 8c8a4ab9e8
8 changed files with 1419 additions and 93 deletions
+11
View File
@@ -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
)
}
}
}
}
@@ -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
}
@@ -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
}
@@ -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,
)
}
}
+219
View File
@@ -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