diff --git a/commons/build.gradle.kts b/commons/build.gradle.kts index 0c642c3c8..e66de925e 100644 --- a/commons/build.gradle.kts +++ b/commons/build.gradle.kts @@ -62,6 +62,8 @@ kotlin { implementation(compose.ui) implementation(compose.foundation) implementation(compose.runtime) + implementation(compose.material3) + implementation(compose.materialIconsExtended) // LruCache (KMP-ready) implementation(libs.androidx.collection) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt new file mode 100644 index 000000000..c6b005d0b --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/navigation/AppScreen.kt @@ -0,0 +1,55 @@ +/** + * 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.commons.navigation + +/** + * Main application screens shared between Desktop and Android. + * Each platform implements its own navigation using these identifiers. + */ +enum class AppScreen( + val label: String, + val route: String, +) { + Feed("Feed", "feed"), + Search("Search", "search"), + Messages("Messages", "messages"), + Notifications("Notifications", "notifications"), + Profile("Profile", "profile"), + Settings("Settings", "settings"), +} + +/** + * Primary navigation destinations (shown in bottom bar on mobile, sidebar on desktop). + */ +val primaryScreens = listOf( + AppScreen.Feed, + AppScreen.Search, + AppScreen.Messages, + AppScreen.Notifications, + AppScreen.Profile, +) + +/** + * Secondary navigation destinations (settings, etc.) + */ +val secondaryScreens = listOf( + AppScreen.Settings, +) diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt new file mode 100644 index 000000000..93ab2de84 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ActionButtons.kt @@ -0,0 +1,92 @@ +/** + * 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.commons.ui.components + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +/** + * Standard button shape used for action buttons. + */ +val ActionButtonShape = RoundedCornerShape(20.dp) + +/** + * Standard content padding for action buttons. + */ +val ActionButtonPadding = PaddingValues(vertical = 0.dp, horizontal = 16.dp) + +/** + * An "Add" action button with consistent styling. + * + * @param onClick Action to perform when clicked + * @param modifier Modifier for the button + * @param text Button text (default: "Add") + * @param enabled Whether the button is enabled + */ +@Composable +fun AddButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: String = "Add", + enabled: Boolean = true, +) { + OutlinedButton( + modifier = modifier, + enabled = enabled, + onClick = onClick, + shape = ActionButtonShape, + contentPadding = ActionButtonPadding, + ) { + Text(text = text, textAlign = TextAlign.Center) + } +} + +/** + * A "Remove" action button with consistent styling. + * + * @param onClick Action to perform when clicked + * @param modifier Modifier for the button + * @param text Button text (default: "Remove") + * @param enabled Whether the button is enabled + */ +@Composable +fun RemoveButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + text: String = "Remove", + enabled: Boolean = true, +) { + OutlinedButton( + modifier = modifier, + onClick = onClick, + shape = ActionButtonShape, + enabled = enabled, + contentPadding = ActionButtonPadding, + ) { + Text(text = text) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt new file mode 100644 index 000000000..b549466dd --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/LoadingState.kt @@ -0,0 +1,158 @@ +/** + * Copyright (c) 2025 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.commons.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * A centered loading state with a progress indicator and message. + * Can be used by both Desktop and Android apps. + */ +@Composable +fun LoadingState( + message: String, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + CircularProgressIndicator() + Spacer(Modifier.height(16.dp)) + Text( + message, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * A centered empty state with title, optional description, and optional refresh action. + */ +@Composable +fun EmptyState( + title: String, + modifier: Modifier = Modifier, + description: String? = null, + onRefresh: (() -> Unit)? = null, + refreshLabel: String = "Refresh", +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + if (description != null) { + Spacer(Modifier.height(8.dp)) + Text( + description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + ) + } + if (onRefresh != null) { + Spacer(Modifier.height(16.dp)) + OutlinedButton(onClick = onRefresh) { + Text(refreshLabel) + } + } + } +} + +/** + * A centered error state with message and optional retry action. + */ +@Composable +fun ErrorState( + message: String, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, + retryLabel: String = "Try Again", +) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + if (onRetry != null) { + Spacer(Modifier.height(16.dp)) + Button(onClick = onRetry) { + Text(retryLabel) + } + } + } +} + +/** + * Empty feed state - a common pattern for feed views. + */ +@Composable +fun FeedEmptyState( + modifier: Modifier = Modifier, + title: String = "Feed is empty", + onRefresh: (() -> Unit)? = null, +) { + EmptyState( + title = title, + modifier = modifier, + onRefresh = onRefresh, + ) +} + +/** + * Feed error state - a common pattern for feed views. + */ +@Composable +fun FeedErrorState( + errorMessage: String, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null, +) { + ErrorState( + message = "Error loading feed: $errorMessage", + modifier = modifier, + onRetry = onRetry, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt new file mode 100644 index 000000000..f5dfce736 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RobohashImage.kt @@ -0,0 +1,110 @@ +/** + * 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.commons.ui.components + +import androidx.compose.foundation.Image +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Face +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.layout.ContentScale +import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash + +/** + * Determines if the current color scheme is light. + * Uses the luminance of the background color. + */ +@Composable +private fun isLightTheme(): Boolean { + val background = MaterialTheme.colorScheme.background + // Simple luminance check: if any RGB component > 0.5, consider it light + return (background.red + background.green + background.blue) / 3 > 0.5f +} + +/** + * Displays a robohash image based on a seed string (typically a public key). + * Falls back to a generic face icon if robohash is disabled. + * + * @param robot The seed string for generating the robohash (e.g., pubkey hex) + * @param modifier Modifier for the image + * @param contentDescription Accessibility description + * @param loadRobohash Whether to load the robohash or show a placeholder + */ +@Composable +fun RobohashImage( + robot: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + loadRobohash: Boolean = true, +) { + if (loadRobohash) { + Image( + imageVector = CachedRobohash.get(robot, isLightTheme()), + contentDescription = contentDescription, + modifier = modifier, + ) + } else { + Image( + imageVector = Icons.Default.Face, + contentDescription = contentDescription, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = modifier, + ) + } +} + +/** + * Displays a robohash image with configurable alignment and content scale. + */ +@Composable +fun RobohashImage( + robot: String, + modifier: Modifier = Modifier, + contentDescription: String? = null, + alignment: Alignment = Alignment.Center, + contentScale: ContentScale = ContentScale.Fit, + colorFilter: ColorFilter? = null, + loadRobohash: Boolean = true, +) { + if (loadRobohash) { + Image( + painter = rememberVectorPainter(CachedRobohash.get(robot, isLightTheme())), + contentDescription = contentDescription, + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + colorFilter = colorFilter, + ) + } else { + Image( + imageVector = Icons.Default.Face, + contentDescription = contentDescription, + colorFilter = colorFilter ?: ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = modifier, + alignment = alignment, + contentScale = contentScale, + ) + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt new file mode 100644 index 000000000..7884b9ba2 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/feed/FeedHeader.kt @@ -0,0 +1,116 @@ +/** + * 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.commons.ui.feed + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +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.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.ui.theme.RelayStatusColors + +/** + * Header component for feed screens with title and relay connection status. + * + * @param title The feed title + * @param connectedRelayCount Number of connected relays + * @param onRefresh Callback when refresh button is clicked + * @param modifier Modifier for the header row + */ +@Composable +fun FeedHeader( + title: String, + connectedRelayCount: Int, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + + RelayStatusIndicator( + connectedCount = connectedRelayCount, + onRefresh = onRefresh, + ) + } +} + +/** + * Compact relay connection status indicator with refresh button. + */ +@Composable +fun RelayStatusIndicator( + connectedCount: Int, + onRefresh: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + val statusColor = when { + connectedCount == 0 -> RelayStatusColors.Disconnected + connectedCount < 3 -> RelayStatusColors.Connecting + else -> RelayStatusColors.Connected + } + + Icon( + imageVector = if (connectedCount > 0) Icons.Default.Check else Icons.Default.Close, + contentDescription = null, + tint = statusColor, + modifier = Modifier.size(16.dp) + ) + + Text( + "$connectedCount relay${if (connectedCount != 1) "s" else ""}", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall + ) + + IconButton(onClick = onRefresh) { + Icon( + Icons.Default.Refresh, + contentDescription = "Reconnect", + tint = MaterialTheme.colorScheme.primary + ) + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt new file mode 100644 index 000000000..92820ee25 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screens/PlaceholderScreens.kt @@ -0,0 +1,89 @@ +/** + * 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.commons.ui.screens + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +/** + * Generic placeholder screen with title and description. + */ +@Composable +fun PlaceholderScreen( + title: String, + description: String, + modifier: Modifier = Modifier, +) { + Column(modifier = modifier) { + Text( + title, + style = MaterialTheme.typography.headlineMedium, + color = MaterialTheme.colorScheme.onBackground + ) + Spacer(Modifier.height(16.dp)) + Text( + description, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } +} + +/** + * Search screen placeholder. + */ +@Composable +fun SearchPlaceholder(modifier: Modifier = Modifier) { + PlaceholderScreen( + title = "Search", + description = "Search for users, notes, and hashtags.", + modifier = modifier, + ) +} + +/** + * Messages/DMs screen placeholder. + */ +@Composable +fun MessagesPlaceholder(modifier: Modifier = Modifier) { + PlaceholderScreen( + title = "Messages", + description = "Your encrypted direct messages will appear here.", + modifier = modifier, + ) +} + +/** + * Notifications screen placeholder. + */ +@Composable +fun NotificationsPlaceholder(modifier: Modifier = Modifier) { + PlaceholderScreen( + title = "Notifications", + description = "Mentions, replies, and reactions will appear here.", + modifier = modifier, + ) +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt new file mode 100644 index 000000000..5ff1b209a --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/theme/Colors.kt @@ -0,0 +1,84 @@ +/** + * 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.commons.ui.theme + +import androidx.compose.ui.graphics.Color + +// Primary brand colors +val Primary50 = Color(red = 127, green = 103, blue = 190) +val Primary60 = Color(red = 154, green = 130, blue = 219) +val Primary70 = Color(red = 182, green = 157, blue = 248) +val Primary80 = Color(red = 208, green = 188, blue = 255) +val DefaultPrimary = Color(red = 208, green = 188, blue = 255) +val LightPurple = Color(red = 187, green = 134, blue = 252) + +// Material Design colors +val Purple200 = Color(0xFFBB86FC) +val Purple500 = Color(0xFF6200EE) +val Purple700 = Color(0xFF3700B3) +val Teal200 = Color(0xFF03DAC5) + +// Bitcoin colors +val BitcoinOrange = Color(0xFFF7931A) +val BitcoinDark = Color(0xFFF7931A) +val BitcoinLight = Color(0xFFB66605) + +// Status colors +val RoyalBlue = Color(0xFF4169E1) +val Following = Color(0xFF03DAC5) +val FollowsFollow = Color.Yellow +val Nip05Verified = Color.Blue + +// NIP-05 email colors +val Nip05EmailColor = Color(0xFFb198ec) +val Nip05EmailColorDark = Color(0xFF6e5490) +val Nip05EmailColorLight = Color(0xFFa770f3) + +// Feedback colors +val DarkerGreen = Color.Green.copy(alpha = 0.32f) +val LightRedColor = Color(0xFFC62828) +val LighterRedColor = Color(0xFFFF0E0E) + +// Warning colors +val LightWarningColor = Color(0xFFffcc00) +val DarkWarningColor = Color(0xFFF8DE22) + +// Surface variant colors +val LightRedColorOnSecondSurface = Color(0xFFC62828) +val DarkRedColorOnSecondSurface = Color(0xFFF34747) +val LightWarningColorOnSecondSurface = Color(0xFFC09B14) +val DarkWarningColorOnSecondSurface = Color(0xFFE1C419) + +// Success colors +val LightAllGoodColor = Color(0xFF339900) +val DarkAllGoodColor = Color(0xFF99cc33) + +// Fundraiser colors +val LightFundraiserProgressColor = Color(0xFF3DB601) +val DarkFundraiserProgressColor = Color(0xFF61A229) + +// Relay status colors +object RelayStatusColors { + val Connected = Color.Green + val Connecting = Color.Yellow + val Disconnected = Color.Red + val Unknown = Color.Gray +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt new file mode 100644 index 000000000..f64005f08 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/NumberFormatters.kt @@ -0,0 +1,92 @@ +/** + * 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.commons.util + +import kotlin.math.roundToInt + +/** + * Formats a count to human-readable format with suffix (K, M, G). + * Example: 1500 -> "1K items", 2500000 -> "2M items" + */ +fun countToHumanReadable(counter: Int, suffix: String): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G $suffix" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M $suffix" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K $suffix" + else -> "$counter $suffix" + } + +/** + * Formats a count to human-readable format with suffix (K, M, G). + */ +fun countToHumanReadable(counter: Long, suffix: String): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G $suffix" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M $suffix" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K $suffix" + else -> "$counter $suffix" + } + +/** + * Formats a count to human-readable format without suffix. + * Example: 1500 -> "1K", 2500000 -> "2M" + */ +fun countToHumanReadable(counter: Int): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K" + else -> "$counter" + } + +/** + * Formats a count to human-readable format without suffix. + */ +fun countToHumanReadable(counter: Long): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()}G" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()}M" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()}K" + else -> "$counter" + } + +/** + * Formats byte count to human-readable format (KB, MB, GB). + * Example: 1500 -> "1 KB", 2500000 -> "2 MB" + */ +fun countToHumanReadableBytes(counter: Int): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()} GB" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()} MB" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()} KB" + else -> "$counter B" + } + +/** + * Formats byte count to human-readable format (KB, MB, GB). + */ +fun countToHumanReadableBytes(counter: Long): String = + when { + counter >= 1_000_000_000 -> "${(counter / 1_000_000_000f).roundToInt()} GB" + counter >= 1_000_000 -> "${(counter / 1_000_000f).roundToInt()} MB" + counter >= 1_000 -> "${(counter / 1_000f).roundToInt()} KB" + else -> "$counter B" + } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt new file mode 100644 index 000000000..40520d4ab --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/util/PubKeyFormatter.kt @@ -0,0 +1,45 @@ +/** + * 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.commons.util + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey + +/** + * Shortens a hex key or npub for display. + * Example: "npub1abcdefghijklmnop..." -> "npub1abc…mnop" + * + * @return Shortened string with ellipsis in the middle, or original if <= 16 chars + */ +fun String.toShortDisplay(): String { + if (length <= 16) return this + return replaceRange(8, length - 8, "…") +} + +/** + * Converts a ByteArray to a shortened hex display. + */ +fun ByteArray.toHexShortDisplay(): String = toHexKey().toShortDisplay() + +/** + * Shortens a HexKey for display. + */ +fun HexKey.toDisplayHexKey(): String = this.toShortDisplay() diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt similarity index 78% rename from desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt rename to commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt index 91f544c25..6fb4df3e3 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/account/AccountManager.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/account/AccountManager.kt @@ -1,5 +1,5 @@ /** - * Copyright (c) 2024 Vitor Pamplona + * Copyright (c) 2025 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 @@ -18,7 +18,7 @@ * 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 +package com.vitorpamplona.amethyst.commons.account import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray import com.vitorpamplona.quartz.nip01Core.core.toHexKey @@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.asStateFlow sealed class AccountState { data object LoggedOut : AccountState() + data class LoggedIn( val signer: NostrSigner, val pubKeyHex: String, @@ -52,13 +53,14 @@ class AccountManager { 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, - ) + 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 } @@ -73,13 +75,14 @@ class AccountManager { 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, - ) + 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) { @@ -94,13 +97,14 @@ class AccountManager { 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, - ) + 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) { diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt new file mode 100644 index 000000000..c14c1365f --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayConnectionManager.kt @@ -0,0 +1,140 @@ +/** + * 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.commons.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.WebsocketBuilder +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Manages Nostr relay connections, subscriptions, and status tracking. + * Can be used by both Android and Desktop apps. + * + * @param websocketBuilder Platform-specific websocket builder (e.g., OkHttp-based) + */ +open class RelayConnectionManager( + websocketBuilder: WebsocketBuilder, +) : IRelayClientListener { + private val client = NostrClient(websocketBuilder) + + private val _relayStatuses = MutableStateFlow>(emptyMap()) + val relayStatuses: StateFlow> = _relayStatuses.asStateFlow() + + val connectedRelays: StateFlow> = client.connectedRelaysFlow() + val availableRelays: StateFlow> = client.availableRelaysFlow() + + 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() { + DefaultRelays.RELAYS.forEach { addRelay(it) } + } + + fun subscribe( + subId: String, + filters: List, + relays: Set = availableRelays.value, + listener: IRequestListener? = null, + ) { + val filterMap = relays.associateWith { filters } + client.openReqSubscription(subId, filterMap, listener) + } + + fun unsubscribe(subId: String) { + client.close(subId) + } + + fun send(event: Event, relays: Set = connectedRelays.value) { + client.send(event, relays) + } + + private fun updateRelayStatus( + url: NormalizedRelayUrl, + update: (RelayStatus) -> RelayStatus, + ) { + _relayStatuses.value = _relayStatuses.value.toMutableMap().apply { + val current = this[url] ?: RelayStatus(url, connected = false) + this[url] = update(current) + } + } + + override fun onConnecting(relay: IRelayClient) { + updateRelayStatus(relay.url) { + it.copy(connected = false, error = null) + } + } + + override fun onConnected(relay: IRelayClient, pingMillis: Int, compressed: Boolean) { + updateRelayStatus(relay.url) { + it.copy(connected = true, pingMs = pingMillis, compressed = compressed, error = null) + } + } + + override fun onDisconnected(relay: IRelayClient) { + updateRelayStatus(relay.url) { + it.copy(connected = false) + } + } + + override fun onCannotConnect(relay: IRelayClient, errorMessage: String) { + updateRelayStatus(relay.url) { + it.copy(connected = false, error = errorMessage) + } + } + + override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) { + // Events are handled by subscription listeners + } + + override fun onSent(relay: IRelayClient, cmdStr: String, cmd: Command, success: Boolean) { + // Command send tracking + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt new file mode 100644 index 000000000..decd6428f --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/network/RelayStatus.kt @@ -0,0 +1,48 @@ +/** + * 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.commons.network + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl + +/** + * Represents the connection status of a Nostr relay. + * Used by both Android and Desktop apps. + */ +data class RelayStatus( + val url: NormalizedRelayUrl, + val connected: Boolean, + val pingMs: Int? = null, + val compressed: Boolean = false, + val error: String? = null, +) + +/** + * Default relay URLs for Nostr connectivity. + */ +object DefaultRelays { + val RELAYS = listOf( + "wss://relay.damus.io", + "wss://relay.nostr.band", + "wss://nos.lol", + "wss://relay.snort.social", + "wss://nostr.wine", + ) +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt new file mode 100644 index 000000000..e93045d37 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/KeyInputField.kt @@ -0,0 +1,107 @@ +/** + * 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.commons.ui.auth + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Visibility +import androidx.compose.material.icons.filled.VisibilityOff +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.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.Modifier +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp + +/** + * Text field for entering Nostr keys (nsec or npub) with visibility toggle. + */ +@Composable +fun KeyInputField( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + label: String = "nsec or npub", + placeholder: String = "nsec1... or npub1...", + errorMessage: String? = null, +) { + var showKey by remember { mutableStateOf(false) } + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + label = { Text(label) }, + placeholder = { Text(placeholder) }, + 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) } + }, + ) +} + +/** + * Card displaying a Nostr key that users can select/copy. + */ +@Composable +fun SelectableKeyText( + key: String, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) { + Text( + text = key, + modifier = Modifier.padding(12.dp).fillMaxWidth(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface, + ) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt new file mode 100644 index 000000000..951089a5b --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/LoginCard.kt @@ -0,0 +1,132 @@ +/** + * 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.commons.ui.auth + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +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.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Login card with Nostr key input field and action buttons. + * + * @param onLogin Callback when login is attempted with the key input + * @param onGenerateNew Callback when "Generate New" is clicked + * @param modifier Modifier for the card + * @param cardWidth Width of the card (default 400.dp) + * @param title Card title + * @param subtitle Subtitle/hint text + */ +@Composable +fun LoginCard( + onLogin: (String) -> Result, + onGenerateNew: () -> Unit, + modifier: Modifier = Modifier, + cardWidth: Dp = 400.dp, + title: String = "Login with your Nostr key", + subtitle: String = "Use nsec for full access or npub for read-only", +) { + var keyInput by remember { mutableStateOf("") } + var errorMessage by remember { mutableStateOf(null) } + + Card( + modifier = modifier.width(cardWidth), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + ) { + Column( + modifier = Modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + ) + + Spacer(Modifier.height(16.dp)) + + KeyInputField( + value = keyInput, + onValueChange = { + keyInput = it + errorMessage = null + }, + errorMessage = errorMessage, + ) + + Spacer(Modifier.height(8.dp)) + + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Spacer(Modifier.height(16.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Button( + onClick = { + onLogin(keyInput).fold( + onSuccess = { /* handled by caller */ }, + onFailure = { errorMessage = it.message }, + ) + }, + modifier = Modifier.weight(1f), + enabled = keyInput.isNotBlank(), + ) { + Text("Login") + } + + OutlinedButton( + onClick = onGenerateNew, + modifier = Modifier.weight(1f), + ) { + Text("Generate New") + } + } + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt new file mode 100644 index 000000000..a5926a37e --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/auth/NewKeyWarningCard.kt @@ -0,0 +1,112 @@ +/** + * 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.commons.ui.auth + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +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.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Warning card displayed after generating a new Nostr key pair. + * Reminds users to save their keys and shows both public and secret keys. + * + * @param npub The public key in npub format + * @param nsec The secret key in nsec format (nullable for read-only accounts) + * @param onContinue Callback when user acknowledges they've saved their keys + * @param modifier Modifier for the card + * @param cardWidth Width of the card (default 500.dp) + */ +@Composable +fun NewKeyWarningCard( + npub: String, + nsec: String?, + onContinue: () -> Unit, + modifier: Modifier = Modifier, + cardWidth: Dp = 500.dp, +) { + Card( + modifier = modifier.width(cardWidth), + 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(npub) + + Spacer(Modifier.height(12.dp)) + + nsec?.let { secretKey -> + Text( + "Secret Key (NEVER share this!):", + style = MaterialTheme.typography.labelMedium, + color = Color.Red, + ) + SelectableKeyText(secretKey) + } + + Spacer(Modifier.height(24.dp)) + + Button( + onClick = onContinue, + modifier = Modifier.fillMaxWidth(), + ) { + Text("I've saved my keys, continue") + } + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt new file mode 100644 index 000000000..a82e4faf8 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/note/NoteCard.kt @@ -0,0 +1,188 @@ +/** + * Copyright (c) 2025 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.commons.ui.note + +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.richtext.RichTextParser +import com.vitorpamplona.amethyst.commons.util.toTimeAgo + +/** + * Data class for displaying a note card. + */ +data class NoteDisplayData( + val id: String, + val pubKeyDisplay: String, + val content: String, + val createdAt: Long, +) + +/** + * Reusable note card composable that displays a Nostr note. + * Can be used by both Desktop and Android apps. + */ +@Composable +fun NoteCard( + note: NoteDisplayData, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, +) { + val richTextParser = remember { RichTextParser() } + val urls = remember(note.content) { richTextParser.parseValidUrls(note.content) } + + Card( + modifier = modifier.fillMaxWidth(), + colors = + CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + ), + onClick = onClick ?: {}, + ) { + Column(modifier = Modifier.padding(12.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + // Author (truncated) + Text( + text = note.pubKeyDisplay.take(20) + if (note.pubKeyDisplay.length > 20) "..." else "", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + ) + + // Timestamp + Text( + text = note.createdAt.toTimeAgo(withDot = false), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + Spacer(Modifier.height(8.dp)) + + RichTextContent( + content = note.content, + urls = urls, + modifier = Modifier.fillMaxWidth(), + ) + + 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: ${note.id.take(12)}...", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), + ) + } + } +} + +/** + * Renders text content with highlighted URLs. + * Uses RichTextParser from commons to detect and highlight links. + */ +@Composable +fun RichTextContent( + content: String, + urls: Set, + modifier: Modifier = Modifier, + maxLines: Int = 10, +) { + if (urls.isEmpty()) { + Text( + text = content, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) + } else { + val annotatedText = + buildAnnotatedString { + var lastIndex = 0 + val sortedUrls = urls.sortedBy { content.indexOf(it) } + + for (url in sortedUrls) { + val startIndex = content.indexOf(url, lastIndex) + if (startIndex == -1) continue + + // Add text before URL + if (startIndex > lastIndex) { + append(content.substring(lastIndex, startIndex)) + } + + // Add URL with styling + withStyle( + SpanStyle( + color = MaterialTheme.colorScheme.primary, + textDecoration = TextDecoration.Underline, + ), + ) { + append(url) + } + + lastIndex = startIndex + url.length + } + + // Add remaining text + if (lastIndex < content.length) { + append(content.substring(lastIndex)) + } + } + + Text( + text = annotatedText, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = maxLines, + overflow = TextOverflow.Ellipsis, + modifier = modifier, + ) + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.kt new file mode 100644 index 000000000..2a6b01d67 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/profile/ProfileInfoCard.kt @@ -0,0 +1,89 @@ +/** + * 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.commons.ui.profile + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +/** + * Card displaying Nostr account key information. + * Shows public key (npub), hex format, and optional read-only indicator. + */ +@Composable +fun ProfileInfoCard( + npub: String, + pubKeyHex: String, + isReadOnly: Boolean, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + if (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( + 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( + pubKeyHex, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurface + ) + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt new file mode 100644 index 000000000..a523f2e05 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/relay/RelayStatusCard.kt @@ -0,0 +1,131 @@ +/** + * 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.commons.ui.relay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +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.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.commons.network.RelayStatus + +/** + * Card displaying the status of a Nostr relay connection. + * Shows connection status, ping time, compression, and errors. + */ +@Composable +fun RelayStatusCard( + status: RelayStatus, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + Card( + modifier = modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant + ) + ) { + Row( + modifier = Modifier.fillMaxWidth().padding(12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.weight(1f) + ) { + val statusColor = when { + status.connected -> Color.Green + status.error != null -> Color.Red + else -> Color.Gray + } + + if (status.connected) { + Icon( + Icons.Default.Check, + contentDescription = "Connected", + tint = statusColor, + modifier = Modifier.size(20.dp) + ) + } else if (status.error != null) { + Icon( + Icons.Default.Close, + contentDescription = "Error", + tint = statusColor, + modifier = Modifier.size(20.dp) + ) + } else { + CircularProgressIndicator( + modifier = Modifier.size(20.dp), + strokeWidth = 2.dp + ) + } + + Column { + Text( + status.url.url, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) + if (status.connected && status.pingMs != null) { + Text( + "${status.pingMs}ms${if (status.compressed) " • compressed" else ""}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + status.error?.let { error -> + Text( + error, + style = MaterialTheme.typography.bodySmall, + color = Color.Red.copy(alpha = 0.8f) + ) + } + } + } + + IconButton(onClick = onRemove) { + Icon( + Icons.Default.Close, + contentDescription = "Remove relay", + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt new file mode 100644 index 000000000..8f8b8d078 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/EventExtensions.kt @@ -0,0 +1,44 @@ +/** + * 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.commons.util + +import com.vitorpamplona.amethyst.commons.ui.note.NoteDisplayData +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArrayOrNull +import com.vitorpamplona.quartz.nip19Bech32.toNpub + +/** + * Extension to convert Event to NoteDisplayData for the shared NoteCard. + */ +fun Event.toNoteDisplayData(): NoteDisplayData { + val npub = try { + pubKey.hexToByteArrayOrNull()?.toNpub() ?: pubKey.take(16) + "..." + } catch (e: Exception) { + pubKey.take(16) + "..." + } + + return NoteDisplayData( + id = id, + pubKeyDisplay = npub, + content = content, + createdAt = createdAt + ) +} diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt new file mode 100644 index 000000000..1b2fb8ea0 --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/TimeAgoFormatter.kt @@ -0,0 +1,118 @@ +/** + * 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.commons.util + +import com.vitorpamplona.quartz.utils.TimeUtils +import java.text.SimpleDateFormat +import java.util.Locale + +private const val YEAR_DATE_FORMAT = "MMM dd, yyyy" +private const val MONTH_DATE_FORMAT = "MMM dd" + +private var locale = Locale.getDefault() +private var yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) +private var monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) + +private fun updateFormattersIfNeeded() { + if (locale != Locale.getDefault()) { + locale = Locale.getDefault() + yearFormatter = SimpleDateFormat(YEAR_DATE_FORMAT, locale) + monthFormatter = SimpleDateFormat(MONTH_DATE_FORMAT, locale) + } +} + +/** + * Formats a Unix timestamp (seconds) as a human-readable time ago string. + * Returns strings like " • 5m", " • 2h", " • Dec 12" + * + * @param time Unix timestamp in seconds, or null + * @param withDot Whether to prefix with " • " (default true) + * @return Formatted time ago string + */ +fun timeAgo( + time: Long?, + withDot: Boolean = true, +): String { + if (time == null) return " " + if (time == 0L) return if (withDot) " • never" else "never" + + val timeDifference = TimeUtils.now() - time + val prefix = if (withDot) " • " else "" + + return when { + timeDifference > TimeUtils.ONE_YEAR -> { + updateFormattersIfNeeded() + prefix + yearFormatter.format(time * 1000) + } + timeDifference > TimeUtils.ONE_MONTH -> { + updateFormattersIfNeeded() + prefix + monthFormatter.format(time * 1000) + } + timeDifference > TimeUtils.ONE_DAY -> { + prefix + (timeDifference / TimeUtils.ONE_DAY).toString() + "d" + } + timeDifference > TimeUtils.ONE_HOUR -> { + prefix + (timeDifference / TimeUtils.ONE_HOUR).toString() + "h" + } + timeDifference > TimeUtils.ONE_MINUTE -> { + prefix + (timeDifference / TimeUtils.ONE_MINUTE).toString() + "m" + } + else -> { + prefix + "now" + } + } +} + +/** + * Formats a Unix timestamp as a date string. + * For recent dates (< 1 day), returns the provided today string. + * + * @param time Unix timestamp in seconds + * @param never String to show for time == 0 + * @param today String to show for today's date + */ +fun dateFormatter( + time: Long?, + never: String = "never", + today: String = "today", +): String { + if (time == null) return " " + if (time == 0L) return " $never" + + val timeDifference = TimeUtils.now() - time + + return when { + timeDifference > TimeUtils.ONE_YEAR -> { + updateFormattersIfNeeded() + yearFormatter.format(time * 1000) + } + timeDifference > TimeUtils.ONE_DAY -> { + updateFormattersIfNeeded() + monthFormatter.format(time * 1000) + } + else -> today + } +} + +/** + * Extension function for Long timestamps. + */ +fun Long.toTimeAgo(withDot: Boolean = true): String = timeAgo(this, withDot) diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt new file mode 100644 index 000000000..d28deafef --- /dev/null +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/util/ZapFormatter.kt @@ -0,0 +1,82 @@ +/** + * 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.commons.util + +import java.math.BigDecimal +import java.math.RoundingMode +import java.text.DecimalFormat + +private val TenGiga = BigDecimal(10_000_000_000) +private val OneGiga = BigDecimal(1_000_000_000) +private val TenMega = BigDecimal(10_000_000) +private val OneMega = BigDecimal(1_000_000) +private val TenKilo = BigDecimal(10_000) +private val OneKilo = BigDecimal(1_000) + +private val dfGBig = ThreadLocal.withInitial { DecimalFormat("#.#G") } +private val dfGSmall = ThreadLocal.withInitial { DecimalFormat("#.0G") } +private val dfMBig = ThreadLocal.withInitial { DecimalFormat("#.#M") } +private val dfMSmall = ThreadLocal.withInitial { DecimalFormat("#.0M") } +private val dfK = ThreadLocal.withInitial { DecimalFormat("#.#k") } +private val dfN = ThreadLocal.withInitial { DecimalFormat("#") } + +/** + * Formats a BigDecimal amount to human-readable format with G/M/K suffixes. + * Returns empty string for null or very small amounts. + * + * Examples: + * - 1500 -> "1.5k" + * - 2500000 -> "2.5M" + * - 10000000000 -> "10G" + */ +fun showAmount(amount: BigDecimal?): String { + if (amount == null) return "" + if (amount.abs() < BigDecimal(0.01)) return "" + + return when { + amount >= TenGiga -> dfGBig.get()!!.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= OneGiga -> dfGSmall.get()!!.format(amount.div(OneGiga).setScale(0, RoundingMode.HALF_UP)) + amount >= TenMega -> dfMBig.get()!!.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= OneMega -> dfMSmall.get()!!.format(amount.div(OneMega).setScale(0, RoundingMode.HALF_UP)) + amount >= TenKilo -> dfK.get()!!.format(amount.div(OneKilo).setScale(0, RoundingMode.HALF_UP)) + else -> dfN.get()!!.format(amount) + } +} + +/** + * Formats a BigDecimal amount to human-readable format. + * Returns "0" for null or very small amounts instead of empty string. + */ +fun showAmountWithZero(amount: BigDecimal?): String { + if (amount == null) return "0" + if (amount.abs() < BigDecimal(0.01)) return "0" + return showAmount(amount) +} + +/** + * Extension function to format Long as zap amount. + */ +fun Long.toZapAmount(): String = showAmount(BigDecimal(this)) + +/** + * Extension function to format Int as zap amount. + */ +fun Int.toZapAmount(): String = showAmount(BigDecimal(this)) diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts index 86b736734..5bd8c6c60 100644 --- a/desktopApp/build.gradle.kts +++ b/desktopApp/build.gradle.kts @@ -25,6 +25,9 @@ dependencies { // Quartz Nostr library (will use JVM target) implementation(project(":quartz")) + // Commons library + implementation(project(":commons")) + // Coroutines implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.coroutines.swing) diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt index 5f0277445..1027609e4 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/Main.kt @@ -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 - ) - } - } - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt index a8583c4fd..c143b4d7b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/network/DesktopRelayConnectionManager.kt @@ -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>(emptyMap()) - val relayStatuses: StateFlow> = _relayStatuses.asStateFlow() - - val connectedRelays: StateFlow> = client.connectedRelaysFlow() - val availableRelays: StateFlow> = client.availableRelaysFlow() - - companion object { - val DEFAULT_RELAYS = listOf( - "wss://relay.damus.io", - "wss://relay.nostr.band", - "wss://nos.lol", - "wss://relay.snort.social", - "wss://nostr.wine", - ) - } - - init { - client.subscribe(this) - } - - fun connect() { - client.connect() - } - - fun disconnect() { - client.disconnect() - } - - fun addRelay(url: String): NormalizedRelayUrl? { - val normalized = RelayUrlNormalizer.normalizeOrNull(url) ?: return null - updateRelayStatus(normalized) { it.copy(connected = false, error = null) } - return normalized - } - - fun removeRelay(url: NormalizedRelayUrl) { - _relayStatuses.value = _relayStatuses.value - url - } - - fun addDefaultRelays() { - DEFAULT_RELAYS.forEach { addRelay(it) } - } - - fun subscribe( - subId: String, - filters: List, - relays: Set = availableRelays.value, - listener: IRequestListener? = null, - ) { - val filterMap = relays.associateWith { filters } - client.openReqSubscription(subId, filterMap, listener) - } - - fun unsubscribe(subId: String) { - client.close(subId) - } - - fun send(event: Event, relays: Set = connectedRelays.value) { - client.send(event, relays) - } - - private fun updateRelayStatus( - url: NormalizedRelayUrl, - update: (RelayStatus) -> RelayStatus - ) { - _relayStatuses.value = _relayStatuses.value.toMutableMap().apply { - val current = this[url] ?: RelayStatus(url, connected = false) - this[url] = update(current) - } - } - - override fun onConnecting(relay: IRelayClient) { - updateRelayStatus(relay.url) { - it.copy(connected = false, error = null) - } - } - - override fun onConnected(relay: IRelayClient, pingMillis: Int, compressed: Boolean) { - updateRelayStatus(relay.url) { - it.copy(connected = true, pingMs = pingMillis, compressed = compressed, error = null) - } - } - - override fun onDisconnected(relay: IRelayClient) { - updateRelayStatus(relay.url) { - it.copy(connected = false) - } - } - - override fun onCannotConnect(relay: IRelayClient, errorMessage: String) { - updateRelayStatus(relay.url) { - it.copy(connected = false, error = errorMessage) - } - } - - override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) { - // Events are handled by subscription listeners - } - - override fun onSent(relay: IRelayClient, cmdStr: String, cmd: Command, success: Boolean) { - // Command send tracking - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt index 8eef7d1e6..5ebb75994 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/FeedScreen.kt @@ -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 - } -} diff --git a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt index 2f9ba4fb1..28632b99b 100644 --- a/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt +++ b/desktopApp/src/jvmMain/kotlin/com/vitorpamplona/amethyst/desktop/ui/LoginScreen.kt @@ -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(null) } var showNewKeyDialog by remember { mutableStateOf(false) } var generatedAccount by remember { mutableStateOf(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, - ) - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt index eae01d726..c7ffb0656 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/core/HexKey.kt @@ -29,6 +29,9 @@ fun ByteArray.toHexKey(): HexKey = Hex.encode(this) fun HexKey.hexToByteArray(): ByteArray = Hex.decode(this) +fun HexKey.hexToByteArrayOrNull(): ByteArray? = + if (Hex.isHex(this)) Hex.decode(this) else null + const val PUBKEY_LENGTH = 64 const val EVENT_ID_LENGTH = 64