2 Commits

Author SHA1 Message Date
92f681e2fa update import screen 2026-05-21 10:30:27 +07:00
e6dff5277d update new identity screen 2026-05-20 16:55:57 +07:00
7 changed files with 345 additions and 99 deletions

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M440,520L200,520L200,440L440,440L440,200L520,200L520,440L760,440L760,520L520,520L520,760L440,760L440,520Z" />
</vector>

View File

@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.Icon
@@ -68,6 +69,8 @@ fun ChatScreen(
val viewModel = LocalNostrViewModel.current
val room = viewModel.getChatRoom(id)
val listState = rememberLazyListState()
val displayName by remember(room) { room.displayNameFlow(viewModel) }.collectAsState("Loading...")
val picture by remember(room) { room.pictureFlow(viewModel) }.collectAsState(null)
@@ -117,6 +120,12 @@ fun ChatScreen(
}
}
LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) {
listState.animateScrollToItem(0)
}
}
Scaffold(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
snackbarHost = { SnackbarHost(snackbarHostState) },
@@ -174,7 +183,8 @@ fun ChatScreen(
.weight(1f)
.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
reverseLayout = true
reverseLayout = true,
state = listState,
) {
groupedMessages.forEach { (dateHeader, messagesInGroup) ->
items(

View File

@@ -1,14 +1,17 @@
package su.reya.coop.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -16,26 +19,46 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
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 coop.composeapp.generated.resources.Res
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 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.Screen
import su.reya.coop.shared.Avatar
import su.reya.coop.short
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
@@ -45,7 +68,48 @@ fun ImportScreen(
onSave: (secret: String) -> Unit
) {
val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.current
val viewModel = LocalNostrViewModel.current
val scope = rememberCoroutineScope()
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(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
@@ -58,6 +122,9 @@ fun ImportScreen(
style = MaterialTheme.typography.titleMediumEmphasized
)
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
@@ -66,16 +133,52 @@ fun ImportScreen(
)
}
},
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
actions = {
IconButton(onClick = { navController.navigate(Screen.Scan) }) {
Icon(
painter = painterResource(Res.drawable.ic_scanner),
contentDescription = "Scanner"
)
}
}
)
},
content = { innerPadding ->
Column(
modifier = Modifier.fillMaxSize(),
) {
Column(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(top = innerPadding.calculateTopPadding()),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(120.dp)
.clip(MaterialShapes.Pentagon.toShape()),
contentAlignment = Alignment.Center
) {
Avatar(
picture = picture,
description = "Profile picture",
modifier = Modifier.fillMaxSize(),
shape = MaterialShapes.Pentagon.toShape(),
)
}
Spacer(modifier = Modifier.size(8.dp))
Text(
text = displayName,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.titleLargeEmphasized,
)
}
Surface(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
.weight(1f)
.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
) {
@@ -84,37 +187,70 @@ fun ImportScreen(
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
OutlinedTextField(
Text(
text = "Enter your Secret Key or Bunker URI:",
style = MaterialTheme.typography.titleMediumEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = secret,
onValueChange = { secret = it },
label = { Text("Enter nsec or bunker") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
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.LargeContainerHeight),
.height(ButtonDefaults.MediumContainerHeight),
enabled = secret.isNotBlank() && !isLoading,
) {
if (isLoading) {
LoadingIndicator()
} else {
Text(
text = "Save & Continue",
style = MaterialTheme.typography.titleLargeEmphasized,
text = if (pubkey == null) "Verify" else "Continue",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
}
}
}
}
}
)
}

View File

@@ -69,8 +69,8 @@ fun NewChatScreen(
val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.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 searchResults = remember { mutableStateListOf<PublicKey>() }
val selectedReceivers = remember { mutableStateListOf<PublicKey>() }

View File

@@ -14,8 +14,8 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
@@ -23,14 +23,15 @@ import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialShapes
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.toShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
@@ -39,12 +40,14 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage
import coop.composeapp.generated.resources.Res
import coop.composeapp.generated.resources.ic_arrow_back
import coop.composeapp.generated.resources.ic_avatar
import coop.composeapp.generated.resources.ic_plus
import org.jetbrains.compose.resources.painterResource
import su.reya.coop.LocalSnackbarHostState
@@ -53,15 +56,16 @@ import su.reya.coop.LocalSnackbarHostState
fun NewIdentityScreen(
isLoading: Boolean,
onBack: () -> Unit,
onSave: (name: String, bio: String, picture: Uri?) -> Unit
onSave: (name: String, bio: String?, picture: Uri?) -> Unit
) {
val snackbarHostState = LocalSnackbarHostState.current
var name by remember { mutableStateOf("") }
var bio by remember { mutableStateOf("") }
var picture by remember { mutableStateOf<Uri?>(null) }
val launcher =
rememberLauncherForActivityResult(contract = ActivityResultContracts.GetContent()) { uri: Uri? ->
val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetContent()
) { uri: Uri? ->
picture = uri
}
@@ -90,25 +94,21 @@ fun NewIdentityScreen(
)
},
content = { innerPadding ->
Surface(
modifier = Modifier
.fillMaxSize()
.padding(top = innerPadding.calculateTopPadding()),
color = MaterialTheme.colorScheme.surface,
shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp),
Column(
modifier = Modifier.fillMaxSize(),
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(24.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
.weight(1f)
.fillMaxWidth()
.padding(top = innerPadding.calculateTopPadding()),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Box(
modifier = Modifier
.size(120.dp)
.clip(CircleShape)
.clip(MaterialShapes.Pentagon.toShape())
.clickable { launcher.launch("image/*") },
contentAlignment = Alignment.Center
) {
@@ -127,7 +127,7 @@ fun NewIdentityScreen(
) {
Box(contentAlignment = Alignment.Center) {
Icon(
painter = painterResource(Res.drawable.ic_avatar),
painter = painterResource(Res.drawable.ic_plus),
contentDescription = "Pick avatar",
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
@@ -136,21 +136,87 @@ fun NewIdentityScreen(
}
}
}
OutlinedTextField(
}
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 = "What others should call you?",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
maxLines = 1,
textStyle = MaterialTheme.typography.headlineLargeEmphasized.copy(
color = MaterialTheme.colorScheme.primaryFixed,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (name.isEmpty()) {
Text(
"Alice",
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
OutlinedTextField(
)
}
innerTextField()
}
}
)
Spacer(modifier = Modifier.size(8.dp))
Text(
text = "Your bio (optional)",
style = MaterialTheme.typography.titleLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
)
BasicTextField(
value = bio,
onValueChange = { bio = it },
label = { Text("Bio:") },
modifier = Modifier
.fillMaxWidth()
.height(150.dp),
minLines = 3,
modifier = Modifier.fillMaxWidth(),
maxLines = 3,
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.primaryFixed,
fontWeight = FontWeight.SemiBold,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (bio.isEmpty()) {
Text(
"I love cat",
style = MaterialTheme.typography.headlineLargeEmphasized.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.5f
)
)
}
innerTextField()
}
}
)
Spacer(modifier = Modifier.weight(1f))
Button(
@@ -159,20 +225,21 @@ fun NewIdentityScreen(
},
modifier = Modifier
.fillMaxWidth()
.height(ButtonDefaults.LargeContainerHeight),
.height(ButtonDefaults.MediumContainerHeight),
enabled = name.isNotBlank() && !isLoading,
) {
if (isLoading) {
LoadingIndicator()
} else {
Text(
text = "Save & Continue",
style = MaterialTheme.typography.titleLargeEmphasized,
text = "Continue",
style = MaterialTheme.typography.titleMediumEmphasized,
)
}
}
}
}
}
}
)
}

View File

@@ -95,7 +95,13 @@ class Nostr {
.websocketTransport(CoopWebSocketClient(httpClient))
.database(lmdb)
.gossip(gossip)
.gossipConfig(GossipConfig().noBackgroundRefresh())
.gossipConfig(
GossipConfig()
.noBackgroundRefresh()
.fetchTimeout(Duration.parse("2s"))
.syncIdleTimeout(Duration.parse("100ms"))
.syncInitialTimeout(Duration.parse("100ms"))
)
.verifySubscriptions(false)
.automaticAuthentication(true)
.sleepWhenIdle(SleepWhenIdle.Enabled(idleTimeout))
@@ -481,7 +487,7 @@ class Nostr {
return msgRelayList
}
suspend fun createIdentity(keys: Keys, name: String, bio: String, picture: String?) {
suspend fun createIdentity(keys: Keys, name: String, bio: String?, picture: String?) {
// Send relay list event
val relayList = getDefaultRelayList()
val relayListEvent = EventBuilder.relayList(relayList).signWithKeys(keys);
@@ -505,7 +511,7 @@ class Nostr {
// Send metadata event
val metadata =
Metadata.fromRecord(MetadataRecord(name = name, about = bio, picture = picture))
Metadata.fromRecord(MetadataRecord(displayName = name, about = bio, picture = picture))
val metadataEvent = EventBuilder.metadata(metadata).signWithKeys(keys)
client?.sendEvent(
@@ -563,7 +569,6 @@ class Nostr {
RelayUrl.parse("wss://purplepag.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.damus.io") to listOf(filter),
)
)
@@ -608,7 +613,7 @@ class Nostr {
}
}
return roomsMap.values.toSet()
return roomsMap.values.sortedByDescending { it.createdAt.asSecs() }.toSet()
} catch (e: Exception) {
println("Failed to get chat rooms: ${e.message}")
return null

View File

@@ -244,9 +244,9 @@ class NostrViewModel(
fun createIdentity(
name: String,
bio: String,
bio: String?,
picture: ByteArray?,
contentType: String?
contentType: String? = null
) {
viewModelScope.launch {
try {
@@ -282,7 +282,7 @@ class NostrViewModel(
}
// Create identity
nostr.createIdentity(keys = keys, name = name, bio = bio, picture = avatarUrl)
nostr.createIdentity(keys = keys, name = name, bio, picture = avatarUrl)
// Save secret to the secret storage
secretStore.set("user_signer", secret)
@@ -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) {
viewModelScope.launch {
if (secret.startsWith("nsec1")) {