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 {