chore: merge the develop branch into master #1

Merged
reya merged 43 commits from develop into master 2026-05-23 00:50:13 +00:00
5 changed files with 200 additions and 45 deletions
Showing only changes of commit 92f681e2fa - Show all commits

View File

@@ -1,14 +1,17 @@
package su.reya.coop.screens package su.reya.coop.screens
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
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.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.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ButtonDefaults
@@ -16,26 +19,46 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface import androidx.compose.material3.Surface
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coop.composeapp.generated.resources.Res import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import rust.nostr.sdk.Keys
import rust.nostr.sdk.NostrConnectUri
import rust.nostr.sdk.PublicKey
import su.reya.coop.LocalNavController
import su.reya.coop.LocalNostrViewModel
import su.reya.coop.LocalSnackbarHostState import su.reya.coop.LocalSnackbarHostState
import su.reya.coop.Screen
import su.reya.coop.shared.Avatar
import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
@@ -45,7 +68,48 @@ fun ImportScreen(
onSave: (secret: String) -> Unit onSave: (secret: String) -> Unit
) { ) {
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current
val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
var secret by remember { mutableStateOf("") } var secret by remember { mutableStateOf("") }
var pubkey by remember { mutableStateOf<PublicKey?>(null) }
val metadata by remember(pubkey) {
if (pubkey != null) {
viewModel.getMetadata(pubkey!!)
} else {
MutableStateFlow(null)
}
}.collectAsState(null)
val profile = metadata?.asRecord()
val displayName = profile?.displayName ?: profile?.name ?: pubkey?.short() ?: "Unknown"
val picture = profile?.picture
val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle
val qrResult by savedStateHandle
?.getStateFlow<String?>("qr_result", null)
?.collectAsState()
?: remember { mutableStateOf(null) }
LaunchedEffect(qrResult) {
qrResult?.let { result ->
runCatching {
if (result.startsWith("nsec")) {
Keys.parse(result)
} else if (result.startsWith("bunker://")) {
NostrConnectUri.parse(result)
} else {
throw IllegalArgumentException("Invalid secret: $result")
}
}
.onSuccess { it -> secret = result }
.onFailure { e -> println("Failed to parse QR: ${e.message}") }
// Clear the nav state
navController.currentBackStackEntry?.savedStateHandle?.remove<String>("qr_result")
}
}
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
@@ -58,6 +122,9 @@ fun ImportScreen(
style = MaterialTheme.typography.titleMediumEmphasized style = MaterialTheme.typography.titleMediumEmphasized
) )
}, },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon( Icon(
@@ -66,51 +133,120 @@ fun ImportScreen(
) )
} }
}, },
colors = TopAppBarDefaults.topAppBarColors( actions = {
containerColor = MaterialTheme.colorScheme.surfaceContainer, IconButton(onClick = { navController.navigate(Screen.Scan) }) {
) Icon(
painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner"
)
}
}
) )
}, },
content = { innerPadding -> content = { innerPadding ->
Surface( Column(
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) { ) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .weight(1f)
.padding(24.dp) .fillMaxWidth()
.verticalScroll(rememberScrollState()), .padding(top = innerPadding.calculateTopPadding()),
horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(16.dp) horizontalAlignment = Alignment.CenterHorizontally
) { ) {
OutlinedTextField( Box(
value = secret,
onValueChange = { secret = it },
label = { Text("Enter nsec or bunker") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
)
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
onSave(secret)
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .size(120.dp)
.height(ButtonDefaults.LargeContainerHeight), .clip(MaterialShapes.Pentagon.toShape()),
enabled = secret.isNotBlank() && !isLoading, contentAlignment = Alignment.Center
) { ) {
if (isLoading) { Avatar(
LoadingIndicator() picture = picture,
} else { description = "Profile picture",
Text( modifier = Modifier.fillMaxSize(),
text = "Save & Continue", shape = MaterialShapes.Pentagon.toShape(),
style = MaterialTheme.typography.titleLargeEmphasized, )
) }
Spacer(modifier = Modifier.size(8.dp))
Text(
text = displayName,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized,
)
}
Surface(
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Text(
text = "Enter your Secret Key or Bunker URI:",
style = MaterialTheme.typography.titleMediumEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = secret,
onValueChange = { secret = it },
modifier = Modifier.fillMaxWidth(),
maxLines = 4,
visualTransformation = PasswordVisualTransformation('*'),
textStyle = MaterialTheme.typography.bodyMediumEmphasized.copy(
color = MaterialTheme.colorScheme.primaryFixed,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (secret.isEmpty()) {
Text(
"bunker://",
style = MaterialTheme.typography.bodyMediumEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
}
innerTextField()
}
}
)
Spacer(modifier = Modifier.weight(1f))
Button(
onClick = {
if (pubkey == null) {
scope.launch {
viewModel.verifyIdentity(secret).let { pubkey = it }
}
} else {
onSave(secret)
}
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.MediumContainerHeight),
enabled = secret.isNotBlank() && !isLoading,
) {
if (isLoading) {
LoadingIndicator()
} else {
Text(
text = if (pubkey == null) "Verify" else "Continue",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
} }
} }
} }

View File

@@ -69,8 +69,8 @@ fun NewChatScreen(
val snackbarHostState = LocalSnackbarHostState.current val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current val navController = LocalNavController.current
val viewModel = LocalNostrViewModel.current val viewModel = LocalNostrViewModel.current
val contactList by viewModel.contactList.collectAsState(initial = emptySet())
val contactList by viewModel.contactList.collectAsState(initial = emptySet())
val createGroup = remember { mutableStateOf(false) } val createGroup = remember { mutableStateOf(false) }
val searchResults = remember { mutableStateListOf<PublicKey>() } val searchResults = remember { mutableStateListOf<PublicKey>() }
val selectedReceivers = remember { mutableStateListOf<PublicKey>() } val selectedReceivers = remember { mutableStateListOf<PublicKey>() }

View File

@@ -63,10 +63,11 @@ fun NewIdentityScreen(
var bio by remember { mutableStateOf("") } var bio by remember { mutableStateOf("") }
var picture by remember { mutableStateOf<Uri?>(null) } var picture by remember { mutableStateOf<Uri?>(null) }
val launcher = val launcher = rememberLauncherForActivityResult(
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri: Uri? -> contract = ActivityResultContracts.GetContent()
picture = uri ) { uri: Uri? ->
} picture = uri
}
Scaffold( Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
@@ -224,7 +225,7 @@ fun NewIdentityScreen(
}, },
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(ButtonDefaults.LargeContainerHeight), .height(ButtonDefaults.MediumContainerHeight),
enabled = name.isNotBlank() && !isLoading, enabled = name.isNotBlank() && !isLoading,
) { ) {
if (isLoading) { if (isLoading) {
@@ -232,7 +233,7 @@ fun NewIdentityScreen(
} else { } else {
Text( Text(
text = "Continue", text = "Continue",
style = MaterialTheme.typography.titleLargeEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
) )
} }
} }

View File

@@ -569,7 +569,6 @@ class Nostr {
RelayUrl.parse("wss://purplepag.es") to listOf(filter), RelayUrl.parse("wss://purplepag.es") to listOf(filter),
RelayUrl.parse("wss://user.kindpag.es") to listOf(filter), RelayUrl.parse("wss://user.kindpag.es") to listOf(filter),
RelayUrl.parse("wss://relay.primal.net") to listOf(filter), RelayUrl.parse("wss://relay.primal.net") to listOf(filter),
RelayUrl.parse("wss://relay.damus.io") to listOf(filter),
) )
) )

View File

@@ -295,6 +295,25 @@ class NostrViewModel(
} }
} }
suspend fun verifyIdentity(secret: String): PublicKey? {
if (secret.startsWith("nsec1")) {
val keys = Keys.parse(secret)
return keys.publicKey()
} else if (secret.startsWith("bunker://")) {
val appKeys = getOrInitAppKeys()
val bunker = NostrConnectUri.parse(secret)
val timeout = Duration.parse("50s") // 50 seconds timeout
val remote = NostrConnect(uri = bunker, appKeys, timeout, null)
// Show toast to ask user to approve the connection
showError("Please approve the connection.")
return remote.getPublicKeyAsync()
} else {
throw IllegalArgumentException("Invalid secret: $secret")
}
}
fun importIdentity(secret: String) { fun importIdentity(secret: String) {
viewModelScope.launch { viewModelScope.launch {
if (secret.startsWith("nsec1")) { if (secret.startsWith("nsec1")) {