update new chat screen

This commit is contained in:
2026-05-16 10:48:44 +07:00
parent 6b448a56f8
commit 5b440112f1
6 changed files with 220 additions and 90 deletions

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:autoMirrored="true"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M647,520L160,520L160,440L647,440L423,216L480,160L800,480L480,800L423,744L647,520Z" />
</vector>

View File

@@ -1,10 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" <vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960" android:viewportWidth="960"
android:viewportHeight="960" android:viewportHeight="960">
android:tint="?attr/colorControlNormal">
<path <path
android:fillColor="@android:color/white" android:fillColor="#000000"
android:pathData="M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z"/> android:pathData="M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z" />
</vector> </vector>

View File

@@ -2,9 +2,8 @@
android:width="24dp" android:width="24dp"
android:height="24dp" android:height="24dp"
android:viewportWidth="960" android:viewportWidth="960"
android:viewportHeight="960" android:viewportHeight="960">
android:tint="?attr/colorControlNormal">
<path <path
android:fillColor="@android:color/white" android:fillColor="#000000"
android:pathData="M336,680L280,624L424,480L280,337L336,281L480,425L623,281L679,337L535,480L679,624L623,680L480,536L336,680Z"/> android:pathData="M336,680L280,624L424,480L280,337L336,281L480,425L623,281L679,337L535,480L679,624L623,680L480,536L336,680Z" />
</vector> </vector>

View File

@@ -3,29 +3,36 @@ 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.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow 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.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.InputChip import androidx.compose.material3.InputChip
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedListItem import androidx.compose.material3.SegmentedListItem
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.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -37,9 +44,11 @@ import androidx.compose.runtime.snapshots.SnapshotStateList
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.SolidColor import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight
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_arrow_next
import coop.composeapp.generated.resources.ic_close_small import coop.composeapp.generated.resources.ic_close_small
import coop.composeapp.generated.resources.ic_scanner import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -60,7 +69,10 @@ 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 createGroup = remember { mutableStateOf(false) }
val searchResults = remember { mutableStateListOf<PublicKey>() }
val selectedReceivers = remember { mutableStateListOf<PublicKey>() } val selectedReceivers = remember { mutableStateListOf<PublicKey>() }
var query by remember { mutableStateOf("") } var query by remember { mutableStateOf("") }
@@ -71,7 +83,28 @@ fun NewChatScreen(
LaunchedEffect(query) { LaunchedEffect(query) {
if (query.length >= 3) { if (query.length >= 3) {
delay(500) // 500ms debounce delay(500) // 500ms debounce
// TODO: Implement search
if (query.startsWith("npub1")) {
val pubkey = try {
PublicKey.parse(query)
} catch (e: Exception) {
null
}
if (pubkey != null) {
selectedReceivers.add(pubkey)
}
} else if (query.contains("@")) {
val pubkey = viewModel.searchByAddress(query)
if (pubkey != null) {
selectedReceivers.add(pubkey)
}
} else {
val results = viewModel.searchByNostr(query)
searchResults.clear()
searchResults.addAll(results)
}
query = ""
} }
} }
@@ -87,7 +120,12 @@ fun NewChatScreen(
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { topBar = {
TopAppBar( TopAppBar(
title = { Text("New Chat") }, title = {
Text(
text = if (createGroup.value) "New group chat" else "New chat",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
colors = TopAppBarDefaults.topAppBarColors( colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer, containerColor = MaterialTheme.colorScheme.surfaceContainer,
), ),
@@ -109,6 +147,37 @@ fun NewChatScreen(
} }
) )
}, },
floatingActionButton = {
if (selectedReceivers.isNotEmpty()) {
TooltipBox(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(
TooltipAnchorPosition.Above,
spacingBetweenTooltipAndAnchor = 8.dp,
),
tooltip = {
PlainTooltip { Text("Next") }
},
state = rememberTooltipState(),
) {
ExtendedFloatingActionButton(
onClick = {
val roomId = viewModel.createChatRoom(selectedReceivers.toList())
navController.navigate(Screen.Chat(roomId))
},
expanded = false,
icon = {
Icon(
painter = painterResource(Res.drawable.ic_arrow_next),
contentDescription = "Next"
)
},
text = { Text("Next") },
containerColor = MaterialTheme.colorScheme.tertiary,
contentColor = MaterialTheme.colorScheme.onTertiary,
)
}
}
},
content = { innerPadding -> content = { innerPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
@@ -119,21 +188,27 @@ fun NewChatScreen(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
shape = RoundedCornerShape(28.dp), shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface, color = MaterialTheme.colorScheme.surface,
) { ) {
FlowRow( Row(
modifier = Modifier modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp), .fillMaxWidth()
horizontalArrangement = Arrangement.spacedBy(8.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.Top,
) { ) {
Text( Text(
text = "To:", text = "To:",
modifier = Modifier.align(Alignment.Top), style = MaterialTheme.typography.bodyMedium.copy(
style = MaterialTheme.typography.labelMediumEmphasized, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant ),
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
Spacer(modifier = Modifier.size(16.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
selectedReceivers.forEach { receiver -> selectedReceivers.forEach { receiver ->
ReceiverChip( ReceiverChip(
pubkey = receiver, pubkey = receiver,
@@ -143,16 +218,14 @@ fun NewChatScreen(
BasicTextField( BasicTextField(
value = query, value = query,
onValueChange = { query = it }, onValueChange = { query = it },
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.widthIn(min = 50.dp)
.align(Alignment.CenterVertically),
textStyle = MaterialTheme.typography.bodyMedium.copy( textStyle = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface color = MaterialTheme.colorScheme.onSurface
), ),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField -> decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) { Box(contentAlignment = Alignment.CenterStart) {
if (query.isEmpty() && selectedReceivers.isEmpty()) { if (query.isEmpty()) {
Text( Text(
"Type a npub or address", "Type a npub or address",
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -167,14 +240,27 @@ fun NewChatScreen(
) )
} }
} }
}
Spacer(modifier = Modifier.size(16.dp)) Spacer(modifier = Modifier.size(16.dp))
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
) { ) {
// TODO: add result list if (searchResults.isNotEmpty()) {
ContactList( ContactList(
items = searchResults,
selectedReceivers = selectedReceivers,
onContactClick = { pubkey ->
val roomId = viewModel.createChatRoom(listOf(pubkey))
navController.navigate(Screen.Chat(roomId))
},
)
Spacer(modifier = Modifier.size(16.dp))
} else {
ContactList(
title = "Contacts",
items = contactList.toList(),
selectedReceivers = selectedReceivers, selectedReceivers = selectedReceivers,
onContactClick = { pubkey -> onContactClick = { pubkey ->
val roomId = viewModel.createChatRoom(listOf(pubkey)) val roomId = viewModel.createChatRoom(listOf(pubkey))
@@ -185,6 +271,7 @@ fun NewChatScreen(
} }
} }
} }
}
) )
} }
@@ -201,10 +288,18 @@ fun ReceiverChip(
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short() val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
val picture = profile?.picture val picture = profile?.picture
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
InputChip( InputChip(
selected = true, selected = true,
onClick = onRemove, onClick = onRemove,
label = { Text(displayName) }, label = {
Text(
text = displayName,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.SemiBold
)
)
},
avatar = { avatar = {
Avatar( Avatar(
picture = picture, picture = picture,
@@ -217,33 +312,38 @@ fun ReceiverChip(
painter = painterResource(Res.drawable.ic_close_small), painter = painterResource(Res.drawable.ic_close_small),
contentDescription = "Close" contentDescription = "Close"
) )
} },
shape = RoundedCornerShape(24.dp),
) )
}
} }
@OptIn(ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun ContactList( fun ContactList(
title: String? = null,
items: List<PublicKey>,
selectedReceivers: SnapshotStateList<PublicKey>, selectedReceivers: SnapshotStateList<PublicKey>,
onContactClick: (PublicKey) -> Unit onContactClick: (PublicKey) -> Unit
) { ) {
val viewModel = LocalNostrViewModel.current if (items.isEmpty()) return
val contactList by viewModel.contactList.collectAsState(initial = emptySet())
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap), verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
) { ) {
if (title != null) {
Text( Text(
text = "Contacts", text = title,
style = MaterialTheme.typography.titleLargeEmphasized, style = MaterialTheme.typography.titleMediumEmphasized,
) )
Spacer(modifier = Modifier.size(8.dp)) Spacer(modifier = Modifier.size(8.dp))
contactList.forEachIndexed { index, item -> }
items.forEachIndexed { index, item ->
ContactListItem( ContactListItem(
pubkey = item, pubkey = item,
index = index, index = index,
total = contactList.size, total = items.size,
isSelected = selectedReceivers.contains(item), isSelected = selectedReceivers.contains(item),
onClick = { onContactClick(item) }, onClick = { onContactClick(item) },
onLongClick = { onLongClick = {

View File

@@ -89,6 +89,7 @@ class Nostr {
// Bootstrap relays // Bootstrap relays
client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) client?.addRelay(RelayUrl.parse("wss://relay.primal.net"))
client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) client?.addRelay(RelayUrl.parse("wss://user.kindpag.es"))
client?.addRelay(RelayUrl.parse("wss://purplepag.es"))
// Add search relay // Add search relay
client?.addRelay( client?.addRelay(
@@ -638,18 +639,18 @@ class Nostr {
} }
} }
suspend fun searchByAddress(query: String): List<PublicKey> { suspend fun searchByAddress(query: String): PublicKey {
try { try {
val address = Nip05Address.parse(query) val address = Nip05Address.parse(query)
val profile = profileFromAddress(HttpClient(), address) val profile = profileFromAddress(HttpClient(), address)
return listOf(profile.publicKey()) return profile.publicKey()
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to search address: ${e.message}", e) throw IllegalStateException("Failed to search address: ${e.message}", e)
} }
} }
suspend fun searchByNostr(query: String) { suspend fun searchByNostr(query: String): List<PublicKey> {
try { try {
val kinds = listOf(Kind.fromStd(KindStandard.METADATA)) val kinds = listOf(Kind.fromStd(KindStandard.METADATA))
val filter = Filter().kinds(kinds).search(query).limit(10u) val filter = Filter().kinds(kinds).search(query).limit(10u)
@@ -672,6 +673,8 @@ class Nostr {
results.add(event.event!!.author()) results.add(event.event!!.author())
} }
} }
return results
} catch (e: Exception) { } catch (e: Exception) {
throw IllegalStateException("Failed to search nostr: ${e.message}", e) throw IllegalStateException("Failed to search nostr: ${e.message}", e)
} }

View File

@@ -380,6 +380,24 @@ class NostrViewModel(
} }
} }
} }
suspend fun searchByAddress(query: String): PublicKey? {
try {
return nostr.searchByAddress(query)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
return null
}
suspend fun searchByNostr(query: String): List<PublicKey> {
try {
return nostr.searchByNostr(query)
} catch (e: Exception) {
showError("Error: ${e.message}")
}
return emptyList()
}
} }
fun PublicKey.short(): String { fun PublicKey.short(): String {