From e6dff5277d9b0ad42022e71828349d5abc736026 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 20 May 2026 16:55:57 +0700 Subject: [PATCH] update new identity screen --- .../composeResources/drawable/ic_plus.xml | 9 + .../kotlin/su/reya/coop/screens/ChatScreen.kt | 12 +- .../su/reya/coop/screens/NewIdentityScreen.kt | 162 ++++++++++++------ .../commonMain/kotlin/su/reya/coop/Nostr.kt | 14 +- .../kotlin/su/reya/coop/NostrViewModel.kt | 6 +- 5 files changed, 147 insertions(+), 56 deletions(-) create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_plus.xml diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_plus.xml b/composeApp/src/androidMain/composeResources/drawable/ic_plus.xml new file mode 100644 index 0000000..6bff660 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_plus.xml @@ -0,0 +1,9 @@ + + + diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt index 4c3a365..b016fd3 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ChatScreen.kt @@ -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( diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt index d1f2d4f..5f9d71a 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewIdentityScreen.kt @@ -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,7 +56,7 @@ 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("") } @@ -90,25 +93,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 +126,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,39 +135,106 @@ fun NewIdentityScreen( } } } - OutlinedTextField( - value = name, - onValueChange = { name = it }, - label = { Text("Name") }, - modifier = Modifier.fillMaxWidth(), - singleLine = true, - ) - OutlinedTextField( - value = bio, - onValueChange = { bio = it }, - label = { Text("Bio:") }, + } + Surface( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp), + ) { + Column( modifier = Modifier - .fillMaxWidth() - .height(150.dp), - minLines = 3, - ) - Spacer(modifier = Modifier.weight(1f)) - Button( - onClick = { - onSave(name, bio, picture) - }, - modifier = Modifier - .fillMaxWidth() - .height(ButtonDefaults.LargeContainerHeight), - enabled = name.isNotBlank() && !isLoading, + .fillMaxSize() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(16.dp) ) { - if (isLoading) { - LoadingIndicator() - } else { - Text( - text = "Save & Continue", - style = MaterialTheme.typography.titleLargeEmphasized, - ) + Text( + text = "What others should call you?", + style = MaterialTheme.typography.titleLargeEmphasized.copy( + fontWeight = FontWeight.SemiBold, + ), + ) + BasicTextField( + value = name, + onValueChange = { name = it }, + modifier = Modifier.fillMaxWidth(), + 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 + ) + ) + } + 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 }, + 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( + onClick = { + onSave(name, bio, picture) + }, + modifier = Modifier + .fillMaxWidth() + .height(ButtonDefaults.LargeContainerHeight), + enabled = name.isNotBlank() && !isLoading, + ) { + if (isLoading) { + LoadingIndicator() + } else { + Text( + text = "Continue", + style = MaterialTheme.typography.titleLargeEmphasized, + ) + } } } } diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index afe37c0..28fbe0f 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -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( @@ -608,7 +614,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 diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt index b604736..adb1846 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -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)