From 6b448a56f844deca7882b766e8768a94e9107bb1 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 15 May 2026 17:09:59 +0700 Subject: [PATCH] new chat screen --- .../composeResources/drawable/ic_close.xml | 10 + .../drawable/ic_close_small.xml | 10 + .../composeResources/drawable/ic_new_chat.xml | 10 + .../androidMain/kotlin/su/reya/coop/App.kt | 39 ++- .../kotlin/su/reya/coop/Navigation.kt | 6 + .../kotlin/su/reya/coop/screens/HomeScreen.kt | 51 ++- .../su/reya/coop/screens/NewChatScreen.kt | 300 ++++++++++++++++++ .../kotlin/su/reya/coop/screens/ScanScreen.kt | 49 +++ .../commonMain/kotlin/su/reya/coop/Nostr.kt | 80 ++++- .../kotlin/su/reya/coop/NostrViewModel.kt | 45 ++- .../commonMain/kotlin/su/reya/coop/Room.kt | 2 +- 11 files changed, 571 insertions(+), 31 deletions(-) create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_close.xml create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_close_small.xml create mode 100644 composeApp/src/androidMain/composeResources/drawable/ic_new_chat.xml create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt create mode 100644 composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_close.xml b/composeApp/src/androidMain/composeResources/drawable/ic_close.xml new file mode 100644 index 0000000..7a0ff35 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_close.xml @@ -0,0 +1,10 @@ + + + diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_close_small.xml b/composeApp/src/androidMain/composeResources/drawable/ic_close_small.xml new file mode 100644 index 0000000..1b7d195 --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_close_small.xml @@ -0,0 +1,10 @@ + + + diff --git a/composeApp/src/androidMain/composeResources/drawable/ic_new_chat.xml b/composeApp/src/androidMain/composeResources/drawable/ic_new_chat.xml new file mode 100644 index 0000000..3d23f4c --- /dev/null +++ b/composeApp/src/androidMain/composeResources/drawable/ic_new_chat.xml @@ -0,0 +1,10 @@ + + + diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt index 82ec4e0..3e826e8 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/App.kt @@ -14,10 +14,10 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.platform.LocalContext import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController @@ -26,8 +26,10 @@ import su.reya.coop.coop.storage.SecretStore import su.reya.coop.screens.ChatScreen import su.reya.coop.screens.HomeScreen import su.reya.coop.screens.ImportScreen +import su.reya.coop.screens.NewChatScreen import su.reya.coop.screens.NewIdentityScreen import su.reya.coop.screens.OnboardingScreen +import su.reya.coop.screens.ScanScreen val LocalNostrViewModel = staticCompositionLocalOf { error("No NostrViewModel provided") @@ -37,18 +39,26 @@ val LocalSnackbarHostState = staticCompositionLocalOf { error("No SnackbarHostState provided") } +val LocalNavController = staticCompositionLocalOf { + error("No NavController provided") +} + @OptIn(ExperimentalMaterial3ExpressiveApi::class) @Composable fun App(dbPath: String) { val context = LocalContext.current + val navController = rememberNavController() + val darkMode = isSystemInDarkTheme() + + // Snackbar + val snackbarHostState = remember { SnackbarHostState() } // Initialize Nostr and SecretStore val nostr = remember { Nostr() } val secretStore = remember { SecretStore(context) } val viewModel: NostrViewModel = viewModel { NostrViewModel(nostr, secretStore) } - - // Dynamic color scheme - val darkMode = isSystemInDarkTheme() + + // Enabled the dynamic color scheme val colorScheme = when { android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.S -> { if (darkMode) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) @@ -58,13 +68,12 @@ fun App(dbPath: String) { else -> expressiveLightColorScheme() } - // Snackbar - val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(Unit) { viewModel.initAndConnect(dbPath) viewModel.startNotificationHandler() viewModel.getChatRooms() + + // Collect error events from the ViewModel viewModel.errorEvents.collect { message -> snackbarHostState.showSnackbar(message) } @@ -76,9 +85,8 @@ fun App(dbPath: String) { CompositionLocalProvider( LocalNostrViewModel provides viewModel, LocalSnackbarHostState provides snackbarHostState, + LocalNavController provides navController, ) { - rememberCoroutineScope() - val navController = rememberNavController() val emptySecret by viewModel.emptySecret.collectAsState(initial = null) LaunchedEffect(emptySecret) { @@ -136,7 +144,8 @@ fun App(dbPath: String) { } composable { backStackEntry -> HomeScreen( - onOpenChat = { id -> navController.navigate(Screen.Chat(id)) } + onOpenChat = { id -> navController.navigate(Screen.Chat(id)) }, + onNewChat = { navController.navigate(Screen.NewChat) } ) } composable { backStackEntry -> @@ -146,6 +155,16 @@ fun App(dbPath: String) { onBack = { navController.popBackStack() }, ) } + composable { backStackEntry -> + NewChatScreen( + onBack = { navController.popBackStack() }, + ) + } + composable { backStackEntry -> + ScanScreen( + onBack = { navController.popBackStack() }, + ) + } } } } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt index 521babf..676eb7d 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/Navigation.kt @@ -9,6 +9,9 @@ sealed interface Screen { @Serializable data class Chat(val id: Long) : Screen + @Serializable + data object NewChat : Screen + @Serializable data object Onboarding : Screen @@ -17,4 +20,7 @@ sealed interface Screen { @Serializable data object NewIdentity : Screen + + @Serializable + data object Scan : Screen } diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt index 4f0f03d..1dfed82 100644 --- a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/HomeScreen.kt @@ -13,9 +13,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size 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.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi +import androidx.compose.material3.ExtendedFloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.ListItem @@ -24,17 +26,23 @@ import androidx.compose.material3.MaterialShapes import androidx.compose.material3.MaterialTheme import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.OutlinedButton +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.rememberModalBottomSheetState +import androidx.compose.material3.rememberTooltipState import androidx.compose.material3.toShape import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -47,6 +55,8 @@ import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.toClipEntry import androidx.compose.ui.unit.dp import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_new_chat +import coop.composeapp.generated.resources.ic_scanner import coop.composeapp.generated.resources.ic_search import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource @@ -61,7 +71,10 @@ import su.reya.coop.short @OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class) @Composable -fun HomeScreen(onOpenChat: (Long) -> Unit) { +fun HomeScreen( + onOpenChat: (Long) -> Unit, + onNewChat: () -> Unit, +) { val clipboard = LocalClipboard.current val snackbarHostState = LocalSnackbarHostState.current val viewModel = LocalNostrViewModel.current @@ -74,6 +87,8 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { val chatRooms by viewModel.chatRooms.collectAsState(initial = emptyList()) val sheetState = rememberModalBottomSheetState() + val listState = rememberLazyListState() + val expandedFab by remember { derivedStateOf { listState.firstVisibleItemIndex == 0 } } var showBottomSheet by remember { mutableStateOf(false) } Scaffold( @@ -98,6 +113,13 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { contentDescription = "Search" ) } + // QR Scanner + IconButton(onClick = { /* TODO: Open search */ }) { + Icon( + painter = painterResource(Res.drawable.ic_scanner), + contentDescription = "Scanner" + ) + } // User IconButton(onClick = { showBottomSheet = true }) { Avatar( @@ -109,6 +131,32 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { } ) }, + floatingActionButton = { + TooltipBox( + positionProvider = TooltipDefaults.rememberTooltipPositionProvider( + TooltipAnchorPosition.Above, + spacingBetweenTooltipAndAnchor = 8.dp, + ), + tooltip = { + if (!expandedFab) { + PlainTooltip { Text("New Chat") } + } + }, + state = rememberTooltipState(), + ) { + ExtendedFloatingActionButton( + onClick = onNewChat, + expanded = expandedFab, + icon = { + Icon( + painter = painterResource(Res.drawable.ic_new_chat), + contentDescription = "New Chat" + ) + }, + text = { Text("New Chat") }, + ) + } + }, content = { innerPadding -> Surface( modifier = Modifier @@ -137,6 +185,7 @@ fun HomeScreen(onOpenChat: (Long) -> Unit) { } } else { LazyColumn( + state = listState, modifier = Modifier.fillMaxSize() ) { items(chatRooms.toList(), key = { it.id }) { room -> diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt new file mode 100644 index 0000000..8e2ed1a --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/NewChatScreen.kt @@ -0,0 +1,300 @@ +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.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.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.InputChip +import androidx.compose.material3.ListItemDefaults +import androidx.compose.material3.MaterialTheme +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.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +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.unit.dp +import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_arrow_back +import coop.composeapp.generated.resources.ic_close_small +import coop.composeapp.generated.resources.ic_scanner +import kotlinx.coroutines.delay +import org.jetbrains.compose.resources.painterResource +import rust.nostr.sdk.PublicKey +import su.reya.coop.LocalNavController +import su.reya.coop.LocalNostrViewModel +import su.reya.coop.LocalSnackbarHostState +import su.reya.coop.Screen +import su.reya.coop.shared.Avatar +import su.reya.coop.short + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun NewChatScreen( + onBack: () -> Unit, +) { + val snackbarHostState = LocalSnackbarHostState.current + val navController = LocalNavController.current + val viewModel = LocalNostrViewModel.current + + val selectedReceivers = remember { mutableStateListOf() } + var query by remember { mutableStateOf("") } + + val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle + val qrResult by savedStateHandle?.getStateFlow("qr_result", null)?.collectAsState() + ?: remember { mutableStateOf(null) } + + LaunchedEffect(query) { + if (query.length >= 3) { + delay(500) // 500ms debounce + // TODO: Implement search + } + } + + LaunchedEffect(qrResult) { + qrResult?.let { + println("QR result: $it") + navController.currentBackStackEntry?.savedStateHandle?.remove("qr_result") + } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + snackbarHost = { SnackbarHost(snackbarHostState) }, + topBar = { + TopAppBar( + title = { Text("New Chat") }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(Res.drawable.ic_arrow_back), + contentDescription = "Back" + ) + } + }, + actions = { + IconButton(onClick = { navController.navigate(Screen.Scan) }) { + Icon( + painter = painterResource(Res.drawable.ic_scanner), + contentDescription = "Scanner" + ) + } + } + ) + }, + content = { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + Surface( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + shape = RoundedCornerShape(28.dp), + color = MaterialTheme.colorScheme.surface, + ) { + FlowRow( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = "To:", + modifier = Modifier.align(Alignment.Top), + style = MaterialTheme.typography.labelMediumEmphasized, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + selectedReceivers.forEach { receiver -> + ReceiverChip( + pubkey = receiver, + onRemove = { selectedReceivers.remove(receiver) } + ) + } + 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)) + Column( + modifier = Modifier + .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)) + } + } + } + ) +} + +@Composable +fun ReceiverChip( + pubkey: PublicKey, + onRemove: () -> Unit +) { + val viewModel = LocalNostrViewModel.current + val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) } + val metadata by metadataFlow.collectAsState(initial = null) + + val profile = metadata?.asRecord() + 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" + ) + } + ) +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ContactList( + selectedReceivers: SnapshotStateList, + onContactClick: (PublicKey) -> Unit +) { + val viewModel = LocalNostrViewModel.current + val contactList by viewModel.contactList.collectAsState(initial = emptySet()) + + 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 -> + ContactListItem( + pubkey = item, + index = index, + total = contactList.size, + isSelected = selectedReceivers.contains(item), + onClick = { onContactClick(item) }, + onLongClick = { + if (!selectedReceivers.contains(item)) { + selectedReceivers.add(item) + } + } + ) + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +fun ContactListItem( + pubkey: PublicKey, + index: Int, + total: Int = 0, + isSelected: Boolean, + onClick: () -> Unit, + onLongClick: () -> Unit +) { + val viewModel = LocalNostrViewModel.current + val metadataFlow = remember(pubkey) { viewModel.getMetadata(pubkey) } + val metadata by metadataFlow.collectAsState(initial = null) + + val profile = metadata?.asRecord() + val displayName = profile?.name ?: profile?.displayName ?: pubkey.short() + val picture = profile?.picture + + SegmentedListItem( + selected = isSelected, + onClick = onClick, + onLongClick = onLongClick, + shapes = ListItemDefaults.segmentedShapes( + index = index, + count = total + ), + leadingContent = { + Avatar( + picture = picture, + description = displayName, + size = 36.dp + ) + }, + supportingContent = { Text(text = pubkey.short()) }, + content = { + Text( + text = displayName, + style = MaterialTheme.typography.titleMediumEmphasized, + ) + } + ) +} diff --git a/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt new file mode 100644 index 0000000..041da7e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/su/reya/coop/screens/ScanScreen.kt @@ -0,0 +1,49 @@ +package su.reya.coop.screens + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import coop.composeapp.generated.resources.Res +import coop.composeapp.generated.resources.ic_arrow_back +import org.jetbrains.compose.resources.painterResource +import su.reya.coop.LocalNavController + +@Composable +fun ScanScreen( + onBack: () -> Unit +) { + val navController = LocalNavController.current + + val onResult: (String) -> Unit = { result -> + navController.previousBackStackEntry + ?.savedStateHandle + ?.set("qr_result", result) + navController.popBackStack() + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Scan QR") }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + painter = painterResource(Res.drawable.ic_arrow_back), + contentDescription = "Back" + ) + } + }, + ) + } + ) { innerPadding -> + Box(modifier = Modifier.padding(innerPadding)) { + Text("Scan QR") + } + } +} \ No newline at end of file diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt index 9607d23..0f3a301 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Nostr.kt @@ -1,7 +1,10 @@ package su.reya.coop import io.ktor.client.HttpClient +import io.ktor.client.call.body import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.request.get +import io.ktor.client.statement.HttpResponse import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay @@ -24,6 +27,8 @@ import rust.nostr.sdk.KindStandard import rust.nostr.sdk.LogLevel import rust.nostr.sdk.Metadata import rust.nostr.sdk.MetadataRecord +import rust.nostr.sdk.Nip05Address +import rust.nostr.sdk.Nip05Profile import rust.nostr.sdk.NostrDatabase import rust.nostr.sdk.NostrGossip import rust.nostr.sdk.PublicKey @@ -56,8 +61,6 @@ class Nostr { private set var msgRelayList: Map> = emptyMap() private set - var contactList: List = emptyList() - private set suspend fun init(dbPath: String) { try { @@ -87,6 +90,12 @@ class Nostr { client?.addRelay(RelayUrl.parse("wss://relay.primal.net")) client?.addRelay(RelayUrl.parse("wss://user.kindpag.es")) + // Add search relay + client?.addRelay( + url = RelayUrl.parse("wss://antiprimal.net"), + capabilities = RelayCapabilities.read() + ) + // Indexer relay for NIP-65 discovery client?.addRelay( url = RelayUrl.parse("wss://indexer.coracle.social"), @@ -107,7 +116,6 @@ class Nostr { suspend fun exit() { signer.switch(Keys.generate()) deviceSigner = null - contactList = emptyList() } suspend fun setSigner(keys: AsyncNostrSigner) { @@ -184,6 +192,7 @@ class Nostr { suspend fun handleNotifications( onMetadataUpdate: (PublicKey, Metadata) -> Unit, + onContactListUpdate: (List) -> Unit, onNewMessage: (UnsignedEvent) -> Unit, onEose: () -> Unit, ) = coroutineScope { @@ -217,6 +226,12 @@ class Nostr { } } + if (event.kind().asStd()?.equals(KindStandard.CONTACT_LIST) == true) { + if (isSignedByUser(event = event)) { + onContactListUpdate(event.tags().publicKeys()) + } + } + if (event.kind().asStd()?.equals(KindStandard.INBOX_RELAYS) == true) { if (isSignedByUser(event = event)) { getUserMessages(msgRelayList = event) @@ -457,7 +472,7 @@ class Nostr { ) ) - client?.subscribe(target = target, id = "metadata-reqs", closeOn = opts) + client?.subscribe(target = target, closeOn = opts) } catch (e: Exception) { throw IllegalStateException("Failed to fetch metadata batch: ${e.message}", e) } @@ -494,13 +509,10 @@ class Nostr { Filter().kind(kind).author(userPubkey).pubkeys(room.members.toList()); // Check if the user is interacting with the room's members - val isInteracting = client?.database()?.query(filter)?.isEmpty() == false; - - // Check if the room's members are in the contact list - val isContact = contactList.containsAll(room.members) + val isOngoing = client?.database()?.query(filter)?.isEmpty() == false; // Set the room kind based on interaction status - if (isInteracting || isContact) { + if (isOngoing) { room.setKind(RoomKind.Ongoing) } @@ -614,4 +626,54 @@ class Nostr { throw IllegalStateException("Failed to send message: ${e.message}", e) } } + + suspend fun profileFromAddress(client: HttpClient, address: Nip05Address): Nip05Profile { + try { + val response: HttpResponse = client.get(address.url()) + val bodyString: String = response.body() + + return Nip05Profile.fromJson(address, bodyString) + } catch (e: Exception) { + throw IllegalStateException("Failed to fetch profile from address: ${e.message}", e) + } + } + + suspend fun searchByAddress(query: String): List { + try { + val address = Nip05Address.parse(query) + val profile = profileFromAddress(HttpClient(), address) + + return listOf(profile.publicKey()) + } catch (e: Exception) { + throw IllegalStateException("Failed to search address: ${e.message}", e) + } + } + + suspend fun searchByNostr(query: String) { + try { + val kinds = listOf(Kind.fromStd(KindStandard.METADATA)) + val filter = Filter().kinds(kinds).search(query).limit(10u) + val target = + ReqTarget.manual(mapOf(RelayUrl.parse("wss://antiprimal.net") to listOf(filter))) + + val stream = client?.streamEvents( + target = target, + id = "search", + timeout = Duration.parse("4s"), + policy = ReqExitPolicy.ExitOnEose + ) + + // Collect the results + val results = mutableListOf() + + // Keep searching until the stream is closed or timeout + stream?.next()?.let { event -> + if (event.event != null) { + results.add(event.event!!.author()) + } + } + } 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 c5df147..861eb83 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/NostrViewModel.kt @@ -17,12 +17,14 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.json.Json +import rust.nostr.sdk.EventBuilder import rust.nostr.sdk.EventId import rust.nostr.sdk.Keys import rust.nostr.sdk.Metadata import rust.nostr.sdk.NostrConnect import rust.nostr.sdk.NostrConnectUri import rust.nostr.sdk.PublicKey +import rust.nostr.sdk.Tag import rust.nostr.sdk.UnsignedEvent import su.reya.coop.blossom.BlossomClient import su.reya.coop.storage.SecretStorage @@ -42,6 +44,9 @@ class NostrViewModel( private val _chatRooms = MutableStateFlow>(emptySet()) val chatRooms = _chatRooms.asStateFlow() + private val _contactList = MutableStateFlow>(emptySet()) + val contactList = _contactList.asStateFlow() + private val _newEvents = MutableSharedFlow(extraBufferCapacity = 100) val newEvents = _newEvents.asSharedFlow() @@ -56,6 +61,16 @@ class NostrViewModel( startMetadataBatchProcessor() } + override fun onCleared() { + super.onCleared() + // Ensure all relays are disconnect + viewModelScope.launch { + withContext(NonCancellable) { + nostr.disconnect() + } + } + } + private fun showError(message: String) { viewModelScope.launch { _errorEvents.send(message) @@ -133,6 +148,9 @@ class NostrViewModel( onMetadataUpdate = { pubkey, metadata -> updateMetadata(pubkey, metadata) }, + onContactListUpdate = { contactList -> + _contactList.value = contactList.toSet() + }, onEose = { getChatRooms() }, @@ -287,6 +305,23 @@ class NostrViewModel( } } + fun createChatRoom(to: List): Long { + if (nostr.signer.currentUser == null) throw IllegalStateException("User not signed in") + if (to.isEmpty()) throw IllegalArgumentException("At least one recipient is required") + + // Construct the rumor event + val rumor = EventBuilder + .privateMsgRumor(to.first(), "") + .tags(to.map { Tag.publicKey(it) }) + .build(nostr.signer.currentUser!!) + + // Create a room from the rumor event + val room = Room.new(rumor, nostr.signer.currentUser!!) + _chatRooms.value += room + + return room.id + } + fun getChatRoom(id: Long): Room { return chatRooms.value.firstOrNull { it.id == id } ?: throw IllegalArgumentException("Room not found") @@ -345,16 +380,6 @@ class NostrViewModel( } } } - - override fun onCleared() { - super.onCleared() - // Ensure all relays are disconnect - viewModelScope.launch { - withContext(NonCancellable) { - nostr.disconnect() - } - } - } } fun PublicKey.short(): String { diff --git a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt index c0260e1..72f8e98 100644 --- a/shared/src/commonMain/kotlin/su/reya/coop/Room.kt +++ b/shared/src/commonMain/kotlin/su/reya/coop/Room.kt @@ -111,7 +111,7 @@ fun Timestamp.ago(): String { val duration = now - inputInstant return when { - duration.inWholeSeconds < SECONDS_IN_MINUTE -> "now" + duration.inWholeSeconds < SECONDS_IN_MINUTE -> "Now" duration.inWholeMinutes < MINUTES_IN_HOUR -> "${duration.inWholeMinutes}m" duration.inWholeHours < HOURS_IN_DAY -> "${duration.inWholeHours}h" duration.inWholeDays < DAYS_IN_MONTH -> "${duration.inWholeDays}d"