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"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z"/>
android:fillColor="#000000"
android:pathData="M256,760L200,704L424,480L200,256L256,200L480,424L704,200L760,256L536,480L760,704L704,760L480,536L256,760Z" />
</vector>

View File

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

View File

@@ -3,29 +3,36 @@ 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.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.ExtendedFloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.InputChip
import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.LocalMinimumInteractiveComponentSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedListItem
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
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.TopAppBarDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
@@ -37,9 +44,11 @@ import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.font.FontWeight
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_arrow_next
import coop.composeapp.generated.resources.ic_close_small
import coop.composeapp.generated.resources.ic_scanner
import kotlinx.coroutines.delay
@@ -60,7 +69,10 @@ fun NewChatScreen(
val snackbarHostState = LocalSnackbarHostState.current
val navController = LocalNavController.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>() }
var query by remember { mutableStateOf("") }
@@ -71,7 +83,28 @@ fun NewChatScreen(
LaunchedEffect(query) {
if (query.length >= 3) {
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) },
topBar = {
TopAppBar(
title = { Text("New Chat") },
title = {
Text(
text = if (createGroup.value) "New group chat" else "New chat",
style = MaterialTheme.typography.titleMediumEmphasized
)
},
colors = TopAppBarDefaults.topAppBarColors(
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 ->
Column(
modifier = Modifier
@@ -119,21 +188,27 @@ fun NewChatScreen(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
shape = RoundedCornerShape(28.dp),
shape = RoundedCornerShape(24.dp),
color = MaterialTheme.colorScheme.surface,
) {
FlowRow(
Row(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
.fillMaxWidth()
.padding(16.dp),
verticalAlignment = Alignment.Top,
) {
Text(
text = "To:",
modifier = Modifier.align(Alignment.Top),
style = MaterialTheme.typography.labelMediumEmphasized,
color = MaterialTheme.colorScheme.onSurfaceVariant
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.SemiBold,
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.size(16.dp))
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
selectedReceivers.forEach { receiver ->
ReceiverChip(
pubkey = receiver,
@@ -143,16 +218,14 @@ fun NewChatScreen(
BasicTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier
.widthIn(min = 50.dp)
.align(Alignment.CenterVertically),
modifier = Modifier.fillMaxWidth(),
textStyle = MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
decorationBox = { innerTextField ->
Box(contentAlignment = Alignment.CenterStart) {
if (query.isEmpty() && selectedReceivers.isEmpty()) {
if (query.isEmpty()) {
Text(
"Type a npub or address",
style = MaterialTheme.typography.bodyMedium,
@@ -167,14 +240,27 @@ fun NewChatScreen(
)
}
}
}
Spacer(modifier = Modifier.size(16.dp))
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
// TODO: add result list
if (searchResults.isNotEmpty()) {
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,
onContactClick = { 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 picture = profile?.picture
CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
InputChip(
selected = true,
onClick = onRemove,
label = { Text(displayName) },
label = {
Text(
text = displayName,
style = MaterialTheme.typography.bodyMedium.copy(
fontWeight = FontWeight.SemiBold
)
)
},
avatar = {
Avatar(
picture = picture,
@@ -217,33 +312,38 @@ fun ReceiverChip(
painter = painterResource(Res.drawable.ic_close_small),
contentDescription = "Close"
)
}
},
shape = RoundedCornerShape(24.dp),
)
}
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ContactList(
title: String? = null,
items: List<PublicKey>,
selectedReceivers: SnapshotStateList<PublicKey>,
onContactClick: (PublicKey) -> Unit
) {
val viewModel = LocalNostrViewModel.current
val contactList by viewModel.contactList.collectAsState(initial = emptySet())
if (items.isEmpty()) return
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(ListItemDefaults.SegmentedGap),
) {
if (title != null) {
Text(
text = "Contacts",
style = MaterialTheme.typography.titleLargeEmphasized,
text = title,
style = MaterialTheme.typography.titleMediumEmphasized,
)
Spacer(modifier = Modifier.size(8.dp))
contactList.forEachIndexed { index, item ->
}
items.forEachIndexed { index, item ->
ContactListItem(
pubkey = item,
index = index,
total = contactList.size,
total = items.size,
isSelected = selectedReceivers.contains(item),
onClick = { onContactClick(item) },
onLongClick = {

View File

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