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"