diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_arrow_next.xml b/composeApp/src/androidMain/composeResources/drawable/ic_arrow_next.xml
new file mode 100644
index 0000000..b04ebdc
--- /dev/null
+++ b/composeApp/src/androidMain/composeResources/drawable/ic_arrow_next.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_close.xml b/composeApp/src/androidMain/composeResources/drawable/ic_close.xml
index 7a0ff35..d970f0f 100644
--- a/composeApp/src/androidMain/composeResources/drawable/ic_close.xml
+++ b/composeApp/src/androidMain/composeResources/drawable/ic_close.xml
@@ -1,10 +1,10 @@
-
+ android:viewportHeight="960">
+
diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_close_small.xml b/composeApp/src/androidMain/composeResources/drawable/ic_close_small.xml
index 1b7d195..640b590 100644
--- a/composeApp/src/androidMain/composeResources/drawable/ic_close_small.xml
+++ b/composeApp/src/androidMain/composeResources/drawable/ic_close_small.xml
@@ -2,9 +2,8 @@
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
- android:viewportHeight="960"
- android:tint="?attr/colorControlNormal">
-
+ android:viewportHeight="960">
+
diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt
index 8e2ed1a..bdf3307 100644
--- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt
+++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt
@@ -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() }
val selectedReceivers = remember { mutableStateListOf() }
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,52 +188,57 @@ 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,
)
- selectedReceivers.forEach { receiver ->
- ReceiverChip(
- pubkey = receiver,
- onRemove = { selectedReceivers.remove(receiver) }
+ Spacer(modifier = Modifier.size(16.dp))
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ selectedReceivers.forEach { receiver ->
+ ReceiverChip(
+ pubkey = receiver,
+ onRemove = { selectedReceivers.remove(receiver) }
+ )
+ }
+ BasicTextField(
+ value = query,
+ onValueChange = { query = it },
+ 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()) {
+ Text(
+ "Type a npub or address",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
+ alpha = 0.5f
+ )
+ )
+ }
+ innerTextField()
+ }
+ }
)
}
- BasicTextField(
- value = query,
- onValueChange = { query = it },
- modifier = Modifier
- .widthIn(min = 50.dp)
- .align(Alignment.CenterVertically),
- 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()) {
- Text(
- "Type a npub or address",
- style = MaterialTheme.typography.bodyMedium,
- color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
- alpha = 0.5f
- )
- )
- }
- innerTextField()
- }
- }
- )
}
}
Spacer(modifier = Modifier.size(16.dp))
@@ -173,15 +247,28 @@ fun NewChatScreen(
.fillMaxWidth()
.padding(horizontal = 16.dp),
) {
- // TODO: add result list
- ContactList(
- selectedReceivers = selectedReceivers,
- onContactClick = { pubkey ->
- val roomId = viewModel.createChatRoom(listOf(pubkey))
- navController.navigate(Screen.Chat(roomId))
- }
- )
- Spacer(modifier = Modifier.size(16.dp))
+ 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))
+ navController.navigate(Screen.Chat(roomId))
+ }
+ )
+ Spacer(modifier = Modifier.size(16.dp))
+ }
}
}
}
@@ -201,49 +288,62 @@ fun ReceiverChip(
val displayName = profile?.name ?: profile?.displayName ?: pubkey.short()
val picture = profile?.picture
- InputChip(
- selected = true,
- onClick = onRemove,
- label = { Text(displayName) },
- avatar = {
- Avatar(
- picture = picture,
- description = displayName,
- size = 24.dp
- )
- },
- trailingIcon = {
- Icon(
- painter = painterResource(Res.drawable.ic_close_small),
- contentDescription = "Close"
- )
- }
- )
+ CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) {
+ InputChip(
+ selected = true,
+ onClick = onRemove,
+ label = {
+ Text(
+ text = displayName,
+ style = MaterialTheme.typography.bodyMedium.copy(
+ fontWeight = FontWeight.SemiBold
+ )
+ )
+ },
+ avatar = {
+ Avatar(
+ picture = picture,
+ description = displayName,
+ size = 24.dp
+ )
+ },
+ trailingIcon = {
+ Icon(
+ painter = painterResource(Res.drawable.ic_close_small),
+ contentDescription = "Close"
+ )
+ },
+ shape = RoundedCornerShape(24.dp),
+ )
+ }
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ContactList(
+ title: String? = null,
+ items: List,
selectedReceivers: SnapshotStateList,
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),
) {
- Text(
- text = "Contacts",
- style = MaterialTheme.typography.titleLargeEmphasized,
- )
- Spacer(modifier = Modifier.size(8.dp))
- contactList.forEachIndexed { index, item ->
+ if (title != null) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ )
+ Spacer(modifier = Modifier.size(8.dp))
+ }
+ items.forEachIndexed { index, item ->
ContactListItem(
pubkey = item,
index = index,
- total = contactList.size,
+ total = items.size,
isSelected = selectedReceivers.contains(item),
onClick = { onContactClick(item) },
onLongClick = {
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt
index 0f3a301..48863a9 100644
--- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt
+++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt
@@ -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 {
+ 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 {
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)
}
diff --git a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
index 861eb83..23d38bd 100644
--- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
+++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt
@@ -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 {
+ try {
+ return nostr.searchByNostr(query)
+ } catch (e: Exception) {
+ showError("Error: ${e.message}")
+ }
+ return emptyList()
+ }
}
fun PublicKey.short(): String {