feat: Extract shared UI components to commons for Android/Desktop reuse
Extract reusable components from desktopApp to commons module: Utilities (commonMain): - NumberFormatters: countToHumanReadable, countToHumanReadableBytes - PubKeyFormatter: toShortDisplay, toDisplayHexKey for key formatting Utilities (jvmAndroid): - TimeAgoFormatter: Human-readable time formatting for Nostr timestamps - ZapFormatter: BigDecimal amount formatting with G/M/k suffixes UI Components (commonMain): - AppScreen enum for shared navigation - LoadingState, EmptyState, ErrorState with refresh/retry support - FeedHeader with relay status indicator - NoteCard for displaying Nostr notes - ActionButtons (AddButton, RemoveButton) - RobohashImage for avatar display - PlaceholderScreens (Search, Messages, Notifications) - RelayStatusColors and shared color definitions UI Components (jvmAndroid): - LoginCard with key input field - NewKeyWarningCard for new key backup warnings - KeyInputField and SelectableKeyText - ProfileInfoCard for account display - RelayStatusCard for relay management Core (jvmAndroid): - AccountManager with login/logout/key generation - RelayConnectionManager base class - RelayStatus and DefaultRelays 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -62,6 +62,8 @@ kotlin {
|
|||||||
implementation(compose.ui)
|
implementation(compose.ui)
|
||||||
implementation(compose.foundation)
|
implementation(compose.foundation)
|
||||||
implementation(compose.runtime)
|
implementation(compose.runtime)
|
||||||
|
implementation(compose.material3)
|
||||||
|
implementation(compose.materialIconsExtended)
|
||||||
|
|
||||||
// LruCache (KMP-ready)
|
// LruCache (KMP-ready)
|
||||||
implementation(libs.androidx.collection)
|
implementation(libs.androidx.collection)
|
||||||
|
|||||||
+55
@@ -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,
|
||||||
|
)
|
||||||
+92
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+158
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
+110
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+116
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
+92
@@ -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"
|
||||||
|
}
|
||||||
+45
@@ -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()
|
||||||
+27
-23
@@ -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
|
* 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
|
* 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
|
* 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.
|
* 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.hexToByteArray
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
@@ -35,6 +35,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||||||
|
|
||||||
sealed class AccountState {
|
sealed class AccountState {
|
||||||
data object LoggedOut : AccountState()
|
data object LoggedOut : AccountState()
|
||||||
|
|
||||||
data class LoggedIn(
|
data class LoggedIn(
|
||||||
val signer: NostrSigner,
|
val signer: NostrSigner,
|
||||||
val pubKeyHex: String,
|
val pubKeyHex: String,
|
||||||
@@ -52,13 +53,14 @@ class AccountManager {
|
|||||||
val keyPair = KeyPair()
|
val keyPair = KeyPair()
|
||||||
val signer = NostrSignerInternal(keyPair)
|
val signer = NostrSignerInternal(keyPair)
|
||||||
|
|
||||||
val state = AccountState.LoggedIn(
|
val state =
|
||||||
signer = signer,
|
AccountState.LoggedIn(
|
||||||
pubKeyHex = keyPair.pubKey.toHexKey(),
|
signer = signer,
|
||||||
npub = keyPair.pubKey.toNpub(),
|
pubKeyHex = keyPair.pubKey.toHexKey(),
|
||||||
nsec = keyPair.privKey?.toNsec(),
|
npub = keyPair.pubKey.toNpub(),
|
||||||
isReadOnly = false,
|
nsec = keyPair.privKey?.toNsec(),
|
||||||
)
|
isReadOnly = false,
|
||||||
|
)
|
||||||
_accountState.value = state
|
_accountState.value = state
|
||||||
return state
|
return state
|
||||||
}
|
}
|
||||||
@@ -73,13 +75,14 @@ class AccountManager {
|
|||||||
val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray())
|
val keyPair = KeyPair(privKey = privKeyHex.hexToByteArray())
|
||||||
val signer = NostrSignerInternal(keyPair)
|
val signer = NostrSignerInternal(keyPair)
|
||||||
|
|
||||||
val state = AccountState.LoggedIn(
|
val state =
|
||||||
signer = signer,
|
AccountState.LoggedIn(
|
||||||
pubKeyHex = keyPair.pubKey.toHexKey(),
|
signer = signer,
|
||||||
npub = keyPair.pubKey.toNpub(),
|
pubKeyHex = keyPair.pubKey.toHexKey(),
|
||||||
nsec = keyPair.privKey?.toNsec(),
|
npub = keyPair.pubKey.toNpub(),
|
||||||
isReadOnly = false,
|
nsec = keyPair.privKey?.toNsec(),
|
||||||
)
|
isReadOnly = false,
|
||||||
|
)
|
||||||
_accountState.value = state
|
_accountState.value = state
|
||||||
Result.success(state)
|
Result.success(state)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
@@ -94,13 +97,14 @@ class AccountManager {
|
|||||||
val keyPair = KeyPair(pubKey = pubKeyHex.hexToByteArray())
|
val keyPair = KeyPair(pubKey = pubKeyHex.hexToByteArray())
|
||||||
val signer = NostrSignerInternal(keyPair)
|
val signer = NostrSignerInternal(keyPair)
|
||||||
|
|
||||||
val state = AccountState.LoggedIn(
|
val state =
|
||||||
signer = signer,
|
AccountState.LoggedIn(
|
||||||
pubKeyHex = keyPair.pubKey.toHexKey(),
|
signer = signer,
|
||||||
npub = keyPair.pubKey.toNpub(),
|
pubKeyHex = keyPair.pubKey.toHexKey(),
|
||||||
nsec = null,
|
npub = keyPair.pubKey.toNpub(),
|
||||||
isReadOnly = true,
|
nsec = null,
|
||||||
)
|
isReadOnly = true,
|
||||||
|
)
|
||||||
_accountState.value = state
|
_accountState.value = state
|
||||||
Result.success(state)
|
Result.success(state)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
+140
@@ -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<Map<NormalizedRelayUrl, RelayStatus>>(emptyMap())
|
||||||
|
val relayStatuses: StateFlow<Map<NormalizedRelayUrl, RelayStatus>> = _relayStatuses.asStateFlow()
|
||||||
|
|
||||||
|
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = client.connectedRelaysFlow()
|
||||||
|
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = client.availableRelaysFlow()
|
||||||
|
|
||||||
|
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<Filter>,
|
||||||
|
relays: Set<NormalizedRelayUrl> = availableRelays.value,
|
||||||
|
listener: IRequestListener? = null,
|
||||||
|
) {
|
||||||
|
val filterMap = relays.associateWith { filters }
|
||||||
|
client.openReqSubscription(subId, filterMap, listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unsubscribe(subId: String) {
|
||||||
|
client.close(subId)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun send(event: Event, relays: Set<NormalizedRelayUrl> = connectedRelays.value) {
|
||||||
|
client.send(event, relays)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateRelayStatus(
|
||||||
|
url: NormalizedRelayUrl,
|
||||||
|
update: (RelayStatus) -> RelayStatus,
|
||||||
|
) {
|
||||||
|
_relayStatuses.value = _relayStatuses.value.toMutableMap().apply {
|
||||||
|
val current = this[url] ?: RelayStatus(url, connected = false)
|
||||||
|
this[url] = update(current)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConnecting(relay: IRelayClient) {
|
||||||
|
updateRelayStatus(relay.url) {
|
||||||
|
it.copy(connected = false, error = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onConnected(relay: IRelayClient, pingMillis: Int, compressed: Boolean) {
|
||||||
|
updateRelayStatus(relay.url) {
|
||||||
|
it.copy(connected = true, pingMs = pingMillis, compressed = compressed, error = null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDisconnected(relay: IRelayClient) {
|
||||||
|
updateRelayStatus(relay.url) {
|
||||||
|
it.copy(connected = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCannotConnect(relay: IRelayClient, errorMessage: String) {
|
||||||
|
updateRelayStatus(relay.url) {
|
||||||
|
it.copy(connected = false, error = errorMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) {
|
||||||
|
// Events are handled by subscription listeners
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onSent(relay: IRelayClient, cmdStr: String, cmd: Command, success: Boolean) {
|
||||||
|
// Command send tracking
|
||||||
|
}
|
||||||
|
}
|
||||||
+48
@@ -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",
|
||||||
|
)
|
||||||
|
}
|
||||||
+107
@@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+132
@@ -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<Unit>,
|
||||||
|
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<String?>(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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+112
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<String>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+89
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+131
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+44
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
+118
@@ -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)
|
||||||
@@ -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))
|
||||||
@@ -25,6 +25,9 @@ dependencies {
|
|||||||
// Quartz Nostr library (will use JVM target)
|
// Quartz Nostr library (will use JVM target)
|
||||||
implementation(project(":quartz"))
|
implementation(project(":quartz"))
|
||||||
|
|
||||||
|
// Commons library
|
||||||
|
implementation(project(":commons"))
|
||||||
|
|
||||||
// Coroutines
|
// Coroutines
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
implementation(libs.kotlinx.coroutines.swing)
|
implementation(libs.kotlinx.coroutines.swing)
|
||||||
|
|||||||
@@ -30,13 +30,10 @@ import androidx.compose.foundation.layout.fillMaxSize
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.material.icons.Icons
|
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.Email
|
||||||
import androidx.compose.material.icons.filled.Home
|
import androidx.compose.material.icons.filled.Home
|
||||||
import androidx.compose.material.icons.filled.Notifications
|
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.Button
|
||||||
import androidx.compose.material3.Card
|
import androidx.compose.material3.Card
|
||||||
import androidx.compose.material3.CardDefaults
|
import androidx.compose.material3.CardDefaults
|
||||||
import androidx.compose.material3.CircularProgressIndicator
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
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.WindowPosition
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
import androidx.compose.ui.window.rememberWindowState
|
import androidx.compose.ui.window.rememberWindowState
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
import com.vitorpamplona.amethyst.commons.account.AccountManager
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
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.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.amethyst.desktop.network.RelayStatus
|
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
|
import com.vitorpamplona.amethyst.desktop.ui.FeedScreen
|
||||||
import com.vitorpamplona.amethyst.desktop.ui.LoginScreen
|
import com.vitorpamplona.amethyst.desktop.ui.LoginScreen
|
||||||
|
|
||||||
enum class Screen {
|
|
||||||
Feed,
|
|
||||||
Search,
|
|
||||||
Messages,
|
|
||||||
Notifications,
|
|
||||||
Profile,
|
|
||||||
Settings
|
|
||||||
}
|
|
||||||
|
|
||||||
fun main() = application {
|
fun main() = application {
|
||||||
val windowState = rememberWindowState(
|
val windowState = rememberWindowState(
|
||||||
width = 1200.dp,
|
width = 1200.dp,
|
||||||
@@ -147,7 +139,7 @@ fun main() = application {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App() {
|
fun App() {
|
||||||
var currentScreen by remember { mutableStateOf(Screen.Feed) }
|
var currentScreen by remember { mutableStateOf(AppScreen.Feed) }
|
||||||
val relayManager = remember { DesktopRelayConnectionManager() }
|
val relayManager = remember { DesktopRelayConnectionManager() }
|
||||||
val accountManager = remember { AccountManager() }
|
val accountManager = remember { AccountManager() }
|
||||||
val accountState by accountManager.accountState.collectAsState()
|
val accountState by accountManager.accountState.collectAsState()
|
||||||
@@ -171,7 +163,7 @@ fun App() {
|
|||||||
is AccountState.LoggedOut -> {
|
is AccountState.LoggedOut -> {
|
||||||
LoginScreen(
|
LoginScreen(
|
||||||
accountManager = accountManager,
|
accountManager = accountManager,
|
||||||
onLoginSuccess = { currentScreen = Screen.Feed }
|
onLoginSuccess = { currentScreen = AppScreen.Feed }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
is AccountState.LoggedIn -> {
|
is AccountState.LoggedIn -> {
|
||||||
@@ -190,8 +182,8 @@ fun App() {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun MainContent(
|
fun MainContent(
|
||||||
currentScreen: Screen,
|
currentScreen: AppScreen,
|
||||||
onScreenChange: (Screen) -> Unit,
|
onScreenChange: (AppScreen) -> Unit,
|
||||||
relayManager: DesktopRelayConnectionManager,
|
relayManager: DesktopRelayConnectionManager,
|
||||||
accountManager: AccountManager,
|
accountManager: AccountManager,
|
||||||
account: AccountState.LoggedIn,
|
account: AccountState.LoggedIn,
|
||||||
@@ -207,36 +199,36 @@ fun MainContent(
|
|||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
icon = { Icon(Icons.Default.Home, contentDescription = "Feed") },
|
icon = { Icon(Icons.Default.Home, contentDescription = "Feed") },
|
||||||
label = { Text("Feed") },
|
label = { Text("Feed") },
|
||||||
selected = currentScreen == Screen.Feed,
|
selected = currentScreen == AppScreen.Feed,
|
||||||
onClick = { onScreenChange(Screen.Feed) }
|
onClick = { onScreenChange(AppScreen.Feed) }
|
||||||
)
|
)
|
||||||
|
|
||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
icon = { Icon(Icons.Default.Search, contentDescription = "Search") },
|
icon = { Icon(Icons.Default.Search, contentDescription = "Search") },
|
||||||
label = { Text("Search") },
|
label = { Text("Search") },
|
||||||
selected = currentScreen == Screen.Search,
|
selected = currentScreen == AppScreen.Search,
|
||||||
onClick = { onScreenChange(Screen.Search) }
|
onClick = { onScreenChange(AppScreen.Search) }
|
||||||
)
|
)
|
||||||
|
|
||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
icon = { Icon(Icons.Default.Email, contentDescription = "Messages") },
|
icon = { Icon(Icons.Default.Email, contentDescription = "Messages") },
|
||||||
label = { Text("DMs") },
|
label = { Text("DMs") },
|
||||||
selected = currentScreen == Screen.Messages,
|
selected = currentScreen == AppScreen.Messages,
|
||||||
onClick = { onScreenChange(Screen.Messages) }
|
onClick = { onScreenChange(AppScreen.Messages) }
|
||||||
)
|
)
|
||||||
|
|
||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") },
|
icon = { Icon(Icons.Default.Notifications, contentDescription = "Notifications") },
|
||||||
label = { Text("Alerts") },
|
label = { Text("Alerts") },
|
||||||
selected = currentScreen == Screen.Notifications,
|
selected = currentScreen == AppScreen.Notifications,
|
||||||
onClick = { onScreenChange(Screen.Notifications) }
|
onClick = { onScreenChange(AppScreen.Notifications) }
|
||||||
)
|
)
|
||||||
|
|
||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
|
icon = { Icon(Icons.Default.Person, contentDescription = "Profile") },
|
||||||
label = { Text("Profile") },
|
label = { Text("Profile") },
|
||||||
selected = currentScreen == Screen.Profile,
|
selected = currentScreen == AppScreen.Profile,
|
||||||
onClick = { onScreenChange(Screen.Profile) }
|
onClick = { onScreenChange(AppScreen.Profile) }
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
@@ -246,8 +238,8 @@ fun MainContent(
|
|||||||
NavigationRailItem(
|
NavigationRailItem(
|
||||||
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
icon = { Icon(Icons.Default.Settings, contentDescription = "Settings") },
|
||||||
label = { Text("Settings") },
|
label = { Text("Settings") },
|
||||||
selected = currentScreen == Screen.Settings,
|
selected = currentScreen == AppScreen.Settings,
|
||||||
onClick = { onScreenChange(Screen.Settings) }
|
onClick = { onScreenChange(AppScreen.Settings) }
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
@@ -260,64 +252,17 @@ fun MainContent(
|
|||||||
modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp)
|
modifier = Modifier.weight(1f).fillMaxHeight().padding(24.dp)
|
||||||
) {
|
) {
|
||||||
when (currentScreen) {
|
when (currentScreen) {
|
||||||
Screen.Feed -> FeedScreen(relayManager)
|
AppScreen.Feed -> FeedScreen(relayManager)
|
||||||
Screen.Search -> SearchPlaceholder()
|
AppScreen.Search -> SearchPlaceholder()
|
||||||
Screen.Messages -> MessagesPlaceholder()
|
AppScreen.Messages -> MessagesPlaceholder()
|
||||||
Screen.Notifications -> NotificationsPlaceholder()
|
AppScreen.Notifications -> NotificationsPlaceholder()
|
||||||
Screen.Profile -> ProfileScreen(account, accountManager)
|
AppScreen.Profile -> ProfileScreen(account, accountManager)
|
||||||
Screen.Settings -> RelaySettingsScreen(relayManager)
|
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
|
@Composable
|
||||||
fun ProfileScreen(account: AccountState.LoggedIn, accountManager: AccountManager) {
|
fun ProfileScreen(account: AccountState.LoggedIn, accountManager: AccountManager) {
|
||||||
@@ -329,47 +274,11 @@ fun ProfileScreen(account: AccountState.LoggedIn, accountManager: AccountManager
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
Card(
|
ProfileInfoCard(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
npub = account.npub,
|
||||||
colors = CardDefaults.cardColors(
|
pubKeyHex = account.pubKeyHex,
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant
|
isReadOnly = account.isReadOnly,
|
||||||
)
|
)
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
if (account.isReadOnly) {
|
|
||||||
Text(
|
|
||||||
"Read-only mode",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = Color.Yellow
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
"Public Key",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
account.npub,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
|
|
||||||
Text(
|
|
||||||
"Hex",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
account.pubKeyHex,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
|
|
||||||
@@ -451,9 +360,10 @@ fun RelaySettingsScreen(relayManager: DesktopRelayConnectionManager) {
|
|||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
) {
|
) {
|
||||||
items(relayStatuses.values.toList()) { status ->
|
items(relayStatuses.values.toList()) { status ->
|
||||||
RelayStatusCard(status) {
|
RelayStatusCard(
|
||||||
relayManager.removeRelay(status.url)
|
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+7
-126
@@ -20,132 +20,13 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.amethyst.desktop.network
|
package com.vitorpamplona.amethyst.desktop.network
|
||||||
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.amethyst.commons.network.RelayConnectionManager
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.listeners.IRelayClientListener
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.single.IRelayClient
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.Message
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.Command
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
|
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket
|
import 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,
|
* Desktop-specific relay connection manager that configures OkHttp for websockets.
|
||||||
val connected: Boolean,
|
* Delegates to the shared RelayConnectionManager from commons.
|
||||||
val pingMs: Int? = null,
|
*/
|
||||||
val compressed: Boolean = false,
|
class DesktopRelayConnectionManager : RelayConnectionManager(
|
||||||
val error: String? = null,
|
websocketBuilder = BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient)
|
||||||
)
|
)
|
||||||
|
|
||||||
class DesktopRelayConnectionManager : IRelayClientListener {
|
|
||||||
private val websocketBuilder = BasicOkHttpWebSocket.Builder(DesktopHttpClient::getHttpClient)
|
|
||||||
private val client = NostrClient(websocketBuilder)
|
|
||||||
|
|
||||||
private val _relayStatuses = MutableStateFlow<Map<NormalizedRelayUrl, RelayStatus>>(emptyMap())
|
|
||||||
val relayStatuses: StateFlow<Map<NormalizedRelayUrl, RelayStatus>> = _relayStatuses.asStateFlow()
|
|
||||||
|
|
||||||
val connectedRelays: StateFlow<Set<NormalizedRelayUrl>> = client.connectedRelaysFlow()
|
|
||||||
val availableRelays: StateFlow<Set<NormalizedRelayUrl>> = client.availableRelaysFlow()
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
val DEFAULT_RELAYS = listOf(
|
|
||||||
"wss://relay.damus.io",
|
|
||||||
"wss://relay.nostr.band",
|
|
||||||
"wss://nos.lol",
|
|
||||||
"wss://relay.snort.social",
|
|
||||||
"wss://nostr.wine",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
init {
|
|
||||||
client.subscribe(this)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun connect() {
|
|
||||||
client.connect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun disconnect() {
|
|
||||||
client.disconnect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addRelay(url: String): NormalizedRelayUrl? {
|
|
||||||
val normalized = RelayUrlNormalizer.normalizeOrNull(url) ?: return null
|
|
||||||
updateRelayStatus(normalized) { it.copy(connected = false, error = null) }
|
|
||||||
return normalized
|
|
||||||
}
|
|
||||||
|
|
||||||
fun removeRelay(url: NormalizedRelayUrl) {
|
|
||||||
_relayStatuses.value = _relayStatuses.value - url
|
|
||||||
}
|
|
||||||
|
|
||||||
fun addDefaultRelays() {
|
|
||||||
DEFAULT_RELAYS.forEach { addRelay(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
fun subscribe(
|
|
||||||
subId: String,
|
|
||||||
filters: List<Filter>,
|
|
||||||
relays: Set<NormalizedRelayUrl> = availableRelays.value,
|
|
||||||
listener: IRequestListener? = null,
|
|
||||||
) {
|
|
||||||
val filterMap = relays.associateWith { filters }
|
|
||||||
client.openReqSubscription(subId, filterMap, listener)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun unsubscribe(subId: String) {
|
|
||||||
client.close(subId)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun send(event: Event, relays: Set<NormalizedRelayUrl> = connectedRelays.value) {
|
|
||||||
client.send(event, relays)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateRelayStatus(
|
|
||||||
url: NormalizedRelayUrl,
|
|
||||||
update: (RelayStatus) -> RelayStatus
|
|
||||||
) {
|
|
||||||
_relayStatuses.value = _relayStatuses.value.toMutableMap().apply {
|
|
||||||
val current = this[url] ?: RelayStatus(url, connected = false)
|
|
||||||
this[url] = update(current)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onConnecting(relay: IRelayClient) {
|
|
||||||
updateRelayStatus(relay.url) {
|
|
||||||
it.copy(connected = false, error = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onConnected(relay: IRelayClient, pingMillis: Int, compressed: Boolean) {
|
|
||||||
updateRelayStatus(relay.url) {
|
|
||||||
it.copy(connected = true, pingMs = pingMillis, compressed = compressed, error = null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDisconnected(relay: IRelayClient) {
|
|
||||||
updateRelayStatus(relay.url) {
|
|
||||||
it.copy(connected = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCannotConnect(relay: IRelayClient, errorMessage: String) {
|
|
||||||
updateRelayStatus(relay.url) {
|
|
||||||
it.copy(connected = false, error = errorMessage)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onIncomingMessage(relay: IRelayClient, msgStr: String, msg: Message) {
|
|
||||||
// Events are handled by subscription listeners
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onSent(relay: IRelayClient, cmdStr: String, cmd: Command, success: Boolean) {
|
|
||||||
// Command send tracking
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+15
-165
@@ -22,49 +22,29 @@ package com.vitorpamplona.amethyst.desktop.ui
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
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.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
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.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
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 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.amethyst.desktop.network.DesktopRelayConnectionManager
|
||||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent
|
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
|
@Composable
|
||||||
fun FeedScreen(relayManager: DesktopRelayConnectionManager) {
|
fun FeedScreen(relayManager: DesktopRelayConnectionManager) {
|
||||||
@@ -126,159 +106,29 @@ fun FeedScreen(relayManager: DesktopRelayConnectionManager) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
// Header
|
FeedHeader(
|
||||||
Row(
|
title = "Global Feed",
|
||||||
modifier = Modifier.fillMaxWidth(),
|
connectedRelayCount = connectedRelays.size,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
onRefresh = { relayManager.connect() }
|
||||||
verticalAlignment = Alignment.CenterVertically
|
)
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
"Global Feed",
|
|
||||||
style = MaterialTheme.typography.headlineMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onBackground
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
|
||||||
) {
|
|
||||||
val connected = connectedRelays.size
|
|
||||||
val color = when {
|
|
||||||
connected == 0 -> Color.Red
|
|
||||||
connected < 3 -> Color.Yellow
|
|
||||||
else -> Color.Green
|
|
||||||
}
|
|
||||||
Icon(
|
|
||||||
if (connected > 0) Icons.Default.Check else Icons.Default.Close,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = color,
|
|
||||||
modifier = Modifier.size(16.dp)
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"$connected relay${if (connected != 1) "s" else ""}",
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
style = MaterialTheme.typography.bodySmall
|
|
||||||
)
|
|
||||||
IconButton(onClick = { relayManager.connect() }) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Refresh,
|
|
||||||
contentDescription = "Reconnect",
|
|
||||||
tint = MaterialTheme.colorScheme.primary
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(Modifier.height(16.dp))
|
Spacer(Modifier.height(16.dp))
|
||||||
|
|
||||||
if (connectedRelays.isEmpty()) {
|
if (connectedRelays.isEmpty()) {
|
||||||
Column(
|
LoadingState("Connecting to relays...")
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Text(
|
|
||||||
"Connecting to relays...",
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else if (events.isEmpty()) {
|
} else if (events.isEmpty()) {
|
||||||
Column(
|
LoadingState("Loading notes...")
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
|
||||||
verticalArrangement = Arrangement.Center
|
|
||||||
) {
|
|
||||||
CircularProgressIndicator()
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Text(
|
|
||||||
"Loading notes...",
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
items(events, key = { it.id }) { event ->
|
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+17
-185
@@ -22,24 +22,11 @@ package com.vitorpamplona.amethyst.desktop.ui
|
|||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
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.MaterialTheme
|
||||||
import androidx.compose.material3.OutlinedButton
|
|
||||||
import androidx.compose.material3.OutlinedTextField
|
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -48,21 +35,17 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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 androidx.compose.ui.unit.dp
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountManager
|
import com.vitorpamplona.amethyst.commons.account.AccountManager
|
||||||
import com.vitorpamplona.amethyst.desktop.account.AccountState
|
import com.vitorpamplona.amethyst.commons.account.AccountState
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.auth.LoginCard
|
||||||
|
import com.vitorpamplona.amethyst.commons.ui.auth.NewKeyWarningCard
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(
|
fun LoginScreen(
|
||||||
accountManager: AccountManager,
|
accountManager: AccountManager,
|
||||||
onLoginSuccess: () -> Unit,
|
onLoginSuccess: () -> Unit,
|
||||||
) {
|
) {
|
||||||
var keyInput by remember { mutableStateOf("") }
|
|
||||||
var showKey by remember { mutableStateOf(false) }
|
|
||||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
|
||||||
var showNewKeyDialog by remember { mutableStateOf(false) }
|
var showNewKeyDialog by remember { mutableStateOf(false) }
|
||||||
var generatedAccount by remember { mutableStateOf<AccountState.LoggedIn?>(null) }
|
var generatedAccount by remember { mutableStateOf<AccountState.LoggedIn?>(null) }
|
||||||
|
|
||||||
@@ -87,97 +70,23 @@ fun LoginScreen(
|
|||||||
|
|
||||||
Spacer(Modifier.height(48.dp))
|
Spacer(Modifier.height(48.dp))
|
||||||
|
|
||||||
Card(
|
LoginCard(
|
||||||
modifier = Modifier.width(400.dp),
|
onLogin = { keyInput ->
|
||||||
colors = CardDefaults.cardColors(
|
accountManager.loginWithKey(keyInput).map {
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceVariant,
|
onLoginSuccess()
|
||||||
),
|
|
||||||
) {
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
onGenerateNew = {
|
||||||
|
generatedAccount = accountManager.generateNewAccount()
|
||||||
|
showNewKeyDialog = true
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if (showNewKeyDialog && generatedAccount != null) {
|
if (showNewKeyDialog && generatedAccount != null) {
|
||||||
Spacer(Modifier.height(24.dp))
|
Spacer(Modifier.height(24.dp))
|
||||||
NewKeyCard(
|
NewKeyWarningCard(
|
||||||
account = generatedAccount!!,
|
npub = generatedAccount!!.npub,
|
||||||
|
nsec = generatedAccount!!.nsec,
|
||||||
onContinue = {
|
onContinue = {
|
||||||
showNewKeyDialog = false
|
showNewKeyDialog = false
|
||||||
onLoginSuccess()
|
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -29,6 +29,9 @@ fun ByteArray.toHexKey(): HexKey = Hex.encode(this)
|
|||||||
|
|
||||||
fun HexKey.hexToByteArray(): ByteArray = Hex.decode(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 PUBKEY_LENGTH = 64
|
||||||
|
|
||||||
const val EVENT_ID_LENGTH = 64
|
const val EVENT_ID_LENGTH = 64
|
||||||
|
|||||||
Reference in New Issue
Block a user